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 等命令精确触发应答
This commit is contained in:
TermiNexus committed 2026-09-16 16:30:38 +08:00
1 parent f1c9d903fa
commit 2e5e8bd7b0
8 files changed
+62063

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
# 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`(可用参数覆盖)。
## 用法
```bash
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 资源全注册),
避免"改坏语义",特别是属性名替换。
## 本地可运行客户端
```bash
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&lt;T&gt; | varint 元素数 + 元素… |
| opt&lt;T&gt; | 1 字节标志(非 0 = 空)+ 有值时紧跟 T |
| enum&lt;T&gt; | 4 字节(库里 `o7g` 是空实现,但游戏自注册的枚举以 i32 上线) |
| 模型类 | 就地展开该模型 codec 的字段(无长度前缀) |
| varint | &lt;128 → 1B;&lt;16384 → 2B(`0x80|高6位, 低8位`);&lt;4194304 → 3B |
字段表的来源(`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 模式)
```bash
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 窗口扫,统计里的"精确覆盖"只作参考。
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+445
View File
@@ -0,0 +1,445 @@
// decode_space_frames.js - 下行(s2c)空间帧解码器:把"记录流"逐条标出模型名/字段/负载
// 用法:
// node decode_space_frames.js [--dir ../packets/plain/space-100072911] [--limit 30] [--all] [--strings]
// node decode_space_frames.js --dir ../packets/plain/space-100045720 --fields ← 用字段表逐字段真解
// 结构(本轮逆向确认):
// 帧 = [2/4 字节头(bit6=zlib压缩)][帧体]
// 帧体 = [nullmap][spaceId int64 8B][记录…]
// 记录 = [methodId int64 8B][负载] ← methodId 查 names/method_table.json
// 字段级编码(来自 module_24635 的序列化器实现,见 tools/extract_codec_fields.js 的注释):
// bool/i8=1B i16=2B i32/f32=4B i64/f64=8B(全部大端)
// string/bytes = varint 长度 + 原始字节
// list<T> = varint 元素数 + 元素…
// opt<T> = 1 字节标志(非 0 = 空)+ 有值时紧跟 T
// enum<T> = 0 字节(EnumCodecInfo 的序列化器 o7g 是空实现)
// 模型类 = 就地展开该模型 codec 的字段(无长度前缀)
// varint: <128 → 1 字节;<16384 → 2 字节(0x80|高6位, 低8位);<4194304 → 3 字节(0xC0|高6位, 中8位, 低8位)
// nullmap: 见 NullmapSize()(1..4 字节分档,或 0x80/0xC0 前缀的变长形式)——spaceId 紧跟其后
//
// --fields 实测(space-100045720):
// ✓ ClanChatCC 吃=9B/9B { inClan:false, selfName:"WYT2WYT" } 与原始字节 00 07 "WYT2WYT" 完全一致
// ✓ ChatCC channels=[{name:"默认", sid:"默认"}] list<ChannelSidAndName> 展开正确
// ✓ NewsShowingCC newsItems=[{description:"现在购买…", header:"…", imageUrl:"…"}] 中文字段全部可读
// 仍有的偏差:个别大模型(如 ChatCC)中段字段会漂移(某个字段的类型/顺序还没对上),
// 容器(methodId 的 codec 是 ArrayCodecInfo,如模型 _t)内部头部几十字节是序号/空间号等,
// 内层元素靠 modelId 窗口扫出,约 40% 能与容器边界吻合。
//
// 容器(无 codec 的记录)已核实的部分(2026-09-16,源码 module_45686 + 抓包字节双向验证):
// · 容器的模型恒为 _t(module_45686:94),其 codec 是 lt(module_45686:483-494),
// 只有 1 个「字段」= idCodec(i64):
// id = 读 i64;id == 0 → 值是紧跟其后的那个 i64;否则按 modelRegister 里该 id 对应的 codec 就地展开
// (modelRegister = module_45686 的 at.k1we(),k7n(id) 取 codec,与 module_61860 的 or.p7g 同源:
// 读不到 codec 会抛 CodecNotFoundForCommandException)
// · 实测容器负载(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/版本),因此容器内层目前仍靠 modelId 窗口扫,
// 不敢按上面的形状硬解。要闭合需要拿到容器命令自身的 codec(不是 _t 的模型 codec)。
// 容器字节归属统计里的「精确覆盖」= 能自洽解到下一个 modelId 候选的字节占比,仅作参考。
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const { LoadTables, MakeReader, FormatValue, FormatFields } = require('./lib_space_codec');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PROJECT = path.resolve(__dirname, '..');
const Table = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'method_table.json'), 'utf8'));
const Limit = Number(Arg('limit', '30')) || 30;
const ShowStrings = Argv.indexOf('--strings') >= 0;
const ShowFields = Argv.indexOf('--fields') >= 0;
const EnumBytes = Number(Arg('enum-bytes', '4'));
const All = Argv.indexOf('--all') >= 0;
// methodId(hex 8B) → 模型信息
const ByHex = new Map();
for (const R of Table.Rows) {
const Buf = Buffer.alloc(8);
Buf.writeBigInt64BE(BigInt(R.MethodId));
ByHex.set(Buf.toString('hex'), R);
}
// modelId(hex 8B) → 模型信息(内层记录用;来源 names/regdump.json 的 u7n_1/w7n_1)
const ByModelHex = new Map();
const ModelTablePath = path.join(PROJECT, 'names', 'method_table.json');
try {
const Reg = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'regdump.json'), 'utf8'));
const U = new Map();
for (const E of Reg.maps['ModelReg.u7n_1'].entries) U.set(E.key, E);
const W = new Map();
for (const E of Reg.maps['ModelReg.w7n_1'].entries) W.set(E.key, E);
const Rows = [];
for (const E of Reg.maps['ModelReg.u7n_1'].entries) {
const Ww = W.get(E.key) || {};
const Buf = Buffer.alloc(8);
Buf.writeBigInt64BE(BigInt(E.key));
const Rec = { ModelId: E.key, Model: E.ctor, ModelFields: E.keys, Codec: Ww.ctor || '', CodecFields: Ww.keys || [] };
ByModelHex.set(Buf.toString('hex'), Rec);
Rows.push(Rec);
}
fs.writeFileSync(path.join(PROJECT, 'names', 'model_table.json'), JSON.stringify({ Note: 'modelId → 模型类/codec(内层记录用)', Count: Rows.length, Rows }, null, 1), 'utf8');
} catch (Err) {
console.log('(未加载 regdump.json,内层记录无法展开:' + Err.message + ')');
}
// ---------------------------------------------------------------- 字段级解码(共享库 tools/lib_space_codec.js)
// names/codec_fields.json:CodecKey → { Fields:[{Name,Type,TypeCodec}], MethodId:[…] }
// names/uplink_table.json:上行 methodId → 命令类 / 发送方法 / 负载字段
const Tables = LoadTables(path.join(PROJECT, 'names'));
if (Tables.CodecError) console.log('(未加载 codec_fields.json,--fields 不可用:' + Tables.CodecError + ')');
if (Tables.UplinkError) console.log('(未加载 uplink_table.json,上行命令名不可用:' + Tables.UplinkError + ')');
const CodecByMethod = Tables.CodecByMethod;
const CodecByKey = Tables.CodecByKey;
const CodecByModelId = Tables.CodecByModelId;
const Reader = MakeReader(Tables, { EnumBytes });
const ReadVarint = Reader.ReadVarint;
const ReadFields = Reader.ReadFields;
// ---------------------------------------------------------------- OptionalMap(nullmap)长度
// 来自 module_24635 的 _n.j7h:
// b0 & 0x80 → 长度前缀形式:b0 & 0x40 时再吃 2 字节组成长度,否则长度 = b0 & 0x3f
// 否则 → 分档:档位 = (b0 & 0x60) >> 5(0..3),总长 = 1 + 档位
function NullmapSize(Buf, Off) {
const B0 = Buf[Off];
if (B0 & 0x80) {
const E = B0 & 0x3f;
if (B0 & 0x40) return 1 + 2 + ((E << 16) + ((Buf[Off + 1] || 0) << 8) + (Buf[Off + 2] || 0));
return 1 + E;
}
return 1 + ((B0 & 0x60) >> 5);
}
// 从 From 起找下一个「8 字节窗口命中方法表」的位置(用于 codec 未知时的兜底跳转)
function NextMethodHit(Buf, From) {
for (let Off = From; Off + 8 <= Buf.length; Off++) if (ByHex.has(Buf.slice(Off, Off + 8).toString('hex'))) return Off;
return Buf.length;
}
// 顺序走记录(记录起点紧跟上一条的负载结束处),返回 { Records, End, Ok }
function WalkRecords(Buf, Start) {
const Out = [];
let At = Start;
let Ok = true;
while (At + 8 <= Buf.length) {
const IdHex = Buf.slice(At, At + 8).toString('hex');
const Info = ByHex.get(IdHex);
if (!Info) {
Out.push({ Off: At, Error: '未知 methodId 0x' + IdHex });
Ok = false;
break;
}
const C = CodecByMethod.get(String(Info.MethodId));
if (!C) {
// 没有对应 codec:负载当容器处理。容器内的元素以 modelId 开头,已解出的就用字段表展开,
// 没解出的按「下一个命中方法表的窗口」兜底跳过去,保证还能继续往下走。
const Next = NextMethodHit(Buf, At + 8 + 1);
Out.push({
Off: At,
Model: Info.Model,
Codec: Info.Codec,
Container: true,
Len: Next - At - 8,
Inner: InnerScan(Buf, At + 8, Next),
});
At = Next;
continue;
}
const R = ReadFields(Buf, At + 8, C, 0);
Out.push({
Off: At,
Model: Info.Model,
Codec: Info.Codec,
CodecKey: C.Module + '::' + C.Codec,
ModelName: C.ModelName || '',
Len: R.Next - At - 8,
Decoded: R.Value,
Bad: R.Bad,
});
if (R.Bad || R.Next <= At + 8) {
// 解到一半就卡住:同样用兜底跳转继续走,避免整帧报废
const Next = NextMethodHit(Buf, At + 8 + 1);
if (Next >= Buf.length || Next <= At) {
Ok = false;
break;
}
At = Next;
continue;
}
At = R.Next;
}
return { Records: Out, End: At, Ok: Ok && At === Buf.length };
}
// 在容器负载里扫 [modelId 8B] 候选,并用「解出来正好落到下一个候选处」过滤假命中
// 容器负载实测结构: [头部 N 字节(序号/版本/spaceId/实体 id 等)] [ (modelId 8B)(负载) … ]
// 真记录的字段一定解到下一个 modelId 开头,假命中(modelId 字节嵌在别的数据里)解不到。
function InnerScan(Buf, From, To) {
const Hits = [];
for (let Off = From; Off + 8 <= To; Off++) {
const C = CodecByModelId.get(Buf.readBigInt64BE(Off).toString());
if (C) Hits.push({ Off, C });
}
const Out = [];
let ExactCover = 0;
let LastEnd = From;
for (let I = 0; I < Hits.length; I++) {
const H = Hits[I];
const Next = I + 1 < Hits.length ? Hits[I + 1].Off : To;
const R = ReadFields(Buf, H.Off + 8, H.C, 0);
const Consumed = R.Next - H.Off - 8;
const Exact = R.Next === Next && Consumed > 0;
if (!Exact && (R.Bad || Consumed <= 0)) continue; // 假命中
if (Exact) {
ExactCover += Next - H.Off;
LastEnd = Next;
}
Out.push({
Off: H.Off,
ModelId: H.C.ModelId,
ModelName: H.C.ModelName || '',
CodecKey: H.C.Module + '::' + H.C.Codec,
Len: Next - H.Off - 8,
Consumed,
Exact,
Decoded: R.Value,
});
}
return { Records: Out, Span: To - From, Header: (Out.length ? Out[0].Off : To) - From, ExactCover, Tail: To - LastEnd };
}
function BodyOf(Raw) {
const B0 = Raw[0];
const HeaderSize = B0 & 0x80 ? 4 : 2;
let Body = Raw.slice(HeaderSize);
if (B0 & 0x40) {
try {
Body = zlib.inflateSync(Body);
} catch (Err) {
return null;
}
}
return Body;
}
function ReadStrings(Buf, Max) {
const Out = [];
let S = '';
for (const B of Buf) {
if (B >= 32 && B < 127) S += String.fromCharCode(B);
else {
if (S.length >= 4) Out.push(S);
S = '';
}
if (Out.length >= Max) break;
}
if (S.length >= 4 && Out.length < Max) Out.push(S);
return Out;
}
// 找出所有"记录起点"(8 字节窗口命中方法表),返回 [{Off, Info}]
function FindRecords(Body) {
const Hits = [];
for (let Off = 0; Off + 8 <= Body.length; Off++) {
const Info = ByHex.get(Body.slice(Off, Off + 8).toString('hex'));
if (Info) Hits.push({ Off, Info });
}
return Hits;
}
// 在容器模型(_t/bj/hj…)的负载里找内层记录([modelId 8B][负载] 重复)
function FindInner(Payload) {
if (!ByModelHex.size || Payload.length < 24) return [];
const Hits = [];
for (let Off = 0; Off + 8 <= Payload.length; Off++) {
const Info = ByModelHex.get(Payload.slice(Off, Off + 8).toString('hex'));
if (Info) Hits.push({ Off, Info });
}
if (Hits.length < 3) return [];
return Hits.map((H, I) => {
const End = I + 1 < Hits.length ? Hits[I + 1].Off : Payload.length;
const Sub = Payload.slice(H.Off + 8, End);
return {
Off: H.Off,
Model: H.Info.Model,
Codec: H.Info.Codec,
CodecFields: (H.Info.CodecFields || []).join(','),
PayloadLen: Sub.length,
Head: Sub.slice(0, 20).toString('hex'),
Strings: ShowStrings ? ReadStrings(Sub, 4) : [],
};
});
}
function DecodeFrame(File, Raw) {
const Body = BodyOf(Raw);
if (!Body) return { File, Error: 'zlib 失败' };
const NmSize = NullmapSize(Body, 0);
const Nullmap = Body[0];
const SpaceId = Body.length >= NmSize + 8 ? Body.readBigInt64BE(NmSize).toString() : '?';
if (ShowFields) {
const W = WalkRecords(Body, NmSize + 8);
return {
File,
Raw: Raw.length,
Body: Body.length,
Compressed: !!(Raw[0] & 0x40),
Nullmap,
NullmapSize: NmSize,
SpaceId,
Records: W.Records,
WalkOk: W.Ok,
WalkEnd: W.End,
WalkFrom: NmSize + 8,
};
}
const Hits = FindRecords(Body);
const Records = Hits.map((H, I) => {
const End = I + 1 < Hits.length ? Hits[I + 1].Off : Body.length;
const Payload = Body.slice(H.Off + 8, End);
return {
Off: H.Off,
End,
Model: H.Info.Model,
Codec: H.Info.Codec,
CodecFields: H.Info.CodecFields,
PayloadLen: Payload.length,
Head: Payload.slice(0, 24).toString('hex'),
Strings: ShowStrings ? ReadStrings(Payload, 8) : [],
Inner: FindInner(Payload),
};
});
return { File, Raw: Raw.length, Body: Body.length, Compressed: !!(Raw[0] & 0x40), Nullmap, NullmapSize: NmSize, SpaceId, Records };
}
const DirArg = Arg('dir', '');
const Dirs = [];
if (All || !DirArg) {
const Root = path.join(PROJECT, 'packets', 'plain');
for (const D of fs.readdirSync(Root)) {
if (fs.statSync(path.join(Root, D)).isDirectory()) Dirs.push(path.join(Root, D));
}
} else Dirs.push(path.resolve(DirArg));
const ModelCount = {};
let Frames = 0;
let Records = 0;
let WalkOkFrames = 0;
let WalkBadFrames = 0;
let InnerTotal = 0;
let InnerExact = 0;
let InnerSpan = 0;
let InnerExactCover = 0;
let InnerHeader = 0;
let StringFields = 0;
const Lines = [];
for (const Dir of Dirs) {
const Files = fs.readdirSync(Dir).filter((F) => /^s2c_\d+\.bin$/.test(F)).sort();
if (!Files.length) continue;
Lines.push('===== ' + path.basename(Dir) + ' (' + Files.length + ' 帧)');
for (const F of Files) {
const R = DecodeFrame(F, fs.readFileSync(path.join(Dir, F)));
if (R.Error) {
Lines.push(' ' + F + ' ' + R.Error);
continue;
}
Frames++;
Records += R.Records.length;
for (const Rec of R.Records) if (Rec.Model) ModelCount[Rec.Model] = (ModelCount[Rec.Model] || 0) + 1;
if (ShowFields) {
if (R.WalkOk) WalkOkFrames++;
else WalkBadFrames++;
for (const Rec of R.Records) {
if (!Rec.Container || !Rec.Inner) continue;
InnerSpan += Rec.Inner.Span;
InnerExactCover += Rec.Inner.ExactCover;
InnerHeader += Rec.Inner.Header;
for (const In of Rec.Inner.Records) {
InnerTotal++;
if (In.Exact) InnerExact++;
}
}
}
Lines.push(
' ' + F.padEnd(14) + ' raw=' + String(R.Raw).padStart(6) + ' 体=' + String(R.Body).padStart(6) +
(R.Compressed ? ' zlib' : ' ') + ' nullmap=0x' + R.Nullmap.toString(16).padStart(2, '0') +
'(' + R.NullmapSize + 'B)' +
' spaceId=' + R.SpaceId + ' 记录 ' + R.Records.length + ' 条' +
(ShowFields ? (R.WalkOk ? ' ✓走完整帧' : ' ⚠走到 @' + R.WalkEnd + '/' + R.Body + ' 就断了') : ''),
);
for (const Rec of R.Records) {
if (ShowFields) {
const Head = ' @' + String(Rec.Off).padStart(6) + ' ' + String(Rec.Model || '?').padEnd(6);
if (Rec.Error) {
Lines.push(Head + ' ' + Rec.Error);
continue;
}
if (Rec.Container) {
const In = Rec.Inner;
Lines.push(
Head + ' 容器负载=' + Rec.Len + 'B 头部 ' + In.Header + 'B ' +
'记录 ' + In.Records.length + ' 条(吃满边界的 ' + In.Records.filter((X) => X.Exact).length + ' 条)' +
' 精确覆盖 ' + In.ExactCover + '/' + In.Span + 'B' + (In.Tail ? ' 尾部余 ' + In.Tail + 'B' : ''),
);
for (const Ri of In.Records) {
Lines.push(
' ↳ @' + String(Ri.Off).padStart(6) + ' ' + String(Ri.ModelName || Ri.ModelId).padEnd(28) +
' 吃=' + String(Ri.Consumed).padStart(6) + 'B/' + String(Ri.Len).padStart(6) + 'B ' + Ri.CodecKey +
(Ri.Exact ? ' ✓' : ' ⚠边界不符(可能是假命中)'),
);
for (const L of FormatFields(Ri.Decoded, ' ')) Lines.push(L);
}
continue;
}
Lines.push(Head + ' ' + String(Rec.ModelName || '').padEnd(28) + ' 负载=' + String(Rec.Len).padStart(6) + 'B ' + Rec.CodecKey + (Rec.Bad ? ' ⚠截断' : ''));
for (const L of FormatFields(Rec.Decoded, ' ')) Lines.push(L);
continue;
}
Lines.push(
' @' + String(Rec.Off).padStart(6) + ' ' + String(Rec.Model).padEnd(6) + ' 负载=' + String(Rec.PayloadLen).padStart(7) +
'B codec=' + String(Rec.Codec).padEnd(5) + ' 字段=[' + String(Rec.CodecFields).slice(0, 60) + ']' +
(Rec.Head ? ' hex=' + Rec.Head : '') + (Rec.Strings.length ? ' str=' + JSON.stringify(Rec.Strings) : ''),
);
for (const In of Rec.Inner.slice(0, 12)) {
Lines.push(
' ↳ @' + String(In.Off).padStart(6) + ' ' + String(In.Model).padEnd(6) + ' 负载=' + String(In.PayloadLen).padStart(6) +
'B codec=' + String(In.Codec).padEnd(5) + ' 字段=[' + String(In.CodecFields).slice(0, 50) + ']' +
(In.Head ? ' hex=' + In.Head : '') + (In.Strings.length ? ' str=' + JSON.stringify(In.Strings) : ''),
);
ModelCount['inner:' + In.Model] = (ModelCount['inner:' + In.Model] || 0) + 1;
}
if (Rec.Inner.length > 12) Lines.push(' ↳ …内层共 ' + Rec.Inner.length + ' 条');
}
}
}
const Tail = [
'',
'== 汇总 ==',
' 帧 ' + Frames + ' 个,记录 ' + Records + ' 条,出现模型 ' + Object.keys(ModelCount).length + ' 种',
' 模型分布(top20): ' + Object.entries(ModelCount).sort((A, B) => B[1] - A[1]).slice(0, 20).map(([K, V]) => K + ':' + V).join(' '),
];
if (ShowFields) {
const Pct = (A, B) => (B ? Math.round((A / B) * 100) + '%' : '-');
Tail.push(
' --fields: 走完整帧 ' + WalkOkFrames + ' / 中途断 ' + WalkBadFrames +
';容器内层记录 ' + InnerTotal + ' 条,吃满边界 ' + InnerExact + ' 条(' + Pct(InnerExact, InnerTotal) + ')' +
'(enum 按 ' + EnumBytes + ' 字节)',
' 容器字节归属:精确覆盖 ' + InnerExactCover + 'B / 容器 ' + InnerSpan + 'B(' + Pct(InnerExactCover, InnerSpan) +
'),未解析头部合计 ' + InnerHeader + 'B',
);
}
const Out = Lines.slice(0, All ? 400 : Limit * 6).concat(Tail);
console.log(Out.join('\n'));
// 落盘时截断,避免 --fields 把每一层字段都写进去导致文件爆到上百 MB
const DumpPath = path.join(PROJECT, 'names', 'decoded_frames.txt');
const MaxDumpLines = Arg('dump-lines', '6000') === '0' ? Infinity : Number(Arg('dump-lines', '6000')) || 6000;
const Dumped = Lines.length > MaxDumpLines ? Lines.slice(0, MaxDumpLines).concat(['…(已截断,共 ' + Lines.length + ' 行;用 --dump-lines 0 可全量写出)']) : Lines;
fs.writeFileSync(DumpPath, Dumped.join('\n') + Tail.join('\n') + '\n', 'utf8');
console.log('完整结果已写出: ' + DumpPath + '(' + Dumped.length + '/' + Lines.length + ' 行)');
+898
View File
@@ -0,0 +1,898 @@
// extract_codec_fields.js - 从空间层 codec 模块里抽出「字段名 + 类型 + 线上顺序」
// 用法:
// node extract_codec_fields.js --scan // 扫 src/ 全部模块(推荐)
// node extract_codec_fields.js --module ../src/module_54199.js
// node extract_codec_fields.js --scan --show st,ft // 打印指定 codec 的字段表
// node extract_codec_fields.js --scan --json // 额外打印 JSON 片段
//
// 原理(Kotlin/JS 协议 codec 的三段式结构;codec 变量名只在本模块内唯一,故键 = 模块::变量名):
// ① 字段名 getter: function G(t) { var n = t.<slot>; if (null != n) return n; v("codec_<字段名>"); }
// —— 报错字符串就是字段名(Kotlin 惰性属性名的兜底提示)
// ② (X(Codec).q7g = function (t) { this.<slot> = t.n7f(new <描述符>(...)); ... })
// —— slot → 类型描述符。首参以 "new " 开头 → 该头是「可选」包装;否则是「必填」包装;
// b(X) → 列表<X>;w().pb() → bool;w().xb() → string
// ③ (X(Codec).p7g = function (t) { ... G(this).p7g(t) ... t.a7g_1.<x>u() ... })
// —— 调用次序 = 线上字段次序;匿名 t.a7g_1.<x>u() 是无名字段的裸读
// 位宽: xy=i8 s2t=i16 t2t=i32 u2t=i64 v2t=f32 w2t=f64(见 module_72834 的读取器定义)
// ④ p7g 里给数据类赋值的属性名(如 i.xi0_1 = ...)是该模型数据类的有序字段名,
// 可与 names/regdump.json 里的模型字段列表 join 出 modelId
// 产物: names/codec_fields.json
const fs = require('fs');
const path = require('path');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PROJECT = path.resolve(__dirname, '..');
const OutPath = path.resolve(Arg('out', path.join(PROJECT, 'names', 'codec_fields.json')));
const Scan = Argv.indexOf('--scan') >= 0;
const SrcDir = path.join(PROJECT, 'src');
const ModuleFiles = Scan
? fs
.readdirSync(SrcDir)
.filter((F) => /^module_\d+\.js$/.test(F))
.map((F) => path.join(SrcDir, F))
: [path.resolve(Arg('module', path.join(SrcDir, 'module_54199.js')))];
// ---------------------------------------------------------------- 通用工具
// 配平扫描:从 Open 处的开括号找到对应闭括号(跳过字符串字面量)
function MatchBracket(Text, Open, OpenCh, CloseCh) {
let Depth = 0;
for (let I = Open; I < Text.length; I++) {
const C = Text[I];
if (C === '"' || C === "'" || C === '`') {
const Q = C;
I++;
while (I < Text.length && Text[I] !== Q) {
if (Text[I] === '\\') I++;
I++;
}
} else if (C === OpenCh) Depth++;
else if (C === CloseCh) {
Depth--;
if (!Depth) return I;
}
}
return -1;
}
// 按顶层逗号切分实参
function SplitArgs(Text) {
const Out = [];
let Depth = 0;
let Start = 0;
for (let I = 0; I < Text.length; I++) {
const C = Text[I];
if (C === '"' || C === "'" || C === '`') {
const Q = C;
I++;
while (I < Text.length && Text[I] !== Q) {
if (Text[I] === '\\') I++;
I++;
}
} else if (C === '(' || C === '[' || C === '{') Depth++;
else if (C === ')' || C === ']' || C === '}') Depth--;
else if (C === ',' && Depth === 0) {
Out.push(Text.slice(Start, I).trim());
Start = I + 1;
}
}
Out.push(Text.slice(Start).trim());
return Out;
}
// ---------------------------------------------------------------- 类型解析
// 基础类型注册表的访问器 → 可读类型名
// 来源: module_37036 的 `Wr($_).<accessor> = function () { return this.<xxx>Class; }`
// (旧版 tools/ 里把它误当成 codec 的 name/sid 读取器,这里以源码为准)
const PrimTable = {
mb: 'any',
nb: 'number',
ob: 'nothing',
pb: 'bool',
qb: 'i8',
rb: 'i16',
sb: 'i32',
tb: 'i64',
ub: 'f32',
vb: 'f64',
wb: 'array',
xb: 'string',
yb: 'throwable',
zb: 'boolArray',
ac: 'charArray',
bc: 'i8Array',
cc: 'i16Array',
dc: 'i32Array',
ec: 'f32Array',
fc: 'f64Array',
};
const PrimName = (K) => PrimTable[K] || 'prim:' + K;
// 读取器方法名 → 线级数值宽度(module_72834 的读取器)
const ReaderType = {
xy: 'i8',
n2u: 'i8',
s2t: 'i16',
o2u: 'i16',
t2t: 'i32',
p2u: 'i32',
u2t: 'i64',
q2u: 'i64',
v2t: 'f32',
r2u: 'f32',
w2t: 'f64',
s2u: 'f64',
};
// 类型描述符表达式 → {Type, Raw}
// 描述符类只在别处定义,各模块用局部别名引用,因此先按「角色」识别别名(见 FindRoles)。
// 角色 List → ListCodecInfo(element, optional) → list<...>
// 角色 Enum → EnumCodecInfo(type, optional) → enum<...>
// 角色 Type → TypeCodecInfo(type, optional) → ...
// 角色 KClass→ kClass(X) → X
// 角色 Prim → KClass 注册表实例,X().pb() 取基础类型类 → bool/i32/string/...
// optional 标志是构造器第 2 参:!0 = optional = true
function TypeOfDescriptor(Expr, Roles, Depth) {
const Raw = Expr.trim();
if (Depth > 6) return { Type: '...', Raw };
const E = Raw.replace(/^new\s+/, '');
// 基础类型:X().pb()
const Whole = /^([A-Za-z_$][\w$]*)\(\)\.([A-Za-z0-9_$]+)\(\)$/.exec(E);
if (Whole) return { Type: PrimName(Whole[2]), Raw };
const M = /^([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/.exec(E);
if (!M) return { Type: E || '?', Raw };
const Role = Roles[M[1]] || '';
const Args = SplitArgs(M[2]);
const First = Args[0] || '';
const Optional = Args.some((A) => A === '!0');
let Base;
if (Role === 'List') {
Base = 'list<' + TypeOfDescriptor(First, Roles, Depth + 1).Type + '>';
} else {
const Inner = TypeOfDescriptor(First, Roles, Depth + 1).Type;
if (Role === 'Enum') Base = 'enum<' + Inner + '>';
else if (Role === 'KClass') Base = Inner;
else Base = Inner || '?';
}
return { Type: Optional ? 'opt<' + Base + '>' : Base, Raw };
}
// JS 级类型线索(来自 p7g 里对数据类赋值的判定)→ 可读类型名
function JsTypeOf(Seg) {
if (/"boolean" == typeof/.test(Seg)) return 'bool';
if (/"string" == typeof/.test(Seg)) return 'string';
const Inst = /instanceof ([A-Za-z_$][\w$]*)/.exec(Seg);
if (Inst) return Inst[1];
if (/[A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*, [A-Za-z_$][\w$]*\)/.test(Seg)) return 'list';
return '';
}
// ---------------------------------------------------------------- 别名角色识别
// 模块结构(切割后):
// ;(window.__modules__ = window.__modules__ || {})[54199] = (t, n, i) => {
// !(function (t, n, i, r, e, u) { "use strict"; ... })
// (t.exports, i(37036), i(24635), i(88693), i(52943), i(61860));
// };
// 所以「IIFE 形参名 → 依赖模块号」可从句尾的调用实参表读出来。
// 描述符三兄弟定义在 module_24635:
// $_$.e = gn = TypeCodecInfo(type, optional) 单值
// $_$.c = wn = ListCodecInfo(element, optional) 列表
// $_$.b = vn = EnumCodecInfo(type, optional) 枚举
// 基础类型注册表与 kClass 助手在 module_37036:$_$.w4 = j_()、$_$.mf = x_(cls)
const CODEC_INFO_MODULE = '24635';
const KOLIB_MODULE = '37036';
const ROLE_KEYS = {
[CODEC_INFO_MODULE + '::e']: 'Type',
[CODEC_INFO_MODULE + '::c']: 'List',
[CODEC_INFO_MODULE + '::b']: 'Enum',
[KOLIB_MODULE + '::w4']: 'Prim',
[KOLIB_MODULE + '::mf']: 'KClass',
};
// module_03569.js → '3569'
const ModIdOf = (File) => String(Number((/module_(\d+)\.js$/.exec(File) || [, '0'])[1]));
// 形参名 → 依赖模块号
function FindDeps(Src) {
const ParamToMod = {};
const At = Src.lastIndexOf('})(');
if (At < 0) return ParamToMod;
let Close = MatchBracket(Src, At + 2, '(', ')');
if (Close < 0) Close = Src.length;
const Args = SplitArgs(Src.slice(At + 3, Close));
const Head = /\(\s*function\s*\(([^)]*)\)\s*\{/.exec(Src);
if (!Head) return ParamToMod;
const Params = SplitArgs(Head[1]);
for (let I = 0; I < Params.length && I < Args.length; I++) {
const M = /\(\s*(\d+)\s*\)/.exec(Args[I]);
if (M) ParamToMod[Params[I]] = M[1];
}
return ParamToMod;
}
// 本模块导出的类:$_$.<key> = <类名>
function FindExports(Src) {
const Out = {};
const Re = /\(\s*t\.\$_\$\.([A-Za-z0-9_$]+) = ([A-Za-z_$][\w$]*)\s*[,);]/g;
let M;
while ((M = Re.exec(Src))) if (!Out[M[1]]) Out[M[1]] = M[2];
return Out;
}
// 局部别名 → 引用的是哪个模块的哪个导出:<别名> = <形参>.$_$.<key>
// 注意:别名可以是 `$`,所以不能用 \b($ 不是单词字符),改用否定后顾
function FindAliases(Src, ParamToMod) {
const Out = {};
const Re = /(?<![A-Za-z0-9_$.])([A-Za-z_$][\w$]*) = ([A-Za-z_$][\w$]*)\.\$_\$\.([A-Za-z0-9_$]+)[,;]/g;
let M;
while ((M = Re.exec(Src))) {
const Dep = ParamToMod[M[2]];
if (Dep && !Out[M[1]]) Out[M[1]] = { Mod: Dep, Key: M[3] };
}
return Out;
}
// 局部别名 → 角色(Type/List/Enum/Prim/KClass)
function FindRoles(Src, ParamToMod) {
const Roles = {};
const Aliases = FindAliases(Src, ParamToMod);
for (const [Name, A] of Object.entries(Aliases)) {
const Role = ROLE_KEYS[A.Mod + '::' + A.Key];
if (Role) Roles[Name] = Role;
}
return Roles;
}
// 数据类真名:Kotlin 生成的 toString 里就写着原始类名
// (d(Ft).toString = function () { var t = "ChannelSidAndName ["; ...
function FindClassNames(Src) {
const Out = {};
const Re = /\(\s*[A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*)\)\.toString = function \(\) \{\s*(?:var [A-Za-z_$][\w$]* = )?"([^"]{1,90})/g;
let M;
while ((M = Re.exec(Src))) {
const N = /^(?:class\s+)?([A-Za-z_][\w$]*)/.exec(M[2]);
if (N && !Out[M[1]]) Out[M[1]] = N[1];
}
return Out;
}
// 数据类工厂:function Ut() { return ((t = M(d(Ft))), Ft.call(t), t); } → Ut 其实是造 Ft
function FindFactories(Src) {
const Out = {};
const Re =
/function ([A-Za-z_$][\w$]*)\(\) \{\s*return \(\(([A-Za-z_$][\w$]*) = [A-Za-z_$][\w$]*\(d\(([A-Za-z_$][\w$]*)\)\)\), \3\.call\(\2\), \2\);/g;
let M;
while ((M = Re.exec(Src))) if (!Out[M[1]]) Out[M[1]] = M[3];
return Out;
}
// ---------------------------------------------------------------- 各段落解析
// 找 `A(Codec).q7g = function (t) {` 形式的块体(o7g 是 (t, n) 两个参数,所以形参表放宽)
function FindBlocks(Src, Prop) {
const Out = [];
const Re = new RegExp('\\b([A-Za-z_$][\\w$]*)\\(([A-Za-z_$][\\w$]*)\\)\\.' + Prop + ' = function \\([^)]*\\)\\s*\\{', 'g');
let M;
while ((M = Re.exec(Src))) {
const Open = Src.indexOf('{', M.index + M[0].length - 1);
const Close = MatchBracket(Src, Open, '{', '}');
if (Close < 0) break;
Out.push({ Codec: M[2], Body: Src.slice(Open + 1, Close) });
Re.lastIndex = Close + 1;
}
return Out;
}
// 字段名 getter: slot → 字段名(模块内唯一)
function FindGetters(Src) {
const SlotToName = {};
const GetterToSlot = {};
const Re =
/function ([A-Za-z_$][\w$]*)\(t\) \{\s*var ([A-Za-z_$][\w$]*) = t\.([a-z0-9]+_\d+);\s*if \(null != \2\) return \2;\s*([A-Za-z_$][\w$]*)\(\s*"(?:codec_)?([A-Za-z][\w]*)"\s*\)/g;
let M;
while ((M = Re.exec(Src))) {
SlotToName[M[3]] = M[5];
GetterToSlot[M[1]] = M[3];
}
return { SlotToName, GetterToSlot };
}
// q7g 块 → [{Slot, Desc, Type, Raw}]
function ParseSlots(Body, Roles) {
const Out = [];
const Re = /this\.([a-z0-9]+_\d+) = t\.n7f\(/g;
let M;
while ((M = Re.exec(Body))) {
const Open = Body.indexOf('(', M.index + M[0].length - 1);
const Close = MatchBracket(Body, Open, '(', ')');
if (Close < 0) break;
const T = TypeOfDescriptor(Body.slice(Open + 1, Close), Roles, 0);
Out.push({ Slot: M[1], Desc: T.Raw, Type: T.Type });
Re.lastIndex = Close + 1;
}
return Out;
}
// p7g 块 → [{Slot|Reader, Prop, Js, Wire}],次序即线上次序
function ParseOrder(Body) {
const Hits = [];
const Re = /([A-Za-z_$][\w$]*)\(this\)\.p7g\(t\)|t\.a7g_1\.([A-Za-z0-9_$]+)\(\)/g;
let M;
while ((M = Re.exec(Body))) Hits.push({ At: M.index, End: Re.lastIndex, Getter: M[1] || '', Reader: M[2] || '' });
const Out = [];
for (let I = 0; I < Hits.length; I++) {
const H = Hits[I];
const From = H.End;
const To = I + 1 < Hits.length ? Hits[I + 1].At : Math.min(Body.length, H.End + 300);
const Seg = Body.slice(From, To);
let Prop = '';
let Js = '';
if (H.Getter) {
// 有名字段:赋值紧跟 getter 调用之后
const A = /\b([A-Za-z_$][\w$]*)\.([a-z0-9]+_\d+) =/.exec(Seg);
if (A) Prop = A[2];
Js = JsTypeOf(Seg);
Out.push({ Slot: '', Name: '', Prop, Js, Wire: '', Getter: H.Getter, Order: I + 1 });
} else {
// 匿名字段:赋值紧贴在读取调用之前
const Before = Body.slice(Math.max(0, H.At - 120), H.At);
const A = /([A-Za-z_$][\w$]*)\.([a-z0-9]+_\d+) = $/.exec(Before);
if (A) Prop = A[2];
Out.push({ Slot: '', Name: '', Prop, Js: '', Wire: ReaderType[H.Reader] || H.Reader, Getter: '', Order: I + 1 });
}
}
return Out;
}
// ---------------------------------------------------------------- 与注册表 join
// names/regdump.json(由 tools/client_registry_dump.js 从运行中的客户端导出)里 ModelReg 的表(语义已核实):
// u7n_1: modelId → 模型类(ctor = 类名;keys 是枚举到的实例属性,不保证等于 codec 槽位)
// w7n_1: modelId → codec(ctor = codec 名;keys = 该 codec 的槽位名,只有被实例化过才有值)
// v7n_1: methodId → 命令类(ctor = 命令类名;text = 该命令负载所属的 modelId)
// 于是:
// ① 用「槽位名有序列表」在 w7n_1 里查 → 拿到 modelId(同时校验 codec 构造器名)
// ② 若该 codec 没被实例化(keys 为空)则退化为「codec 构造器名唯一匹配」
// ③ 再用 modelId 去 v7n_1 反查 methodId 列表(下行方法表)
function JoinRegistries(Codecs, RegPath) {
if (!fs.existsSync(RegPath)) return { Ok: false, Reason: '找不到 ' + RegPath };
const Reg = JSON.parse(fs.readFileSync(RegPath, 'utf8'));
const Maps = Reg.maps || Reg;
const Entries = (Name) => (Maps[Name] && Maps[Name].entries) || [];
const CodecByModel = new Map(); // modelId → codec 构造器名
const ModelBySig = new Map(); // 槽位签名 → modelId
for (const E of Entries('ModelReg.w7n_1')) {
CodecByModel.set(String(E.key), E.ctor || '');
if (E.keys && E.keys.length) ModelBySig.set(E.keys.join(','), String(E.key));
}
const ModelClass = new Map(); // modelId → 模型类名
for (const E of Entries('ModelReg.u7n_1')) ModelClass.set(String(E.key), E.ctor || '');
const MethodByModel = new Map(); // modelId → [methodId]
const CommandClass = new Map(); // methodId → 命令类名
for (const E of Entries('ModelReg.v7n_1')) {
const Id = String(E.key);
CommandClass.set(Id, E.ctor || '');
if (!E.text) continue;
const M = String(E.text);
if (!MethodByModel.has(M)) MethodByModel.set(M, []);
MethodByModel.get(M).push(Id);
}
// codec 构造器名 → [modelId](仅当全局唯一时才敢用于兜底)
const ModelsByCtor = new Map();
for (const [Id, Ctor] of CodecByModel) {
if (!Ctor) continue;
if (!ModelsByCtor.has(Ctor)) ModelsByCtor.set(Ctor, []);
ModelsByCtor.get(Ctor).push(Id);
}
// 本次扫到的 codec 里,构造器名也必须唯一(压缩后跨模块重名极常见,如 st/ht/V)
const CtorCount = {};
for (const C of Object.values(Codecs)) CtorCount[C.Codec] = (CtorCount[C.Codec] || 0) + 1;
const Conflicts = [];
let BySigHit = 0;
let ByCtorHit = 0;
for (const [Key, C] of Object.entries(Codecs)) {
const Sig = (C.Slots || []).map((S) => S.Slot).join(',');
let ModelId = Sig ? ModelBySig.get(Sig) : undefined;
if (ModelId) {
BySigHit++;
const Expect = CodecByModel.get(ModelId);
if (Expect && Expect !== C.Codec) Conflicts.push(Key + ' 签名命中 ' + ModelId + ' 但 codec 名不符(期望 ' + Expect + ')');
} else if (CtorCount[C.Codec] === 1) {
// 兜底:codec 构造器名在注册表与本轮扫描里都唯一
const Cand = ModelsByCtor.get(C.Codec);
if (Cand && Cand.length === 1) {
ModelId = Cand[0];
ByCtorHit++;
}
}
if (!ModelId) continue;
C.ModelId = ModelId;
C.ModelClass = ModelClass.get(ModelId) || '';
C.MethodId = MethodByModel.get(ModelId) || [];
}
const Count = { ModelId: 0, MethodId: 0 };
for (const C of Object.values(Codecs)) {
if (C.ModelId) Count.ModelId++;
if (C.MethodId && C.MethodId.length) Count.MethodId++;
}
return {
Ok: true,
CodecEntries: CodecByModel.size,
MethodEntries: CommandClass.size,
BySigHit,
ByCtorHit,
Count,
Conflicts,
};
}
// ---------------------------------------------------------------- 类名还原
// 字段类型里出现的裸局部类名(Ft / en / Wn / ni / $ …)需要还原成 Kotlin 原名:
// ① 本模块内定义的数据类 → 看 (d(X).toString = ...) 里的字符串
// ② 从别的模块 import 的类 → 先查别名(X = 形参.$_$.key)定位到「模块+导出键」,
// 再取那个模块的导出表拿到它本地的类名,最后同样查它自己的 toString
// ③ 没有 toString 的类(非 data class)→ 用「模型类 → codec」反查,
// 这样即使名字还原不了,也至少能指向解码该嵌套模型用的 codec
const PRIM_WORDS = new Set(
Object.values(PrimTable).concat(['i8', 'i16', 'i32', 'i64', 'f32', 'f64', 'bytes', 'opt', 'list', 'enum'])
);
// 返回 { Name, Via, Mod, Class };Via 为空表示没还原出原名
function ResolveClass(Name, ModId, ModuleInfo, Seen) {
const Info = ModuleInfo[ModId];
const Self = { Name, Via: '', Mod: ModId, Class: Name };
if (!Info) return Self;
if (Info.Classes[Name]) return { Name: Info.Classes[Name], Via: 'local', Mod: ModId, Class: Name };
const A = Info.Aliases[Name];
if (!A) return Self;
const Dep = ModuleInfo[A.Mod];
const Cls = Dep && Dep.Exports[A.Key];
if (!Cls) return Self;
if (Dep.Classes[Cls]) return { Name: Dep.Classes[Cls], Via: 'import', Mod: A.Mod, Class: Cls };
// 导出的类自己也可能只是别处的再导出 → 顺着别名链再走一层
if (!Seen.includes(A.Mod)) {
const R = ResolveClass(Cls, A.Mod, ModuleInfo, Seen.concat(A.Mod));
if (R.Via) return R;
}
return { Name: Cls, Via: 'import-raw', Mod: A.Mod, Class: Cls };
}
// 单个别名 → { Name, Codec }(Codec = 该类的 codec 键,供递归解码用)
// 名字优先用 codec 的模型真名;拿不到就退回 toString 得到的名字
function ResolveOne(Name, ModId, ModuleInfo, ClassToCodec) {
const R = ResolveClass(Name, ModId, ModuleInfo, []);
const CodecKey = ClassToCodec[R.Mod + '::' + R.Class] || '';
if (CodecKey) return { Name: Codecs[CodecKey].ModelName || R.Name, Codec: CodecKey };
return { Name: R.Name, Codec: '' };
}
// 类型串 → { Type, Codecs };把里面的裸类名逐个替换掉
function ResolveType(Type, ModId, ModuleInfo, ClassToCodec) {
const Refs = [];
const Out = String(Type || '').replace(/[A-Za-z_$][\w$]*/g, (W) => {
if (PRIM_WORDS.has(W)) return W;
const R = ResolveOne(W, ModId, ModuleInfo, ClassToCodec);
if (R.Codec) Refs.push(R.Codec);
// 协议核心类没有 codec 时按「线上基础类型」换算(如实体引用 xr → i64),否则保留类名
else if (WireClasses[R.Name]) return WireClasses[R.Name];
return R.Name;
});
return { Type: Out, Codecs: Refs };
}
// ---------------------------------------------------------------- 第一遍:建全模块符号表
const ModuleInfo = {};
for (const File of ModuleFiles) {
let Src;
try {
Src = fs.readFileSync(File, 'utf8');
} catch (Err) {
continue;
}
const ModId = ModIdOf(File);
const Deps = FindDeps(Src);
ModuleInfo[ModId] = {
File,
Aliases: FindAliases(Src, Deps),
Roles: FindRoles(Src, Deps),
Exports: FindExports(Src),
Classes: FindClassNames(Src),
Factories: FindFactories(Src),
};
}
// --who <模块号>:<导出键>:反查哪些模块以什么别名引用了该符号,以及 `new 别名(` 的出现次数
// 例:node extract_codec_fields.js --who 61860:l → 谁在用 SpaceCommand
const Who = Arg('who', '');
if (Who) {
const [WMod, WKey] = Who.split(':');
let Total = 0;
for (const [ModId, Info] of Object.entries(ModuleInfo)) {
for (const [Name, A] of Object.entries(Info.Aliases)) {
if (A.Mod !== WMod || A.Key !== WKey) continue;
const Src2 = fs.readFileSync(Info.File, 'utf8');
const Esc = Name.replace(/\$/g, '\\$');
const News = (Src2.match(new RegExp('new ' + Esc + '\\(', 'g')) || []).length;
const Calls = (Src2.match(new RegExp('(?<![\\w$.])' + Esc + '\\(', 'g')) || []).length;
Total += News;
console.log('module_' + ModId + ' 别名 ' + Name + ' new ' + News + ' 次,调用 ' + Calls + ' 次');
}
}
console.log('合计 new ' + Total + ' 次');
}
// ---------------------------------------------------------------- 协议核心类 → 线上基础类型
// module_61860 用 `vi(t).m7f(G(类), new Codec())` 给协议核心类注册 codec。这类 codec 的 o7g 往往只调一次
// 写入器,于是「类 → 线上宽度」能直接从 o7g 的写入方法读出:
// 实体引用 xr 的 codec cr → t.a7g_1.f2u(...) → i64
// 只认「恰好一次写入」的标量 codec;向量(fi 写 3 次 l2u)、嵌套命令(or/fr)会被自动跳过。
const WRITER_TYPE = {
x2t: 'i8', i2u: 'i8',
c2u: 'i16', j2u: 'i16',
d2u: 'i32', k2u: 'i32',
f2u: 'i64', p1s: 'i64',
g2u: 'f32', l2u: 'f32',
h2u: 'f64', m2u: 'f64',
};
const WireClasses = {};
for (const File of ModuleFiles) {
let Src;
try {
Src = fs.readFileSync(File, 'utf8');
} catch (Err) {
continue;
}
const Roles = (ModuleInfo[ModIdOf(File)] || {}).Roles || {};
const ByCodec = new Map(FindBlocks(Src, 'o7g').map((B) => [B.Codec, B.Body]));
if (!ByCodec.size) continue;
const Re = /\.m7f\(\s*([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\s*,\s*new ([A-Za-z_$][\w$]*)\(/g;
let M;
while ((M = Re.exec(Src))) {
if ((Roles[M[1]] || '') !== 'KClass') continue; // 第一参必须是 kClass(类)
const Body = ByCodec.get(M[3]);
if (!Body) continue;
// 只认「整个 o7g 里恰好调一次写入器」的标量 codec:
// 向量(fi 写 3 次 l2u)、嵌套命令(fr 写 2 次 f2u)都因次数不为 1 被跳过
const Calls = [];
for (const W of Body.matchAll(/(?<![\w$.])[A-Za-z_$][\w$]*\.[A-Za-z0-9_$]+\.([A-Za-z0-9_$]+)\(/g))
if (WRITER_TYPE[W[1]]) Calls.push(W[1]);
if (Calls.length !== 1) continue;
const Type = WRITER_TYPE[Calls[0]];
if (Type && !WireClasses[M[2]]) WireClasses[M[2]] = Type;
}
}
// ---------------------------------------------------------------- 第二遍:抽 codec
const Codecs = {};
let ModulesUsed = 0;
let TypeResolved = 0;
let TypeUnresolved = 0;
for (const File of ModuleFiles) {
let Src;
try {
Src = fs.readFileSync(File, 'utf8');
} catch (Err) {
continue;
}
const Mod = path.basename(File, '.js');
const ModId = ModIdOf(File);
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
const Factories = (ModuleInfo[ModId] || {}).Factories || {};
const { SlotToName, GetterToSlot } = FindGetters(Src);
const SlotBlocks = FindBlocks(Src, 'q7g');
const OrderBlocks = FindBlocks(Src, 'p7g');
if (!SlotBlocks.length && !OrderBlocks.length) continue;
ModulesUsed++;
const OrderByCodec = new Map(OrderBlocks.map((B) => [B.Codec, B.Body]));
const Names = new Set([...SlotBlocks.map((B) => B.Codec), ...OrderBlocks.map((B) => B.Codec)]);
for (const CodecName of Names) {
const SBs = SlotBlocks.filter((B) => B.Codec === CodecName);
const Slots = [];
for (const B of SBs) for (const S of ParseSlots(B.Body, Roles)) Slots.push(S);
const BySlot = new Map(Slots.map((S) => [S.Slot, S]));
const OrderBody = OrderByCodec.get(CodecName) || '';
const Order = ParseOrder(OrderBody);
const Fields = Order.map((O) => {
let Name = '(匿名)';
let Type = O.Wire || '?';
let Desc = '';
let Slot = '';
if (O.Getter) {
Slot = GetterToSlot[O.Getter] || '';
Name = SlotToName[Slot] || '?';
const S = BySlot.get(Slot);
if (S) {
Desc = S.Desc;
Type = S.Type;
} else if (O.Js) Type = O.Js;
}
return { Order: O.Order, Name, Type, Js: O.Js, Slot, Desc, Prop: O.Prop, Wire: O.Wire };
});
// 模型类:优先看 o7g 里的 instanceof(直接是数据类),其次看 p7g 用的工厂函数(可能是 Ut → Ft)
const O7g = FindBlocks(Src, 'o7g').find((B) => B.Codec === CodecName);
const Inst = O7g && /instanceof ([A-Za-z_$][\w$]*)/.exec(O7g.Body);
const Made = /var [A-Za-z_$][\w$]* = ([A-Za-z_$][\w$]*)\(\)/.exec(OrderBody);
const DataClass = (Inst && Inst[1]) || (Made ? Made[1] : '');
const Key = Mod + '::' + CodecName;
Codecs[Key] = {
Module: Mod,
ModuleId: ModId,
Codec: CodecName,
ModelClassLocal: DataClass,
ModelClassReal: DataClass ? Factories[DataClass] || DataClass : '',
ModelName: '',
Slots,
Fields,
ModelKeys: Fields.map((F) => F.Prop).filter(Boolean),
};
}
}
// ---------------------------------------------------------------- 第三遍:还原类名与嵌套 codec 链接
// 「模块::类名 → codec」——同一模块内类名唯一,足以定位解码该嵌套模型要用哪个 codec
const ClassToCodec = {};
for (const [Key, C] of Object.entries(Codecs)) {
if (C.ModelClassLocal) ClassToCodec[C.ModuleId + '::' + C.ModelClassLocal] = Key;
if (C.ModelClassReal) ClassToCodec[C.ModuleId + '::' + C.ModelClassReal] = Key;
}
for (const C of Object.values(Codecs)) {
const ModId = C.ModuleId;
C.ModelName = C.ModelClassReal ? ResolveClass(C.ModelClassReal, ModId, ModuleInfo, []).Name : '';
for (const F of C.Fields) {
const Raw = F.Type;
const R = ResolveType(Raw, ModId, ModuleInfo, ClassToCodec);
F.TypeRaw = Raw;
F.Type = R.Type;
if (R.Codecs.length) F.TypeCodec = R.Codecs[0];
if (F.Type !== Raw) TypeResolved++;
else if (!PRIM_WORDS.has(Raw) && !/^prim:/.test(Raw)) TypeUnresolved++;
}
}
// ---------------------------------------------------------------- 第四遍:上行命令表
// 上行命令在源码里的形态(见 module_85183 / 10283 / 47243 / 85667 …):
// ① 命令类构造函数里放着「methodId 长整型字面量」和「负载 codec」:
// function mq() {
// ((this.zl3_1 = new vi(Mn(fi), null)), (this.bl3_1 = new Xn(1724953279, 829170552)));
// } ↑ hi*2^32 + lo(无符号) = methodId
// ② 每个发送方法按顺序把负载字段写进共享缓冲,再包成 SpaceCommand 发出去:
// (p(xn).sh1 = function (t, n) {
// (this.kh1_1.o7g(this.uh0_1, t), this.lh1_1.o7g(this.uh0_1, n)); ← 负载字段(有序)
// var i = new U(spaceId, this.jh1_1, this.uh0_1), … ← SpaceCommand(objectId, methodId, buffer)
// ③ 负载字段的类型声明与下行同源:(n.kh1_1 = jn(n).n7f(new w(v().pb(), !1)))
const LongOf = (Lo, Hi) => (BigInt(Hi) * 4294967296n + BigInt(Lo >>> 0)).toString();
// 模块内「字段 → {Long, Desc}」:Long 来自两整数构造的字面量,Desc 来自 .n7f(new …)
function FindFieldInfo(Src, Roles) {
const Long = {};
const ReLong = /this\.([a-z0-9]+_\d+) = new ([A-Za-z_$][\w$]*)\((-?\d+), (-?\d+)\)/g;
let M;
while ((M = ReLong.exec(Src)))
Long[M[1]] = { Cls: M[2], Lo: Number(M[3]), Hi: Number(M[4]), Value: LongOf(Number(M[3]), Number(M[4])) };
const Desc = {};
const Re = /\.n7f\(/g;
while ((M = Re.exec(Src))) {
const Open = M.index + M[0].length - 1;
const Close = MatchBracket(Src, Open, '(', ')');
if (Close < 0) break;
const Before = Src.slice(Src.lastIndexOf('\n', M.index) + 1, M.index);
const A = /([a-z0-9]+_\d+) = [^\n]{0,60}?$/.exec(Before);
if (A && !Desc[A[1]]) Desc[A[1]] = TypeOfDescriptor(Src.slice(Open + 1, Close), Roles, 0);
Re.lastIndex = Close + 1;
}
return { Long, Desc };
}
// 找 `X(Cls).Fn = function (…) { … }` 的方法块(用于定位发送点的宿主方法与宿主类)
function FindMethods(Src) {
const Out = [];
const Re = /\(?([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\.([A-Za-z_$][\w$]*) = function \([^)]*\)\s*\{/g;
let M;
while ((M = Re.exec(Src))) {
const Open = Src.indexOf('{', M.index + M[0].length - 1);
const Close = MatchBracket(Src, Open, '{', '}');
if (Close < 0) break;
Out.push({ Owner: M[2], Name: M[3], BodyStart: Open + 1, End: Close });
Re.lastIndex = Close + 1;
}
return Out;
}
// 命令类构造函数里登记的 codec/model:new <Codec>(<kClass>(<Model>), null)
function OwnerModels(Src, Owner, ModId) {
if (!Owner) return [];
const At = Src.search(new RegExp('\\bfunction ' + Owner.replace(/\$/g, '\\$') + '\\('));
if (At < 0) return [];
const Open = Src.indexOf('{', At);
const Close = MatchBracket(Src, Open, '{', '}');
if (Close < 0) return [];
const Body = Src.slice(Open + 1, Close);
const Out = [];
for (const G of Body.matchAll(/\(([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)/g)) {
if (!Out.includes(G[2])) Out.push(G[2]);
}
return Out.map((N) => ({ Local: N, Name: ResolveClass(N, ModId, ModuleInfo, []).Name }));
}
const UplinkCommands = [];
const UpSeen = new Set();
const UpSendRe = /new ([A-Za-z_$][\w$]*)\([^;]*?this\.([a-z0-9]+_\d+), this\.([a-z0-9]+_\d+)\)/g;
for (const File of ModuleFiles) {
let Src;
try {
Src = fs.readFileSync(File, 'utf8');
} catch (Err) {
continue;
}
const ModId = ModIdOf(File);
const Mod = path.basename(File, '.js');
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
const Classes = (ModuleInfo[ModId] || {}).Classes || {};
const { Long, Desc } = FindFieldInfo(Src, Roles);
if (!Object.keys(Long).length) continue;
const Methods = FindMethods(Src);
let M;
UpSendRe.lastIndex = 0;
while ((M = UpSendRe.exec(Src))) {
const Inf = Long[M[2]];
if (!Inf) continue; // 第二个实参必须是本模块里的长整型字面量字段,否则不是 SpaceCommand
const At = M.index;
const Host = Methods.find((X) => At > X.BodyStart && At < X.End);
// 负载字段:宿主方法里、发送点之前的所有 this.<codec>.o7g( 接收者,按出现顺序
const Fields = [];
if (Host) {
const Head = Src.slice(Host.BodyStart, At);
for (const G of Head.matchAll(/this\.([a-z0-9]+_\d+)\.o7g\(/g)) {
const D = Desc[G[1]];
const T = D ? ResolveType(D.Type, ModId, ModuleInfo, ClassToCodec) : { Type: '?', Codecs: [] };
Fields.push({ Field: G[1], Type: T.Type, TypeCodec: T.Codecs[0] || '', Desc: D ? D.Raw : '' });
}
}
const Key = Inf.Value + '|' + Mod + '|' + (Host ? Host.Owner + '.' + Host.Name : '');
if (UpSeen.has(Key)) continue;
UpSeen.add(Key);
UplinkCommands.push({
MethodId: Inf.Value,
LongLiteral: [Inf.Lo, Inf.Hi],
Module: Mod,
OwnerClass: Host ? Host.Owner : '',
OwnerName: Host ? Classes[Host.Owner] || '' : '',
SendFn: Host ? Host.Name : '',
BufferField: M[3],
Models: OwnerModels(Src, Host ? Host.Owner : '', ModId),
Fields,
});
}
}
// ---------------------------------------------------------------- 写出与统计
const RegPath = path.resolve(Arg('reg', path.join(PROJECT, 'names', 'regdump.json')));
const Join = JoinRegistries(Codecs, RegPath);
const AllFields = Object.values(Codecs).reduce((A, C) => A.concat(C.Fields), []);
const Named = AllFields.filter((F) => F.Name !== '(匿名)' && F.Name !== '?');
const Unnamed = AllFields.filter((F) => F.Name === '(匿名)');
const Unknown = Named.filter((F) => F.Name === '?');
const TypeCount = {};
for (const F of AllFields) TypeCount[F.Type] = (TypeCount[F.Type] || 0) + 1;
const Out = {
Note: '空间层 codec 字段表(键 = 模块::codec变量名)。字段名来自 v("codec_名字");类型来自 q7g 描述符(已把裸局部类名还原成 Kotlin 原名,TypeRaw 保留原名);Js= p7g 里的 JS 级线索;Prop= 数据类属性名;TypeCodec= 该嵌套模型对应的 codec 键;ModelId/MethodId 来自 regdump.json join',
Scan,
Modules: ModuleFiles.length,
ModulesWithCodec: ModulesUsed,
CodecCount: Object.keys(Codecs).length,
FieldCount: AllFields.length,
NamedCount: Named.length,
AnonymousCount: Unnamed.length,
WireClasses,
Join,
Codecs,
};
fs.writeFileSync(OutPath, JSON.stringify(Out, null, 1), 'utf8');
// 上行命令表单独落盘(names/uplink_table.json)
const UpOutPath = path.resolve(Arg('up-out', path.join(PROJECT, 'names', 'uplink_table.json')));
const UpOut = {
Note:
'上行命令表(methodId → 命令类 / 发送方法 / 负载字段)。methodId = hi*2^32 + (lo>>>0),由源码里的 Long 字面量还原。' +
'字段只有 Kotlin 局部 slot 名(如 bcq_1)+ 类型:命令类不像模型 codec 那样保留 codec_<名字> getter,' +
'555 个字段全都没有属性访问点,压缩后已无原可读名可挖(要可读名只能人工标注或靠调用点语义推断)',
Count: UplinkCommands.length,
Commands: UplinkCommands.sort((A, B) => (BigInt(A.MethodId) < BigInt(B.MethodId) ? -1 : 1)),
};
fs.writeFileSync(UpOutPath, JSON.stringify(UpOut, null, 1), 'utf8');
console.log('扫描模块 ' + ModuleFiles.length + ' 个(含 codec ' + ModulesUsed + ' 个)');
console.log('codec ' + Object.keys(Codecs).length + ' 个;字段 ' + AllFields.length + ' 条(有名 ' + Named.length + ',匿名 ' + Unnamed.length + ',未解 ' + Unknown.length + ')');
console.log('已写出 ' + OutPath);
console.log('类型分布: ' + Object.entries(TypeCount).sort((A, B) => B[1] - A[1]).slice(0, 18).map(([K, V]) => K + '=' + V).join(' '));
if (Join.Ok)
console.log(
'注册表 join: codec 表 ' + Join.CodecEntries + ' 条 / 方法表 ' + Join.MethodEntries + ' 条;对上 modelId ' +
Join.Count.ModelId + ' 个 codec(槽位签名 ' + Join.BySigHit + ' + 构造器名兜底 ' + Join.ByCtorHit +
'),其中能反查到 methodId 的 ' + Join.Count.MethodId + ' 个' +
(Join.Conflicts.length ? ' ⚠ 冲突 ' + Join.Conflicts.length + ' 处: ' + Join.Conflicts.slice(0, 3).join('; ') : ' ✓ 无冲突')
);
else console.log('注册表 join: 跳过(' + Join.Reason + ')');
// 自检:字段的原始描述符里若还残留未识别形状(既不是基础类型也不是 X(Y) 也不是 new X(...)),列出来看
const Suspicious = new Set();
for (const K of Object.keys(Codecs)) {
for (const F of Codecs[K].Fields) {
const D = (F.Desc || '').replace(/^new\s+/, '');
if (!D) continue;
if (/^[A-Za-z_$][\w$]*\(\)\.[A-Za-z0-9_$]+\(\)$/.test(D)) continue; // X().pb()
if (/^[A-Za-z_$][\w$]*\([\s\S]*\)$/.test(D)) continue; // X(...)
Suspicious.add(F.Desc);
}
}
console.log('未识别描述符形状: ' + (Suspicious.size ? [...Suspicious].slice(0, 8).join(' | ') : '无'));
console.log('类型裸类名还原: 成功 ' + TypeResolved + ' 条,仍为裸名 ' + TypeUnresolved + ' 条(多半是没有 toString 的非 data class 资源类)');
const NamedCodecs = Object.values(Codecs).filter((C) => C.ModelName).length;
const Refed = AllFields.filter((F) => F.TypeCodec).length;
console.log('codec 的模型类真名: ' + NamedCodecs + ' / ' + Object.keys(Codecs).length + ' 个;字段里带嵌套 codec 链接的 ' + Refed + ' 条');
const UpWithFields = UplinkCommands.filter((C) => C.Fields.length).length;
console.log('上行命令表: ' + UplinkCommands.length + ' 条(带负载字段的 ' + UpWithFields + ' 条)→ ' + UpOutPath);
// --find-up <methodId>:查一条上行命令的负载字段
const FindUp = Arg('find-up', '');
if (FindUp) {
const Hit = UplinkCommands.filter((C) => C.MethodId === FindUp);
if (!Hit.length) console.log('\n上行 methodId ' + FindUp + ' 未在源码里找到');
for (const C of Hit) {
console.log('\n上行 ' + C.MethodId + ' [' + C.LongLiteral.join(', ') + '] ' + C.Module + ' ' + C.OwnerClass + '(' + (C.OwnerName || '?') + ').' + C.SendFn);
if (C.Models.length) console.log(' 命令类登记的模型: ' + C.Models.map((X) => X.Local + (X.Name && X.Name !== X.Local ? '(' + X.Name + ')' : '')).join(', '));
for (const [I, F] of C.Fields.entries()) console.log(' ' + (I + 1) + '. ' + F.Type.padEnd(28) + ' ' + F.Field + ' ' + F.Desc);
if (!C.Fields.length) console.log(' (负载为空)');
}
}
const Show = Arg('show', 'st,ft,ht,dt,V').split(',');
for (const C of Show) {
const Hit = Object.keys(Codecs).filter((K) => K === C || K.endsWith('::' + C) || K.includes('::' + C));
for (const K of Hit.slice(0, 4)) {
const Cd = Codecs[K];
const Ids = [
Cd.ModelName ? '模型=' + Cd.ModelName : '',
Cd.ModelId ? 'modelId=' + Cd.ModelId : '',
Cd.ModelClass ? 'modelClass=' + Cd.ModelClass : '',
Cd.MethodId && Cd.MethodId.length ? 'methodId=' + Cd.MethodId.join(',') : '',
].filter(Boolean).join(' ');
console.log('\n=== codec ' + K + '(' + Cd.Fields.length + ' 字段)' + (Ids ? ' ' + Ids : '') + ' ===');
for (const F of Cd.Fields) {
console.log(
' ' + String(F.Order).padStart(2) + '. ' + String(F.Name).padEnd(32) + ' ' + String(F.Type).padEnd(16) +
' js=' + String(F.Js || '-').padEnd(10) + ' ' + F.Slot + ' ' + (F.Desc || '')
);
}
}
}
// --find-model <modelId> / --find-method <methodId>:反查 codec 并打印字段表
for (const [Opt, Field, Label] of [
['find-model', 'ModelId', 'modelId'],
['find-method', 'MethodId', 'methodId'],
]) {
const Id = Arg(Opt, '');
if (!Id) continue;
const Hit = Object.entries(Codecs).filter(([, C]) => {
const V = C[Field];
return Array.isArray(V) ? V.includes(Id) : V === Id;
});
console.log('\n' + Label + ' = ' + Id + ' → ' + (Hit.length ? Hit.map(([K, C]) => K + '(' + C.Codec + ', ' + C.Fields.length + ' 字段)').join(' ') : '无匹配'));
for (const [K, C] of Hit.slice(0, 2)) {
for (const F of C.Fields) console.log(' ' + String(F.Order).padStart(2) + '. ' + String(F.Name).padEnd(30) + ' ' + F.Type);
}
}
+208
View File
@@ -0,0 +1,208 @@
// lib_space_codec.js - 空间层负载的字段级解码器(下行 s2c / 上行 c2s 共用)
//
// 编码规则来自客户端源码(module_24635 的序列化器实现,推导过程见 tools/extract_codec_fields.js):
// bool/i8=1B i16=2B i32/f32=4B i64/f64=8B(全部大端)
// string/bytes = varint 长度 + 原始字节
// list<T> = varint 元素数 + 元素…
// opt<T> = 1 字节标志(非 0 = 空)+ 有值时紧跟 T
// enum<T> = 4 字节(库里的 EnumCodecInfo 序列化器 o7g 是空实现,但游戏自己注册的枚举写 4 字节;
// 实测 ChatCC 按 4 字节解才能精确吃满 1476B 负载,按 0 字节只有 40% 的容器内层能对上边界)
// 模型类 = 就地展开该模型 codec 的字段(无长度前缀)
// varint: <128 → 1 字节;<16384 → 2 字节(0x80|高6位, 低8位);<4194304 → 3 字节(0xC0|高6位, 中8位, 低8位)
//
// 用法:
// const { LoadTables, MakeReader } = require('./lib_space_codec');
// const Tables = LoadTables(path.join(__dirname, '..', 'names'));
// const Reader = MakeReader(Tables, { EnumBytes: 4 });
// const R = Reader.ReadFields(Buf, Offset, Tables.CodecByKey.get(CodecKey), 0);
// -> { Value: {字段名: 值}, Next: 下一个偏移, Bad: 是否中途卡住 }
const fs = require('fs');
const path = require('path');
const MAX_DEPTH = 8;
// 载入 names/ 下的三张表
// codec_fields.json: 下行/上行 codec 的字段表(含 ModelId / MethodId / TypeCodec)
// uplink_table.json: 上行命令表(methodId → 命令类 / 发送方法 / 负载字段)
function LoadTables(NamesDir) {
const Out = {
CodecByKey: new Map(),
CodecByMethod: new Map(),
CodecByModelId: new Map(),
CodecByModelName: new Map(),
WireClasses: new Map(),
UplinkByMethod: new Map(),
};
try {
const CF = JSON.parse(fs.readFileSync(path.join(NamesDir, 'codec_fields.json'), 'utf8'));
for (const [Key, C] of Object.entries(CF.Codecs)) {
Out.CodecByKey.set(Key, C);
for (const M of C.MethodId || []) if (!Out.CodecByMethod.has(M)) Out.CodecByMethod.set(M, C);
if (C.ModelId && !Out.CodecByModelId.has(C.ModelId)) Out.CodecByModelId.set(C.ModelId, C);
// 模型真名 → codec:字段里只写了类名、没带 TypeCodec 链接时的兜底(如 ji = module_24635::Xt 单 i64)
if (C.ModelName && !Out.CodecByModelName.has(C.ModelName)) Out.CodecByModelName.set(C.ModelName, C);
}
// 协议核心类(module_61860 注册的「一写一读」codec)→ 线上基础类型,如实体引用 xr → i64
for (const [Name, Type] of Object.entries(CF.WireClasses || {})) Out.WireClasses.set(Name, Type);
} catch (Err) {
Out.CodecError = Err.message;
}
try {
const UP = JSON.parse(fs.readFileSync(path.join(NamesDir, 'uplink_table.json'), 'utf8'));
for (const C of UP.Commands || []) if (!Out.UplinkByMethod.has(C.MethodId)) Out.UplinkByMethod.set(C.MethodId, C);
} catch (Err) {
Out.UplinkError = Err.message;
}
return Out;
}
function ReadVarint(Buf, Off) {
if (Off >= Buf.length) return { Value: 0, Next: Off, Bad: true };
const B0 = Buf[Off];
if (!(B0 & 0x80)) return { Value: B0, Next: Off + 1 };
const B1 = Buf[Off + 1] || 0;
if (B0 & 0x40) return { Value: ((B0 & 0x3f) << 16) + (B1 << 8) + (Buf[Off + 2] || 0), Next: Off + 3 };
return { Value: ((B0 & 0x3f) << 8) + B1, Next: Off + 2 };
}
function MakeReader(Tables, Options) {
const Opts = Options || {};
const EnumBytes = Opts.EnumBytes === undefined ? 4 : Opts.EnumBytes;
const CodecByKey = Tables.CodecByKey;
const CodecByModelName = Tables.CodecByModelName || new Map();
const WireClasses = Tables.WireClasses || new Map();
// 读一个值:返回 { Value, Next, Bad }
function ReadValue(Buf, Off, Type, TypeCodec, Depth) {
if (Depth > MAX_DEPTH) return { Value: '<深度上限>', Next: Off, Bad: true };
if (Type === undefined || Type === null || Type === '' || Type === '?') return { Value: '<未知类型>', Next: Off, Bad: true };
const T = String(Type);
const Opt = /^opt<([\s\S]+)>$/.exec(T);
if (Opt) {
if (Off >= Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
// Ht 包装:先 1 字节标志,非 0 表示 null;有值则紧跟内层
if (Buf[Off] !== 0) return { Value: null, Next: Off + 1 };
return ReadValue(Buf, Off + 1, Opt[1], TypeCodec, Depth);
}
const List = /^list<([\s\S]+)>$/.exec(T);
if (List) {
const N = ReadVarint(Buf, Off);
let At = N.Next;
const Arr = [];
for (let I = 0; I < N.Value; I++) {
if (I >= 4096) {
Arr.push('<元素过多>');
break;
}
const R = ReadValue(Buf, At, List[1], TypeCodec, Depth + 1);
Arr.push(R.Value);
if (R.Bad || R.Next <= At) {
Arr.push('<卡住>');
break;
}
At = R.Next;
}
return { Value: Arr, Next: At, Bad: N.Bad };
}
const Enum = /^enum<([\s\S]+)>$/.exec(T);
if (Enum) {
if (EnumBytes === 0) return { Value: '<enum 不上线>', Next: Off };
if (Off + EnumBytes > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
let V = 0;
for (let I = 0; I < EnumBytes; I++) V = (V << 8) | Buf[Off + I];
return { Value: 'enum#' + V, Next: Off + EnumBytes };
}
switch (T) {
case 'bool':
if (Off >= Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf[Off] !== 0, Next: Off + 1 };
case 'i8':
if (Off + 1 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readInt8(Off), Next: Off + 1 };
case 'i16':
if (Off + 2 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readInt16BE(Off), Next: Off + 2 };
case 'i32':
if (Off + 4 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readInt32BE(Off), Next: Off + 4 };
case 'i64':
if (Off + 8 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readBigInt64BE(Off).toString(), Next: Off + 8 };
case 'f32':
if (Off + 4 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Number(Buf.readFloatBE(Off).toFixed(4)), Next: Off + 4 };
case 'f64':
if (Off + 8 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readDoubleBE(Off), Next: Off + 8 };
case 'string':
case 'bytes':
case 'array': {
const N = ReadVarint(Buf, Off);
const End = N.Next + N.Value;
if (End > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
const Raw = Buf.slice(N.Next, End);
return { Value: T === 'string' ? Raw.toString('utf8') : '0x' + Raw.toString('hex').slice(0, 64), Next: End };
}
default:
break;
}
// 模型类:就地展开它自己的 codec 字段(无长度前缀)
if (TypeCodec && CodecByKey.has(TypeCodec)) {
const R = ReadFields(Buf, Off, CodecByKey.get(TypeCodec), Depth + 1);
return { Value: R.Value, Next: R.Next, Bad: R.Bad };
}
// 字段里没带 codec 链接时:先按模型真名找 codec,再按「协议核心类 → 线上基础类型」表换算
if (CodecByModelName.has(T)) {
const R = ReadFields(Buf, Off, CodecByModelName.get(T), Depth + 1);
return { Value: R.Value, Next: R.Next, Bad: R.Bad };
}
if (WireClasses.has(T)) return ReadValue(Buf, Off, WireClasses.get(T), '', Depth + 1);
return { Value: '<无 codec:' + T + '>', Next: Off, Bad: true };
}
function ReadFields(Buf, Off, Codec, Depth) {
const Obj = {};
let At = Off;
let Bad = false;
for (const F of (Codec && Codec.Fields) || []) {
const R = ReadValue(Buf, At, F.Type, F.TypeCodec, Depth);
// 匿名字段(表里没有名字,只有线宽)用序号区分,否则会同名互相覆盖
const Key = F.Name === '(匿名)' || F.Name === '?' ? '(匿名#' + F.Order + ')' : F.Name;
Obj[Key] = R.Value;
if (R.Bad) {
Bad = true;
break;
}
At = R.Next;
}
return { Value: Obj, Next: At, Bad };
}
// 按 codec 键解一条记录
function ReadByKey(Buf, Off, CodecKey, Depth) {
const C = CodecByKey.get(CodecKey);
if (!C) return null;
return ReadFields(Buf, Off, C, Depth || 0);
}
return { ReadValue, ReadFields, ReadByKey, ReadVarint, EnumBytes };
}
function FormatValue(V) {
if (V === null) return 'null';
if (Array.isArray(V)) return '[' + V.length + ']' + (V.length <= 6 ? JSON.stringify(V, null, 0) : '');
if (typeof V === 'object') return JSON.stringify(V, null, 0);
if (typeof V === 'string' && V.length > 90) return JSON.stringify(V.slice(0, 90) + '…');
return JSON.stringify(V);
}
// 把解出的对象渲染成多行(字段名左对齐 28 列)
function FormatFields(Obj, Indent) {
const L = [];
for (const [K, V] of Object.entries(Obj || {})) L.push(Indent + String(K).padEnd(28) + ' = ' + FormatValue(V));
return L;
}
module.exports = { LoadTables, MakeReader, ReadVarint, FormatValue, FormatFields, MAX_DEPTH };
+218
View File
@@ -0,0 +1,218 @@
// pair_flow.js - 把抓包里的 c2s / s2c 按时间戳配对成「请求 → 响应」流
// 用法: node pair_flow.js [--root ../packets/plain] [--window 1500] [--out ../names/flow.json]
// 原理:
// dump_plain.js 已产出 timeline_c2s.txt / timeline_s2c.txt(每帧相对首条消息的毫秒)。
// 对每条 s2c 帧,找「时间上最近的、且相距 <= Window 的」c2s 帧作为触发者;
// 找不到触发者的就是服务端主动推送(ping / 周期 inform)。
// 产物:
// <space>/flow.txt 人类可读的配对表
// <out> 机器可读:每空间的 {触发 cmdId -> 回复 cmdId 列表} + 全局统计
// 目的: 让 fake 服务器从「定时吐帧」变成「收到请求才应答」(第 1 层)
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PROJECT = path.resolve(__dirname, '..');
const Root = path.resolve(Arg('root', path.join(PROJECT, 'packets', 'plain')));
const Window = Number(Arg('window', '1500')) || 1500;
const OutPath = path.resolve(Arg('out', path.join(PROJECT, 'names', 'flow.json')));
// 上行空间帧:帧体 = [nullmap][objectId int64 8B][methodId int64 8B][负载](同 wss_server.js 的 DescribeUplink)
// 判据 = 首字节是 objectId 最高位(0x00 小正数 / 0xff 负数)+ At+8 处命中 names/uplink_table.json 的 methodId。
// 用 methodId 当应答签名:它是客户端源码里的常量,跨会话稳定,于是「某条上行命令」能精确命中它的应答组。
const UplinkByName = new Map();
const UplinkByHex = new Map();
try {
const Up = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'uplink_table.json'), 'utf8'));
for (const C of Up.Commands || []) {
const B = Buffer.alloc(8);
B.writeBigInt64BE(BigInt(C.MethodId));
const Hex = B.toString('hex');
UplinkByHex.set(Hex, C);
UplinkByName.set(C.MethodId, (C.OwnerName || C.OwnerClass || '?') + '.' + (C.SendFn || '?'));
}
} catch (Err) {
console.log('(未加载 names/uplink_table.json,上行空间帧只能按命令 ID 配对)');
}
function SpaceFrame(Body, At) {
const B0 = Body[At];
if (B0 !== 0x00 && B0 !== 0xff) return null;
if (At + 16 > Body.length) return null;
const Cmd = UplinkByHex.get(Body.slice(At + 8, At + 16).toString('hex'));
if (!Cmd) return null;
return { ObjectId: Body.readBigInt64BE(At).toString(), MethodId: Body.readBigInt64BE(At + 8).toString() };
}
// 帧 → { Cmd, Size };Compressed 帧先解压再取 nullmap/命令 ID
function FrameInfo(Buf, Direction) {
const B0 = Buf[0];
const HeaderSize = B0 & 0x80 ? 4 : 2;
// 4 字节头:bit6 是压缩位,长度取低 6 位 + 后 3 字节(注意不是 & 0x7f)
const Total =
HeaderSize === 4
? 4 + ((((B0 & 0x3f) << 24) | (Buf[1] << 16) | (Buf[2] << 8) | Buf[3]) >>> 0)
: 2 + ((((B0 & 0x3f) << 8) | Buf[1]) >>> 0);
let Body = Buf.slice(HeaderSize);
if (B0 & 0x40) {
try {
Body = zlib.inflateSync(Body);
} catch (Err) {
return { Cmd: -2, Size: Total, Note: 'zlib 失败' };
}
}
const Nm = Body[0];
let At;
if (Nm & 0x80) At = Nm & 0x40 ? 4 + (((Nm & 0x3f) << 16) | (Body[1] << 8) | Body[2]) : 1 + (Nm & 0x3f);
else At = 1 + ((Nm & 0x60) >>> 5);
if (At >= Body.length) return { Cmd: -1, Size: Total, Note: '保活 ping' };
if (Direction === 'c2s') {
const Sp = SpaceFrame(Body, At);
if (Sp) return { Cmd: 0, Size: Total, Space: true, ...Sp, Payload: Body.slice(At + 16) };
}
return { Cmd: Body[At], Size: Total, Payload: Body.slice(At + 1) };
}
function ReadTimeline(Dir, Direction) {
const P = path.join(Dir, 'timeline_' + Direction + '.txt');
if (!fs.existsSync(P)) return [];
const Out = [];
for (const Line of fs.readFileSync(P, 'utf8').split('\n')) {
const M = /^(\S+)\t(\d+)$/.exec(Line.trim());
if (M) Out.push({ Name: M[1], Ms: Number(M[2]) });
}
return Out;
}
// 帧签名:空间帧用 methodId(S:<methodId>),其余用「命令 ID + 负载前 4 字节」
// 必须与 wss_server.js 的 FrameSignature 完全一致
function Signature(Info) {
if (Info.Space) return 'S:' + Info.MethodId;
const Head = Info.Payload ? Info.Payload.slice(0, 4).toString('hex') : '';
return Info.Cmd + (Head ? ':' + Head : '');
}
// 签名 → 人类可读标签(flow.txt / 日志用)
function SigLabel(Sig) {
const M = /^S:(\d+)$/.exec(Sig);
if (!M) return 'cmd=' + Sig.split(':')[0];
const Name = UplinkByName.get(M[1]);
return '上行=' + (Name || '?') + '(' + M[1] + ')';
}
const Spaces = [];
const Global = { AsTrigger: {}, AsReply: {}, Push: {}, Spaces: 0, Pairs: 0, S2c: 0 };
for (const Name of fs.readdirSync(Root).sort()) {
const Dir = path.join(Root, Name);
if (!fs.statSync(Dir).isDirectory()) continue;
const C2s = ReadTimeline(Dir, 'c2s');
const S2c = ReadTimeline(Dir, 's2c');
if (!C2s.length || !S2c.length) continue;
Global.Spaces++;
const C2sFrames = C2s.map((X) => ({ ...X, Info: FrameInfo(fs.readFileSync(path.join(Dir, X.Name)), 'c2s') }));
const S2cFrames = S2c.map((X) => ({ ...X, Info: FrameInfo(fs.readFileSync(path.join(Dir, X.Name)), 's2c') }));
const Lines = ['空间 ' + Name + ' 请求 ' + C2s.length + ' 条 / 响应 ' + S2c.length + ' 条(窗口 ' + Window + 'ms)', ''];
const Triggers = [];
let Pushed = 0;
let Pairs = 0;
for (const S of S2cFrames) {
if (S.Info.Cmd === -1) continue; // ping 单独统计
Global.S2c++;
// 找最近的、时间不超过窗口的 c2s
let Best = null;
for (const C of C2sFrames) {
if (C.Ms <= S.Ms && S.Ms - C.Ms <= Window) Best = C; // 列表按时间升序,取最后一个满足的
}
if (Best) {
const Key = Signature(Best.Info);
let Group = Triggers.find((G) => G.Trigger === Key);
if (!Group) {
Group = { Trigger: Key, TriggerName: Best.Name, TriggerMs: Best.Ms, Replies: [] };
Triggers.push(Group);
}
Group.Replies.push({ Name: S.Name, Cmd: S.Info.Cmd, Ms: S.Ms, Delay: S.Ms - Best.Ms, Size: S.Info.Size });
Global.AsTrigger[Key] = (Global.AsTrigger[Key] || 0) + 1;
Global.AsReply[S.Info.Cmd] = (Global.AsReply[S.Info.Cmd] || 0) + 1;
Pairs++;
Global.Pairs++;
} else {
Pushed++;
Global.Push[S.Info.Cmd] = (Global.Push[S.Info.Cmd] || 0) + 1;
}
}
for (const G of Triggers) {
Lines.push(
'触发 c2s ' + G.TriggerName + ' ' + SigLabel(G.Trigger) + ' @+' + G.TriggerMs + 'ms → ' + G.Replies.length + ' 条回复',
);
for (const R of G.Replies) {
Lines.push(' +' + String(R.Delay).padStart(6) + 'ms ' + R.Name + ' cmd=' + R.Cmd + ' ' + R.Size + 'B');
}
}
Lines.push('', '无触发的主动推送: ' + Pushed + ' 条');
const PushCount = {};
for (const S of S2cFrames) if (S.Info.Cmd !== -1) PushCount[S.Info.Cmd] = (PushCount[S.Info.Cmd] || 0) + 1;
Lines.push(' 各命令总条数: ' + JSON.stringify(PushCount));
fs.writeFileSync(path.join(Dir, 'flow.txt'), Lines.join('\n') + '\n', 'utf8');
// 给 fake 服务器用的极简配对表:每行「s2c 文件 \t 触发签名|- \t 相对触发的延迟ms \t 原时间线ms」
const Tsv = [];
for (const S of S2cFrames) {
if (S.Info.Cmd === -1) continue; // 保活 ping 交给服务器自己的定时器
let Trigger = '-';
let Delay = 0;
for (const C of C2sFrames) {
if (C.Ms <= S.Ms && S.Ms - C.Ms <= Window) {
Trigger = Signature(C.Info);
Delay = S.Ms - C.Ms;
}
}
Tsv.push(S.Name + '\t' + Trigger + '\t' + Delay + '\t' + S.Ms);
}
fs.writeFileSync(path.join(Dir, 'flow_s2c.tsv'), Tsv.join('\n') + '\n', 'utf8');
Spaces.push({
Space: Name,
Triggers: Triggers.map((G) => ({
Trigger: G.Trigger,
TriggerLabel: SigLabel(G.Trigger),
TriggerName: G.TriggerName,
Replies: G.Replies.map((R) => ({ Cmd: R.Cmd, Delay: R.Delay, Size: R.Size })),
})),
Pushed,
});
console.log(
Name.padEnd(20) + ' c2s ' + String(C2s.length).padStart(3) + ' s2c ' + String(S2c.length).padStart(3) +
' 配对 ' + String(Pairs).padStart(4) + ' 推送 ' + Pushed,
);
}
fs.writeFileSync(
OutPath,
JSON.stringify(
{
Note: '由 pair_flow.js 生成:s2c 帧按时间就近归到触发的 c2s 帧;无触发的记为主动推送',
Window,
Stats: Global,
Spaces,
},
null,
2,
),
'utf8',
);
console.log('\n全局统计:');
console.log(' 作为触发的 c2s 命令: ' + JSON.stringify(Global.AsTrigger));
console.log(' 作为回复的 s2c 命令: ' + JSON.stringify(Global.AsReply));
console.log(' 主动推送的 s2c 命令: ' + JSON.stringify(Global.Push));
console.log(' 已写出: ' + OutPath);
+768
View File
@@ -0,0 +1,768 @@
// wss_server.js - 本地游戏通信服务器(替代 wss://c1.3dtank.com:9090/)
// 用法:
// node wss_server.js [--port 9091] [--mode relay|fake] [--upstream wss://c1.3dtank.com:9090/] [--label 名字]
// 模式:
// relay 中继(推荐): 客户端 → 本地 → 真实服务器。抓包落盘 + 实时解析:
// 明文段直接解命令;进入加密段后自动穷举 1 字节密钥并后续实时解密;
// 帧头 0x40 位为 zlib 压缩,自动解压后再解命令。
// fake 离线模拟: 不连真实服务器,自己回 Hash 握手(Hash Response + Open Space)。
// 新连接首帧 ID=1 → 大厅握手;首帧 ID=3 → 按 spaceId 回放该空间(不回握手)。
// 回放分两类:主动推送按抓包时间线(timeline_s2c.txt),
// 应答类(flow_s2c.tsv 里带触发签名)等对应 c2s 到达才发、超时按原时间线兜底。
// 触发签名:上行空间帧 = "S:<methodId>"(源码常量,跨会话稳定,能精确到某条命令),
// 其余 = "命令 ID:负载前 4 字节"(与 pair_flow.js 的 Signature 一致,改一边就要改另一边)。
// --replay-scale 缩放、--replay-gap-max 给单次间隔封顶、--replay-gap 兜底间隔。
// 挂载: 客户端 config.xml 指向本服务器(local/config.xml + serve.js 的 /config.xml 本地覆盖)
// <server address="localhost" wsProtocol="WS"> + <port>9091</port> → ws://localhost:9091/
// 产物: 3DTank/packets/<label>/session_00N/{log.txt, c2s_%04d.bin, s2c_%04d.bin}
//
// 协议(逆向自 module_61860 + sockets/):
// 帧 = [2 或 4 字节大端长度][帧体];b0 & 0x80 → 4 字节头;b0 & 0x40 → 帧体 zlib 压缩
// 帧体 = [nullmap][payload];payload = [1 字节命令 ID][body]
// 链路编码器: 明文 nr(直接拷贝)/ 加密 ir(自变体 XOR 流密码)
// 解密: p = c ^ key[idx]; key[idx] = p; idx ^= (p & 7)
// key[i] = K ^ (i << 3)(下发方向);上传方向再 ^ 87;K 为单字节
// Open Space 之后切换加密,故抓包里明文/密文混排
//
// 上行命令识别(默认开,--show-uplink 0 关闭):
// c2s 空间帧的正文 = [nullmap][objectId int64 8B][(methodId int64 8B)(负载)…]
// 出处:module_61860 的 SpaceCommand codec —— fr.o7g 依次写 f2u(objectId)、f2u(methodId),
// 之后才是命令自身负载;or.o7g = 先写命令 id 再写该命令注册的 codec。
// methodId 查 names/uplink_table.json → 命令类 / 发送方法;负载优先用 names/codec_fields.json 的字段名逐字段解。
// 解不动就停在原地并把剩余字节原样打出来(不做宽度猜测)。
const fs = require('fs');
const http = require('http');
const path = require('path');
const zlib = require('zlib');
const { LoadTables, MakeReader, FormatValue } = require('./lib_space_codec');
const PROJECT = path.resolve(__dirname, '..');
const ROOT = path.resolve(PROJECT, '..');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PORT = Number(Arg('port', '9091'));
const MODE = Arg('mode', 'relay');
const UPSTREAM = Arg('upstream', 'wss://c1.3dtank.com:9090/');
const LABEL = Arg('label', new Date().toISOString().replace(/[:.]/g, '-'));
const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets', LABEL)));
// 字段表:names/codec_fields.json(codec 字段)+ names/uplink_table.json(上行命令名 / 负载字段)
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Reader = MakeReader(Tables, { EnumBytes: 4 });
const ShowUplink = Arg('show-uplink', '1') !== '0';
// methodId(8 字节大端 hex) → 上行命令(用于在上行帧里认 methodId,以及给应答签名用)
const UplinkByHex = new Map();
for (const [MethodId, Cmd] of Tables.UplinkByMethod) {
const B = Buffer.alloc(8);
B.writeBigInt64BE(BigInt(MethodId));
UplinkByHex.set(B.toString('hex'), Cmd);
}
// ws 模块:优先当前目录解析,回落到 sockets/(唯一装过 ws 的地方)
function RequireWs() {
try {
return require('ws');
} catch (Err) {
return require(path.join(ROOT, 'sockets', 'node_modules', 'ws'));
}
}
const Ws = RequireWs();
// 上游握手头:真实服务器会校验来源
const UPSTREAM_HEADERS = {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/',
};
// 命令名(入口层已从源码确证;ID 可跨方向复用,故按方向区分)
const CMD_NAME = {
c2s: { 1: 'Hash Request', 3: 'Auth/EnterSpace', 32: 'ClientLog' },
s2c: { 2: 'Hash Response', 32: 'Open Space' },
};
// ============================================================
// 帧 / nullmap / 密码
// ============================================================
function PeekFrame(Buf) {
if (Buf.length < 2) return null;
const B0 = Buf[0];
if (B0 & 0x80) {
if (Buf.length < 4) return null;
return {
TotalSize: 4 + ((((B0 & 0x3f) << 24) | (Buf[1] << 16) | (Buf[2] << 8) | Buf[3]) >>> 0),
HeaderSize: 4,
Compressed: (B0 & 0x40) !== 0,
};
}
return { TotalSize: 2 + (((B0 & 0x3f) << 8) | Buf[1]), HeaderSize: 2, Compressed: (B0 & 0x40) !== 0 };
}
// 拆出一整串帧;有任何残余则视为「不是帧」返回 null
function ParseFrames(Buf) {
const Frames = [];
let Off = 0;
while (Off < Buf.length) {
const Info = PeekFrame(Buf.slice(Off));
if (!Info || Off + Info.TotalSize > Buf.length) return null;
Frames.push({ Body: Buf.slice(Off + Info.HeaderSize, Off + Info.TotalSize), Compressed: Info.Compressed });
Off += Info.TotalSize;
}
return Frames.length ? Frames : null;
}
// nullmap 长度(对应 x7g)
function NullmapSize(Buf, Offset) {
const R = Buf[Offset];
if (R === undefined) return 0;
if ((R & 0x80) !== 0) {
if (R & 0x40) return 4 + (((R & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]);
return 1 + (R & 0x3f);
}
return 1 + ((R & 0x60) >>> 5);
}
// 帧签名:命令 ID + 负载前 4 字节(必须与 pair_flow.js 的 Signature 完全一致)
// 上行空间帧没有命令 ID 字节层,改用 methodId 当签名(S:<methodId>)——它是客户端源码里的常量,
// 跨会话稳定,于是 fake 模式能按「具体哪条上行命令」精确触发它的应答组。
function FrameSignature(Frame) {
const At = NullmapSize(Frame.Body, 0);
if (At >= Frame.Body.length) return '-1:';
const Sp = SpaceMethodId(Frame.Body, At);
if (Sp) return 'S:' + Sp;
return Frame.Body[At] + ':' + Frame.Body.slice(At + 1, At + 5).toString('hex');
}
// ============================================================
// 上行命令识别(c2s 空间帧)
// ============================================================
// 帧体 = [nullmap][objectId int64 8B][(methodId int64 8B)(负载)…](出处见文件头注释)
// 逐条记录查 names/uplink_table.json;负载字段名优先取 codec_fields.json(按 methodId 反查 codec)
// 返回 null 表示这条帧不是空间命令(如握手帧、保活帧)
// 首字节是 objectId 的最高字节(实测 0x00 = 小正数 id,0xff = 负数 id);握手/日志帧的首字节是命令 id
// (1=Hash Request、3=Auth、32=ClientLog),且 At+8 处必须命中上行命令表,两道门一起挡住误判。
function SpaceMethodId(Body, At) {
const B0 = Body[At];
if (B0 !== 0x00 && B0 !== 0xff) return null;
if (At + 16 > Body.length) return null;
const Hex = Body.slice(At + 8, At + 16).toString('hex');
return UplinkByHex.has(Hex) ? Body.readBigInt64BE(At + 8).toString() : null;
}
function DescribeUplink(Body, At) {
if (!SpaceMethodId(Body, At)) return null;
const ObjectId = Body.readBigInt64BE(At).toString();
const Records = [];
let Off = At + 8;
while (Off + 8 <= Body.length) {
const Hex = Body.slice(Off, Off + 8).toString('hex');
const Cmd = UplinkByHex.get(Hex);
if (!Cmd) break;
const MethodId = Body.readBigInt64BE(Off).toString();
Off += 8;
const Codec = Tables.CodecByMethod.get(MethodId);
const Fields = Codec
? Codec.Fields
: (Cmd.Fields || []).map((F) => ({ Name: F.Field, Type: F.Type, TypeCodec: F.TypeCodec || '' }));
const Values = [];
let Bad = false;
for (const F of Fields) {
const R = Reader.ReadValue(Body, Off, F.Type, F.TypeCodec, 0);
Values.push([F.Name || '(匿名)', F.Type, R.Value]);
if (R.Bad || R.Next <= Off) {
Bad = true;
break;
}
Off = R.Next;
}
Records.push({
MethodId,
Name: (Cmd.OwnerName || Cmd.OwnerClass || '?') + '.' + (Cmd.SendFn || '?'),
Module: Cmd.Module,
Values,
Bad,
});
}
if (!Records.length) return null;
return { ObjectId, Records, Next: Off };
}
// 把 DescribeUplink 的结果渲染成日志行
function FormatUplink(Info, Body) {
const Lines = [' 上行 objectId=' + Info.ObjectId + ' 命令 ' + Info.Records.length + ' 条'];
for (const R of Info.Records) {
Lines.push(' ' + R.Name + ' (' + R.Module + ') methodId=' + R.MethodId);
for (const [K, T, V] of R.Values)
Lines.push(' ' + String(K).padEnd(22) + ' ' + String(T).padEnd(14) + ' = ' + FormatValue(V));
if (R.Bad) Lines.push(' <负载解不动,剩余原始字节: ' + Hex(Body.slice(Info.Next), 32) + '>');
}
return Lines;
}
// 自变体 XOR 流密码(module_61860 类 ir)
class Cipher {
constructor(K, Encoded) {
this.Slot = new Int32Array(8);
for (let I = 0; I < 8; I++) this.Slot[I] = ((K ^ (I << 3)) ^ (Encoded ? 87 : 0)) & 0xff;
this.Idx = 0;
}
Apply(Buf) {
const Out = Buffer.alloc(Buf.length);
for (let I = 0; I < Buf.length; I++) {
const Plain = (Buf[I] ^ this.Slot[this.Idx]) & 0xff;
Out[I] = Plain;
this.Slot[this.Idx] = Plain;
this.Idx = this.Idx ^ (Plain & 7);
}
return Out;
}
static Series(Window, K, Encoded) {
const C = new Cipher(K, Encoded);
return Window.map((Buf) => C.Apply(Buf));
}
}
function Hex(Buf, N) {
return Buf.slice(0, N || 32).toString('hex');
}
function Ascii(Buf, N) {
return Array.from(Buf.slice(0, N || 40))
.map((B) => (B >= 32 && B < 127 ? String.fromCharCode(B) : '.'))
.join('');
}
// 变长整数长度前缀的字符串(x7g 家族),返回 { Str, Next }
function ReadLogString(Buf, Offset) {
let Len = Buf[Offset];
let Next = Offset + 1;
if (Len & 0x80) {
if (Len & 0x40) {
Len = (((Len & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]) >>> 0;
Next = Offset + 3;
} else {
Len = ((Len & 0x3f) << 8) | Buf[Offset + 1];
Next = Offset + 2;
}
}
return { Str: Buf.slice(Next, Next + Len).toString('utf8'), Next: Next + Len };
}
// ============================================================
// 单向流解码器:明文 → 探测密钥 → 实时解密
// ============================================================
class StreamDecoder {
constructor(State, Direction) {
this.State = State;
this.Direction = Direction;
this.Encoded = Direction === 'c2s'; // 上传方向密钥表再 ^87
this.Mode = 'plain';
this.Cipher = null;
this.Key = null;
this.Hint = null; // 对向方向破解出的密钥
this.Window = [];
}
Feed(Buf) {
if (this.Mode === 'cipher') {
this.Report(this.Cipher.Apply(Buf), true);
return;
}
if (this.Mode === 'plain') {
if (ParseFrames(Buf)) {
this.Report(Buf, false);
return;
}
// 明文解析失败 → 加密从这里开始
this.Mode = 'probing';
this.Window = [Buf];
this.State.Log('[' + this.Direction + '] 明文段结束,开始穷举密钥(首条密文 ' + Buf.length + ' 字节)');
return;
}
// probing:累积窗口,直到密钥唯一确定或放弃
// 先试对向密钥:命中即当场确定,省掉穷举等待
if (this.Hint !== null && StreamDecoder.SeriesOk(this.Window, this.Hint, this.Encoded)) {
this.State.Log('[' + this.Direction + '] 沿用对向密钥 K=' + this.Hint);
this.Key = this.Hint;
this.Cipher = new Cipher(this.Hint, this.Encoded);
this.Mode = 'cipher';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(this.Cipher.Apply(B), true);
return;
}
this.Window.push(Buf);
const Bytes = this.Window.reduce((A, B) => A + B.length, 0);
const Hits = [];
for (let K = 0; K < 256; K++) {
if (StreamDecoder.SeriesOk(this.Window, K, this.Encoded)) Hits.push(K);
}
if (Hits.length === 1) {
const K = Hits[0];
this.State.Log('[' + this.Direction + '] 密钥已破解 K=' + K + '(窗口 ' + this.Window.length + ' 条 / ' + Bytes + ' 字节)');
this.Key = K;
this.Cipher = new Cipher(K, this.Encoded);
this.Mode = 'cipher';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(this.Cipher.Apply(B), true);
return;
}
if (this.Window.length >= 8 || Bytes > 262144) {
this.State.Log('[' + this.Direction + '] 密钥探测失败(候选 ' + Hits.length + ' 个),后续按原始字节记录');
this.Mode = 'plain';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(B, false);
}
}
// 候选密钥:窗口内每条消息解密后都必须是完整帧
static SeriesOk(Window, K, Encoded) {
for (const Buf of Cipher.Series(Window, K, Encoded)) {
if (!ParseFrames(Buf)) return false;
}
return true;
}
Report(Buf, Decrypted) {
const Tag = Decrypted ? '[密] ' : '[明] ';
const Frames = ParseFrames(Buf);
if (!Frames) {
this.State.Log(Tag + '非帧数据 ' + Buf.length + ' 字节: ' + Hex(Buf, 32));
return;
}
for (const Frame of Frames) {
let Body = Frame.Body;
let Note = Frame.Compressed ? ' [zlib…]' : '';
if (Frame.Compressed) {
try {
Body = zlib.inflateSync(Body);
Note = ' [zlib ' + Frame.Body.length + '→' + Body.length + ']';
} catch (Err) {
Note = ' [zlib 解压失败]';
}
}
const At = NullmapSize(Body, 0);
if (At >= Body.length) {
this.State.Log(Tag + '保活 ping (' + Buf.length + 'B)' + Note);
continue;
}
// 上行空间帧没有「命令 id」字节层:帧体直接是 SpaceCommand(objectId, methodId, 负载)
const Up = this.Direction === 'c2s' && ShowUplink ? DescribeUplink(Body, At) : null;
if (Up) {
this.State.Log(Tag + '空间命令帧 ' + (Body.length - At) + 'B' + Note);
for (const L of FormatUplink(Up, Body)) this.State.Log(L);
continue;
}
const Id = Body[At];
const Payload = Body.slice(At + 1, At + 41);
const Name = (CMD_NAME[this.Direction] && CMD_NAME[this.Direction][Id]) || '未知';
const Extra = this.Direction === 'c2s' && Id === 32 ? ' ' + StreamDecoder.DescribeClientLog(Body.slice(At + 1)) : '';
this.State.Log(
Tag + '命令 ID=' + String(Id).padStart(3) + ' (' + Name + ') 负载=' + (Body.length - At - 1) + 'B' + Note + Extra,
);
this.State.Log(' ' + Payload.toString('hex') + ' |' + Ascii(Payload) + '|');
}
}
// ClientLog body = [LogEntryType][string][string]
static DescribeClientLog(Buf) {
try {
const Type = Buf[0];
const S1 = ReadLogString(Buf, 1);
const S2 = ReadLogString(Buf, S1.Next);
return '类型=' + Type + ' ' + JSON.stringify(S1.Str) + ' ' + JSON.stringify(S2.Str);
} catch (Err) {
return '';
}
}
// 只取 ClientLog 的第二个字符串(UI 事件 JSON,如 {"screen":"Lobby.Home","button":"GARAGE"})
static ClientLogText(Buf) {
try {
const S1 = ReadLogString(Buf, 1);
return ReadLogString(Buf, S1.Next).Str;
} catch (Err) {
return '';
}
}
}
// ============================================================
// 会话与落盘
// ============================================================
fs.mkdirSync(PACKET_DIR, { recursive: true });
function NewSession() {
const Used = fs
.readdirSync(PACKET_DIR, { withFileTypes: true })
.filter((D) => D.isDirectory() && D.name.startsWith('session_'))
.map((D) => Number(D.name.slice('session_'.length)))
.filter((N) => !Number.isNaN(N));
const Next = (Used.length ? Math.max.apply(null, Used) : 0) + 1;
const Dir = path.join(PACKET_DIR, 'session_' + String(Next).padStart(3, '0'));
fs.mkdirSync(Dir, { recursive: true });
const State = { Dir, Stream: fs.createWriteStream(path.join(Dir, 'log.txt'), { flags: 'w' }), C2s: 0, S2c: 0 };
State.Log = (Msg) => {
const Line = '[' + new Date().toISOString() + '] ' + Msg;
console.log(Line);
State.Stream.write(Line + '\n');
};
return State;
}
// 原始字节落盘 + 记录顺序(解析交给 StreamDecoder)
function SaveFrame(State, Direction, Buf, Note) {
const Seq = Direction === 'c2s' ? State.C2s++ : State.S2c++;
fs.writeFileSync(path.join(State.Dir, Direction + '_' + String(Seq).padStart(4, '0') + '.bin'), Buf);
State.Log(
(Direction === 'c2s' ? '>>>' : '<<<') + ' ' + Direction + '_' + String(Seq).padStart(4, '0') + '.bin ' +
Buf.length + ' 字节' + (Note ? ' ' + Note : ''),
);
State.Stream.write(Buf.toString('hex') + '\n');
}
// ============================================================
// fake 模式:自己完成 Hash 握手(明文模式)
// ============================================================
// 依据真实抓包(relay-04/session_002,该连接服务端全程明文)还原的握手尾序列
const FAKE_PING = Buffer.from([0x00, 0x01, 0x00]);
const FAKE_HASH_SPACE = 100045260; // 0x05f691cc:与 Hash Response 同帧下发的空间
// 回放库根目录:<root>/space-<spaceId>/s2c_*.bin(由 dump_plain.js 解密产出)
const REPLAY_ROOT = path.resolve(Arg('replay', path.join(PROJECT, 'packets', 'plain')));
// 回放节奏:优先按 dump_plain.js 产出的 timeline_s2c.txt(原始抓包时刻);
// 没有时间线时退回固定间隔。--replay-scale 缩放整体时间(0.5 = 快一倍),
// --replay-gap-max 给单次间隔封顶(毫秒,0 = 不封顶)
const ReplayGap = Number(Arg('replay-gap', '150')) || 150;
const ReplayScale = Number(Arg('replay-scale', '1')) || 1;
const ReplayGapMax = Number(Arg('replay-gap-max', '0')) || 0;
// 空间 ID 必须与客户端真实空间列表一致(取自抓包字节,勿手抄换算):
// 0x5f691cc=100045260(大厅,与 Hash Response 同帧) 0xd634b693fb=920007382011(快速战斗队列)
// 0x5f7b228=100119080 0x5f6fdcf=100072911 0x5f69398=100045720
// 真实服务器会 Announce 这 5 个;离线模拟默认只 Announce 大厅,
// 想额外 Announce 用 --spaces 920007382011,100119080,...
const EXTRA_SPACES = Arg('spaces', '')
.split(',')
.map((S) => Number(S.trim()))
.filter((N) => !Number.isNaN(N) && N > 0)
.map((Space, I) => ({ Delay: 2000 + I * 1000, Space }));
// 点击驱动的空间下发:真实服是按客户端上报的 button_click 才回 Open Space。
// 抓包确证(garage-01/session_001):05:08:59.549 收到 button_click "GARAGE",
// 05:08:59.992 服务器下发 Open Space(0x5f6fe6e=100073070) ← 车库空间
const CLICK_SPACES = { GARAGE: 100073070 };
function EncodeFrame(Payload, Nullmap) {
const Nm = Nullmap || Buffer.from([0x00]);
const Length = Nm.length + Payload.length;
if (Length < 0x4000) return Buffer.concat([Buffer.from([(Length >> 8) & 0xff, Length & 0xff]), Nm, Payload]);
return Buffer.concat([
Buffer.from([((Length >>> 24) & 0x3f) | 0x80, (Length >>> 16) & 0xff, (Length >>> 8) & 0xff, Length & 0xff]),
Nm,
Payload,
]);
}
// Open Space 命令体 = int64 大端 spaceId
function OpenSpaceCommand(Space) {
const Cmd = Buffer.alloc(9);
Cmd[0] = 32;
Cmd.writeBigInt64BE(BigInt(Space), 1);
return Cmd;
}
function FakeHandshake(State, Socket, Timers) {
const Hash = Buffer.alloc(32);
for (let I = 0; I < 32; I++) Hash[I] = Math.floor(Math.random() * 256);
// 末位 bool = 客户端 t.v7j_1(是否启用链路加密);发 0 让整条链路保持明文
const HashCmd = Buffer.concat([Buffer.from([2]), Hash, Buffer.from([0x00])]);
const Frame = EncodeFrame(Buffer.concat([HashCmd, OpenSpaceCommand(FAKE_HASH_SPACE)]), Buffer.from([0x00]));
State.Log(
'握手: Hash Response(hash=' + Hash.toString('hex') + ', 明文模式) + Open Space(' + FAKE_HASH_SPACE + ') ' + Frame.length + 'B',
);
Socket.send(Frame);
// 按真实抓包的节奏继续下发 Announce 的空间
let Acc = 0;
for (const Step of EXTRA_SPACES) {
Acc += Step.Delay;
Timers.push(
setTimeout(() => {
if (Socket.readyState !== Ws.OPEN) return;
State.Log('下发 Open Space(' + Step.Space + ')');
Socket.send(EncodeFrame(OpenSpaceCommand(Step.Space), Buffer.from([0x00])));
}, Acc),
);
}
}
// 读取 dump_plain.js 产出的时间线(帧名 → 原始抓包时刻,毫秒)
function ReadTimeline(Dir) {
const P = path.join(Dir, 'timeline_s2c.txt');
const Out = new Map();
if (!fs.existsSync(P)) return Out;
for (const Line of fs.readFileSync(P, 'utf8').split('\n')) {
const M = /^(\S+)\t(\d+)$/.exec(Line.trim());
if (M) Out.set(M[1], Number(M[2]));
}
return Out;
}
// 读取 pair_flow.js 产出的应答表(帧名 → { Trigger, Delay, Ms })
function ReadFlow(Dir) {
const P = path.join(Dir, 'flow_s2c.tsv');
const Out = new Map();
if (!fs.existsSync(P)) return Out;
for (const Line of fs.readFileSync(P, 'utf8').split('\n')) {
const C = Line.trim().split('\t');
if (C.length < 4) continue;
Out.set(C[0], { Trigger: C[1], Delay: Number(C[2]) || 0, Ms: Number(C[3]) || 0 });
}
return Out;
}
// 立刻发出某一个待触发的应答组(组内按抓包记录的相对延迟顺序)
function FlushWatcher(State, Socket, Item) {
if (Item.Done) return;
Item.Done = true;
for (const T of Item.Timers) clearTimeout(T);
Item.Timers = [];
let Acc = 0;
for (const X of Item.Frames) {
Acc += Math.max(0, X.Delay * ReplayScale);
const At = Math.round(Acc);
setTimeout(() => {
if (Socket.readyState !== Ws.OPEN) return;
State.Log('应答 ' + X.File + ' ' + X.Buf.length + 'B (触发 ' + SigLabel(Item.Signature) + ')');
Socket.send(X.Buf);
}, At);
}
}
// 应答签名 → 日志标签:S:<methodId> 补上上行命令名(如 S:1816… (G.q1wd))
function SigLabel(Sig) {
const M = /^S:(\d+)$/.exec(Sig);
if (!M) return Sig;
const C = Tables.UplinkByMethod.get(M[1]);
return Sig + (C ? ' (' + (C.OwnerName || C.OwnerClass || '?') + '.' + (C.SendFn || '?') + ')' : '');
}
// 空间回放:
// · 无触发者的帧(主动推送:ping 周期、状态同步)按抓包时间线发;
// · 有触发者的帧(应答)挂到 Watchers,等对应 c2s 到达再发,同时按原时间线兜底。
// CurrentSig = 当前这条 c2s 的签名(进空间那一条要立刻触发初始化序列)
function ReplaySpace(State, Socket, Timers, Space, Watchers, CurrentSig) {
const Dir = path.join(REPLAY_ROOT, 'space-' + Space);
if (!fs.existsSync(Dir)) {
State.Log('(fake 模式)无回放数据: ' + Dir);
return 0;
}
const Files = fs
.readdirSync(Dir)
.filter((F) => /^s2c_\d+\.bin$/.test(F))
.sort();
const Timeline = ReadTimeline(Dir);
const Flow = ReadFlow(Dir);
const Timed = [];
const Held = [];
let Prev = null; // 上一条消息的时间线时刻(含被跳过的保活 ping,保证节奏不压缩)
for (const F of Files) {
const Buf = fs.readFileSync(path.join(Dir, F));
const Info = PeekFrame(Buf);
const IsPing =
Info && Info.TotalSize === Buf.length && NullmapSize(Buf, Info.HeaderSize) >= Buf.length - Info.HeaderSize;
const Ms = Timeline.has(F) ? Timeline.get(F) : Prev === null ? 0 : Prev + ReplayGap;
Prev = Ms;
const Plan = Flow.get(F);
if (Plan && Plan.Trigger !== '-') Held.push({ File: F, Buf, At: Ms, Delay: Plan.Delay, Trigger: Plan.Trigger });
else Timed.push({ File: F, Buf, IsPing, At: Ms });
}
// 主动推送:相对空间进入时刻按时间线铺开
let Acc = 100;
let PrevMs = null;
for (const X of Timed) {
const Gap = PrevMs === null ? 0 : (X.At - PrevMs) * ReplayScale;
PrevMs = X.At;
Acc += ReplayGapMax > 0 ? Math.min(Gap, ReplayGapMax) : Gap;
if (X.IsPing) continue;
const At = Math.round(Acc);
Timers.push(
setTimeout(() => {
if (Socket.readyState !== Ws.OPEN) return;
State.Log('推送 ' + X.File + ' ' + X.Buf.length + 'B @+' + At + 'ms');
Socket.send(X.Buf);
}, At),
);
}
// 应答:按触发签名分组,等待 c2s;超过原时间线仍没等到就兜底发出去
const Groups = new Map();
for (const X of Held) {
let G = Groups.get(X.Trigger);
if (!G) {
G = { Signature: X.Trigger, Frames: [], Done: false, Timers: [] };
Groups.set(X.Trigger, G);
}
G.Frames.push(X);
}
for (const G of Groups.values()) {
for (const X of G.Frames) {
const At = Math.round(100 + X.At * ReplayScale);
G.Timers.push(
setTimeout(() => {
State.Log('应答组 ' + SigLabel(G.Signature) + ' 未等到触发,按原时间线兜底');
FlushWatcher(State, Socket, G);
}, At),
);
}
Watchers.push(G);
// 进空间那条 c2s 要立刻把初始化序列打出去(签名里带的是本会话随机 hash,不能按值比对)
if (CurrentSig && (G.Signature === CurrentSig || (G.Signature.startsWith('3:') && CurrentSig.startsWith('3:')))) {
FlushWatcher(State, Socket, G);
}
}
State.Log(
'(fake 模式)回放 space-' + Space + ':推送 ' + Timed.length + ' 帧(时间线) + 应答 ' + Held.length +
' 帧(' + Groups.size + ' 组等 c2s 触发)' + (Timeline.size ? '' : '(无时间线,固定 ' + ReplayGap + 'ms)'),
);
return Timed.length + Held.length;
}
// ============================================================
// 服务端
// ============================================================
const Server = http.createServer((Req, Res) => {
Res.writeHead(426, { 'Content-Type': 'text/plain; charset=utf-8' }).end('WebSocket only\n');
});
// 两个方向的 K 必然相同(一次构造展开两套密钥表),一侧破解后提示另一侧
function SyncKeys(Decoders) {
if (Decoders.s2c.Key !== null && Decoders.c2s.Hint === null) Decoders.c2s.Hint = Decoders.s2c.Key;
if (Decoders.c2s.Key !== null && Decoders.s2c.Hint === null) Decoders.s2c.Hint = Decoders.c2s.Key;
}
const WsServer = new Ws.Server({ server: Server });
WsServer.on('connection', (Client, Req) => {
const State = NewSession();
State.Log('=== 新客户端 ===');
State.Log('来源: ' + (Req.socket.remoteAddress || '?') + ' Origin: ' + (Req.headers.origin || '无'));
State.Log('模式: ' + MODE + (MODE === 'relay' ? ' 上游: ' + UPSTREAM : ''));
const Decoders = { c2s: new StreamDecoder(State, 'c2s'), s2c: new StreamDecoder(State, 's2c') };
const Timers = []; // fake 模式的定时下发
const Announced = new Set(); // 本连接已下发的(点击驱动的)空间,避免重复
const Watchers = []; // 本连接的应答组(等对应 c2s 到达才发)
let Keepalive = null;
let Upstream = null;
const Pending = []; // 上游未就绪时暂存(握手帧常比上游 TLS 建连更早到达)
if (MODE === 'relay') {
Upstream = new Ws(UPSTREAM, { headers: UPSTREAM_HEADERS, rejectUnauthorized: false });
Upstream.binaryType = 'nodebuffer';
Upstream.on('open', () => {
State.Log('上游已连接: ' + UPSTREAM + ',补发暂存 ' + Pending.length + ' 帧');
while (Pending.length) Upstream.send(Pending.shift());
});
Upstream.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 's2c', Buf);
Decoders.s2c.Feed(Buf);
SyncKeys(Decoders);
if (Client.readyState === Ws.OPEN) Client.send(Buf);
});
Upstream.on('close', (Code) => {
State.Log('上游关闭 code=' + Code);
Client.close();
});
Upstream.on('error', (Err) => State.Log('上游错误: ' + Err.message));
}
Client.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 'c2s', Buf);
Decoders.c2s.Feed(Buf);
SyncKeys(Decoders);
if (MODE === 'relay') {
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.send(Buf);
else {
State.Log('上游尚未就绪,暂存 ' + Buf.length + ' 字节待补发');
Pending.push(Buf);
}
return;
}
// fake 模式
const Frames = ParseFrames(Buf);
if (!Frames) {
State.Log('(fake 模式)无法解析的帧,忽略');
return;
}
for (const Frame of Frames) {
const At = NullmapSize(Frame.Body, 0);
const Id = At < Frame.Body.length ? Frame.Body[At] : -1;
const Sig = FrameSignature(Frame);
// 上行空间命令的识别与打印已由上面的 Decoders.c2s.Feed() 完成,这里只取结果用于抑制「未实现命令」噪声
const Up = ShowUplink ? DescribeUplink(Frame.Body, At) : null;
// 应答式回放:本条 c2s 命中某个待发的应答组时立刻把它发出去
for (const W of Watchers) if (!W.Done && W.Signature === Sig) FlushWatcher(State, Client, W);
if (Id === 1) {
FakeHandshake(State, Client, Timers);
if (!Keepalive) {
// 真实服务器每 10s 发一次保活
Keepalive = setInterval(() => {
if (Client.readyState === Ws.OPEN) Client.send(FAKE_PING);
}, 10000);
}
} else if (Id === -1) {
State.Log('保活 ping,原样回包');
Client.send(FAKE_PING);
} else if (Id === 3 && At + 41 <= Frame.Body.length) {
const Space = Frame.Body.readBigInt64BE(At + 33).toString();
State.Log(
'(fake 模式)收到空间鉴权: digest=' +
Frame.Body.slice(At + 1, At + 33).toString('hex').slice(0, 16) +
'… spaceId=' +
Space,
);
ReplaySpace(State, Client, Timers, Space, Watchers, Sig);
} else if (Id === 32) {
// UI 事件:照真实服的做法,按 button_click 下发对应空间
const Text = StreamDecoder.ClientLogText(Frame.Body.slice(At + 1));
const M = /"button"\s*:\s*"([A-Za-z_]+)"/.exec(Text);
const Space = M ? CLICK_SPACES[M[1]] : undefined;
if (Space && !Announced.has(Space)) {
Announced.add(Space);
State.Log('客户端点击 ' + M[1] + ' → 下发 Open Space(' + Space + ')');
Client.send(EncodeFrame(OpenSpaceCommand(Space), Buffer.from([0x00])));
}
} else if (!Up) State.Log('(fake 模式)未实现命令 ID=' + Id + ',忽略');
}
});
Client.on('close', (Code) => {
State.Log('客户端断开 code=' + Code + ' 收 ' + State.C2s + ' 条 / 发 ' + State.S2c + ' 条');
if (Keepalive) clearInterval(Keepalive);
for (const T of Timers) clearTimeout(T);
State.Stream.end();
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.close();
});
Client.on('error', (Err) => State.Log('客户端错误: ' + Err.message));
});
Server.listen(PORT, () => {
console.log('游戏通信服务器已启动: ws://localhost:' + PORT + '/');
console.log(' 模式 : ' + MODE + (MODE === 'relay' ? ' → ' + UPSTREAM : ' (离线模拟)'));
console.log(' 抓包目录: ' + PACKET_DIR);
console.log(' 实时解析: 明文直解;加密段自动穷举密钥后实时解密;zlib 压缩帧自动解压');
});