← 返回信息流
AI 资讯Hacker News·3 天前

用Ruby逆向解析Codemasters的BIGF存档格式

原标题:Reverse-engineering Codemasters' BIGF archive format in Ruby

速览

本文详细描述了使用Ruby语言对Codemasters游戏公司的BIGF存档格式进行逆向工程的过程。作者分析了文件结构、解包方法,并提供了实现代码。该研究有助于游戏模组开发或数据提取。

AI 深度解读

背景

逆向工程二进制文件格式通常被认为是一项需要 C、Python 的 struct 模块或 Kaitai Struct 等专用工具的任务,很少有人会想到用 Ruby 来完成。Ruby 在多数人的印象中是用于 Web 应用、DSL 和愉悦编程的语言,而不是用来解析 2003 年赛车游戏中的二进制浮点数。然而,这种普遍印象是错误的。Codemasters 的 BIGF 存档格式(用于存储 TOCA Race Driver 中 AI 数据的容器)的读取器,正是用纯 Ruby 编写的,无任何外部依赖,并且能够读取四款不同游戏的存档文件。值得一提的是,整个逆向工程过程全程由 AI 辅助完成——人类负责决策、验证每个字节的准确性;模型负责编写代码、回忆标准库中的细节并提出可供测试的假设。

核心内容

字符串即字节

Ruby 的 String 并不等同于“文本”。它本质上是带有编码标签的字节序列。以二进制模式读取文件时,会得到原始的字节序列,可以像普通字符串一样索引和切片:

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)。将格式字符串指令传给它,它会解码字节。在 BIGF 格式中,90% 的工作由两个指令完成:

  • V —— 无符号 32 位整数,小端序。BIGF 中所有的计数、块索引、偏移量和大小都是 V
  • e —— 小端序单精度浮点数(32 位)。AI 数据就是这些浮点数的数组:赛车线坐标、控制值、填充数据。

例子:

data[4, 4].unpack1("V")    # => 39 — 条目数,无符号 32 位小端序
data[12, 16].unpack("e4")  # => [0.0, 0.0, 137.0, 0.0] — 四个 float32

字节序(endianness)体现在指令中:V 是小端序 u32,N 是大端序;e 是小端序浮点数,g 是大端序。Codemasters 的 PC 游戏使用小端序,因此全部使用 Ve。(当后来分析 Xbox 360 文件(大端序 PowerPC)时,则需使用 Ng——只需要修改格式字符串。)

unpack 在解释器内部是用 C 实现的,因此解码几十万个浮点数并不慢,不需要付出“脚本语言”的性能代价。

遍历容器

BIGF 格式包含头部、目录和数据部分。头部检查一行代码即可完成:

MAGIC = "BIGF".b
raise "not a BIGF archive" unless data[0, 4] == MAGIC

这里 .b 方法值得注意:它返回字符串字面量的二进制副本,因此无论源文件编码如何,比较都是逐字节进行的。在二进制常量中全都使用了这个技巧。

BIGF 有两种目录布局。第一种是固定 24 字节记录的平面表:char name[16]; u32 size; u32 offset——这正是 unpack 循环的经典场景:

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 字段读取的,而不是硬编码,因为分析 1,371 个实际文件后发现它并不总是人们假设的 0x800。

第二种目录布局是可变长度的:名称与一个 0x44 00 00 00 标记交错排列。这正是 String#index 的用武之地——扫描扩展名,后退到前一个 NUL 找到名称起点,再在标记后查找:

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 profile 中,每 16 字节包含四个 float32,解析器根据位模式对每条记录进行分类:

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 解包这 4 个字节得到答案(0.7470588…)。分类器读起来几乎像自然语言,这对于正在确定格式规范的过程非常重要。

一个真正的陷阱在于 coordish? 方法:一些 16 字节记录作为浮点数读取后可能是非规范数或 NaN。Ruby 的 Float#nan? 和幅度检查可以妥善处理——但必须记住,x == x 对于 NaN 为假,因此防护应该是 `!x.nan? &&

查看原文 →davidslv.uk