一次读懂 UEViewer:从 .pak 字节流到模型贴图的完整资源提取链路
【免费下载链接】UEViewerViewer and exporter for Unreal Engine 1-4 assets (UE Viewer).项目地址: https://gitcode.com/gh_mirrors/ue/UEViewer
UEViewer(社区俗称 umodel)是一款面向虚幻引擎 1–4 全系列游戏资源的查看与提取工具,它能从.pak、.upk、.uasset这些二进制包里还原出模型、贴图、动画与材质。想象这样一个场景:你双击 UEViewer 主程序,选定游戏目录,展开包文件列表,右键一个资源点下"导出"——几秒钟后,磁盘上多出一个 PSK 模型和一张 TGA 贴图。这篇文章要回答的问题是:这几秒钟里,程序内部到底发生了什么?我们把这次导出当成一场"从字节到资产"的旅行,沿着数据流经的每一站,看看 UEViewer 如何一步步把 0 和 1 翻译成你能用的文件。
先给这场旅行画一张地图
一次导出可以被拆成四个连续的驿站,每一站只负责一件事,把上一站的产物交给下一站:
四个驿站各司其职,各站之间只通过"文件路径"和"内存对象"这两种简单的中间产物通信。这份地图很关键,因为后面每一次"为什么这么设计"的讨论,都可以回到这张图上找到落点。
第一站:先把"压缩包"伪装成"普通文件夹"
它解决什么问题?游戏资源通常被打进.pak或.upk包里,一个包可能塞了上千个文件。如果上层解析逻辑必须自己处理"文件在包内第几个字节、是否压缩、是否加密",那么所有模块都会写满压缩相关的脏代码。
它是怎么设计的?Unreal/FileSystem/目录下的GameFileSystem与FVirtualFileSystem提供了统一抽象。包内每个文件都会被登记成一条CGameFileInfo记录,字段里既保存文件名、大小,也保存它在包内的索引(IndexInVfs)。为了快速查找,每条记录还带一个基于文件名计算出的哈希指针(HashNext),于是"按名字找文件"变成一次 O(1) 的哈希命中,而不是遍历整个包目录。
底层解包工作在Unreal/FileSystem/UnArchivePak.cpp中完成。它先检查包头的魔数0x5A6F12E1,再按FPakInfo里的版本号决定如何解释后续字段——因为从 UE4.13 到 4.25,pak 格式本身也一直在演化:
| pak 版本演进 | 新增内容 | 对应的解析策略 |
|---|---|---|
| 早期版本 | 只有偏移、大小、压缩方式 | 基础字段直接读 |
| FNameBasedCompressionMethod | 压缩算法从数字改为名字 | 读出 4 组 32 字节名称再映射 |
| IndexEncryption | 索引区可能被加密 | 读入bEncryptedIndex标志 |
| EncryptionKeyGuid | 引入密钥 GUID | 读到 GUID 后去匹配用户提供的 AES 密钥 |
为什么这样优于其他方案?一个很能说明问题的细节是MAX_OPEN_PAKS = 32:程序刻意限制同时打开的 pak 文件句柄数量。注释里写明原因——C 运行时库对进程可打开的文件数有 2048 的上限,而一个游戏可能有几十上百个 pak。用一个小型句柄缓存,既绕开了操作系统限制,又避免频繁开关文件的开销。这是典型的"用工程经验换健壮性"的取舍。
第二站:验明正身——这是哪一代引擎、哪一款游戏?
它解决什么问题?同一个结构体,在《真人快打 X》里字段是 64 位的,在《子弹风暴》里多了一个来历不明的字段,到了《火箭联盟》又换了字节序。如果不先搞清"我在跟谁说话",后续一切解析都是空谈。
它是怎么设计的?解析入口是Unreal/UnrealPackage/UnPackage.h里的FPackageFileSummary。第一步是核对标签,标准包文件以PACKAGE_FILE_TAG = 0x9E2A83C1开头。但不少厂商魔改了标签,于是代码里维护了一张"特殊标签表":
#define PACKAGE_FILE_TAG 0x9E2A83C1 // 标准 Unreal 包 // 部分游戏自定义的标签 // 0x9E2A83C2 -> Killing Floor // 0x7E4A8BCA -> iStorm // 0xEA31928C -> Hawken // 0x12345678 -> TaoYuan // 0xEC201133 -> StormWar(后面还跟了一个长度字段,需要跳过)如果读到0xC1832A9E(标签的字节反转),则说明文件是小端/大端互反的,程序会翻转自己的"读字节序开关"(Ar.ReverseBytes = true)再继续。这比逐字段判断要省事得多。
确定标签后,用版本号把引擎划分成几代:
#define PACKAGE_V2 100 // UE2 起的分界 #define PACKAGE_V3 180 // UE3 起的分界UE4 的情况更特殊:它的包版本是负数,且从某个版本起可能出现"无版本号包"(unversioned package)。此时版本信息退化成一组自定义版本容器FCustomVersionContainer,程序无法自行推断,只能回调UE4UnversionedPackage()向用户弹窗询问——这正是很多 UE4 游戏首次打开时会跳出一个版本对话框的原因。
为什么这样优于其他方案?看一个代表性特例——压缩块结构FCompressedChunk的序列化代码:
friend FArchive& operator<<(FArchive &Ar, FCompressedChunk &C) { #if MKVSDC || ROCKET_LEAGUE if ((Ar.Game == GAME_MK && Ar.ArVer >= 677) || (Ar.Game == GAME_RocketLeague && Ar.ArLicenseeVer >= 22)) { // MK X 与 Rocket League 使用 64 位文件偏移 int64 UncompressedOffset64, CompressedOffset64; Ar << UncompressedOffset64 << C.UncompressedSize << CompressedOffset64 << C.CompressedSize; ... } #endif #if BULLETSTORM if (Ar.Game == GAME_Bulletstorm && Ar.ArLicenseeVer >= 21) { int32 unk10; // 未知字段,可能是 0 或 1 Ar << unk10; } #endif }注意这些特例全部被#if宏包裹(MKVSDC、ROCKET_LEAGUE、BULLETSTORM),宏来自Unreal/GameDefines.h。这套"一个游戏一个宏"的设计,让每种特例都能独立开关:编译时不需要的游戏支持会被整个剔除,体积和解析负担都随之下降,而新增一款游戏的补丁也只需改动个别文件,不会污染主流程。
第三站:会报出版本号的"尺子"——FArchive
它解决什么问题?引擎里几乎每一个对象(贴图、骨骼、动画曲线、材质节点)都是"按固定顺序写入的字段序列"。需要一个统一的读写游标,让几千处对象解析代码共享同一套行为。
它是怎么设计的?答案在Unreal/UnCore.h的抽象基类FArchive里:
class FArchive { public: int ArVer; // 引擎版本号 int ArLicenseeVer; // 厂商私有版本号 bool IsLoading; // 当前是读还是写 bool ReverseBytes; // 是否需要字节序反转 virtual void Seek(int Pos) = 0; virtual void Serialize(void *data, int size) = 0; void DetectGame(); // 根据版本号与内容推断游戏 int Engine() const { return (Game & GAME_ENGINE); } };可以把它想象成一把会主动报出自己"出厂版本"的尺子:它不只会移动游标和读写字节,还随身携带ArVer、ArLicenseeVer和Game三个"身份牌"。任何一处结构体解析在拿不准时,都可以低头问一句"现在是在哪一代引擎、哪款游戏的环境下",然后决定按哪种布局读取。包内的FObjectExport表则记录每个导出对象的类索引、名字、以及SerialOffset/SerialSize——即它在文件里的精确位置和长度,对象模型层据此"按图索骥"地跳转读取。
为什么这样优于其他方案?一种朴素的做法是"每代引擎写一套完整解析器",但那样代码量会爆炸且难以维护。UEViewer 的选择是一份代码、多处版本分支:把版本判断下沉到序列化函数内部,用Ar.Game与ArLicenseeVer做细粒度分流。另外还有一处值得注意的取舍:UnCore.h里定义了USE_COMPACT_PACKAGE_STRUCTS宏,默认开启后,FObjectExport中那些"框架用不到"的字段(如SuperIndex、ObjectFlags)会被直接跳过不读。代价是丢掉一部分调试信息,收益是解析更快、内存更省——这是典型的"为实用放弃完整"的工程决策。
第四站:导出器的"点名册"与"查重表"
它解决什么问题?还原出来的对象种类繁多:骨骼网格、静态网格、材质、纹理、声音、动画集……导出模块需要一种低成本的方式,把对象类型分发到对应的导出函数,同时保证同一资源不被重复写出。
它是怎么设计的?Exporters/Exporters.h暴露了一个极简的注册接口:
typedef void (*ExporterFunc_t)(const UObject*); void RegisterExporter(const char* ClassName, ExporterFunc_t Func); // 模板包装,免去手写类型转换 template<class T> FORCEINLINE void RegisterExporter(void (*Func)(const T*)) { RegisterExporter(T::StaticGetTypeinfo()->Name + 1, (ExporterFunc_t)Func); }Exporters/Exporters.cpp里维护了一个容量为MAX_EXPORTERS(20)的静态数组。每个导出实现文件在初始化时调用一次注册即可,例如Exporters/ExportPsk.cpp注册 PSK 导出、Exporters/ExportTexture.cpp注册纹理导出。新增一种格式,就是"新建一个文件 + 追加一行注册",分发逻辑一行都不用改。
更妙的是去重机制。ExportContext用"包指针 + 导出索引"作为键,构建了一张 4096 桶的哈希表(ItemExists/AddItem)。为什么必须去重?代码注释里有个震撼的数字:在某个测试案例中,580 张纹理被不同材质引用了 18000 次——UE3/UE4 里一张贴图被成百上千个材质共享是常态。如果不查重,一次批量导出会重复解压同一张贴图几千次。更绝的是,OnObjectLoad回调会在纹理导出完成后把它替换成占位对象,后续加载直接跳过真实数据,省下的不仅是磁盘,还有海量解压时间。
从"会用"到"能改":三条上手路径
理解了四站链路后,你会发现 UEViewer 的扩展点被刻意做得很浅。按照你的角色,有三条对应的上手路径:
| 你的角色 | 目标 | 该看哪里 | 最小改动 |
|---|---|---|---|
| 快速使用者 | 编译并跑起来 | 根目录common.project+package_lnx.sh | 克隆仓库后按脚本编译出可执行文件 |
| 二次开发者 | 加一种导出格式 | Exporters/下的现有实现 | 新写一个导出函数 + 一行RegisterExporter |
| 格式研究者 | 适配一款新游戏 | Unreal/GameDefines.h、Unreal/GameSpecific/、GameDatabase.cpp | 加一个游戏宏,在特例处用Ar.Game分流 |
值得说明的是,这个项目没有用 CMake 这类主流构建系统,而是维护了一套基于 Perl 的自定义构建器Tools/genmake。它读取人类友好的.project描述文件(变量、条件、平台声明),再生成对应平台的 Makefile。你可以在common.project里看到LIBC = shared、OPTIMIZE = size这类声明式配置——整个工程几十个源文件、上百个游戏宏,全靠这一套脚本组织。想亲手验证本文的每一站,可以这样开始:
git clone https://gitcode.com/gh_mirrors/ue/UEViewer cd UEViewer # 查看根目录的 package_lnx.sh 与 Tools/genmake,了解构建入口地图的边界:哪些地方走不通?
诚实地说,这张地图有几处"禁区"。其一,它只覆盖视觉资源——着色器还原、蓝图逻辑这类非可视化数据不在范围内。其二,UE4 的 AES 加密包需要你手动提供密钥(界面里那个密钥输入框就是干这个的),且新引擎版本的支持往往要等社区跟进。其三,部分厂商私有格式是通过逆向推断出来的,个别字段含义不明时,USE_COMPACT_PACKAGE_STRUCTS这类开关会选择"跳过不读",极端情况下模型可能出现细微瑕疵——这是所有逆向工程的宿命。
读完这条链路,你对 UEViewer 的理解就不再是"一个能提取资源的黑盒",而是一张有明确路标的地图:哈希索引的文件登记、验明正身的包解析、随身携带版本号的FArchive、以及一行注册一个导出器的分发机制。下次当你导出一个模型时,不妨想想它刚刚走过的四个驿站。
那么轮到你了:在实际逆向解析游戏资源的过程中,最常让你卡住的是哪一环——是版本兼容判断、pak 的压缩与加密、纹理的 GPU 压缩格式,还是骨骼动画的绑定关系?欢迎分享你的经历,也期待看到你基于这张地图扩展出的新玩法。
【免费下载链接】UEViewerViewer and exporter for Unreal Engine 1-4 assets (UE Viewer).项目地址: https://gitcode.com/gh_mirrors/ue/UEViewer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考