UE 的 .uasset 对大多数人一直是黑盒:编辑器里看属性面板,打包后看 paks,很少有人关心中间这层二进制长什么样。但真撞上"Cook 后的包能不能改"、"两个包为什么循环依赖"、"补丁 diff 为什么对不上"这类问题时,最后都要落到格式本身。这一篇把 UE5.3 的 Package 二进制骨架拆一遍,结构定义在 Engine/Source/Runtime/CoreUObject/Public/UObject/PackageFileSummary.h 一带。
文件头:FPackageFileSummary
任何 .uasset 以 4 字节魔数 0x9E2A83C1 开头(小端 uint32),紧跟着 FPackageFileSummary——整个文件的目录页。它回答三件事:这文件是什么版本、各张表在什么位置、这个包有哪些全局性质。
核心字段:
Tag+LegacyVersion+FPackageFileVersion{FileVersionUE4, FileVersionUE5}+LicenseeVersion:四层版本号。CustomVersions数组给每个插件的序列化格式独立发版(GUID + Version),引擎升级时老资产靠它逐项适配,而不是整包作废;TotalHeaderSize/NameCount + NameOffset/ImportCount + ImportOffset/ExportCount + ExportOffset/DependsOffset:各表的条目数与文件偏移。全部是"count + offset"成对出现,读到 summary 就能直接跳转,不必顺序解析;PackageFlags:EPackageFlags位集,PKG_ContainsMap、PKG_CookGenerated这类性质全在这;Guid+Generations:Generation 是{ExportCount, NameCount, ImportCount}的数组,补丁 diff 拿它当包的指纹;BulkDataStartOffset:包内联 bulk data 的起点,纹理、音源这类大对象不走主序列化流。
有一个容易踩的点:summary 里的 offset 是绝对文件偏移,从文件头 0 算起,不是相对 header 末尾。自己写解析器时直接从 summary 跳表;顺序读一张几万条目的 Names 表,加载时间会被拖成平方级。
Names 表:字符串去重池
Names 表存这个包里出现过的所有 FName 字符串(外加一个 number,处理 Name_0 这种后缀)。所有对象名、属性名、枚举值名在这里只存一份,正文里出现的位置全部存"表内索引 + 后缀号"两个整数。
省多少算一笔账:设包里共 $n$ 处名字引用,去重后 $m$ 个字符串,直接存字符串的总开销约 $\sum_{i=1}^{m} \mathrm{len}_i \times k$($k$ 为单字符序列化开销),走索引池后正文部分固定为
$$S_{\text{正文}} = n \times (\text{sizeof(int32)} + \text{sizeof(int32)}) = 8n\ \text{字节}$$
Blueprint 资产动辄几万次属性名引用,前者是几百 KB 级,后者固定几十 KB。收益更大的在比较速度:加载期按索引查表构造 FName,运行期的名字比较退化成整数比较——这就是 UE 到处用 FName 而不是 FString 的底气。
Import / Export 表:包的边界在哪
Export 表(FObjectExport)列的是这个包拥有的对象:对象名、Outer、Class、Super、模板对象,外加 SerialSize / SerialOffset——序列化数据在文件里的位置。Import 表(FObjectImport)列的是这个包引用但属于别人的对象:ClassPackage + ClassName 标识类型,Outer 指向它在宿主包里的路径。
这个划分正好对应链接模型的两半:Export 是自己提供的符号表,Import 是待解析的外部符号。加载器 FLinkerLoad 拿到 Import 表后逐个 CreateImport:按 ClassPackage 加载依赖包,在依赖包的 Export 里按路径找到目标 UObject。整个资产依赖图就是从 import 表逐层递归展开的——本分类第 9010 篇讲的软引用解析,底层就是反复走这两张表。
Depends map 是第三张辅助表:每个 export 依赖的其它 export 的索引列表。加载按它做拓扑序反序列化,保证处理一个对象时它引用的对象已经 PostLoad 完成。
FPackageIndex:全文件统一的"指针"
三张表之间、表与正文之间互相引用,用的都是同一个 32 位有符号整数 FPackageIndex:
- $index > 0$:Export 表第 $index - 1$ 项
- $index < 0$:Import 表第 $-index - 1$ 项
- $index = 0$:空
一个整数同时编码"指向哪张表、哪一项",可直接序列化、可直接做数组下标,比存字符串路径省得多也快得多。想手工验证:把 export 表第 1 项的 Class 字段(一个 FPackageIndex)解出来,是负数就说明这个对象的类型是 import 进来的——Blueprint 资产里几乎所有对象的 UClass 都是这样。
Cook 之后头变瘦了
编辑器里的 uasset 是"全量"资产:带缩略图、CPF_EditorOnly 属性、Blueprint 的 Ubergraph 节点图。Cook 存盘时序列化上下文带着"正在 Cook"的标志,写盘流程按标志跳过:EditorOnly 属性不序列化、编辑器专用对象不进 Export 表、节点图只留下编译产物(FKismetCompiledFunctionData 那套字节码)。结果 cooked 包比编辑器包小一个量级,且不可逆——想从 cooked 包还原编辑器资产,物理上没有数据。
名字表在 cooked 包里保留(运行时要构造 FName),但 header 被精简;UE5 的 IoStore 路径还会把 bulk 数据切去 .uexp/.ubulk,主文件只剩索引能定位的最小集。
实操:读一个 uasset
两条路:
- 现成的:
UnrealPak.exe <pak> -List列出容器内容,-Extract拆出 uasset,再用 UEViewer/uassetgui 这类工具按上面的结构可视化; - 自己写解析器:按 summary 的 offset 跳三张表。FName 字符串是 FString 格式(int32 长度前缀 + 数据,编码看 PackageFlags 的 UTF8 位),Import/Export 条目是定长结构,字段按版本号对齐读。
验证理解的最快办法:挑一个小资产(比如一个枚举 Blueprint),手算 export 数和 Depends 拓扑序,再和游戏里 obj list 的输出对一遍——对上了,说明格式真的吃透了。