Add save-side collection seam scanners

This commit is contained in:
Jan Petykiewicz 2026-04-18 11:45:52 -07:00
commit ec4919fdbf
4 changed files with 534 additions and 112 deletions

View file

@ -27,7 +27,8 @@ use rrt_runtime::{
SmpRt3105PackedProfileBlock, SmpSaveLoadSummary, WinInspectionReport, execute_step_command, SmpRt3105PackedProfileBlock, SmpSaveLoadSummary, WinInspectionReport, execute_step_command,
extract_pk4_entry_file, inspect_campaign_exe_file, inspect_cargo_economy_sources_with_bindings, extract_pk4_entry_file, inspect_campaign_exe_file, inspect_cargo_economy_sources_with_bindings,
inspect_cargo_skin_pk4, inspect_cargo_types_dir, inspect_pk4_file, inspect_cargo_skin_pk4, inspect_cargo_types_dir, inspect_pk4_file,
inspect_save_company_and_chairman_analysis_file, inspect_smp_file, inspect_win_file, inspect_save_company_and_chairman_analysis_file, inspect_smp_file,
inspect_unclassified_save_collection_headers_file, inspect_win_file,
load_runtime_snapshot_document, load_runtime_state_import, load_save_slice_file, load_runtime_snapshot_document, load_runtime_state_import, load_save_slice_file,
project_save_slice_to_runtime_state_import, save_runtime_overlay_import_document, project_save_slice_to_runtime_state_import, save_runtime_overlay_import_document,
save_runtime_save_slice_document, save_runtime_snapshot_document, save_runtime_save_slice_document, save_runtime_snapshot_document,
@ -130,6 +131,9 @@ enum Command {
RuntimeInspectSaveCompanyChairman { RuntimeInspectSaveCompanyChairman {
smp_path: PathBuf, smp_path: PathBuf,
}, },
RuntimeInspectUnclassifiedSaveCollections {
smp_path: PathBuf,
},
RuntimeImportSaveState { RuntimeImportSaveState {
smp_path: PathBuf, smp_path: PathBuf,
output_path: PathBuf, output_path: PathBuf,
@ -853,6 +857,9 @@ fn real_main() -> Result<(), Box<dyn std::error::Error>> {
Command::RuntimeInspectSaveCompanyChairman { smp_path } => { Command::RuntimeInspectSaveCompanyChairman { smp_path } => {
run_runtime_inspect_save_company_chairman(&smp_path)?; run_runtime_inspect_save_company_chairman(&smp_path)?;
} }
Command::RuntimeInspectUnclassifiedSaveCollections { smp_path } => {
run_runtime_inspect_unclassified_save_collections(&smp_path)?;
}
Command::RuntimeImportSaveState { Command::RuntimeImportSaveState {
smp_path, smp_path,
output_path, output_path,
@ -1056,6 +1063,13 @@ fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
smp_path: PathBuf::from(path), smp_path: PathBuf::from(path),
}) })
} }
[command, subcommand, path]
if command == "runtime" && subcommand == "inspect-unclassified-save-collections" =>
{
Ok(Command::RuntimeInspectUnclassifiedSaveCollections {
smp_path: PathBuf::from(path),
})
}
[command, subcommand, smp_path, output_path] [command, subcommand, smp_path, output_path]
if command == "runtime" && subcommand == "import-save-state" => if command == "runtime" && subcommand == "import-save-state" =>
{ {
@ -1259,7 +1273,7 @@ fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
}) })
} }
_ => Err( _ => 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 summarize-save-load <file.smp> | runtime load-save-slice <file.smp> | runtime inspect-save-company-chairman <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 summarize-save-load <file.smp> | runtime load-save-slice <file.smp> | runtime inspect-save-company-chairman <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(), .into(),
), ),
} }
@ -1500,6 +1514,18 @@ fn run_runtime_inspect_save_company_chairman(
Ok(()) Ok(())
} }
fn run_runtime_inspect_unclassified_save_collections(
smp_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
println!(
"{}",
serde_json::to_string_pretty(&inspect_unclassified_save_collection_headers_file(
smp_path
)?)?
);
Ok(())
}
fn run_runtime_import_save_state( fn run_runtime_import_save_state(
smp_path: &Path, smp_path: &Path,
output_path: &Path, output_path: &Path,

View file

@ -118,6 +118,7 @@ pub use smp::{
SmpRuntimePostSpanHeaderCandidate, SmpRuntimePostSpanProbe, SmpRuntimeTrailerBlock, SmpRuntimePostSpanHeaderCandidate, SmpRuntimePostSpanProbe, SmpRuntimeTrailerBlock,
SmpSaveAnchorRunBlock, SmpSaveBootstrapBlock, SmpSaveChairmanRecordAnalysisEntry, SmpSaveAnchorRunBlock, SmpSaveBootstrapBlock, SmpSaveChairmanRecordAnalysisEntry,
SmpSaveCompanyChairmanAnalysisReport, SmpSaveCompanyRecordAnalysisEntry, SmpSaveDwordCandidate, SmpSaveCompanyChairmanAnalysisReport, SmpSaveCompanyRecordAnalysisEntry, SmpSaveDwordCandidate,
SmpSavePlacedStructureDynamicSideBufferProbe,
SmpSaveLoadCandidateTableSummary, SmpSaveLoadSummary, SmpSaveScalarCandidate, SmpSaveLoadCandidateTableSummary, SmpSaveLoadSummary, SmpSaveScalarCandidate,
SmpSaveTaggedCollectionHeaderProbe, SmpSaveWorldEconomicTuningProbe, SmpSaveTaggedCollectionHeaderProbe, SmpSaveWorldEconomicTuningProbe,
SmpSaveWorldFinanceNeighborhoodProbe, SmpSaveWorldIssue37Probe, SmpSaveWorldFinanceNeighborhoodProbe, SmpSaveWorldIssue37Probe,
@ -125,7 +126,8 @@ pub use smp::{
SmpSecondaryVariantProbe, SmpSharedHeader, SmpSpecialConditionEntry, SmpSpecialConditionsProbe, SmpSecondaryVariantProbe, SmpSharedHeader, SmpSpecialConditionEntry, SmpSpecialConditionsProbe,
inspect_save_company_and_chairman_analysis_bytes, inspect_save_company_and_chairman_analysis_bytes,
inspect_save_company_and_chairman_analysis_file, inspect_smp_bytes, inspect_smp_file, inspect_save_company_and_chairman_analysis_file, inspect_smp_bytes, inspect_smp_file,
load_save_slice_file, load_save_slice_from_report, inspect_unclassified_save_collection_headers_file, load_save_slice_file,
load_save_slice_from_report,
}; };
pub use step::{BoundaryEvent, ServiceEvent, StepCommand, StepResult, execute_step_command}; pub use step::{BoundaryEvent, ServiceEvent, StepCommand, StepResult, execute_step_command};
pub use summary::RuntimeSummary; pub use summary::RuntimeSummary;

View file

@ -1773,6 +1773,36 @@ pub struct SmpSavePlacedStructureRecordTripletProbe {
pub evidence: Vec<String>, pub evidence: Vec<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpSavePlacedStructureDynamicSideBufferProbe {
pub profile_family: String,
pub source_kind: String,
pub semantic_family: String,
pub metadata_tag_offset: usize,
pub records_tag_offset: usize,
pub close_tag_offset: usize,
pub records_span_len: usize,
pub direct_record_stride: u32,
pub direct_record_stride_hex: String,
pub live_id_bound: u32,
pub live_id_bound_hex: String,
pub live_record_count: u32,
pub live_record_count_hex: String,
pub prefix_leading_dword: u32,
pub prefix_leading_dword_hex: String,
pub prefix_trailing_word: u16,
pub prefix_trailing_word_hex: String,
pub prefix_separator_byte: u8,
pub prefix_separator_byte_hex: String,
pub first_embedded_name_tag_relative_offset: usize,
pub embedded_name_tag_count: usize,
#[serde(default)]
pub first_embedded_primary_name: Option<String>,
#[serde(default)]
pub first_embedded_secondary_name: Option<String>,
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpRt3105SaveNameTableProbe { pub struct SmpRt3105SaveNameTableProbe {
pub profile_family: String, pub profile_family: String,
@ -2719,6 +2749,9 @@ pub struct SmpSaveCompanyChairmanAnalysisReport {
#[serde(default)] #[serde(default)]
pub placed_structure_record_triplets: Option<SmpSavePlacedStructureRecordTripletProbe>, pub placed_structure_record_triplets: Option<SmpSavePlacedStructureRecordTripletProbe>,
#[serde(default)] #[serde(default)]
pub placed_structure_dynamic_side_buffer:
Option<SmpSavePlacedStructureDynamicSideBufferProbe>,
#[serde(default)]
pub unclassified_tagged_collection_headers: Vec<SmpSaveUnclassifiedTaggedCollectionHeaderProbe>, pub unclassified_tagged_collection_headers: Vec<SmpSaveUnclassifiedTaggedCollectionHeaderProbe>,
#[serde(default)] #[serde(default)]
pub company_entries: Vec<SmpSaveCompanyRecordAnalysisEntry>, pub company_entries: Vec<SmpSaveCompanyRecordAnalysisEntry>,
@ -2987,6 +3020,9 @@ pub struct SmpInspectionReport {
pub save_placed_structure_record_triplet_probe: pub save_placed_structure_record_triplet_probe:
Option<SmpSavePlacedStructureRecordTripletProbe>, Option<SmpSavePlacedStructureRecordTripletProbe>,
#[serde(default)] #[serde(default)]
pub save_placed_structure_dynamic_side_buffer_probe:
Option<SmpSavePlacedStructureDynamicSideBufferProbe>,
#[serde(default)]
pub save_unclassified_tagged_collection_header_probes: pub save_unclassified_tagged_collection_header_probes:
Vec<SmpSaveUnclassifiedTaggedCollectionHeaderProbe>, Vec<SmpSaveUnclassifiedTaggedCollectionHeaderProbe>,
#[serde(default)] #[serde(default)]
@ -3023,6 +3059,73 @@ pub fn inspect_smp_file(path: &Path) -> Result<SmpInspectionReport, Box<dyn std:
)) ))
} }
pub fn inspect_unclassified_save_collection_headers_file(
path: &Path,
) -> Result<Vec<SmpSaveUnclassifiedTaggedCollectionHeaderProbe>, Box<dyn std::error::Error>> {
let bytes = fs::read(path)?;
let file_extension_hint = path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase());
let shared_header = parse_shared_header(&bytes);
let header_variant_probe = shared_header.as_ref().map(classify_header_variant_probe);
let first_ascii_run = find_first_ascii_run(&bytes);
let early_content_probe = first_ascii_run
.as_ref()
.and_then(|ascii_run| probe_early_content_layout(&bytes, ascii_run));
let secondary_variant_probe = early_content_probe
.as_ref()
.and_then(classify_secondary_variant_probe);
let container_profile = classify_container_profile(
file_extension_hint.as_deref(),
header_variant_probe.as_ref(),
secondary_variant_probe.as_ref(),
);
let save_company_collection_header_probe = parse_save_company_collection_header_probe(
&bytes,
file_extension_hint.as_deref(),
container_profile.as_ref(),
);
let save_chairman_profile_collection_header_probe =
parse_save_chairman_profile_collection_header_probe(
&bytes,
file_extension_hint.as_deref(),
container_profile.as_ref(),
);
let save_train_collection_header_probe = parse_save_train_collection_header_probe(
&bytes,
file_extension_hint.as_deref(),
container_profile.as_ref(),
);
let save_region_collection_header_probe = parse_save_region_collection_header_probe(
&bytes,
file_extension_hint.as_deref(),
container_profile.as_ref(),
);
let save_placed_structure_collection_header_probe =
parse_save_placed_structure_collection_header_probe(
&bytes,
file_extension_hint.as_deref(),
container_profile.as_ref(),
);
let known_header_probes = [
save_company_collection_header_probe.as_ref(),
save_chairman_profile_collection_header_probe.as_ref(),
save_train_collection_header_probe.as_ref(),
save_region_collection_header_probe.as_ref(),
save_placed_structure_collection_header_probe.as_ref(),
];
let probes = scan_save_unclassified_tagged_collection_header_probes(
&bytes,
file_extension_hint.as_deref(),
container_profile.as_ref(),
);
Ok(filter_unclassified_tagged_collection_header_probes_outside_known_spans(
probes,
&known_header_probes,
))
}
pub fn inspect_smp_bytes(bytes: &[u8]) -> SmpInspectionReport { pub fn inspect_smp_bytes(bytes: &[u8]) -> SmpInspectionReport {
inspect_bundle_bytes(bytes, None) inspect_bundle_bytes(bytes, None)
} }
@ -3338,6 +3441,18 @@ pub fn load_save_slice_from_report(
probe.entries.first().map(|entry| entry.profile_status_kind.as_str()) probe.entries.first().map(|entry| entry.profile_status_kind.as_str())
)); ));
} }
if let Some(probe) = &report.save_placed_structure_dynamic_side_buffer_probe {
notes.push(format!(
"Raw save also exposes the separate placed-structure dynamic-side-buffer candidate 0x38a5/0x38a6/0x38a7: live_record_count={}, first compact prefix=({},{},{}), first embedded names={:?}/{:?}, embedded 0x55f1 row count={}.",
probe.live_record_count,
probe.prefix_leading_dword_hex,
probe.prefix_trailing_word_hex,
probe.prefix_separator_byte_hex,
probe.first_embedded_primary_name.as_deref(),
probe.first_embedded_secondary_name.as_deref(),
probe.embedded_name_tag_count
));
}
if let Some(roster) = &report.save_company_roster_probe { if let Some(roster) = &report.save_company_roster_probe {
notes.push(format!( notes.push(format!(
"Raw save inspection reconstructed {} company direct records from the tagged company collection.", "Raw save inspection reconstructed {} company direct records from the tagged company collection.",
@ -3402,8 +3517,11 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
let region_record_triplets = report.save_region_record_triplet_probe.clone(); let region_record_triplets = report.save_region_record_triplet_probe.clone();
let placed_structure_record_triplets = let placed_structure_record_triplets =
report.save_placed_structure_record_triplet_probe.clone(); report.save_placed_structure_record_triplet_probe.clone();
let unclassified_tagged_collection_headers = let placed_structure_dynamic_side_buffer =
report.save_unclassified_tagged_collection_header_probes.clone(); report.save_placed_structure_dynamic_side_buffer_probe.clone();
let unclassified_tagged_collection_headers = report
.save_unclassified_tagged_collection_header_probes
.clone();
let company_header_probe = report.save_company_collection_header_probe.as_ref(); let company_header_probe = report.save_company_collection_header_probe.as_ref();
let chairman_header_probe = report let chairman_header_probe = report
.save_chairman_profile_collection_header_probe .save_chairman_profile_collection_header_probe
@ -3822,6 +3940,7 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
.save_placed_structure_collection_header_probe .save_placed_structure_collection_header_probe
.clone(), .clone(),
placed_structure_record_triplets, placed_structure_record_triplets,
placed_structure_dynamic_side_buffer,
unclassified_tagged_collection_headers, unclassified_tagged_collection_headers,
company_entries, company_entries,
chairman_entries, chairman_entries,
@ -7848,11 +7967,27 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
bytes, bytes,
save_placed_structure_collection_header_probe.as_ref(), save_placed_structure_collection_header_probe.as_ref(),
); );
let save_placed_structure_dynamic_side_buffer_probe =
parse_save_placed_structure_dynamic_side_buffer_probe(
bytes,
file_extension_hint.as_deref(),
container_profile.as_ref(),
);
let known_header_probes = [
save_company_collection_header_probe.as_ref(),
save_chairman_profile_collection_header_probe.as_ref(),
save_train_collection_header_probe.as_ref(),
save_region_collection_header_probe.as_ref(),
save_placed_structure_collection_header_probe.as_ref(),
];
let save_unclassified_tagged_collection_header_probes = let save_unclassified_tagged_collection_header_probes =
filter_unclassified_tagged_collection_header_probes_outside_known_spans(
scan_save_unclassified_tagged_collection_header_probes( scan_save_unclassified_tagged_collection_header_probes(
bytes, bytes,
file_extension_hint.as_deref(), file_extension_hint.as_deref(),
container_profile.as_ref(), container_profile.as_ref(),
),
&known_header_probes,
); );
let save_company_roster_probe = parse_save_company_roster_probe( let save_company_roster_probe = parse_save_company_roster_probe(
bytes, bytes,
@ -8023,6 +8158,7 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
save_region_record_triplet_probe, save_region_record_triplet_probe,
save_placed_structure_collection_header_probe, save_placed_structure_collection_header_probe,
save_placed_structure_record_triplet_probe, save_placed_structure_record_triplet_probe,
save_placed_structure_dynamic_side_buffer_probe,
save_unclassified_tagged_collection_header_probes, save_unclassified_tagged_collection_header_probes,
save_company_roster_probe, save_company_roster_probe,
save_chairman_profile_table_probe, save_chairman_profile_table_probe,
@ -10411,6 +10547,159 @@ fn parse_save_placed_structure_collection_header_probe(
) )
} }
fn parse_save_placed_structure_dynamic_side_buffer_probe(
bytes: &[u8],
file_extension_hint: Option<&str>,
container_profile: Option<&SmpContainerProfile>,
) -> Option<SmpSavePlacedStructureDynamicSideBufferProbe> {
if file_extension_hint != Some("gms") {
return None;
}
let profile = container_profile?;
if !matches!(
profile.profile_family.as_str(),
"rt3-classic-save-container-v1"
| "rt3-105-save-container-v1"
| "rt3-105-scenario-save-container-v1"
| "rt3-105-alt-save-container-v1"
) {
return None;
}
let metadata_offsets = find_u32_le_offsets(bytes, 0x000038a5);
let records_offsets = find_u32_le_offsets(bytes, 0x000038a6);
let close_offsets = find_u32_le_offsets(bytes, 0x000038a7);
for metadata_tag_offset in metadata_offsets {
let Some(records_tag_offset) = records_offsets
.iter()
.copied()
.find(|offset| *offset > metadata_tag_offset) else {
continue;
};
let Some(close_tag_offset) = close_offsets
.iter()
.copied()
.find(|offset| *offset > records_tag_offset) else {
continue;
};
let Some(payload) = bytes.get(metadata_tag_offset + 4..records_tag_offset) else {
continue;
};
if payload.len() < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN {
continue;
}
let Some(header_words) = (0..INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT)
.map(|index| read_u32_at(payload, index * 4))
.collect::<Option<Vec<_>>>() else {
continue;
};
let Some(header_words): Option<[u32; INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT]> =
header_words.try_into().ok()
else {
continue;
};
let summary = IndexedCollectionHeaderSummary {
metadata_tag_offset,
records_tag_offset,
close_tag_offset,
direct_collection_flag: header_words[0],
direct_record_stride: header_words[1],
live_id_bound: header_words[4],
live_record_count: header_words[5],
header_words,
};
if !(summary.direct_collection_flag == 0
&& summary.direct_record_stride == 0x06
&& summary.header_words.get(2) == Some(&1000)
&& summary.header_words.get(3) == Some(&500)
&& summary.header_words.get(6) == Some(&0)
&& summary.header_words.get(7) == Some(&1)
&& summary.live_id_bound >= 0x100
&& summary.live_id_bound <= 0x1000
&& summary.live_record_count >= 0x100
&& summary.live_record_count <= summary.live_id_bound)
{
continue;
}
let Some(records_payload) = bytes.get(records_tag_offset + 4..close_tag_offset) else {
continue;
};
let embedded_name_tag_offsets =
find_u16_le_offsets(records_payload, SAVE_REGION_RECORD_NAME_TAG);
let Some(&first_embedded_name_tag_relative_offset) = embedded_name_tag_offsets.first() else {
continue;
};
let Some(prefix_payload) = records_payload.get(..first_embedded_name_tag_relative_offset) else {
continue;
};
if prefix_payload.len() < 7 {
continue;
}
let Some(prefix_leading_dword) = read_u32_at(prefix_payload, 0) else {
continue;
};
let Some(prefix_trailing_word) = read_u16_at(prefix_payload, 4) else {
continue;
};
let Some(prefix_separator_byte) = prefix_payload.get(6).copied() else {
continue;
};
let mut parsed_embedded_names = None;
for relative_name_offset in [4usize, 6usize] {
let Some(name_payload) = records_payload
.get(first_embedded_name_tag_relative_offset + relative_name_offset..) else {
continue;
};
if let Some(names) = parse_save_len_prefixed_ascii_name_pair(name_payload) {
parsed_embedded_names = Some(names);
break;
}
}
let Some((first_embedded_primary_name, first_embedded_secondary_name)) =
parsed_embedded_names
else {
continue;
};
return Some(SmpSavePlacedStructureDynamicSideBufferProbe {
profile_family: profile.profile_family.clone(),
source_kind: "save-placed-structure-dynamic-side-buffer-records".to_string(),
semantic_family: "scenario-save-placed-structure-dynamic-side-buffer-records".to_string(),
metadata_tag_offset,
records_tag_offset,
close_tag_offset,
records_span_len: close_tag_offset.saturating_sub(records_tag_offset + 4),
direct_record_stride: summary.direct_record_stride,
direct_record_stride_hex: format!("0x{:08x}", summary.direct_record_stride),
live_id_bound: summary.live_id_bound,
live_id_bound_hex: format!("0x{:08x}", summary.live_id_bound),
live_record_count: summary.live_record_count,
live_record_count_hex: format!("0x{:08x}", summary.live_record_count),
prefix_leading_dword,
prefix_leading_dword_hex: format!("0x{prefix_leading_dword:08x}"),
prefix_trailing_word,
prefix_trailing_word_hex: format!("0x{prefix_trailing_word:04x}"),
prefix_separator_byte,
prefix_separator_byte_hex: format!("0x{prefix_separator_byte:02x}"),
first_embedded_name_tag_relative_offset,
embedded_name_tag_count: embedded_name_tag_offsets.len(),
first_embedded_primary_name: Some(first_embedded_primary_name.clone()),
first_embedded_secondary_name: Some(first_embedded_secondary_name.clone()),
evidence: vec![
"exact little-endian u32 tag family 0x38a5/0x38a6/0x38a7 appears as a separate save-side tagged collection on grounded saves".to_string(),
"records payload begins with a compact 6-byte prefix plus one separator byte before the first embedded 0x55f1 name row".to_string(),
"first embedded 0x55f1 row decodes with placed-structure-style dual names, which makes this the strongest current candidate for the separate placed-structure dynamic side-buffer owner seam".to_string(),
format!(
"grounded first embedded names are {:?}/{:?} with {} embedded 0x55f1 name rows in the tagged records span",
Some(first_embedded_primary_name),
Some(first_embedded_secondary_name),
embedded_name_tag_offsets.len()
),
],
});
}
None
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct IndexedCollectionHeaderSummary { struct IndexedCollectionHeaderSummary {
metadata_tag_offset: usize, metadata_tag_offset: usize,
@ -10552,15 +10841,29 @@ fn scan_save_unclassified_tagged_collection_header_probes(
0x000036b1, 0x000036b1,
EVENT_RUNTIME_COLLECTION_METADATA_TAG as u32, EVENT_RUNTIME_COLLECTION_METADATA_TAG as u32,
]); ]);
let mut probes = Vec::new(); let mut low_tag_offsets: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
for metadata_tag_offset in 0..bytes.len().saturating_sub(INDEXED_COLLECTION_SERIALIZED_HEADER_LEN + 4) for offset in 0..bytes.len().saturating_sub(4) {
{ let Some(tag) = read_u32_at(bytes, offset) else {
let Some(metadata_tag) = read_u32_at(bytes, metadata_tag_offset) else {
continue; continue;
}; };
if metadata_tag > 0xffff || known_metadata_tags.contains(&metadata_tag) { if (3..=0xffff).contains(&tag) {
low_tag_offsets.entry(tag).or_default().push(offset);
}
}
let mut probes = Vec::new();
for (&metadata_tag, metadata_offsets) in &low_tag_offsets {
if known_metadata_tags.contains(&metadata_tag) {
continue; continue;
} }
let Some(records_offsets) = low_tag_offsets.get(&(metadata_tag + 1)) else {
continue;
};
let Some(close_offsets) = low_tag_offsets.get(&(metadata_tag + 2)) else {
continue;
};
let records_tag = metadata_tag + 1;
let close_tag = metadata_tag + 2;
for &metadata_tag_offset in metadata_offsets {
let mut header_words = [0u32; INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT]; let mut header_words = [0u32; INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT];
let mut valid_header = true; let mut valid_header = true;
for (index, word) in header_words.iter_mut().enumerate() { for (index, word) in header_words.iter_mut().enumerate() {
@ -10585,43 +10888,38 @@ fn scan_save_unclassified_tagged_collection_header_probes(
}; };
if !matches!(summary.direct_collection_flag, 0 | 1) if !matches!(summary.direct_collection_flag, 0 | 1)
|| summary.direct_record_stride == 0 || summary.direct_record_stride == 0
|| summary.direct_record_stride > 0x4000 || summary.direct_record_stride > 0x2000
|| summary.live_id_bound == 0 || summary.live_id_bound == 0
|| summary.live_record_count == 0 || summary.live_record_count == 0
|| summary.live_record_count > summary.live_id_bound || summary.live_record_count > summary.live_id_bound
|| summary.live_id_bound > 0x100000 || summary.live_id_bound > 0x1000
|| summary.live_record_count > 0x1000
{ {
continue; continue;
} }
let records_tag = metadata_tag + 1;
let close_tag = metadata_tag + 2;
let records_search_start = metadata_tag_offset + 4; let records_search_start = metadata_tag_offset + 4;
let Some(records_relative_offset) = let records_index =
find_u32_le_offsets(&bytes[records_search_start..], records_tag) records_offsets.partition_point(|offset| *offset < records_search_start);
.into_iter() let Some(&records_tag_offset) = records_offsets.get(records_index) else {
.next()
else {
continue; continue;
}; };
let records_tag_offset = records_search_start + records_relative_offset;
let close_search_start = records_tag_offset + 4; let close_search_start = records_tag_offset + 4;
let Some(close_relative_offset) = let close_index = close_offsets.partition_point(|offset| *offset < close_search_start);
find_u32_le_offsets(&bytes[close_search_start..], close_tag) let Some(&close_tag_offset) = close_offsets.get(close_index) else {
.into_iter()
.next()
else {
continue; continue;
}; };
let close_tag_offset = close_search_start + close_relative_offset;
let records_span_len = close_tag_offset.saturating_sub(records_tag_offset + 4); let records_span_len = close_tag_offset.saturating_sub(records_tag_offset + 4);
if records_span_len == 0 { if records_span_len == 0 || records_span_len < summary.live_record_count as usize {
continue; continue;
} }
if probes.iter().any(|probe: &SmpSaveUnclassifiedTaggedCollectionHeaderProbe| { if probes
.iter()
.any(|probe: &SmpSaveUnclassifiedTaggedCollectionHeaderProbe| {
probe.metadata_tag_offset == metadata_tag_offset probe.metadata_tag_offset == metadata_tag_offset
&& probe.records_tag_offset == records_tag_offset && probe.records_tag_offset == records_tag_offset
&& probe.close_tag_offset == close_tag_offset && probe.close_tag_offset == close_tag_offset
}) { })
{
continue; continue;
} }
probes.push(SmpSaveUnclassifiedTaggedCollectionHeaderProbe { probes.push(SmpSaveUnclassifiedTaggedCollectionHeaderProbe {
@ -10658,6 +10956,7 @@ fn scan_save_unclassified_tagged_collection_header_probes(
], ],
}); });
} }
}
probes.sort_by(|left, right| { probes.sort_by(|left, right| {
right right
.live_record_count .live_record_count
@ -10669,6 +10968,24 @@ fn scan_save_unclassified_tagged_collection_header_probes(
probes probes
} }
fn filter_unclassified_tagged_collection_header_probes_outside_known_spans(
probes: Vec<SmpSaveUnclassifiedTaggedCollectionHeaderProbe>,
known_header_probes: &[Option<&SmpSaveTaggedCollectionHeaderProbe>],
) -> Vec<SmpSaveUnclassifiedTaggedCollectionHeaderProbe> {
probes
.into_iter()
.filter(|probe| {
!known_header_probes
.iter()
.flatten()
.any(|known| {
probe.metadata_tag_offset >= known.metadata_tag_offset
&& probe.close_tag_offset <= known.close_tag_offset
})
})
.collect()
}
fn parse_save_len_prefixed_ascii_name(bytes: &[u8]) -> Option<String> { fn parse_save_len_prefixed_ascii_name(bytes: &[u8]) -> Option<String> {
let len = *bytes.first()? as usize; let len = *bytes.first()? as usize;
let text_bytes = bytes.get(1..1 + len)?; let text_bytes = bytes.get(1..1 + len)?;
@ -18259,6 +18576,71 @@ mod tests {
); );
} }
#[test]
fn parses_placed_structure_dynamic_side_buffer_probe_from_embedded_name_row() {
let mut bytes = vec![0u8; 0x400];
let metadata_tag_offset = 0x40usize;
let records_tag_offset = 0x140usize;
let close_tag_offset = 0x220usize;
bytes[metadata_tag_offset..metadata_tag_offset + 4]
.copy_from_slice(&0x000038a5u32.to_le_bytes());
bytes[records_tag_offset..records_tag_offset + 4]
.copy_from_slice(&0x000038a6u32.to_le_bytes());
bytes[close_tag_offset..close_tag_offset + 4]
.copy_from_slice(&0x000038a7u32.to_le_bytes());
let header_words = [
0u32, 0x06, 1000, 500, 1000, 388, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
for (index, word) in header_words.into_iter().enumerate() {
let offset = metadata_tag_offset + 4 + index * 4;
bytes[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
}
let payload_offset = records_tag_offset + 4;
bytes[payload_offset..payload_offset + 4].copy_from_slice(&0x0005d368u32.to_le_bytes());
bytes[payload_offset + 4..payload_offset + 6].copy_from_slice(&0x0001u16.to_le_bytes());
bytes[payload_offset + 6] = 0xff;
let name_tag_offset = payload_offset + 7;
bytes[name_tag_offset..name_tag_offset + 2]
.copy_from_slice(&SAVE_REGION_RECORD_NAME_TAG.to_le_bytes());
let first_name = "TrackCapST_Cap.3dp";
let second_name = "Infrastructure";
bytes[name_tag_offset + 4] = first_name.len() as u8;
bytes[name_tag_offset + 5..name_tag_offset + 5 + first_name.len()]
.copy_from_slice(first_name.as_bytes());
let second_len_offset = name_tag_offset + 5 + first_name.len();
bytes[second_len_offset] = second_name.len() as u8;
bytes[second_len_offset + 1..second_len_offset + 1 + second_name.len()]
.copy_from_slice(second_name.as_bytes());
let probe = parse_save_placed_structure_dynamic_side_buffer_probe(
&bytes,
Some("gms"),
Some(&SmpContainerProfile {
profile_family: "rt3-105-save-container-v1".to_string(),
profile_evidence: vec![],
is_known_profile: true,
}),
)
.expect("placed-structure dynamic side-buffer probe should parse");
assert_eq!(probe.direct_record_stride, 0x06);
assert_eq!(probe.live_id_bound, 1000);
assert_eq!(probe.live_record_count, 388);
assert_eq!(probe.prefix_leading_dword_hex, "0x0005d368");
assert_eq!(probe.prefix_trailing_word_hex, "0x0001");
assert_eq!(probe.prefix_separator_byte_hex, "0xff");
assert_eq!(probe.first_embedded_name_tag_relative_offset, 7);
assert_eq!(probe.embedded_name_tag_count, 1);
assert_eq!(
probe.first_embedded_primary_name.as_deref(),
Some("TrackCapST_Cap.3dp")
);
assert_eq!(
probe.first_embedded_secondary_name.as_deref(),
Some("Infrastructure")
);
}
#[test] #[test]
fn parses_placed_structure_tagged_collection_header_probe_from_exact_u32_tags() { fn parses_placed_structure_tagged_collection_header_probe_from_exact_u32_tags() {
let mut bytes = vec![0u8; 0x400]; let mut bytes = vec![0u8; 0x400];
@ -18308,10 +18690,9 @@ mod tests {
.copy_from_slice(&0x00007001u32.to_le_bytes()); .copy_from_slice(&0x00007001u32.to_le_bytes());
bytes[records_tag_offset..records_tag_offset + 4] bytes[records_tag_offset..records_tag_offset + 4]
.copy_from_slice(&0x00007002u32.to_le_bytes()); .copy_from_slice(&0x00007002u32.to_le_bytes());
bytes[close_tag_offset..close_tag_offset + 4] bytes[close_tag_offset..close_tag_offset + 4].copy_from_slice(&0x00007003u32.to_le_bytes());
.copy_from_slice(&0x00007003u32.to_le_bytes());
let header_words = [ let header_words = [
0u32, 0x12, 0x0a, 0x14, 0x900, 0x808, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0u32, 0x12, 0x0a, 0x14, 0x90, 0x78, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
]; ];
for (index, word) in header_words.into_iter().enumerate() { for (index, word) in header_words.into_iter().enumerate() {
let offset = metadata_tag_offset + 4 + index * 4; let offset = metadata_tag_offset + 4 + index * 4;
@ -18335,9 +18716,12 @@ mod tests {
assert_eq!(probe.records_tag, 0x7002); assert_eq!(probe.records_tag, 0x7002);
assert_eq!(probe.close_tag, 0x7003); assert_eq!(probe.close_tag, 0x7003);
assert_eq!(probe.direct_record_stride, 0x12); assert_eq!(probe.direct_record_stride, 0x12);
assert_eq!(probe.live_id_bound, 0x900); assert_eq!(probe.live_id_bound, 0x90);
assert_eq!(probe.live_record_count, 0x808); assert_eq!(probe.live_record_count, 0x78);
assert_eq!(probe.records_span_len, close_tag_offset - (records_tag_offset + 4)); assert_eq!(
probe.records_span_len,
close_tag_offset - (records_tag_offset + 4)
);
} }
#[test] #[test]

View file

@ -21,8 +21,10 @@ Working rule:
`0x36b1/0x36b2/0x36b3` header seam so the blocked city-connection / linked-transit branch can `0x36b1/0x36b2/0x36b3` header seam so the blocked city-connection / linked-transit branch can
stop depending on atlas-only placed-structure and local-runtime refresh notes, especially the stop depending on atlas-only placed-structure and local-runtime refresh notes, especially the
semantics of the now-grounded compact `0x55f3` footer dword/status lane and the newly exposed semantics of the now-grounded compact `0x55f3` footer dword/status lane and the newly exposed
unclassified tagged-collection candidates that may correspond to the separate placed-structure separate tagged side-buffer seam candidates, especially the exact `0x38a5/0x38a6/0x38a7`
dynamic side-buffer lane. family whose compact `6`-byte header pattern and embedded placed-structure-style `0x55f1`
name rows now make it the strongest current candidate for the separate placed-structure dynamic
side-buffer owner.
- Extend shellless clock advancement so more periodic-company service branches consume owned - Extend shellless clock advancement so more periodic-company service branches consume owned
runtime time state directly instead of only the explicit periodic service command. runtime time state directly instead of only the explicit periodic service command.
- Keep widening selected-year world-owner state only when a full owning reader/rebuild family is - Keep widening selected-year world-owner state only when a full owning reader/rebuild family is
@ -78,8 +80,16 @@ Working rule:
from “find the hidden tail inside this payload” to “find the separate owner seam that backs the from “find the hidden tail inside this payload” to “find the separate owner seam that backs the
runtime latches the city-connection branch still reads.” runtime latches the city-connection branch still reads.”
- Save inspection now also exports a generic low-tag unclassified collection scan over plausible - Save inspection now also exports a generic low-tag unclassified collection scan over plausible
indexed-collection headers, so the next city-connection pass can compare real save candidates indexed-collection headers, now through a lightweight CLI path that does not require full bundle
against the atlas-owned placed-structure dynamic side-buffer lane instead of blind tag hunting. inspection and now filters out candidates nested inside already-grounded company/chairman/train/
region/placed-structure spans.
- That lightweight scan now also narrows the real save frontier to a much smaller stable candidate
set across `p.gms`, `q.gms`, and `Autosave.gms`, with the exact `0x38a5/0x38a6/0x38a7` family
standing out as the strongest current placed-structure dynamic side-buffer candidate.
- The `0x38a5/0x38a6/0x38a7` family now also has a first dedicated parser scaffold in
`rrt-runtime`: its synthetic regression is grounded, its header shape is checked in, and the
parser now expects a compact 6-byte prefix plus separator byte before an embedded
placed-structure-style dual-name row rather than treating the family as anonymous residue.
- The placed-structure tagged save stream now also exposes repeated `0x55f1/0x55f2/0x55f3` - The placed-structure tagged save stream now also exposes repeated `0x55f1/0x55f2/0x55f3`
triplets with dual name stems, a fixed five-`f32` policy row, and a compact `0x5dc1...0x5dc2` triplets with dual name stems, a fixed five-`f32` policy row, and a compact `0x5dc1...0x5dc2`
footer carrying one raw `u32` payload lane plus one live `i32` status lane, so the remaining footer carrying one raw `u32` payload lane plus one live `i32` status lane, so the remaining