Ground widened add-building descriptor names

This commit is contained in:
Jan Petykiewicz 2026-04-19 02:01:26 -07:00
commit a3df447186
6 changed files with 287 additions and 15 deletions

View file

@ -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"
}
]
}

View file

@ -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,

View file

@ -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<String, u32>,
}
#[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<String>,
entries: Vec<RuntimeCandidateTableEntrySample>,
}
#[derive(Debug, Serialize)]
struct RuntimeCandidateTableComparisonReport {
file_count: usize,
@ -946,6 +977,9 @@ fn real_main() -> Result<(), Box<dyn std::error::Error>> {
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<Command, Box<dyn std::error::Error>> {
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<Command, Box<dyn std::error::Error>> {
})
}
_ => Err(
"usage: rrt-cli [validate [repo-root] | finance eval <snapshot.json> | finance diff <left.json> <right.json> | runtime validate-fixture <fixture.json> | runtime summarize-fixture <fixture.json> | runtime export-fixture-state <fixture.json> <snapshot.json> | runtime diff-state <left.json> <right.json> | runtime summarize-state <snapshot.json> | runtime import-state <input.json> <snapshot.json> | runtime inspect-smp <file.smp> | runtime inspect-compact-event-dispatch-cluster <maps-dir> | runtime summarize-save-load <file.smp> | runtime load-save-slice <file.smp> | runtime inspect-save-company-chairman <file.smp> | runtime inspect-save-placed-structure-triplets <file.smp> | runtime compare-region-fixed-row-runs <left.gms> <right.gms> | runtime inspect-periodic-company-service-trace <file.smp> | runtime inspect-region-service-trace <file.smp> | runtime inspect-infrastructure-asset-trace <file.smp> | runtime inspect-save-region-queued-notice-records <file.smp> | runtime inspect-placed-structure-dynamic-side-buffer <file.smp> | runtime inspect-unclassified-save-collections <file.smp> | runtime import-save-state <file.smp> <snapshot.json> | runtime export-save-slice <file.smp> <save-slice.json> | runtime export-overlay-import <snapshot.json> <save-slice.json> <overlay-import.json> | runtime inspect-pk4 <file.pk4> | runtime inspect-cargo-types <CargoTypes-dir> | runtime inspect-cargo-skins <Cargo106.PK4> | runtime inspect-cargo-economy-sources <CargoTypes-dir> <Cargo106.PK4> | runtime inspect-cargo-production-selector <CargoTypes-dir> <Cargo106.PK4> | runtime inspect-cargo-price-selector <CargoTypes-dir> <Cargo106.PK4> | runtime inspect-win <file.win> | runtime extract-pk4-entry <file.pk4> <entry-name> <output-path> | runtime inspect-campaign-exe <RT3.exe> | runtime compare-classic-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-105-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-candidate-table <file1> <file2> [fileN...] | runtime compare-recipe-book-lines <file1> <file2> [fileN...] | runtime compare-setup-payload-core <file1> <file2> [fileN...] | runtime compare-setup-launch-payload <file1> <file2> [fileN...] | runtime compare-post-special-conditions-scalars <file1> <file2> [fileN...] | runtime scan-candidate-table-headers <root-dir> | runtime scan-special-conditions <root-dir> | runtime scan-aligned-runtime-rule-band <root-dir> | runtime scan-post-special-conditions-scalars <root-dir> | runtime scan-post-special-conditions-tail <root-dir> | runtime scan-recipe-book-lines <root-dir> | runtime export-profile-block <save.gms> <profile.json>]"
"usage: rrt-cli [validate [repo-root] | finance eval <snapshot.json> | finance diff <left.json> <right.json> | runtime validate-fixture <fixture.json> | runtime summarize-fixture <fixture.json> | runtime export-fixture-state <fixture.json> <snapshot.json> | runtime diff-state <left.json> <right.json> | runtime summarize-state <snapshot.json> | runtime import-state <input.json> <snapshot.json> | runtime inspect-smp <file.smp> | runtime inspect-candidate-table <file.smp> | runtime inspect-compact-event-dispatch-cluster <maps-dir> | runtime summarize-save-load <file.smp> | runtime load-save-slice <file.smp> | runtime inspect-save-company-chairman <file.smp> | runtime inspect-save-placed-structure-triplets <file.smp> | runtime compare-region-fixed-row-runs <left.gms> <right.gms> | runtime inspect-periodic-company-service-trace <file.smp> | runtime inspect-region-service-trace <file.smp> | runtime inspect-infrastructure-asset-trace <file.smp> | runtime inspect-save-region-queued-notice-records <file.smp> | runtime inspect-placed-structure-dynamic-side-buffer <file.smp> | runtime inspect-unclassified-save-collections <file.smp> | runtime import-save-state <file.smp> <snapshot.json> | runtime export-save-slice <file.smp> <save-slice.json> | runtime export-overlay-import <snapshot.json> <save-slice.json> <overlay-import.json> | runtime inspect-pk4 <file.pk4> | runtime inspect-cargo-types <CargoTypes-dir> | runtime inspect-cargo-skins <Cargo106.PK4> | runtime inspect-cargo-economy-sources <CargoTypes-dir> <Cargo106.PK4> | runtime inspect-cargo-production-selector <CargoTypes-dir> <Cargo106.PK4> | runtime inspect-cargo-price-selector <CargoTypes-dir> <Cargo106.PK4> | runtime inspect-win <file.win> | runtime extract-pk4-entry <file.pk4> <entry-name> <output-path> | runtime inspect-campaign-exe <RT3.exe> | runtime compare-classic-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-105-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-candidate-table <file1> <file2> [fileN...] | runtime compare-recipe-book-lines <file1> <file2> [fileN...] | runtime compare-setup-payload-core <file1> <file2> [fileN...] | runtime compare-setup-launch-payload <file1> <file2> [fileN...] | runtime compare-post-special-conditions-scalars <file1> <file2> [fileN...] | runtime scan-candidate-table-headers <root-dir> | runtime scan-special-conditions <root-dir> | runtime scan-aligned-runtime-rule-band <root-dir> | runtime scan-post-special-conditions-scalars <root-dir> | runtime scan-post-special-conditions-tail <root-dir> | runtime scan-recipe-book-lines <root-dir> | runtime export-profile-block <save.gms> <profile.json>]"
.into(),
),
}
@ -2246,6 +2287,12 @@ fn run_runtime_compare_candidate_table(
Ok(())
}
fn run_runtime_inspect_candidate_table(smp_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
@ -3119,6 +3166,161 @@ fn load_candidate_table_sample(
})
}
fn load_candidate_table_inspection_report(
smp_path: &Path,
) -> Result<RuntimeCandidateTableInspectionReport, Box<dyn std::error::Error>> {
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<RuntimeRecipeBookLineSample, Box<dyn std::error::Error>> {

View file

@ -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",

View file

@ -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

View file

@ -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"),