[TOC]
导读
前两篇把存档的"写"和"读"都走通了。这一篇收尾,讲两件让存档系统真正经得起时间考验的事:
- 版本兼容:游戏会更新,存档结构会变。老玩家的旧存档怎么在新版本里继续读?
URPGSaveGame::Serialize那段 fixup 就是答案。 - 打开存档看一眼:
.sav文件到底长什么样?它不是黑盒——是 UE 标准的 GVAS 格式。我们用真实字节把它拆开,再给出从游戏内命令到在线网站的全套查看工具。
读完你应该能回答:
- 游戏加了新字段、删了老字段,旧存档为什么还能读?
ERPGSaveGameVersion那个LatestVersion = VersionPlusOne - 1的小技巧是干嘛的?.sav文件开头那串GVAS是什么?里面都存了些什么?- 不进游戏,怎么把磁盘上的存档解出来看?
阅读前提:读过存档 01、02,知道
URPGSaveGame的三个UPROPERTY字段和它的读写流程。源码范围:
RPGSaveGame.h(ERPGSaveGameVersion)、RPGSaveGame.cpp(Serialize)。引擎版本 4.27.2。
一、版本兼容:游戏会更新,存档会变老
设想这个场景:1.0 版本的存档里,物品只存了一个货号列表;1.1 版本你想给每个物品加上"数量 + 等级"。结构变了——那些 1.0 的旧存档,新代码还读得动吗?
ActionRPG 用一个版本号驱动迁移。先看版本枚举(RPGSaveGame.h:10-26):
namespace ERPGSaveGameVersion
{
enum type
{
Initial, // 最初版本
AddedInventory, // 加了背包
AddedItemData, // 加了 ItemData(数量/等级)
// -----<新版本必须加在这一行之前>-----
VersionPlusOne,
LatestVersion = VersionPlusOne - 1 // 自动取"最新真实版本"
};
}
这里有个值得抄走的小技巧:LatestVersion = VersionPlusOne - 1。你加新版本时,只要在 VersionPlusOne 前面插一行(比如 AddedSkills,),VersionPlusOne 自动后移,LatestVersion 也自动跟着 +1,不用手动改任何数字。一个枚举值帮你维护"当前最新版本号"。
Serialize 里的 fixup
每个 URPGSaveGame 实例都存了一个 SavedDataVersion,记录"这份存档是用哪个版本写出来的"。读档时 Serialize 比对版本、按需迁移(RPGSaveGame.cpp:6-25):
void URPGSaveGame::Serialize(FArchive& Ar)
{
Super::Serialize(Ar); // 先走引擎默认序列化(读/写所有 UPROPERTY)
// 只在"读档"且"版本不是最新"时才做迁移
if (Ar.IsLoading() && SavedDataVersion != ERPGSaveGameVersion::LatestVersion)
{
if (SavedDataVersion < ERPGSaveGameVersion::AddedItemData)
{
// 旧存档只有一个货号列表,没有数量/等级
// → 把列表里每个货号转成 {count:1, level:1} 填进新的 InventoryData
for (const FPrimaryAssetId& ItemId : InventoryItems_DEPRECATED)
{
InventoryData.Add(ItemId, FRPGItemData(1, 1));
}
InventoryItems_DEPRECATED.Empty();
}
SavedDataVersion = ERPGSaveGameVersion::LatestVersion; // 标记为已升到最新
}
}

三个要点:
Super::Serialize先跑:引擎先把所有UPROPERTY读进来(包括那个被淘汰的旧字段)。fixup 是在数据已经在内存里之后,再做"形状转换"。Ar.IsLoading()守卫:迁移只在读的时候做,写的时候不做——写出去的永远是最新结构。- 淘汰字段标
_DEPRECATED:InventoryItems_DEPRECATED这个老字段是"读得进、不再写出"。它还带UPROPERTY(所以旧存档能反序列化进来),但新代码只在 fixup 里读它一次、转换完就Empty(),下次写盘就不会再有它。
这是一种"读时升级(upgrade-on-load)"策略:存档在磁盘上可以是任意旧版本,一旦被读进内存就地升级到最新结构,再次写盘时就是新结构了。玩家无感,老存档自动续命。
二、动手:给自己做个存档查看器 DumpSaveGame
理解版本兼容后,最好的验证方式是把存档内容打出来看。仿照项目里已有的 TestAssetManager,加一个控制台命令。
头文件声明:
UFUNCTION(Exec)
void DumpSaveGame();
实现:
void ARPGPlayerControllerBase::DumpSaveGame()
{
URPGGameInstanceBase* GI = GetWorld()->GetGameInstance<URPGGameInstanceBase>();
URPGSaveGame* Save = GI ? GI->GetCurrentSaveGame() : nullptr;
if (!Save) { UE_LOG(LogActionRPG, Warning, TEXT("No save game!")); return; }
UE_LOG(LogActionRPG, Display, TEXT("=== SaveGame Dump ==="));
UE_LOG(LogActionRPG, Display, TEXT("InventoryData: %d items"), Save->InventoryData.Num());
for (auto& Pair : Save->InventoryData)
{
UE_LOG(LogActionRPG, Display, TEXT(" %s Count=%d Level=%d"),
*Pair.Key.ToString(), Pair.Value.ItemCount, Pair.Value.ItemLevel);
}
UE_LOG(LogActionRPG, Display, TEXT("SlottedItems: %d slots"), Save->SlottedItems.Num());
for (auto& Pair : Save->SlottedItems)
{
UE_LOG(LogActionRPG, Display, TEXT(" [%s #%d] = %s"),
*Pair.Key.ItemType.ToString(), Pair.Key.SlotNumber, *Pair.Value.ToString());
}
}
PIE 中按 ~ 打开控制台输入 DumpSaveGame,就能看到内存里 CurrentSaveGame 的全部货号、数量、等级、槽位。这是"游戏内、内存态"视角。下面补上"游戏外、磁盘态"视角。
三、.sav 是什么:GVAS 格式拆解
UGameplayStatics::SaveGameToSlot 默认把存档写到 Saved/SaveGames/<槽位名>.sav,本项目就是 Saved/SaveGames/SaveGame.sav。用十六进制打开,开头 4 个字节是:
00000000: 4756 4153 ... GVAS
GVAS = GameSAVE,是 UE4/UE5 所有 USaveGame 落盘的统一文件格式标签。
为什么通用工具能读它? 因为 URPGSaveGame::Serialize 第一行就是 Super::Serialize(Ar),自定义代码只在后面追加版本 fixup,没有改变落盘格式。所以文件就是标准 GVAS,凡是认 GVAS 的工具都能解析,无需任何项目专属知识。
GVAS 文件 = Header(元数据) + Body(属性数据)。把本项目 SaveGame.sav 的真实头部字节解出来:
| 偏移 | 字段 | 原始字节 | 解出来的值 | 作用 |
|---|---|---|---|---|
| 0x00 | 魔数 | 47 56 41 53 | GVAS | 认格式 |
| 0x04 | SaveGameFileVersion | 02 00 00 00 | 2 | 引擎级存档格式版本 |
| 0x08 | PackageFileUE4Version | 0A 02 00 00 | 522 | UE4 包序列化版本 |
| 0x0C | EngineVersion | 04 00 1B 00 02 00 | 4.27.2 | 哪个引擎存的 |
| 0x12 | Changelist | 18 8A 17 01 | 18319896 | 引擎 CL |
| 0x16 | Branch | 长度 19 + 字节 | ++UE4+Release-4.27 | 引擎分支 |
| 0x31 | CustomVersion 数量 | 39 00 00 00 | 57 | 各子系统序列化版本表 |
| … | SaveGameClassName | FString | /Script/ActionRPG.RPGSaveGame | 读档时实例化哪个类 |
几个看点:
- EngineVersion 是铁证:光看头部就知道这是 4.27.2 存的档。跨大版本读档出问题,引擎就靠它判断。
- SaveGameClassName =
/Script/ActionRPG.RPGSaveGame:读档时引擎先读到这个名字,反射创建出URPGSaveGame实例,再往里填属性——正是存档 02 讲的"读档"在存档对象这一层的体现。
Header 之后是 Body,存的就是那三个 UPROPERTY。关键在于引擎用的归档器把 FName 以字符串形式写盘,所以你哪怕用记事本打开 .sav,也能在乱码里直接看到 InventoryData、SlottedItems 这些字段名。正因为类型信息和字段名都随数据落盘,通用工具才能在不认识 URPGSaveGame 的情况下把整棵数据树还原成 JSON。
别把两个"版本号"搞混
读档时有两个版本号在起作用,职责完全不同——这也是最容易绕晕的地方:
| GVAS 文件版本 | ActionRPG 数据版本 | |
|---|---|---|
| 字段 | Header 里的 SaveGameFileVersion(=2) | Body 里的 SavedDataVersion(=AddedItemData=2) |
| 谁定义 | 引擎(FSaveGameFileVersion) | 本游戏(ERPGSaveGameVersion) |
| 管什么 | 存档文件格式怎么排版 | 业务数据结构怎么迁移 |
| 谁处理 | 引擎 SaveGame 系统 | 第一节那段 Serialize fixup |
SavedDataVersion 只是 Body 里一个普通的 int32 属性,和 UserId 平级。引擎管"文件格式"版本,游戏管"业务数据"版本,两套并行。
四、解析工具:从命令行到在线网站
不进游戏也能把 .sav 解开看,由重到轻:
| 场景 | 工具 | 安装成本 | 能改回写 |
|---|---|---|---|
| 已在跑游戏、随手看 | DumpSaveGame(第二节) | 写 C++ + 编译 | 否 |
| 脚本化 / 改存档 | uesave-rs | 装 Rust | ✅ |
| 二次开发 / 集成 | CUE4Parse | 写 C# | ✅ |
| 只想点开看一眼 | 在线网站 | 零安装 | ✅ |
① 命令行:uesave-rs(Rust,GVAS ↔ JSON 双向,可改回写)
cargo install --git https://github.com/trumank/uesave-rs
uesave to-json -i "Saved/SaveGames/SaveGame.sav" -o SaveGame.json
# 具体子命令/参数以 `uesave --help` 为准
解出的 JSON 里能直接看到 InventoryData(货号→{ItemCount,ItemLevel})、SlottedItems(槽位→货号)、UserId、SavedDataVersion,结构和 C++ 声明一一对应。
② 库 / 二次开发:CUE4Parse(C#,能力最全):https://github.com/FabianFG/CUE4Parse,适合把解析能力集成进自己的工具。
③ 在线网站(零安装,拖进浏览器就看)
- SaveEditor.Top — Unreal 编辑器:https://saveeditor.top/editor/unreal/
- 开源项目
paradoxie/saveeditor的官方站,全程 WebAssembly 客户端本地处理,存档不上传——拿真存档来说这点很重要。 - 明确支持 UE4.x ~ UE5.x 标准 GVAS,可解析、改值、导出。

- 开源项目
- SaveEditOnline:https://www.saveeditonline.com/ —— 同类在线编辑器,自动识别格式,适合交叉验证。
三路互验:同一份 SaveGame.sav,DumpSaveGame(内存态)、uesave JSON、在线网站三者结果应当完全一致;JSON 里的 SavedDataVersion 应等于源码 ERPGSaveGameVersion::LatestVersion(当前为 AddedItemData = 2)。对上了,说明你既懂"内存态"也懂"磁盘态"。
边界与坑:通用 / 在线工具只吃"标准、未加密"的 GVAS。很多商业游戏会在 GVAS 外再套压缩或加密容器,那种存档网页只能只读、甚至打不开。ActionRPG 没做任何额外封装,所以三路结果必然一致,是最干净的学习样本。安全提醒:别把真实存档随手传陌生网站,优先选本地处理(WASM / CLI)的工具。
五、动手与验收
动手任务
- 实现
DumpSaveGame控制台命令,PIE 中打印存档内容。 - 用 uesave-rs(或 saveeditor.top)把
Saved/SaveGames/SaveGame.sav转成 JSON,和DumpSaveGame的输出逐项对照。 - 用十六进制工具打开
.sav,亲手认出开头的GVAS魔数和4.27.2引擎版本。 - 阅读
ERPGSaveGameVersion,在VersionPlusOne前插一行假想的新版本,确认LatestVersion自动 +1。
验收清单
- 能解释"读时升级"策略:旧存档怎么在
Serialize里被迁移到最新结构。 - 能说清
LatestVersion = VersionPlusOne - 1和_DEPRECATED字段各自的作用。 - 能认出
.sav是 GVAS 格式,并解释"为什么通用工具不需要懂本项目就能解析"。 - 能区分 GVAS 文件版本(引擎级)和
SavedDataVersion(游戏级)两个版本号。 - 能用至少一种工具把
.sav转成 JSON,并和DumpSaveGame的输出对上。


被折叠的 条评论
为什么被折叠?



