Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions rust_parser/core/examples/compat_probe.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String> = std::env::args().collect();
let [_, sav] = &args[..] else {
eprintln!("usage: compat_probe <save.sav>");
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);
}
}
}
2 changes: 1 addition & 1 deletion rust_parser/core/src/editor/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down
24 changes: 17 additions & 7 deletions rust_parser/core/src/level.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Header> {
pub(crate) fn parse_one_header(c: &mut Cursor, header_save_version: u32) -> PResult<Header> {
// 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!(
Expand Down Expand Up @@ -58,7 +61,7 @@ pub(crate) fn parse_one_header(c: &mut Cursor) -> PResult<Header> {
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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions rust_parser/core/src/mapdata/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(']');
}
Expand Down
11 changes: 11 additions & 0 deletions rust_parser/core/src/properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()?]),
Expand Down
10 changes: 6 additions & 4 deletions rust_parser/core/src/save_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
2 changes: 2 additions & 0 deletions rust_parser/core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Loading