COB / BOS — ユニットスクリプトのバイトコード
Total Annihilationのユニットはどれもスクリプトを実行しており、それによってピースを動かし、武器の発射炎を出し、効果音を鳴らし、ゲームのイベントに応答します。ソース言語はBOS(「Basic Object Script」)で、エンジンが実際に実行するコンパイル済みのバイトコードはCOB(「Cobble」。「compiled BOS」をもじったCavedogの駄洒落)です。ファイルはユニット1体につき1つで、FBIにあるそのユニットの
ObjectNameにちなんだ名前が付きます。
Tip
自分で試す
# Decompile a unit's bytecode back to readable BOS
kbot cob decompile scripts/ARMCOM.cob
# Disassemble to opcode-level assembly with control-flow arrows
kbot cob disassemble scripts/ARMCOM.cob -a
# Lint a whole directory for common script issues
kbot cob lint scripts/ -q
# Round-trip the entire scripts/ tree to verify byte-perfect compile
kbot cob roundtrip scripts/
すべてのフラグ(とlintルールの完全な表)については、CLIのkbot cobリファレンスを参照してください。
Goから使う場合は、kbot-ioのformats/scriptingを使います。
import "github.com/coreprime/kbot-io/formats/scripting"
cob, _ := scripting.LoadFromFile("scripts/ARMCOM.cob")
fmt.Printf("%d scripts, %d pieces\n", cob.NumScripts, cob.NumPieces)
for i, name := range cob.ScriptNames {
fmt.Printf(" [%d] %s\n", i, name)
}
2つの言語、1つのファイル形式
| 段階 | 形式 | ツール |
|---|---|---|
| ソース | BOS — Cに似た構造化言語 | 手書き。SwiteckのScriptor。kbot cob decompileでCOBからラウンドトリップ |
| コンパイル済み | COB — スタックベースのバイトコード | エンジンが読み込む。Scriptorまたはkbot cob compileで生成 |
エンジンが目にするのはCOBだけです。BOSは人間が書くための便宜上のもので、「ユニットのスクリプトを編集する」というのは、実際にはBOSのソースを編集し、COBに再コンパイルし、そのCOBをユニットのアドオンの.ufoに入れて配布することを指します。
最小限の例:
// armcom.bos — fragment from the Arm Commander
piece base, pelvis, lthigh, lleg;
#define SIG_WALK 2
Create() {
hide pelvis;
set-signal-mask SIG_WALK;
}
Walk() {
set-signal-mask SIG_WALK;
while (1) {
turn lthigh to x-axis [-25] speed [50];
turn lleg to x-axis [50] speed [100];
wait-for-turn lthigh around x-axis;
sleep 100;
}
}
kbot cob compileはこれをバイナリのブロブに変換します。kbot cob disassemble -aはオペコードのストリームを制御フローの矢印と並べて表示します。kbot cob decompileはその逆を行い、ほぼ必ずバイト単位で同一にラウンドトリップするBOSを復元します(kbot cob roundtripがゲームのカタログ全体でこれを検証します)。
ソースは、.version 6で始まらない限りTAのスクリプト(COBバージョン4)としてコンパイルされます。コンパイラーは、TA 3.1cが誤って実行してしまうものを、プレースホルダーを書き出して済ませたりせず拒否します。TA: Kingdomsの構文(play-sound、Mission-Command、__tak_math_*、.sound_name)と%は、TAのスクリプトではエラーになります(TAには剰余の命令がなく、かつて%の変換先だったワード0x10037000は、TAではビット単位のXORです)。未知の識別子や、ゲームの32個のスタックスロットより多くを必要とする関数もエラーです。コンパイルはできるものの確認したほうがよいもの、つまり2回定義された関数(ゲームの名前検索と同じく、呼び出しはすべて最初の定義に結び付きます)は、TA互換性のlintルールによる指摘とあわせて警告として表示されます。--strictを付けると、警告でもコマンドが失敗します。kbot cob assembleは、手書きのリスティングに同じチェックを適用します。
COBのディスク上のレイアウト
Important
2つの方言。この節では、Total Annihilationの方言であるCOB v4を説明します。TA: KingdomsはCOB v6(VersionSignature == 6)を使います。v6は以下の内容をすべて保ちつつ、ヘッダーを8バイト拡張し、ピース名の配列の後にコマンド文字列のオフセットテーブルを1つ追加しています。v6の詳細はTA: Kingdoms — COB v6の差分を参照してください。オペコードの一覧は両方に当てはまり、TAKだけの追加オペコードは付録Cで解説しています。
typedef struct { // 44-byte header (11 × uint32)
uint32 VersionSignature; // 4 for TA, 6 for TA: Kingdoms
uint32 NumScripts; // Number of functions (Create, Killed, …)
uint32 NumPieces; // Number of named pieces
uint32 LengthOfScripts; // Total code size, in DWORDs
uint32 NumberOfStaticVars; // Static-variable count
uint32 UKZero; // Always 0 in retail bytecode; purpose unknown
uint32 OffsetToScriptCodeIndexArray; // → uint32 × NumScripts: code-index per script
uint32 OffsetToScriptNameOffsetArray; // → uint32 × NumScripts: absolute offsets into string pool
uint32 OffsetToPieceNameOffsetArray; // → uint32 × NumPieces: absolute offsets into string pool
uint32 OffsetToScriptCode; // → packed uint32 opcode stream
uint32 OffsetToNameArray; // Byte just past the piece-name offset array (= sound-name offset table start for TAK v6, or string-pool start for TA v4)
} COBHeader;
Note
正式なフィールド名は、ScriptorのHEADER_COB構造体に合わせています。以前のkbotのリビジョンではLengthOfScripts / NumberOfStaticVars / UKZero / OffsetToNameArrayを「Unknown0/1/2/3」と呼んでいました。オフセットは同じで、判明した意味を名前に反映しただけです。
その後に、次のレイアウトで続きます。
┌─ Script code ─────────────────────────────┐ uint32 opcodes/operands, packed
├─ Script code-index table ─────────────────┤ uint32[NumScripts] — *not* byte offsets
├─ Script name offset table ────────────────┤ uint32[NumScripts]
├─ Piece name offset table ─────────────────┤ uint32[NumPieces]
└─ String pool ─────────────────────────────┘ NUL-terminated names, in order
スクリプトの解決
コードインデックスのテーブルに入っているのは、uint32のワードオフセットで、バイトオフセットではありません。スクリプト番号iのバイトコードを見つけるには、次のようにします。
uint32 first_instr_word = ScriptCodeIndexArray[i];
uint32 byte_offset = OffsetToScriptCode + first_instr_word * 4;
スクリプトは、次のスクリプトが始まる位置で終わります(最後のスクリプトの場合は、インデックステーブルの先頭で終わります)。
Important
名前テーブル内の文字列オフセットは、文字列プールからの相対位置ではなく、ファイル内の絶対オフセットです。Cavedogのツールは文字列プールを動かさなかったので、実際には問題になりませんでした。COBを手で組み替えるなら、すべてのオフセットを更新しなければなりません。
オペコードのストリーム
COBはスタックベースの仮想マシンです。ほとんどのオペコードは、スクリプトごとのデータスタックから引数をポップし、処理を行い、結果をプッシュして戻します。インラインオペランド(ジャンプ先、ピース番号)を持つオペコードもあります。
各オペコードは1つのuint32で、レイアウトは次のとおりです。
MSB LSB
┌─────────┬─────────┬─────────┬─────────┐
│ 0x10 │ family │ op │ flags │
└─────────┴─────────┴─────────┴─────────┘
最上位バイトは常に0x10で、family/op/flagsはオペコードによって異なります。ゲームはワード全体を比較しません。word & 0x100FF000で処理を振り分けるので、このマスクの外のビットは無視され、0x10064001はJUMPとして実行されます(kbotの逆アセンブラーは、こうしたワードを変更なしにアセンブルし直せるようJUMP@0x10064001と書きます)。PUSH(0x10021000)とPOP(0x10023000)は、さらに下位3ビットから読み出し元や書き込み先を読み取ります。kbot-ioのscripting/opcodes.goがカタログで、scripting.Opcodes()はすべての命令を、インラインオペランドとスタック効果とともに一覧にします。
オペコードのファミリー
| ファミリー | 例 | 備考 |
|---|---|---|
0x100x |
MOVE、TURN、SPIN、SHOW、HIDE、EMIT_SFX |
ピースのアニメーション。ピース番号と軸番号がインラインで続きます。 |
0x101x |
WAIT_FOR_TURN、WAIT_FOR_MOVE、SLEEP |
非同期のバリア。条件が満たされるまで、スクリプトは制御を譲ります。 |
0x102x |
PUSH_CONST、PUSH_LOCAL、PUSH_STATIC、POP_* |
スタックの操作。 |
0x103x |
ADD、SUB、MUL、DIV、BITWISE_AND、BITWISE_OR、XOR、NOT |
算術とビット演算。剰余はありません。 |
0x104x |
RAND、GET_UNIT_VALUE、GET、IS_CARRYING_UNIT、CARRIER_UNIT_ID |
エンジンへの問い合わせ。 |
0x105x |
<、<=、>、>=、==、!=、&&、||、XOR_ALT、! |
比較と論理演算。XOR_ALTは論理XORではなく、ビット単位のXORです。 |
0x106x |
START_SCRIPT、CALL_SCRIPT、DISCARD_CALL、JUMP、RETURN、JUMP_IF_FALSE、SIGNAL、SET_SIGNAL_MASK |
制御フローと並行処理。 |
0x107x |
EXPLODE、PLAY_SOUND |
特殊効果。PLAY_SOUNDはTA: Kingdoms専用です。 |
0x108x |
SET_VALUE、ATTACH_UNIT、DROP_UNIT |
エンジンの状態の変更。 |
小さいながらも代表的な例:
0x10021001 PUSH_CONST <value> ( -- v )
0x10021002 PUSH_LOCAL <var#> ( -- v )
0x10023002 POP_LOCAL <var#> ( v -- )
0x10033000 MUL ( a b -- a*b )
0x10051000 LESS_THAN ( a b -- a<b )
0x10066000 JUMP_IF_FALSE <byteOffset> ( v -- ) pops; falls through if v != 0
0x10064000 JUMP <byteOffset> ( -- )
0x10065000 RETURN ( v -- ) pops the return value
0x10001000 MOVE <piece> <axis> ( speed distance -- )
0x10003000 SPIN <piece> <axis> ( speed -- )
0x1000F000 EMIT_SFX <piece> ( flags -- )
0x10068000 SET_SIGNAL_MASK ( mask -- )
Note
ジャンプのオフセットは、(現在の命令ではなく)スクリプトのコードセクションの先頭からのバイトオフセットです。JUMP 0x4Cは「OffsetToScriptCode + 0x4Cへジャンプ」を意味します。逆アセンブラーの-aフラグは、対応する命令の間に矢印を描いて、これをわかりやすくします。
並行処理モデル
1つのユニットは、Walk、Aim、SmokeUnit、MoveWatcherなど、多くのスクリプトを同時に実行できます。これらは協調的にスケジュールされます。
sleep <ticks>:Nティック(1ティックは1/30秒)のあいだ制御を譲ります。wait-for-turn <piece> around <axis>:そのピースが現在のturnアニメーションを終えるまで制御を譲ります。wait-for-moveも同様です。signal <n>:signal_mask & n != 0であるスクリプトをすべて停止させます。set-signal-mask <n>:現在のスクリプトが関心を持つシグナルを宣言します。
シグナルマスクは、スクリプトがプリエンプション(割り込み)を実現する手段です。MoveWatcherはマスクSIG_MOVEを設定して速度を監視し、ユニットが止まるとSIG_MOVEを送って、待機ポーズを始める前に歩行アニメーションをきれいに停止させます。
Warning
SET_SIGNAL_MASKは関心を登録するだけで、何も停止させません。マスクXが設定されたスクリプトを止めるのは、signal Xを送ることです。kbot cob lintのsignal-never-signalledルールは、誰もシグナルを送らないマスクを設定しているスクリプトを検出します(これはほぼ必ずバグです)。
プリエンプションの実際の流れ
よくあるパターンは、MoveUnitがWalkアニメーションのスクリプトを起動し、移動が止まると、Idleが始まる前にきれいに後片付けするためにエンジンがSIG_MOVEを送る、というものです。流れは次のとおりです。
Engine MoveUnit script Walk script Idle script
│ │ │ │
│ unit ordered to move │ │ │
├─ start-script MoveUnit──▶│ │ │
│ ├─ set-signal-mask │ │
│ │ SIG_MOVE │ │
│ ├─ start-script Walk─▶│ │
│ │ ├─ set-signal-mask │
│ │ │ SIG_MOVE │
│ │ │ (loops: │
│ │ │ turn pieces, │
│ │ │ sleep, repeat) │
│ │ │ │
│ movement completes │ │ │
├─ engine sets velocity=0 │ │ │
│ ├─ signal SIG_MOVE ──┐│ │
│ │ ││ │
│ ◀─── KILLED ────────────────────────────── ◀┤ │
│ ◀─── KILLED ──────────┤ │ │
│ │
├─ start-script Idle ─────────────────────────────────────────────▶│
│ │ (loops)
注目すべき点は3つあります。
- シグナルが発生すると、
SIG_MOVEを設定した両方のスクリプトが停止します。MoveUnit自身も含まれます(仕事は終わっているので問題ありません)。 - シグナルを送るスクリプト(
MoveUnit)は、停止させられる前にシグナルを送るところまでは生き残ります。ランタイムはsignalオペコードから戻った後でマスクを確認するからです。 IdleはSIG_MOVEを設定しないので、次のsignal SIG_MOVEの影響を受けません。
実例 — cortruck.cob(CORE建設トラック)
kbot cob decompile scripts/cortruck.cob:
piece base;
SmokeUnit() {
var local_0, local_1, local_2;
while (get BUILD_PERCENT_LEFT) { sleep 400; }
while (1) {
local_0 = get HEALTH;
if (local_0 < 66) {
local_2 = (256 | 2);
if (rand(1, 66) < local_0) { local_2 = (256 | 1); }
emit-sfx local_2 from base;
}
local_1 = (local_0 * 50);
if (local_1 < 200) { local_1 = 200; }
sleep local_1;
}
return 0;
}
Create() { return 0; }
Killed() {
var severity;
if (severity <= 25) { explode base type (32 | 256); return 0; }
if (severity <= 50) { explode base type (32 | 256); return 0; }
if (severity <= 99) { explode base type (32 | 256); return 0; }
explode base type (32 | 256);
}
対応する逆アセンブル(kbot cob disassemble -a -s SmokeUnit)を見ると、実際のオペコードのストリームが、while/ifの本体を貫くJUMP_IF_FALSE/JUMPの矢印付きで表示されます。逆コンパイラーの出力があいまいなときに役立ちます。
lintによる検査
kbot cob lint(とアセットエクスプローラーのLintタブ)は、kbot-ioの既定のルールセットを実行します。内容は、Cavedog自身のスクリプトと、コミュニティから寄せられたMODの修正パッチに見つかった実際のバグに基づくスタイルルール、ファイル単位のチェック2つ、そしてバージョン6を宣言していないすべてのCOBに対するTA 3.1c互換性ルールです。スタイルルールの一覧:
| ルール | 重大度 | 検出するもの |
|---|---|---|
unused-piece |
warning | 宣言されているが、どのスクリプトでも使われていないピース。 |
unused-static / unused-local |
warning | 確保されたが、一度も読まれない変数。 |
always-true / dead-code |
info / warning | if(1) / while(0)。 |
long-function / high-complexity |
warning | 命令数が100を超える、または循環的複雑度が15を超える。 |
invalid-call |
error | 存在しない関数へのcall-script。 |
speed-zero |
warning | speed <0>のmove/turn。アニメーションが終わりません。 |
empty-function |
info | 関数の本体がreturn 0だけ。 |
duplicate-animation |
warning | 同一のアニメーション命令が2つ連続している。 |
sleep-only-guard |
info | ifブロックの中身がsleepだけ。 |
duplicate-if |
info | 同じ条件のifが2つ連続している。 |
raw-signal |
info | signal SIG_AIMではなくsignal 4と書いている。 |
unnamed-global |
info | 名前付きの静的変数ではなくglobal_3を使っている。 |
signal-never-signalled |
warning | 誰も送らないシグナルを、スクリプトがマスクしている。 |
recursive-call |
warning | call-scriptの循環。 |
duplicate-function |
warning | 2つのスクリプトが同じ名前を持つ。ゲームが呼ぶのは最初のものだけ。 |
malformed-cob |
error / warning | ファイルが読み込めない、またはスクリプトのコードが途中で切れている(error)。リーダーが許容した名前テーブル(warning)。 |
TA互換性ルールは、TA 3.1cが異常終了するものや、異なる動作をするものを報告します。これらはエラーで、ほかのルールと同じく--ciのSARIF出力に現れ、TA: Kingdoms(バージョン6)のファイルには適用されません。
| ルール | 検出するもの |
|---|---|
ta-kingdoms-opcode |
TAのスクリプト内のPLAY_SOUND、MISSION_COMMAND、TAK_MATH_*演算子。TAにはこれらのハンドラーがなく、スクリプトはそこに到達すると異常終了します。 |
ta-unknown-opcode |
どのゲームも実行しないオペコードのワード。 |
ta-push-flags |
下位ビットが1(定数)、2(ローカル)、4(静的)のいずれでもないPUSH、または下位ビットが2か4でないPOP。一部のツールが書き出していた、フラグのない0x10021000や0x10021008も含みます。 |
ta-stack-limit |
32個を超えるSTACK_ALLOC、またはローカル変数と積まれている値の合計がゲームの32個のスタックスロットを超えるもの。ゲームはこれを検査せず、スクリプトの後ろのメモリを上書きします。 |
ta-get-arguments |
積まれている値が5個未満の状態で到達するGET。GETは常にポート1つと引数4つをポップするので、引数1つのgetは残りを関数のローカル変数から取り、それらを上書きしてしまいます。 |
ta-stack-underflow |
それ以外の、積まれている以上の値をポップする命令、またはスタックの深さが異なる経路の合流。 |
ta-discard-call |
引数が4つを超えるDISCARD_CALL。 |
ルールの詳しい説明はkbotのREADMEにあります。逆コンパイラーは、元の変数とピースの番号付けを意図的に保ちます。そのため、逆コンパイル→lint→修正→コンパイルのサイクルを回すと、手入れが必要なMODが浮かび上がります。
注意点
Warning
call-scriptは0x10062000です。0x10063000は別の命令DISCARD_CALLで、同じ2つのインラインワード(スクリプト番号と引数の数)を持ちますが、引数をポップするだけで何も実行しません。製品版のスクリプトでこれを使うものはありません。kbotの逆コンパイラーは、これを__discard_call(word, args…)と書きます。
Warning
公開されているオペコード表のいくつかは、ビット演算のワードの名前を間違えています。TAは0x10037000をビット単位のXOR(剰余ではありません。TAには剰余の命令がありません)、0x10038000を単項のビット単位のNOT(XORではありません)、0x10059000を2つ目のビット単位のXOR(論理XORではありません。6 XOR_ALT 3は5)として実行します。0x1003A000はTA: Kingdomsの演算子で、TAでは異常終了します。古い名前(MOD、BITWISE_XOR、BITWISE_NOT、LOGICAL_XOR)で書かれたリスティングも、元になったワードにアセンブルされます。
- COBヘッダー内のオフセットはすべてファイル内の絶対オフセットですが、
ScriptCodeIndexArrayのエントリはコードセクション内のワードオフセットです。混同しやすいので注意してください。 - オペコード内のピース番号は、ピース名テーブルへのインデックスです。文字列ではありません。COBは名前を文字列プールとして持ちますが、ランタイムは速度のためにインデックスを使います。
get UNIT_VALUEのオペコードは、TAとTA: Kingdomsで微妙に異なります。TA: Kingdomsは魔力の回復やコマンドポイントなどの値IDを追加しており、TAの値テーブルはもっとコンパクトです。kbotの逆コンパイラーは現在TAの値に名前を付けており、TAKの21以上のポート番号は数値リテラルとして出力されます(それでもアセンブラーを通したラウンドトリップは成り立ちます)。差分の全体はTA: Kingdoms — COB v6の差分を参照してください。- 変数には2種類あります。
local_N(関数ローカルのスタックスロット)とglobal_N(呼び出しをまたいで保持される、ユニットごとの静的変数)です。逆コンパイラーは既定でlocal_N/global_Nを出力するので、読みやすくするにはソース上で名前を付け替えてください。 - CavedogのScriptorツールは、ときどき
CURRENT_SPEEDをリテラルの29としてインライン化します。そのため、ラウンドトリップ後にMoveWatcher型のスクリプトが壊れます。kbotの逆コンパイラーはシンボル名を復元します。どうしてもScriptorでラウンドトリップする必要があるなら、括弧で囲んでください((get CURRENT_SPEED) > 5)。
典型的なサイズ
| アセット | Cavedogのscripts/*.cobで見られる範囲 |
|---|---|
| ファイルサイズ | 0.5 KB〜25 KB |
| ヘッダーのオーバーヘッド | 常に44バイト |
| ユニットあたりのスクリプト数 | 3〜30(一般的なユニット:5〜10) |
| ユニットあたりのピース数 | 3〜80(クロゴス ≈ 80) |
| コードセクションのサイズ | 300 B〜20 KB(複雑さによって大きく変わる) |
armcom.cob(コマンダー) |
約14 KB、30スクリプト |
cortruck.cob(単純なユニット) |
約750 B、3スクリプト |
TA: Kingdoms — COB v6の差分
Note
この節はTA:Kだけの差分です。ここまでの内容は、すべてCOB v4(TA)の説明です。TA: Kingdomsは、同じスタックVM、同じ文字列プール、同じオペコードのエンコード方式、同じスクリプト名・ピース名の仕組みを保っています。違いは、拡張されたヘッダー、ピース名の配列の後に追加された文字列テーブル、そして4つの新しいオペコードです。
v6のファイルレイアウト
┌─ 44-byte v4 header ──────────────────────────────────┐
│ VersionSignature == 6, NumScripts, NumPieces, … │
├─ Sub-header (8 bytes, TAK-only) ─────────────────────┤ (soundNameOffsetTableStart, soundNameCount)
├─ Script code (uint32 opcode stream) ─────────────────┤ same encoding as v4
├─ ScriptCodeIndexArray (uint32 × NumScripts) ─────────┤ unchanged
├─ ScriptNameOffsetArray (uint32 × NumScripts) ────────┤ unchanged
├─ PieceNameOffsetArray (uint32 × NumPieces) ──────────┤ unchanged
├─ SoundNameOffsetArray (uint32 × Count) ──────────────┤ TAK-only — count from sub-header[1]
└─ String pool ────────────────────────────────────────┘ script names → piece names → sound names (in that order)
8バイトのサブヘッダーは、OffsetToNameArray(バイト44で終わる標準の11 DWORDヘッダーの末尾)と、コードセクションの先頭(OffsetToScriptCode、通常は0x34 = 52)の間にあります。中身はリトルエンディアンのuint32が2つです。
| フィールド | 意味 |
|---|---|
[0] |
サウンド名オフセットテーブルの、ファイル内の絶対オフセット。常にOffsetToPieceNameOffsetArray + NumPieces × 4、つまりピース名オフセット配列の直後のバイトに等しくなります。標準のレイアウトから導けるので冗長で、ライターが再構築します。 |
[1] |
テーブル内のサウンド名の数。こちらが正式な数です。 |
サウンド名オフセットテーブルの各エントリは、文字列プール内のNUL終端文字列を指す、ファイル内の絶対オフセットです。文字列は"SetMission o 1, s"、"create ARASWORD"、"ARROW10"のようなものです。バイトコードはこれらを、MISSION_COMMANDオペコードを通じて(オフセットではなく)インデックスで参照します(付録Cを参照)。このテーブルがサウンド名の配列と呼ばれるのは、標準のScriptorコンパイラーがADDSOUNDアクションで文字列を登録するからです。実際の中身は、音声ファイル名ではなくエンジンのコマンド文字列です。
kbotでの表現
scripting.COBは、サウンド名を構造化された[]stringフィールドとして持ちます。不透明なバイト列として保存することはありません。それ以外のもの(サブヘッダーの値、オフセットテーブル、プール内の各文字列のオフセット)は、書き込み時にこのリストから再構築されます。
type COB struct {
// …canonical TA fields…
SoundNames []string // TAK-only; nil for v4
}
v4のファイルではこのスライスは常にnilで、サブヘッダーとサウンド名オフセットテーブルはどちらも出力から省かれます。
逆アセンブラーと逆コンパイラーは各文字列を.sound_nameディレクティブとして出力するので、.cobaや.bosを経由するラウンドトリップでも失われません。
.version 6
.statics 6
.sound_name "create NPCEMEN"
.sound_name "SetMission o 1, s"
.sound_name "ARROW10"
.script Start
…
新しいオペコード
TAKの.cobファイルは、TA 3.1cにハンドラーのない命令(PLAY_SOUND、MISSION_COMMAND、TAK_MATH_*演算子)と、TAでは何もしない命令(no-op)として実行されるDONT_SHADOWを使います。ゲーム側での正確な意味は文書化されていませんが、バイナリのレイアウト(サイズとインラインパラメーターの数)は製品版のすべてのTAKの.cobからわかっており、逆アセンブル→アセンブルのラウンドトリップで元のバイトをそのまま復元するには、それで十分です。完全なリファレンスは付録Cを参照してください。
ラウンドトリップの状況
| パイプライン | TA | TA: Kingdoms |
|---|---|---|
disassemble → assemble(バイト単位で同一) |
278 / 278 ✅ | 267 / 267 ✅ |
decompile → compile(バイト単位で同一) |
278 / 278 ✅ | 単純なユニットのスクリプトと多くのミッションのCOB(≈ 70 / 267)は、バイト単位で完全にラウンドトリップします。残りの失敗は制御フローのパターンによるもので、逆コンパイラーが構造化されたif/whileを再構築する際に選ぶJUMPの配置を、コンパイラーがわずかに異なる形で再生成します。バイトコードは意味的には等価で(TAKのオペコード、コマンド文字列、サブヘッダー、v6のバージョンワードはすべて保たれます)、バイト単位で同一ではないというだけです。同じ種類の不一致は、単純でないスクリプトで逆コンパイル→コンパイルのサイクルを回せば、どんなものでも起こりえます。 |
TAK拡張のBOS表記
逆コンパイラーは、TAKのCOBがBOSを経由してラウンドトリップできるよう、次の擬似呼び出しを出力します。コンパイラーも同じ構文を受け付けますが、.version 6の後に限ります(例外はdont-shadowで、TAはこれを何もしない命令として実行します)。dont-shadowとMission-CommandはScriptorの標準のTAKキーワードを使います。数学オペコードにはScriptorでの文書化された名前がない(Scriptorは??や????と表示する)ので、kbotは__tak_math_*のプレースホルダーを使い続けます。
// Statement-level (emitted on their own line):
dont-shadow(<piece>); // DONT_SHADOW — disables shadow casting for one piece
Mission-Command("sound name", arg1, arg2, …); // MISSION_COMMAND — engine call (statement form drops the return value)
return_val = Mission-Command("sound name", args…); // assignment form keeps the pushed result
// Expression-level (binary operators: two values popped, one pushed):
local_x = __tak_math_09(a, b); // TAK_MATH_09
local_x = __tak_math_0a(a, b); // TAK_MATH_0A
local_x = __tak_math_0b(a, b); // TAK_MATH_0B
Mission-Commandの最初の引数は、引用符で囲んだ文字列で書くサウンド名です。コンパイラーはこれをサウンド名テーブルから探します(そのため、対応する.sound_nameディレクティブが、ファイル内でそれより前に現れていなければなりません)。残りの引数はスタックにプッシュされ、エンジンが消費します。
v6のメタデータの残りは、ファイル先頭の2種類のディレクティブが担います。
.version 6
.sound_name "create NPCEMEN"
.sound_name "SetMission o 1, s"
.sound_name "ARROW10"
.versionは常に出力されます(v4のTAのファイルも、同じパイプラインで同一にラウンドトリップさせるためです)。.sound_nameはそれぞれ、COBごとのサウンド名テーブルの1エントリです。ライターはlen(SoundNames)とエントリの出現順から、8バイトのサブヘッダーとサウンド名オフセットテーブルを再構築します。
v6で値のないreturn;がすべて出力する、TAKのSTACK_ALLOC ; RETURNというエピローグは、コンパイラーが処理します。.version 6のとき、return;はSTACK_ALLOC ; RETURNに、return <expr>;は前置きなしの<expr> ; RETURNに変換されます(Cavedogのコンパイラーと同じです)。
自分のインストールで再現するには:
kbot ctx use kingdoms # or wherever you registered TAK
kbot cob roundtrip
ポート20より上のポートのシンボル
TA: Kingdomsのスクリプトは、46までのget_unit_valueポートを使います(CURRENT_SPEED、VETERAN_LEVEL、魔力プールのフィールドなど)。kbotの逆コンパイラーが現在シンボル名を付けるのはポート1〜20だけで(付録Bを参照)、TAKの21以上のポートは数値リテラルとして出力されます。バイトコードにはどちらにしても整数として格納されるので、ラウンドトリップには影響しません。損なわれるのはBOSの読みやすさだけです。TAKのポート名の追加は、別途管理されています。
付録A — 全オペコードのリファレンス
TAまたはTA: Kingdomsが実行するすべての命令です。正となる情報源は、kbot-ioのformats/scripting/opcodes.goです。スタック表記はForth流の( before -- after )を使います。インラインの列は、オペコードのワード自体に続くuint32ワードの数を示します(スタックからではなく、デコード時に消費されます)。
アニメーション — ピースの操作(0x100x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10001000 |
MOVE |
2(piece、axis) |
( speed dist -- ) |
move <piece> to <axis> <dist> speed <speed>; |
0x10002000 |
TURN |
2(piece、axis) |
( speed dir -- ) |
turn <piece> to <axis> <dir> speed <speed>; |
0x10003000 |
SPIN |
2(piece、axis) |
( speed -- ) |
spin <piece> around <axis> speed <speed>; |
0x10004000 |
STOP_SPIN |
2(piece、axis) |
( decel -- ) |
stop-spin <piece> around <axis> [decel <d>]; |
0x10005000 |
SHOW |
1(piece) |
( -- ) |
show <piece>; |
0x10006000 |
HIDE |
1(piece) |
( -- ) |
hide <piece>; |
0x10007000 |
CACHE |
1(piece) |
( -- ) |
cache <piece>; |
0x10008000 |
DONT_CACHE |
1(piece) |
( -- ) |
dont-cache <piece>; |
0x10009000 |
PIECE_OP_09 |
1(piece) |
( a b -- ) |
__piece_op_09(<piece>, a, b); — TAはこれを実行しますが、ユニットのスクリプトにとっては何の働きもしません。製品版のスクリプトでは使われていません。 |
0x1000A000 |
DONT_SHADOW |
1(piece) |
( -- ) |
dont-shadow <piece>; — TAは何もしない命令として実行します。付録Cを参照。 |
0x1000B000 |
MOVE_NOW |
2(piece、axis) |
( pos -- ) |
move <piece> to <axis> <pos> now; |
0x1000C000 |
TURN_NOW |
2(piece、axis) |
( angle -- ) |
turn <piece> to <axis> <angle> now; |
0x1000D000 |
SHADE |
1(piece) |
( -- ) |
shade <piece>; |
0x1000E000 |
DONT_SHADE |
1(piece) |
( -- ) |
dont-shade <piece>; |
0x1000F000 |
EMIT_SFX |
1(piece) |
( type -- ) |
emit-sfx <type> from <piece>; |
待機の操作(0x101x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10011000 |
WAIT_FOR_TURN |
2(piece、axis) |
( -- ) |
wait-for-turn <piece> around <axis>; |
0x10012000 |
WAIT_FOR_MOVE |
2(piece、axis) |
( -- ) |
wait-for-move <piece> along <axis>; |
0x10013000 |
SLEEP |
0 | ( ticks -- ) |
sleep <ticks>; |
スタックの操作(0x102x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10021000 |
PUSH_IMM |
1(value) |
— | フラグのないPUSH。ゲームはこれで異常終了します(ta-push-flags)。コンパイラーが書き出すことはありません。 |
0x10021001 |
PUSH_CONST |
1(value) |
( -- v ) |
任意の式の中の<literal> |
0x10021002 |
PUSH_LOCAL |
1(var#) |
( -- v ) |
任意の式の中のlocal_N |
0x10021004 |
PUSH_STATIC |
1(var#) |
( -- v ) |
任意の式の中のglobal_N |
0x10022000 |
STACK_ALLOC |
0 | ( -- ) |
(暗黙的。varと引数1つごとに1つ) |
0x10023002 |
POP_LOCAL |
1(var#) |
( v -- ) |
local_N = expr; |
0x10023004 |
POP_STATIC |
1(var#) |
( v -- ) |
global_N = expr; |
0x10024000 |
POP_STACK |
0 | ( v -- ) |
(式の結果を捨てる) |
算術(0x103x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10031000 |
ADD |
0 | ( a b -- a+b ) |
a + b |
0x10032000 |
SUB |
0 | ( a b -- a-b ) |
a - b |
0x10033000 |
MUL |
0 | ( a b -- a*b ) |
a * b |
0x10034000 |
DIV |
0 | ( a b -- a/b ) |
a / b |
0x10035000 |
BITWISE_AND |
0 | ( a b -- a&b ) |
a & b |
0x10036000 |
BITWISE_OR |
0 | ( a b -- a|b ) |
a | b |
0x10037000 |
XOR |
0 | ( a b -- a^b ) |
a ^ b |
0x10038000 |
NOT |
0 | ( a -- ~a ) |
~a |
TAには剰余の命令がありません。TAのスクリプトで%を使うとコンパイルエラーになります。0x10039000〜0x1003B000はTA: KingdomsのTAK_MATH_*演算子で(付録C)、TAでは異常終了します。
エンジンへの問い合わせ(0x104x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10041000 |
RAND |
0 | ( low high -- rnd ) |
rand(low, high) |
0x10042000 |
GET_UNIT_VALUE |
0 | ( port -- value ) |
get <PORT> |
0x10043000 |
GET |
0 | ( port a b c d -- value ) |
get <PORT>(a, b, …) / get(port, …) — 常にポート1つと引数4つをポップします。足りない引数には、コンパイラーがゼロをプッシュします |
0x10044000 |
IS_CARRYING_UNIT |
0 | ( unit -- bool ) |
__is_carrying_unit(unit) — そのユニットがこのユニットに乗っていれば1 |
0x10045000 |
CARRIER_UNIT_ID |
0 | ( -- id ) |
__carrier_unit_id() — このユニットを運んでいるユニット、または0 |
ポートの一覧は付録Bを参照してください。
比較と論理(0x105x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10051000 |
LESS_THAN |
0 | ( a b -- a<b ) |
a < b |
0x10052000 |
LESS_OR_EQUAL |
0 | ( a b -- a<=b ) |
a <= b |
0x10053000 |
GREATER_THAN |
0 | ( a b -- a>b ) |
a > b |
0x10054000 |
GREATER_EQUAL |
0 | ( a b -- a>=b ) |
a >= b |
0x10055000 |
EQUAL |
0 | ( a b -- a==b ) |
a == b |
0x10056000 |
NOT_EQUAL |
0 | ( a b -- a!=b ) |
a != b |
0x10057000 |
LOGICAL_AND |
0 | ( a b -- a&&b ) |
a AND b |
0x10058000 |
LOGICAL_OR |
0 | ( a b -- a||b ) |
a OR b |
0x10059000 |
XOR_ALT |
0 | ( a b -- a^b ) |
a XOR b — 0x10037000と同じビット単位のXOR(6 XOR 3は5)で、論理XORではありません |
0x1005A000 |
LOGICAL_NOT |
0 | ( a -- !a ) |
NOT a |
制御フローと並行処理(0x106x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10061000 |
START_SCRIPT |
2(script#、argc) |
( args… -- ) |
start-script Foo(a, b); |
0x10062000 |
CALL_SCRIPT |
2(script#、argc) |
( args… -- ) |
call-script Foo(a, b); |
0x10063000 |
DISCARD_CALL |
2(word、argc) |
( args… -- ) |
__discard_call(word, args…); — 引数をポップし(4つを超えるとゲームのバッファーがあふれます)、何も実行しません |
0x10064000 |
JUMP |
1(offset) |
( -- ) |
(コンパイラー — goto、ループ) |
0x10065000 |
RETURN |
0 | ( v -- ) |
return v; |
0x10066000 |
JUMP_IF_FALSE |
1(offset) |
( v -- ) |
(コンパイラー — if/while) |
0x10067000 |
SIGNAL |
0 | ( mask -- ) |
signal <mask>; |
0x10068000 |
SET_SIGNAL_MASK |
0 | ( mask -- ) |
set-signal-mask <mask>; |
JUMP / JUMP_IF_FALSEのoffsetは、現在の命令からではなく、OffsetToScriptCodeからのバイトオフセットです。
エフェクト(0x107x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10071000 |
EXPLODE |
1(piece) |
( type -- ) |
explode <piece> type <type>; |
0x10072000 |
PLAY_SOUND |
1(vol) |
( sound -- result ) |
play-sound(<sound>, <vol>); — TA: Kingdoms専用(.version 6)。TAでは異常終了します |
設定の操作(0x108x)
| オペコード | ニーモニック | インライン | スタック効果 | BOS構文 |
|---|---|---|---|---|
0x10082000 |
SET_VALUE |
0 | ( port value -- ) |
set <PORT_NAME> to <value>; |
0x10083000 |
ATTACH_UNIT |
1(piece) |
( unit -- ) |
attach-unit <unit> to <piece>; |
0x10084000 |
DROP_UNIT |
0 | ( unit -- ) |
drop-unit <unit>; |
オペコードのエンコーディング
オペコードのワードはどれも0x10CCSSFFの形で、各部分は次のとおりです。
CC= カテゴリのバイト(アニメーション、数学、制御フロー、…)。SS= サブコマンドのニブル。上から2番目のバイトの上位ニブルにあります。FF= フラグのバイト。
ゲームはword & 0x100FF000で処理を振り分け、ほかのビットは無視します。そのため0x10064001のようなワードはJUMPとして実行されます。逆アセンブラーはこれをJUMP@0x10064001と書き、アセンブラーはそれを読んで同じワードに戻します。PUSHとPOPは、下位3ビットから読み出し元や書き込み先を読み取ります。PUSHは1(定数)、2(ローカル)、4(静的)を、POPは2と4を受け付け、それ以外の値ではスクリプトが異常終了します。
どのゲームも実行しないワードは、逆アセンブラーがUNKNOWN_0x10xxxxxxとして表示します(ta-unknown-opcodeでも報告されます)。実際のファイルでこれを見つけたら、COBのサンプルを添えてissueを開いてください。
付録B — get_unit_valueのポート表
kbotの逆コンパイラーが現在シンボル名を付ける20個のポートです。実際のTAには約80個あり、TA: Kingdomsは魔法に関する量のために、さらに約15個を追加しています。現在の逆コンパイラーの出力では、ポート20より上はすべて数値リテラルとして表示されます。
| ポート | シンボル名 | 戻り値 | 備考 |
|---|---|---|---|
| 1 | ACTIVATION |
bool | ユニットの「稼働」トグルがオンかどうか(レーダー、妨害装置、防御施設)。 |
| 2 | STANDINGMOVEORDERS |
enum | 0 = 現在地を固守、1 = 機動、2 = 自由移動。 |
| 3 | STANDINGFIREORDERS |
enum | 0 = 射撃中止、1 = 反撃、2 = 自由射撃。 |
| 4 | HEALTH |
0〜100 | MaxDamageの残りの割合(パーセント)。 |
| 5 | INBUILDSTANCE |
bool | ユニットが現在ナノ旋盤を伸ばしているかどうか。 |
| 6 | BUSY |
bool | ユニットが現在、命令の実行中かどうか。 |
| 7 | PIECE_XZ |
packed | ピースのX/Zを組み合わせた値(エンコード済み)。発射体の照準に使います。 |
| 8 | PIECE_Y |
int | ユニットの原点を基準にした、ピースのY座標。 |
| 9 | UNIT_XZ |
packed | ユニットのX/Zを組み合わせた値。 |
| 10 | UNIT_Y |
int | ユニットのY座標。 |
| 11 | UNIT_HEIGHT |
int | 地形からの高さ。 |
| 12 | XZ_ATAN |
int | XZエンコードされた2つの値の間のatan2(砲塔の照準用)。 |
| 13 | XZ_HYPOT |
int | XZエンコードされた2つの値の間の斜辺の長さ。 |
| 14 | ATAN |
int | プッシュされた2つの値のatan2(y, x)。 |
| 15 | HYPOT |
int | hypot(x, y)。 |
| 16 | GROUND_HEIGHT |
int | ユニットの位置での地形の標高。 |
| 17 | BUILD_PERCENT_LEFT |
0〜100 | SmokeUnitが建設の進み具合を知るのに使います。建設が終わると0。 |
| 18 | YARD_OPEN |
bool | ヤードマップを持つ建物で、扉が開いているかどうか。 |
| 19 | BUGGER_OFF |
bool | ユニットが道をあける必要があるときに、エンジンがセットします。 |
| 20 | ARMORED |
bool | ユニットの装甲状態が有効かどうか(ブルドッグ、ゴリアテなど)。 |
それより上のポートには、CURRENT_SPEED(29。CavedogのScriptorがリテラルとしてインライン化することで有名です。上の注意点を参照)、MAX_ID、UNIT_TEAM、UNIT_BUILD_PERCENT、BUILDER、UNIT_ALLIEDなど、ほかにも多くのものがあります。完全な一覧はCavedogの内部資料にあります。公開されているものでは、TAUniverseフォーラムのコミュニティwikiが最も網羅的なカタログです。
TA: Kingdomsはポートの範囲を広げています。kbotが認識するTAKだけのポート(逆コンパイラーはシンボル名を表示し、コンパイラーは入力として受け付けます)は、Scriptorの[UNITVLAUES]テーブルから取っています。
| ポート | シンボル | 備考 |
|---|---|---|
| 21 | WEAPON_AIM_ABORTED |
武器の照準の解が取り消されたときにセットされます。 |
| 22 | WEAPON_READY |
武器の再装填が終わり、発射できる状態。 |
| 23 | WEAPON_LAUNCH_NOW |
エンジンが武器を発射した。 |
| 26 | FINISHED_DYING |
死亡アニメーションが終わった。 |
| 27 | ORIENTATION |
ユニットの向き。 |
| 28 | IN_WATER |
ユニットが水中にいる。 |
| 29 | CURRENT_SPEED |
現在の移動速度(TAと共通。Scriptorはここでも同じ番号を付けています)。 |
| 31 | MAGIC_DEATH |
ユニットが魔法ダメージで死んだ。 |
| 32 | VETERAN_LEVEL |
ベテランの段階。 |
| 34 | ON_ROAD |
ユニットが道路タイルの上にいる。 |
付録C — TA: Kingdomsのオペコード
Note
TA:K専用のオペコード。製品版のTAの.cobファイルには、これらは1つも現れません。DONT_SHADOWとMISSION_COMMANDは、Scriptor(SwiteckによるTAK対応のコンパイラー兼逆コンパイラー)の標準のニーモニックを使います。数学演算には文書化された意味がなく、Scriptorは??や????と表示するので、kbotはTAK_MATH_09 / TAK_MATH_0A / TAK_MATH_0Bのプレースホルダーを使い続けます。製品版のTA: Kingdomsのスクリプトは、ビットフィールドのパックとアンパックで、どれも二項演算子(2つの値をポップし、1つをプッシュ)として使っています。TA 3.1cにはPLAY_SOUND、MISSION_COMMAND、数学演算子のハンドラーがなく、DONT_SHADOWは何もしない命令として実行します。
| オペコード | ニーモニック | インライン | 製品版TAKでの出現箇所 | 備考 |
|---|---|---|---|---|
0x1000A000 |
DONT_SHADOW |
1(piece) |
66 | 1つのピースの影の投影を無効にします。アニメーションのカテゴリでDONT_SHADE(0x1000E000)の隣にあります。kbotは以前これをTAK_ANIM_0Aと呼んでいました。ディスク上の形はほかのANIM_*系のオペコードと同じで、4バイトのオペコードとpieceのDWORD 1つです。BOSのキーワード:dont-shadow <piece>;。 |
0x10039000 |
TAK_MATH_09 |
0 | 264 | 数学のカテゴリの二項演算子で、全体で4バイト:( a b -- r )。BOS:__tak_math_09(a, b)。 |
0x1003A000 |
TAK_MATH_0A |
0 | — | 同じ形。これをBITWISE_NOTと呼ぶオペコード表もありますが、TAのビット単位のNOTは0x10038000です。BOS:__tak_math_0a(a, b)。 |
0x1003B000 |
TAK_MATH_0B |
0 | 66 | TAK_MATH_09と同じ形。BOS:__tak_math_0b(a, b)。 |
0x10073000 |
MISSION_COMMAND |
2(soundNameIdx、argCount) |
3,885 | エンジンのコマンド呼び出し。1つ目のインラインDWORDはCOBのSoundNamesテーブルへのインデックス、2つ目は引数としてスタックからポップする値の数です。このオペコードは結果を1つスタックにプッシュして戻します(通常は直後のPOP_STACKで捨てられるか、POP_STATIC/POP_LOCALで格納されます)。TAKだけのオペコードの中では、群を抜いて多く使われます。BOSのキーワード:Mission-Command("name", args…)。Scriptorの標準の表記と一致します。 |
エンコーディング(おさらい)
COBのオペコードのワードはどれも0x10CCSSFFの形で、CCがカテゴリ、SSがサブコマンドです。これらのTAKのオペコードは、新しいカテゴリのバイトを導入するのではなく、既存のカテゴリ内でそれまで使われていなかったCC:SSのスロットを占めているだけです。DONT_SHADOWはアニメーションのカテゴリでDONT_CACHE/DONT_SHADEと並び、MISSION_COMMANDはエフェクトのカテゴリでEXPLODEやPLAY_SOUNDと並んでいます。
Tip
実際のバイトコードと照らし合わせる。
kbot ctx use tak-30bb-flat
kbot cob disassemble scripts/araat.cob -a | grep -E 'DONT_SHADOW|MISSION|TAK_MATH|PLAY_SOUND'
リファレンスカタログの実例
この形式の極端な使い方をしている実際のユニットです。本文中の作例ではなく、本物のスクリプトの複雑さを見るには、これらを開いてください。
CORKROG— クロゴス(CORE) — ゲーム中で最大級のCOBの1つ(約25 KB、30以上のスクリプト、複数のピースにまたがる複雑なアニメーション)。ARMCOM— ARMコマンダー — 代表的な「充実した」ユニット。歩行・照準・待機・建設のサイクル、複数の武器、Dガンの扱い。CORTRUCK— 建設トラック — このページのウォークスルーに使った、ほぼ最小限のCOB(スクリプトはSmokeUnit、Create、Killedの3つ)。ARMSAM— サムソン — 砲塔を持つユニットのよい例(turret1を回転させての照準、反動アニメーション、wait-for-turnによる同期)。
kbotのコンテキストを設定すれば、kbot cob decompile scripts/<name>.cobで、どれでも手元で逆コンパイルできます。