Files
TankIC/3DTank/tools/mine_downlink.js
T
TermiNexus 2ef424929b fix(protocol): 适配 e98d0216 版编解码 API 并重生成协议表
- extract_codec_fields:字段工厂 n7f→r7g、读写方法 p7g/o7g→t7h/s7h、槽位初始化 q7g→u7h
- 资源类注册 u83→y84,核心类型按 模块::导出键 解析(Vector3/Long/实体引用)
- mine_downlink:读取器映射按当前 ByteBuffer 修正(t2u=f32、u2u=f64 等),容器槽位泛化
- 重生成 codec_fields/uplink_table/downlink_table:762 下行字段 0 未解,361 上行命令全量有类型
2026-10-21 00:03:07 +08:00

499 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/module_72834.js(p2u=xy=getInt8 … t2u=getFloat32、u2u=getFloat64)
const ReaderType = { xy: 'i8', p2u: 'i8', q2u: 'i16', r2u: 'i32', s2u: 'i64', t2u: 'f32', u2u: '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', 'vector3']);
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] });
// 基础类型:旧包固定缓冲槽位 a7g_1,新包槽位名会变,故泛化
const RePrim = /\.([a-z0-9]+_\d+)\.([A-Za-z0-9_$]+)\(\)/g;
while ((M = RePrim.exec(Body))) Hits.push({ At: M.index, Reader: M[2] });
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$]*)\)\.u7j = 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 + ')');
}
}
}
}