Skip to content

SaveCompanionFile

QinShenYu edited this page Jun 16, 2026 · 1 revision

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.


When You Need This

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.


Quick Start

1. Implement ICustomItemSaveable

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;
    }
}

2. Hook it up in your CustomItemBuilder

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.


ICustomItemSaveable

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 / Parse defaults are locale-sensitive and will round-trip badly across locales. JSON via Newtonsoft.Json is fine, plain InvariantCulture numerics are fine, CultureInfo.CurrentCulture is 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

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.


MissingItemTag

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 missing ItemInfo, because ScavLib injects a placeholder ItemInfo for 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

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.


When the Patches Run

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.


Limitations

  • 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 SaveKey or your payload format, write your own migration in Load(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 ICustomItemSaveable components get separate payloads. A single GameObject with multiple ICustomItemSaveable components works as long as their SaveKey values 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.InstanceData on Multiplayer Compatibility.

Clone this wiki locally