Persistence Guide
Last updated:
- Published on
Introduction
RitsuLib provides a structured persistence layer for mod data, with scoped storage, profile switching support, backup fallback, and schema migrations.
简介
RitsuLib 提供了一套结构化的 Mod 数据持久化层,支持作用域存储、档位切换、备份回退以及 schema 迁移。
Main APIs
| API | Purpose |
|---|---|
RitsuLibFramework.BeginModDataRegistration(modId) | Batch registration scope |
RitsuLibFramework.GetDataStore(modId) | Access the mod’s ModDataStore |
ModDataStore.Register<T>(...) | Register one persistent entry |
ModDataStore.Get<T>(key) | Read data |
ModDataStore.Modify<T>(key, ...) | Mutate data |
ModDataStore.Save(key) / SaveAll() | Persist changes |
主要 API
| API | 作用 |
|---|---|
RitsuLibFramework.BeginModDataRegistration(modId) | 批量注册作用域 |
RitsuLibFramework.GetDataStore(modId) | 获取该 Mod 的 ModDataStore |
ModDataStore.Register<T>(...) | 注册一个持久化条目 |
ModDataStore.Get<T>(key) | 读取数据 |
ModDataStore.Modify<T>(key, ...) | 修改数据 |
ModDataStore.Save(key) / SaveAll() | 持久化写盘 |
Why Data Is Registered As Classes
Persistent entries are registered as class types with a parameterless constructor.
This allows the framework to support:
- structured JSON payloads
- future schema expansion
- versioned migration
- safer defaults and cloning
So instead of registering a raw integer, define a small data object:
public sealed class CounterData
{
public int Value { get; set; }
}为什么数据以 class 形式注册
RitsuLib 的持久化条目要求是带无参构造的类。
这么做是为了自然支持:
- 结构化 JSON
- 后续字段扩展
- schema 迁移
- 更安全的默认值克隆
所以不要注册一个裸 int,而是定义一个小数据对象:
public sealed class CounterData
{
public int Value { get; set; }
}Registering Data
using STS2RitsuLib.Data;
using STS2RitsuLib.Utils.Persistence;
using (RitsuLibFramework.BeginModDataRegistration("MyMod"))
{
var store = RitsuLibFramework.GetDataStore("MyMod");
store.Register<CounterData>(
key: "counter",
fileName: "counter.json",
scope: SaveScope.Profile,
defaultFactory: () => new CounterData(),
autoCreateIfMissing: true);
}Parameters worth understanding:
key: lookup key inside the storefileName: file name written under the resolved mod-data pathscope:GlobalorProfiledefaultFactory: default value when no file exists or recovery is neededautoCreateIfMissing: immediately write the default file when missing
注册数据
using STS2RitsuLib.Data;
using STS2RitsuLib.Utils.Persistence;
using (RitsuLibFramework.BeginModDataRegistration("MyMod"))
{
var store = RitsuLibFramework.GetDataStore("MyMod");
store.Register<CounterData>(
key: "counter",
fileName: "counter.json",
scope: SaveScope.Profile,
defaultFactory: () => new CounterData(),
autoCreateIfMissing: true);
}这些参数的含义需要特别注意:
key:在 store 内部查找该条目的键fileName:写入磁盘时使用的文件名scope:Global或ProfiledefaultFactory:没有文件或需要恢复时使用的默认值autoCreateIfMissing:文件不存在时是否立即写出默认文件
Global vs Profile Scope
SaveScope has two values:
Global: shared across all profilesProfile: isolated per game profile
Design intent:
- use
Globalfor mod settings or machine-wide caches - use
Profilefor unlocks, progression, and run-adjacent player data
Profile-scoped entries are initialized only after profile services are ready.
Global 与 Profile 作用域
SaveScope 只有两个值:
Global:所有档位共享Profile:按游戏档位隔离
设计建议:
- Mod 设置、机器级缓存适合
Global - 解锁、进度、玩家档位相关数据适合
Profile
Profile 作用域的数据只会在档位服务准备好之后初始化。
Reading And Writing
var store = RitsuLibFramework.GetDataStore("MyMod");
var counter = store.Get<CounterData>("counter");
store.Modify<CounterData>("counter", data =>
{
data.Value += 1;
});
store.Save("counter");Notes:
Get<T>returns the live registered objectModify<T>is just a convenience wrapper around that live object- saving is explicit unless you choose to save immediately after mutation
读取与写入
var store = RitsuLibFramework.GetDataStore("MyMod");
var counter = store.Get<CounterData>("counter");
store.Modify<CounterData>("counter", data =>
{
data.Value += 1;
});
store.Save("counter");几点说明:
Get<T>返回的是当前注册条目的活动对象Modify<T>本质上只是对这个活动对象做一次包装- 保存默认是显式的,是否每次改完立刻写盘由作者自己决定
Registration Timing
BeginModDataRegistration is the recommended registration pattern because it lets the store defer initialization until the batch is complete.
That helps avoid partial setup states when a mod registers several entries in one place.
At the end of the registration scope:
- global entries initialize immediately
- profile entries initialize when profile services are available
注册时机
推荐始终通过 BeginModDataRegistration 批量注册。
这样做的好处是,数据存储器可以在整个批次结束后再统一初始化,避免半注册状态。
作用域结束时:
- 全局条目会立即初始化
- 档位条目会在档位服务可用时初始化
Profile Changes
Profile-scoped entries are aware of profile switching.
When the active profile changes, RitsuLib:
- saves the old profile-scoped data to the old profile path
- reloads the data from the new profile path
This is handled by the framework; mods do not need to manually rebind their profile-scoped stores.
档位切换
档位作用域的数据会自动感知档位切换。
当当前档位改变时,RitsuLib 会:
- 先把旧档位数据保存回旧档位路径
- 再从新档位路径重新加载
这部分由框架接管,Mod 不需要手写档位切换时的重绑定逻辑。
Existing Data Checks
if (store.HasExistingData("counter"))
{
// There was already persisted data on disk
}This is useful when you want different startup behavior for first-time initialization vs loading an existing save.
判断是否已有存档数据
if (store.HasExistingData("counter"))
{
// 磁盘上已经存在旧数据
}这个判断常用于区分“首次初始化”和“读取旧存档”两种启动路径。
Recovery And Backup Behavior
The persistence layer tries to be defensive:
- if the main file cannot be read, it attempts backup fallback
- if migrated backup data loads successfully, it can be written back
- if migration or parsing fails badly enough, corrupt data can be renamed with a
.corruptsuffix - when recovery fails, the entry falls back to default values
This is meant to keep the mod usable even when local data is damaged.
备份与恢复行为
持久化层会尽量采用保守策略:
- 主文件读取失败时尝试备份回退
- 如果从备份成功恢复并完成迁移,可以写回主文件
- 当迁移或解析严重失败时,损坏文件可能被重命名为
.corrupt - 若恢复失败,则回退为默认值
目标是:即使本地数据损坏,Mod 仍尽量保持可用。
Migrations
Register<T> accepts both migration config and migration steps:
store.Register<MyData>(
key: "settings",
fileName: "settings.json",
scope: SaveScope.Global,
defaultFactory: () => new MyData(),
migrationConfig: new ModDataMigrationConfig(currentDataVersion: 2, minimumSupportedDataVersion: 1),
migrations:
[
new SettingsV1ToV2Migration(),
]);Migration rules:
- if no config is registered, data is deserialized directly
- if config exists, the framework reads the schema version field
- migrations run in version order
- data below the minimum supported version is rejected for recovery
- successfully migrated data is saved back in the new format
Use migrations when a file format is published and later evolves.
数据迁移
Register<T> 支持同时传入迁移配置与迁移步骤:
store.Register<MyData>(
key: "settings",
fileName: "settings.json",
scope: SaveScope.Global,
defaultFactory: () => new MyData(),
migrationConfig: new ModDataMigrationConfig(currentDataVersion: 2, minimumSupportedDataVersion: 1),
migrations:
[
new SettingsV1ToV2Migration(),
]);迁移规则:
- 没有 migration config 时,直接反序列化
- 有 config 时,框架会先读取 schema version 字段
- migration 会按版本顺序执行
- 低于最小支持版本的数据会被拒绝并进入恢复路径
- 成功迁移后的数据会回写成新格式
只要文件格式已经发布并且后续会演进,就建议尽早引入迁移版本号。
AttachedState vs SavedAttachedState
AttachedState<TKey, TValue> is for runtime-only sidecar state on reference objects.
Use it when:
- the value only matters during the current process
- the key object already defines the lifetime you want
- you do not want to subclass or mutate the target type
SavedAttachedState<TKey, TValue> is the persisted counterpart for objects that already flow through SavedProperties.FromInternal(...) and SavedProperties.FillInternal(...).
Use it when:
- the key is a model object that participates in vanilla save serialization
- the attached value should survive save/load round-trips
- the value type is already supported by
SavedProperties
Supported value types are:
intboolstringModelId- enums
int[]- enum arrays
SerializableCardSerializableCard[]List<SerializableCard>
Example:
using STS2RitsuLib.Utils;
private static readonly SavedAttachedState<MyModel, int> BonusDamage =
new("bonus_damage", () => 0);
BonusDamage[model] = 4;
var bonus = BonusDamage.GetOrCreate(model);Notes:
- persisted names must be globally unique after the
"{typeof(TKey).Name}_{name}"prefix is applied SavedAttachedStateis not a generic JSON sideband channel; it is intentionally limited toSavedProperties-compatible value types- reward-specific
EncounterStatesideband serialization remains a special-case implementation, not the default persistence pattern
AttachedState 与 SavedAttachedState
AttachedState<TKey, TValue> 用于给引用类型对象挂运行时 sidecar 状态。
适合场景:
- 值只在当前进程内有效
- 希望状态生命周期跟随 key 对象
- 不想为目标类型做继承或直接改模型字段
SavedAttachedState<TKey, TValue> 是它的可持久化版本,面向已经会经过 SavedProperties.FromInternal(...) 和 SavedProperties.FillInternal(...) 的对象。
适合场景:
- key 是会参与原生存档序列化的模型对象
- 附加值需要跨 save/load 保留
- 值类型本身受
SavedProperties支持
当前支持的值类型:
intboolstringModelId- enum
int[]- enum 数组
SerializableCardSerializableCard[]List<SerializableCard>
示例:
using STS2RitsuLib.Utils;
private static readonly SavedAttachedState<MyModel, int> BonusDamage =
new("bonus_damage", () => 0);
BonusDamage[model] = 4;
var bonus = BonusDamage.GetOrCreate(model);说明:
- 持久化字段名在套用
"{typeof(TKey).Name}_{name}"前缀后必须全局唯一 SavedAttachedState不是任意 JSON sideband 通道,而是刻意限制在SavedProperties可表示的值类型范围内- reward 专用的
EncounterStatesideband 序列化依然只是特例,不是默认推荐模式
Recommended Usage Pattern
- define one data class per persisted concept
- use
AttachedStatefor ephemeral runtime-only object state - use
SavedAttachedStateonly for model objects that already participate inSavedProperties - keep file names stable after release
- use
Profilescope by default for progression-like data - batch registration inside
BeginModDataRegistration - add schema versions before you need them, not after a breaking change has already shipped
推荐实践
- 每个持久化概念定义一个独立 class
- 纯运行时对象状态优先使用
AttachedState - 只有模型对象本来就参与
SavedProperties时才使用SavedAttachedState - 发布后尽量保持
fileName稳定 - 进度类数据默认优先考虑
Profile - 始终在
BeginModDataRegistration中批量注册 - schema version 最好在真正需要迁移前就准备好