feat(3dtank): 协议逆向工程与自研服务器入库(工具链 + 协议表 + 空间回放 + MySQL)

工具链(3DTank/tools/):extract(模块提取)、mine_evidence/apply_names(命名证据与别名回填)、extract_codec_fields(字段表 + 全库槽位类型 + PrimClassWire/ResourceClasses)、mine_downlink(下行字段表)、extract_space_init/rebase_space_init/audit_space_init(空间模板与记录切分审计)、extract_space_session(把真实会话切成按上行触发的回放模板)、pair_flow(请求→应答配对)、probe_containers、ingest/decrypt_frames(抓包入库与解密)、serve/fetch_assets(本地站点镜像)、import_db(抓包对象仓库导入 MySQL)。

服务器(3DTank/tools/server/):link/entry/codec/container/dispatch/world/space/semantic(协议层与 lt/pt/zt 三个容器布局)、session(会话回放,顺序推进)、db + schema.sql(MySQL 连接层与对象仓库表结构)、store/responder(查库组帧与定向应答;接管范围默认只开 ba.lcr)。

同时修 pair_flow 的时间轴偏移:timeline_c2s 与 timeline_s2c 的 0 点相差 144~4453ms,改前配对整体偏约 2 秒(实测 space-100073070 偏 2084ms),改后 22.4% 的触发者被纠正。

src/、src_named/、packets/、3DTank/local/(含数据库口令)不进库,可用上述工具重新生成。
This commit is contained in:
TermiNexus committed 2026-09-17 20:00:41 +08:00
1 parent 90c377ad79
commit b6f2cb71ee
65 files changed
+710187 -868

No files matched your search

+61 -37
View File
@@ -12,11 +12,14 @@
index.json # 模块索引
names/
evidence.json # 命名证据(见下)
command_alias.json # 派生命名词典(读源码证实语义后起的别名,不是原始名;含 Classes/Methods/Fields/MethodIds/Spaces)
rename_report.json # apply_names.js 的产出报告(含 Aliases 段)
tools/
extract.js # bundle → src/
mine_evidence.js # src/ → names/evidence.json
(待建) src_named/ # 应用重命名后的源码
(待建) tools/apply_names.js / build_bundle.js
apply_names.js # src/ → src_named/(证据命名;加 --aliases 时附带回填 command_alias.json 的别名)
src_named/ # 应用重命名后的源码(126 个模块,三道校验:node --check / 字符串集合一致 / 标识符计数)
(待建) tools/build_bundle.js
```
原始 bundle 不复制进本工程,默认引用 `../main.6469b966.js`(可用参数覆盖)。
@@ -152,52 +155,73 @@ getter(`v("codec_名字")` 的报错串就是字段名)/ `q7g`(slot →
**上行字段没有可读名**:命令类不像模型 codec 那样保留 `codec_<名字>` getter,555 个上行字段在模块内
没有任何属性访问点,压缩后原可读名已不可恢复,只能人工标注或靠调用点语义推断。
### 离线模拟(fake 模式)
### 服务器:中继(分析)与真服务器(`server/`)
```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 wss_server.js # 中继:客户端 → 本地 → 真实服务器(抓包 + 实时解析)
node server/index.js --port 9091 # 真服务器(M1):自己握手 / 自己管空间 / 把未实现命令记全
node server/selftest.js # MakeWriter 往返自检(拿真实抓包逐字节比对)
node decode_space_frames.js --dir ../packets/plain/space-100045720 --fields # 事后逐字段解一包
node pair_flow.js # 重新生成请求→应答配对表
node ingest.js --label mine-01 # 把一次抓包入库到 packets/plain/
node pair_flow.js # 重新生成请求→应答配对表
node ingest.js --label mine-01 # 把一次抓包入库到 packets/plain/
```
回放分两类:**主动推送**按抓包时间线(`timeline_s2c.txt`)铺开;**应答**挂在 `Watchers` 上等对应 c2s 到达。
应答签名(必须与 `pair_flow.js` 的 `Signature` 一致,改一边就要改另一边):
**回放(fake 模式)方案已废弃**(2026-09-16 实测结论)。原因:客户端一次会话是「大厅 + 6 个空间轮流连」
的链(`mine-01` 的 `session_001…007`:大厅 → 100045260 → 920007382011 → 100119080 → 100072911 →
100045720 → 车库 100073070),导航全部由大厅那几帧 `Open Space` 驱动,且每个连接都会往客户端的
**全局注册表**(gameType / model / resource / space)里塞数据。只回放单条连接必然缺状态,实测表现为
进车库后抛 `Class not found exception class=-2147482689`(`src/module_45686.js:117` 的 `ft()` 查
`gameTypeRegistry` 落空),弹错后页面自动刷新,死循环。**空间层是问答式的,只能真服务器。**
- 上行空间帧 → `S:<methodId>`(源码常量,跨会话稳定,**能精确到某条具体命令**)
- 其余 → `<命令 id>:<负载前 4 字节 hex>`
`wss_server.js` 只保留 relay(协议分析);真服务器在 `tools/server/`:
于是"客户端点商店/改名/切频道"这类上行都能触发它自己的那组下行,等不到触发的仍按原时间线兜底。
### 容器(无 codec 的记录)现状
容器的模型恒为 `_t`(`src/module_45686.js:94`),codec 是 `lt`(同文件 483-494),只有一个字段 `idCodec`(i64):
读 i64,`0` → 值是紧跟其后的那个 i64;否则按 modelRegister(`module_45686` 的 `at.k1we()`)里该 id 的 codec 就地展开。
容器负载 = `[头部][内层数据]`,头部实测形状是**模式表**:
```
[i32 a][i32 b][i64 objId][i32 N][N × i64 modelId] 共 20 + 8N 字节
```
N 个 modelId 就是"容器里出现哪些模型",语义完全对得上:
| 实测容器 | 模式表 |
| 文件 | 职责 |
|---|---|
| `space-100045260/s2c_0000`(30 个) | CaptchaCC / RegistrationModelCC / EntranceResourcesCC / ExternalEntranceCC / ServerHaltEntranceCC … (登录+大厅初始化批次) |
| `space-100045720/s2c_0000`(5 个) | _t / Kz / ChatCC / ClanChatCC / NewsShowingCC(聊天批次) |
| `space-100072911`(2 个) | Jj / ShopCategoryCC(商店) |
| `link.js` | 帧边界 / nullmap / 明文协商(握手回帧里把「启用链路加密」置 0,整条链路明文) |
| `entry.js` | Hash Request·Response / EnterSpace / Open Space / ClientLog |
| `codec.js` | `MakeWriter`(写包)+ `ResolveFields`(按 methodId 取字段表,服务端与自检同一口径) |
| `dispatch.js` | 空间帧识别(两道门)→ 逐条记录解码 → 落地日志 → 记「未实现」 |
| `world.js` | 空间实例表 / 空间分类 / 未实现命令清单 |
| `index.js` | 启动、连接生命周期、日志落盘(`server-logs/<label>/session_00N/`) |
| `selftest.js` | MakeWriter 往返自检 |
`decode_space_frames.js` 会用这个模式表约束内层扫描(只认表里的模型、且按表里的先后顺序出现),
内层记录"吃满边界"的比例从 60% 提到 **70%**,假命中基本清掉。
M1 现状:入口层全量(握手、鉴权、ClientLog、保活);空间层**只记录、不伪造**,一个字节都不回放。
未实现清单就是 M2 的施工图。M2 要做的是按 `src/module_29554.js` 的 modelService 协议
(`_loadDependencies` / `_loadObjectsData` / `_setModelCommandAlias` / `_unloadObjects`)与
`src/module_45686.js` 的容器三编码(`lt`/`pt`/`zt`)把四张注册表填起来,让客户端进车库不报错。
**写包能力验收**(`node server/selftest.js`:13621 个抓包文件 / 1283 个空间帧 / 1206 条记录):
逐字节一致 **1108 条(92%)**、不一致 9 条、解码中断 89 条、写侧不支持 0 条。
残留的 9 + 89 条全部落在 `ChatCC` / `ClanNotifierData` 两个命令上(字段表漂移),不是写包逻辑问题。
### 容器(无 codec 的记录)—— 三个布局已闭合
容器命令没有普通 codec(客户端只读不写:`src/module_45686.js` 里 `lt.o7g` 是空实现、`pt.o7g`/`zt.o7g` 直接
`throw`),它的负载由三个「容器 codec」描述。**三个布局已逐字节标定完成**,实现在 `tools/server/container.js`
(读写都有,写侧由读侧反推,用真实抓包往返验证):
```
lt : [i64 id] id == 0 → 值 = 紧跟的那个 i64;否则按 modelRegister[id] 的 codec 就地展开
pt : [i32 C][C × (i64 objId, i64 gameTypeId)][i32 D][D × lt项]
zt : [i32 X][i32 C][C × { i64 ObjId, i32 N, N × i64 ModelId }] ← 模型归属表(原「模式表」)
[i32 D][D × { i64 R, i16 E, i16 U, i64 S, u8 O, varint Skip, Skip × i64 }]
```
- 宽度依据:`zt` 项的字段顺序来自源码 `$t()`(`q2u, o2u, o2u, q2u, bool` + `zt` 的 `n2u` 跳过数)。
实测每项恰好 **22 字节** ⇒ `8 + 2w + 8 + 1 + s = 22` ⇒ `o2u` = **i16**、`n2u` = 1 字节(按 varint 读)。
- 标定证据:`space-100045260/s2c_0000.bin`(915B,解压 1799B)用 `zt` 布局 **1782/1782 字节精确吃满**。
- 计数一律 **i32(4 字节)大端**,不是 varint(这一点此前被误判过)。
- 模型归属表里的 N 个 modelId 语义与旧结论一致,例如
`space-100045260`(30 个)= CaptchaCC / RegistrationModelCC / EntranceResourcesCC …(登录+大厅初始化批次)、
`space-100045720`(5 个)= _t / Kz / ChatCC / ClanChatCC / NewsShowingCC(聊天批次)、
`space-100072911`(2 个)= Jj / ShopCategoryCC(商店)。
- **布局判定以「哪个布局恰好吃到帧尾」为准**,不要信 `names/method_table.json` 的 `Codec` 列 ——
实测它把 `_t` 那类行标成 `lt`,而真实负载是 `zt` 形状。
- 验收:`node server/selftest.js` 里容器帧 **11/11 逐字节一致**(10 个 `zt` + 1 个 `pt`)。
仍有 2800 余个帧的 methodId 不在普通 codec / 上行表里且三布局都判不出——那些是**尚未拿到 codec 的其它命令**,
不是容器,属于后续要补的表。
**尚未闭合**:头部前 12 字节(`a`/`b`/`objId`)与模型负载之间还没对齐 —— `_t` 的 codec 读到的首个 i64 实测是 `1`,
而 modelRegister 里没有 id=1 的条目,说明 methodId 与模型负载之间还有一小段(可能是命令 codec 自己写的 tag/版本);
大帧(如 41KB 那个容器)连模式表都认不出,头部形状可能还随场景变化。要彻底闭合需要拿到容器命令自身的 codec,
而不是 `_t` 的模型 codec。
File diff suppressed because it is too large. Load diff
+129
View File
@@ -0,0 +1,129 @@
{
"Note": "派生命名词典:这些名字**不是客户端原始名**(原始名在压缩过程中丢了,见 names/evidence.json 的说明),而是我们读源码证实语义后起的名。用途:① server/ 与 relay 的日志直接用别名,可读;② apply_names.js --aliases 可把它们回填进 src_named/(须与证据命名分开统计)。每条都带 Evidence 说明依据。键格式:Classes/Methods/Fields = \"<moduleId>::<短名>\";MethodIds = 十进制 methodId;Spaces = spaceId。语义没证实的条目标注【推测】或【语义未证】,别名只描述事实,不编造领域名。",
"Classes": {
"10283::Bn": { "Alias": "LoginCommand", "Evidence": "module_10283.js:742 持 methodId 常数 ph3_1=登录/th3_1=2FA;2315 发送法 wh3" },
"10283::Dn": { "Alias": "AccountLoginModel", "Evidence": "module_10283.js:716 三个 s2c 分支全是登录错误/2FA(2316 起 q7i)" },
"10283::$n": { "Alias": "ExternalLoginModel", "Evidence": "module_10283.js:2137 八分支:OAuth 链接/绑定冲突/2FA" },
"25377::dn": { "Alias": "UserActivityReporter", "Evidence": "module_25377.js:1516 gdk 六字符串 = 用户行为上报;gT 在 module_18334.js:3895 调用" },
"29554::G": { "Alias": "ModelServiceProxy", "Evidence": "module_29554.js:280 q1wd 编码 ObjectsData 后发 methodId n1wd_1" },
"29554::U": { "Alias": "ModelServiceProtocol", "Evidence": "module_29554.js:246 q7i 分发 4 条 s2c(_loadDependencies/_loadObjectsData/_setModelCommandAlias/_unloadObjects)" },
"45686::_t": { "Alias": "ProtocolRegistries", "Evidence": "module_45686.js:94 持有 gameType/model/resource/space 四张注册表" },
"61860::mr": { "Alias": "CommandRegistry", "Evidence": "module_61860.js:1681 l7n 按 methodId 找模型并分发;h7n 注册" },
"61860::Ie": { "Alias": "WebSocketTransport", "Evidence": "module_61860.js:981 构造 p7t_1=URL 后 new WebSocket(全库唯一建连点)" },
"61860::Vr": { "Alias": "Space", "Evidence": "module_61860.js:600 构造时自建一条 WS;持有 spaceId 与协议" },
"61860::Ji": { "Alias": "OpenSpaceCommand", "Evidence": "module_61860.js:271 命令名 \"Open space\"、命令 ID 32" },
"61860::Oi": { "Alias": "SpaceOpenedCommand", "Evidence": "module_61860.js:229 空间连接建立后回发的鉴权命令(spaceId + hash)" },
"61860::fr": { "Alias": "SpaceCommandCodec", "Evidence": "module_61860.js:1633 o7g 依次写 objectId(i64)、methodId(i64)" },
"45686::lt": { "Alias": "ContainerItemCodec", "Evidence": "module_45686.js:483/486/487 读 i64 id,0 → 下一个 i64 为值,否则按 modelRegister 展开" },
"45686::pt": { "Alias": "ContainerObjectsCodec", "Evidence": "module_45686.js:525/531 对象引用表 + lt 项表" },
"45686::zt": { "Alias": "ContainerModelsCodec", "Evidence": "module_45686.js:557/563 模型归属表 + 对象项表(915B 入口帧 1782/1782 字节标定)" },
"10283::xn": { "Alias": "EntranceCommands", "Evidence": "module_10283.js:2114 入口模型新建的 c2s 发送器;2197 sh1" },
"10283::ji": { "Alias": "RegisterStateCommands", "Evidence": "module_10283.js:2710 注册流程发送器;2782 ehd / 2788 zax / 2794 fhd / 2803 ghd" },
"10283::ki": { "Alias": "RegistrationModel", "Evidence": "module_10283.js:1110 七个 s2c 常数;2735 _enteredUidIsBusy_advisedUidsCodec" },
"10283::Fn": { "Alias": "RememberLoginModel", "Evidence": "module_10283.js:762;2353 _remember2FaHash / 2360 _rememberUsersHash" },
"10283::Hn": { "Alias": "RememberLoginCommands", "Evidence": "module_10283.js:789;2368 wh4 / 2374 xh4" },
"10283::ci": { "Alias": "SocialNetworkCommands", "Evidence": "module_10283.js:2574 六个双字符串上报;2566 _unlinkSuccess_socialNetworkIdCodec" },
"10283::Ui": { "Alias": "ClientParamsCommands", "Evidence": "module_10283.js:2980 只有 thf 一条上行;调用点 18334.js:8990 算时区" },
"10283::gn": { "Alias": "EntranceRequestModelCC", "Evidence": "module_10283.js:529 持有 s2c 常数 ngz_1=(2025886178375810581);被导出为 t.$_$.j" },
"10283::mn": { "Alias": "EntranceRequestModel", "Evidence": "module_10283.js:538 gn.taw 现场创建;唯一上行 xbl(空负载)" },
"85667::cs": { "Alias": "SpaceObjectsModel", "Evidence": "module_85667.js:681 该模型无 s2c,只有一个上行 codec ls" },
"85667::ls": { "Alias": "SpaceObjectsCommands", "Evidence": "module_85667.js:2752 xbl 空负载上行" },
"85196::ka": { "Alias": "GarageDevicesModel", "Evidence": "module_85196.js:6943 s2c ccr_1 = _devicesLoaded_devicesCodec" },
"85196::ba": { "Alias": "GarageDevicesCommands", "Evidence": "module_85196.js:6973 lcr 空负载上行" },
"10032::ZY": { "Alias": "LobbyHomeScreen", "Evidence": "module_10032.js:9733 屏幕名 \"Lobby.Home\"" },
"10032::OX": { "Alias": "GarageScreen", "Evidence": "module_10032.js:9903 屏幕名 \"Garage\"" },
"10032::lY": { "Alias": "EntranceLoginByPasswordScreen", "Evidence": "module_10032.js:9591 屏幕名 \"Entrance.LoginByPassword\"" },
"10032::fY": { "Alias": "EntranceSelectTypeScreen", "Evidence": "module_10032.js:9594 屏幕名 \"Entrance.SelectEntranceType\"" },
"10032::wY": { "Alias": "EntranceGameAccountScreen", "Evidence": "module_10032.js:9600 屏幕名 \"Entrance.GameAccountEntrance\"" }
},
"Methods": {
"10283::Bn.wh3": { "Alias": "SendLogin", "Evidence": "module_10283.js:2315 写 账号/密码/rememberMe 后发 ph3_1" },
"10283::Bn.nh1": { "Alias": "SendTwoFactorCode", "Evidence": "module_10283.js:2319 写 2FA 码后发 th3_1" },
"10283::Dn.q7i": { "Alias": "DispatchLoginResult", "Evidence": "module_10283.js:2316 三分支:需 2FA / 2FA 错 / 密码错" },
"29554::G.q1wd": { "Alias": "RequestObjects", "Evidence": "module_29554.js:280 客户端向服务端要对象数据(实测负载 i32)" },
"25377::dn.gdk": { "Alias": "ReportActivity", "Evidence": "module_25377.js:1516 六字符串上报(第 2 个是菜单项名)" },
"25377::dn.fdk": { "Alias": "ReportActivityShort", "Evidence": "module_25377.js:1510 两字符串版本" },
"10283::Mn.bh3": { "Alias": "SendButtonClickText", "Evidence": "module_10283.js:2284 发送 zh2_1 文本;实测 \"click button X\" / \"select entrance type\"" },
"10283::xn.sh1": { "Alias": "SetRememberMe", "Evidence": "module_10283.js:2197 写 kh1_1(bool)+lh1_1(string);调用点 18334.js:8637 sh1(!0, window.location.hostname);UI 命令 10032.js:16958 Jit(\"SetRememberMe(rememberMe=\")" },
"10283::ji.ehd": { "Alias": "SendPassword", "Evidence": "module_10283.js:2782 写 thc_1(string);调用点 18334.js:8876 SetPassword / 8122 CheckPassword" },
"10283::ji.ghd": { "Alias": "SendUniqueUserId", "Evidence": "module_10283.js:2803 写 chd_1(单 i64 模型);值来自 localStorage uniqueUserId(18334.js:7815-7837)" },
"10283::ji.zax": { "Alias": "SendUid", "Evidence": "module_10283.js:2788 写 vhc_1(string);调用点 18334.js:8870 SetUid" },
"10283::ji.fhd": { "Alias": "SendRegister", "Evidence": "module_10283.js:2794 写 login/password/url/forceLogin;调用点 18334.js:8864" },
"10283::ci.sh9": { "Alias": "SendSocialNetworkValue", "Evidence": "【语义未证】module_10283.js:2604 写 jh9_1(string);模型 ai 的 7 条上行之一,实测空串" },
"10283::Hn.wh4": { "Alias": "SendLoginViaHash", "Evidence": "module_10283.js:2368 写 sh4_1(string=64 字符 token);调用点 18334.js:5608 LoginViaHash" },
"10283::Hn.xh4": { "Alias": "SendSecondHash", "Evidence": "【语义未证】module_10283.js:2374 写 uh4_1(string),与 wh4 对称;触发方未定位" },
"10283::Ui.thf": { "Alias": "SendTimezoneOffset", "Evidence": "module_18334.js:8990 算 (getTimezoneOffset/60) 并加号;module_10283.js:2995 写 rhf_1" },
"10283::mn.xbl": { "Alias": "SendEmptyRequest", "Evidence": "【语义未证】module_10283.js:2095 空负载上行(methodId 3553092084033962840);配对 s2c = gn 的 ngz_1(读 1 个 long);无 toString/报错串可佐证领域名" },
"85667::ls.xbl": { "Alias": "RequestSpaceObjects", "Evidence": "【推测】module_85667.js:2752 空负载上行;调用点 18334.js:4359/5916/8607;应答是 41590B 大帧" },
"85196::ba.lcr": { "Alias": "LoadAvailableDevices", "Evidence": "【推测】module_85196.js:6973 空负载上行;触发链 Ur=$_$.c15=wU=GarageLoadAvailableDevices(10032.js:51762)" }
},
"Fields": {
"10283::Bn.qh3_1": { "Alias": "login", "Evidence": "实测值 \"WYT2WYT\";源码 wh3(t,n,i) 第 1 参" },
"10283::Bn.rh3_1": { "Alias": "password", "Evidence": "实测值 \"1Qq@1Qq@\";wh3 第 2 参" },
"10283::Bn.sh3_1": { "Alias": "rememberMe", "Evidence": "wh3 第 3 参 bool,实测 true" },
"10283::Bn.uh3_1": { "Alias": "twoFactorCode", "Evidence": "module_10283.js:2319 nh1 写的唯一字段" },
"10283::xn.kh1_1": { "Alias": "rememberMe", "Evidence": "module_10283.js:2197 sh1(t,n) 第 1 参;实测 bool=true" },
"10283::xn.lh1_1": { "Alias": "host", "Evidence": "sh1 第 2 参;调用点 18334.js:8637 传 window.location.hostname;实测 \"localhost\"" },
"10283::ji.thc_1": { "Alias": "password", "Evidence": "module_10283.js:2782 ehd 写的唯一字段(string)" },
"10283::ji.vhc_1": { "Alias": "uid", "Evidence": "module_10283.js:2788 zax 写的唯一字段(string)" },
"10283::ji.xhc_1": { "Alias": "registerLogin", "Evidence": "【推测】module_10283.js:2794 fhd(t,n,i,r) 第 1 参(string),按 2718-2723 的声明序推断" },
"10283::ji.yhc_1": { "Alias": "registerPassword", "Evidence": "【推测】fhd 第 2 参(string),同上序推" },
"10283::ji.zhc_1": { "Alias": "registerUrl", "Evidence": "【推测】fhd 第 3 参(string),同上序推" },
"10283::ji.ahd_1": { "Alias": "registerForceLogin", "Evidence": "【推测】fhd 第 4 参,codec 为 v().pb()(bool),其余 3 个是 v().xb()(string)" },
"10283::ji.chd_1": { "Alias": "uniqueUserId", "Evidence": "module_10283.js:2803 ghd 写的唯一字段(i64);值来自 localStorage uniqueUserId(18334.js:7815-7837)" },
"10283::ci.jh9_1": { "Alias": "socialNetworkValue", "Evidence": "【语义未证】module_10283.js:2604 sh9 写的唯一字段(string);实测空串" },
"10283::Hn.sh4_1": { "Alias": "loginHash", "Evidence": "module_10283.js:2368 wh4 写的唯一字段(string);实测 64 字符 token" },
"10283::Hn.uh4_1": { "Alias": "secondHash", "Evidence": "【语义未证】module_10283.js:2374 xh4 写的唯一字段(string);与 sh4_1 同为 64 字符 token" },
"10283::Ui.rhf_1": { "Alias": "timezoneOffsetHours", "Evidence": "module_10283.js:2995 thf 写的唯一字段(string);实测 \"+8\"" },
"10283::Fn.hh4_1": { "Alias": "remember2FaHash_codecField", "Evidence": "module_10283.js:2353 取该 s2c 负载用,报错串 \"_remember2FaHash_hashCodec\"" },
"10283::Fn.jh4_1": { "Alias": "rememberUsersHash_codecField", "Evidence": "module_10283.js:2360 取该 s2c 负载用,报错串 \"_rememberUsersHash_hashCodec\"" },
"10283::ki.chc_1": { "Alias": "advisedUids_codecField", "Evidence": "module_10283.js:2735 取该 s2c 负载用,报错串 \"_enteredUidIsBusy_advisedUidsCodec\"" },
"29554::G.o1wd_1": { "Alias": "requestPayload", "Evidence": "q1wd 用它编码传入值;实测 4 字节 i32=1,语义未完全确认" },
"25377::dn.zdj_1": { "Alias": "menuItem", "Evidence": "gdk 第 2 参;实测值 \"GARAGE\"" }
},
"MethodIds": {
"2045663294076292": { "Alias": "SocialNetworkCommands.SendSocialNetworkValue", "Dir": "c2s" },
"108605496059850042": { "Alias": "LoginCommand.SendLogin", "Dir": "c2s" },
"237130683810296524": { "Alias": "RememberLoginModel.RememberUsersHash", "Dir": "s2c", "Note": "module_10283.js:770 ih4_1 = R(-1109675316, 55211289);2356 分支读串 \"_rememberUsersHash_hashCodec\"(旧表里的 ...296524 即此值,勿改成 ...294924)" },
"761463638726320835": { "Alias": "RegistrationModel.s2cViaGhc", "Dir": "s2c", "Note": "module_10283.js:1120 ghc_1 = R(531145411, 177292069);2745 分支调 ohc(),无负载" },
"1151554561693937214": { "Alias": "RegisterStateCommands.SendPassword", "Dir": "c2s" },
"1247179769701109529": { "Alias": "RegistrationModel.EnteredUidIsBusy", "Dir": "s2c", "Note": "module_10283.js:1116 bhc_1;2731 分支读 advisedUids 列表(报错串 2735)" },
"1247179769701225356": { "Alias": "RegistrationModel.s2cViaDhc", "Dir": "s2c", "Note": "module_10283.js:1117 dhc_1(与 bhc_1 同高位,低位差 115827);2739 分支调 lhc(),无负载" },
"1816792453857564692": { "Alias": "ModelService.RequestObjects", "Dir": "c2s" },
"1968866752316155064": { "Alias": "RegisterStateCommands.SendRegister", "Dir": "c2s" },
"1968867162405923123": { "Alias": "RegisterStateCommands.SendUid", "Dir": "c2s" },
"2025886178375810581": { "Alias": "EntranceRequestModelCC.ReceiveLong", "Dir": "s2c", "Note": "【语义未证】模型 ModelId 2951079444907754024(module_10283 gn,字段 jgz_1..ogz_1);2090 分支读 1 个 long(a7g_1.q2u());与 c2s mn.xbl(3553092084033962840) 配对" },
"2522279596878141909": { "Alias": "UserActivityReporter.ReportActivity", "Dir": "c2s" },
"2528403217084400809": { "Alias": "SpaceObjectsCommands.RequestSpaceObjects", "Dir": "c2s", "Note": "【推测】module_85667 ls.xbl,空负载" },
"2851527043709977805": { "Alias": "RegistrationModel.s2cViaHhc", "Dir": "s2c", "Note": "module_10283.js:1121 hhc_1;2747 分支调 phc(),无负载" },
"3048017108161529684": { "Alias": "RegisterStateCommands.SendUniqueUserId", "Dir": "c2s" },
"3216143066888387731": { "Alias": "ModelService.LoadDependencies", "Dir": "s2c" },
"3553092084033962840": { "Alias": "EntranceRequestModel.SendEmptyRequest", "Dir": "c2s", "Note": "【语义未证】module_10283.js:539 sgz_1 = R(-484916392, 827268716),2095 mn.xbl 空负载" },
"3559444790692169462": { "Alias": "EntranceCommands.SetRememberMe", "Dir": "c2s" },
"3777380964115009138": { "Alias": "AccountLoginModel.NeedTwoFactor", "Dir": "s2c" },
"4091278427760769105": { "Alias": "ClientParamsCommands.SendTimezoneOffset", "Dir": "c2s" },
"4345588892358775163": { "Alias": "RegistrationModel.s2cViaEhc", "Dir": "s2c", "Note": "module_10283.js:1118 ehc_1;2741 分支调 mhc(),无负载" },
"5785879225652927897": { "Alias": "RememberLoginModel.s2cViaFh4", "Dir": "s2c", "Note": "module_10283.js:768 fh4_1;2348 分支调 mh4(),无负载" },
"6160917066910786241": { "Alias": "AccountLoginModel.WrongPassword", "Dir": "s2c" },
"6180114602676583672": { "Alias": "RegistrationModel.s2cViaFhc", "Dir": "s2c", "Note": "module_10283.js:1119 fhc_1;2743 分支调 nhc(),无负载" },
"6941935892500153516": { "Alias": "AccountLoginModel.TwoFactorWrong", "Dir": "s2c" },
"7286120219821118707": { "Alias": "LoginCommand.SendTwoFactorCode", "Dir": "c2s" },
"7546805075905111829": { "Alias": "RegistrationModel.s2cViaAhc", "Dir": "s2c", "Note": "module_10283.js:1115 ahc_1;2730 分支读 1 个 long(reportUid?)" },
"7640916300855664666": { "Alias": "ModelService.LoadObjectsData", "Dir": "s2c" },
"7806260029063678398": { "Alias": "GarageDevicesCommands.LoadAvailableDevices", "Dir": "c2s", "Note": "【推测】module_85196 ba.lcr,空负载" },
"7889047569932192655": { "Alias": "RememberLoginModel.Remember2FaHash", "Dir": "s2c", "Note": "module_10283.js:769 gh4_1;2349 分支读串 \"_remember2FaHash_hashCodec\"" },
"7981625407112679465": { "Alias": "RememberLoginCommands.SendLoginViaHash", "Dir": "c2s" },
"8179162314198150518": { "Alias": "ModelService.SetModelCommandAlias", "Dir": "s2c" },
"8701688322991734706": { "Alias": "RememberLoginModel.s2cViaEh4", "Dir": "s2c", "Note": "module_10283.js:767 eh4_1;2347 分支调 lh4(),无负载" },
"9038756763225879447": { "Alias": "ModelService.UnloadObjects", "Dir": "s2c" }
},
"Spaces": {
"100045260": { "Alias": "Entrance", "Evidence": "握手帧同帧下发;入口空间" },
"100073070": { "Alias": "Garage", "Evidence": "button_click GARAGE 后真实服下发" },
"100072911": { "Alias": "Shop", "Evidence": "容器模式表 = Jj/ShopCategoryCC" },
"100045720": { "Alias": "Chat", "Evidence": "容器模式表 = _t/Kz/ChatCC/ClanChatCC/NewsShowingCC" },
"920007382011": { "Alias": "Queue", "Evidence": "登录后空间列表第 1 条(旧注释:快速战斗队列)" },
"100119080": { "Alias": "Unknown1", "Evidence": "登录后空间列表第 2 条,用途未证" },
"3074457345817190050": { "Alias": "Battle", "Evidence": "帧量极大(3551 条 s2c),疑似战斗,未证" }
}
}
+189
View File
@@ -0,0 +1,189 @@
{
"Labels": [
"relay-04"
],
"Sessions": 7,
"Messages": {
"c2s": 52,
"s2c": 142
},
"C2s": {
"1": {
"Name": "Hash Request",
"Count": 2,
"Bytes": 989,
"Nullmaps": {
"1": 2
},
"Samples": [
"0c046c616e671262726f777365725f757365725f6167656e74026f730b6465766963655f747970650b636c69656e745f",
"0e097265736f75726365730f636f6e6669672d74656d706c617465046c616e671262726f777365725f757365725f6167"
]
},
"3": {
"Name": "未知",
"Count": 5,
"Bytes": 200,
"Nullmaps": {
"1": 5
},
"Samples": [
"24262a4120f033289c56357945aa3147e92b219c49693abc5fac74a1a7db0f440000000005f691cc"
]
},
"32": {
"Name": "ClientLog",
"Count": 12,
"Bytes": 528,
"Nullmaps": {
"1": 12
},
"Samples": [
"000c636c69656e745f72656164790a666f7267655f74657374",
"07066c6f675f696e00",
"070b73637265656e5f73686f77177b2273637265656e223a224c6f6262792e486f6d65227d",
"070c627574746f6e5f636c69636b277b2273637265656e223a224c6f6262792e486f6d65222c22627574746f6e223a22"
]
},
"54": {
"Name": "未知",
"Count": 1,
"Bytes": 63,
"Nullmaps": {
"18": 1
},
"Samples": [
"587365c11a064227867f2fb8351d420d03127e727c6c0a7c363f3c27182a06032422233d5901033075593a7255140600"
]
}
},
"S2c": {
"0": {
"Name": "未知",
"Count": 6,
"Bytes": 348,
"Nullmaps": {
"1": 2,
"2": 2,
"4": 2
},
"Samples": [
"05f50346b5d1aa2f1777a363331480001ce175d2",
"00d607bfe194fc2cc1e8227642fbfb0072edd8000000000033e24d000000000000d65bbc67c8bffa3ee560580a58",
"00000005cbc19dbcd71d8823b15910070000aa8f0003d3",
"000000053ac19da2fac95f3ce41c903b1100000101066c6d9b70463de90000002975a37a120000000000000000000000"
]
},
"2": {
"Name": "Hash Response",
"Count": 2,
"Bytes": 84,
"Nullmaps": {
"1": 2
},
"Samples": [
"9b370d5d44364182da241393ad7899d5fd9ee844b88bc9c5c71c0fec4800829401200000000005f691cc"
]
},
"19": {
"Name": "未知",
"Count": 1,
"Bytes": 102,
"Nullmaps": {
"49": 1
},
"Samples": [
"6862f72a4d80001e07ae09b40000000541b62d17d09b9c9db514a9d6562a3180001eb4ae7db4000000051bb62d64e922"
]
},
"32": {
"Name": "Open Space",
"Count": 3,
"Bytes": 33,
"Nullmaps": {
"1": 3
},
"Samples": [
"000000d634b693fb",
"0000000005f6fdcf200000000005f69398"
]
},
"201": {
"Name": "未知",
"Count": 1,
"Bytes": 102,
"Nullmaps": {
"12": 1
},
"Samples": [
"5f4fe41c303b1100000000063f778a704668e90000002975a5c6300000000000000000000000001fafc07e8317631daf"
]
}
},
"ClientLogs": {
"client_ready": {
"Count": 1,
"Types": {
"0": 1
},
"S2": [
"forge_test"
]
},
"log_in": {
"Count": 1,
"Types": {
"7": 1
},
"S2": []
},
"screen_show": {
"Count": 7,
"Types": {
"7": 7
},
"S2": [
"{\"screen\":\"Lobby.Home\"}",
"{\"screen\":\"Lobby.Battles.Modes\"}",
"{\"screen\":\"Lobby.Containers\"}"
]
},
"button_click": {
"Count": 3,
"Types": {
"7": 3
},
"S2": [
"{\"screen\":\"Lobby.Home\",\"button\":\"PLAY\"}",
"{\"screen\":\"Lobby.Home\",\"button\":\"BattlePick.MODES\"}",
"{\"screen\":\"Lobby.Home\",\"button\":\"CONTAINERS\",\"active\":true}"
]
}
},
"Other": {
"c2s": {
"Count": 29,
"Bytes": 656,
"Samples": [
"relay-04/session_003/c2s_0001.bin (23B) 8c15a40000000005e32d788daa27d073",
"relay-04/session_003/c2s_0002.bin (30B) 911c360000000005f75b3aba607c9dcf",
"relay-04/session_003/c2s_0003.bin (27B) 6f196f000000000582943a592dbef8e5",
"relay-04/session_003/c2s_0004.bin (22B) 5414640000000005005a66bdff3dfbb6",
"relay-04/session_003/c2s_0005.bin (20B) 38126300000000053a93de24076f731c",
"relay-04/session_003/c2s_0007.bin (19B) 411165000000000581d2aa43001b5d2f"
]
},
"s2c": {
"Count": 36,
"Bytes": 286270,
"Samples": [
"relay-04/session_003/s2c_0000.bin (915B) 9852b3e486059c6f6f5dcf3403d4f82f",
"relay-04/session_003/s2c_0001.bin (468B) d589231cf90000000539aa32b8350aeb",
"relay-04/session_004/s2c_0000.bin (129B) 497f71000000d675d7c284bf74bf7da3",
"relay-04/session_004/s2c_0001.bin (338B) 015172e3000000d64d941037f93d07e5",
"relay-04/session_004/s2c_0003.bin (3B) 9b01db",
"relay-04/session_005/s2c_0000.bin (1811B) 5a3455e4a887686d03c1515cdf22de1f"
]
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"Note": "由 decrypt_frames.js 暴力穷举单字节密钥后统计,仅包含完全自洽的会话方向",
"Keys": {
"garage-01/session_005 c2s": {
"K": 27,
"Start": 1,
"Perfect": true
},
"garage-01/session_005 s2c": {
"K": 27,
"Start": 0,
"Perfect": false
}
},
"C2s": {
"0": {
"Name": "未知",
"Count": 6,
"Bytes": 113,
"Sample": "0000000005f6fdcf19368b1c6aca581400000001"
},
"3": {
"Name": "Auth/EnterSpace(待证)",
"Count": 1,
"Bytes": 41,
"Sample": "033230f0cd76470519bd4eb2fc147fcc68c32acd4359fd4c"
},
"42": {
"Name": "未知",
"Count": 18,
"Bytes": 364,
"Sample": "2aaaaaaab685cb64793bf2d473c51fc4d20a7678"
},
"255": {
"Name": "未知",
"Count": 5,
"Bytes": 80,
"Sample": "ffffffff800004e32316b154734544a9"
}
},
"S2c": {}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18690
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"handles":{"ModelReg":{"ctor":"mr","keys":["s7n_1","t7n_1","u7n_1","v7n_1","w7n_1"]},"SpaceReg":{"ctor":"or","keys":["x7m_1","y7m_1","w7m_1"]},"Conn":{"ctor":"ur","keys":["l7m_1","m7m_1","n7m_1","o7m_1","p7m_1","q7m_1"]}},"maps":{"ModelReg.s7n_1":{"kind":"MapLike","size":517,"entries":[{"key":"_t(j7j_1={obj},k7j_1={obj},l7j_1={obj},u1wc_1={obj},v1wc_1={obj},x1wc_1={obj})","ctor":"_t","keys":["j7j_1","k7j_1","l7j_1","u1wc_1","v1wc_1","x1wc_1","y1wc_1","a1wd_1","c1wd_1","e1wd_1","w1wc_1","z1wc_1","b1wd_1","d1wd_1","f1wd_1","g1wd_1","h1we_1","i1we_1"],"head":"function _t() { (b.call(this), (this.h1we_1 = new $(m(q), null)), (this.i1we_1 = new $(m(j), null))); }","text":""},{"key":"Yz(j7j_1={obj},k7j_1={obj},l7j_1={obj},of2_1={obj},pf2_1={obj},rf2_1={obj})","ctor":"Yz","keys":["j7j_1","k7j_1","l7j_1","of2_1","pf2_1","rf2_1","qf2_1","sf2_1","fwp_1"],"head":"function Yz(t) { (qc.call(this), (this.fwp_1 = t)); }","text":""},{"key":"u$(j7j_1={obj},k7j_1={obj},l7j_1={obj},tb5_1={obj},ub5_1={obj},wb5_1={obj})","ctor":"u$","keys":["j7j_1","k7j_1","l7j_1","tb5_1","ub5_1","wb5_1","vb5_1","xb5_1","cvd_1"],"head":"function u$(t) { (zt.call(this), (this.cvd_1 = t)); }","text":""},{"key":"DE(j7j_1={obj},k7j_1={obj},l7j_1={obj},wbi_1={obj},xbi_1={obj},zbi_1={obj})","ctor":"DE","keys":["j7j_1","k7j_1","l7j_1","wbi_1","xbi_1","zbi_1","abj_1","ybi_1","bbj_1","cbj_1","rxq_1"],"head":"function DE(t) { (Fg.call(this), (this.rxq_1 = t)); }","text":""},{"key":"MC(j7j_1={obj},k7j_1={obj},l7j_1={obj},gem_1={obj},hem_1={obj},jem_1={obj})","ctor":"MC","keys":["j7j_1","k7j_1","l7j_1","gem_1","hem_1","jem_1","kem_1","iem_1","lem_1","mem_1","wx7_1"],"head":"function MC(t) { (Dv.call(this), (this.wx7_1 = t)); }","text":""},{"key":"bx(j7j_1={obj},k7j_1={obj},l7j_1={obj},xez_1={obj},yez_1={obj},af0_1={obj})","ctor":"bx","keys":["j7j_1","k7j_1","l7j_1","xez_1","yez_1","af0_1","bf0_1","df0_1","zez_1","cf0_1","ef0_1","ff0_1","bw2_1"],"head":"function bx(t) { (Ms.call(this), (this.bw2_1 = t)); }","text":""},{"key":"px(j7j_1={obj},k7j_1={obj},l7j_1={obj},ndc_1={obj},odc_1={obj},qdc_1={obj})","ctor":"px","keys":["j7j_1","k7j_1","l7j_1","ndc_1","odc_1","qdc_1","pdc_1","rdc_1"],"head":"function px() { qs.call(this); }","text":""},{"key":"GI(j7j_1={obj},k7j_1={obj},l7j_1={obj},cfw_1={obj},dfw_1={obj},ffw_1={obj})","ctor":"GI","keys":["j7j_1","k7j_1","l7j_1","cfw_1","dfw_1","ffw_1","gfw_1","efw_1","hfw_1","ifw_1","oyi_1"],"head":"function GI(t) { (lq.call(this), (this.oyi_1 = t)); }","text":""},{"key":"WC(j7j_1={obj},k7j_1={obj},l7j_1={obj},thr_1={obj},uhr_1={obj},whr_1={obj})","ctor":"WC","keys":["j7j_1","k7j_1","l7j_1","thr_1","uhr_1","whr_1","xhr_1","yhr_1","zhr_1","ahs_1","bhs_1","chs_1","vhr_1","dhs_1","ehs_1","zx8_1"],"head":"function WC() { (Yv.call(this), (this.zx8_1 = new Q(Z(J), null))); }","text":""},{"key":"FE(j7j_1={obj},k7j_1={obj},l7j_1={obj},len_1={obj},men_1={obj},oen_1={obj})","ctor":"FE","keys":["j7j_1","k7j_1","l7j_1","len_1","men_1","oen_1","pen_1","ren_1","uen_1","wen_1","yen_1","aeo_1","ceo_1","eeo_1","nen_1","qen_1","sen_1","ten_1","ven_1","xen_1","zen_1","beo_1","deo_1","feo_1","geo_1","cxs_1","dxs_1","exs_1"],"head":"function FE(t) { (Qg.call(this), (this.cxs_1 = t), (this.dxs_1 = new Q(Z(XE), null)), (this.exs_1 = new Q(Z(Gh), null))); }","text":""},{"key":"Iz(j7j_1={obj},k7j_1={obj},l7j_1={obj},kdk_1={obj},ldk_1={obj},ndk_1={obj})","ctor":"Iz","keys":["j7j_1","k7j_1","l7j_1","kdk_1","ldk_1","ndk_1","odk_1","qdk_1","sdk_1","mdk_1","pdk_1","rdk_1","tdk_1","udk_1","mwj_1"],"head":"function Iz(t) { (jh.call(this), (this.mwj_1 = t)); }","text":""},{"key":"pz(j7j_1={obj},k7j_1={obj},l7j_1={obj},dep_1={obj},eep_1={obj},gep_1={obj})","ctor":"pz","keys":["j7j_1","k7j_1","l7j_1","dep_1","eep_1","gep_1","hep_1","fep_1","iep_1","jep_1","owd_1"],"head":"function pz(t) { (ka.call(this), (this.owd_1 = t)); }","text":""},{"key":"ZC(j7j_1={obj},k7j_1={obj},l7j_1={obj},ehr_1={obj},fhr_1={obj},hhr_1={obj})","ctor":"ZC","keys":["j7j_1","k7j_1","l7j_1","ehr_1","fhr_1","hhr_1","ihr_1","jhr_1","ghr_1","khr_1","lhr_1","txa_1"],"head":"function ZC() { (fw.call(this), (this.txa_1 = new Q(Z(J), null))); }","text":""},{"key":"H$(j7j_1={obj},k7j_1={obj},l7j_1={obj},lgv_1={obj},mgv_1={obj},ogv_1={obj})","ctor":"H$","keys":["j7j_1","k7j_1","l7j_1","lgv_1","mgv_1","ogv_1","pgv_1","rgv_1","tgv_1","ngv_1","qgv_1","sgv_1","ugv_1","vgv_1","dvp_1"],"head":"function H$() { (rr.call(this), (this.dvp_1 = new Q(Z(J), null))); }","text":""},{"key":"eS(j7j_1={obj},k7j_1={obj},l7j_1={obj},efb_1={obj},ffb_1={obj},hfb_1={obj})","ctor":"eS","keys":["j7j_1","k7j_1","l7j_1","efb_1","ffb_1","hfb_1","gfb_1","ifb_1"],"head":"function eS() { Nc.call(this); }","text":""},{"key":"lj(j7j_1={obj},k7j_1={obj},l7j_1={obj},pcr_1={obj},qcr_1={obj},scr_1={obj})","ctor":"lj","keys":["j7j_1","k7j_1","l7j_1","pcr_1","qcr_1","scr_1","rcr_1","tcr_1"],"head":"function lj() { Qr.call(this); }","text":""},{"key":"cj(j7j_1={obj},k7j_1={obj},l7j_1={obj},uck_1={obj},vck_1={obj},xck_1={obj})","cLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
[{"t":1789541983365,"cls":"Mi","text":"[HashRequestCommand id=1, name=Hash request, parameterNames=[hornet, resources, config-template, lang, browser_user_agent, os, device_type, client_type, hardware_concurrency, time_zone, video_card, mobile_device, touchscreen_support, client_version, ram_size], parameterValues=[1, http://localhost:8080/resources, http://localhost:8080/config.xml, cn, 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, Win32, Desktop, BROWSER, 16, Asia/Shanghai, ANGLE (AMD, AMD Radeon(TM) 860M Graphics (0x00001114) Direct3D11 vs_5_0 ps_5_0, D3D11), false, false, 83fabafa6dc46cc3940ff1fd9aee94f3b8752bb1, 32768]]","len":572,"hex":"023a00010f06686f726e6574097265736f75726365730f636f6e6669672d74656d706c617465046c616e671262726f777365725f757365725f6167656e74026f730b6465766963655f747970650b636c69656e745f747970651468617264776172655f636f6e63757272656e63790974696d655f7a6f6e650a766964656f5f636172640d6d6f62696c655f64657669636513746f75636873637265656e5f737570706f72740e636c69656e745f76657273696f6e0872616d5f73697a650f01311f687474703a2f2f6c6f63616c686f73743a383038302f7265736f757263657320687474703a2f2f6c6f63616c686f73743a383038302f636f6e6669672e786d6c02636e7d4d6f7a696c6c612f352e30202857696e646f7773204e542031302e303b2057696e36343b2078363429204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f3135322e302e302e30205361666172692f3533372e3336204564672f3135322e302e302e300557696e3332074465736b746f700742524f575345520231360d417369612f5368616e6768616957414e474c452028414d442c20414d4420526164656f6e28544d2920203836304d2047726170686963732028307830303030313131342920446972656374334431312076735f355f302070735f355f302c204433443131290566616c73650566616c73652838336661626166613664633436636333393430666631666439616565393466336238373532626231053332373638"},{"t":1789541984609,"cls":"Oi","text":"[SpaceOpenedCommand spaceId=100045260 hash=[object Object]]","len":44,"hex":"002a0003e0ddbed02dddf55864f4944ed900fb70693cd89f06a94bf020e9e3305885cd530000000005f691cc"},{"t":1789541986387,"cls":"Ni","text":"SpaceCommand(objectId=100045260, methodId=1816792453857564692, protocolBuffer=\n=== Optional Map ===\nreadPosition: 0 size: 0 mask:[object Object])","len":23,"hex":"b015980000000005e31144b1961bd07372324c1c000001"},{"t":1789541986458,"cls":"Ni","text":"SpaceCommand(objectId=100045260, methodId=3559444790692169462, protocolBuffer=\n=== Optional Map ===\nreadPosition: 0 size: 0 mask:[object Object])","len":30,"hex":"911c360000000005f75b3aba607c9dcf24ece247386dfe7f0d654507a118"},{"t":1789541986464,"cls":"Ni","text":"SpaceCommand(objectId=100045260, methodId=3048017108161529684, protocolBuffer=\n=== Optional Map ===\nreadPosition: 0 size: 0 mask:[object Object])","len":27,"hex":"6f196f000000000582943a592dbef8e5214f18918380001cafccd2"},{"t":1789541986466,"cls":"Ni","text":"SpaceCommand(objectId=100045260, methodId=4091278427760769105, protocolBuffer=\n=== Optional Map ===\nreadPosition: 0 size: 0 mask:[object Object])","len":22,"hex":"5414640000000005005a66bdff3dfbb677356916ec69"},{"t":1789541986468,"cls":"Ni","text":"SpaceCommand(objectId=100045260, methodId=2045663294076292, protocolBuffer=\n=== Optional Map ===\nreadPosition: 0 size: 0 mask:[object Object])","len":20,"hex":"38126300000000053a93de24076f731cbd2c01f9"},{"t":1789541986468,"cls":"Ni","text":"SpaceCommand(objectId=100045260, methodId=7981625407112679465, protocolBuffer=\n=== Optional Map ===\nreadPosition: 0 size: 0 mask:[object Object])","len":84,"hex":"0052910000000005f1a49eeac1ddc89fc9d4398c36587365c11a064227867f2fb8351d420d03127e727c6c0a7c363f3c27182a06032422233d5901033075593a72551406004424240860000d24137e3e5226532a"},{"t":1789541986512,"cls":"Ni","text":"SpaceCommand(objectId=100045260, methodId=3553092084033962840, protocolBuffer=\n=== Optional Map ===\nreadPosition: 0 size: 0 mask:[object Object])","len":19,"hex":"411165000000000581d2aa43001b5d2f09dbbb"},{"t":1789541986614,"cls":"Oi","text":"[SpaceOpenedCommand spaceId=920007382011 hash=[object Object]]","len":44,"hex":"002a0003e0ddbed02dddf55864f4944ed900fb70693cd89f06a94bf020e9e3305885cd53000000d634b693fb"},{"t":1789541986770,"cls":"Ni","text":"SpaceCommand(objectId=920007382011, methodId=1816792453857564692, protocolBuffer=\n=== Optional Map ===\nreadPosition: 0 size: 0 mask:[object Object])","len":23,"hex":"22150a000000d60eacb9c98a2399ca73fe324c1c000001"},{"t":1789541987636,"cls":"Oi","text":"[SpaceOpenedCommand spaceId=100119080 hash=[object Object]]","len":44,"hex":"002a0003e0ddbed02dddf55864f4944ed900fb70693cd89f06a94bf020e9e3305885cd530000000005f7b228"},{"t":1789541988815,"cls":"Bi","text":"LogCommand(level=ANALYTICS, messageType=log_in, message=)","len":13,"hex":"000b002007066c6f675f696e00"},{"t":1789541989632,"cls":"Oi","text":"[SpaceOpenedCommand spaceId=100072911 hash=[object Object]]","len":44,"hex":"002a0003e0ddbed02dddf55864f4944ed900fb70693cd89f06a94bf020e9e3305885cd530000000005f6fdcf"},{"t":1Line truncated
+759
View File
@@ -0,0 +1,759 @@
{
"SpaceId": "100045260",
"Note": "入口空间认证后首批 s2c;结构由我们的编码器重写,不存原始字节(除不能解析的记录)",
"Frames": [
{
"File": "s2c_0000.bin",
"SpaceId": "100045260",
"Records": [
{
"ObjectId": "100045260",
"MethodId": "3216143066888387731",
"Layout": "zt",
"Value": {
"X": 1,
"Batches": [
{
"ObjectId": "100044933",
"ModelIds": [
"821863604528384663",
"2108103923322474513",
"4593246568620992239",
"7838366943662257567",
"7029434995040841158",
"6791702738669939115",
"3989435919503695016",
"1435989843446835228",
"1223644484523767160",
"2951079444907754024",
"466296741210741928",
"9117714962311439886",
"2037623193005108804",
"7384979261462408242",
"7138983621201379368",
"2404946944681207165",
"766378817905541412",
"5457907984000701384",
"513176302374590130",
"9208992626215411416",
"6021121731930682870",
"2474458842977623992",
"7300500077035374704",
"4065909569861626318",
"7603023820150538809",
"2642146783969719957",
"7972367263889240147",
"2861934860141556490",
"4862526161024984254",
"4969430308539551271"
]
}
],
"Items": [
{
"R": "1785252764738",
"E": 10,
"U": 10,
"S": "1785252788259",
"O": 1,
"Skipped": []
},
{
"R": "1788178032155",
"E": 10,
"U": 10,
"S": "1788178082366",
"O": 1,
"Skipped": []
},
{
"R": "1729656156831",
"E": 4,
"U": 41,
"S": "1748862483376",
"O": 0,
"Skipped": []
},
{
"R": "1736499734814",
"E": 4,
"U": 41,
"S": "1748862483716",
"O": 0,
"Skipped": []
},
{
"R": "1736499783945",
"E": 4,
"U": 41,
"S": "1748862483453",
"O": 0,
"Skipped": []
},
{
"R": "1729656356669",
"E": 4,
"U": 41,
"S": "1748862483921",
"O": 0,
"Skipped": []
},
{
"R": "1729656445578",
"E": 4,
"U": 41,
"S": "1748862483758",
"O": 0,
"Skipped": []
},
{
"R": "1776112514112",
"E": 4,
"U": 41,
"S": "1776163388799",
"O": 0,
"Skipped": []
},
{
"R": "1776112828842",
"E": 4,
"U": 41,
"S": "1776113718936",
"O": 0,
"Skipped": []
},
{
"R": "1751515246652",
"E": 10,
"U": 10,
"S": "1782124838483",
"O": 0,
"Skipped": []
},
{
"R": "1760696339741",
"E": 10,
"U": 10,
"S": "1782124871319",
"O": 0,
"Skipped": []
},
{
"R": "1760695178806",
"E": 10,
"U": 10,
"S": "1782124894174",
"O": 0,
"Skipped": []
},
{
"R": "1657530119558",
"E": 17,
"U": 0,
"S": "1775743437165",
"O": 0,
"Skipped": []
},
{
"R": "500254565",
"E": 9,
"U": 0,
"S": "1775743167446",
"O": 0,
"Skipped": []
},
{
"R": "1667192182950",
"E": 10,
"U": 14,
"S": "1667192200957",
"O": 1,
"Skipped": []
},
{
"R": "1700125403626",
"E": 26,
"U": 0,
"S": "1754292774814",
"O": 0,
"Skipped": []
},
{
"R": "1667192182947",
"E": 10,
"U": 14,
"S": "1667192210031",
"O": 1,
"Skipped": []
},
{
"R": "1700125403594",
"E": 26,
"U": 0,
"S": "1754292647034",
"O": 0,
"Skipped": []
},
{
"R": "1667192182906",
"E": 10,
"U": 14,
"S": "1667192200909",
"O": 1,
"Skipped": []
},
{
"R": "1700125403585",
"E": 26,
"U": 0,
"S": "1754292813746",
"O": 0,
"Skipped": []
},
{
"R": "1667192182931",
"E": 10,
"U": 14,
"S": "1667192201203",
"O": 1,
"Skipped": []
},
{
"R": "1700125403616",
"E": 26,
"U": 0,
"S": "1754292676772",
"O": 0,
"Skipped": []
},
{
"R": "1668156934932",
"E": 14,
"U": 0,
"S": "1743588257131",
"O": 1,
"Skipped": []
},
{
"R": "1690957048451",
"E": 26,
"U": 0,
"S": "1754292702376",
"O": 1,
"Skipped": []
},
{
"R": "1667192182930",
"E": 10,
"U": 14,
"S": "1667192201120",
"O": 1,
"Skipped": []
},
{
"R": "1700125403612",
"E": 26,
"U": 0,
"S": "1754292751243",
"O": 0,
"Skipped": []
},
{
"R": "1667192182923",
"E": 10,
"U": 14,
"S": "1667192200492",
"O": 1,
"Skipped": []
},
{
"R": "1700125403610",
"E": 26,
"U": 0,
"S": "1754292798606",
"O": 0,
"Skipped": []
},
{
"R": "1668156934933",
"E": 14,
"U": 0,
"S": "1743588263527",
"O": 1,
"Skipped": []
},
{
"R": "1690956905761",
"E": 26,
"U": 0,
"S": "1754292631584",
"O": 1,
"Skipped": []
},
{
"R": "1668156934930",
"E": 14,
"U": 0,
"S": "1743588238751",
"O": 1,
"Skipped": []
},
{
"R": "1690957167393",
"E": 26,
"U": 0,
"S": "1754292820911",
"O": 1,
"Skipped": []
},
{
"R": "1667192182948",
"E": 10,
"U": 14,
"S": "1667192200752",
"O": 1,
"Skipped": []
},
{
"R": "1700125403609",
"E": 26,
"U": 0,
"S": "1754292744098",
"O": 0,
"Skipped": []
},
{
"R": "1668156934931",
"E": 14,
"U": 0,
"S": "1743588245180",
"O": 1,
"Skipped": []
},
{
"R": "1690956941928",
"E": 26,
"U": 0,
"S": "1754292638747",
"O": 1,
"Skipped": []
},
{
"R": "1667192182922",
"E": 10,
"U": 14,
"S": "1667192210040",
"O": 1,
"Skipped": []
},
{
"R": "1700125403613",
"E": 26,
"U": 0,
"S": "1754292767902",
"O": 0,
"Skipped": []
},
{
"R": "1667192182952",
"E": 10,
"U": 14,
"S": "1667192200685",
"O": 1,
"Skipped": []
},
{
"R": "1700125403614",
"E": 26,
"U": 0,
"S": "1754292658045",
"O": 0,
"Skipped": []
},
{
"R": "1667192182924",
"E": 10,
"U": 14,
"S": "1667192200526",
"O": 1,
"Skipped": []
},
{
"R": "1700125403596",
"E": 26,
"U": 0,
"S": "1754292710221",
"O": 0,
"Skipped": []
},
{
"R": "1667192182951",
"E": 10,
"U": 14,
"S": "1667192200699",
"O": 1,
"Skipped": []
},
{
"R": "1700125403600",
"E": 26,
"U": 0,
"S": "1754292828444",
"O": 0,
"Skipped": []
},
{
"R": "1667192182936",
"E": 10,
"U": 14,
"S": "1667192200552",
"O": 1,
"Skipped": []
},
{
"R": "1700125403617",
"E": 26,
"U": 0,
"S": "1754292717324",
"O": 0,
"Skipped": []
},
{
"R": "1667192182946",
"E": 10,
"U": 14,
"S": "1667192201068",
"O": 1,
"Skipped": []
},
{
"R": "1700125403611",
"E": 26,
"U": 0,
"S": "1754292783068",
"O": 0,
"Skipped": []
},
{
"R": "1667192182934",
"E": 10,
"U": 14,
"S": "1667192200909",
"O": 1,
"Skipped": []
},
{
"R": "1700125403618",
"E": 26,
"U": 0,
"S": "1754292668640",
"O": 1,
"Skipped": []
},
{
"R": "1667192182945",
"E": 10,
"U": 14,
"S": "1667192201049",
"O": 1,
"Skipped": []
},
{
"R": "1700125403599",
"E": 26,
"U": 0,
"S": "1754292790522",
"O": 0,
"Skipped": []
},
{
"R": "1667192182929",
"E": 10,
"U": 14,
"S": "1667192201116",
"O": 1,
"Skipped": []
},
{
"R": "1700125403615",
"E": 26,
"U": 0,
"S": "1754292758635",
"O": 0,
"Skipped": []
},
{
"R": "1769060639722",
"E": 10,
"U": 14,
"S": "1769061204432",
"O": 1,
"Skipped": []
},
{
"R": "1668156934917",
"E": 14,
"U": 0,
"S": "1743588281787",
"O": 1,
"Skipped": []
},
{
"R": "1690957195222",
"E": 26,
"U": 0,
"S": "1754292837247",
"O": 1,
"Skipped": []
},
{
"R": "1668156934934",
"E": 14,
"U": 0,
"S": "1743588209731",
"O": 1,
"Skipped": []
},
{
"R": "1690957017359",
"E": 26,
"U": 0,
"S": "1754292694736",
"O": 1,
"Skipped": []
},
{
"R": "1668156934935",
"E": 14,
"U": 0,
"S": "1743588269079",
"O": 1,
"Skipped": []
},
{
"R": "1690957139048",
"E": 26,
"U": 0,
"S": "1754292806444",
"O": 1,
"Skipped": []
},
{
"R": "1668156934929",
"E": 14,
"U": 0,
"S": "1743588231680",
"O": 1,
"Skipped": []
},
{
"R": "1690957082056",
"E": 26,
"U": 0,
"S": "1754292726062",
"O": 1,
"Skipped": []
},
{
"R": "1668156934925",
"E": 14,
"U": 0,
"S": "1743588290812",
"O": 1,
"Skipped": []
},
{
"R": "1690956768056",
"E": 26,
"U": 0,
"S": "1754292622624",
"O": 1,
"Skipped": []
},
{
"R": "1668156934927",
"E": 14,
"U": 0,
"S": "1743588276625",
"O": 1,
"Skipped": []
},
{
"R": "1690956986283",
"E": 26,
"U": 0,
"S": "1754292685618",
"O": 1,
"Skipped": []
},
{
"R": "1668156934926",
"E": 14,
"U": 0,
"S": "1743588251643",
"O": 1,
"Skipped": []
},
{
"R": "1690957108704",
"E": 26,
"U": 0,
"S": "1754292733933",
"O": 1,
"Skipped": []
}
]
}
}
],
"DelayMs": 0,
"NullmapHex": "00"
},
{
"File": "s2c_0001.bin",
"SpaceId": "100045260",
"Records": [
{
"ObjectId": "100045260",
"MethodId": "7640916300855664666",
"Layout": "pt",
"Value": {
"Objects": [
{
"ObjectId": "100045260",
"GameTypeId": "100044933"
}
],
"Items": [
{
"Id": "0",
"Value": "100045260"
},
{
"Id": "2108103923322474513",
"Value": {
"clientKey": "6LfNI_srAAAAACptxRSLfhdOMuyZuYJq1d5khles",
"stateWithCaptcha": [],
"useFake": false
}
},
{
"Id": "7838366943662257567",
"Value": {
"realNameCheckEnabled": true
}
},
{
"Id": "6791702738669939115",
"Value": {
"background": "1788178032155",
"endDate": "1790283600000",
"startDate": "1788469200000"
}
},
{
"Id": "466296741210741928",
"Value": {
"socialNetworkParams": [
{
"clientId": "558265767576435",
"enabled": false,
"snId": "facebook"
},
{
"clientId": "156495950351-9vvpk8ubf4udrdbrvsma33n01gqm484d.apps.googleusercontent.com",
"enabled": false,
"snId": "google"
},
{
"clientId": "com.3dtank.mobile.production",
"enabled": true,
"snId": "apple"
},
{
"clientId": "53596836",
"enabled": false,
"snId": "vkontakte"
}
]
}
},
{
"Id": "9117714962311439886",
"Value": {
"enabled": false
}
},
{
"Id": "5457907984000701384",
"Value": {
"disableRegistrationMessage": null,
"disableShopMessage": null,
"homeScreenMessage": null
}
},
{
"Id": "513176302374590130",
"Value": {
"forceSocialNetwork": null
}
},
{
"Id": "2474458842977623992",
"Value": {
"disabled": false,
"enableRequiredEmail": false,
"(匿名#3)": 20,
"(匿名#4)": 8,
"passwordNeedDigit": true,
"passwordNeedLower": true,
"passwordNeedUpper": true
}
},
{
"Id": "7300500077035374704",
"Value": {
"entranceBackgrounds": [
"1751515246652",
"1760696339741",
"1760695178806"
],
"entranceSound": "1729656156831",
"garageSound": "1736499783945",
"lobbySound": "1736499734814",
"proBattlesLimitSound": "1776112828842",
"proBattlesSound": "1776112514112",
"shopSound": "1729656356669",
"tankBuildSound": "1729656445578"
}
},
{
"Id": "4065909569861626318",
"Value": {
"serverHalt": false
}
}
]
}
}
],
"DelayMs": 455,
"NullmapHex": "203c"
},
{
"File": "s2c_0002.bin",
"SpaceId": "100045260",
"Records": [
{
"ObjectId": "100045260",
"MethodId": "2025886178375810581",
"Fields": {
"(匿名#1)": "3074457345817168740"
}
}
],
"DelayMs": 660,
"NullmapHex": "00"
}
]
}
+216
View File
@@ -0,0 +1,216 @@
{
"SpaceId": "100045720",
"Note": "结构化初始化模板:容器存对象树(发送时重新编码),解不动的记录存 hex;nullmap 原样",
"Frames": [
{
"File": "s2c_0000.bin",
"SpaceId": "100045720",
"NullmapHex": "00",
"DelayMs": 0,
"Compressed": true,
"Records": [
{
"ObjectId": "100045720",
"MethodId": "3216143066888387731",
"Layout": "zt",
"Value": {
"X": 0,
"Batches": [
{
"ObjectId": "100045311",
"ModelIds": [
"821863604528384663",
"3650502766592528965",
"6071565290933648049",
"891137447706578619",
"868364325972433765"
]
}
],
"Items": []
}
},
{
"ObjectId": "100045720",
"MethodId": "7640916300855664666",
"Layout": "pt",
"Value": {
"Objects": [
{
"ObjectId": "100045720",
"GameTypeId": "100045311"
}
],
"Items": [
{
"Id": "0",
"Value": "100045720"
},
{
"Id": "6071565290933648049",
"Value": {
"admin": false,
"antifloodEnabled": true,
"(匿名#3)": 60,
"channels": [
{
"name": "默认",
"sid": "默认"
}
],
"chatEnabled": true,
"chatModeratorLevel": "enum#0",
"hashTags": [
{
"tag": "help",
"text": "Help",
"url": "https://en.tankiwiki.com/Technical_support"
},
{
"tag": "rules",
"text": "Rules",
"url": "http://tankionline.com/en/rules/"
},
{
"tag": "plans",
"text": "Developers’ missions and plans",
"url": "http://en.tankiwiki.com/Development_Plans"
},
{
"tag": "ranks",
"text": "Ranks",
"url": "https://en.tankiwiki.com/Ranks_and_Experience"
},
{
"tag": "clans",
"text": "Clans",
"url": "http://en.tankiforum.com/index.php?showforum=42"
},
{
"tag": "forum",
"text": "Forum",
"url": "http://en.tankiforum.com/"
},
{
"tag": "updates",
"text": "Updates",
"url": "http://en.tankiforum.com/index.php?showforum=246"
},
{
"tag": "theft",
"text": "What if my tank is stolen",
"url": "https://en.tankiwiki.com/What_to_do_if_my_account_has_been_stolen%3F"
},
{
"tag": "feedback",
"text": "Feedback",
"url": "http://en.tankiforum.com/index.php?showforum=182"
},
{
"tag": "nick",
"text": "Change nick",
"url": "https://en.tankiwiki.com/Nicknames"
},
{
"tag": "news",
"text": "News",
"url": "https://tankionline.com/en/home/"
},
{
"tag": "teams",
"text": "Teams",
"url": "http://en.tankiforum.com/index.php?showforum=123"
},
{
"tag": "wiki",
"text": "Encyclopedia of Game",
"url": "http://en.tankiwiki.com/Main_Page"
},
{
"tag": "apply",
"text": "Apply",
"url": "https://helper.tankionline.com"
},
{
"tag": "unban",
"text": "Appeal the ban",
"url": "https://pages.tankionline.com/forms/unban?lang=en"
}
],
"linksWhiteList": [
"tankionline.com",
"alternativaplatform.com",
"3dtank.com",
"test.3dtank.com",
"forum.3dtank.com",
"test.tankionline.com",
"www.tankionline.com",
"test2.tankionline.com",
"ru.tankiforum.com",
"de.tankiforum.com",
"en.tankiforum.com",
"tankiforum.com",
"ru.tankiwiki.com",
"en.tankiwiki.com",
"de.tankiwiki.com",
"tankix.com",
"www.tankix.com",
"ratings.tankionline.com",
"pl.tankiwiki.com",
"pl.tankiforum.com",
"codes.tankionline.com",
"www.youtube.com",
"prnt.sc",
"tankisport.com",
"alternativa.games",
"helper.tankionline.com",
"tap.tankionline.com",
"pages.tankionline.com",
"tankianalytics.com",
"tankireward.com"
],
"(匿名#9)": 60,
"(匿名#10)": 5,
"privateMessagesEnabled": true,
"selfName": "WYT2WYT",
"showLinks": true,
"typingSpeedAntifloodEnabled": true
}
},
{
"Id": "891137447706578619",
"Value": {
"inClan": false,
"selfName": "WYT2WYT"
}
},
{
"Id": "868364325972433765",
"Value": {
"newsItems": [
{
"(匿名#1)": 1780119226,
"description": "现在购买“每日红宝石通行证”,以1700坦克币的超值价格,获得海量红宝石!\n💎 超值内容\n售价:1700坦克币\n立得:500红宝石\n每日领:登录即领150红宝石/天,持续30天(共4500)\n总计获得:5000红宝石",
"(匿名#3)": 0,
"header": "《3D坦克》每日红宝石通行证(月卡)上架啦!",
"(匿名#5)": "3074457345813528604",
"imageUrl": "https://tankionline.com/s/icons/icon_news.png"
},
{
"(匿名#1)": 1743454800,
"description": "<strong>信息安全防骗建议和异常行为举报!</strong><u>请勿相信任何中奖信息、赠送坦克币、进YY送首充等欺骗信息!官方也不支持游戏内任何形式的交易、换号、外借账号的行为,也不要点击不知名的游戏内外部跳转链接,也不要开启使用不知名第三方应用程序,避免造成不必要的损失。如在战场内发现异常行为,请提供相关完整视频到邮箱举报。描述相关坦克名称,具体发生的问题,以便专员核实查看具体情况!</u>",
"(匿名#3)": 0,
"header": "信息安全防骗建议和异常行为举报",
"(匿名#5)": "3074457345799588604",
"imageUrl": "https://tankionline.com/s/icons/icon_news.png"
}
]
}
}
]
}
}
]
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+671
View File
@@ -0,0 +1,671 @@
{
"SpaceId": "100119080",
"Note": "结构化初始化模板:容器存对象树(发送时重新编码),解不动的记录存 hex;nullmap 原样",
"Frames": [
{
"File": "s2c_0000.bin",
"SpaceId": "100119080",
"NullmapHex": "66001080",
"DelayMs": 0,
"Compressed": true,
"Records": [
{
"ObjectId": "100119080",
"MethodId": "3216143066888387731",
"Layout": "zt",
"Value": {
"X": 0,
"Batches": [
{
"ObjectId": "100109410",
"ModelIds": [
"821863604528384663",
"7185560430911351668",
"9195251661754137714",
"7949064601138261608",
"8841505497388123861",
"2108103923322474513",
"5255782026414758079",
"6363628754704336051",
"2795796318435010995",
"6847548337746045159",
"7175193612424807833",
"3107451137018937608",
"5427664491088584235",
"6403067749608679876",
"1850509145998626828",
"3885077615914307071",
"2009560714711279199",
"1298753286651761171",
"258682513334072065",
"4255726087158005051",
"7019981464353739346",
"3527205014716342329",
"6492191529573968679",
"6627517808336136295"
]
}
],
"Items": []
}
},
{
"ObjectId": "100119080",
"MethodId": "7640916300855664666",
"Layout": "pt",
"Value": {
"Objects": [
{
"ObjectId": "100119080",
"GameTypeId": "100109410"
}
],
"Items": [
{
"Id": "0",
"Value": "100119080"
},
{
"Id": "7185560430911351668",
"Value": {
"formats": [
{
"description": "在此模式下,规则由你制定。创建对战,任意调校所有设置;结交好友,追猎敌人。无论你热衷战斗还是交流,选择权都在你手中。",
"formatId": "NO_FORMAT",
"name": "专家战场",
"optionsDefaults": [
"enum#6",
"enum#10",
"enum#14",
"enum#13",
"enum#8",
"enum#16",
"enum#3",
"enum#11",
"enum#1",
"enum#2",
"enum#9",
"enum#15"
],
"optionsToShow": [
"enum#6",
"enum#10",
"enum#8",
"enum#7",
"enum#11",
"enum#1",
"enum#2",
"enum#9",
"enum#12",
"enum#5",
"enum#13",
"enum#16",
"enum#4",
"enum#3",
"enum#0"
]
},
{
"description": "本模式仅面向最硬核的坦克手。此处,最轻型的坦克搭载最重型的激光炮。绝对纯粹,极致简约。",
"formatId": "RAIL_MASTERS",
"name": "激光炮大师",
"optionsDefaults": [
"enum#6",
"enum#10",
"enum#4",
"enum#1",
"enum#15"
],
"optionsToShow": [
"enum#6",
"enum#10",
"enum#12",
"enum#5",
"enum#16",
"enum#4",
"enum#7",
"enum#0",
"enum#1"
]
},
{
"description": "全员平等的团队对战。此模式下,所有装备均升级至满级,以确保公平竞技。防御模块、装备改造与无人机全部禁用——唯有过硬的实力方能助你取胜。",
"formatId": "SPORT",
"name": "竞技模式",
"optionsDefaults": [
"enum#10",
"enum#12",
"enum#8",
"enum#16",
"enum#1",
"enum#15",
"enum#9"
],
"optionsToShow": [
"enum#6",
"enum#5",
"enum#8",
"enum#4",
"enum#7",
"enum#0"
]
},
{
"description": "空中布满了飞行的坦克。在此格式中,地图限制和障碍将被删除。跳得最有型的坦克获胜。",
"formatId": "PARKOUR",
"name": "跑酷",
"optionsDefaults": [
"enum#6",
"enum#10",
"enum#14",
"enum#16",
"enum#4",
"enum#1"
],
"optionsToShow": [
"enum#6",
"enum#10",
"enum#8",
"enum#11",
"enum#7",
"enum#1",
"enum#2",
"enum#9",
"enum#5",
"enum#13",
"enum#16",
"enum#3",
"enum#4",
"enum#0"
]
},
{
"description": "在此模式中,对道具的使用和地图移动的限制将被解除。战斗前请务必补充道具。",
"formatId": "HYPER",
"name": "超限战场",
"optionsDefaults": [
"enum#6",
"enum#10",
"enum#19",
"enum#14",
"enum#16",
"enum#4",
"enum#18",
"enum#1"
],
"optionsToShow": [
"enum#6",
"enum#10",
"enum#8",
"enum#11",
"enum#7",
"enum#1",
"enum#2",
"enum#9",
"enum#5",
"enum#13",
"enum#16",
"enum#3",
"enum#4",
"enum#0"
]
}
]
}
},
{
"Id": "7949064601138261608",
"Value": {
"(匿名#1)": "0",
"clanTag": null,
"(匿名#3)": "3074457345817168740"
}
},
{
"Id": "2108103923322474513",
"Value": {
"clientKey": "6LfNI_srAAAAACptxRSLfhdOMuyZuYJq1d5khles",
"stateWithCaptcha": [],
"useFake": false
}
},
{
"Id": "5255782026414758079",
"Value": {
"browserExtensionsToDetect": [
"373655AA417CA59E949CF1B3CFE15A5E",
"4AC4FF9609E5AF8E5141621E71C5B305",
"98D90220B6A4643DD22E74906DBD5306",
"9B55477B5A3137455204AFCA75F7C7F6"
]
}
},
{
"Id": "7175193612424807833",
"Value": {
"serverHaltTS": "0"
}
},
{
"Id": "6403067749608679876",
"Value": {
"(匿名#1)": "3074457345817168740"
}
},
{
"Id": "1850509145998626828",
"Value": {
"loggingEnabled": false
}
},
{
"Id": "1298753286651761171",
"Value": {
"(匿名#1)": 0
}
},
{
"Id": "258682513334072065",
"Value": {
"(匿名#1)": 4,
"(匿名#2)": "3074457345817168740"
}
},
{
"Id": "4255726087158005051",
"Value": {
"partnerUid": null,
"uid": "WYT2WYT",
"(匿名#3)": "3074457345817168740"
}
},
{
"Id": "7019981464353739346",
"Value": {
"(匿名#1)": 172800
}
},
{
"Id": "3527205014716342329",
"Value": {
"ranks": [
{
"(匿名#1)": 1,
"name": "新兵"
},
{
"(匿名#1)": 2,
"name": "二等兵"
},
{
"(匿名#1)": 3,
"name": "一等兵"
},
{
"(匿名#1)": 4,
"name": "下士"
},
{
"(匿名#1)": 5,
"name": "中士"
},
{
"(匿名#1)": 6,
"name": "上士"
},
{
"(匿名#1)": 7,
"name": "三级军士长"
},
{
"(匿名#1)": 8,
"name": "二级军士长"
},
{
"(匿名#1)": 9,
"name": "一级军士长"
},
{
"(匿名#1)": 10,
"name": "军士长"
},
{
"(匿名#1)": 11,
"name": "五级准尉"
},
{
"(匿名#1)": 12,
"name": "四级准尉"
},
{
"(匿名#1)": 13,
"name": "三级准尉"
},
{
"(匿名#1)": 14,
"name": "二级准尉"
},
{
"(匿名#1)": 15,
"name": "一级准尉"
},
{
"(匿名#1)": 16,
"name": "特级准尉"
},
{
"(匿名#1)": 17,
"name": "少尉"
},
{
"(匿名#1)": 18,
"name": "中尉"
},
{
"(匿名#1)": 19,
"name": "上尉"
},
{
"(匿名#1)": 20,
"name": "少校"
},
{
"(匿名#1)": 21,
"name": "中校"
},
{
"(匿名#1)": 22,
"name": "上校"
},
{
"(匿名#1)": 23,
"name": "准将"
},
{
"(匿名#1)": 24,
"name": "少将"
},
{
"(匿名#1)": 25,
"name": "中将"
},
{
"(匿名#1)": 26,
"name": "上将"
},
{
"(匿名#1)": 27,
"name": "元帅"
},
{
"(匿名#1)": 28,
"name": "陆军元帅"
},
{
"(匿名#1)": 29,
"name": "统帅"
},
{
"(匿名#1)": 30,
"name": "大元帅"
},
{
"(匿名#1)": 31,
"name": "传奇"
}
]
}
},
{
"Id": "6627517808336136295",
"Value": {
"(匿名#1)": 20
}
}
]
}
},
{
"ObjectId": "100119080",
"MethodId": "3216143066888387731",
"Layout": "zt",
"Value": {
"X": 0,
"Batches": [
{
"ObjectId": "-2147482755",
"ModelIds": [
"7272122855372520854",
"6666386288157781248",
"7286757952195114814",
"433350986715773690",
"6165842138816273510",
"6493584756469019867",
"1115275516484037832",
"2023935033691193766",
"5297988375509642470",
"4813819753066546054",
"649690707811173020",
"881620222948704326",
"6768558945605412944",
"6220257539261493170",
"8495889215182031654",
"8115862559097350286",
"6101288557408637643",
"6042266992054445914"
]
}
],
"Items": []
}
},
{
"ObjectId": "100119080",
"MethodId": "7640916300855664666",
"Layout": "pt",
"Value": {
"Objects": [
{
"ObjectId": "-2147209018",
"GameTypeId": "-2147482755"
}
],
"Items": [
{
"Id": "0",
"Value": "-2147209018"
},
{
"Id": "7272122855372520854",
"Value": {
"(匿名#1)": 300,
"(匿名#2)": 50,
"local": true,
"(匿名#4)": 5
}
},
{
"Id": "6666386288157781248",
"Value": {
"users": []
}
},
{
"Id": "7286757952195114814",
"Value": {
"users": []
}
},
{
"Id": "433350986715773690",
"Value": {
"users": []
}
},
{
"Id": "6165842138816273510",
"Value": {
"users": []
}
},
{
"Id": "1115275516484037832",
"Value": {
"hasClan": false,
"loadingInServiceSpace": true,
"(匿名#3)": 0
}
},
{
"Id": "2023935033691193766",
"Value": {
"objects": []
}
},
{
"Id": "5297988375509642470",
"Value": {
"objects": []
}
},
{
"Id": "4813819753066546054",
"Value": {
"objects": []
}
},
{
"Id": "649690707811173020",
"Value": {
"objects": []
}
},
{
"Id": "881620222948704326",
"Value": {
"objects": []
}
},
{
"Id": "6768558945605412944",
"Value": {
"objects": []
}
},
{
"Id": "6220257539261493170",
"Value": {
"objects": []
}
},
{
"Id": "8495889215182031654",
"Value": {
"hasClanLicense": false
}
},
{
"Id": "8115862559097350286",
"Value": {
"users": []
}
},
{
"Id": "6042266992054445914",
"Value": {
"(匿名#1)": 11
}
}
]
}
},
{
"ObjectId": "100119080",
"MethodId": "7772769798498565531",
"Fields": {
"state": "enum#0"
}
},
{
"ObjectId": "100119080",
"MethodId": "8814983091022938481",
"Fields": {
"userData": [
{
"(匿名#1)": "0",
"clanTag": null,
"(匿名#3)": "3074457345817168740"
}
]
}
},
{
"ObjectId": "100119080",
"MethodId": "7795510382230390111",
"Fields": {
"users": [
{
"online": false,
"(匿名#2)": "3074457345817168740"
}
]
}
},
{
"ObjectId": "100119080",
"MethodId": "4567332396287339500",
"Fields": {
"users": [
{
"hasPremium": false,
"(匿名#2)": "3074457345817168740"
}
]
}
},
{
"ObjectId": "100119080",
"MethodId": "5543735653822042458",
"Fields": {
"users": [
{
"(匿名#1)": 4,
"(匿名#2)": "3074457345817168740"
}
]
}
},
{
"ObjectId": "100119080",
"MethodId": "1246109053620123806",
"Fields": {
"users": [
{
"partnerUid": null,
"uid": "WYT2WYT",
"(匿名#3)": "3074457345817168740"
}
]
}
}
]
},
{
"File": "s2c_0001.bin",
"SpaceId": "100119080",
"NullmapHex": "00",
"DelayMs": 2816,
"Compressed": false,
"Records": [
{
"ObjectId": "100119080",
"MethodId": "9114987333090113769",
"Fields": {
"origin": "enum#0",
"state": "enum#0"
}
}
]
}
]
}
+179
View File
@@ -0,0 +1,179 @@
{
"SpaceId": "920007382011",
"Note": "结构化初始化模板:容器存对象树(发送时重新编码),解不动的记录存 hex;nullmap 原样",
"Frames": [
{
"File": "s2c_0000.bin",
"SpaceId": "920007382011",
"NullmapHex": "00",
"DelayMs": 0,
"Compressed": false,
"Records": [
{
"ObjectId": "920007382011",
"MethodId": "3216143066888387731",
"Layout": "zt",
"Value": {
"X": 1,
"Batches": [
{
"ObjectId": "920007382010",
"ModelIds": [
"821863604528384663",
"6562332220215057023",
"1777275958066775110",
"6675582676627481252",
"7164185146287630122",
"3163202902638184096",
"9207859197453853002",
"5932347630672640435"
]
}
],
"Items": [
{
"R": "1665582956571",
"E": 10,
"U": 10,
"S": "1723732452209",
"O": 1,
"Skipped": []
}
]
}
}
]
},
{
"File": "s2c_0001.bin",
"SpaceId": "920007382011",
"NullmapHex": "2200",
"DelayMs": 109,
"Compressed": false,
"Records": [
{
"ObjectId": "920007382011",
"MethodId": "7640916300855664666",
"Layout": "pt",
"Value": {
"Objects": [
{
"ObjectId": "920007382011",
"GameTypeId": "920007382010"
}
],
"Items": [
{
"Id": "0",
"Value": "920007382011"
},
{
"Id": "5932347630672640435",
"Value": {
"borderColorForEventButton": "0xff8000",
"disabledModes": [
"enum#1",
"enum#9"
],
"eventBigTile": "1665582956571",
"eventEndDate": null,
"groupsInEventMode": false,
"holidayDescription": "专为当前游戏内庆祝活动创建的特殊模式。 仅在有限的时间内可用。",
"holidayEnabled": true,
"holidayIcon": "1665582956571",
"holidayTitle": "节日模式",
"matchmakingModeRanks": [
{
"matchmakingMode": "enum#0",
"(匿名#2)": 1
},
{
"matchmakingMode": "enum#1",
"(匿名#2)": 1
},
{
"matchmakingMode": "enum#2",
"(匿名#2)": 1
},
{
"matchmakingMode": "enum#3",
"(匿名#2)": 6
},
{
"matchmakingMode": "enum#4",
"(匿名#2)": 2
},
{
"matchmakingMode": "enum#5",
"(匿名#2)": 3
},
{
"matchmakingMode": "enum#6",
"(匿名#2)": 7
},
{
"matchmakingMode": "enum#7",
"(匿名#2)": 5
},
{
"matchmakingMode": "enum#8",
"(匿名#2)": 4
},
{
"matchmakingMode": "enum#9",
"(匿名#2)": 7
},
{
"matchmakingMode": "enum#10",
"(匿名#2)": 8
}
],
"(匿名#11)": 2,
"(匿名#12)": 9
}
}
]
}
},
{
"ObjectId": "920007382011",
"MethodId": "8009147528103070285",
"Fields": {
"limit": {
"(匿名#1)": 29400,
"(匿名#2)": 0,
"(匿名#3)": 13300,
"(匿名#4)": 0
}
}
}
]
},
{
"File": "s2c_0002.bin",
"SpaceId": "920007382011",
"NullmapHex": "00",
"DelayMs": 2037,
"Compressed": false,
"Records": [
{
"ObjectId": "920007382011",
"MethodId": "8009147528103070285",
"Fields": {
"limit": {
"(匿名#1)": 29400,
"(匿名#2)": 0,
"(匿名#3)": 13300,
"(匿名#4)": 0
}
}
},
{
"ObjectId": "920007382011",
"MethodId": "3182939728106937130",
"Fields": {}
}
]
}
]
}
@@ -0,0 +1,93 @@
{
"SpaceId": "100045260",
"Note": "登录态补帧:真实服在客户端登录成功后、入口空间上额外下发的帧。记录已由 tools/structure_space_init.js 升级成结构化字段(语义层负责编码),字段名/类型来自 names/downlink_table.json;同一帧里的多条记录各自带 ObjectId。",
"Frames": [
{
"File": "s2c_0002.bin",
"SpaceId": "100045260",
"NullmapHex": "00",
"DelayMs": 0,
"Records": [
{
"ObjectId": "100045260",
"MethodId": "761463638726320835",
"Fields": {}
}
]
},
{
"File": "s2c_0003.bin",
"SpaceId": "100045260",
"NullmapHex": "00",
"DelayMs": 9561,
"Records": [
{
"ObjectId": "100045260",
"MethodId": "1247179769701109529",
"Fields": {
"advisedUids": [
"WYT3WYT525",
"WYT3WYT789",
"WYT3WYT432",
"WYT3WYT83",
"WYT3WYT897"
]
}
},
{
"ObjectId": "100045260",
"MethodId": "6180114602676583672",
"Fields": {}
}
]
},
{
"File": "s2c_0004.bin",
"SpaceId": "100045260",
"NullmapHex": "00",
"DelayMs": 14052,
"Records": [
{
"ObjectId": "100045260",
"MethodId": "1247179769701225356",
"Fields": {}
}
]
},
{
"File": "s2c_0005.bin",
"SpaceId": "100045260",
"NullmapHex": "00",
"DelayMs": 19468,
"Records": [
{
"ObjectId": "100045260",
"MethodId": "761463638726320835",
"Fields": {}
}
]
},
{
"File": "s2c_0006.bin",
"SpaceId": "100045260",
"NullmapHex": "00",
"DelayMs": 26073,
"Records": [
{
"ObjectId": "100045260",
"MethodId": "237130683810296524",
"Fields": {
"hash": "vwZaPJp2mwMANXE5HKH6G1ZkMwtKPHbAHlVmg5jdR43wF1c55qrGNQGCIA7Oewfm"
}
},
{
"ObjectId": "100045260",
"MethodId": "2025886178375810581",
"Fields": {
"(匿名#1)": "3074457345817168740"
}
}
]
}
]
}
File diff suppressed because it is too large. Load diff
+63 -63
View File
@@ -86,7 +86,7 @@
"Fields": [
{
"Field": "zfs_1",
"Type": "list<ji>",
"Type": "list<i64>",
"TypeCodec": "",
"Desc": "new Ei(new yi(bi(xi), !1), !1)"
}
@@ -252,7 +252,7 @@
"Fields": [
{
"Field": "efe_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -520,7 +520,7 @@
"Fields": [
{
"Field": "sfj_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
},
@@ -678,7 +678,7 @@
"Fields": [
{
"Field": "yhw_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new v(p(x), !1)"
}
@@ -984,7 +984,7 @@
"Fields": [
{
"Field": "zv0_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new q(A(M), !1)"
}
@@ -1010,7 +1010,7 @@
"Fields": [
{
"Field": "peu_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -1237,7 +1237,7 @@
"Fields": [
{
"Field": "bft_1",
"Type": "opt<ji>",
"Type": "opt<i64>",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !0)"
}
@@ -1269,7 +1269,7 @@
},
{
"Field": "vgi_1",
"Type": "opt<ji>",
"Type": "opt<i64>",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !0)"
}
@@ -1838,7 +1838,7 @@
"Fields": [
{
"Field": "whw_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new v(p(x), !1)"
}
@@ -1864,7 +1864,7 @@
"Fields": [
{
"Field": "uhw_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new v(p(x), !1)"
}
@@ -1948,7 +1948,7 @@
"Fields": [
{
"Field": "jb4_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
},
@@ -2506,7 +2506,7 @@
"Fields": [
{
"Field": "xb2_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -2532,7 +2532,7 @@
"Fields": [
{
"Field": "sax_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -2861,7 +2861,7 @@
"Fields": [
{
"Field": "hax_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -2887,7 +2887,7 @@
"Fields": [
{
"Field": "pax_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -2913,7 +2913,7 @@
"Fields": [
{
"Field": "uax_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -2939,7 +2939,7 @@
"Fields": [
{
"Field": "tbs_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j(I(T), !1)"
}
@@ -2965,7 +2965,7 @@
"Fields": [
{
"Field": "bbt_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j(I(T), !1)"
}
@@ -3023,7 +3023,7 @@
"Fields": [
{
"Field": "sdi_1",
"Type": "list<ji>",
"Type": "list<i64>",
"TypeCodec": "",
"Desc": "new U(new L(B(Q), !1), !1)"
}
@@ -3146,7 +3146,7 @@
"Fields": [
{
"Field": "sgi_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -3394,7 +3394,7 @@
"Fields": [
{
"Field": "chd_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new w(b(R), !1)"
}
@@ -3420,7 +3420,7 @@
"Fields": [
{
"Field": "abp_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new ce(le(de), !1)"
}
@@ -3465,7 +3465,7 @@
"Fields": [
{
"Field": "reu_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
},
@@ -3643,7 +3643,7 @@
"Fields": [
{
"Field": "qut_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new q(A(M), !1)"
}
@@ -4041,7 +4041,7 @@
"Fields": [
{
"Field": "gfy_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -4238,7 +4238,7 @@
"Fields": [
{
"Field": "ibv_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j(I(T), !1)"
}
@@ -4698,7 +4698,7 @@
"Fields": [
{
"Field": "hey_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -4878,7 +4878,7 @@
"Fields": [
{
"Field": "ni5_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new p(b(A), !1)"
}
@@ -4966,7 +4966,7 @@
},
{
"Field": "bf8_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
},
@@ -5049,7 +5049,7 @@
"Fields": [
{
"Field": "quw_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new q(A(M), !1)"
}
@@ -5502,7 +5502,7 @@
"Fields": [
{
"Field": "ccn_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new ye(ze(Me), !1)"
}
@@ -5586,7 +5586,7 @@
"Fields": [
{
"Field": "phv_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new v(p(x), !1)"
}
@@ -5714,7 +5714,7 @@
"Fields": [
{
"Field": "zbs_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j(I(T), !1)"
}
@@ -5958,7 +5958,7 @@
"Fields": [
{
"Field": "cbp_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new ce(le(de), !1)"
}
@@ -6010,7 +6010,7 @@
"Fields": [
{
"Field": "wuz_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new q(A(M), !1)"
}
@@ -6107,7 +6107,7 @@
"Fields": [
{
"Field": "jey_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
},
@@ -6297,7 +6297,7 @@
"Fields": [
{
"Field": "xel_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -6323,7 +6323,7 @@
"Fields": [
{
"Field": "qdi_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new L(B(Q), !1)"
}
@@ -6535,7 +6535,7 @@
"Fields": [
{
"Field": "gaz_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -6561,7 +6561,7 @@
"Fields": [
{
"Field": "pfd_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -6701,7 +6701,7 @@
"Fields": [
{
"Field": "ilf_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new Dn(Mn(Xn), !1)"
}
@@ -6875,7 +6875,7 @@
"Fields": [
{
"Field": "ueu_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -7063,7 +7063,7 @@
"Fields": [
{
"Field": "ofg_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -7127,7 +7127,7 @@
"Fields": [
{
"Field": "dbg_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -7159,7 +7159,7 @@
},
{
"Field": "jf7_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -7211,19 +7211,19 @@
"Fields": [
{
"Field": "ngi_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
},
{
"Field": "ogi_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
},
{
"Field": "pgi_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -7475,7 +7475,7 @@
"Fields": [
{
"Field": "efy_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -7546,7 +7546,7 @@
"Fields": [
{
"Field": "kb8_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -7784,7 +7784,7 @@
"Fields": [
{
"Field": "elo_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new Dn(Mn(Xn), !1)"
}
@@ -7829,7 +7829,7 @@
"Fields": [
{
"Field": "kf5_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -7900,7 +7900,7 @@
"Fields": [
{
"Field": "fb2_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j($(q), !1)"
}
@@ -7970,7 +7970,7 @@
},
{
"Field": "mfa_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -8322,7 +8322,7 @@
"Fields": [
{
"Field": "vel_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -8755,7 +8755,7 @@
"Fields": [
{
"Field": "xeo_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -8781,13 +8781,13 @@
"Fields": [
{
"Field": "ueo_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
},
{
"Field": "veo_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -8813,7 +8813,7 @@
"Fields": [
{
"Field": "seo_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -8889,7 +8889,7 @@
"Fields": [
{
"Field": "gbu_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new j(I(T), !1)"
}
@@ -9850,7 +9850,7 @@
"Fields": [
{
"Field": "bgp_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -9921,7 +9921,7 @@
"Fields": [
{
"Field": "nfc_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new yi(bi(xi), !1)"
}
@@ -10102,7 +10102,7 @@
"Fields": [
{
"Field": "tuy_1",
"Type": "ji",
"Type": "i64",
"TypeCodec": "",
"Desc": "new q(A(M), !1)"
}
+221
View File
@@ -0,0 +1,221 @@
// analyze_packets.js - 抓包分析:从 wss_server 的抓包产物还原「命令 ID 表」
// 用法: node analyze_packets.js [--dir ../packets] [--label relay-04]
// 输出: 控制台汇总 + names/commands.json(命令 ID → 方向/次数/样本)
//
// 背景(逆向自 module_61860):
// 命令基类 ControlCommand(id, name),入口层协议只注册了 4 条:
// c2s 编码表: 1 = "Hash request" 32 = "ClientLog"
// s2c 解码表: 2 = "Hash response" 32 = "Open space"
// 帧结构: [2/4 字节大端长度][nullmap][payload],payload = [1 字节命令 ID][body]
// ClientLog body = [LogEntryType 1 字节][字符串][字符串]
// 抓包里还有一批「非入口层」的帧(大帧/首字节带 0x80|0x40 标志),另行统计。
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
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 PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets')));
const LABEL = Arg('label', '');
const OUT_FILE = path.join(PROJECT, 'names', 'commands.json');
// 已知命令(入口层)
const CMD_TABLE = {
c2s: { 1: 'Hash Request', 32: 'ClientLog' },
s2c: { 2: 'Hash Response', 32: 'Open Space' },
};
// nullmap 长度(对应 x7g):高位标志决定「位数档位」或「显式字节数」
// bit7=0: tier = bit6..5 → 1/2/3/4 字节
// bit7=1: bit6=1 → 1 字节长度(低位 6 位) + 数据;bit6=0 → 3 字节长度 + 数据
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);
}
// 帧头解析:返回 { TotalSize, HeaderSize, BodySize, Compressed }
function PeekFrame(Buf) {
if (Buf.length < 2) return null;
const B0 = Buf[0];
let BodySize, HeaderSize;
if (B0 & 0x80) {
if (Buf.length < 4) return null;
BodySize = (((B0 & 0x3f) << 24) | (Buf[1] << 16) | (Buf[2] << 8) | Buf[3]) >>> 0;
HeaderSize = 4;
} else {
BodySize = ((B0 & 0x3f) << 8) | Buf[1];
HeaderSize = 2;
}
return { TotalSize: HeaderSize + BodySize, HeaderSize, BodySize, Compressed: (B0 & 0x40) !== 0 };
}
// 变长整数(对应 g7g/h7g):<0x80 一字节;<0x4000 两字节;否则三字节
function ReadVarint(Buf, Offset) {
const B0 = Buf[Offset];
if ((B0 & 0x80) === 0) return { Value: B0 & 0x7f, Next: Offset + 1 };
if ((B0 & 0x40) === 0) return { Value: ((B0 & 0x3f) << 8) | Buf[Offset + 1], Next: Offset + 2 };
return { Value: ((B0 & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2], Next: Offset + 3 };
}
function ReadString(Buf, Offset) {
const Len = ReadVarint(Buf, Offset);
if (Len.Next + Len.Value > Buf.length) return null;
return { Value: Buf.slice(Len.Next, Len.Next + Len.Value).toString('utf8'), Next: Len.Next + Len.Value };
}
// ClientLog body = [type][string][string]
function ParseClientLog(Buf, Offset) {
const Type = Buf[Offset];
const S1 = ReadString(Buf, Offset + 1);
if (!S1) return null;
const S2 = ReadString(Buf, S1.Next);
return { Type, S1: S1.Value, S2: S2 ? S2.Value : '' };
}
function Hex(Buf, N) {
return Buf.slice(0, N).toString('hex');
}
// ============================================================
// 扫描
// ============================================================
const Labels = LABEL
? [LABEL]
: fs
.readdirSync(PACKET_DIR, { withFileTypes: true })
.filter((D) => D.isDirectory())
.map((D) => D.name);
const Stat = {
Frames: { c2s: {}, s2c: {} }, // 方向 → id → { Count, Samples[], Bytes }
ClientLogs: {}, // S1 → { Count, Types:Set, S2Samples:Set }
Other: { c2s: { Count: 0, Bytes: 0, Samples: [] }, s2c: { Count: 0, Bytes: 0, Samples: [] } },
Pings: { c2s: 0, s2c: 0 }, // 空 payload 保活帧
Sessions: 0,
Messages: { c2s: 0, s2c: 0 },
};
function Bump(Direction, Id, Buf, NmSize) {
const Bucket = (Stat.Frames[Direction][Id] = Stat.Frames[Direction][Id] || { Count: 0, Bytes: 0, Samples: [], Nullmaps: {} });
Bucket.Count++;
Bucket.Bytes += Buf.length;
Bucket.Nullmaps[NmSize] = (Bucket.Nullmaps[NmSize] || 0) + 1;
if (Bucket.Samples.length < 4 && !Bucket.Samples.some((S) => S.length === Buf.length)) {
Bucket.Samples.push(Buf.slice(0, 48));
}
}
function ScanFile(Label, Session, Direction, File) {
const Buf = fs.readFileSync(File);
Stat.Messages[Direction]++;
const Info = PeekFrame(Buf);
// 完整一帧且未压缩 → 按入口层命令帧解析(nullmap 可能多字节)
const IsFrame = Info && Info.TotalSize === Buf.length && !Info.Compressed;
if (!IsFrame) {
const Bucket = Stat.Other[Direction];
Bucket.Count++;
Bucket.Bytes += Buf.length;
if (Bucket.Samples.length < 6) {
Bucket.Samples.push(Label + '/' + Session + '/' + path.basename(File) + ' (' + Buf.length + 'B) ' + Hex(Buf, 16));
}
return;
}
const NmSize = NullmapSize(Buf, Info.HeaderSize);
const PayloadAt = Info.HeaderSize + NmSize;
const Id = Buf[PayloadAt];
if (Id === undefined) {
// 空 payload(保活 ping)
Stat.Pings[Direction]++;
return;
}
const Payload = Buf.slice(PayloadAt + 1);
Bump(Direction, Id, Payload, NmSize);
if (Direction === 'c2s' && Id === 32) {
const Log = ParseClientLog(Payload, 0);
if (Log) {
const Entry = (Stat.ClientLogs[Log.S1] = Stat.ClientLogs[Log.S1] || { Count: 0, Types: {}, S2: [] });
Entry.Count++;
Entry.Types[Log.Type] = (Entry.Types[Log.Type] || 0) + 1;
if (Log.S2 && Entry.S2.length < 3 && !Entry.S2.includes(Log.S2)) Entry.S2.push(Log.S2);
}
}
}
for (const Label of Labels) {
const Dir = path.join(PACKET_DIR, Label);
if (!fs.existsSync(Dir)) continue;
for (const D of fs.readdirSync(Dir, { withFileTypes: true })) {
if (!D.isDirectory() || !D.name.startsWith('session_')) continue;
Stat.Sessions++;
const SessionDir = path.join(Dir, D.name);
for (const F of fs.readdirSync(SessionDir).sort()) {
const M = /^(c2s|s2c)_(\d+)\.bin$/.exec(F);
if (!M) continue;
ScanFile(Label, D.name, M[1], path.join(SessionDir, F));
}
}
}
// ============================================================
// 输出
// ============================================================
const Result = { Labels, Sessions: Stat.Sessions, Messages: Stat.Messages, C2s: {}, S2c: {}, ClientLogs: {}, Other: {} };
function Report(Direction) {
const Title = Direction === 'c2s' ? '客户端 → 服务器' : '服务器 → 客户端';
console.log('\n===== ' + Title + ' 命令表 =====');
const Ids = Object.keys(Stat.Frames[Direction])
.map(Number)
.sort((A, B) => A - B);
const Target = Direction === 'c2s' ? Result.C2s : Result.S2c;
for (const Id of Ids) {
const Bucket = Stat.Frames[Direction][Id];
const Name = (CMD_TABLE[Direction] && CMD_TABLE[Direction][Id]) || '未知';
const Nm = Object.keys(Bucket.Nullmaps)
.map((N) => N + 'B×' + Bucket.Nullmaps[N])
.join(' ');
console.log(
' ID=' + String(Id).padStart(4) + ' ' + Name.padEnd(16) + ' 次数=' + String(Bucket.Count).padStart(5) + ' 负载=' + String(Bucket.Bytes).padStart(7) + 'B nullmap=' + Nm,
);
for (const S of Bucket.Samples) console.log(' 样本: ' + S.toString('hex'));
Target[Id] = { Name, Count: Bucket.Count, Bytes: Bucket.Bytes, Nullmaps: Bucket.Nullmaps, Samples: Bucket.Samples.map((S) => S.toString('hex')) };
}
if (Stat.Pings[Direction]) console.log(' [保活 ping 空帧] ' + Stat.Pings[Direction] + ' 条');
const Other = Stat.Other[Direction];
if (Other.Count) {
console.log(' [其他/非入口层] 消息数=' + Other.Count + ' 字节=' + Other.Bytes);
for (const S of Other.Samples) console.log(' ' + S);
Result.Other[Direction] = Other;
}
}
console.log('抓包目录: ' + PACKET_DIR);
console.log('标签: ' + (Labels.join(', ') || '(无)'));
console.log('会话数: ' + Stat.Sessions + ' 消息数: c2s=' + Stat.Messages.c2s + ' s2c=' + Stat.Messages.s2c);
Report('c2s');
Report('s2c');
console.log('\n===== ClientLog 内容(c2s ID=32)=====');
const Keys = Object.keys(Stat.ClientLogs).sort((A, B) => Stat.ClientLogs[B].Count - Stat.ClientLogs[A].Count);
for (const K of Keys.slice(0, 60)) {
const E = Stat.ClientLogs[K];
console.log(
' ' + K.padEnd(28) + ' 次数=' + String(E.Count).padStart(4) + ' 类型=' + JSON.stringify(E.Types) + (E.S2.length ? ' 样本=' + E.S2.join(' | ') : ''),
);
}
Result.ClientLogs = Stat.ClientLogs;
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(Result, null, 2), 'utf8');
console.log('\n已写出: ' + OUT_FILE);
+786
View File
@@ -0,0 +1,786 @@
// apply_names.js - 命名回填:把 names/ 里的命名证据写回 src/ 的反编译源码,产出可读版本 src_named/
// 用法:
// node apply_names.js [--in ../src] [--out ../src_named] [--report ../names/rename_report.json] [--dry] [--aliases]
//
// --aliases:额外读 names/command_alias.json,把「派生命名」(我们读源码证实语义后起的别名,**不是**原始名)
// 一并回填进 src_named/。这一类与证据命名分开统计(报告里的 Aliases 段、控制台的别名段)。
// 比证据命名多一道闸门:属性名必须「全库只有本模块这一处定义」才改(同名跨模块调用点不受影响)。
//
// 命名分层(严格遵守):
// ① 模块内局部名(类名 / 函数名 / 局部变量名)—— 只在所属模块内生效;
// ② 属性名(t.xxx / 对象字面量键)—— 全局共享,必须在全部模块里一次性同步替换;
// ③ 白名单名(宿主 API / Kotlin-JS 运行时关键名 / 含 $_$ 的导出)—— 永不改。
//
// 安全规则:
// · 证据不唯一(同一短名在同一作用域内指向多个实体)→ 跳过,计入 Conflict;
// · 短名出现在任何字符串字面量里 → 跳过(改字符串会破坏「字符串集合一致」这条硬校验);
// · 属性名的目标名如果已是既有的属性名 → 跳过(否则会把两个不同字段合并成一个);
// · 局部名要求「全模块唯一绑定 + 所有引用都落在该绑定作用域内」,否则跳过(无法保证一致);
// · 只改标识符,不碰字符串内容、不碰代码结构。
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
// ---------------------------------------------------------------- 依赖探测
// acorn 在 3DTank/tools/node_modules(也可能在仓库根 / sockets 下);找不到就明确报错,不静默降级
function RequireAcorn() {
const Project = path.resolve(__dirname, '..');
const Candidates = [
path.join(__dirname, 'node_modules', 'acorn'),
path.join(Project, 'node_modules', 'acorn'),
path.resolve(Project, '..', 'node_modules', 'acorn'),
path.resolve(Project, '..', 'sockets', 'node_modules', 'acorn'),
];
for (const Dir of Candidates) {
if (!fs.existsSync(Dir)) continue;
try { return require(Dir); } catch (Err) { /* 继续探测下一个 */ }
}
try { return require('acorn'); } catch (Err) { /* 全部失败 */ }
console.error('找不到 acorn。已尝试以下位置:');
for (const Dir of Candidates) console.error(' ' + Dir);
console.error('请先安装依赖:cd "' + path.join(Project, 'tools') + '" ; npm install');
process.exit(2);
return null;
}
const Acorn = RequireAcorn();
// ---------------------------------------------------------------- 参数
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 ToolsDir = __dirname;
const InDir = path.resolve(ToolsDir, Arg('in', path.join('..', 'src')));
const OutDir = path.resolve(ToolsDir, Arg('out', path.join('..', 'src_named')));
const ReportPath = path.resolve(ToolsDir, Arg('report', path.join('..', 'names', 'rename_report.json')));
const DryRun = Argv.indexOf('--dry') >= 0;
const UseAliases = Argv.indexOf('--aliases') >= 0;
const NamesDir = path.resolve(ToolsDir, '..', 'names');
// ---------------------------------------------------------------- 白名单
const ReservedWords = new Set([
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else',
'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof',
'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void',
'while', 'with', 'yield', 'let', 'static', 'await',
]);
// 宿主 / 内置 API
const BuiltinNames = new Set([
'window', 'globalThis', 'self', 'document', 'navigator', 'location', 'history', 'screen', 'console',
'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt', 'Math', 'JSON', 'Date', 'RegExp',
'Error', 'TypeError', 'RangeError', 'SyntaxError', 'EvalError', 'URIError', 'ReferenceError', 'AggregateError',
'Promise', 'Proxy', 'Reflect', 'Function', 'Map', 'Set', 'WeakMap', 'WeakSet', 'WeakRef', 'FinalizationRegistry',
'ArrayBuffer', 'SharedArrayBuffer', 'DataView', 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array',
'Uint16Array', 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', 'BigInt64Array', 'BigUint64Array',
'TextEncoder', 'TextDecoder', 'URL', 'URLSearchParams', 'Blob', 'File', 'FileReader', 'FormData', 'Headers',
'Request', 'Response', 'Image', 'ImageData', 'Audio', 'AudioContext', 'CanvasRenderingContext2D', 'Path2D',
'WebSocket', 'Worker', 'MessageChannel', 'MessagePort', 'BroadcastChannel', 'EventSource', 'XMLHttpRequest',
'Event', 'CustomEvent', 'MouseEvent', 'KeyboardEvent', 'TouchEvent', 'PointerEvent', 'WheelEvent', 'ErrorEvent',
'Notification', 'localStorage', 'sessionStorage', 'indexedDB', 'crypto', 'performance', 'WebAssembly', 'process',
'undefined', 'null', 'NaN', 'Infinity', 'arguments', 'this', 'super', 'global', 'eval', 'module', 'exports',
'require', 'define', 'amd', 'Gamepad', 'WebGLRenderingContext', 'WebGL2RenderingContext', 'AudioBuffer',
'OffscreenCanvas', 'setImmediate', 'structuredClone', 'queueMicrotask', 'postMessage', 'addEventListener',
'removeEventListener', 'dispatchEvent', 'alert', 'confirm', 'prompt', 'atob', 'btoa', 'fetch',
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent',
'escape', 'unescape', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
'requestAnimationFrame', 'cancelAnimationFrame', 'TextMetrics', 'DOMParser', 'XMLSerializer', 'IntersectionObserver',
]);
// Kotlin/JS 运行时关键名(改名会破坏元数据 / 协议读取器 / 编译器契约)
const KotlinRuntimeNames = new Set([
'equals', 'hashCode', 'toString', 'callableName', '$metadata$', '$metadata', 'prototype', 'constructor',
'e1', 'g1', 'h1', 'd3', 'p57', 'q7i', 'o7g', 'p7g', 'q7g', 'n7f', 'l7f', 'm7f', 'e2b', 'v7o', 'i7q', 'z7f', 'u7r_1',
]);
// 目标名危险表:这些名字一旦落在普通对象上会触发宿主 / 运行时的既有行为
const UnsafeTargets = new Set([
'__proto__', 'constructor', 'prototype', 'then', 'length', 'call', 'apply', 'bind', 'hasOwnProperty',
'isPrototypeOf', 'propertyIsEnumerable', 'valueOf', 'toLocaleString', 'caller', 'arguments', 'eval',
]);
// 名字合法性(AsTarget=true 时额外挡住危险目标名)
function NameProblem(Name, AsTarget) {
if (!/^[A-Za-z_$][\w$]*$/.test(Name)) return '不是合法标识符';
if (ReservedWords.has(Name)) return 'JS 保留字';
if (BuiltinNames.has(Name)) return '宿主/内置 API 名';
if (KotlinRuntimeNames.has(Name)) return 'Kotlin/JS 运行时关键名';
if (AsTarget && UnsafeTargets.has(Name)) return '目标名会触发宿主既有行为';
return '';
}
const HasExportSigil = (Name) => Name.indexOf('$_$') >= 0; // 以 $_$ 形式出现的导出一律不动
// ---------------------------------------------------------------- 通用 AST 工具
const IsFnNode = (N) => N.type === 'FunctionDeclaration' || N.type === 'FunctionExpression' || N.type === 'ArrowFunctionExpression';
const IsScopeNode = (N) => N.type === 'Program' || IsFnNode(N);
const ChildKeys = (N) => Object.keys(N).filter((K) => K !== 'type' && K !== 'start' && K !== 'end' && K !== 'loc' && K !== 'range');
const IdentOf = (N) => (N && N.type === 'Identifier' ? N.name : null);
// 名字表一律用无原型对象,避免 constructor / toString 之类的名字撞上 Object.prototype
const NewTable = () => Object.create(null);
function WalkWithParent(Node, Parent, Cb, Grand) {
if (!Node || typeof Node.type !== 'string') return;
Cb(Node, Parent, Grand);
for (const K of ChildKeys(Node)) {
const V = Node[K];
if (Array.isArray(V)) { for (const C of V) if (C && typeof C.type === 'string') WalkWithParent(C, Node, Cb, Parent); }
else if (V && typeof V.type === 'string') WalkWithParent(V, Node, Cb, Parent);
}
}
function PatternNames(Node, Out) {
Out = Out || [];
if (!Node || typeof Node.type !== 'string') return Out;
if (Node.type === 'Identifier') Out.push(Node.name);
else if (Node.type === 'ObjectPattern') for (const P of Node.properties) PatternNames(P.type === 'RestElement' ? P.argument : P.value, Out);
else if (Node.type === 'ArrayPattern') for (const E of Node.elements) if (E) PatternNames(E, Out);
else if (Node.type === 'AssignmentPattern') PatternNames(Node.left, Out);
else if (Node.type === 'RestElement') PatternNames(Node.argument, Out);
return Out;
}
// 「名字 → 绑定位置列表」;每项带该绑定的作用域范围(var/函数声明 = 最近的外层函数体,参数 = 函数自身)
function CollectBindings(Ast, ScopeStart, ScopeEnd, Out) {
if (!Ast || typeof Ast.type !== 'string') return Out;
Out = Out || {};
const Push = (Name, Start, End) => { if (!Out[Name]) Out[Name] = []; Out[Name].push({ Start, End }); };
if (Ast.type === 'FunctionDeclaration' && Ast.id) Push(Ast.id.name, ScopeStart, ScopeEnd);
if (Ast.type === 'ClassDeclaration' && Ast.id) Push(Ast.id.name, ScopeStart, ScopeEnd);
if (Ast.type === 'VariableDeclarator') for (const N of PatternNames(Ast.id)) Push(N, ScopeStart, ScopeEnd);
if (Ast.type === 'CatchClause' && Ast.param) for (const N of PatternNames(Ast.param)) Push(N, ScopeStart, ScopeEnd);
let S1 = ScopeStart;
let S2 = ScopeEnd;
if (IsScopeNode(Ast)) { S1 = Ast.start; S2 = Ast.end; }
if (IsFnNode(Ast) && Ast.params) for (const P of Ast.params) for (const N of PatternNames(P)) Push(N, S1, S2);
for (const K of ChildKeys(Ast)) {
const V = Ast[K];
if (Array.isArray(V)) { for (const C of V) if (C && typeof C.type === 'string') CollectBindings(C, S1, S2, Out); }
else if (V && typeof V.type === 'string') CollectBindings(V, S1, S2, Out);
}
return Out;
}
// 字符串字面量 token 原文列表(含引号),用于「字符串集合一致」校验
function StringTokenList(Text) {
const Tokens = [];
try {
Acorn.parse(Text, { ecmaVersion: 'latest', sourceType: 'script', allowReturnOutsideFunction: true, onToken: Tokens });
} catch (Err) { return null; }
return Tokens.filter((T) => T.type.label === 'string' || T.type.label === 'template').map((T) => Text.slice(T.start, T.end));
}
const ParseSrc = (Text) => Acorn.parse(Text, { ecmaVersion: 'latest', sourceType: 'script', allowReturnOutsideFunction: true });
// ---------------------------------------------------------------- 单模块扫描
// 一次遍历同时得到:属性位名字 / 值位名字 / 简写属性 / 绑定表 / 闭包原名 / 数据类名 / 枚举三元组
function ScanModule(Text, Ast) {
const Mod = {
PropNames: NewTable(), // 属性位置的名字 → [Identifier 节点]
PropDefs: NewTable(), // 属性「定义处」的名字 → [Identifier 节点](用于判断某个属性名全库是否只有一处定义)
ValueNames: NewTable(), // 值位置(引用或绑定)的名字 → [Identifier 节点]
Shorthand: new Set(),
Bindings: NewTable(),
Closures: NewTable(), // 短名 → Set(callableName)(AST 版,比 mine_evidence 的正则更全)
ClassNames: NewTable(), // 类短名 → Set(toString 里的真名)
Enums: [], // { Cls, Field, Member, Value, Site }
};
const Push = (Table, Name, Node) => { if (!Table[Name]) Table[Name] = []; Table[Name].push(Node); };
WalkWithParent(Ast, null, (Node, Parent, Grand) => {
if (Node.type === 'Identifier') {
const IsPropMember = Parent && Parent.type === 'MemberExpression' && Parent.property === Node && !Parent.computed;
const IsPropKey = Parent && (Parent.type === 'Property' || Parent.type === 'PropertyDefinition' || Parent.type === 'MethodDefinition') && Parent.key === Node && !Parent.computed;
if (IsPropMember || IsPropKey) {
Push(Mod.PropNames, Node.name, Node);
// 定义处 = 对象字面量键 / 类成员,或「成员表达式出现在赋值左侧 / 自增自减」
const IsDef = IsPropKey ||
(Parent.type === 'MemberExpression' && Grand && (
(Grand.type === 'AssignmentExpression' && Grand.left === Parent) ||
(Grand.type === 'UpdateExpression' && Grand.argument === Parent)));
if (IsDef) Push(Mod.PropDefs, Node.name, Node);
if (IsPropKey && Parent.shorthand) Mod.Shorthand.add(Node.name);
} else Push(Mod.ValueNames, Node.name, Node);
}
// 闭包原名:X.callableName = "…" 与 (X = function(){…}).callableName = "…" 两种形态
if (Node.type === 'AssignmentExpression' && Node.left.type === 'MemberExpression' && IdentOf(Node.left.property) === 'callableName' && Node.right.type === 'Literal' && typeof Node.right.value === 'string') {
const Obj = Node.left.object;
let Short = null;
if (Obj.type === 'Identifier') Short = Obj.name;
else if (Obj.type === 'AssignmentExpression' && IdentOf(Obj.left)) Short = Obj.left.name;
if (Short) { if (!Mod.Closures[Short]) Mod.Closures[Short] = new Set(); Mod.Closures[Short].add(Node.right.value); }
}
// 数据类真名:Q(Cls).toString = function () { return "Name(…" }
// 与 mine_evidence.js 的规则等价(函数体第一条就是 return,且返回串以 "<标识符>(" 开头),
// 只是换成 AST 判定,避免正则漏配;不额外放宽,防止凭空造名字。
if (Node.type === 'AssignmentExpression' && Node.left.type === 'MemberExpression' && IdentOf(Node.left.property) === 'toString') {
const Obj = Node.left.object;
if (Obj.type === 'CallExpression' && Obj.arguments.length === 1 && IdentOf(Obj.arguments[0]) && Node.right.type === 'FunctionExpression') {
const Cls = Obj.arguments[0].name;
const Body = Node.right.body && Node.right.body.body;
const Ret = Body && Body.length && Body[0].type === 'ReturnStatement' ? Body[0] : null;
if (Ret && Ret.argument) {
let Head = Ret.argument;
while (Head.type === 'BinaryExpression') Head = Head.left;
if (Head.type === 'Literal' && typeof Head.value === 'string') {
const M = /^([A-Za-z_$][\w$]*)\(/.exec(Head.value);
if (M) { if (!Mod.ClassNames[Cls]) Mod.ClassNames[Cls] = new Set(); Mod.ClassNames[Cls].add(M[1]); }
}
}
}
}
// 枚举成员:Field = new Cls("Member", 数字)
if (Node.type === 'AssignmentExpression' && Node.left.type === 'Identifier' && Node.right.type === 'NewExpression' &&
IdentOf(Node.right.callee) && Node.right.arguments.length === 2 &&
Node.right.arguments[0].type === 'Literal' && typeof Node.right.arguments[0].value === 'string' &&
Node.right.arguments[1].type === 'Literal' && typeof Node.right.arguments[1].value === 'number') {
Mod.Enums.push({ Cls: Node.right.callee.name, Field: Node.left.name, Member: Node.right.arguments[0].value, Value: Node.right.arguments[1].value, Site: Node.start });
}
});
Mod.Bindings = CollectBindings(Ast, 0, Text.length, NewTable());
return Mod;
}
// ---------------------------------------------------------------- 载入证据
function LoadJson(File, Must) {
const P = path.join(NamesDir, File);
if (!fs.existsSync(P)) { if (Must) { console.error('缺少证据文件:' + P); process.exit(2); } return null; }
return JSON.parse(fs.readFileSync(P, 'utf8'));
}
const Evidence = LoadJson('evidence.json', true);
const CodecFields = LoadJson('codec_fields.json', false);
const UplinkTable = LoadJson('uplink_table.json', false);
const AliasTable = LoadJson('command_alias.json', false); // 派生命名词典,仅 --aliases 时使用
// ---------------------------------------------------------------- 扫描源码
const SrcFiles = fs.readdirSync(InDir).filter((F) => /^module_\d+\.js$/.test(F)).sort();
const OtherFiles = fs.readdirSync(InDir).filter((F) => !/^module_\d+\.js$/.test(F) && fs.statSync(path.join(InDir, F)).isFile());
const Modules = NewTable();
const GlobalPropNames = NewTable();
const GlobalPropDefs = NewTable(); // 属性「定义处」的全局计数
const GlobalValueNames = NewTable();
const GlobalStrings = new Set();
const GlobalShorthand = new Set();
let ParseFail = 0;
for (const F of SrcFiles) {
const Text = fs.readFileSync(path.join(InDir, F), 'utf8');
const Id = String(Number(/module_(\d+)\.js$/.exec(F)[1]));
let Ast;
try { Ast = ParseSrc(Text); } catch (Err) { ParseFail++; console.error('解析失败 ' + F + ':' + Err.message); continue; }
const Mod = ScanModule(Text, Ast);
const Strings = StringTokenList(Text) || [];
for (const S of Strings) GlobalStrings.add(S.slice(1, -1));
for (const [K, V] of Object.entries(Mod.PropNames)) GlobalPropNames[K] = (GlobalPropNames[K] || 0) + V.length;
for (const [K, V] of Object.entries(Mod.PropDefs)) GlobalPropDefs[K] = (GlobalPropDefs[K] || 0) + V.length;
for (const [K, V] of Object.entries(Mod.ValueNames)) GlobalValueNames[K] = (GlobalValueNames[K] || 0) + V.length;
for (const S of Mod.Shorthand) GlobalShorthand.add(S);
Modules[Id] = { Id, File: F, Text, Mod, Strings, Ast };
}
if (ParseFail) { console.error('有 ' + ParseFail + ' 个模块解析失败,终止'); process.exit(2); }
const EvidenceByModule = NewTable();
for (const [Key, Val] of Object.entries(Evidence.byModule || {})) EvidenceByModule[String(Number(Key))] = Val;
// ---------------------------------------------------------------- 计划容器
const Categories = ['数据类名', '闭包名', '枚举成员', '字段→组件名', '属性名', '命令字段', '别名类名', '别名方法', '别名字段'];
const Reasons = ['Conflict', 'InStringLiteral', 'Whitelist', 'MissingEvidence'];
const Plans = [];
const Skips = [];
const AddPlan = (Module, Category, Namespace, Short, Long, Nodes) => {
Plans.push({ Module, Category, Namespace, Short, Long, Nodes: Nodes || [] });
};
const AddSkip = (Module, Category, Namespace, Short, Long, Reason, Detail) => {
Skips.push({ Module, Category, Namespace, Short, Long, Reason, Detail });
};
// ================================================================ ① 属性名(全局共享)
// 来源 A:evidence.fieldsGlobal(短字段 → 组件名) → 类别「字段→组件名」
// 来源 B:codec_fields 的 Slot / Prop → 字段名 → 类别「属性名」
const PropCandidates = [];
const GroupMap = (Obj) => {
const Out = {};
for (const [Short, Longs] of Object.entries(Obj || {})) {
for (const L of Longs) { if (!Out[Short]) Out[Short] = new Set(); Out[Short].add(L); }
}
return Out;
};
for (const [Short, Longs] of Object.entries(GroupMap(Evidence.fieldsGlobal))) {
// 每条证据已经带有「组件名」语义,取唯一值
const L = [...Longs];
if (L.length > 1) { AddSkip('*', '字段→组件名', 'Property', Short, L.join('/'), 'Conflict', '同一短字段对应 ' + L.length + ' 个组件名'); continue; }
PropCandidates.push({ Short, Long: L[0], Category: '字段→组件名' });
}
if (CodecFields && CodecFields.Codecs) {
const SlotMap = NewTable();
const PropMap = NewTable();
for (const Codec of Object.values(CodecFields.Codecs)) {
for (const F of Codec.Fields || []) {
const Long = F.Name;
if (!Long || Long === '?' || Long === '(匿名)') continue;
if (F.Slot) { if (!SlotMap[F.Slot]) SlotMap[F.Slot] = new Set(); SlotMap[F.Slot].add(Long); }
if (F.Prop) { if (!PropMap[F.Prop]) PropMap[F.Prop] = new Set(); PropMap[F.Prop].add(Long); }
}
}
for (const [Short, Longs] of Object.entries(SlotMap)) {
const L = [...Longs];
if (L.length > 1) { AddSkip('*', '属性名', 'Property', Short, L.join('/'), 'Conflict', 'codec 槽位在不同 codec 里对应不同字段名'); continue; }
PropCandidates.push({ Short, Long: L[0], Category: '属性名', Kind: 'codec 槽位' });
}
for (const [Short, Longs] of Object.entries(PropMap)) {
const L = [...Longs];
if (L.length > 1) { AddSkip('*', '属性名', 'Property', Short, L.join('/'), 'Conflict', '模型属性对应多个字段名'); continue; }
PropCandidates.push({ Short, Long: L[0], Category: '属性名', Kind: '模型属性' });
}
}
const PropertyRename = {}; // Short → Long
PropCandidates.sort((A, B) => (A.Short < B.Short ? -1 : A.Short > B.Short ? 1 : 0));
for (const C of PropCandidates) {
const { Short, Long, Category } = C;
const Rec = (Reason, Detail) => AddSkip('*', Category, 'Property', Short, Long, Reason, Detail);
if (HasExportSigil(Short) || HasExportSigil(Long)) { Rec('Whitelist', '含 $_$ 的导出名'); continue; }
const PS = NameProblem(Short, false);
if (PS) { Rec('Whitelist', '短名命中白名单:' + PS); continue; }
const PL = NameProblem(Long, true);
if (PL) { Rec('Whitelist', '目标名命中白名单:' + PL); continue; }
if (Short === Long) { Rec('Conflict', '目标名与短名相同'); continue; }
if (GlobalStrings.has(Short)) { Rec('InStringLiteral', '短名出现在字符串字面量里'); continue; }
if (GlobalPropNames[Short] === undefined) { Rec('MissingEvidence', '源码里找不到该属性名'); continue; }
if (GlobalPropNames[Long] !== undefined) { Rec('Conflict', '目标名已是既有属性名(会把两个不同字段合并成一个)'); continue; }
if (GlobalShorthand.has(Short)) { Rec('Conflict', '存在同名的简写属性(属性名与局部名是同一节点)'); continue; }
PropertyRename[Short] = Long;
}
for (const [Short, Long] of Object.entries(PropertyRename)) {
for (const M of Object.values(Modules)) if (M.Mod.PropNames[Short]) AddPlan(M.Id, null, 'Property', Short, Long, M.Mod.PropNames[Short]);
}
// 属性名类别需要区分「字段→组件名」与「属性名」——回填类别
const PropCategoryOf = {};
for (const C of PropCandidates) if (PropertyRename[C.Short] === C.Long && !PropCategoryOf[C.Short]) PropCategoryOf[C.Short] = C.Category;
for (const P of Plans) if (P.Namespace === 'Property') P.Category = PropCategoryOf[P.Short] || '属性名';
// ================================================================ ② 局部名(逐模块)
// 通用准入:全模块唯一绑定 + 所有值位引用都在该绑定作用域内 + 目标名在该模块的值命名空间里是新的
// 返回 '' 表示准入通过;否则返回跳过原因(同时已写入 Skips)
function LocalRenameProblem(M, Short, Long, Category) {
const Rec = (Reason, Detail) => { AddSkip(M.Id, Category, 'Local', Short, Long, Reason, Detail); return Reason; };
if (HasExportSigil(Short) || HasExportSigil(Long)) return Rec('Whitelist', '含 $_$ 的导出名');
const PS = NameProblem(Short, false);
if (PS) return Rec('Whitelist', '短名命中白名单:' + PS);
const PL = NameProblem(Long, true);
if (PL) return Rec('Whitelist', '目标名命中白名单:' + PL);
if (Short === Long) return Rec('Conflict', '目标名与短名相同');
if (GlobalStrings.has(Short)) return Rec('InStringLiteral', '短名出现在字符串字面量里');
const Refs = M.Mod.ValueNames[Short];
if (!Refs || !Refs.length) return Rec('MissingEvidence', '源码里找不到该局部名的使用点');
const B = M.Mod.Bindings[Short];
if (!B || !B.length) return Rec('MissingEvidence', '源码里找不到该名字的绑定声明');
if (B.length > 1) return Rec('Conflict', '全模块有 ' + B.length + ' 处同名绑定(存在遮蔽),改名无法保证一致');
const Scope = B[0];
for (const N of Refs) if (N.start < Scope.Start || N.end > Scope.End) return Rec('Conflict', '有引用落在绑定作用域之外(可能指向别处)');
if (M.Mod.Shorthand.has(Short)) return Rec('Conflict', '存在同名的简写属性');
if (M.Mod.ValueNames[Long]) return Rec('Conflict', '目标名在本模块的值命名空间里已被占用');
return '';
}
const LocalRenameAllowed = (M, Short, Long, Category) => LocalRenameProblem(M, Short, Long, Category) === '';
const LocalTargetUsed = NewTable(); // 模块号 → Set(目标名)
for (const M of Object.values(Modules)) {
const Used = new Set();
LocalTargetUsed[M.Id] = Used;
const Try = (Short, Long, Category) => {
if (!LocalRenameAllowed(M, Short, Long, Category)) return false;
if (Used.has(Long)) { AddSkip(M.Id, Category, 'Local', Short, Long, 'Conflict', '目标名在本模块已被其它改名占用'); return false; }
Used.add(Long);
AddPlan(M.Id, Category, 'Local', Short, Long, M.Mod.ValueNames[Short]);
return true;
};
// ---- 数据类名:直接用 AST 重新推导(比 mine_evidence 的正则更准),并与证据交叉核对
const Ev = EvidenceByModule[M.Id] || {};
const EvClassNames = Ev.classNames || {};
for (const [Cls, Names] of Object.entries(M.Mod.ClassNames)) {
const L = [...Names];
if (L.length > 1) { AddSkip(M.Id, '数据类名', 'Local', Cls, L.join('/'), 'Conflict', '同一个类名短名对应多个数据类真名'); continue; }
if (EvClassNames[Cls] && EvClassNames[Cls] !== L[0]) { AddSkip(M.Id, '数据类名', 'Local', Cls, L[0], 'Conflict', '与证据 ' + EvClassNames[Cls] + ' 不一致'); continue; }
Try(Cls, L[0], '数据类名');
}
for (const [Cls, Name] of Object.entries(EvClassNames)) {
if (!M.Mod.ClassNames[Cls]) AddSkip(M.Id, '数据类名', 'Local', Cls, Name, 'MissingEvidence', '证据里有但源码里复核不到该 toString 模式');
}
// ---- 闭包名:X.callableName = "…"
for (const [Short, Names] of Object.entries(M.Mod.Closures)) {
const L = [...Names];
if (L.length > 1) { AddSkip(M.Id, '闭包名', 'Local', Short, L.join('/'), 'Conflict', '同一短名对应 ' + L.length + ' 个闭包原名'); continue; }
Try(Short, L[0], '闭包名');
}
// ---- 枚举成员:Field = new Cls("Member", n)
const EvEnums = Ev.enums || {};
const SeenTriple = new Set();
for (const E of M.Mod.Enums) {
const Key = E.Cls + '|' + E.Field + '|' + E.Member;
if (SeenTriple.has(Key)) continue;
SeenTriple.add(Key);
const InEvidence = (EvEnums[E.Cls] || []).some((X) => X.field === E.Field && X.member === E.Member && X.value === E.Value);
if (!InEvidence) { AddSkip(M.Id, '枚举成员', 'Local', E.Field, E.Member, 'MissingEvidence', '证据里没有枚举 ' + E.Cls + ' 的该成员'); continue; }
Try(E.Field, E.Member, '枚举成员');
}
for (const [Cls, Members] of Object.entries(EvEnums)) {
for (const X of Members) {
if (!M.Mod.Enums.some((E) => E.Cls === Cls && E.Field === X.field && E.Member === X.member && X.value === E.Value)) {
AddSkip(M.Id, '枚举成员', 'Local', X.field, X.member, 'MissingEvidence', '证据里有但源码里复核不到(' + Cls + ')');
}
}
}
}
// ================================================================ ③ 命令字段
// uplink_table 的负载字段只有「命令对象上的 codec 槽位」(如 this.jh9_1.o7g(...)),源码里没有 codec_<名字> getter,
// 因此没有可回填的可读名 —— 全部按「证据缺失」计入报告(见 uplink_table.json 的 Note 自述)。
if (UplinkTable && UplinkTable.Commands) {
for (const Cmd of UplinkTable.Commands) {
// uplink_table 的 Module 形如 "module_10283",统一成去前导零的模块号,和报告其它地方保持一致
const Mid = String(Number(String(Cmd.Module || '0').replace(/^module_/, '')) || 0);
for (const F of Cmd.Fields || []) {
AddSkip(Mid, '命令字段', 'Property', F.Field, '', 'MissingEvidence', '上行命令负载字段没有保留可读名(无 codec_<名字> getter)');
}
}
}
// ================================================================ ③.5 派生命名(--aliases)
// 来源:names/command_alias.json。这些**不是**原始名,是我们读源码证实语义后起的别名,
// 与证据命名同表共存、但分开统计(报告 Aliases 段 + 控制台「派生命名」段)。
// 比证据命名多一道闸门:命名空间是 Property 时,该属性名在全库里必须**只有本模块这一处定义**
// (别的模块里的同名出现只允许是「读」)—— 属性名跨模块共享,若别处还有同名定义就是另一个字段,
// 全局改名会把两者合并成一个,直接跳过。
const AliasReport = {
Enabled: UseAliases, File: path.join(NamesDir, 'command_alias.json'), Loaded: !!AliasTable,
Entries: 0, Applied: [], Skipped: [], Note: [],
};
if (UseAliases && !AliasTable) AliasReport.Note.push('找不到 command_alias.json,本次没有别名可回填');
if (UseAliases && AliasTable) {
const AliasPropTargets = new Set(); // 别名已经占用的属性目标名
const AliasPropShorts = new Set(); // 已经被别名处理过的属性短名
const Applied = (E, Namespace, Count) => AliasReport.Applied.push({
Key: E.Key, Kind: E.Kind, Module: E.Module, Owner: E.Owner || '', Short: E.Short, Long: E.Long, Namespace, Count,
});
const Entries = [];
for (const [Key, Val] of Object.entries(AliasTable.Classes || {})) {
const P = String(Key).split('::');
Entries.push({ Key, Kind: 'Classes', Module: P[0], Owner: '', Short: P[1], Long: Val.Alias, Category: '别名类名', Namespace: 'Local' });
}
for (const Kind of ['Methods', 'Fields']) {
for (const [Key, Val] of Object.entries(AliasTable[Kind] || {})) {
const P = String(Key).split('::');
const Seg = String(P[1] || '').split('.');
Entries.push({
Key, Kind, Module: P[0], Owner: Seg.slice(0, -1).join('.'),
Short: Seg[Seg.length - 1], Long: Val.Alias,
Category: Kind === 'Methods' ? '别名方法' : '别名字段', Namespace: 'Property',
});
}
}
Entries.sort((A, B) => (A.Key < B.Key ? -1 : A.Key > B.Key ? 1 : 0));
AliasReport.Entries = Entries.length;
for (const E of Entries) {
const M = Modules[E.Module];
const Rec = (Reason, Detail) => {
AddSkip('*', E.Category, E.Namespace === 'Local' ? 'Local' : 'Property', E.Short, E.Long, Reason, Detail);
AliasReport.Skipped.push({ Key: E.Key, Kind: E.Kind, Short: E.Short, Long: E.Long, Reason, Detail });
};
if (!M) { Rec('MissingEvidence', '模块 ' + E.Module + ' 不在本次输入里'); continue; }
if (E.Namespace === 'Local') {
const Reason = LocalRenameProblem(M, E.Short, E.Long, E.Category);
if (Reason) { AliasReport.Skipped.push({ Key: E.Key, Kind: E.Kind, Short: E.Short, Long: E.Long, Reason, Detail: '局部名准入未通过,详见 SkipExamples' }); continue; }
const Used = LocalTargetUsed[E.Module];
if (Used.has(E.Long)) { Rec('Conflict', '目标名在本模块已被其它改名占用'); continue; }
const Nodes = M.Mod.ValueNames[E.Short];
Used.add(E.Long);
AddPlan(E.Module, E.Category, 'Local', E.Short, E.Long, Nodes);
Applied(E, 'Local', Nodes.length);
continue;
}
// ---- Property(属性名,全局共享)
if (HasExportSigil(E.Short) || HasExportSigil(E.Long)) { Rec('Whitelist', '含 $_$ 的导出名'); continue; }
const PS = NameProblem(E.Short, false);
if (PS) { Rec('Whitelist', '短名命中白名单:' + PS); continue; }
const PL = NameProblem(E.Long, true);
if (PL) { Rec('Whitelist', '目标名命中白名单:' + PL); continue; }
if (E.Short === E.Long) { Rec('Conflict', '目标名与短名相同'); continue; }
const Nodes = M.Mod.PropNames[E.Short];
if (!Nodes || !Nodes.length) { Rec('MissingEvidence', '本模块里找不到该属性名'); continue; }
if (GlobalStrings.has(E.Short)) { Rec('InStringLiteral', '短名出现在字符串字面量里'); continue; }
// 闸门:这个属性名在全库里必须**只有本模块这一处定义**。
// 别的模块里出现同名,只允许是「读」(调用点 / 取字段)—— 唯一的定义在我们这里,
// 那些读访问读的就只可能是这个属性,全局改名安全。
// 若别处还有同名定义,说明是另一个字段(Kotlin/JS 的短名会跨类复用),改名会把两者合并,必须跳过。
const DefsHere = (M.Mod.PropDefs[E.Short] || []).length;
const DefsAll = GlobalPropDefs[E.Short] || 0;
if (!DefsHere) { Rec('MissingEvidence', '本模块里找不到该属性名的定义处'); continue; }
if (DefsAll !== DefsHere) {
Rec('Conflict', '全库还有 ' + (DefsAll - DefsHere) + ' 处同名属性定义(全库 ' + DefsAll + ' 处 / 本模块 ' + DefsHere + ' 处),可能是另一个字段');
continue;
}
if (M.Mod.Shorthand.has(E.Short)) { Rec('Conflict', '存在同名的简写属性'); continue; }
if (PropertyRename[E.Short]) { Rec('Conflict', '该短名已被证据命名占用(→' + PropertyRename[E.Short] + ')'); continue; }
if (AliasPropShorts.has(E.Short)) { Rec('Conflict', '该短名已被另一条别名处理'); continue; }
if (GlobalPropNames[E.Long] !== undefined) { Rec('Conflict', '目标名已是既有属性名(会把两个不同字段合并成一个)'); continue; }
if (AliasPropTargets.has(E.Long) || Object.values(PropertyRename).indexOf(E.Long) >= 0) { Rec('Conflict', '目标名已被其它改名占用'); continue; }
AliasPropTargets.add(E.Long);
AliasPropShorts.add(E.Short);
let Count = 0;
for (const Other of Object.values(Modules)) {
if (!Other.Mod.PropNames[E.Short]) continue;
AddPlan(Other.Id, E.Category, 'Property', E.Short, E.Long, Other.Mod.PropNames[E.Short]);
Count += Other.Mod.PropNames[E.Short].length;
}
Applied(E, 'Property', Count);
}
}
// ================================================================ ④ 应用改名并写出
for (const P of Plans) P.Count = P.Nodes.length;
const EditsByModule = {};
for (const P of Plans) {
if (!EditsByModule[P.Module]) EditsByModule[P.Module] = [];
for (const N of P.Nodes) EditsByModule[P.Module].push({ Start: N.start, End: N.end, Text: P.Long });
}
// 从后往前替换,避免位移;同时检查区间不重叠
function ApplyEdits(Text, Edits) {
const Sorted = Edits.slice().sort((A, B) => B.Start - A.Start);
let Out = Text;
let Last = Infinity;
for (const E of Sorted) {
if (E.End > Last) throw new Error('替换区间重叠 @' + E.Start + '-' + E.End);
Out = Out.slice(0, E.Start) + E.Text + Out.slice(E.End);
Last = E.Start;
}
return Out;
}
const Outputs = {}; // 模块号 → 改名后的源码
for (const M of Object.values(Modules)) Outputs[M.Id] = ApplyEdits(M.Text, EditsByModule[M.Id] || []);
if (!DryRun) {
fs.mkdirSync(OutDir, { recursive: true });
for (const M of Object.values(Modules)) fs.writeFileSync(path.join(OutDir, M.File), Outputs[M.Id], 'utf8');
for (const F of OtherFiles) fs.copyFileSync(path.join(InDir, F), path.join(OutDir, F));
}
// ================================================================ ⑤ 校验
// ① node --check 语法
// ② 字符串字面量集合与原文逐模块完全一致
// ③ 标识符出现次数自检:被改的短名在各命名空间里清零、目标名增量等于替换次数
const Validation = { SyntaxChecked: 0, SyntaxFailed: [], StringIdentical: 0, StringMismatch: [], CountChecked: 0, CountMismatch: [], TotalIdentSame: true };
function ScanNamesOf(Text) {
const Ast = ParseSrc(Text);
const Mod = ScanModule(Text, Ast);
return Mod;
}
// 期望:按 (模块, 命名空间, 目标名) 汇总替换次数
const ExpectByTarget = {};
const ExpectShortGone = [];
for (const P of Plans) {
const Key = P.Module + '|' + P.Namespace + '|' + P.Long;
ExpectByTarget[Key] = (ExpectByTarget[Key] || 0) + P.Count;
ExpectShortGone.push({ Module: P.Module, Namespace: P.Namespace, Short: P.Short, Category: P.Category });
}
for (const M of Object.values(Modules)) {
const Text = Outputs[M.Id];
// ① 语法
if (!DryRun) {
const Res = spawnSync(process.execPath, ['--check', path.join(OutDir, M.File)], { encoding: 'utf8' });
Validation.SyntaxChecked++;
if (Res.status !== 0) Validation.SyntaxFailed.push(M.File + ':' + String(Res.stderr || '').split('\n').slice(0, 2).join(' '));
} else {
try { ParseSrc(Text); Validation.SyntaxChecked++; } catch (Err) { Validation.SyntaxFailed.push(M.File + ':' + Err.message); }
}
// ② 字符串字面量集合(逐模块多重集比对)
const After = StringTokenList(Text);
const Before = M.Strings;
const A = After ? After.slice().sort().join('\u0001') : '';
const B = Before.slice().sort().join('\u0001');
if (After && A === B) Validation.StringIdentical++;
else Validation.StringMismatch.push(M.File);
// ③ 标识符计数
let AfterMod;
try { AfterMod = ScanNamesOf(Text); } catch (Err) { AfterMod = null; }
if (!AfterMod) { Validation.CountMismatch.push(M.File + ':改名后无法解析'); continue; }
const CountIn = (Table, Name) => (Table[Name] ? Table[Name].length : 0);
let Bad = false;
for (const E of ExpectShortGone) {
if (E.Module !== M.Id) continue;
const Table = E.Namespace === 'Property' ? AfterMod.PropNames : AfterMod.ValueNames;
if (CountIn(Table, E.Short) !== 0) { Validation.CountMismatch.push(M.File + ':短名 ' + E.Short + ' 仍有残留(' + E.Category + ')'); Bad = true; }
}
for (const [Key, Expect] of Object.entries(ExpectByTarget)) {
const Parts = Key.split('|');
if (Parts[0] !== M.Id) continue;
const Table = Parts[1] === 'Property' ? AfterMod.PropNames : AfterMod.ValueNames;
const Actual = CountIn(Table, Parts[2]);
if (Actual !== Expect) { Validation.CountMismatch.push(M.File + ':' + Parts[2] + '(' + Parts[1] + ') 期望 ' + Expect + ' 实际 ' + Actual); Bad = true; }
}
// 总标识符出现次数必须与原文一致(只换了名字,不增删标识符)
const TotalBefore = Object.values(M.Mod.PropNames).reduce((A, V) => A + V.length, 0) + Object.values(M.Mod.ValueNames).reduce((A, V) => A + V.length, 0);
const TotalAfter = Object.values(AfterMod.PropNames).reduce((A, V) => A + V.length, 0) + Object.values(AfterMod.ValueNames).reduce((A, V) => A + V.length, 0);
if (TotalBefore !== TotalAfter) { Validation.TotalIdentSame = false; Bad = true; }
if (!Bad) Validation.CountChecked++;
}
// ================================================================ ⑥ 报告
const SumBy = (List, KeyFn) => List.reduce((A, V) => { const K = KeyFn(V); A[K] = (A[K] || 0) + 1; return A; }, {});
const Renamed = {};
for (const C of Categories) {
const L = Plans.filter((P) => P.Category === C);
const Entries = new Set(L.map((P) => P.Short + '→' + P.Long));
Renamed[C] = {
Entries: Entries.size, // 唯一「短名→可读名」条数
Edits: L.reduce((A, P) => A + P.Count, 0), // 实际替换的标识符出现次数
Examples: L.slice(0, 15).map((P) => ({ Module: P.Module, Short: P.Short, Long: P.Long, Count: P.Count })),
};
}
const Skipped = {};
const SkipExamples = {};
for (const C of Categories) {
Skipped[C] = {};
for (const R of Reasons) Skipped[C][R] = 0;
}
const SkipGroups = {};
for (const S of Skips) {
if (!Skipped[S.Category]) { Skipped[S.Category] = {}; for (const R of Reasons) Skipped[S.Category][R] = 0; }
Skipped[S.Category][S.Reason] = (Skipped[S.Category][S.Reason] || 0) + 1;
const K = S.Category + '|' + S.Reason;
SkipGroups[K] = SkipGroups[K] || { Count: 0, Detail: S.Detail, Examples: [] };
SkipGroups[K].Count++;
if (SkipGroups[K].Examples.length < 10) SkipGroups[K].Examples.push({ Module: S.Module, Short: S.Short, Long: S.Long, Detail: S.Detail });
}
for (const [K, V] of Object.entries(SkipGroups)) SkipExamples[K] = { Count: V.Count, Examples: V.Examples };
// 证据复核:AST 重新推导 vs 证据。要求「证据里的条目 100% 被 AST 复现」,反向多出来的必须是同源模式
const Recheck = {
ClassNamesEvidence: 0, ClassNamesFromAst: 0, ClassNamesEvidenceMissed: [],
ClosuresEvidence: 0, ClosuresFromAst: 0, ClosuresEvidenceMissed: [],
EnumMembersEvidence: 0, EnumMembersFromAst: 0, EnumMembersEvidenceMissed: [],
};
for (const [Id, Ev] of Object.entries(EvidenceByModule)) {
const M = Modules[Id];
if (!M) continue;
for (const [Cls, Name] of Object.entries(Ev.classNames || {})) {
Recheck.ClassNamesEvidence++;
if (!M.Mod.ClassNames[Cls] || !M.Mod.ClassNames[Cls].has(Name)) Recheck.ClassNamesEvidenceMissed.push('module_' + Id + ' ' + Cls + '=' + Name);
}
for (const [Short, Names] of Object.entries(Ev.closures || {})) {
for (const N of Names) {
Recheck.ClosuresEvidence++;
if (!M.Mod.Closures[Short] || !M.Mod.Closures[Short].has(N)) Recheck.ClosuresEvidenceMissed.push('module_' + Id + ' ' + Short + '=' + N);
}
}
for (const [Cls, Members] of Object.entries(Ev.enums || {})) {
for (const X of Members) {
Recheck.EnumMembersEvidence++;
if (!M.Mod.Enums.some((E) => E.Cls === Cls && E.Field === X.field && E.Member === X.member && E.Value === X.value)) Recheck.EnumMembersEvidenceMissed.push('module_' + Id + ' ' + Cls + ' ' + X.field + '=' + X.member);
}
}
}
for (const M of Object.values(Modules)) {
Recheck.ClassNamesFromAst += Object.keys(M.Mod.ClassNames).length;
Recheck.ClosuresFromAst += Object.values(M.Mod.Closures).reduce((A, V) => A + V.size, 0);
Recheck.EnumMembersFromAst += M.Mod.Enums.length;
}
const Report = {
GeneratedAt: new Date().toISOString(),
In: InDir,
Out: OutDir,
DryRun,
Parser: 'acorn (AST)',
ModuleCount: Object.keys(Modules).length,
OtherFilesCopied: OtherFiles,
Note: [
'属性名(JS 属性访问 / 对象字面量键)是全局共享的:先收集全局映射,再在全部模块里一次性写回。',
'局部名(类名 / 闭包名 / 枚举成员)只在所属模块内改,且要求「全模块唯一绑定 + 所有引用都在该作用域内」。',
'【规则取舍】原需求同时写了「属性名可全局一起改(定义处 + 字符串处一起改)」与「字符串字面量集合必须与原文完全一致」,二者不可兼得。',
' 本工具选择「绝不碰字符串」:短名只要在任何字符串字面量里出现过就跳过(属性名与局部名一视同仁)。',
' 对 s/t/n 这类单字母短名,「字符串处一起改」会把所有恰好等于该串的无关字面量(如单位后缀 "s")一起改掉,风险不可接受。',
'属性名目标若已是既有属性名:跳过(否则会把两个不同字段合并成一个)。',
'命令字段(uplink_table 的 555 条负载槽位)在源码里没有保留可读名,全部计入证据缺失。',
'闭包名 / 数据类名用与 mine_evidence.js 同源但更抗格式化的 AST 规则重新推导,并与证据交叉核对(见 Recheck)。',
' 证据里的条目必须 100% 被 AST 复现(*EvidenceMissed 应为空);AST 反向多出来的只是同一模式的漏配(如 prettier 换行导致正则抓不到)。',
'【派生命名】只有 --aliases 打开时才回填;来自 command_alias.json,是「读源码证实语义后起的别名」而非原始名,单独记在 Aliases 段,不计入上面的证据改名统计。',
],
Renamed,
Skipped,
SkipExamples,
Recheck,
Aliases: AliasReport,
Validation,
};
fs.mkdirSync(path.dirname(ReportPath), { recursive: true });
fs.writeFileSync(ReportPath, JSON.stringify(Report, null, 1), 'utf8');
// ---------------------------------------------------------------- 控制台摘要
console.log('源目录 : ' + InDir);
console.log('输出目录 : ' + OutDir + (DryRun ? ' (--dry:未写出)' : ''));
console.log('模块 : ' + Object.keys(Modules).length + ' 个;非 module 文件 ' + OtherFiles.length + ' 个');
console.log('');
console.log('=== 改名统计(Entries = 唯一短名→可读名 条数,Edits = 替换的标识符出现次数)===');
for (const C of Categories) {
const R = Renamed[C];
console.log(' ' + C.padEnd(7) + ' Entries ' + String(R.Entries).padStart(5) + ' Edits ' + String(R.Edits).padStart(7));
for (const E of R.Examples.slice(0, 4)) console.log(' 例: ' + C + ' ' + E.Short + ' → ' + E.Long + ' [' + (E.Module === '*' ? '全局' : 'module_' + E.Module) + ' ×' + E.Count + ']');
}
console.log('');
console.log('=== 跳过统计 ===');
const ReasonLabel = { Conflict: '冲突', InStringLiteral: '出现在字符串字面量里', Whitelist: '不可改名白名单', MissingEvidence: '证据缺失' };
for (const C of Categories) {
const R = Skipped[C] || {};
const Line = Reasons.map((X) => ReasonLabel[X] + ' ' + (R[X] || 0)).join(' ');
console.log(' ' + C.padEnd(7) + ' ' + Line);
}
console.log('');
console.log('=== 典型跳过例子 ===');
for (const [K, V] of Object.entries(SkipExamples)) {
if (!V.Count) continue;
const [Cat, Reason] = K.split('|');
console.log(' [' + Cat + ' / ' + ReasonLabel[Reason] + ' ×' + V.Count + ']');
for (const E of V.Examples.slice(0, 3)) console.log(' ' + (E.Module === '*' ? '全局' : 'module_' + E.Module) + ' ' + E.Short + (E.Long ? ' → ' + E.Long : '') + ' ' + E.Detail);
}
console.log('');
console.log('=== 派生命名(' + (UseAliases ? '已启用' : '未启用:加 --aliases 才会回填') + ')===');
console.log(' 词典 : ' + AliasReport.File + (AliasReport.Loaded ? '' : '(未找到)'));
console.log(' 条目 : ' + AliasReport.Entries + ' 条;采用 ' + AliasReport.Applied.length + ' 条,跳过 ' + AliasReport.Skipped.length + ' 条');
if (UseAliases) {
const ByCat = {};
for (const A of AliasReport.Applied) { const K = A.Kind; ByCat[K] = (ByCat[K] || 0) + 1; }
for (const A of AliasReport.Applied.slice(0, 12)) {
console.log(' ✓ ' + A.Key + ' ' + A.Short + ' → ' + A.Long + ' [' + A.Namespace + ' ×' + A.Count + ']');
}
if (AliasReport.Applied.length > 12) console.log(' … 另 ' + (AliasReport.Applied.length - 12) + ' 条见报告 Aliases.Applied');
const SkipByReason = {};
for (const S of AliasReport.Skipped) SkipByReason[S.Reason] = (SkipByReason[S.Reason] || 0) + 1;
for (const A of AliasReport.Skipped.slice(0, 8)) {
console.log(' × ' + A.Key + ' ' + A.Short + ' → ' + A.Long + ' [' + A.Reason + '] ' + A.Detail);
}
if (AliasReport.Skipped.length > 8) console.log(' … 另 ' + (AliasReport.Skipped.length - 8) + ' 条见报告 Aliases.Skipped');
console.log(' 采用明细: ' + Object.entries(ByCat).map(([K, V]) => K + ' ' + V).join(' ') || '(无)');
}
console.log('');
console.log('=== 校验 ===');
console.log(' ① node --check : 通过 ' + Validation.SyntaxChecked + ' / ' + Object.keys(Modules).length + (Validation.SyntaxFailed.length ? ' 失败: ' + Validation.SyntaxFailed.slice(0, 5).join(' | ') : ' ✓'));
console.log(' ② 字符串字面量集合 : 一致 ' + Validation.StringIdentical + ' / ' + Object.keys(Modules).length + (Validation.StringMismatch.length ? ' 不一致: ' + Validation.StringMismatch.slice(0, 5).join(', ') : ' ✓'));
console.log(' ③ 标识符计数自检 : 通过 ' + Validation.CountChecked + ' / ' + Object.keys(Modules).length + ';标识符总数不变 ' + (Validation.TotalIdentSame ? '✓' : '✗') + (Validation.CountMismatch.length ? ' 异常: ' + Validation.CountMismatch.slice(0, 5).join(' | ') : ' ✓'));
console.log(' ④ 证据复核(AST 复现): 数据类名 ' + Recheck.ClassNamesEvidence + '→' + Recheck.ClassNamesFromAst +
'(漏 ' + Recheck.ClassNamesEvidenceMissed.length + ');闭包名 ' + Recheck.ClosuresEvidence + '→' + Recheck.ClosuresFromAst +
'(漏 ' + Recheck.ClosuresEvidenceMissed.length + ');枚举成员 ' + Recheck.EnumMembersEvidence + '→' + Recheck.EnumMembersFromAst +
'(漏 ' + Recheck.EnumMembersEvidenceMissed.length + ')');
const Ok = !Validation.SyntaxFailed.length && !Validation.StringMismatch.length && !Validation.CountMismatch.length && Validation.TotalIdentSame &&
!Recheck.ClassNamesEvidenceMissed.length && !Recheck.ClosuresEvidenceMissed.length && !Recheck.EnumMembersEvidenceMissed.length;
console.log('');
console.log(Ok ? '全部校验通过。报告: ' + ReportPath : '校验未通过,请看上面的异常列表。报告: ' + ReportPath);
if (!Ok) process.exitCode = 1;
+157
View File
@@ -0,0 +1,157 @@
// audit_space_init.js - 审计 names/space-init-*.json 的记录切分
// 用法:
// node audit_space_init.js [--fix] [--names ../names]
//
// 为什么需要它:模板是 tools/extract_space_init.js 从抓包切出来的,而它当时的帧结构理解是
// 「[nullmap][spaceId][(methodId)(负载)…]」——把**第一条记录的 objectId** 当成了帧级 spaceId。
// 于是就出现一类系统性错误:一条记录的 `Hex` 里其实还塞着后面几条记录的头
// (因为切分点只在第一条记录之后才对齐)。字节没丢,但「记录」这个语义层级是错的:
// 想按记录改一条、或按 methodId 找一条,都会错位。
//
// 判据(不猜,靠已知 methodId 表):在 Hex 里每找到一处「8 字节 = 已知 methodId」,
// 且它前面 8 字节长得像 objectId(等于所属空间号 / 负数合成 id),就认为那里是一条记录的边界。
//
// --fix:把该记录拆成多条(每条自带 ObjectId + MethodId + Hex 负载)。
// 拆分是**字节中性**的:space.js 组帧时每条记录写 [objectId][methodId][负载],
// 拆前=拆后拼出来的帧必须逐字节相同(工具会自己断言,不一致就拒绝写回)。
'use strict';
const fs = require('fs');
const path = require('path');
const ToolsDir = __dirname;
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 NamesDir = path.resolve(ToolsDir, Arg('names', path.join('..', 'names')));
const Fix = Argv.indexOf('--fix') >= 0;
const Link = require('./server/link');
const Space = require('./server/space');
const Sem = require('./server/semantic').Load(NamesDir);
// 已知 methodId(下行 + 上行 + method_table):用来在 Hex 里找记录边界
// KindByMethod:只有「字段型」记录的边界能被确定性验证(字段列表消耗的字节是死的);
// 容器型(zt/pt/lt)的容器解码在多个候选边界上都可能"成功",会误判(实测 _loadDependencies
// 那帧被拆成 28 条),所以容器型一律只报告不拆。
const KnownMethods = new Set();
const KindByMethod = new Map();
for (const F of ['downlink_table.json', 'method_table.json']) {
const P = path.join(NamesDir, F);
if (!fs.existsSync(P)) continue;
const J = JSON.parse(fs.readFileSync(P, 'utf8'));
for (const C of J.Commands || []) {
KnownMethods.add(String(C.MethodId));
KindByMethod.set(String(C.MethodId), C.Kind || 'fields');
}
for (const R of J.Methods || J.Rows || []) {
KnownMethods.add(String(R.MethodId));
if (!KindByMethod.has(String(R.MethodId))) KindByMethod.set(String(R.MethodId), 'fields');
}
}
// 「长得像 objectId」:等于所属空间号、或负数合成 id(实测 -214748xxxx 一族)
const LooksLikeObjectId = (V, SpaceId) => {
const S = V.toString();
if (S === String(SpaceId)) return true;
if (V < 0n) return true;
return false;
};
// 在 Hex 里找记录边界:返回 [{At, ObjectId, MethodId, Known}],At = 记录头起始偏移
function FindRecordHeads(Buf, SpaceId) {
const Out = [];
for (let I = 0; I + 16 <= Buf.length; I++) {
const MethodId = Buf.readBigInt64BE(I + 8).toString();
if (!KnownMethods.has(MethodId)) continue;
const Obj = Buf.readBigInt64BE(I);
if (!LooksLikeObjectId(Obj, SpaceId)) continue;
Out.push({ At: I, ObjectId: Obj.toString(), MethodId });
}
return Out;
}
// 一条 Hex 负载 → 拆成 [{ObjectId, MethodId, Hex}];第一条沿用模板已有的 ObjectId/MethodId。
// 边界**必须被语义层确认**:当前记录的负载(到候选头为止)要能被 DecodeRecord 完整解出、
// 且恰好吃到候选头位置。只靠"字节中性"不够 —— 负载里偶然出现一个像 methodId 的 8 字节
// 也能凑出字节中性的拆分,所以必须有解码证据。
function SplitRecord(Rec, SpaceId, Sem) {
const Buf = Buffer.from(Rec.Hex || '', 'hex');
const Heads = FindRecordHeads(Buf, SpaceId);
if (!Heads.length) return null;
const PayloadFits = (MethodId, From, To) => {
// 只认字段型记录:容器型的解码在多个 End 上都可能通过(见文件头 KindByMethod 的说明)
if ((KindByMethod.get(String(MethodId)) || 'fields') === 'container') return false;
const R = Sem.DecodeRecord(MethodId, Buf, From, To);
return !R.Bad && From + R.Consumed === To;
};
let At = 0;
let Cur = { ObjectId: Rec.ObjectId, MethodId: Rec.MethodId };
const Out = [];
for (const H of Heads) {
if (H.At < At) continue;
if (!PayloadFits(Cur.MethodId, At, H.At)) continue; // 当前记录的负载解不到这个位置 → 不是边界
Out.push({ ...Cur, Hex: Buf.slice(At, H.At).toString('hex') });
Cur = { ObjectId: H.ObjectId, MethodId: H.MethodId };
At = H.At + 16; // 16 字节头被当成新记录的头吃掉
}
Out.push({ ...Cur, Hex: Buf.slice(At).toString('hex') });
return Out.length > 1 ? Out : null;
}
// 模板记录 → 组帧,用于「拆前/拆后字节必须相同」的断言
function FrameBytes(Space, Records, NullmapHex, SpaceId) {
return Space.BuildSpaceFrame(SpaceId, Records.map((R) => ({
ObjectId: R.ObjectId === undefined || R.ObjectId === null ? SpaceId : R.ObjectId,
MethodId: R.MethodId,
Payload: Buffer.from(R.Hex || '', 'hex'),
})), NullmapHex);
}
let Total = 0;
let Split = 0;
let Kept = 0;
const Lines = [];
for (const F of fs.readdirSync(NamesDir).filter((X) => /^space-init-.*\.json$/.test(X)).sort()) {
const P = path.join(NamesDir, F);
const T = JSON.parse(fs.readFileSync(P, 'utf8'));
let Changed = 0;
for (const Fr of T.Frames || []) {
// 帧级 SpaceId 可能被写成第一条记录的 objectId(见文件头)——组帧时需要真实空间号
const RealSpace = String(T.SpaceId);
for (let I = 0; I < (Fr.Records || []).length; I++) {
const R = Fr.Records[I];
Total++;
if (!R.Hex) continue;
const Parts = SplitRecord({ ...R, ObjectId: R.ObjectId === undefined ? RealSpace : R.ObjectId }, RealSpace, Sem);
if (!Parts) { Kept++; continue; }
const Before = FrameBytes(Space, [R.ObjectId === undefined ? { ...R, ObjectId: RealSpace } : R], Fr.NullmapHex, RealSpace);
const After = FrameBytes(Space, Parts, Fr.NullmapHex, RealSpace);
if (!Before.equals(After)) {
Lines.push(' ✗ ' + F + ' ' + Fr.File + ' methodId=' + R.MethodId +
' 拆分后字节不一致(拆前 ' + Before.length + 'B / 拆后 ' + After.length + 'B)→ 拒绝拆分');
Kept++;
continue;
}
Lines.push(' → ' + F + ' ' + Fr.File + ' methodId=' + R.MethodId + '(Hex ' + (R.Hex.length / 2) + 'B)' +
' 拆成 ' + Parts.length + ' 条:' + Parts.map((X) => X.ObjectId + '/' + X.MethodId + '=' + (X.Hex || '').length / 2 + 'B').join(' '));
if (Fix) {
Fr.Records.splice(I, 1, ...Parts);
I += Parts.length - 1;
}
Split++;
Changed++;
}
}
if (Fix && Changed) {
T.Note = (T.Note || '') + ' 【记录已按对账拆分:原来把后续记录的头塞在前一条的 Hex 里】';
fs.writeFileSync(P, JSON.stringify(T, null, 1) + '\n', 'utf8');
}
}
console.log('=== space-init 模板记录切分审计 ===');
console.log('模板目录: ' + NamesDir + ' 已知 methodId ' + KnownMethods.size + ' 条');
console.log('记录总数: ' + Total + ' 需要拆分: ' + Split + ' 保持不变: ' + Kept + ' 模式: ' + (Fix ? '已写回(--fix)' : '只审计'));
if (Lines.length) for (const L of Lines) console.log(L);
if (!Split) console.log(' (没有发现"Hex 里含后续记录头"的记录)');
+248
View File
@@ -0,0 +1,248 @@
// client_registry_dump.js - dump 协议注册表(空间层命令语义的正解)
// 前置:本地镜像的 module_61860.js 已加 3 行 [Hornet 调试] 暴露句柄(见下),
// 所以这里不用再"猜着找",直接读 window 上挂好的实例。
// window.__HornetModelReg__ = mr RPC 分发器 : v7n_1(methodId→modelId) / u7n_1(modelId→模型)
// / w7n_1(modelId→codec) / t7n_1 / s7n_1
// window.__HornetSpaceReg__ = or 空间命令注册表 : x7m_1 / y7m_1(id→codec)
// window.__HornetConn__ = ur 连接实例 : o7m_1(懒容器,含 "spaceRegistry") / p7m_1(分发器)
// 用法:进游戏(大厅/车库都行)→ 控制台整段粘贴 → copy(JSON.stringify(window.__regDump__))
(function () {
// ---------- 自检:确认浏览器跑的是"带钩子"的那份 module_61860 ==========
var M = window.__modules__ && window.__modules__[61860];
var SelfCheck = {
url: location.href,
frames: window.frames.length,
modules: window.__modules__ ? Object.keys(window.__modules__).length : 0,
mod61860Loaded: !!M,
mod61860HasHook: M ? String(M).indexOf('__Hornet') >= 0 : null,
wpRequire: typeof window.__wp_require__,
gameMode: typeof window.__gameMode__,
handleModelReg: typeof window.__HornetModelReg__,
handleSpaceReg: typeof window.__HornetSpaceReg__,
handleConn: typeof window.__HornetConn__,
};
console.log('[RegDump/自检] ' + JSON.stringify(SelfCheck));
if (SelfCheck.mod61860Loaded && SelfCheck.mod61860HasHook === false) {
console.warn('[RegDump/自检] 加载的是旧模块(无 __Hornet 钩子)→ 请硬刷新(Ctrl+Shift+R)或勾上 DevTools 的 Disable cache 再刷新');
}
if (SelfCheck.mod61860HasHook === true && SelfCheck.handleConn === 'undefined') {
console.warn('[RegDump/自检] 钩子已加载但没执行过 → 说明该连接类/引导函数在此流程里没被调用,需要换挂载点');
}
function CtorHead(V) {
try {
if (typeof V === 'function') return String(V).replace(/\s+/g, ' ').slice(0, 130);
var C = V && V.constructor;
return C ? String(C).replace(/\s+/g, ' ').slice(0, 130) : '';
} catch (E) {
return '';
}
}
function CtorName(V) {
try {
return (V && V.constructor && V.constructor.name) || typeof V;
} catch (E) {
return '?';
}
}
function Keys(V) {
try {
return Object.keys(V).slice(0, 50);
} catch (E) {
return [];
}
}
// 这些注册表字段是 Kotlin 的 Map 包装(.g3=put / .z2=get / .a3=keys),不是原生 Map
function IsKollection(V) {
return !!V && typeof V === 'object' && (typeof V.a3 === 'function' || typeof V.t === 'function');
}
// Kotlin 迭代器协议:t() 取迭代器,u() 有下一个,v() 取值
function Iterate(C) {
var Out = [];
var It = null;
try {
It = C.t ? C.t() : null;
} catch (E) {
It = null;
}
if (It && typeof It.u === 'function' && typeof It.v === 'function') {
var Guard = 0;
try {
while (It.u() && Guard++ < 5000) Out.push(It.v());
} catch (E2) {}
return Out;
}
try {
return Array.from(C);
} catch (E3) {
return Out;
}
}
// 统一取「键值对」:原生 Map / Kotlin Map 都吃
function Entries(M, Cap) {
var Out = [];
if (M instanceof Map) {
var N = 0;
M.forEach(function (Val, Key) {
if (N++ < Cap) Out.push({ key: Key, value: Val });
});
return Out;
}
if (IsKollection(M)) {
var Ks = [];
try {
Ks = typeof M.a3 === 'function' ? Iterate(M.a3()) : Iterate(M);
} catch (E) {
Ks = [];
}
for (var I = 0; I < Ks.length && I < Cap; I++) {
var K = Ks[I];
var V = K;
try {
if (typeof M.z2 === 'function') V = M.z2(K);
} catch (E2) {}
Out.push({ key: K, value: V });
}
return Out;
}
return Out;
}
function SizeOf(M) {
try {
if (typeof M.e1 === 'function') return M.e1();
} catch (E) {}
if (M instanceof Map) return M.size;
return -1;
}
// 键/值的可读渲染:这些是 Kotlin 对象,String() 常常是 "[object Object]"
// 优先 p57()(协议 id),其次 toString()(数据类都实现了,如 UserLabelData(userId=…)),
// 最后退化成「类名 + 前几个标量字段」
function KeyLabel(K) {
if (K === null || K === undefined) return String(K);
var T = typeof K;
if (T !== 'object' && T !== 'function') return String(K);
if (T === 'function') {
// 类当键(t7n_1 = 类→模型):直接用类名
try {
return 'class:' + (K.name || '?');
} catch (E0) {
return 'class:?';
}
}
try {
if (typeof K.p57 === 'function') {
var Id = K.p57();
if (Id !== null && Id !== undefined && typeof Id !== 'object') return 'id:' + String(Id);
}
} catch (E) {}
try {
var S = String(K);
if (S && S !== '[object Object]' && S !== '[object Function]') return S;
} catch (E2) {}
var Own = [];
var Ks = Object.keys(K).slice(0, 6);
for (var I = 0; I < Ks.length; I++) {
var V;
try {
V = K[Ks[I]];
} catch (E3) {
continue;
}
var R;
if (V === null || V === undefined) R = String(V);
else if (typeof V !== 'object') R = String(V);
else if (typeof V.p57 === 'function') {
try {
R = 'id:' + String(V.p57());
} catch (E4) {
R = '{obj}';
}
} else R = '{obj}';
Own.push(Ks[I] + '=' + R);
}
return CtorName(K) + '(' + Own.join(',') + ')';
}
function DumpMap(M, Cap) {
if (!(M instanceof Map) && !IsKollection(M)) return { kind: typeof M, size: -1, head: CtorHead(M) };
var Out = [];
var Ent = Entries(M, Cap);
for (var I = 0; I < Ent.length; I++) {
var Val = Ent[I].value;
var Key = Ent[I].key;
var T = typeof Val;
var IsPrim = Val === null || T === 'number' || T === 'string' || T === 'boolean' || T === 'bigint';
Out.push({
key: KeyLabel(Key),
raw: IsPrim ? String(Val) : undefined, // 保留原始标量(v7n_1 的值=模型哈希,join 要用)
ctor: CtorName(Val),
keys: Keys(Val),
head: CtorHead(Val),
text: (function () {
try {
var S = String(Val);
return S && S !== '[object Object]' ? S.slice(0, 300) : '';
} catch (E) {
return '';
}
})(),
});
}
return { kind: 'MapLike', size: SizeOf(M), entries: Out };
}
var Sources = {
ModelReg: window.__HornetModelReg__,
SpaceReg: window.__HornetSpaceReg__,
Conn: window.__HornetConn__,
};
var Dump = { handles: {}, maps: {} };
for (var Name in Sources) {
var H = Sources[Name];
Dump.handles[Name] = H ? { ctor: CtorName(H), keys: Keys(H) } : null;
if (!H) continue;
var Ks = Keys(H);
for (var I = 0; I < Ks.length; I++) {
var V;
try {
V = H[Ks[I]];
} catch (E) {
continue;
}
if (V instanceof Map || IsKollection(V)) Dump.maps[Name + '.' + Ks[I]] = DumpMap(V, 800);
else if (V && typeof V === 'object' && !Array.isArray(V)) {
// 懒容器(如 Conn.o7m_1)里可能嵌着 Map/集合,再挖一层
var K2 = Keys(V);
for (var J = 0; J < K2.length; J++) {
try {
var V2 = V[K2[J]];
if (V2 instanceof Map || IsKollection(V2)) Dump.maps[Name + '.' + Ks[I] + '.' + K2[J]] = DumpMap(V2, 400);
} catch (E2) {}
}
}
}
}
window.__regDump__ = Dump;
console.log('[RegDump] 句柄: ' + Object.keys(Dump.handles).map(function (N) {
return N + '=' + (Dump.handles[N] ? Dump.handles[N].keys.length + '字段' : '缺失');
}).join(' '));
Object.keys(Dump.maps).forEach(function (P) {
console.log(' ' + P + ' size=' + Dump.maps[P].size);
});
// 直接上传到本地服务器落盘(serve.js 的 /__dump__/ 端点,写到 3DTank/names/ 下)
try {
var Body = JSON.stringify(Dump);
fetch('/__dump__/regdump.json', { method: 'POST', body: Body }).then(function (R) {
console.log('[RegDump] 已上传 /__dump__/regdump.json → HTTP ' + R.status + '(' + Body.length + ' 字节)');
}).catch(function (E) {
console.warn('[RegDump] 上传失败,请手动 copy(JSON.stringify(window.__regDump__)):' + E.message);
});
} catch (E5) {
console.warn('[RegDump] 上传异常:' + E5.message);
}
console.log('[RegDump] 备用导出:copy(JSON.stringify(window.__regDump__))');
})();
+21
View File
@@ -0,0 +1,21 @@
// client_send_dump.js - 把客户端记录下来的"上行命令"上传到本地服务器,用于反查上行方法号
// 前置:本地镜像 module_61860.js 的发送点(Ie.i7q)已加 [Hornet 调试],
// 每次上行都会往 window.__HornetSendLog__ 里塞 {t, cls, text, len, hex}。
// 用法:进游戏点几下(车库/商店/任务…)→ 控制台粘贴本文件 → 等"已上传"提示
(function () {
var Log = window.__HornetSendLog__;
if (!Log || !Log.length) {
console.warn('[SendDump] window.__HornetSendLog__ 为空:确认镜像 module_61860.js 已带调试钩子,并且刷新过页面');
return;
}
console.log('[SendDump] 共 ' + Log.length + ' 条上行记录,样例:');
Log.slice(-8).forEach(function (E) {
console.log(' len=' + E.len + ' cls=' + E.cls + ' ' + String(E.text).slice(0, 90));
});
var Body = JSON.stringify(Log);
fetch('/__dump__/sendlog.json', { method: 'POST', body: Body }).then(function (R) {
console.log('[SendDump] 已上传 /__dump__/sendlog.json → HTTP ' + R.status + '(' + Body.length + ' 字节)');
}).catch(function (E) {
console.warn('[SendDump] 上传失败:' + E.message + '(可手动 copy(JSON.stringify(window.__HornetSendLog__)))');
});
})();
+255
View File
@@ -0,0 +1,255 @@
// decrypt_frames.js - 破解并解密游戏通信流(自变体 XOR 流密码)
// 用法: node decrypt_frames.js [--dir ../packets] [--label relay-04] [--key N] [--verbose]
//
// 密码算法(逆向自 module_61860,类 ir):
// 构造: key[8] 数组,key[i] = K ^ (i << 3) (解码方向 g7m_1)
// key[i] = K ^ (i << 3) ^ 87 (编码方向 i7m_1)
// 解码: p = c ^ key[idx]; key[idx] = p; idx ^= (p & 7)
// 编码: c = p ^ key[idx]; key[idx] = p; idx ^= (p & 7) —— 与解码同构
// K 由握手数据折叠而来(1 字节),故可直接穷举 256 种可能:
// 正确的 K 会让解密后的每个 WebSocket 消息都成为长度自洽的帧。
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const PROJECT = path.resolve(__dirname, '..');
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 PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets')));
const LABEL = Arg('label', '');
const FIXED_KEY = Arg('key', null);
const VERBOSE = Argv.indexOf('--verbose') >= 0;
// 帧头:{ TotalSize, HeaderSize, BodySize } —— 返回 null 表示数据不足
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,
Long: true,
Compressed: (B0 & 0x40) !== 0,
};
}
return { TotalSize: 2 + (((B0 & 0x3f) << 8) | Buf[1]), HeaderSize: 2, Long: false, Compressed: (B0 & 0x40) !== 0 };
}
// 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);
}
// 流密码解密:Bufs 依次解密,密钥状态跨消息连续
function DecryptStream(Bufs, Key, Encoded) {
const Slot = new Int32Array(8);
for (let I = 0; I < 8; I++) Slot[I] = ((Key ^ (I << 3)) ^ (Encoded ? 87 : 0)) & 0xff;
let Idx = 0;
return Bufs.map((Buf) => {
const Out = Buffer.alloc(Buf.length);
for (let I = 0; I < Buf.length; I++) {
const Plain = (Buf[I] ^ Slot[Idx]) & 0xff;
Out[I] = Plain;
Slot[Idx] = Plain;
Idx = Idx ^ (Plain & 7);
}
return Out;
});
}
// 评分:解密后有多少条消息是长度自洽的帧
function Score(Decoded) {
let Ok = 0;
let Ping = 0;
for (const Buf of Decoded) {
const Info = PeekFrame(Buf);
if (Info && Info.TotalSize === Buf.length) {
Ok++;
const At = Info.HeaderSize + NullmapSize(Buf, Info.HeaderSize);
if (At >= Buf.length) Ping++; // 空 payload = 保活
}
}
return { Ok, Ping };
}
function LoadSessions() {
const Labels = LABEL
? [LABEL]
: fs
.readdirSync(PACKET_DIR, { withFileTypes: true })
.filter((D) => D.isDirectory())
.map((D) => D.name);
const Sessions = [];
for (const L of Labels) {
const Dir = path.join(PACKET_DIR, L);
if (!fs.existsSync(Dir)) continue;
for (const D of fs.readdirSync(Dir, { withFileTypes: true })) {
if (!D.isDirectory() || !D.name.startsWith('session_')) continue;
const SessionDir = path.join(Dir, D.name);
const Messages = { c2s: [], s2c: [] };
for (const F of fs.readdirSync(SessionDir).sort()) {
const M = /^(c2s|s2c)_\d+\.bin$/.exec(F);
if (M) Messages[M[1]].push(fs.readFileSync(path.join(SessionDir, F)));
}
Sessions.push({ Name: L + '/' + D.name, Messages });
}
}
return Sessions;
}
const Sessions = LoadSessions().filter((S) => !Arg('session', '') || S.Name.indexOf(Arg('session', '')) >= 0);
console.log('抓包目录: ' + PACKET_DIR + ' 会话数: ' + Sessions.length);
// 全量命令统计(只统计密钥完全自洽「解出来的」会话方向,避免脏数据污染)
const Tally = { c2s: {}, s2c: {} };
const Keys = {};
function CountMessage(Direction, Buf) {
const Info = PeekFrame(Buf);
if (!Info || Info.TotalSize !== Buf.length) {
Tally[Direction].BAD = (Tally[Direction].BAD || 0) + 1;
return;
}
let Body = Buf.slice(Info.HeaderSize);
if (Info.Compressed) {
// 帧头 0x40 位 = 帧体被 zlib 压缩(客户端用 fflate 的 unzlibSync)
Tally[Direction].COMPRESSED = (Tally[Direction].COMPRESSED || 0) + 1;
try {
Body = zlib.inflateSync(Body);
Tally[Direction].UNZIP_OK = (Tally[Direction].UNZIP_OK || 0) + 1;
} catch (Err) {
Tally[Direction].UNZIP_FAIL = (Tally[Direction].UNZIP_FAIL || 0) + 1;
return;
}
}
const At = NullmapSize(Body, 0);
if (At >= Body.length) {
Tally[Direction].PING = (Tally[Direction].PING || 0) + 1;
return;
}
const Id = Body[At];
const E = (Tally[Direction][Id] = Tally[Direction][Id] || { Count: 0, Bytes: 0, Sample: Body.slice(At).slice(0, 24) });
E.Count++;
E.Bytes += Body.length - At;
}
for (const Session of Sessions) {
for (const Direction of ['c2s', 's2c']) {
const All = Session.Messages[Direction];
if (!All.length) continue;
const Encoded = Direction === 'c2s'; // 客户端编码用 i7m_1(^87)
const Total = All.reduce((A, B) => A + B.length, 0);
// 密码可能在会话中途才启用,故同时穷举「起始消息下标 Start」:
// Start 之前的消息保持明文,Start 起的消息用新密钥状态解密
const Results = [];
for (let Start = 0; Start < All.length; Start++) {
// 前缀必须是「明文且自洽」的帧,否则这个起始点不成立(防止把乱码当明文刷分)
let Bad = 0;
for (let I = 0; I < Start; I++) {
const Info = PeekFrame(All[I]);
if (!Info || Info.TotalSize !== All[I].length) Bad++;
}
if (Bad) continue;
const Bufs = All.slice(Start);
const Keys = FIXED_KEY !== null ? [Number(FIXED_KEY)] : Array.from({ length: 256 }, (_, I) => I);
for (const K of Keys) {
const S = Score(DecryptStream(Bufs, K, Encoded));
Results.push({ K, Start, Ok: S.Ok + Start, CipherOk: S.Ok, Ping: S.Ping, N: All.length });
}
}
Results.sort((A, B) => B.Ok - A.Ok || B.Ping - A.Ping || A.Start - B.Start);
const Best = Results[0];
const RunnerUp = Results[1];
const Perfect = Best.Ok === All.length;
const Verdict = Perfect ? '★ 全部自洽' : Best.Ok > (RunnerUp ? RunnerUp.Ok : 0) ? '领先' : '不确定';
Keys[Session.Name + ' ' + Direction] = { K: Best.K, Start: Best.Start, Perfect };
// 只有完全自洽的方向才计入总表
if (Perfect) {
All.slice(0, Best.Start).forEach((Buf) => CountMessage(Direction, Buf));
DecryptStream(All.slice(Best.Start), Best.K, Encoded).forEach((Buf) => CountMessage(Direction, Buf));
}
console.log(
'\n' + Session.Name + ' ' + Direction + ' 消息=' + All.length + ' 字节=' + Total +
' → 最佳 明文' + Best.Start + '条 + K=' + Best.K + ' 加密' + Best.CipherOk + '条' +
' 合计自洽=' + Best.Ok + '/' + All.length + ' 保活=' + Best.Ping +
' 次优 ' + (RunnerUp ? '明文' + RunnerUp.Start + '+K=' + RunnerUp.K + '(' + RunnerUp.Ok + ')' : '-') + ' ' + Verdict,
);
if (VERBOSE) {
const Decoded = DecryptStream(All.slice(Best.Start), Best.K, Encoded);
All.slice(0, Best.Start).forEach((Buf, I) => console.log(' #' + String(I).padStart(3) + ' [明文] ' + Buf.slice(0, 24).toString('hex')));
Decoded.forEach((Buf, I) => {
const Info = PeekFrame(Buf);
const At = Info && Info.TotalSize === Buf.length ? Info.HeaderSize + NullmapSize(Buf, Info.HeaderSize) : -1;
const Id = At >= 0 && At < Buf.length ? Buf[At] : -1;
const Ascii = Array.from(Buf.slice(0, 36))
.map((B) => (B >= 32 && B < 127 ? String.fromCharCode(B) : '.'))
.join('');
console.log(
' #' + String(I + Best.Start).padStart(3) + ' ' + String(Buf.length).padStart(6) + 'B 长=' + (Info ? Info.TotalSize : '?') +
' 命令ID=' + String(Id).padStart(4) + ' ' + Buf.slice(0, 28).toString('hex') + ' |' + Ascii + '|',
);
});
}
}
}
// ============================================================
// 解密后的命令总表
// ============================================================
const CMD_NAME = {
c2s: { 1: 'Hash Request', 3: 'Auth/EnterSpace(待证)', 32: 'ClientLog' },
s2c: { 0: '待证(带 int64 与时间戳)', 2: 'Hash Response', 32: 'Open Space' },
};
function Report(Direction) {
console.log('\n===== 解密后命令总表(' + (Direction === 'c2s' ? '客户端 → 服务器' : '服务器 → 客户端') + ')=====');
const Table = Tally[Direction];
const Ids = Object.keys(Table)
.filter((K) => /^\d+$/.test(K))
.map(Number)
.sort((A, B) => A - B);
for (const Id of Ids) {
const E = Table[Id];
const Name = (CMD_NAME[Direction] && CMD_NAME[Direction][Id]) || '未知';
console.log(
' ID=' + String(Id).padStart(4) + ' ' + Name.padEnd(24) + ' 次数=' + String(E.Count).padStart(5) +
' 负载=' + String(E.Bytes).padStart(8) + 'B 样本=' + E.Sample.toString('hex'),
);
}
if (Table.PING) console.log(' [保活 ping] ' + Table.PING + ' 条');
if (Table.COMPRESSED) console.log(' [压缩帧] ' + Table.COMPRESSED + ' 条(解压成功 ' + (Table.UNZIP_OK || 0) + ' / 失败 ' + (Table.UNZIP_FAIL || 0) + ')');
if (Table.BAD) console.log(' [解不开/残缺] ' + Table.BAD + ' 条');
}
Report('c2s');
Report('s2c');
const Out = {
Note: '由 decrypt_frames.js 暴力穷举单字节密钥后统计,仅包含完全自洽的会话方向',
Keys,
C2s: {},
S2c: {},
};
for (const Direction of ['c2s', 's2c']) {
const Target = Direction === 'c2s' ? Out.C2s : Out.S2c;
for (const K of Object.keys(Tally[Direction])) {
const E = Tally[Direction][K];
Target[K] = E.Count !== undefined ? { Name: CMD_NAME[Direction][K] || '未知', Count: E.Count, Bytes: E.Bytes, Sample: E.Sample.toString('hex') } : E;
}
}
const OutFile = path.join(PROJECT, 'names', 'commands_decrypted.json');
fs.writeFileSync(OutFile, JSON.stringify(Out, null, 2), 'utf8');
console.log('\n已写出: ' + OutFile);
+123
View File
@@ -0,0 +1,123 @@
// extract.js - 把 webpack bundle 拆成可读源码工程
// 用法: node extract.js [bundle路径] [输出目录]
// 默认 bundle = ../../main.6469b966.js(TankIC 根目录)
// 默认输出 = ../src
// 输出:
// src/module_<id>.js 每个代码模块(prettier 美化)
// src/resources.json 资源模块 ID → 路径
// src/index.json 模块索引(id / 文件 / 是否资源)
const fs = require('fs');
const path = require('path');
const acorn = require('acorn');
const walk = require('acorn-walk');
const prettier = require('prettier');
const PROJECT = path.resolve(__dirname, '..');
const argv = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const BUNDLE_PATH = path.resolve(argv[0] || path.join(PROJECT, '..', 'main.6469b966.js'));
const SRC_DIR = path.resolve(argv[1] || path.join(PROJECT, 'src'));
const RESOURCES_JSON = path.join(SRC_DIR, 'resources.json');
const INDEX_JSON = path.join(SRC_DIR, 'index.json');
function tryExtractResource(valueSrc) {
let m = valueSrc.match(/\{\s*"use strict";\s*(?:t|n|i)\.exports\s*=\s*(?:t|n|i)\.p\+"([^"]+)"\s*;?\s*\}/);
if (m) return m[1];
m = valueSrc.match(/(?:t|n|i)\.exports\s*=\s*(?:t|n|i)\.p\+"([^"]+)"\s*;?/);
if (m && valueSrc.length < 200) return m[1];
return null;
}
(async () => {
if (!fs.existsSync(BUNDLE_PATH)) {
console.error('找不到 bundle: ' + BUNDLE_PATH);
process.exit(1);
}
if (!fs.existsSync(SRC_DIR)) fs.mkdirSync(SRC_DIR, { recursive: true });
// 清理旧产物
for (const f of fs.readdirSync(SRC_DIR)) {
if (/^module_\d+\.js$/.test(f) || f === 'resources.json' || f === 'index.json') {
fs.unlinkSync(path.join(SRC_DIR, f));
}
}
console.log('[1/4] 读取 bundle: ' + BUNDLE_PATH);
const source = fs.readFileSync(BUNDLE_PATH, 'utf8');
console.log(' 大小: ' + (source.length / 1024 / 1024).toFixed(2) + ' MB');
console.log('[2/4] 解析 AST...');
const ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'script',
allowReturnOutsideFunction: true,
});
let modulesDecl = null;
walk.simple(ast, {
VariableDeclarator(node) {
if (node.id && node.id.name === '__webpack_modules__' && node.init && node.init.type === 'ObjectExpression') {
modulesDecl = node;
}
},
});
if (!modulesDecl) throw new Error('未找到 __webpack_modules__');
console.log('[3/4] 拆分模块...');
const props = modulesDecl.init.properties;
const resourceMap = {};
const index = [];
let codeCount = 0, resourceCount = 0, failCount = 0;
for (const prop of props) {
let keyRaw, keyNum;
if (prop.key.type === 'Identifier') {
keyRaw = prop.key.name;
keyNum = Number(keyRaw);
} else {
keyRaw = String(prop.key.value);
keyNum = typeof prop.key.value === 'number' ? prop.key.value : keyRaw;
}
const valueSrc = source.slice(prop.value.start, prop.value.end);
const resourcePath = tryExtractResource(valueSrc);
if (resourcePath) {
resourceMap[keyNum] = resourcePath;
resourceCount++;
index.push({ id: keyNum, type: 'resource', path: resourcePath });
continue;
}
let beautified = valueSrc;
try {
beautified = await prettier.format('(' + valueSrc + ')', {
parser: 'babel',
semi: true,
singleQuote: false,
printWidth: 110,
tabWidth: 2,
});
beautified = beautified.trim();
if (beautified.startsWith('(') && beautified.endsWith(')')) beautified = beautified.slice(1, -1).trim();
while (beautified.endsWith(';')) beautified = beautified.slice(0, -1).trimEnd();
} catch (e) {
failCount++;
}
const padded = String(keyNum).padStart(5, '0');
const file = 'module_' + padded + '.js';
const header =
'// ============================================================\n' +
'// Module ID: ' + keyRaw + '\n' +
'// ============================================================\n';
fs.writeFileSync(path.join(SRC_DIR, file), header + ';(window.__modules__ = window.__modules__ || {})[' + keyNum + '] = ' + beautified + ';\n', 'utf8');
codeCount++;
index.push({ id: keyNum, type: 'code', file });
}
console.log('[4/4] 写 resources.json / index.json...');
fs.writeFileSync(RESOURCES_JSON, JSON.stringify({ type: 'webpack-resource-map', resourceMap }, null, 0), 'utf8');
fs.writeFileSync(INDEX_JSON, JSON.stringify({ bundle: path.basename(BUNDLE_PATH), modules: index }, null, 1), 'utf8');
console.log('代码模块 ' + codeCount + ' / 资源模块 ' + resourceCount + ' / 美化失败 ' + failCount);
console.log('输出目录: ' + SRC_DIR);
})().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});
+401 -15
View File
@@ -1,9 +1,10 @@
// 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 片段
// node extract_codec_fields.js // 扫 src/ 全部模块(默认)
// node extract_codec_fields.js --scan // 同上(保留这个写法)
// node extract_codec_fields.js --module ../src/module_54199.js // 只扫一个模块(调试用)
// node extract_codec_fields.js --show st,ft // 打印指定 codec 的字段表
// node extract_codec_fields.js --find-method <id> // 反查 methodId 的字段表
//
// 原理(Kotlin/JS 协议 codec 的三段式结构;codec 变量名只在本模块内唯一,故键 = 模块::变量名):
// ① 字段名 getter: function G(t) { var n = t.<slot>; if (null != n) return n; v("codec_<字段名>"); }
@@ -16,7 +17,9 @@
// 位宽: 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
// ⑤ 顶层 SlotTypes:全库扫 `<接收者>.<槽位> = <工厂>(<接收者>).n7f(new <描述符>)`(q7g 之外的局部 codec 实例),
// 供下行字段表定位「q7i 分支里读的那个槽位是什么类型」
// 产物: names/codec_fields.json(+ names/uplink_table.json)
const fs = require('fs');
const path = require('path');
@@ -28,14 +31,18 @@ const Arg = (Name, 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 ModuleArg = Arg('module', '');
// 默认就是整库扫:本脚本的产物 names/codec_fields.json 永远是「全库表」,
// 只扫一个模块会把它覆盖成残表(下游 mine_downlink / lib_space_codec 会直接失效),
// 所以「只扫单模块」必须显式给 --module(或 --scan 与 --module 并存时以 --scan 为准)。
const Scan = Argv.indexOf('--scan') >= 0 || !ModuleArg;
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')))];
: [path.resolve(ModuleArg)];
// ---------------------------------------------------------------- 通用工具
// 配平扫描:从 Open 处的开括号找到对应闭括号(跳过字符串字面量)
@@ -84,6 +91,22 @@ function SplitArgs(Text) {
return Out;
}
// 偏移量 → 行号:SlotTypes 的 Desc 必须能指回源码(如 module_10283.js:2343),否则没法核证据
function MakeLineIndex(Text) {
const Starts = [0];
for (let I = 0; I < Text.length; I++) if (Text[I] === '\n') Starts.push(I + 1);
return (Off) => {
let Lo = 0;
let Hi = Starts.length - 1;
while (Lo < Hi) {
const Mid = (Lo + Hi + 1) >> 1;
if (Starts[Mid] <= Off) Lo = Mid;
else Hi = Mid - 1;
}
return Lo + 1;
};
}
// ---------------------------------------------------------------- 类型解析
// 基础类型注册表的访问器 → 可读类型名
// 来源: module_37036 的 `Wr($_).<accessor> = function () { return this.<xxx>Class; }`
@@ -132,6 +155,7 @@ const ReaderType = {
// 描述符类只在别处定义,各模块用局部别名引用,因此先按「角色」识别别名(见 FindRoles)。
// 角色 List → ListCodecInfo(element, optional) → list<...>
// 角色 Enum → EnumCodecInfo(type, optional) → enum<...>
// 角色 Map → MapCodecInfo(key, value, optional) → map<K, V>
// 角色 Type → TypeCodecInfo(type, optional) → ...
// 角色 KClass→ kClass(X) → X
// 角色 Prim → KClass 注册表实例,X().pb() 取基础类型类 → bool/i32/string/...
@@ -152,6 +176,15 @@ function TypeOfDescriptor(Expr, Roles, Depth) {
let Base;
if (Role === 'List') {
Base = 'list<' + TypeOfDescriptor(First, Roles, Depth + 1).Type + '>';
} else if (Role === 'Map') {
// map 描述符:MapCodecInfo(keyCodec, valueCodec, optional)(src/module_24635.js:262-264 的 pn 构造函数:
// this.t7h_1 = key、this.u7h_1 = value,第 3 参 optional 走 cn.call(this, i))。
// 真正上线的是 src/module_24635.js:489-512 的 Jt:o7g/p7g 先写/读一个变长条数(w.s7g/w.t7g,
// 与 list/string 同一个长度编解码,见 src/module_24635.js:589-611),再逐条写/读 key、value。
// 所以线上形状 = map<K, V>,K/V 各自按自己的描述符编码。
const K = TypeOfDescriptor(Args[0] || '', Roles, Depth + 1).Type;
const V = TypeOfDescriptor(Args[1] || '', Roles, Depth + 1).Type;
Base = 'map<' + (K || '?') + ', ' + (V || '?') + '>';
} else {
const Inner = TypeOfDescriptor(First, Roles, Depth + 1).Type;
if (Role === 'Enum') Base = 'enum<' + Inner + '>';
@@ -182,6 +215,8 @@ function JsTypeOf(Seg) {
// $_$.e = gn = TypeCodecInfo(type, optional) 单值
// $_$.c = wn = ListCodecInfo(element, optional) 列表
// $_$.b = vn = EnumCodecInfo(type, optional) 枚举
// $_$.d = pn = MapCodecInfo(key, value, optional) 映射(README 里说的「三兄弟」其实有四兄弟,
// d 这一支以前没被认出来,于是 map 字段被当成 enum 之类按 4 字节读 → 负载错位)
// 基础类型注册表与 kClass 助手在 module_37036:$_$.w4 = j_()、$_$.mf = x_(cls)
const CODEC_INFO_MODULE = '24635';
const KOLIB_MODULE = '37036';
@@ -189,6 +224,7 @@ const ROLE_KEYS = {
[CODEC_INFO_MODULE + '::e']: 'Type',
[CODEC_INFO_MODULE + '::c']: 'List',
[CODEC_INFO_MODULE + '::b']: 'Enum',
[CODEC_INFO_MODULE + '::d']: 'Map',
[KOLIB_MODULE + '::w4']: 'Prim',
[KOLIB_MODULE + '::mf']: 'KClass',
};
@@ -439,7 +475,7 @@ function JoinRegistries(Codecs, RegPath) {
// ③ 没有 toString 的类(非 data class)→ 用「模型类 → codec」反查,
// 这样即使名字还原不了,也至少能指向解码该嵌套模型用的 codec
const PRIM_WORDS = new Set(
Object.values(PrimTable).concat(['i8', 'i16', 'i32', 'i64', 'f32', 'f64', 'bytes', 'opt', 'list', 'enum'])
Object.values(PrimTable).concat(['i8', 'i16', 'i32', 'i64', 'f32', 'f64', 'bytes', 'opt', 'list', 'enum', 'map'])
);
// 返回 { Name, Via, Mod, Class };Via 为空表示没还原出原名
@@ -471,15 +507,47 @@ function ResolveOne(Name, ModId, ModuleInfo, ClassToCodec) {
return { Name: R.Name, Codec: '' };
}
// 名字在本模块里的「引用链」:每个跳点写成 模块::导出键(= PrimClassWire 的键)。
// 为什么要这条链而不是还原后的类名:ResolveClass 把跨模块引用还原成**那个模块里的本地类名**,
// 而这类本地名是混淆器按模块发的短名(如 15 个模块各有一个 `ji`),拿它查表会撞名;
// 模块::导出键才是唯一身份(module_37036.js:11088 的 qj 只有一个 ji)。
function AliasChain(Name, ModId, ModuleInfo) {
const Hops = [];
let Cur = Name;
let Mod = ModId;
for (let I = 0; I < 6; I++) {
const Info = ModuleInfo[Mod];
const A = Info && Info.Aliases[Cur];
if (!A) break;
Hops.push(A.Mod + '::' + A.Key);
const Dep = ModuleInfo[A.Mod];
const Cls = (Dep && Dep.Exports[A.Key]) || '';
if (!Cls) break;
Cur = Cls;
Mod = A.Mod;
}
return Hops;
}
// 类型串 → { Type, Codecs };把里面的裸类名逐个替换掉
function ResolveType(Type, ModId, ModuleInfo, ClassToCodec) {
// Trace(可选,调用方给就填):把「既没有 codec、也不在 WireClasses / PrimClassWire 里」的裸类名收进 Leftover。
// SlotTypes 段靠它判「类型到底推没推出来」——残留裸名意味着线上没有解码器,只能如实留空。
function ResolveType(Type, ModId, ModuleInfo, ClassToCodec, Trace) {
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];
else {
// Kolib 原始类型类(kClass(跨模块别名的原始类型类),如 module_47243.js:181 的 xi = 37036::qj
// = Long):按模块::导出键查表换成线上类型(见 PrimClassWire 的注释)。
const Hop = AliasChain(W, ModId, ModuleInfo).find((H) => PrimClassWire[H]);
// 协议核心类没有 codec 时按「线上基础类型」换算(如实体引用 xr → i64),否则保留类名
if (Hop) return PrimClassWire[Hop];
if (WireClasses[R.Name]) return WireClasses[R.Name];
// 两条都没命中:这个裸类名在线上没有解码器,记进 Leftover 让调用方判「类型没推出来」
else if (Trace) Trace.Leftover.push(W);
}
return R.Name;
});
return { Type: Out, Codecs: Refs };
@@ -540,6 +608,25 @@ const WRITER_TYPE = {
h2u: 'f64', m2u: 'f64',
};
const WireClasses = {};
// 「Kolib 原始类型类 → 线上类型」,键 = **模块::导出键**(不是裸类名)。
// 为什么必须带模块:这类类的名字是混淆器按模块发的短名,全库有 15 个模块各有一个 `ji`
// (src/module_37036.js:661 才是 Kolib 的 Long,其余同名者与协议无关),裸类名当键必然串味。
// 证据链(登记点在下面循环里,消费点在 ResolveType):
// src/module_37036.js:661 function ji(t, n) ← Kolib 的 Long(:651-657 的 qi() 用
// new ji(0, -2147483648) 造 MIN_VALUE)
// src/module_37036.js:11088 t.$_$.qj = ji ← 导出键 qj
// src/module_24635.js:36 R = i.$_$.qj ← 引入方(module_24635)的别名
// src/module_24635.js:731-733 case "Long": i = L(R) ← an.n7f 的 primitive 分派:字符串 "Long"
// 就映射到这个类(L = kClass 助手)
// src/module_24635.js:247 this.m7f(L(R), new Xt()) ← 给它注册 codec Xt
// src/module_24635.js:482-487 Xt.o7g 恰好一次写入 t.a7g_1.f2u(...)、Xt.p7g 读 t.a7g_1.u2t()
// ⇒ 线上 8 字节 = i64(WRITER_TYPE.f2u)
// src/module_47243.js:181 xi = n.$_$.qj ← 使用方别名
// src/module_47243.js:15869 this.nft_1 = Mm(this).n7f(new Ei(new yi(bi(xi), !1), !1))
// → ListCodecInfo(TypeCodecInfo(kClass(ji))) ⇒ 线上 list<i64>
// 所以 kClass(<原始类型类>) 推不出来的根因不是角色表(bi 已是 KClass),而是「KClass 的实参是
// 一个跨模块别名的原始类型类」时,只登记裸别名/裸类名会漏。这里按模块::导出键补齐。
const PrimClassWire = {};
for (const File of ModuleFiles) {
let Src;
try {
@@ -547,7 +634,8 @@ for (const File of ModuleFiles) {
} catch (Err) {
continue;
}
const Roles = (ModuleInfo[ModIdOf(File)] || {}).Roles || {};
const ModId = ModIdOf(File);
const Roles = (ModuleInfo[ModId] || {}).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;
@@ -563,7 +651,47 @@ for (const File of ModuleFiles) {
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;
if (!Type) continue;
// 键必须用「还原后的类名」,因为消费方 ResolveType 查的就是它(见上面 ResolveType 的 WireClasses[R.Name])。
// 只登记源码里的裸别名是不够的:别名只在本模块内有效,跨模块引用同一类时两边名字不同,
// 本模块定义、没人再引用它的类(如 module_61860.js:439 的 `function xr() {}`)才会碰巧对上。
// 两种键都登记:裸别名保持既有行为(如 'xr'),原始类型类另走 PrimClassWire(模块::导出键)。
if (!WireClasses[M[2]]) WireClasses[M[2]] = Type;
// kClass 的实参是本模块的「跨模块别名」(如 module_24635.js:247 的 R = i.$_$.qj)⇒ 记下它指向的
// 导出身份,供 ResolveType 在**任何**模块里引用同一类时认得出来(见 PrimClassWire 的注释)。
// 只认别名、不认本地类名:本地类名是「本模块自己定义的类」,不是原始类型类。
const Alias = (ModuleInfo[ModId] || {}).Aliases[M[2]];
if (Alias && !PrimClassWire[Alias.Mod + '::' + Alias.Key]) PrimClassWire[Alias.Mod + '::' + Alias.Key] = Type;
}
}
// ---------------------------------------------------------------- 资源类 → 线上 i64
// 出处:src/module_71105.js:12439-12518 的资源类型注册块,形状 `r.u83(<typeId>, kClass(<类>), <工厂>)`
// (si = module_37036 的 $_$.mf = kClass,见 src/module_71105.js:156)。
// 被注册的类都是「资源」:资源实例在空间协议里只以 i64 key 出现(查表/构造见 src/module_45686.js:209-228 的
// qt() → resourceRegistry.x83 与 L() 的 i64/i16/i16/i64/bool 读序)。
// 为什么从源码导出而不是手写:容器层原来手写了 10 个(container.js 的 RESOURCE_CLASS_WIRE),
// 漏了 typeId 13 的 no 与 zv 两兄弟——实测 space-100072911/s2c_0001 的 DonationAlertCC.imageWithEmail
// (类型 = module_71105 的导出 d = 类 no)就是因此解不动。全库 `.u83(` 恰好只出现 12 次、全在 module_71105,
// 不会被别处同名方法污染,所以这里按注册块全量导出,不做任何选择。
const ResourceClasses = {};
for (const File of ModuleFiles) {
let Src;
try {
Src = fs.readFileSync(File, 'utf8');
} catch (Err) {
continue;
}
const ModId = ModIdOf(File);
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
const LineOf = MakeLineIndex(Src);
const Re = /\.u83\(\s*([^,()]+),\s*([A-Za-z_$][\w$]*)\(\s*([A-Za-z_$][\w$]*)\s*\)/g;
let M;
while ((M = Re.exec(Src))) {
if ((Roles[M[2]] || '') !== 'KClass') continue; // 第 2 参必须是 kClass(类)
const Name = ResolveClass(M[3], ModId, ModuleInfo, []).Name;
if (!ResourceClasses[Name])
ResourceClasses[Name] = { Wire: 'i64', TypeId: M[1].trim(), From: path.basename(File) + ':' + LineOf(M.index) };
}
}
@@ -657,6 +785,91 @@ for (const C of Object.values(Codecs)) {
}
}
// ---------------------------------------------------------------- 顶层段:SlotTypes(全模块「槽位 → 类型」)
// 为什么需要它:上面的 Codecs 段只收「被抽成 codec 条目的类」的字段。可下行方法(模型类的 q7i 分支)
// 读的是模型类里那些**局部 codec 实例**(chc_1 / jh4_1 / b1wd_1 …),它们由 taw() 之类的一次性初始化
// 赋成 `<槽位> = <工厂>(<接收者>).n7f(new <描述符>)`,往往压根没进 Codecs 段(那个模型没注册 feature、
// 或 codec 条目没被抽到)。于是「这个槽位是什么类型」在全库查不到,下行字段就只能报未解。
// 这里对**全部模块**扫这个形状,复用与上面完全相同的 TypeOfDescriptor + ResolveType 推类型:
// 源码证据(src/module_10283.js:2343) this.hh4_1 = Un(this).n7f(new w(v().xb(), !1))
// 源码证据(src/module_10283.js:2727) this.chc_1 = mi(this).n7f(new $(new w(v().xb(), !1), !1))
// 源码证据(src/module_29554.js:242) this.b1wd_1 = B(this).n7f(new _(o(W), !1))
// 键 = 模块::槽位:槽位名本来是「模块内唯一」,但槽位一旦被别处引用(如 q7i 分支、上行发送点)
// 就必须能定位到「哪个模块的哪个槽位」,所以把模块号写进键里,避免将来把同名槽位认错。
// (实测本包 1073 个槽位名恰好全局唯一,但那是混淆器给的下标,不该依赖。)
function ParseSlotAssignments(Src, Roles, LineOf) {
const Out = new Map();
const Conflicts = [];
const Re = /\.n7f\(/g;
let M;
while ((M = Re.exec(Src))) {
const Open = M.index + M[0].length - 1;
const Close = MatchBracket(Src, Open, '(', ')');
if (Close < 0) break;
// prettier 会把实参换行,窗口放宽到 200 字符;工厂实参允许换行但不容嵌套括号
const Win = Src.slice(Math.max(0, M.index - 200), M.index);
const Head = /(?:([A-Za-z_$][\w$]*)\.)?([a-z0-9]+_\d+)\s*=\s*([A-Za-z_$][\w$]*)\s*\([^()]{0,60}\)\s*$/.exec(Win);
if (Head) {
const Slot = Head[2];
const T = TypeOfDescriptor(Src.slice(Open + 1, Close), Roles, 0);
const Prev = Out.get(Slot);
// 同一槽位名在本模块被赋了不同描述符 → 记冲突、保留第一条(宁可少解,不可解错)
if (Prev && Prev.Desc !== T.Raw) Conflicts.push(Slot + ' → ' + Prev.Desc + ' / ' + T.Raw);
else if (!Prev) Out.set(Slot, { Slot, Desc: T.Raw, Raw: T.Type, Line: LineOf(M.index) });
}
Re.lastIndex = Close + 1;
}
return { Map: Out, Conflicts };
}
const SlotTypes = {};
const SlotTypeConflicts = [];
let SlotTypeOk = 0;
let SlotTypeBlank = 0;
const SlotTypeBySlotName = new Map(); // 槽位名 → [{Mod, Type}],只用来统计「同名槽位类型不一致」
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);
// 键里的模块名统一用「去前导零」的 module_<数字>(与 Codecs[].ModuleId 同一套写法):
// 文件名是 module_01510 而模块号是 1510,两套写法混用会让消费方(mine_downlink)查不到。
const ModKey = 'module_' + ModId;
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
const Parsed = ParseSlotAssignments(Src, Roles, MakeLineIndex(Src));
for (const C of Parsed.Conflicts) SlotTypeConflicts.push(Mod + ' ' + C);
for (const [Slot, Rec] of Parsed.Map) {
const Trace = { Leftover: [] };
const R = ResolveType(Rec.Raw, ModId, ModuleInfo, ClassToCodec, Trace);
// 「推不出来就留空」:类型串里还残留没有 codec 的裸类名(Trace.Leftover)或压根没解析出形状
// ('?' / 'prim:xx')时,线上没有对应的解码器,如实留空——不要拿裸类名硬当类型。
const Ok = !!R.Type && R.Type !== '?' && R.Type !== '...' && !/^prim:/.test(R.Type) && !Trace.Leftover.length;
if (Ok) SlotTypeOk++;
else SlotTypeBlank++;
SlotTypes[ModKey + '::' + Slot] = {
Module: ModKey,
Slot,
Type: Ok ? R.Type : '',
TypeCodec: Ok ? R.Codecs[0] || '' : '',
Desc: Rec.Desc,
Line: Rec.Line,
};
if (!SlotTypeBySlotName.has(Slot)) SlotTypeBySlotName.set(Slot, []);
SlotTypeBySlotName.get(Slot).push(SlotTypes[ModKey + '::' + Slot]);
}
}
// 同名槽位在不同模块被推成不同类型 → 只按槽位名查表必然解错,这里把数量报出来当护栏
let SlotNameMismatch = 0;
for (const List of SlotTypeBySlotName.values()) {
if (List.length < 2) continue;
const First = List[0].Type;
if (List.some((X) => X.Type !== First)) SlotNameMismatch++;
}
// ---------------------------------------------------------------- 第四遍:上行命令表
// 上行命令在源码里的形态(见 module_85183 / 10283 / 47243 / 85667 …):
// ① 命令类构造函数里放着「methodId 长整型字面量」和「负载 codec」:
@@ -776,9 +989,130 @@ for (const File of ModuleFiles) {
}
}
// ---------------------------------------------------------------- 模型注册点 → modelId ↔ codec(源码第三证据)
// 客户端把「modelId → codec」登记在 RPC 分发器的模型注册表里:src/module_61860.js:1664-1671 的
// j7n(t, n) { this.w7n_1.g3(t, n) } —— 就是 names/regdump.json 的 maps['ModelReg.w7n_1']
// 源码里的登记点形状(都是 taw() 里,三处互相印证):
// src/module_99527.js:122 this.ide_1 = new C(-841295065, 916213531) → modelId 3935107155251354407
// src/module_99527.js:357 Y(this).j7n(this.ide_1, K(this).n7f(new v(k(Z), !1))) → codec 取的是类 Z 的 codec(module_99527::N)
// src/module_85196.js:1198 this.icp_1 = new Me(-769625095, 727121853) → modelId 3122964582367261689
// src/module_85196.js:6835 na(this).j7n(this.icp_1, ta(this).n7f(new ye(ze(Q_), !1))) → codec 取类 Q_ 的 codec(module_85196::vu)
// src/module_25377.js:274 this.ldi_1 = new Q(-2144376380, 1490830385) → modelId 6403067749608679876
// src/module_25377.js:1413 sn(this).j7n(this.ldi_1, un(this).n7f(new L(B(en), !1))) → codec 取类 en 的 codec(module_25377::ft)
// 判断口径:第 1 个别名必须是 TypeCodecInfo(module_24635::e = gn)、第 2 个必须是 kClass(module_37036::mf),
// 也就是 an.n7f 那条「给某个模型类取 codec」的分支(src/module_24635.js:746-784)。长整型字面量 → modelId 用 LongOf。
function FindModelRegistrations(Src, Roles) {
const IdOf = {};
const ReId = /this\.([a-z0-9]+_\d+) = new ([A-Za-z_$][\w$]*)\((-?\d+), (-?\d+)\)/g;
let M;
while ((M = ReId.exec(Src))) IdOf[M[1]] = LongOf(Number(M[3]), Number(M[4]));
const Out = [];
if (!Object.keys(IdOf).length) return Out;
const Re = /j7n\(\s*this\.([a-z0-9]+_\d+)\s*,[\s\S]{0,90}?\.n7f\(\s*new ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)/g;
while ((M = Re.exec(Src))) {
const Id = IdOf[M[1]];
if (!Id) continue;
if ((Roles[M[2]] || '') !== 'Type' || (Roles[M[3]] || '') !== 'KClass') continue;
Out.push({ ModelId: Id, Class: M[4] });
}
return Out;
}
const SrcModelCodec = new Map(); // modelId → { CodecKey, Mod, Class }
const SrcRegConflicts = [];
for (const File of ModuleFiles) {
let Src;
try {
Src = fs.readFileSync(File, 'utf8');
} catch (Err) {
continue;
}
const ModId = ModIdOf(File);
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
for (const R of FindModelRegistrations(Src, Roles)) {
const One = ResolveOne(R.Class, ModId, ModuleInfo, ClassToCodec);
if (!One.Codec) continue;
const Prev = SrcModelCodec.get(R.ModelId);
if (Prev && Prev.CodecKey !== One.Codec) SrcRegConflicts.push(R.ModelId + ' 在两个模块登记了不同 codec:' + Prev.CodecKey + ' / ' + One.Codec);
else if (!Prev) SrcModelCodec.set(R.ModelId, { CodecKey: One.Codec, Mod: ModId, Class: R.Class });
}
}
// regdump.json 的 maps['ModelReg.w7n_1'] 是同一个表的运行时 dump:modelId → codec 实例,
// 每条带 ctor(codec 类短名)与 keys(该 codec 实例自己的槽位名)。
// 指纹 = 「codec 类短名 + 槽位名集合」,拿它去本轮抽出的 Codecs 里找唯一匹配。
// 为什么需要这一步:压缩后短名会跨模块重名(如 vu/N/ft 各有多个),单看名字会绑错;
// 而槽位名(缩器按下标生成的 x_1 形式)在同一 codec 内是确定的,两者合起来才能消歧。
// 仍然多解时用「源码登记点」(FindModelRegistrations)定音——但**只允许在候选集合里挑**,
// 源码登记点落在候选之外就如实留空并列出,绝不硬绑。
function BindModelIds(Codecs, RegPath) {
const Stats = { W7nEntries: 0, ByFingerprint: 0, BySrcReg: 0, ZeroCandidate: 0, Ambiguous: 0, Bound: 0, ConflictWithJoin: 0 };
const Unresolved = [];
const Conflicts = [];
if (!fs.existsSync(RegPath)) {
Stats.Reason = '找不到 ' + RegPath;
return { Stats, Unresolved };
}
const Reg = JSON.parse(fs.readFileSync(RegPath, 'utf8'));
const Maps = Reg.maps || Reg;
const W = (Maps['ModelReg.w7n_1'] && Maps['ModelReg.w7n_1'].entries) || [];
const ByFingerprint = new Map(); // 短名|排序后的槽位名 → [codec 键]
for (const [Key, C] of Object.entries(Codecs)) {
const Sig = C.Codec + '|' + (C.Slots || []).map((S) => S.Slot).slice().sort().join(',');
if (!ByFingerprint.has(Sig)) ByFingerprint.set(Sig, []);
ByFingerprint.get(Sig).push(Key);
}
for (const E of W) {
const ModelId = String(E.key);
Stats.W7nEntries++;
const Sig = String(E.ctor || '') + '|' + (E.keys || []).slice().sort().join(',');
const Cand = ByFingerprint.get(Sig) || [];
const Src = SrcModelCodec.get(ModelId);
let Key = '';
let How = '';
if (Cand.length === 1) {
Key = Cand[0];
How = '唯一指纹';
} else if (Cand.length > 1) {
if (Src && Cand.includes(Src.CodecKey)) {
Key = Src.CodecKey;
How = '指纹命中 ' + Cand.length + ' 个,源码登记点(' + Src.Mod + '::' + Src.Class + ')定音';
} else {
Stats.Ambiguous++;
Unresolved.push(ModelId + ':指纹 ' + Sig + ' 命中 ' + Cand.length + ' 个 [' + Cand.join(', ') + ']' +
(Src ? ',源码登记点 ' + Src.CodecKey + ' 不在候选里' : ',源码里没有登记点'));
continue;
}
} else {
if (Src) {
Key = Src.CodecKey;
How = '指纹 0 命中,源码登记点(' + Src.Mod + '::' + Src.Class + ')';
} else {
Stats.ZeroCandidate++;
Unresolved.push(ModelId + ':指纹 ' + Sig + ' 在 Codecs 里 0 命中,源码里也没有登记点');
continue;
}
}
if (How === '唯一指纹') Stats.ByFingerprint++;
else Stats.BySrcReg++;
const C = Codecs[Key];
if (!C.ModelIds) C.ModelIds = [];
if (!C.ModelIds.includes(ModelId)) C.ModelIds.push(ModelId);
if (!C.ModelHow) C.ModelHow = How;
if (C.ModelId && C.ModelId !== ModelId && !C.ModelIds.includes(C.ModelId)) {
// 旧 join(槽位签名 / 构造器名兜底)给的是另一个 modelId:不是错,但要如实报出来
Stats.ConflictWithJoin++;
Conflicts.push(Key + ' 旧 ModelId=' + C.ModelId + ',新绑定=' + ModelId + '(两个都会进 CodecByModelId)');
}
Stats.Bound++;
}
return { Stats, Unresolved, Conflicts };
}
// ---------------------------------------------------------------- 写出与统计
const RegPath = path.resolve(Arg('reg', path.join(PROJECT, 'names', 'regdump.json')));
const Join = JoinRegistries(Codecs, RegPath);
const ModelBind = BindModelIds(Codecs, RegPath);
const AllFields = Object.values(Codecs).reduce((A, C) => A.concat(C.Fields), []);
const Named = AllFields.filter((F) => F.Name !== '(匿名)' && F.Name !== '?');
@@ -788,7 +1122,10 @@ 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',
Note: '空间层 codec 字段表(键 = 模块::codec变量名)。字段名来自 v("codec_名字");类型来自 q7g 描述符(已把裸局部类名还原成 Kotlin 原名,TypeRaw 保留原名);Js= p7g 里的 JS 级线索;Prop= 数据类属性名;TypeCodec= 该嵌套模型对应的 codec 键;ModelId/MethodId 来自 regdump.json join;ModelIds= 同一个 codec 被多个 modelId 共用时的完整列表(regdump w7n_1 指纹绑定,见 ModelIdBinding)',
SlotTypesNote:
'SlotTypes:全模块「槽位 → 类型」表,键 = 模块::槽位(如 module_10283::jh4_1)。来源是 `<接收者>.<槽位> = <工厂>(<接收者>).n7f(new <描述符>)` 的构造赋值(q7g 之外的局部 codec 实例),' +
'供 mine_downlink.js 解 q7i 分支里读到的槽位;Type 推不出来时留空字符串(不编),TypeCodec 为空表示该类型是基础类型/线上基础类型,不需要嵌套 codec',
Scan,
Modules: ModuleFiles.length,
ModulesWithCodec: ModulesUsed,
@@ -797,7 +1134,30 @@ const Out = {
NamedCount: Named.length,
AnonymousCount: Unnamed.length,
WireClasses,
PrimClassWire,
ResourceClasses,
SlotTypes,
SlotTypeStats: {
Count: Object.keys(SlotTypes).length,
Typed: SlotTypeOk,
Blank: SlotTypeBlank,
DescConflicts: SlotTypeConflicts.length,
NameMismatch: SlotNameMismatch,
},
Join,
ModelIdBinding: {
Note:
'ModelId → codec 的权威绑定。主证据 = names/regdump.json 的 maps["ModelReg.w7n_1"](modelId → codec 实例:' +
'ctor = codec 类短名、keys = 该 codec 实例的槽位名),指纹 = 「短名 + 槽位名集合」,在本表 Codecs 里唯一命中即绑定;' +
'若指纹多解,用源码登记点(`j7n(<id 槽位>, X(this).n7f(new TypeCodecInfo(kClass(类), !1)))`,见 extract_codec_fields.js ' +
'的 FindModelRegistrations)在候选集合内定音;多解且定不了 / 零命中且源码也没有登记点 → 如实留空并列在 Unresolved,绝不硬绑。' +
'ByFingerprint = 靠唯一指纹绑上的条数,BySrcReg = 靠源码登记点绑上(含指纹 0 命中)的条数。',
SrcRegistrations: SrcModelCodec.size,
SrcRegConflicts,
...ModelBind.Stats,
Unresolved: ModelBind.Unresolved,
ConflictsWithJoin: ModelBind.Conflicts || [],
},
Codecs,
};
fs.writeFileSync(OutPath, JSON.stringify(Out, null, 1), 'utf8');
@@ -816,7 +1176,16 @@ 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(
'slot 类型表: ' + Out.SlotTypeStats.Count + ' 个(推出 ' + SlotTypeOk + ',留空 ' + SlotTypeBlank +
');同槽位描述符冲突 ' + SlotTypeConflicts.length + ',同名槽位类型不一致 ' + SlotNameMismatch + ' 处(故本表按 模块::槽位 索引)'
);
if (SlotTypeConflicts.length) for (const X of SlotTypeConflicts.slice(0, 3)) console.log(' ⚠ ' + X);
console.log('已写出 ' + OutPath);
console.log('协议核心类(线上基础类型): ' + JSON.stringify(WireClasses));
console.log('Kolib 原始类型类(模块::导出键 → 线上类型): ' + JSON.stringify(PrimClassWire));
console.log('资源类(u83 注册块导出 → 线上 i64): ' + Object.keys(ResourceClasses).length + ' 个 — ' +
Object.entries(ResourceClasses).map(([N, R]) => N + '(typeId ' + R.TypeId + ')').join(' '));
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(
@@ -827,6 +1196,21 @@ if (Join.Ok)
);
else console.log('注册表 join: 跳过(' + Join.Reason + ')');
// modelId → codec 绑定(regdump w7n_1 指纹 + 源码登记点定音)报告
const MB = Out.ModelIdBinding;
if (MB.Reason) console.log('modelId 绑定: 跳过(' + MB.Reason + ')');
else {
console.log(
'modelId → codec 绑定: w7n_1 ' + MB.W7nEntries + ' 条;绑上 ' + MB.Bound + ' 条(唯一指纹 ' + MB.ByFingerprint +
' + 源码登记点定音 ' + MB.BySrcReg + ');仍绑不上 ' + (MB.Ambiguous + MB.ZeroCandidate) +
'(指纹多解 ' + MB.Ambiguous + ',零命中 ' + MB.ZeroCandidate + ')'
);
console.log(' 源码登记点(taw 里的 j7n(id, codec))共 ' + MB.SrcRegistrations + ' 个 modelId' +
(MB.SrcRegConflicts.length ? ';⚠ 同 id 两处登记不同 codec ' + MB.SrcRegConflicts.length + ' 处' : ';无冲突') +
(MB.ConflictWithJoin ? ';与旧 join 给的 ModelId 不一致 ' + MB.ConflictWithJoin + ' 处' : ''));
for (const U of MB.Unresolved.slice(0, 8)) console.log(' ⚠ 未绑定 ' + U);
}
// 自检:字段的原始描述符里若还残留未识别形状(既不是基础类型也不是 X(Y) 也不是 new X(...)),列出来看
const Suspicious = new Set();
for (const K of Object.keys(Codecs)) {
@@ -889,7 +1273,9 @@ for (const [Opt, Field, Label] of [
if (!Id) continue;
const Hit = Object.entries(Codecs).filter(([, C]) => {
const V = C[Field];
return Array.isArray(V) ? V.includes(Id) : V === Id;
if (Array.isArray(V) ? V.includes(Id) : V === Id) return true;
// modelId 可能只出现在新绑定(ModelIds)里:一个 codec 被多个 modelId 共用时 ModelId 只留了一个
return Field === 'ModelId' && Array.isArray(C.ModelIds) && C.ModelIds.includes(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)) {
+120
View File
@@ -0,0 +1,120 @@
// extract_space_init.js - 把某个空间的 s2c 抓包抽成「结构化初始化模板」names/space-init-<spaceId>.json
//
// 用法: node extract_space_init.js --dir ../packets/plain/space-100073070 [--space 100073070] [--max 6]
//
// 模板不是原始帧字节:
// · 容器记录 → 存「解出来的对象树」,发送时由 server/container.js 重新编码(可改、可换数据)
// · 解不动的记录 → 存 hex(如实标注,不假装结构化)
// · nullmap 原样保留(实测有 2 字节且带内容的 nullmap,含义是「哪些字段为空」,写 0 会错位)
// · DelayMs 取自同目录 timeline_s2c.txt(相对首帧),保持原节奏
//
// 容器布局判定:三个布局都试,取「恰好吃到帧尾,或吃完后剩余字节里能认出下一条记录的 methodId」的那个。
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./server/link');
const { LoadTables, MakeReader } = require('../tools/lib_space_codec');
const { MakeWriter } = require('./server/codec');
const { MakeContainerCodec } = require('./server/container');
const ModelService = require('./server/modelservice');
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 Dir = path.resolve(Arg('dir', ''));
const Max = Number(Arg('max', '8')) || 8;
if (!Dir || !fs.existsSync(Dir)) {
console.error('用法: node extract_space_init.js --dir ../packets/plain/space-<id> [--space <id>] [--max N]');
process.exit(1);
}
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Reader = MakeReader(Tables, { EnumBytes: 4 });
const Writer = MakeWriter(Tables, { EnumBytes: 4 });
const Container = MakeContainerCodec(Tables, Reader, Writer);
const LAYOUTS = ['zt', 'pt', 'lt'];
// 8 字节处是不是一个「已知的 methodId」(method_table / codec_fields / 上行表任一)
function KnownMethodId(Buf, Off) {
if (Off + 8 > Buf.length) return false;
const V = Buf.readBigInt64BE(Off).toString();
return Tables.CodecByMethod.has(V) || Tables.UplinkByMethod.has(V) || ModelService.Name(V) !== null;
}
function PickLayout(Buf, Off) {
for (const L of LAYOUTS) {
const R = Container.Read(L, Buf, Off);
if (R.Bad) continue;
if (R.Next === Buf.length || KnownMethodId(Buf, R.Next)) return { Layout: L, Result: R };
}
return null;
}
const Timeline = new Map();
const Tl = path.join(Dir, 'timeline_s2c.txt');
if (fs.existsSync(Tl)) {
for (const Line of fs.readFileSync(Tl, 'utf8').split('\n')) {
const M = /^(\S+)\t(\d+)$/.exec(Line.trim());
if (M) Timeline.set(M[1], Number(M[2]));
}
}
const Files = fs.readdirSync(Dir).filter((F) => /^s2c_\d+\.bin$/.test(F)).sort().slice(0, Max);
const SpaceArg = Arg('space', '');
const Out = { SpaceId: '', Note: '结构化初始化模板:容器存对象树(发送时重新编码),解不动的记录存 hex;nullmap 原样', Frames: [] };
let Base = null;
let Structured = 0;
let RawOnly = 0;
for (const File of Files) {
const RawFile = fs.readFileSync(path.join(Dir, File));
const Info = Link.PeekFrame(RawFile);
const Body = Link.DecodeFrame(Link.ParseFrames(RawFile)[0]);
const At = Link.NullmapSize(Body, 0);
if (At + 8 > Body.length) continue; // 保活 ping 之类:帧体只有 nullmap,没有 spaceId
const SpaceId = Body.readBigInt64BE(At).toString();
Out.SpaceId = SpaceArg || SpaceId;
const Nullmap = Body.slice(0, At).toString('hex');
let Off = At + 8;
const Records = [];
while (Off + 8 <= Body.length) {
const MethodId = Body.readBigInt64BE(Off).toString();
const Start = Off;
Off += 8;
const Picked = PickLayout(Body, Off);
if (Picked) {
Records.push({ MethodId, Layout: Picked.Layout, Value: Picked.Result.Value });
Off = Picked.Result.Next;
Structured++;
} else {
Records.push({ MethodId, Hex: Body.slice(Off).toString('hex'), Note: '解不动,原样存' });
Off = Body.length;
RawOnly++;
}
if (Records[Records.length - 1].Layout === undefined) break;
if (Off === Body.length) break;
}
const Ms = Timeline.has(File) ? Timeline.get(File) : null;
if (Base === null) Base = Ms === null ? 0 : Ms;
Out.Frames.push({
File,
SpaceId,
NullmapHex: Nullmap,
DelayMs: Ms === null ? 0 : Ms - Base,
Compressed: Info.Compressed,
Records,
});
}
const OutFile = path.join(PROJECT, 'names', 'space-init-' + Out.SpaceId + '.json');
fs.writeFileSync(OutFile, JSON.stringify(Out, null, 1));
console.log('空间 ' + Out.SpaceId + ':' + Out.Frames.length + ' 帧,容器记录 ' + Structured + ' 个,原样存的记录 ' + RawOnly + ' 个');
for (const F of Out.Frames) {
console.log(' ' + F.File.padEnd(14) + ' ' + F.Records.length + ' 条记录 @+' + F.DelayMs + 'ms nullmap=' + F.NullmapHex +
(F.Compressed ? ' (原压缩)' : '') + ' ' + F.Records.map((R) => (R.Layout ? R.Layout : 'hex') + ':' + (ModelService.Name(R.MethodId) || '#' + R.MethodId)).join(' '));
}
console.log('已写 ' + OutFile + ' ' + fs.statSync(OutFile).size + ' 字节');
+315
View File
@@ -0,0 +1,315 @@
// extract_space_session.js - 把真实官方服会话里的「上行 → 下行」对应关系抽成会话重放模板
//
// 为什么需要它(根因,别被"看起来对"骗了):
// 现有 names/space-init-<spaceId>.json 只是「进入空间后服务器主动推的那几帧」——
// 车库模板(packets/plain/space-100073070 共 68 条 s2c)只切了前 5 条(s2c_0000..0004),
// 之后客户端发的 G.q1wd / ba.lcr / M_.hcn / ls.xbl / M_.fcn / Qh.ycw 全部零应答:
// 真实服是「客户端问一句、服务器答一句」,模型数据的绝大部分(对象/资源/设备)都挂在这些应答上。
// 所以光有"初始化帧"不够,必须把**每一帧 s2c 是被哪条上行换来的**也记下来。
//
// 做法(不猜字节、不臆造数据):
// 1) 读 packets/mine-01/session_007/log.txt 的**双向合并时间线**(`>>> c2s_NNNN.bin` / `<<< s2c_NNNN.bin`)。
// 2) 逐帧读 packets/plain/space-<id>/s2c_*.bin(与模板 Frames[].File 同名)→
// Link.ParseFrames + Link.DecodeFrame 得帧体 → Sem.DecodeFrameBody 得**权威记录列表**
// (容器型 → { Layout, Value };字段型 → { Fields };解不动 → { Hex, Note }),
// 与 names/space-init-*.json 的记录形态**完全一致**(space.js 能直接消费)。
// 3) After = 触发该 s2c 帧的上行清单:在合并时间线里,位于「上一条 s2c 事件」与「本条 s2c 事件」
// 之间的所有 c2s 事件,每项 { MethodId, Target }(取法见 dispatch.js:c2s 帧体 =
// [nullmap][objectId 8B][(methodId 8B)(负载)…],Target = 第一个 i64)。
// 入口帧(command id 3 = Auth/EnterSpace,本会话是 c2s_0000)不算上行、直接排除。
// 4) DelayMs:同一条 After 组(连续若干帧 After 完全相同)里,基准 = 组内最后一条上行的时间戳
// 与**上一条 s2c 帧**的时间戳之差;组内第 k 帧再叠加它自己与组内首帧的真实时间差。
// After 为空的纯推送帧,基准 = 组内首帧自己的时间戳(即与上一条 s2c 的真实间隔)。
// 上一条 s2c 不存在(会话第一帧)→ 0。ping 帧整条跳过:既不入 Frames,也不当时间线边界
// (否则它前后的上行会凭空丢一组;实测两种口径下本会话的分组完全相同)。
// 5) 安全阀:每帧用 space.js 的组帧路径重建,必须与原始帧体**逐字节一致**;不一致就拒绝写该帧,
// 并打印前 32 个差异偏移 + 两侧 hex(照 rebase_space_init.js:114-123 的写法)。
// 解码出现 Short / ObjectId === undefined(模板三形态表达不了)时,整帧按 { Hex } 原样保留并写明原因。
//
// 用法: node extract_space_session.js [--space 100073070] [--session ../packets/mine-01/session_007]
// [--names ../names] [--plain ../packets/plain] [--dry]
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./server/link');
const Space = require('./server/space');
const SemanticModule = require('./server/semantic');
const { MakeDispatcher } = require('./server/dispatch');
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 ToolsDir = __dirname;
const NamesDir = path.resolve(ToolsDir, Arg('names', path.join('..', 'names')));
const PlainRoot = path.resolve(ToolsDir, Arg('plain', path.join('..', 'packets', 'plain')));
const SessionDir = path.resolve(ToolsDir, Arg('session', path.join('..', 'packets', 'mine-01', 'session_007')));
const SpaceId = Arg('space', '100073070');
const Dry = Argv.indexOf('--dry') >= 0;
const Sem = SemanticModule.Load(NamesDir);
// 上行记录用服务端**同一条**识别路径(dispatch.js 的 Describe),避免"工具认得出、服务器认不出"
const Dispatcher = MakeDispatcher(Sem.Tables, Sem.Reader, { NoteUnimplemented: () => {} }, {});
const PlainDir = path.join(PlainRoot, 'space-' + SpaceId);
const RelSession = path.relative(path.resolve(ToolsDir, '..'), SessionDir).replace(/\\/g, '/');
const OutFile = path.join(NamesDir, 'space-session-' + SpaceId + '.json');
// 差异偏移(前 Limit 个);用来把"不一致"说清楚,而不是只丢一句"对不上"
function DiffOffsets(A, B, Limit) {
const Out = [];
for (let I = 0; I < Math.max(A.length, B.length) && Out.length < Limit; I++) if (A[I] !== B[I]) Out.push(I);
return Out;
}
// 偏移附近的 hex 窗口:整帧可能几十万字节,只贴差异邻域(±Span)
function HexAround(Buf, At, Span) {
const S = Math.max(0, At - Span);
const E = Math.min(Buf.length, At + Span);
return '偏移 ' + S + '..' + E + ':' + Buf.slice(S, E).toString('hex');
}
// 模板记录 → 帧体(含 nullmap)。走服务端真正用的那条路径(space.js → container/semantic 现编),
// 所以它同时验证「记录形态」与「编码器」两件事。
function BuildBody(File, NmHex, DelayMs, Records) {
const Built = Space.BuildInitFrames(
{ Frames: [{ File, SpaceId, NullmapHex: NmHex, DelayMs, Records }] },
Sem.Container,
Sem,
)[0].Frame;
return Link.DecodeFrame(Link.ParseFrames(Built)[0]);
}
// ---------------------------------------------------------------- 1. 合并时间线
// 返回按时间排序的 [{ Time, Dir, Name }];Name 形如 c2s_0007.bin
function ReadEvents(LogPath) {
if (!fs.existsSync(LogPath)) return null;
const Out = [];
for (const Line of fs.readFileSync(LogPath, 'utf8').split('\n')) {
const M = /^\[([^\]]+)\]\s*(>>>|<<<)\s+((?:c2s|s2c)_\d+\.bin)/.exec(Line.trim());
if (!M) continue;
const Time = Date.parse(M[1]);
if (Number.isNaN(Time)) continue;
Out.push({ Time, Dir: M[2] === '>>>' ? 'c2s' : 's2c', Name: M[3] });
}
// 同一毫秒内的多条按出现顺序保持稳定(Array.sort 在 Node 里是稳定的)
Out.sort((A, B) => A.Time - B.Time);
return Out;
}
// ---------------------------------------------------------------- 2. 上行帧 → 记录
// 一份「文件 → { Target, Records: [{ MethodId, Name }], Why }」表;解不出的一律带 Why,不静默丢
function ReadUplink(Files) {
const Table = new Map();
for (const File of Files) {
const Parsed = Link.ParseFrames(fs.readFileSync(path.join(PlainDir, File)));
if (!Parsed || Parsed.length !== 1) {
Table.set(File, { Why: '明文文件不是"恰好一整帧"(帧数 ' + (Parsed ? Parsed.length : 'null') + ')' });
continue;
}
const Body = Link.DecodeFrame(Parsed[0]);
const At = Link.NullmapSize(Body, 0);
if (At >= Body.length) {
Table.set(File, { Why: '空负载帧(无目标 id)' });
continue;
}
const Info = Dispatcher.Describe(Body, At);
if (!Info.Records.length) {
Table.set(File, { Target: Info.TargetId, Why: '首条 methodId 不在上行表(负载=' + Body.slice(At + 8).toString('hex').slice(0, 48) + ')' });
continue;
}
Table.set(File, {
Target: Info.TargetId,
Records: Info.Records.map((R) => ({ MethodId: R.MethodId, Name: R.Name, Bad: R.Bad })),
});
}
return Table;
}
// ---------------------------------------------------------------- 主流程
const Lines = [];
const Unmatched = [];
const Stat = { S2c: 0, Rebuilt: 0, Refused: 0, Ping: 0, Bad: 0, Hex: 0, Records: 0, Ctr: 0, Fld: 0, RecHex: 0 };
if (!fs.existsSync(PlainDir)) {
console.log('明文目录不存在: ' + PlainDir);
process.exit(1);
}
const LogPath = path.join(SessionDir, 'log.txt');
const Events = ReadEvents(LogPath);
const S2cFiles = fs.readdirSync(PlainDir).filter((F) => /^s2c_\d+\.bin$/.test(F)).sort();
const C2sFiles = fs.readdirSync(PlainDir).filter((F) => /^c2s_\d+\.bin$/.test(F)).sort();
const Uplink = ReadUplink(C2sFiles);
// 帧体 + 结构(供断言与 After 分组两处用)
const FrameInfo = new Map();
for (const File of S2cFiles) {
Stat.S2c++;
const Parsed = Link.ParseFrames(fs.readFileSync(path.join(PlainDir, File)));
if (!Parsed || Parsed.length !== 1) {
Stat.Bad++;
Lines.push(' ' + File.padEnd(14) + ' 跳过:明文文件不是"恰好一整帧"(帧数 ' + (Parsed ? Parsed.length : 'null') + ')');
continue;
}
const Body = Link.DecodeFrame(Parsed[0]);
const NmSize = Link.NullmapSize(Body, 0);
FrameInfo.set(File, { Body, NmHex: Body.slice(0, NmSize).toString('hex'), Ping: Body.length <= NmSize });
}
// 事件 → After / DelayMs
const AfterByFile = new Map();
const DelayByFile = new Map();
const PendingList = [];
let PrevS2cTime = null;
let GroupStart = null; // 同一 After 组的(首帧文件, 基准时刻)
let GroupBase = null;
let Assigned = 0;
for (const E of Events || []) {
if (E.Dir === 'c2s') {
if (E.Name === 'c2s_0000.bin') continue; // 入口帧(Auth/EnterSpace)不算空间上行
const U = Uplink.get(E.Name);
if (!U || !U.Records) {
Unmatched.push({ File: E.Name, Time: E.Time, Why: (U && U.Why) || '取不到目标 id' });
continue;
}
for (const R of U.Records) PendingList.push({ MethodId: R.MethodId, Target: U.Target, File: E.Name, Time: E.Time, Name: R.Name });
continue;
}
const Info = FrameInfo.get(E.Name);
if (!Info) {
Unmatched.push({ File: E.Name, Time: E.Time, Why: '明文目录里没有同名的 s2c 帧' });
continue;
}
if (Info.Ping) continue; // ping 帧跳过:不入 Frames、也不当时间线边界
const LastUplinkTime = PendingList.length ? PendingList[PendingList.length - 1].Time : null;
const After = PendingList.map((X) => ({ MethodId: X.MethodId, Target: X.Target }));
AfterByFile.set(E.Name, After);
PendingList.length = 0;
Assigned++;
// DelayMs:同组(After 完全相同)首帧用「组内最后一条上行 − 上一条 s2c」,组内后续帧叠加真实时间差;
// 组内没有任何上行(纯推送帧)时用首帧自己的时间戳,得到的就是与上一条 s2c 的真实间隔。
const Key = JSON.stringify(After);
if (GroupStart === null || GroupStart.Key !== Key) {
GroupStart = { Key, File: E.Name, Time: E.Time };
GroupBase = PrevS2cTime === null ? 0 : Math.max(0, (After.length ? LastUplinkTime : E.Time) - PrevS2cTime);
}
DelayByFile.set(E.Name, GroupBase + (E.Time - GroupStart.Time));
PrevS2cTime = E.Time;
}
// 逐帧生成 Frames[]
const Frames = [];
for (const File of S2cFiles) {
const Info = FrameInfo.get(File);
if (!Info) continue; // 不是恰好一整帧:上面已计数并打印原因
if (Info.Ping) {
Stat.Ping++;
continue;
}
const Body = Info.Body;
const After = AfterByFile.get(File) || [];
const DelayMs = DelayByFile.has(File) ? DelayByFile.get(File) : 0;
const Entry = { File, SpaceId, NullmapHex: Info.NmHex, DelayMs, After, Records: [] };
const Decoded = Sem.DecodeFrameBody(Body);
const Unrepresentable = Decoded.Records.find((R) => R.ObjectId === undefined || R.Short);
if (Unrepresentable) {
// 模板三形态表达不了「短命令(nullmap bit=1 的 1 字节命令号)」与「解码器判定不了形态」这两种情况
// (semantic.js:236-243 / :230-235 会标 Short / 只留 Remaining)——整帧原样保留,绝不猜边界。
Entry.Hex = Body.toString('hex');
Entry.Note = '形态表达不了,整帧原样保留:' + (Unrepresentable.Note || '');
Stat.Hex++;
Lines.push(' ' + File.padEnd(14) + ' 帧体 ' + Body.length + 'B 原样保留(形态表达不了):' + Unrepresentable.Note);
Frames.push(Entry);
continue;
}
for (const R of Decoded.Records) {
if (R.Bad) {
Entry.Records.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Hex: Body.slice(R.At + 16).toString('hex'), Note: R.Note });
Stat.RecHex++;
} else if (R.Kind === 'container') {
Entry.Records.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Layout: R.Layout, Value: R.Value });
Stat.Ctr++;
} else {
const Fields = {};
for (const X of R.Fields || []) Fields[X.Name] = X.Value;
Entry.Records.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Fields });
Stat.Fld++;
}
Stat.Records++;
}
// 硬性断言:重建的帧体必须与原始帧体逐字节一致。断言用「JSON 往返之后」的记录
//(= 真正会落盘的那份),顺带保证幂等:过不了 JSON 的值(如 float 的 NaN/Infinity)会当场暴露。
const Persisted = JSON.parse(JSON.stringify(Entry.Records));
let BuiltBody = null;
let Err = null;
try {
BuiltBody = BuildBody(File, Info.NmHex, DelayMs, Persisted);
} catch (E) {
Err = E.message;
}
if (!BuiltBody || !BuiltBody.equals(Body)) {
Stat.Refused++;
Lines.push(' ' + File.padEnd(14) + ' 拒绝写回:组帧与原始帧体不一致' +
(Err ? '(组帧抛错:' + Err + ')' : '(' + (BuiltBody ? BuiltBody.length : '?') + 'B vs 原始 ' + Body.length + 'B)'));
if (BuiltBody) {
const Offs = DiffOffsets(BuiltBody, Body, 32);
Lines.push(' 差异偏移(前 32 个): ' + JSON.stringify(Offs));
for (const At of Offs.slice(0, 3)) {
Lines.push(' @' + At + ' 组帧 ' + HexAround(BuiltBody, At, 16));
Lines.push(' @' + At + ' 原始 ' + HexAround(Body, At, 16));
}
}
continue;
}
Stat.Rebuilt++;
Entry.Records = Persisted;
Frames.push(Entry);
const Shapes = Entry.Records.map((R) => (R.Layout !== undefined ? '容器/' + R.Layout : R.Fields !== undefined ? '字段(' + Object.keys(R.Fields).length + ')' : 'hex'));
Lines.push(' ' + File.padEnd(14) + ' 帧体 ' + String(Body.length).padStart(7) + 'B nullmap=' + Info.NmHex.slice(0, 24) +
(Info.NmHex.length > 24 ? '…' : '') + ' ' + String(Entry.Records.length).padStart(2) + ' 条记录 [' + Shapes.join(' + ') + ']' +
' After(' + After.length + ')' + (After.length ? ' = ' + After.map((A) => A.MethodId + '@' + A.Target).join(', ') : '') +
' DelayMs=' + DelayMs);
}
const Product = {
Note: '车库空间会话回放:来自真实官方服中继抓包 ' + RelSession +
'。每条 s2c 帧按「客户端的哪条上行换来的」索引(After = 触发它的上行清单,空 = 纯推送);' +
'Records 形态与 space-init-*.json 一致,组帧与原始帧体逐字节相同。',
Source: RelSession + '/log.txt',
SpaceId,
Skipped: { Ping: Stat.Ping, Undecodable: Stat.Hex + Stat.Bad },
Unmatched,
Frames,
};
const Text = JSON.stringify(Product, null, 1) + '\n';
let Changed = false;
if (!Dry) {
const Old = fs.existsSync(OutFile) ? fs.readFileSync(OutFile, 'utf8') : null;
if (Old !== Text) {
fs.writeFileSync(OutFile, Text, 'utf8');
Changed = true;
}
}
console.log('=== 从真实会话抽「上行 → 下行」对应关系 ===');
console.log('会话: ' + SessionDir);
console.log('明文目录: ' + PlainDir);
console.log('产物: ' + OutFile + (Dry ? '(--dry:一个字节都没写)' : Changed ? '(已写回)' : '(无变化)'));
console.log('');
console.log('合并时间线事件 ' + (Events ? Events.length : 0) + ' 条' +
(Events ? '(c2s ' + Events.filter((E) => E.Dir === 'c2s').length + ' / s2c ' + Events.filter((E) => E.Dir === 's2c').length + ')' : ':没有 log.txt,After 一律为空'));
console.log('s2c 帧 ' + Stat.S2c + ' 条:非 ping ' + (Stat.S2c - Stat.Ping) + ' 条,其中配对到 After 的 ' + Assigned + ' 条');
for (const L of Lines) console.log(L);
console.log('');
console.log('帧 ' + Stat.S2c + ' / 逐字节一致 ' + Stat.Rebuilt + ' / 跳过 ' + Stat.Ping +
'(ping;形态表达不了整帧原样 ' + Stat.Hex + ' 条,不是一整帧 ' + Stat.Bad + ' 条,组帧不一致拒写 ' + Stat.Refused + ' 条)');
console.log('记录 ' + Stat.Records + ' 条:容器 ' + Stat.Ctr + ' / 字段 ' + Stat.Fld + ' / hex ' + Stat.RecHex);
console.log('纯推送帧(After 为空,进空间即发):' + Frames.filter((F) => !F.After.length).map((F) => F.File).join(', ') || '(无)');
if (PendingList.length) console.log('⚠ 结尾有 ' + PendingList.length + ' 条上行没配上任何 s2c(未并入 After):' + PendingList.map((X) => X.File).join(', '));
if (Unmatched.length) {
console.log('未配对的上行事件 ' + Unmatched.length + ' 条(已如实记进 Unmatched 字段):');
for (const U of Unmatched) console.log(' ' + U.File + ' ' + U.Why);
}
process.exit(Stat.Refused || Stat.Bad || Stat.Rebuilt + Stat.Hex !== Stat.S2c - Stat.Ping ? 1 : 0);
+121
View File
@@ -0,0 +1,121 @@
// fetch_assets.js - 从 3dtank.com 镜像本地可运行客户端所需文件
// 用法: node fetch_assets.js [--index i.html] [--out web] [--concurrency 8] [--base https://3dtank.com]
// 内容:
// 1) index.html 里引用的 /play/**(字体、图标、manifest;main.js 跳过,用本地切割版)
// 2) src/resources.json 里的 1218 个资源(static/**.svg|webp|json|mp3|webm|3ds...)
const fs = require('fs');
const path = require('path');
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 BASE = Arg('base', 'https://3dtank.com');
const INDEX_HTML = path.resolve(Arg('index', path.join(ROOT, 'i.html')));
const OUT_DIR = path.resolve(Arg('out', path.join(PROJECT, 'web')));
const CONCURRENCY = Number(Arg('concurrency', '8'));
const RESOURCES = path.join(PROJECT, 'src', 'resources.json');
// 与 curlcmd 一致的请求头
const HEADERS = {
accept: '*/*',
'accept-language': 'zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7,en-US;q=0.6',
'cache-control': 'no-cache',
pragma: 'no-cache',
origin: BASE,
referer: BASE + '/',
'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',
};
// 收集待下载路径(不含域名,形如 /play/xxx)
function CollectPaths() {
const Paths = new Set();
const Html = fs.readFileSync(INDEX_HTML, 'utf8');
for (const M of Html.matchAll(/["'(](\/play\/[^"')]+)["')]/g)) Paths.add(M[1]);
for (const M of Html.matchAll(/url\(['"]?(\/play\/[^'")]+)['"]?\)/g)) Paths.add(M[1]);
const Res = JSON.parse(fs.readFileSync(RESOURCES, 'utf8')).resourceMap;
for (const K of Object.keys(Res)) Paths.add('/play/' + Res[K]);
// main.js 用本地切割版,不覆盖
const List = [...Paths].filter((P) => !/\/play\/static\/js\/main\.[0-9a-f]+\.js$/.test(P));
// 本地已存在的 hornet 文件也不要覆盖
return List.filter((P) => !/\/hornet_[^/]*\.js$/.test(P));
}
async function DownloadOne(PathName) {
const Rel = PathName.replace(/^\/+/, '');
const File = path.join(OUT_DIR, Rel);
if (fs.existsSync(File) && fs.statSync(File).size > 0) return { PathName, skipped: true };
fs.mkdirSync(path.dirname(File), { recursive: true });
const Url = BASE + PathName;
for (let Attempt = 1; Attempt <= 3; Attempt++) {
try {
const Resp = await fetch(Url, { headers: HEADERS, redirect: 'follow' });
if (!Resp.ok) throw new Error('HTTP ' + Resp.status);
const Buf = Buffer.from(await Resp.arrayBuffer());
if (Buf.length === 0) throw new Error('empty body');
fs.writeFileSync(File, Buf);
return { PathName, bytes: Buf.length };
} catch (Err) {
if (Attempt === 3) return { PathName, error: String(Err.message || Err) };
await new Promise((R) => setTimeout(R, 300 * Attempt));
}
}
return { PathName, error: 'unreachable' };
}
async function Pool(Items, Worker, Concurrency) {
let Next = 0;
const Results = [];
const Runners = new Array(Math.min(Concurrency, Items.length)).fill(0).map(async () => {
while (true) {
const I = Next++;
if (I >= Items.length) return;
Results[I] = await Worker(Items[I]);
}
});
await Promise.all(Runners);
return Results;
}
(async () => {
const Paths = CollectPaths();
console.log('待下载: ' + Paths.length + ' 个(并发 ' + CONCURRENCY + ')');
console.log('来源: ' + BASE + ' 输出: ' + OUT_DIR);
// 首页就地复制一份(i.html 即 index.html)
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.copyFileSync(INDEX_HTML, path.join(OUT_DIR, 'index.html'));
const Started = Date.now();
let Done = 0;
const Results = await Pool(
Paths,
async (P) => {
const R = await DownloadOne(P);
Done++;
if (Done % 100 === 0 || R.error) {
console.log(' 进度 ' + Done + '/' + Paths.length + (R.error ? (' [失败] ' + R.PathName + ' ' + R.error) : ''));
}
return R;
},
CONCURRENCY
);
const Failed = Results.filter((R) => R && R.error);
const Skipped = Results.filter((R) => R && R.skipped).length;
const Bytes = Results.reduce((S, R) => S + (R && R.bytes ? R.bytes : 0), 0);
console.log('=== 完成 ===');
console.log(' 成功下载: ' + (Results.length - Failed.length - Skipped) + ' 跳过(已存在): ' + Skipped + ' 失败: ' + Failed.length);
console.log(' 新增体积: ' + (Bytes / 1024 / 1024).toFixed(1) + ' MB 用时: ' + ((Date.now() - Started) / 1000).toFixed(0) + ' s');
if (Failed.length) {
fs.writeFileSync(path.join(OUT_DIR, '..', 'names', 'fetch_failed.txt'), Failed.map((F) => F.PathName + ' ' + F.error).join('\n'), 'utf8');
console.log(' 失败清单: 3DTank/names/fetch_failed.txt');
}
})();
+808
View File
@@ -0,0 +1,808 @@
// import_db.js - 把抓包里解出来的「对象仓库」全量导入本机 MySQL,并自证可逆
//
// 数据来源:packets/plain/<目录>/s2c_*.bin(每个文件 = 恰好一整帧的帧体)。
// 解码范式:逐字照 tools/rebase_space_init.js:100-146 ——
// Link.ParseFrames(readFileSync(F)) → Link.DecodeFrame(Parsed[0]) → Sem.DecodeFrameBody(Body)。
// 容器记录 → { Layout, Value };字段记录 → { Fields };解不动 → Bad(**丢弃并统计**,不猜)。
//
// 落库口径(与 tools/server/schema.sql 的头注一一对应):
// Containers 每条容器记录(pt/zt/lt)的原文:Layout + ValueJson(zt 另填 X)
// SpaceObjects pt.Value.Objects = (ObjectId, GameTypeId)
// ObjectModels pt.Value.Items 按对象分组后的切片(分组规则见下),Ord 从 0 保序
// DependencyBatches / DependencyItems zt.Value.Batches / zt.Value.Items(身份 = 该条 zt 记录,BatchId = zt 首字段 X)
// Items itemsOnDepot(3996477900528000389) / itemsOnMarket(5236393599344825589)
// / mountedItems(3692495084100053385) 三个旗标的并集
// DeviceSlots / SlotDevices / Mounted devicesLoaded(7615142300796401818) 的三块
// Accounts 抓包账号(Uid 的出处见 ACCOUNT_UID 的注释)
//
// ---------------------------------------------------------------------------
// 分组规则(**按源码,不许猜**):src/module_45686.js:420-434 的 `x(_t).i1wd`
// ★ 结论先说:**组首项 = Id 恰为 0 的那一项**(container.js 里 Id === '0'),它的 Value 是**单一个 i64**
// (= 对象 id,即 container.js 的 Value 就是 i64 十进制字符串),等于 Objects[i].ObjectId;
// 组尾 = 下一个 Id === 0 的项之前。**不是 (objId, gameTypeId) 对** —— 帧里根本没随组首项下发 gameTypeId。
// i1wd 遍历 pt.Value.Items(源码里的 n.h1wc(),元素是 lt.p7g 解出来的 ModelData,即 I(data, id)):
// u = item(e).b85() ← 组首项的 data = I 的第 1 个构造参数 = container.js 的 Value
// s = u instanceof p ? u : w() p = module_37036.$_$.qj,是「i64 包装类」(new p(0,0) 就是 0,
// 且它有 q3_1/r3_1 两半 + 8 字节/64 位的 hashCode,见 module_37036.js:11119);
// 不在 p 上就调 w()(= 37036.$_$.vj = Jr,直接抛)→ 组首项的 Value 必须是 i64
// o = r.x7o(s) ← 用这个 i64 在空间注册表里取对象 ⇒ 它只能是该对象自己的 ObjectId
// _ = e + 1;while (_ < len && item(_).o1wc_1 != new p(0,0)) _++
// ← 内层扫描到「下一项的 id(= I 的第 2 个构造参数 = container.js 的 Id)
// 等于 0」的哨兵为止;e.._-1 这些项按 a.o1wc_1(= modelId)挂到 o 上
// 硬断言因此写作:「组数 == Objects.length」+「组首项 Value === Objects[i].ObjectId」(实测全部相等)。
// ⚠ 那个 (objId, gameTypeId) 对**无法断言**:i1wd 只把组首项的 Value 当 i64 用,不读第二个 id。
// 断言不过的容器**不硬塞**:该容器的 Items 全部以 ObjectId = NULL 落库(Ord 保序),并统计占比。
//
// 幂等 & 主键冲突口径:
// 写入一律 INSERT ... ON DUPLICATE KEY UPDATE,且**统一 KeepFirst** —— 同一主键在抓包里出现多份
// 不同内容时保留**首次出现**的版本(ObjectModels 的 ObjectId IS NULL 那批不受唯一键保护,先 DELETE 再插)。
// 撞主键的行数一律用 CountKeyCollisions 在主键上自己算;被丢的行**不在库里**,所以「到底丢没丢数据」
// 在同一处再按**整行**去重算一次,报告里拆成两列:原样重发(整行逐字节相同)/ 内容不同(真冲突)。
// 依赖批次的身份 = **哪一条容器记录**,不是 X:主键 (SpaceId, SourceFile, RecordIndex, Ord),与 Containers
// 同一套自然键;BatchId(zt 首字段 X)只是列 + 索引 DependencyBatchesLookupIdx,按 X 查走索引即可。
// ObjectModels 的唯一键是 (SpaceId, ObjectId, Ord)(同一对象在不同空间可以内容不同,见 schema.sql 头注 9)。
// 本次实测(推入 → 入库 / 撞主键,其中「原样重发」= 整行与已入库那份逐字节相同):
// ObjectModels 28835 → 28827 / 8(原样重发 8 / 内容不同 0)—— 残余 8 行是「同一空间内同一对象
// 被原样重发」,整行逐字节相同 ⇒ 唯一键不允许重复是**正确行为**,没丢数据;
// DependencyBatches 528 → 528 / 0 —— 按记录身份落库后不再丢行;
// DependencyItems 4501 → 4501 / 0 —— 同上。
// ⚠ schema.sql 头注 8 的「zt 的 X 只在空间内唯一」**不成立**:实测空间 100073070 的 X=1 被 11 条 zt
// 以互不相同的对象表重发(s2c_0000#0 带 8 个 modelId、s2c_0003#0 带 16 个、s2c_0006#0 带 4 个……)。
// 所以 X **不能**当唯一键 —— 旧版 (SpaceId, BatchId, Ord) 只能 KeepFirst,白丢 46 + 66 行;
// 现在批次身份是容器记录,这些 zt 一条不丢地各自成行,原文也一字不少地留在 Containers。
// 反过来说,ObjectModels 上不存在「同空间内同对象不同切片」(实测 0 个),故验证② 的拼回必然逐项相等。
//
// 持久化精度口径(本次已改,不再是边界):Containers.ValueJson / ObjectModels.ValueJson /
// DependencyBatches.ModelIds / DependencyItems.Skipped 四列一律 **LONGTEXT + CHECK(JSON_VALID(...))**,
// 写入传 JSON.stringify 的原文、读出自己 JSON.parse —— 全程只走文本,不经过 MySQL 的 binary JSON,
// 所以 double 往返逐位可逆(旧版用 JSON 列时实测 -10.321807861328125 → -10.321807861328123,差 1 个 ulp,
// 见 schema.sql 头注 7a);大 i64 也只以 JSON 字符串形态存在,不丢精度。
//
// 用法: node import_db.js [--reset] [--space 100073070] [--owner <uid>] [--dry] [--verify] [--plain <dir>]
// --reset 先清本次要写的表再导入(默认 upsert,重复跑不产生重复行)
// --space 只导一个空间(调试用);默认导 packets/plain 下全部含 s2c_*.bin 的目录
// --owner 覆盖抓包账号 uid(默认用下面确定的那个)
// --dry 只解析与统计(含分组断言与撞主键分析),不连库、不写库
// --verify 不导入,只跑四项验证(逐字节重建 / 切片可重组 / 行数 / MySQL 侧核对)
// 不带 --dry / --verify 时:解析 → 写库 → 依次打印上面四项验证的结果。
'use strict';
const fs = require('fs');
const path = require('path');
const Crypto = require('crypto');
const Link = require('./server/link');
const SemanticModule = require('./server/semantic');
const Db = require('./server/db');
// ---------------------------------------------------------------- 命令行
const Argv = process.argv.slice(2);
function Arg(Name, Def) {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
}
function Has(Name) {
return Argv.indexOf('--' + Name) >= 0;
}
const ToolsDir = __dirname;
const PlainDir = path.resolve(ToolsDir, Arg('plain', path.join('..', 'packets', 'plain')));
const NamesDir = path.resolve(ToolsDir, Arg('names', path.join('..', 'names')));
const OnlySpace = Arg('space', null);
const Dry = Has('dry');
const Reset = Has('reset');
const VerifyOnly = Has('verify');
const Sem = SemanticModule.Load(NamesDir);
// 抓包账号的 uid:以「hash 登录」那条链上最权威的一处确定 ——
// packets/plain-relay/session_008/s2c_0006.bin(就是 names/space-init-login-100045260.json 的 s2c_0006.bin)
// 一帧两条记录:RememberLoginModel.RememberUsersHash(字段 hash,64 字符 token)
// + EntranceRequestModelCC.ReceiveLong(唯一字段是一个匿名 i64 = 3074457345817168740)
// 依据三条:① 该 i64 与登录 token 同帧、紧随其后,是服务端告诉客户端「你是哪个对象」;
// ② tools/server/dispatch.js:49 独立把它记成「用户对象 id(实测 3074457345817168740 = 0x2AAAAAAAAAB685CB64)」;
// ③ 它在 packets/plain/space-100072911 与 space-3074457345817190050 里大量作为**记录头 objectId** 出现
// (客户端拿它当目标 id 发空间命令),与「本机玩家的对象 id」这个角色吻合。
// LoginHash 留 NULL(真登录时再绑);所有 OwnerUid 列都填这个 uid —— 这是「抓包账号」的归属,
// **不是多账号隔离**:本次抓包只对应这一个账号,任何 OwnerUid 上的差异都只是数据来源的区别。
const DEFAULT_ACCOUNT_UID = '3074457345817168740';
const AccountUid = String(Arg('owner', DEFAULT_ACCOUNT_UID));
// 要用的字段型下行 methodId(名字以 names/downlink_table.json 为准)
const METHOD = {
ItemsOnDepot: '3996477900528000389', // itemsOnDepot: list<i64>
ItemsOnMarket: '5236393599344825589', // itemsOnMarket: list<i64>
MountedItems: '3692495084100053385', // mountedItems: list<i64>
DevicesLoaded: '7615142300796401818', // devices: list<i64> + mountedDevice: opt<i64>
};
// 本次要写的 9 张表(Accounts 不在内:它是 upsert 的账号行,--reset 不清它,免得把已绑的 LoginHash 抹掉)
const TARGET_TABLES = [
'Containers', 'SpaceObjects', 'ObjectModels', 'DependencyBatches', 'DependencyItems',
'Items', 'DeviceSlots', 'SlotDevices', 'Mounted',
];
// ---------------------------------------------------------------- 小工具
function FirstDiffOffset(A, B) {
for (let I = 0; I < Math.max(A.length, B.length); I++) if (A[I] !== B[I]) return I;
return -1;
}
// 逐项比较用的规范化序列化。列现在是 LONGTEXT,文本里的键序本就原样保留,不再像 JSON 列那样被重排;
// 这里仍统一按键序排序,只是为了让比较结果**不依赖任何一方的键序**(键序不是 JSON 语义,规范化是安全的)。
function StableJson(V) {
if (Array.isArray(V)) return '[' + V.map(StableJson).join(',') + ']';
if (V && typeof V === 'object') {
const Keys = Object.keys(V).sort();
return '{' + Keys.map((K) => JSON.stringify(K) + ':' + StableJson(V[K])).join(',') + '}';
}
return JSON.stringify(V === undefined ? null : V);
}
// 四个 JSON 列现在是 LONGTEXT + CHECK(JSON_VALID(...)):写进去的是 JSON.stringify 的原文,
// mysql2 读回来一定是**文本**(不再像 JSON 列那样自动解析),所以这里一律自己 JSON.parse。
// 为什么可以无条件 parse:文本本身就是 JSON 的完整编码 —— 字符串值带引号(ObjectModels 里 Id==0 那种
// "100073070" 在库里是带引号的 `"100073070"`,parse 回来仍是不丢精度的十进制字符串)、
// 数字值不带引号(parse 回来是 double,逐位还原)。
function JsonValue(V) {
return typeof V === 'string' ? JSON.parse(V) : V;
}
// 批量 upsert。db.js 的 InsertMany 是纯 INSERT,不满足「重复跑不产生重复行」,所以在工具内自备一份。
// KeepFirst=true ⇒ 主键已存在时**不改**(`ON DUPLICATE KEY UPDATE 首列 = VALUES(首列)` 是空操作):
// 同一主键在抓包里出现多份不同内容时以**首次出现**为准。为什么不用 INSERT IGNORE:它连
// 「字段值非法」这类错误也一起吞掉并塞默认值,那是静默改数据,不允许。
// ⚠ 不要用 affectedRows 判断「撞了多少主键」:mysql2 默认带 CLIENT_FOUND_ROWS,
// ON DUPLICATE KEY UPDATE 的 affectedRows 返回的是**匹配行数**(实测 = 推入行数,撞了也数 1),
// 所以撞主键的行数一律用 CountKeyCollisions 在主键上自己算(见下)。
async function UpsertMany(Pool, Table, Columns, Values, KeepFirst, ChunkSize) {
if (!Values.length) return 0;
const Size = ChunkSize || 200;
const Cols = Columns.map((C) => '`' + C + '`').join(', ');
const Sets = KeepFirst
? '`' + Columns[0] + '` = VALUES(`' + Columns[0] + '`)'
: Columns.map((C) => '`' + C + '` = VALUES(`' + C + '`)').join(', ');
let Done = 0;
for (let I = 0; I < Values.length; I += Size) {
const Chunk = Values.slice(I, I + Size);
const Placeholders = Chunk.map(() => '(' + Columns.map(() => '?').join(', ') + ')').join(', ');
const Flat = [];
for (const Row of Chunk) for (const V of Row) Flat.push(V);
await Pool.query(
'INSERT INTO `' + Table + '` (' + Cols + ') VALUES ' + Placeholders + ' ON DUPLICATE KEY UPDATE ' + Sets,
Flat,
);
Done += Chunk.length;
}
return Done;
}
// 主键去重统计:KeepFirst 下真正入库的行数 = 去重后的主键个数(+ 主键带 NULL 的行,MySQL 的唯一键管不住 NULL)。
// KeyIndexes = 主键在行数组里的列下标;NullKeyIndex = 允许为 NULL 的那一列(ObjectModels.ObjectId)。
// ★ 撞主键被丢的行**不在库里**,所以「到底丢没丢数据」只能在这里算:同时按**整行**(全部列)去重
// (DistinctFull),于是 Collisions 可拆成两半 ——
// Pushed - DistinctFull = 原样重发(整行与已入库那份逐字节相同,丢的不是数据)
// DistinctFull - Distinct = 内容不同(真冲突,只留了首次出现那份)
function CountKeyCollisions(Rows2, KeyIndexes, NullKeyIndex) {
const Seen = new Set();
const Full = new Set();
let Nulls = 0;
for (const R of Rows2) {
Full.add(StableJson(R.map((V) => (V === null ? null : String(V)))));
if (NullKeyIndex !== undefined && R[NullKeyIndex] === null) {
Nulls++;
continue;
}
Seen.add(KeyIndexes.map((I) => String(R[I])).join('\u0000'));
}
return {
Distinct: Seen.size + Nulls,
Collisions: Rows2.length - Nulls - Seen.size,
Nulls,
DistinctFull: Full.size,
SameRowDropped: Rows2.length - Full.size,
};
}
// ---------------------------------------------------------------- 扫描目标目录
// 目录名 space-<真实空间号> ⇒ SpaceId = 那个号;其它目录(lobby-session_*)用**目录名**当 SpaceId
// (那些帧里没有可解码的空间号,编一个号只会更难查;目录名恰好满足 (SpaceId, SourceFile) 的唯一性)。
function TargetDirs() {
const Out = [];
for (const E of fs.readdirSync(PlainDir, { withFileTypes: true })) {
if (!E.isDirectory()) continue;
const Full = path.join(PlainDir, E.name);
const Files = fs.readdirSync(Full).filter((F) => /^s2c_.*\.bin$/.test(F)).sort();
if (!Files.length) continue;
const SpaceId = E.name.indexOf('space-') === 0 ? E.name.slice('space-'.length) : E.name;
if (OnlySpace && SpaceId !== String(OnlySpace)) continue;
Out.push({ Dir: Full, Name: E.name, SpaceId, Files });
}
return Out;
}
// 一个 .bin = 恰好一整帧;不是就返回 null(不猜、不切)
function ReadFrameBody(File) {
const Parsed = Link.ParseFrames(fs.readFileSync(File));
if (!Parsed || Parsed.length !== 1) return null;
return Link.DecodeFrame(Parsed[0]);
}
// ---------------------------------------------------------------- 分组(见文件头「分组规则」)
function GroupPtItems(Items, Objects) {
const Groups = [];
let At = 0;
while (At < Items.length) {
const Head = Items[At];
if (String(Head.Id) !== '0') {
return { Ok: false, Why: '第 ' + At + ' 项本应是组首(Id 应为 0),实际 Id=' + Head.Id, Groups: null };
}
let End = At + 1;
while (End < Items.length && String(Items[End].Id) !== '0') End++;
Groups.push({ Ref: String(Head.Value), Items: Items.slice(At, End) });
At = End;
}
if (Groups.length !== Objects.length) {
return { Ok: false, Why: '组数 ' + Groups.length + ' ≠ Objects 个数 ' + Objects.length, Groups: null };
}
for (let I = 0; I < Groups.length; I++) {
if (Groups[I].Ref !== String(Objects[I].ObjectId)) {
return {
Ok: false,
Why: '第 ' + I + ' 组组首 Value=' + Groups[I].Ref + ' ≠ Objects[' + I + '].ObjectId=' + Objects[I].ObjectId,
Groups: null,
};
}
}
return { Ok: true, Groups, Why: '' };
}
// ---------------------------------------------------------------- 导入
const Stat = {
Dirs: 0, Files: 0, NotSingleFrame: 0, Frames: 0, FramesPartBad: 0,
Records: 0, ContainerRecords: 0, FieldRecords: 0, BadRecords: 0,
Pt: 0, Zt: 0, Lt: 0,
PtOk: 0, PtFail: 0, NullModelItems: 0, PtFailExamples: [],
ObjSliceVersions: new Map(), // ObjectId → Set(切片指纹):同一个对象在多条 pt 里有几份不同内容
FlagRecords: 0, DevicesLoadedRecords: 0, ZtBatchIds: new Set(),
SpaceObjConflicts: 0, JsonBytes: 0,
PerDir: [],
};
const Rows = {
Containers: [],
SpaceObjects: new Map(), // ObjectId → [ObjectId, GameTypeId, OwnerUid, SourceSpace]
ObjectModels: [],
DependencyBatches: [],
DependencyItems: [],
Items: new Map(), // ItemId → { Depot, Market, Mounted }
DeviceSlots: new Map(), // SlotObjectId → [SlotObjectId, OwnerUid]
SlotDevices: new Map(), // SlotObjectId|DeviceId → [SlotObjectId, Ord, DeviceId]
Mounted: new Map(), // SlotObjectId → [SlotObjectId, OwnerUid, DeviceId|null]
};
function MarkItem(Id, Flag) {
const Key = String(Id);
let Row = Rows.Items.get(Key);
if (!Row) {
Row = { Depot: 0, Market: 0, Mounted: 0 };
Rows.Items.set(Key, Row);
}
Row[Flag] = 1;
}
function PutContainer(SpaceId, File, Index, R) {
Stat.ContainerRecords++;
const IsZt = R.Layout === 'zt';
if (IsZt) Stat.Zt++;
else if (R.Layout === 'pt') Stat.Pt++;
else Stat.Lt++;
const Json = JSON.stringify(R.Value);
Stat.JsonBytes += Json.length;
Rows.Containers.push([
SpaceId, File, Index, String(R.ObjectId), String(R.MethodId), R.Layout,
IsZt ? R.Value.X : null, Json,
]);
}
function PutPt(SpaceId, File, Index, R) {
const V = R.Value;
// SpaceObjects:同一 ObjectId 可能出现在多个空间/多条容器里,取首次出现(冲突只统计,不覆盖)
for (const O of V.Objects) {
const Key = String(O.ObjectId);
const Prev = Rows.SpaceObjects.get(Key);
if (Prev && String(Prev[1]) !== String(O.GameTypeId)) Stat.SpaceObjConflicts++;
if (!Prev) Rows.SpaceObjects.set(Key, [Key, String(O.GameTypeId), AccountUid, SpaceId]);
}
const G = GroupPtItems(V.Items, V.Objects);
if (G.Ok) {
Stat.PtOk++;
for (let I = 0; I < G.Groups.length; I++) {
const ObjId = String(V.Objects[I].ObjectId);
const Slice = G.Groups[I].Items;
// 记录「这个对象的切片指纹」:唯一键是 (SpaceId, ObjectId, Ord),**同一空间内**同一对象若被多条 pt
// 重发且内容不同,只能留住首次出现的那份;指纹用于在报告里如实报出受影响的到底是哪几个对象。
const Fp = Crypto.createHash('sha1').update(JSON.stringify(Slice)).digest('hex');
const FpKey = SpaceId + '/' + ObjId;
if (!Stat.ObjSliceVersions.has(FpKey)) Stat.ObjSliceVersions.set(FpKey, new Set());
Stat.ObjSliceVersions.get(FpKey).add(Fp);
for (let M = 0; M < Slice.length; M++) {
Rows.ObjectModels.push([SpaceId, ObjId, M, String(Slice[M].Id), JSON.stringify(Slice[M].Value)]);
}
}
} else {
Stat.PtFail++;
Stat.NullModelItems += V.Items.length;
if (Stat.PtFailExamples.length < 3) {
Stat.PtFailExamples.push({ SpaceId, File, Index, Why: G.Why, Items: V.Items.length, Objects: V.Objects.length });
}
// 不硬塞:整条容器的 Items 以 ObjectId = NULL 落库,Ord 保序
for (let M = 0; M < V.Items.length; M++) {
Rows.ObjectModels.push([SpaceId, null, M, String(V.Items[M].Id), JSON.stringify(V.Items[M].Value)]);
}
}
}
// zt 的 X(客户端回调号)**不是唯一键**(会被复用),一条依赖批次的身份是「哪一条容器记录」:
// 主键 = (SpaceId, SourceFile, RecordIndex, Ord),与 Containers 同一套自然键;X 只是列 + 索引。
// RecordIndex = 该 zt 记录在帧内是第几条记录(从 0 开始,与 Containers.RecordIndex 同一口径)。
function PutZt(SpaceId, File, Index, R) {
const V = R.Value;
const BatchId = V.X;
Stat.ZtBatchIds.add(SpaceId + '/' + BatchId);
for (let I = 0; I < (V.Batches || []).length; I++) {
const B = V.Batches[I];
Rows.DependencyBatches.push([
SpaceId, File, Index, I, BatchId, String(B.ObjectId), JSON.stringify((B.ModelIds || []).map(String)),
]);
}
for (let I = 0; I < (V.Items || []).length; I++) {
const X = V.Items[I];
Rows.DependencyItems.push([
SpaceId, File, Index, I, BatchId, String(X.R), X.E, X.U, String(X.S), X.O,
JSON.stringify((X.Skipped || []).map(String)),
]);
}
}
function PutFields(R) {
Stat.FieldRecords++;
const Get = (Name) => {
const F = (R.Fields || []).find((X) => X.Name === Name);
return F ? F.Value : undefined;
};
if (R.MethodId === METHOD.ItemsOnDepot) {
Stat.FlagRecords++;
const V = Get('itemsOnDepot');
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Depot');
} else if (R.MethodId === METHOD.ItemsOnMarket) {
Stat.FlagRecords++;
const V = Get('itemsOnMarket');
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Market');
} else if (R.MethodId === METHOD.MountedItems) {
Stat.FlagRecords++;
const V = Get('mountedItems');
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Mounted');
} else if (R.MethodId === METHOD.DevicesLoaded) {
Stat.DevicesLoadedRecords++;
const Slot = String(R.ObjectId);
Rows.DeviceSlots.set(Slot, [Slot, AccountUid]);
const Devices = Get('devices');
if (Array.isArray(Devices)) {
for (let I = 0; I < Devices.length; I++) {
const Key = Slot + '|' + String(Devices[I]);
Rows.SlotDevices.set(Key, [Slot, I, String(Devices[I])]);
}
}
const M = Get('mountedDevice');
Rows.Mounted.set(Slot, [Slot, AccountUid, M === undefined || M === null ? null : String(M)]);
}
}
function ScanAll(Dirs) {
for (const D of Dirs) {
Stat.Dirs++;
const Cnt = { Files: 0, Containers: 0, Fields: 0, Bad: 0, Pt: 0, Zt: 0, Lt: 0 };
for (const File of D.Files) {
Stat.Files++;
Cnt.Files++;
const Body = ReadFrameBody(path.join(D.Dir, File));
if (!Body) {
Stat.NotSingleFrame++;
continue;
}
Stat.Frames++;
const Decoded = Sem.DecodeFrameBody(Body);
if (Decoded.Bad) Stat.FramesPartBad++;
for (let I = 0; I < Decoded.Records.length; I++) {
const R = Decoded.Records[I];
Stat.Records++;
// Bad / 短命令形态:没有可用的布局或字段,**丢弃并统计**(不写 Containers,避免把猜的东西当原文)
if (R.Bad || R.ObjectId === undefined) {
Stat.BadRecords++;
Cnt.Bad++;
continue;
}
if (R.Kind === 'container') {
PutContainer(D.SpaceId, File, I, R);
Cnt.Containers++;
if (R.Layout === 'pt') {
Cnt.Pt++;
PutPt(D.SpaceId, File, I, R);
} else if (R.Layout === 'zt') {
Cnt.Zt++;
PutZt(D.SpaceId, File, I, R);
} else {
Cnt.Lt++;
}
} else {
PutFields(R);
Cnt.Fields++;
}
}
}
Stat.PerDir.push({ Dir: D.Name, SpaceId: D.SpaceId, ...Cnt });
}
}
// ---------------------------------------------------------------- 写库
// 逐表的落库计划(列序 / 主键列 / 允许 NULL 的主键列)。写库与行数统计共用同一份,避免两处口径漂移。
// 全表统一 KeepFirst:同一主键在抓包里出现多份不同内容时保留**首次出现**的版本(撞主键的行数在报告里给出)。
function TablePlans() {
return [
{ Table: 'Containers', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'ObjectId', 'MethodId', 'Layout', 'X', 'ValueJson'], Keys: [0, 1, 2], Values: Rows.Containers },
{ Table: 'SpaceObjects', Columns: ['ObjectId', 'GameTypeId', 'OwnerUid', 'SourceSpace'], Keys: [0], Values: [...Rows.SpaceObjects.values()] },
{ Table: 'ObjectModels', Columns: ['SpaceId', 'ObjectId', 'Ord', 'ModelId', 'ValueJson'], Keys: [0, 1, 2], NullKey: 1, Values: Rows.ObjectModels },
{ Table: 'DependencyBatches', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'Ord', 'BatchId', 'ObjectId', 'ModelIds'], Keys: [0, 1, 2, 3], Values: Rows.DependencyBatches },
{ Table: 'DependencyItems', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'Ord', 'BatchId', 'R', 'E', 'U', 'S', 'O', 'Skipped'], Keys: [0, 1, 2, 3], Values: Rows.DependencyItems },
{ Table: 'Items', Columns: ['ItemId', 'OwnerUid', 'InDepot', 'InMarket', 'Mounted'], Keys: [0], Values: [...Rows.Items.entries()].map(([K, V]) => [K, AccountUid, V.Depot, V.Market, V.Mounted]) },
{ Table: 'DeviceSlots', Columns: ['SlotObjectId', 'OwnerUid'], Keys: [0], Values: [...Rows.DeviceSlots.values()] },
{ Table: 'SlotDevices', Columns: ['SlotObjectId', 'Ord', 'DeviceId'], Keys: [0, 2], Values: [...Rows.SlotDevices.values()] },
{ Table: 'Mounted', Columns: ['SlotObjectId', 'OwnerUid', 'DeviceId'], Keys: [0], Values: [...Rows.Mounted.values()] },
];
}
// 每张表的「推入 / 入库(主键去重后)/ 撞主键被留住原值」——全部在主键上自己算(见 UpsertMany 的注释)
function RowsReport() {
return TablePlans().map((P) => Object.assign({}, P, CountKeyCollisions(P.Values, P.Keys, P.NullKey), { Pushed: P.Values.length }));
}
async function WriteAll(Pool) {
if (Reset) {
const Order = ['ObjectModels', 'DependencyItems', 'DependencyBatches', 'SlotDevices', 'Mounted', 'DeviceSlots', 'Items', 'SpaceObjects', 'Containers'];
for (const T of Order) await Pool.query('DELETE FROM `' + T + '`');
console.log('--reset:已清空 ' + Order.join(' / '));
}
// ObjectModels 里 ObjectId IS NULL 那批不受唯一键保护(MySQL 允许唯一键上多个 NULL)→ 先删再插,保证幂等
const [DelNull] = await Pool.query('DELETE FROM ObjectModels WHERE ObjectId IS NULL');
if (DelNull.affectedRows) console.log('清理上一轮的 ObjectId IS NULL 行:' + DelNull.affectedRows);
const Out = RowsReport();
for (const P of Out) P.Written = await UpsertMany(Pool, P.Table, P.Columns, P.Values, true);
await Pool.query('INSERT INTO Accounts (Uid, LoginHash) VALUES (?, NULL) ON DUPLICATE KEY UPDATE Uid = VALUES(Uid)', [AccountUid]);
return Out;
}
// ---------------------------------------------------------------- 验证
// ① 逐字节重建:从 Containers 表读出 ValueJson → container.Write(Layout, Value) → 与帧里那条负载比
async function VerifyRebuild(Pool, Dirs) {
let Same = 0;
let Diff = 0;
const Worst = [];
for (const D of Dirs) {
for (const File of D.Files) {
const DbRows = await Db.Many(Pool,
'SELECT Seq, RecordIndex, Layout, ValueJson FROM Containers WHERE SpaceId = ? AND SourceFile = ? ORDER BY RecordIndex',
[D.SpaceId, File]);
if (!DbRows.length) continue;
const Body = ReadFrameBody(path.join(D.Dir, File));
if (!Body) continue;
const Decoded = Sem.DecodeFrameBody(Body);
const ByIndex = new Map(DbRows.map((R) => [Number(R.RecordIndex), R]));
for (let I = 0; I < Decoded.Records.length; I++) {
const R = Decoded.Records[I];
const Row = ByIndex.get(I);
if (!Row || R.Kind !== 'container' || R.Bad) continue;
const Orig = Body.slice(R.At + 16, R.At + 16 + R.Consumed);
let Built = null;
let Err = null;
try {
Built = Sem.Container.Write(R.Layout, JsonValue(Row.ValueJson));
} catch (E) {
Err = E.message;
}
if (Built && Built.equals(Orig)) Same++;
else {
Diff++;
if (Worst.length < 3) {
Worst.push({
Seq: Row.Seq, SpaceId: D.SpaceId, File, Index: I, Layout: R.Layout,
DiffAt: Built ? FirstDiffOffset(Built, Orig) : -1, Below: Built ? Built.length : -1, Orig: Orig.length, Err,
});
}
}
}
}
}
return { Same, Diff, Worst };
}
// ② 对象切片可重组:随机取 3 个 pt 容器,用 ObjectModels 的切片拼回 Items 全序列,再整体重编码比字节。
// ⚠ 必须按 SpaceId 限定:同一个对象在不同空间可以各有一份不同内容,不限定就会把别的空间的切片拼进来。
async function LoadSlices(Pool, SpaceId, ObjectIds) {
const Out = new Map();
for (let I = 0; I < ObjectIds.length; I += 200) {
const Chunk = ObjectIds.slice(I, I + 200);
const Rows2 = await Db.Many(Pool,
'SELECT ObjectId, Ord, ModelId, ValueJson FROM ObjectModels WHERE SpaceId = ? AND ObjectId IN (' +
Chunk.map(() => '?').join(',') + ') ORDER BY ObjectId, Ord', [SpaceId].concat(Chunk));
for (const R of Rows2) {
const Key = String(R.ObjectId);
if (!Out.has(Key)) Out.set(Key, []);
Out.get(Key).push({ Id: String(R.ModelId), Value: JsonValue(R.ValueJson) });
}
}
return Out;
}
// 找出两棵 JSON 树的第一处差异(路径 + 两侧值)。用途:验证② 里「Items 逐项相等 ✗」时
// 把差异定位到**具体字段**,而不是只丢一句 ✗。
// 历史:旧版四个 JSON 列用 MySQL 的 JSON 类型,double 往返差 1 个 ulp,这里就是当初用来定位它的;
// 现在列是 LONGTEXT(见文件头「持久化精度口径」),这条路径预期不会再被触发,保留作为定位工具。
function FirstJsonDiff(A, B, At) {
const P = At || '';
if (Array.isArray(A) && Array.isArray(B)) {
if (A.length !== B.length) return { Path: P, A: A.length, B: B.length };
for (let I = 0; I < A.length; I++) {
const R = FirstJsonDiff(A[I], B[I], P + '/' + I);
if (R) return R;
}
return null;
}
if (A && B && typeof A === 'object' && typeof B === 'object') {
for (const K of [...new Set([...Object.keys(A), ...Object.keys(B)])].sort()) {
const R = FirstJsonDiff(A[K], B[K], P + '/' + K);
if (R) return R;
}
return null;
}
return StableJson(A) === StableJson(B) ? null : { Path: P, A, B };
}
// 随机取样用的 PRNG(固定种子,mulberry32):**随机取 3 条**,但同一次抓包每次跑都抽到同样 3 条,
// 报告里的三个样本号才可复算 —— 用 Math.random 的话这份报告就没法被第二个人重现。
function SeededRandom(Seed) {
let A = Seed >>> 0;
return function () {
A = (A + 0x6d2b79f5) >>> 0;
let T = A;
T = Math.imul(T ^ (T >>> 15), T | 1);
T ^= T + Math.imul(T ^ (T >>> 7), T | 61);
return ((T ^ (T >>> 14)) >>> 0) / 4294967296;
};
}
async function VerifyReassemble(Pool, Dirs) {
const Total = Number((await Db.One(Pool, "SELECT COUNT(*) AS N FROM Containers WHERE Layout = 'pt'")).N);
const Rand = SeededRandom(20260917);
const Picked = [];
const Seen = new Set();
while (Picked.length < Math.min(3, Total)) {
const Off = Math.floor(Rand() * Total);
if (Seen.has(Off)) continue;
Seen.add(Off);
const R = await Db.One(Pool,
"SELECT Seq, SpaceId, SourceFile, RecordIndex FROM Containers WHERE Layout = 'pt' ORDER BY Seq LIMIT 1 OFFSET " + Off);
if (R) Picked.push(R);
}
const Out = [];
for (const P of Picked) {
const D = Dirs.find((X) => X.SpaceId === String(P.SpaceId));
const File = D ? path.join(D.Dir, String(P.SourceFile)) : null;
const Rec = { Seq: P.Seq, SpaceId: P.SpaceId, File: P.SourceFile, Index: Number(P.RecordIndex) };
const Body = File && fs.existsSync(File) ? ReadFrameBody(File) : null;
if (!Body) {
Out.push(Object.assign(Rec, { Ok: false, Why: '找不到原始帧' }));
continue;
}
const Decoded = Sem.DecodeFrameBody(Body);
const R = Decoded.Records[Number(P.RecordIndex)];
if (!R || R.Kind !== 'container' || R.Layout !== 'pt') {
Out.push(Object.assign(Rec, { Ok: false, Why: '帧里第 ' + P.RecordIndex + ' 条不是 pt 容器记录' }));
continue;
}
const V = R.Value;
const ObjIds = V.Objects.map((O) => String(O.ObjectId));
const Slices = await LoadSlices(Pool, P.SpaceId, ObjIds);
const Rebuilt = [];
let Missing = 0;
for (const Id of ObjIds) {
const Slice = Slices.get(Id);
if (!Slice) {
Missing++;
continue;
}
for (const It of Slice) Rebuilt.push(It);
}
const ItemsSame = StableJson(Rebuilt) === StableJson(V.Items);
// 逐对象定位:若某对象的切片与帧里不一致,把对象 id 点出来。唯一键是 (SpaceId, ObjectId, Ord),
// 所以只有「同一空间内同一对象被多条 pt 重发且内容不同」才会对不上(实测为 0,见报告)。
const Bad = [];
const G = GroupPtItems(V.Items, V.Objects);
if (G.Ok) {
for (let K = 0; K < G.Groups.length; K++) {
const Id = ObjIds[K];
const Stored = Slices.get(Id) || [];
if (StableJson(Stored) !== StableJson(G.Groups[K].Items)) {
Bad.push(Object.assign(
{ ObjectId: Id, Ord: K, InFrame: G.Groups[K].Items.length, InDb: Stored.length },
FirstJsonDiff(G.Groups[K].Items, Stored) || {},
));
}
}
}
let BytesSame = false;
let Err = null;
try {
const Built = Sem.Container.Write('pt', { Objects: V.Objects, Items: Rebuilt });
BytesSame = Built.equals(Body.slice(R.At + 16, R.At + 16 + R.Consumed));
} catch (E) {
Err = E.message;
}
Out.push(Object.assign(Rec, {
Objects: V.Objects.length, Items: V.Items.length, Rebuilt: Rebuilt.length,
MissingObjects: Missing, ItemsSame, BytesSame, Err, Bad,
Loaded: [...Slices.values()].reduce((A, B) => A + B.length, 0),
}));
}
return Out;
}
// ③ 行数统计(含派生的那 9 个数)
async function CountAll(Pool) {
const Count = {};
for (const T of TARGET_TABLES.concat(['Accounts'])) {
Count[T] = Number((await Db.One(Pool, 'SELECT COUNT(*) AS N FROM `' + T + '`')).N);
}
const One2 = async (Sql) => Number((await Db.One(Pool, Sql)).N);
Count.DepotCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE InDepot = 1');
Count.MarketCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE InMarket = 1');
Count.MountedCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE Mounted = 1');
Count.DeviceSlotCount = Count.DeviceSlots;
Count.DeviceCount = Count.SlotDevices;
Count.ObjectCount = Count.SpaceObjects;
Count.ModelItemCount = Count.ObjectModels;
Count.BatchCount = Count.DependencyBatches;
Count.ContainerCount = Count.Containers;
return Count;
}
// ④ MySQL 侧核对(直接查,原始输出原样贴报告)
async function VerifyMysql(Pool) {
const A = await Db.One(Pool, 'SELECT COUNT(*) AS N, MIN(ObjectId) AS Lo, MAX(ObjectId) AS Hi FROM SpaceObjects');
const B = await Db.One(Pool, 'SELECT COUNT(*) AS N FROM Mounted WHERE DeviceId IS NOT NULL');
const C = await Db.One(Pool, 'SELECT COUNT(*) AS N FROM ObjectModels WHERE ObjectId IS NULL');
return { SpaceObjects: A, MountedWithDevice: B, NullObjectModels: C };
}
function Report() {
console.log('');
console.log('=== 解析统计 ===');
console.log('目录 ' + Stat.Dirs + ' / 文件 ' + Stat.Files + '(非整帧 ' + Stat.NotSingleFrame + ')→ 帧 ' + Stat.Frames +
'(含解不动记录的帧 ' + Stat.FramesPartBad + ')');
console.log('记录 ' + Stat.Records + ' 条:容器 ' + Stat.ContainerRecords +
'(pt ' + Stat.Pt + ' / zt ' + Stat.Zt + ' / lt ' + Stat.Lt + ')/ 字段 ' + Stat.FieldRecords +
' / 丢弃(Bad·短命令) ' + Stat.BadRecords);
console.log('ValueJson 合计 ' + (Stat.JsonBytes / 1048576).toFixed(2) + ' MB');
console.log('pt 分组:通过 ' + Stat.PtOk + ' / 失败 ' + Stat.PtFail +
(Stat.PtFail ? '(占 ' + (100 * Stat.PtFail / Math.max(1, Stat.PtOk + Stat.PtFail)).toFixed(2) + '%,NULL 落库项 ' + Stat.NullModelItems + ')' : ''));
for (const E of Stat.PtFailExamples) {
console.log(' 例:' + E.SpaceId + '/' + E.File + ' 第 ' + E.Index + ' 条 Items=' + E.Items + ' Objects=' + E.Objects + ' → ' + E.Why);
}
const MultiVersion = [...Stat.ObjSliceVersions.entries()].filter(([, S]) => S.size > 1);
console.log('同空间内切片有多份不同内容的「空间/对象」:' + MultiVersion.length + ' 个' +
(MultiVersion.length ? '(' + MultiVersion.slice(0, 3).map(([K, S]) => K + '×' + S.size).join(',') + ')' : '') +
' —— 只有这些才会在 ObjectModels 上撞 (SpaceId, ObjectId, Ord) 而只留首次出现的那份');
console.log('字段记录用到:三旗标 ' + Stat.FlagRecords + ' 条 / devicesLoaded ' + Stat.DevicesLoadedRecords + ' 条');
console.log('');
console.log('=== 目录明细 ===');
for (const D of Stat.PerDir) {
console.log(' ' + D.Dir.padEnd(26) + ' SpaceId=' + String(D.SpaceId).padEnd(22) +
' 文件 ' + String(D.Files).padStart(5) + ' 容器 ' + String(D.Containers).padStart(5) +
'(pt ' + D.Pt + ' / zt ' + D.Zt + ' / lt ' + D.Lt + ')字段 ' + String(D.Fields).padStart(4) + ' 丢弃 ' + D.Bad);
}
console.log('');
console.log('=== 待写入行数(KeepFirst:撞主键保留首次出现;被丢的行按整行是否原样重发再分一次)===');
console.log(' ' + '表'.padEnd(20) + '推入'.padEnd(8) + '入库'.padEnd(8) + '撞主键'.padEnd(8) + '原样重发'.padEnd(10) + '内容不同');
for (const P of RowsReport()) {
console.log(' ' + P.Table.padEnd(20) + String(P.Pushed).padEnd(8) + String(P.Distinct).padEnd(8) +
String(P.Collisions).padEnd(8) + String(P.SameRowDropped).padEnd(10) + (P.DistinctFull - P.Distinct));
}
console.log(' zt 的 (SpaceId, X) 去重后 ' + Stat.ZtBatchIds.size + ' 个');
console.log(' 账号 Uid=' + AccountUid);
if (Stat.SpaceObjConflicts) console.log(' ⚠ SpaceObjects 同 ObjectId 撞不同 GameTypeId:' + Stat.SpaceObjConflicts + ' 次(保留首次出现)');
}
// ---------------------------------------------------------------- 主流程
async function Main() {
const Dirs = TargetDirs();
console.log('import_db.js 明文目录=' + PlainDir + ' 名字目录=' + NamesDir);
console.log('目标目录 ' + Dirs.length + ' 个' + (OnlySpace ? '(--space ' + OnlySpace + ')' : '') + (Dry ? '(--dry:不连库、不写库)' : ''));
if (!VerifyOnly) {
ScanAll(Dirs);
Report();
if (Dry) return;
}
const Pool = Db.Connect(Db.LoadConfig());
try {
const Tables = await Db.EnsureSchema(Pool);
console.log('');
console.log('库 ' + Db.LoadConfig().Database + ' 已有表 ' + Tables.length + ' 张:' + Tables.join(', '));
if (!VerifyOnly) {
const Written = await WriteAll(Pool);
console.log('');
console.log('=== 已写入(KeepFirst upsert;被丢的行按整行是否原样重发再分一次)===');
console.log(' ' + '表'.padEnd(20) + '推入'.padEnd(8) + '入库'.padEnd(8) + '撞主键'.padEnd(8) + '原样重发'.padEnd(10) + '内容不同');
for (const P of Written) {
console.log(' ' + P.Table.padEnd(20) + String(P.Pushed).padEnd(8) + String(P.Distinct).padEnd(8) +
String(P.Collisions).padEnd(8) + String(P.SameRowDropped).padEnd(10) + (P.DistinctFull - P.Distinct));
}
console.log(' ' + 'Accounts'.padEnd(20) + String(1).padEnd(8) + String(1).padEnd(8) + String(0).padEnd(8) + String(0).padEnd(10) + 0 + ' (Uid=' + AccountUid + ',LoginHash=NULL)');
}
console.log('');
console.log('=== 验证① 逐字节重建(Containers.ValueJson → container.Write 现编 vs 帧里原始负载)===');
const V1 = await VerifyRebuild(Pool, Dirs);
console.log(' 一致 ' + V1.Same + ' / 不一致 ' + V1.Diff);
for (const W of V1.Worst) {
console.log(' 不一致:Seq=' + W.Seq + ' ' + W.SpaceId + '/' + W.File + ' 第 ' + W.Index + ' 条 Layout=' + W.Layout +
' 差异偏移=' + W.DiffAt + ' 现编 ' + W.Below + 'B vs 原始 ' + W.Orig + 'B' + (W.Err ? ' 抛错:' + W.Err : ''));
}
console.log('');
console.log('=== 验证② 对象切片可重组(随机 3 个 pt 容器,种子固定 20260917 → 可复算)===');
const V2 = await VerifyReassemble(Pool, Dirs);
for (const R of V2) {
console.log(' Seq=' + R.Seq + ' ' + R.SpaceId + '/' + R.File + ' 第 ' + R.Index + ' 条 对象 ' + R.Objects +
' Items ' + R.Items + ' → 拼回 ' + R.Rebuilt + '(从库读 ' + R.Loaded + ',缺对象 ' + R.MissingObjects + ')' +
' Items 逐项相等 ' + (R.ItemsSame ? '✓' : '✗') + ' 整段 Write 逐字节一致 ' + (R.BytesSame ? '✓' : '✗') +
(R.Why ? ' ' + R.Why : '') + (R.Err ? ' 抛错:' + R.Err : ''));
for (const B of (R.Bad || []).slice(0, 3)) {
console.log(' 不一致对象 ObjectId=' + B.ObjectId + '(第 ' + B.Ord + ' 组):帧里 ' + B.InFrame + ' 项 vs 库里 ' + B.InDb + ' 项' +
(B.Path !== undefined ? ';首处差异 ' + (B.Path || '/') + ':帧 ' + JSON.stringify(B.A) + ' vs 库 ' + JSON.stringify(B.B) : ''));
}
}
console.log('');
console.log('=== 验证③ 行数统计 ===');
const C = await CountAll(Pool);
for (const T of TARGET_TABLES.concat(['Accounts'])) console.log(' ' + T.padEnd(20) + C[T]);
console.log(' ---- 派生 ----');
for (const K of ['DepotCount', 'MarketCount', 'MountedCount', 'DeviceSlotCount', 'DeviceCount', 'ObjectCount', 'ModelItemCount', 'BatchCount', 'ContainerCount']) {
console.log(' ' + K.padEnd(20) + C[K]);
}
console.log('');
console.log('=== 验证④ MySQL 侧核对(原始输出)===');
const M = await VerifyMysql(Pool);
console.log(' SELECT COUNT(*), MIN(ObjectId), MAX(ObjectId) FROM SpaceObjects; → ' +
M.SpaceObjects.N + ' | ' + M.SpaceObjects.Lo + ' | ' + M.SpaceObjects.Hi);
console.log(' SELECT COUNT(*) FROM Mounted WHERE DeviceId IS NOT NULL; → ' + M.MountedWithDevice.N);
console.log(' SELECT COUNT(*) FROM ObjectModels WHERE ObjectId IS NULL; → ' + M.NullObjectModels.N + '(分组失败的兜底行,应为 0)');
} finally {
await Pool.end();
}
}
Main().catch((Err) => {
console.error('失败:' + (Err && Err.stack ? Err.stack : Err));
process.exit(1);
});
+94
View File
@@ -0,0 +1,94 @@
// ingest.js - 把一次抓包(packets/<label>/session_*)批量入库到回放库 packets/plain/
// 用法: node ingest.js [--label mine-01] [--root ../packets] [--dry]
// 判定: 看每个 session 的首条 c2s 帧——
// cmd=1 (Hash Request) → 入口/大厅连接,入库为 lobby-<session>
// cmd=3 (EnterSpace) → 空间连接,末 8 字节是 spaceId,入库为 space-<spaceId>
// 同一空间有多个 session 时,只保留 s2c 帧最多的那个(回放库靠 space-<id> 目录名索引,
// 不能有重名),其余在报告里列为备用。
// 产物: packets/plain/<space-*|lobby-*>/{s2c_*.bin, c2s_*.bin, timeline_*.txt, summary.txt}
const fs = require('fs');
const path = require('path');
const cp = require('child_process');
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')));
const Label = Arg('label', '');
const Dry = Argv.indexOf('--dry') >= 0;
const LabelDir = Label ? path.join(Root, Label) : Root;
function NullmapSize(Buf, Offset) {
const R = Buf[Offset];
if (R === undefined) return 0;
if (R & 0x80) return R & 0x40 ? 4 + (((R & 0x3f) << 16) | (Buf[1] << 8) | Buf[2]) : 1 + (R & 0x3f);
return 1 + ((R & 0x60) >>> 5);
}
// 首条 c2s 帧 → { Cmd, SpaceId }
function Peek(Buf) {
const B0 = Buf[0];
const HeaderSize = B0 & 0x80 ? 4 : 2;
const Body = Buf.slice(HeaderSize);
const At = NullmapSize(Body, 0);
if (At >= Body.length) return { Cmd: -1 };
const Cmd = Body[At];
if (Cmd === 3 && At + 41 <= Body.length) return { Cmd, SpaceId: Body.readBigInt64BE(At + 33).toString() };
return { Cmd };
}
const Sessions = fs
.readdirSync(LabelDir, { withFileTypes: true })
.filter((D) => D.isDirectory() && D.name.startsWith('session_'))
.map((D) => path.join(LabelDir, D.name))
.sort();
const Plan = new Map(); // 目标目录名 → { Session, Count, Cmd, Space }
const Rows = [];
for (const Dir of Sessions) {
const Files = fs.readdirSync(Dir);
const First = Files.filter((F) => /^c2s_\d+\.bin$/.test(F)).sort()[0];
if (!First) continue;
const Info = Peek(fs.readFileSync(path.join(Dir, First)));
const Count = Files.filter((F) => /^s2c_\d+\.bin$/.test(F)).length;
let Target;
if (Info.Cmd === 3 && Info.SpaceId) Target = 'space-' + Info.SpaceId;
else if (Info.Cmd === 1) Target = 'lobby-' + path.basename(Dir);
else Target = 'other-' + path.basename(Dir);
const Prev = Plan.get(Target);
if (!Prev) Plan.set(Target, { Session: Dir, Count, Cmd: Info.Cmd, Space: Info.SpaceId, Skipped: [] });
else if (Count > Prev.Count) {
Prev.Skipped.push(Prev.Session);
Plan.set(Target, { Session: Dir, Count, Cmd: Info.Cmd, Space: Info.SpaceId, Skipped: Prev.Skipped });
} else Prev.Skipped.push(Dir);
}
console.log('入库计划(label=' + (Label || '(root)') + ',共 ' + Sessions.length + ' 个会话):');
for (const [Target, Item] of Plan) {
const Out = path.join(PROJECT, 'packets', 'plain', Target);
console.log(
' ' + Target.padEnd(28) + ' ← ' + path.basename(Item.Session) + ' s2c ' + String(Item.Count).padStart(3) + ' 帧' +
(Item.Cmd === 3 ? ' spaceId=' + Item.Space : '') + (fs.existsSync(Out) ? ' [覆盖已存在]' : ''),
);
Rows.push({ Target, Session: Item.Session, Out });
}
if (Dry) {
console.log('\n--dry:只打印计划,未落盘');
process.exit(0);
}
console.log('\n开始解密入库...');
for (const R of Rows) {
const Out = cp
.execFileSync('node', [path.join(__dirname, 'dump_plain.js'), '--session', R.Session, '--out', R.Out], { encoding: 'utf8' })
.split('\n')
.filter((L) => /明文前|未能解出/.test(L))
.join(' ');
console.log(' ' + R.Target.padEnd(28) + ' ' + Out.trim());
}
console.log('\n入库完成。下一步: node pair_flow.js');
+150 -11
View File
@@ -4,7 +4,22 @@
// bool/i8=1B i16=2B i32/f32=4B i64/f64=8B(全部大端)
// string/bytes = varint 长度 + 原始字节
// list<T> = varint 元素数 + 元素…
// map<K, V> = varint 条数 + 条数 × (K, V)
// —— 出处 src/module_24635.js:489-512 的 Jt.o7g/p7g:先 w.s7g/w.t7g 写/读条数,
// 再对每个条目 this.w7g_1(key codec)/ this.x7g_1(value codec)各写/读一次;
// w.s7g/w.t7g 就是 list/string 用的同一个变长长度编解码(src/module_24635.js:589-611)。
// 描述符是 src/module_24635.js:262-264 的 pn = MapCodecInfo(key, value, optional)。
// 值是 [[key, value], …](不是对象):map 的键可能是任意类型/可能重复,用对象会丢信息。
// opt<T> = 1 字节标志(非 0 = 空)+ 有值时紧跟 T
// —— **这只是字段表那一层的近似**。真实线形是 1 个 bit(见下 OptBits),
// 两者只有在「调用方没法拿到 nullmap 比特流」时才等价(表驱动的那条路径就是这么用的)。
// opt<T>(OptBits 模式)= 从 nullmap 比特流读 1 个 bit:1 = 空(负载里什么都没有),0 = 有值、紧跟 T。
// 出处 src/module_24635.js:412-417:Ht.o7g 写 `t.b7g_1.x7f(!0/!1)`(位图写 1 bit),
// Ht.p7g 读 `t.b7g_1.xy() ? null : this.n7g_1.p7g(t)`(读 1 bit,为空则不读值)。
// b7g_1 就是 nullmap 的位读取器(src/module_24635.js:137-139 的上下文 a7g_1/b7g_1,
// src/module_24635.js:809-845 的 r7f 把 nullmap 灌进 b7g_1)。
// 关键:这个位图是「记录形态位 + 模型内 opt 位」共用的同一条比特流,所以按字节猜
// 「opt 有没有标志字节」在负载里出现 opt 字段时必然错位(容器负载正是这种情况)。
// enum<T> = 4 字节(库里的 EnumCodecInfo 序列化器 o7g 是空实现,但游戏自己注册的枚举写 4 字节;
// 实测 ChatCC 按 4 字节解才能精确吃满 1476B 负载,按 0 字节只有 40% 的容器内层能对上边界)
// 模型类 = 就地展开该模型 codec 的字段(无长度前缀)
@@ -31,6 +46,7 @@ function LoadTables(NamesDir) {
CodecByModelId: new Map(),
CodecByModelName: new Map(),
WireClasses: new Map(),
ResourceClasses: {},
UplinkByMethod: new Map(),
};
try {
@@ -39,11 +55,17 @@ function LoadTables(NamesDir) {
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);
// ModelIds:同一个 codec 类被多个 modelId 注册时(regdump 的 w7n_1 里 ctor+槽位指纹相同的多条),
// 这里把「同一个 codec 键下的全部 modelId」都登记上。只补空缺、不覆盖已绑定的,
// 这样对既有行为是纯增量(老绑定怎么解还怎么解)。
for (const M of C.ModelIds || []) if (!Out.CodecByModelId.has(M)) Out.CodecByModelId.set(M, 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);
// 资源类(源码 module_71105 的 u83 注册块导出)→ 线上 i64:容器层拿它当「裸类名直译表」的一部分
Out.ResourceClasses = CF.ResourceClasses || {};
} catch (Err) {
Out.CodecError = Err.message;
}
@@ -65,13 +87,66 @@ function ReadVarint(Buf, Off) {
return { Value: ((B0 & 0x3f) << 8) + B1, Next: Off + 2 };
}
// 把复合类型的内层按「顶层逗号」切开:map<enum<X>, i32> → ['enum<X>', 'i32']。
// 只按 <> 深度判断(类型串里只有尖括号会嵌套),不做任何猜测。
function SplitTypeArgs(Text) {
const Out = [];
let Depth = 0;
let Start = 0;
for (let I = 0; I < Text.length; I++) {
const C = Text[I];
if (C === '<') Depth++;
else if (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;
}
function MakeReader(Tables, Options) {
const Opts = Options || {};
const EnumBytes = Opts.EnumBytes === undefined ? 4 : Opts.EnumBytes;
// OptBits:opt 的「空/非空」改从 nullmap 比特流读 1 个 bit(见文件头注释)。
// 默认 false = 老口径(1 字节标志),也就是「字段表」那条路径的既有行为,不动它。
const OptBits = !!Opts.OptBits;
// TypeAlias:codec_fields.json 里推不出嵌套 codec 的裸类名 → 线上基础类型(容器层才给)。
// 只做「查表直译」,不做任何猜测:没命中就照旧报「无 codec」。
const TypeAlias = Opts.TypeAlias || null;
// StringLiteralOnly:string 一律按「字面 UTF-8」解,**不再退回 '0x…' 十六进制转义**(容器层才开)。
// 为什么要这个开关:'0x…' 转义与「本身就是 "0xff8000" 这样的字面量」在写侧撞车——
// 实测 space-920007382011/s2c_0001.bin 的 borderColorForEventButton 就是字面量 "0xff8000",
// 走转义约定时写回会变成 3 字节裸字节(少 5 字节)。容器路径没有历史包袱,所以这里取
// 「要么字面量、要么如实报错」,不做有歧义的转义(字段表那条路径保持原样,不动它的口径)。
const StringLiteralOnly = !!Opts.StringLiteralOnly;
const CodecByKey = Tables.CodecByKey;
const CodecByModelName = Tables.CodecByModelName || new Map();
const WireClasses = Tables.WireClasses || new Map();
// nullmap 比特流游标(OptBits 用)。由调用方(container.js)AttachBits 进来:
// AttachBits(Bits, Index) → 从 Bits[Index] 开始消费;DetachBits() → 断开(opt 再遇到就报错)
let BitStream = null;
let BitIndex = 0;
function AttachBits(Bits, Index) {
BitStream = Bits;
BitIndex = Index || 0;
}
function DetachBits() {
BitStream = null;
BitIndex = 0;
}
function BitCursor() {
return { Bits: BitStream, Index: BitIndex };
}
function TakeBit() {
if (!BitStream || BitIndex >= BitStream.length) return null;
const B = BitStream[BitIndex];
BitIndex++;
return B;
}
// 读一个值:返回 { Value, Next, Bad }
function ReadValue(Buf, Off, Type, TypeCodec, Depth) {
if (Depth > MAX_DEPTH) return { Value: '<深度上限>', Next: Off, Bad: true };
@@ -80,6 +155,12 @@ function MakeReader(Tables, Options) {
const Opt = /^opt<([\s\S]+)>$/.exec(T);
if (Opt) {
if (OptBits) {
const Bit = TakeBit();
if (Bit === null) return { Value: '<nullmap 位用尽>', Next: Off, Bad: true };
if (Bit) return { Value: null, Next: Off };
return ReadValue(Buf, Off, Opt[1], TypeCodec, Depth);
}
if (Off >= Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
// Ht 包装:先 1 字节标志,非 0 表示 null;有值则紧跟内层
if (Buf[Off] !== 0) return { Value: null, Next: Off + 1 };
@@ -89,21 +170,29 @@ function MakeReader(Tables, Options) {
if (List) {
const N = ReadVarint(Buf, Off);
let At = N.Next;
let Bad = N.Bad;
const Arr = [];
// 「元素读不动」的判据在 OptBits 模式下要放宽:元素若是 opt<T> 且为空,值一个字节都不占
// (只有 nullmap 里的 1 个 bit,见 src/module_24635.js:412-417 的 Ht.p7g:读到位=1 时直接 return null),
// 于是 Next == At 是**合法**的。旧口径把它当「卡住」→ list<opt<T>> 里只要有空元素就整段解不动
// (实测 space-100073070/s2c_0001 的 MountedResistancesCC.resistances 就卡在这里)。
const ZeroOk = OptBits && /^opt</.test(String(List[1]));
for (let I = 0; I < N.Value; I++) {
if (I >= 4096) {
Arr.push('<元素过多>');
Bad = true;
break;
}
const R = ReadValue(Buf, At, List[1], TypeCodec, Depth + 1);
Arr.push(R.Value);
if (R.Bad || R.Next <= At) {
if (R.Bad || (R.Next <= At && !ZeroOk)) {
Arr.push('<卡住>');
Bad = true;
break;
}
At = R.Next;
}
return { Value: Arr, Next: At, Bad: N.Bad };
return { Value: Arr, Next: At, Bad };
}
const Enum = /^enum<([\s\S]+)>$/.exec(T);
if (Enum) {
@@ -113,6 +202,40 @@ function MakeReader(Tables, Options) {
for (let I = 0; I < EnumBytes; I++) V = (V << 8) | Buf[Off + I];
return { Value: 'enum#' + V, Next: Off + EnumBytes };
}
const Map = /^map<([\s\S]+)>$/.exec(T);
if (Map) {
// 条数用的是与 list/string 同一个变长整数(src/module_24635.js:589-611),
// 之后逐条 key、value(src/module_24635.js:498-511)。
const KV = SplitTypeArgs(Map[1]);
const N = ReadVarint(Buf, Off);
let At = N.Next;
let Bad = N.Bad;
const Pairs = [];
// 与 list 同源:OptBits 模式下 opt<…> 为空时 0 字节是合法的(见上面 list 分支的注释)
const ZeroK = OptBits && /^opt</.test(String(KV[0]));
const ZeroV = OptBits && /^opt</.test(String(KV[1]));
for (let I = 0; I < N.Value; I++) {
if (I >= 4096) {
Pairs.push(['<条目过多>', '']);
Bad = true;
break;
}
const RK = ReadValue(Buf, At, KV[0], TypeCodec, Depth + 1);
if (RK.Bad || (RK.Next <= At && !ZeroK)) {
Pairs.push([RK.Value, '<键读不动>']);
Bad = true;
break;
}
const RV = ReadValue(Buf, RK.Next, KV[1], TypeCodec, Depth + 1);
Pairs.push([RK.Value, RV.Value]);
if (RV.Bad || (RV.Next <= RK.Next && !ZeroV)) {
Bad = true;
break;
}
At = RV.Next;
}
return { Value: Pairs, Next: At, Bad };
}
switch (T) {
case 'bool':
@@ -132,7 +255,8 @@ function MakeReader(Tables, Options) {
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 };
// 不做 toFixed 截断:截断只属于显示层(见 FormatValue),否则写回时精度就变了
return { Value: Buf.readFloatBE(Off), Next: Off + 4 };
case 'f64':
if (Off + 8 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readDoubleBE(Off), Next: Off + 8 };
@@ -141,9 +265,16 @@ function MakeReader(Tables, Options) {
case 'array': {
const N = ReadVarint(Buf, Off);
const End = N.Next + N.Value;
if (End > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
if (End > Buf.length) return { Value: '<越界>', Next: Off, Bad: true, Why: '长度 ' + N.Value + ' 越界' };
const Raw = Buf.slice(N.Next, End);
return { Value: T === 'string' ? Raw.toString('utf8') : '0x' + Raw.toString('hex').slice(0, 64), Next: End };
if (T !== 'string') return { Value: '0x' + Raw.toString('hex'), Next: End };
const Str = Raw.toString('utf8');
// 二进制负载(字段类型被标成 string 但内容不是合法 UTF-8)→ 退回十六进制。
// 否则替换字符 U+FFFD 会吃掉原始字节,写回时无法还原(MakeWriter 往返自检会抓到)。
const Round = Buffer.from(Str, 'utf8');
if (Round.equals(Raw)) return { Value: Str, Next: End };
if (StringLiteralOnly) return { Value: '<不是合法 UTF-8>', Next: Off, Bad: true, Why: '字符串值不是合法 UTF-8(本路径不做 0x 转义,避免与字面量 0x… 撞车)' };
return { Value: '0x' + Raw.toString('hex'), Next: End };
}
default:
break;
@@ -151,21 +282,24 @@ function MakeReader(Tables, Options) {
// 模型类:就地展开它自己的 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 };
return { Value: R.Value, Next: R.Next, Bad: R.Bad, Why: R.Why };
}
// 字段里没带 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 };
return { Value: R.Value, Next: R.Next, Bad: R.Bad, Why: R.Why };
}
if (WireClasses.has(T)) return ReadValue(Buf, Off, WireClasses.get(T), '', Depth + 1);
return { Value: '<无 codec:' + T + '>', Next: Off, Bad: true };
// 容器层给的「裸类名 → 线上基础类型」直译表(只查表,不做推断;没命中就照旧报无 codec)
if (TypeAlias && Object.prototype.hasOwnProperty.call(TypeAlias, T)) return ReadValue(Buf, Off, TypeAlias[T], '', Depth + 1);
return { Value: '<无 codec:' + T + '>', Next: Off, Bad: true, Why: '类型 ' + T + ' 没有对应 codec' };
}
function ReadFields(Buf, Off, Codec, Depth) {
const Obj = {};
let At = Off;
let Bad = false;
let Why = '';
for (const F of (Codec && Codec.Fields) || []) {
const R = ReadValue(Buf, At, F.Type, F.TypeCodec, Depth);
// 匿名字段(表里没有名字,只有线宽)用序号区分,否则会同名互相覆盖
@@ -173,11 +307,14 @@ function MakeReader(Tables, Options) {
Obj[Key] = R.Value;
if (R.Bad) {
Bad = true;
// 诊断用的原因串:哪个字段、什么类型、卡在负载内第几字节、内层如果直接说得出原因也带上
// (container / probe_containers 靠它把「卡点」分类说清楚,不参与解码判定)
Why = '字段 ' + Key + '(' + F.Type + ')在 +' + (At - Off) + ' 处解不动' + (R.Why ? ':' + R.Why : '');
break;
}
At = R.Next;
}
return { Value: Obj, Next: At, Bad };
return { Value: Obj, Next: At, Bad, Why };
}
// 按 codec 键解一条记录
@@ -187,13 +324,15 @@ function MakeReader(Tables, Options) {
return ReadFields(Buf, Off, C, Depth || 0);
}
return { ReadValue, ReadFields, ReadByKey, ReadVarint, EnumBytes };
return { ReadValue, ReadFields, ReadByKey, ReadVarint, EnumBytes, OptBits, AttachBits, DetachBits, BitCursor };
}
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 === 'number' && !Number.isInteger(V)) return String(Number(V.toFixed(4)));
if (typeof V === 'string' && V.length > 90) return JSON.stringify(V.slice(0, 90) + '…');
return JSON.stringify(V);
}
@@ -205,4 +344,4 @@ function FormatFields(Obj, Indent) {
return L;
}
module.exports = { LoadTables, MakeReader, ReadVarint, FormatValue, FormatFields, MAX_DEPTH };
module.exports = { LoadTables, MakeReader, ReadVarint, SplitTypeArgs, FormatValue, FormatFields, MAX_DEPTH };
+80
View File
@@ -0,0 +1,80 @@
// match_methods.js - 用客户端 dump 出的 methodId 表去扫抓包帧,验证"线上哪个字段是 methodId"
// 用法: node match_methods.js [--root ../packets/plain] [--table ../names/method_table.json] [--limit 40]
// 原理:
// names/method_table.json 是 methodId(64 位) → modelId → 模型类/codec(客户端注册表 dump)。
// 空间层的 RPC 分发是 mr.l7n(ctx, methodId, data),所以帧里应能找到 methodId 的 8 字节大端。
// 命中即说明该帧属于哪个模型(命令),从而把命令语义一个个钉死。
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 TablePath = path.resolve(Arg('table', path.join(PROJECT, 'names', 'method_table.json')));
const Limit = Number(Arg('limit', '40')) || 40;
const Table = JSON.parse(fs.readFileSync(TablePath, 'utf8'));
// methodId → 模型信息;同时准备 8 字节大端字节串
const ByHex = new Map();
for (const R of Table.Rows) {
const Big = BigInt(R.MethodId);
const Buf = Buffer.alloc(8);
Buf.writeBigInt64BE(Big);
ByHex.set(Buf.toString('hex'), R);
}
function Unwrap(Buf) {
const B0 = Buf[0];
const HeaderSize = B0 & 0x80 ? 4 : 2;
let Body = Buf.slice(HeaderSize);
if (B0 & 0x40) {
try {
Body = zlib.inflateSync(Body);
} catch (Err) {
return null;
}
}
return Body;
}
if (!fs.existsSync(Root)) {
console.log('目录不存在: ' + Root);
process.exit(1);
}
const Hits = [];
let Scanned = 0;
for (const Space of fs.readdirSync(Root).sort()) {
const Dir = path.join(Root, Space);
if (!fs.statSync(Dir).isDirectory()) continue;
for (const F of fs.readdirSync(Dir)) {
if (!/\.bin$/.test(F)) continue;
const Raw = fs.readFileSync(path.join(Dir, F));
const Body = Unwrap(Raw);
if (!Body || Body.length < 8) continue;
Scanned++;
for (let Off = 0; Off + 8 <= Body.length; Off++) {
const Hex = Body.slice(Off, Off + 8).toString('hex');
const R = ByHex.get(Hex);
if (R) Hits.push({ File: Space + '/' + F, Off, Size: Raw.length, Model: R.Model, MethodId: R.MethodId, Codec: R.Codec, CodecFields: R.CodecFields });
}
}
}
console.log('扫描文件 ' + Scanned + ' 个,命中 methodId ' + Hits.length + ' 处');
const Count = {};
for (const H of Hits) Count[H.Model] = (Count[H.Model] || 0) + 1;
console.log('命中模型分布: ' + JSON.stringify(Count));
console.log('');
for (const H of Hits.slice(0, Limit)) {
console.log(
H.File.padEnd(30) + ' @' + String(H.Off).padStart(5) + ' ' + H.Size + 'B model=' + H.Model +
' codec=' + H.Codec + ' codecFields=' + String(H.CodecFields).slice(0, 70),
);
}
+497
View File
@@ -0,0 +1,497 @@
// mine_downlink.js - 挖「下行(s2c)命令字段表」:methodId → 有序字段
// 用法:
// node mine_downlink.js [--src ../src] [--out ../names/downlink_table.json] [--check]
//
// ⚠ 必须跑在 **../src**(原始短名)而不是 ../src_named:
// names/*.json 各表(codec_fields / method_table / uplink_table)的键都是原始短名(如 hh4_1),
// 而 src_named/ 里 apply_names 会把这些槽位改成别名(hh4_1 → remember2FaHash_codecField),
// 于是「源码里的槽位名」和「表里的键」对不上,字段会整片挖空。
//
// 为什么需要它:上行命令有 names/uplink_table.json(methodId → 命令类 / 发送方法 / 负载字段),
// 下行只有 names/method_table.json(methodId → 模型 + 模型字段全集),**没有"这条方法实际带哪些字段"**。
// 于是下发的帧只能当字节回放,想改一个字段(比如用户名)就得做字节替换。
// 本工具把"每条 s2c 方法的有序字段"挖出来,server/semantic.js 就能据此把帧解成 JSON、再编码回字节。
//
// 依据全部来自 src/ 源码,不猜:
// ① 模型类的 q7i(t, n):t = 收到的 methodId,n = 读游标。
// `X(t, this.<slot>)` 是分支判定,<slot> 的 Long 值就是该分支的 methodId。
// (判定函数名逐模块不同:M / tu / Ae / …,所以只按「两个参数恰好是 t 与 this.<slot>」的形状匹配。)
// ② 分支里按线上次序读字段,两种形态:
// a) 嵌套 codec:`(function (t) { var x = t.<codecSlot>; … })(this).p7g(n)`
// 类型按两级优先级解(见 ResolveField):
// ① codec_fields.json 的 Codecs[*].Fields[].Slot(带 Name,因为那些 codec 有 codec_<名字> getter)
// ② codec_fields.json 的 SlotTypes["<模块>::<槽位>"](全模块扫 `<槽位> = <工厂>(this).n7f(new <描述符>)`
// 得来,只有 Type/TypeCodec;名称再从分支里的报错串 f("_方法_字段Codec") 取)
// 两级都查不到 / 查到的类型读取器没登记 → Type = '<无 codec>',并入该方法的 Unresolved。
// b) 基础类型:`n.a7g_1.<reader>()`(reader 名 → 类型见 ReaderType)
// ③ 容器型负载(_loadDependencies / _loadObjectsData / _unloadObjects / _setModelCommandAlias)
// 不走 q7i,由 container.js 的三种布局负责;这里把它们的 Kind 标成 container。
//
// ⚠ 帧结构(src/module_61860.js:2111-2131 的空间命令解析 u7m,据实修正旧注释):
// 帧体 = [nullmap][记录…],nullmap 是「一条记录 1 bit」的位图(MSB 在前,见 module_24635.js:357-364);
// 记录 = bit=1 → 1 字节短命令(查 Gr(space) 表) | bit=0 → [objectId i64][methodId i64][负载]。
// 也就是说**每条记录自带 objectId**(单记录帧里它就等于空间 id),不是「帧头一个 spaceId」。
// 旧版 extract_space_init.js 把"第一条记录的 objectId"当成整帧的 spaceId、并在解不动时把**帧体剩余字节**
// 整段存成一个记录的 Hex(见该文件 84-100 行),于是多记录帧的 Hex 里会带着下一条记录的头。
// --check 因此按「记录链」解:读完本条字段后若还有剩余,就再读 [objectId][methodId] 接着解。
'use strict';
const fs = require('fs');
const path = require('path');
const ToolsDir = __dirname;
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 SrcDir = path.resolve(ToolsDir, Arg('src', path.join('..', 'src')));
const NamesDir = path.resolve(ToolsDir, '..', 'names');
const OutPath = path.resolve(ToolsDir, Arg('out', path.join('..', 'names', 'downlink_table.json')));
const Check = Argv.indexOf('--check') >= 0;
// ByteBuffer 的定长读取器:名字来自 src_named/module_72834.js(xi/n2u=xy=getInt8 … s2u=w2t=getFloat64)
const ReaderType = { n2u: 'i8', o2u: 'i16', p2u: 'i32', q2u: 'i64', r2u: 'f32', s2u: 'f64' };
// ---------------------------------------------------------------- 载入 codec 字段表
const CodecFields = JSON.parse(fs.readFileSync(path.join(NamesDir, 'codec_fields.json'), 'utf8'));
const SlotField = new Map(); // slot → { Codec: 'module_x::Y', Name, Type, TypeCodec, Order }
const SlotConflicts = [];
// 新增的顶层段 SlotTypes(extract_codec_fields.js 产出):'<模块>::<槽位>' → { Module, Slot, Type, TypeCodec, Desc }
// 为什么要它:q7i 读的槽位是模型类里的**局部 codec 实例**(chc_1 / jh4_1 / b1wd_1 …),
// 它们不是任何 codec 条目的字段,所以旧版三级查表在 codec_fields 里压根查不到,只能报未解。
const SlotTypes = CodecFields.SlotTypes || {};
// 字段名 → 类型:只作最末一级兜底(槽位没进 codec_fields / SlotTypes 时,用 f("_方法_字段Codec") 解出的字段名反查)
const NameType = new Map();
const NameAmbiguous = new Set();
const ModelNames = new Map(); // 模型真名 → 该真名对应的 codec 个数(>1 说明裸名定位不到唯一 codec,不能拿来解)
for (const [Key, C] of Object.entries(CodecFields.Codecs)) {
if (C.ModelName) ModelNames.set(C.ModelName, (ModelNames.get(C.ModelName) || 0) + 1);
for (const F of C.Fields || []) {
if (!F.Slot) continue;
const Rec = { Codec: Key, Name: F.Name || '', Type: F.Type || '', TypeCodec: F.TypeCodec || '', Order: F.Order || 0 };
const Prev = SlotField.get(F.Slot);
// 同名 slot 落在不同 codec 上 → 只记第一次,并把冲突报出来
if (Prev && Prev.Codec !== Key) {
SlotConflicts.push(F.Slot + ' → ' + Prev.Codec + ' / ' + Key);
continue;
}
SlotField.set(F.Slot, Rec);
// 同名字段在不同 codec 上类型不一致 → 这一级兜底作废,免得拿错类型去解字节
if (Rec.Name && Rec.Name !== '?' && Rec.Name !== '(匿名)') {
const Seen = NameType.get(Rec.Name);
if (!Seen) NameType.set(Rec.Name, { Type: Rec.Type, TypeCodec: Rec.TypeCodec });
else if (Seen.Type !== Rec.Type || Seen.TypeCodec !== Rec.TypeCodec) NameAmbiguous.add(Rec.Name);
}
}
}
for (const N of NameAmbiguous) NameType.delete(N);
// ---------------------------------------------------------------- 类型能不能被 lib_space_codec 解
// 读取器实际登记的类型(tools/lib_space_codec.js:120-171):
// 定长基础类型 bool/i8/i16/i32/i64/f32/f64、string/bytes/array、opt<T>/list<T>、enum<T>(固定 4 字节)、
// 以及「模型类」——但模型类只有在能定位到 codec 时才解得出(TypeCodec 链接,或该真名全局唯一)。
// 不在这个集合里的(any/number/nothing/throwable/xxArray/prim:xx/认不出的裸类名)一律算没解出来,
// 免得把解不动的类型写进表里、到 semantic.js 那一步才炸。
const ReaderLeaf = new Set(['bool', 'i8', 'i16', 'i32', 'i64', 'f32', 'f64', 'string', 'bytes', 'array']);
function Readable(Type, TypeCodec) {
const T = String(Type || '');
if (!T || T === '?' || T === '...' || T[0] === '<') return false;
const Opt = /^opt<([\s\S]+)>$/.exec(T);
if (Opt) return Readable(Opt[1], TypeCodec);
const List = /^list<([\s\S]+)>$/.exec(T);
if (List) return Readable(List[1], TypeCodec);
if (/^enum<[\s\S]+>$/.test(T)) return true; // 枚举固定 4 字节,不需要嵌套 codec
if (ReaderLeaf.has(T)) return true;
if (/^prim:/.test(T)) return false;
if (TypeCodec) return true;
// 裸模型真名:只有全局唯一时才敢当 codec 用(重名会解成别的模型的数据)
return ModelNames.get(T) === 1;
}
// f("_<方法名>_<字段名>Codec") → 字段名(惰性 getter 的报错串,形如 module_10283.js:2353 的 "_remember2FaHash_hashCodec")
function FieldNameOfTag(Tag) {
const S = String(Tag || '');
const M = /^_?[A-Za-z0-9_$]*?_([A-Za-z][A-Za-z0-9]*)Codec$/.exec(S);
if (M) return M[1];
const N = /^_?([A-Za-z][A-Za-z0-9]*)Codec$/.exec(S);
return N ? N[1] : '';
}
// ---------------------------------------------------------------- 文本扫描小工具
// 从 `{` 位置配平花括号,跳过字符串与注释(源码是 prettier 输出,但仍要防字符串里有花括号)
function MatchBrace(Text, Open) {
let Depth = 0;
let I = Open;
while (I < Text.length) {
const Ch = Text[I];
if (Ch === '"' || Ch === "'" || Ch === '`') {
const Q = Ch;
I++;
while (I < Text.length && Text[I] !== Q) {
if (Text[I] === '\\') I++;
I++;
}
I++;
continue;
}
if (Ch === '/' && Text[I + 1] === '/') {
while (I < Text.length && Text[I] !== '\n') I++;
continue;
}
if (Ch === '/' && Text[I + 1] === '*') {
I += 2;
while (I < Text.length && !(Text[I] === '*' && Text[I + 1] === '/')) I++;
I += 2;
continue;
}
if (Ch === '{') Depth++;
else if (Ch === '}') {
Depth--;
if (Depth === 0) return I;
}
I++;
}
return -1;
}
// 模块内 `this.<slot> = new <Cls>(-?\d+, -?\d+)` → slot → methodId(十进制字符串)
// Long 合成:value = hi*2^32 + (lo>>>0);Long 类名逐模块不同(R / Me / xi / $ / …),所以不限定类名。
const LongOf = (Lo, Hi) => (BigInt(Hi) * 4294967296n + BigInt(Lo >>> 0)).toString();
function FindLongs(Text) {
const Out = new Map();
const Re = /this\.([a-z0-9]+_\d+) = new ([A-Za-z_$][\w$]*)\((-?\d+), (-?\d+)\)/g;
let M;
while ((M = Re.exec(Text))) Out.set(M[1], { Value: LongOf(Number(M[3]), Number(M[4])), Lo: Number(M[3]), Hi: Number(M[4]) });
return Out;
}
// 分支里读字段:返回按出现位置排序的 [{At, Slot|Reader, Type, ...}]
function ReadOrder(Body) {
const Hits = [];
// 形态:`var x = (function (t) {\n var n = t.<codecSlot>;\n … f("_<方法名>_<字段名>Codec");\n })(this).p7g(n);`
// prettier 会在 `{` 后换行,所以必须用 \s* 而不是单个空格。
// 顺带把 f("…") 的报错串抓出来 —— 里面就写着字段名(见 FieldNameOfTag)。
const ReCodec =
/\(function \(t\) \{\s*var ([A-Za-z0-9_$]+) = t\.([a-z0-9]+_\d+);\s*if \(null != \1\) return \1;\s*[A-Za-z_$][\w$]*\(\s*"([^"]*)"\s*\);\s*\}\)\(this\)/g;
let M;
while ((M = ReCodec.exec(Body))) Hits.push({ At: M.index, Slot: M[2], Tag: M[3] });
const RePrim = /\.a7g_1\.([A-Za-z0-9_$]+)\(\)/g;
while ((M = RePrim.exec(Body))) Hits.push({ At: M.index, Reader: M[1] });
Hits.sort((A, B) => A.At - B.At);
return Hits;
}
// ---------------------------------------------------------------- 容器型方法(不走 q7i)
// 声明得比挖掘循环早,是因为挖出来的条目要靠它标 Kind(容器型不能当普通字段解,见 server/container.js)
const Containers = [
{ MethodId: '3216143066888387731', Name: 'ModelService.LoadDependencies', Layouts: ['zt'] },
{ MethodId: '7640916300855664666', Name: 'ModelService.LoadObjectsData', Layouts: ['pt'] },
{ MethodId: '9038756763225879447', Name: 'ModelService.UnloadObjects', Layouts: ['lt'] },
{ MethodId: '8179162314198150518', Name: 'ModelService.SetModelCommandAlias', Layouts: ['alias'] },
];
const ContainerIds = new Set(Containers.map((C) => C.MethodId));
// ---------------------------------------------------------------- 逐模块挖 q7i
const Files = fs.readdirSync(SrcDir).filter((F) => /^module_\d+\.js$/.test(F)).sort();
const Commands = [];
const Problems = [];
let HandlerCount = 0;
let BySlotField = 0; // 类型来自 codec_fields 的 Codecs[*].Fields[].Slot
let BySlotTypes = 0; // 类型来自 codec_fields 的 SlotTypes(全模块「槽位 → 类型」)
let ByName = 0; // 类型是从同名字段反查出来的字段数
for (const File of Files) {
const ModuleId = String(Number(/module_(\d+)\.js$/.exec(File)[1]));
const Mod = 'module_' + ModuleId;
const Text = fs.readFileSync(path.join(SrcDir, File), 'utf8');
const Longs = FindLongs(Text);
// 找每个 `.q7i = function (...) {` 的函数体。源码形态是 `(p(Pt).q7i = function (t, n) {` ——
// 类名在包装函数的括号里(包装函数逐模块不同:p / ke / ee / nt / …),所以捕获 `)<方法名>` 前那个标识符。
const ReHead = /([A-Za-z_$][\w$]*)\)\.q7i = function \(([^)]*)\) \{/g;
let H;
while ((H = ReHead.exec(Text))) {
const Owner = H[1];
const Open = Text.indexOf('{', H.index + H[0].length - 1);
const Close = MatchBrace(Text, Open);
if (Close < 0) {
Problems.push(Mod + ' ' + Owner + '.q7i 花括号配平失败');
continue;
}
HandlerCount++;
const Body = Text.slice(Open + 1, Close);
// 分支判定:<任意函数名>(t, this.<slot>) —— 判定函数逐模块不同
const ReBranch = /[A-Za-z_$][\w$]*\(t, this\.([a-z0-9]+_\d+)\)/g;
const Marks = [];
let B;
while ((B = ReBranch.exec(Body))) Marks.push({ At: B.index, End: ReBranch.lastIndex, Slot: B[1] });
if (!Marks.length) {
// 空实现 `function (t, n) {}` 是正常的(该模型没有 s2c),只有真的有方法体才算可疑
if (Body.trim()) Problems.push(Mod + ' ' + Owner + '.q7i 有方法体但找不到分支判定(可能是 switch/表驱动)');
continue;
}
for (let I = 0; I < Marks.length; I++) {
const Slot = Marks[I].Slot;
const Seg = Body.slice(Marks[I].End, I + 1 < Marks.length ? Marks[I + 1].At : Body.length);
const Long = Longs.get(Slot);
if (!Long) {
Problems.push(Mod + ' ' + Owner + '.q7i 分支 ' + Slot + ' 找不到对应的 methodId 常量');
continue;
}
const Fields = [];
let Unresolved = 0;
for (const Hit of ReadOrder(Seg)) {
if (Hit.Slot) {
// 槽位查表顺序(两级,都是源码证据,不是猜):
// ① codec_fields 的 Codecs[*].Fields[].Slot —— 那些 codec 有 codec_<名字> getter,所以带 Name
// ② codec_fields 的 SlotTypes["<模块>::<槽位>"] —— q7i 读的局部 codec 实例都在这里,只有 Type
// 字段名:先取 ① 的 Name,没有再取分支里 f("_<方法名>_<字段名>Codec") 的报错串
const F = SlotField.get(Hit.Slot);
const S = SlotTypes[Mod + '::' + Hit.Slot];
const Name = (F && F.Name) || FieldNameOfTag(Hit.Tag) || '';
let Type = '';
let TypeCodec = '';
let Codec = '';
let Src = '';
if (F && Readable(F.Type, F.TypeCodec)) {
Type = F.Type;
TypeCodec = F.TypeCodec;
Codec = F.Codec;
Src = 'codec_fields:' + F.Codec + ' 的槽位 ' + Hit.Slot;
BySlotField++;
} else if (S && Readable(S.Type, S.TypeCodec)) {
Type = S.Type;
TypeCodec = S.TypeCodec;
Src = S.Module + '.js:' + S.Line + ' ' + S.Desc;
BySlotTypes++;
} else if (Name && NameType.has(Name) && Readable(NameType.get(Name).Type, NameType.get(Name).TypeCodec)) {
// 末级兜底:拿 f("…") 里的字段名去 codec_fields 反查同名字段(同名同类型才用,见 NameAmbiguous)
const N = NameType.get(Name);
Type = N.Type;
TypeCodec = N.TypeCodec;
Src = 'codec_fields:同名字段 ' + Name;
ByName++;
}
if (!Type) {
// 查不到类型、或者读到的类型读取器没登记 → 如实标未解,并把现场(描述符/出处)留下,不要编一个类型
Type = '<无 codec>';
Unresolved++;
if (S) Src = 'SlotTypes ' + S.Module + '.js:' + S.Line + ' ' + S.Desc + '(类型推不出来)';
else if (!Src) Src = 'SlotTypes 无此槽位 ' + Mod + '::' + Hit.Slot;
}
const Rec = { Order: Fields.length + 1, Slot: Hit.Slot, Name, Type };
if (TypeCodec) Rec.TypeCodec = TypeCodec;
if (Codec) Rec.Codec = Codec;
if (Src) Rec.Src = Src;
Fields.push(Rec);
} else {
const T = ReaderType[Hit.Reader] || '<未知读取器:' + Hit.Reader + '>';
if (T[0] === '<') Unresolved++;
Fields.push({ Order: Fields.length + 1, Reader: Hit.Reader, Name: '', Type: T });
}
}
Commands.push({
MethodId: Long.Value,
Module: Mod,
OwnerClass: Owner,
LongLiteral: [Long.Lo, Long.Hi],
// 容器型负载的字节不是「按字段顺序排」,不能让 codec 去硬解(见 server/container.js)
Kind: ContainerIds.has(Long.Value) ? 'container' : 'fields',
Fields,
Unresolved,
});
}
}
}
// 同一 methodId 出现在多处(模型类重名/重复实现)→ 保留字段更多的那条,并记下分歧
const ByMethod = new Map();
const Duplicated = [];
for (const C of Commands) {
const Prev = ByMethod.get(C.MethodId);
if (!Prev) {
ByMethod.set(C.MethodId, C);
continue;
}
Duplicated.push(C.MethodId + ' ' + Prev.Module + ' ' + Prev.OwnerClass + '(' + Prev.Fields.length + ' 字段) 与 ' + C.Module + ' ' + C.OwnerClass + '(' + C.Fields.length + ' 字段)');
if (C.Fields.length > Prev.Fields.length) ByMethod.set(C.MethodId, C);
}
const Final = [...ByMethod.values()].sort((A, B) => (BigInt(A.MethodId) < BigInt(B.MethodId) ? -1 : 1));
// 覆盖统计:和 method_table.json 对一下
const MethodTable = JSON.parse(fs.readFileSync(path.join(NamesDir, 'method_table.json'), 'utf8'));
const AllMethods = (MethodTable.Methods || MethodTable.Rows || []).map((R) => R.MethodId);
const MinedSet = new Set(Final.map((C) => C.MethodId));
const Covered = AllMethods.filter((M) => MinedSet.has(M));
const AllFields = Final.flatMap((C) => C.Fields);
const Note = [
'下行(s2c)命令字段表:methodId → 有序字段。由 tools/mine_downlink.js 从 src/ 挖出,不猜。',
'字段来源两类:Slot(源码里的 codec 槽位)/ Reader(ByteBuffer 定长读取器,Type 已换算成 i8..f64)。',
'Slot 的类型按两级优先级解:① codec_fields.json 的 Codecs[*].Fields[].Slot(带 Codec / Name / TypeCodec)② codec_fields.json 的 SlotTypes["<模块>::<槽位>"](全模块扫 `<槽位> = <工厂>(接收者).n7f(new <描述符>)` 得来,带 Src = 文件:行号)。',
'两级都没解出来、或解出来的类型 lib_space_codec 没登记(any/number/throwable/xxArray/认不出的裸类名…)→ Type = "<无 codec>",并计入该方法的 Unresolved;不会拿裸类名硬当类型。',
'字段名优先用 ① 的 Name;没有时取分支里 f("_<方法名>_<字段名>Codec") 的报错串中间那段(如 _enteredUidIsBusy_advisedUidsCodec → advisedUids);都取不到就留空。',
'帧结构(src/module_61860.js:2111-2131):[nullmap][记录…],nullmap 一条记录 1 bit(bit=1 → 1 字节短命令;bit=0 → [objectId i64][methodId i64][负载])。所以记录的负载里不会出现【字段】之外的字节,帧体内多出来的字节一定是下一条记录。',
'Kind = container 的条目(LoadDependencies / LoadObjectsData / UnloadObjects / SetModelCommandAlias)负载不是「按字段顺序排」,必须走 server/container.js 的布局,不能按 Fields 硬解。',
'Unresolved > 0 表示这条方法还有字段没解出来(缺 codec 或读取器未登记),semantic.js 遇到它会退回「部分语义化」。',
].join(' ');
const Report = {
Note,
Src: SrcDir,
ReaderType,
Stats: {
Handlers: HandlerCount,
MinedCommands: Final.length,
MethodTableEntries: AllMethods.length,
CoveredByMethodTable: Covered.length,
Fields: AllFields.length,
TypeBySlotField: BySlotField,
TypeBySlotTypes: BySlotTypes,
TypeByName: ByName,
UnknownFields: AllFields.filter((F) => !F.Name && !F.Reader).length,
WithUnresolved: Final.filter((C) => C.Unresolved > 0).length,
UnresolvedFields: Final.reduce((A, C) => A + C.Unresolved, 0),
AllFieldsNamed: Final.filter((C) => C.Fields.length && C.Fields.every((F) => F.Name)).length,
SlotConflicts: SlotConflicts.length,
DuplicatedMethods: Duplicated.length,
},
Containers,
Commands: Final,
Duplicated,
SlotConflicts,
Problems,
};
fs.writeFileSync(OutPath, JSON.stringify(Report, null, 1), 'utf8');
// ---------------------------------------------------------------- 控制台摘要
console.log('源目录 : ' + SrcDir);
console.log('输出 : ' + OutPath);
console.log('');
console.log('q7i 处理器 : ' + HandlerCount);
console.log('挖出下行方法 : ' + Final.length + ' 条');
console.log(' 与 method_table 命中 : ' + Covered.length + ' / ' + AllMethods.length);
console.log(' 字段总数 : ' + AllFields.length + '(槽位查 Fields[].Slot 解出 ' + BySlotField + ' 条,查 SlotTypes 解出 ' + BySlotTypes + ' 条,靠同名字段反查 ' + ByName + ' 条)');
console.log(' 字段全有名 : ' + Report.Stats.AllFieldsNamed);
console.log(' 还有字段没解出来 : ' + Report.Stats.WithUnresolved + ' 条方法 / ' + Report.Stats.UnresolvedFields + ' 个字段');
console.log(' 同 methodId 多实现 : ' + Duplicated.length);
console.log(' slot 多义 : ' + SlotConflicts.length);
console.log(' 解析告警 : ' + Problems.length);
if (Problems.length) for (const P of Problems.slice(0, 8)) console.log(' · ' + P);
console.log('');
console.log('样例(前 8 条):');
for (const C of Final.slice(0, 8)) {
console.log(' ' + C.MethodId + ' ' + C.Module + ' ' + C.OwnerClass +
' ' + C.Fields.map((F) => (F.Name || F.Slot || F.Reader) + ':' + F.Type).join(', '));
}
// ---------------------------------------------------------------- --check:拿真实抓包帧验证
if (Check) {
const { LoadTables, MakeReader } = require('./lib_space_codec');
const Reader = MakeReader(LoadTables(NamesDir), { EnumBytes: 4 });
// 结构化记录要用语义层现场编码(见下面 Buf/Origin 的分支)
const Semantic = require('./server/semantic').Load(NamesDir);
const Cases = [
{
File: 'space-init-login-100045260.json',
MethodId: '1247179769701109529',
Why: 'RegistrationModel.EnteredUidIsBusy:advisedUids = list<string>[5 个账号名](55B),随后同帧记录 2(objectId+methodId=6180114602676583672,0 字段)→ 共 71B',
},
{
File: 'space-init-login-100045260.json',
MethodId: '237130683810296524',
Why: 'RememberLoginModel.RememberUsersHash:hash = 64 字符 token(65B),随后同帧记录 2(objectId + methodId=2025886178375810581 + 其 i64 负载 8B)→ 共 89B',
},
{ File: 'space-init-100045260.json', MethodId: '2025886178375810581', Why: '单记录:1 个 i64 = 3074457345817168740,恰好吃满 8/8' },
{ File: 'space-init-100045260.json', MethodId: '7640916300855664666', Why: '容器 pt(_loadObjectsData,448B):应识别为容器,不硬解成字段' },
];
// 字段在解出对象里的键:匿名字段(表里只有线宽)用 (匿名#序) 区分,否则会互相覆盖
const FieldKey = (Fd) => (Fd.Name || Fd.Slot || '(匿名#' + Fd.Order + ')') + ':' + Fd.Type;
const FieldList = (Spec) => (Spec && Spec.Fields.length ? Spec.Fields.map((F) => (F.Name || F.Slot || F.Reader) + ':' + F.Type).join(', ') : '(无字段)');
console.log('');
console.log('=== --check:用真实抓包帧验证字段表 ===');
for (const C of Cases) {
const P = path.join(NamesDir, C.File);
if (!fs.existsSync(P)) continue;
const T = JSON.parse(fs.readFileSync(P, 'utf8'));
for (const F of T.Frames || []) {
for (const R of F.Records || []) {
if (R.MethodId !== C.MethodId) continue;
const Spec = ByMethod.get(R.MethodId);
console.log(' ' + R.MethodId + ' ' + C.File + ' ' + C.Why);
// 容器型:负载字节不是「按字段顺序排」,报容器而不是硬解(容器布局见 server/container.js)
if (Spec && Spec.Kind === 'container') {
const L = (Containers.find((X) => X.MethodId === R.MethodId) || {}).Layouts || [];
console.log(' 字段表: [容器型 layout=' + L.join(',') + '] ' + FieldList(Spec) +
' ← 不作为普通字段解,交给 container.js');
console.log(' 解出 : [容器](Hex ' + (R.Hex ? R.Hex.length / 2 : 0) + ' 字节,容器布局不按字段顺序)');
continue;
}
// 模板升级成结构化记录之后,这里不再是"原始字节":改用 server/semantic.js /
// server/container.js 现场编码出字节,再走同一套校验。这样校验从"能不能解开真实字节"
// 变成"编码出的字节能不能被同一张表解回原值",对结构化记录仍有意义(能抓出编码/解码不自洽)。
let Buf;
let Origin;
if (R.Hex) {
Buf = Buffer.from(R.Hex, 'hex');
Origin = '原始 hex';
} else if (R.Fields) {
// 注意语义层的入参形状是「记录对象」(含 Fields),不是裸的 Fields 表
Buf = Semantic.EncodeRecord(R.MethodId, R);
Origin = '结构化 Fields → 现场编码';
} else {
console.log(' 跳过:记录既没有 Hex 也没有 Fields(Layout=' + R.Layout + ')');
continue;
}
const Out = {};
let Off = 0;
let Bad = false;
let Why2 = '';
const Chain = [];
// 记录链:模板里的 Hex 是「该帧体从本条记录负载起剩下的全部字节」(见文件头「帧结构」注释),
// 所以吃完本条字段后若还有剩余,一定是下一条记录 —— bit=0 的长形态 [objectId i64][methodId i64][负载]。
// (nullmap 的 bit 不在 Hex 里,短形态(bit=1,1 字节命令号)无法判也无法解,遇到就如实报错。)
let CurSpec = Spec;
let CurId = R.MethodId;
let ObjectId = null;
for (let Round = 0; ; Round++) {
const Val = {};
for (const Fd of (CurSpec && CurSpec.Fields) || []) {
const Res = Reader.ReadValue(Buf, Off, Fd.Type, Fd.TypeCodec, 0);
Val[FieldKey(Fd)] = Res.Value;
if (Res.Bad || Res.Next <= Off) { Bad = true; break; }
Off = Res.Next;
}
Chain.push({ Round: Round + 1, MethodId: CurId, ObjectId, Fields: CurSpec ? CurSpec.Fields : null, Value: Val });
for (const [K, V] of Object.entries(Val)) Out[(Round ? '#' + (Round + 1) + ' ' : '') + K] = V;
if (Bad) { Why2 = '本条字段解不动'; break; }
if (Off >= Buf.length) break;
if (Off + 16 > Buf.length) { Bad = true; Why2 = '剩余 ' + (Buf.length - Off) + 'B 不足一条记录头(objectId+methodId=16B)'; break; }
ObjectId = Buf.readBigInt64BE(Off).toString();
CurId = Buf.readBigInt64BE(Off + 8).toString();
Off += 16;
const Next = ByMethod.get(CurId);
if (!Next) { Bad = true; Why2 = '续记录 methodId=' + CurId + ' 不在字段表里'; break; }
if (Next.Kind === 'container') { Bad = true; Why2 = '续记录 methodId=' + CurId + ' 是容器型,容器不能出现在记录链中间'; break; }
CurSpec = Next;
}
console.log(' 字段表: ' + FieldList(Spec));
// 证据:源码描述符解出来的字段把出处一起打出来(格式 模块.js:行号 描述符)
const Ev = ((Spec && Spec.Fields) || []).filter((F) => F.Src).map((F) => (F.Name || F.Slot) + ' ← ' + F.Src);
if (Ev.length) for (const E of Ev) console.log(' 出处 : ' + E);
if (Chain.length > 1) {
console.log(' 记录链: ' + Chain.map((X) =>
'#' + X.Round + ' objectId=' + (X.ObjectId === null ? '(本条)' : X.ObjectId) + ' methodId=' + X.MethodId +
' [' + FieldList(ByMethod.get(X.MethodId)) + ']').join(' → '));
for (const X of Chain) if (X.Round > 1) console.log(' 记录' + X.Round + ' 解出: ' + JSON.stringify(X.Value));
}
console.log(' 解出 : ' + JSON.stringify(Out) + ' 吃掉 ' + Off + '/' + Buf.length + ' 字节' +
(Bad ? ' ⚠' + (Why2 || '中途失败') : Off === Buf.length ? ' ✓恰好吃满' : ' ⚠有剩余 ' + (Buf.length - Off) + 'B') +
' (负载来源:' + Origin + ')');
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
// mine_evidence.js - 从混淆代码里挖掘"原始命名证据"
// 用法: node mine_evidence.js [src目录] [输出json]
//
// 依据(编译产物里保留的线索):
// 1) 懒加载组件 getter : var x = t.<字段>; if (null != x) return x; vj("<组件名>")
// 2) 服务定位器 : t.<字段>.e2b(t, ct("<服务名>", 1, ...)) / x$("<服务名>", ...)
// 3) 闭包原名 : fn.callableName = "<原名>"
// 4) 数据类 toString : Q(类).toString = function () { return "<类名>(" + ... }
// 5) 枚举构造 : E = new 枚举类("<成员名>", 0)
// 6) 字符串常量频次(辅助命名)
//
// 结构说明:**类名/局部名都是模块内作用域**,因此按模块分域存放;
// 属性名(字段)是全局共享字符串,故全局聚合。
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const argv = process.argv.slice(2);
const SRC_DIR = path.resolve(argv[0] || path.join(PROJECT, 'src'));
const OUT = path.resolve(argv[1] || path.join(PROJECT, 'names', 'evidence.json'));
function Push(Target, Key, Value) {
if (Target instanceof globalThis.Map) {
if (!Target.has(Key)) Target.set(Key, []);
const List = Target.get(Key);
if (List.indexOf(Value) < 0) List.push(Value);
return;
}
// 普通对象:Key → [Value, ...]
if (!Target[Key]) Target[Key] = [];
if (Target[Key].indexOf(Value) < 0) Target[Key].push(Value);
}
function Run() {
const Files = fs.readdirSync(SRC_DIR).filter((F) => /^module_\d+\.js$/.test(F)).sort();
const ByModule = {}; // 模块ID → 各类证据
const FieldsGlobal = new Map(); // 字段 → 组件/服务名(全局)
const Services = new Set();
const Strings = new Map();
const Stats = { modules: 0, fields: 0, classNames: 0, enumMembers: 0, closures: 0, exports: 0 };
for (const F of Files) {
const Text = fs.readFileSync(path.join(SRC_DIR, F), 'utf8');
const Id = (F.match(/module_(\d+)/) || [])[1];
const Mod = { classNames: {}, closures: {}, enums: {}, exports: {}, componentGetters: {} };
let M;
// 1) 懒加载组件 getter(容忍换行/空格)
const GetterRe = /var\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\s*;[\s\S]{0,40}?if\s*\(\s*null\s*!=\s*\1\s*\)\s*return\s+\1\s*;[\s\S]{0,40}?(?:vj|Ys|rq)\s*\(\s*"([^"]+)"\s*\)/g;
while ((M = GetterRe.exec(Text)) !== null) {
Mod.componentGetters[M[3]] = M[4];
Push(FieldsGlobal, M[3], M[4]);
}
// 2) 服务定位器 ct("name", 1, ...) / x$("name", ...)
for (M of Text.matchAll(/\b(?:ct|x\$|E\$|ct)\s*\(\s*"([^"]+)"\s*,\s*\d+/g)) Services.add(M[1]);
for (M of Text.matchAll(/\.e2b\s*\([^,]+,\s*(?:ct|x\$)\s*\(\s*"([^"]+)"/g)) Services.add(M[1]);
// 3) callableName(模块内作用域)
for (M of Text.matchAll(/([A-Za-z_$][\w$]*)\.callableName\s*=\s*"([^"]+)"/g)) Push(Mod.closures, M[1], M[2]);
for (M of Text.matchAll(/([A-Za-z_$][\w$]*)\(\s*([A-Za-z_$][\w$]*)\s*\)\.toString\s*=\s*function\s*\(\s*\)\s*\{\s*return\s+"([A-Za-z_$][\w$]*)\(/g)) {
Mod.classNames[M[2]] = M[3];
}
// 5) 枚举: X = new Cls("Member", 数字)
for (M of Text.matchAll(/([A-Za-z_$][\w$]*)\s*=\s*new\s+([A-Za-z_$][\w$]*)\(\s*"([A-Za-z_$][\w$ -]*)"\s*,\s*(-?\d+)\s*\)/g)) {
if (!Mod.enums[M[2]]) Mod.enums[M[2]] = [];
Mod.enums[M[2]].push({ field: M[1], member: M[3], value: Number(M[4]) });
}
// 6) 字符串常量
for (M of Text.matchAll(/"([^"\\\n]{2,60})"/g)) {
const V = M[1];
if (/^[0-9a-f]{8,}$/i.test(V) || /^[A-Za-z0-9+/=]{40,}$/.test(V)) continue;
Strings.set(V, (Strings.get(V) || 0) + 1);
}
// 7) 导出表 (t.$_$.xx = LocalName)
for (M of Text.matchAll(/t\.\$_\$\.([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)/g)) Mod.exports[M[1]] = M[2];
if (Object.keys(Mod.classNames).length) Stats.classNames += Object.keys(Mod.classNames).length;
for (const K of Object.keys(Mod.enums)) Stats.enumMembers += Mod.enums[K].length;
for (const K of Object.keys(Mod.closures)) Stats.closures += Mod.closures[K].length;
Stats.exports += Object.keys(Mod.exports).length;
Stats.fields += Object.keys(Mod.componentGetters).length;
Stats.modules++;
// 只保留有内容的模块,减小体积
if (
Object.keys(Mod.classNames).length || Object.keys(Mod.enums).length ||
Object.keys(Mod.closures).length || Object.keys(Mod.exports).length ||
Object.keys(Mod.componentGetters).length
) {
ByModule[Id] = Mod;
}
}
const Result = {
bundle: 'main.6469b966.js',
generatedAt: new Date().toISOString(),
stats: Stats,
byModule: ByModule,
fieldsGlobal: Object.fromEntries([...FieldsGlobal].sort()),
services: [...Services].sort(),
strings: Object.fromEntries([...Strings].sort((A, B) => B[1] - A[1])),
};
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, JSON.stringify(Result, null, 1), 'utf8');
console.log('=== 证据统计 ===');
for (const [K, V] of Object.entries(Stats)) console.log(' ' + K + ': ' + V);
console.log(' services: ' + Services.size + ' strings: ' + Strings.size);
console.log('--- 字段 → 组件名(前 15)---');
let C = 0;
for (const [K, V] of [...FieldsGlobal].sort()) {
if (C++ >= 15) break;
console.log(' ' + K + ' -> ' + V.join('/'));
}
console.log('--- 数据类名示例(模块 87072)---');
const M87072 = ByModule['87072'] || {};
C = 0;
for (const [K, V] of Object.entries(M87072.classNames || {})) {
if (C++ >= 10) break;
console.log(' ' + K + ' = ' + V);
}
console.log('--- 服务名(前 12)---');
console.log(' ' + [...Services].sort().slice(0, 12).join(', '));
console.log('输出: ' + OUT);
}
Run();
+189
View File
@@ -0,0 +1,189 @@
{
"name": "3dtank-reverse",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "3dtank-reverse",
"version": "1.0.0",
"dependencies": {
"acorn": "^8.12.1",
"acorn-walk": "^8.3.3",
"mysql2": "^3.24.4",
"prettier": "^3.3.3"
}
},
"node_modules/@types/node": {
"version": "22.20.3",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.3.tgz",
"integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.5",
"resolved": "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.5.tgz",
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/mysql2": {
"version": "3.24.4",
"resolved": "https://registry.npmmirror.com/mysql2/-/mysql2-3.24.4.tgz",
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sql-escaper": {
"version": "1.5.2",
"resolved": "https://registry.npmmirror.com/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT",
"peer": true
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "3dtank-reverse",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"description": "3DTank 客户端逆向工程:模块提取、命名证据挖掘、重命名与重组验证",
"dependencies": {
"acorn": "^8.12.1",
"acorn-walk": "^8.3.3",
"mysql2": "^3.24.4",
"prettier": "^3.3.3"
}
}
+86 -13
View File
@@ -1,7 +1,17 @@
// 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(每帧相对首条消息的毫秒)。
// dump_plain.js 已产出 timeline_c2s.txt / timeline_s2c.txt(每帧相对「该方向首条消息」的毫秒)。
// ⚠ 这两个 0 点不是同一时刻:c2s 的 0 点 = 客户端首条消息,s2c 的 0 点 = 服务端首条消息,
// 实测相差 144~4453ms(space-100073070 = 2084ms)。直接相比会让每个 s2c 帧的"触发者"
// 整体前移一个偏移量,配对表偏 1~4 秒。
// 修法:把两个方向的帧换算到同一个时钟 —— 来源会话 log.txt 里的绝对时间戳
// (同一次抓包、同一台机器的本地时钟,天然同一时间轴)。summary.txt 记着来源会话路径,
// 回读它的 log.txt 取双方首帧绝对时刻相减得 ClockOffsetMs;
// s2c 帧的对齐时刻 = 相对值 + ClockOffsetMs,与 c2s 的相对值落在同一时间轴上。
// (为何不逐帧读 log.txt 的绝对时刻:log.txt 每条 WS 消息只有一个时刻,而
// dump_plain.js 把「帧 → 消息」的映射留在内存里没落盘;timeline 的相对值保留了
// 方向内每帧的时刻,所以与绝对时钟只差一个基准差。)
// 对每条 s2c 帧,找「时间上最近的、且相距 <= Window 的」c2s 帧作为触发者;
// 找不到触发者的就是服务端主动推送(ping / 周期 inform)。
// 产物:
@@ -90,6 +100,36 @@ function ReadTimeline(Dir, Direction) {
return Out;
}
// 取「两个 timeline 0 点之差」:明细目录只记了相对时刻,来源会话的 log.txt 才有绝对时刻。
// summary.txt 首行是 dump_plain.js 写的「来源会话: <绝对路径>」。
// 基准取「序号最小的那条消息」的时刻,与 dump_plain.js 的 Times[0](按序号升序)严格一致。
// 取不到(无 summary.txt / 无 log.txt / 缺某方向)时返回 null,调用方按偏移 0 处理并在统计里计数。
function ReadClockOffset(Dir) {
const SummaryPath = path.join(Dir, 'summary.txt');
if (!fs.existsSync(SummaryPath)) return null;
const M = /^来源会话[::]\s*(.+)$/m.exec(fs.readFileSync(SummaryPath, 'utf8'));
if (!M) return null;
const SessionDir = M[1].trim();
const LogPath = path.join(SessionDir, 'log.txt');
if (!fs.existsSync(LogPath)) return null;
const First = {}; // 方向 -> { Seq, Time }
for (const Line of fs.readFileSync(LogPath, 'utf8').split('\n')) {
const X = /^\[([^\]]+)\]\s*(?:>>>|<<<)\s+(c2s|s2c)_(\d+)\.bin/.exec(Line.trim());
if (!X) continue;
const Time = Date.parse(X[1]);
if (!Number.isFinite(Time)) continue;
const Seq = Number(X[3]);
if (!First[X[2]] || Seq < First[X[2]].Seq) First[X[2]] = { Seq, Time };
}
if (!First.c2s || !First.s2c) return null;
return {
OffsetMs: First.s2c.Time - First.c2s.Time,
Session: path.basename(SessionDir),
C2sBase: new Date(First.c2s.Time).toISOString(),
S2cBase: new Date(First.s2c.Time).toISOString(),
};
}
// 帧签名:空间帧用 methodId(S:<methodId>),其余用「命令 ID + 负载前 4 字节」
// 必须与 wss_server.js 的 FrameSignature 完全一致
function Signature(Info) {
@@ -107,7 +147,7 @@ function SigLabel(Sig) {
}
const Spaces = [];
const Global = { AsTrigger: {}, AsReply: {}, Push: {}, Spaces: 0, Pairs: 0, S2c: 0 };
const Global = { AsTrigger: {}, AsReply: {}, Push: {}, Spaces: 0, Pairs: 0, S2c: 0, Unanswered: {}, Clocks: {}, UnknownClock: 0 };
for (const Name of fs.readdirSync(Root).sort()) {
const Dir = path.join(Root, Name);
@@ -117,21 +157,31 @@ for (const Name of fs.readdirSync(Root).sort()) {
if (!C2s.length || !S2c.length) continue;
Global.Spaces++;
// 两个 timeline 的 0 点对齐差(见文件头原理);取不到就按 0 处理(退化成旧行为)
const Clock = ReadClockOffset(Dir);
const Offset = Clock ? Clock.OffsetMs : 0;
if (Clock) Global.Clocks[Name] = Offset;
else Global.UnknownClock++;
// s2c 帧的「对齐时刻」= 相对 s2c 首帧 + 偏移,单位同 c2s 的相对毫秒(同一时间轴)
const Aligned = (Ms) => Ms + Offset;
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 Lines = ['空间 ' + Name + ' 请求 ' + C2s.length + ' 条 / 响应 ' + S2c.length + ' 条(窗口 ' + Window + 'ms)',
'时钟对齐: s2c 相对值 + ' + Offset + 'ms(来源会话 ' + (Clock ? Clock.Session : '未知') + ')', ''];
const Triggers = [];
const UsedSignatures = new Set();
let Pushed = 0;
let Pairs = 0;
for (const S of S2cFrames) {
if (S.Info.Cmd === -1) continue; // ping 单独统计
Global.S2c++;
// 找最近的、时间不超过窗口的 c2s
// 找最近的、时间不超过窗口的 c2s(两者都换算到同一时间轴后比较)
let Best = null;
for (const C of C2sFrames) {
if (C.Ms <= S.Ms && S.Ms - C.Ms <= Window) Best = C; // 列表按时间升序,取最后一个满足的
if (C.Ms <= Aligned(S.Ms) && Aligned(S.Ms) - C.Ms <= Window) Best = C; // 列表按时间升序,取最后一个满足的
}
if (Best) {
const Key = Signature(Best.Info);
@@ -140,9 +190,10 @@ for (const Name of fs.readdirSync(Root).sort()) {
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 });
Group.Replies.push({ Name: S.Name, Cmd: S.Info.Cmd, Ms: S.Ms, Delay: Aligned(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;
UsedSignatures.add(Key);
Pairs++;
Global.Pairs++;
} else {
@@ -151,6 +202,18 @@ for (const Name of fs.readdirSync(Root).sort()) {
}
}
// 未匹配到应答的请求:整段会话里「从未成为过任何 s2c 触发者」的请求签名,按条数排序
const Unanswered = new Map();
for (const C of C2sFrames) {
const Key = Signature(C.Info);
if (UsedSignatures.has(Key)) continue;
Unanswered.set(Key, (Unanswered.get(Key) || 0) + 1);
}
const UnansweredList = [...Unanswered.entries()]
.map(([Trigger, Count]) => ({ Trigger, Label: SigLabel(Trigger), Count }))
.sort((A, B) => B.Count - A.Count);
for (const U of UnansweredList) Global.Unanswered[U.Trigger] = (Global.Unanswered[U.Trigger] || 0) + U.Count;
for (const G of Triggers) {
Lines.push(
'触发 c2s ' + G.TriggerName + ' ' + SigLabel(G.Trigger) + ' @+' + G.TriggerMs + 'ms → ' + G.Replies.length + ' 条回复',
@@ -163,37 +226,44 @@ for (const Name of fs.readdirSync(Root).sort()) {
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));
Lines.push('未匹配到应答的请求: ' + UnansweredList.reduce((A, B) => A + B.Count, 0) + ' 条 / ' + UnansweredList.length + ' 种命令');
for (const U of UnansweredList.slice(0, 10)) Lines.push(' ' + String(U.Count).padStart(4) + ' 条 ' + U.Label + ' ' + U.Trigger);
fs.writeFileSync(path.join(Dir, 'flow.txt'), Lines.join('\n') + '\n', 'utf8');
// 给 fake 服务器用的极简配对表:每行「s2c 文件 \t 触发签名|- \t 相对触发的延迟ms \t 原时间线ms」
// 给 fake 服务器用的极简配对表:每行「s2c 文件 \t 触发签名|- \t 相对触发的延迟ms \t 对齐后 s2c 时刻ms」
// 第 4 列已换算到 c2s 的 0 点(= 第 3 列可直接用),不再是 s2c 单方向的相对值
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) {
if (C.Ms <= Aligned(S.Ms) && Aligned(S.Ms) - C.Ms <= Window) {
Trigger = Signature(C.Info);
Delay = S.Ms - C.Ms;
Delay = Aligned(S.Ms) - C.Ms;
}
}
Tsv.push(S.Name + '\t' + Trigger + '\t' + Delay + '\t' + S.Ms);
Tsv.push(S.Name + '\t' + Trigger + '\t' + Delay + '\t' + Aligned(S.Ms));
}
fs.writeFileSync(path.join(Dir, 'flow_s2c.tsv'), Tsv.join('\n') + '\n', 'utf8');
Spaces.push({
Space: Name,
ClockOffsetMs: Offset,
ClockSource: Clock ? Clock.Session : null,
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 })),
})),
Unanswered: UnansweredList,
Pushed,
});
console.log(
Name.padEnd(20) + ' c2s ' + String(C2s.length).padStart(3) + ' s2c ' + String(S2c.length).padStart(3) +
' 配对 ' + String(Pairs).padStart(4) + ' 推送 ' + Pushed,
Name.padEnd(20) + ' c2s ' + String(C2s.length).padStart(3) + ' s2c ' + String(S2c.length).padStart(3) +
' 配对 ' + String(Pairs).padStart(4) + ' 推送 ' + Pushed + ' 偏移 ' + String(Offset).padStart(5) + 'ms' +
' 未应答 ' + UnansweredList.reduce((A, B) => A + B.Count, 0) + ' 条',
);
}
@@ -201,7 +271,7 @@ fs.writeFileSync(
OutPath,
JSON.stringify(
{
Note: '由 pair_flow.js 生成:s2c 帧按时间就近归到触发的 c2s 帧;无触发的记为主动推送',
Note: '由 pair_flow.js 生成:s2c 帧先按 log.txt 绝对时刻对齐到 c2s 时间轴(ClockOffsetMs),再按时间就近归到触发的 c2s 帧;无触发的记为主动推送;Unanswered = 整段会话里从未触发过任何 s2c 的请求命令',
Window,
Stats: Global,
Spaces,
@@ -215,4 +285,7 @@ console.log('\n全局统计:');
console.log(' 作为触发的 c2s 命令: ' + JSON.stringify(Global.AsTrigger));
console.log(' 作为回复的 s2c 命令: ' + JSON.stringify(Global.AsReply));
console.log(' 主动推送的 s2c 命令: ' + JSON.stringify(Global.Push));
const TopUnanswered = Object.entries(Global.Unanswered).sort((A, B) => B[1] - A[1]).slice(0, 10);
console.log(' 未匹配到应答的请求 TOP10:');
for (const [Key, Count] of TopUnanswered) console.log(' ' + String(Count).padStart(5) + ' 条 ' + SigLabel(Key) + ' ' + Key);
console.log(' 已写出: ' + OutPath);
+136
View File
@@ -0,0 +1,136 @@
// probe_containers.js - 只读诊断:空间初始化模板里「容器记录」到底卡在哪
//
// 为什么单独有这个工具:容器记录(modelService 的 4 条方法)过去只会被整帧留成 hex,
// 而「解不动」有三种完全不同的原因,混在一起就变成了「再试一次也不会有进展」:
// ① 布局/边界:负载的切分口径不对(多记录帧里容器负载不该按「吃到帧尾」定界)
// ② 叶子 codec 缺失:pt/lt 项里的 modelId 在 names/codec_fields.json 查不到 codec,
// 或字段类型是推不出来的裸类名(16 个 token 之一,见 codec_fields.json 的 SlotTypeStats)
// ③ nullmap 位游标:opt 字段的「空/非空」位与记录形态位共用同一条比特流,
// 拿不到位游标时只能如实报不可解(不能退回按字节猜标志位)
//
// 本工具只读模板与 packets/plain,不写任何文件。
// 用法: node probe_containers.js [--names ../names] [--plain ../packets/plain]
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./server/link');
const Semantic = require('./server/semantic').Load(path.join(__dirname, '..', 'names'));
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 NamesDir = path.resolve(__dirname, Arg('names', path.join('..', 'names')));
const PlainDir = path.resolve(__dirname, Arg('plain', path.join('..', 'packets', 'plain')));
const LAYOUT_BY_METHOD = Semantic.Container.LAYOUT_BY_METHOD;
const NAME_BY_METHOD = {
'3216143066888387731': 'ModelService.LoadDependencies',
'7640916300855664666': 'ModelService.LoadObjectsData',
'9038756763225879447': 'ModelService.UnloadObjects',
'8179162314198150518': 'ModelService.SetModelCommandAlias',
};
const LAYOUTS = ['zt', 'pt', 'lt'];
// model_table(modelId → 模型类)用来区分「这个 modelId 是真模型、只是 codec_fields 没抽出 codec」
// 与「这个 modelId 根本不存在 → 说明前面的项读串位了(字段表抽错/不全)」
const ModelTable = new Map(
JSON.parse(fs.readFileSync(path.join(NamesDir, 'model_table.json'), 'utf8')).Rows.map((R) => [String(R.ModelId), R]),
);
// 原因分类(每条记录只归一类,取「最先卡住」的那个)
const Causes = new Map();
function Classify(Rec) {
if (Rec.Accepted) return '✓ 已解出(布局/边界正确)';
const Why = Rec.Why || '';
if (/没有 nullmap 比特流|位游标/.test(Why)) return '③ nullmap 位游标拿不到(前一条记录的字段表不在 codec_fields)';
const M = /modelId=(\d+)/.exec(Why);
if (M && !ModelTable.has(M[1])) return '⑤ 字段表抽错/不全 → 项读串位(例如 MapCodecInfo 被抽成 enum<…>)';
if (M) return '② 叶子 codec 缺失(modelId 在 model_table 里有,codec_fields 里没有 codec)';
if (/无 codec:|没有对应 codec/.test(Why)) return '② 叶子 codec 缺失(字段类型是推不出 codec 的裸类名)';
if (/抛出异常|越界|不合理|超上限/.test(Why)) return '① 布局/边界不对(负载切分口径)';
return '④ 其它:' + Why.split(':')[0];
}
const MissingModelIds = new Map();
const MissingTypes = new Map();
const Rows = [];
for (const File of fs.readdirSync(NamesDir).filter((X) => /^space-init-.*\.json$/.test(X)).sort()) {
if (/^space-init-login-/.test(File)) continue;
const T = JSON.parse(fs.readFileSync(path.join(NamesDir, File), 'utf8'));
const Dir = path.join(PlainDir, 'space-' + T.SpaceId);
for (const Fr of T.Frames || []) {
const Plain = path.join(Dir, Fr.File);
if (!fs.existsSync(Plain)) continue;
const Parsed = Link.ParseFrames(fs.readFileSync(Plain));
if (!Parsed || Parsed.length !== 1) continue;
const Body = Link.DecodeFrame(Parsed[0]);
const Decoded = Semantic.DecodeFrameBody(Body);
for (const [Idx, R] of Decoded.Records.entries()) {
if (R.ObjectId === undefined || R.At === undefined) continue;
const MethodId = String(R.MethodId);
const Expect = LAYOUT_BY_METHOD[MethodId];
if (!Expect) continue;
const Off = R.At + 16;
const Limit = Body.length - Off;
const Row = {
File,
Frame: Fr.File,
Ord: Idx + 1,
Method: NAME_BY_METHOD[MethodId] || MethodId,
Expect,
Load: Limit,
Why: '',
Accepted: false,
Tried: [],
};
// 三布局各试一遍,把「吃到哪 / 在哪中断 / 为什么」都记下来
let Hit = null;
for (const L of LAYOUTS) {
const B = Semantic.Container.Read(L, Body, Off);
Row.Tried.push(L + (B.Bad ? ' 中断@+' + (B.Next - Off) : ' 吃到 +' + (B.Next - Off)) + (B.Why ? '(' + B.Why + ')' : ''));
if (!B.Bad && L === Expect) Hit = L;
}
const R2 = Semantic.Container.Read(Expect, Body, Off);
if (!R2.Bad) {
Row.Accepted = true;
Row.Consumed = R2.Next - Off;
Row.Layout = Expect;
} else {
Row.Why = R2.Why || '(无原因)';
}
// 缺 codec 统计:从容器值里扫 modelId / 从原因串里抠 modelId、类型名
const M = /modelId=(\d+)/.exec(Row.Why);
if (M) MissingModelIds.set(M[1], (MissingModelIds.get(M[1]) || 0) + 1);
const MT = /无 codec:([^)]+)/.exec(Row.Why);
if (MT) MissingTypes.set(MT[1], (MissingTypes.get(MT[1]) || 0) + 1);
Row.Cause = Classify(Row);
Causes.set(Row.Cause, (Causes.get(Row.Cause) || 0) + 1);
Rows.push(Row);
}
}
}
console.log('=== 容器记录诊断(只读;数据源 ' + NamesDir + ' + ' + PlainDir + ')===');
console.log('');
for (const R of Rows) {
console.log(R.File.padEnd(32) + ' ' + String(R.Frame).padEnd(14) + ' 第' + String(R.Ord).padStart(2) + '条 ' +
R.Method.padEnd(30) + ' 负载 ' + String(R.Load).padStart(7) + 'B 期望布局=' + R.Expect);
console.log(' 三布局:' + R.Tried.join(' | '));
if (R.Accepted) console.log(' ✓ 解出 ' + R.Consumed + 'B(' + R.Layout + ');原因分类:' + R.Cause);
else console.log(' ✗ ' + R.Cause + ';卡点:' + R.Why);
}
console.log('');
console.log('=== 汇总 ===');
console.log('容器记录 ' + Rows.length + ' 条:已解出 ' + Rows.filter((R) => R.Accepted).length +
' / 仍不可解 ' + Rows.filter((R) => !R.Accepted).length);
for (const [K, V] of [...Causes].sort((A, B) => B[1] - A[1])) console.log(' ' + String(V).padStart(3) + ' ' + K);
if (MissingModelIds.size) {
console.log(' 缺 codec 的 modelId(去重 ' + MissingModelIds.size + ' 个):');
for (const [K, V] of [...MissingModelIds].sort((A, B) => B[1] - A[1])) console.log(' ' + K + ' ×' + V);
}
if (MissingTypes.size) {
console.log(' 缺 codec 的裸类名(去重 ' + MissingTypes.size + ' 个):');
for (const [K, V] of [...MissingTypes].sort((A, B) => B[1] - A[1])) console.log(' ' + K + ' ×' + V);
}
+261
View File
@@ -0,0 +1,261 @@
// rebase_space_init.js - 以真实抓包明文帧为准,重写 names/space-init-*.json 的记录切分
//
// 为什么需要它(根因,别被"看起来对"骗了):
// 模板最早由 tools/extract_space_init.js 从抓包切出来,而它把**第一条记录的 objectId** 当成了
// 「帧级 spaceId」:extract_space_init.js:79 直接读帧体第 At 处的 i64 当 SpaceId、:105 写进
// Frames[].SpaceId,随后 :82-85 从 At+8 起把接下来 8 字节当第一条记录的 methodId ——
// 于是第一条记录**丢了自己的 objectId**,而帧级 SpaceId 里存的是它的真值。
// 后来 tools/audit_space_init.js --fix 给这类记录补记录头时,兜底用的是**模板顶层 SpaceId**
// (audit_space_init.js:127 / :129 的 RealSpace)——对「第一条记录 objectId ≠ 空间号」的帧就补错了。
// 实测 space-init-100072911.json 的 s2c_0002.bin(对应 packets/plain/space-100072911/s2c_0002.bin):
// 真值 rec1.objectId = 3074457345817168740(0x2aaaaaaab685cb64,帧体偏移 2..9 这 8 字节)
// 模板里却写成 100072911(空间号,0x0000000005f6fdcf)→ 正好差这 8 字节。
// tools/server/space.js:30 已经是「优先用记录自带的 ObjectId,只有记录没写才回退帧级 SpaceId」——
// 这条优先级规则是对的(也符合 semantic.js 头注:每条记录自带 objectId),所以错的是**模板里的值**,
// 不是 space.js:它只是老老实实把记录自带的错值写了出来。本工具负责把这批值按真实帧改对。
//
// 做法(不猜边界):
// 逐帧读 packets/plain/space-<真实空间号>/<File>(与模板 Frames[].File 同名)→
// Link.ParseFrames + Link.DecodeFrame 得真实帧体 → Sem.DecodeFrameBody 得**权威记录列表**
// (每条带 ObjectId / MethodId / 负载)。容器型 → { Layout, Value };字段型完整解出 → { Fields };
// 解不动 / 短命令形态 → 整帧原样保留并写明原因(不伪造切分、不丢字节)。
// 帧级 SpaceId 写成真实空间号(明文目录名 space-<id> 里的 id)。
//
// 安全阀:改写后的模板经 space.js 组帧,产出必须与原始帧体**逐字节一致**;
// 不一致就拒绝写回该帧,并打印前 32 个差异偏移 + 两侧 hex。
// 幂等:产物由真实帧唯一决定,重复运行不产生变化(只写到有变化时才落盘)。
//
// 用法: node rebase_space_init.js [--dry] [--names ../names] [--plain ../packets/plain]
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./server/link');
const Space = require('./server/space');
const SemanticModule = require('./server/semantic');
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 ToolsDir = __dirname;
const NamesDir = path.resolve(ToolsDir, Arg('names', path.join('..', 'names')));
const PlainDir = path.resolve(ToolsDir, Arg('plain', path.join('..', 'packets', 'plain')));
const Dry = Argv.indexOf('--dry') >= 0;
const Sem = SemanticModule.Load(NamesDir);
// 差异偏移(前 Limit 个);用来把"不一致"说清楚,而不是只丢一句"对不上"
function DiffOffsets(A, B, Limit) {
const Out = [];
for (let I = 0; I < Math.max(A.length, B.length) && Out.length < Limit; I++) if (A[I] !== B[I]) Out.push(I);
return Out;
}
// 偏移附近的 hex 窗口:整帧可能几十万字节,只贴差异邻域(±Span)
function HexAround(Buf, At, Span) {
const S = Math.max(0, At - Span);
const E = Math.min(Buf.length, At + Span);
return '偏移 ' + S + '..' + E + ':' + Buf.slice(S, E).toString('hex');
}
// 模板记录 → 组帧体(含 nullmap)。走的是服务端真正用的那条路径(space.js → container/semantic 现编),
// 所以它同时验证「模板记录形态」与「编码器」两件事。
function BuildBody(File, RealSpace, NullmapHex, DelayMs, Records) {
const Built = Space.BuildInitFrames(
{ Frames: [{ File, SpaceId: RealSpace, NullmapHex, DelayMs, Records }] },
Sem.Container,
Sem,
)[0].Frame;
return Link.DecodeFrame(Link.ParseFrames(Built)[0]);
}
const Stat = { Templates: 0, Frames: 0, Rebased: 0, KeptNoPlain: 0, KeptUndecodable: 0, KeptAssert: 0, Records: 0, Ctr: 0, Fld: 0, Hex: 0, ObjFilled: 0, ObjFixed: 0, ShortFrames: 0, ShortRecords: 0 };
const Lines = [];
const Skipped = [];
for (const File of fs.readdirSync(NamesDir).filter((X) => /^space-init-.*\.json$/.test(X)).sort()) {
// 登录模板来自中继抓包(packets/relay-logout/session_008),packets/plain 下没有它的对应帧
// (space-100045260 里碰巧同名的 s2c_0002.bin 是另一条连接的另一帧)——按任务要求整份跳过。
if (/^space-init-login-/.test(File)) {
Skipped.push(File);
continue;
}
Stat.Templates++;
const Path2 = path.join(NamesDir, File);
const T = JSON.parse(fs.readFileSync(Path2, 'utf8'));
const DirName = 'space-' + T.SpaceId; // 明文目录名里就是真实空间号
const Dir = path.join(PlainDir, DirName);
const RealSpace = DirName.slice('space-'.length);
let Changed = false;
Lines.push('== ' + File + ' 真实空间号=' + RealSpace + (fs.existsSync(Dir) ? '' : ' ⚠ 没有明文目录:' + Dir));
for (const Fr of T.Frames || []) {
Stat.Frames++;
const Plain = path.join(Dir, Fr.File);
if (!fs.existsSync(Plain)) {
Stat.KeptNoPlain++;
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 原样保留:' + Dir + ' 下没有同名明文帧');
continue;
}
const Parsed = Link.ParseFrames(fs.readFileSync(Plain));
if (!Parsed || Parsed.length !== 1) {
Stat.KeptUndecodable++;
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 原样保留:明文文件不是"恰好一整帧"(帧数 ' + (Parsed ? Parsed.length : 'null') + ')');
continue;
}
const Body = Link.DecodeFrame(Parsed[0]);
const NmSize = Link.NullmapSize(Body, 0);
const NmHex = Body.slice(0, NmSize).toString('hex');
const Decoded = Sem.DecodeFrameBody(Body);
// 模板的三种记录形态都表达不了「短命令(nullmap bit=1 的 1 字节命令号)」
// 与「解码器已经判定不了形态」这两种情况(semantic.js:236-243 / :230-235 会标 Short / 只留 Remaining),
// 这时整帧原样保留,绝不猜。
const Unrepresentable = Decoded.Records.find((R) => R.ObjectId === undefined || R.Short);
if (Unrepresentable) {
Stat.KeptUndecodable++;
if (Unrepresentable.Short) {
Stat.ShortFrames++;
Stat.ShortRecords++;
}
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 原样保留(形态表达不了):' + Unrepresentable.Note);
continue;
}
const NewRecords = [];
const Shapes = [];
for (const R of Decoded.Records) {
if (R.Bad) {
// 解不动的记录(含容器三布局都吃不到记录尾):如实保留剩余字节 + 原因。
// DecodeFrameBody 一遇到 Bad 就停(semantic.js:255-259),所以这段剩余字节就是本条负载。
NewRecords.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Hex: Body.slice(R.At + 16).toString('hex'), Note: R.Note });
Shapes.push('hex(' + (Body.length - R.At - 16) + 'B)');
Stat.Hex++;
} else if (R.Kind === 'container') {
NewRecords.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Layout: R.Layout, Value: R.Value });
Shapes.push('容器/' + R.Layout);
Stat.Ctr++;
} else {
const Fields = {};
for (const X of R.Fields || []) Fields[X.Name] = X.Value;
NewRecords.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Fields });
Shapes.push('字段(' + (R.Fields || []).length + ')');
Stat.Fld++;
}
Stat.Records++;
}
// 硬性断言:改后的模板组帧必须与原始帧体逐字节一致。
// 断言用「JSON 往返之后」的记录(= 真正会落盘的那份),这样顺带保证了幂等:
// 万一某个值过不了 JSON(比如 float 的 NaN/Infinity 会变 null),这里就会当场暴露成"组帧不一致"。
const Persisted = JSON.parse(JSON.stringify(NewRecords));
let BuiltBody = null;
let Err = null;
try {
BuiltBody = BuildBody(Fr.File, RealSpace, NmHex, Fr.DelayMs || 0, Persisted);
} catch (E) {
Err = E.message;
}
if (!BuiltBody || !BuiltBody.equals(Body)) {
Stat.KeptAssert++;
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 拒绝写回:组帧与原始帧体不一致' +
(Err ? '(组帧抛错:' + Err + ')' : '(' + (BuiltBody ? BuiltBody.length : '?') + 'B vs 原始 ' + Body.length + 'B)'));
if (BuiltBody) {
const Offs = DiffOffsets(BuiltBody, Body, 32);
Lines.push(' 差异偏移(前 32 个): ' + JSON.stringify(Offs));
for (const At of Offs.slice(0, 3)) {
Lines.push(' @' + At + ' 模板组帧 ' + HexAround(BuiltBody, At, 16));
Lines.push(' @' + At + ' 原始帧体 ' + HexAround(Body, At, 16));
}
}
continue;
}
// objectId 对账:本次到底改了什么(补上 / 改错值)
const Old = Fr.Records || [];
const Fixes = [];
for (let I = 0; I < NewRecords.length; I++) {
const O = Old[I] || {};
if (O.ObjectId === undefined) Stat.ObjFilled++;
else if (String(O.ObjectId) !== String(NewRecords[I].ObjectId)) {
Stat.ObjFixed++;
Fixes.push(' 修正 objectId:第 ' + (I + 1) + ' 条 ' + String(O.ObjectId) + ' → ' + NewRecords[I].ObjectId +
'(methodId=' + NewRecords[I].MethodId + ')');
}
}
const Before = JSON.stringify(Old);
if (Before !== JSON.stringify(Persisted)) {
Fr.Records = Persisted;
Changed = true;
}
if (Fr.SpaceId !== RealSpace) {
Fr.SpaceId = RealSpace;
Changed = true;
}
if (String(Fr.NullmapHex || '') !== NmHex) {
Fr.NullmapHex = NmHex;
Changed = true;
}
Stat.Rebased++;
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 帧体 ' + Body.length + 'B nullmap=' + NmHex.slice(0, 32) +
(NmHex.length > 32 ? '…' : '') + ' ' + NewRecords.length + ' 条记录 [' + Shapes.join(' + ') + ']');
for (const F of Fixes) Lines.push(F);
}
if (Changed && !Dry) fs.writeFileSync(Path2, JSON.stringify(T, null, 1) + '\n', 'utf8');
Lines.push(' ' + (Changed ? (Dry ? '(--dry)有变化,未写回' : '已写回') : '无变化'));
}
console.log('=== 以真实明文帧为准重写 space-init 模板 ===');
console.log('模板目录: ' + NamesDir);
console.log('明文目录: ' + PlainDir);
for (const L of Lines) console.log(L);
console.log('');
console.log('模板 ' + Stat.Templates + ' 份 帧 ' + Stat.Frames +
'(重写 ' + Stat.Rebased + ' / 无明文 ' + Stat.KeptNoPlain + ' / 形态表达不了 ' + Stat.KeptUndecodable + ' / 组帧不一致拒写 ' + Stat.KeptAssert + ')');
console.log('记录 ' + Stat.Records + ' 条:容器 ' + Stat.Ctr + ' / 字段 ' + Stat.Fld + ' / hex ' + Stat.Hex);
console.log('objectId:补上 ' + Stat.ObjFilled + ' 条 / 修正错值 ' + Stat.ObjFixed + ' 条');
console.log('短命令形态:' + Stat.ShortFrames + ' 帧 / ' + Stat.ShortRecords + ' 条记录(语义层判定不了命令号 → 整帧原样保留)');
for (const F of Skipped) console.log('跳过 ' + F + ':登录模板,来自中继抓包、packets/plain 下没有对应明文帧 → 记录不动');
if (Dry) console.log('(--dry:一个字节都没写)');
// ---------------------------------------------------------------- 全量对账
// 把「模板组帧(走 space.js 的现编路径)」与「原始明文帧体」逐帧比一遍:这是本工具的最终验收。
console.log('');
console.log('=== 模板组帧 vs 原始帧体 全量对账 ===');
const Rec = { Same: 0, Diff: 0, NoPlain: 0, Skip: 0 };
for (const File of fs.readdirSync(NamesDir).filter((X) => /^space-init-.*\.json$/.test(X)).sort()) {
const T = JSON.parse(fs.readFileSync(path.join(NamesDir, File), 'utf8'));
if (/^space-init-login-/.test(File)) {
console.log(' ' + File.padEnd(34) + ' 跳过(无对应明文抓包) 帧 ' + (T.Frames || []).length);
Rec.Skip += (T.Frames || []).length;
continue;
}
const Dir = path.join(PlainDir, 'space-' + T.SpaceId);
for (const Fr of T.Frames || []) {
const Plain = path.join(Dir, Fr.File);
if (!fs.existsSync(Plain)) {
console.log(' ' + File.padEnd(34) + ' ' + String(Fr.File).padEnd(14) + ' 无明文帧 → 无法对账(已原样保留)');
Rec.NoPlain++;
continue;
}
const Body = Link.DecodeFrame(Link.ParseFrames(fs.readFileSync(Plain))[0]);
let BuiltBody = null;
let Err = null;
try {
BuiltBody = BuildBody(Fr.File, String(Fr.SpaceId), Fr.NullmapHex, Fr.DelayMs || 0, Fr.Records || []);
} catch (E) {
Err = E.message;
}
const Same = !!BuiltBody && BuiltBody.equals(Body);
if (Same) Rec.Same++;
else Rec.Diff++;
const Offs = Same ? [] : DiffOffsets(BuiltBody || Buffer.alloc(0), Body, 8);
console.log(' ' + File.padEnd(34) + ' ' + String(Fr.File).padEnd(14) + (Same ? ' ✓ 一致 ' : ' ✗ 不一致 ') +
'帧体 ' + Body.length + 'B 记录 ' + (Fr.Records || []).length + (Err ? ' 组帧抛错:' + Err : '') +
(Same ? '' : ' 差异偏移=' + JSON.stringify(Offs)));
}
}
console.log('合计:一致 ' + Rec.Same + ' / 不一致 ' + Rec.Diff + ' / 无明文 ' + Rec.NoPlain + ' / 跳过(登录)' + Rec.Skip);
process.exit(Rec.Diff ? 1 : 0);
+245
View File
@@ -0,0 +1,245 @@
// serve.js - 本地静态服务器:把镜像目录当作 3dtank.com 的站点根来跑
// 用法: node serve.js [--port 8080] [--web web] [--no-proxy] [--verbose]
// --verbose 打印每一个请求(含状态码/来源),排查页面加载问题时用
// 挂载:
// / → 3DTank/web/(index.html + play/static 资源)
// /play/static/js/ → TankIC/3dtank.com/play/static/js/(切割版 JS,单一来源,不重复拷贝)
// 代理(关键):游戏在非 3dtank.com 域名下会把配置指向本机,
// 见 src/module_13431.js:1704 分支 → resources=http://本机/resources、config-template=http://本机/config.xml
// 因此这里把这三个路径转发到真实站点,并强制带上 origin/referer(CDN 会校验),同时落盘缓存。
// 记录: 所有 404 会打印并追加到 3DTank/names/missing.txt,方便补齐缺失资源
const fs = require('fs');
const http = require('http');
const path = require('path');
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', '8080'));
const WEB_DIR = path.resolve(Arg('web', path.join(PROJECT, 'web')));
const JS_DIR = path.join(ROOT, '3dtank.com', 'play', 'static', 'js');
const MISSING_LOG = path.join(PROJECT, 'names', 'missing.txt');
const CACHE_DIR = path.join(PROJECT, 'cache');
const NO_PROXY = Argv.indexOf('--no-proxy') >= 0;
const VERBOSE = Argv.indexOf('--verbose') >= 0;
// /config.xml 本地覆盖:local/config.xml 存在时用它(把游戏指向本地 wss_server 9091),
// 加 --real-config 可强制走真实服务器
const LOCAL_CONFIG = path.join(PROJECT, 'local', 'config.xml');
const REAL_CONFIG = Argv.indexOf('--real-config') >= 0;
// 本机路径 → 真实站点(StripPrefix: 去掉本机前缀再拼到 Target 上;KeepQuery: 是否保留查询串)
const PROXY = [
{ Prefix: '/resources', Target: 'https://res.3dtank.com', StripPrefix: true, KeepQuery: true },
{ Prefix: '/config.xml', Target: 'https://c1.3dtank.com', StripPrefix: false, KeepQuery: false },
{ Prefix: '/s/', Target: 'https://3dtank.com', StripPrefix: false, KeepQuery: false },
];
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',
'accept-language': 'zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7,en-US;q=0.6',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/',
};
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
// config.xml 必须给 XML 类型:客户端用 responseType="document" 读取,
// 非 XML 类型时 responseXML 恒为 null → 抛 NullPointerException(module_61860 onload)
'.xml': 'application/xml; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.webm': 'video/webm',
'.mp4': 'video/mp4',
'.wasm': 'application/wasm',
'.txt': 'text/plain; charset=utf-8',
'.3ds': 'application/octet-stream',
'.bin': 'application/octet-stream',
};
function Resolve(UrlPath) {
const Clean = decodeURIComponent(UrlPath.split('?')[0]);
// 切割版 JS 走 TankIC 目录
if (Clean.startsWith('/play/static/js/')) {
return path.join(JS_DIR, Clean.slice('/play/static/js/'.length));
}
const Rel = Clean.replace(/^\/+/, '');
return Rel === '' ? path.join(WEB_DIR, 'index.html') : path.join(WEB_DIR, Rel);
}
if (!fs.existsSync(MISSING_LOG)) {
fs.mkdirSync(path.dirname(MISSING_LOG), { recursive: true });
fs.writeFileSync(MISSING_LOG, '', 'utf8');
} else {
// 每次启动清空,只保留本次会话的 404,避免旧记录干扰排查
fs.writeFileSync(MISSING_LOG, '', 'utf8');
}
function MatchProxy(UrlPath) {
if (NO_PROXY) return null;
const Q = UrlPath.indexOf('?');
const PathPart = Q >= 0 ? UrlPath.slice(0, Q) : UrlPath;
const Query = Q >= 0 ? UrlPath.slice(Q) : '';
for (const Rule of PROXY) {
const NoSlash = Rule.Prefix.replace(/\/$/, '');
if (PathPart !== NoSlash && !PathPart.startsWith(Rule.Prefix)) continue;
const Rest = Rule.StripPrefix ? PathPart.slice(NoSlash.length) : PathPart;
return { Url: Rule.Target + (Rest.startsWith('/') ? Rest : '/' + Rest) + (Rule.KeepQuery ? Query : '') };
}
return null;
}
const ProxyStats = { total: 0, cached: 0, failed: 0 };
function CachePath(Url) {
const U = new URL(Url);
const Rel = (U.hostname + U.pathname).replace(/[^\w.\-/]/g, '_');
return path.join(CACHE_DIR, Rel + (U.search ? '_' + Buffer.from(U.search).toString('hex').slice(0, 12) : ''));
}
async function HandleProxy(Rule, Req, Res) {
ProxyStats.total++;
if (ProxyStats.total % 50 === 0) {
console.log('[proxy] 累计 ' + ProxyStats.total + ' 次(命中缓存 ' + ProxyStats.cached + ',失败 ' + ProxyStats.failed + ')');
}
const CacheFile = Rule.SaveFile || CachePath(Rule.Url);
if (VERBOSE) console.log('[代理] ' + Rule.Url);
try {
if (fs.existsSync(CacheFile)) {
ProxyStats.cached++;
const Buf = fs.readFileSync(CacheFile);
Res.writeHead(200, {
'Content-Type': MIME[path.extname(new URL(Rule.Url).pathname).toLowerCase()] || 'application/octet-stream',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Proxy': 'cache',
});
Res.end(Buf);
return;
}
const Upstream = await fetch(Rule.Url, { headers: UPSTREAM_HEADERS, redirect: 'follow' });
const Buf = Buffer.from(await Upstream.arrayBuffer());
if (!Upstream.ok) {
ProxyStats.failed++;
console.log('[proxy] ' + Upstream.status + ' ' + Rule.Url);
Res.writeHead(Upstream.status, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Upstream ' + Upstream.status);
return;
}
fs.mkdirSync(path.dirname(CacheFile), { recursive: true });
fs.writeFileSync(CacheFile, Buf);
Res.writeHead(200, {
// 优先按扩展名给类型(config.xml 必须是 XML,否则 responseXML 为 null)
'Content-Type':
MIME[path.extname(new URL(Rule.Url).pathname).toLowerCase()] ||
Upstream.headers.get('content-type') ||
'application/octet-stream',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Proxy': 'upstream',
});
Res.end(Buf);
} catch (Err) {
ProxyStats.failed++;
console.log('[proxy] ERR ' + Rule.Url + ' : ' + Err.message);
Res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Proxy error: ' + Err.message);
}
}
const Server = http.createServer((Req, Res) => {
const UrlPath = Req.url.split('#')[0];
// 浏览器 → 本地落盘:控制台脚本把 dump 结果 POST 上来,写到 names/ 下
// 用法(控制台):fetch('/__dump__/regdump.json', {method:'POST', body: JSON.stringify(x)})
if (Req.method === 'POST' && UrlPath.startsWith('/__dump__/')) {
const Name = path.basename(decodeURIComponent(UrlPath.slice('/__dump__/'.length))).replace(/[^\w.\-]/g, '_') || 'dump.json';
const Out = path.join(PROJECT, 'names', Name);
const Chunks = [];
Req.on('data', (C) => Chunks.push(C));
Req.on('end', () => {
try {
const Buf = Buffer.concat(Chunks);
fs.mkdirSync(path.dirname(Out), { recursive: true });
fs.writeFileSync(Out, Buf);
console.log('[dump] 已写入 ' + Out + '(' + Buf.length + ' 字节)');
Res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }).end('saved ' + Name + ' ' + Buf.length);
} catch (Err) {
console.log('[dump] 写入失败: ' + Err.message);
Res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }).end('write failed: ' + Err.message);
}
});
return;
}
// 某些插件/缓存会来探 Vite HMR 客户端,给个空模块避免报错
if (UrlPath.startsWith('/@vite/')) {
Res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }).end('/* noop */\n');
return;
}
// /config.xml 本地覆盖:存在 local/config.xml 时用它,把游戏指向本地 wss_server
if (!REAL_CONFIG && UrlPath.split('?')[0] === '/config.xml' && fs.existsSync(LOCAL_CONFIG)) {
const Conf = fs.readFileSync(LOCAL_CONFIG);
Res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': Conf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Config': 'local',
}).end(Conf);
return;
}
const Rule = MatchProxy(UrlPath);
if (Rule) {
HandleProxy(Rule, Req, Res);
return;
}
const File = Resolve(UrlPath);
if (!File.startsWith(WEB_DIR) && !File.startsWith(JS_DIR)) {
Res.writeHead(403).end('Forbidden');
return;
}
fs.stat(File, (Err, Stat) => {
if (Err || !Stat.isFile()) {
// 自愈:本地没有的静态资源自动回源 3dtank.com 并落盘缓存(GET 才回源)
if (Req.method === 'GET' && !NO_PROXY) {
console.log('[回源] ' + UrlPath);
HandleProxy({ Url: 'https://3dtank.com' + UrlPath }, Req, Res);
return;
}
fs.appendFile(MISSING_LOG, UrlPath + '\n', () => {});
console.log('[404] ' + UrlPath);
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Not Found: ' + UrlPath);
return;
}
Res.writeHead(200, {
'Content-Type': MIME[path.extname(File).toLowerCase()] || 'application/octet-stream',
'Content-Length': Stat.size,
'Cache-Control': 'no-cache',
});
if (VERBOSE) console.log('[200] ' + Stat.size + '\t' + UrlPath);
fs.createReadStream(File).pipe(Res);
});
});
process.on('uncaughtException', (Err) => console.log('[server error] ' + Err.message));
Server.listen(PORT, () => {
console.log('本地客户端: http://localhost:' + PORT + '/');
console.log(' 站点根 : ' + WEB_DIR);
console.log(' 切割版 JS : ' + JS_DIR);
console.log(' 404 记录 : ' + MISSING_LOG);
console.log(' config.xml: ' + (!REAL_CONFIG && fs.existsSync(LOCAL_CONFIG) ? '本地覆盖 ' + LOCAL_CONFIG : '回源 c1.3dtank.com(--real-config)'));
});
+238
View File
@@ -0,0 +1,238 @@
// codec.js - 空间层负载的字段级编码器 MakeWriter(写包用,与 lib_space_codec 的 MakeReader 严格对称)
//
// 编码规则(出处同 lib_space_codec.js 头部注释,module_24635 的序列化器实现):
// bool/i8=1B i16=2B i32/f32=4B i64/f64=8B(大端)
// string/bytes = varint 长度 + 原始字节
// list<T> = varint 元素数 + 元素…
// map<K, V> = varint 条数 + 条数 × (K, V)(读侧同源,见 lib_space_codec.js 头部 map 那段)
// opt<T> = 1 字节标志(0 = 有值,非 0 = 空)+ 有值时紧跟 T
// —— 这是「字段表」那条路径的近似口径。真实线形是 nullmap 里的 1 个 bit(见下 OptBits)。
// opt<T>(OptBits 模式)= 不写任何标志字节:空值写 0 字节,有值只写内层 T。
// 为什么写侧不用管那个 bit:位在 nullmap 里,而 nullmap 由帧级 NullmapHex 原样带上
// (见 space.js:26-32、semantic.js:267-284);写侧只要保证「空值真的一个字节都不写」
// (src/module_24635.js:412-414 的 Ht.o7g:null 时只写位、不写值)。
// opt<T>(OptBits 模式 + AttachBits)= 「空/非空」那个 bit **写回**调用方给的 nullmap 比特流
// (就地覆盖 Bits[Index],见下 PutBit)。语义层重建 nullmap 时按这条路径走:
// 位图 = 形态位 + 各 opt 位,读写必须消费同一顺序,否则位就串了。
// enum<T> = 4 字节(大端无符号;读侧按同一宽度拼出,可能为负,这里用 asUintN 还原)
// 模型类 = 就地展开该模型 codec 的字段(无长度前缀)
// 取值形状:**与 MakeReader 的输出一一对应**(i64 = 十进制字符串、enum = 'enum#N' 或数字、
// bytes = Buffer/0x 十六进制串、opt 空 = null),这样「解出来再写回去」能逐字节还原。
// 缺字段:opt 缺 = 空;非 opt 缺 → 直接抛错(不做静默兜底)。
// 未实现:容器三编码(lt/pt/zt,见 module_45686)与模块内建 codec(Ea=变长数组、nh 等)→ 抛错暴露。
'use strict';
const MAX_DEPTH = 8;
const HEX = /^0x[0-9a-fA-F]*$/;
const { SplitTypeArgs } = require('../lib_space_codec');
function MakeWriter(Tables, Options) {
const Opts = Options || {};
const EnumBytes = Opts.EnumBytes === undefined ? 4 : Opts.EnumBytes;
const OptBits = !!Opts.OptBits;
const TypeAlias = Opts.TypeAlias || null;
// 与 MakeReader 的 StringLiteralOnly 严格同源:开了就把 string 一律当字面 UTF-8 写,
// 不再把 '0x…' 当二进制转义(否则字面量 "0xff8000" 会被写成 3 个裸字节)。
const StringLiteralOnly = !!Opts.StringLiteralOnly;
const CodecByKey = Tables.CodecByKey;
const CodecByModelName = Tables.CodecByModelName || new Map();
const WireClasses = Tables.WireClasses || new Map();
// nullmap 比特流的**写**游标(与 lib_space_codec.js:130-148 的读游标同源、同顺序)。
// 由调用方(semantic.js:EncodeFrameBody / container.js:Write)AttachBits 进来:
// AttachBits(Bits, Index) → Bits 是要就地覆盖的位数组(长度固定 = 原 nullmap 的位图位数)
// DetachBits() → 断开(opt 回到「不写位」的老路径)
let BitStream = null;
let BitIndex = 0;
function AttachBits(Bits, Index) {
BitStream = Bits;
BitIndex = Index || 0;
}
function DetachBits() {
BitStream = null;
BitIndex = 0;
}
function BitCursor() {
return { Bits: BitStream, Index: BitIndex };
}
// 写 1 个 opt 位。位不够 = nullmap 比负载短 → 直接抛错(不静默丢弃,否则组帧出来是错的)
function PutBit(V) {
if (!BitStream) throw new Error('内部错误:没有 attach nullmap 比特流却要写 opt 位');
if (BitIndex >= BitStream.length) throw new Error('nullmap 位图用完(已写 ' + BitIndex + ' 位,容量 ' + BitStream.length + ')');
BitStream[BitIndex++] = V ? 1 : 0;
}
function WriteVarint(N) {
const V = Number(N);
if (!Number.isFinite(V) || V < 0) throw new Error('varint 值非法: ' + N);
if (V < 0x80) return Buffer.from([V]);
if (V < 0x4000) return Buffer.from([0x80 | (V >> 8), V & 0xff]);
if (V < 0x400000) return Buffer.from([0xc0 | (V >> 16), (V >> 8) & 0xff, V & 0xff]);
throw new Error('varint 过大: ' + V);
}
function WriteString(S) {
// 读侧对「不是合法 UTF-8 的字符串」会给 0x 十六进制形式,这里原样还原成字节
// (StringLiteralOnly 模式下读侧不会产生这种形式,所以不认它)
if (!StringLiteralOnly && typeof S === 'string' && HEX.test(S)) {
const Raw = Buffer.from(S.slice(2), 'hex');
return Buffer.concat([WriteVarint(Raw.length), Raw]);
}
const B = Buffer.from(String(S), 'utf8');
return Buffer.concat([WriteVarint(B.length), B]);
}
function ToBytes(V) {
if (Buffer.isBuffer(V)) return V;
if (V === null || V === undefined) return Buffer.alloc(0);
if (typeof V === 'string' && HEX.test(V)) return Buffer.from(V.slice(2), 'hex');
if (Array.isArray(V)) return Buffer.from(V);
throw new Error('bytes 值无法识别: ' + typeof V);
}
function WriteValue(Type, TypeCodec, Value, Depth) {
if (Depth > MAX_DEPTH) throw new Error('嵌套超过 ' + MAX_DEPTH + ' 层');
if (Type === undefined || Type === null || Type === '' || Type === '?') throw new Error('字段类型缺失');
const T = String(Type);
const Opt = /^opt<([\s\S]+)>$/.exec(T);
if (Opt) {
if (OptBits) {
// 空值 = 负载里 0 字节(bit 在 nullmap 里);有值只写内层
const Empty = Value === null || Value === undefined;
// 有写游标 ⇒ 把「空/非空」写回 nullmap 比特流(src/module_24635.js:412-414 的 Ht.o7g:
// 写侧对空值只写 1 个 bit 的 !0、不写值)。注意「写位」与「写值」的顺序必须与读侧一致。
if (BitStream) PutBit(Empty);
if (Empty) return Buffer.alloc(0);
return WriteValue(Opt[1], TypeCodec, Value, Depth + 1);
}
if (Value === null || Value === undefined) return Buffer.from([1]);
return Buffer.concat([Buffer.from([0]), WriteValue(Opt[1], TypeCodec, Value, Depth + 1)]);
}
const List = /^list<([\s\S]+)>$/.exec(T);
if (List) {
if (!Array.isArray(Value)) throw new Error('list 值必须是数组: ' + typeof Value);
const Parts = [WriteVarint(Value.length)];
for (const X of Value) Parts.push(WriteValue(List[1], TypeCodec, X, Depth + 1));
return Buffer.concat(Parts);
}
const Enum = /^enum<([\s\S]+)>$/.exec(T);
if (Enum) {
if (EnumBytes === 0) return Buffer.alloc(0);
const N = typeof Value === 'string' ? Number(String(Value).replace(/^enum#/, '')) : Number(Value);
if (!Number.isFinite(N)) throw new Error('enum 值无法识别: ' + Value);
const U = BigInt.asUintN(EnumBytes * 8, BigInt(Math.trunc(N)));
const Out = Buffer.alloc(EnumBytes);
Out.writeUIntBE(Number(U), 0, EnumBytes);
return Out;
}
const Map = /^map<([\s\S]+)>$/.exec(T);
if (Map) {
// 与读侧严格对称:varint 条数 + 逐条 (key, value)(出处见 lib_space_codec.js 头部 map 那段)。
// 值形状必须是 [[key, value], …]——不做「对象转条目」的兜底,那会在键重复/非字符串键时静默丢字节。
const KV = SplitTypeArgs(Map[1]);
if (!Array.isArray(Value)) throw new Error('map 值必须是 [[key, value], …]:' + typeof Value);
const Parts = [WriteVarint(Value.length)];
for (const Pair of Value) {
if (!Array.isArray(Pair) || Pair.length !== 2) throw new Error('map 条目必须是 [key, value]:' + JSON.stringify(Pair));
Parts.push(WriteValue(KV[0], TypeCodec, Pair[0], Depth + 1));
Parts.push(WriteValue(KV[1], TypeCodec, Pair[1], Depth + 1));
}
return Buffer.concat(Parts);
}
switch (T) {
case 'bool': {
const Out = Buffer.alloc(1);
Out[0] = Value ? 1 : 0;
return Out;
}
case 'i8': {
const Out = Buffer.alloc(1);
Out.writeInt8(Number(Value) | 0, 0);
return Out;
}
case 'i16': {
const Out = Buffer.alloc(2);
Out.writeInt16BE(Number(Value) | 0, 0);
return Out;
}
case 'i32': {
const Out = Buffer.alloc(4);
Out.writeInt32BE(Number(Value) | 0, 0);
return Out;
}
case 'i64': {
const Out = Buffer.alloc(8);
Out.writeBigInt64BE(BigInt(Value), 0);
return Out;
}
case 'f32': {
const Out = Buffer.alloc(4);
Out.writeFloatBE(Number(Value), 0);
return Out;
}
case 'f64': {
const Out = Buffer.alloc(8);
Out.writeDoubleBE(Number(Value), 0);
return Out;
}
case 'string':
return WriteString(Value);
case 'bytes':
case 'array': {
const B = ToBytes(Value);
return Buffer.concat([WriteVarint(B.length), B]);
}
default:
break;
}
// 模型类:就地展开它自己的 codec 字段
if (TypeCodec && CodecByKey.has(TypeCodec)) return WriteFields(CodecByKey.get(TypeCodec), Value, Depth + 1);
if (CodecByModelName.has(T)) return WriteFields(CodecByModelName.get(T), Value, Depth + 1);
if (WireClasses.has(T)) return WriteValue(WireClasses.get(T), '', Value, Depth + 1);
// 容器层给的「裸类名 → 线上基础类型」直译表(与 MakeReader 的 TypeAlias 严格同源)
if (TypeAlias && Object.prototype.hasOwnProperty.call(TypeAlias, T)) return WriteValue(TypeAlias[T], '', Value, Depth + 1);
throw new Error('字段类型没有对应 codec: ' + T);
}
function WriteFields(Codec, Obj, Depth) {
const Parts = [];
for (const F of (Codec && Codec.Fields) || []) {
const Key = F.Name === '(匿名)' || F.Name === '?' ? '(匿名#' + F.Order + ')' : F.Name;
const Has = Obj !== null && Obj !== undefined && Object.prototype.hasOwnProperty.call(Obj, Key);
if (!Has) {
if (!/^opt</.test(String(F.Type))) {
throw new Error('缺少必填字段 ' + Key + '(codec=' + ((Codec && Codec.ModelName) || (Codec && Codec.Codec)) + ')');
}
Parts.push(WriteValue(F.Type, F.TypeCodec, null, Depth));
continue;
}
Parts.push(WriteValue(F.Type, F.TypeCodec, Obj[Key], Depth));
}
return Buffer.concat(Parts);
}
function WriteByKey(CodecKey, Obj) {
const C = CodecByKey.get(CodecKey);
if (!C) throw new Error('表里没有这个 codec: ' + CodecKey);
return WriteFields(C, Obj, 0);
}
return { WriteValue, WriteFields, WriteByKey, WriteVarint, WriteString, EnumBytes, OptBits, AttachBits, DetachBits, BitCursor };
}
// 按 methodId 取「描述这条命令负载」的字段表(服务端与自检共用同一套解析,避免两处口径不一):
// 优先 names/codec_fields.json 按 methodId 反查到的 codec(带类型),否则回落到
// names/uplink_table.json 里命令自带的字段列表(只有名字与类型串)。
// 实测两张表的 methodId 基本不重合(上行 360 条走 uplink_table,下行 codec 432 个走 codec_fields),
// 所以两个来源都得看。
function ResolveFields(Tables, MethodId, Cmd) {
const Codec = Tables.CodecByMethod.get(String(MethodId));
if (Codec) return { Codec, Fields: Codec.Fields || [] };
const Fields = ((Cmd && Cmd.Fields) || []).map((F) => ({ Name: F.Field, Type: F.Type, TypeCodec: F.TypeCodec || '' }));
return { Codec: null, Fields };
}
module.exports = { MakeWriter, ResolveFields, MAX_DEPTH };
+455
View File
@@ -0,0 +1,455 @@
// container.js - 容器命令(modelService 那几条)的读/写
//
// 出处:src/module_45686.js 的三个容器 codec + src/module_29554.js 的 4 条命令封装,
// 再逐条用 `packets/plain/space-<id>/*.bin` 的真实明文帧「解出来再写回去」逐字节标定。
//
// ---------------------------------------------------------------------------
// 一、哪个方法用哪个布局:**由 methodId 决定**,不再靠「哪个布局恰好吃到帧尾」
// 依据(三处互相印证):
// src/module_29554.js:79-83 4 条 modelService 方法的长整型常量(x1wc_1/y1wd_1/…)
// src/module_29554.js:239-244 taw() 里给每条方法挂的负载 codec:
// _loadDependencies(3216143066888387731) → new _(o(K), !1) K = ObjectsDependencies
// _loadObjectsData (7640916300855664666) → new _(o(W), !1) W = ObjectsData
// _unloadObjects (9038756763225879447) → new a(new _(o(h), !1), !1)
// src/module_45686.js:600 把三个 codec 注册给三个模型类:
// m(Q)=ModelData → lt,m(tt)=ObjectsData → pt,m(nt)=ObjectsDependencies → zt
// names/downlink_table.json 的 Containers 段与方法表一致(Independent 第二证据)。
// ★ 旧实现按「三布局都试、取恰好吃到帧尾的那个」判定,这在**多记录帧**上必然失败:
// 容器记录的负载只占帧体的一段,后面还有别的记录(semantic.js 传给我们的 End 是帧尾)。
// 实例:space-920007382011/s2c_0001.bin = nullmap(2B) + pt 记录(16+286) + 第 3 条记录(16+16),
// pt 负载 286B 而帧体 336B,「吃到帧尾」永远不成立 → 旧实现把整帧当 hex 留着。
//
// 二、三个布局的负载形状(源码行号逐个给出)
// lt(_unloadObjects)= [varint 个数][个数 × i64 对象 id]
// 依据:module_29554.js:244 的 `new a(new _(o(h), !1), !1)`:a = ListCodecInfo
// (module_24635.js:877-882 的 wn,`$_$.c`),元素类型 = 类 h 的 codec;
// h = r.$_$.r 而 r = i(61860)(module_29554.js 尾部依赖表)→ module_61860 的导出 r = xr
// (module_61860.js 导出表 `t.$_$.r = xr`),xr 的 codec 只写一次 i64
// (extract_codec_fields 的 WireClasses:`"xr":"i64"`,来自 module_61860.js 的 `m7f(G(xr), new cr())`)。
// 标定:space-100073070/s2c_0002.bin 帧体 126B = nullmap(1) + 5 × (16B 头 + 9B 负载),
// 每个负载 = `01` + 8B 对象 id(如 01 ffffffff80000744)→ 5×25+1 = 126 精确吃满。
// pt(_loadObjectsData)= [i32 C][C × (i64 objId, i64 gameTypeId)][i32 D][D × LtItem]
// 依据:module_45686.js:531-556 的 pt.p7g:g1wc_1 = ft 列表(module_45686.js:111-120,
// 两次 a7g_1.q2u)、f1wc_1 = lt.p7g 列表;两处计数都是 c7g_1.p2u(= i32,
// module_72834.js:278-280 p2u → t2t → getInt32)。
// LtItem(pt 里的一项,= ModelData 的 lt codec,注意与上面 lt 布局同名但不同物)
// = [i64 id];id == 0 → 再读一个 i64 当值;否则 id 是 modelId,
// 就地展开该模型的字段。依据 module_45686.js:487-494 的 lt.p7g。
// zt(_loadDependencies)= [i32 X][i32 C][C × {i64 ObjId, i32 N, N × i64 ModelId}]
// [i32 D][D × {i64 R, i16 E, i16 U, i64 S, i8 O, i8 Skip, Skip × i64}]
// 依据:module_45686.js:563-596 的 zt.p7g(X = c7g_1.p2u;kt = module_45686.js:190-205;
// $t = module_45686.js:213-228 依次 q2u/o2u/o2u/q2u/布尔位;跳过个数是 i.n2u = 1 字节
// (module_72834.js:272-274 n2u → xy → getInt8),不是 varint)。
//
// 三、opt 字段与记录形态位共用 nullmap 比特流(这是旧实现解不动的第二个根因)
// 位图 = 「一条记录 1 个形态位」+「负载里每个 opt 字段 1 位」,按同一顺序消费:
// src/module_61860.js:2116-2119 形态位(b7g_1.xy())
// src/module_24635.js:412-417 opt 位(Ht.p7g/o7g)
// src/module_24635.js:809-845 nullmap 灌进位读取器(r7f → j7h(c, n.b7g_1))
// src/module_61860.js:1022-1038 记录链循环(有字节就一直解下一条、逐条派发)
// 所以容器负载里的叶子要按 **bit** 读 opt,且必须先把「本条记录之前」的位消费掉
// (见 ResolveCursor 的注释)。位游标拿不到时,容器负载里的 opt 一律如实报不可解,
// 绝不退回「按字节猜标志位」——那会把负载整体解错位。
//
// 四、codec_fields 里推不出嵌套 codec 的裸类名(16 个 token)里,资源类按「资源引用 = i64」解
// 依据:module_71105.js 的资源类型注册块(`r.u83(typeId, si(类), 工厂)`,typeId 4/7/8/9/10/11/17/25/26)
// + module_71105.js `i.m7f(si(Gf), i.p7f(si(pv)))`(Gf 与 pv 共用同一个 codec);
// 资源实例是 `Zu(i16 typeId, …, i64 key, …)`(module_71105.js 的 Zu 构造器 = module_45686.js:226
// 的 L,读序 i64/i16/i16/i64/bool),查资源用 i64 key(module_45686.js:209 qt() → resourceRegistry.x83)。
// 实测标定:space-920007382011/s2c_0001.bin 的 MatchmakingLayoutCC 按 pv=i64 解出的字段完全自洽
// (两个 opt<pv> 取到同一个资源 key、holidayDescription/holidayTitle 是长度精确吻合的 UTF-8、
// matchmakingModeRanks 的枚举 0..10 递增),且负载恰好止于 286B、下一条记录头紧随其后。
// 没进这张表的裸类名(Pc / Ea / nh …)**一律不猜**:解到就如实报「无 codec」。
'use strict';
const Link = require('./link');
const { MakeReader } = require('../lib_space_codec');
const { MakeWriter } = require('./codec');
const LAYOUT = { Lt: 'lt', Pt: 'pt', Zt: 'zt' };
// methodId(十进制字符串,与 names/downlink_table.json 的 Containers 完全一致)→ 负载布局
const LAYOUT_BY_METHOD = {
'3216143066888387731': LAYOUT.Zt, // ModelService.LoadDependencies
'7640916300855664666': LAYOUT.Pt, // ModelService.LoadObjectsData
'9038756763225879447': LAYOUT.Lt, // ModelService.UnloadObjects
};
// 资源类(module_71105.js 的资源类型注册块 + Gf 复用 pv 的 codec)→ 线上一律是 i64 引用。
// 只列有源码出处的那几个;表外的一律不猜。
const RESOURCE_CLASS_WIRE = {
Ea: 'i64',
nh: 'i64',
W_: 'i64',
uo: 'i64',
pv: 'i64',
B_: 'i64',
Da: 'i64',
qs: 'i64',
uh: 'i64',
Gf: 'i64',
kv: 'i64',
// Pc:**源码侧没定位到 codec**(codec_fields 里 Pc 没有 codec;module_47243.js:180 的
// `ji = u.$_$.l2` 指向 module_52943 的 Pc,而那是 `function Pc() {}` 空壳,没有 q7g/p7g)。
// 这里是**实测标定**:space-100045260/s2c_0001.bin 的 LoaderBackgroundModelCC
// 三个字段按 (Gf=i64, Pc=i64, Pc=i64) 解出的值分别是
// 1788178032155 / 1790283600000 / 1788469200000
// —— 全是 13 位毫秒时间戳,且 startDate < endDate、区间与帧采集日期自洽;同时 pt 负载
// 精确止于 448B(第 5..11 项、共 11 项全部落在合法边界上)。按 4 字节解会在下一项 id 处错位。
// 结论:Pc 是毫秒时间戳引用 → i64。这条不是源码证据,单独标出来。
Pc: 'i64',
};
// nullmap 的位图(与 semantic.js:46-58 同一口径,依据 src/module_24635.js:640-695 的 j7h):
// 长形态(首字节 bit7=1):位图 = 标签之后的字节按 MSB 先读
// 短形态:整串按 MSB 先读、跳过前 3 个 bit(那 3 位是长度标签)
function NullmapBits(Bytes) {
const Bits = [];
const Push = (B) => {
for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1);
};
if (Bytes.length && Bytes[0] & 0x80) {
for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]);
} else {
for (const B of Bytes) Push(B);
Bits.splice(0, 3);
}
return Bits;
}
function MakeContainerCodec(Tables, Reader, Writer, Options) {
const Opts = Options || {};
const MaxCount = Opts.MaxCount || 1 << 20; // 计数上限,超出即认定布局不成立
// 容器负载专用的读写器:opt 按 nullmap 的 bit 读/写(见文件头第三节),
// 并带上「裸类名 → 线上基础类型」直译表。语义层原来给的那两个实例保持原样不动
// (它们服务的是字段表那条路径,口径本来就不同)。
// 直译表 = codec_fields.json 的 ResourceClasses(由源码 u83 注册块导出,见 extract_codec_fields.js)
// + 上面手写的 RESOURCE_CLASS_WIRE(手写表优先:里面有 Pc 这种「实测标定」的条目)。
// 为什么要合并:原来只认手写的 10 个资源类,漏了 typeId 13 的 no(与 nh/B_/pv/kv/Da/W_/uo/Ea/qs/uh 同块注册),
// 于是 space-100072911/s2c_0001 的 DonationAlertCC.imageWithEmail 报「类型 no 没有对应 codec」。
const Alias = {};
for (const [N, R] of Object.entries(Tables.ResourceClasses || {})) Alias[N] = R.Wire;
for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) Alias[N] = W;
const LeafReader = MakeReader(Tables, {
EnumBytes: Reader.EnumBytes,
OptBits: true,
TypeAlias: Alias,
StringLiteralOnly: true,
});
const LeafWriter = MakeWriter(Tables, {
EnumBytes: Writer.EnumBytes,
OptBits: true,
TypeAlias: Alias,
StringLiteralOnly: true,
});
const CodecByMethod = Tables.CodecByMethod || new Map();
// ---------- nullmap 位游标 ----------
function MethodIdAt(Buf, Off) {
return Off >= 8 && Off + 8 <= Buf.length ? Buf.readBigInt64BE(Off - 8).toString() : '';
}
// 本条记录负载的起始位游标。**必须把前面的记录走一遍**,因为位图是共用的:
// 位游标(负载) = Σ前面每条记录的形态位 + Σ前面每条记录负载里的 opt 位
// 走不动(短命令形态 / 前面某条方法的字段表不在 codec_fields / 走完对不齐 Off)→ 返回 null,
// 上层就按「拿不到位」如实报不可解(不猜)。
function ResolveCursor(Buf, Off) {
const NmSize = Link.NullmapSize(Buf, 0);
if (NmSize > Buf.length || NmSize > Off) return null;
const Bits = NullmapBits(Buf.slice(0, NmSize));
let At = NmSize;
let Bit = 0;
for (;;) {
// 走到目标记录的负载 → 位游标就是「已消费的形态位 + 前面负载里的 opt 位」
if (At === Off) return { Bits, Index: Bit };
if (At > Off || Bit >= Bits.length) return null;
if (Bits[Bit++]) return null; // 短命令形态:命令号 → (objectId, methodId) 的查表数据还没挖
if (At + 16 > Buf.length) return null;
const MethodId = Buf.readBigInt64BE(At + 8).toString();
At += 16;
if (At === Off) return { Bits, Index: Bit }; // 目标负载紧跟它自己的 16 字节记录头
const Layout = LAYOUT_BY_METHOD[MethodId];
let R;
const Ctx = { Bits, Index: Bit };
if (Layout) {
R = Readers[Layout](Buf, At, Ctx);
} else {
const C = CodecByMethod.get(MethodId);
if (!C) return null; // 前面那条方法的字段表不在 codec_fields:数不出它耗掉几个位
R = ReadFieldsBits(Ctx, Buf, At, C, 0);
}
Bit = Ctx.Index;
if (R.Bad || !(R.Next > At)) return null;
At = R.Next;
}
}
// 把当前位游标临时接到 LeafReader 上跑一次字段读;读完把新游标写回 Ctx
function ReadFieldsBits(Ctx, Buf, Off, Codec, Depth) {
if (!Ctx.Bits) return { Value: null, Next: Off, Bad: true, Bit: Ctx.Index, Why: '没有 nullmap 比特流,opt 字段无法判定(不按字节猜)' };
LeafReader.AttachBits(Ctx.Bits, Ctx.Index);
let R;
try {
R = LeafReader.ReadFields(Buf, Off, Codec, Depth);
} finally {
Ctx.Index = LeafReader.BitCursor().Index;
LeafReader.DetachBits();
}
return { Value: R.Value, Next: R.Next, Bad: R.Bad, Bit: Ctx.Index, Why: R.Why };
}
// ---------- 读 ----------
// pt 里的一项 = ModelData 的 lt codec(module_45686.js:487-494)
function ReadLtItem(Buf, At, Ctx) {
const Id = Buf.readBigInt64BE(At).toString();
let Next = At + 8;
if (Id === '0') {
if (Next + 8 > Buf.length) return { Value: null, Next: At, Bad: true, Why: 'lt 项 id=0 但没有紧跟的 i64 值' };
return { Value: { Id, Value: Buf.readBigInt64BE(Next).toString() }, Next: Next + 8, Bit: Ctx.Index };
}
const Codec = Tables.CodecByModelId.get(Id);
if (!Codec) {
return { Value: null, Next: At, Bad: true, Why: 'modelId=' + Id + ' 在 codec_fields 里查不到 codec(叶子 codec 缺失)' };
}
const R = ReadFieldsBits(Ctx, Buf, Next, Codec, 1);
if (R.Bad) {
return { Value: null, Next: At, Bad: true, Why: 'modelId=' + Id + '(' + Codec.Module + '::' + Codec.Codec + ')的字段解不动:' + (R.Why || '未知类型/越界') };
}
return { Value: { Id, Value: R.Value }, Next: R.Next, Bit: Ctx.Index };
}
// lt:_unloadObjects 的负载 = list<i64 对象 id>
function ReadLt(Buf, Off, Ctx) {
const N = LeafReader.ReadVarint(Buf, Off);
if (!N || N.Bad) return { Value: null, Next: Off, Bad: true, Why: '流变量计数读不动' };
let At = N.Next;
if (N.Value > MaxCount) return { Value: null, Next: Off, Bad: true, Why: '个数 ' + N.Value + ' 超上限' };
if (At + 8 * N.Value > Buf.length) return { Value: null, Next: Off, Bad: true, Why: '个数 ' + N.Value + ' 但只剩 ' + (Buf.length - At) + ' 字节' };
const Ids = [];
for (let I = 0; I < N.Value; I++) {
Ids.push(Buf.readBigInt64BE(At).toString());
At += 8;
}
return { Value: { Ids }, Next: At, Bit: Ctx.Index };
}
// pt:对象引用表 + ModelData 表
function ReadPt(Buf, Off, Ctx) {
if (Off + 4 > Buf.length) return { Value: null, Next: Off, Bad: true, Why: '不足 4 字节读对象表个数' };
const C = Buf.readInt32BE(Off);
let At = Off + 4;
if (C < 0 || C > MaxCount) return { Value: null, Next: Off, Bad: true, Why: '对象表个数 C=' + C + ' 不合理' };
if (At + 16 * C + 4 > Buf.length) return { Value: null, Next: Off, Bad: true, Why: '对象表 C=' + C + ' 超出负载长度' };
const Objects = [];
for (let I = 0; I < C; I++) {
Objects.push({ ObjectId: Buf.readBigInt64BE(At).toString(), GameTypeId: Buf.readBigInt64BE(At + 8).toString() });
At += 16;
}
const D = Buf.readInt32BE(At);
At += 4;
if (D < 0 || D > MaxCount) return { Value: null, Next: Off, Bad: true, Why: 'ModelData 表个数 D=' + D + ' 不合理' };
const Items = [];
for (let I = 0; I < D; I++) {
if (At + 8 > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + '/' + D + ' 项读 id 越界' };
const R = ReadLtItem(Buf, At, Ctx);
if (R.Bad) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + '/' + D + ' 项:' + R.Why };
Items.push(R.Value);
At = R.Next;
}
return { Value: { Objects, Items }, Next: At, Bit: Ctx.Index };
}
// zt:模型归属表 + 资源引用项表
function ReadZt(Buf, Off, Ctx) {
if (Off + 8 > Buf.length) return { Value: null, Next: Off, Bad: true, Why: '不足 8 字节读 X/批次个数' };
const X = Buf.readInt32BE(Off);
const C = Buf.readInt32BE(Off + 4);
let At = Off + 8;
if (C < 0 || C > MaxCount) return { Value: null, Next: Off, Bad: true, Why: '批次个数 C=' + C + ' 不合理' };
const Batches = [];
for (let I = 0; I < C; I++) {
if (At + 12 > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + '/' + C + ' 批读头部越界' };
const ObjectId = Buf.readBigInt64BE(At).toString();
const N = Buf.readInt32BE(At + 8);
At += 12;
if (N < 0 || N > MaxCount) return { Value: null, Next: At - 4, Bad: true, Why: '第 ' + (I + 1) + ' 批 modelId 个数 N=' + N + ' 不合理' };
if (At + 8 * N > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + ' 批 N=' + N + ' 超出负载长度' };
const ModelIds = [];
for (let K = 0; K < N; K++) {
ModelIds.push(Buf.readBigInt64BE(At).toString());
At += 8;
}
Batches.push({ ObjectId, ModelIds });
}
if (At + 4 > Buf.length) return { Value: null, Next: At, Bad: true, Why: '批次之后不足 4 字节读项个数' };
const D = Buf.readInt32BE(At);
At += 4;
if (D < 0 || D > MaxCount) return { Value: null, Next: At - 4, Bad: true, Why: '项个数 D=' + D + ' 不合理' };
const Items = [];
for (let I = 0; I < D; I++) {
if (At + 22 > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + '/' + D + ' 项越界' };
const R = Buf.readBigInt64BE(At).toString();
const E = Buf.readInt16BE(At + 8);
const U = Buf.readInt16BE(At + 10);
const S = Buf.readBigInt64BE(At + 12).toString();
const O = Buf[At + 20];
const Skip = Buf.readInt8(At + 21); // n2u = 1 字节(module_72834.js:272-274)
At += 22;
if (Skip < 0 || Skip > MaxCount) return { Value: null, Next: At - 1, Bad: true, Why: '第 ' + (I + 1) + ' 项跳过个数 ' + Skip + ' 不合理' };
if (At + 8 * Skip > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + ' 项跳过 ' + Skip + ' 个 id 越界' };
const Skipped = [];
for (let K = 0; K < Skip; K++) {
Skipped.push(Buf.readBigInt64BE(At).toString());
At += 8;
}
Items.push({ R, E, U, S, O, Skipped });
}
return { Value: { X, Batches, Items }, Next: At, Bit: Ctx.Index };
}
const Readers = { lt: ReadLt, pt: ReadPt, zt: ReadZt };
// 第 4 个参数是**可选的帧级位游标** Ctx = { Bits, Index }(由 semantic.DecodeFrameBody 顺序推进后传进来):
// 传了 ⇒ 直接用它,不再自己 ResolveCursor。为什么必须这样:ResolveCursor 要把「本条记录之前」的
// 每条记录重走一遍,多记录帧上是 O(n²)(space-100073070/s2c_0001 的容器负载 766KB)。
// 没传 ⇒ 保持既有行为(按 methodId 判定要不要算游标)。selftest.js:40/51、probe_containers.js:91/95、
// extract_space_init.js:50 走的都是这条。
// (原来这里是没用的 Depth 形参:三个布局的读者都按 (Buf, Off, Ctx) 调用,Depth 从来没被读过。)
function Read(Layout, Buf, Off, Ctx) {
if (!Readers[Layout]) throw new Error('未知容器布局: ' + Layout);
// 只有「确认是 modelService 容器记录」才去算位游标:其它调用(自检把可疑帧拿来试布局)
// 不该触发走路,也不该让 opt 走位路径。
const Known = !!LAYOUT_BY_METHOD[MethodIdAt(Buf, Off)];
const Use = Ctx && Ctx.Bits ? Ctx : (Known ? ResolveCursor(Buf, Off) : null) || { Bits: null, Index: 0 };
const R = Readers[Layout](Buf, Off, Use);
if (Ctx) Ctx.Index = Use.Index; // 把本条负载消费掉的 opt 位数交回给调用方(缺口 A)
if (!R.Bad) return { Value: R.Value, Next: R.Next };
return { Value: null, Next: R.Next, Bad: true, Why: R.Why || '' };
}
// 逐个试布局,取「恰好吃到 End」的那个;都不精确则返回 null(不猜)。
// 已知的 modelService 方法直接按 methodId 定布局(多记录帧必须走这条)。
function Detect(Buf, Off, End) {
const MethodId = MethodIdAt(Buf, Off);
const Known = LAYOUT_BY_METHOD[MethodId];
if (Known) {
const R = Read(Known, Buf, Off);
return R.Bad ? null : Known;
}
const Hits = [];
for (const Layout of [LAYOUT.Zt, LAYOUT.Pt, LAYOUT.Lt]) {
const R = Read(Layout, Buf, Off);
if (!R.Bad && R.Next === End) Hits.push(Layout);
}
return Hits.length ? Hits[0] : null;
}
// ---------- 写(由读侧反推,逐字节验证过) ----------
function WriteVarint(N) {
return Writer.WriteVarint(N);
}
// pt 里的一项
function WriteLtItem(Item) {
if (!Item || Item.Id === undefined || Item.Id === null) throw new Error('lt 项缺少 Id');
const Id = Buffer.alloc(8);
Id.writeBigInt64BE(BigInt(Item.Id), 0);
if (String(Item.Id) === '0') {
const V = Buffer.alloc(8);
V.writeBigInt64BE(BigInt(Item.Value), 0);
return Buffer.concat([Id, V]);
}
const Codec = Tables.CodecByModelId.get(String(Item.Id));
if (!Codec) throw new Error('modelId=' + Item.Id + ' 在 codec_fields 里查不到 codec,无法展开');
return Buffer.concat([Id, LeafWriter.WriteFields(Codec, Item.Value, 0)]);
}
function WriteLt(Obj) {
if (!Obj || !Array.isArray(Obj.Ids)) throw new Error('lt 结构缺少 Ids');
const Parts = [WriteVarint(Obj.Ids.length)];
for (const Id of Obj.Ids) {
const B = Buffer.alloc(8);
B.writeBigInt64BE(BigInt(Id), 0);
Parts.push(B);
}
return Buffer.concat(Parts);
}
function WritePt(Obj) {
if (!Obj || !Array.isArray(Obj.Objects) || !Array.isArray(Obj.Items)) throw new Error('pt 结构缺少 Objects / Items');
const Head = Buffer.alloc(4);
Head.writeInt32BE(Obj.Objects.length, 0);
const Pairs = Obj.Objects.map((X) => {
const B = Buffer.alloc(16);
B.writeBigInt64BE(BigInt(X.ObjectId), 0);
B.writeBigInt64BE(BigInt(X.GameTypeId), 8);
return B;
});
const Count = Buffer.alloc(4);
Count.writeInt32BE(Obj.Items.length, 0);
return Buffer.concat([Head, ...Pairs, Count, ...Obj.Items.map(WriteLtItem)]);
}
function WriteZt(Obj) {
if (!Obj || !Array.isArray(Obj.Batches) || !Array.isArray(Obj.Items)) throw new Error('zt 结构缺少 Batches / Items');
const Head = Buffer.alloc(8);
Head.writeInt32BE(Obj.X, 0);
Head.writeInt32BE(Obj.Batches.length, 4);
const Batches = Obj.Batches.map((B) => {
const Id = Buffer.alloc(8);
Id.writeBigInt64BE(BigInt(B.ObjectId), 0);
const N = Buffer.alloc(4);
N.writeInt32BE(B.ModelIds.length, 0);
const Ids = B.ModelIds.map((M) => {
const Buf = Buffer.alloc(8);
Buf.writeBigInt64BE(BigInt(M), 0);
return Buf;
});
return Buffer.concat([Id, N, ...Ids]);
});
const Count = Buffer.alloc(4);
Count.writeInt32BE(Obj.Items.length, 0);
const Items = Obj.Items.map((X) => {
const Buf = Buffer.alloc(22);
Buf.writeBigInt64BE(BigInt(X.R), 0);
Buf.writeInt16BE(X.E, 8);
Buf.writeInt16BE(X.U, 10);
Buf.writeBigInt64BE(BigInt(X.S), 12);
Buf[20] = X.O;
Buf.writeInt8((X.Skipped || []).length, 21);
const Skipped = (X.Skipped || []).map((S) => {
const B = Buffer.alloc(8);
B.writeBigInt64BE(BigInt(S), 0);
return B;
});
return Buffer.concat([Buf, ...Skipped]);
});
return Buffer.concat([Head, ...Batches, Count, ...Items]);
}
const Writers = { lt: WriteLt, pt: WritePt, zt: WriteZt };
// 第 3 个参数是**可选的帧级位游标** Ctx = { Bits, Index }:语义层重建 nullmap 时传进来,
// 叶子里每个 opt 字段的「空/非空」位就按编码顺序写回该比特流(codec.js:PutBit)。
// 不传 ⇒ 保持既有行为:只保证「空值一个字节都不写」,位由帧级 NullmapHex 原样带上
// (selftest.js:53、probe_containers 走的都是这条,口径一字不变)。
function Write(Layout, Obj, Ctx) {
const F = Writers[Layout];
if (!F) throw new Error('未知容器布局: ' + Layout);
if (!(Ctx && Ctx.Bits)) return F(Obj);
LeafWriter.AttachBits(Ctx.Bits, Ctx.Index);
try {
return F(Obj);
} finally {
Ctx.Index = LeafWriter.BitCursor().Index;
LeafWriter.DetachBits();
}
}
return { Read, Write, Detect, LAYOUT, LAYOUT_BY_METHOD };
}
module.exports = { MakeContainerCodec, LAYOUT, LAYOUT_BY_METHOD };
+125
View File
@@ -0,0 +1,125 @@
// db.js - MySQL 连接层(mysql2 连接池)+ schema 装载
//
// 配置来源优先级(高 → 低):
// 1. 调用方显式传入的配置对象
// 2. 环境变量 TANK_DB_HOST / TANK_DB_USER / TANK_DB_PASSWORD / TANK_DB_PORT / TANK_DB_NAME
// 3. local/db.json(未跟踪目录,凭据不进 git)
// 4. 内置默认(127.0.0.1:3306 / root / 库名 tanki)
//
// 关键取数口径:协议里的 id 是 i64,且**确实存在负数**(-2147481788 一族合成对象 id),
// 所以这里必须 supportBigNumbers + bigNumberStrings —— 大整数一律以**十进制字符串**返回,
// 与 tools/ 里既有代码「id 全部当字符串比较」的口径一致(改用 Number 会在大 id 上静默丢精度)。
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..', '..');
const CONFIG_PATH = path.join(PROJECT, 'local', 'db.json');
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
const DEFAULTS = {
Host: '127.0.0.1',
User: 'root',
Password: '',
Port: 3306,
Database: 'tanki',
};
function ReadConfigFile(File) {
const P = File || CONFIG_PATH;
if (!fs.existsSync(P)) return {};
try {
return JSON.parse(fs.readFileSync(P, 'utf8'));
} catch (Err) {
throw new Error('读不到数据库配置 ' + P + ':' + Err.message);
}
}
// 合出最终配置;缺项一律落到 DEFAULTS,不做隐式猜测
function LoadConfig(Explicit) {
const FromFile = ReadConfigFile(Explicit && Explicit.ConfigFile);
const Env = {
Host: process.env.TANK_DB_HOST,
User: process.env.TANK_DB_USER,
Password: process.env.TANK_DB_PASSWORD,
Port: process.env.TANK_DB_PORT ? Number(process.env.TANK_DB_PORT) : undefined,
Database: process.env.TANK_DB_NAME,
};
const Pick = (Key) => {
const Sources = [Explicit && Explicit[Key], Env[Key], FromFile[Key], DEFAULTS[Key]];
for (const V of Sources) if (V !== undefined && V !== null && V !== '') return V;
return DEFAULTS[Key];
};
return {
Host: String(Pick('Host')),
User: String(Pick('User')),
Password: String(Pick('Password')),
Port: Number(Pick('Port')) || DEFAULTS.Port,
Database: String(Pick('Database')),
};
}
// 建连接池。mysql2 是可选依赖:没装时给出可操作的报错,而不是一句 module not found
function Connect(Config) {
let mysql;
try {
mysql = require('mysql2/promise');
} catch (Err) {
throw new Error('没装 mysql2(在 tools/ 下执行 npm install mysql2)');
}
const C = Config || LoadConfig();
return mysql.createPool({
host: C.Host,
user: C.User,
password: C.Password,
port: C.Port,
database: C.Database,
waitForConnections: true,
connectionLimit: 8,
charset: 'utf8mb4',
supportBigNumbers: true,
bigNumberStrings: true,
multipleStatements: true, // EnsureSchema 要整份 schema.sql 一次执行
});
}
// 把 schema.sql 跑一遍(幂等:全部 CREATE TABLE IF NOT EXISTS)
async function EnsureSchema(Pool, SchemaFile) {
const Sql = fs.readFileSync(SchemaFile || SCHEMA_PATH, 'utf8');
await Pool.query(Sql);
const [Rows] = await Pool.query(
'SELECT TABLE_NAME AS Name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME',
);
return Rows.map((R) => R.Name);
}
// 取一行(没有则 null)
async function One(Pool, Sql, Params) {
const [Rows] = await Pool.query(Sql, Params || []);
return Rows.length ? Rows[0] : null;
}
// 取多行
async function Many(Pool, Sql, Params) {
const [Rows] = await Pool.query(Sql, Params || []);
return Rows;
}
// 批量插入(Values 是数组的数组),自动按列切片拼多条 VALUES
async function InsertMany(Pool, Table, Columns, Values, ChunkSize) {
if (!Values.length) return 0;
const Size = ChunkSize || 500;
const Cols = Columns.map((C) => '`' + C + '`').join(', ');
let Done = 0;
for (let I = 0; I < Values.length; I += Size) {
const Chunk = Values.slice(I, I + Size);
const Placeholders = Chunk.map(() => '(' + Columns.map(() => '?').join(', ') + ')').join(', ');
const Flat = [];
for (const Row of Chunk) for (const V of Row) Flat.push(V);
await Pool.query('INSERT INTO `' + Table + '` (' + Cols + ') VALUES ' + Placeholders, Flat);
Done += Chunk.length;
}
return Done;
}
module.exports = { LoadConfig, Connect, EnsureSchema, One, Many, InsertMany, CONFIG_PATH, SCHEMA_PATH, DEFAULTS };
+138
View File
@@ -0,0 +1,138 @@
// dispatch.js - 空间层:识别空间帧 → 逐条记录解码 → 落日志 → 把「未实现」记进世界状态
//
// 帧体 = [nullmap][第一个 int64][(methodId int64)(负载)…]
// 第一个 int64 的语义按方向不同(出处:module_61860 的 SpaceCommand codec,fr.o7g 依次写 f2u(第一个)、f2u(methodId)):
// c2s → objectId(目标对象:实测有空间自身 id,也有实体 id)
// s2c → spaceId
// 判据(两道门,与 relay 模式一致,实测 171/171 命中):
// ① 首字节必须是第一个 id 的最高位:0x00 = 小正数,0xff = 负数(握手/日志帧首字节是命令 ID 1/3/32)
// ② 8 字节之后必须命中 names/uplink_table.json 的 methodId
// 负载长度不靠猜:按名字/codec 表逐字段解码推进;解不动就停在原地并把剩余字节原样打出(fail-fast)。
//
// Handle 的第 5 个参数 OnRecord 是可选钩子(index.js 用它接会话重放):返回真值 = 这条上行已被处理,
// 不再记进未实现清单。
'use strict';
const { FormatValue } = require('../lib_space_codec');
const { ResolveFields } = require('./codec');
const ModelService = require('./modelservice');
function MakeDispatcher(Tables, Reader, World, Options) {
const Opts = Options || {};
const Classes = (Opts.Alias && Opts.Alias.Classes) || {};
const Methods = (Opts.Alias && Opts.Alias.Methods) || {};
const Fields = (Opts.Alias && Opts.Alias.Fields) || {};
// 别名词典(names/command_alias.json)是**派生命名**,不是客户端原始名;键 = "<moduleId>::<短名>"
const Mod2 = (M) => String(M || '').replace(/^module_/, '');
const AliasOfClass = (M, N) => {
const E = Classes[M + '::' + N];
return E ? E.Alias : null;
};
const AliasOfMethod = (M, O, F) => {
const E = Methods[M + '::' + O + '.' + F];
return E ? E.Alias : null;
};
const AliasOfField = (M, O, F) => {
const E = Fields[M + '::' + O + '.' + F];
return E ? E.Alias : null;
};
// methodId(8 字节大端 hex)→ 上行命令
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);
}
// 判定「空间层帧」的唯一可靠依据 = 偏移 At+8 处的 8 字节 methodId 在上行表里。
// 不要再加「目标 id 首字节必须是 0x00 / 0xff」这类启发式:目标 id 不一定是空间号,
// 也可能是用户对象 id(实测 3074457345817168740 = 0x2AAAAAAAAAB685CB64,首字节 0x2A)。
// 加了那条会把合法空间命令整条误判成入口命令整包丢弃(server-logs/hashlogin-01/session_010:17,19,46
// 的 "未知入口命令 ID=42" 就是它:ID=42 其实是 0x2A,目标 id 的第一个字节)。
// 反方向的误判风险可忽略:上行表 360 条,8 字节随机值撞上任一条的概率约 360/2^64。
function IsSpaceFrame(Body, At) {
if (At + 16 > Body.length) return false;
return UplinkByHex.has(Body.slice(At + 8, At + 16).toString('hex'));
}
// At = 第一个 id 的起始偏移(已跳过 nullmap)
function Describe(Body, At) {
const TargetId = Body.readBigInt64BE(At).toString();
const Records = [];
let Off = At + 8;
while (Off + 8 <= Body.length) {
const Cmd = UplinkByHex.get(Body.slice(Off, Off + 8).toString('hex'));
if (!Cmd) break;
const MethodId = Body.readBigInt64BE(Off).toString();
Off += 8;
const PayloadStart = Off;
const { Codec, Fields } = ResolveFields(Tables, MethodId, Cmd);
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,
CodecName: Codec ? Codec.ModelName || Codec.ModelClassReal || '' : '',
Payload: Body.slice(PayloadStart, Off),
Values,
Bad,
});
}
return { TargetId, Records, Next: Off, Tail: Body.slice(Off) };
}
// 落盘 + 记未实现;返回 Describe 的结果供调用方继续用
// OnRecord(可选)= 调用方插进来的「这条上行我自己处理」钩子,返回真值表示**不算未实现**。
// 为什么钩子要放在这里而不是调用方:NoteUnimplemented 就在本函数里记,
// 调用方(index.js)拿到 Info 时"未实现"已经记完了。会话重放(session.js)命中并发帧后
// 必须能拦住这一笔记账,否则断开时统计里还是"这些命令没人管"。
function Handle(State, Body, At, SpaceKind, OnRecord) {
const Info = Describe(Body, At);
if (!Info.Records.length) {
State.Log(' 空间帧,但 methodId 没解出记录:' + Body.slice(At).toString('hex').slice(0, 120));
return Info;
}
State.Log(' 目标 id=' + Info.TargetId + ' 命令 ' + Info.Records.length + ' 条');
for (const R of Info.Records) {
const Ms = ModelService.Name(R.MethodId);
const Ma = Mod2(R.Module);
const Parts = String(R.Name).split('.');
const Owner = Parts[0];
const Fn = Parts[1];
const ClassAlias = AliasOfClass(Ma, Owner);
const MethodAlias = AliasOfMethod(Ma, Owner, Fn);
const AliasTag =
ClassAlias || MethodAlias ? ' 【' + (ClassAlias || Owner) + '.' + (MethodAlias || Fn) + '】' : '';
State.Log(
' ' + R.Name + ' (' + R.Module + ')' + (R.CodecName ? ' [' + R.CodecName + ']' : '') + AliasTag +
(Ms ? ' ← modelService: ' + Ms : '') + ' methodId=' + R.MethodId + (R.Bad ? ' ⚠ 负载解码中断' : ''),
);
if (Ms && Ms.indexOf('G.q1wd') === 0) State.Log(' → 请求内容: ' + FormatValue(ModelService.DescribeRequest(R.Payload || Buffer.alloc(0))));
for (const [K, T, V] of R.Values) {
const Fa = AliasOfField(Ma, Owner, K);
State.Log(' ' + String(Fa ? Fa + ' (' + K + ')' : K).padEnd(28) + String(T).padEnd(14) + ' = ' + FormatValue(V));
}
if (!(typeof OnRecord === 'function' && OnRecord(R, Info.TargetId))) {
World.NoteUnimplemented(SpaceKind, R.Name, R.MethodId, Info.TargetId);
}
}
if (Info.Tail.length) {
State.Log(' ⚠ 尾部剩 ' + Info.Tail.length + 'B 没解出:' + Info.Tail.toString('hex').slice(0, 96));
}
return Info;
}
return { IsSpaceFrame, Describe, Handle, UplinkByHex };
}
module.exports = { MakeDispatcher };
+112
View File
@@ -0,0 +1,112 @@
// entry.js - 入口层:握手 / 进空间 / 客户端日志
//
// c2s 1 Hash Request 体 = [varint N][N × 字符串 键][varint M][M × 字符串 值](两个各自计数的表)
// c2s 3 Auth/EnterSpace 体 = [32 字节 digest][int64 spaceId](digest = 回显服务器下发的 hash)
// c2s 32 ClientLog 体 = [1 字节类型][字符串 名称][字符串 正文](正文常是 UI 事件 JSON,按钮事件来源)
// s2c 2 Hash Response 体 = [1 字节 2][32 字节 hash][1 字节 是否启用链路加密]
// s2c 32 Open Space 体 = [1 字节 32][int64 spaceId]
//
// 注 1:命令 ID 可跨方向复用(32 在两个方向含义不同),故按方向分表。
// 注 2:实测真实服务器的「加密」标志是 1(整条链路转加密,抓包里后续帧都要解 K);
// 本服务器发 0,客户端接受并保持明文(fake 模式与 M1 冒烟都验证过),省掉密钥轮换。
'use strict';
const { ReadVarint } = require('../lib_space_codec');
const C2S_NAME = { 1: 'Hash Request', 3: 'Auth/EnterSpace', 32: 'ClientLog' };
const S2C_NAME = { 2: 'Hash Response', 32: 'Open Space' };
// varint 长度前缀 + utf8 字节
function ReadString(Buf, Off) {
const N = ReadVarint(Buf, Off);
const End = N.Next + N.Value;
if (End > Buf.length) return { Str: '<越界>', Next: Buf.length, Bad: true };
return { Str: Buf.slice(N.Next, End).toString('utf8'), Next: End };
}
function WriteString(S) {
const B = Buffer.from(String(S), 'utf8');
return Buffer.concat([WriteVarint(B.length), B]);
}
function WriteVarint(N) {
const V = Number(N);
if (V < 0x80) return Buffer.from([V]);
if (V < 0x4000) return Buffer.from([0x80 | (V >> 8), V & 0xff]);
if (V < 0x400000) return Buffer.from([0xc0 | (V >> 16), (V >> 8) & 0xff, V & 0xff]);
throw new Error('varint 过大: ' + V);
}
// At = 命令体起始(已跳过 nullmap 与命令 ID 字节)
// 布局实测(lobby 抓包 563B / 572B 两例,均吃满到帧体末尾):
// [varint N][N × (varint 长度 + utf8 键)][varint M][M × (varint 长度 + utf8 值)]
// 两个计数各自独立(实测 N == M),按序号配对。
function ParseHashRequest(Buf, At) {
const Cn = ReadVarint(Buf, At);
const Keys = [];
const Values = [];
let Off = Cn.Next;
for (let I = 0; I < Cn.Value; I++) {
const R = ReadString(Buf, Off);
if (R.Bad) return { Count: Cn.Value, Keys, Values, Ok: false, Next: Off };
Keys.push(R.Str);
Off = R.Next;
}
const Cm = ReadVarint(Buf, Off);
Off = Cm.Next;
for (let I = 0; I < Cm.Value; I++) {
const R = ReadString(Buf, Off);
if (R.Bad) return { Count: Cn.Value, Keys, Values, Ok: false, Next: Off };
Values.push(R.Str);
Off = R.Next;
}
return { Count: Cn.Value, CountValues: Cm.Value, Keys, Values, Ok: Off === Buf.length, Next: Off };
}
function ParseEnterSpace(Buf, At) {
if (At + 40 > Buf.length) return null;
return { Digest: Buf.slice(At, At + 32).toString('hex'), SpaceId: Buf.readBigInt64BE(At + 32).toString() };
}
function ParseClientLog(Buf, At) {
if (At >= Buf.length) return null;
const S1 = ReadString(Buf, At + 1);
const S2 = ReadString(Buf, S1.Next);
return { Type: Buf[At], Name: S1.Str, Text: S2.Str };
}
// 回调 hash:32 字节随机;Plaintext = true → 置 0,整条链路明文
function BuildHashResponse(Hash, Plaintext) {
if (Hash.length !== 32) throw new Error('hash 必须 32 字节');
const Out = Buffer.alloc(34);
Out[0] = 2;
Hash.copy(Out, 1);
Out[33] = Plaintext ? 0 : 1;
return Out;
}
function BuildOpenSpace(SpaceId) {
const Out = Buffer.alloc(9);
Out[0] = 32;
Out.writeBigInt64BE(BigInt(SpaceId), 1);
return Out;
}
function MakeHash() {
const Out = Buffer.alloc(32);
for (let I = 0; I < 32; I++) Out[I] = Math.floor(Math.random() * 256);
return Out;
}
module.exports = {
C2S_NAME,
S2C_NAME,
ReadString,
WriteString,
WriteVarint,
ParseHashRequest,
ParseEnterSpace,
ParseClientLog,
BuildHashResponse,
BuildOpenSpace,
MakeHash,
};
+564
View File
@@ -0,0 +1,564 @@
// index.js - 真·替代服务器(M1 骨架):自己握手、自己管空间、把客户端的诉求照实打出来
//
// 用法: node server/index.js [--port 9091] [--log ../server-logs] [--label 名字] [--keepalive 10000] [--store-preempt]
// 客户端挂载: local/config.xml → <server address="localhost" wsProtocol="WS"><port>9091</port>
//
// M1 已实现(入口层全量):
// Hash Request → 回 Hash Response(随机 hash + 明文)与 Open Space(入口空间)
// Auth/EnterSpace → 建/取空间实例,记 spaceId 与类别
// ClientLog → 解出类型/名称/正文(正文常含 UI 事件 JSON,按钮点击的来源)
// 保活 ping(负载为空的帧)
// M1 刻意留空(下一步 M2):
// 空间层的语义 —— 收到空间命令只做「识别 + 解码 + 记未实现」,一个字节都不伪造。
//
// 空间层的应答(本文件的下半部分,优先级自上而下):
// ① 查库定向应答(responder.js + store.js):ba.lcr / ls.xbl / G.q1wd 三条上行按 MySQL 里的数据
// 自己组帧发出去(库里没数据就返回 null,让位给 ②);每次命中都打一行「→ 查库应答:…」日志。
// ② 会话回放(session.js):真实服大部分 s2c 是「答某条上行」的,会话文件里记着 After 清单。
// ③ 都没命中 ⇒ dispatch 内部把这条命令记进未实现清单。
// --store-preempt:严格优先(命中查库后不再让回放器发那条帧);默认非严格,回放行为与升级前一致。
//
// 会话重放(本文件里的 SpaceReplays / session.js):真实服的空间层是「一问一答」,
// names/space-init-<id>.json 只装得下"进空间时主动推的那几帧"(车库 5 帧 / 会话里 39 帧),
// 所以有 names/space-session-<id>.json 的空间改用它:After 为空的帧进空间即发,
// 其余等客户端发到"换它出来的那条上行"时补发(同一帧只发一次),命中后不再计入未实现。
'use strict';
const fs = require('fs');
const http = require('http');
const path = require('path');
const { LoadTables, MakeReader } = require('../lib_space_codec');
const Link = require('./link');
const Entry = require('./entry');
const { MakeWriter } = require('./codec');
const { MakeContainerCodec } = require('./container');
const Space = require('./space');
const SemanticModule = require('./semantic');
const SessionReplay = require('./session');
const ModelService = require('./modelservice');
const { MakeDispatcher } = require('./dispatch');
const { MakeWorld, ENTRANCE_SPACE, SPACE_KIND } = require('./world');
const Store = require('./store');
const StoreResponder = require('./responder');
const Db = require('./db');
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 PORT = Number(Arg('port', '9091')) || 9091;
const KeepaliveMs = Number(Arg('keepalive', '10000')) || 0;
const LABEL = Arg('label', new Date().toISOString().replace(/[:.]/g, '-'));
const LOG_DIR = path.resolve(Arg('log', path.join(PROJECT, 'server-logs', LABEL)));
// 严格优先模式(--store-preempt):命中查库定向应答的上行不再交给会话回放器发帧(回放器只推进顺序)。
// 默认关 —— 查库应答照发,回放器一字不改(现状已知能跑通,不拿它冒险)。
const STORE_PREEMPT = Argv.indexOf('--store-preempt') >= 0;
// 查库接管范围:默认**只接管 ba.lcr**(理由见 responder.js 头注:模板/回放里已经播发过的东西不能重复发,
// 否则客户端 nap() 会抛 "Model already subscribed!")。--store-all 才把 ls.xbl / G.q1wd 一起打开,仅供实验。
const STORE_ALL = Argv.indexOf('--store-all') >= 0;
const STORE_SCOPE = STORE_ALL
? { BaLcr: true, LsXbl: true, GQ1wd: true }
: { BaLcr: true, LsXbl: false, GQ1wd: false };
// ws 模块:优先本项目解析,回落 sockets/(唯一装过 ws 的地方)
function RequireWs() {
try {
return require('ws');
} catch (Err) {
return require(path.resolve(PROJECT, '..', 'sockets', 'node_modules', 'ws'));
}
}
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Reader = MakeReader(Tables, { EnumBytes: 4 });
const Writer = MakeWriter(Tables, { EnumBytes: 4 });
const Container = MakeContainerCodec(Tables, Reader, Writer);
// 语义层:模板里的「字段 JSON」记录(space-init-*.json 的 Fields 形态)由它现编成字节
const Semantic = SemanticModule.Load(path.join(PROJECT, 'names'));
const World = MakeWorld();
// 派生命名词典(不是客户端原始名):让日志里出现 LoginCommand.SendLogin 这类可读标注
let Alias = null;
try {
Alias = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'command_alias.json'), 'utf8'));
} catch (Err) {
Alias = null;
}
const Dispatch = MakeDispatcher(Tables, Reader, World, { Alias });
// ============================================================
// 会话与落盘(沿用 relay 的产物形状:log.txt + c2s_XXXX.bin / s2c_XXXX.bin)
// ============================================================
fs.mkdirSync(LOG_DIR, { recursive: true });
function NewSession() {
const Used = fs
.readdirSync(LOG_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(LOG_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;
}
function SaveFrame(State, Direction, Buf) {
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 + ' 字节',
);
}
function Send(State, Client, Buf) {
SaveFrame(State, 's2c', Buf);
Client.send(Buf);
}
// ============================================================
// 收帧
// ============================================================
function HandleHashRequest(State, Client, Conn, Body, At) {
const H = Entry.ParseHashRequest(Body, At);
State.Log(' Hash Request 键 ' + H.Count + ' 条 / 值 ' + H.CountValues + ' 条' + (H.Ok ? '' : '(没吃满帧体,可能还有别的段)'));
for (let I = 0; I < H.Keys.length; I++) {
State.Log(' ' + String(H.Keys[I]).padEnd(24) + ' = ' + JSON.stringify(H.Values[I] === undefined ? '<缺>' : H.Values[I]));
}
const Hash = Entry.MakeHash();
Conn.Hash = Hash;
// 新的 Hash Request = 新的客户端会话。同机重连时远端地址(::1)与上一轮相同,
// 必须重置「已宣告空间」集合,否则第二轮起会一直判「已宣告过,跳过」,客户端一个空间都收不到。
// 实测(server-logs/loginframes-01/session_012:54,73):920007382011 / 100119080 都被跳过。
AnnouncedByPeer.set(Conn.Peer, new Set([ENTRANCE_SPACE])); // 入口空间已在握手帧里宣告,之后别再重复宣告(客户端会销毁重建)
EntryConns.set(Client, State); // 这条就是控制通道:入口层命令(Open space)只从它下发
// 真实服务器把 Hash Response 与第一条 Open Space 放在同一帧里(实测 46B)
const Reply = Buffer.concat([Entry.BuildHashResponse(Hash, true), Entry.BuildOpenSpace(ENTRANCE_SPACE)]);
Send(State, Client, Link.EncodeFrame(Reply));
State.Log(' → Hash Response(hash=' + Hash.toString('hex').slice(0, 16) + '…, 明文) + Open Space(' + ENTRANCE_SPACE + ')');
}
function HandleEnterSpace(State, Client, Conn, Body, At) {
const E = Entry.ParseEnterSpace(Body, At);
if (!E) {
State.Log(' ⚠ Auth/EnterSpace 负载不足 40B:' + Body.slice(At).toString('hex').slice(0, 96));
return;
}
Conn.Space = World.EnsureSpace(E.SpaceId);
SpaceConns.set(E.SpaceId, { Client, State }); // 之后该空间的 s2c(含登录态补帧)都走这条连接
State.Log(' 空间鉴权: digest=' + E.Digest.slice(0, 16) + '… spaceId=' + E.SpaceId + ' 类别=' + Conn.Space.Kind);
// 查库定向应答器(按空间维度):ba.lcr / ls.xbl / G.q1wd 的应答与「X → 已发出的 zt 批次」表都挂在它身上。
// 与回放器同级、无条件建;库不可用时它每条上行都返回「让位」(store.js 只记一行日志)。
const Responder = StoreResponder.MakeResponder({
SpaceId: E.SpaceId,
Preempt: STORE_PREEMPT,
Scope: STORE_SCOPE,
Log: (M) => State.Log(' ' + M),
});
SpaceResponders.set(String(E.SpaceId), Responder);
if (!Store.Available()) State.Log(' (查库定向应答不可用:本条空间的应答全部走会话回放)');
// 会话回放模板优先。为什么:真实服的绝大部分 s2c 不是"进空间就推",而是**答客户端的那条上行**
//(车库实测:space-init-100073070.json 只装得下前 5 帧,另外 34 帧全靠 G.q1wd / ba.lcr / M_.hcn /
// ls.xbl / M_.fcn / Qh.ycw 换出来;只发初始化帧 ⇒ 客户端发问却零应答,武器列表一动不动)。
// space-init-*.json 模板本身保留不动(本分支只是"有会话文件时优先用它")。
const Session = SessionReplay.Load(path.join(PROJECT, 'names'), E.SpaceId);
if (Session) {
const Replay = SessionReplay.MakeReplay(Session.Frames, { Container, Semantic, Log: (M) => State.Log(M) });
SpaceReplays.set(String(E.SpaceId), Replay);
const Push = Session.Frames.filter((F) => !(F.After || []).length).length;
State.Log(' 会话回放: names/space-session-' + E.SpaceId + '.json 帧 ' + Session.Frames.length +
'(纯推送 ' + Push + ' 帧,其余 ' + (Session.Frames.length - Push) + ' 帧等客户端上行触发)');
// 发帧走现成路径:session.js 里 Space.BuildInitFrames → space.js → Link.EncodeFrame
const Sent = Replay.OnEnter(MakeReplaySender(State, Client, Responder));
State.Log(' → 纯推送帧已发 ' + Sent + ' 条');
return;
}
// 认证后必须下发该空间的初始化数据,否则客户端静默等数据(实测:一字节上行都不会发)
const Template = Space.LoadInitTemplate(path.join(PROJECT, 'names'), E.SpaceId);
if (!Template) {
State.Log(' ⚠ 没有 names/space-init-' + E.SpaceId + '.json:客户端会停在等数据的状态');
return;
}
const Frames = Space.BuildInitFrames(Template, Container, Semantic);
const Tags = new Map();
for (const T of Template.Frames || []) {
Tags.set(T.File, (T.Records || []).map((R) => ModelService.Name(R.MethodId) || '#' + R.MethodId).join(' + '));
}
State.Log(' → 下发初始化 ' + Frames.length + ' 帧(container.js/space.js 现场编码,非原始字节)');
for (const F of Frames) {
const Tpl = (Template.Frames || []).find((X) => X.File === F.File);
const Tag = Tags.get(F.File) || '';
const Deliver = () => {
if (Client.readyState !== Ws.OPEN) return;
if (Tpl) Responder.NoteSentFrame(Tpl); // 帧里的 zt 要记进 X → 批次身份表
State.Log(' ← ' + F.File + ' ' + F.Frame.length + 'B @+' + F.DelayMs + 'ms' + (Tag ? ' [' + Tag + ']' : ''));
Send(State, Client, F.Frame);
};
if (F.DelayMs > 0) setTimeout(Deliver, F.DelayMs);
else Deliver();
}
}
// ============================================================
// 按钮驱动的空间下发
// ============================================================
// 真实服是按客户端上报的 button_click 才回 Open Space,不是自己乱推。
// 实测(旧 wss_server.js 注释引用的 garage-01/session_001):button_click "GARAGE" → Open Space(100073070)。
// 客户端上报格式见 ClientLog 正文:{"screen":"Lobby.Home","button":"GARAGE"}
const CLICK_SPACES = { GARAGE: 100073070 };
// 登录后下发的空间列表。实测(packets/relay-logout/session_007):真实服在客户端 log_in 前后
// 按秒级节奏推这几个空间,客户端随后就切到 Lobby.Home。
// 顺序与相对时刻:920007382011 +0s、100119080 +2s、100072911 +4s、100045720 +4s(后两个同帧一条)。
const LOGIN_SPACES = [
{ Space: 920007382011, DelayMs: 0 },
{ Space: 100119080, DelayMs: 2000 },
{ Space: 100072911, DelayMs: 4000 },
{ Space: 100045720, DelayMs: 4000 },
];
// 用户行为命令 dn.gdk(src/module_25377.js:1516-1526):6 个字符串参数 = (eventName, menuItemName, …)
// methodId 由源码 `new Q(69409237, 587263982)` 按 value = B<<32|A 算得。
// 客户端在进入/离开大厅菜单屏时发它(src/module_18334.js:3895-3919 → uaw().gdk(...)),
// 菜单项名就是 "GARAGE" 这类值——比 button_click 更可靠的"打开了某屏"信号。
const USER_ACTIVITY_METHOD = '2522279596878141909';
// 入口层命令(Hash Response / Open space)只能走「控制通道」——即发过 Hash Request 的那条连接。
// 实测(packets/relay-logout/session_007):真实服的 4 条 Open space 都发在控制通道上;
// 空间连接上只跑空间命令帧([nullmap][spaceId][(methodId)(payload)])。
// 教训:曾经把空间列表推在登录命令所在的空间连接上,客户端按空间帧去解 → BufferUnderflowException
// (抛点 src/module_72834.js:224,ByteBuffer 读越界)。
const EntryConns = new Map(); // Client → State(控制通道)
const SpaceConns = new Map(); // spaceId → { Client, State }(该空间的空间连接)
const AnnouncedByPeer = new Map(); // 远端地址 → 已宣告空间集合(同一个客户端的多条连接共享)
const SpaceReplays = new Map(); // spaceId → 会话重放器(names/space-session-<id>.json,session.js)
const SpaceResponders = new Map(); // spaceId → 查库定向应答器(responder.js;回放之外的「查库自己组帧」那条路)
// 会话重放器的发帧出口:s2c 走**收到那条上行的连接**(客户端就在这条连接上等应答)。
// 返回 true = 真的发出去了;连接已断返回 false,重放器据此不把该帧标记成"已发"。
// 顺带把发出去的 zt(回放器发的也算)记进查库应答器的 X → 批次身份表(G.q1wd 靠它做定向应答)。
function MakeReplaySender(State, Client, Responder) {
return (F, Buf) => {
if (Client.readyState !== Ws.OPEN) return false;
if (Responder) Responder.NoteSentFrame(F);
Send(State, Client, Buf);
return true;
};
}
// 登录态补帧:真实服在客户端登录成功后,在入口空间上还会额外下发几帧
// (实测 packets/relay-logout/session_008:17B / 88B(含账号) / 17B / 17B / 106B(长 token),
// 原始相对时刻 +0 / +9.5s / +14s / +19.5s / +26s —— 那其实是应答客户端那几条入口命令的节奏)。
// 这些帧是**空间命令**,必须走该空间的「空间连接」,不能走控制通道(否则就是 BufferUnderflow 那个坑)。
const LOGIN_FRAME_GAP_MAX = 1500;
function SendLoginStateFrames(State, SpaceId) {
const Template = Space.LoadInitTemplate(path.join(PROJECT, 'names'), 'login-' + SpaceId);
if (!Template) {
State.Log(' (没有 space-init-login-' + SpaceId + '.json,跳过登录态补帧)');
return;
}
const Target = SpaceConns.get(String(SpaceId));
if (!Target || Target.Client.readyState !== Ws.OPEN) {
State.Log(' ⚠ 找不到空间 ' + SpaceId + ' 的空间连接,登录态补帧发不出去');
return;
}
const Frames = Space.BuildInitFrames(Template, Container, Semantic);
State.Log(' 登录态补帧 ' + Frames.length + ' 帧 → 空间 ' + SpaceId + ' 的连接');
for (const F of Frames) {
const At = Math.min(F.DelayMs, LOGIN_FRAME_GAP_MAX); // 原始间隔是"应答节奏",这里压到 1.5s 内尽快给全
setTimeout(() => {
if (Target.Client.readyState !== Ws.OPEN) return;
Target.State.Log(' ← 登录态 ' + F.File + ' ' + F.Frame.length + 'B @+' + At + 'ms');
Send(Target.State, Target.Client, F.Frame);
}, At);
}
}
function PeerSet(Peer) {
let S = AnnouncedByPeer.get(Peer);
if (!S) {
S = new Set();
AnnouncedByPeer.set(Peer, S);
}
return S;
}
function AnnounceSpace(State, Peer, Space, Reason) {
const Set2 = PeerSet(Peer);
if (Set2.has(Space)) {
State.Log(' 空间 ' + Space + ' 已宣告过,跳过(重复宣告会让客户端销毁重建)');
return;
}
Set2.add(Space);
const Targets = [...EntryConns.entries()].filter(([C]) => C.readyState === Ws.OPEN);
if (!Targets.length) {
State.Log(' ⚠ 没有活着的控制通道,Open Space(' + Space + ') 发不出去');
return;
}
for (const [C, S] of Targets) {
S.Log(' ' + Reason + ' → 下发 Open Space(' + Space + ')');
Send(S, C, Link.EncodeFrame(Entry.BuildOpenSpace(Space)));
}
}
// 登录命令 Bn.wh3(src/module_10283.js:2315,字段 = 账号 string / 密码 string / rememberMe bool)。
// methodId 由源码 `new R(-1924305606, 25286687)` 算得。
// 源码结论(module_10283 + module_18334):登录命令**发完不等应答**(无 future、无超时),
// 账号密码登录模型 `Dn` 只有三个分支且全是错误/2FA(语义见下),**没有"登录成功"命令**——
// 客户端的"成功了"是靠服务端推进空间/对象(新空间对象让 UI store 反应)实现的。
// 所以我们收到登录命令就直接推空间列表。
const LOGIN_METHOD = '108605496059850042';
// 登录命令 Hn.wh4 = SendLoginViaHash(src/module_10283.js:2368,字段 = 64 字符 token)。
// 真客户端很多时候**不走账号密码**,而是用 remember-me/localStorage 里的 token 直接发这条
// (回填别名词典后 src_named/module_18334.js:5608 的调用点可读 / 改名前的 src/module_18334.js 同位置)。
// 实测(server-logs/alias-01/session_002):客户端只发 wh4,我们当时没有这个分支 → 零响应,
// 客户端等空间列表等到 3 小时后断开。真实服对 wh4 的反应与 wh3 完全一致:
// 0.45s 后就推第 1 个 Open Space(packets/relay-logout/session_008:79 的 wh4 @12:49:58.718
// → session_007:44 的 Open Space(920007382011) @12:49:59.192)。
const LOGIN_VIA_HASH_METHOD = '7981625407112679465';
// `Dn` 的三个 s2c 分支(负载为空),备查;目前不发:
// 3777380964115009138 需要 2FA(客户端跳 Entrance.Login2FA)
// 6941935892500153516 2FA 码错误
// 6160917066910786241 密码错误(登录框显红)
function PushLoginSpaces(State, Conn, Reason) {
State.Log(' ' + Reason + ' → 推送空间列表 ' + LOGIN_SPACES.map((X) => X.Space).join(', '));
for (const X of LOGIN_SPACES) {
const Deliver = () => AnnounceSpace(State, Conn.Peer, X.Space, '空间列表 +' + X.DelayMs + 'ms');
if (X.DelayMs > 0) setTimeout(Deliver, X.DelayMs);
else Deliver();
}
}
// 上行记录里找登录命令 / 用户行为命令 → 触发空间下发
function HandleUplinkRecords(State, Conn, Info) {
for (const R of Info.Records || []) {
if (R.MethodId === LOGIN_METHOD || R.MethodId === LOGIN_VIA_HASH_METHOD) {
const Account = R.MethodId === LOGIN_METHOD && R.Values[0] ? R.Values[0][2] : '?';
const Who = R.MethodId === LOGIN_METHOD ? 'Bn.wh3(账号密码)' : 'Hn.wh4(hash token)';
State.Log(' 登录请求 ' + Who + ': 首字段=' + JSON.stringify(Account) + '(源码里没有"登录成功"应答,靠推空间推进)');
PushLoginSpaces(State, Conn, '收到登录命令 ' + Who);
SendLoginStateFrames(State, Info.TargetId); // 入口空间上的登录态补帧(真实服登录后会补发几帧)
continue;
}
if (R.MethodId !== USER_ACTIVITY_METHOD) continue;
const Menu = R.Values[1] ? R.Values[1][2] : null;
State.Log(' 用户行为: 菜单项 = ' + JSON.stringify(Menu));
const Space = CLICK_SPACES[Menu];
if (Space) AnnounceSpace(State, Conn.Peer, Space, '用户行为 ' + Menu);
}
}
function HandleLogIn(State, Conn, Log) {
if (Log.Name !== 'log_in') return;
PushLoginSpaces(State, Conn, '客户端 log_in');
}
function HandleButtonClick(State, Conn, Log) {
if (Log.Name !== 'button_click') return;
let Event = null;
try {
Event = JSON.parse(Log.Text);
} catch (Err) {
return;
}
const Space = CLICK_SPACES[Event.button];
if (!Space) return;
AnnounceSpace(State, Conn.Peer, Space, '按钮 ' + Event.button + '(屏幕 ' + Event.screen + ')');
}
function HandleFrame(State, Client, Conn, Frame) {
if (Link.IsPingFrame(Frame)) {
State.Log(' 保活 ping');
return;
}
const Body = Link.DecodeFrame(Frame);
const At = Link.NullmapSize(Body, 0);
if (At >= Body.length) {
State.Log(' 空负载帧');
return;
}
if (Dispatch.IsSpaceFrame(Body, At)) {
// 空间层的应答优先级:**查库定向应答 → 会话回放 → 记未实现**。
// ① 查库定向应答(responder.js):ba.lcr / ls.xbl / G.q1wd 三条上行,库里有数据就自己组帧发出去。
// 同步判定「能不能答」(小表在内存里;G.q1wd 看的是「之前发过 X=n 的 zt」这张表),
// 真正的查库/组帧在 thunk 里异步做;失败一律显式回落回放。
// ② 会话回放(session.js):回放器先问它这条上行换来哪几帧 s2c。命中并发出去了 ⇒ 不该再记进未实现清单
// (钩子在 dispatch 里,因为 NoteUnimplemented 就是在 Dispatch.Handle 内部记的)。
// ③ 都没命中 ⇒ dispatch 内部照旧记未实现。
const SpaceId = Conn.Space ? String(Conn.Space.Id) : '';
const Replay = SpaceReplays.get(SpaceId);
const Responder = SpaceResponders.get(SpaceId);
const Sender = MakeReplaySender(State, Client, Responder); // 回放器的发帧出口
const StoreSender = (Buf) => {
if (Client.readyState !== Ws.OPEN) return false;
Send(State, Client, Buf); // 查库应答的帧也照常落盘 + 打日志(Send 里会写 s2c_XXXX.bin)
return true;
};
const Pending = [];
const OnRecord = (R, TargetId) => {
const Ctx = { Sender: StoreSender, Replay, ReplaySender: Sender };
const Task = Responder ? Responder.Prepare(R, TargetId, Ctx) : null;
if (Task) {
// 查库应答先发;非严格模式下再让回放器补发它那条帧(顺序:查库 → 回放)。
// 之所以把回放放进同一个 thunk 里排队:Dispatch.Handle 是同步的,不等它就会让回放帧抢在查库帧前面。
const Follow = !(Responder && Responder.Preempt) && Replay ? Replay : null;
const Record = { MethodId: R.MethodId, Target: String(TargetId), Name: R.Name };
Pending.push(async () => {
await Task();
if (!Follow) return;
const Hits = Follow.OnUplink(Record, Sender);
if (Hits) State.Log(' → 会话回放同时命中:发出 ' + Hits + ' 帧');
});
return true; // 这条上行已由查库路径接管,不再计入未实现
}
if (!Replay) return false;
const Hits = Replay.OnUplink({ MethodId: R.MethodId, Target: String(TargetId), Name: R.Name }, Sender);
if (Hits) State.Log(' → 会话重放命中:发出 ' + Hits + ' 帧(该上行不再计入未实现)');
return Hits > 0;
};
const Info = Dispatch.Handle(State, Body, At, Conn.Space ? Conn.Space.Kind : '未进空间', OnRecord);
HandleUplinkRecords(State, Conn, Info);
// 异步的查库应答(失败自己回落回放):按记录顺序排队,保证「查库应答先于回放帧」这条优先级
if (Pending.length) {
(async () => {
for (const T of Pending) {
try {
await T();
} catch (Err) {
State.Log(' ⚠ 查库应答任务异常(已忽略,回放不受影响):' + (Err && Err.message ? Err.message : Err));
}
}
})();
}
return;
}
const Id = Body[At];
const PAt = At + 1;
if (Id === 1) return HandleHashRequest(State, Client, Conn, Body, PAt);
if (Id === 3) return HandleEnterSpace(State, Client, Conn, Body, PAt);
if (Id === 32) {
const L = Entry.ParseClientLog(Body, PAt);
State.Log(' ClientLog 类型=' + L.Type + ' 名称=' + JSON.stringify(L.Name) + ' 正文=' + JSON.stringify(L.Text));
HandleButtonClick(State, Conn, L);
HandleLogIn(State, Conn, L);
return;
}
// 首字节像空间帧的第一个 id,但 methodId 不在上行表里 → 表缺项,如实报出来
if ((Id === 0x00 || Id === 0xff) && At + 16 <= Body.length) {
const Hex = Body.slice(At + 8, At + 16).toString('hex');
State.Log(' ⚠ 疑似空间帧,但 methodId 不在上行表:' + Hex + '(id=' + Body.readBigInt64BE(At).toString() + ')');
return;
}
State.Log(' ⚠ 未知入口命令 ID=' + Id + ' 负载=' + Body.slice(PAt).toString('hex').slice(0, 96));
}
// ============================================================
// 服务
// ============================================================
const Server = http.createServer((Req, Res) => {
Res.writeHead(426, { 'Content-Type': 'text/plain; charset=utf-8' }).end('WebSocket only\n');
});
const Ws = RequireWs();
const WsServer = new Ws.Server({ server: Server });
WsServer.on('connection', (Client, Req) => {
const State = NewSession();
const Conn = { Hash: null, Space: null, Peer: Req.socket.remoteAddress || '?' };
State.Log('=== 新客户端 ===');
State.Log('来源: ' + (Req.socket.remoteAddress || '?') + ' Origin: ' + (Req.headers.origin || '无'));
State.Log('模式: real(真服务器,M1:入口层全量 / 空间层只记录 + 有会话模板的空间按上行重放)');
const Timer = KeepaliveMs ? setInterval(() => {
if (Client.readyState !== Ws.OPEN) return;
Send(State, Client, Link.PingFrame());
}, KeepaliveMs) : null;
Client.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 'c2s', Buf);
const Frames = Link.ParseFrames(Buf);
if (!Frames) {
State.Log(' ⚠ 不是整帧流(可能是加密流或被分片):' + Buf.toString('hex').slice(0, 96));
return;
}
for (const F of Frames) HandleFrame(State, Client, Conn, F);
});
Client.on('close', (Code) => {
if (Timer) clearInterval(Timer);
EntryConns.delete(Client);
for (const [Id, V] of SpaceConns) if (V.Client === Client) SpaceConns.delete(Id);
State.Log('客户端断开 code=' + Code);
LogResponderSummary(State);
const S = World.UnimplementedSummary();
State.Log('未实现命令累计:' + S.Total + ' 次 / ' + S.Distinct + ' 种');
for (const R of S.Rows.slice(0, 30)) {
State.Log(' ' + String(R.Count).padStart(5) + ' ' + R.Kind.padEnd(10) + R.Name + ' methodId=' + R.MethodId + ' 目标=' + R.Targets.join(','));
}
});
Client.on('error', (Err) => State.Log('客户端错误: ' + Err.message));
});
// 查库命中 / 回落回放的计数口径:每条上行记录只走一条路(详见 responder.js 头注)——
// Hit = 查库路径接管并成功发出帧;Miss = 库可用但查不到;Disabled = 该上行不在接管范围内;
// Unavailable = 库不可用;Fallback = 已接管但后续查库/组帧/发帧失败 → 显式回放补发(FallbackFrames = 这些帧数)
function LogResponderSummary(State) {
if (!SpaceResponders.size) return;
for (const [SpaceId, R] of SpaceResponders) {
const C = R.Counters();
State.Log('查库定向应答(空间 ' + SpaceId + '):命中 ' + C.Hit + ' / 库里查不到(Miss) ' + C.Miss +
' / 不在接管范围 ' + C.Disabled + ' / 库不可用 ' + C.Unavailable + ' / 回落回放 ' + C.Fallback + '(补发帧 ' + C.FallbackFrames + ')' +
(C.Preempt ? ' / 严格优先(已抑制回放) ' + C.Suppressed : '(非严格:回放照旧)') + ' / X 表 ' + C.X + ' 条');
State.Log(' 按上行分:' + (Object.keys(C.ByMethod).length ? Object.entries(C.ByMethod).map(([K, V]) => K + '=' + V).join(' ') : '(无)'));
}
}
Server.listen(PORT, () => {
console.log('真服务器(real / M1)已监听 ws://localhost:' + PORT + '/');
console.log(' 协议表: codec ' + Tables.CodecByKey.size + ' 个 / 上行命令 ' + Tables.UplinkByMethod.size + ' 条' + (Tables.CodecError ? ' ⚠ ' + Tables.CodecError : ''));
console.log(' 入口空间: ' + ENTRANCE_SPACE + '(握手帧里下发)');
console.log(' 已知空间类别: ' + Object.entries(SPACE_KIND).map(([K, V]) => K + '=' + V).join(' '));
const SessionFiles = fs.readdirSync(path.join(PROJECT, 'names')).filter((F) => /^space-session-.*\.json$/.test(F));
console.log(' 会话回放模板: ' + (SessionFiles.length ? SessionFiles.join(', ') : '(无:空间层只记录不重放)'));
console.log(' 查库定向应答: ' + (STORE_PREEMPT ? '严格优先(命中即不发回放帧)' : '非严格(查库应答 + 回放并存,回放行为与升级前一致)') +
' 接管范围=' + Object.entries(STORE_SCOPE).filter(([, V]) => V).map(([K]) => K).join(',') +
(STORE_ALL ? '(--store-all:实验性,已知会与模板/回放里已播发的帧撞车)' : '(默认只接管 ba.lcr,理由见 responder.js 头注)'));
Store.SetLog((M) => console.log(M));
Store.Open().then((Pool) => {
Store.SetSemantic(Semantic); // 复用本进程这份表,别重复读 names/
console.log(' MySQL: ' + (Pool ? '已连上(' + Db.LoadConfig().Database + ')' : '不可用 → 查库定向应答全部回落会话回放/模板'));
});
console.log(' 日志: ' + LOG_DIR);
console.log(' 客户端挂载: local/config.xml 指向 ws://localhost:' + PORT + '/');
});
process.on('SIGINT', () => {
const S = World.UnimplementedSummary();
console.log('\n未实现命令累计:' + S.Total + ' 次 / ' + S.Distinct + ' 种(Top 30)');
for (const R of S.Rows.slice(0, 30)) {
console.log(' ' + String(R.Count).padStart(5) + ' ' + R.Kind.padEnd(10) + R.Name + ' methodId=' + R.MethodId);
}
for (const [SpaceId, R] of SpaceResponders) console.log('查库定向应答(空间 ' + SpaceId + '):' + JSON.stringify(R.Counters()));
process.exit(0);
});
+83
View File
@@ -0,0 +1,83 @@
// link.js - 链路层:帧边界 / nullmap / 明文协商
//
// 帧 = [2 或 4 字节大端长度][帧体]
// 长度首字节 bit7=1 → 4 字节头;bit6=1 → 帧体 zlib 压缩;低 6 位参与长度
// 帧体 = [nullmap][payload]
// payload = [1 字节入口命令 ID][命令体](entry.js)
// | [8 字节目标 id][空间命令记录…](dispatch.js)
// nullmap:长度 = NullmapSize()。本服务器发出的帧一律 1 字节 0x00(没有空字段);
// 读入按同一函数算长度,兼容抓包里的变长 nullmap。
// 加密:自变体 XOR(k[i] = K ^ (i<<3),上行再 ^87)。本服务器在 Hash Response 里把
// 「是否启用链路加密」置 0,整条链路走明文;解密只在 relay 模式做(见 ../wss_server.js)。
'use strict';
const zlib = require('zlib');
// 一帧的头部信息 → { TotalSize, HeaderSize, Compressed };字节不够返回 null
function PeekFrame(Buf, Offset) {
const Off = Offset || 0;
if (Buf.length - Off < 2) return null;
const B0 = Buf[Off];
if (B0 & 0x80) {
if (Buf.length - Off < 4) return null;
const Size = ((((B0 & 0x3f) << 24) | (Buf[Off + 1] << 16) | (Buf[Off + 2] << 8) | Buf[Off + 3]) >>> 0) + 4;
return { TotalSize: Size, HeaderSize: 4, Compressed: (B0 & 0x40) !== 0 };
}
return { TotalSize: 2 + (((B0 & 0x3f) << 8) | Buf[Off + 1]), HeaderSize: 2, Compressed: (B0 & 0x40) !== 0 };
}
// 把一串字节拆成整帧;有任何残余(说明不是整帧流)返回 null
function ParseFrames(Buf) {
const Frames = [];
let Off = 0;
while (Off < Buf.length) {
const Info = PeekFrame(Buf, 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)
// 长格式实测:`c0 <2 字节长度>` ⇒ 总长 = 3 + 长度(**不是 4 +**)。
// 这一字节之差会让「大 nullmap」的帧整体偏 1 字节,进而所有后续解析全错——
// 车库那几帧(16771B / 137119B)的 nullmap 分别是 800B / 269B,正是踩在这个坑上。
function NullmapSize(Buf, Offset) {
const R = Buf[Offset];
if (R === undefined) return 0;
if (R & 0x80) {
if (R & 0x40) return 3 + (((R & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]);
return 1 + (R & 0x3f);
}
return 1 + ((R & 0x60) >>> 5);
}
function DecodeFrame(Frame) {
return Frame.Compressed ? zlib.inflateSync(Frame.Body) : Frame.Body;
}
// 组帧:Length < 0x4000 用 2 字节头,否则 4 字节头(bit7)
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,
]);
}
// 保活帧 = 负载为空的帧(实测 3 字节:00 01 00)
function PingFrame() {
return EncodeFrame(Buffer.alloc(0));
}
// 帧体只有 nullmap、没有 payload
function IsPingFrame(Frame) {
return Frame.Body.length > 0 && NullmapSize(Frame.Body, 0) >= Frame.Body.length;
}
module.exports = { PeekFrame, ParseFrames, NullmapSize, DecodeFrame, EncodeFrame, PingFrame, IsPingFrame };
+46
View File
@@ -0,0 +1,46 @@
// modelservice.js - 模型/对象服务协议(module_29554)的语义层
//
// 出处:src/module_29554.js。客户端 `U.q7i(methodId, reader)` 按 methodId 分发 4 条 s2c:
// _loadDependencies → K{ callbackId i32, resources list }
// _loadObjectsData → W{ modelsData list<list<X>>, objects list<list<h>> }
// _setModelCommandAlias → alias + 两个 i64(告诉客户端「某 gameType 由某 methodId 承载」)
// _unloadObjects → objects
// 客户端 → 服务端只有一条:`G.q1wd`(见下),负载实测 4 字节 i32。
//
// 实测结论(命名/语义对应,标定过):
// 这 4 条命令的 methodId 就是 names/method_table.json 里 4 条 `_t` / codec=`lt` 的行,
// 而它们的**真实负载布局是容器的 zt 形状**(method_table 的 `Codec` 列在这几行上是错的,见 container.js 头注)。
// 空间 100045260 认证后那 3 帧,前两帧正好是 _loadDependencies 与 _loadObjectsData。
'use strict';
const fs = require('fs');
const path = require('path');
// 语义表:methodId(十进制字符串)→ 名字 + 方向
const METHOD = {
'3216143066888387731': { Name: '_loadDependencies', Dir: 's2c' },
'7640916300855664666': { Name: '_loadObjectsData', Dir: 's2c' },
'8179162314198150518': { Name: '_setModelCommandAlias', Dir: 's2c' },
'9038756763225879447': { Name: '_unloadObjects', Dir: 's2c' },
'1816792453857564692': { Name: 'G.q1wd(客户端请求对象数据)', Dir: 'c2s' },
};
function Name(MethodId) {
const M = METHOD[String(MethodId)];
return M ? M.Name : null;
}
// G.q1wd 的请求负载:实测 4 字节大端 i32(老注释里写的 ObjectsData 结构对不上字节,故按实测来)
function DescribeRequest(Payload) {
if (Payload.length === 4) return { Kind: 'i32', Value: Payload.readInt32BE(0) };
return { Kind: '未知', Hex: Payload.toString('hex').slice(0, 64) };
}
// 从 method_table.json 里把这 4 行的模型名/ModelId 也读出来,便于日志与后续建表
function LoadRows(NamesDir) {
const P = path.join(NamesDir, 'method_table.json');
if (!fs.existsSync(P)) return [];
const J = JSON.parse(fs.readFileSync(P, 'utf8'));
return (J.Rows || []).filter((R) => METHOD[String(R.MethodId)]);
}
module.exports = { METHOD, Name, DescribeRequest, LoadRows };
+205
View File
@@ -0,0 +1,205 @@
// responder.js - 查库定向应答:把上行接到 store.js 上
//
// ★ 优先级口径(2026-09-17 真机教训,别再反过来):
// **已播发的帧(空间模板 / 会话回放)优先,查库只补它们没覆盖的东西。**
// 早先按「查库优先」接管,真机在**入口空间**当场抛异常:
// 模板在 EnterSpace 时已经发过那条 _loadObjectsData 了,查库路径又按 G.q1wd(1) 发了第二遍,
// 客户端 nap() 里 `s2c_1.has(t)` 命中 → module_10849.js:440 抛 "Model already subscribed!"。
// (`nap` 是**模型订阅**,同一模型订阅两次直接抛;这跟 devicesLoaded 那种「覆盖写设备行」不一样。)
//
// 接管范围由 Scope 决定,默认**只接管 ba.lcr**:
// BaLcr(默认开) ba.lcr 7806260029063678398 靶 = 槽位对象 id → 一条 devicesLoaded
// 安全原因:客户端消费它走 srg(按设备 id 覆盖写 + 置 mounted 位),重复下发幂等,不会抛异常。
// LsXbl(默认关) ls.xbl 2528403217084400809 靶 = 要卸载的对象 id → 一条 _unloadObjects([靶])
// 关的原因:模板空间在进空间时已经把「对 ls.xbl 的应答」一起播发了(模板是照抄真实会话的帧、
// 不区分触发者),再答一遍属于重复卸载,没验证过客户端会不会有副作用。
// GQ1wd(默认关) G.q1wd 1816792453857564692 负载 i32 = n → 按库里的批次组一条 _loadObjectsData(pt)
// 关的原因有两条:① 与模板/回放里那条**逐字节正确**的 pt 撞车(就是上面那个异常);
// ② zt 批次里存的是**类型 id**,展开成实例 id 只能靠 SpaceObjects 反查(实测抓包 1371/20475,
// 展开出来 1368/20461),这一步是推测,不该拿它覆盖已知正确的字节。
// 要实验请显式 `--store-all`。
// 开关见 index.js 的 --store-all。
//
// 「X → 批次身份」表(按空间维度,本对象持有):只有 GQ1wd 接管时才用得上
// 每发一条含 zt 的帧(回放器发的也算)就更新 X → { SourceFile, RecordIndex, BatchId } —— X 是客户端侧
// 回调号、会被复用(schema.sql 头注 8),所以身份必须记成「哪一条 zt 记录」,跟 Containers/Dependency* 同一套自然键。
//
// 计数口径(每一条上行记录只走一条路,见 Counters()):
// Hit 查库路径接管并**成功发出**了帧
// Miss 库可用但这条查不到(缺行/缺字段/没发过 X=n)→ 交给回放
// Disabled 该上行类型未在接管范围内(默认的 ls.xbl / G.q1wd 走这里)→ 交给回放
// Unavailable 库不可用(没连上 / 表缺)→ 交给回放(行为与「没有本文件」完全一致)
// Fallback 已接管但后续查库/组帧/发帧失败 → 显式回放补发
// FallbackFrames 这些回落补发出来的帧数(不统计 index.js 那条正常回放路径发的帧)
'use strict';
const Store = require('./store');
const METHOD = {
BaLcr: '7806260029063678398', // ba.lcr
LsXbl: '2528403217084400809', // ls.xbl
GQ1wd: '1816792453857564692', // G.q1wd(客户端请求对象数据,负载 i32)
};
const TAG = {};
TAG[METHOD.BaLcr] = 'ba.lcr';
TAG[METHOD.LsXbl] = 'ls.xbl';
TAG[METHOD.GQ1wd] = 'G.q1wd';
function MakeResponder(Deps) {
const D = Deps || {};
const SpaceId = String(D.SpaceId);
const Log = typeof D.Log === 'function' ? D.Log : () => {};
// 严格优先级:命中查库的这条上行**不再交给回放器发帧**(回放器只推进顺序)。
// 默认关(false):查库应答照发,回放器行为与今天一模一样 —— 现状已知能跑通,不拿它冒险;
// index.js 的 --store-preempt 打开严格模式。
const Preempt = !!D.Preempt;
// 接管范围:默认只开 ba.lcr(理由见文件头注)
const Scope = Object.assign({ BaLcr: true, LsXbl: false, GQ1wd: false }, D.Scope || {});
const XTable = new Map();
const Count = { Hit: 0, Miss: 0, Disabled: 0, Unavailable: 0, Fallback: 0, FallbackFrames: 0, Suppressed: 0, ByMethod: {} };
function Bump(Kind, Tag) {
Count[Kind]++;
const K = Kind + '/' + Tag;
Count.ByMethod[K] = (Count.ByMethod[K] || 0) + 1;
}
// 我们(含会话回放器)每发一帧就调一次:把帧里每条 zt 的 X 记下来
function NoteSentFrame(F) {
const Records = (F && F.Records) || [];
for (let I = 0; I < Records.length; I++) {
const R = Records[I];
if (R.Layout !== 'zt' || !R.Value || !Array.isArray(R.Value.Batches)) continue;
const X = Number(R.Value.X);
XTable.set(String(X), {
SourceFile: F.File,
RecordIndex: I,
BatchId: X,
ObjectIds: R.Value.Batches.map((B) => String(B.ObjectId)),
});
}
}
function ReadI32(Payload) {
if (!Buffer.isBuffer(Payload) || Payload.length < 4) return null;
return Payload.readInt32BE(0);
}
// 同步判定:返回 thunk(查库路径**已接管**这条上行)或 null(让位给回放 / 未实现清单)
// Ctx = { Sender, Replay, ReplaySender }:Sender(Buf)→bool 是发字节的出口;Replay 是会话回放器(可为 null)
function Prepare(R, TargetId, Ctx) {
const C = Ctx || {};
const Method = String(R.MethodId);
const Tag = TAG[Method];
if (!Tag) return null;
if (!Store.Available()) {
Bump('Unavailable', Tag); // 库不可用:整条查库路径静默让位(store.js 只记一行日志)
return null;
}
// 接管范围之外的上行直接让位(默认的 ls.xbl / G.q1wd 走这里,见文件头注)
const ScopeKey = Method === METHOD.BaLcr ? 'BaLcr' : Method === METHOD.LsXbl ? 'LsXbl' : 'GQ1wd';
if (!Scope[ScopeKey]) {
Bump('Disabled', Tag);
return null;
}
if (Method === METHOD.BaLcr) {
const Rec = Store.DevicesLoaded(SpaceId, String(TargetId));
if (!Rec) {
Bump('Miss', Tag);
return null;
}
return () => {
Log('→ 查库应答:devicesLoaded(槽位 ' + TargetId + ') ' + Rec.Fields.devices.length + ' 个设备 / mountedDevice=' +
(Rec.Fields.mountedDevice === null ? 'null' : Rec.Fields.mountedDevice));
return Emit([Rec], Tag, R, TargetId, C);
};
}
if (Method === METHOD.LsXbl) {
// UnloadObjects 是纯函数(id 来自上行靶),不需要查表:库不可用时才是真查不到
const Rec = Store.UnloadObjects(SpaceId, [String(TargetId)]);
return () => {
Log('→ 查库应答:unloadObjects([靶 ' + TargetId + ']) 1 条(与 ls.xbl 1:1)');
return Emit([Rec], Tag, R, TargetId, C);
};
}
// G.q1wd
const N = ReadI32(R.Payload);
const X = N === null ? null : XTable.get(String(N));
if (!X) {
Bump('Miss', Tag);
Log('→ 查库未命中:G.q1wd(' + (N === null ? '负载不是 4 字节 i32' : N) + ') 没发过这个 X 的 zt → 回落回放');
return null;
}
Log('→ 查库应答:G.q1wd(' + N + ') 命中已发出的 zt 批次(' + X.SourceFile + ' 第 ' + (X.RecordIndex + 1) +
' 条,' + X.ObjectIds.length + ' 个类型 id)');
return async () => {
try {
const Ids = await Store.BatchInstances(SpaceId, X.SourceFile, X.RecordIndex, X.BatchId);
if (!Ids) throw new Error('批次的实例 id 展开为空');
const Rec = await Store.ObjectsData(SpaceId, Ids);
if (!Rec) throw new Error('ObjectsData 查库失败');
Log('→ 查库应答:objectsData(' + Rec.Value.Objects.length + ' 个对象 / ' + Rec.Value.Items.length + ' 项模型数据)');
return Emit([Rec], Tag, R, TargetId, C);
} catch (Err) {
Log(' ⚠ 查库路径未成(' + Err.message + ')→ 回落会话回放');
return Fallback(R, TargetId, C, Tag);
}
};
}
// 组帧 + 发帧;失败一律显式回落回放(不发半条帧)
function Emit(Records, Tag, R, TargetId, C) {
let Buf;
try {
Buf = Store.BuildFrame(Records);
} catch (Err) {
Log(' ⚠ 组帧失败(' + Err.message + ')→ 回落会话回放');
return Fallback(R, TargetId, C, Tag);
}
const Ok = typeof C.Sender === 'function' ? C.Sender(Buf) !== false : false;
if (!Ok) {
Log(' ⚠ 查库应答没发出去(连接已断)→ 不计命中');
return false;
}
Bump('Hit', Tag);
if (Preempt && C.Replay && typeof C.Replay.OnUplink === 'function') {
// 严格模式:这条上行已经由查库应答接管 —— 回放器只推进自己的顺序,不再重复发那条帧
const Suppressed = C.Replay.OnUplink(
{ MethodId: R.MethodId, Target: String(TargetId), Name: R.Name },
C.ReplaySender,
{ Suppress: true },
);
Count.Suppressed++;
Log(' (严格优先:回放器跳过这条上行对应的帧,只推进顺序)');
}
return true;
}
function Fallback(R, TargetId, C, Tag) {
Bump('Fallback', Tag);
if (!C.Replay || typeof C.Replay.OnUplink !== 'function') return false;
const Hits = C.Replay.OnUplink(
{ MethodId: R.MethodId, Target: String(TargetId), Name: R.Name },
C.ReplaySender,
);
if (Hits) {
Count.FallbackFrames += Hits;
Log(' → 回落回放:发出 ' + Hits + ' 帧');
}
return Hits > 0;
}
function Counters() {
return {
Hit: Count.Hit, Miss: Count.Miss, Disabled: Count.Disabled, Unavailable: Count.Unavailable,
Fallback: Count.Fallback, FallbackFrames: Count.FallbackFrames, Suppressed: Count.Suppressed,
ByMethod: Object.assign({}, Count.ByMethod), X: XTable.size, Preempt, Scope: Object.assign({}, Scope),
};
}
return { SpaceId, Preempt, Scope, Prepare, NoteSentFrame, Counters, XTable };
}
module.exports = { MakeResponder, METHOD, TAG };
+154
View File
@@ -0,0 +1,154 @@
-- schema.sql - 3DTank 替代服务器的持久化表结构(库名见 local/db.json 的 Database,默认 tanki)
--
-- 设计口径(与协议对齐,不是凭空拟的):
-- 1. 协议里的「对象」= (ObjectId, GameTypeId) 一对 i64,来自 _loadObjectsData 的 pt.Objects
-- (tools/server/container.js 的 ReadPt)。所以 SpaceObjects 就是对象仓库主表。
-- 2. 「对象的模型数据」= pt.Items 里属于该对象的那一段(container.js 的 LtItem:
-- Id + 就地展开的字段)。按 Ord 保序存进 ObjectModels,重放时按序拼回去。
-- 3. `G.q1wd(n)` 的 n 就是 _loadDependencies 容器 zt 的首字段 X(源码依据:
-- module_29554.js 的 O.p7g 先读 i32 进 k1wc_1;module_45686.js:362 的 h1wd 用 k1wc_1 回调 q1wd)。
-- 所以 DependencyBatches / DependencyItems 就是「第 n 批要装哪些对象/模型」的表。
-- 4. Containers 存的是「按 methodId 定布局」解出来的原始容器记录(Layout + ValueJson),
-- 既能逐字节重建原帧(当审计基线),也是导入失败时唯一的真相来源。
-- 5. 所有 id 列一律 **有符号 BIGINT**:协议里确实存在负数合成对象 id
-- (实测 -2147481788 = 0xFFFFFFFF80000744 一族,见 tools/audit_space_init.js 头注)。
-- Node 侧 mysql2 按字符串取(见 db.js 的 supportBigNumbers),与现有代码的「id 一律当字符串」一致。
-- 6. zt.Items 的 `R`/`E`/`U`/`S`/`O` 五个字母**照容器布局写**,语义尚未解出,不硬起名字误导后人。
-- 7. 所有 JSON 文本列用 **LONGTEXT + JSON_VALID 检查**,不用 JSON 列。两个实测原因:
-- a) MySQL 的 JSON 列对 double 不是逐位可逆(实测同一个值往返差 1 ulp:
-- -10.321807861328125 → -10.321807861328123)。本项目的底线是「从库里能把帧逐字节重建」,
-- 一旦出现真 f64 字段就会破功;LONGTEXT 存原文本,不经过 double。
-- b) 模型 id 是 i64,可以超过 2^53(如 3074457345817168740),走 JSON 数字有精度风险。
-- 8. `zt` 的首字段 X(客户端 `G.q1wd` 回的那个数)是**客户端侧回调号、会复用**:
-- 实测空间 100073070 里 X=1 被 11 条互不相同的 zt 重发。所以依赖批次的身份是
-- 「哪一条容器记录」,主键 = `(SpaceId, SourceFile, RecordIndex, Ord)`;按 X 查只走索引。
-- 9. 同一个对象在不同空间里可以被重发且内容不同(实测本账号对象 3074457345817168740 有 3 份不同内容),
-- 所以 ObjectModels 的唯一键是 `(SpaceId, ObjectId, Ord)`。
-- 账号:登录 token(Hn.wh4 的 loginHash)→ 玩家对象 id
CREATE TABLE IF NOT EXISTS Accounts (
Uid VARCHAR(24) NOT NULL COMMENT '玩家对象 id(i64 十进制字符串)',
LoginHash VARCHAR(160) NULL COMMENT 'Hn.wh4 的登录 token',
NickName VARCHAR(64) NULL,
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (Uid),
KEY AccountsLoginHashIdx (LoginHash)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '账号';
-- 对象仓库主表:(ObjectId, GameTypeId)
CREATE TABLE IF NOT EXISTS SpaceObjects (
ObjectId BIGINT NOT NULL COMMENT '对象 id(可负)',
GameTypeId BIGINT NOT NULL COMMENT '对象类型 id',
OwnerUid VARCHAR(24) NULL COMMENT '归属账号;空间级合成对象为 NULL',
SourceSpace VARCHAR(24) NULL COMMENT '首次出现的空间号',
PRIMARY KEY (ObjectId),
KEY SpaceObjectsGameTypeIdx (GameTypeId),
KEY SpaceObjectsOwnerIdx (OwnerUid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '对象仓库';
-- 对象的模型数据项(pt.Items 按对象切出来的片,按 Ord 保序)
CREATE TABLE IF NOT EXISTS ObjectModels (
Seq BIGINT NOT NULL AUTO_INCREMENT,
SpaceId VARCHAR(24) NOT NULL COMMENT '所属空间(同一对象在不同空间可以内容不同)',
ObjectId BIGINT NULL COMMENT '归属对象;NULL = 没能归属到具体对象(保留但不参与组帧)',
Ord INT NOT NULL COMMENT '该对象内的次序,从 0 开始',
ModelId BIGINT NOT NULL COMMENT '0 = 「id=0 + 紧跟一个 i64 字面量」那种项',
ValueJson LONGTEXT NOT NULL COMMENT 'container.js 的 LtItem.Value 的 JSON 文本(Id 为 0 时是 i64 字符串)',
PRIMARY KEY (Seq),
UNIQUE KEY ObjectModelsOrdIdx (SpaceId, ObjectId, Ord),
KEY ObjectModelsModelIdx (ModelId),
CONSTRAINT ObjectModelsJsonChk CHECK (JSON_VALID(ValueJson))
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '对象模型数据';
-- 依赖批次(_loadDependencies 容器 zt):BatchId 就是首字段 X,也就是客户端 G.q1wd 回的那个数
--
-- ★ 为什么主键里带 SourceFile / RecordIndex(而不是 (SpaceId, BatchId)):
-- X 是**客户端侧的回调号**,会被复用 —— 实测空间 100073070 里 X=1 被 11 条互不相同的 zt
-- 重发(s2c_0000 带 8 个 modelId、s2c_0003 带 16 个、s2c_0006 带 4 个…)。
-- 所以「一批依赖」的身份 = **哪一条容器记录**,跟 Containers 表用同一套自然键;
-- 按 X 查只是索引(DependencyBatchesLookupIdx),不是唯一性。
CREATE TABLE IF NOT EXISTS DependencyBatches (
SpaceId VARCHAR(24) NOT NULL,
SourceFile VARCHAR(24) NOT NULL,
RecordIndex INT NOT NULL,
Ord INT NOT NULL,
BatchId INT NOT NULL COMMENT 'zt 首字段 X(客户端 G.q1wd 回的就是它,会复用)',
ObjectId BIGINT NOT NULL,
ModelIds LONGTEXT NOT NULL COMMENT '该批次要装的模型 id 列表(十进制字符串数组的 JSON)',
PRIMARY KEY (SpaceId, SourceFile, RecordIndex, Ord),
KEY DependencyBatchesLookupIdx (SpaceId, BatchId),
CONSTRAINT DependencyBatchesJsonChk CHECK (JSON_VALID(ModelIds))
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '依赖批次-对象';
CREATE TABLE IF NOT EXISTS DependencyItems (
SpaceId VARCHAR(24) NOT NULL,
SourceFile VARCHAR(24) NOT NULL,
RecordIndex INT NOT NULL,
Ord INT NOT NULL,
BatchId INT NOT NULL,
`R` BIGINT NOT NULL COMMENT '字母照 zt 布局,语义未解',
`E` SMALLINT NOT NULL,
`U` SMALLINT NOT NULL,
`S` BIGINT NOT NULL,
`O` TINYINT UNSIGNED NOT NULL,
Skipped LONGTEXT NOT NULL COMMENT '该跳过的 id 列表(十进制字符串数组的 JSON)',
PRIMARY KEY (SpaceId, SourceFile, RecordIndex, Ord),
KEY DependencyItemsLookupIdx (SpaceId, BatchId),
CONSTRAINT DependencyItemsJsonChk CHECK (JSON_VALID(Skipped))
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '依赖批次-资源项';
-- 原始容器记录(审计基线 + 逐字节重建的来源)
CREATE TABLE IF NOT EXISTS Containers (
Seq BIGINT NOT NULL AUTO_INCREMENT,
SpaceId VARCHAR(24) NOT NULL,
SourceFile VARCHAR(24) NOT NULL COMMENT '如 s2c_0001.bin',
RecordIndex INT NOT NULL COMMENT '帧内第几条记录,从 0 开始',
ObjectId BIGINT NOT NULL COMMENT '记录头里的 objectId',
MethodId BIGINT NOT NULL,
Layout VARCHAR(4) NOT NULL COMMENT 'lt / pt / zt',
`X` INT NULL COMMENT '仅 zt 有:依赖批次号',
ValueJson LONGTEXT NOT NULL COMMENT 'container.js 的 Value 的 JSON 文本',
PRIMARY KEY (Seq),
UNIQUE KEY ContainersLocIdx (SpaceId, SourceFile, RecordIndex),
KEY ContainersMethodIdx (MethodId),
CONSTRAINT ContainersJsonChk CHECK (JSON_VALID(ValueJson))
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '容器记录原文';
-- 物品:仓库 / 市场 / 已挂载 三个旗标(Items 表就是 itemsOnDepot / itemsOnMarket / mountedItems 的落地)
CREATE TABLE IF NOT EXISTS Items (
ItemId BIGINT NOT NULL,
OwnerUid VARCHAR(24) NULL,
InDepot TINYINT (1) NOT NULL DEFAULT 0,
InMarket TINYINT (1) NOT NULL DEFAULT 0,
Mounted TINYINT (1) NOT NULL DEFAULT 0,
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (ItemId),
KEY ItemsOwnerIdx (OwnerUid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '物品';
-- 车库挂载位(对应 devicesLoaded 记录里那些 ObjectId)
CREATE TABLE IF NOT EXISTS DeviceSlots (
SlotObjectId BIGINT NOT NULL,
OwnerUid VARCHAR(24) NULL,
PRIMARY KEY (SlotObjectId)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '挂载位';
-- 挂载位的可用设备(devicesLoaded 的 devices 列表)
CREATE TABLE IF NOT EXISTS SlotDevices (
SlotObjectId BIGINT NOT NULL,
Ord INT NOT NULL,
DeviceId BIGINT NOT NULL,
PRIMARY KEY (SlotObjectId, DeviceId),
KEY SlotDevicesOrdIdx (SlotObjectId, Ord)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '挂载位可用设备';
-- 挂载位当前挂载的设备(devicesLoaded 的 mountedDevice)。
-- ★ 抓包里 28 条 mountedDevice 全是 null,所以这张表初始全空;
-- 「已装备标记」要显示出来,就靠这里被写进值(挂载动作落地后)。
CREATE TABLE IF NOT EXISTS Mounted (
SlotObjectId BIGINT NOT NULL,
OwnerUid VARCHAR(24) NULL,
DeviceId BIGINT NULL COMMENT 'NULL = 该挂载位当前没有装备',
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (SlotObjectId)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '当前挂载';
+187
View File
@@ -0,0 +1,187 @@
// selftest.js - MakeWriter 往返自检:拿抓包里的真实空间帧,逐字段解码 → 再编码 → 与原字节比对
//
// 这是写包能力的唯一验收手段:真服务器要能发 s2c,前提是「解出来再写回去」必须逐字节一致。
// 覆盖不到的地方(容器 lt/pt/zt、模块内建 codec 如 Ea)会被单独计数并列出,作为 M2 的待补清单。
//
// 用法: node server/selftest.js [--dir ../packets/plain] [--limit 0] [--show 5]
'use strict';
const fs = require('fs');
const path = require('path');
const { LoadTables, MakeReader } = require('../lib_space_codec');
const Link = require('./link');
const { MakeWriter, ResolveFields } = require('./codec');
const { MakeContainerCodec } = require('./container');
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('dir', path.join(PROJECT, 'packets', 'plain')));
const Limit = Number(Arg('limit', '0')) || 0;
const Show = Number(Arg('show', '5')) || 5;
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Reader = MakeReader(Tables, { EnumBytes: 4 });
const Writer = MakeWriter(Tables, { EnumBytes: 4 });
const Container = MakeContainerCodec(Tables, Reader, Writer);
// 容器帧(methodId 不在普通 codec / 上行表里):用容器三布局判定,要求恰好吃到帧尾
const BoxStat = { Tried: 0, Detected: 0, Exact: 0, Bad: 0, ByLayout: new Map(), FailSamples: [] };
function TryContainer(Body, At, File) {
if (At + 16 > Body.length) return;
const MethodId = Body.readBigInt64BE(At + 8).toString();
if (Tables.CodecByMethod.has(MethodId) || Tables.UplinkByMethod.has(MethodId)) return;
BoxStat.Tried++;
const Off = At + 16;
const Hits = ['zt', 'pt', 'lt'].filter((L) => {
const R = Container.Read(L, Body, Off);
return !R.Bad && R.Next === Body.length;
});
if (!Hits.length) {
BoxStat.Bad++;
if (BoxStat.FailSamples.length < 5) BoxStat.FailSamples.push(File + ' methodId=' + MethodId);
return;
}
BoxStat.Detected++;
const Layout = Hits[0];
BoxStat.ByLayout.set(Layout, (BoxStat.ByLayout.get(Layout) || 0) + 1);
const R = Container.Read(Layout, Body, Off);
try {
if (Container.Write(Layout, R.Value).equals(Body.slice(Off))) BoxStat.Exact++;
else Note('容器写回不一致', File + ' ' + Layout);
} catch (Err) {
Note('容器写侧抛错', File + ' ' + Layout + ' ' + Err.message);
}
}
// 首字节是第一个 id 的最高位,且 8 字节后的 methodId 在「下行 codec 表」或「上行命令表」里 → 认作空间记录流
// (实测两张表的 methodId 基本不重合:上行 360 条只在 uplink_table,下行 codec 只在 codec_fields)
function IsSpaceLike(Body, At) {
if (At >= Body.length) return false;
const B0 = Body[At];
if (B0 !== 0x00 && B0 !== 0xff) return false;
if (At + 16 > Body.length) return false;
const M = Body.readBigInt64BE(At + 8).toString();
return Tables.CodecByMethod.has(M) || Tables.UplinkByMethod.has(M);
}
const Stats = { Dirs: 0, Files: 0, Frames: 0, SpaceFrames: 0, Records: 0, Exact: 0, Bad: 0, Unsupported: 0, Mismatch: 0 };
const UnsupportedKinds = new Map();
const BadSamples = [];
const MismatchSamples = [];
function Note(Kind, Msg) {
UnsupportedKinds.set(Kind, (UnsupportedKinds.get(Kind) || 0) + 1);
if (BadSamples.length < 20) BadSamples.push(Kind + ' ' + Msg);
}
function ScanFile(File) {
Stats.Files++;
const Buf = fs.readFileSync(File);
const Frames = Link.ParseFrames(Buf);
if (!Frames) return;
for (const Frame of Frames) {
Stats.Frames++;
let Body;
try {
Body = Link.DecodeFrame(Frame);
} catch (Err) {
continue;
}
const At = Link.NullmapSize(Body, 0);
if (!IsSpaceLike(Body, At)) {
TryContainer(Body, At, path.basename(File));
continue;
}
Stats.SpaceFrames++;
let Off = At + 8;
while (Off + 8 <= Body.length) {
const MethodId = Body.readBigInt64BE(Off).toString();
const { Codec, Fields } = ResolveFields(Tables, MethodId, Tables.UplinkByMethod.get(MethodId));
if (!Codec && !Fields.length) break;
const Label = Codec ? Codec.ModelName || Codec.ModelClassReal || '?' : '上行命令';
const MethodRaw = Body.slice(Off, Off + 8);
const Start = Off;
Off += 8;
const Values = [];
let Bad = false;
for (const F of Fields) {
const R = Reader.ReadValue(Body, Off, F.Type, F.TypeCodec, 0);
Values.push([F, R.Value]);
if (R.Bad || R.Next <= Off) {
Bad = true;
break;
}
Off = R.Next;
}
Stats.Records++;
if (Bad) {
Stats.Bad++;
Note('解码中断', Label + ' methodId=' + MethodId);
continue;
}
const Original = Body.slice(Start, Off);
let Encoded = null;
try {
const Parts = [MethodRaw];
for (const [F, V] of Values) Parts.push(Writer.WriteValue(F.Type, F.TypeCodec, V, 0));
Encoded = Buffer.concat(Parts);
} catch (Err) {
Stats.Unsupported++;
Note(String(Err.message).split(':')[0], Label + ' methodId=' + MethodId);
continue;
}
if (Encoded.equals(Original)) Stats.Exact++;
else {
Stats.Mismatch++;
if (MismatchSamples.length < Show) {
MismatchSamples.push(
Label + ' methodId=' + MethodId + '\n 原 ' + Original.toString('hex').slice(0, 160) +
'\n 写 ' + Encoded.toString('hex').slice(0, 160),
);
}
}
}
}
}
function ScanDir(Dir) {
for (const E of fs.readdirSync(Dir, { withFileTypes: true })) {
const P = path.join(Dir, E.name);
if (E.isDirectory()) {
Stats.Dirs++;
ScanDir(P);
} else if (/^[cs]2[cs]_\d+\.bin$/.test(E.name)) {
if (Limit && Stats.Files >= Limit) return;
ScanFile(P);
}
}
}
ScanDir(Root);
console.log('MakeWriter 往返自检(' + Root + ')');
console.log(' 目录 ' + Stats.Dirs + ' 文件 ' + Stats.Files + ' 帧 ' + Stats.Frames + ' 空间帧 ' + Stats.SpaceFrames);
console.log(' 记录 ' + Stats.Records + '');
console.log(' ✓ 逐字节一致 ' + Stats.Exact);
console.log(' ✗ 不一致 ' + Stats.Mismatch);
console.log(' ⚠ 解码中断 ' + Stats.Bad);
console.log(' ⚠ 写侧不支持 ' + Stats.Unsupported + '(类型/编码还没做)');
if (UnsupportedKinds.size) {
console.log(' 写侧不支持的原因(Top 10):');
for (const [K, V] of [...UnsupportedKinds].sort((A, B) => B[1] - A[1]).slice(0, 10)) console.log(' ' + String(V).padStart(6) + ' ' + K);
}
if (BadSamples.length) {
console.log(' 样本(前 10):');
for (const S of BadSamples.slice(0, 10)) console.log(' ' + S);
}
if (MismatchSamples.length) {
console.log(' 不一致样例:');
for (const S of MismatchSamples) console.log(' ' + S);
}
console.log(' 容器帧(methodId 不在普通 codec / 上行表里): 候选 ' + BoxStat.Tried + ' 判定出布局 ' + BoxStat.Detected + ' 逐字节一致 ' + BoxStat.Exact + ' 判定失败 ' + BoxStat.Bad);
if (BoxStat.ByLayout.size) console.log(' 布局分布: ' + JSON.stringify([...BoxStat.ByLayout]));
for (const S of BoxStat.FailSamples) console.log(' 判定失败样例: ' + S);
+540
View File
@@ -0,0 +1,540 @@
// semantic.js - 空间帧的「语义层」:内部用 JSON 表示,收发时用 codec 双向序列化
//
// 为什么要有这一层:模板里的帧原来是「原始 hex」,想改一个用户名字段就得做字节替换。
// 有了它,帧变成可读可改的字段 JSON,改完交给 codec(codec.js / container.js)重新编码出字节。
//
// 帧体结构(依据 src/module_61860.js:2111-2131 的空间命令解析 u7m):
// 帧体 = [nullmap][记录…]
// nullmap 是「一条记录 1 bit」的位图,按 MSB 先读(src/module_24635.js:357-364 的 Et.xy):
// bit=1 → 1 字节短命令号(查 Gr(space) 表,src/module_61860.js:2116-2118)
// bit=0 → [objectId i64][methodId i64][负载](src/module_61860.js:2119)
// **每条记录自带 objectId**(src/module_61860.js:2120 用第一个 i64 定位对象)。
// 单记录帧里它恰好等于空间 id,所以旧模板把它当「帧级 spaceId」——语义层不沿用这个简化。
//
// 负载 = 该 s2c 方法的有序字段串接。字段表来自 names/downlink_table.json(tools/mine_downlink.js 挖的),
// 容器型负载(modelService 的 4 条)交给 container.js 的三布局。
//
// 用法:
// const Semantic = require('./semantic').Load(namesDir);
// const R = Semantic.DecodeRecord(MethodId, Buf, Off);
// const Bytes = Semantic.EncodeRecord(MethodId, R);
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./link');
const { LoadTables, MakeReader } = require('../lib_space_codec');
const { MakeWriter, ResolveFields } = require('./codec');
const { MakeContainerCodec } = require('./container');
const NAMES_DIR = path.resolve(__dirname, '..', '..', 'names');
// 匿名字段(表里只有线宽、没有名字)在 JSON 里的键:与 lib_space_codec.js:181 / codec.js:138 完全同口径,
// 否则「解出来的键」和「写回去要找的键」对不上。
function FieldKey(F) {
return F.Name && F.Name !== '(匿名)' && F.Name !== '?' ? F.Name : '(匿名#' + F.Order + ')';
}
// nullmap 的位图(依据 src/module_24635.js:640-695 的解码器 _n.j7h 与 :612-639 的编码器 i7h):
// 短形态(首字节 bit7=0):总长 = 1 + ((b0 & 0x60) >>> 5),位图 = 这串字节按 MSB 先读、**跳过前 3 个 bit**
// (那 3 bit 是长度标签:i7h 存的是 `byte >>> 3`,即标签占低 3 位,位图占高 5 位);
// 位图 bit 数 = 8 * 总长 - 3,即 5 / 13 / 21 / 29。
// 长形态(首字节 bit7=1):长度标签占 1 或 3 字节(link.js 的 NullmapSize 算的正是总长),
// 位图 = 标签之后的字节原样按 MSB 先读,bit 数 = 长度 * 8。
// 注意:这个位图是「记录形态位 + 模型里 opt 字段的位」共用的**比特流**(opt 位见 src/module_24635.js:412-417 的
// Ht.p7g/o7g),所以第 N 条记录的形态位并不总是第 N 个 bit —— 只有前面记录的负载没消耗 bit 时才严格对齐。
// 因此解码必须维护一个**跨记录顺序推进**的位游标 Ctx = { Bits, Index }(见 DecodeFrameBody),
// 否则「前一条是容器、负载吃掉上千个 opt 位」时后一条的形态位就读错(误判成短命令)。
function NullmapBits(Bytes) {
const Bits = [];
const Push = (B) => {
for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1);
};
if (Bytes.length && Bytes[0] & 0x80) {
for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]);
} else {
for (const B of Bytes) Push(B);
Bits.splice(0, 3); // 前 3 bit 是长度标签(src/module_24635.js:614)
}
return Bits;
}
// NullmapBits 的逆:把(就地改过的)位图写回 nullmap 字节,**长度一字节都不变**。
// 为什么必须长度不变:nullmap 的长度标签(短形态低 3 bit = i7h 的 `byte >>> 3`;长形态 1/3 字节)
// 属于帧结构,位图位数 = 8*总长-3(短)/ 8*总长(长)。长度变了说明比特流顺序还没对,直接抛错。
// 位序号用「整串按 MSB 先读」的口径:短形态标签占 bit 0..2,长形态标签占前 Skip 个字节。
function WriteNullmapBits(Orig, Bits) {
const Out = Buffer.from(Orig);
const SkipBits = Orig.length && Orig[0] & 0x80 ? (Orig[0] & 0x40 ? 24 : 8) : 3;
const Cap = Orig.length * 8 - SkipBits;
if (Bits.length !== Cap) {
throw new Error('位图位数 ' + Bits.length + ' 与 nullmap 容量 ' + Cap + '(' + Orig.length + 'B)不一致:比特流顺序不对');
}
for (let I = 0; I < Cap; I++) {
const P = SkipBits + I;
const Mask = 0x80 >> (P & 7);
if (Bits[I]) Out[P >> 3] |= Mask;
else Out[P >> 3] &= ~Mask;
}
return Out;
}
function Load(NamesDir) {
const Dir = NamesDir || NAMES_DIR;
const Tables = LoadTables(Dir);
const Reader = MakeReader(Tables, { EnumBytes: 4 });
const Writer = MakeWriter(Tables, { EnumBytes: 4 });
// 「位模式」的实例:opt<T> 的「空/非空」从 nullmap 比特流读/写(真实线形,见 lib_space_codec.js:16-22)。
// 读侧(BitReader)**只在调用方给了位游标时用**:dispatch.js 的上行/下行字段路径与 audit_space_init.js
// 走的是 Reader(旧的 1 字节 flag 近似),口径一字不变。
const BitReader = MakeReader(Tables, { EnumBytes: 4, OptBits: true });
// 写侧的字段路径实例(FieldWriter)与容器的 LeafWriter(container.js:139-144)**同口径**:opt 一律
// 按真实线形写(空值 0 字节、有值只写内层),不给 Ctx 时不写位。
// 为什么字段路径不能继续用「1 字节 flag」:帧的 nullmap 是**帧级原样带上**的(space.js:36 的
// Link.EncodeFrame(Body, Nm)),负载必须是位模式才对得上;否则实测 space-100072911/s2c_0001 会差
// 1 个字节(@37113 原始 0x92 被 codec.js:109 的 `Buffer.from([1])` 写成 0x01)。
// 容器记录一直是位模式写的,所以能逐字节还原;字段路径这里只是把口径统一过来。
// 老的「1 字节 flag」口径原样保留在 Writer(codec.js 的默认实例)里,不动它。
const FieldWriter = MakeWriter(Tables, { EnumBytes: 4, OptBits: true });
const Container = MakeContainerCodec(Tables, Reader, Writer);
// 下行字段表:methodId → 该 s2c 方法实际读的有序字段(Kind='fields')或容器标记(Kind='container')
const Downlink = JSON.parse(fs.readFileSync(path.join(Dir, 'downlink_table.json'), 'utf8'));
const SpecByMethod = new Map();
for (const C of Downlink.Commands || []) SpecByMethod.set(String(C.MethodId), C);
const ContainerName = new Map();
for (const C of Downlink.Containers || []) ContainerName.set(String(C.MethodId), C.Name);
// 可读名(派生命名词典):methodId → LoginCommand.SendLogin 这类标注
const AliasById = Object.create(null);
try {
Object.assign(AliasById, JSON.parse(fs.readFileSync(path.join(Dir, 'command_alias.json'), 'utf8')).MethodIds || {});
} catch (Err) {
// 词典是可选的显示层,缺了不影响语义
}
// 模型真名(method_table 的 Model 列,如 _t / EE):只用于显示
const ModelByMethod = new Map();
try {
const MT = JSON.parse(fs.readFileSync(path.join(Dir, 'method_table.json'), 'utf8'));
for (const R of MT.Rows || []) ModelByMethod.set(String(R.MethodId), R);
} catch (Err) {}
// methodId → 上一次解出来的 {字段名: 值}:EncodeRecord 只给部分字段时用它回填「原值」。
// 之所以要它:模板里改一个字段时不该被迫抄一遍整条记录,但缺的字段必须有据可依(不能编)。
const LastDecoded = new Map();
function NameOf(MethodId, Spec) {
const A = AliasById[MethodId];
if (A && A.Alias) return A.Alias;
if (Spec && Spec.Kind === 'container' && ContainerName.has(MethodId)) return ContainerName.get(MethodId);
return '';
}
function ModelOf(MethodId) {
const M = ModelByMethod.get(MethodId);
return (M && M.Model) || '';
}
// 一条记录的字段表:① downlink_table(专为「这条方法实际读哪些字段」挖的,首选)
// ② codec_fields 里 methodId → 模型 codec 的字段全集(codec.js:166 的既有口径,第二证据源)
// 两个都没有 → null(如实报不可解,不硬凑字段)
function FieldsOf(MethodId, Spec) {
if (Spec) return (Spec.Fields || []).map((F) => ({ Name: F.Name, Type: F.Type, TypeCodec: F.TypeCodec, Order: F.Order }));
const R = ResolveFields(Tables, MethodId, null);
if (R.Codec || (R.Fields && R.Fields.length)) {
return R.Fields.map((F, I) => ({ Name: F.Name, Type: F.Type, TypeCodec: F.TypeCodec, Order: F.Order === undefined ? I + 1 : F.Order, Source: 'codec_fields' }));
}
return null;
}
// ---------------------------------------------------------------- 解码
// Buf[Off..End) 解一条记录的负载。End 缺省 = Buf 末尾(容器只能靠「恰好吃到记录尾」定界,见 container.js)
// Ctx(可选)= 帧级位游标 { Bits, Index }(见 DecodeFrameBody)。给了 ⇒ opt 按 nullmap 的 bit 读,
// 并把本条负载消费掉的 opt 位数写回 Ctx.Index;没给 ⇒ 保持旧的「1 字节 flag」近似
// (audit_space_init.js:86 的 DecodeRecord(MethodId, Buf, From, To) 走的就是这条,不受影响)。
function DecodeRecord(MethodId, Buf, Off, End, Ctx) {
const Id = String(MethodId);
const Spec = SpecByMethod.get(Id);
const Limit = End === undefined ? Buf.length : End;
const Out = {
MethodId: Id,
Name: NameOf(Id, Spec),
Model: ModelOf(Id),
Kind: Spec ? Spec.Kind : 'fields',
Fields: [],
Consumed: 0,
Bad: false,
Note: '',
};
// 「解不动就留原字节」:本函数的每条 Bad 出路都会把**本条负载的原字节**放进 Out.Hex
// (从负载起点 Off 到调用方给的 Limit;解不动时记录尾无从判定,只能按 Limit 取)。
// 为什么非留不可:语义层的承诺是「解不动不猜」,可原来只留了一句 Note——产物里没有任何可回写的
// payload,EncodeFrameBody 走到这里只能拿「前 k 个字段」去编(缺的字段非 opt 会抛错、opt 会被
// 填成 null,见 EncodeRecord 的缺字段分支),那既不像「保真」也不像「报错」,是把解码器的失败
// 偷偷变成了编码器的猜测。留了 Hex 之后 EncodeFrameBody 才有一条「原样透传 + 带上原因」的路。
// ⚠ 这不是兜底:Hex 只存字节、不改判 Bad/Note,调用方(rebase_space_init.js / structure_space_init.js)
// 照样按 Bad 决定「不结构化」。
if (Spec && Spec.Kind === 'container') {
// 容器型:三布局都试,只认「恰好吃到记录尾」的那个(container.js 的 Detect 不猜)
// 有帧级位游标时按 methodId 直接定布局:Detect 内部会重算游标(ResolveCursor 要把前面的记录
// 重走一遍),多记录帧上是 O(n²),而调用方已经把游标送过来了。
const Layout = (Ctx && Ctx.Bits && Container.LAYOUT_BY_METHOD[Id]) || Container.Detect(Buf, Off, Limit);
if (!Layout) {
// 三布局各试一遍,把「吃到哪 / 在哪中断」写进 Note:比一句「解不动」有用得多
// (比如三布局都在 +0 中断 = 布局形状不对;lt 在 +8 中断 = modelRegister 里缺那个 modelId 的 codec)
const Tried = ['zt', 'pt', 'lt'].map((L) => {
const R = Container.Read(L, Buf, Off);
return L + (R.Bad ? ' 在 +' + (R.Next - Off) + ' 处中断' : ' 吃到 +' + (R.Next - Off));
});
Out.Bad = true;
Out.Note = '容器三布局都吃不到记录尾:' + Tried.join(',') + '(记录尾 ' + Limit + ',负载从 ' + Off + ' 起)';
Out.Hex = Buf.slice(Off, Limit).toString('hex'); // 解不动 → 原字节留住(原则见本函数开头)
return Out;
}
const R = Container.Read(Layout, Buf, Off, Ctx);
if (R.Bad) {
Out.Bad = true;
Out.Note = '容器布局 ' + Layout + ' 读到一半失败';
Out.Hex = Buf.slice(Off, Limit).toString('hex');
return Out;
}
Out.Layout = Layout;
Out.Value = R.Value;
Out.Consumed = R.Next - Off;
return Out;
}
const Fields = FieldsOf(Id, Spec);
if (Fields === null) {
Out.Bad = true;
Out.Note = 'methodId=' + Id + ' 不在 downlink_table、也不在 codec_fields 里:没有字段表,不硬解';
Out.Hex = Buf.slice(Off, Limit).toString('hex'); // 解不动 → 原字节留住(原则见本函数开头)
return Out;
}
Out.Source = Spec ? 'downlink' : 'codec_fields';
let At = Off;
const Seen = {};
// opt 走位模式还是字节模式,由**有没有位游标**显式门控(不是全局打开):
// 位游标在位 ⇒ BitReader(位不够会如实报 Bad,不退回按字节猜)。
const UseBits = !!(Ctx && Ctx.Bits);
if (UseBits) BitReader.AttachBits(Ctx.Bits, Ctx.Index);
try {
for (const F of Fields) {
const Key = FieldKey(F);
const R = UseBits
? BitReader.ReadValue(Buf, At, F.Type, F.TypeCodec, 0)
: Reader.ReadValue(Buf, At, F.Type, F.TypeCodec, 0);
Out.Fields.push({ Name: Key, Type: F.Type, Value: R.Value });
// 位模式下 opt<T> 为空是**合法的 0 字节字段**(只有 nullmap 里的 1 个 bit,见
// src/module_24635.js:412-417 的 Ht.p7g:读到位=1 直接 return null)。旧口径把「没推进字节」
// 一律当卡住 → 实测 space-100073070/s2c_0004 的 mountedDevice(opt<i64>) 为空时整条记录被误判 Bad。
// 与 lib_space_codec.js:179 对 list<opt<T>> 的 ZeroOk 同一口径。
const ZeroOk = UseBits && /^opt</.test(String(F.Type));
if (R.Bad || (R.Next <= At && !ZeroOk)) {
Out.Bad = true;
Out.Note = '字段 ' + Key + '(' + F.Type + ')在偏移 ' + At + ' 解不动' + (R.Why ? ':' + R.Why : '');
break;
}
Seen[Key] = R.Value;
At = R.Next;
}
} finally {
if (UseBits) {
Ctx.Index = BitReader.BitCursor().Index; // 本条负载消费掉的 opt 位数(缺口 A 靠它跨记录推进)
BitReader.DetachBits();
}
}
Out.Consumed = At - Off;
if (Out.Bad) Out.Hex = Buf.slice(Off, Limit).toString('hex'); // 解不动 → 原字节留住(原则见本函数开头)
else LastDecoded.set(Id, Seen);
return Out;
}
// ---------------------------------------------------------------- 编码
// Json 形状:{ Fields: {字段名: 值} | [{Name,Value}] };容器型:{ Layout, Value }
// 只给部分字段时,缺的字段用「同一 methodId 上一次解出的原值」回填;连原值都没有且不是 opt → 报错(不静默补零)
// Ctx(可选)= 帧级位游标 { Bits, Index }:给了 ⇒ 除了写出负载,还把 opt 的「空/非空」位按编码顺序
// 写回该比特流(EncodeFrameBody 重建 nullmap 用);没给 ⇒ 只写负载(位由帧级 nullmap 原样带上)。
function EncodeRecord(MethodId, Json, Ctx) {
const Id = String(MethodId);
const Spec = SpecByMethod.get(Id);
const J = Json || {};
if (Spec && Spec.Kind === 'container') {
const Layout = J.Layout || (Spec.Layouts || [])[0];
if (!Layout) throw new Error('methodId=' + Id + ' 是容器型,但没说用哪个布局');
if (J.Value === undefined) throw new Error('methodId=' + Id + ' 容器型记录缺少 Value');
return Container.Write(Layout, J.Value, Ctx);
}
const Fields = FieldsOf(Id, Spec);
if (Fields === null) throw new Error('methodId=' + Id + ' 没有字段表,无法编码');
const Given = {};
if (Array.isArray(J.Fields)) for (const F of J.Fields) Given[F.Name] = F.Value;
else if (J.Fields && typeof J.Fields === 'object') Object.assign(Given, J.Fields);
const Base = LastDecoded.get(Id) || {};
const UseBits = !!(Ctx && Ctx.Bits);
const Parts = [];
if (UseBits) FieldWriter.AttachBits(Ctx.Bits, Ctx.Index);
try {
for (const F of Fields) {
const Key = FieldKey(F);
let Value;
if (Object.prototype.hasOwnProperty.call(Given, Key)) Value = Given[Key];
else if (Object.prototype.hasOwnProperty.call(Base, Key)) Value = Base[Key];
else if (/^opt</.test(String(F.Type))) Value = null;
else throw new Error('缺少字段 ' + Key + '(' + F.Type + '),也没有原值可回填');
Parts.push(FieldWriter.WriteValue(F.Type, F.TypeCodec, Value, 0));
}
} finally {
if (UseBits) {
Ctx.Index = FieldWriter.BitCursor().Index; // 把本条负载写掉的 opt 位数交回给调用方,供下一条写形态位
FieldWriter.DetachBits();
}
}
return Buffer.concat(Parts);
}
// ---------------------------------------------------------------- 帧体
// 记录链 → { Nullmap, Records }。逐 bit 判形态;判不出来就如实标 Bad/Short 并停,不猜(剩下的字节不丢)
// 位游标 Ctx.Index 跨记录推进:每读一条先消费 1 个形态位,再把该条负载里的 opt 位(容器/字段两条路径都算)
// 一起推到位 —— 位图是共用的,不推进就会把后一条的形态位读错(gap A)。
function DecodeFrameBody(Body) {
const NmSize = Link.NullmapSize(Body, 0);
const Nullmap = Body.slice(0, NmSize);
const Bits = NullmapBits(Nullmap);
const Out = { Nullmap, Records: [], Bad: false, Note: '' };
let Off = NmSize;
const Ctx = { Bits, Index: 0 };
while (Off < Body.length) {
if (Ctx.Index >= Bits.length) {
Out.Bad = true;
Out.Note = 'nullmap 的 ' + Bits.length + ' 个 bit 用完了,帧体还剩 ' + (Body.length - Off) + ' 字节没读';
Out.Records.push({ At: Off, Remaining: Body.slice(Off).toString('hex'), Bad: true, Note: Out.Note });
break;
}
if (Bits[Ctx.Index]) {
Out.Bad = true;
Out.Note =
'第 ' + (Out.Records.length + 1) + ' 条记录是短命令形态(nullmap 第 ' + Ctx.Index + ' 个 bit=1,1 字节命令号 0x' +
Body[Off].toString(16).padStart(2, '0') + '):命令号 → (objectId, methodId) 的查表数据还没挖出来,判定不了';
Out.Records.push({ At: Off, Short: true, CmdByte: Body[Off], Note: Out.Note });
break;
}
Ctx.Index++; // 形态位 = 长记录([objectId i64][methodId i64][负载])
if (Off + 16 > Body.length) {
Out.Bad = true;
Out.Note = '第 ' + (Out.Records.length + 1) + ' 条记录的剩余 ' + (Body.length - Off) + ' 字节不足一条记录头(objectId i64 + methodId i64 = 16B)';
Out.Records.push({ At: Off, Remaining: Body.slice(Off).toString('hex'), Bad: true, Note: Out.Note });
break;
}
const ObjectId = Body.readBigInt64BE(Off).toString();
const MethodId = Body.readBigInt64BE(Off + 8).toString();
const R = DecodeRecord(MethodId, Body, Off + 16, undefined, Ctx);
Out.Records.push(Object.assign({ ObjectId, At: Off }, R));
if (R.Bad) {
Out.Bad = true;
Out.Note = '第 ' + Out.Records.length + ' 条记录(methodId=' + MethodId + ')解不动:' + R.Note;
break;
}
Off += 16 + R.Consumed;
}
return Out;
}
// { Nullmap, Records } → [nullmap][每条 [objectId i64][methodId i64][负载]]
// 每条记录的负载来源(按优先序):Payload(已编好的字节)/ Hex(未解记录原样透传)/ 走 EncodeRecord 现编
// nullmap **按编码顺序重建**:形态位(长记录=0)+ 各条负载里 opt 字段的位,就地覆盖原 nullmap 的位图。
// 长度一字节不变(WriteNullmapBits 会校验位数);没被覆盖到的尾部 bit 保持原值(原始帧里 nullmap
// 的填充位我们无从判定,原样保真比编一个值可靠)。
// Payload / Hex 这条透传路径拿不到位游标(不知道该条负载吃掉几个 opt 位)⇒ 只把它自己的形态位写上,
// 之后停止重建(后面的 bit 保持原值)——不猜、也不因此丢掉字节。
// Hex 透传是 DecodeRecord「解不动就留原字节」的对偶:DecodeFrameBody → EncodeFrameBody 的往返因此
// 对「含解不动记录的帧」也成立(原来这种帧会在编码侧抛错——记录里没有可回写的负载)。
// 原因不吞掉:Trail(可选,调用方给的数组)逐条记下 { Index, MethodId, Bytes, Reason },
// Reason 就是解码侧写好的那条 Note;不给 Trail 也不影响字节,只是没人接收这句原因。
function EncodeFrameBody(Frame, Trail) {
const F = Frame || {};
if (F.Nullmap === undefined || F.Nullmap === null) throw new Error('EncodeFrameBody 需要 Nullmap(不许默默用 0x00 代替)');
const Nm = Buffer.isBuffer(F.Nullmap) ? F.Nullmap : Buffer.from(String(F.Nullmap), 'hex');
const NmSize = Link.NullmapSize(Nm, 0);
if (NmSize > Nm.length) throw new Error('nullmap 声明 ' + NmSize + 'B 但只有 ' + Nm.length + 'B');
const OrigNm = Nm.slice(0, NmSize);
const Bits = NullmapBits(OrigNm);
const NewBits = Bits.slice();
const Ctx = { Bits: NewBits, Index: 0 };
let Rebuild = true; // 一旦遇到拿不到位游标的记录就置 false(见上)
const Parts = [];
let RecCount = 0;
for (const R of F.Records || []) {
RecCount++;
const Id = Buffer.alloc(8);
Id.writeBigInt64BE(BigInt(R.ObjectId), 0);
const Method = Buffer.alloc(8);
Method.writeBigInt64BE(BigInt(R.MethodId), 0);
let Payload;
if (Rebuild) {
// 形态位:长记录 = 0。位图用完还写不下 = 这帧表达不了(不静默忽略,否则 nullmap 是错的)
if (Ctx.Index >= NewBits.length) {
throw new Error('nullmap 位图只有 ' + NewBits.length + ' 位,写不下第 ' + RecCount + ' 条记录的形态位');
}
NewBits[Ctx.Index++] = 0;
}
if (Buffer.isBuffer(R.Payload)) {
Payload = R.Payload;
Rebuild = false;
} else if (R.Hex !== undefined) {
Payload = Buffer.from(R.Hex || '', 'hex');
Rebuild = false;
// 透传 = 这条记录没能被解出来(或本来就是 Hex 形态),字节原样走。
// 原因(解码侧写的 Note)跟着一起交给调用方,免得「解不动」在编码这一步变成静默通过。
if (Trail) {
const Why = R.Note || (R.Bad ? '记录解不动(解码侧没写出原因)' : '记录以 Hex 形态给定,未走解码器');
Trail.push({ Index: RecCount, MethodId: R.MethodId, Bytes: Payload.length, Reason: Why });
}
} else {
Payload = EncodeRecord(R.MethodId, R, Rebuild ? Ctx : null);
}
Parts.push(Id, Method, Payload);
}
return Buffer.concat([WriteNullmapBits(OrigNm, NewBits), ...Parts]);
}
return { Tables, Reader, Writer, BitReader, FieldWriter, Container, DecodeRecord, EncodeRecord, DecodeFrameBody, EncodeFrameBody, NullmapBits, WriteNullmapBits, NameOf, ModelOf };
}
// ============================================================
// --demo:验收三段(往返一致 / 语义可读 / 改字段再编码)
// 用法: node server/semantic.js --demo
// ============================================================
function RunDemo() {
const Space = require('./space');
const NamesDir = NAMES_DIR;
const Semantic = Load(NamesDir);
const File = path.join(NamesDir, 'space-init-login-100045260.json');
const Template = JSON.parse(fs.readFileSync(File, 'utf8'));
const Frames = Space.BuildInitFrames(Template, Semantic.Container, Semantic);
let Failed = 0;
// 参考字节 = 按模板里存的形态组帧(Hex 原样透传 / Fields 走 EncodeRecord / 容器走 Container.Write)。
// objectId 缺省时回退帧级 SpaceId —— 这正是升级前模板组帧的规则,所以它就是「升级前的那串字节」。
const ReferenceBody = (F) => {
const Parts = [Buffer.from(F.NullmapHex || '00', 'hex')];
for (const R of F.Records || []) {
const Id = Buffer.alloc(8);
Id.writeBigInt64BE(BigInt(R.ObjectId === undefined ? F.SpaceId : R.ObjectId), 0);
const Method = Buffer.alloc(8);
Method.writeBigInt64BE(BigInt(R.MethodId), 0);
let Payload;
if (R.Hex !== undefined) Payload = Buffer.from(R.Hex || '', 'hex');
else if (R.Layout !== undefined) Payload = Semantic.Container.Write(R.Layout, R.Value);
else Payload = Semantic.EncodeRecord(R.MethodId, R);
Parts.push(Id, Method, Payload);
}
return Buffer.concat(Parts);
};
// 帧体(含 nullmap)→ 整帧(补长度头)。Link.EncodeFrame 自己拼 nullmap,所以这里拆开喂
const ToFrame = (Body) => {
const N = Link.NullmapSize(Body, 0);
return Link.EncodeFrame(Body.slice(N), Body.slice(0, N));
};
// 记录的可读形态:Fields 数组 → {字段名: 值}
const Readable = (R) => {
const O = { ObjectId: R.ObjectId, MethodId: R.MethodId, Name: R.Name, Model: R.Model, Kind: R.Kind };
if (R.Kind === 'container') {
O.Layout = R.Layout;
O.Value = R.Value;
} else {
O.Fields = {};
for (const F of R.Fields || []) O.Fields[F.Name] = F.Value;
}
if (R.Bad) O.Bad = R.Note;
return O;
};
const Check = (Ok, Msg) => {
if (!Ok) Failed++;
console.log(' ' + (Ok ? '✓' : '✗') + ' ' + Msg);
};
console.log('语义层验收(' + File.replace(path.resolve(__dirname, '..', '..') + path.sep, '') + ')');
console.log('');
console.log('(A) 往返一致:每条记录 Decode → Encode,整帧(含 nullmap)必须逐字节相同');
Template.Frames.forEach((F, I) => {
const Ref = ReferenceBody(F);
const Decoded = Semantic.DecodeFrameBody(Ref);
let Encoded;
try {
Encoded = Semantic.EncodeFrameBody(Decoded);
} catch (Err) {
Check(false, F.File + ' 重新编码抛错:' + Err.message);
return;
}
const Same = Encoded.equals(Ref);
// 顺带验服务端真正用的那条路径(space.js → semantic 现编)也产出同样的整帧
const Built = Frames[I] && Frames[I].Frame;
const SameBuilt = !!Built && Built.equals(ToFrame(Ref));
Check(Same && SameBuilt, F.File + ' ' + Ref.length + 'B ' + F.Records.length + ' 条记录' +
(Same ? '' : ' ⚠差异 ' + Encoded.length + 'B') + (SameBuilt ? '' : ' ⚠服务端组帧不同') +
' ' + F.Records.map((R) => Semantic.NameOf(R.MethodId) || '#' + R.MethodId).join(' + '));
});
console.log('');
console.log('(B) 语义可读:打印解析结果(JSON)');
for (const Name of ['s2c_0003.bin', 's2c_0006.bin']) {
const F = Template.Frames.find((X) => X.File === Name);
if (!F) {
Check(false, '模板里没有 ' + Name);
continue;
}
const Decoded = Semantic.DecodeFrameBody(ReferenceBody(F));
console.log(' ' + Name + ' nullmap=' + Decoded.Nullmap.toString('hex') + ' →');
console.log(JSON.stringify({ Nullmap: Decoded.Nullmap.toString('hex'), Records: Decoded.Records.map(Readable) }, null, 1)
.split('\n').map((L) => ' ' + L).join('\n'));
if (Name === 's2c_0003.bin') {
const Uids = (Decoded.Records[0].Fields.find((X) => X.Name === 'advisedUids') || {}).Value;
Check(Array.isArray(Uids) && Uids.length === 5 && Uids.every((X) => typeof X === 'string'), 'advisedUids 是 5 个字符串的数组');
Check(Decoded.Records.length === 2 && Decoded.Records[1].Fields.length === 0, '第 2 条记录存在且 0 字段(methodId=' + Decoded.Records[1].MethodId + ')');
}
if (Name === 's2c_0006.bin') {
const Hash = (Decoded.Records[0].Fields.find((X) => X.Name === 'hash') || {}).Value;
Check(typeof Hash === 'string' && Hash.length === 64, 'hash 是 64 字符 token(' + (Hash || '').length + ' 字符)');
Check(Decoded.Records.length === 2, '第 2 条记录存在(methodId=' + Decoded.Records[1].MethodId + ')');
}
}
console.log('');
console.log('(C) 改字段再编码:把 advisedUids[0] 的末位字符换成一个同长度的不同字符');
const F3 = Template.Frames.find((X) => X.File === 's2c_0003.bin');
const RefBody = ReferenceBody(F3);
const Decoded = Semantic.DecodeFrameBody(RefBody);
const UidField = Decoded.Records[0].Fields.find((X) => X.Name === 'advisedUids');
const Before = UidField.Value[0];
// 只动 1 个字符且保持长度 —— 断言才可能精确到"只有 1 个字节不同"。
// 不写死 WYT2WYT→WYT3WYT:模板里的值本身会被我们改(现在就是 WYT3WYT),
// 写死会让这个断言随数据变化而失效(曾经就失效过一次)。
const LastCh = Before[Before.length - 1];
const NextCh = /[0-8]/.test(LastCh) ? String(Number(LastCh) + 1) : LastCh === '9' ? '8' : 'A';
UidField.Value[0] = Before.slice(0, -1) + NextCh;
const NewBody = Semantic.EncodeFrameBody(Decoded);
const OldFrame = ToFrame(RefBody);
const NewFrame = ToFrame(NewBody);
const Diff = [];
for (let I = 0; I < Math.max(OldFrame.length, NewFrame.length); I++) if (OldFrame[I] !== NewFrame[I]) Diff.push(I);
console.log(' 旧帧 ' + OldFrame.length + 'B: ' + OldFrame.toString('hex'));
console.log(' 新帧 ' + NewFrame.length + 'B: ' + NewFrame.toString('hex'));
console.log(' ' + Before + ' → ' + UidField.Value[0]);
Check(NewFrame.length === OldFrame.length, '新帧与旧帧长度相同(' + NewFrame.length + ' = ' + OldFrame.length + ')');
Check(Diff.length === 1, '只有 1 个字节不同(差异位置 ' + Diff.map((I) => I + ':' + OldFrame[I].toString(16) + '→' + NewFrame[I].toString(16)).join(', ') + ')');
Check(Diff.length === 1 && OldFrame[Diff[0]] === LastCh.charCodeAt(0) && NewFrame[Diff[0]] === NextCh.charCodeAt(0),
'该字节正是 ASCII "' + LastCh + '" → "' + NextCh + '"');
console.log('');
console.log(Failed ? '✗ ' + Failed + ' 项断言未通过' : '✓ 全部断言通过');
return Failed ? 1 : 0;
}
module.exports = { Load, NAMES_DIR };
if (process.argv.indexOf('--demo') >= 0) process.exit(RunDemo());
+187
View File
@@ -0,0 +1,187 @@
// session.js - 会话重放应答器:把真实官方服会话里「上行 → 下行」的对应关系搬到我们服务器上
//
// 为什么要有它:车库空间(100073070)在真实服里是**一问一答**的——
// 模板 names/space-init-<id>.json 只装得下"进空间时服务器主动推的那几帧"(车库 5 帧),
// 而客户端进车库后发的 G.q1wd / ba.lcr / M_.hcn / ls.xbl / M_.fcn / Qh.ycw 换来的 s2c
// (对象数据、资源、容器增量)原来一条都没回,于是武器列表/切换全都不动。
// 本模块读的是 names/space-session-<id>.json(由 tools/extract_space_session.js 从真实抓包抽出):
// 每条 s2c 帧带一个 After = 「换它出来的上行清单」({MethodId, Target})。
//
// 约定(与产物文件一一对应):
// After 为空 → 纯推送帧(真实服在客户端进空间后无人请求就推的那几帧);
// DelayMs=0 的进空间立刻发,DelayMs>0 的按真实时延定时发
// (实测车库那 2 帧:s2c_0000 立刻,s2c_0040 在 +8.7s)
// After 非空 → 等客户端发到清单里的上行时发(同一帧只发一次)
// 命中判定 → MethodId 相同,且(After 项没有 Target 或 Target 相同)
//
// 顺序推进(关键,别再改回「任一命中即发」):
// 真实服是一问一答、且**成批**——s2c_0003 要等 28 条 ba.lcr 齐了才发一条含 28 条
// LoadDependencies 的帧,s2c_0004 要等 28 条 G.q1wd 齐了才发。
// 所以每次上行**只推进会话顺序里最靠前的那个「还没发的被触发帧」**,一帧一上行。
// 早期实现用「任一 After 项命中就发」:因为会话里 30 多帧的 After 都含 `G.q1wd @100073070`,
// 客户端第一个 G.q1wd(1) 就把 21 帧全扑了出去(server-logs/session-02/session_007/log.txt:59),
// 客户端拿到乱序数据当场抛错重载,第二轮起车库再也进不去 —— 务必保持顺序推进。
// Frame.Hex → 形态表达不了(Short 命令号 / 解不出 objectId)时整帧原样的兜底:
// 直接按帧体重组,一个字节都不改(extract_space_session.js 里写明原因)
//
// 发送走的是现成的组帧路径(Space.BuildInitFrames → space.js → Link.EncodeFrame),
// 与 space-init-*.json 那条路完全同源,所以「模板里存的记录能逐字节重建原帧」这条保证直接继承过来。
//
// 用法:
// const Session = require('./session');
// const Doc = Session.Load(namesDir, spaceId); // 没有产物文件 → null
// const Replay = Session.MakeReplay(Doc.Frames, { Container, Semantic, Log });
// Replay.OnEnter(SendFrames); // 进空间时
// const Hits = Replay.OnUplink({ MethodId, Target }, SendFrames); // 每条上行都问一次;返回本次发出的帧数
// const Hits2 = Replay.OnUplink({ MethodId, Target }, SendFrames, { Suppress: true });
// // 该上行已由查库定向应答接管 → 命中的帧只标记已发、不发字节
// SendFrames(Frame, Bytes) 返回 true 表示**真的发出去了**(连接已断就返回 false,这时不标记已发)
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./link');
const Space = require('./space');
const SemanticModule = require('./semantic');
const NAMES_DIR = path.resolve(__dirname, '..', '..', 'names');
// 卡住看门狗阈值:连续这么多条上行都匹配不上「会话顺序里最靠前的待触发帧」就跳过它并大声记日志。
// 取值偏大(我们的客户端与抓包那份是同一个构建,正常不该触发),只是为了不把整条会话卡死。
const STALL_LIMIT = 40;
// 读 names/space-session-<SpaceId>.json;不存在返回 null(调用方回落到 space-init-<id>.json)
function Load(NamesDir, SpaceId) {
const File = path.join(NamesDir || NAMES_DIR, 'space-session-' + SpaceId + '.json');
if (!fs.existsSync(File)) return null;
return JSON.parse(fs.readFileSync(File, 'utf8'));
}
// After 清单里有没有命中这条上行记录
function Match(After, Record) {
for (const A of After || []) {
if (String(A.MethodId) !== String(Record.MethodId)) continue;
if (A.Target === undefined || A.Target === null || A.Target === '') return true;
if (String(A.Target) === String(Record.Target)) return true;
}
return false;
}
function MakeReplay(Frames, Deps) {
const Opts = Deps || {};
const Log = typeof Opts.Log === 'function' ? Opts.Log : () => {};
// 没给 Container / Semantic 时自己加载一份(用法上 index.js 会传现成的实例,避免重复读表)
const Semantic = Opts.Semantic || SemanticModule.Load(NAMES_DIR);
const Container = Opts.Container || Semantic.Container;
const All = Frames || [];
const Sent = new Set();
const Waited = new Set();
const Timers = [];
const BytesByFile = new Map();
const State = { Stall: 0 };
// 纯推送帧 = After 为空的帧(真实服没人问就推)
function IsPush(F) {
return !(F.After || []).length;
}
// 帧字节按需构建(车库那两帧是 58KB / 775KB,进空间时没必要全编一遍)
function BytesOf(F) {
if (BytesByFile.has(F.File)) return BytesByFile.get(F.File);
let Buf;
if (F.Hex !== undefined) {
// 形态表达不了的整帧原样兜底:帧体 → [nullmap][payload] 交给 Link.EncodeFrame(长度头由它补)
const Body = Buffer.from(String(F.Hex), 'hex');
const NmSize = Link.NullmapSize(Body, 0);
Buf = Link.EncodeFrame(Body.slice(NmSize), Body.slice(0, NmSize));
} else {
Buf = Space.BuildInitFrames({ Frames: [F] }, Container, Semantic)[0].Frame;
}
BytesByFile.set(F.File, Buf);
return Buf;
}
function Send(F, SendFrames, Why) {
const Buf = BytesOf(F);
Log(' ← 重放:发 ' + F.File + ' ' + Buf.length + 'B(' + Why + ')');
const Ok = SendFrames ? SendFrames(F, Buf) !== false : true;
if (Ok) Sent.add(F.File);
else Log(' ⚠ 重放 ' + F.File + ' 没真正发出(连接已断),不标记已发');
return Ok;
}
// 进空间时:只有纯推送帧走这条(DelayMs=0 立刻发,>0 的按真实时延定时发)
function OnEnter(SendFrames) {
let Count = 0;
for (const F of All) {
if (Sent.has(F.File)) continue;
if (!IsPush(F)) continue;
const Delay = Number(F.DelayMs) || 0;
if (Delay <= 0) {
if (Send(F, SendFrames, '纯推送帧,进空间即发')) Count++;
continue;
}
const T = setTimeout(() => Send(F, SendFrames, '纯推送帧,按真实时延 +' + Delay + 'ms'), Delay);
if (T && typeof T.unref === 'function') T.unref();
Timers.push(T);
}
return Count;
}
// 会话顺序里最靠前的「还没发的、需要上行触发」的帧;跳过错过的纯推送帧
function NextGated() {
for (const F of All) {
if (Sent.has(F.File)) continue;
if (IsPush(F)) continue;
return F;
}
return null;
}
// After 清单的可读摘要(只用于日志:让人一眼看出这帧在等什么)
function Describe(After) {
const Count = new Map();
for (const A of After || []) {
const Key = String(A.MethodId) + (A.Target === undefined || A.Target === null || A.Target === '' ? '' : '@' + A.Target);
Count.set(Key, (Count.get(Key) || 0) + 1);
}
return Array.from(Count.entries()).map(([K, N]) => K + (N > 1 ? '×' + N : '')).join(' + ');
}
// 收到一条上行记录:只推进一帧(会话顺序里最靠前的那个待触发帧),返回本次发出的帧数
// Options.Suppress = true(可选,查库定向应答用):这条上行已经被查库路径接管,
// 命中的那帧**只标记已发、不发字节**——顺序照旧推进,避免后面所有待触发帧被堵住(看门狗要 40 条才跳)。
function OnUplink(Record, SendFrames, Options) {
const F = NextGated();
if (!F) return 0;
if (Match(F.After, Record)) {
State.Stall = 0;
if (Options && Options.Suppress) {
Sent.add(F.File);
Log(' (' + F.File + ' 本由 ' + Record.MethodId + ' 触发,但该上行已由查库定向应答接管 → 跳过重放)');
return 0;
}
const Why = '被 ' + Record.MethodId + ' 触发' + (Record.Name ? ',' + Record.Name : '');
return Send(F, SendFrames, Why) ? 1 : 0;
}
State.Stall++;
// 每条待触发帧只提醒一次,避免刷屏
if (!Waited.has(F.File)) {
Waited.add(F.File);
Log(' ⚠ 重放等待:' + F.File + ' 需要 [' + Describe(F.After) + '],本条上行是 ' +
Record.MethodId + (Record.Target === undefined ? '' : '@' + Record.Target));
}
// 卡住看门狗:会话顺序本该与我们的客户端一致;连续多次对不上说明抓包与本次客户端行为不一致,
// 这时**大声**跳过这一帧继续(不静默兜底——跳过意味着这一步的下行缺失,必须能在日志里看见)
if (State.Stall >= STALL_LIMIT) {
Log(' ⚠ 重放卡住:' + F.File + ' 连续 ' + State.Stall + ' 条上行都没等到 [' +
Describe(F.After) + '],跳过它继续(该帧的下行缺失)');
Sent.add(F.File);
State.Stall = 0;
}
return 0;
}
return { OnEnter, OnUplink };
}
module.exports = { Load, MakeReplay, NAMES_DIR };
+65
View File
@@ -0,0 +1,65 @@
// space.js - 空间帧组装 + 空间初始化模板
//
// s2c 空间帧 = [nullmap][(objectId int64)(methodId int64)(负载)…](出处见 dispatch.js 头注 + semantic.js 头注)
// 注意是**每条记录自带 objectId**(src/module_61860.js:2119-2120),不是「帧头一个 spaceId」;
// 单记录帧里两者恰好相同,所以旧的「帧级 spaceId」写法仍然等价(见 BuildSpaceFrame 的兼容路径)。
//
// 「初始化模板」= names/space-init-<spaceId>.json。它存的是**结构化模板**而不是原始帧字节:
// 容器记录存「解出来的对象树」(发送时由 container.js 重新编码),
// 普通字段记录存「字段 JSON」(发送时由 semantic.js 现编),
// 只有解不动的记录才存 hex。
// 这样模板里的每个 id / modelId / 项 / 字段都可以被替换成我们自己的数据(M2 的后续工作),
// 而不是把抓包字节原样回放。
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./link');
// Records = [{ ObjectId?, MethodId, Payload: Buffer }]
// NullmapHex:nullmap 原样字节(十六进制)。注意它不是「可有可无的 0」——
// 实测有帧的 nullmap 是 2 字节且带内容(如 `20 3c`),含义是「哪些字段为空、负载里因此没有它们」,
// 写成 0x00 会让客户端按「非空」去读,导致错位。所以能拿到原 nullmap 就必须原样带上。
// (EncodeFrame 自己会拼 nullmap,所以这里 Parts 里不能再放一个。)
// ObjectId 缺省 → 回退用帧级 SpaceId:这是**兼容路径**,只为让「每条记录 objectId 相同」这类旧调用
// (以及还没升级出 ObjectId 的老模板)继续能跑;新模板里每条记录都应带自己的 ObjectId。
function BuildSpaceFrame(SpaceId, Records, NullmapHex) {
const Nm = Buffer.from(NullmapHex || '00', 'hex');
const Parts = [];
for (const R of Records) {
const Id = Buffer.alloc(8);
Id.writeBigInt64BE(BigInt(R.ObjectId === undefined || R.ObjectId === null ? SpaceId : R.ObjectId), 0);
const Method = Buffer.alloc(8);
Method.writeBigInt64BE(BigInt(R.MethodId), 0);
Parts.push(Id, Method, R.Payload);
}
const Body = Buffer.concat(Parts);
return Link.EncodeFrame(Body, Nm);
}
function LoadInitTemplate(NamesDir, SpaceId) {
const File = path.join(NamesDir, 'space-init-' + SpaceId + '.json');
if (!fs.existsSync(File)) return null;
return JSON.parse(fs.readFileSync(File, 'utf8'));
}
// 模板 → [{ File, DelayMs, Frame }];负载由 Container / Semantic 重新编码
// 记录的三种形态:① { Layout, Value } 容器(container.js)② { Fields } 普通字段(semantic.js)③ { Hex } 未解原样透传
function BuildInitFrames(Template, Container, Semantic) {
const Out = [];
for (const F of Template.Frames || []) {
const Records = F.Records.map((R) => {
let Payload;
if (R.Layout !== undefined) Payload = Container.Write(R.Layout, R.Value);
else if (R.Fields !== undefined) {
// 字段 JSON 必须由语义层现编:它才知道该 methodId 的有序字段与类型
if (!Semantic) throw new Error('模板记录是 Fields 形态(methodId=' + R.MethodId + '),但没传 semantic.js 实例');
Payload = Semantic.EncodeRecord(R.MethodId, R);
} else Payload = Buffer.from(R.Hex || '', 'hex');
return { ObjectId: R.ObjectId, MethodId: R.MethodId, Payload };
});
Out.push({ File: F.File, DelayMs: F.DelayMs || 0, Frame: BuildSpaceFrame(F.SpaceId, Records, F.NullmapHex) });
}
return Out;
}
module.exports = { BuildSpaceFrame, LoadInitTemplate, BuildInitFrames };
+728
View File
@@ -0,0 +1,728 @@
// store.js - 查库组帧层:从本机 MySQL(tanki 库)查出对象/模型/依赖/挂载数据,组装成可直接下发的 s2c 记录
//
// 定位:这是「查库定向应答」的底层,上层是 responder.js(ba.lcr / ls.xbl / G.q1wd 三条上行的定向应答)
// 与 index.js(按优先级 查库 → 会话回放 → 记未实现 接线)。本文件**只管查库与组帧**,不发字节。
//
// 与回放(session.js)的关系:本层只做加法。库里缺表、缺行、缺字段一律返回 null(不抛错、不猜),
// 调用方拿到 null 就原样回落到会话回放。所以「库不可用」在协议行为上等价于「没有本文件」。
//
// 口径(与 schema.sql 头注一一对应,不许自行发明):
// * id 一律当十进制字符串(db.js 开了 supportBigNumbers + bigNumberStrings;负数合成 id 不许转 Number)
// * Containers/ObjectModels/Dependency* 的 JSON 列是 LONGTEXT,读出后自己 JSON.parse(不经 MySQL 的 double)
// * Items 的三个旗标 = Items 表的 InDepot / InMarket / Mounted(itemsOnDepot / itemsOnMarket / mountedItems)
// * 依赖批次的身份是「哪一条 zt 记录」:主键 (SpaceId, SourceFile, RecordIndex, Ord),BatchId(=zt 首字段 X)
// 只是列 + 索引 —— 同一个 X 在抓包里被多条 zt 重发,绝不能拿 X 当唯一键
// * SpaceObjects.GameTypeId:zt 的批次里那个 i64 就是「类型 id」(= 实例对象的 GameTypeId),
// 实例本身在 SpaceObjects 里(SpaceObjectsGameTypeIdx 就是为这条展开建的索引)
//
// 内存缓存口径("不要把整个库读进内存"):
// * 小表整表缓存:DeviceSlots + SlotDevices + Mounted(28 + 515 + 28 行)——DevicesLoaded 因此**同步**可答
// * Items 的三种旗标清单按 (SpaceId, Kind) 缓存一次
// * 对象「出现序」按空间缓存一次(ObjectModels 的 MIN(Seq),见下)
// * ObjectModels 切片按 (SpaceId, ObjectId) 逐对象缓存;DependencyBatches/Items 按批次身份缓存
//
// ---------------------------------------------------------------- 关于「顺序」的一条重要事实
// 抓包里的**列表顺序**(pt.Objects 的对象序、itemsOnDepot/mountedItems/itemsOnMarket 的项序、
// s2c_0001/s2c_0004 里那 28 条 devicesLoaded 的记录序)在下面这些表里**没有对应的列**,是服务端
// 自己的排布。能对上的是:
// * ObjectModels.Seq 是 AUTO_INCREMENT,导入顺序 = 抓包扫描顺序 ⇒ 同一空间里对象「首次出现」的
// 先后(MIN(Seq))就是抓包 s2c_0001 里 pt.Objects 的顺序(实测 1371 项逐项相同,见 --selfcheck)
// * 因此本层提供 SpaceObjectIds()(库侧能给的规范顺序),而 ObjectsData() 的 ObjectIds 由调用方给
// (与任务口径一致:Objects 从 SpaceObjects **按传入顺序**取);运行时传的是批次的实例 id 列表,
// 自检时传抓包那一帧自己的顺序(自检报告里把这一点如实写出来,并对库序做独立对账)
'use strict';
const fs = require('fs');
const path = require('path');
const Db = require('./db');
const Link = require('./link');
const SemanticModule = require('./semantic');
const PROJECT = path.resolve(__dirname, '..', '..');
const NAMES_DIR = path.join(PROJECT, 'names');
const PLAIN_DIR = path.join(PROJECT, 'packets', 'plain');
// 本层要用的方法号(十进制字符串;名字出处 names/uplink_table.json + names/downlink_table.json)
const METHOD = {
DevicesLoaded: '7615142300796401818',
UnloadObjects: '9038756763225879447',
ObjectsData: '7640916300855664666',
Dependencies: '3216143066888387731',
ItemsOnDepot: '3996477900528000389',
ItemsOnMarket: '5236393599344825589',
MountedItems: '3692495084100053385',
};
const LAYOUT = { Lt: 'lt', Pt: 'pt', Zt: 'zt' };
// 三种物品旗标:Kind → table 列 / 字段名 / methodId
const FLAG = {
Depot: { Column: 'InDepot', Field: 'itemsOnDepot', MethodId: METHOD.ItemsOnDepot },
Market: { Column: 'InMarket', Field: 'itemsOnMarket', MethodId: METHOD.ItemsOnMarket },
Mounted: { Column: 'Mounted', Field: 'mountedItems', MethodId: METHOD.MountedItems },
};
let Pool = null;
let Enabled = false;
let Opening = null;
let Logger = () => {};
let Sem = null; // semantic.js 实例(组帧要用;index.js 会把自己那份塞进来,避免重复读表)
const Cache = {
Devices: null, // { Slots: Set<slot>, BySlot: Map<slot, string[]>, Mounted: Map<slot, string|null> }
Flags: new Map(), // SpaceId|Kind → string[]
Order: new Map(), // SpaceId → Map<ObjectId, 首次出现序号>
OrderList: new Map(), // SpaceId → string[](首次出现序)
Slices: new Map(), // SpaceId|ObjectId → [{Id, Value}]
BatchIds: new Map(), // SpaceId|SourceFile|RecordIndex|BatchId → string[](批次里的 id,按 Ord)
BatchInst: new Map(), // 同上 → string[](展开成实例 id)
Deps: new Map(), // 同上 → zt 记录
};
function Log(Msg) {
Logger('[store] ' + Msg);
}
function SetLog(Fn) {
Logger = typeof Fn === 'function' ? Fn : () => {};
}
function SetSemantic(Inst) {
Sem = Inst;
}
function Semantic() {
if (!Sem) Sem = SemanticModule.Load(NAMES_DIR);
return Sem;
}
// ---------------------------------------------------------------- 连接
// 连不上不重试太勤(Available 每条上行都会问一次),但也不能一次失败就永久关掉这条路径 ——
// 服务器可能跑很久,MySQL 只是晚起来一会儿的话应当自己接上。节流间隔:30s。
const RETRY_GAP_MS = 30000;
let RetryAfter = 0;
// 懒建连接池(单例)。失败只记一行日志并把 Enabled 置 false —— 整条查库路径静默让位给回放,绝不抛错。
async function Open(Config) {
if (Pool) return Pool;
if (Opening) return Opening;
Opening = (async () => {
try {
const P = Db.Connect(Db.LoadConfig(Config));
await P.query('SELECT 1');
Pool = P;
Enabled = true;
await LoadDeviceTables();
Log('已连库(' + Db.LoadConfig(Config).Database + '@' + Db.LoadConfig(Config).Host + '):挂载位 ' +
(Cache.Devices ? Cache.Devices.Slots.size : 0) + ' 个 / 可用设备 ' +
(Cache.Devices ? [...Cache.Devices.BySlot.values()].reduce((A, B) => A + B.length, 0) : 0) + ' 条');
} catch (Err) {
Enabled = false;
Pool = null;
Opening = null; // 允许以后重试(见 Available 的节流)
RetryAfter = Date.now() + RETRY_GAP_MS;
Log('库不可用(' + (Err && Err.message ? Err.message : Err) + ')→ 空间层整条走会话回放');
}
return Pool;
})();
return Opening;
}
// 是否可用。没开过(或上次失败已过节流窗口)就顺手发起一次 Open(不阻塞、不抛错)
function Available() {
if (Pool) return Enabled;
if (!Opening && Date.now() >= RetryAfter) Open().catch(() => {});
return false;
}
async function Close() {
if (Pool) {
const P = Pool;
Pool = null;
Enabled = false;
Opening = null;
await P.end();
}
}
// 小表整表缓存:DeviceSlots / SlotDevices / Mounted
async function LoadDeviceTables() {
const Slots = await Db.Many(Pool, 'SELECT SlotObjectId FROM DeviceSlots');
const Rows = await Db.Many(Pool, 'SELECT SlotObjectId, Ord, DeviceId FROM SlotDevices ORDER BY SlotObjectId, Ord');
const Mounted = await Db.Many(Pool, 'SELECT SlotObjectId, DeviceId FROM Mounted');
const BySlot = new Map();
for (const R of Rows) {
const S = String(R.SlotObjectId);
if (!BySlot.has(S)) BySlot.set(S, []);
BySlot.get(S).push(String(R.DeviceId));
}
const M = new Map();
for (const R of Mounted) M.set(String(R.SlotObjectId), R.DeviceId === null || R.DeviceId === undefined ? null : String(R.DeviceId));
Cache.Devices = { Slots: new Set(Slots.map((R) => String(R.SlotObjectId))), BySlot, Mounted: M };
}
function Require() {
if (!Pool) throw new Error('store 还没连库');
}
// ---------------------------------------------------------------- 空间内的对象「首次出现序」
// 库侧能给的规范顺序:ObjectModels 的 MIN(Seq)(= 抓包扫描顺序 = pt.Objects 的顺序,见文件头)
async function LoadObjectOrder(SpaceId) {
const Key = String(SpaceId);
if (!Cache.Order.has(Key)) {
const Rows = await Db.Many(
Pool,
'SELECT ObjectId, MIN(Seq) AS First FROM ObjectModels WHERE SpaceId = ? AND ObjectId IS NOT NULL GROUP BY ObjectId ORDER BY First',
[Key],
);
const Rank = new Map();
const List = [];
Rows.forEach((R, I) => {
const Id = String(R.ObjectId);
Rank.set(Id, I);
List.push(Id);
});
Cache.Order.set(Key, Rank);
Cache.OrderList.set(Key, List);
}
return Cache.Order.get(Key);
}
// 该空间在库里的对象序(供自检对账与文档化;运行时 ObjectsData 的顺序由调用方给)
async function SpaceObjectIds(SpaceId) {
Require();
await LoadObjectOrder(SpaceId);
return Cache.OrderList.get(String(SpaceId)).slice();
}
// ---------------------------------------------------------------- 记录构造
// devicesLoaded:从 DeviceSlots/SlotDevices/Mounted 三张表来(整表缓存在内存,故同步返回)
// 库里没有这个挂载位 → null(调用方回落回放)
function DevicesLoaded(SpaceId, SlotObjectId) {
if (!Enabled || !Cache.Devices) return null;
const Slot = String(SlotObjectId);
if (!Cache.Devices.Slots.has(Slot)) return null;
const Devices = (Cache.Devices.BySlot.get(Slot) || []).slice();
if (!Devices.length) return null;
const Mounted = Cache.Devices.Mounted.has(Slot) ? Cache.Devices.Mounted.get(Slot) : null;
return {
ObjectId: Slot,
MethodId: METHOD.DevicesLoaded,
Fields: { devices: Devices, mountedDevice: Mounted },
};
}
// _unloadObjects:lt 容器,负载 = id 列表(**id 来自客户端上行** ls.xbl 的靶,不是库里的数据)
function UnloadObjects(SpaceId, Ids) {
return {
ObjectId: String(SpaceId),
MethodId: METHOD.UnloadObjects,
Layout: LAYOUT.Lt,
Value: { Ids: (Ids || []).map(String) },
};
}
// 对象的模型切片(ObjectModels 按 Ord 升序),按 (SpaceId, ObjectId) 缓存
async function LoadSlices(SpaceId, ObjectIds) {
const Key = String(SpaceId);
const Need = [];
for (const Id of ObjectIds) {
if (!Cache.Slices.has(Key + '|' + Id)) Need.push(String(Id));
}
for (let I = 0; I < Need.length; I += 200) {
const Chunk = Need.slice(I, I + 200);
const Rows = await Db.Many(
Pool,
'SELECT ObjectId, Ord, ModelId, ValueJson FROM ObjectModels WHERE SpaceId = ? AND ObjectId IN (' +
Chunk.map(() => '?').join(',') + ') ORDER BY ObjectId, Ord',
[Key].concat(Chunk),
);
const By = new Map();
for (const R of Rows) {
const K = String(R.ObjectId);
if (!By.has(K)) By.set(K, []);
// LtItem 形态:Id = modelId(0 时 Value 是 i64 字符串),Value = 就地展开的字段/字面量
By.get(K).push({ Id: String(R.ModelId), Value: JSON.parse(R.ValueJson) });
}
for (const Id of Chunk) Cache.Slices.set(Key + '|' + Id, By.get(Id) || []);
}
}
// _loadObjectsData:pt 容器 = 对象引用表 + 模型数据表
// Objects 从 SpaceObjects **按传入顺序**取;Items 把每个对象的 ObjectModels 切片按对象顺序拼成扁平序列。
// 任何一项缺(对象不在 SpaceObjects / 该对象没有模型行)→ 返回 null(宁可不答,也不发一条错的 pt)
async function ObjectsData(SpaceId, ObjectIds) {
Require();
const Ids = (ObjectIds || []).map(String);
if (!Ids.length) return null;
const Found = new Map();
for (let I = 0; I < Ids.length; I += 200) {
const Chunk = Ids.slice(I, I + 200);
const Rows = await Db.Many(
Pool,
'SELECT ObjectId, GameTypeId FROM SpaceObjects WHERE ObjectId IN (' + Chunk.map(() => '?').join(',') + ')',
Chunk,
);
for (const R of Rows) Found.set(String(R.ObjectId), String(R.GameTypeId));
}
const Missing = Ids.filter((X) => !Found.has(X));
if (Missing.length) {
Log('ObjectsData 缺对象(前 3:' + Missing.slice(0, 3).join(',') + ',共 ' + Missing.length + '/' + Ids.length + ')→ 不答');
return null;
}
await LoadSlices(SpaceId, Ids);
const Objects = [];
const Items = [];
for (const Id of Ids) {
Objects.push({ ObjectId: Id, GameTypeId: Found.get(Id) });
const Slice = Cache.Slices.get(String(SpaceId) + '|' + Id) || [];
if (!Slice.length) {
Log('ObjectsData 对象 ' + Id + ' 没有模型切片 → 不答');
return null;
}
for (const It of Slice) Items.push(It);
}
return { ObjectId: String(SpaceId), MethodId: METHOD.ObjectsData, Layout: LAYOUT.Pt, Value: { Objects, Items } };
}
// _loadDependencies:zt 容器 = 批次表(对象/类型 → 模型 id 列表)+ 资源项表
// BatchId 不给时按 (SpaceId, SourceFile, RecordIndex) 从库反查(一个 zt 记录只有一批,X 是同一个)
async function Dependencies(SpaceId, SourceFile, RecordIndex, BatchId) {
Require();
const Key = [SpaceId, SourceFile, RecordIndex, BatchId === undefined || BatchId === null ? '' : BatchId].join('|');
if (Cache.Deps.has(Key)) return Cache.Deps.get(Key);
const B = await Db.Many(
Pool,
'SELECT Ord, BatchId, ObjectId, ModelIds FROM DependencyBatches WHERE SpaceId = ? AND SourceFile = ? AND RecordIndex = ? ORDER BY Ord',
[String(SpaceId), String(SourceFile), Number(RecordIndex)],
);
if (!B.length) {
Log('Dependencies 库里没有这条 zt(' + SpaceId + '/' + SourceFile + ' 第 ' + RecordIndex + ' 条)→ 不答');
return null;
}
const X = Number(B[0].BatchId);
if (BatchId !== undefined && BatchId !== null && Number(BatchId) !== X) {
Log('Dependencies 批次号不符:请求 X=' + BatchId + ',库里该记录 X=' + X + ' → 不答');
return null;
}
const Items0 = await Db.Many(
Pool,
'SELECT Ord, `R`, `E`, `U`, `S`, `O`, Skipped FROM DependencyItems WHERE SpaceId = ? AND SourceFile = ? AND RecordIndex = ? ORDER BY Ord',
[String(SpaceId), String(SourceFile), Number(RecordIndex)],
);
if (!Items0.length) {
Log('Dependencies 库里没有这条 zt 的资源项 → 不答');
return null;
}
const Batches = B.map((R) => ({ ObjectId: String(R.ObjectId), ModelIds: JSON.parse(R.ModelIds).map(String) }));
const Items = Items0.map((R) => ({
R: String(R.R),
E: Number(R.E),
U: Number(R.U),
S: String(R.S),
O: Number(R.O),
Skipped: JSON.parse(R.Skipped).map(String),
}));
const Rec = { ObjectId: String(SpaceId), MethodId: METHOD.Dependencies, Layout: LAYOUT.Zt, Value: { X, Batches, Items } };
Cache.Deps.set(Key, Rec);
return Rec;
}
// 某个依赖批次里的 id 列表(按 Ord 取;这就是 zt 的 Batches[].ObjectId,语义上是**类型 id**)
async function BatchObjectIds(SpaceId, SourceFile, RecordIndex, BatchId) {
Require();
const Key = [SpaceId, SourceFile, RecordIndex, BatchId === undefined || BatchId === null ? '' : BatchId].join('|');
if (!Cache.BatchIds.has(Key)) {
const B = await Db.Many(
Pool,
'SELECT Ord, ObjectId FROM DependencyBatches WHERE SpaceId = ? AND SourceFile = ? AND RecordIndex = ? ORDER BY Ord',
[String(SpaceId), String(SourceFile), Number(RecordIndex)],
);
Cache.BatchIds.set(Key, B.map((R) => String(R.ObjectId)));
}
const Ids = Cache.BatchIds.get(Key);
return Ids.length ? Ids : null;
}
// 批次里那些「类型 id」在空间里的**实例 id**(SpaceObjects.GameTypeId 索引展开)。
// ★ 为什么必须有这一步:zt 的批次里那个 i64 是类型 id(如 -2147482522),SpaceObjects 里存的是
// 实例(如 920005132720 → GameTypeId=-2147482522,见 schema.sql 头注 1/4)。客户端 G.q1wd(n) 要的是
// 「这批对象的数据」,所以发给它的 pt.Objects 必须是实例 id。
async function BatchInstances(SpaceId, SourceFile, RecordIndex, BatchId) {
Require();
const Key = [SpaceId, SourceFile, RecordIndex, BatchId === undefined || BatchId === null ? '' : BatchId].join('|');
if (Cache.BatchInst.has(Key)) return Cache.BatchInst.get(Key);
const Types = await BatchObjectIds(SpaceId, SourceFile, RecordIndex, BatchId);
if (!Types) return null;
const Ph = Types.map(() => '?').join(',');
let Rows = await Db.Many(
Pool,
'SELECT ObjectId FROM SpaceObjects WHERE GameTypeId IN (' + Ph + ') AND SourceSpace = ? ORDER BY GameTypeId, ObjectId',
Types.concat([String(SpaceId)]),
);
if (!Rows.length) {
// 兜底:SourceSpace 是「首次出现的空间号」,实例可能是在别处首次出现的 —— 去掉空间限定再查一次
Rows = await Db.Many(Pool, 'SELECT ObjectId FROM SpaceObjects WHERE GameTypeId IN (' + Ph + ') ORDER BY GameTypeId, ObjectId', Types);
}
const Out = [];
const Seen = new Set();
for (const R of Rows) {
const Id = String(R.ObjectId);
if (Seen.has(Id)) continue;
Seen.add(Id);
Out.push(Id);
}
Cache.BatchInst.set(Key, Out.length ? Out : null);
return Out.length ? Out : null;
}
// 物品旗标记录(itemsOnDepot / itemsOnMarket / mountedItems)。
// 顺序口径:按该空间对象「首次出现序」(ObjectModels 的 MIN(Seq))—— 实测 itemsOnMarket 992 项与抓包
// s2c_0001 完全一致;itemsOnDepot(15)/mountedItems(5) 的抓包顺序库里没有对应列(如实写进 --selfcheck 报告)
async function FlaggedItems(SpaceId, Kind) {
Require();
const Spec = FLAG[Kind];
if (!Spec) throw new Error('未知的物品旗标: ' + Kind);
const Key = String(SpaceId) + '|' + Kind;
if (!Cache.Flags.has(Key)) {
const Rows = await Db.Many(Pool, 'SELECT ItemId FROM Items WHERE `' + Spec.Column + '` = 1');
const Rank = await LoadObjectOrder(SpaceId);
const Ids = Rows.map((R) => String(R.ItemId));
Ids.sort((A, B) => {
const RA = Rank.has(A) ? Rank.get(A) : Number.MAX_SAFE_INTEGER;
const RB = Rank.has(B) ? Rank.get(B) : Number.MAX_SAFE_INTEGER;
if (RA !== RB) return RA - RB;
return BigInt(A) < BigInt(B) ? -1 : BigInt(A) > BigInt(B) ? 1 : 0;
});
Cache.Flags.set(Key, Ids);
}
const Ids = Cache.Flags.get(Key);
if (!Ids.length) return null;
const Fields = {};
Fields[Spec.Field] = Ids.slice();
return { ObjectId: String(SpaceId), MethodId: Spec.MethodId, Fields };
}
// ---------------------------------------------------------------- 组帧
// 一条记录要吃掉几个 nullmap 位 = 1 个形态位 + 该记录负载里的所有 opt 位。
// 数法:拿一个够大的零位数组当帧级位游标,把记录按**编码顺序**走一遍(semantic.EncodeRecord /
// container.Write 都接受 Ctx 并把消费掉的位数写回 Ctx.Index)—— 与读侧同一顺序,见 container.js 头注三。
function CountBits(Records) {
for (let Cap = 1 << 12; ; Cap <<= 1) {
const Ctx = { Bits: new Array(Cap).fill(0), Index: 0 };
try {
for (const R of Records) {
Ctx.Index++; // 形态位(长记录 = 0)
Semantic().EncodeRecord(R.MethodId, R, Ctx);
}
return Ctx.Index;
} catch (Err) {
if (Cap < (1 << 24) && /用完|容量/.test(String(Err.message))) continue;
throw Err;
}
}
}
// 自造 nullmap:位容量恰好 ≥ Bits,形式与 link.js 的 NullmapSize 一一对应(短形态 8L-3 位 / 长形态 8L 位)。
// 位图全 0 —— 真正的位由 EncodeFrameBody 按编码顺序就地写回(照 space.js 的现编路径)
function MakeNullmap(Bits) {
if (Bits <= 29) {
const L = Math.max(1, Math.ceil((Bits + 3) / 8)); // L ∈ 1..4
const Out = Buffer.alloc(L);
Out[0] = ((L - 1) & 0x03) << 5;
return Out;
}
const L = Math.ceil(Bits / 8);
if (L <= 0x3f) {
const Out = Buffer.alloc(1 + L);
Out[0] = 0x80 | L;
return Out;
}
const Out = Buffer.alloc(3 + L);
Out[0] = 0xc0 | ((L >> 16) & 0x3f);
Out[1] = (L >> 8) & 0xff;
Out[2] = L & 0xff;
return Out;
}
// 记录 → 整帧字节。NullmapHex 不给 ⇒ 按 CountBits 自造(运行时用);给了 ⇒ 原样当帧级 nullmap 用
// (自检时给抓包那一帧的 nullmap,才能逐字节比较;这也是 space.js/space-init 模板一贯的口径)
function BuildFrame(Records, NullmapHex) {
const Nm = NullmapHex ? Buffer.from(String(NullmapHex), 'hex') : MakeNullmap(CountBits(Records));
const Body = Semantic().EncodeFrameBody({ Nullmap: Nm, Records });
const N = Link.NullmapSize(Body, 0);
return Link.EncodeFrame(Body.slice(N), Body.slice(0, N));
}
// ============================================================
// --selfcheck / --verify:用上面这套查库能力把抓包帧从库里重建出来,逐字节比
// 用法: node tools/server/store.js --selfcheck [--space 100073070] [--plain <dir>] [--names <dir>]
// ============================================================
const PRINT = (Msg) => console.log(Msg);
function DiffOffsets(A, B, Limit) {
const Out = [];
for (let I = 0; I < Math.max(A.length, B.length) && Out.length < Limit; I++) if (A[I] !== B[I]) Out.push(I);
return Out;
}
// 差异报告:前 32 个差异偏移 + 差异总字节数 + 「落在哪一条记录里」——即任务要求的「卡在哪条、差多少」
function DiffReport(Built, Body, Dec) {
const Out = [];
const Total = DiffOffsets(Built, Body, Number.MAX_SAFE_INTEGER).length;
const Offs = DiffOffsets(Built, Body, 32);
Out.push(' 差异偏移(前 32 个): ' + JSON.stringify(Offs) + ',差异字节合计 ' + Total +
'(现编 ' + Built.length + 'B / 原始 ' + Body.length + 'B)');
// 偏移 → 第几条记录(记录头 16B + 负载)
const At2Rec = (At) => {
for (let I = 0; I < Dec.Records.length; I++) {
const R = Dec.Records[I];
const End = R.At + 16 + (R.Consumed || 0);
if (At >= R.At && At < End) return I;
if (R.Consumed === undefined && At >= R.At) return I;
}
return -1;
};
const Seen = new Set();
for (const At of Offs) {
const I = At2Rec(At);
if (Seen.has(I)) continue;
Seen.add(I);
const R = Dec.Records[I] || {};
const Where = I < 0 ? '记录之外(nullmap / 尾部)' : '第 ' + (I + 1) + ' 条记录(methodId=' + R.MethodId +
(R.Layout ? ' 容器 ' + R.Layout : ' 字段 ' + (R.Fields || []).length + ' 个') + ',负载 ' + R.At + '..' + (R.At + 16 + (R.Consumed || 0)) + ')';
Out.push(' 首个差异落在 ' + Where + ' @' + At);
const S = Math.max(0, At - 8);
const E = Math.min(Body.length, At + 8);
Out.push(' @' + At + ' 现编 ' + Built.slice(S, E).toString('hex') + ' 原始 ' + Body.slice(S, E).toString('hex'));
}
return Out;
}
// 读抓包明文帧(一个 .bin = 恰好一整帧的帧体)
function ReadBody(PlainDir, SpaceId, File) {
const P = path.join(PlainDir, 'space-' + SpaceId, File);
const Parsed = Link.ParseFrames(fs.readFileSync(P));
return Link.DecodeFrame(Parsed[0]);
}
function SessionAfter(SpaceId, File) {
const P = path.join(NAMES_DIR, 'space-session-' + SpaceId + '.json');
if (!fs.existsSync(P)) return null;
const D = JSON.parse(fs.readFileSync(P, 'utf8'));
const F = (D.Frames || []).find((X) => X.File === File);
return F ? F.After || [] : null;
}
async function SelfCheck() {
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 SpaceId = String(Arg('space', '100073070'));
const PlainDir = path.resolve(Arg('plain', PLAIN_DIR));
const Frames = ['s2c_0002.bin', 's2c_0003.bin', 's2c_0004.bin', 's2c_0001.bin'];
PRINT('store --selfcheck 空间=' + SpaceId + ' 抓包=' + PlainDir);
setLogFn();
const P = await Open();
if (!P) {
PRINT('✗ 连不上库,自检无法进行');
return 1;
}
PRINT('库已连上;下面每帧都是「库里查出来的记录 → 现编组帧」,与原始帧体逐字节比。');
PRINT(' 输入口径:pt.Objects 的顺序、devicesLoaded 的记录序、ls.xbl 的靶 id 在库里**没有对应列**,');
PRINT(' 这三处按抓包帧自己的顺序/上行靶传入(自检报告里逐帧标注),其余字段全部来自库。');
let Fail = 0;
const Lines = [];
for (const File of Frames) {
const Body = ReadBody(PlainDir, SpaceId, File);
const Dec = Semantic().DecodeFrameBody(Body);
const N = Link.NullmapSize(Body, 0);
const NmHex = Body.slice(0, N).toString('hex');
const Notes = [];
let Records = null;
const T0 = Date.now();
try {
Records = await BuildFrameRecords(SpaceId, File, Dec, Notes);
} catch (Err) {
Notes.push('组记录抛出:' + Err.message);
}
Lines.push('');
Lines.push('== ' + File + ' 原始帧体 ' + Body.length + 'B nullmap ' + N + 'B 记录 ' + Dec.Records.length + ' 条');
if (!Records) {
Lines.push(' ✗ 重建失败:' + Notes.join(';'));
Fail++;
continue;
}
let Built = null;
let Err = null;
try {
Built = Link.DecodeFrame(Link.ParseFrames(BuildFrame(Records, NmHex))[0]);
} catch (E) {
Err = E.message;
}
const Same = !!Built && Built.equals(Body);
if (!Same) Fail++;
Lines.push(' 组帧记录 ' + Records.length + ' 条 ' +
(Same ? '✓ 逐字节一致' : '✗ 不一致(现编 ' + (Built ? Built.length : '?') + 'B vs 原始 ' + Body.length + 'B)') +
' ' + (Date.now() - T0) + 'ms');
if (Err) Lines.push(' 组帧抛错:' + Err);
if (Built && !Same) {
for (const L of DiffReport(Built, Body, Dec)) Lines.push(L);
}
for (const T of Notes) Lines.push(' · ' + T);
// nullmap 位用量:现编这条帧据库里数据吃掉多少 bit vs 原始 nullmap 的容量
try {
const Bits = CountBits(Records);
Lines.push(' 记录形状: ' + Dec.Records.map((R) =>
(R.Layout || 'fields') + '#' + (R.Layout === 'pt' ? R.Value.Objects.length + '/' + R.Value.Items.length :
R.Layout === 'zt' ? 'X=' + R.Value.X + '/' + R.Value.Batches.length + '/' + R.Value.Items.length :
R.Layout === 'lt' ? 'Ids=' + R.Value.Ids.length : (R.Fields || []).length)).join(' ') +
' nullmap 位用量 ' + Bits + '/' + (N * 8 - (Body[0] & 0x80 ? (Body[0] & 0x40 ? 24 : 8) : 3)));
} catch (E2) {
Lines.push(' 记录形状: ' + Dec.Records.length + ' 条;位用量统计失败:' + E2.message);
}
// 库里能独立对账的部分
const Diag = await DbDiagnostics(SpaceId, File, Dec);
for (const D of Diag) Lines.push(' · ' + D);
}
for (const L of Lines) PRINT(L);
PRINT('');
PRINT(Fail ? '✗ ' + Fail + ' 帧未能逐字节重建(详见上方每帧的差异偏移)' : '✓ 四帧全部逐字节重建');
await Close();
return Fail ? 1 : 0;
}
function setLogFn() {
SetLog((M) => PRINT(' ' + M));
}
// 按帧把「查库记录」拼出来。Note 里如实记下哪些输入不来自库
async function BuildFrameRecords(SpaceId, File, Dec, Notes) {
if (File === 's2c_0002.bin') {
const After = SessionAfter(SpaceId, File);
if (!After) throw new Error('没有 names/space-session-' + SpaceId + '.json 的 ' + File + ' After 清单,拿不到 ls.xbl 的靶 id');
const Ids = After.filter((A) => String(A.MethodId) === '2528403217084400809').map((A) => String(A.Target));
Notes.push('5 条 _unloadObjects 的 id 来自客户端上行 ls.xbl 的靶(库里的 UnloadObjects 是纯函数,无表可查)');
Notes.push('抓包帧里的 Ids=' + JSON.stringify(Dec.Records.map((R) => R.Value.Ids.join(','))) +
';上行靶=' + JSON.stringify(Ids));
return Ids.map((Id) => UnloadObjects(SpaceId, [Id]));
}
if (File === 's2c_0003.bin') {
Notes.push('28 条 _loadDependencies:SourceFile/RecordIndex 来自帧内位置,BatchId(X) 由库按 (SpaceId,SourceFile,RecordIndex) 反查');
const Out = [];
for (let I = 0; I < Dec.Records.length; I++) {
const R = await Dependencies(SpaceId, File, I, null);
if (!R) throw new Error('库里查不到 s2c_0003 第 ' + I + ' 条 zt');
Out.push(R);
}
return Out;
}
if (File === 's2c_0004.bin') {
const PtIds = Dec.Records[0].Value.Objects.map((O) => String(O.ObjectId));
Notes.push('pt 的 ObjectIds 顺序按抓包帧自身的 pt.Objects 传入(库里无此列);类型与开销数据全部来自 SpaceObjects/ObjectModels');
Notes.push('28 条 devicesLoaded 的记录序按抓包帧自身的记录序传入(库里无此列);设备清单与 mountedDevice 全部来自 SlotDevices/Mounted');
const Pt = await ObjectsData(SpaceId, PtIds);
if (!Pt) throw new Error('ObjectsData 查库失败');
const Out = [Pt];
for (const R of Dec.Records.slice(1)) {
const Rec = DevicesLoaded(SpaceId, String(R.ObjectId));
if (!Rec) throw new Error('库里没有挂载位 ' + R.ObjectId);
Out.push(Rec);
}
return Out;
}
if (File === 's2c_0001.bin') {
const PtIds = Dec.Records[0].Value.Objects.map((O) => String(O.ObjectId));
Notes.push('pt 的 ObjectIds 顺序按抓包帧自身的 pt.Objects 传入(库里无此列)');
Notes.push('3 条物品旗标来自 Items 表;最后一条 methodId=1936068342294351641 是 0 字段记录,直接按 methodId 组');
const Pt = await ObjectsData(SpaceId, PtIds);
if (!Pt) throw new Error('ObjectsData 查库失败');
const Out = [Pt];
for (const Kind of ['Depot', 'Mounted', 'Market']) {
const Rec = await FlaggedItems(SpaceId, Kind);
if (!Rec) throw new Error('Items 表里没有 ' + Kind + ' 旗标');
Out.push(Rec);
}
Out.push({ ObjectId: String(SpaceId), MethodId: '1936068342294351641', Fields: {} });
return Out;
}
throw new Error('没有为 ' + File + ' 准备重建路径');
}
// 库里能**独立**验证的那部分:集合是否一致、库侧规范顺序与抓包顺序差在哪
async function DbDiagnostics(SpaceId, File, Dec) {
const Out = [];
try {
if (File === 's2c_0001.bin') {
const PtIds = Dec.Records[0].Value.Objects.map((O) => String(O.ObjectId));
const DbOrder = await SpaceObjectIds(SpaceId);
let At = -1;
for (let I = 0; I < PtIds.length; I++) if (PtIds[I] !== DbOrder[I]) { At = I; break; }
Out.push('库侧对账:该空间 ObjectModels 里共 ' + DbOrder.length + ' 个对象;抓包 pt 的 ' + PtIds.length +
' 项是库序(MIN(Seq))的前缀? ' + (At < 0 ? '是' : '否(首个不同 @' + At + ')'));
// 类型对账:SpaceObjects 是**全局**表(主键只有 ObjectId,KeepFirst),同一个对象在别的空间首次出现
// 会把它在那边的 GameTypeId 带进来 —— 这是库的口径,不是本层能修的,如实报出来
const Rows = await Db.Many(Pool, 'SELECT ObjectId, GameTypeId, SourceSpace FROM SpaceObjects WHERE ObjectId IN (' +
PtIds.map(() => '?').join(',') + ')', PtIds);
const T = new Map(Rows.map((R) => [String(R.ObjectId), R]));
const Bad = [];
Dec.Records[0].Value.Objects.forEach((O, I) => {
const R = T.get(String(O.ObjectId));
if (!R) Bad.push('ptIndex=' + I + ' ' + O.ObjectId + ' 库里没有这行');
else if (String(R.GameTypeId) !== String(O.GameTypeId)) {
Bad.push('ptIndex=' + I + ' ObjectId=' + O.ObjectId + ' 帧=' + O.GameTypeId + ' 库=' + R.GameTypeId +
'(SourceSpace=' + R.SourceSpace + ')');
}
});
Out.push('库侧对账:GameTypeId 与帧不一致的对象 ' + Bad.length + ' / ' + PtIds.length +
(Bad.length ? '(' + Bad.slice(0, 3).join(';') + ')——SpaceObjects 全局唯一键 + KeepFirst 的口径所致' : ''));
for (const Kind of ['Depot', 'Mounted', 'Market']) {
const Frame = (Dec.Records.find((R) => (R.Fields || []).some((F) => F.Name === FLAG[Kind].Field)) || { Fields: [] })
.Fields.find((F) => F.Name === FLAG[Kind].Field);
const Db = (await FlaggedItems(SpaceId, Kind)).Fields[FLAG[Kind].Field];
const F = Frame ? Frame.Value.map(String) : [];
let A = -1;
for (let I = 0; I < Math.max(Db.length, F.length); I++) if (Db[I] !== F[I]) { A = I; break; }
Out.push('库侧对账:' + FLAG[Kind].Field + ' 库里 ' + Db.length + ' 项 / 帧内 ' + F.length +
' 项;集合一致 ' + (Db.slice().sort().join() === F.slice().sort().join()) +
';库序与帧序一致 ' + (A < 0) + (A < 0 ? '' : '(首个不同 @' + A + ':库 ' + Db[A] + ' vs 帧 ' + F[A] + ')'));
}
}
if (File === 's2c_0004.bin') {
const PtIds = Dec.Records[0].Value.Objects.map((O) => String(O.ObjectId));
const Devs = [];
for (const R of Dec.Records.slice(1)) for (const D of R.Fields.find((F) => F.Name === 'devices').Value) Devs.push(String(D));
Out.push('库侧对账:帧内 28 条 devicesLoaded 拼出的设备 ' + Devs.length + ' 个;pt.Objects ' + PtIds.length +
' 个;顺序相同 ' + (JSON.stringify(Devs) === JSON.stringify(PtIds)));
const All = [];
for (const [, V] of Cache.Devices.BySlot) for (const D of V) All.push(D);
Out.push('库侧对账:SlotDevices 共 ' + All.length + ' 条;与 pt.Objects 集合一致 ' +
(All.slice().sort().join() === PtIds.slice().sort().join()) + '(顺序不一致:库里没有「槽位先后」这一列)');
let At = -1;
for (let I = 0; I < PtIds.length; I++) if (PtIds[I] !== All[I]) { At = I; break; }
Out.push('库侧对账:若按 (SlotObjectId, Ord) 取库序,首个不同 @' + At +
'(库 ' + (At >= 0 ? All[At] : '-') + ' vs 帧 ' + (At >= 0 ? PtIds[At] : '-') + ')');
}
} catch (Err) {
Out.push('对账时出错:' + Err.message);
}
return Out;
}
module.exports = {
METHOD, Open, Available, Close, SetLog, SetSemantic,
DevicesLoaded, UnloadObjects, ObjectsData, Dependencies, BatchObjectIds, BatchInstances, FlaggedItems,
SpaceObjectIds, BuildFrame, MakeNullmap, CountBits, Cache,
};
if (process.argv.indexOf('--selfcheck') >= 0 || process.argv.indexOf('--verify') >= 0) {
SelfCheck()
.then((Code) => process.exit(Code))
.catch((Err) => {
console.error('自检失败:' + (Err && Err.stack ? Err.stack : Err));
process.exit(1);
});
}
+67
View File
@@ -0,0 +1,67 @@
// world.js - 服务端世界状态:空间实例表 / 空间分类 / 未实现命令清单
//
// M1 只承载「服务器需要记住什么」:客户端连过哪些空间、每个空间收到了哪些命令(尚未实现语义)。
// 这四个注册表(gameType / model / resource / space)在 M2 才会真正填内容——
// 现在先把「客户端要什么」记全,那就是施工图。
'use strict';
// 入口空间:真实服务器在 Hash Response 同一帧里下发(实测 0x5f691cc = 100045260)
const ENTRANCE_SPACE = 100045260;
// 空间分类。置信度如实标注:
// 实测 = 抓包/源码直接对得上;容器 = 从该空间容器的模式表推出来的用途;待核实 = 只有命名线索
// 注意:超过 2^53 的 id 必须写成字符串(3074457345817190050 用数字字面量会掉精度)
const SPACE_KIND = {
100045260: 'Entrance', // 实测:与 Hash Response 同帧下发,只有 3 条 s2c
100073070: 'Garage', // 实测:客户端上报 button_click "GARAGE" 后服务器 Open Space(100073070)
100072911: 'Shop', // 容器模式表 = Jj / ShopCategoryCC
100045720: 'Chat', // 容器模式表 = _t / Kz / ChatCC / ClanChatCC / NewsShowingCC
920007382011: 'Queue', // 待核实(旧注释写「快速战斗队列」)
100119080: 'Unknown',
'3074457345817190050': 'Unknown', // 待核实:帧量极大(3551 条 s2c),疑似战斗
};
function MakeWorld() {
const Spaces = new Map();
const Unimplemented = new Map();
let NoteCount = 0;
function EnsureSpace(Id) {
const Key = String(Id);
let S = Spaces.get(Key);
if (!S) {
S = { Id: Key, Kind: ClassifySpace(Key), Commands: 0, Since: new Date().toISOString() };
Spaces.set(Key, S);
}
return S;
}
// 记一条「还没实现语义」的上行命令(按 空间类别 + 命令名 归并)
function NoteUnimplemented(Kind, Name, MethodId, Target) {
NoteCount++;
const Key = Kind + ' ' + Name;
let U = Unimplemented.get(Key);
if (!U) {
U = { Kind, Name, MethodId, Count: 0, Targets: new Set() };
Unimplemented.set(Key, U);
}
U.Count++;
if (U.Targets.size < 5) U.Targets.add(String(Target));
}
// 会话结束/定时打印用:按次数排序的未实现清单
function UnimplementedSummary() {
const Rows = [...Unimplemented.values()]
.sort((A, B) => B.Count - A.Count)
.map((U) => ({ Kind: U.Kind, Name: U.Name, MethodId: U.MethodId, Count: U.Count, Targets: [...U.Targets] }));
return { Total: NoteCount, Distinct: Rows.length, Rows };
}
return { Spaces, Unimplemented, EnsureSpace, NoteUnimplemented, UnimplementedSummary };
}
function ClassifySpace(Id) {
return SPACE_KIND[String(Id)] || 'Unknown';
}
module.exports = { MakeWorld, ClassifySpace, ENTRANCE_SPACE, SPACE_KIND };
+174
View File
@@ -0,0 +1,174 @@
// structure_space_init.js - 把空间初始化模板从「字节」升级成「JSON」
//
// 为什么需要它:names/space-init-*.json 里绝大多数记录存的是原始 hex(抓包字节),
// 想改一个字段(比如用户名字)就得做字节替换。semantic.js 有了字段级解码能力后,
// 这里把那些 hex 记录整段当「记录链」解一遍(旧模板的 Hex 是「该帧体从本条记录负载起剩下的全部字节」,
// 所以一段 hex 里可能含多条记录 —— 见 tools/extract_space_init.js:94 与 mine_downlink.js 头注),
// 能完整解出、且解出再编码逐字节一致的,就换成结构化记录;否则**原样保留 hex 并把原因写进 Note**。
//
// 安全阀:只有当「结构化后的模板经 space.js 的组帧路径产出与升级前完全相同的字节」时才提交,
// 所以这个脚本不可能改变任何一帧的字节(解不动的一律不动)。
//
// 用法: node tools/structure_space_init.js
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./server/link');
const Space = require('./server/space');
const Semantic = require('./server/semantic').Load(path.join(__dirname, '..', 'names'));
const NamesDir = path.join(__dirname, '..', 'names');
const Files = fs.readdirSync(NamesDir).filter((F) => /^space-init-.*\.json$/.test(F)).sort();
// 按模板里存的形态拼帧体(含 nullmap):Hex 原样透传 / Layout+Value 走容器 / Fields 走语义层。
// objectId 缺省回退帧级 SpaceId —— 这正是升级前模板组帧的规则,所以这就是「升级前的字节」。
function ReferenceBody(F) {
const Parts = [Buffer.from(F.NullmapHex || '00', 'hex')];
for (const R of F.Records || []) {
const Id = Buffer.alloc(8);
Id.writeBigInt64BE(BigInt(R.ObjectId === undefined || R.ObjectId === null ? F.SpaceId : R.ObjectId), 0);
const Method = Buffer.alloc(8);
Method.writeBigInt64BE(BigInt(R.MethodId), 0);
let Payload;
if (R.Hex !== undefined) Payload = Buffer.from(R.Hex || '', 'hex');
else if (R.Layout !== undefined) Payload = Semantic.Container.Write(R.Layout, R.Value);
else if (R.Fields !== undefined) Payload = Semantic.EncodeRecord(R.MethodId, R);
else return null; // 形态不认识 → 交给调用方判为不可升级
Parts.push(Id, Method, Payload);
}
return Buffer.concat(Parts);
}
function ToFrame(Body) {
const N = Link.NullmapSize(Body, 0);
return Link.EncodeFrame(Body.slice(N), Body.slice(0, N));
}
// 解出来的记录 → 模板里的形态(① 容器 {Layout,Value} ② 普通字段 {Fields});每条都带自己的 ObjectId
function ToTemplate(R) {
const Out = { ObjectId: R.ObjectId, MethodId: R.MethodId };
if (R.Kind === 'container') {
Out.Layout = R.Layout;
Out.Value = R.Value;
} else {
Out.Fields = {};
for (const F of R.Fields || []) Out.Fields[F.Name] = F.Value;
}
return Out;
}
// 保留 hex 的原因分类(不要笼统写「解不动」——分类本身是施工图)
function Classify(Decoded) {
for (const R of Decoded.Records || []) {
if (R.Short) return '短命令形态(nullmap bit=1 的 1 字节命令号):命令号 → (objectId, methodId) 的查表数据还没挖';
}
const Notes = (Decoded.Records || []).filter((R) => R.Note).map((R) => R.Note);
const Note = Notes[0] || Decoded.Note || '未知';
if (/容器三布局/.test(Note)) return '容器布局吃不到记录尾:' + Note;
if (/没有字段表|不在 downlink_table/.test(Note)) return '没有字段表:' + Note;
if (/解不动/.test(Note)) return '字段/记录解不动:' + Note;
return Note;
}
const Total = { Spaces: 0, Frames: 0, EmptyFrames: 0, Records: 0, RecordsStructured: 0, RecordsHex: 0, HexBytes: 0, Upgraded: 0, HexReplaced: 0, Kept: 0 };
const Reasons = new Map();
const Details = [];
for (const File of Files) {
const Path2 = path.join(NamesDir, File);
const T = JSON.parse(fs.readFileSync(Path2, 'utf8'));
const Rec = { File, SpaceId: T.SpaceId, Frames: (T.Frames || []).length, EmptyFrames: 0, Records: 0, Structured: 0, Hex: 0, HexBytes: 0, Upgraded: 0, HexReplaced: 0, Kept: 0 };
let Changed = false;
const ReasonsHere = new Map();
for (const F of T.Frames || []) {
const Records = F.Records || [];
Rec.Records += Records.length;
if (!Records.length) {
Rec.EmptyFrames++;
continue;
}
for (const R of Records) {
if (R.Hex !== undefined) {
Rec.Hex++;
Rec.HexBytes += (R.Hex || '').length / 2;
} else Rec.Structured++;
}
// 只处理「含未解 hex 记录」的帧:容器记录(Layout 已存在)与已结构化的帧保持不动(也保证可重复运行)
if (!Records.some((R) => R.Hex !== undefined)) continue;
const Ref = ReferenceBody(F);
let Why = '';
let Decoded = null;
if (!Ref) Why = '记录形态既不认识 Hex/Layout/Fields 中的任何一种,不敢动';
else {
try {
Decoded = Semantic.DecodeFrameBody(Ref);
} catch (Err) {
Why = '解码抛错:' + Err.message;
}
}
if (!Why && Decoded.Bad) Why = Classify(Decoded);
if (!Why && !Decoded.Records.length) Why = '帧体里没解出记录';
// 安全阀:结构化后的帧必须与升级前逐字节相同,否则一律不提交
if (!Why) {
const NewRecords = Decoded.Records.map(ToTemplate);
let Built = null;
try {
Built = Space.BuildInitFrames({ Frames: [{ SpaceId: F.SpaceId, NullmapHex: F.NullmapHex, Records: NewRecords }] }, Semantic.Container, Semantic)[0].Frame;
} catch (Err) {
Why = '重新组帧失败:' + Err.message;
}
if (!Why && !Built.equals(ToFrame(Ref))) Why = '组帧字节与升级前不同(' + Built.length + ' vs ' + ToFrame(Ref).length + ' 字节)';
if (!Why) {
F.Records = NewRecords;
Changed = true;
Rec.Upgraded += NewRecords.length;
Rec.HexReplaced += Records.filter((R) => R.Hex !== undefined).length;
}
}
if (Why) {
Rec.Kept++;
const Kind = Why.split(':')[0];
Reasons.set(Kind, (Reasons.get(Kind) || 0) + 1);
ReasonsHere.set(Kind, (ReasonsHere.get(Kind) || 0) + 1);
// 解不动就如实保留原始 hex,并把原因写进 Note(原来的 Note 会被这条更具体的原因取代)
for (const R of F.Records) if (R.Hex !== undefined) R.Note = Why;
Details.push(' ' + File + ' ' + F.File + ' ' + F.Records.map((R) => '#' + R.MethodId).join(',') + ' ' + Why);
}
}
if (Changed) fs.writeFileSync(Path2, JSON.stringify(T, null, 1), 'utf8');
Total.Spaces++;
Total.Frames += Rec.Frames;
Total.EmptyFrames += Rec.EmptyFrames;
Total.Records += Rec.Records;
Total.RecordsStructured += Rec.Structured;
Total.RecordsHex += Rec.Hex;
Total.HexBytes += Rec.HexBytes;
Total.Upgraded += Rec.Upgraded;
Total.HexReplaced += Rec.HexReplaced;
Total.Kept += Rec.Kept;
Rec.StructuredAfter = Rec.Structured + Rec.Upgraded;
Rec.HexAfter = Rec.Hex - Rec.HexReplaced;
Details.push(' ' + File.padEnd(34) + '空间 ' + String(Rec.SpaceId).padEnd(14) +
'帧 ' + String(Rec.Frames).padStart(3) + ' 记录 ' + String(Rec.Records).padStart(3) +
' 升级前:结构化 ' + String(Rec.Structured).padStart(3) + ' / hex ' + String(Rec.Hex).padStart(3) +
'(' + Rec.HexBytes + 'B)' + ' → 升级后:结构化 ' + String(Rec.StructuredAfter).padStart(3) + ' / hex ' + String(Rec.HexAfter).padStart(3) +
' 本次 ' + Rec.HexReplaced + ' 条 hex → ' + Rec.Upgraded + ' 条结构化' + (Changed ? '(已写回)' : '(无变化)') +
(Rec.EmptyFrames ? ' ⚠ 空记录帧 ' + Rec.EmptyFrames : '') +
(Rec.Kept ? ' 保留 hex 原因:' + [...ReasonsHere].map(([K, V]) => K + '×' + V).join('、') : ''));
}
console.log('空间初始化模板结构化(升级前统计 → 升级后)');
console.log('');
for (const L of Details) console.log(L);
console.log('');
console.log('合计:空间 ' + Total.Spaces + ' 帧 ' + Total.Frames + '(其中空记录帧 ' + Total.EmptyFrames + ') 记录 ' + Total.Records);
console.log(' 升级前:结构化 ' + Total.RecordsStructured + ' 条 / 原始 hex ' + Total.RecordsHex + ' 条(' + Total.HexBytes + ' 字节)');
console.log(' 本次升级 ' + Total.HexReplaced + ' 条 hex 记录 → ' + Total.Upgraded + ' 条结构化记录;保留 hex ' + Total.Kept + ' 帧');
console.log(' 升级后:结构化 ' + (Total.RecordsStructured + Total.Upgraded) + ' 条 / 原始 hex ' + (Total.RecordsHex - Total.HexReplaced) + ' 条');
if (Reasons.size) {
console.log(' 保留 hex 的原因分类(按帧计):');
for (const [K, V] of [...Reasons].sort((A, B) => B[1] - A[1])) console.log(' ' + String(V).padStart(5) + ' ' + K);
}
+26 -319
View File
@@ -1,17 +1,10 @@
// wss_server.js - 本地游戏通信服务器(替代 wss://c1.3dtank.com:9090/)
// 用法:
// node wss_server.js [--port 9091] [--mode relay|fake] [--upstream wss://c1.3dtank.com:9090/] [--label 名字]
// node wss_server.js [--port 9091] [--upstream wss://c1.3dtank.com:9090/] [--label 名字]
// 模式:
// relay 中继(推荐): 客户端 → 本地 → 真实服务器。抓包落盘 + 实时解析:
// 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}
@@ -45,7 +38,6 @@ const Arg = (Name, 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)));
@@ -128,17 +120,6 @@ function NullmapSize(Buf, Offset) {
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 空间帧)
// ============================================================
@@ -383,16 +364,6 @@ class StreamDecoder {
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 '';
}
}
}
// ============================================================
@@ -431,213 +402,6 @@ function SaveFrame(State, Direction, Buf, 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;
}
// ============================================================
// 服务端
// ============================================================
@@ -658,102 +422,45 @@ 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 : ''));
State.Log('模式: 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));
}
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 + ',忽略');
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.send(Buf);
else {
State.Log('上游尚未就绪,暂存 ' + Buf.length + ' 字节待补发');
Pending.push(Buf);
}
});
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();
});
@@ -762,7 +469,7 @@ WsServer.on('connection', (Client, Req) => {
Server.listen(PORT, () => {
console.log('游戏通信服务器已启动: ws://localhost:' + PORT + '/');
console.log(' 模式 : ' + MODE + (MODE === 'relay' ? ' → ' + UPSTREAM : ' (离线模拟)'));
console.log(' 模式 : relay → ' + UPSTREAM);
console.log(' 抓包目录: ' + PACKET_DIR);
console.log(' 实时解析: 明文直解;加密段自动穷举密钥后实时解密;zlib 压缩帧自动解压');
});
+84
View File
@@ -0,0 +1,84 @@
// wss_test.js - 本地中继自测客户端:验证「明文 → 加密」实时解码链路
// 用法: node wss_test.js [--url ws://localhost:9091/] [--key 42]
// 流程: 先发一条明文帧(应被识别为明文),再发 3 条用同一密钥加密的帧,
// 观察中继日志应出现「明文段结束 → 密钥已破解 K=42 → [密] 命令 ID=...」
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..');
function RequireWs() {
try {
return require('ws');
} catch (Err) {
return require(path.join(ROOT, 'sockets', 'node_modules', 'ws'));
}
}
const Ws = RequireWs();
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 URL = Arg('url', 'ws://localhost:9091/');
const KEY = Number(Arg('key', '42'));
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) & 0xff) | 0x80, (Length >>> 16) & 0xff, (Length >>> 8) & 0xff, Length & 0xff]),
Nm,
Payload,
]);
}
// 与中继侧同构的自变体 XOR 流密码(编码方向:密钥表再 ^87)
class Cipher {
constructor(K) {
this.Slot = new Int32Array(8);
for (let I = 0; I < 8; I++) this.Slot[I] = ((K ^ (I << 3)) ^ 87) & 0xff;
this.Idx = 0;
}
Apply(Buf) {
const Out = Buffer.alloc(Buf.length);
for (let I = 0; I < Buf.length; I++) {
const Plain = Buf[I] & 0xff;
Out[I] = (Plain ^ this.Slot[this.Idx]) & 0xff;
this.Slot[this.Idx] = Plain;
this.Idx = this.Idx ^ (Plain & 7);
}
return Out;
}
}
const Socket = new Ws(URL);
Socket.binaryType = 'nodebuffer';
Socket.on('open', () => {
// 1) 明文帧:命令 ID=1(Hash Request 形态,内容随意)
const PlainFrame = EncodeFrame(Buffer.concat([Buffer.from([1]), Buffer.from('0000000005f691cc', 'hex')]), Buffer.from([0x00]));
Socket.send(PlainFrame);
console.log('已发送明文帧 ' + PlainFrame.length + ' 字节');
// 2) 加密帧:命令 ID=7 / 19 / 42,用同一密钥
const Cipher1 = new Cipher(KEY);
for (const Id of [7, 19, 42]) {
const Payload = Buffer.concat([Buffer.from([Id]), Buffer.from([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22])]);
Socket.send(Cipher1.Apply(EncodeFrame(Payload, Buffer.from([0x00]))));
}
console.log('已发送 3 条加密帧(密钥 K=' + KEY + ')');
});
Socket.on('message', (Data) => console.log('<<< ' + Buffer.from(Data).length + ' 字节'));
Socket.on('close', (Code) => {
console.log('连接关闭 code=' + Code);
process.exit(0);
});
Socket.on('error', (Err) => {
console.log('错误: ' + Err.message);
process.exit(1);
});
setTimeout(() => {
Socket.close();
process.exit(0);
}, 4000);