news 2026/9/9 23:52:33

纯JavaScript解析GRF文件:从二进制格式到浏览器提取游戏资源

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯JavaScript解析GRF文件:从二进制格式到浏览器提取游戏资源

简介:这是一个基于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 也能把解析逻辑接进业务系统。而且现在TextDecoderDataViewDecompressionStream这些 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 type="file">,然后用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; }

这个判断逻辑看着简单,实际使用中却可能遇到一种情况:某些第三方补丁工具写出来的 GRF,compressedSize 和 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 URL:

const 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 开始,对照偏移量逐步验证,就算遇到版本差异,也能很快定位问题出在哪一层。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 23:51:37

E3 1231V3 + B85-D3H黑苹果EFI配置指南:从BIOS到Monterey完美驱动

简介&#xff1a;面向采用Intel Xeon E3 1231V3处理器与B85芯片组主板的黑苹果用户&#xff0c;这一EFI引导包是针对该平台定制的Clover/OpenCore引导配置&#xff0c;旨在解决macOS安装过程中常见的硬件兼容问题&#xff0c;让声卡、网卡与显示输出在安装后即可正常驱动。整个…

作者头像 李华
网站建设 2026/9/9 23:48:12

Qbot 量化交易平台:完全本地部署,跑通策略回测全流程

Qbot 量化交易平台&#xff1a;完全本地部署&#xff0c;跑通策略回测全流程 【免费下载链接】Qbot [&#x1f525;updating ...] AI 自动量化交易机器人(完全本地部署) AI-powered Quantitative Investment Research Platform. &#x1f4c3; online docs: https://ufund-me.g…

作者头像 李华