Bridge packed event collection through save import

This commit is contained in:
Jan Petykiewicz 2026-04-14 20:01:43 -07:00
commit 83f55fa26e
13 changed files with 653 additions and 35 deletions

View file

@ -79,6 +79,13 @@ 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 EVENT_RUNTIME_COLLECTION_METADATA_TAG: u16 = 0x4e99;
const EVENT_RUNTIME_COLLECTION_RECORDS_TAG: u16 = 0x4e9a;
const EVENT_RUNTIME_COLLECTION_CLOSE_TAG: u16 = 0x4e9b;
const EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION: u32 = 0x000003e9;
const INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT: usize = 19;
const INDEXED_COLLECTION_SERIALIZED_HEADER_LEN: usize =
INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT * 4;
const SMP_ALIGNED_RUNTIME_RULE_POST_WINDOW_OVERLAP_START_INDEX: usize =
(POST_SPECIAL_CONDITIONS_SCALAR_OFFSET - SPECIAL_CONDITIONS_OFFSET) / 4;
const SMP_ALIGNED_RUNTIME_RULE_POST_WINDOW_OVERLAP_DWORD_COUNT: usize =
@ -1167,6 +1174,23 @@ pub struct SmpLoadedSpecialConditionsTable {
pub entries: Vec<SmpSpecialConditionEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedEventRuntimeCollectionSummary {
pub source_kind: String,
pub mechanism_family: String,
pub mechanism_confidence: String,
#[serde(default)]
pub container_profile_family: Option<String>,
pub metadata_tag_offset: usize,
pub records_tag_offset: usize,
pub close_tag_offset: usize,
pub packed_state_version: u32,
pub packed_state_version_hex: String,
pub live_id_bound: u32,
pub live_record_count: usize,
pub live_entry_ids: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedSaveSlice {
pub file_extension_hint: Option<String>,
@ -1178,6 +1202,7 @@ pub struct SmpLoadedSaveSlice {
pub profile: Option<SmpLoadedProfile>,
pub candidate_availability_table: Option<SmpLoadedCandidateAvailabilityTable>,
pub special_conditions_table: Option<SmpLoadedSpecialConditionsTable>,
pub event_runtime_collection: Option<SmpLoadedEventRuntimeCollectionSummary>,
pub notes: Vec<String>,
}
@ -1220,6 +1245,7 @@ pub struct SmpInspectionReport {
pub classic_rehydrate_profile_probe: Option<SmpClassicRehydrateProfileProbe>,
pub rt3_105_packed_profile_probe: Option<SmpRt3105PackedProfileProbe>,
pub save_load_summary: Option<SmpSaveLoadSummary>,
pub event_runtime_collection_summary: Option<SmpLoadedEventRuntimeCollectionSummary>,
pub contains_grounded_runtime_tags: bool,
pub known_tag_hits: Vec<SmpKnownTagHit>,
pub notes: Vec<String>,
@ -1343,10 +1369,128 @@ pub fn load_save_slice_from_report(
profile,
candidate_availability_table,
special_conditions_table,
event_runtime_collection: report.event_runtime_collection_summary.clone(),
notes: summary.notes.clone(),
})
}
fn parse_event_runtime_collection_summary(
bytes: &[u8],
container_profile: Option<&SmpContainerProfile>,
save_load_summary: Option<&SmpSaveLoadSummary>,
) -> Option<SmpLoadedEventRuntimeCollectionSummary> {
let metadata_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_METADATA_TAG);
let record_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_RECORDS_TAG);
let close_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_CLOSE_TAG);
for metadata_tag_offset in metadata_offsets {
let packed_state_version = read_u32_at(bytes, metadata_tag_offset + 2)?;
if packed_state_version != EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION {
continue;
}
let records_tag_offset = record_offsets
.iter()
.copied()
.find(|offset| *offset > metadata_tag_offset + 6)?;
let close_tag_offset = close_offsets
.iter()
.copied()
.find(|offset| *offset > records_tag_offset)?;
let metadata_payload = bytes.get(metadata_tag_offset + 6..records_tag_offset)?;
if metadata_payload.len() < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN {
continue;
}
let header_words = (0..INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT)
.map(|index| read_u32_at(metadata_payload, index * 4))
.collect::<Option<Vec<_>>>()?;
let direct_collection_flag = header_words[0];
let direct_record_stride = usize::try_from(header_words[1]).ok()?;
let live_id_bound = header_words[4];
let live_record_count = usize::try_from(header_words[5]).ok()?;
if direct_collection_flag == 0 || direct_record_stride == 0 {
continue;
}
let bitset_len = ((usize::try_from(live_id_bound).ok()?).saturating_add(15)) / 8;
let payload_bytes = direct_record_stride.checked_mul(live_record_count)?;
if metadata_payload.len() < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN + bitset_len {
continue;
}
if metadata_payload.len() < bitset_len + payload_bytes {
continue;
}
let bitset_offset = metadata_payload.len() - bitset_len - payload_bytes;
if bitset_offset < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN {
continue;
}
let bitset = metadata_payload.get(bitset_offset..bitset_offset + bitset_len)?;
let live_entry_ids = decode_live_entry_ids_from_tombstone_bitset(bitset, live_id_bound)?;
if live_entry_ids.len() != live_record_count {
continue;
}
return Some(SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: save_load_summary
.map(|summary| summary.mechanism_family.clone())
.unwrap_or_else(|| "unknown".to_string()),
mechanism_confidence: save_load_summary
.map(|summary| summary.mechanism_confidence.clone())
.unwrap_or_else(|| "inferred".to_string()),
container_profile_family: container_profile
.map(|profile| profile.profile_family.clone()),
metadata_tag_offset,
records_tag_offset,
close_tag_offset,
packed_state_version,
packed_state_version_hex: format!("0x{packed_state_version:08x}"),
live_id_bound,
live_record_count,
live_entry_ids,
});
}
None
}
fn decode_live_entry_ids_from_tombstone_bitset(
bitset: &[u8],
live_id_bound: u32,
) -> Option<Vec<u32>> {
let ids = decode_live_entry_ids_with_mapping(bitset, live_id_bound, false);
if ids.is_some() {
return ids;
}
decode_live_entry_ids_with_mapping(bitset, live_id_bound, true)
}
fn decode_live_entry_ids_with_mapping(
bitset: &[u8],
live_id_bound: u32,
subtract_one: bool,
) -> Option<Vec<u32>> {
let mut live_entry_ids = Vec::new();
for entry_id in 1..=live_id_bound {
let bit_index = if subtract_one {
entry_id.checked_sub(1)?
} else {
entry_id
};
let byte_index = usize::try_from(bit_index / 8).ok()?;
let bit_mask = 1u8.checked_shl(bit_index % 8).unwrap_or(0);
let tombstone_byte = *bitset.get(byte_index)?;
if tombstone_byte & bit_mask == 0 {
live_entry_ids.push(entry_id);
}
}
Some(live_entry_ids)
}
fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> SmpInspectionReport {
let known_tag_hits = KNOWN_TAG_DEFINITIONS
.iter()
@ -1475,6 +1619,11 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
rt3_105_packed_profile_probe.as_ref(),
rt3_105_save_name_table_probe.as_ref(),
);
let event_runtime_collection_summary = parse_event_runtime_collection_summary(
bytes,
container_profile.as_ref(),
save_load_summary.as_ref(),
);
let mut warnings = Vec::new();
if bytes.is_empty() {
warnings
@ -1562,6 +1711,7 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
classic_rehydrate_profile_probe,
rt3_105_packed_profile_probe,
save_load_summary,
event_runtime_collection_summary,
contains_grounded_runtime_tags: !known_tag_hits.is_empty(),
known_tag_hits,
notes: vec![
@ -6025,6 +6175,110 @@ mod tests {
assert!(slice.candidate_availability_table.is_none());
}
#[test]
fn parses_event_runtime_collection_summary_from_synthetic_chunks() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_METADATA_TAG.to_le_bytes());
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION.to_le_bytes());
let header_words = [1u32, 4, 0, 0, 5, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for word in header_words {
bytes.extend_from_slice(&word.to_le_bytes());
}
bytes.extend_from_slice(&[0x14, 0x00]);
bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]);
bytes.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]);
bytes.extend_from_slice(&[0x55, 0x66, 0x77, 0x88]);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes());
bytes.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_CLOSE_TAG.to_le_bytes());
let report = inspect_smp_bytes(&bytes);
let summary = report
.event_runtime_collection_summary
.as_ref()
.expect("event runtime collection summary should parse");
assert_eq!(summary.packed_state_version, 0x3e9);
assert_eq!(summary.live_id_bound, 5);
assert_eq!(summary.live_record_count, 3);
assert_eq!(summary.live_entry_ids, vec![1, 3, 5]);
assert_eq!(summary.records_tag_offset, 96);
}
#[test]
fn loads_event_runtime_collection_summary_from_report() {
let mut report = inspect_smp_bytes(&[]);
let classic_probe = SmpClassicRehydrateProfileProbe {
profile_family: "rt3-classic-save-container-v1".to_string(),
progress_32dc_offset: 0x76e8,
progress_3714_offset: 0x76ec,
progress_3715_offset: 0x77f8,
packed_profile_offset: 0x76f0,
packed_profile_len: 0x108,
packed_profile_len_hex: "0x108".to_string(),
packed_profile_block: SmpClassicPackedProfileBlock {
relative_len: 0x108,
relative_len_hex: "0x108".to_string(),
leading_word_0: 3,
leading_word_0_hex: "0x00000003".to_string(),
trailing_zero_word_count_after_leading_word: 3,
map_path_offset: 0x13,
map_path: Some("British Isles.gmp".to_string()),
display_name_offset: 0x46,
display_name: Some("British Isles".to_string()),
profile_byte_0x77: 0,
profile_byte_0x77_hex: "0x00".to_string(),
profile_byte_0x82: 0,
profile_byte_0x82_hex: "0x00".to_string(),
profile_byte_0x97: 0,
profile_byte_0x97_hex: "0x00".to_string(),
profile_byte_0xc5: 0,
profile_byte_0xc5_hex: "0x00".to_string(),
stable_nonzero_words: vec![],
},
ascii_runs: vec![],
};
report.classic_rehydrate_profile_probe = Some(classic_probe.clone());
report.save_load_summary = build_save_load_summary(
Some("gms"),
Some(&SmpContainerProfile {
profile_family: "rt3-classic-save-container-v1".to_string(),
profile_evidence: vec![],
is_known_profile: true,
}),
None,
None,
Some(&classic_probe),
None,
None,
);
report.event_runtime_collection_summary = Some(SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 5,
live_record_count: 3,
live_entry_ids: vec![1, 3, 5],
});
let slice = load_save_slice_from_report(&report).expect("classic save slice");
assert_eq!(
slice
.event_runtime_collection
.as_ref()
.map(|summary| summary.live_entry_ids.clone()),
Some(vec![1, 3, 5])
);
}
#[test]
fn loads_rt3_105_save_slice_from_report() {
let mut report = inspect_smp_bytes(&[]);