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
41 changes: 41 additions & 0 deletions rust_parser/core/examples/debug_modded.rs
Original file line number Diff line number Diff line change
@@ -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<String, (usize, usize, usize)> = 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);
}
129 changes: 118 additions & 11 deletions rust_parser/core/src/object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
));
}
}
Loading