From 579970f26d33b0c5ea18b4d712e5417b0123e2b6 Mon Sep 17 00:00:00 2001 From: Jason Cavinder Date: Sun, 9 Aug 2026 08:04:41 -1000 Subject: [PATCH 1/2] feat: add experimental map event catalog --- README.md | 27 +- crates/tilewright-cli/README.md | 33 +- crates/tilewright-cli/src/main.rs | 638 +++++++++ crates/tilewright-cli/tests/cli.rs | 96 ++ crates/tilewright/README.md | 36 + .../tilewright/src/rpg_maker_mz/map_events.rs | 1197 +++++++++++++++++ crates/tilewright/src/rpg_maker_mz/mod.rs | 1 + docs/architecture.md | 9 +- docs/capability-roadmap.md | 27 +- docs/compatibility.md | 5 +- .../0011-experimental-map-event-catalog.md | 101 ++ docs/decisions/README.md | 4 +- docs/formats/rpg-maker-mz/README.md | 3 + docs/formats/rpg-maker-mz/map-events.md | 152 +++ docs/formats/rpg-maker-mz/map-summary.md | 6 +- .../rpg-maker-mz/project-layout-coverage.md | 2 +- docs/formats/rpg-maker-mz/research-ledger.md | 97 ++ docs/open-questions.md | 5 +- docs/safety.md | 13 +- 19 files changed, 2407 insertions(+), 45 deletions(-) create mode 100644 crates/tilewright/src/rpg_maker_mz/map_events.rs create mode 100644 docs/decisions/0011-experimental-map-event-catalog.md create mode 100644 docs/formats/rpg-maker-mz/map-events.md diff --git a/README.md b/README.md index 7db7228..4dd0c45 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,11 @@ not a current support claim. > 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. 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. +> 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. ## What Tilewright aims to provide @@ -56,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 catalog, 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 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 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-mcp`](crates/tilewright-mcp/README.md) | Thin MCP adapter over the library | Scaffold | The dependency direction is inward: @@ -114,6 +116,7 @@ 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 -- map path/to/project 1 +cargo run -p tilewright-cli -- events path/to/project 1 cargo run -p tilewright-cli -- system path/to/project cargo run -p tilewright-cli -- validate path/to/project cargo run -p tilewright-cli -- inspect-json path/to/file.json @@ -131,6 +134,7 @@ tilewright inventory path/to/project tilewright snapshot path/to/project tilewright maps path/to/project tilewright map path/to/project 1 +tilewright events path/to/project 1 tilewright system path/to/project tilewright validate path/to/project tilewright inspect-json path/to/file.json @@ -142,12 +146,13 @@ 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 system-setting summaries, bounded player-start validation, -and strict lossless JSON syntax inspection. 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 the full -verification process described in +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 +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 +the full verification process described in [CONTRIBUTING.md](CONTRIBUTING.md#verification). ## License diff --git a/crates/tilewright-cli/README.md b/crates/tilewright-cli/README.md index b662586..bf3511b 100644 --- a/crates/tilewright-cli/README.md +++ b/crates/tilewright-cli/README.md @@ -7,13 +7,14 @@ ## Status This crate is experimental. It provides help and version output plus read-only -`discover`, `inventory`, `snapshot`, `maps`, `map`, `system`, `validate`, and -`inspect-json` +`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, system summary, player-start -validation, and strict lossless JSON syntax APIs. It does not provide general -project understanding, project validity, editor compatibility, or modification. +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. ## Install from a checkout @@ -69,6 +70,10 @@ cargo run -p tilewright-cli -- maps path/to/project --format json cargo run -p tilewright-cli -- map path/to/project 1 cargo run -p tilewright-cli -- map path/to/project 1 --format json +# List bounded events on one catalog-selected map. +cargo run -p tilewright-cli -- events path/to/project 1 +cargo run -p tilewright-cli -- events path/to/project 1 --format json + # Summarize selected project-level system settings. cargo run -p tilewright-cli -- system path/to/project cargo run -p tilewright-cli -- system path/to/project --format json @@ -138,6 +143,18 @@ options shown above are available on both `maps` and `map`. Neither command validates editor compatibility, provides stable project-wide resource identity, or establishes mutation, round-trip, or write support. +The `events` command requires the same coherent map catalog and evidenced map +document, then reports map-scoped event IDs, decoded names, nonnegative +coordinates, and opaque page counts in ID order. Coordinates outside the map's +dimensions are successful contextual findings, not claims that MZ rejects the +state. Structural event errors exit with code 1. + +The command does not emit notes, raw page bodies, commands, or unprojected +fields. Event IDs are scoped to the selected map, and page counts do not imply +page or command understanding. The snapshot resource-limit options are +available on `events`. The command does not establish editor compatibility, +runtime behavior, mutation, round-trip, or write support. + The `system` command loads the same bounded snapshot and delegates projection to the core library. It reports the decoded game title, currency unit, locale, stored editor-map scalar, and player-start map/X/Y scalars from exact @@ -171,9 +188,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`, `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. +`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 efe5182..29794a5 100644 --- a/crates/tilewright-cli/src/main.rs +++ b/crates/tilewright-cli/src/main.rs @@ -19,6 +19,10 @@ use tilewright::rpg_maker_mz::inventory::{ use tilewright::rpg_maker_mz::map_catalog::{ JsonValueKind, MapCatalog, MapCatalogError, MapCatalogFinding, MapId, MapInfoField, map_catalog, }; +use tilewright::rpg_maker_mz::map_events::{ + MapEventCatalog, MapEventCatalogError, MapEventField, MapEventFinding, MapEventMapField, + map_event_catalog, +}; use tilewright::rpg_maker_mz::map_summary::{MapSummary, MapSummaryError, map_summary}; use tilewright::rpg_maker_mz::player_start_validation::{ PlayerStartFinding, PlayerStartValidation, PlayerStartValidationError, validate_player_start, @@ -145,6 +149,19 @@ enum Command { #[command(flatten)] limits: SnapshotLimitArgs, }, + /// List bounded events on one catalog-selected RPG Maker MZ map. + Events { + /// RPG Maker MZ project directory to inspect. + path: PathBuf, + /// Positive map ID from the project's map catalog. + #[arg(value_parser = parse_map_id)] + map_id: MapId, + /// Output intended for a person or a script. + #[arg(long, value_enum, default_value_t = OutputFormat::Human)] + format: OutputFormat, + #[command(flatten)] + limits: SnapshotLimitArgs, + }, /// Summarize selected experimental RPG Maker MZ system settings. System { /// RPG Maker MZ project directory to inspect. @@ -502,6 +519,107 @@ struct SelectedMapErrorReport { error: MapSummaryErrorDetail, } +#[derive(Debug, Serialize)] +struct MapEventsReport { + schema_version: u8, + root: PathReport, + snapshot_completeness: SnapshotCompletenessReport, + limits: SnapshotLimitsReport, + snapshot_diagnostic_count: usize, + map: MapEventsMapDetail, + event_count: usize, + finding_count: usize, + events: Vec, + findings: Vec, + snapshot_diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +struct MapEventsMapDetail { + id: u32, + catalog_name: String, + document_path: PathReport, + width: u32, + height: u32, +} + +#[derive(Debug, Serialize)] +struct MapEventDetail { + id: u32, + name: String, + x: u32, + y: u32, + page_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case", tag = "category")] +enum MapEventFindingReport { + CoordinatesOutsideMap { + event_id: u32, + x: u32, + y: u32, + width: u32, + height: u32, + }, + Unrecognized, +} + +#[derive(Debug, Serialize)] +struct MapEventsErrorReport { + schema_version: u8, + root: PathReport, + snapshot_completeness: SnapshotCompletenessReport, + limits: SnapshotLimitsReport, + snapshot_diagnostics: Vec, + error: MapEventsErrorDetail, +} + +#[derive(Debug, Serialize)] +struct MapEventsErrorDetail { + category: MapEventsErrorCategory, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + map_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[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, + #[serde(skip_serializing_if = "Option::is_none")] + catalog_error: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum MapEventsErrorCategory { + CatalogError, + UnknownMapId, + UnevidencedDocumentPath, + MissingDocument, + UnavailableDocument, + UnexpectedRootKind, + MissingMapField, + DuplicateMapField, + UnexpectedMapFieldKind, + UnsupportedMapInteger, + UnexpectedEventEntryKind, + ReservedEventIndex, + EventIndexOutOfRange, + MissingEventField, + DuplicateEventField, + UnexpectedEventFieldKind, + UnsupportedEventInteger, + InvalidEventString, + IdIndexMismatch, + Unrecognized, +} + #[derive(Debug, Serialize)] struct MapSummaryErrorDetail { category: MapSummaryErrorCategory, @@ -997,6 +1115,348 @@ impl SelectedMapErrorReport { } } +impl MapEventsReport { + fn new( + root: &Path, + snapshot: &ProjectSnapshot, + catalog: &MapEventCatalog, + limits: SnapshotLimits, + ) -> Self { + Self { + schema_version: OUTPUT_SCHEMA_VERSION, + root: PathReport::new(root), + snapshot_completeness: SnapshotCompletenessReport::new(snapshot.completeness()), + limits: SnapshotLimitsReport::new(limits), + snapshot_diagnostic_count: snapshot.diagnostics().len(), + map: MapEventsMapDetail { + id: catalog.map_id().get(), + catalog_name: catalog.catalog_name().to_owned(), + document_path: PathReport::new(catalog.document_path()), + width: catalog.width(), + height: catalog.height(), + }, + event_count: catalog.records().len(), + finding_count: catalog.findings().len(), + events: catalog + .records() + .values() + .map(|event| MapEventDetail { + id: event.id().get(), + name: event.name().to_owned(), + x: event.x(), + y: event.y(), + page_count: event.page_count(), + }) + .collect(), + findings: catalog + .findings() + .iter() + .map(MapEventFindingReport::new) + .collect(), + snapshot_diagnostics: snapshot + .diagnostics() + .iter() + .map(|(path, diagnostic)| SnapshotDiagnosticReport::new(path, diagnostic)) + .collect(), + } + } +} + +impl MapEventFindingReport { + fn new(finding: &MapEventFinding) -> Self { + match finding { + MapEventFinding::CoordinatesOutsideMap { + event_id, + x, + y, + width, + height, + .. + } => Self::CoordinatesOutsideMap { + event_id: event_id.get(), + x: *x, + y: *y, + width: *width, + height: *height, + }, + _ => Self::Unrecognized, + } + } +} + +impl MapEventsErrorReport { + fn new( + root: &Path, + snapshot: &ProjectSnapshot, + limits: SnapshotLimits, + error: &MapEventCatalogError, + ) -> 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: MapEventsErrorDetail::new(error), + } + } +} + +impl MapEventsErrorDetail { + fn new(error: &MapEventCatalogError) -> Self { + let mut detail = Self { + category: MapEventsErrorCategory::Unrecognized, + message: error.to_string(), + map_id: None, + path: None, + index: None, + field: None, + actual_kind: None, + decoded_id: None, + catalog_error: None, + }; + + match error { + MapEventCatalogError::Catalog { source, .. } => { + detail.category = MapEventsErrorCategory::CatalogError; + detail.catalog_error = Some(MapCatalogErrorDetail::new(source)); + } + MapEventCatalogError::UnknownMapId { map_id, .. } => { + detail.category = MapEventsErrorCategory::UnknownMapId; + detail.map_id = Some(map_id.get()); + } + MapEventCatalogError::UnevidencedDocumentPath { map_id, .. } => { + detail.category = MapEventsErrorCategory::UnevidencedDocumentPath; + detail.map_id = Some(map_id.get()); + } + MapEventCatalogError::MissingDocument { map_id, path, .. } => { + detail.category = MapEventsErrorCategory::MissingDocument; + detail.map_id = Some(map_id.get()); + detail.path = Some(PathReport::new(path)); + } + MapEventCatalogError::UnavailableDocument { map_id, path, .. } => { + detail.category = MapEventsErrorCategory::UnavailableDocument; + detail.map_id = Some(map_id.get()); + detail.path = Some(PathReport::new(path)); + } + MapEventCatalogError::UnexpectedRootKind { + map_id, + path, + actual, + .. + } => { + detail.category = MapEventsErrorCategory::UnexpectedRootKind; + detail.map_id = Some(map_id.get()); + detail.path = Some(PathReport::new(path)); + detail.actual_kind = Some(json_value_kind_name(*actual)); + } + MapEventCatalogError::MissingMapField { + map_id, + path, + field, + .. + } => detail.set_map_field( + MapEventsErrorCategory::MissingMapField, + *map_id, + path, + *field, + None, + ), + MapEventCatalogError::DuplicateMapField { + map_id, + path, + field, + .. + } => detail.set_map_field( + MapEventsErrorCategory::DuplicateMapField, + *map_id, + path, + *field, + None, + ), + MapEventCatalogError::UnexpectedMapFieldKind { + map_id, + path, + field, + actual, + .. + } => detail.set_map_field( + MapEventsErrorCategory::UnexpectedMapFieldKind, + *map_id, + path, + *field, + Some(*actual), + ), + MapEventCatalogError::UnsupportedMapInteger { + map_id, + path, + field, + .. + } => detail.set_map_field( + MapEventsErrorCategory::UnsupportedMapInteger, + *map_id, + path, + *field, + None, + ), + MapEventCatalogError::UnexpectedEventEntryKind { + map_id, + path, + index, + actual, + .. + } => { + detail.category = MapEventsErrorCategory::UnexpectedEventEntryKind; + detail.map_id = Some(map_id.get()); + detail.path = Some(PathReport::new(path)); + detail.index = Some(*index); + detail.actual_kind = Some(json_value_kind_name(*actual)); + } + MapEventCatalogError::ReservedEventIndex { map_id, path, .. } => { + detail.category = MapEventsErrorCategory::ReservedEventIndex; + detail.map_id = Some(map_id.get()); + detail.path = Some(PathReport::new(path)); + detail.index = Some(0); + } + MapEventCatalogError::EventIndexOutOfRange { + map_id, + path, + index, + .. + } => { + detail.category = MapEventsErrorCategory::EventIndexOutOfRange; + detail.map_id = Some(map_id.get()); + detail.path = Some(PathReport::new(path)); + detail.index = Some(*index); + } + MapEventCatalogError::MissingEventField { + map_id, + path, + index, + field, + .. + } => detail.set_event_field( + MapEventsErrorCategory::MissingEventField, + *map_id, + path, + *index, + *field, + None, + ), + MapEventCatalogError::DuplicateEventField { + map_id, + path, + index, + field, + .. + } => detail.set_event_field( + MapEventsErrorCategory::DuplicateEventField, + *map_id, + path, + *index, + *field, + None, + ), + MapEventCatalogError::UnexpectedEventFieldKind { + map_id, + path, + index, + field, + actual, + .. + } => detail.set_event_field( + MapEventsErrorCategory::UnexpectedEventFieldKind, + *map_id, + path, + *index, + *field, + Some(*actual), + ), + MapEventCatalogError::UnsupportedEventInteger { + map_id, + path, + index, + field, + .. + } => detail.set_event_field( + MapEventsErrorCategory::UnsupportedEventInteger, + *map_id, + path, + *index, + *field, + None, + ), + MapEventCatalogError::InvalidEventString { + map_id, + path, + index, + field, + .. + } => detail.set_event_field( + MapEventsErrorCategory::InvalidEventString, + *map_id, + path, + *index, + *field, + None, + ), + MapEventCatalogError::IdIndexMismatch { + map_id, + path, + index, + decoded_id, + .. + } => { + detail.category = MapEventsErrorCategory::IdIndexMismatch; + detail.map_id = Some(map_id.get()); + detail.path = Some(PathReport::new(path)); + detail.index = Some(*index); + detail.field = Some(MapEventField::Id.to_string()); + detail.decoded_id = Some(decoded_id.get()); + } + _ => {} + } + + detail + } + + fn set_map_field( + &mut self, + category: MapEventsErrorCategory, + map_id: MapId, + path: &Path, + field: MapEventMapField, + actual: Option, + ) { + self.category = category; + self.map_id = Some(map_id.get()); + self.path = Some(PathReport::new(path)); + self.field = Some(field.to_string()); + self.actual_kind = actual.map(json_value_kind_name); + } + + fn set_event_field( + &mut self, + category: MapEventsErrorCategory, + map_id: MapId, + path: &Path, + index: usize, + field: MapEventField, + actual: Option, + ) { + self.category = category; + self.map_id = Some(map_id.get()); + self.path = Some(PathReport::new(path)); + self.index = Some(index); + self.field = Some(field.to_string()); + self.actual_kind = actual.map(json_value_kind_name); + } +} + impl SystemReport { fn new( root: &Path, @@ -1558,6 +2018,12 @@ fn main() -> ExitCode { format, limits, } => run_map(&path, id, format, limits.limits()), + Command::Events { + path, + map_id, + format, + limits, + } => run_events(&path, map_id, format, limits.limits()), Command::System { path, format, @@ -1711,6 +2177,58 @@ fn run_map(path: &Path, map_id: MapId, format: OutputFormat, limits: SnapshotLim } } +fn run_events( + 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, + 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 map_event_catalog(&snapshot, map_id) { + Ok(catalog) => { + let report = MapEventsReport::new(path, &snapshot, &catalog, limits); + let write_result = match format { + OutputFormat::Human => write_human_map_events_report(io::stdout().lock(), &report), + OutputFormat::Json => write_json(io::stdout().lock(), &report), + }; + finish_write(write_result) + } + Err(error) => { + let report = MapEventsErrorReport::new(path, &snapshot, limits, &error); + write_map_events_error_report(&report, format) + } + } +} + fn run_system(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, @@ -2049,6 +2567,18 @@ fn write_selected_map_error_report( } } +fn write_map_events_error_report(report: &MapEventsErrorReport, format: OutputFormat) -> ExitCode { + let write_result = match format { + OutputFormat::Human => write_human_map_events_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_system_error_report(report: &SystemErrorReport, format: OutputFormat) -> ExitCode { let write_result = match format { OutputFormat::Human => write_human_system_error(io::stderr().lock(), report), @@ -2341,6 +2871,97 @@ fn write_human_selected_map_report( ) } +fn write_human_map_events_report( + mut writer: impl Write, + report: &MapEventsReport, +) -> io::Result<()> { + writeln!( + writer, + "Events on map {} ({}) for {}:", + report.map.id, + escape_controls(&report.map.catalog_name), + escape_controls(&report.root.display) + )?; + writeln!( + writer, + " Document: {}", + escape_controls(&report.map.document_path.display) + )?; + writeln!( + writer, + " Map size: {} x {}", + report.map.width, report.map.height + )?; + writeln!(writer, " Events: {}", report.event_count)?; + writeln!(writer, " Findings: {}", report.finding_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.events.is_empty() { + writeln!(writer, " (no events)")?; + } else { + writeln!(writer, "Event catalog:")?; + for event in &report.events { + writeln!( + writer, + " - {}: {} at ({}, {}) ({} {})", + event.id, + escape_controls(&event.name), + event.x, + event.y, + event.page_count, + if event.page_count == 1 { + "page" + } else { + "pages" + } + )?; + } + } + + if !report.findings.is_empty() { + writeln!(writer, "Event findings:")?; + for finding in &report.findings { + match finding { + MapEventFindingReport::CoordinatesOutsideMap { + event_id, + x, + y, + width, + height, + } => writeln!( + writer, + " - event {event_id} coordinate ({x}, {y}) is outside map size {width} x {height}" + )?, + MapEventFindingReport::Unrecognized => { + writeln!(writer, " - unrecognized event finding")? + } + } + } + } + + write_human_snapshot_diagnostics( + &mut writer, + "Snapshot diagnostics:", + &report.snapshot_diagnostics, + ) +} + fn write_human_system_report(mut writer: impl Write, report: &SystemReport) -> io::Result<()> { writeln!( writer, @@ -2594,6 +3215,23 @@ fn write_human_selected_map_error( ) } +fn write_human_map_events_error( + mut writer: impl Write, + report: &MapEventsErrorReport, +) -> 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_system_error(mut writer: impl Write, report: &SystemErrorReport) -> io::Result<()> { writeln!( writer, diff --git a/crates/tilewright-cli/tests/cli.rs b/crates/tilewright-cli/tests/cli.rs index 539ffa8..428bc19 100644 --- a/crates/tilewright-cli/tests/cli.rs +++ b/crates/tilewright-cli/tests/cli.rs @@ -1235,6 +1235,102 @@ fn map_catalog_failures_and_operational_errors_remain_distinct() { assert!(stderr(&operational).contains("missing\\n\\u{1b}[31mdir")); } +#[test] +fn events_help_lists_map_id_format_and_snapshot_limits() { + let help = tilewright(&["events", "--help"]); + assert!(help.status.success()); + let output = stdout(&help); + assert!(output.contains("")); + 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 events_reports_bounded_catalog_and_escapes_controls_for_people() { + let temp = TempDir::new().unwrap(); + let root = write_selected_map_project( + &temp, + br#"[null,{"id":1,"name":"Map\n\u001b[31m","order":1,"parentId":0}]"#, + br#"{"width":10,"height":8,"events":[null,{"id":1,"name":"Door\r\u001b[32m","note":"do not print","x":0,"y":7,"pages":[{"secret":"do not print"}]}]}"#, + ); + + let output = tilewright(&["events", root.to_str().unwrap(), "1"]); + + assert!(output.status.success()); + assert!(stderr(&output).is_empty()); + let output = stdout(&output); + assert!(output.contains("Events on map 1 (Map\\n\\u{1b}[31m)")); + assert!(output.contains("1: Door\\r\\u{1b}[32m at (0, 7) (1 page)")); + assert!(output.contains("Map size: 10 x 8")); + assert!(!output.contains('\u{1b}')); + assert!(!output.contains("do not print")); +} + +#[test] +fn events_emits_deterministic_versioned_json_without_opaque_contents() { + let temp = TempDir::new().unwrap(); + let root = write_selected_map_project( + &temp, + br#"[null,{"id":1,"name":"Town","order":1,"parentId":0}]"#, + br#"{"width":20,"height":15,"events":[null,{"id":1,"name":"Door","note":"memo secret","x":20,"y":2,"pages":[{"command_secret":true}]},null,{"id":3,"name":"Chest","x":4,"y":5,"pages":[]}]}"#, + ); + + let first = tilewright(&["events", root.to_str().unwrap(), "1", "--format", "json"]); + let second = tilewright(&["events", root.to_str().unwrap(), "1", "--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["map"]["id"], 1); + assert_eq!(report["map"]["catalog_name"], "Town"); + assert_eq!(report["map"]["width"], 20); + assert_eq!(report["map"]["height"], 15); + assert_eq!(report["event_count"], 2); + assert_eq!(report["finding_count"], 1); + assert_eq!(report["events"][0]["id"], 1); + assert_eq!(report["events"][0]["name"], "Door"); + assert_eq!(report["events"][0]["x"], 20); + assert_eq!(report["events"][0]["y"], 2); + assert_eq!(report["events"][0]["page_count"], 1); + assert_eq!(report["events"][1]["id"], 3); + assert_eq!(report["findings"][0]["category"], "coordinates_outside_map"); + assert_eq!(report["findings"][0]["event_id"], 1); + let output = stdout(&first); + assert!(!output.contains("memo secret")); + assert!(!output.contains("command_secret")); +} + +#[test] +fn events_projection_errors_have_structured_human_and_json_forms() { + let temp = TempDir::new().unwrap(); + let root = write_selected_map_project( + &temp, + br#"[null,{"id":1,"name":"One","order":1,"parentId":0}]"#, + br#"{"width":10,"height":8,"events":[null,{"id":2,"name":"Wrong","x":0,"y":0,"pages":[]}]}"#, + ); + + let human = tilewright(&["events", root.to_str().unwrap(), "2"]); + assert_eq!(human.status.code(), Some(1)); + assert!(stdout(&human).is_empty()); + assert!(stderr(&human).contains("no record for map 2")); + + let json = tilewright(&["events", root.to_str().unwrap(), "1", "--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"]["map_id"], 1); + assert_eq!(report["error"]["index"], 1); + assert_eq!(report["error"]["field"], "id"); + assert_eq!(report["error"]["decoded_id"], 2); +} + #[test] fn help_lists_system_and_resource_limits() { let help = tilewright(&["--help"]); diff --git a/crates/tilewright/README.md b/crates/tilewright/README.md index ecaa083..e880ba8 100644 --- a/crates/tilewright/README.md +++ b/crates/tilewright/README.md @@ -13,6 +13,8 @@ can inspect map IDs, names, display order, and parent relationships. It does not yet provide broader understanding, general project validity, or write support. A second experimental projection can summarize one catalog-selected map's display name, dimensions, tileset ID scalar, and opaque event count. +Another experimental projection can list that selected map's map-scoped event +IDs, names, coordinates, and opaque page counts while retaining event bodies. 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 @@ -193,6 +195,40 @@ evidenced three-digit filename family. It does not validate tileset references, interpret events or tile layers, establish editor compatibility, or expose mutation and serialization. +### Example: Selected-Map Event Catalog + +The experimental event catalog reads bounded event identity and placement from +one catalog-selected map while keeping event pages and commands opaque. + +```rust +use tilewright::rpg_maker_mz::map_catalog::MapId; +use tilewright::rpg_maker_mz::map_events::map_event_catalog; +use tilewright::rpg_maker_mz::snapshot::ProjectSnapshot; + +fn print_events(snapshot: &ProjectSnapshot, map_id: MapId) { + match map_event_catalog(snapshot, map_id) { + Ok(catalog) => { + for event in catalog.records().values() { + println!( + "{}: {} at ({}, {}), {} pages", + event.id(), + event.name(), + event.x(), + event.y(), + event.page_count() + ); + } + } + Err(error) => eprintln!("event catalog unavailable: {error}"), + } +} +``` + +The identifier is scoped to its containing map. Coordinate findings compare +stored values with map dimensions without claiming editor invalidity. The +operation does not expose notes or page bodies, interpret commands, establish +editor compatibility, or provide mutation and serialization. + ### Example: System Summary The experimental system summary reads seven bounded fields from exact diff --git a/crates/tilewright/src/rpg_maker_mz/map_events.rs b/crates/tilewright/src/rpg_maker_mz/map_events.rs new file mode 100644 index 0000000..86be70c --- /dev/null +++ b/crates/tilewright/src/rpg_maker_mz/map_events.rs @@ -0,0 +1,1197 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! Experimental, read-only event catalog for one selected RPG Maker MZ map. + +use crate::rpg_maker_mz::map_catalog::{ + JsonValueKind, MapCatalogError, MapId, evidenced_map_document_path, map_catalog, +}; +use crate::rpg_maker_mz::snapshot::ProjectSnapshot; +use jsonc_parser::cst::{CstNode, CstObject}; +use std::collections::BTreeMap; +use std::fmt; +use std::num::NonZeroU32; +use std::path::{Path, PathBuf}; + +/// A positive map-event identifier scoped to one selected map. +/// +/// This experimental identifier is not a stable project-wide resource ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MapEventId(NonZeroU32); + +impl MapEventId { + /// Creates an event 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 MapEventId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, formatter) + } +} + +/// One bounded event record from a selected map. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct MapEventRecord { + id: MapEventId, + name: String, + x: u32, + y: u32, + page_count: usize, +} + +impl MapEventRecord { + /// Returns the event ID, scoped to the containing map. + pub fn id(&self) -> MapEventId { + self.id + } + + /// Returns the decoded editor-facing event name. + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the nonnegative stored horizontal coordinate. + pub fn x(&self) -> u32 { + self.x + } + + /// Returns the nonnegative stored vertical coordinate. + pub fn y(&self) -> u32 { + self.y + } + + /// Returns the number of opaque entries in the event's page array. + /// + /// Page bodies and commands are not interpreted by this projection. + pub fn page_count(&self) -> usize { + self.page_count + } +} + +/// A structurally coherent event catalog for one selected map. +#[derive(Debug)] +#[non_exhaustive] +pub struct MapEventCatalog { + map_id: MapId, + catalog_name: String, + document_path: PathBuf, + width: NonZeroU32, + height: NonZeroU32, + records: BTreeMap, + findings: Vec, +} + +impl MapEventCatalog { + /// Returns the selected map ID. + pub fn map_id(&self) -> MapId { + self.map_id + } + + /// Returns the selected map's decoded editor-facing catalog name. + pub fn catalog_name(&self) -> &str { + &self.catalog_name + } + + /// Returns the exact evidenced project-relative map-document path. + pub fn document_path(&self) -> &Path { + &self.document_path + } + + /// Returns the positive map width used for coordinate findings. + pub fn width(&self) -> u32 { + self.width.get() + } + + /// Returns the positive map height used for coordinate findings. + pub fn height(&self) -> u32 { + self.height.get() + } + + /// Returns event records in ascending event-ID order. + pub fn records(&self) -> &BTreeMap { + &self.records + } + + /// Returns one event by its map-scoped ID. + pub fn get(&self, id: MapEventId) -> Option<&MapEventRecord> { + self.records.get(&id) + } + + /// Returns deterministic contextual coordinate findings. + /// + /// Findings do not claim that RPG Maker MZ rejects the project. + pub fn findings(&self) -> &[MapEventFinding] { + &self.findings + } +} + +/// A contextual finding produced after event structure is coherent. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum MapEventFinding { + /// The stored event coordinate is outside the selected map dimensions. + #[non_exhaustive] + CoordinatesOutsideMap { + event_id: MapEventId, + x: u32, + y: u32, + width: u32, + height: u32, + }, +} + +/// A required selected-map field used by the event catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MapEventMapField { + /// The positive map width scalar. + Width, + /// The positive map height scalar. + Height, + /// The array of event objects and null holes. + Events, +} + +impl MapEventMapField { + fn name(self) -> &'static str { + match self { + Self::Width => "width", + Self::Height => "height", + Self::Events => "events", + } + } +} + +impl fmt::Display for MapEventMapField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.name()) + } +} + +/// A required field in one map-event object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MapEventField { + /// The positive map-scoped event ID. + Id, + /// The decoded editor-facing event name. + Name, + /// The nonnegative horizontal coordinate. + X, + /// The nonnegative vertical coordinate. + Y, + /// The array of opaque event pages. + Pages, +} + +impl MapEventField { + fn name(self) -> &'static str { + match self { + Self::Id => "id", + Self::Name => "name", + Self::X => "x", + Self::Y => "y", + Self::Pages => "pages", + } + } +} + +impl fmt::Display for MapEventField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.name()) + } +} + +/// Why a selected map could not produce a coherent event catalog. +#[derive(Debug)] +#[non_exhaustive] +pub enum MapEventCatalogError { + /// The project could not produce a structurally coherent map catalog. + #[non_exhaustive] + Catalog { source: MapCatalogError }, + /// The requested ID has no record in the coherent map catalog. + #[non_exhaustive] + UnknownMapId { map_id: MapId }, + /// The ID is outside the evidenced three-digit map-document family. + #[non_exhaustive] + UnevidencedDocumentPath { map_id: MapId }, + /// No loaded document exists at the expected path. + #[non_exhaustive] + MissingDocument { map_id: MapId, path: PathBuf }, + /// Snapshot diagnostics exist instead of a loaded document at the path. + #[non_exhaustive] + UnavailableDocument { map_id: MapId, path: PathBuf }, + /// The selected map document root is not an object. + #[non_exhaustive] + UnexpectedRootKind { + map_id: MapId, + path: PathBuf, + actual: JsonValueKind, + }, + /// A required selected-map field is absent. + #[non_exhaustive] + MissingMapField { + map_id: MapId, + path: PathBuf, + field: MapEventMapField, + }, + /// A required selected-map field occurs more than once. + #[non_exhaustive] + DuplicateMapField { + map_id: MapId, + path: PathBuf, + field: MapEventMapField, + }, + /// A required selected-map field has the wrong kind. + #[non_exhaustive] + UnexpectedMapFieldKind { + map_id: MapId, + path: PathBuf, + field: MapEventMapField, + actual: JsonValueKind, + }, + /// A selected-map dimension is not a supported positive integer. + #[non_exhaustive] + UnsupportedMapInteger { + map_id: MapId, + path: PathBuf, + field: MapEventMapField, + }, + /// A non-null event-array entry is not an object. + #[non_exhaustive] + UnexpectedEventEntryKind { + map_id: MapId, + path: PathBuf, + index: usize, + actual: JsonValueKind, + }, + /// An event object occupies reserved array index zero. + #[non_exhaustive] + ReservedEventIndex { map_id: MapId, path: PathBuf }, + /// An event-array index cannot be represented by the identifier type. + #[non_exhaustive] + EventIndexOutOfRange { + map_id: MapId, + path: PathBuf, + index: usize, + }, + /// A required event-object field is absent. + #[non_exhaustive] + MissingEventField { + map_id: MapId, + path: PathBuf, + index: usize, + field: MapEventField, + }, + /// A required event-object field occurs more than once. + #[non_exhaustive] + DuplicateEventField { + map_id: MapId, + path: PathBuf, + index: usize, + field: MapEventField, + }, + /// A required event-object field has the wrong kind. + #[non_exhaustive] + UnexpectedEventFieldKind { + map_id: MapId, + path: PathBuf, + index: usize, + field: MapEventField, + actual: JsonValueKind, + }, + /// A required event number is outside the supported unsigned form. + #[non_exhaustive] + UnsupportedEventInteger { + map_id: MapId, + path: PathBuf, + index: usize, + field: MapEventField, + }, + /// An event name string could not be decoded. + #[non_exhaustive] + InvalidEventString { + map_id: MapId, + path: PathBuf, + index: usize, + field: MapEventField, + }, + /// The decoded event ID does not equal its array index. + #[non_exhaustive] + IdIndexMismatch { + map_id: MapId, + path: PathBuf, + index: usize, + decoded_id: MapEventId, + }, +} + +impl fmt::Display for MapEventCatalogError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Catalog { source, .. } => { + write!(formatter, "map catalog is unavailable: {source}") + } + Self::UnknownMapId { map_id, .. } => { + write!(formatter, "map catalog has no record for map {map_id}") + } + Self::UnevidencedDocumentPath { map_id, .. } => write!( + formatter, + "map {map_id} is outside the evidenced three-digit document family" + ), + Self::MissingDocument { map_id, path, .. } => { + write!( + formatter, + "map {map_id} document {} is missing", + path.display() + ) + } + Self::UnavailableDocument { map_id, path, .. } => write!( + formatter, + "map {map_id} document {} was not loaded; inspect snapshot diagnostics", + path.display() + ), + Self::UnexpectedRootKind { + map_id, + path, + actual, + .. + } => write!( + formatter, + "map {map_id} document {} root is {actual}, expected object", + path.display() + ), + Self::MissingMapField { + map_id, + path, + field, + .. + } => write!( + formatter, + "map {map_id} document {} is missing required field {field}", + path.display() + ), + Self::DuplicateMapField { + map_id, + path, + field, + .. + } => write!( + formatter, + "map {map_id} document {} has duplicate required field {field}", + path.display() + ), + Self::UnexpectedMapFieldKind { + map_id, + path, + field, + actual, + .. + } => write!( + formatter, + "map {map_id} document {} field {field} is {actual}, expected {}", + path.display(), + expected_map_kind(*field) + ), + Self::UnsupportedMapInteger { + map_id, + path, + field, + .. + } => write!( + formatter, + "map {map_id} document {} field {field} is not a supported positive unsigned integer", + path.display() + ), + Self::UnexpectedEventEntryKind { + map_id, + path, + index, + actual, + .. + } => write!( + formatter, + "map {map_id} document {} event entry {index} is {actual}, expected object or null", + path.display() + ), + Self::ReservedEventIndex { map_id, path, .. } => write!( + formatter, + "map {map_id} document {} has an event object at reserved index 0", + path.display() + ), + Self::EventIndexOutOfRange { + map_id, + path, + index, + .. + } => write!( + formatter, + "map {map_id} document {} event index {index} exceeds the supported identifier range", + path.display() + ), + Self::MissingEventField { + map_id, + path, + index, + field, + .. + } => write!( + formatter, + "map {map_id} document {} event entry {index} is missing required field {field}", + path.display() + ), + Self::DuplicateEventField { + map_id, + path, + index, + field, + .. + } => write!( + formatter, + "map {map_id} document {} event entry {index} has duplicate required field {field}", + path.display() + ), + Self::UnexpectedEventFieldKind { + map_id, + path, + index, + field, + actual, + .. + } => write!( + formatter, + "map {map_id} document {} event entry {index} field {field} is {actual}, expected {}", + path.display(), + expected_event_kind(*field) + ), + Self::UnsupportedEventInteger { + map_id, + path, + index, + field, + .. + } => write!( + formatter, + "map {map_id} document {} event entry {index} field {field} is not a supported unsigned integer", + path.display() + ), + Self::InvalidEventString { + map_id, + path, + index, + field, + .. + } => write!( + formatter, + "map {map_id} document {} event entry {index} field {field} could not be decoded", + path.display() + ), + Self::IdIndexMismatch { + map_id, + path, + index, + decoded_id, + .. + } => write!( + formatter, + "map {map_id} document {} event entry {index} has decoded ID {decoded_id}", + path.display() + ), + } + } +} + +impl std::error::Error for MapEventCatalogError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Catalog { source, .. } => Some(source), + _ => None, + } + } +} + +/// Projects one catalog-selected map into a bounded event catalog. +/// +/// The operation reads only lossless documents already present in `snapshot`. +/// It exposes map-scoped event IDs, names, coordinates, and opaque page counts. +/// Event notes, page bodies, commands, and unknown fields remain untouched in +/// the raw snapshot. +/// +/// Coordinate findings compare stored nonnegative coordinates with the map's +/// positive dimensions; they do not claim editor invalidity. This operation +/// performs no filesystem I/O, mutation, serialization, persistence, page or +/// command interpretation, runtime execution, or compatibility verification. +/// +/// # Errors +/// +/// Returns [`MapEventCatalogError`] when map selection or required map/event +/// structure is absent, ambiguous, or outside the bounded numeric contract. +pub fn map_event_catalog( + snapshot: &ProjectSnapshot, + map_id: MapId, +) -> Result { + let catalog = + map_catalog(snapshot).map_err(|source| MapEventCatalogError::Catalog { source })?; + let map_record = catalog + .get(map_id) + .ok_or(MapEventCatalogError::UnknownMapId { map_id })?; + let path = evidenced_map_document_path(map_id) + .ok_or(MapEventCatalogError::UnevidencedDocumentPath { map_id })?; + let Some(document) = snapshot.documents().get(path.as_path()) else { + return if snapshot.diagnostics().contains_key(path.as_path()) { + Err(MapEventCatalogError::UnavailableDocument { map_id, path }) + } else { + Err(MapEventCatalogError::MissingDocument { map_id, path }) + }; + }; + + let root = + document + .cst_root() + .value() + .ok_or_else(|| MapEventCatalogError::UnexpectedRootKind { + map_id, + path: path.clone(), + actual: JsonValueKind::Unrecognized, + })?; + let object = root + .as_object() + .ok_or_else(|| MapEventCatalogError::UnexpectedRootKind { + map_id, + path: path.clone(), + actual: json_kind(&root), + })?; + let width = required_map_positive_integer(&object, map_id, &path, MapEventMapField::Width)?; + let height = required_map_positive_integer(&object, map_id, &path, MapEventMapField::Height)?; + let events_node = required_map_node(&object, map_id, &path, MapEventMapField::Events)?; + let events = + events_node + .as_array() + .ok_or_else(|| MapEventCatalogError::UnexpectedMapFieldKind { + map_id, + path: path.clone(), + field: MapEventMapField::Events, + actual: json_kind(&events_node), + })?; + + let mut records = BTreeMap::new(); + let mut findings = Vec::new(); + for (index, event_node) in events.elements().into_iter().enumerate() { + if event_node.as_null_keyword().is_some() { + continue; + } + let event = event_node.as_object().ok_or_else(|| { + MapEventCatalogError::UnexpectedEventEntryKind { + map_id, + path: path.clone(), + index, + actual: json_kind(&event_node), + } + })?; + let event_id = event_id_from_index(map_id, &path, index)?; + let decoded_id = + required_event_positive_integer(&event, map_id, &path, index, MapEventField::Id)?; + if decoded_id != event_id { + return Err(MapEventCatalogError::IdIndexMismatch { + map_id, + path, + index, + decoded_id, + }); + } + let name = required_event_string(&event, map_id, &path, index, MapEventField::Name)?; + let x = required_event_integer(&event, map_id, &path, index, MapEventField::X)?; + let y = required_event_integer(&event, map_id, &path, index, MapEventField::Y)?; + let pages_node = required_event_node(&event, map_id, &path, index, MapEventField::Pages)?; + let pages = pages_node.as_array().ok_or_else(|| { + MapEventCatalogError::UnexpectedEventFieldKind { + map_id, + path: path.clone(), + index, + field: MapEventField::Pages, + actual: json_kind(&pages_node), + } + })?; + + if x >= width.get() || y >= height.get() { + findings.push(MapEventFinding::CoordinatesOutsideMap { + event_id, + x, + y, + width: width.get(), + height: height.get(), + }); + } + records.insert( + event_id, + MapEventRecord { + id: event_id, + name, + x, + y, + page_count: pages.elements().len(), + }, + ); + } + + Ok(MapEventCatalog { + map_id, + catalog_name: map_record.name().to_owned(), + document_path: path, + width, + height, + records, + findings, + }) +} + +fn event_id_from_index( + map_id: MapId, + path: &Path, + index: usize, +) -> Result { + let value = u32::try_from(index).map_err(|_| MapEventCatalogError::EventIndexOutOfRange { + map_id, + path: path.to_owned(), + index, + })?; + MapEventId::new(value).ok_or_else(|| MapEventCatalogError::ReservedEventIndex { + map_id, + path: path.to_owned(), + }) +} + +fn required_map_node( + object: &CstObject, + map_id: MapId, + path: &Path, + field: MapEventMapField, +) -> Result { + let mut matches = matching_properties(object, field.name()); + let property = matches + .next() + .ok_or_else(|| MapEventCatalogError::MissingMapField { + map_id, + path: path.to_owned(), + field, + })?; + if matches.next().is_some() { + return Err(MapEventCatalogError::DuplicateMapField { + map_id, + path: path.to_owned(), + field, + }); + } + property + .value() + .ok_or_else(|| MapEventCatalogError::MissingMapField { + map_id, + path: path.to_owned(), + field, + }) +} + +fn required_map_positive_integer( + object: &CstObject, + map_id: MapId, + path: &Path, + field: MapEventMapField, +) -> Result { + let node = required_map_node(object, map_id, path, field)?; + let number = + node.as_number_lit() + .ok_or_else(|| MapEventCatalogError::UnexpectedMapFieldKind { + map_id, + path: path.to_owned(), + field, + actual: json_kind(&node), + })?; + let value = number + .to_string() + .parse::() + .ok() + .and_then(NonZeroU32::new) + .ok_or_else(|| MapEventCatalogError::UnsupportedMapInteger { + map_id, + path: path.to_owned(), + field, + })?; + Ok(value) +} + +fn required_event_node( + object: &CstObject, + map_id: MapId, + path: &Path, + index: usize, + field: MapEventField, +) -> Result { + let mut matches = matching_properties(object, field.name()); + let property = matches + .next() + .ok_or_else(|| MapEventCatalogError::MissingEventField { + map_id, + path: path.to_owned(), + index, + field, + })?; + if matches.next().is_some() { + return Err(MapEventCatalogError::DuplicateEventField { + map_id, + path: path.to_owned(), + index, + field, + }); + } + property + .value() + .ok_or_else(|| MapEventCatalogError::MissingEventField { + map_id, + path: path.to_owned(), + index, + field, + }) +} + +fn required_event_integer( + object: &CstObject, + map_id: MapId, + path: &Path, + index: usize, + field: MapEventField, +) -> Result { + let node = required_event_node(object, map_id, path, index, field)?; + let number = + node.as_number_lit() + .ok_or_else(|| MapEventCatalogError::UnexpectedEventFieldKind { + map_id, + path: path.to_owned(), + index, + field, + actual: json_kind(&node), + })?; + number + .to_string() + .parse::() + .map_err(|_| MapEventCatalogError::UnsupportedEventInteger { + map_id, + path: path.to_owned(), + index, + field, + }) +} + +fn required_event_positive_integer( + object: &CstObject, + map_id: MapId, + path: &Path, + index: usize, + field: MapEventField, +) -> Result { + let value = required_event_integer(object, map_id, path, index, field)?; + MapEventId::new(value).ok_or_else(|| MapEventCatalogError::UnsupportedEventInteger { + map_id, + path: path.to_owned(), + index, + field, + }) +} + +fn required_event_string( + object: &CstObject, + map_id: MapId, + path: &Path, + index: usize, + field: MapEventField, +) -> Result { + let node = required_event_node(object, map_id, path, index, field)?; + let string = + node.as_string_lit() + .ok_or_else(|| MapEventCatalogError::UnexpectedEventFieldKind { + map_id, + path: path.to_owned(), + index, + field, + actual: json_kind(&node), + })?; + string + .decoded_value() + .map_err(|_| MapEventCatalogError::InvalidEventString { + map_id, + path: path.to_owned(), + index, + field, + }) +} + +fn matching_properties<'a>( + object: &'a CstObject, + name: &'a str, +) -> impl Iterator + 'a { + object.properties().into_iter().filter(move |property| { + property + .name() + .and_then(|value| value.decoded_value().ok()) + .is_some_and(|value| value == name) + }) +} + +fn expected_map_kind(field: MapEventMapField) -> JsonValueKind { + match field { + MapEventMapField::Width | MapEventMapField::Height => JsonValueKind::Number, + MapEventMapField::Events => JsonValueKind::Array, + } +} + +fn expected_event_kind(field: MapEventField) -> JsonValueKind { + match field { + MapEventField::Id | MapEventField::X | MapEventField::Y => JsonValueKind::Number, + MapEventField::Name => JsonValueKind::String, + MapEventField::Pages => JsonValueKind::Array, + } +} + +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 tempfile::TempDir; + + type ErrorPredicate = fn(&MapEventCatalogError) -> bool; + + fn snapshot_with( + map_infos: &[u8], + source: Option<&[u8]>, + max_bytes_per_document: usize, + ) -> (TempDir, ProjectSnapshot) { + let temp = TempDir::new().unwrap(); + fs::create_dir(temp.path().join("data")).unwrap(); + fs::write(temp.path().join("data/MapInfos.json"), map_infos).unwrap(); + if let Some(source) = source { + fs::write(temp.path().join("data/Map001.json"), 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_per_document).unwrap(), + max_aggregate_bytes: NonZeroUsize::new(131_072).unwrap(), + }, + ) + .unwrap(); + (temp, loaded) + } + + fn snapshot(source: &[u8]) -> (TempDir, ProjectSnapshot) { + snapshot_with( + br#"[null,{"id":1,"name":"Catalog Map","order":1,"parentId":0}]"#, + Some(source), + 65_536, + ) + } + + #[test] + fn projects_bounded_records_in_id_order_and_preserves_raw_bytes() { + let source = br#"{"width":10,"height":8,"events":[null,{"id":1,"name":"Door \u2603","note":"private memo","x":0,"y":7,"pages":[{"unknown":true},{"commands":[1]}],"plugin":{"value":1}},null,{"id":3,"name":"Chest","x":9,"y":0,"pages":[]}],"unknown":true}"#; + let (_temp, snapshot) = snapshot(source); + + let catalog = map_event_catalog(&snapshot, MapId::new(1).unwrap()).unwrap(); + + assert_eq!(catalog.map_id(), MapId::new(1).unwrap()); + assert_eq!(catalog.catalog_name(), "Catalog Map"); + assert_eq!(catalog.document_path(), Path::new("data/Map001.json")); + assert_eq!((catalog.width(), catalog.height()), (10, 8)); + assert!(catalog.findings().is_empty()); + let ids: Vec<_> = catalog.records().keys().map(|id| id.get()).collect(); + assert_eq!(ids, [1, 3]); + let first = catalog.get(MapEventId::new(1).unwrap()).unwrap(); + assert_eq!(first.name(), "Door ☃"); + assert_eq!((first.x(), first.y(), first.page_count()), (0, 7, 2)); + assert_eq!( + catalog + .get(MapEventId::new(3).unwrap()) + .unwrap() + .page_count(), + 0 + ); + assert_eq!( + snapshot.documents()[Path::new("data/Map001.json")].source_bytes(), + source + ); + } + + #[test] + fn reports_out_of_bounds_coordinates_without_losing_records() { + let source = br#"{"width":10,"height":8,"events":[null,{"id":1,"name":"Edge","x":10,"y":8,"pages":[{}]}]}"#; + let (_temp, snapshot) = snapshot(source); + + let catalog = map_event_catalog(&snapshot, MapId::new(1).unwrap()).unwrap(); + + assert_eq!(catalog.records().len(), 1); + assert_eq!( + catalog.findings(), + [MapEventFinding::CoordinatesOutsideMap { + event_id: MapEventId::new(1).unwrap(), + x: 10, + y: 8, + width: 10, + height: 8, + }] + ); + } + + #[test] + fn refuses_reserved_index_and_id_index_mismatch() { + let (_temp, reserved_snapshot) = snapshot( + br#"{"width":1,"height":1,"events":[{"id":1,"name":"A","x":0,"y":0,"pages":[]}]}"#, + ); + assert!(matches!( + map_event_catalog(&reserved_snapshot, MapId::new(1).unwrap()), + Err(MapEventCatalogError::ReservedEventIndex { .. }) + )); + + let (_temp, mismatch_snapshot) = snapshot( + br#"{"width":1,"height":1,"events":[null,{"id":2,"name":"A","x":0,"y":0,"pages":[]}]}"#, + ); + assert!(matches!( + map_event_catalog(&mismatch_snapshot, MapId::new(1).unwrap()), + Err(MapEventCatalogError::IdIndexMismatch { + index: 1, + decoded_id, + .. + }) if decoded_id == MapEventId::new(2).unwrap() + )); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn refuses_missing_duplicate_and_wrong_kind_event_fields() { + let cases: &[(&[u8], ErrorPredicate)] = &[ + ( + br#"{"width":1,"height":1,"events":[null,{"id":1,"x":0,"y":0,"pages":[]}]}"#, + |error| matches!(error, MapEventCatalogError::MissingEventField { field: MapEventField::Name, .. }), + ), + ( + br#"{"width":1,"height":1,"events":[null,{"id":1,"name":"A","n\u0061me":"B","x":0,"y":0,"pages":[]}]}"#, + |error| matches!(error, MapEventCatalogError::DuplicateEventField { field: MapEventField::Name, .. }), + ), + ( + br#"{"width":1,"height":1,"events":[null,{"id":1,"name":false,"x":0,"y":0,"pages":[]}]}"#, + |error| matches!(error, MapEventCatalogError::UnexpectedEventFieldKind { field: MapEventField::Name, actual: JsonValueKind::Boolean, .. }), + ), + ( + br#"{"width":1,"height":1,"events":[null,{"id":1,"name":"A","x":0,"y":0,"pages":{}}]}"#, + |error| matches!(error, MapEventCatalogError::UnexpectedEventFieldKind { field: MapEventField::Pages, actual: JsonValueKind::Object, .. }), + ), + ]; + + for (source, predicate) in cases { + let (_temp, snapshot) = snapshot(source); + let error = map_event_catalog(&snapshot, MapId::new(1).unwrap()).unwrap_err(); + assert!(predicate(&error), "unexpected error: {error}"); + } + } + + #[test] + fn refuses_unsupported_integers_and_non_object_entries() { + for source in [ + br#"{"width":1,"height":1,"events":[null,{"id":0,"name":"A","x":0,"y":0,"pages":[]}]}"#.as_slice(), + br#"{"width":1,"height":1,"events":[null,{"id":1,"name":"A","x":-1,"y":0,"pages":[]}]}"#.as_slice(), + br#"{"width":1,"height":1,"events":[null,{"id":1,"name":"A","x":1.0,"y":0,"pages":[]}]}"#.as_slice(), + br#"{"width":1,"height":1,"events":[null,{"id":1,"name":"A","x":4294967296,"y":0,"pages":[]}]}"#.as_slice(), + ] { + let (_temp, snapshot) = snapshot(source); + assert!(matches!( + map_event_catalog(&snapshot, MapId::new(1).unwrap()), + Err(MapEventCatalogError::UnsupportedEventInteger { .. }) + )); + } + + let (_temp, snapshot) = snapshot(br#"{"width":1,"height":1,"events":[null,true]}"#); + assert!(matches!( + map_event_catalog(&snapshot, MapId::new(1).unwrap()), + Err(MapEventCatalogError::UnexpectedEventEntryKind { + index: 1, + actual: JsonValueKind::Boolean, + .. + }) + )); + } + + #[test] + fn refuses_ambiguous_map_structure_and_unknown_map() { + let (_temp, snapshot) = snapshot(br#"{"width":1,"w\u0069dth":2,"height":1,"events":[]}"#); + assert!(matches!( + map_event_catalog(&snapshot, MapId::new(1).unwrap()), + Err(MapEventCatalogError::DuplicateMapField { + field: MapEventMapField::Width, + .. + }) + )); + assert!(matches!( + map_event_catalog(&snapshot, MapId::new(2).unwrap()), + Err(MapEventCatalogError::UnknownMapId { .. }) + )); + } + + #[test] + fn distinguishes_catalog_identity_and_document_failures() { + let (_temp, malformed_catalog) = snapshot_with(br#"{}"#, None, 65_536); + let catalog_error = + map_event_catalog(&malformed_catalog, MapId::new(1).unwrap()).unwrap_err(); + assert!(matches!( + catalog_error, + MapEventCatalogError::Catalog { .. } + )); + assert!(std::error::Error::source(&catalog_error).is_some()); + + let (_temp, missing) = snapshot_with( + br#"[null,{"id":1,"name":"One","order":1,"parentId":0}]"#, + None, + 65_536, + ); + assert!(matches!( + map_event_catalog(&missing, MapId::new(1).unwrap()), + Err(MapEventCatalogError::MissingDocument { .. }) + )); + + let long_map = format!( + r#"{{"width":1,"height":1,"events":[],"padding":"{}"}}"#, + "x".repeat(256) + ); + let (_temp, unavailable) = snapshot_with( + br#"[null,{"id":1,"name":"One","order":1,"parentId":0}]"#, + Some(long_map.as_bytes()), + 96, + ); + assert!(matches!( + map_event_catalog(&unavailable, MapId::new(1).unwrap()), + Err(MapEventCatalogError::UnavailableDocument { .. }) + )); + + let mut entries = vec!["null"; 1_001]; + entries[1_000] = r#"{"id":1000,"name":"Far","order":1,"parentId":0}"#; + let infos = format!("[{}]", entries.join(",")); + let (_temp, outside_family) = snapshot_with(infos.as_bytes(), None, 65_536); + assert!(matches!( + map_event_catalog(&outside_family, MapId::new(1_000).unwrap()), + Err(MapEventCatalogError::UnevidencedDocumentPath { .. }) + )); + } + + #[test] + fn refuses_malformed_required_map_fields() { + let cases: &[(&[u8], ErrorPredicate)] = &[ + (br#"[]"#, |error| { + matches!(error, MapEventCatalogError::UnexpectedRootKind { .. }) + }), + (br#"{"height":1,"events":[]}"#, |error| { + matches!( + error, + MapEventCatalogError::MissingMapField { + field: MapEventMapField::Width, + .. + } + ) + }), + (br#"{"width":"1","height":1,"events":[]}"#, |error| { + matches!( + error, + MapEventCatalogError::UnexpectedMapFieldKind { + field: MapEventMapField::Width, + actual: JsonValueKind::String, + .. + } + ) + }), + (br#"{"width":0,"height":1,"events":[]}"#, |error| { + matches!( + error, + MapEventCatalogError::UnsupportedMapInteger { + field: MapEventMapField::Width, + .. + } + ) + }), + (br#"{"width":1,"height":1.0,"events":[]}"#, |error| { + matches!( + error, + MapEventCatalogError::UnsupportedMapInteger { + field: MapEventMapField::Height, + .. + } + ) + }), + (br#"{"width":1,"height":1,"events":{}}"#, |error| { + matches!( + error, + MapEventCatalogError::UnexpectedMapFieldKind { + field: MapEventMapField::Events, + actual: JsonValueKind::Object, + .. + } + ) + }), + ]; + + for (source, predicate) in cases { + let (_temp, snapshot) = snapshot(source); + let error = map_event_catalog(&snapshot, MapId::new(1).unwrap()).unwrap_err(); + assert!(predicate(&error), "unexpected error: {error}"); + } + } + + #[test] + fn accepts_unsigned_coordinate_boundaries() { + let source = br#"{"width":4294967295,"height":4294967295,"events":[null,{"id":1,"name":"Boundary","x":4294967294,"y":4294967294,"pages":[]}]}"#; + let (_temp, snapshot) = snapshot(source); + + let catalog = map_event_catalog(&snapshot, MapId::new(1).unwrap()).unwrap(); + let event = catalog.get(MapEventId::new(1).unwrap()).unwrap(); + + assert_eq!(catalog.width(), u32::MAX); + assert_eq!(catalog.height(), u32::MAX); + assert_eq!(event.x(), u32::MAX - 1); + assert_eq!(event.y(), u32::MAX - 1); + assert!(catalog.findings().is_empty()); + } + + #[test] + fn checks_index_conversion_without_allocating_a_huge_array() { + let map_id = MapId::new(1).unwrap(); + assert!(matches!( + event_id_from_index(map_id, Path::new("data/Map001.json"), usize::MAX), + Err(MapEventCatalogError::EventIndexOutOfRange { .. }) + )); + } +} diff --git a/crates/tilewright/src/rpg_maker_mz/mod.rs b/crates/tilewright/src/rpg_maker_mz/mod.rs index 151fdab..5c792d2 100644 --- a/crates/tilewright/src/rpg_maker_mz/mod.rs +++ b/crates/tilewright/src/rpg_maker_mz/mod.rs @@ -7,6 +7,7 @@ pub mod discovery; pub mod inventory; pub mod map_catalog; +pub mod map_events; pub mod map_summary; pub mod player_start_validation; pub mod snapshot; diff --git a/docs/architecture.md b/docs/architecture.md index 3a4aea9..0b4254f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,10 +111,11 @@ 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, and system summary are the first 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. +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. 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 3b171b2..9f1e61d 100644 --- a/docs/capability-roadmap.md +++ b/docs/capability-roadmap.md @@ -170,6 +170,15 @@ The selected-map summary contract is accepted in experimentally. Neither acceptance nor implementation makes the capability Supported. +A bounded selected-map event catalog is implemented experimentally under the +proposed [ADR 0011](decisions/0011-experimental-map-event-catalog.md). It +projects map-scoped IDs, names, coordinates, and opaque page counts, while page +bodies, commands, notes, and unknown fields remain in the raw snapshot. Its +aggregate evidence covers 1,555 events across 196 MZ 1.10.0 map documents. +An independent differential audit matched all 10,323 bounded comparisons across +that corpus. Neither the proposal nor implementation makes the capability +Supported. + The bounded `System.json` orientation summary is accepted in [ADR 0009](decisions/0009-experimental-system-summary.md) and implemented experimentally. It projects only the seven accepted string and nonnegative @@ -287,15 +296,19 @@ 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 or event contents. 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 compose those projections experimentally; -they do not establish general project validity or editor compatibility. +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 +compose those projections experimentally; they do not establish general project +validity or editor compatibility. Differential verification matched all 196 catalog records and all 196 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 slice must again begin with a bounded evidence question and -explicit contract rather than expanding adjacent fields speculatively. +`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. diff --git a/docs/compatibility.md b/docs/compatibility.md index 3ea6654..f2afec1 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, 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, system summary, player-start validation, and strict lossless JSON syntax inspection. Initial `open_ambient_dir` acquisition for inventory, snapshot loading, map 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 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. | | `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. | @@ -40,6 +40,7 @@ not use “supported” to mean only that one file happened to parse. | 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 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. | | RPG Maker MZ player-start validation | Experimental | Composes the system summary, coherent map catalog, and selected-map dimensions over one existing snapshot. It reports the observed exact zero triplet as an unset player start, treats a zero map ID with nonzero coordinates as unevidenced, reports a missing positive catalog record, and checks `x < width` and `y < height`. Structural projection failures remain operation errors. Findings do not imply general project validity or editor rejection, and a finding-free report does not establish compatibility, passability, runtime success, mutation safety, or write support. Direct editor evidence covers one controlled MZ 1.10.0 relocation and recognition/preservation of the prepared zero triplet; Delete-generated serialization, mixed-zero behavior, malformed-reference editor behavior, and later versions remain unknown. | | General project validation | Not implemented | Only the bounded experimental player-start relationship is implemented; no general validity, severity, compatibility, repair, or write-time validation contract exists. | diff --git a/docs/decisions/0011-experimental-map-event-catalog.md b/docs/decisions/0011-experimental-map-event-catalog.md new file mode 100644 index 0000000..318b92e --- /dev/null +++ b/docs/decisions/0011-experimental-map-event-catalog.md @@ -0,0 +1,101 @@ +# ADR 0011: Experimental Selected-Map Event Catalog + +- **Status:** Proposed +- **Date:** 2026-08-09 + +## Context + +Tilewright can select and summarize one map, but its events remain opaque beyond +their count. The next useful read-only slice is a compact list of event IDs, +editor names, positions, and page counts. Event-page contents and commands must +remain opaque until their own evidence and contracts exist. + +The [map-event research](../formats/rpg-maker-mz/map-events.md) combines +official editor documentation with an aggregate audit of 1,555 events across +196 MZ 1.10.0 map documents. The public ownership, identifier scope, +malformed-structure behavior, coordinate findings, and adapter boundary require +an explicit decision. + +## Decision + +For the experimental selected-map event-catalog slice: + +1. The core library will expose a pure, read-only operation over an existing + `ProjectSnapshot` and catalog-scoped `MapId`. Adapters will not reimplement + event-document interpretation. +2. A positive `MapEventId` will be scoped to the selected map. It is not a + stable project-wide resource identifier. +3. The operation will require a coherent map catalog, matching record, and + evidenced three-digit map document. It will parse only positive map + dimensions and the `events` array from the map root. +4. A successful `MapEventCatalog` will own the selected map ID and catalog name, + exact project-relative document path, dimensions, event records in ascending + ID order, and deterministic contextual findings. +5. Each `MapEventRecord` will contain its map-scoped positive ID, decoded name, + nonnegative `u32` coordinates, and opaque page count. It will not expose the + memo, page objects, conditions, movement, images, commands, or extension + fields. +6. Null array entries are accepted as holes. A non-null entry must be an object + with unambiguous `id`, `name`, `x`, `y`, and `pages` fields. IDs must use the + bounded unsigned-decimal form, be positive, and equal their array indexes. + Coordinates use the same bounded form but may be zero. `pages` must be an + array; its contents remain uninterpreted and an empty array is reported as + count zero rather than declared invalid. +7. Coordinates outside the positive map dimensions produce ordered contextual + findings. They do not prevent other event records from being returned and do + not claim that RPG Maker MZ rejects the state. +8. Missing, duplicate, wrong-kind, malformed-string, unsupported-integer, + reserved-zero, index-range, and ID/index mismatch conditions are typed + structural errors. A structural error prevents the catalog rather than + returning a partial typed result. +9. The raw snapshot retains all source bytes and unknown data. This operation + performs no I/O, mutation, serialization, persistence, runtime execution, or + editor compatibility check. + +## Rationale + +Event identity, names, and placement provide immediate inspection value without +requiring page or command semantics. A map-scoped identifier reflects the +official documentation and observed array relationship without prematurely +inventing project-wide identity. Separating coordinate findings from structural +errors follows the existing map-catalog pattern and avoids converting an +observed relationship into an editor-validity claim. + +An owned projection keeps the provisional CST private and is straightforward +for CLI, MCP, and application callers. Leaving page bodies and memos in the raw +snapshot minimizes exposure and preserves unknown or plugin-defined content. + +## Consequences + +- Callers can list bounded event identity and placement for one selected map. +- Page count is useful scale information, not semantic understanding of a page. +- Unknown event and page content remains byte-identical in the snapshot. +- Malformed one-event structure prevents the typed catalog but not raw access. +- Future page, command, validation, or mutation slices require separate + evidence and contracts. +- Evidence currently covers MZ 1.10.0 only, so the capability remains + Experimental even if implemented. + +## Alternatives Considered + +- **Expose complete event and page models:** Rejected because page fields and + commands have not completed evidence or preservation design. +- **Return CST nodes or generic JSON values:** Rejected because it leaks the + provisional representation and makes adapters own domain behavior. +- **Use one project-wide event identifier:** Rejected because official and + observed evidence scopes event identity to a map. +- **Treat out-of-bounds coordinates as a fatal parse error:** Rejected because + the relationship can be reported without claiming editor invalidity. +- **Require nonempty page arrays:** Rejected because the corpus observation does + not establish malformed-input editor behavior. +- **Expose event memos immediately:** Deferred because the first CLI use case + does not require free-form memo content. + +## Validation + +Before implementation is ready for review, it must include generated synthetic +tests for success, holes, ordering, decoded names, coordinate findings, opaque +page counts, every structural refusal, raw-byte preservation, and absence of +mutation. Public Rustdoc and compatibility documentation must state identifier +scope and exact non-claims. Each adapter must add equivalent output, error, and +terminal-control tests when it exposes the operation. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ea9a15c..3743e1e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -5,7 +5,9 @@ implementation context has changed. ## Proposed decisions -No decisions are currently proposed. +| ADR | Status | Summary | +| --- | --- | --- | +| [0011: Experimental selected-map event catalog](0011-experimental-map-event-catalog.md) | Proposed | Add a read-only owned projection of map-scoped event IDs, names, positions, and opaque page counts for one catalog-selected map. | ## Accepted decisions diff --git a/docs/formats/rpg-maker-mz/README.md b/docs/formats/rpg-maker-mz/README.md index dd488a4..9750aac 100644 --- a/docs/formats/rpg-maker-mz/README.md +++ b/docs/formats/rpg-maker-mz/README.md @@ -27,6 +27,9 @@ the version actually documented or observed. first typed `MapInfos.json` projection and its remaining unknowns. - [Selected-map summary contract](map-summary.md) records the aggregate `MapNNN.json` shape audit and bounds the next read-only typed slice. +- [Selected-map event catalog contract](map-events.md) records the aggregate + event-shape audit and bounds event identity, placement, and opaque page + counts without interpreting page bodies or commands. - [System-summary contract](system-summary.md) records the aggregate `System.json` shape audit and bounds the implemented project-orientation slice. diff --git a/docs/formats/rpg-maker-mz/map-events.md b/docs/formats/rpg-maker-mz/map-events.md new file mode 100644 index 0000000..657fbef --- /dev/null +++ b/docs/formats/rpg-maker-mz/map-events.md @@ -0,0 +1,152 @@ +# RPG Maker MZ selected-map event catalog contract + +This document defines the evidence boundary for a small read-only projection of +event identity and placement from one evidenced `data/MapNNN.json` document. It +is a research result and proposed experimental contract, not a support claim. + +## Question and scope + +What is the smallest event projection that lets callers list the events on one +catalog-selected map without interpreting event pages, conditions, movement +routes, images, or commands? + +Direct observations cover 1,555 event objects and 1,576 pages in 196 map +documents from four user-owned projects created by RPG Maker MZ 1.10.0. Official +MZ help separately documents map-scoped event IDs, editor-facing names and +memos, numbered event pages, and placement on a map. Later versions, +malformed-input editor behavior, and event mutation remain unknown. + +## Evidence ledger + +| Claim | Evidence | Classification | Confidence | Remaining uncertainty | +| --- | --- | --- | --- | --- | +| Map events have map-scoped automatically assigned IDs, editor names and memos, and numbered pages. | `MZ-HELP-MAP-EVENT-SETTINGS-2026-08-09` | Documented | High for the editor concepts | The help does not define their JSON encoding or malformed-input behavior. | +| Every audited event object has exactly the decoded keys `id`, `name`, `note`, `pages`, `x`, and `y`, with consistent scalar or array kinds. | `MZ-1.10.0-MAP-EVENT-SHAPE-AUDIT-2026-08-09` | Observed | High across 1,555 event objects | Plugins and later versions may add fields; decoded duplicate keys were not established absent. | +| Every observed event ID is a positive integer equal to its array index and unique within its map. | Shape audit | Observed | High across all audited events | Allocation after deletion and editor handling of mismatches remain unknown. | +| Event names are strings; all observed names are nonempty. | Shape audit plus official help | Documented and Observed | High in the audited scope | Empty-name editor behavior and whether names must be unique remain unknown. | +| Event `x` and `y` values are nonnegative integers and all observed coordinates are inside the containing map dimensions. | Shape audit plus documented placement behavior | Observed; placement meaning Documented | High in the audited scope | Coordinate limits, loop-map behavior, and editor handling of out-of-bounds values remain unknown. | +| Event `pages` values are nonempty arrays containing only objects; observed events have one through three pages. | Shape audit plus official help | Documented and Observed | High in the audited scope | Page-object fields, page semantics, commands, and editor handling of empty arrays remain outside this slice. | +| Tilewright's selected-map event catalog matches an independent direct extraction of every bounded field. | `MZ-1.10.0-MAP-EVENT-DIFFERENTIAL-2026-08-09` | Observed | High across 196 maps and 1,555 events | This does not test editor mutation, malformed-input behavior, later versions, or page semantics. | + +## Aggregate shape audit + +On 2026-08-09, a read-only aggregate audit inspected the same 196 three-digit +map documents authorized under `MZ-1.10.0-FRESH-4-2026-08-01`. The audit +verified the canonical ignored research root, 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 coordinate bounds. It emitted no +event names, notes, page contents, commands, project paths, raw documents, +excerpts, hashes, or per-project manifests. + +The audit observed: + +- 196 map documents containing 1,555 event objects and 1,576 page objects; +- one shared event key set: `id`, `name`, `note`, `pages`, `x`, and `y`; +- integer event IDs from 1 through 67, all equal to their array index and unique + within the containing map; +- string names for every event, with no empty names; +- nonnegative integer coordinates, all within the containing map dimensions, + with observed `x` values from 0 through 183 and `y` values from 0 through 176; +- nonempty page arrays containing one through three object entries; and +- a null entry at event-array index zero in every map, with all other array + entries either null holes or event objects. + +These are observations of MZ-generated states, not editor validation rules. + +## Differential projection audit + +On 2026-08-09, the selected-map event catalog was run read-only against the +same four authorized MZ 1.10.0 projects. For every coherent catalog record, an +independent `jq` extraction compared the map identity and dimensions, every +event ID, name, coordinate, and page count, the derived coordinate findings, +and the bounded CLI envelope. + +All 196 selected maps, 1,555 event records, and 10,323 individual comparisons +matched exactly. Every snapshot was complete, every report had zero snapshot +diagnostics, and the independently derived and Tilewright coordinate-finding +lists were both empty. The audit emitted only aggregate counts and booleans. + +This verifies one implementation against independently decoded MZ-generated +data. It does not establish editor validation, mutation, save/reopen fidelity, +page or command semantics, or behavior in later versions. No project path, +event name, note, page body, command, raw document, excerpt, field value, report, +hash, or per-project manifest was retained. + +## Official documentation + +The official *Map Event Settings* help page says that an event ID is unique +within its map and automatically assigned in creation order. It separately +describes the editor-facing Name, free-form Memo, and consecutively numbered +event pages. The official *Events* help page describes map events as events +placed and edited on a map. + +- *Map Event Settings*, RPG Maker MZ Help, + , accessed + 2026-08-09. +- *Events*, RPG Maker MZ Help, + , accessed + 2026-08-09. + +The help documents editor concepts, not the JSON property names or exact +serialization rules. Those remain bounded by direct observation. + +## Proposed bounded typed contract + +The experimental slice should accept an existing `ProjectSnapshot` and a +catalog-scoped `MapId`, then expose: + +- a positive event ID scoped to the selected map; +- the decoded editor-facing event name; +- nonnegative `x` and `y` coordinate scalars; and +- the number of opaque event-page objects. + +The result should include the selected map identity, exact project-relative map +path, map dimensions, records ordered by event ID, and deterministic contextual +findings for coordinates outside those dimensions. An out-of-bounds coordinate +is a Tilewright relationship finding, not a claim that MZ rejects the project. + +The projection should require a coherent map catalog, a matching catalog +record, the evidenced three-digit map document, an object root, positive map +dimensions, and an `events` array. Each non-null entry should be an object with +unambiguous `id`, `name`, `x`, `y`, and `pages` fields. Event IDs should be +positive bounded integers equal to their array indexes. Coordinates should be +bounded unsigned integers. Page contents remain opaque; an empty page array can +be reported as a count of zero rather than treated as editor-invalid. + +The `note` field, page bodies, commands, conditions, images, movement settings, +and every unknown field remain only in the untouched raw snapshot. The +projection introduces no mutation, serialization, persistence, runtime, or +editor-compatibility behavior. + +## Fixture implications + +Tests can generate minimal strict JSON in temporary project trees. No vendor +project or application asset is needed. The matrix should include: + +- multiple event objects separated by null holes and returned in ID order; +- decoded names, zero coordinates, multiple opaque pages, and unknown fields; +- an empty page array without interpreting it as an editor-valid state; +- a deterministic out-of-bounds contextual finding; +- missing, duplicate, wrong-kind, malformed-string, and integer-boundary + refusals for every required field; +- reserved index zero, index overflow, and ID/index mismatch refusals; +- catalog, document, root, dimensions, and event-array failures; and +- proof that unknown event and page fields remain byte-identical in the raw + snapshot. + +Generated values are Tilewright test inputs only. They do not establish that MZ +accepts or rejects malformed projects. + +## Remaining unknowns and next experiments + +- Create, move, rename, add a page to, and delete one event in separate + disposable MZ 1.10.0 copies, saving and reopening after each action. +- Determine event-ID allocation and hole behavior after deletion and creation. +- Observe whether page deletion can produce an empty page array. +- Test how the editor handles out-of-bounds coordinates only if a future + validation or write contract depends on that behavior. +- Audit page structure and command lists separately before typing either. +- Observe another named MZ version at or above 1.10.0 before generalizing the + contract. diff --git a/docs/formats/rpg-maker-mz/map-summary.md b/docs/formats/rpg-maker-mz/map-summary.md index df67207..0f4a693 100644 --- a/docs/formats/rpg-maker-mz/map-summary.md +++ b/docs/formats/rpg-maker-mz/map-summary.md @@ -24,7 +24,7 @@ creation. Later versions and malformed-input editor behavior remain unknown. | `displayName` is present and string-valued; all fresh-project values are empty. | Shape audit plus controlled map-creation records | Observed | High for presence and kind in the observed scope | Nonempty editor-authored values and malformed-input behavior were not directly audited here. | | `width` and `height` are positive integer-valued numbers; observed ranges are 17–200 and 13–200. | Shape audit | Observed syntax; dimension meaning Inferred | High for observed kinds and ranges; high-confidence interpretation from names and tile-data relationship | Editor limits, zero or negative handling, and other versions remain unknown. | | `tilesetId` is a positive integer-valued number from 1 through 4, and every value resolves to a matching `Tilesets.json` record in its project. | Shape audit and independent cross-file comparison | Observed; reference meaning Inferred | High for all 196 documents | Tileset zero, missing references, other identifiers, and editor enforcement remain unknown. | -| `events` is an array containing only null holes and objects; the corpus contains 1,555 object entries, and every observed object ID equals its array index. | Shape audit plus documented map-event role and controlled event creation | Documented and Observed | High across all 196 documents | Event fields, pages, commands, duplicate IDs, malformed entries, and editor enforcement remain outside this slice. | +| `events` is an array containing only null holes and objects; the corpus contains 1,555 object entries, and every observed object ID equals its array index. | Shape audit plus the separate [map-event audit](map-events.md) | Documented and Observed | High across all 196 documents | Page bodies, commands, malformed-input editor behavior, and editor enforcement remain outside this slice. | | `data` is an integer array whose length equals `width * height * 6` in every audited document. | Shape audit | Observed; layer meaning Unknown | High for the arithmetic relationship in this corpus | Layer ordering, tile encoding, mutation rules, and other-version stability remain unestablished. | | Tilewright's selected-map summary matches an independent direct extraction of every bounded field. | `MZ-1.10.0-MAP-SUMMARY-DIFFERENTIAL-2026-08-06` | Observed | High across all 196 summaries in the four-project MZ 1.10.0 corpus | This does not test malformed-input editor behavior, later versions, tile meaning, event meaning, or editor reopen behavior. | @@ -130,7 +130,7 @@ that MZ accepts or rejects malformed projects. - Determine whether zero dimensions or tileset ID zero can be produced or tolerated before describing them as editor-invalid. - Establish tile-layer ordering before exposing typed tile data. -- Audit event structure separately before exposing event IDs, names, pages, or - command counts. +- Continue the separate [map-event investigation](map-events.md) with controlled + editor lifecycle experiments and differential verification. - Observe another named MZ version at or above 1.10.0 before generalizing the contract. diff --git a/docs/formats/rpg-maker-mz/project-layout-coverage.md b/docs/formats/rpg-maker-mz/project-layout-coverage.md index be57047..6a91757 100644 --- a/docs/formats/rpg-maker-mz/project-layout-coverage.md +++ b/docs/formats/rpg-maker-mz/project-layout-coverage.md @@ -44,7 +44,7 @@ enumerating every path a project may legally contain. | Marker contents | **Partial** | Generated projects use `RPGMZ 1.10.0`; empty content is rejected; MZ 1.10.0 accepts and preserves `RPGMZ 1.10.1`. | Complete grammar, prefix validation, whitespace/encoding tolerance, and other versions. | | 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 and aggregate map shape** | `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 opaque event entry counts have been audited. | Deeper hierarchy, parent deletion, multiple-hole selection, IDs above 999, malformed-input enforcement, tile-layer meaning, event semantics, mutation, 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. | | 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/research-ledger.md b/docs/formats/rpg-maker-mz/research-ledger.md index c29df06..d8510ce 100644 --- a/docs/formats/rpg-maker-mz/research-ledger.md +++ b/docs/formats/rpg-maker-mz/research-ledger.md @@ -1654,6 +1654,103 @@ 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-map-events-001`: What is the smallest useful map-event catalog? + +- **Status:** Active +- **Behavior depending on this:** Read-only listing of map-scoped event IDs, + editor names, stored coordinates, and opaque page counts for one selected + map. +- **Scope:** Event objects in 196 three-digit map documents from four RPG Maker + MZ 1.10.0 projects. Page bodies, commands, 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 [`map-events.md`](map-events.md#evidence-ledger). + +### Evidence record: `MZ-HELP-MAP-EVENT-SETTINGS-2026-08-09` + +- **Kind:** Official editor documentation. +- **Source:** *Map Event Settings*, RPG Maker MZ Help, + , and *Events*, + ; accessed + 2026-08-09. +- **Documented:** Map events have IDs unique within each map and automatically + assigned in creation order, editor-facing names and memos, numbered pages, + and placement on a map. +- **Limits:** The help does not define JSON property names, serialization, + numeric bounds, malformed-input behavior, or persistence fidelity. + +### Evidence record: `MZ-1.10.0-MAP-EVENT-SHAPE-AUDIT-2026-08-09` + +- **Kind:** Read-only aggregate shape and relationship 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 event and page + property names, JSON kinds, counts, integer relationships and ranges, page + array lengths, and coordinate bounds. The audit emitted aggregate results + only. +- **Observed:** The 196 maps contain 1,555 event objects and 1,576 page objects. + Every event shares the decoded key set `id`, `name`, `note`, `pages`, `x`, and + `y`. IDs are positive integers from 1 through 67, equal their array indexes, + and are unique within each map. Names are nonempty strings. Coordinates are + nonnegative integers and within map dimensions, with `x` from 0 through 183 + and `y` from 0 through 176. Page arrays are nonempty and contain one through + three object entries. Every event array has null at index zero and contains + only null holes or objects. +- **Limits:** Decoded duplicate properties were not established absent. The + audit does not establish plugin behavior, editor validation, event-ID + allocation after deletion, empty-page behavior, page or command semantics, + mutation fidelity, or other-version behavior. +- **Redistribution:** No event name, note, page content, command, 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 a positive `MapEventId` scoped to +one selected map, decoded name, nonnegative coordinates, and opaque page count. +It must retain all event notes, page bodies, commands, and unknown fields in the +raw snapshot; refuse ambiguous required structure; and distinguish coordinate +findings from editor-validity claims. The proposed architecture is recorded in +[ADR 0011](../../decisions/0011-experimental-map-event-catalog.md). + +### Evidence record: `MZ-1.10.0-MAP-EVENT-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 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, + dimensions, every event ID/name/X/Y/page count, coordinate findings, counts, + schema version, snapshot completeness, and diagnostic envelope. It emitted + aggregate counts only. +- **Observed:** All 196 selected maps, 1,555 events, and 10,323 individual + comparisons matched. Every snapshot was complete, every report had zero + snapshot diagnostics, and both derived coordinate-finding lists were empty. +- **Limits:** This verifies one implementation on four MZ-generated 1.10.0 + states. It does not establish editor validation, mutation or save/reopen + fidelity, malformed-input behavior, page or command semantics, runtime + behavior, or later-version compatibility. +- **Redistribution:** No project path, event name, note, page body, command, 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 + +Create, move, rename, add a page to, and delete one event in separate disposable +MZ 1.10.0 copies, saving and reopening after each action. Use those results to +check the current read-only contract before considering page-body semantics or +mutation. + ## `mz-system-summary-001`: What is the smallest useful system summary? - **Status:** Active diff --git a/docs/open-questions.md b/docs/open-questions.md index 14d9256..1235134 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -57,8 +57,9 @@ 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 0009 deliberately retains - system map fields as unvalidated `u32` scalars. + not a stable project-wide identity scheme. Proposed ADR 0011 similarly + introduces a `MapEventId` scoped to one selected map. 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 e58b4e1..fe2948d 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -23,12 +23,13 @@ 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, and -system summary copy bounded typed values from retained CSTs without changing -raw bytes. 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 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. ## Evidence limits writes From 7072751507e0230bc4859a695a565f1137f125bd Mon Sep 17 00:00:00 2001 From: Jason Cavinder Date: Sun, 9 Aug 2026 11:31:05 -1000 Subject: [PATCH 2/2] docs: accept ADR 0011 --- docs/capability-roadmap.md | 6 +++--- docs/decisions/0011-experimental-map-event-catalog.md | 2 +- docs/decisions/README.md | 5 ++--- docs/formats/rpg-maker-mz/map-events.md | 4 ++-- docs/formats/rpg-maker-mz/research-ledger.md | 2 +- docs/open-questions.md | 2 +- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/capability-roadmap.md b/docs/capability-roadmap.md index 9f1e61d..2595871 100644 --- a/docs/capability-roadmap.md +++ b/docs/capability-roadmap.md @@ -170,13 +170,13 @@ The selected-map summary contract is accepted in experimentally. Neither acceptance nor implementation makes the capability Supported. -A bounded selected-map event catalog is implemented experimentally under the -proposed [ADR 0011](decisions/0011-experimental-map-event-catalog.md). It +A bounded selected-map event catalog is implemented experimentally under +accepted [ADR 0011](decisions/0011-experimental-map-event-catalog.md). It projects map-scoped IDs, names, coordinates, and opaque page counts, while page bodies, commands, notes, and unknown fields remain in the raw snapshot. Its aggregate evidence covers 1,555 events across 196 MZ 1.10.0 map documents. An independent differential audit matched all 10,323 bounded comparisons across -that corpus. Neither the proposal nor implementation makes the capability +that corpus. Neither acceptance nor implementation makes the capability Supported. The bounded `System.json` orientation summary is accepted in diff --git a/docs/decisions/0011-experimental-map-event-catalog.md b/docs/decisions/0011-experimental-map-event-catalog.md index 318b92e..039440f 100644 --- a/docs/decisions/0011-experimental-map-event-catalog.md +++ b/docs/decisions/0011-experimental-map-event-catalog.md @@ -1,6 +1,6 @@ # ADR 0011: Experimental Selected-Map Event Catalog -- **Status:** Proposed +- **Status:** Accepted - **Date:** 2026-08-09 ## Context diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 3743e1e..ef534d1 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -5,9 +5,7 @@ implementation context has changed. ## Proposed decisions -| ADR | Status | Summary | -| --- | --- | --- | -| [0011: Experimental selected-map event catalog](0011-experimental-map-event-catalog.md) | Proposed | Add a read-only owned projection of map-scoped event IDs, names, positions, and opaque page counts for one catalog-selected map. | +No decisions are currently proposed. ## Accepted decisions @@ -23,6 +21,7 @@ implementation context has changed. | [0008: Experimental selected-map summary](0008-experimental-selected-map-summary.md) | Accepted | Adopt a read-only owned summary for one catalog-selected map's basic metadata, dimensions, tileset scalar, and opaque event count. | | [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. | ## Adding or changing a decision diff --git a/docs/formats/rpg-maker-mz/map-events.md b/docs/formats/rpg-maker-mz/map-events.md index 657fbef..e43e86e 100644 --- a/docs/formats/rpg-maker-mz/map-events.md +++ b/docs/formats/rpg-maker-mz/map-events.md @@ -2,7 +2,7 @@ This document defines the evidence boundary for a small read-only projection of event identity and placement from one evidenced `data/MapNNN.json` document. It -is a research result and proposed experimental contract, not a support claim. +is a research result and accepted experimental contract, not a support claim. ## Question and scope @@ -92,7 +92,7 @@ placed and edited on a map. The help documents editor concepts, not the JSON property names or exact serialization rules. Those remain bounded by direct observation. -## Proposed bounded typed contract +## Accepted bounded typed contract The experimental slice should accept an existing `ProjectSnapshot` and a catalog-scoped `MapId`, then expose: diff --git a/docs/formats/rpg-maker-mz/research-ledger.md b/docs/formats/rpg-maker-mz/research-ledger.md index d8510ce..13425c9 100644 --- a/docs/formats/rpg-maker-mz/research-ledger.md +++ b/docs/formats/rpg-maker-mz/research-ledger.md @@ -1724,7 +1724,7 @@ 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 proposed event-catalog implementation on 2026-08-09; Rust 1.97.1 and + 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 diff --git a/docs/open-questions.md b/docs/open-questions.md index 1235134..09aadb6 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -57,7 +57,7 @@ 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. Proposed ADR 0011 similarly + 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. - How should event-command parameter arrays be typed incrementally?