feat(3dtank): 解出容器「模式表」头部并约束内层扫描

- ContainerSchema:容器负载头部 = [i32 a][i32 b][i64 objId][i32 N][N × i64 modelId],
  这 N 个 modelId 就是容器里出现的模型(登录批次/聊天批次/商店各自对得上)
- InnerScan 支持模式表:只认表里的模型且按表内顺序出现,内层「吃满边界」60% → 70%,假命中基本清掉
- 容器负载打印模式表模型名;README/注释补已核实结论与未闭合环节
This commit is contained in:
TermiNexus committed 2026-09-16 16:38:24 +08:00
1 parent 2e5e8bd7b0
commit 8d2aa5566d
2 files changed
+83 -19

No files matched your search

+19 -6
View File
@@ -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。
+64 -13
View File
@@ -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) +