From a3df447186ed19acc727b9fd0bd2f51fbf2f25c1 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 19 Apr 2026 02:01:26 -0700 Subject: [PATCH] Ground widened add-building descriptor names --- .../event-effects-building-bindings.json | 45 ++++ .../event-effects-semantic-catalog.json | 10 +- crates/rrt-cli/src/main.rs | 204 +++++++++++++++++- crates/rrt-model/src/lib.rs | 1 + docs/rehost-queue.md | 15 +- .../py/build_event_effect_semantic_catalog.py | 27 ++- 6 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 artifacts/exports/rt3-1.06/event-effects-building-bindings.json diff --git a/artifacts/exports/rt3-1.06/event-effects-building-bindings.json b/artifacts/exports/rt3-1.06/event-effects-building-bindings.json new file mode 100644 index 0000000..bad7f0e --- /dev/null +++ b/artifacts/exports/rt3-1.06/event-effects-building-bindings.json @@ -0,0 +1,45 @@ +{ + "binding_catalog_version": 1, + "notes": [ + "Add-building descriptor ids 503..613 now ground candidate id as descriptor_id - 503 through direct disassembly of 0x00430270 world_try_place_random_structure_batch_from_compact_record.", + "The concrete candidate names below are checked against the stable RT3 1.05 candidate-availability table order exposed by runtime inspect-candidate-table on Alternate USA, Southern Pacific, and Spanish Mainline saves.", + "Availability bits vary by scenario, but the ordered candidate names for these ids are stable across the checked saves." + ], + "bindings": [ + { + "descriptor_id": 521, + "candidate_id": 18, + "candidate_name": "FarmGrain", + "binding_index": 19, + "binding_source": "rt3_105_candidate_table" + }, + { + "descriptor_id": 526, + "candidate_id": 23, + "candidate_name": "Furniture Factory", + "binding_index": 24, + "binding_source": "rt3_105_candidate_table" + }, + { + "descriptor_id": 528, + "candidate_id": 25, + "candidate_name": "Logging Camp", + "binding_index": 26, + "binding_source": "rt3_105_candidate_table" + }, + { + "descriptor_id": 548, + "candidate_id": 45, + "candidate_name": "Port01", + "binding_index": 46, + "binding_source": "rt3_105_candidate_table" + }, + { + "descriptor_id": 563, + "candidate_id": 60, + "candidate_name": "Warehouse05", + "binding_index": 61, + "binding_source": "rt3_105_candidate_table" + } + ] +} diff --git a/artifacts/exports/rt3-1.06/event-effects-semantic-catalog.json b/artifacts/exports/rt3-1.06/event-effects-semantic-catalog.json index 921c36f..f3d52b9 100644 --- a/artifacts/exports/rt3-1.06/event-effects-semantic-catalog.json +++ b/artifacts/exports/rt3-1.06/event-effects-semantic-catalog.json @@ -4694,7 +4694,7 @@ }, { "descriptor_id": 521, - "label": "Add Building Slot 19", + "label": "Add Building FarmGrain", "target_mask_bits": 8, "parameter_family": "world_building_spawn", "runtime_key": null, @@ -4739,7 +4739,7 @@ }, { "descriptor_id": 526, - "label": "Add Building Slot 24", + "label": "Add Building Furniture Factory", "target_mask_bits": 8, "parameter_family": "world_building_spawn", "runtime_key": null, @@ -4757,7 +4757,7 @@ }, { "descriptor_id": 528, - "label": "Add Building Slot 26", + "label": "Add Building Logging Camp", "target_mask_bits": 8, "parameter_family": "world_building_spawn", "runtime_key": null, @@ -4937,7 +4937,7 @@ }, { "descriptor_id": 548, - "label": "Add Building Slot 46", + "label": "Add Building Port01", "target_mask_bits": 8, "parameter_family": "world_building_spawn", "runtime_key": null, @@ -5072,7 +5072,7 @@ }, { "descriptor_id": 563, - "label": "Add Building Slot 61", + "label": "Add Building Warehouse05", "target_mask_bits": 8, "parameter_family": "world_building_spawn", "runtime_key": null, diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index d5da00e..a16cc47 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -125,6 +125,9 @@ enum Command { RuntimeInspectSmp { smp_path: PathBuf, }, + RuntimeInspectCandidateTable { + smp_path: PathBuf, + }, RuntimeInspectCompactEventDispatchCluster { root_path: PathBuf, }, @@ -521,6 +524,34 @@ struct RuntimeCandidateTableSample { availability_by_name: BTreeMap, } +#[derive(Debug, Serialize)] +struct RuntimeCandidateTableEntrySample { + index: usize, + offset: usize, + text: String, + availability_dword: u32, + availability_dword_hex: String, + trailer_word: u32, + trailer_word_hex: String, +} + +#[derive(Debug, Serialize)] +struct RuntimeCandidateTableInspectionReport { + path: String, + profile_family: String, + source_kind: String, + semantic_family: String, + header_word_0_hex: String, + header_word_1_hex: String, + header_word_2_hex: String, + observed_entry_capacity: usize, + observed_entry_count: usize, + zero_trailer_entry_count: usize, + nonzero_trailer_entry_count: usize, + zero_trailer_entry_names: Vec, + entries: Vec, +} + #[derive(Debug, Serialize)] struct RuntimeCandidateTableComparisonReport { file_count: usize, @@ -946,6 +977,9 @@ fn real_main() -> Result<(), Box> { Command::RuntimeInspectSmp { smp_path } => { run_runtime_inspect_smp(&smp_path)?; } + Command::RuntimeInspectCandidateTable { smp_path } => { + run_runtime_inspect_candidate_table(&smp_path)?; + } Command::RuntimeInspectCompactEventDispatchCluster { root_path } => { run_runtime_inspect_compact_event_dispatch_cluster(&root_path)?; } @@ -1167,6 +1201,13 @@ fn parse_command() -> Result> { smp_path: PathBuf::from(path), }) } + [command, subcommand, path] + if command == "runtime" && subcommand == "inspect-candidate-table" => + { + Ok(Command::RuntimeInspectCandidateTable { + smp_path: PathBuf::from(path), + }) + } [command, subcommand, root_path] if command == "runtime" && subcommand == "inspect-compact-event-dispatch-cluster" => @@ -1459,7 +1500,7 @@ fn parse_command() -> Result> { }) } _ => Err( - "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime diff-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime inspect-compact-event-dispatch-cluster | runtime summarize-save-load | runtime load-save-slice | runtime inspect-save-company-chairman | runtime inspect-save-placed-structure-triplets | runtime compare-region-fixed-row-runs | runtime inspect-periodic-company-service-trace | runtime inspect-region-service-trace | runtime inspect-infrastructure-asset-trace | runtime inspect-save-region-queued-notice-records | runtime inspect-placed-structure-dynamic-side-buffer | runtime inspect-unclassified-save-collections | runtime import-save-state | runtime export-save-slice | runtime export-overlay-import | runtime inspect-pk4 | runtime inspect-cargo-types | runtime inspect-cargo-skins | runtime inspect-cargo-economy-sources | runtime inspect-cargo-production-selector | runtime inspect-cargo-price-selector | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-recipe-book-lines [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime compare-post-special-conditions-scalars [fileN...] | runtime scan-candidate-table-headers | runtime scan-special-conditions | runtime scan-aligned-runtime-rule-band | runtime scan-post-special-conditions-scalars | runtime scan-post-special-conditions-tail | runtime scan-recipe-book-lines | runtime export-profile-block ]" + "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime diff-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime inspect-candidate-table | runtime inspect-compact-event-dispatch-cluster | runtime summarize-save-load | runtime load-save-slice | runtime inspect-save-company-chairman | runtime inspect-save-placed-structure-triplets | runtime compare-region-fixed-row-runs | runtime inspect-periodic-company-service-trace | runtime inspect-region-service-trace | runtime inspect-infrastructure-asset-trace | runtime inspect-save-region-queued-notice-records | runtime inspect-placed-structure-dynamic-side-buffer | runtime inspect-unclassified-save-collections | runtime import-save-state | runtime export-save-slice | runtime export-overlay-import | runtime inspect-pk4 | runtime inspect-cargo-types | runtime inspect-cargo-skins | runtime inspect-cargo-economy-sources | runtime inspect-cargo-production-selector | runtime inspect-cargo-price-selector | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-recipe-book-lines [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime compare-post-special-conditions-scalars [fileN...] | runtime scan-candidate-table-headers | runtime scan-special-conditions | runtime scan-aligned-runtime-rule-band | runtime scan-post-special-conditions-scalars | runtime scan-post-special-conditions-tail | runtime scan-recipe-book-lines | runtime export-profile-block ]" .into(), ), } @@ -2246,6 +2287,12 @@ fn run_runtime_compare_candidate_table( Ok(()) } +fn run_runtime_inspect_candidate_table(smp_path: &Path) -> Result<(), Box> { + let report = load_candidate_table_inspection_report(smp_path)?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + fn run_runtime_compare_recipe_book_lines( smp_paths: &[PathBuf], ) -> Result<(), Box> { @@ -3119,6 +3166,161 @@ fn load_candidate_table_sample( }) } +fn load_candidate_table_inspection_report( + smp_path: &Path, +) -> Result> { + let inspection = inspect_smp_file(smp_path)?; + if let Some(probe) = inspection.rt3_105_save_name_table_probe { + return Ok(RuntimeCandidateTableInspectionReport { + path: smp_path.display().to_string(), + profile_family: probe.profile_family, + source_kind: probe.source_kind, + semantic_family: probe.semantic_family, + header_word_0_hex: probe.header_word_0_hex, + header_word_1_hex: probe.header_word_1_hex, + header_word_2_hex: probe.header_word_2_hex, + observed_entry_capacity: probe.observed_entry_capacity, + observed_entry_count: probe.observed_entry_count, + zero_trailer_entry_count: probe.zero_trailer_entry_count, + nonzero_trailer_entry_count: probe.nonzero_trailer_entry_count, + zero_trailer_entry_names: probe.zero_trailer_entry_names, + entries: probe + .entries + .into_iter() + .map(|entry| RuntimeCandidateTableEntrySample { + index: entry.index, + offset: entry.offset, + text: entry.text, + availability_dword: entry.availability_dword, + availability_dword_hex: entry.availability_dword_hex, + trailer_word: entry.trailer_word, + trailer_word_hex: entry.trailer_word_hex, + }) + .collect(), + }); + } + + let bytes = fs::read(smp_path)?; + let header_offset = 0x6a70usize; + let entries_offset = 0x6ad1usize; + let block_end_offset = 0x73c0usize; + let entry_stride = 0x22usize; + if bytes.len() < block_end_offset + || !matches_candidate_table_header_bytes(&bytes, header_offset) + { + return Err(format!( + "{} did not expose an RT3 1.05 candidate-availability table", + smp_path.display() + ) + .into()); + } + + let observed_entry_capacity = read_u32_le(&bytes, header_offset + 0x1c) + .ok_or_else(|| format!("{} is missing candidate table capacity", smp_path.display()))? + as usize; + let observed_entry_count = read_u32_le(&bytes, header_offset + 0x20) + .ok_or_else(|| format!("{} is missing candidate table count", smp_path.display()))? + as usize; + if observed_entry_capacity < observed_entry_count { + return Err(format!( + "{} has invalid candidate table capacity/count {observed_entry_capacity}/{observed_entry_count}", + smp_path.display() + ) + .into()); + } + let entries_end_offset = entries_offset + .checked_add( + observed_entry_count + .checked_mul(entry_stride) + .ok_or("candidate table length overflow")?, + ) + .ok_or("candidate table end overflow")?; + if entries_end_offset > block_end_offset { + return Err(format!( + "{} candidate table overruns fixed block end", + smp_path.display() + ) + .into()); + } + + let mut zero_trailer_entry_names = Vec::new(); + let mut entries = Vec::new(); + for index in 0..observed_entry_count { + let offset = entries_offset + index * entry_stride; + let chunk = &bytes[offset..offset + entry_stride]; + let nul_index = chunk + .iter() + .position(|byte| *byte == 0) + .unwrap_or(entry_stride - 4); + let text = std::str::from_utf8(&chunk[..nul_index]).map_err(|_| { + format!( + "{} contains invalid UTF-8 in candidate table", + smp_path.display() + ) + })?; + let availability_dword = + read_u32_le(&bytes, offset + entry_stride - 4).ok_or_else(|| { + format!( + "{} is missing candidate availability dword", + smp_path.display() + ) + })?; + if availability_dword == 0 { + zero_trailer_entry_names.push(text.to_string()); + } + entries.push(RuntimeCandidateTableEntrySample { + index, + offset, + text: text.to_string(), + availability_dword, + availability_dword_hex: format!("0x{availability_dword:08x}"), + trailer_word: availability_dword, + trailer_word_hex: format!("0x{availability_dword:08x}"), + }); + } + + Ok(RuntimeCandidateTableInspectionReport { + path: smp_path.display().to_string(), + profile_family: classify_candidate_table_header_profile( + smp_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()), + &bytes, + ), + source_kind: match smp_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("gmp") => "map-fixed-catalog-range", + Some("gms") => "save-fixed-catalog-range", + _ => "fixed-catalog-range", + } + .to_string(), + semantic_family: "scenario-named-candidate-availability-table".to_string(), + header_word_0_hex: format!( + "0x{:08x}", + read_u32_le(&bytes, header_offset).ok_or("missing candidate header word 0")? + ), + header_word_1_hex: format!( + "0x{:08x}", + read_u32_le(&bytes, header_offset + 4).ok_or("missing candidate header word 1")? + ), + header_word_2_hex: format!( + "0x{:08x}", + read_u32_le(&bytes, header_offset + 8).ok_or("missing candidate header word 2")? + ), + observed_entry_capacity, + observed_entry_count, + zero_trailer_entry_count: zero_trailer_entry_names.len(), + nonzero_trailer_entry_count: observed_entry_count - zero_trailer_entry_names.len(), + zero_trailer_entry_names, + entries, + }) +} + fn load_recipe_book_line_sample( smp_path: &Path, ) -> Result> { diff --git a/crates/rrt-model/src/lib.rs b/crates/rrt-model/src/lib.rs index 828ecf4..c4fc687 100644 --- a/crates/rrt-model/src/lib.rs +++ b/crates/rrt-model/src/lib.rs @@ -32,6 +32,7 @@ pub const REQUIRED_EXPORTS: &[&str] = &[ "artifacts/exports/rt3-1.06/pending-template-store-management.md", "artifacts/exports/rt3-1.06/event-effects-table.json", "artifacts/exports/rt3-1.06/event-effects-cargo-bindings.json", + "artifacts/exports/rt3-1.06/event-effects-building-bindings.json", "artifacts/exports/rt3-1.06/event-effects-semantic-catalog.json", "artifacts/exports/rt3-1.06/economy-cargo-sources.json", "artifacts/exports/rt3-1.06/selected-year-bucket-ladder.json", diff --git a/docs/rehost-queue.md b/docs/rehost-queue.md index 17f71f5..9568dc8 100644 --- a/docs/rehost-queue.md +++ b/docs/rehost-queue.md @@ -289,18 +289,21 @@ Working rule: cover the full `614`-row export instead of the old truncated `520`-row slice - that closes the earlier unlabeled cluster: grouped descriptor ids `521`, `526`, `528`, `548`, and `563` are now recovered as - `Add Building Slot 19`, `Add Building Slot 24`, `Add Building Slot 26`, `Add Building Slot 46`, - and `Add Building Slot 61` respectively, all in the widened shell-owned add-building strip - `503..613` + `Add Building FarmGrain`, `Add Building Furniture Factory`, `Add Building Logging Camp`, + `Add Building Port01`, and `Add Building Warehouse05` respectively. The checked-in + `event-effects-building-bindings.json` artifact grounds those names from the stable RT3 1.05 + candidate-table order plus the direct `descriptor_id - 503` candidate-id bridge in + `0x00430270` - the earlier `label_id - 2000` bridge for `548` and `563` is now known to be a false lead: those numeric collisions hit the special-condition label table (`Disable Building Stations`, `Completely Disable Money-Related Things`), but the extended EventEffects table proves the actual grouped descriptors are add-building slots, not special-condition verbs - the compact opcode-`8` frontier therefore shifts: - the next static-analysis pass should target which add-building slot numbers correspond to which - concrete building classes and whether opcode `8` on that shell-owned strip means a distinct - add-building shell flow, not more missing-label recovery + the next static-analysis pass should widen the concrete add-building candidate-name bridge + beyond the already grounded `521/526/528/548/563` rows and then determine whether opcode `8` + on that shell-owned strip means a distinct add-building shell flow, not more missing-label + recovery - the concrete owner strip above that bundle is grounded now too: `0x00433060` is the direct non-direct serializer loop that writes `0x4e99/0x4e9a/0x4e9b`, calls `0x00430d70` per live collection row, and sits beside the sibling `0x00433130` size/load diff --git a/tools/py/build_event_effect_semantic_catalog.py b/tools/py/build_event_effect_semantic_catalog.py index ee4e994..fb7a7dd 100644 --- a/tools/py/build_event_effect_semantic_catalog.py +++ b/tools/py/build_event_effect_semantic_catalog.py @@ -115,8 +115,21 @@ def load_cargo_bindings(raw_table_path: Path) -> dict[int, dict[str, object]]: } +def load_building_bindings(raw_table_path: Path) -> dict[int, dict[str, object]]: + bindings_path = raw_table_path.parent / "event-effects-building-bindings.json" + if not bindings_path.exists(): + return {} + artifact = json.loads(bindings_path.read_text(encoding="utf-8")) + return { + int(binding["descriptor_id"]): binding + for binding in artifact.get("bindings", []) + } + + def classify( - row: dict[str, object], cargo_bindings: dict[int, dict[str, object]] + row: dict[str, object], + cargo_bindings: dict[int, dict[str, object]], + building_bindings: dict[int, dict[str, object]], ) -> dict[str, object]: descriptor_id = int(row["descriptor_id"]) label = str(row["label"]) @@ -223,7 +236,11 @@ def classify( executable_in_runtime = True elif 503 <= descriptor_id <= 613: parameter_family = "world_building_spawn" - label = f"Add Building Slot {descriptor_id - 502}" + binding = building_bindings.get(descriptor_id) + if binding is not None: + label = f"Add Building {binding['candidate_name']}" + else: + label = f"Add Building Slot {descriptor_id - 502}" runtime_status = "shell_owned" elif "Earthquake" in label or "Storm" in label: parameter_family = "world_disaster_scalar" @@ -249,7 +266,11 @@ def main() -> None: raw_artifact = json.loads(args.raw_table.read_text(encoding="utf-8")) cargo_bindings = load_cargo_bindings(args.raw_table) - descriptors = [classify(row, cargo_bindings) for row in raw_artifact["descriptors"]] + building_bindings = load_building_bindings(args.raw_table) + descriptors = [ + classify(row, cargo_bindings, building_bindings) + for row in raw_artifact["descriptors"] + ] artifact = { "descriptor_count": len(descriptors), "raw_table_binary_sha256": raw_artifact.get("binary_sha256"),