Thread

🛡️

Ballance/Database.tdb解体新書

BallanceというゲームのDatabase.tdbファイルの中身を解説する

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 ALNOT 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がどっちかわからなくなって壊しちゃうのを防ぐためである。

というわけで、これで晴れてハイスコアの名前などをいじれるようになった。
めでたしめでたし。

Replies (0)

No replies yet. Be the first to leave a comment!