-
Notifications
You must be signed in to change notification settings - Fork 0
SaveCompanionFile
Namespace: ScavLib.save
ScavLib's companion file system lets custom items persist per-instance
state across save / load cycles without writing into the vanilla save
format. Each save gets a small JSON sidecar in the same directory as
the game's save file; ScavLib reads it on load and offers the recorded
state to your custom items via the ICustomItemSaveable interface.
The design intent is simple: never extend the vanilla save schema. Game updates can rearrange that schema at any time, and any mod that wedged extra fields into it would silently corrupt saves on the next patch. Companion files are isolated, mod-owned, and can be deleted by hand without harming the vanilla save.
You only need ICustomItemSaveable if your custom item carries
per-instance state that ScavLib cannot reconstruct from the
ItemInfo. Common examples:
- A radio with a tunable frequency.
- A notebook whose pages the player edited.
- A weapon with a kill counter or cosmetic charm.
- A backpack with a colour the player chose at craft time.
If your item is fully described by its ItemInfo (weight, tags, sprite,
container capacity, etc.), ScavLib already round-trips it correctly
through the vanilla save — you do not need this system.
Attach a MonoBehaviour to the cloned prefab in OnSpawn, and have it
implement ICustomItemSaveable:
using ScavLib.save;
using UnityEngine;
public class RadioState : MonoBehaviour, ICustomItemSaveable
{
public float Frequency = 88.5f;
public string SaveKey => "frequency";
public string Save()
{
return Frequency.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
}
public void Load(string payload)
{
if (float.TryParse(payload, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var f))
Frequency = f;
}
}CustomItemBuilder.Create("mymod_radio", "MyMod", ItemTemplate.MP3Player)
.DisplayName("Field Radio")
.OnSpawn(go =>
{
if (go.GetComponent<RadioState>() == null)
go.AddComponent<RadioState>();
})
.Register(out _);That's it. ScavLib persists RadioState.Save() into the companion file
on save, and calls RadioState.Load(payload) on load before the player
ever sees the spawned instance.
public interface ICustomItemSaveable
{
/// <summary>
/// Stable string key under which this component's payload is stored
/// inside the companion file. Must be unique per component type
/// attached to a single GameObject — collisions overwrite silently.
/// </summary>
string SaveKey { get; }
/// <summary>
/// Serialize the current state to a string. Called once per save.
/// Return null or empty to skip persistence for this instance.
/// </summary>
string Save();
/// <summary>
/// Restore state from a previously serialized payload. Called once
/// per load, before the GameObject becomes interactable.
/// </summary>
void Load(string payload);
}Recommendations:
- Use a mod-prefixed
SaveKey ("mymod_radio_freq") if you might share a GameObject with another mod's component. Collisions are silent. - Pick a culture-invariant string format.
ToString/Parsedefaults are locale-sensitive and will round-trip badly across locales. JSON viaNewtonsoft.Jsonis fine, plainInvariantCulturenumerics are fine,CultureInfo.CurrentCultureis not. - Keep payloads small. Companion files are JSON; gigabyte-scale data belongs in your own files, not here.
- The interface is value-only, not reference-graph. If you have
cross-instance references, persist their identifying ids and re-link
in
Load.
SaveCompanionFile is the static class that reads and writes the
sidecar. You normally do not call it directly — the SaveGamePatch and
TryLoadGamePatch Harmony patches drive it on the right ticks. The
public surface that matters to mod code is the directory resolver:
SaveCompanionFile.SaveDirResolver = () => myCustomDir;| Member | Description |
|---|---|
SaveDirResolver |
Func<string> returning the directory in which to read/write companion files. Defaults to Application.persistentDataPath. |
When the Krokosha MP bridge is active, it overrides SaveDirResolver so
companion files follow the active MP save directory. See
Multiplayer Compatibility for the
resolution order.
When ScavLib loads a vanilla save that references a custom id whose mod
is no longer present (uninstalled, disabled, version mismatch), the
spawned instance gets a MissingItemTag MonoBehaviour attached instead
of being silently dropped. The tag carries the original id and any
companion-file payload that was serialized for it, so:
- The save round-trips losslessly: re-installing the mod restores the item exactly.
- The player can still pick the item up, drop it, or destroy it — it just lacks its mod-defined behaviour.
- No NullReferenceException on
Item.Start()from a missingItemInfo, because ScavLib injects a placeholderItemInfofor unknown ids during the patched load path.
You don't need to do anything to opt into this behaviour — it's how ScavLib responds to any unknown id during save load.
SaveDataSchema is the internal JSON shape used in the companion file.
It's exposed so other tooling (export scripts, save inspectors) can
deserialize it without re-deriving the layout. The shape is roughly:
{
"version": 1,
"instances": [
{
"id": "mymod_radio",
"guid": "<save-stable id>",
"components": {
"frequency": "88.50",
"mymod_radio_kills": "3"
}
}
]
}You should not write this file by hand — round-tripping it through
SaveCompanionFile keeps version and key handling consistent across
ScavLib upgrades.
| Patch | Hook | What it does |
|---|---|---|
SaveGamePatch |
Postfix of the vanilla save flow |
Walks every loaded Item carrying ICustomItemSaveable components and writes their payloads to the companion file. |
TryLoadGamePatch |
Postfix of the vanilla load flow |
Reads the companion file, applies Load(payload) to every matching component, and attaches MissingItemTag for unresolved ids. |
Both patches are individually wrapped in try/catch and report failures
through scavlib check, so a broken save file never blocks the game from
loading.
- Companion files are not transactional with the vanilla save. If the game writes a save and then crashes before the companion file is flushed, you can end up with mismatched state on next load. ScavLib detects mismatches by id presence and discards orphan companion entries; it does not roll the vanilla save back.
-
No automatic migration helpers. If you change
SaveKeyor your payload format, write your own migration inLoad(payload)— detect the old shape and translate it, or reject it cleanly. There is no schema-versioning DSL. -
Per-component, not per-prefab. Two custom items that ship with
different
ICustomItemSaveablecomponents get separate payloads. A single GameObject with multipleICustomItemSaveablecomponents works as long as theirSaveKeyvalues differ. -
Multiplayer: companion files are written by the host. Remote
clients do not maintain their own copy. If a mod relies on
authoritative per-instance state in MP, treat the host as the source
of truth and resync on join — see the design note about
CustomItemTag.InstanceDataon Multiplayer Compatibility.