From 314d886e300d07c7c5a71a527e12737f17113167 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 6 Sep 2026 19:59:02 +0200 Subject: [PATCH] Load saves whose mods serialise their own payloads FicsItCam, the camera mod streamers record with, parks a camera recording on its FICCamera component -- ~300 KB each, three of them in the reported save. The parser required every object to end exactly where its properties did, so the first such component failed the whole load: Found 267135 extra trailing bytes for ComponentHeader /Script/FicsItCam.FICCamera. Nothing about that is new: this path has not changed since the original Rust port, FicsItCam has never been handled, and the save is on the same game build (502094) as saves that load fine. What changed for the reporter is having camera recordings in the save at all. An object declares its own size, so a payload we do not understand is skippable without guessing: we know exactly where the object ends. The remainder is now kept verbatim as opaque bytes -- but only for classes that came from a mod, because trailing bytes on VANILLA content mean a hole in our own reading of the format, and staying loud about those is how the conveyor item-state bug got found. MODDED_RAW stays an explicit list: a mod may ship under a vanilla-looking path, as the golden golfcart does. In the reported save this covers 3 objects of 1.3 million; the other 13 modded classes present (DoggoHardHat, SkyUI, UtilityMod, EfficiencyChecker and the rest) already parsed cleanly and are untouched. Verified: all 23 corpus saves produce byte-identical payload and index JSON, so no vanilla save changes at all; the reported save now parses, round-trips through export to its exact 1.54 GB body, and renders its 1,299,672 objects in the browser. Co-Authored-By: Claude Opus 5 (1M context) --- rust_parser/core/examples/debug_modded.rs | 41 +++++++ rust_parser/core/src/object.rs | 129 ++++++++++++++++++++-- 2 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 rust_parser/core/examples/debug_modded.rs diff --git a/rust_parser/core/examples/debug_modded.rs b/rust_parser/core/examples/debug_modded.rs new file mode 100644 index 0000000..da0b1c3 --- /dev/null +++ b/rust_parser/core/examples/debug_modded.rs @@ -0,0 +1,41 @@ +//! Survey the non-vanilla (modded) classes in a save and report which ones +//! carry a trailing payload the parser skips as opaque bytes. + +use sav_core::level::parse_full_save_lean; +use sav_core::object::ClassTables; +use sav_core::store::{ActorSpecific, Header}; +use std::collections::BTreeMap; + +fn main() { + let path = std::env::args().nth(1).expect("save path"); + let bytes = std::fs::read(&path).expect("read save"); + let store = parse_full_save_lean(&bytes, &ClassTables::embedded(), None).expect("lean parse"); + + // class -> (instances, instances with opaque trailing bytes, total bytes) + let mut modded: BTreeMap = BTreeMap::new(); + for (li, level) in store.levels.iter().enumerate() { + for (oi, header) in level.headers.iter().enumerate() { + let class = match header { + Header::Actor(a) => store.s(a.type_path).to_string(), + Header::Component(c) => store.s(c.class_name).to_string(), + }; + if class.starts_with("/Script/FactoryGame.") || class.starts_with("/Game/FactoryGame/") { + continue; + } + let entry = modded.entry(class).or_default(); + entry.0 += 1; + if let Ok(object) = store.parse_object_at(li, oi) { + if let ActorSpecific::RawBytes(d) = object.actor_specific { + entry.1 += 1; + entry.2 += d.len as usize; + } + } + } + } + println!("{:<58} {:>7} {:>8} {:>12}", "modded class", "count", "opaque", "opaque bytes"); + for (class, (n, opaque, bytes)) in &modded { + println!("{class:<58} {n:>7} {opaque:>8} {bytes:>12}"); + } + let total: usize = modded.values().map(|v| v.0).sum(); + println!("\n{} modded classes, {} objects", modded.len(), total); +} diff --git a/rust_parser/core/src/object.rs b/rust_parser/core/src/object.rs index 5a18485..a47838d 100644 --- a/rust_parser/core/src/object.rs +++ b/rust_parser/core/src/object.rs @@ -70,6 +70,16 @@ pub(crate) const CONVEYOR_CHAINS: [&str; 5] = [ "/Script/FactoryGame.FGConveyorChainActor_RepSizeHuge", ]; const PICKUP_SPAWNABLE: &str = "/Script/FactoryGame.FGItemPickup_Spawnable"; +/// Vanilla content ships under these two prefixes; anything else in a save +/// was put there by a mod. Only used to decide what an object we could not +/// fully consume means: a mod serialising its own payload (skip it, load the +/// save) or a hole in our own reading of the format (fail, so it gets fixed). +/// `MODDED_RAW` above stays an explicit list because a mod may also ship +/// under a vanilla-looking path -- the golden golfcart does. +fn is_vanilla_class(class: &str) -> bool { + class.starts_with("/Script/FactoryGame.") || class.starts_with("/Game/FactoryGame/") +} + const MODDED_RAW: [&str; 5] = [ "/AB_CableMod/Cables_Heavy/Build_AB-PLHeavy-Cu.Build_AB-PLHeavy-Cu_C", "/FlexSplines/Conveyor/Build_Belt2.Build_Belt2_C", @@ -492,18 +502,29 @@ pub fn parse_object( )); } if c.pos < offset_start_this + object_size { - return match header { - Header::Actor(ah) => Err(perr!( - "Found {} extra trailing bytes for ActorHeader {}.", - offset_start_this + object_size - c.pos, - ah.type_path.to_string(c.data) - )), - Header::Component(ch) => Err(perr!( - "Found {} extra trailing bytes for ComponentHeader {}.", - offset_start_this + object_size - c.pos, - ch.class_name.to_string(c.data) - )), + let remaining = offset_start_this + object_size - c.pos; + let (class, kind) = match header { + Header::Actor(ah) => (ah.type_path, "ActorHeader"), + Header::Component(ch) => (ch.class_name, "ComponentHeader"), }; + let class_str = std::str::from_utf8(class.bytes(c.data)).unwrap_or(""); + if is_vanilla_class(class_str) { + // Vanilla content we failed to consume is OUR bug, and staying + // loud about it is how the conveyor item-state hole got found. + return Err(perr!( + "Found {} extra trailing bytes for {} {}.", + remaining, + kind, + class_str + )); + } + // A mod serialises whatever it likes into its own actors and + // components -- FicsItCam parks a camera recording (hundreds of KB) + // on its FICCamera component. The object's own declared size tells + // us exactly where it ends, so the bytes can be carried verbatim + // without understanding any of them, and one mod cannot stop a save + // from loading. + actor_specific = ActorSpecific::RawBytes(c.data_ref(remaining)?); } if object_game_version >= 53 { @@ -570,3 +591,89 @@ impl crate::store::SaveStore { Ok(object) } } + +#[cfg(test)] +mod modded_class_tests { + use super::*; + + /// The rule that decides whether an object we could not fully consume is + /// a mod's own payload (skip it) or our bug (fail loudly). Getting it + /// wrong in one direction hides parser holes; in the other it refuses + /// saves over data we were never going to read. + #[test] + fn vanilla_and_modded_classes_are_told_apart() { + for vanilla in [ + "/Script/FactoryGame.FGConveyorChainActor", + "/Script/FactoryGame.FGInventoryComponent", + "/Game/FactoryGame/Buildable/Factory/PowerLine/Build_PowerLine.Build_PowerLine_C", + "/Game/FactoryGame/Character/Player/BP_PlayerState.BP_PlayerState_C", + ] { + assert!(is_vanilla_class(vanilla), "{vanilla} should count as vanilla"); + } + for modded in [ + "/Script/FicsItCam.FICCamera", + "/Script/FicsItNetworks.FINComputerCase", + "/DoggoHardHat/HAT.HAT_C", + "/FlexSplines/Conveyor/Build_Belt2.Build_Belt2_C", + "/SkyUI/SkyUI_SubSystem.SkyUI_SubSystem_C", + "", + ] { + assert!(!is_vanilla_class(modded), "{modded} should count as modded"); + } + // Near-misses that must NOT be mistaken for vanilla: a mod is free to + // name itself something that merely starts the same way. + for lookalike in [ + "/Script/FactoryGameExtra.Thing", + "/Game/FactoryGameMod/Thing.Thing_C", + ] { + assert!(!is_vanilla_class(lookalike), "{lookalike} should count as modded"); + } + } + + /// Mods whose trailing shape the parser has actually LEARNED, rather + /// than skipping as opaque. They matter because the branch that knows + /// them runs first: it consumes the trailing bytes, so the object ends + /// where it should and the opaque fallback never sees it. If one of + /// these ever changes shape, that branch fails on its own terms instead + /// of the failure being quietly absorbed. + const KNOWN_MODDED_SPECIAL_CASES: [&str; 7] = [ + "/Script/FicsitFarming.FFDoggoHealthInfoComponent", + "/EditSwatchNames/DataHolder.DataHolder_C", + // MODDED_RAW: read to the object's end as raw bytes. + "/AB_CableMod/Cables_Heavy/Build_AB-PLHeavy-Cu.Build_AB-PLHeavy-Cu_C", + "/FlexSplines/Conveyor/Build_Belt2.Build_Belt2_C", + "/FlexSplines/PowerLine/Build_FlexPowerline.Build_FlexPowerline_C", + "/Game/FactoryGame/Buildable/Vehicle/Golfcart/BP_GolfcartGold.BP_GolfcartGold_C", + "/CharacterReplacer/Logic/SCS_CR_PlayerHook.SCS_CR_PlayerHook_C", + ]; + + /// Every class the parser special-cases by name is either vanilla or one + /// of the handful of mods we have deliberately learned. A NEW modded + /// special-case showing up here is worth a thought: its branch has to + /// consume the object fully, or the opaque fallback will absorb whatever + /// it leaves behind instead of reporting it. + #[test] + fn special_cased_classes_are_vanilla_or_known_mods() { + let named = GAME_MODE_STATE + .iter() + .chain(CONVEYOR_CHAINS.iter()) + .chain(POWER_LINES.iter()) + .chain(COMPONENT_TRAILING.iter()) + .chain(CALC_ACTOR_WHITELIST.iter()) + .chain(CALC_COMPONENT_WHITELIST.iter()) + .chain(MODDED_RAW.iter()) + .copied() + .chain([PLAYER_STATE, DRONE_TRANSPORT, CIRCUIT_SUBSYSTEM]); + for class in named { + assert!( + is_vanilla_class(class) || KNOWN_MODDED_SPECIAL_CASES.contains(&class), + "{class} is a modded class the parser special-cases but the test does not know about -- make sure its branch consumes the whole object, then add it to KNOWN_MODDED_SPECIAL_CASES" + ); + } + // The golden golfcart is why MODDED_RAW cannot be replaced by the + // prefix rule: it is a mod shipping under a vanilla-looking path. + assert!(is_vanilla_class( + "/Game/FactoryGame/Buildable/Vehicle/Golfcart/BP_GolfcartGold.BP_GolfcartGold_C" + )); + } +}