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:
1 parent
f1c9d903fa
commit
2e5e8bd7b0
8 files changed
+62063
No files matched your search
@@ -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<T> | varint 元素数 + 元素… |
|
||||
| opt<T> | 1 字节标志(非 0 = 空)+ 有值时紧跟 T |
|
||||
| enum<T> | 4 字节(库里 `o7g` 是空实现,但游戏自注册的枚举以 i32 上线) |
|
||||
| 模型类 | 就地展开该模型 codec 的字段(无长度前缀) |
|
||||
| varint | <128 → 1B;<16384 → 2B(`0x80|高6位, 低8位`);<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
@@ -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 + ' 行)');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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 压缩帧自动解压');
|
||||
});
|
||||
Reference in new issue
Block a user