すべての形式のページ ↓
ユニット、スクリプト、設定

COB / BOS — ユニットスクリプトのバイトコード

出典:coreprime/kbot @ 0ab97bf · GitHubで編集

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つあります。

  1. シグナルが発生すると、SIG_MOVEを設定した両方のスクリプトが停止します。MoveUnit自身も含まれます(仕事は終わっているので問題ありません)。
  2. シグナルを送るスクリプト(MoveUnit)は、停止させられる前にシグナルを送るところまでは生き残ります。ランタイムはsignalオペコードから戻った後でマスクを確認するからです。
  3. 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で、どれでも手元で逆コンパイルできます。


関連項目

  • 3DO — COBスクリプトが参照するピース名は、ユニットの.3doにあります。
  • FBI — ユニットのメタデータのファイル。そこにあるObjectNameがCOBを3DOに結び付け、Soundsがスクリプトで再生できる音声バンクを定義します。
  • kbot CLIのcob lintルール表。
  • 用語集 — piece(ピース)、axis(軸)、signal mask(シグナルマスク)、tick(ティック)。