Files
TankIC/3DTank/README.md
T
TermiNexus 2e5e8bd7b0 feat(3dtank): 协议工具链入库:上行命令接入 fake server + 字段表/WireClasses
- wss_server:识别 c2s 空间命令([nullmap][objectId i64][methodId i64][负载])并逐字段解码
- 应答签名改用 S:<methodId>(pair_flow.js 同步),fake 模式能按具体上行命令触发对应下行回放
- extract_codec_fields:新增「协议核心类 → 线上基础类型」挖掘(WireClasses,xr/R → i64)
- lib_space_codec:按模型真名/核心类换算兜底(ji → i64、xr → i64),抽成上下行共享库
- decode_space_frames:记录容器(_t/lt)已核实结构与未闭合环节
- README:补「协议逆向与离线模拟」章节(帧格式/字段编码/命令表/fake 回放/容器现状)

实测:297 条上行记录 100% 完整解出;fake 模式按 G.q1wd、ls.xbl 等命令精确触发应答
2026-09-16 16:30:38 +08:00

9.7 KiB
Raw Blame History

3DTank 逆向工程

把 main.6469b966.js(webpack5 打包的游戏客户端)还原成可读、可改的源码工程。

目录

3DTank/
  src/                      # 从 bundle 拆出的源码(prettier 美化)
    module_<id>.js          #   126 个代码模块
    resources.json          #   1218 个资源模块(ID → 路径)
    index.json              #   模块索引
  names/
    evidence.json           # 命名证据(见下)
  tools/
    extract.js              # bundle → src/
    mine_evidence.js        # src/ → names/evidence.json
  (待建) src_named/         # 应用重命名后的源码
  (待建) tools/apply_names.js / build_bundle.js

原始 bundle 不复制进本工程,默认引用 ../main.6469b966.js(可用参数覆盖)。

用法

cd tools
npm install
node extract.js                      # 拆模块 → ../src
node mine_evidence.js                # 挖命名证据 → ../names/evidence.json

命名证据(逆向的关键抓手)

编译产物里保留了相当多"原始名字",这是还原命名的地基:

证据 例子 数量
组件/服务懒加载 getter var x = t.a11l_1; if (null != x) return x; vj("gameMode") → 字段 a11l_1 就是 gameMode 1718 个字段
数据类 toString Q(r7).toString = function () { return "Data(" + ... } → 类 r7 即 Data 921 个类
枚举构造 E = new Qc("Shadows", 0) → 类 Qc、成员 Shadows=0 337 个枚举 / 2633 个成员
闭包原名 fn.callableName = "addToBattle" 477 处
导出表 t.$_$.ac = Tat → 模块 87072 的 ac 导出 6249 条
字符串常量 错误信息、协议动作名等 21792 条

命名分层(作用域决定重命名安全性)

  1. 模块内局部名(类名、函数名、变量名):仅在本模块有效,可自由改名 → 用 classNames/enums/closures 打底,其余靠上下文推断。
  2. 属性名(字段/方法):全局共享字符串(如 a11l_1、rym),改名必须全局一致 → 由 fieldsGlobal + 导出表 + 人工词典统一驱动。
  3. 不可改名:宿主/浏览器 API、equals/toString/h1/e1/g1 等工具链与引擎已依赖的名字。

验证闭环

重命名后必须能重新组装成可加载的 bundle 并通过加载测试(126 模块 + 1218 资源全注册), 避免"改坏语义",特别是属性名替换。

本地可运行客户端

cd tools
node fetch_assets.js --concurrency 10   # 镜像 1223 个文件(≈70MB)到 web/
node serve.js --port 8080               # 起本地站点
# 浏览器打开 http://localhost:8080/

目录/挂载:

  • web/:index.html(源站首页)+ play/static/**(1218 个资源、字体、图标、manifest)
  • /play/static/js/** 直接挂 ../3dtank.com/play/static/js/(切割版 JS,单一来源,含 hornet 注入)
  • 404 会记到 names/missing.txt;代理响应会缓存到 cache/

域名守卫与代理(关键)

src/module_13431.js:1656-1716:游戏按 location.host 决定配置端点;非 *.3dtank.com 域名(如 localhost)会退化成

resources       = http(s)://<本机>/resources
config-template = http(s)://<本机>/config.xml

这两条在本机原本 404,config.xml 取不到就重试并重载页面 → 表现为"一直刷新"。 serve.js 因此内置代理:

本机路径 转发到 说明
/resources/** https://res.3dtank.com/** CDN 资源基址
/config.xml https://c1.3dtank.com/config.xml 服务器配置(指向 wss://c1.3dtank.com:9090)
/s/status.js https://3dtank.com/s/status.js balancer 节点状态

代理会强制带上 origin: https://3dtank.com 与 referer(CDN 有来源校验),并落盘缓存。 另外 URL 查询参数优先级高于上述默认值,也可用 ?resources=…&config-template=…&balancer=… 直接覆盖(值需 URL 编码)。

协议逆向与离线模拟(tools/ 协议工具链)

把 wss://c1.3dtank.com:9090/ 的握手、加解密、空间帧结构、命令表全部还原,并能在本地起一个 "假服务器"离线跑客户端。

帧格式

帧   = [2 或 4 字节大端长度][帧体]        b0&0x80 → 4 字节头;b0&0x40 → 帧体 zlib 压缩
帧体 = [nullmap][payload]                 payload = [1 字节命令 id][body]
入口层命令 id:c2s 1=Hash Request 3=Auth/EnterSpace 32=ClientLog;s2c 2=Hash Response 32=Open Space

进入空间后链路切到加密(变体 XOR 流密码,见 wss_server.js 的 Cipher:p = c ^ key[idx]; key[idx] = p; idx ^= p & 7)。

空间帧(两个方向,形状不同)

方向 形状
s2c [nullmap][spaceId i64 8B][ (methodId i64 8B)(负载) … ]
c2s [nullmap][objectId i64 8B][ (methodId i64 8B)(负载) … ](没有命令 id 字节层)

上行形状的出处是 src/module_61860.js 的 SpaceCommand codec(fr.o7g 依次写 objectId、methodId 再写命令自身负载),并与抓包字节互相验证(171/171 帧 methodId 命中、G.q1wd 的 i32=1 恰好吃满 4 字节)。

字段级解码(names/codec_fields.json)

lib_space_codec.js 按下面的规则逐字段解码,规则全部来自 src/module_24635.js 的序列化器实现:

类型 线上宽度
bool/i8 / i16 / i32,f32 / i64,f64 1 / 2 / 4 / 8 字节(大端)
string,bytes varint 长度 + 原始字节
list<T> varint 元素数 + 元素…
opt<T> 1 字节标志(非 0 = 空)+ 有值时紧跟 T
enum<T> 4 字节(库里 o7g 是空实现,但游戏自注册的枚举以 i32 上线)
模型类 就地展开该模型 codec 的字段(无长度前缀)
varint <128 → 1B;<16384 → 2B(`0x80

字段表的来源(tools/extract_codec_fields.js):每个 codec 有三段式结构 —— getter(v("codec_名字") 的报错串就是字段名)/ q7g(slot → 类型描述符)/ p7g(字段线上顺序,调用次序即顺序)。 类型描述符三兄弟在 module_24635:$_$.e=单值、$_$.c=list、$_$.b=enum,构造器第 2 参 !0 = optional。

两条"没有名字也能救回来"的兜底(都不是猜宽度,而是从源码取的证据):

  • WireClasses:协议核心类用 m7f(kClass(类), new Codec()) 注册,这些 codec 的 o7g 只调一次写入器 → 类 → 线上基础类型可反推(实体引用 xr → i64、R → i64)。只认「恰好一次写入」的标量 codec,向量/嵌套命令自动跳过。
  • CodecByModelName:字段只写了类名、没带 codec 链接时按模型真名找 codec(如 ji = module_24635::Xt 单 i64)。

命令表

产物 内容 来源
names/method_table.json s2c methodId → 模型/codec names/regdump.json(运行时 dump 的注册表)
names/uplink_table.json c2s methodId → 命令类 / 发送方法 / 负载字段 源码里的 Long 字面量(new $(lo, hi))+ 发送点前的 this.<codec>.o7g( 次序
names/flow.json / flow_s2c.tsv 请求 → 应答配对 tools/pair_flow.js(按时间就近配对)

Long 合成:value = hi*2^32 + (lo>>>0),已用源码 h7n() 对客户端 dump 的注册表 680/680 标定。

上行字段没有可读名:命令类不像模型 codec 那样保留 codec_<名字> getter,555 个上行字段在模块内 没有任何属性访问点,压缩后原可读名已不可恢复,只能人工标注或靠调用点语义推断。

离线模拟(fake 模式)

cd tools
node wss_server.js --mode relay            # 中继:客户端 → 本地 → 真实服务器(抓包 + 实时解析)
node wss_server.js --mode fake             # 离线:自己回 Hash 握手 + Open Space,按抓包回放
node wss_server.js --mode fake --replay-scale 0.5 --replay-gap-max 200
node decode_space_frames.js --dir ../packets/plain/space-100045720 --fields   # 事后逐字段解一包
node pair_flow.js                          # 重新生成请求→应答配对表
node ingest.js --label mine-01             # 把一次抓包入库到 packets/plain/

回放分两类:主动推送按抓包时间线(timeline_s2c.txt)铺开;应答挂在 Watchers 上等对应 c2s 到达。 应答签名(必须与 pair_flow.js 的 Signature 一致,改一边就要改另一边):

  • 上行空间帧 → S:<methodId>(源码常量,跨会话稳定,能精确到某条具体命令)
  • 其余 → <命令 id>:<负载前 4 字节 hex>

于是"客户端点商店/改名/切频道"这类上行都能触发它自己的那组下行,等不到触发的仍按原时间线兜底。

容器(无 codec 的记录)现状

容器的模型恒为 _t(src/module_45686.js:94),codec 是 lt(同文件 483-494),只有一个字段 idCodec(i64): 读 i64,0 → 值是紧跟其后的那个 i64;否则按 modelRegister(module_45686 的 at.k1we())里该 id 的 codec 就地展开。 实测容器负载(space-100045720/s2c_0000.bin):

[i64 version=1][i64 idA=100045311][i32 N=5][N × i64 modelId][i32 0][i64 idB=100045720]
[(i64 methodId)(负载)…]        ← 内层与外层帧体同形,且可无限嵌套(内层又是 _t 容器)

尚未闭合:负载首字节并不是 modelRegister 的 id(实测是 i64 1),说明 methodId 与模型负载之间还有一小段 头部(可能是命令 codec 自己写的 tag/版本)。要彻底确定需要拿到容器命令自身的 codec,而不是 _t 的模型 codec; 在此之前容器内层仍靠 modelId 窗口扫,统计里的"精确覆盖"只作参考。