使用Ruby逆向工程Codemasters的BIGF存档格式
摘要
一位逆向工程师解释了如何使用纯Ruby从《TOCA Race Driver》中读取Codemasters的BIGF存档格式,重点介绍使用String#unpack方法进行二进制解码。
暂无内容
查看缓存全文
缓存时间: 2026/07/04 06:37
# 用 Ruby 读取二进制游戏格式
来源:<https://davidslv.uk/2026/06/30/reading-binary-in-ruby.html>
当你说“我要逆向工程一个二进制文件格式”时,人们想到的是 C、或者带 `struct` 的 Python、或者 Kaitai。没人会想到 Ruby。Ruby 是给 Web 应用、DSL 和愉悦体验用的;在大众想象中,它不是用来从 2003 年的赛车游戏中抠出字节浮点数的。但这个大众想象是错的。Codemasters 的 BIGF 归档格式的读取器——也就是存放 TOCA Race Driver 中 AI 数据的容器——是纯 Ruby 实现的、零依赖的,并且能读取四种不同游戏的归档。
我应该坦诚它是怎么来的:这是全程**与 AI 合作**完成的逆向工程——我负责掌舵、决定信任什么,并对照字节验证每一项主张;模型负责起草代码、回忆标准库的角落,并提出我随后测试的假设。接下来的内容就是 Ruby 中让这次合作变得真正愉悦的部分:**Ruby 字符串就是字节缓冲区,而 `String#unpack` 是一个隐藏在你眼前的小巧、快速的二进制解析器。**
## 字符串就是字节
首先需要内化的一点是:Ruby 的 `String` 不是“文本”。它是一个带有编码标签的字节序列。以二进制模式读取文件,你会得到原始的字节,可以像任何字符串一样索引和切片:
```ruby
data = File.binread("aib.big") # 整个文件作为一个 ASCII-8BIT 字符串
data[0, 4] # => "BIGF" — 前四个字节
data.bytesize # => 3448832
```
`File.binread` 是关键:它按二进制模式读取文件(`ASCII-8BIT`/`BINARY` 编码),因此不会有 UTF-8 解释器破坏你的 `0x80` 及以上的字节。接着,`data[offset, length]` 用于切出字节范围,`data.index(needle, from)` 用于在文件中寻找魔数或标记。这已经构成了解析器的大部分工作。
## `unpack`:你已有的二进制解码器
主力是 `String#unpack`(及其单值变体 `unpack1`)。你给它一个由指令组成的**格式字符串**,它就会解码这些字节。这里 90% 的工作由两个指令完成:
- **`V`**——无符号 32 位整数,**小端序**。BIGF 中的计数、块索引、偏移量和大小全部都是 `V`。
- **`e`**——**小端序单精度浮点数**(32 位)。AI 数据就是这些浮点数的数组:赛线坐标、控制值、填充数据。
```ruby
data[4, 4].unpack1("V") # => 39 — 条目数量,作为 u32 LE
data[12, 16].unpack("e4") # => [0.0, 0.0, 137.0, 0.0] — 四个 float32
```
端序存在于指令中,这至关重要:`V` 是小端序 u32,`N` 是大端序;`e` 是小端序浮点,`g` 是大端序。Codemasters 的 PC 游戏是小端序,所以全程使用 `V` 和 `e`。(后来我们处理 Xbox 360 文件时,它是大端序 PowerPC,那时就会用 `N` 和 `g`——只是格式字符串变了而已。)
`unpack` 在解释器内部是用 C 实现的,因此解码几十万个浮点数并不慢。你不会在这里付出“脚本语言”的代价。
## 遍历容器
BIGF 包含一个头部、一个目录和一个数据区。头部检查是一行代码:
```ruby
MAGIC = "BIGF".b
raise "not a BIGF archive" unless data[0, 4] == MAGIC
```
那个 `.b` 值得一个脚注:它返回字符串字面量的二进制副本,因此比较是逐字节的,不受源文件编码影响。我把它用在每一个二进制常量上。
BIGF 有两种目录布局。一种是**固定 24 字节记录**的扁平表——`char name[16]; u32 size; u32 offset`——这是一个教科书式的 `unpack` 循环:
```ruby
count = data[4, 4].unpack1("V")
base = data[8, 4].unpack1("V") # 数据区基址,从头部读取(不做假设!)
off = 0x24 # 记录从 0x20 头部 + 4 字节填充后开始
count.times do
rec = data[off, 24]
name = rec[0, 16].split("\x00").first.to_s # NUL 终结的名称字段
size, offset = rec[16, 8].unpack("V2") # 一次取出两个 u32
members << Entry.new(name:, offset: base + offset, size:)
off += 24
end
```
这里三个小的 Ruby 便利功能承担了实际工作。`rec[0, 16].split("\x00").first` 将一个固定宽度、NUL 填充的 C 字符串转换为 Ruby 字符串。`unpack("V2")` 一次取出**两个**整数(计数后缀)。还有一点——一个来之不易的细节——`base` 是从头部 0x08 处的字段读取的,而不是硬编码,因为测量 1371 个真实文件后发现它并不总是人们假设的 `0x800`。
另一种布局是可变长度:名称之间穿插着 `0x44 00 00 00` 标记。这时 `String#index` 就大放异彩了——你扫描扩展名,向前回溯到前一个 NUL 以找到名称起始位置,然后查看紧接其后的标记:
```ruby
while (idx = data.index(".aib", pos)) && idx < limit
s = idx
s -= 1 while s.positive? && data.getbyte(s - 1) != 0 # 回溯到 NUL
name = data[s...(idx + 4)]
# ...标记后跟着块索引...
pos = idx + 4
end
```
`getbyte` 读取单个字节作为整数,而不分配子字符串——这在紧凑的回溯扫描中正是你想要的。
## 解码内部的记录
切出一个成员只是一个切片——`data[entry.offset, entry.size]`——而 Ruby 的切片是**安全**的:请求文件末尾之后的字节,你会得到一个短字符串或 `nil`,永远不会崩溃。
在 AI 配置文件中,每 16 个字节就是四个 float32,解析器按位模式对每条记录进行分类:
```ruby
SENTINEL = "\x3f\x3f\x3f\x3f".b.unpack1("e") # => 0.7470588... (填充值)
KTAG_MAGIC = "\x0c\x00\x00\x00\x08\x00\x00\x00".b
def classify(bytes)
return [:ktag, bytes[8, 4].unpack1("e")] if bytes[0, 8] == KTAG_MAGIC
a, b, c, d = bytes.unpack("e4")
if [a, b, c, d].all? { |x| (x - SENTINEL).abs < 1e-5 }
then :pad
elsif a.zero? && b.zero? && c.zero? && d.zero?
then :zero
elsif b.zero? && d.zero?
then :scalar # (v,0,v,0)
elsif [a,b,c,d].all? { |x| coordish?(x) }
then :path # (x,y,x,y)
else
:other
end
end
```
那行 `SENTINEL` 是一个小小的乐趣:没有人需要去计算器上查“`0x3f3f3f3f` 是什么浮点数?”——我们让 Ruby 通过解包这四个字节来告诉我们(`0.7470588...`)。分类器几乎读起来像散文,当散文本身就是你试图确定格式规范时,这很重要。
一个真正的陷阱隐藏在 `coordish?` 中:某些 16 字节记录,作为浮点数读取时,是非规约数或 `NaN`。Ruby 的 `Float#nan?` 和幅度检查可以干净地处理——但你必须记住,对于 `NaN`,`x == x` 是 `false`,所以守卫条件是 `!x.nan? && x.abs < 1e30 && ...`,而不是朴素的比较。(如果你写了 `x == x` 这个技巧,RuboCop 甚至会唠叨你改用 `nan?`。)
## 为什么是 Ruby,具体来说
做完这件事后,Ruby 在二进制逆向工程任务上的理由变得具体:
- **字符串即缓冲区 + 切片**使导航符合人体工程学——没有游标对象,没有 read/seek 仪式,只有 `data[off, len]`。
- **`unpack` 是一个完整、快速、C 后端的二进制解码器**,每个整数和浮点数的宽度与端序都有一个单字符词汇。
- **零依赖**。整个读取器只用标准库。一个五年后要在陌生人机器上运行的研究工具,不应该依赖一个 API 已经漂移的 gem。
- **它读起来像规格说明**。当分类一条记录的代码短到能装进脑子里时,代码**就变成了**你对格式的文档——而这就是逆向工程的全部意义。
- **REPL 闭环**。在实际工作中,`irb` 配合 `File.binread` 和一行 `unpack`,是问“偏移 `0x5c00` 处是什么?”并在想法还没完成之前就得到答案的最快方式。
陷阱很少,而且全都没偏离二进制领域:用 `binread` 读取,用 `.b` 编写二进制常量,正确地设置端序指令(`V`/`e` 而非 `N`/`g`),需要单个值时用 `unpack1` 而非数组,并尊重 `NaN`。这些都不是 Ruby 的错;它们只是二进制本身。
一个 2003 年赛车游戏的 AI、一个四字节魔数、一个偏移量表、以及几十万个小端序浮点数——全部由二十行标准库 Ruby 读取。人们用来写 `has_many :comments` 的语言,结果是一个完美的反汇编笔记本——而且,搭配一个永不厌倦解包下一个十六字节的 AI,速度也很快。
---
### 参考资料
完整的读取器是开源的——`String#unpack` 在两个表布局和四个游戏中全力工作:
- **仓库:**`github.com/davidslv/bigf` (https://github.com/davidslv/bigf)(MIT)
- 容器解析器:`lib/bigf/archive.rb` · 记录解码器:`lib/bigf/toca/profile.rb`
相似文章
Show HN:我逆向工程了《Test Drive III》(1990年DOS游戏)的世界地图
对1990年DOS游戏《Test Drive III》的地图进行了逆向工程和提取,提供了在线查看器和OBJ导出功能。该项目包含文件格式规范及提取工具。
使用ImHex逆向工程未知文件格式
本文展示了如何使用ImHex这款免费开源的十六进制编辑器来逆向工程游戏FEZ的存档文件格式,提供了从二进制分析到模式定义的实用指南。
Captain Bible 逆向工程
Peter Kelly 的仓库记录了 1990 年代 DOS 游戏《Captain Bible in the Dome of Darkness》的逆向工程,提供了可重现的 QEMU 环境、研究笔记和洁净室引擎规范。
从零开始的反编译项目:Claude Code与2001年GBA游戏的51%
文章详细介绍了使用Claude Code启动一个2001年GBA游戏的反编译项目,实现了51%的反编译并发现了一个隐藏的彩蛋。
Speeding Up (small) Ruby Hashes
A deep dive into Ruby's internal ar_table structure for small hashes, explaining the linear lookup mechanism and exploring potential optimizations.