Probe raw save selection context
This commit is contained in:
parent
bd9e1421a1
commit
40c0e94ad5
9 changed files with 581 additions and 137 deletions
|
|
@ -97,9 +97,11 @@ struct SaveSliceProjection {
|
|||
event_runtime_records: Vec<RuntimeEventRecord>,
|
||||
companies: Vec<RuntimeCompany>,
|
||||
has_company_projection: bool,
|
||||
has_company_selection_override: bool,
|
||||
selected_company_id: Option<u32>,
|
||||
chairman_profiles: Vec<RuntimeChairmanProfile>,
|
||||
has_chairman_projection: bool,
|
||||
has_chairman_selection_override: bool,
|
||||
selected_chairman_profile_id: Option<u32>,
|
||||
candidate_availability: BTreeMap<String, u32>,
|
||||
named_locomotive_availability: BTreeMap<String, u32>,
|
||||
|
|
@ -269,11 +271,19 @@ pub fn project_save_slice_to_runtime_state_import(
|
|||
world_restore: projection.world_restore,
|
||||
metadata: projection.metadata,
|
||||
companies: projection.companies,
|
||||
selected_company_id: projection.selected_company_id,
|
||||
selected_company_id: if projection.has_company_projection {
|
||||
projection.selected_company_id
|
||||
} else {
|
||||
None
|
||||
},
|
||||
players: Vec::new(),
|
||||
selected_player_id: None,
|
||||
chairman_profiles: projection.chairman_profiles,
|
||||
selected_chairman_profile_id: projection.selected_chairman_profile_id,
|
||||
selected_chairman_profile_id: if projection.has_chairman_projection {
|
||||
projection.selected_chairman_profile_id
|
||||
} else {
|
||||
None
|
||||
},
|
||||
trains: Vec::new(),
|
||||
locomotive_catalog: projection.locomotive_catalog.unwrap_or_default(),
|
||||
cargo_catalog: projection.cargo_catalog.unwrap_or_default(),
|
||||
|
|
@ -349,7 +359,9 @@ pub fn project_save_slice_overlay_to_runtime_state_import(
|
|||
} else {
|
||||
base_state.companies.clone()
|
||||
},
|
||||
selected_company_id: if projection.has_company_projection {
|
||||
selected_company_id: if projection.has_company_projection
|
||||
|| projection.has_company_selection_override
|
||||
{
|
||||
projection.selected_company_id
|
||||
} else {
|
||||
base_state.selected_company_id
|
||||
|
|
@ -361,7 +373,9 @@ pub fn project_save_slice_overlay_to_runtime_state_import(
|
|||
} else {
|
||||
base_state.chairman_profiles.clone()
|
||||
},
|
||||
selected_chairman_profile_id: if projection.has_chairman_projection {
|
||||
selected_chairman_profile_id: if projection.has_chairman_projection
|
||||
|| projection.has_chairman_selection_override
|
||||
{
|
||||
projection.selected_chairman_profile_id
|
||||
} else {
|
||||
base_state.selected_chairman_profile_id
|
||||
|
|
@ -809,7 +823,7 @@ fn project_save_slice_components(
|
|||
None
|
||||
};
|
||||
|
||||
let (companies, has_company_projection, selected_company_id) =
|
||||
let (companies, has_company_projection, has_company_selection_override, selected_company_id) =
|
||||
if let Some(roster) = &save_slice.company_roster {
|
||||
metadata.insert(
|
||||
"save_slice.company_roster_source_kind".to_string(),
|
||||
|
|
@ -829,55 +843,77 @@ fn project_save_slice_components(
|
|||
selected_company_id.to_string(),
|
||||
);
|
||||
}
|
||||
(
|
||||
roster
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| RuntimeCompany {
|
||||
company_id: entry.company_id,
|
||||
current_cash: entry.current_cash,
|
||||
debt: entry.debt,
|
||||
credit_rating_score: entry.credit_rating_score,
|
||||
prime_rate: entry.prime_rate,
|
||||
active: entry.active,
|
||||
available_track_laying_capacity: entry.available_track_laying_capacity,
|
||||
controller_kind: entry.controller_kind,
|
||||
linked_chairman_profile_id: entry.linked_chairman_profile_id,
|
||||
book_value_per_share: entry.book_value_per_share,
|
||||
investor_confidence: entry.investor_confidence,
|
||||
management_attitude: entry.management_attitude,
|
||||
takeover_cooldown_year: entry.takeover_cooldown_year,
|
||||
merger_cooldown_year: entry.merger_cooldown_year,
|
||||
track_piece_counts: entry.track_piece_counts,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
true,
|
||||
roster.selected_company_id,
|
||||
)
|
||||
if roster.entries.is_empty() {
|
||||
(
|
||||
Vec::new(),
|
||||
false,
|
||||
roster.selected_company_id.is_some(),
|
||||
roster.selected_company_id,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
roster
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| RuntimeCompany {
|
||||
company_id: entry.company_id,
|
||||
current_cash: entry.current_cash,
|
||||
debt: entry.debt,
|
||||
credit_rating_score: entry.credit_rating_score,
|
||||
prime_rate: entry.prime_rate,
|
||||
active: entry.active,
|
||||
available_track_laying_capacity: entry.available_track_laying_capacity,
|
||||
controller_kind: entry.controller_kind,
|
||||
linked_chairman_profile_id: entry.linked_chairman_profile_id,
|
||||
book_value_per_share: entry.book_value_per_share,
|
||||
investor_confidence: entry.investor_confidence,
|
||||
management_attitude: entry.management_attitude,
|
||||
takeover_cooldown_year: entry.takeover_cooldown_year,
|
||||
merger_cooldown_year: entry.merger_cooldown_year,
|
||||
track_piece_counts: entry.track_piece_counts,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
true,
|
||||
roster.selected_company_id.is_some(),
|
||||
roster.selected_company_id,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(Vec::new(), false, None)
|
||||
(Vec::new(), false, false, None)
|
||||
};
|
||||
|
||||
let (chairman_profiles, has_chairman_projection, selected_chairman_profile_id) =
|
||||
if let Some(table) = &save_slice.chairman_profile_table {
|
||||
let (
|
||||
chairman_profiles,
|
||||
has_chairman_projection,
|
||||
has_chairman_selection_override,
|
||||
selected_chairman_profile_id,
|
||||
) = if let Some(table) = &save_slice.chairman_profile_table {
|
||||
metadata.insert(
|
||||
"save_slice.chairman_profile_table_source_kind".to_string(),
|
||||
table.source_kind.clone(),
|
||||
);
|
||||
metadata.insert(
|
||||
"save_slice.chairman_profile_table_semantic_family".to_string(),
|
||||
table.semantic_family.clone(),
|
||||
);
|
||||
metadata.insert(
|
||||
"save_slice.chairman_profile_table_entry_count".to_string(),
|
||||
table.observed_entry_count.to_string(),
|
||||
);
|
||||
if let Some(selected_chairman_profile_id) = table.selected_chairman_profile_id {
|
||||
metadata.insert(
|
||||
"save_slice.chairman_profile_table_source_kind".to_string(),
|
||||
table.source_kind.clone(),
|
||||
"save_slice.selected_chairman_profile_id".to_string(),
|
||||
selected_chairman_profile_id.to_string(),
|
||||
);
|
||||
metadata.insert(
|
||||
"save_slice.chairman_profile_table_semantic_family".to_string(),
|
||||
table.semantic_family.clone(),
|
||||
);
|
||||
metadata.insert(
|
||||
"save_slice.chairman_profile_table_entry_count".to_string(),
|
||||
table.observed_entry_count.to_string(),
|
||||
);
|
||||
if let Some(selected_chairman_profile_id) = table.selected_chairman_profile_id {
|
||||
metadata.insert(
|
||||
"save_slice.selected_chairman_profile_id".to_string(),
|
||||
selected_chairman_profile_id.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if table.entries.is_empty() {
|
||||
(
|
||||
Vec::new(),
|
||||
false,
|
||||
table.selected_chairman_profile_id.is_some(),
|
||||
table.selected_chairman_profile_id,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
table
|
||||
.entries
|
||||
|
|
@ -895,11 +931,13 @@ fn project_save_slice_components(
|
|||
})
|
||||
.collect::<Vec<_>>(),
|
||||
true,
|
||||
table.selected_chairman_profile_id.is_some(),
|
||||
table.selected_chairman_profile_id,
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), false, None)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
(Vec::new(), false, false, None)
|
||||
};
|
||||
|
||||
let named_locomotive_cost = BTreeMap::new();
|
||||
let all_cargo_price_override = None;
|
||||
|
|
@ -920,6 +958,8 @@ fn project_save_slice_components(
|
|||
&& companies
|
||||
.iter()
|
||||
.all(|company| company.controller_kind != RuntimeCompanyControllerKind::Unknown);
|
||||
} else if has_company_selection_override {
|
||||
packed_event_context.selected_company_id = selected_company_id;
|
||||
}
|
||||
if has_chairman_projection {
|
||||
packed_event_context.known_chairman_profile_ids = chairman_profiles
|
||||
|
|
@ -927,6 +967,8 @@ fn project_save_slice_components(
|
|||
.map(|profile| profile.profile_id)
|
||||
.collect();
|
||||
packed_event_context.selected_chairman_profile_id = selected_chairman_profile_id;
|
||||
} else if has_chairman_selection_override {
|
||||
packed_event_context.selected_chairman_profile_id = selected_chairman_profile_id;
|
||||
}
|
||||
if let Some(catalog) = &locomotive_catalog {
|
||||
packed_event_context.locomotive_catalog_names_by_id = catalog
|
||||
|
|
@ -976,9 +1018,11 @@ fn project_save_slice_components(
|
|||
event_runtime_records,
|
||||
companies,
|
||||
has_company_projection,
|
||||
has_company_selection_override,
|
||||
selected_company_id,
|
||||
chairman_profiles,
|
||||
has_chairman_projection,
|
||||
has_chairman_selection_override,
|
||||
selected_chairman_profile_id,
|
||||
candidate_availability,
|
||||
named_locomotive_availability,
|
||||
|
|
@ -4918,11 +4962,12 @@ mod tests {
|
|||
descriptor_id: u32,
|
||||
value: i32,
|
||||
) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
||||
let slot = descriptor_id.saturating_sub(105);
|
||||
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
||||
group_index: 0,
|
||||
row_index: 0,
|
||||
descriptor_id,
|
||||
descriptor_label: Some("Unknown Cargo Price".to_string()),
|
||||
descriptor_label: Some(format!("Named Cargo Price Slot {slot}")),
|
||||
target_mask_bits: Some(0x08),
|
||||
parameter_family: Some("cargo_price_scalar".to_string()),
|
||||
grouped_target_subject: None,
|
||||
|
|
@ -4937,7 +4982,7 @@ mod tests {
|
|||
value_word_0x16: 0,
|
||||
row_shape: "scalar_assignment".to_string(),
|
||||
semantic_family: Some("scalar_assignment".to_string()),
|
||||
semantic_preview: Some(format!("Set Unknown Cargo Price to {value}")),
|
||||
semantic_preview: Some(format!("Set Named Cargo Price Slot {slot} to {value}")),
|
||||
recovered_cargo_slot: None,
|
||||
recovered_cargo_class: None,
|
||||
recovered_cargo_label: None,
|
||||
|
|
@ -5874,6 +5919,118 @@ mod tests {
|
|||
assert_eq!(import.state.territories, base_state.territories);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_applies_selection_only_company_and_chairman_context_from_save_slice() {
|
||||
let base_state = RuntimeState {
|
||||
companies: vec![
|
||||
crate::RuntimeCompany {
|
||||
company_id: 1,
|
||||
current_cash: 100,
|
||||
debt: 0,
|
||||
credit_rating_score: None,
|
||||
prime_rate: None,
|
||||
active: true,
|
||||
available_track_laying_capacity: None,
|
||||
controller_kind: RuntimeCompanyControllerKind::Human,
|
||||
linked_chairman_profile_id: Some(1),
|
||||
book_value_per_share: 0,
|
||||
investor_confidence: 0,
|
||||
management_attitude: 0,
|
||||
takeover_cooldown_year: None,
|
||||
merger_cooldown_year: None,
|
||||
track_piece_counts: RuntimeTrackPieceCounts::default(),
|
||||
},
|
||||
crate::RuntimeCompany {
|
||||
company_id: 42,
|
||||
current_cash: 200,
|
||||
debt: 0,
|
||||
credit_rating_score: None,
|
||||
prime_rate: None,
|
||||
active: true,
|
||||
available_track_laying_capacity: None,
|
||||
controller_kind: RuntimeCompanyControllerKind::Human,
|
||||
linked_chairman_profile_id: Some(9),
|
||||
book_value_per_share: 0,
|
||||
investor_confidence: 0,
|
||||
management_attitude: 0,
|
||||
takeover_cooldown_year: None,
|
||||
merger_cooldown_year: None,
|
||||
track_piece_counts: RuntimeTrackPieceCounts::default(),
|
||||
},
|
||||
],
|
||||
selected_company_id: Some(42),
|
||||
chairman_profiles: vec![
|
||||
crate::RuntimeChairmanProfile {
|
||||
profile_id: 1,
|
||||
name: "Selected".to_string(),
|
||||
active: true,
|
||||
current_cash: 0,
|
||||
linked_company_id: Some(1),
|
||||
company_holdings: BTreeMap::new(),
|
||||
holdings_value_total: 0,
|
||||
net_worth_total: 0,
|
||||
purchasing_power_total: 0,
|
||||
},
|
||||
crate::RuntimeChairmanProfile {
|
||||
profile_id: 9,
|
||||
name: "Base".to_string(),
|
||||
active: true,
|
||||
current_cash: 0,
|
||||
linked_company_id: Some(42),
|
||||
company_holdings: BTreeMap::new(),
|
||||
holdings_value_total: 0,
|
||||
net_worth_total: 0,
|
||||
purchasing_power_total: 0,
|
||||
},
|
||||
],
|
||||
selected_chairman_profile_id: Some(9),
|
||||
..state()
|
||||
};
|
||||
let save_slice = SmpLoadedSaveSlice {
|
||||
file_extension_hint: Some("gms".to_string()),
|
||||
container_profile_family: Some("rt3-105-save-container-v1".to_string()),
|
||||
mechanism_family: "rt3-105-save-post-span-bridge-v1".to_string(),
|
||||
mechanism_confidence: "mixed".to_string(),
|
||||
trailer_family: Some("rt3-105-save-trailer-v1".to_string()),
|
||||
bridge_family: Some("rt3-105-save-post-span-bridge-v1".to_string()),
|
||||
profile: None,
|
||||
candidate_availability_table: None,
|
||||
named_locomotive_availability_table: None,
|
||||
locomotive_catalog: None,
|
||||
cargo_catalog: None,
|
||||
company_roster: Some(crate::SmpLoadedCompanyRoster {
|
||||
source_kind: "save-direct-world-block-company-selection-only".to_string(),
|
||||
semantic_family: "scenario-selected-company-context".to_string(),
|
||||
observed_entry_count: 0,
|
||||
selected_company_id: Some(1),
|
||||
entries: Vec::new(),
|
||||
}),
|
||||
chairman_profile_table: Some(crate::SmpLoadedChairmanProfileTable {
|
||||
source_kind: "save-direct-world-block-chairman-selection-only".to_string(),
|
||||
semantic_family: "scenario-selected-chairman-context".to_string(),
|
||||
observed_entry_count: 0,
|
||||
selected_chairman_profile_id: Some(1),
|
||||
entries: Vec::new(),
|
||||
}),
|
||||
special_conditions_table: None,
|
||||
event_runtime_collection: None,
|
||||
notes: vec![],
|
||||
};
|
||||
|
||||
let import = project_save_slice_overlay_to_runtime_state_import(
|
||||
&base_state,
|
||||
&save_slice,
|
||||
"overlay-save-selection-only-context",
|
||||
None,
|
||||
)
|
||||
.expect("overlay import should project");
|
||||
|
||||
assert_eq!(import.state.companies, base_state.companies);
|
||||
assert_eq!(import.state.selected_company_id, Some(1));
|
||||
assert_eq!(import.state.chairman_profiles, base_state.chairman_profiles);
|
||||
assert_eq!(import.state.selected_chairman_profile_id, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_executable_packed_records_into_runtime_and_services_follow_on() {
|
||||
let save_slice = SmpLoadedSaveSlice {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@ const RECIPE_BOOK_LINE_STRIDE: usize = 0x30;
|
|||
const RECIPE_BOOK_LINE_AREA_LEN: usize = RECIPE_BOOK_LINE_COUNT * RECIPE_BOOK_LINE_STRIDE;
|
||||
const RECIPE_BOOK_SUMMARY_END_OFFSET: usize =
|
||||
RECIPE_BOOK_ROOT_OFFSET + RECIPE_BOOK_COUNT * RECIPE_BOOK_STRIDE;
|
||||
const RT3_SAVE_WORLD_BLOCK_CHUNK_TAG: u32 = 0x000032c8;
|
||||
const RT3_SAVE_WORLD_BLOCK_NEXT_CHUNK_TAG: u32 = 0x000032c9;
|
||||
const RT3_SAVE_WORLD_BLOCK_LEN: usize = 0x4f2c;
|
||||
const RT3_SAVE_WORLD_BLOCK_SELECTED_COMPANY_ID_RELATIVE_OFFSET: usize = 0x1d;
|
||||
const RT3_SAVE_WORLD_BLOCK_SELECTED_CHAIRMAN_PROFILE_ID_RELATIVE_OFFSET: usize = 0x21;
|
||||
const EVENT_RUNTIME_COLLECTION_METADATA_TAG: u16 = 0x4e99;
|
||||
const EVENT_RUNTIME_COLLECTION_RECORDS_TAG: u16 = 0x4e9a;
|
||||
const EVENT_RUNTIME_COLLECTION_CLOSE_TAG: u16 = 0x4e9b;
|
||||
|
|
@ -1444,6 +1449,24 @@ pub struct SmpRt3105SaveBridgePayloadProbe {
|
|||
pub evidence: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SmpSaveWorldSelectionContextProbe {
|
||||
pub profile_family: String,
|
||||
pub source_kind: String,
|
||||
pub semantic_family: String,
|
||||
pub chunk_tag_offset: usize,
|
||||
pub payload_offset: usize,
|
||||
pub payload_len: usize,
|
||||
pub payload_len_hex: String,
|
||||
pub selected_company_id_offset: usize,
|
||||
pub selected_company_id: u32,
|
||||
pub selected_company_id_hex: String,
|
||||
pub selected_chairman_profile_id_offset: usize,
|
||||
pub selected_chairman_profile_id: u32,
|
||||
pub selected_chairman_profile_id_hex: String,
|
||||
pub evidence: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SmpRt3105SaveNameTableProbe {
|
||||
pub profile_family: String,
|
||||
|
|
@ -2362,6 +2385,7 @@ pub struct SmpInspectionReport {
|
|||
pub runtime_post_span_probe: Option<SmpRuntimePostSpanProbe>,
|
||||
pub rt3_105_post_span_bridge_probe: Option<SmpRt3105PostSpanBridgeProbe>,
|
||||
pub rt3_105_save_bridge_payload_probe: Option<SmpRt3105SaveBridgePayloadProbe>,
|
||||
pub save_world_selection_context_probe: Option<SmpSaveWorldSelectionContextProbe>,
|
||||
pub rt3_105_save_name_table_probe: Option<SmpRt3105SaveNameTableProbe>,
|
||||
pub rt3_105_save_named_locomotive_availability_probe:
|
||||
Option<SmpRt3105SaveNamedLocomotiveAvailabilityProbe>,
|
||||
|
|
@ -2497,6 +2521,14 @@ pub fn load_save_slice_from_report(
|
|||
.recipe_book_summary_probe
|
||||
.as_ref()
|
||||
.and_then(derive_cargo_catalog_from_recipe_book_probe);
|
||||
let company_roster = report
|
||||
.save_world_selection_context_probe
|
||||
.as_ref()
|
||||
.and_then(derive_selection_only_company_roster_from_save_world_probe);
|
||||
let chairman_profile_table = report
|
||||
.save_world_selection_context_probe
|
||||
.as_ref()
|
||||
.and_then(derive_selection_only_chairman_profile_table_from_save_world_probe);
|
||||
let special_conditions_table =
|
||||
report
|
||||
.special_conditions_probe
|
||||
|
|
@ -2509,6 +2541,21 @@ pub fn load_save_slice_from_report(
|
|||
enabled_visible_labels: probe.enabled_visible_labels.clone(),
|
||||
entries: probe.entries.clone(),
|
||||
});
|
||||
let mut notes = summary.notes.clone();
|
||||
if let Some(probe) = &report.save_world_selection_context_probe {
|
||||
notes.push(format!(
|
||||
"Raw save fixed world block exposes selected_company_id={} at file offset 0x{:x}.",
|
||||
probe.selected_company_id, probe.selected_company_id_offset
|
||||
));
|
||||
notes.push(format!(
|
||||
"Raw save fixed world block exposes selected_chairman_profile_id={} at file offset 0x{:x}.",
|
||||
probe.selected_chairman_profile_id, probe.selected_chairman_profile_id_offset
|
||||
));
|
||||
notes.push(
|
||||
"Raw save inspection still does not reconstruct full company_roster or chairman_profile_table payloads; the grounded package-save path only proves selection ids and header-level collection state for those families."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SmpLoadedSaveSlice {
|
||||
file_extension_hint: summary.file_extension_hint.clone(),
|
||||
|
|
@ -2522,11 +2569,11 @@ pub fn load_save_slice_from_report(
|
|||
named_locomotive_availability_table,
|
||||
locomotive_catalog,
|
||||
cargo_catalog,
|
||||
company_roster: None,
|
||||
chairman_profile_table: None,
|
||||
company_roster,
|
||||
chairman_profile_table,
|
||||
special_conditions_table,
|
||||
event_runtime_collection: report.event_runtime_collection_summary.clone(),
|
||||
notes: summary.notes.clone(),
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2609,6 +2656,30 @@ fn derive_cargo_catalog_from_recipe_book_probe(
|
|||
})
|
||||
}
|
||||
|
||||
fn derive_selection_only_company_roster_from_save_world_probe(
|
||||
probe: &SmpSaveWorldSelectionContextProbe,
|
||||
) -> Option<SmpLoadedCompanyRoster> {
|
||||
Some(SmpLoadedCompanyRoster {
|
||||
source_kind: format!("{}-company-selection-only", probe.source_kind),
|
||||
semantic_family: "scenario-selected-company-context".to_string(),
|
||||
observed_entry_count: 0,
|
||||
selected_company_id: Some(probe.selected_company_id),
|
||||
entries: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_selection_only_chairman_profile_table_from_save_world_probe(
|
||||
probe: &SmpSaveWorldSelectionContextProbe,
|
||||
) -> Option<SmpLoadedChairmanProfileTable> {
|
||||
Some(SmpLoadedChairmanProfileTable {
|
||||
source_kind: format!("{}-chairman-selection-only", probe.source_kind),
|
||||
semantic_family: "scenario-selected-chairman-context".to_string(),
|
||||
observed_entry_count: 0,
|
||||
selected_chairman_profile_id: Some(probe.selected_chairman_profile_id),
|
||||
entries: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn known_cargo_slot_definition(slot_id: u32) -> Option<KnownCargoSlotDefinition> {
|
||||
KNOWN_CARGO_SLOT_DEFINITIONS
|
||||
.iter()
|
||||
|
|
@ -5264,6 +5335,11 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
|
|||
);
|
||||
let rt3_105_save_bridge_payload_probe =
|
||||
parse_rt3_105_save_bridge_payload_probe(bytes, rt3_105_post_span_bridge_probe.as_ref());
|
||||
let save_world_selection_context_probe = parse_save_world_selection_context_probe(
|
||||
bytes,
|
||||
file_extension_hint.as_deref(),
|
||||
container_profile.as_ref(),
|
||||
);
|
||||
let rt3_105_save_name_table_probe = parse_rt3_105_save_name_table_probe(
|
||||
bytes,
|
||||
file_extension_hint.as_deref(),
|
||||
|
|
@ -5410,6 +5486,7 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
|
|||
runtime_post_span_probe,
|
||||
rt3_105_post_span_bridge_probe,
|
||||
rt3_105_save_bridge_payload_probe,
|
||||
save_world_selection_context_probe,
|
||||
rt3_105_save_name_table_probe,
|
||||
rt3_105_save_named_locomotive_availability_probe,
|
||||
special_conditions_probe,
|
||||
|
|
@ -6826,6 +6903,74 @@ fn parse_rt3_105_save_bridge_payload_probe(
|
|||
})
|
||||
}
|
||||
|
||||
fn parse_save_world_selection_context_probe(
|
||||
bytes: &[u8],
|
||||
file_extension_hint: Option<&str>,
|
||||
container_profile: Option<&SmpContainerProfile>,
|
||||
) -> Option<SmpSaveWorldSelectionContextProbe> {
|
||||
if file_extension_hint != Some("gms") {
|
||||
return None;
|
||||
}
|
||||
let profile = container_profile?;
|
||||
let supported = 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"
|
||||
);
|
||||
if !supported {
|
||||
return None;
|
||||
}
|
||||
|
||||
for chunk_tag_offset in find_u32_le_offsets(bytes, RT3_SAVE_WORLD_BLOCK_CHUNK_TAG) {
|
||||
let payload_offset = chunk_tag_offset + 4;
|
||||
let next_chunk_tag_offset = payload_offset.checked_add(RT3_SAVE_WORLD_BLOCK_LEN)?;
|
||||
if read_u32_at(bytes, next_chunk_tag_offset) != Some(RT3_SAVE_WORLD_BLOCK_NEXT_CHUNK_TAG) {
|
||||
continue;
|
||||
}
|
||||
let selected_company_id_offset =
|
||||
payload_offset + RT3_SAVE_WORLD_BLOCK_SELECTED_COMPANY_ID_RELATIVE_OFFSET;
|
||||
let selected_chairman_profile_id_offset =
|
||||
payload_offset + RT3_SAVE_WORLD_BLOCK_SELECTED_CHAIRMAN_PROFILE_ID_RELATIVE_OFFSET;
|
||||
let selected_company_id = read_u32_at(bytes, selected_company_id_offset)?;
|
||||
let selected_chairman_profile_id = read_u32_at(bytes, selected_chairman_profile_id_offset)?;
|
||||
return Some(SmpSaveWorldSelectionContextProbe {
|
||||
profile_family: profile.profile_family.clone(),
|
||||
source_kind: "save-direct-world-block".to_string(),
|
||||
semantic_family: "scenario-selected-company-and-chairman-context".to_string(),
|
||||
chunk_tag_offset,
|
||||
payload_offset,
|
||||
payload_len: RT3_SAVE_WORLD_BLOCK_LEN,
|
||||
payload_len_hex: format!("0x{:x}", RT3_SAVE_WORLD_BLOCK_LEN),
|
||||
selected_company_id_offset,
|
||||
selected_company_id,
|
||||
selected_company_id_hex: format!("0x{selected_company_id:08x}"),
|
||||
selected_chairman_profile_id_offset,
|
||||
selected_chairman_profile_id,
|
||||
selected_chairman_profile_id_hex: format!("0x{selected_chairman_profile_id:08x}"),
|
||||
evidence: vec![
|
||||
format!(
|
||||
"chunk tag 0x32c8 at 0x{chunk_tag_offset:x} matches the fixed [world+0x04] save block"
|
||||
),
|
||||
format!(
|
||||
"next chunk tag 0x32c9 appears at 0x{next_chunk_tag_offset:x}, matching the documented 0x4f2c payload span"
|
||||
),
|
||||
format!(
|
||||
"selected company id comes from payload +0x{:x} ([world+0x21])",
|
||||
RT3_SAVE_WORLD_BLOCK_SELECTED_COMPANY_ID_RELATIVE_OFFSET
|
||||
),
|
||||
format!(
|
||||
"selected chairman profile id comes from payload +0x{:x} ([world+0x25])",
|
||||
RT3_SAVE_WORLD_BLOCK_SELECTED_CHAIRMAN_PROFILE_ID_RELATIVE_OFFSET
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_rt3_105_save_name_table_probe(
|
||||
bytes: &[u8],
|
||||
file_extension_hint: Option<&str>,
|
||||
|
|
@ -8619,6 +8764,15 @@ fn find_u16_le_offsets(bytes: &[u8], needle: u16) -> Vec<usize> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn find_u32_le_offsets(bytes: &[u8], needle: u32) -> Vec<usize> {
|
||||
let pattern = needle.to_le_bytes();
|
||||
bytes
|
||||
.windows(pattern.len())
|
||||
.enumerate()
|
||||
.filter_map(|(offset, window)| (window == pattern).then_some(offset))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_next_nonzero_offset(bytes: &[u8], start: usize) -> Option<usize> {
|
||||
bytes
|
||||
.iter()
|
||||
|
|
@ -13025,6 +13179,114 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_save_world_selection_context_probe_from_fixed_world_block() {
|
||||
let mut bytes = vec![0u8; 0x8000];
|
||||
let chunk_tag_offset = 0x3ceusize;
|
||||
let payload_offset = chunk_tag_offset + 4;
|
||||
bytes[chunk_tag_offset..chunk_tag_offset + 4]
|
||||
.copy_from_slice(&RT3_SAVE_WORLD_BLOCK_CHUNK_TAG.to_le_bytes());
|
||||
bytes[payload_offset + RT3_SAVE_WORLD_BLOCK_SELECTED_COMPANY_ID_RELATIVE_OFFSET
|
||||
..payload_offset + RT3_SAVE_WORLD_BLOCK_SELECTED_COMPANY_ID_RELATIVE_OFFSET + 4]
|
||||
.copy_from_slice(&7u32.to_le_bytes());
|
||||
bytes[payload_offset + RT3_SAVE_WORLD_BLOCK_SELECTED_CHAIRMAN_PROFILE_ID_RELATIVE_OFFSET
|
||||
..payload_offset
|
||||
+ RT3_SAVE_WORLD_BLOCK_SELECTED_CHAIRMAN_PROFILE_ID_RELATIVE_OFFSET
|
||||
+ 4]
|
||||
.copy_from_slice(&9u32.to_le_bytes());
|
||||
let next_chunk_offset = payload_offset + RT3_SAVE_WORLD_BLOCK_LEN;
|
||||
bytes[next_chunk_offset..next_chunk_offset + 4]
|
||||
.copy_from_slice(&RT3_SAVE_WORLD_BLOCK_NEXT_CHUNK_TAG.to_le_bytes());
|
||||
|
||||
let probe = parse_save_world_selection_context_probe(
|
||||
&bytes,
|
||||
Some("gms"),
|
||||
Some(&SmpContainerProfile {
|
||||
profile_family: "rt3-105-save-container-v1".to_string(),
|
||||
profile_evidence: vec![],
|
||||
is_known_profile: true,
|
||||
}),
|
||||
)
|
||||
.expect("selection-context probe should parse");
|
||||
|
||||
assert_eq!(probe.chunk_tag_offset, chunk_tag_offset);
|
||||
assert_eq!(probe.payload_offset, payload_offset);
|
||||
assert_eq!(probe.selected_company_id, 7);
|
||||
assert_eq!(probe.selected_chairman_profile_id, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_selection_only_company_and_chairman_context_from_save_world_probe() {
|
||||
let mut report = inspect_smp_bytes(&[]);
|
||||
report.save_load_summary = Some(SmpSaveLoadSummary {
|
||||
file_extension_hint: Some("gms".to_string()),
|
||||
container_profile_family: Some("rt3-105-save-container-v1".to_string()),
|
||||
mechanism_family: "rt3-105-save-post-span-bridge-v1".to_string(),
|
||||
mechanism_confidence: "mixed".to_string(),
|
||||
packed_profile_kind: None,
|
||||
packed_profile_family: None,
|
||||
packed_profile_offset: None,
|
||||
packed_profile_len: None,
|
||||
map_path: None,
|
||||
display_name: None,
|
||||
profile_byte_0x77: None,
|
||||
profile_byte_0x77_hex: None,
|
||||
profile_byte_0x82: None,
|
||||
profile_byte_0x82_hex: None,
|
||||
profile_byte_0x97: None,
|
||||
profile_byte_0x97_hex: None,
|
||||
profile_byte_0xc5: None,
|
||||
profile_byte_0xc5_hex: None,
|
||||
trailer_family: Some("rt3-105-save-trailer-v1".to_string()),
|
||||
bridge_family: Some("rt3-105-save-post-span-bridge-v1".to_string()),
|
||||
candidate_table: None,
|
||||
notes: vec![],
|
||||
});
|
||||
report.save_world_selection_context_probe = Some(SmpSaveWorldSelectionContextProbe {
|
||||
profile_family: "rt3-105-save-container-v1".to_string(),
|
||||
source_kind: "save-direct-world-block".to_string(),
|
||||
semantic_family: "scenario-selected-company-and-chairman-context".to_string(),
|
||||
chunk_tag_offset: 0x3ce,
|
||||
payload_offset: 0x3d2,
|
||||
payload_len: RT3_SAVE_WORLD_BLOCK_LEN,
|
||||
payload_len_hex: format!("0x{:x}", RT3_SAVE_WORLD_BLOCK_LEN),
|
||||
selected_company_id_offset: 0x3ef,
|
||||
selected_company_id: 1,
|
||||
selected_company_id_hex: "0x00000001".to_string(),
|
||||
selected_chairman_profile_id_offset: 0x3f3,
|
||||
selected_chairman_profile_id: 9,
|
||||
selected_chairman_profile_id_hex: "0x00000009".to_string(),
|
||||
evidence: vec![],
|
||||
});
|
||||
|
||||
let slice = load_save_slice_from_report(&report).expect("save slice");
|
||||
|
||||
let company_roster = slice.company_roster.expect("selection-only company roster");
|
||||
assert_eq!(company_roster.observed_entry_count, 0);
|
||||
assert_eq!(company_roster.selected_company_id, Some(1));
|
||||
assert!(company_roster.entries.is_empty());
|
||||
|
||||
let chairman_table = slice
|
||||
.chairman_profile_table
|
||||
.expect("selection-only chairman table");
|
||||
assert_eq!(chairman_table.observed_entry_count, 0);
|
||||
assert_eq!(chairman_table.selected_chairman_profile_id, Some(9));
|
||||
assert!(chairman_table.entries.is_empty());
|
||||
|
||||
assert!(
|
||||
slice
|
||||
.notes
|
||||
.iter()
|
||||
.any(|note| note.contains("selected_company_id=1"))
|
||||
);
|
||||
assert!(
|
||||
slice
|
||||
.notes
|
||||
.iter()
|
||||
.any(|note| note.contains("selected_chairman_profile_id=9"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_rt3_105_post_span_bridge_variants() {
|
||||
let base_trailer = SmpRuntimeTrailerBlock {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue