夜雨聆风学习资料网

ARTICLE · 1217889

UE5 .pak 打包系统源码级全拆解:一个 64KB 分块容器,如何撑起挂载、加密与热更新

UE5 .pak 打包系统源码级全拆解:一个 64KB 分块容器,如何撑起挂载、加密与热更新

Epic 官方文档里有一句没人留意的话:「.pak 文件用于存放访问频率较低的散文件。」听起来像在给 .pak 降级——毕竟 UE5 里,烘焙好的资产大头都进了 .utoc/.ucas(IoStore),.pak 好像退居二线了。

但如果你真这么想,去翻一遍 Runtime/PakFile/ 这个目录,你会愣住:这个「访问频率较低」的容器,藏着 UE 里最完整的挂载、加密、签名、热更新体系。今天把它拆开。

一个文件,装下一整个文件系统

.pak 是个典型的「存档容器」(archive),它的野心很简单:把散落在磁盘上的成千上万个资源文件,压成一个连续的、可以随机读的二进制文件。这么做有两个好处,一个是部署省事(一个文件好拷好传),另一个更关键——顺序读取一个连续文件,比满盘随机 seek 快得多。

但「压成一个文件」这个动作,背后要解决三个问题:怎么快速找到某个文件在 pak 里的位置?怎么把大文件切成可独立解压的块?怎么在不解压全部的情况下读中间的某一段?

答案全在 pak 的头部结构里。翻 IPlatformFilePak.h,你会先撞上这个:

structFPakInfo{enum    {        PakFile_Magic = 0x5A6F12E1,   // 魔数,验明正身        MaxChunkDataSize = 64*1024,   // 分块大小:64KB        CompressionMethodNameLen = 32,// 压缩格式名长度        MaxNumCompressionMethods = 5, // 一个 pak 最多 5 种压缩法    };    ...    uint32 Magic;              // 魔数    int32  Version;            // pak 格式版本号    int64  IndexOffset;        // 索引在文件里的偏移    int64  IndexSize;          // 索引大小    FSHAHash IndexHash;        // 索引的 SHA1,防损坏    uint8  bEncryptedIndex;    // 索引是否加密    FGuid  EncryptionKeyGuid;  // 加密密钥的 GUID    TArray<FName> CompressionMethods; // 本 pak 用到的压缩法列表};

IPlatformFilePak.h:147,这几十行就是整个 .pak 的「户口本首页」。魔数 0x5A6F12E1 用来快速判断「这是不是个合法的 pak」;IndexOffset + IndexSize 指向文件末尾的那块索引区;IndexHash 是索引区的 SHA1,加载时算一遍对不上就说明文件被截断或损坏。

注意 MaxChunkDataSize = 64*1024。这个 64KB 是贯穿整个系统的常数——分块压缩按它切,内存缓存按它预留。这就是标题里「64KB 分块容器」的出处。

版本号:一段十二级台阶的演进史

FPakInfo 里那串 PakFile_Version_Initial 到 PakFile_Version_Utf8PakDirectory,一共 12 个版本,本身就是一部打包系统的演化年表。挑几个有信息的说:

  • Version_2 NoTimestamps:去掉时间戳。早期 pak 给每个文件存时间戳,后来发现对游戏没用,还干扰增量构建的确定性,砍了。
  • Version_3 CompressionEncryption:压缩和加密从这版开始进 pak。
  • Version_4 IndexEncryption:索引单独加密。这一步很重要——不解密索引,你就不知道 pak 里到底装了什么文件、每个文件在哪。这比只加密数据要狠得多。
  • Version_5 RelativeChunkOffsets:分块偏移改成相对值。好处是数据块可以整体搬移,不用改内部偏移。
  • Version_8 FNameBasedCompressionMethod:压缩法从「int 枚举」改成「FName 字符串」。这让第三方能注册自己的压缩法,Oodle 这种商业库就是靠这个挂进来的。

看懂这串版本号,你就懂了 .pak 为什么到今天还能兼容十几年前的包——加载器会从最新版本往回试,直到认出来为止(Serialize 里 Magic != PakFile_Magic 就 return,让调用方换老版本再试)。

FPakEntry:每个文件的「档案卡」

索引区装的是什么?是一张张 FPakEntry,一张卡片描述一个文件。翻 IPlatformFilePak.h:405:

structFPakEntry{staticconst uint8 Flag_None = 0x00;staticconst uint8 Flag_Encrypted = 0x01;  // 这个文件加密了staticconst uint8 Flag_Deleted = 0x02;    // 这个文件被删了(补丁用)    int64  Offset;              // 在 pak 里的偏移    int64  Size;                // 序列化后大小    int64  UncompressedSize;    // 解压后大小    uint8  Hash[20];            // 文件 SHA1    TArray<FPakCompressedBlock> CompressionBlocks; // 分块描述    uint32 CompressionBlockSize;    // 分块大小    uint32 CompressionMethodIndex;  // 指向 FPakInfo 的压缩法列表    uint8  Flags;               // 加密/删除标志    ...};

这张卡片的精髓在两个字段:CompressionBlocks 和 CompressionBlockSize。一个 10MB 的贴图,不会整体压成一个块,而是切成 10MB / 64KB ≈ 160 个块,每个块独立压缩,CompressionBlocks 里记录每个块「压缩后起点 + 压缩后终点」。这么做的回报很直接:你要读贴图中段的 mip 数据,只需要解压对应的那一个 64KB 块,不用把整个 10MB 都解出来。

Flag_Deleted 也值得单独说一句。热更新补丁里,一个资源被删了怎么办?不可能真的把老 pak 里的数据物理删除,于是就在补丁 pak 里放一条 Flag_Deleted 的 entry 盖住它。挂载时新 pak 优先级高,读到这条标记就当「这文件没了」。这就是「删除」在只读容器里的实现——不是删,是盖。

压缩与加密:Oodle 挂载 + AES 分块对齐

压缩这块,UE 支持 Zlib、Gzip、LZ4、Oodle 四种,靠 FName 注册,CompressionMethods 里存列表,每个文件的 CompressionMethodIndex 指向列表下标。读文件的解压入口在 IPlatformFilePak.cpp 里,核心一句是:

bFailed = !FCompression::UncompressMemory(    CompressionMethod, Output, Block.ProcessedSize,    Block.Raw, Block.DecompressionRawSize);

IPlatformFilePak.cpp:3532——按块逐个解压,解压失败就回退到「恐慌同步读」(ForceDecompressionFails 这个 CVar 就是拿来测这条回退路径的)。

加密这边,真正的解密函数其实很短,IPlatformFilePak.cpp:445:

voidDecryptData(uint8* InData, uint64 InDataSize, FGuid InEncryptionKeyGuid){    uint32 DataSize = IntCastChecked<uint32>(InDataSize);if (FPakPlatformFile::GetPakCustomEncryptionDelegate().IsBound())    {// 项目自定义加密:交给你自己的解密器        FPakPlatformFile::GetPakCustomEncryptionDelegate().Execute(            InData, DataSize, InEncryptionKeyGuid);    }else    {SCOPE_SECONDS_ACCUMULATOR(STAT_PakCache_DecryptTime);        FAES::FAESKey Key;        FPakPlatformFile::GetPakEncryptionKey(Key, InEncryptionKeyGuid);check(Key.IsValid());        FAES::DecryptData(InData, DataSize, Key);  // 标准 AES    }}

两个信息点。第一,UE 留了 GetPakCustomEncryptionDelegate() 这个后门,项目可以完全替换掉加密实现——商业游戏里常见做法就是换成自己内部的算法。第二,默认走 FAES::DecryptData(AES-256),而且密钥是按 EncryptionKeyGuid 找的,不是全局一把钥匙——这支持一个项目多套密钥,不同资产用不同 key 加密。

还有一层「签名」,跟加密是两码事:加密防「看」,签名防「改」。签名逻辑在 SignedArchiveReader.cpp 里,用的是非对称签名——公钥随包分发、私钥留在构建机上,玩家改了包里的任何一个字节,签名校验立刻露馅。这才是对抗外挂/改包的那道墙。

挂载:MountPoint 和 PakOrder 的两条规则

.pak 最精彩的设计,其实在挂载阶段。翻 IPlatformFilePak.cpp:5998:

boolFPakPlatformFile::Mount(const TCHAR* InPakFilename,    uint32 PakOrder,const TCHAR* InPath /*= nullptr*/,bool bLoadIndex /*= true*/,    FPakListEntry* OutPakListEntry /*= nullptr*/)

MountPoint(挂载点)和 PakOrder(挂载顺序)这两个参数,决定了 .pak 的精髓。挂载点是一个「公共目录前缀」,pak 里的文件路径都相对它存。两个作用:省空间(不用重复存 /Game/MyMod/ 这个前缀),省时间(查文件时先按挂载点分流)。

PakOrder 解决的是更现实的问题:多个 pak 里都有 /Game/UI/MainMenu.uasset,读哪个?规则是 order 大的赢。这就是热更新的底层——补丁 pak 用更高的 order 挂载,读到同名资源时新包盖旧包。看 Mount 里对 _P.pak 的处理:文件名后缀 _P.pak 会被解析出「补丁版本号」,版本越新 order 越高。

说白了,热更新不是魔法,是挂载顺序加版本号。

IoStore vs .pak:两代容器的分工

很多人以为 UE5 里 .pak 被 IoStore 淘汰了。对一半。翻 IoDispatcherFileBackend.cpp 和官方 Zen Loader 文档,两者的分工其实很清晰:

维度
.pak(PakFile)
.utoc/.ucas(IoStore)
存储单位
单个文件,路径可枚举
chunk,无独立文件概念
元数据
索引内联在 pak 里
.utoc 单独存,与 .ucas 数据分离
可枚举性
能用 UnrealPak -List 列出所有文件
AssetRegistry 扫不到,需 .bin
典型用途
散文件、DLC、mod、热补丁
烘焙资产主容器
挂载粒度
单个 .pak 灵活挂载
容器级挂载

一句话总结:IoStore 为「极致加载性能」而生,.pak 为「灵活分发与挂载」而生。前者把资产元数据和数据拆开、按 chunk 排布,加载快但失去了「逐文件」的粒度;后者保留了完整的文件抽象,代价是加载路径稍长。所以 DLC、UGC、mod、加密敏感内容,今天依然走 .pak。

动手:挂载一个 mod pak

看完原理,跑一遍完整流程。先在项目里写个挂载函数:

#include"IPlatformFilePak.h"#include"HAL/PlatformFileManager.h"voidMountModPak(const FString& PakPath, uint32 PakOrder = 100){// 拿到 pak 平台文件层    FPakPlatformFile* PakPlatform = static_cast<FPakPlatformFile*>(        FPlatformFileManager::Get().FindPlatformFile(            FPakPlatformFile::GetTypeName()));if (PakPlatform && PakPlatform->Mount(*PakPath, PakOrder))    {// 挂载后,资源就能按 /Game/MyMod/... 路径正常加载了UE_LOG(LogTemp, Log, TEXT("Mounted: %s"), *PakPath);    }else    {UE_LOG(LogTemp, Warning, TEXT("Mount failed: %s"), *PakPath);    }}

打一个 mod pak 用 UnrealPak 命令行。先写清单文件 filelist.txt:

"../../../MyProject/Content/MyMod/MyAsset.uasset" "../../../MyProject/Content/MyMod/"

第一列是源文件绝对路径,第二列是它在 pak 里的相对路径。然后执行:

UnrealPak.exe MyMod.pak -Create=filelist.txt -compress=Oodle

想加密加 -encrypt,想签名加 -sign。想让 mod 覆盖本体资源,就把挂载时 PakOrder 设得比主 pak 高。验证挂载结果,编辑器控制台敲 pak List 能列出当前已挂载的所有 pak 和它们的挂载点。

这套「清单 + UnrealPak + Mount」的组合,就是 UE 生态里 mod 作者和 DLC 系统每天都在用的流水线。理解了 FPakInfo/FPakEntry/Mount 这三层,你就掌握了这条流水线的全部暗门。

收尾

.pak 解决了「打包 + 挂载 + 加密 + 热更新」这一整条链路,但有一个问题它始终没碰:当 pak 里的文件被频繁增删改、需要就地更新时,.pak 这种只读、追加式的容器会越滚越大,靠 Flag_Deleted 盖而不是真删,索引会积压垃圾。IoStore 也没彻底解决——它干脆放弃了「逐文件」的粒度。那么「既能随机读写、又能按文件寻址、还能热更新的容器」到底存不存在?这已经不是打包格式的问题了,这是数据库和文件系统的老战场搬进了游戏引擎。

我每周拆一个 UE5 底层系统,从源码讲到能跑的代码——不为别的,就想把技术讲透。觉得有用,点个「在看」,关注不走丢。你还想看我拆哪个子系统?评论区点名。

相关学习资料