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" + )); + } +}