diff --git a/3DTank/README.md b/3DTank/README.md index d5b369d..03eff47 100644 --- a/3DTank/README.md +++ b/3DTank/README.md @@ -176,15 +176,28 @@ node ingest.js --label mine-01 # 把一次抓包入库到 packets/pl 容器的模型恒为 `_t`(`src/module_45686.js:94`),codec 是 `lt`(同文件 483-494),只有一个字段 `idCodec`(i64): 读 i64,`0` → 值是紧跟其后的那个 i64;否则按 modelRegister(`module_45686` 的 `at.k1we()`)里该 id 的 codec 就地展开。 -实测容器负载(`space-100045720/s2c_0000.bin`): + +容器负载 = `[头部][内层数据]`,头部实测形状是**模式表**: ``` -[i64 version=1][i64 idA=100045311][i32 N=5][N × i64 modelId][i32 0][i64 idB=100045720] -[(i64 methodId)(负载)…] ← 内层与外层帧体同形,且可无限嵌套(内层又是 _t 容器) +[i32 a][i32 b][i64 objId][i32 N][N × i64 modelId] 共 20 + 8N 字节 ``` -**尚未闭合**:负载首字节并不是 modelRegister 的 id(实测是 i64 `1`),说明 methodId 与模型负载之间还有一小段 -头部(可能是命令 codec 自己写的 tag/版本)。要彻底确定需要拿到容器命令自身的 codec,而不是 `_t` 的模型 codec; -在此之前容器内层仍靠 modelId 窗口扫,统计里的"精确覆盖"只作参考。 +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(商店) | + +`decode_space_frames.js` 会用这个模式表约束内层扫描(只认表里的模型、且按表里的先后顺序出现), +内层记录"吃满边界"的比例从 60% 提到 **70%**,假命中基本清掉。 + +**尚未闭合**:头部前 12 字节(`a`/`b`/`objId`)与模型负载之间还没对齐 —— `_t` 的 codec 读到的首个 i64 实测是 `1`, +而 modelRegister 里没有 id=1 的条目,说明 methodId 与模型负载之间还有一小段(可能是命令 codec 自己写的 tag/版本); +大帧(如 41KB 那个容器)连模式表都认不出,头部形状可能还随场景变化。要彻底闭合需要拿到容器命令自身的 codec, +而不是 `_t` 的模型 codec。 + diff --git a/3DTank/tools/decode_space_frames.js b/3DTank/tools/decode_space_frames.js index 3ab50cd..7d19cea 100644 --- a/3DTank/tools/decode_space_frames.js +++ b/3DTank/tools/decode_space_frames.js @@ -30,12 +30,19 @@ // id = 读 i64;id == 0 → 值是紧跟其后的那个 i64;否则按 modelRegister 里该 id 对应的 codec 就地展开 // (modelRegister = module_45686 的 at.k1we(),k7n(id) 取 codec,与 module_61860 的 or.p7g 同源: // 读不到 codec 会抛 CodecNotFoundForCommandException) -// · 实测容器负载(space-100045720/s2c_0000.bin): -// [i64 version=1][i64 idA=100045311][i32 N=5][N × i64 modelId][i32 0][i64 idB=100045720] -// [(i64 methodId)(负载)…] ← 内层与外层帧体同形,且可无限嵌套(内层又是 _t 容器) -// · 仍缺的一环:负载首字节并不是 modelRegister 的 id(实测是 i64 1),说明 methodId 与模型负载之间 -// 还有一小段头部(可能是命令 codec 自己写的 tag/版本),因此容器内层目前仍靠 modelId 窗口扫, -// 不敢按上面的形状硬解。要闭合需要拿到容器命令自身的 codec(不是 _t 的模型 codec)。 +// · 容器负载 = [头部][内层数据],头部实测形状(见 ContainerSchema): +// [i32 a][i32 b][i64 objId][i32 N][N × i64 modelId] 共 20 + 8N 字节 +// N 个 modelId 是「容器里出现哪些模型」的模式表,实测语义完全对得上,例如 +// space-100045260/s2c_0000 的 30 个 = CaptchaCC / RegistrationModelCC / EntranceResourcesCC / …(登录+大厅初始化批次) +// space-100045720/s2c_0000 的 5 个 = _t / Kz / ChatCC / ClanChatCC / NewsShowingCC(聊天批次) +// space-100072911 的 2 个 = Jj / ShopCategoryCC(商店) +// 另一个 8 个 = _t / bj / PassToShopCC / uj / oj / aj / DelayMountCategoryCC / MountedResistancesCC +// · 认出模式表后:内层只接受表里的模型、且按表里的先后顺序出现(把假命中几乎清干净, +// 内层记录「吃满边界」的比例从 60% 提到 70%)。 +// · 仍缺的一环:负载开头那几个字节(头部前 12 字节的 a/b/objId)与「模型负载」之间还没对齐—— +// _t 的 codec 读的首个 i64 实测是 1,而 modelRegister 里没有 id=1 的条目,说明 methodId 与模型负载之间 +// 还有一小段(可能是命令 codec 自己写的 tag/版本);大帧(如 41KB 那个容器)连模式表都认不出, +// 头部形状可能还随场景变化。要彻底闭合需要拿到容器命令自身的 codec,而不是 _t 的模型 codec。 // 容器字节归属统计里的「精确覆盖」= 能自洽解到下一个 modelId 候选的字节占比,仅作参考。 const fs = require('fs'); const path = require('path'); @@ -66,6 +73,8 @@ for (const R of Table.Rows) { // modelId(hex 8B) → 模型信息(内层记录用;来源 names/regdump.json 的 u7n_1/w7n_1) const ByModelHex = new Map(); +// modelId(十进制串) → 模型信息(容器「模式表」校验用,含没有 codec 的模型) +const ByModelId = new Map(); const ModelTablePath = path.join(PROJECT, 'names', 'method_table.json'); try { const Reg = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'regdump.json'), 'utf8')); @@ -80,6 +89,7 @@ try { Buf.writeBigInt64BE(BigInt(E.key)); const Rec = { ModelId: E.key, Model: E.ctor, ModelFields: E.keys, Codec: Ww.ctor || '', CodecFields: Ww.keys || [] }; ByModelHex.set(Buf.toString('hex'), Rec); + ByModelId.set(String(E.key), Rec); Rows.push(Rec); } fs.writeFileSync(path.join(PROJECT, 'names', 'model_table.json'), JSON.stringify({ Note: 'modelId → 模型类/codec(内层记录用)', Count: Rows.length, Rows }, null, 1), 'utf8'); @@ -135,16 +145,20 @@ function WalkRecords(Buf, Start) { } const C = CodecByMethod.get(String(Info.MethodId)); if (!C) { - // 没有对应 codec:负载当容器处理。容器内的元素以 modelId 开头,已解出的就用字段表展开, - // 没解出的按「下一个命中方法表的窗口」兜底跳过去,保证还能继续往下走。 + // 没有对应 codec:负载当容器处理。容器头部带「模式表」(出现哪些模型), + // 内层元素以 modelId 开头,已解出的就用字段表展开,没解出的按「下一个命中方法表的窗口」兜底跳过去。 const Next = NextMethodHit(Buf, At + 8 + 1); + const Head = ContainerSchema(Buf, At + 8); + // 认出模式表就从表后开始扫(表里的 id 本身不是数据记录),认不出就退回从负载开头扫 + const From = Head ? Math.min(Head.End, Next) : At + 8; Out.push({ Off: At, Model: Info.Model, Codec: Info.Codec, Container: true, Len: Next - At - 8, - Inner: InnerScan(Buf, At + 8, Next), + Schema: Head ? Head.Schema : null, + Inner: InnerScan(Buf, From, Next, Head ? Head.Schema : null), }); At = Next; continue; @@ -175,14 +189,43 @@ function WalkRecords(Buf, Start) { return { Records: Out, End: At, Ok: Ok && At === Buf.length }; } +// 容器头部:实测形状 [i32 a][i32 b][i64 objId][i32 N][N × i64 modelId](共 20 + 8N 字节) +// a/b 是两个小整数(实测 0/1、1/1、1/42 等),objId 是对象/空间族 id(如 100045311、920004789023) +// N 个 modelId 是「容器里出现哪些模型」的模式表(实测 space-100045720/s2c_0000 的 5 个 id +// = _t + ChatCC + ClanChatCC + NewsShowingCC 等,正是内层记录用的模型)。 +// 校验不过就返回 null —— 不猜,宁可按老办法窗口扫。 +// 判据只依赖「N 个 id 全部是已注册模型」:509 个已知 id 命中 64 个连号的概率可忽略。 +function ContainerSchema(Buf, Off) { + if (Off + 20 > Buf.length) return null; + const N = Buf.readInt32BE(Off + 16); + if (N <= 0 || N > 64 || Off + 20 + 8 * N > Buf.length) return null; + const Schema = []; + for (let K = 0; K < N; K++) { + const Id = Buf.readBigInt64BE(Off + 20 + 8 * K).toString(); + if (!ByModelId.has(Id)) return null; + Schema.push(Id); + } + return { Schema, End: Off + 20 + 8 * N }; +} + // 在容器负载里扫 [modelId 8B] 候选,并用「解出来正好落到下一个候选处」过滤假命中 -// 容器负载实测结构: [头部 N 字节(序号/版本/spaceId/实体 id 等)] [ (modelId 8B)(负载) … ] +// 容器负载实测结构: [头部(含模式表)] [ (modelId 8B)(负载) … ] // 真记录的字段一定解到下一个 modelId 开头,假命中(modelId 字节嵌在别的数据里)解不到。 -function InnerScan(Buf, From, To) { +// 有模式表时只用表里的模型、且按表里的先后顺序出现,假命中几乎绝迹。 +function InnerScan(Buf, From, To, Schema) { + const Allowed = Schema ? new Set(Schema) : null; const Hits = []; + let LastRank = -1; for (let Off = From; Off + 8 <= To; Off++) { - const C = CodecByModelId.get(Buf.readBigInt64BE(Off).toString()); - if (C) Hits.push({ Off, C }); + const Id = Buf.readBigInt64BE(Off).toString(); + const C = CodecByModelId.get(Id); + if (!C) continue; + if (Allowed) { + const Rank = Schema.indexOf(Id); + if (Rank < 0 || Rank < LastRank) continue; + LastRank = Rank; + } + Hits.push({ Off, C }); } const Out = []; let ExactCover = 0; @@ -386,6 +429,14 @@ for (const Dir of Dirs) { '记录 ' + In.Records.length + ' 条(吃满边界的 ' + In.Records.filter((X) => X.Exact).length + ' 条)' + ' 精确覆盖 ' + In.ExactCover + '/' + In.Span + 'B' + (In.Tail ? ' 尾部余 ' + In.Tail + 'B' : ''), ); + if (Rec.Schema) { + const Names = Rec.Schema.map((Id) => { + const R = ByModelId.get(Id) || {}; + const C = CodecByModelId.get(Id); + return (C && C.ModelName) || R.Model || Id; + }); + Lines.push(' 模式表(' + Rec.Schema.length + '): ' + Names.join(', ')); + } for (const Ri of In.Records) { Lines.push( ' ↳ @' + String(Ri.Off).padStart(6) + ' ' + String(Ri.ModelName || Ri.ModelId).padEnd(28) +