【UE源码精读-ActionRPG】存档系统03:版本兼容与GVAS解析

[TOC]

导读

前两篇把存档的"写"和"读"都走通了。这一篇收尾,讲两件让存档系统真正经得起时间考验的事:

  1. 版本兼容:游戏会更新,存档结构会变。老玩家的旧存档怎么在新版本里继续读?URPGSaveGame::Serialize 那段 fixup 就是答案。
  2. 打开存档看一眼.sav 文件到底长什么样?它不是黑盒——是 UE 标准的 GVAS 格式。我们用真实字节把它拆开,再给出从游戏内命令到在线网站的全套查看工具。

读完你应该能回答:

  • 游戏加了新字段、删了老字段,旧存档为什么还能读?
  • ERPGSaveGameVersion 那个 LatestVersion = VersionPlusOne - 1 的小技巧是干嘛的?
  • .sav 文件开头那串 GVAS 是什么?里面都存了些什么?
  • 不进游戏,怎么把磁盘上的存档解出来看?

阅读前提:读过存档 01、02,知道 URPGSaveGame 的三个 UPROPERTY 字段和它的读写流程。

源码范围RPGSaveGame.hERPGSaveGameVersion)、RPGSaveGame.cppSerialize)。引擎版本 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() 守卫:迁移只在的时候做,写的时候不做——写出去的永远是最新结构。
  • 淘汰字段标 _DEPRECATEDInventoryItems_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 53GVAS认格式
0x04SaveGameFileVersion02 00 00 002引擎级存档格式版本
0x08PackageFileUE4Version0A 02 00 00522UE4 包序列化版本
0x0CEngineVersion04 00 1B 00 02 004.27.2哪个引擎存的
0x12Changelist18 8A 17 0118319896引擎 CL
0x16Branch长度 19 + 字节++UE4+Release-4.27引擎分支
0x31CustomVersion 数量39 00 00 0057各子系统序列化版本表
SaveGameClassNameFString/Script/ActionRPG.RPGSaveGame读档时实例化哪个类

几个看点:

  • EngineVersion 是铁证:光看头部就知道这是 4.27.2 存的档。跨大版本读档出问题,引擎就靠它判断。
  • SaveGameClassName = /Script/ActionRPG.RPGSaveGame:读档时引擎先读到这个名字,反射创建出 URPGSaveGame 实例,再往里填属性——正是存档 02 讲的"读档"在存档对象这一层的体现。

Header 之后是 Body,存的就是那三个 UPROPERTY。关键在于引擎用的归档器把 FName 以字符串形式写盘,所以你哪怕用记事本打开 .sav,也能在乱码里直接看到 InventoryDataSlottedItems 这些字段名。正因为类型信息和字段名都随数据落盘,通用工具才能在不认识 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(槽位→货号)、UserIdSavedDataVersion,结构和 C++ 声明一一对应。

② 库 / 二次开发:CUE4Parse(C#,能力最全):https://github.com/FabianFG/CUE4Parse,适合把解析能力集成进自己的工具。

③ 在线网站(零安装,拖进浏览器就看)

三路互验:同一份 SaveGame.savDumpSaveGame(内存态)、uesave JSON、在线网站三者结果应当完全一致;JSON 里的 SavedDataVersion 应等于源码 ERPGSaveGameVersion::LatestVersion(当前为 AddedItemData = 2)。对上了,说明你既懂"内存态"也懂"磁盘态"。

边界与坑:通用 / 在线工具只吃"标准、未加密"的 GVAS。很多商业游戏会在 GVAS 外再套压缩或加密容器,那种存档网页只能只读、甚至打不开。ActionRPG 没做任何额外封装,所以三路结果必然一致,是最干净的学习样本。安全提醒:别把真实存档随手传陌生网站,优先选本地处理(WASM / CLI)的工具。


五、动手与验收

动手任务

  1. 实现 DumpSaveGame 控制台命令,PIE 中打印存档内容。
  2. 用 uesave-rs(或 saveeditor.top)把 Saved/SaveGames/SaveGame.sav 转成 JSON,和 DumpSaveGame 的输出逐项对照。
  3. 用十六进制工具打开 .sav,亲手认出开头的 GVAS 魔数和 4.27.2 引擎版本。
  4. 阅读 ERPGSaveGameVersion,在 VersionPlusOne 前插一行假想的新版本,确认 LatestVersion 自动 +1。

验收清单

  • 能解释"读时升级"策略:旧存档怎么在 Serialize 里被迁移到最新结构。
  • 能说清 LatestVersion = VersionPlusOne - 1_DEPRECATED 字段各自的作用。
  • 能认出 .sav 是 GVAS 格式,并解释"为什么通用工具不需要懂本项目就能解析"。
  • 能区分 GVAS 文件版本(引擎级)和 SavedDataVersion(游戏级)两个版本号。
  • 能用至少一种工具把 .sav 转成 JSON,并和 DumpSaveGame 的输出对上。
内容概要:本文围绕可变桨叶四旋翼无人机的规范控制点对点运动模拟展开,重点研究优化推力分配策略在翻转动作中的应用性能比较。通过Matlab代码实现,构建了四旋翼动力学模型,并设计了多种控制算法以实现精确的姿态调整轨迹跟踪。研究对比了不同推力分配方案在执行高机动性翻转动作时的稳定性、能耗效率响应速度,旨在提升无人机在复杂飞行任务中的动态性能控制精度。该仿真研究为无人机飞控系统的设计优化提供了理论依据和技术支持。; 适合人群:具备一定自动控制理论基础和Matlab编程能力,从事无人机控制、飞行器动力学或机器人系统研究的科研人员及研究生。; 使用场景及目标:① 实现四旋翼无人机在三维空间中的精确点对点运动控制;② 对比分析不同推力分配策略在执行翻转等高难度动作时的控制效果能耗表现,优化飞行性能;③ 为无人机自主飞行、特技飞行及复杂环境下的机动控制提供算法验证平台。; 阅读建议:此资源以Matlab仿真为核心,建议读者结合相关控制理论知识,深入理解代码实现细节,重点关注动力学建模、控制律设计推力分配模块。在学习过程中,应动手调试参数,复现文中翻转动作的仿真结果,并尝试拓展至其他复杂飞行任务,以加深对无人机控制机理的理解。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夜猫逐梦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值