存档 JSON 往返编辑工具:把 .sav 拆开改再装回去
存档 JSON 往返编辑工具:把 .sav 拆开改再装回去
做存档调试时经常要改档:改角色数值、改背包物品、换装备武器。但 .sav 是不透明的二进制,没法直接看、直接改。这篇记录一个把 .sav 导出成可读 JSON、手工编辑后再导回 .sav 的工具,以及做无损往返时踩到的几个坑——它们都和 UE 的 SaveGame 序列化机制细节相关。
整体只有两条控制台命令,在编辑器或 PIE 里输入:
SaveSystem.ExportSlotToJson <完整.sav路径>
SaveSystem.ImportJsonToSlot <完整.json路径>
输出文件写在同目录、同文件名、仅扩展名互换,会覆盖已有文件——导入前自己先备份原 .sav。
无损原理:DataRaw 兜底 + Properties 叠加
存档里每条 record 的数据是一段不透明二进制 blob,由存档专用的 archive(ArIsSaveGame=true)写入。它既写反射属性,也写对象自定义 Serialize() 里手写的字节。要保证往返无损,光靠"反射属性转 JSON 再转回来"不够——手写字节会丢。
所以导出时每条 record 额外写一个 DataRaw 字段,存原始 blob 的 Base64;Properties 字段则是该对象反射属性的可读视图。导入时分四步:
- 先用
DataRaw把对象完整还原(含自定义 Serialize 数据); - 再把(可能被你编辑过的)
Properties叠加到反射字段上; - 重新序列化,生成新 blob;
- 若新 blob 与
DataRaw逐字节相同(说明你没改动影响这个对象),直接透传原始 blob——零漂移。
结论:你没编辑的 record 100% 无损;你编辑反射字段的改动会生效。
坑一:CheckFlags 要传 0,不是 CPF_SaveGame
导出/导入用 FJsonObjectConverter::UStructToJsonObject / JsonObjectToUStruct 展开、写回反射属性时,CheckFlags 必须传 0(导出全部反射 UPROPERTY),不能传 CPF_SaveGame。这一条是我最初"改了武器却不生效"的根因。
原因在存档 archive 的行为:它确实只写带 CPF_SaveGame 标记的属性,但一旦进入一个 SaveGame 的 struct 或数组,它会整体序列化该元素的全部反射属性(tagged-property 格式),不再逐个字段过滤 CPF_SaveGame。也就是说,很多"可编辑、且确实存进了存档"的数据,其实挂在 SaveGame 容器内部的普通(非 SaveGame)UPROPERTY 上。
如果导出按 CPF_SaveGame 过滤,这些嵌套字段会被导成空 {},在 JSON 里根本看不见、改不了。武器数据正是这样藏在嵌套里的。
坑二:创建透明对象不能带 RF_ArchetypeObject,但也不能盲目回退
导出/导入时要创建一个临时对象,用存档 archive 从 blob 还原它、再读写属性。这个对象必须用普通的 NewObject<UObject>(GetTransientPackage(), Class),不能带 RF_ArchetypeObject。
因为 RF_ArchetypeObject 会让 UE 把该对象当成"模板/原型",导致 FFastArraySerializer 派生类型、以及嵌套 struct 数组属性反序列化失败——数组长度能读出来,但每个元素是默认构造的空值。库存数据(元素含继承自 FFastArraySerializer 的 tag 栈容器)正是这种结构,一旦带这个标志,武器/装备数据全读成空。
但不能简单地"先试普通、失败再回退带标志"。普通 NewObject 遇到 UCLASS(Within=...) 类型(比如各种 Within=GameInstance 的 Subsystem)在 transient package 下会直接 fatal crash(StaticAllocateObjectErrorTests),不是返回 null——回退分支根本走不到。
所以要按 ClassWithin 预判后再决定:
- 无 Within 约束(含库存的 SaveData、Actor / Component、AttributeSet)→ 普通
NewObject,FastArray / 嵌套数组能正确反序列化,武器数据读得出来。 - 有 Within 约束(Subsystem 等)→ 用
RF_ArchetypeObject避免崩溃;这些类型本来就不含库存 FastArray 数据,这个标志的副作用无所谓。
数据在哪:以"改武器/装备"为例
玩家武器/装备数据的存储位置不直观,找错地方改了也没用:
- 不是
GlobalActorRecords[...].Components[Inventory]里的allItemDefsIncludingRemoved——那只是"图鉴/已获得物品定义清单",改它不影响手里的武器。 - 正确位置:
SlotData.CustomSaveGameRecords里IdentifyName == "InventoryManagerComponent"那条(库存的 SaveData 类)的Properties.saveInfos数组。
每个 saveInfos[i] 的关键字段:
| 字段 | 含义 |
|---|---|
itemDef | 物品定义类(换武器改这里) |
stackCount | 堆叠数量 |
statTags | 该物品实例的 tag 栈;装备状态存在这里 |
装备槽由 statTags 里 Inventory.Item.Stat.EquipSlotIndex 这个 tag 的 count 表示:存的 count = 实际槽位 + 1;没有该 tag,或 count 为 -1,表示未装备。
端到端验证
- 编译相关模块(Development Editor)。
- 取一个真实
.sav,ExportSlotToJson得到.json。 - 打开 JSON,确认
CustomSaveGameRecords中InventoryManagerComponent那条的saveInfos[i]不再是空{},能看到itemDef/stackCount/statTags。 - 编辑目标字段(换
itemDef,或改EquipSlotIndex)。 ImportJsonToSlot生成.sav(先备份原文件)。- 游戏内加载该存档,确认不崩溃且改动生效。
不进引擎也能快速核对 JSON 里数据是否落对了地方,用一小段 Python:
import json, base64
d = json.load(open('MySlot.json', encoding='utf-8-sig'))
inv = next(r for r in d['SlotData']['CustomSaveGameRecords']
if r['IdentifyName'] == 'InventoryManagerComponent')
print(len(inv['Properties']['saveInfos'])) # 物品条数
raw = base64.b64decode(inv['DataRaw'])
print(raw.count(b'EquipSlotIndex'), raw.count(b'ItemDef')) # blob 内标记计数
已知局限
Properties是反射属性的视图;纯手写自定义Serialize()(不经反射)写入的字节,在Properties里看不到,只能靠DataRaw原样透传,不可编辑。目前遇到的武器/装备数据都在反射属性范围内,可编辑。- 导入默认写非压缩
.sav(读侧两种格式都能吃,且便于 diff)。 - 编辑深层嵌套、依赖顺序的结构时,改完先重新 export 一次做 diff 自查。