
UEViewer 内部运转指南一次完整的从 pak 字节到 3D 模型资源还原之旅【免费下载链接】UEViewerViewer and exporter for Unreal Engine 1-4 assets (UE Viewer).项目地址: https://gitcode.com/gh_mirrors/ue/UEViewerUEViewer社区俗称 UModel是一个支持虚幻引擎 1 到 4UE1–UE4全系列资源包的查看与导出工具它能从.pak、.upk等二进制包体中还原出角色模型、贴图、动画和材质并导出为 GLTF、PSK、TGA 等通用格式。很多游戏 Mod 作者和引擎研究者都靠它考古老游戏资产。本文不打算介绍它的界面和按钮而是跟随一次真实的资源还原过程——从磁盘上的一堆字节到屏幕上旋转的 3D 模型——逐站拆解它内部怎么运转、为什么这么设计、如何上手扩展。先说痛点为什么读 .pak 这么难如果你用十六进制编辑器打开一个.upk文件看到的只会是9E 2A 83 C1开头的一串乱码。UE 资源包本质上是一份高度结构化但没有文档的二进制文件里面塞着名称表Name Table、导入导出表、压缩块、对象序列化数据而且 UE1、UE2、UE3、UE4 每一代的布局都不同甚至同一代引擎的不同游戏还会私自改动格式。没有工具的情况下你想提取一个角色模型需要自己逆向出包文件头Package Summary的字段布局读懂名称表如何压缩存储搞清楚导出表Export Table里每一条记录如何对应磁盘上的字节区间再面对 UE3/UE4 的压缩块、AES 加密、IOStore 等一堆附加题。这正是 UEViewer 存在的理由它把读懂 UE 二进制资源这件高度专业的事封装成了一条清晰的单向流水线。顺着这条流水线走一遍项目的整个架构就浮出水面了。全局地图四站式单向流水线UEViewer 的处理路径高度线性数据从入口到出口只经过四个层次每层只对相邻层负责文件系统层Unreal/FileSystem/负责扫描游戏目录、打开.pak/.obb等容器、管理虚拟文件系统向上层屏蔽文件到底在磁盘还是压缩包内的差异。包解析层Unreal/UnrealPackage/读取包文件头、名称表、导入/导出表按引擎年代分流解析策略是版本兼容性的核心阵地。对象模型层Unreal/下的UnObject.cpp、UnCore.h等把导出表记录反序列化为内存中的UObject对象FArchive是所有序列化的地基。导出层Exporters/消费已还原的对象按对象类名分发给对应格式的导出器。这套分层的直接收益是引擎版本升级时改动被限制在特定层内。UE3 新增的材质表达式网络只影响对象模型层UE4 的 IOStore 只影响文件系统层导出层几乎从不感知版本号——它面对的永远是还原后的统一对象。第一站FArchive——带版本记忆的读写游标所有 UE 资源本质上都是按固定顺序写入的字段序列。UEViewer 在Unreal/UnCore.h里定义了一个抽象基类FArchive统一了读包和写导出文件两种场景class FArchive { public: int ArVer; // 引擎版本号 int ArLicenseeVer; // 厂商私有版本号 bool IsLoading; // 当前是读还是写 bool ReverseBytes; // 是否需要字节序反转 int Game; // 游戏标识如 GAME_MK / GAME_RocketLeague int Platform; // 平台影响字节序与对齐 virtual void Seek(int Pos) 0; virtual void Serialize(void *data, int size) 0; void DetectGame(); // 根据版本号与包内容推断引擎与游戏 inline int Engine() const { return (Game GAME_ENGINE); } };这段代码回答了为什么这样设计ArVer和Game两个字段让同一个序列化函数可以针对不同版本做出不同行为。举例来说包文件中的压缩块结构FCompressedChunk在大多数游戏里是 4 个 32 位整数但在《真人快打 X》和《火箭联盟》里偏移量变成了 64 位在《子弹风暴》里又多了一个未知字段——处理方式是在operator里就地判断friend FArchive operator(FArchive Ar, FCompressedChunk C) { 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; ... return Ar; } Ar C.UncompressedOffset C.UncompressedSize C.CompressedOffset C.CompressedSize; ... }为什么这样设计与其为每个游戏特例写一套独立的解析函数不如把版本上下文挂在每个读写操作都能触达的游标对象上遇到差异就就地打补丁。代价也很明显FArchive的字段会越来越多特例逻辑散布在各operator里可读性打折。但考虑到 UE 格式的版本爆炸特性这几乎是性价比最高的方案——你可以把FArchive理解成一把自带当前朝代记忆的尺子量哪个年代的字段就用哪个年代的刻度。FArchive还有一个容易被忽略的细节ArStopper止步线。解析对象时给它设一个读到哪就该停的位置一旦越界就报错——这防止了恶意或损坏的包让解析器无限越界读下去。第二站UnPackage——包文件头与按代分流包解析的核心入口是UnPackage类Unreal/UnrealPackage/UnPackage.h。注意一个反直觉的设计UnPackage本身继承自FArchive——一个包文件本身就是一把可读的游标这是非常引擎原生的思维方式UE 官方引擎里的 UObject 加载也是这么干的。解析的第一步是读取FPackageFileSummary它记录了包的版本、名称表与导出表的数量与偏移、GUID、世代信息FGenerationInfo等文件骨架信息struct FPackageFileSummary { uint32 Tag; // PACKAGE_FILE_TAG 0x9E2A83C1 uint16 FileVersion; // 如 100、180 ... uint16 LicenseeVersion; int32 NameCount, NameOffset; // 名称表 int32 ExportCount, ExportOffset; // 导出表 int32 ImportCount, ImportOffset; // 导入表 ... TArrayFCompressedChunk CompressedChunks; // UE3 起支持压缩块 // 内部还有 Serialize2 / Serialize3 / Serialize4 三个私有序列化函数 };项目用两个简单宏划分引擎时代#define PACKAGE_V2 100 // UE2 起的分界版本 #define PACKAGE_V3 180 // UE3 起的分界版本 #define PACKAGE_FILE_TAG 0x9E2A83C1FArchive::DetectGame()会根据版本号和包内容推断出引擎代次甚至能认出《堡垒之夜》《火箭联盟》这类具体游戏。随后解析被分流到UnPackageReader.cpp、UnPackage2.cpp、UnPackage3.cpp、UnPackage4.cpp四个文件——每个文件对应一代引擎的格式差异。UE4 还引入了无版本号包unversioned package和自定义版本容器FCustomVersionContainerUEViewer 在版本缺失时会通过回调弹窗向用户询问这正是它兼容 UE4 各版本的关键。为什么这样设计把版本兼容集中到包解析层是为了让上游对象层、导出层保持天真。代价是包解析层自身承担了巨大的复杂度——你可以看到FCompressedChunk里针对单个游戏的 if 分支这类逆向工程式的补丁正是版本兼容的日常。第三站对象模型——导出表记录如何活过来包文件头解析完成后Summary里记录了导出表Export Table的位置。导出表的每一条FObjectExport都描述了某个对象叫什么名字、属于哪个类、序列化数据在文件的哪个偏移、多大struct FObjectExport { int32 ClassIndex; // 指向类的引用 int32 PackageIndex; // 指向所属包的引用 FName ObjectName; // 对象名字 int32 SerialSize; // 序列化数据大小 int32 SerialOffset; // 序列化数据在文件中的偏移 UObject* Object; // 不参与序列化由加载器填充 ... };UEViewer 的策略是懒加载只有当你真的需要某个对象比如某个模型或材质被导出器请求时才根据SerialOffset定位、用对应的FArchive读取那段字节、反序列化成UObject。这种按需加载让一个包含数千对象的包不至于一次性吃光内存——典型包体里绝大多数对象你根本不会碰。对象还原完成后数据就已经从二进制变回了逻辑实体网格、贴图、材质、动画、声音各归其位等待导出层认领。第四站导出层——注册表分发与导出一次去重这是全项目最优雅的设计位于Exporters/Exporters.h和Exporters.cpp。它的核心思想是把类名 → 导出函数登记进一张静态表导出时按类名查找分发typedef void (*ExporterFunc_t)(const UObject*); // 模板包装避免到处手写类型转换 templateclass T FORCEINLINE void RegisterExporter(void (*Func)(const T*)) { RegisterExporter(T::StaticGetTypeinfo()-Name 1, (ExporterFunc_t)Func); }RegisterExporter的实现维护了一个容量为MAX_EXPORTERS20的静态数组exporters[]每种对象类型只需在某个导出器文件里追加一行注册代码。比如ExportPsk.cpp里注册网格导出RegisterExporter(ExportPsk);ExportTexture.cpp里注册贴图导出RegisterExporter(ExportTexture);真正导出时导出器拿到一个UObject读它的类名去表里找对应的处理函数。新增一种导出格式时分发逻辑一行都不用改。为什么去重在这里是生死攸关的UE3/UE4 的资源引用关系是高度共享的一张贴图可能被上千个材质引用。如果每次遇到引用都重新导出一次一次批量导出可能把同一张贴图写出几千份。为此ExportContext用一张基于包指针 导出索引的哈希表记录已导出对象struct ExportContext { TArrayExportedObjectEntry Objects; // 已导出对象列表 int ObjectHash[EXPORTED_LIST_HASH_SIZE]; // 哈希桶大小 4096 bool ItemExists(const UObject* Obj); // 查重 bool AddItem(const UObject* Obj); // 不存在则登记返回 true };而OnObjectLoad回调把这个机制用到了极致——在 UE3/UE4 中一张贴图一旦导出成功后续加载时直接用一个空壳替身占位连反序列化都省了static bool OnObjectLoad(UObject* Obj) { if (strncmp(Obj-GetClassName(), Texture, 7) 0) { // 580 张贴图被材质引用了 18000 次 // 导出过的贴图直接替换为 dummy不再完整加载 return IsObjectExported(Obj) false; } return true; }为什么这样设计用哈希表换每个资源只导一次是一个典型的空间换时间取舍多花一点内存记录已导出清单省下的是数以千计的重复磁盘写入和反序列化开销。代码注释里那句580 textures were loaded 18000 times是真实压测数据足以说明这个优化在 UE3/UE4 场景下有多关键。工程化不用 CMake用 Perl 生成 MakefileUEViewer 没有使用 CMake 或 Meson而是维护了一套自研的 Perl 构建系统Tools/genmake。这套工具把人类友好的项目描述文件如根目录的common.project、UmodelTool/umodel.project转换成平台对应的 Makefile再由构建脚本统一驱动支持--6464 位编译、--debug、--profile等选项。.project文件里是 Bash 风格变量赋值、条件编译指令和平台定义PLATFORM win32 | win64 | cygwin | unix | osx COMPILER VisualC | GnuC TARGET vc-win32 | vc-win64 | mingw32 | cygwin | linux | osx为什么不用现成构建系统这套工具写于 2000 年代初期当时跨平台 C 构建工具远不如现在成熟自研生成器的好处是能在.project里表达 UEViewer 特有的多目标命令行版 / GUI 版复用同一批源码的组织方式。代价是需要维护一套语言学习成本不低——但对想要改造构建流程的开发者来说Tools/genmake的文档注释非常详细照着写即可。跨平台策略上项目用宏与条件编译隔离平台差异Windows 专用逻辑集中在Core/CoreWin32.cpp图形上下文与 OpenGL 绑定位于Core/GL/SDL2 通过libs/SDL2/SDL2Loader.cpp动态加载避免强依赖Linux 构建则直接复用系统自带的 zlib、libpng。二次开发三条可落地的扩展路径对想改源码的开发者UEViewer 的扩展点刻意做得比较浅新增导出格式在Exporters/下新建文件参照ExportGLTF.cpp或ExportPsk.cpp实现导出函数函数签名是void ExportXxx(const T* Obj)再调用RegisterExporter注册分发逻辑零改动。适配被篡改格式的新游戏如果某游戏改动了个别字段可以在Unreal/GameSpecific/下加补丁逻辑并通过Ar.Game的枚举判断启用时机如果只是偏移量、字段数量级的变化FCompressedChunk那样的就地分支就是现成范例。深度定制解析行为Unreal/GameDefines.h与GameDatabase.cpp集中维护各游戏的特征参数是定位格式特例的第一站UnCore.h里的USE_COMPACT_PACKAGE_STRUCTS宏可以控制是否丢弃框架用不到的字段——关掉它能把解析器切换成全量还原模式调试时很有用。动手实践的建议路径先读Unreal/UnCore.h的FArchive建立心智模型再读Unreal/UnrealPackage/UnPackage.h的FPackageFileSummary理解包骨架接着对照Exporters/Exporters.cpp的注册与去重机制手写一个最简导出器最后把工程编译起来克隆仓库后运行构建脚本git clone https://gitcode.com/gh_mirrors/ue/UEViewer拿一个真实游戏包验证理解。应用场景与诚实的边界典型用途老游戏资产考古从 UE1–UE3 时代的包体批量导出模型与贴图用于 MOD 制作或美术参考命令行模式可一次遍历整个包目录。引擎格式研究把解析代码当活教材——对比UnPackage2.cpp与UnPackage4.cpp的字段差异就能直观看到 UE 资源格式十几年的演进史。自动化流水线集成通过Exporters.h暴露的ExportObject、GetExportPath等接口把导出能力嵌入自研批处理工具。已知局限说点实在的只覆盖视觉资源着色器还原、蓝图逻辑等非可视化数据不在范围内。UE4 加密与新版本存在滞后AES 加密的包体需要用户提供密钥见UmodelTool/UE4AesKeyDialog.h且 UE 版本迭代快对新版本的支持依赖社区持续跟进。解析依赖逆向推断部分厂商私有格式是逆向推导的个别字段含义不明时会被跳过USE_COMPACT_PACKAGE_STRUCTS宏默认开启正是为此极端情况下模型可能出现小瑕疵。收尾它的价值与一条学习路线UEViewer 的价值在于把读懂虚幻引擎二进制资源这一高度专业的能力做成了层次清晰、可扩展的工程实现FArchive用带版本记忆的游标消解了格式爆炸包解析层用按代分流隔离了版本差异导出层用注册表 哈希去重实现了零侵入扩展。它既是趁手的提取工具也是一份难得的引擎格式研究资料。想深入学习的读者建议按这条路线推进读FArchiveUnreal/UnCore.h理解版本感知序列化这个全项目的地基读FPackageFileSummary与FGenerationInfoUnreal/UnrealPackage/UnPackage.h搞懂包文件结构读Exporters.cpp的注册与去重亲手写一个最简导出器编译并跑通拿真实游戏包验证你的理解。你在逆向解析游戏资源时最常被哪个环节卡住——是版本兼容、纹理压缩格式ASTC/BC7 那套还是骨骼动画的绑定关系欢迎在评论区聊聊你的经历也期待看到你基于这套架构折腾出的新玩法。【免费下载链接】UEViewerViewer and exporter for Unreal Engine 1-4 assets (UE Viewer).项目地址: https://gitcode.com/gh_mirrors/ue/UEViewer创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考