COB / BOS——单位脚本字节码
横扫千军中的每个单位都运行一个脚本,用来驱动其部件(piece)的动画、发出武器闪光、播放音效,并响应游戏事件。源语言是 BOS(“Basic Object Script”);引擎实际运行的编译后字节码是 COB(“Cobble”——Cavedog 对“compiled BOS”的文字游戏)。每个单位一个文件,以该单位 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)
}
两种语言,一种文件格式
| 阶段 | 格式 | 工具 |
|---|---|---|
| 源代码 | 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 把操作码(opcode)流与控制流箭头并排显示;kbot cob decompile 则反向操作,还原出的 BOS 几乎总能逐字节一致地往返(kbot cob roundtrip 会在整个游戏目录上验证这一点)。
除非源代码以 .version 6 开头,否则它会被编译为 TA 脚本(COB 版本 4)。对于 TA 3.1c 会错误执行的内容,编译器直接拒绝,而不是写入占位符:横扫千军:王国的构造(play-sound、Mission-Command、__tak_math_*、.sound_name)和 % 在 TA 脚本中都是错误(TA 没有取模指令,而 % 过去被编译成的字 0x10037000 在 TA 中是按位 XOR),未知标识符以及需要超过游戏 32 个栈槽的函数同样是错误。仍能编译但值得一看的情况——同一函数定义了两次(每次调用都绑定到第一个定义,与游戏的名称查找方式一致)——会作为警告打印出来,同时打印的还有 TA 兼容性 lint 规则的任何发现;--strict 会让警告导致命令失败。kbot cob assemble 对手写的汇编清单执行同样的检查。
COB 磁盘布局
Important
两种方言。 本节描述的是 COB v4,即《横扫千军》所用的方言。《横扫千军:王国》使用 COB v6(VersionSignature == 6),它保留下面的全部内容,但把文件头扩展了 8 字节,并在部件名数组之后插入一个额外的命令字符串偏移表。v6 的具体细节见横扫千军:王国——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 是一个基于栈的虚拟机。大多数操作码从每个脚本各自的数据栈中弹出参数,执行操作,再把结果压回栈中。有些操作码带有内联操作数(跳转目标、部件编号)。
每个操作码是一个 uint32,布局如下:
MSB LSB
┌─────────┬─────────┬─────────┬─────────┐
│ 0x10 │ family │ op │ flags │
└─────────┴─────────┴─────────┴─────────┘
最高字节总是 0x10;family/op/flags 随操作码而变。游戏并不比较整个字:它按 word & 0x100FF000 分派,因此该掩码之外的位会被忽略,0x10064001 会作为 JUMP 执行(kbot 的反汇编器把这样的字写成 JUMP@0x10064001,以便汇编回去时保持不变)。随后,PUSH(0x10021000)和 POP(0x10023000)从最低三位读取它们的来源或目标。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 仅用于横扫千军:王国。 |
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 参数会在对应的指令之间画出箭头,让这一点一目了然。
并发模型
一个单位可以同时运行许多脚本——Walk、Aim、SmokeUnit、MoveWatcher 等。它们采用协作式调度:
sleep <ticks>让出执行 N 个 tick(每个 1/30 秒)。wait-for-turn <piece> around <axis>让出执行,直到该部件完成当前的turn动画。wait-for-move同理。signal <n>终止所有满足signal_mask & n != 0的脚本。set-signal-mask <n>声明当前脚本关心哪些信号。
脚本正是靠信号掩码(signal mask)实现抢占:MoveWatcher 设置掩码 SIG_MOVE 并监视速度;当单位停止移动时,它发出 SIG_MOVE,在开始空闲姿态之前干净地终止行走动画。
Warning
SET_SIGNAL_MASK 只是登记关注——它不会终止任何东西。 真正让设置了掩码 X 的脚本停止的,是发送 signal X。kbot cob lint 中的 signal-never-signalled lint 规则会捕获那些设置了却没有任何人发送的掩码的脚本(这几乎总是 bug)。
抢占的实际过程
一种常见模式:MoveUnit 触发一个 Walk 动画脚本;移动停止时,引擎发送 SIG_MOVE,在 Idle 开始之前干净地收尾。顺序如下:
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)
有三点值得注意:
- 信号触发时,两个设置了
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)展示了实际的操作码流,JUMP_IF_FALSE/JUMP 箭头穿过 while/if 的循环体与分支体——当反编译器的输出有歧义时,这很有用。
Lint 检查
kbot cob lint(以及资源浏览器的 Lint 标签页)运行 kbot-io 的默认规则集:风格规则取材于 Cavedog 自己脚本中的真实 bug 和社区提交的模组补丁,另有两项文件级检查,并且——对每个没有声明版本 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 | move/turn 使用 speed <0>——动画永远不会完成。 |
empty-function |
info | 函数体只有 return 0。 |
duplicate-animation |
warning | 两条完全相同的动画命令紧挨在一起。 |
sleep-only-guard |
info | if 块中只有 sleep。 |
duplicate-if |
info | 连续两个条件相同的 if。 |
raw-signal |
info | 写了 signal 4 而不是 signal SIG_AIM。 |
unnamed-global |
info | 写了 global_3 而不是一个有名字的静态变量。 |
signal-never-signalled |
warning | 脚本屏蔽了一个没有任何人发出的信号。 |
recursive-call |
warning | call-script 形成环。 |
duplicate-function |
warning | 两个脚本同名;游戏只会调用第一个。 |
malformed-cob |
error / warning | 文件无法加载,或某个脚本的代码被截断(error);读取器容忍了的异常名称表(warning)。 |
TA 兼容性规则报告 TA 3.1c 会出错或执行结果不同的内容。它们都是 error,与其他规则一样出现在 --ci 的 SARIF 输出中,并且跳过横扫千军:王国(版本 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 |
执行到 GET 时待处理值少于五个。GET 总是弹出一个端口和四个参数,因此只带一个参数的 get 会从函数的局部变量中取走其余的值并将其覆盖。 |
ta-stack-underflow |
任何其他弹出值多于待处理值的指令,或以不同栈深度汇合的路径。 |
ta-discard-call |
参数超过四个的 DISCARD_CALL。 |
完整的规则说明见 kbot README。反编译器有意保留原始的变量和部件编号,这样“反编译→lint→修复→编译”的循环就能凸显需要维护的模组。
坑点
Warning
call-script 是 0x10062000。 0x10063000 是另一条指令 DISCARD_CALL:它带有同样的两个内联字(脚本编号和参数个数),但只弹出参数,什么也不执行。没有任何零售版脚本使用它;kbot 的反编译器把它写成 __discard_call(word, args…)。
Warning
一些已发布的操作码表把按位运算的字命名错了。 TA 把 0x10037000 当作按位 XOR 执行(不是取模——TA 没有取模指令),把 0x10038000 当作一元按位 NOT 执行(不是 XOR),把 0x10059000 当作第二个按位 XOR 执行(不是逻辑 XOR:6 XOR_ALT 3 等于 5)。0x1003A000 是横扫千军:王国的运算符,TA 遇到它会出错。用旧名称(MOD、BITWISE_XOR、BITWISE_NOT、LOGICAL_XOR)写的汇编清单仍会汇编成它们原本对应的字。
- COB 文件头中的所有偏移量都是文件绝对偏移量,但
ScriptCodeIndexArray的条目是代码段内以字为单位的偏移量。两者很容易混淆。 - 操作码中的部件编号是部件名表的索引,而不是字符串。COB 以字符串池的形式携带名称;运行时为了速度使用索引。
get UNIT_VALUE操作码在 TA 与横扫千军:王国之间有细微差别。 横扫千军:王国为魔法回复、指挥点数等增加了值 ID;TA 的值表更紧凑。kbot 的反编译器目前标注的是 TA 的这一组;TAK 中 ≥ 21 的端口号以数字字面量输出(仍可通过汇编器往返)。完整的差异见横扫千军:王国——COB v6 差异。- 变量有两种:
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(Krogoth ≈ 80) |
| 代码段大小 | 300 B – 20 KB(随复杂度变化很大) |
armcom.cob(指挥官) |
约 14 KB,30 个脚本 |
cortruck.cob(简单单位) |
约 750 B,3 个脚本 |
横扫千军:王国——COB v6 差异
Note
本节只讲 TA:K 的差异。 上面的内容描述的都是 COB v4(TA)。横扫千军:王国保留了同样的栈式虚拟机、同样的字符串池、同样的操作码编码方案,以及同样的脚本名/部件名机制;区别在于扩展的文件头、在部件名数组之后追加的一个额外字符串表,以及四个新操作码。
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(规范的 11-DWORD 文件头的末尾,在第 44 字节处)与代码段起始处(OffsetToScriptCode,通常为 0x34 = 52)之间。它包含两个小端序 uint32:
| 字段 | 含义 |
|---|---|
[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_* 运算符),另外还有 DONT_SHADOW,TA 把它当作空操作执行。它们在游戏中的确切语义没有文档,但它们的二进制布局(大小 + 内联参数个数)可以从每个零售版 TAK .cob 中得知,这足以让“反汇编→汇编”往返逐字节还原原始数据。完整参考见附录 C。
往返状态
| 流程 | TA | 横扫千军:王国 |
|---|---|---|
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 中没有有据可查的名字(它把它们标为 ?? 和 ????),所以 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 元数据:
.version 6
.sound_name "create NPCEMEN"
.sound_name "SetMission o 1, s"
.sound_name "ARROW10"
.version 总是会输出(这样 v4 的 TA 文件经由同一流程也能完全一致地往返)。每条 .sound_name 是该 COB 的声音名表中的一个条目;写入器根据 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 以上的端口符号
横扫千军:王国的脚本使用的 get_unit_value 端口最高到 46(CURRENT_SPEED、VETERAN_LEVEL、魔法池字段等)。kbot 的反编译器目前只为端口 1–20 标注符号名(见附录 B);TAK 中 ≥ 21 的端口以数字字面量输出。字节码无论如何都以整数存储它们,所以往返不受影响——只是 BOS 的可读性会差一些。添加 TAK 端口名的工作另行跟踪。
附录 A——完整操作码参考
TA 或横扫千军:王国执行的每一条指令。以 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 和参数各一个) |
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 是横扫千军:王国的 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, …)——总是弹出一个端口和四个参数;缺少的参数由编译器压入零 |
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…);——弹出参数(超过四个会溢出游戏的缓冲区),什么也不执行 |
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>);——仅用于横扫千军:王国(.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= 子命令半字节,位于从高位数第二个字节的高半字节中。FF= 标志位字节。
游戏按 word & 0x100FF000 分派并忽略其他位,因此像 0x10064001 这样的字会作为 JUMP 执行;反汇编器把它写成 JUMP@0x10064001,汇编器读回时还原为同一个字。PUSH 和 POP 从最低三位读取来源或目标:PUSH 接受 1(常量)、2(局部变量)和 4(静态变量),POP 接受 2 和 4,其他任何值都会让脚本出错。
任何游戏都不执行的字,在反汇编器中显示为 UNKNOWN_0x10xxxxxx(并由 ta-unknown-opcode 报告);如果你在真实文件中发现了这样的字,请附上该 COB 样本提交 issue。
附录 B——get_unit_value 端口表
kbot 的反编译器目前标注符号名的 20 个端口。真实的 TA 大约有 80 个;横扫千军:王国又为魔法相关的数值增加了约 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 | 单位当前是否正伸出纳米车床(nano-lathe)? |
| 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 编码值之间的 atan2(用于瞄准炮塔)。 |
| 13 | XZ_HYPOT |
int | 两个 XZ 编码值之间的斜边长度。 |
| 14 | ATAN |
int | 两个压栈值的 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 | 对带 yardmap 的建筑:门是否打开? |
| 19 | BUGGER_OFF |
bool | 当单位需要让路时由引擎设置。 |
| 20 | ARMORED |
bool | 单位的装甲姿态是否启用(Bulldog、Goliath 等)? |
更高的端口包括 CURRENT_SPEED(29——它被 Cavedog 的 Scriptor 内联为字面量是出了名的;见上面的坑点)、MAX_ID、UNIT_TEAM、UNIT_BUILD_PERCENT、BUILDER、UNIT_ALLIED 等等。完整列表在 Cavedog 的内部文档中;TAUniverse 论坛的社区 wiki 拥有最全面的公开目录。
横扫千军:王国扩展了端口范围。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——横扫千军:王国操作码
Note
TA:K 专有操作码。 这些操作码都不会出现在零售版 TA 的 .cob 文件中。DONT_SHADOW 和 MISSION_COMMAND 使用 Scriptor(Switeck 的支持 TAK 的编译器/反编译器)中的规范助记符;数学操作没有有据可查的语义——Scriptor 把它们标为 ?? 和 ????,所以 kbot 保留 TAK_MATH_09 / TAK_MATH_0A / TAK_MATH_0B 占位符。零售版横扫千军:王国的脚本在打包和解包位字段时,把它们各自用作二元运算符(弹出两个值,压入一个)。TA 3.1c 对 PLAY_SOUND、MISSION_COMMAND 和这些数学运算符都没有处理程序;DONT_SHADOW 则被它当作空操作执行。
| 操作码 | 助记符 | 内联 | 零售版 TAK 中的出现次数 | 说明 |
|---|---|---|---|---|
0x1000A000 |
DONT_SHADOW |
1(piece) |
66 | 为单个部件禁用阴影投射。位于动画类别中,紧挨着 DONT_SHADE(0x1000E000)——kbot 早先把它叫作 TAK_ANIM_0A。磁盘上的形态与其他 ANIM_* 操作相同:4 字节操作码 + 一个 piece DWORD。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 | 引擎命令调用。第一个内联 DWORD 是 COB 的 SoundNames 表的索引;第二个是要从栈中弹出作为参数的值的个数。该操作码把一个结果压回栈中(通常随后用 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——Krogoth(Core)——游戏中最大的 COB 之一(约 25 KB,30 多个脚本,复杂的多部件动画)。ARMCOM——Arm 指挥官——有代表性的“丰富”单位:行走/瞄准/空闲/建造循环、多种武器、D-gun 处理。CORTRUCK——建造卡车——近乎最小的 COB,即本页实例解析所用的文件(3 个脚本:SmokeUnit、Create、Killed)。ARMSAM——Samson——炮塔单位的好例子(turret1旋转瞄准、后坐动画、wait-for-turn同步)。
设置好 kbot 上下文后,可以用 kbot cob decompile scripts/<name>.cob 在本地反编译其中任何一个。