Ballance/Database.tdb解体新書
2004年にAtariから発売されたBallanceというゲームのDatabase.tdbファイルについて説明する。
Steam版(2024年発売)が前提だが、たぶんCD版も変わらない。
Database.tdb?
ハイスコアとか設定が記録されている。
変な名前でハイスコアを登録してしまったら、より強い10スコアを記録するしかない。それはつらい。
改竄しよう。
中身
Database.tdbはぱっと見で改竄されないようにするためか、ファイル全体にスクランブルがかかっている。
それを解けば、バイナリデータがお出ましだ。
スクランブル
まずはスクランブルを解除しよう。
...\BuildingBlocks\TT_DatabaseManager_RT.dll (4DF5A3B83A9A3A2FCFF7A4C9EF4BD6EDB209F0B6A61C9D7C296DC7386E2706ED) の0x1EC1あたりにあるコードを引用すると、以下のような感じ:
10001ec1 8a45ff MOV AL,byte ptr [EBP + local_5]
10001ec4 c0c003 ROL AL,0x3
10001ec7 34af XOR AL,0xaf
10001ec9 f6d8 NEG AL
10001ecb 8845ff MOV byte ptr [EBP + local_5],AL
Cっぽく書くと、 -(rol8(byte, 3) ^ 0xAF) ということ。可逆操作のオンパレードである。
蛇足だがrol8はビットローテートである。Cっぽく書けば、 uint8_t rol8(uint8_t value, int shift){ return (value << shift) | (value >> (8 - shift)); } という操作である。
最初 NEG AL を NOT AL と読み違えたのはここだけの話だ。(1敗)
構造
そうすると生の値が見えてくる。ただしバイナリフォーマットである。
でもそんなむずかしくないので、適当に読む。
すると以下のような感じであることがわかる。(kaitai structです)
meta:
id: tdb
file-extension: tdb
encoding: utf-8
endian: le
seq:
- id: entries
type: top
repeat: eos
types:
top:
seq:
- id: name
type: strz
- id: size
type: u4
- id: st
type: struc
size: size
-webide-representation: "{name} {st.nelems} {st.metas}"
struc:
seq:
- id: nfields
type: u4
- id: nelems
type: u4
- id: rsvd
type: u4
- id: metas
type: stmeta
repeat: expr
repeat-expr: nfields
- id: values
type: sent(metas[_index].type, nelems)
repeat: expr
repeat-expr: nfields
stmeta:
seq:
- id: name
type: strz
- id: type
type: u4
-webide-representation: "{name} {type}"
sent:
params:
- id: type
type: u4
- id: nelems
type: u4
seq:
- id: svalues
type: strz
repeat: expr
repeat-expr: nelems
if: type == 3
- id: fvalues
type: f4
repeat: expr
repeat-expr: nelems
if: type == 2
- id: ivalues
type: u4
repeat: expr
repeat-expr: nelems
if: type == 1
解析を進めながら書いてるので名前が適当すぎる。そこは目を瞑ってほしい。
こんな感じでバイナリデータを読むと、以下のような構造が見えてくる:
- DB_Highscore_Lv01:
- Playername: ["Mr. Default", ...]
- Points: [1234, ...]
- DB_Highscore_Lv02:
- ...
- ...
- DB_Levelfreischaltung:
- Freigeschaltet?: [1, ...]
- DB_Options:
- Volume: [1.0]
- Synch to Screen?: [0]
- ...
急にドイツ語が出てきてびっくりするのがおもしろい。開発はドイツの会社だった気がするので、そういうことだろう。
脱線するが巷にある「全ステージ解放チート」は用意されたDatabase.tdbを上書きするスタイルであるが、そうするとハイスコアも上書きされてしまう。ここをいじればそんなことなく解放できる。といってもこのゲームはそこまでむずかしくないので、普通に解放したほうが早いかもしれない。
なかみいじり™
さて、構造が見えたので、あとはいじるだけである。
残念ながら私はKaitai Structの逆をやる方法を知らないので、構造が見えたということでcodecを手で書こう。(今ならAI使うべきかもね。)
# coding: utf-8
# ruby 3.4.4 (2025-05-14 revision a38531fd3f) +PRISM [x64-mingw-ucrt]
# delegate (default: 0.4.0)
# psych (default: 5.2.2)
# stringio (default: 3.1.2)
# yaml (default: 0.4.0)
require "delegate"
require "stringio"
require "yaml"
def filetype src
case File.binread(src,1,0).ord
when 0x62; return :tdb
#when 0x5b; return :human # json "{"
when 0x2D; return :human # yaml "-"--
else; raise "?unknown-file-type of #{src}"
end
end
module Rot
def rot value, nleft, width=8
0 <= nleft or return rot(value, width + nleft, width)
m = (1 << width) - 1
value &= m
((value << nleft) | (value >> (width - nleft))) & m
end
end
class TdbObfuscatedIO < Delegator
include Rot
def initialize underlying
@underlying = underlying
end
def __getobj__; @underlying; end
def read nbytes=nil
raw = @underlying.read nbytes
return raw.unpack("C*").map{ -(rot(it, 3) ^ 0xAF) }.pack("C*")
end
def write rawbytes
encoded = rawbytes.unpack("C*").map{ rot((-it) ^ 0xAF, -3) }.pack("C*")
return @underlying.write encoded
end
end
class MIO < Delegator
def initialize io
@io = io
end
def __getobj__; @io; end
def readu4; @io.read(4).unpack1 "L<"; end
def readf4; @io.read(4).unpack1 "f"; end
def readstrz
s = "".b
loop {
c = @io.read(1)
c.ord == 0 and break
s << c
}
s
end
def writeu4 v; @io.write [v].pack "L<"; end
def writef4 v; @io.write [v].pack "f"; end
def writestrz v; @io.write(v + "\0"); end
end
def readsoa rio
io = MIO.new rio
nfields = io.readu4
nelems = io.readu4
rsvd = io.readu4
rsvd == 0xFFFFffff or raise "?rsvd-not-ff #{rsvd.to_s 16}"
fields = nfields.times.map {
name = io.readstrz
type = io.readu4
{ name:, type: [nil,:u32,:f32,:strz][type] }
}
values = fields.map { |f|
case f[:type]
when :u32; nelems.times.map{ io.readu4 }
when :f32; nelems.times.map{ io.readf4 }
when :strz; nelems.times.map{ io.readstrz }
end
}
values.transpose.map{ |tu| fields.map{ it[:name] }.zip(tu).to_h }
end
def readtable rio
io = MIO.new rio
name = io.readstrz
size = io.readu4
sio = StringIO.new io.read size
{ name => readsoa(sio) }
end
def readdb io
ts = []
until io.eof
ts << readtable(io)
end
ts
end
# https://stackoverflow.com/a/47556711
# -> do-it-yourself https://docs.ruby-lang.org/en/3.2/Psych.html#method-c-dump
# -> node#value always stringified... fuck.
class QuoteYAMLTree < Psych::Visitors::YAMLTree
def visit_String o
@emitter.scalar o, nil, nil, true, true, Psych::Nodes::Scalar::DOUBLE_QUOTED
end
end
def quotedyaml obj
v = QuoteYAMLTree.create
v << obj
ast = v.tree
# Second pass, unquote keys
ast.grep(Psych::Nodes::Mapping).each do |node|
node.children.each_slice(2) do |k, _|
k.plain = true
k.quoted = false
k.style = Psych::Nodes::Scalar::ANY
end
end
ast.yaml
end
def totype obj
{Integer=>1,Float=>2,String=>3}[obj.class] or raise "?unknown-type #{obj.class}"
end
def writesoa rio, aos
fields = []
aos.first.each{ |k,v|
fields << { name: k, type: totype(v) }
}
fieldset = fields.map{ it[:name] }.to_set
aos.each{ |o|
fields.each { |f|
totype(o[f[:name]]) == f[:type] or raise "?aos-type-mismatch #{f[:name]}/#{f[:type]} of #{o}"
}
(o.keys.to_set - fieldset).empty? or raise "?extra-keys #{o}"
}
soa = aos.map{ |o| fields.map{ |f| o[f[:name]] } }.transpose
io = MIO.new rio
io.writeu4 fields.size
io.writeu4 aos.size
io.writeu4 0xFFFFffff
fields.each{ |f|
io.writestrz(f[:name])
io.writeu4(f[:type])
}
soa.each{ |a|
case a.first
when Integer; a.each{ io.writeu4 it }
when Float; a.each{ io.writef4 it }
when String; a.each{ io.writestrz it }
else; raise "?programError"
end
}
end
def writetab rio, tab
name, aos = tab.first
stab = StringIO.open{ |f| writesoa(f, aos); f.string }
io = MIO.new rio
io.writestrz name
io.writeu4 stab.size
io.write stab
end
def writedb io, arr
arr.each{ |t|
writetab io, t
}
end
def main src, to, dst
to == "to" or raise "?missing-to-keyword"
case filetype src
when :tdb # tdb to yaml
File.binwrite(dst, quotedyaml(File.open(src,"rb"){readdb TdbObfuscatedIO.new it}))
when :human # yaml to tdb
File.binwrite(dst, StringIO.open{ |f| writedb(TdbObfuscatedIO.new(f), YAML::safe_load(File.read(src))); f.string })
else; raise "?program-error unknown src filetype"
end
end
$0 == __FILE__ and main *ARGV
……なんか手元で書いてるライブラリをもってきたり、YAMLでstringだけquoteしてほしいがためだけに内部ゴリったりして大変なことになっているが、動けば官軍なのである。
このコードは、
ruby tdb.rb Database.tdb to database.ymlという風に書けばtdb→YAML変換し、ruby tdb.rb database.yml to Database.tdbという風に書けばその逆をやってくれる。
toをはさまないといけないのは、srcとdstがどっちかわからなくなって壊しちゃうのを防ぐためである。
というわけで、これで晴れてハイスコアの名前などをいじれるようになった。
めでたしめでたし。