简介这是一个基于JavaScript开发的GRF文件解析库专门用于读取《仙境传说》客户端中的GRF归档资源。通过简洁的getFile接口即可获取目标文件缓存同时借助entries字段遍历整个文件索引适合游戏资源解包、工具开发或MOD研究场景对Node.js开发者非常友好。压缩包内共6个文件以3个核心js文件为主分别实现DES解密、文件读取与主解析流程同时附带TypeScript类型声明、JSON配置及说明文档整体仅6KB结构轻量、便于快速集成。目前已有703人学习下载适合具备一定JavaScript基础、希望探索游戏文件格式或构建资源管理工具的技术爱好者。资源内包含完整可运行源码和基本用法示例读者可直接调用getFile方法提取指定路径文件并结合entries字段掌握GRF索引构建方式为后续扩展二次开发提供清晰参考。 手头要是有一份《仙境传说》的 data.grf想看看它里面到底装了什么或者想把某张角色立绘、某段地图音效抠出来直接拖进浏览器就能干——这就是 grf-reader 这个项目的出发点。我用纯 JavaScript 实现了 GRF 文件的解析不依赖 C 工具链也不需要起什么本地服务。整个读取过程可以在浏览器里完成也能在 Node.js 里跑适合对老游戏资源结构感兴趣的开发者和想给 RO 私服、资料站做工具的朋友参考。这篇文章我会把 GRF 的容器设计、二进制解析思路、资源解压细节和调试时踩过的坑完整梳理一遍。1. 刨开 GRF 之前这个格式到底解决了什么问题RO 从 2002 年上线到现在仍然有大量私人服务器和社区在维护衍生版本而客户端里绝大多数资源都用 GRF 打包。GRF 全称是 Gravity Resource Format从设计目标看它就是一个典型的游戏资源容器包把所有散落的贴图、音频、地图、UI 配置集中到一个文件里按顺序写入磁盘再在文件头部和文件表中记录每个资源的位置和长度。这样做的好处是减少大量小文件的磁盘读写开销也方便版本更新时只替换一个包缺点是内部格式不对外公开想读 GRF 的人只能对着二进制自己逆向。1.1 解析 GRF 到底有什么实际用途说几个真实的使用场景。第一是资产提取把某个版本的角色立绘、时装模型、BGM 音效整理出来这对做资料站、做二创内容非常有用。第二是工具链集成比如你在做 RO 私服的 Web 管理后台想直接在浏览器里预览玩家上传的补丁包内容一个纯前端解析器就是最合适的方案。第三是格式教学GRF 头部做了 XOR 混淆、文件表是变长记录、数据区采用 zlib 压缩这几乎是一个二进制解析从入门到进阶的完整样本。把这份格式吃透你再去拆其他游戏资源格式会顺手很多。1.2 为什么选择 JavaScript 而不是其他语言早几年社区里的 GRF 工具基本是 C# 写的 GRF Editor、Python 脚本和 Go 写的小工具。用 JavaScript 做最大的优势是跨平台且零安装。浏览器里打开一个 HTML 文件就能选文件解析Node.js 环境一行 npm install 也能把解析逻辑接进业务系统。而且现在TextDecoder、DataView、DecompressionStream这些 API 已经相当成熟处理二进制的体验并不比 C# 差。劣势是性能解压 1GB 级别的 GRF 时 JavaScript 确实会慢一些但干一套查询和提取工具完全够用。2. GRF 的容器结构头、表、数据三件套GRF 整体上可以拆成三块文件头Header、文件表File Table、数据区Data Area。理解这三块的关系解析思路就清晰了。用书来类比头部相当于封面和版权声明文件表是目录数据区是正文。要找到正文里某一页的内容先翻目录要读目录又得先打开封面拿到目录所在的页码。GRF 就是这样一本结构规整的书。2.1 头部字段与 XOR 解密GRF 的头部是固定长度但前 16 个字节做了加密处理。加密方式非常原始把每个字节和0x95异或一遍。解密之后能读到一个固定字符串作为魔数这类似 PNG 文件前面固定的那几个字节用于识别文件类型。解密后的头部还会跟几个 4 字节字段分别记录版本号、文件表偏移、文件表条目数和文件表总大小。这些字段一律使用小端字节序存储后面用 DataView 读取时必须把第二个参数传成true否则解析出来的偏移全是天文数字。2.2 文件表一条记录对应一个资源文件表是一个顺序排列的条目列表每个条目描述一个具体资源。GRF 的条目设计是变长记录开头 2 字节是文件名的字节长度紧跟着就是文件名本身之后是固定长度的资源属性包括压缩后大小、解压后大小、文件类型标志、数据区偏移。变长设计能省不少空间但代价是遍历时必须严格按照字段长度推进读取位置哪怕中间漏算了 1 个字节下一条条目就会从错误位置开始解析结果全乱。3. 从零写 grf-reader解析流程与核心代码现在进入正题。这一节我把在浏览器端实现 grf-reader 的完整流程写出来并在代码里做了注释。整体顺序是拿到文件字节 → 解密头部 → 读取固定字段 → 跳到文件表 → 逐条循环解析 → 拿到文件清单。3.1 读取文件字节浏览器端读取本地 GRF 最直接的方式是通过input typefile然后用file.arrayBuffer()一次性拿到整个文件的二进制内存。但 GRF 动辄几百 MB这个方法会把整个文件载入内存。文件不大或者内存充足时没问题如果文件特别大建议用后面第 6 节讲到的分段读取方案。3.2 解析头部function parseGRF(buffer) { const view new DataView(buffer); const header new Uint8Array(buffer, 0, 16); // 前 16 字节做 XOR 0x95 解密 for (let i 0; i 16; i) { header[i] ^ 0x95; } const magic new TextDecoder(latin1).decode(header); if (!magic.startsWith(Master of Magic)) { throw new Error(不是合法的 GRF 文件); } const version view.getUint32(16, true); const tableOffset view.getUint32(20, true); const entryCount view.getUint32(24, true); const tableSize view.getUint32(28, true); return { version, tableOffset, entryCount, tableSize, }; }这里要注意魔数判断。解密后的头部 16 字节如果直接用 UTF-8 解码可能因为某些字节不在有效 UTF-8 范围内而被替换成乱码所以用latin1等价于逐字节转字符是最安全的方式。比较时用startsWith而不是全等于是因为不同版本对结尾的填充字节处理不完全一致开头一致基本就能确认类型了。3.3 遍历文件表function readEntries(buffer, header) { const view new DataView(buffer); const entries []; let pos header.tableOffset; for (let i 0; i header.entryCount; i) { if (pos 2 buffer.byteLength) break; const nameLength view.getUint16(pos, true); pos 2; if (pos nameLength 16 buffer.byteLength) break; const nameBytes new Uint8Array(buffer, pos, nameLength); const name new TextDecoder(utf-8).decode(nameBytes); pos nameLength; const compressedSize view.getUint32(pos, true); const uncompressedSize view.getUint32(pos 4, true); const flags view.getUint32(pos 8, true); const dataOffset view.getUint32(pos 12, true); pos 16; // 0x103 及以上版本的条目会额外带 16 字节的 CRC 扩展信息 if (header.version 0x103) { pos 16; } entries.push({ name, compressedSize, uncompressedSize, flags, dataOffset, }); } return entries; }写文件表遍历时最重要的就是版本分支。很多时候解析到一半条目错乱不是格式理解错了而是拿 0x102 的条目长度去读 0x103 的文件导致后面的字段整体错位。我在代码里保留了header.version 0x103的判断这个分支在解析新客户端资源时几乎一定会用到。3.4 把流程串起来调用上面两个函数就能得到一份完整的文件清单。对一个真实的 data.grf 来说解析出的条目可能有数千条name 字段看起来像data\sprite\...、data\wav\...这样的路径。能做到这一步grf-reader 最核心的读取逻辑已经完成了剩下的就是根据需求提取和展示数据。4. 解压资源zlib 在 Node 和浏览器里的两种用法文件表里存了两个大小字段compressedSize 和 uncompressedSize。如果两者相等说明数据在 GRF 里是明文存储直接切片读出来就行如果不相等则说明数据用 zlib 压缩过需要先解压才能得到原始文件字节。GRF 数据区里大量资源都做了压缩处理尤其贴图、模型这类体积大的资产压缩率非常可观。4.1 判断是否需要解压function isCompressed(entry) { return entry.compressedSize ! entry.uncompressedSize; }这个判断逻辑看着简单实际使用中却可能遇到一种情况某些第三方补丁工具写出来的 GRFcompressedSize 和 uncompressedSize 不相等但落盘的数据却是未压缩的也就是标志字段和真实数据不一致。遇到这种情况直接解压会抛异常所以解压函数最好加上失败回退逻辑。4.2 Node.js 环境zlib 模块Node.js 中解压一份 zlib 数据非常直接const zlib require(zlib); function extractData(buffer, entry) { const start entry.dataOffset; const size entry.compressedSize; const data Buffer.from(buffer, start, size); if (!isCompressed(entry)) return data; try { return zlib.inflateSync(data); } catch (e) { // 失败时回退到原始数据 return data; } }Node 内置的inflateSync处理的是带 zlib 头的数据。GRF 内的压缩数据基本是标准 zlib 格式直接用没问题。如果你调试时遇到Error: invalid distance too far back之类的报错大概率是数据根本不是 zlib 流或者数据起始偏移读错了。4.3 浏览器环境DecompressionStream浏览器里没有 zlib 模块但现代浏览器提供了DecompressionStreamAPI让前端也能解压 zlib 数据async function inflateInBrowser(uint8Array) { const stream new Blob([uint8Array]) .stream() .pipeThrough(new DecompressionStream(deflate)); const result await new Response(stream).arrayBuffer(); return new Uint8Array(result); }DecompressionStream(deflate)对应的是标准 zlib 压缩格式。如果你遇到的是裸 deflate 流参数就要改成deflate-raw。我实测下来GRF 里大部分资源用的是前者但也碰到过直接以 deflate 流存储的文件所以两种参数都要预留。4.4 解压失败的排查顺序如果解压总是报错我的排查顺序是先确认数据偏移对不对再看 compressedSize 是否属实最后才怀疑压缩格式。很多所谓的解析问题其实是文件表里 dataOffset 拿错了导致切片切到了别的资源上。用 Hex Fiend 之类的工具打开 GRF跳到 dataOffset 位置对比十六进制开头比盲试代码高效得多。5. 实测把 GRF 里的登录图渲染到页面解析器能跑通之后我做了一个简单的 demo 页面选完 data.grf从文件表里找到登录界面的背景图解压后用img标签渲染到页面上。这是整个 grf-reader 项目里最有成就感的一步因为肉眼看到图片出现在浏览器里的瞬间你就能确信自己的解析逻辑是对的。5.1 定位目标资源先从文件列表里筛选出 jpg 后缀的文件const target entries.find((e) e.name.toLowerCase().endsWith(.jpg));GRF 文件名里可能存在中文或韩文路径分隔符是反斜杠。如果你要匹配子目录建议用includes而不是先split(/)再比对目录名。我在调试时就踩过这个坑在 Windows 风格路径里用正斜杠分割结果怎么都匹配不到目标资源。5.2 提取并渲染找到目标条目后从 buffer 中切出对应字节解压再生成 Blob URLconst raw new Uint8Array(buffer, target.dataOffset, target.compressedSize); const uncompressed await inflateInBrowser(raw); const blob new Blob([uncompressed], { type: image/jpeg }); const url URL.createObjectURL(blob); document.getElementById(preview).src url;这里有一个小细节如果 GRF 里存的不是 jpg而是 bmp、spr 这类格式img是没法直接显示的。demo 特意挑了 jpg 是为了绕开子格式解析让你先验证 GRF 读取本身的正确性。如果之后想解析 spr 精灵或 gat 地图还需要再单独写对应的子格式解析器那是另一块工作量了。6. 解析过程中最容易翻车的几个细节最后总结一下我实际开发 grf-reader 时遇到的问题。这些坑很隐蔽但任何一个都能让整个解析结果功亏一篑。6.1 文件名的编码问题GRF 里的文件名按原始字节存储没有强制指定编码。国际服客户端常用 ASCII 或 Latin-1韩服客户端则很可能是 EUC-KR。直接TextDecoder(utf-8)遇到非 UTF-8 字节时会变成替换符导致文件名显示成一堆问号。更麻烦的是TextDecoder(euc-kr)在大多数浏览器里并不支持。我在项目里做了折中方案尝试按 UTF-8 解码如果发现替换符就改用 Latin-1 兜底至少保证路径字节不丢失。如果你明确知道 GRF 来自韩服可以引入一个 EUC-KR 码表还原出完整的韩文文件名。6.2 大文件不要一次性整读前面例子为了简单用了file.arrayBuffer()把整个 GRF 读进内存。但一个超过 1GB 的 data.grf这样做会把浏览器内存直接拉满。优化思路是只读取关键部分头部固定 32 字节用file.slice(0, 32).arrayBuffer()读文件表虽然大但比数据区小得多可以用一次slice(tableOffset, tableOffset tableSize)读取定位到具体资源时再用slice(dataOffset, dataOffset compressedSize)精确读取目标数据。这样即使 GRF 有 2GB单次内存峰值也能控制住。6.3 版本不同文件表条目长度不同0x102 版本是本文前面代码里假设的结构也是 RO 早期最常见的版本。但 0x103 和 0x104 版本的文件表条目会在末尾追加 16 字节的扩展信息。如果拿 0x102 的固定长度去解析 0x103 文件文件表会越读越偏解析出来的 name 和 dataOffset 全是错的。解决方法是拿到头部 version 字段后先判断版本再决定每条目读取后多前进多少字节。这种版本差异导致的错位新手最容易忽略。6.4 DataView 越界问题GRF 文件数据损坏或者某些补丁头写错时文件表条目可能指向非法偏移。我实现时每读取一个字段前都做pos length buffer.byteLength的检查一旦越界就停止解析并返回已读到的条目。实际使用中这种容错比直接抛异常更友好至少用户能看到已经解析出来的部分列表而不是整个页面白屏。最后分享一个我自己的习惯每次拿到陌生格式的二进制文件先用十六进制编辑器打开看开头几十字节再做解析器。GRF 前 16 字节是加密的第一次看全是乱码但这恰恰提醒你要先处理 XOR 解密再去碰后面的字段。如果你也想试着手写一个解析器我建议从体积小的 GRF 开始对照偏移量逐步验证就算遇到版本差异也能很快定位问题出在哪一层。本文还有配套的精品资源点击获取