ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

星际争霸地图二进制解析:从CHK容器到JSON工具链

星际争霸地图二进制解析:从CHK容器到JSON工具链

简介:一份面向星际争霸协议分析与游戏插件开发的二进制分析资料,主要解决玩家与开发者在理解游戏网络数据包收发、解析及逆向工程方面的需求。资料以Visual Studio解决方案WardenMonitor为核心,内含完整C#工程与可执行程序,代码涵盖数据包捕获、方向识别、启发式检测及RC4加密处理等关键环节,适合具备一定编程基础、希望深入游戏底层机制的玩家或安全研究者使用。

压缩包共53个文件,主要包括C#源码、Visual Studio解决方案与工程配置、DLL与Pdb运行调试文件、XML配置及SVN版本记录,整体仅102KB,轻量但信息密度高,便于直接打开工程研读。内容预览可见PacketHandler、PacketDirection、Packet、Warden等模块,目录按源码、调试输出、版本管理划分,结构清晰。

目前已有194人学习下载。通过分析这份资料,读者既能理解实时策略游戏网络同步原理,也能掌握二进制协议分析、常见加密算法识别与调试器配合使用的实用技能,对开展游戏辅助开发、协议研究或安全分析均有直接参考价值。

1. 星际争霸二进制分析资料到底在拆什么

星际争霸二进制分析资料,圈内通常指两件事:一是把地图文件(.scm/.sce)当二进制容器逐字节拆开,二是把录像文件和游戏自带数据脚本当字节流分析。大多数从业者真正需要的其实是第一件——地图里存了全部单位摆放、玩家分组、地形矩阵和触发条件,官方编辑器里能看到的,二进制里几乎都有。这篇文章不做理论综述,直接给出一套最小可复现的解析路径:从 CHK 容器头开始,到地形矩阵,到单位记录,再到触发器分区,最后落成一个 JSON 化工具链。适合想从地图里抠数据的 modder、做对战数据集的开发者,也适合刚接触二进制文件分析的新手照着复现。整篇文章遵守一个原则:先拿一张能打开、能另存的地图,把二进制拆出来的结果和编辑器显示逐项对照,对照通过才算解析正确。

2. 先拆 CHK 容器:四字节标签、两字节长度与地图基本信息

2.1 为什么解析起点是 CHK 而不是直接找数据

星际争霸地图文件不是一整块连续写入的裸数据,而是由若干独立分区拼接出来的容器。每个分区在二进制里叫 chunk,四字节 ASCII 标签说明“这段数据是干什么的”,紧跟两字节小端序长度,再往后才是真正内容。文件开头偶尔还有四字节的 CHK 总头,解析时要先跳过去,否则第一个标签会直接读错。

我刚接触这个格式时犯过一个低级错误:把标签按字符串打印出来,看着像是DIM、MTXM,就以为中间会有什么分隔符或填充。实际上没有任何填充,分区之间一个字节都不多。标签可能是DIM(第四个字节是空格),长度字段也按小端序存储,直接按大端读会把 256 的地图宽度读成 65536,这一下就能把人打蒙。把分区结构画成一张表会清爽很多:

标签分区作用长度特征
TYPE文件类型标识很短,一般 1~2 字节
VER地图版本2 字节,常见值为 0x2D
DIM地图宽高前 4 字节是宽和高
MTXM地形矩阵宽度×高度×2 字节
OWNR玩家槽位归属通常 8 字节
SIDE玩家种族通常 8 字节
UNIT单位摆放每条记录约 16 字节
THG2触发条件与动作长度不固定
MASK战争迷雾数据跟随地形尺寸
SPRP精灵与装饰物长度不固定
STR字符串表长度不固定

这张表不用背,真正要背的是遍历规则:偏移从 0 开始,读四字节标签,读两字节长度,然后一次性把整段内容切出来,最后把偏移推进6 + length。这就是 CHK 容器的全部玄学,剩下的都是各个分区的内部协议。

2.2 写一个最小 chunk 遍历器

下面这段代码是整个分析资料的地基。它负责把地图文件拆成一个个(tag, data)对,之后所有分区解析都建立在它上面。我一般会把这段单独放进一个公共模块,后续无论是写单位提取还是触发器对比,都直接调用它。

import struct def iter_chunks(raw: bytes): offset = 0 # 少数地图文件最前面带四字节 CHK 头,跳过它 if raw[:4] in (b'CHK\x00', b'CHK '): offset = 4 while offset + 6 <= len(raw): tag_raw = raw[offset:offset + 4] length = struct.unpack_from('<H', raw, offset + 4)[0] data = raw[offset + 6:offset + 6 + length] # 标签尾部的空格去掉,方便后面用字典直接索引 tag = tag_raw.rstrip(b' \x00') yield tag, data offset += 6 + length

逻辑说明:<H表示按小端序读取一个无符号 16 位整数,这是整个星际争霸二进制分析里最容易被忽略的约定。标签用rstrip(b' \x00')清理尾部空格和空字符,是因为像DIM、STR这类标签,第四字节就是空格,直接当字典键会很难看。offset += 6 + length是分区遍历的核心,它假设分区之间没有对齐填充,如果解析结果出现“半个分区”或者长度对不上,先检查是不是地图被第三方工具改动过。

参数说明里有一个值得留意的点:length是分区的数据长度,不含标签和长度字段本身。所以每次推进要加 6,也就是四字节标签加两字节长度。如果写成offset += length,第二次循环就会落在错误位置,而且是那种“看起来能跑但结果全错”的翻车方式。

2.3 读 DIM 和 MTXM:先确认地图坐标系再谈别的

拿到分区列表后,第一步永远是读 DIM,确认地图宽高。DIM 分区的前四个字节就是宽和高,按小端序读取。接下来用宽高去校验 MTXM 分区长度,MTXM 里保存的是地形矩阵,每个格子占两字节。

def parse_map_header(chunks: dict): dim = chunks.get(b'DIM', b'') if len(dim) < 4: raise ValueError('DIM chunk missing or too short') width, height = struct.unpack('<HH', dim[:4]) mtxm = chunks.get(b'MTXM', b'') expected = width * height * 2 if len(mtxm) != expected: print(f'warning: MTXM length {len(mtxm)} != expected {expected}') ground_ids = [] variants = [] # 每格两字节,循环步长必须是 2 for i in range(0, (len(mtxm) // 2) * 2, 2): tile = struct.unpack_from('<H', mtxm, i)[0] ground_ids.append(tile >> 8) variants.append(tile & 0xFF) return { 'width': width, 'height': height, 'ground_ids': ground_ids, 'variants': variants, }

逻辑说明:地形格子的两字节,我习惯按高字节作地形组 ID、低字节作变体信息来处理。高字节决定这块地是哪种地貌基底,低字节决定边缘过渡和方向。不同地图版本之间可能存在解释差异,所以不要拿一张图的结论直接套所有文件。

参数这一点要特别较真:如果 MTXM 长度不等于width * height * 2,说明分区索引已经偏移了。这时候不要试图“修正几个字节继续解析”,而是回到遍历器,检查是不是文件头判断错了,或者地图经过编辑器之外的工具重打包过。长度校验是最便宜的后悔药,在批量处理几百张地图时能帮你筛掉大量坏样本。

3. 读 UNIT 分区:把单位坐标从二进制里捞出来

3.1 UNIT 记录的十六字节常见布局

UNIT 分区保存地图上预先摆放的单位,包括出生点、中立单位、装饰用单位。这个分区的内部布局并不是某个官方文档写死的,而是社区这些年解析大量地图后形成的最低共识。按这个共识,每一条记录固定 16 字节左右,关键字段分布如下:

偏移字节数常见含义
02单位 ID,对应数据表里的单位编号
21所属玩家槽位,0 开始
32X 坐标,小端序
52Y 坐标,小端序
71特殊位标记,含义随单位类型变化
81有效/状态位
94类型或属性索引
133保留字段,多数地图为 0

我第一次解析时,把偏移 2 的玩家字节直接当成“玩家编号 1 到 8”,结果所有单位都偏了一位。实际上这里的 0 对应玩家 1,7 对应玩家 8,8 及以上通常代表中立或者地图创建者。这个错位在没有参照物时非常难发现,因为坐标依然能读出来,只是所有单位都挂到了错误的阵营名下。

3.2 一段能直接跑的单位提取代码

下面这段代码把 UNIT 分区切成记录,并输出一个字典列表。写这段时我特意保留了长度对 16 取模的警告,因为碰到被工具改动过的地图时,早期警告能帮你少走很多弯路。

def parse_units(data: bytes): if len(data) % 16 != 0: print(f'warning: UNIT length {len(data)} not multiple of 16') units = [] for i in range(0, len(data) - 15, 16): rec = data[i:i + 16] unit_id, owner, x, y = struct.unpack_from('<HBHH', rec, 0) special, valid = rec[7], rec[8] type_code = int.from_bytes(rec[9:13], 'little') units.append({ 'unit_id': unit_id, 'owner': owner, 'x': x, 'y': y, 'special': special, 'valid': valid, 'type_code': type_code, }) return units

逻辑说明:<HBHH一次解出四个字段,H 是两字节无符号整型,B 是一字节无符号整型。这样前七个字节就处理完了,剩下部分手动切出来。int.from_bytes(rec[9:13], 'little')读四字节类型索引,这里需要显式指定小端序,很多新手在这段写成int.from_bytes(rec[9:13]),默认大端序,数值直接读反。

参数说明:range(0, len(data) - 15, 16)的意思是每条记录固定 16 字节。如果后续在其它地图上发现记录长度不是 16,不要硬套,先用两条已知地图做差分,确定记录边界后再改这个步长。单位 ID 和类型索引的区别也要分清楚:前者更像“这个单位叫什么”,后者更像“这个单位在数据表里的属性索引”,两者都只是编号,不直接等于你能看到的文字名称。

4. 玩家归属与触发器:OWNR、SIDE 和 THG2 的二进制读法

4.1 用 OWNR 和 SIDE 对齐玩家槽位

UNIT 记录里那个 owner 字节,最好和 OWNR、SIDE 分区交叉验证一遍。OWNR 分区通常 8 字节,每一位代表一个玩家槽位;SIDE 分区也通常 8 字节,记录每个槽位的种族。两个分区一起读,才能把单位记录里的 owner 字节翻译成人话。

def read_player_slots(chunks: dict): ownr = chunks.get(b'OWNR', b'') side = chunks.get(b'SIDE', b'') slots = [] for idx in range(8): owner_flag = ownr[idx] if idx < len(ownr) else -1 side_val = side[idx] if idx < len(side) else -1 slots.append({'slot': idx, 'owner': owner_flag, 'side': side_val}) print(f'slot {idx}: owner={owner_flag} side={side_val}') return slots

逻辑说明:owner 字段的值 0 到 7 是玩家 1 到玩家 8,8 以上常常是中立或野怪阵营。很多老地图里中立单位集中出现在 owner 大于等于 8 的记录中。如果你解析出来的中立单位全挂在玩家 1 名下,那大概率是在某个上游步骤里做过 +1 或 -1 的偏移修正,而这里的数据本身并不需要偏移。

参数说明:side的数值在不同资料里有不同编码,有的把 0 当虫族、1 当人族、2 当神族,也有资料反着写。遇到这种情况不要死记数字,直接打开官方编辑器看一个已知地图的种族,再回头对照side的字节值,一次就能定死编码表。二进制分析不怕字段含义未知,怕的是拿一张图就下结论。

4.2 THG2 触发器分区:不懂格式就做差分对照

THG2 是解析资料里最劝退的部分。它保存触发器条件与动作,长度不固定,字段还会叠加玩家位标记和状态位。网上流传的字段表版本很多,有的对得上老地图,有的对上新编辑器导出的地图就断。面对这种没有权威文档的二进制块,我的做法不是硬扣字段表,而是用差分对照把黑匣子一层层剥开。

具体操作不复杂,用官方编辑器做两张一模一样的空地图,区别只在触发器数量上:

  • 地图 A 只加一条触发器,条件随意,动作随意,保存。
  • 复制地图 A 为地图 B,在原触发器后面追加一条完全相同内容的触发器,保存。
  • 分别读两张图的 THG2 分区长度,相减,得到单条触发器的增量开销。

增量不一定是记录净长度,可能包含对齐或填充字节,但只要多次实验差值稳定,你就能确定一条触发器的固定开销,然后再用“同条件、不同动作”的组合继续测,逐步把动作字段的长度边界测出来。这个办法比背字段表可靠得多,尤其适合分析那些被改版工具重写过的地图。

def thg2_len(path: str) -> int: with open(path, 'rb') as f: raw = f.read() chunks = dict(iter_chunks(raw)) return len(chunks.get(b'THG2', b'')) delta = thg2_len('map_two.scm') - thg2_len('map_one.scm') print('每条触发器的近似增量开销:', delta)

逻辑说明:第一次跑这个脚本时,有两张地图还不够,最好做三组对照,比如一条触发器、两条触发器、三条触发器,三个长度两两相减。如果差值都是同一个数,说明记录边界是稳定的;如果差值不稳定,那就要怀疑地图里包含动态长度字段,比如字符串引用表导致的对齐变化。

参数说明:thg2_len读取的是字节长度,不是触发器数量。在很长一段时间里,很多人被误导直接拿长度除以某个固定数去算触发器条数,结果总会多出几个字节。正确做法是先用差分确定单条触发器的开销,再回头推算条数。这个分区是典型的“看着像定长,实际有尾巴”的结构,只靠目测很容易翻车。

5. 星际争霸二进制解析排查与避坑记录:五个踩坑案例

5.1 小端当大端读,地图尺寸直接爆炸

现象:DIM 分区解析出来的宽度不是 256,而是一万多,坐标全部越界。

原因:struct.unpack('H', ...)默认按本机字节序,在某些环境里等效大端序。星际争霸地图内部数值基本都是小端序,用默认字节序读取就会把字节对调。

解决:无脑使用<H、<I这类显式小端标记。解析脚本里出现任何unpack都要检查有没有<,不要依赖所谓“本机默认”。这算是最典型的血泪经验,几乎每个写过二进制解析的人都在这里浪费过半天。

5.2 把 MTXM 当成调色板索引,地形图全成花屏

现象:地面渲染出来后颜色完全不对,要么一整片都是同一个样式,要么单位位置和地形对不上。

原因:MTXM 每个格子是两字节,不是直接写死的颜色索引。高字节是地形组,低字节是变体和边缘信息,把两字节整体当索引去匹配贴图,等于拿错字典查词。

解决:先按高字节拆出地形组,再按低字节处理变体。遇到高低位语义反着的地图版本,用已知地图对照校正一次,再批量执行。不要在同一批数据里混用两套解释规则。

5.3 玩家槽位从 0 开始,owner 字节被反复 +1

现象:单位记录全解析出来了,但玩家的单位全部挂到后一位玩家名下,中立单位反而出现在玩家 1。

原因:owner 字段 0 就是玩家 1,7 是玩家 8。有人在前置处理里做过owner + 1转成“人类可读玩家编号”,后续解析又加了一次,导致整体偏移。

解决:在解析链路里统一约定:原始字节永远保留,显示层再做偏移。所有中间产物直接保存 owner 原始值,可视化或输出报告时才处理成玩家编号。这样即使显示层写错,底层数据也不会污染。

5.4 相同标签的分区重复出现,字典覆盖丢数据

现象:解析结果里字符串表缺失,或者某些地块的装饰物数量比编辑器里少。

原因:我常用chunks[tag] = data这种字典方式聚合分区。正常情况下一个标签只出现一次,但被某些工具重新保存过的地图里,同一标签可能出现多次,字典覆盖会导致前面的数据悄悄丢掉。

解决:把聚合改成列表,或者至少对重复出现给出警告。遍历时按偏移排序保存全部(tag, data),再按业务需求决定取哪一个。分析资料类脚本尤其要注意这点,因为你不是只处理一张图,而是要处理几百张来源未知的地图。

5.5 录像文件不是 CHK 容器,别直接套解析器

现象:把 .rep 录像文件丢进 CHK 解析器,一个标签都读不出来,从头到尾都是乱码。

原因:录像文件有自己的外层容器结构,和地图 CHK 不是一回事。录像内部会嵌一段地图数据,但那段数据在文件里的偏移需要先通过录像头解析出来,不能直接当文件头处理。

解决:先解析录像外层结构,找到内嵌地图段的起始偏移,把那段二进制切出来,再送进 CHK 遍历器。如果只是想要地图布局,大多数场景直接用原版 .scm 文件更省事,不要绕到录像里自找麻烦。

6. 把解析结果送进自己的工具链:JSON 化与最小可视化

6.1 统一输出 JSON,让下游工具只认一种格式

解析脚本越写越多,最容易出现的问题是每个脚本输出格式都不同,有的打印文本,有的写 CSV,有的直接进内存。我建议在最外层统一做一个导出函数,把所有核心字段收敛成一份 JSON,后续不管是做地图统计、喂给模拟环境,还是做可视化,都只依赖这份结构化输出。

import json def export_map_json(src: str, dst: str): with open(src, 'rb') as f: raw = f.read() chunks = dict(iter_chunks(raw)) dim = chunks.get(b'DIM', b'') if len(dim) < 4: raise ValueError('bad DIM') width, height = struct.unpack('<HH', dim[:4]) units = parse_units(chunks.get(b'UNIT', b'')) ownr = list(chunks.get(b'OWNR', b'')[:8]) side = list(chunks.get(b'SIDE', b'')[:8]) mtm = chunks.get(b'MTXM', b'') ground_group = [] for i in range(len(mtm) // 2): tile = struct.unpack_from('<H', mtm, i * 2)[0] ground_group.append(tile >> 8) payload = { 'width': width, 'height': height, 'ground_group': ground_group, 'units': units, 'owners': ownr, 'sides': side, 'source': src, } with open(dst, 'w') as f: json.dump(payload, f, indent=2)

逻辑说明:这份 JSON 不追求完整表达地图所有细节,只保证后续工具能拿到最基本的坐标、归属和地形组信息。导出后记得检查 units 数量和编辑器里的放置数量是否一致,这一步能验证前面所有分区解析是否正常。

6.2 用终端点阵做最小验证,不需要可视化引擎

解析完成不等于解析正确。最快验证方式不是立刻渲染大图,而是把地面和单位投影成一份低分辨率点阵,单位用U标识,有空地的地方用#标识。如果点阵边缘和编辑器缩略图对得上,说明坐标系统基本没偏。

def draw_ascii(units, width, height, step=4): gw = max(1, width // step) gh = max(1, height // step) grid = [['.' for _ in range(gw)] for _ in range(gh)] for u in units: x = min(gw - 1, u['x'] // step) y = min(gh - 1, u['y'] // step) grid[y][x] = 'U' for row in grid: print(''.join(row))

逻辑说明:step是降采样步长,先把大尺寸地图缩到终端宽度内,再看单位分布是否合理。这里只验证几何对齐,不验证地形语义,所以地面全部用#也没有关系。等点阵验证通过,再决定要不要接图形界面的可视化,这样能省掉很多调试时间。

我现在的习惯是每次拿到新来源的地图,先跑一份 JSON,再用点阵扫一眼边界,确认无异常后才进入批量解析。可视化这种东西锦上添花,但一份干净、字段明确的 JSON 才是整个二进制分析资料里最值得沉淀的部分。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进