ue-serialization-savegames

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

UE Serialization & Save Games

UE 序列化与存档游戏

You are an expert in Unreal Engine's serialization and save game systems. You implement save/load pipelines using
USaveGame
,
FArchive
, config files, and versioning so player progress persists correctly across sessions and game updates.

你是Unreal Engine序列化和存档游戏系统的专家。你可以使用
USaveGame
FArchive
、配置文件和版本控制实现保存/加载流程,确保玩家进度在游戏会话和版本更新间正确持久化。

Step 1: Read Project Context

步骤1:读取项目上下文

Read
.agents/ue-project-context.md
before giving any recommendations. You need:
  • Engine version (UE 5.0+ has
    ULocalPlayerSaveGame
    ; earlier versions differ)
  • Module names (the save system lives in a specific module)
  • Target platforms (console vs. PC save paths and user indices differ)
  • Whether multiplayer is in scope (server-authoritative vs. client-local saves)
If the file does not exist, ask the user to run
/ue-project-context
first.

在给出任何建议前,请先阅读
.agents/ue-project-context.md
文件。你需要获取以下信息:
  • 引擎版本(UE 5.0+包含
    ULocalPlayerSaveGame
    ;早期版本有所不同)
  • 模块名称(存档系统属于特定模块)
  • 目标平台(主机与PC的存档路径和用户索引存在差异)
  • 是否涉及多人游戏(服务器权威存档 vs 客户端本地存档)
如果该文件不存在,请要求用户先运行
/ue-project-context
命令。

Step 2: Gather Requirements

步骤2:收集需求

Ask before writing code:
  1. Save complexity: Simple key/value data, or complex world state with hundreds of objects?
  2. Data types: Primitives, nested structs, asset references (soft vs. hard)?
  3. Versioning needs: Live game with future patches? Old saves must keep working?
  4. Multiple save slots: How many? Does each player/user get their own?
  5. Async requirement: Can save/load stall the game thread, or must it be background?

在编写代码前,请询问以下问题:
  1. 存档复杂度:是简单的键值对数据,还是包含数百个对象的复杂世界状态?
  2. 数据类型:基本类型、嵌套结构体、资源引用(软引用 vs 硬引用)?
  3. 版本控制需求:游戏已上线且未来会发布补丁?旧存档必须兼容新版本?
  4. 多存档槽位:需要多少个槽位?是否为每个玩家/用户分配独立槽位?
  5. 异步需求:保存/加载操作是否会阻塞游戏线程,还是必须在后台执行?

Step 3: USaveGame Subclass

步骤3:USaveGame子类

USaveGame
is an abstract
UObject
from
GameFramework/SaveGame.h
. Subclass it and mark fields with
UPROPERTY(SaveGame)
for automatic tagged serialization by
UGameplayStatics
.
cpp
// MyGameSaveGame.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MyGameSaveGame.generated.h"

USTRUCT(BlueprintType)
struct FInventoryItemData
{
    GENERATED_BODY() // Required — missing GENERATED_BODY() breaks struct serialization silently

    UPROPERTY(SaveGame) FName  ItemID;
    UPROPERTY(SaveGame) int32  Quantity = 0;
    UPROPERTY(SaveGame) bool   bIsEquipped = false;
};

UCLASS(BlueprintType)
class MYGAME_API UMyGameSaveGame : public USaveGame
{
    GENERATED_BODY()
public:
    UPROPERTY(SaveGame) int32   SaveVersion = 0;      // Always include a version field
    UPROPERTY(SaveGame) float   PlayerHealth = 100.f;
    UPROPERTY(SaveGame) int32   PlayerLevel = 1;
    UPROPERTY(SaveGame) FVector LastCheckpointLocation = FVector::ZeroVector;
    UPROPERTY(SaveGame) FString PlayerDisplayName;
    UPROPERTY(SaveGame) float   TotalPlayTimeSeconds = 0.f;
    UPROPERTY(SaveGame) TArray<FInventoryItemData>   InventoryItems;
    UPROPERTY(SaveGame) TMap<FName, int32>            AbilityLevels;
    // TSet<FName> is also supported in UPROPERTY(SaveGame) fields and serializes/deserializes automatically.

    // Asset references: FSoftObjectPath stores a string path — safe across saves
    // Never use raw UObject* or hard TObjectPtr<> to content assets in save data
    UPROPERTY(SaveGame) FSoftObjectPath LastEquippedWeaponPath;
};
USaveGame
GameFramework/SaveGame.h
中的抽象
UObject
。创建其子类并为字段标记
UPROPERTY(SaveGame)
,即可通过
UGameplayStatics
实现自动标记序列化。
cpp
// MyGameSaveGame.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MyGameSaveGame.generated.h"

USTRUCT(BlueprintType)
struct FInventoryItemData
{
    GENERATED_BODY() // 必填项 — 缺少GENERATED_BODY()会导致结构体序列化静默失败

    UPROPERTY(SaveGame) FName  ItemID;
    UPROPERTY(SaveGame) int32  Quantity = 0;
    UPROPERTY(SaveGame) bool   bIsEquipped = false;
};

UCLASS(BlueprintType)
class MYGAME_API UMyGameSaveGame : public USaveGame
{
    GENERATED_BODY()
public:
    UPROPERTY(SaveGame) int32   SaveVersion = 0;      // 始终包含版本字段
    UPROPERTY(SaveGame) float   PlayerHealth = 100.f;
    UPROPERTY(SaveGame) int32   PlayerLevel = 1;
    UPROPERTY(SaveGame) FVector LastCheckpointLocation = FVector::ZeroVector;
    UPROPERTY(SaveGame) FString PlayerDisplayName;
    UPROPERTY(SaveGame) float   TotalPlayTimeSeconds = 0.f;
    UPROPERTY(SaveGame) TArray<FInventoryItemData>   InventoryItems;
    UPROPERTY(SaveGame) TMap<FName, int32>            AbilityLevels;
    // TSet<FName>同样支持在UPROPERTY(SaveGame)字段中使用,可自动序列化/反序列化。

    // 资源引用:FSoftObjectPath存储字符串路径 — 跨存档安全
    // 切勿在存档数据中使用原始UObject*或硬TObjectPtr<>指向内容资源
    UPROPERTY(SaveGame) FSoftObjectPath LastEquippedWeaponPath;
};

Saving and Loading

保存与加载

cpp
#include "Kismet/GameplayStatics.h"

static const FString SlotName  = TEXT("MainSave");
static constexpr int32 UserIdx = 0; // Always 0 on PC; use GetPlatformUserIndex() on console

// Create the object first, populate its fields, then save
UMySaveGame* SaveGame = Cast<UMySaveGame>(UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
SaveGame->PlayerHealth = 75.f;
// Then pass SaveGame to SaveGameToSlot / AsyncSaveGameToSlot below

// Sync save (blocks game thread — avoid in gameplay)
bool bSaved = UGameplayStatics::SaveGameToSlot(SaveData, SlotName, UserIdx);

// Async save (preferred — does not block)
FAsyncSaveGameToSlotDelegate OnSaved;
OnSaved.BindUObject(this, &USaveManager::OnAsyncSaveComplete);
UGameplayStatics::AsyncSaveGameToSlot(SaveData, SlotName, UserIdx, OnSaved);

// Load
if (UGameplayStatics::DoesSaveGameExist(SlotName, UserIdx))
{
    UMyGameSaveGame* Save = Cast<UMyGameSaveGame>(
        UGameplayStatics::LoadGameFromSlot(SlotName, UserIdx));
}

// Async load
FAsyncLoadGameFromSlotDelegate OnLoaded;
OnLoaded.BindUObject(this, &USaveManager::OnAsyncLoadComplete);
UGameplayStatics::AsyncLoadGameFromSlot(SlotName, UserIdx, OnLoaded);

// Delete
UGameplayStatics::DeleteGameInSlot(SlotName, UserIdx);

cpp
#include "Kismet/GameplayStatics.h"

static const FString SlotName  = TEXT("MainSave");
static constexpr int32 UserIdx = 0; // PC平台始终为0;主机平台使用GetPlatformUserIndex()

// 先创建对象,填充字段,再执行保存
UMySaveGame* SaveGame = Cast<UMySaveGame>(UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
SaveGame->PlayerHealth = 75.f;
// 之后将SaveGame传入下方的SaveGameToSlot / AsyncSaveGameToSlot

// 同步保存(阻塞游戏线程 — 避免在游戏过程中使用)
bool bSaved = UGameplayStatics::SaveGameToSlot(SaveData, SlotName, UserIdx);

// 异步保存(推荐 — 不会阻塞线程)
FAsyncSaveGameToSlotDelegate OnSaved;
OnSaved.BindUObject(this, &USaveManager::OnAsyncSaveComplete);
UGameplayStatics::AsyncSaveGameToSlot(SaveData, SlotName, UserIdx, OnSaved);

// 加载
if (UGameplayStatics::DoesSaveGameExist(SlotName, UserIdx))
{
    UMyGameSaveGame* Save = Cast<UMyGameSaveGame>(
        UGameplayStatics::LoadGameFromSlot(SlotName, UserIdx));
}

// 异步加载
FAsyncLoadGameFromSlotDelegate OnLoaded;
OnLoaded.BindUObject(this, &USaveManager::OnAsyncLoadComplete);
UGameplayStatics::AsyncLoadGameFromSlot(SlotName, UserIdx, OnLoaded);

// 删除存档
UGameplayStatics::DeleteGameInSlot(SlotName, UserIdx);

Step 4: ULocalPlayerSaveGame (UE 5.0+)

步骤4:ULocalPlayerSaveGame(UE 5.0+)

ULocalPlayerSaveGame
ties a save to a specific local player, tracks versioning via
GetLatestDataVersion()
, and provides
HandlePostLoad()
for migrations.
cpp
UCLASS()
class MYGAME_API UMyLocalPlayerSave : public ULocalPlayerSaveGame
{
    GENERATED_BODY()
public:
    virtual int32 GetLatestDataVersion() const override { return 3; }
    virtual void  HandlePostLoad() override;

    UPROPERTY(SaveGame) TMap<FName, int32> UnlockedAbilities;
};

void UMyLocalPlayerSave::HandlePostLoad()
{
    Super::HandlePostLoad();
    const int32 Ver = GetSavedDataVersion(); // version when last saved

    if (Ver < 2) { UnlockedAbilities.Add(TEXT("Dash"), 1); }
    // Ver < 3 migrations go here
}
cpp
// Load or create (sync)
UMyLocalPlayerSave* Save = ULocalPlayerSaveGame::LoadOrCreateSaveGameForLocalPlayer(
    UMyLocalPlayerSave::StaticClass(), PlayerController, TEXT("PlayerSlot0"));

// Load or create (async)
ULocalPlayerSaveGame::AsyncLoadOrCreateSaveGameForLocalPlayer(
    UMyLocalPlayerSave::StaticClass(), PlayerController, TEXT("PlayerSlot0"),
    FOnLocalPlayerSaveGameLoadedNative::CreateUObject(this, &AMyPC::OnSaveLoaded));

// Save back
Save->AsyncSaveGameToSlotForLocalPlayer(); // async (preferred)
Save->SaveGameToSlotForLocalPlayer();      // sync

ULocalPlayerSaveGame
将存档与特定本地玩家绑定,通过
GetLatestDataVersion()
跟踪版本,并提供
HandlePostLoad()
用于数据迁移。
cpp
UCLASS()
class MYGAME_API UMyLocalPlayerSave : public ULocalPlayerSaveGame
{
    GENERATED_BODY()
public:
    virtual int32 GetLatestDataVersion() const override { return 3; }
    virtual void  HandlePostLoad() override;

    UPROPERTY(SaveGame) TMap<FName, int32> UnlockedAbilities;
};

void UMyLocalPlayerSave::HandlePostLoad()
{
    Super::HandlePostLoad();
    const int32 Ver = GetSavedDataVersion(); // 上次存档时的版本

    if (Ver < 2) { UnlockedAbilities.Add(TEXT("Dash"), 1); }
    // Ver < 3的数据迁移逻辑写在此处
}
cpp
// 加载或创建(同步)
UMyLocalPlayerSave* Save = ULocalPlayerSaveGame::LoadOrCreateSaveGameForLocalPlayer(
    UMyLocalPlayerSave::StaticClass(), PlayerController, TEXT("PlayerSlot0"));

// 加载或创建(异步)
ULocalPlayerSaveGame::AsyncLoadOrCreateSaveGameForLocalPlayer(
    UMyLocalPlayerSave::StaticClass(), PlayerController, TEXT("PlayerSlot0"),
    FOnLocalPlayerSaveGameLoadedNative::CreateUObject(this, &AMyPC::OnSaveLoaded));

// 保存存档
Save->AsyncSaveGameToSlotForLocalPlayer(); // 异步(推荐)
Save->SaveGameToSlotForLocalPlayer();      // 同步

Step 5: FArchive and Custom Serialization

步骤5:FArchive与自定义序列化

FArchive
(from
Serialization/Archive.h
) is the base for all UE serialization. Key API:
cpp
Ar.IsLoading()    // true when deserializing — same operator<< handles both directions
Ar.IsSaving()     // true when serializing to output
Ar.IsError()      // true after any read/write failure — always check before continuing
Ar.Tell()         // current position (int64); -1 if not seekable
Ar.CustomVer(Key) // returns the registered version number for a FGuid key
FArchive
(来自
Serialization/Archive.h
)是所有UE序列化的基础类。核心API如下:
cpp
Ar.IsLoading()    // 反序列化时为true — 同一个operator<<可处理序列化和反序列化
Ar.IsSaving()     // 序列化到输出时为true
Ar.IsError()      // 读写失败后为true — 后续操作前务必检查
Ar.Tell()         // 当前位置(int64);不可寻址时返回-1
Ar.CustomVer(Key) // 返回FGuid键对应的已注册版本号

FMemoryWriter and FMemoryReader

FMemoryWriter与FMemoryReader

FMemoryWriter
/
FMemoryReader
(from
Serialization/MemoryWriter.h
/
MemoryReader.h
) serialize to/from
TArray<uint8>
:
cpp
// Serialize to bytes
TArray<uint8> OutBytes;
FMemoryWriter Writer(OutBytes, /*bIsPersistent=*/true);
int32 Version = 2;
Writer << Version;          // Serialize version header first — always
Writer << SomeData;
checkf(!Writer.IsError(), TEXT("Serialization failed"));

// Deserialize from bytes
FMemoryReader Reader(OutBytes, /*bIsPersistent=*/true);
int32 LoadedVersion = 0;
Reader << LoadedVersion;
if (LoadedVersion < 1 || Reader.IsError()) { /* corrupt data */ return; }
Reader << SomeData;
FMemoryWriter
/
FMemoryReader
(来自
Serialization/MemoryWriter.h
/
MemoryReader.h
)用于在
TArray<uint8>
中进行序列化/反序列化:
cpp
// 序列化为字节数组
TArray<uint8> OutBytes;
FMemoryWriter Writer(OutBytes, /*bIsPersistent=*/true);
int32 Version = 2;
Writer << Version;          // 始终先序列化版本头
Writer << SomeData;
checkf(!Writer.IsError(), TEXT("序列化失败"));

// 从字节数组反序列化
FMemoryReader Reader(OutBytes, /*bIsPersistent=*/true);
int32 LoadedVersion = 0;
Reader << LoadedVersion;
if (LoadedVersion < 1 || Reader.IsError()) { /* 数据损坏 */ return; }
Reader << SomeData;

FBufferArchive

FBufferArchive

FBufferArchive
(from
Serialization/BufferArchive.h
) combines
FMemoryWriter
+
TArray<uint8>
— the object is the output buffer:
cpp
FBufferArchive Buffer(/*bIsPersistent=*/true);
int32 Magic = 0x53415645; // 'SAVE'
Buffer << Magic;
Buffer << MyStruct;        // requires operator<< overload
TArray<uint8> Bytes = MoveTemp(Buffer); // FBufferArchive IS a TArray<uint8>
FBufferArchive
(来自
Serialization/BufferArchive.h
)结合了
FMemoryWriter
+
TArray<uint8>
— 对象本身就是输出缓冲区:
cpp
FBufferArchive Buffer(/*bIsPersistent=*/true);
int32 Magic = 0x53415645; // 'SAVE'
Buffer << Magic;
Buffer << MyStruct;        // 需要重载operator<<
TArray<uint8> Bytes = MoveTemp(Buffer); // FBufferArchive本质是TArray<uint8>

Custom operator<< for Structs

结构体的自定义operator<<

Define
operator<<
to make a struct serializable via any
FArchive
(required when passing it to
FBufferArchive
,
FMemoryWriter
, etc.):
cpp
FArchive& operator<<(FArchive& Ar, FMyCustomData& Data)
{
    Ar << Data.Name << Data.Value << Data.Timestamp;
    return Ar;
}
定义
operator<<
可让结构体通过任意
FArchive
进行序列化(传递给
FBufferArchive
FMemoryWriter
等时必需):
cpp
FArchive& operator<<(FArchive& Ar, FMyCustomData& Data)
{
    Ar << Data.Name << Data.Value << Data.Timestamp;
    return Ar;
}

Compressed Archives

压缩存档

For large saves, use
FArchiveSaveCompressedProxy
/
FArchiveLoadCompressedProxy
(from
Serialization/ArchiveSaveCompressedProxy.h
):
cpp
// Compress
TArray<uint8> Compressed;
FArchiveSaveCompressedProxy Comp(Compressed, NAME_Zlib);
Comp.Serialize(RawData.GetData(), RawData.Num());
Comp.Flush();

// Decompress
FArchiveLoadCompressedProxy Decomp(Compressed, NAME_Zlib);
TArray<uint8> Raw;
Raw.SetNum(KnownUncompressedSize);
Decomp.Serialize(Raw.GetData(), Raw.Num());
对于大型存档,使用
FArchiveSaveCompressedProxy
/
FArchiveLoadCompressedProxy
(来自
Serialization/ArchiveSaveCompressedProxy.h
):
cpp
// 压缩
TArray<uint8> Compressed;
FArchiveSaveCompressedProxy Comp(Compressed, NAME_Zlib);
Comp.Serialize(RawData.GetData(), RawData.Num());
Comp.Flush();

// 解压
FArchiveLoadCompressedProxy Decomp(Compressed, NAME_Zlib);
TArray<uint8> Raw;
Raw.SetNum(KnownUncompressedSize);
Decomp.Serialize(Raw.GetData(), Raw.Num());

Custom Serialize() on UObject

UObject的自定义Serialize()

Override
Serialize(FArchive& Ar)
for precise binary layout control:
cpp
void UMyObject::Serialize(FArchive& Ar)
{
    Super::Serialize(Ar); // always call Super first
    Ar << BinaryField;
    Ar << UniqueRunID;
    if (Ar.IsLoading() && Ar.IsError()) { /* handle corruption */ }
}

重载
Serialize(FArchive& Ar)
可精确控制二进制布局:
cpp
void UMyObject::Serialize(FArchive& Ar)
{
    Super::Serialize(Ar); // 务必先调用父类方法
    Ar << BinaryField;
    Ar << UniqueRunID;
    if (Ar.IsLoading() && Ar.IsError()) { /* 处理数据损坏 */ }
}

Step 6: Versioning

步骤6:版本控制

Integer Versioning in USaveGame

USaveGame中的整数版本控制

cpp
namespace ESaveVersion
{
    enum Type : int32
    {
        Initial          = 0,
        AddedInventory   = 1,
        SoftRefForWeapon = 2,
        VersionPlusOne,
        Latest = VersionPlusOne - 1
    };
}

void USaveManager::RunMigrations(UMyGameSaveGame* Save)
{
    if (Save->SaveVersion == ESaveVersion::Latest) { return; }

    if (Save->SaveVersion < ESaveVersion::AddedInventory)
        Save->InventoryItems.Reset();

    if (Save->SaveVersion < ESaveVersion::SoftRefForWeapon)
    { /* convert old FName field to FSoftObjectPath */ }

    Save->SaveVersion = ESaveVersion::Latest; // stamp after migration
}
cpp
namespace ESaveVersion
{
    enum Type : int32
    {
        Initial          = 0,
        AddedInventory   = 1,
        SoftRefForWeapon = 2,
        VersionPlusOne,
        Latest = VersionPlusOne - 1
    };
}

void USaveManager::RunMigrations(UMyGameSaveGame* Save)
{
    if (Save->SaveVersion == ESaveVersion::Latest) { return; }

    if (Save->SaveVersion < ESaveVersion::AddedInventory)
        Save->InventoryItems.Reset();

    if (Save->SaveVersion < ESaveVersion::SoftRefForWeapon)
    { /* 将旧的FName字段转换为FSoftObjectPath */ }

    Save->SaveVersion = ESaveVersion::Latest; // 迁移完成后更新版本号
}

FCustomVersionRegistration (FArchive-based saves)

FCustomVersionRegistration(基于FArchive的存档)

cpp
// Declare version enum + GUID (generate once with FGuid::NewGuid(), then hardcode)
struct FMySaveVersion
{
    enum Type { Initial = 0, AddedQuestData = 1, VersionPlusOne, Latest = VersionPlusOne - 1 };
    static const FGuid GUID;
};
const FGuid FMySaveVersion::GUID(0xA1B2C3D4, 0xE5F60718, 0x293A4B5C, 0x6D7E8F90);

// Register globally (module startup or static):
FCustomVersionRegistration GReg(FMySaveVersion::GUID, FMySaveVersion::Latest, TEXT("MySave"));

// In Serialize():
Ar.UsingCustomVersion(FMySaveVersion::GUID);
const int32 Ver = Ar.CustomVer(FMySaveVersion::GUID);
Ar << CoreData;
if (Ver >= FMySaveVersion::AddedQuestData)
    Ar << QuestData;
else if (Ar.IsLoading())
    QuestData.Reset(); // Initialize missing data on old saves
cpp
// 声明版本枚举 + GUID(使用FGuid::NewGuid()生成一次后硬编码)
struct FMySaveVersion
{
    enum Type { Initial = 0, AddedQuestData = 1, VersionPlusOne, Latest = VersionPlusOne - 1 };
    static const FGuid GUID;
};
const FGuid FMySaveVersion::GUID(0xA1B2C3D4, 0xE5F60718, 0x293A4B5C, 0x6D7E8F90);

// 全局注册(模块启动时或静态注册):
FCustomVersionRegistration GReg(FMySaveVersion::GUID, FMySaveVersion::Latest, TEXT("MySave"));

// 在Serialize()中使用:
Ar.UsingCustomVersion(FMySaveVersion::GUID);
const int32 Ver = Ar.CustomVer(FMySaveVersion::GUID);
Ar << CoreData;
if (Ver >= FMySaveVersion::AddedQuestData)
    Ar << QuestData;
else if (Ar.IsLoading())
    QuestData.Reset(); // 在旧存档中初始化缺失的数据

Struct Field Migration

结构体字段迁移

When a struct field is renamed or its type changes, override
Serialize()
on the struct to migrate old data:
cpp
void FMyStruct::Serialize(FArchive& Ar)
{
    Ar.UsingCustomVersion(FMySaveVersion::GUID);
    if (Ar.CustomVer(FMySaveVersion::GUID) < FMySaveVersion::RenamedHealthToHP)
    {
        float OldHealth;
        Ar << OldHealth;
        HP = OldHealth; // Migrate old field name to new
    }
    else
    {
        Ar << HP;
    }
}

当结构体字段重命名或类型变更时,重载结构体的
Serialize()
以迁移旧数据:
cpp
void FMyStruct::Serialize(FArchive& Ar)
{
    Ar.UsingCustomVersion(FMySaveVersion::GUID);
    if (Ar.CustomVer(FMySaveVersion::GUID) < FMySaveVersion::RenamedHealthToHP)
    {
        float OldHealth;
        Ar << OldHealth;
        HP = OldHealth; // 将旧字段名的数据迁移到新字段
    }
    else
    {
        Ar << HP;
    }
}

Step 7: Config Files

步骤7:配置文件

UGameUserSettings (user preferences)

UGameUserSettings(用户偏好设置)

cpp
UCLASS()
class MYGAME_API UMyGameUserSettings : public UGameUserSettings
{
    GENERATED_BODY()
public:
    UPROPERTY(Config, BlueprintReadWrite, Category="Game")
    float MasterVolume = 1.0f;

    UPROPERTY(Config, BlueprintReadWrite, Category="Game")
    bool bSubtitlesEnabled = true;

    void ApplyAndSave() { ApplySettings(false); SaveSettings(); }
};
// Register in DefaultEngine.ini:
// [/Script/Engine.Engine]
// GameUserSettingsClassName=/Script/MyGame.MyGameUserSettings
cpp
UCLASS()
class MYGAME_API UMyGameUserSettings : public UGameUserSettings
{
    GENERATED_BODY()
public:
    UPROPERTY(Config, BlueprintReadWrite, Category="Game")
    float MasterVolume = 1.0f;

    UPROPERTY(Config, BlueprintReadWrite, Category="Game")
    bool bSubtitlesEnabled = true;

    void ApplyAndSave() { ApplySettings(false); SaveSettings(); }
};
// 在DefaultEngine.ini中注册:
// [/Script/Engine.Engine]
// GameUserSettingsClassName=/Script/MyGame.MyGameUserSettings

UDeveloperSettings (project settings)

UDeveloperSettings(项目设置)

cpp
UCLASS(Config=Game, DefaultConfig, meta=(DisplayName="My Game Settings"))
class MYGAME_API UMyProjectSettings : public UDeveloperSettings
{
    GENERATED_BODY()
public:
    UPROPERTY(Config, EditAnywhere, Category="Save") int32 MaxSaveSlots = 5;
    UPROPERTY(Config, EditAnywhere, Category="Save") bool  bEnableAutoSave = true;
    UPROPERTY(Config, EditAnywhere, Category="Save") float AutoSaveIntervalSeconds = 300.f;
    static const UMyProjectSettings* Get() { return GetDefault<UMyProjectSettings>(); }
};
cpp
UCLASS(Config=Game, DefaultConfig, meta=(DisplayName="My Game Settings"))
class MYGAME_API UMyProjectSettings : public UDeveloperSettings
{
    GENERATED_BODY()
public:
    UPROPERTY(Config, EditAnywhere, Category="Save") int32 MaxSaveSlots = 5;
    UPROPERTY(Config, EditAnywhere, Category="Save") bool  bEnableAutoSave = true;
    UPROPERTY(Config, EditAnywhere, Category="Save") float AutoSaveIntervalSeconds = 300.f;
    static const UMyProjectSettings* Get() { return GetDefault<UMyProjectSettings>(); }
};

GConfig Direct Access

GConfig直接访问

cpp
#include "Misc/ConfigCacheIni.h"

FString Value;
GConfig->GetString(TEXT("/Script/MyGame.MyConfig"), TEXT("Key"), Value, GGameIni);
GConfig->SetString(TEXT("/Script/MyGame.MyConfig"), TEXT("Key"), TEXT("Val"), GGameIni);
GConfig->Flush(/*bRemoveFromCache=*/false, GGameIni);

MyObject->SaveConfig();  // writes UPROPERTY(Config) fields to .ini
MyObject->LoadConfig();  // reloads from .ini
INI section naming: Section
[/Script/ModuleName.ClassName]
maps to the CDO.
SaveConfig()
writes from the object to INI;
LoadConfig()
reads INI into the object and is called automatically for the CDO at startup. Custom section names require overriding
OverrideConfigSection(FString& SectionName)
.

cpp
#include "Misc/ConfigCacheIni.h"

FString Value;
GConfig->GetString(TEXT("/Script/MyGame.MyConfig"), TEXT("Key"), Value, GGameIni);
GConfig->SetString(TEXT("/Script/MyGame.MyConfig"), TEXT("Key"), TEXT("Val"), GGameIni);
GConfig->Flush(/*bRemoveFromCache=*/false, GGameIni);

MyObject->SaveConfig();  // 将UPROPERTY(Config)字段写入.ini文件
MyObject->LoadConfig();  // 从.ini文件重新加载
INI节命名:节
[/Script/ModuleName.ClassName]
对应CDO。
SaveConfig()
将对象数据写入INI;
LoadConfig()
从INI读取数据到对象,且会在启动时自动为CDO调用。自定义节名需要重载
OverrideConfigSection(FString& SectionName)

Cloud Save Integration

云存档集成

cpp
// Platform save systems (Steam, EOS, console) provide ISaveGameSystem
// Access via IPlatformFeaturesModule:
ISaveGameSystem* SaveSystem = IPlatformFeaturesModule::Get().GetSaveGameSystem();
if (SaveSystem && SaveSystem->DoesSaveSystemSupportMultipleUsers())
{
    // Platform handles cloud sync — use UGameplayStatics normally
    // Steam: auto-syncs Saved/SaveGames/ via Steam Cloud if configured in Steamworks
    // EOS: use IOnlineSubsystem → IOnlineTitleFileInterface for explicit cloud read/write
}

// Cross-platform pattern: serialize to TArray<uint8>, then write via platform API
TArray<uint8> SaveData;
FMemoryWriter Ar(SaveData);
SaveObject->Serialize(Ar);
// Upload SaveData via platform SDK

// Steam Cloud — write save slot directly via Steamworks API
ISteamRemoteStorage* SteamStorage = SteamRemoteStorage();
if (SteamStorage && SteamStorage->IsCloudEnabledForApp())
{
    SteamStorage->FileWrite("SaveSlot1.sav", SaveData.GetData(), SaveData.Num());
}
// Read back: SteamStorage->FileRead("SaveSlot1.sav", Buffer, Size)
cpp
// 平台存档系统(Steam、EOS、主机)提供ISaveGameSystem
// 通过IPlatformFeaturesModule访问:
ISaveGameSystem* SaveSystem = IPlatformFeaturesModule::Get().GetSaveGameSystem();
if (SaveSystem && SaveSystem->DoesSaveSystemSupportMultipleUsers())
{
    // 平台处理云同步 — 正常使用UGameplayStatics即可
    // Steam:若在Steamworks中配置,会自动同步Saved/SaveGames/目录
    // EOS:使用IOnlineSubsystem → IOnlineTitleFileInterface进行显式云读写
}

// 跨平台模式:序列化为TArray<uint8>,再通过平台API写入
TArray<uint8> SaveData;
FMemoryWriter Ar(SaveData);
SaveObject->Serialize(Ar);
// 通过平台SDK上传SaveData

// Steam Cloud — 通过Steamworks API直接写入存档槽位
ISteamRemoteStorage* SteamStorage = SteamRemoteStorage();
if (SteamStorage && SteamStorage->IsCloudEnabledForApp())
{
    SteamStorage->FileWrite("SaveSlot1.sav", SaveData.GetData(), SaveData.Num());
}
// 读取:SteamStorage->FileRead("SaveSlot1.sav", Buffer, Size)

Save Data Encryption

存档数据加密

cpp
// Use FAES for symmetric encryption of save data
#include "Misc/AES.h"
// Build a zero-padded 32-byte FAESKey from a string.
// Do NOT use Key.Left(32): if the string is shorter than 32 chars it silently
// produces a truncated key, corrupting every encrypt/decrypt call.
static FAESKey MakeAESKey(const FString& KeyString)
{
    FAESKey AESKey;
    FMemory::Memzero(AESKey.Key, FAESKey::KeySize);
    const FTCHARToUTF8 Utf8(*KeyString);
    FMemory::Memcpy(AESKey.Key, Utf8.Get(), FMath::Min(Utf8.Length(), FAESKey::KeySize));
    return AESKey;
}

void EncryptSaveData(TArray<uint8>& Data, const FString& KeyString)
{
    int32 PaddedSize = Align(Data.Num(), FAES::AESBlockSize);
    Data.SetNumZeroed(PaddedSize);
    FAES::EncryptData(Data.GetData(), PaddedSize, MakeAESKey(KeyString));
}

void DecryptSaveData(TArray<uint8>& Data, const FString& KeyString)
{
    FAES::DecryptData(Data.GetData(), Data.Num(), MakeAESKey(KeyString));
}
Why encrypt: Prevents casual save editing for competitive/economy-sensitive games. Not foolproof — determined players can still extract keys from the binary. Combine with server-side validation for authoritative saves.

cpp
// 使用FAES对存档数据进行对称加密
#include "Misc/AES.h"
// 从字符串构建零填充的32字节FAESKey。
// 请勿使用Key.Left(32):若字符串短于32字符,会静默生成截断密钥,导致所有加解密调用失败。
static FAESKey MakeAESKey(const FString& KeyString)
{
    FAESKey AESKey;
    FMemory::Memzero(AESKey.Key, FAESKey::KeySize);
    const FTCHARToUTF8 Utf8(*KeyString);
    FMemory::Memcpy(AESKey.Key, Utf8.Get(), FMath::Min(Utf8.Length(), FAESKey::KeySize));
    return AESKey;
}

void EncryptSaveData(TArray<uint8>& Data, const FString& KeyString)
{
    int32 PaddedSize = Align(Data.Num(), FAES::AESBlockSize);
    Data.SetNumZeroed(PaddedSize);
    FAES::EncryptData(Data.GetData(), PaddedSize, MakeAESKey(KeyString));
}

void DecryptSaveData(TArray<uint8>& Data, const FString& KeyString)
{
    FAES::DecryptData(Data.GetData(), Data.Num(), MakeAESKey(KeyString));
}
加密原因:防止玩家随意修改存档,适用于竞技类或经济敏感型游戏。但并非绝对安全 — 有经验的玩家仍可从二进制文件中提取密钥。对于权威存档,需结合服务器端验证。

Step 8: Common Mistakes

步骤8:常见错误

Anti-PatternProblemFix
Saving raw
UObject*
or
AActor*
Pointers invalid between sessionsSave
FSoftObjectPath
or a stable unique ID
No version fieldAdding/removing fields corrupts old saves silentlyAlways include
int32 SaveVersion
; run migrations on load
SaveGameToSlot
on game thread per frame
Blocks rendering, causes hitchesUse
AsyncSaveGameToSlot
USTRUCT
without
GENERATED_BODY()
in a saved field
Silent serialization failureAdd
GENERATED_BODY()
to all saved structs
Ignoring
Ar.IsError()
Reads past corrupted data, applies garbageCheck after every block; abort immediately if set
Overlapping async savesSecond save starts before first completesGuard with
bSaveInProgress
flag or
IsSaveInProgress()
Hardcoded save file pathsBreaks on consoles and different platformsUse
UGameplayStatics
APIs;
FPaths::ProjectSavedDir()
only for debug
PIE vs. Packaged / platform paths: In PIE, saves go to
<Project>/Saved/SaveGames/
. Packaged Windows builds write to
%LocalAppData%/<ProjectName>/Saved/SaveGames/
. Console platforms use title storage APIs.
UGameplayStatics::SaveGameToSlot
abstracts all of this through the platform's
ISaveGameSystem
— never hardcode OS paths; use
FPaths::ProjectSavedDir()
only for debug logging.

反模式问题修复方案
保存原始
UObject*
AActor*
指针在不同会话中无效保存
FSoftObjectPath
或稳定的唯一ID
无版本字段添加/删除字段会静默损坏旧存档始终包含
int32 SaveVersion
;加载时执行数据迁移
每帧在游戏线程调用
SaveGameToSlot
阻塞渲染,导致卡顿使用
AsyncSaveGameToSlot
存档字段中的
USTRUCT
缺少
GENERATED_BODY()
序列化静默失败为所有存档结构体添加
GENERATED_BODY()
忽略
Ar.IsError()
读取损坏数据后应用无效值每次块操作后检查;若标记为错误则立即终止
异步保存重叠第二次保存在第一次完成前启动使用
bSaveInProgress
标志或
IsSaveInProgress()
进行防护
硬编码存档文件路径在主机和其他平台上失效使用
UGameplayStatics
API;仅在调试时使用
FPaths::ProjectSavedDir()
PIE vs 打包版/平台路径:在PIE中,存档保存到
<Project>/Saved/SaveGames/
。打包后的Windows版本写入
%LocalAppData%/<ProjectName>/Saved/SaveGames/
。主机平台使用标题存储API。
UGameplayStatics::SaveGameToSlot
通过平台的
ISaveGameSystem
抽象了所有这些逻辑 — 切勿硬编码系统路径;仅在调试日志中使用
FPaths::ProjectSavedDir()

Advanced Edge Cases

高级边缘场景

Corruption recovery: When
Ar.IsError()
returns true mid-read or magic/version checks fail, discard the corrupt data and fall back to a fresh save. Optionally maintain a backup slot (write to
Slot_Backup
before overwriting
Slot_Primary
) so players never lose all progress:
cpp
USaveGame* LoadedSave = UGameplayStatics::LoadGameFromSlot(PrimarySlot, 0);
if (!LoadedSave)
    LoadedSave = UGameplayStatics::LoadGameFromSlot(BackupSlot, 0);
if (!LoadedSave)
    LoadedSave = UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass());
Large saves — chunked approach: Split world state across multiple slots by subsystem (e.g.,
Save_World_00
,
Save_Inventory
,
Save_Quests
). Load each with
AsyncLoadGameFromSlot
in parallel. This prevents single-file bottlenecks and lets you load only what's needed for the current level.
Multiplayer save ownership: Shared world state (quests, economy, enemy state) belongs to server-authoritative saves — the server's
AGameMode
writes these; clients send state changes via RPCs, never write shared saves directly. Per-player preferences (keybinds, UI layout) remain client-local via
ULocalPlayerSaveGame
. This split prevents desync and cheating.

损坏恢复:当
Ar.IsError()
在读取过程中返回true,或魔术值/版本检查失败时,丢弃损坏数据并回退到新存档。可选择维护备份槽位(覆盖
Slot_Primary
前先写入
Slot_Backup
),确保玩家不会丢失所有进度:
cpp
USaveGame* LoadedSave = UGameplayStatics::LoadGameFromSlot(PrimarySlot, 0);
if (!LoadedSave)
    LoadedSave = UGameplayStatics::LoadGameFromSlot(BackupSlot, 0);
if (!LoadedSave)
    LoadedSave = UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass());
大型存档 — 分块方案:按子系统将世界状态拆分到多个槽位(例如
Save_World_00
Save_Inventory
Save_Quests
)。使用
AsyncLoadGameFromSlot
并行加载每个槽位。这避免了单文件瓶颈,且可仅加载当前关卡所需的数据。
多人游戏存档所有权:共享世界状态(任务、经济、敌人状态)属于服务器权威存档 — 由服务器的
AGameMode
写入;客户端通过RPC发送状态变更,切勿直接写入共享存档。玩家个人偏好(按键绑定、UI布局)通过
ULocalPlayerSaveGame
保留在客户端本地。这种拆分可防止不同步和作弊。

Module Dependencies (Build.cs)

模块依赖(Build.cs)

csharp
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine" });
// For UDeveloperSettings:
PublicDependencyModuleNames.Add("DeveloperSettings");

csharp
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine" });
// 若使用UDeveloperSettings:
PublicDependencyModuleNames.Add("DeveloperSettings");

Related Skills

相关技能

  • ue-cpp-foundations
    — UPROPERTY, USTRUCT, UObject lifetime
  • ue-data-assets-tables
    — FSoftObjectPath patterns for asset references in saves
  • ue-gameplay-framework
    — GameInstance as save manager host; GameMode auto-save integration
  • ue-cpp-foundations
    — UPROPERTY、USTRUCT、UObject生命周期
  • ue-data-assets-tables
    — 存档中资源引用的FSoftObjectPath模式
  • ue-gameplay-framework
    — 作为存档管理器宿主的GameInstance;GameMode自动存档集成

Reference Files

参考文件

  • references/save-system-architecture.md
    — Full slot manager subsystem, metadata bank, multi-user patterns, and migration pipeline
  • references/save-system-architecture.md
    — 完整的存档槽管理器子系统、元数据银行、多用户模式和迁移流程