From 334407bb3c5eaf179ce786b896aeadd4f9ac3ca7 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 26 Jul 2026 23:01:45 +0200 Subject: [PATCH] parser: experimental Update 8 (saveVersion 42) compatibility Accept headerType 13 / saveVersion 42 saves behind version gates: - header: no saveName field before headerType 14 - levels: no per-level saveVersion u32 before 1.0 (fall back to header) - actor/component headers: no flags u32 before 1.0 - persistent level: trailing collectables list still present pre-1.0 - InventoryItem property struct: pre-44 (level, path) itemState pair (new InvItemProps::LegacyRef variant) Verified on a 6GB-body U8 save (5.4M objects, full payload+index build) and regression-tested against the 1.0/1.1 suite (all 65 tests pass). Gates marked COMPAT EXPERIMENT: thresholds separate 42 from 52+ correctly but exact introduction versions for 43-51 are unverified, and editor write paths still emit 1.0-format records (do not widen the accept list or enable splice edits on old saves without auditing those). compat_probe example: header/decompress/body probe with hexdumps for diagnosing unsupported save layouts. Co-Authored-By: Claude Fable 5 --- rust_parser/core/examples/compat_probe.rs | 122 ++++++++++++++++++++++ rust_parser/core/src/editor/clipboard.rs | 2 +- rust_parser/core/src/level.rs | 24 +++-- rust_parser/core/src/mapdata/display.rs | 8 ++ rust_parser/core/src/properties.rs | 11 ++ rust_parser/core/src/save_header.rs | 10 +- rust_parser/core/src/store.rs | 2 + 7 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 rust_parser/core/examples/compat_probe.rs diff --git a/rust_parser/core/examples/compat_probe.rs b/rust_parser/core/examples/compat_probe.rs new file mode 100644 index 0000000..789bd7d --- /dev/null +++ b/rust_parser/core/examples/compat_probe.rs @@ -0,0 +1,122 @@ +//! Scratch probe for old-save-version compatibility: parse the header, dump +//! the start of the decompressed body, then attempt a lean body parse and +//! report exactly where it derails. +//! +//! cargo run --release --features parallel --example compat_probe -- save.sav + +use sav_core::decompress::decompress_save_file; +use sav_core::level::parse_body_bytes_lean; +use sav_core::object::ClassTables; +use sav_core::save_header::parse_save_file_info; + +fn hexdump(data: &[u8], start: usize, len: usize) { + let end = (start + len).min(data.len()); + for off in (start..end).step_by(16) { + let row = &data[off..(off + 16).min(end)]; + let hex: Vec = row.iter().map(|b| format!("{:02x}", b)).collect(); + let ascii: String = row + .iter() + .map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' }) + .collect(); + eprintln!("{:08x}: {:<48} {}", off, hex.join(" "), ascii); + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let [_, sav] = &args[..] else { + eprintln!("usage: compat_probe "); + std::process::exit(2); + }; + let bytes = std::fs::read(sav).expect("read save"); + eprintln!("file: {} bytes", bytes.len()); + + let (info, body_offset) = match parse_save_file_info(&bytes) { + Ok(v) => v, + Err(e) => { + eprintln!("HEADER FAILED: {}", e); + hexdump(&bytes, 0, 128); + std::process::exit(1); + } + }; + eprintln!( + "header ok: headerType={} saveVersion={} build={} session={:?} body_offset={}", + info.save_header_type, info.save_version, info.build_version, info.session_name, body_offset + ); + + let t = std::time::Instant::now(); + let decompressed = match decompress_save_file(&bytes, body_offset, None) { + Ok(v) => v, + Err(e) => { + eprintln!("DECOMPRESS FAILED: {}", e); + eprintln!("chunk table starts at {:#x}:", body_offset); + hexdump(&bytes, body_offset, 128); + std::process::exit(1); + } + }; + eprintln!("decompress ok: {} bytes in {:.2?}", decompressed.len(), t.elapsed()); + eprintln!("body start:"); + hexdump(&decompressed, 0, 256); + + let file_header = bytes[..body_offset].to_vec(); + drop(bytes); + + // Manual walk of the body prefix so we can see where the level layout + // diverges from the 1.0 expectations. + { + use sav_core::reader::Cursor; + let mut c = Cursor::new(&decompressed, 8); + let partition_count = c.u32().unwrap(); + eprintln!("partition_count={}", partition_count); + for i in 0..partition_count { + let name = c.string().unwrap(); + let a = c.u32().unwrap(); + let b = c.u32().unwrap(); + let n = c.u32().unwrap(); + eprintln!( + " partition {}: {:?} a={:#x} b={:#x} levels={}", + i, + String::from_utf8_lossy(name.bytes(&decompressed)), + a, + b, + n + ); + for _ in 0..n { + let _ln = c.string().unwrap(); + let _h = c.u32().unwrap(); + } + } + let level_count = c.u32().unwrap(); + eprintln!("level_count={} levels start at {:#x}", level_count, c.pos); + let name = c.string().unwrap(); + eprintln!("level 0 name: {:?}", String::from_utf8_lossy(name.bytes(&decompressed))); + eprintln!("bytes after level 0 name (pos {:#x}):", c.pos); + hexdump(&decompressed, c.pos, 96); + let size_u64 = u64::from_le_bytes(decompressed[c.pos..c.pos + 8].try_into().unwrap()); + let size_u32 = u32::from_le_bytes(decompressed[c.pos..c.pos + 4].try_into().unwrap()); + eprintln!("TOC size as u64: {} as u32: {}", size_u64, size_u32); + eprintln!("last 160 bytes of body:"); + hexdump(&decompressed, decompressed.len().saturating_sub(160), 160); + } + + let t = std::time::Instant::now(); + match parse_body_bytes_lean(decompressed, file_header, info, &ClassTables::embedded(), None) { + Ok(store) => { + eprintln!("BODY PARSE OK in {:.2?}", t.elapsed()); + eprintln!("levels: {}", store.levels.len()); + let total: usize = store.levels.iter().map(|l| l.headers.len()).sum(); + eprintln!("total objects: {}", total); + eprintln!("calculator_extras: {:?}", store.calculator_extras); + let last = store.levels.last().unwrap(); + eprintln!( + "persistent level: {} objects, level_save_version={}", + last.headers.len(), + last.level_save_version + ); + } + Err(e) => { + eprintln!("BODY PARSE FAILED after {:.2?}: {}", t.elapsed(), e); + std::process::exit(1); + } + } +} diff --git a/rust_parser/core/src/editor/clipboard.rs b/rust_parser/core/src/editor/clipboard.rs index 39846a0..587c5b4 100644 --- a/rust_parser/core/src/editor/clipboard.rs +++ b/rust_parser/core/src/editor/clipboard.rs @@ -292,7 +292,7 @@ fn decode_foreign( combined.extend_from_slice(&body_blob); let mut hc = Cursor::new(&combined, 0); - let header = parse_one_header(&mut hc)?; + let header = parse_one_header(&mut hc, save_version)?; if hc.pos != body_start { return Err(perr!("Clipboard header record has trailing bytes")); } diff --git a/rust_parser/core/src/level.rs b/rust_parser/core/src/level.rs index dcc9aa4..3a953de 100644 --- a/rust_parser/core/src/level.rs +++ b/rust_parser/core/src/level.rs @@ -16,14 +16,17 @@ pub type ProgressFn<'a> = &'a mut dyn FnMut(u8, u64, u64); /// One object-header record ([u32 headerType][actor or component fields]). /// Also used standalone by the cross-save clipboard, whose blobs are exactly /// these records (StrRefs come out relative to the cursor's buffer). -pub(crate) fn parse_one_header(c: &mut Cursor) -> PResult
{ +pub(crate) fn parse_one_header(c: &mut Cursor, header_save_version: u32) -> PResult
{ + // COMPAT EXPERIMENT: the header `flags` u32 was added with 1.0; U8 (v42) + // headers go straight from instanceName to the next field. + let has_flags = header_save_version >= 46; let header_type = c.u32()?; match header_type { 1 => { let type_path = c.string()?; let root_object = c.string()?; let instance_name = c.string()?; - let flags = c.u32()?; + let flags = if has_flags { c.u32()? } else { 0 }; let need_transform = c.bool_u32("needTransform")?; if c.pos + 40 > c.data.len() { return Err(perr!( @@ -58,7 +61,7 @@ pub(crate) fn parse_one_header(c: &mut Cursor) -> PResult
{ let class_name = c.string()?; let root_object = c.string()?; let instance_name = c.string()?; - let flags = c.u32()?; + let flags = if has_flags { c.u32()? } else { 0 }; let parent_actor_name = c.string()?; Ok(Header::Component(ComponentHeader { class_name, @@ -100,7 +103,7 @@ fn parse_headers_and_level( let mut last_report = c.pos; for _ in 0..actor_and_component_count { let header_record_start = c.pos; - let h = parse_one_header(c)?; + let h = parse_one_header(c, header_save_version)?; headers.push(h); header_spans.push((header_record_start, (c.pos - header_record_start) as u32)); if let Some(cb) = progress.as_deref_mut() { @@ -155,17 +158,24 @@ fn parse_headers_and_level( bodies_insert_off: object_start + all_objects_size, }; - let level_save_version = c.u32()?; + // COMPAT EXPERIMENT: the per-level save version field appeared with 1.0 + // (not present in U8 v42 saves); fall back to the header version there. + let level_save_version = + if header_save_version >= 46 { c.u32()? } else { header_save_version }; let mut collectables2 = Vec::new(); let mut save_object_version_data = None; let object_ue5_version: i32; - if !persistent_level_flag { - let mut v: i32 = -1; + // COMPAT EXPERIMENT: pre-1.0 saves carry a collectables list on the + // persistent level too. + if !persistent_level_flag || header_save_version < 46 { let n = c.u32()?; for _ in 0..n { collectables2.push(parse_object_reference(c)?); } + } + if !persistent_level_flag { + let mut v: i32 = -1; if header_save_version >= 53 { let has = c.bool_u32("hasSaveObjectVersionData")?; if has { diff --git a/rust_parser/core/src/mapdata/display.rs b/rust_parser/core/src/mapdata/display.rs index 936751d..4c40d24 100644 --- a/rust_parser/core/src/mapdata/display.rs +++ b/rust_parser/core/src/mapdata/display.rs @@ -450,6 +450,14 @@ fn write_struct(out: &mut String, data: &[u8], sv: &StructValue) { write_types(out, data, props); out.push(']'); } + // COMPAT EXPERIMENT: pre-44 itemState reference pair. + InvItemProps::LegacyRef { level, path } => { + out.push('['); + write_quoted(out, data, *level); + out.push_str(", "); + write_quoted(out, data, *path); + out.push(']'); + } } out.push(']'); } diff --git a/rust_parser/core/src/properties.rs b/rust_parser/core/src/properties.rs index d6d92d1..b54bd4a 100644 --- a/rust_parser/core/src/properties.rs +++ b/rust_parser/core/src/properties.rs @@ -641,6 +641,16 @@ pub fn parse_properties( "InventoryItem" => { c.confirm_u32(0)?; let item_name = c.string()?; + // COMPAT EXPERIMENT: pre-44 itemState is a + // (levelName, pathName) string pair, not a flag. + if current_entity_save_version < 44 { + let level = c.string()?; + let path = c.string()?; + StructValue::InventoryItem { + item_name, + item_properties: InvItemProps::LegacyRef { level, path }, + } + } else { let has_props = c.bool_u32("StructProperty.InventoryItem.itemHasPropertiesFlag")?; let item_properties = if has_props { @@ -664,6 +674,7 @@ pub fn parse_properties( InvItemProps::One }; StructValue::InventoryItem { item_name, item_properties } + } } "LinearColor" => StructValue::LinearColor([c.f32()?, c.f32()?, c.f32()?, c.f32()?]), "Vector2D" => StructValue::Vector2D([c.f64()?, c.f64()?]), diff --git a/rust_parser/core/src/save_header.rs b/rust_parser/core/src/save_header.rs index 75e1270..3a1405b 100644 --- a/rust_parser/core/src/save_header.rs +++ b/rust_parser/core/src/save_header.rs @@ -31,16 +31,18 @@ pub const EPOCH_1_TO_1970: u64 = 719_162 * 24 * 60 * 60; pub fn parse_save_file_info(data: &[u8]) -> PResult<(SaveFileInfo, usize)> { let mut c = Cursor::new(data, 0); let save_header_type = c.u32()?; - if save_header_type != 14 { + // COMPAT EXPERIMENT: 13 = Update 8 header (no saveName field). + if !matches!(save_header_type, 13 | 14) { return Err(perr!("Unsupported save header version number {}.", save_header_type)); } let save_version = c.u32()?; - if !matches!(save_version, 52 | 53 | 58 | 59 | 60) { + // COMPAT EXPERIMENT: 42 = Update 8. + if !matches!(save_version, 42 | 52 | 53 | 58 | 59 | 60) { return Err(perr!("Unsupported save version number {}.", save_version)); } let build_version = c.u32()?; - // save_version >= 14 always true for the accepted set; gate kept for parity. - let save_name = if save_version >= 14 { c.string_owned()? } else { String::new() }; + // saveName exists from header type 14 (1.0) on. + let save_name = if save_header_type >= 14 { c.string_owned()? } else { String::new() }; let map_name = c.string_owned()?; let map_options = c.string_owned()?; let session_name = c.string_owned()?; diff --git a/rust_parser/core/src/store.rs b/rust_parser/core/src/store.rs index b54626e..c8bee48 100644 --- a/rust_parser/core/src/store.rs +++ b/rust_parser/core/src/store.rs @@ -134,6 +134,8 @@ pub enum InvItemProps { One, Two, Props { type_path: StrRef, props: PropList }, + /// COMPAT EXPERIMENT: pre-1.0 (save version < 44) itemState reference pair. + LegacyRef { level: StrRef, path: StrRef }, } #[derive(Debug)]