Files
TankIC/client/tools/CompareContainerDecode.mjs
TermiNexus 830d6408b8 feat(client): 协议层 M2 —— 容器三布局 lt/pt/zt 落定,多记录帧不再丢记录
客户端实现三种容器布局的读,与 JS 实现(3DTank/tools/server/container.js)逐字段对照:5/5 帧、一致叶子 26950 / 不一致 0,两份规范 JSON SHA256 完全相同(各 795139 B)。覆盖 space-100073070/s2c_0004.bin 的 pt(7210 项,含 515 个 Id=0 字面量与 6695 个模型展开) 与其后 28 条 fields 记录——M1 会把这 28 条全丢掉。协议表由 codec_fields.json 的 1143497 B 裁到 ProtocolTables.json 的 124270 B(削 89.1%),做法:只留解码必需属性 + 闭包裁剪(根=所有带 ModelId 的 codec ∪ 命令/容器方法,沿 TypeCodec 与模型真名取闭包,保留 559/643 个 codec),闭包外如实报「无 codec」。计划外但必需:space-100045260 的 s2c_0000/s2c_0004 是 zlib 压缩帧,M1 直接抛错,现用 Node zlib 解压(browser 侧仍显式抛错);并新增正式产物 client/tools/CompareContainerDecode.mjs。
2026-09-18 10:11:10 +08:00

281 lines
11 KiB
JavaScript
Raw Permalink 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.
// CompareContainerDecode.mjs —— M2 容器三布局解码的「JS ↔ Kotlin」逐字段对照(正式产物)
//
// 目的:证明客户端的 lt/pt/zt 读法与服务端权威实现(3DTank/tools/server/container.js,
// 经 semantic.js 的 DecodeFrameBody 驱动)在**真实抓包帧**上逐字段一致。
//
// 两侧结果的产出方式:
// · JS 侧:本脚本直接 require 服务端 container.js / semantic.js,把帧解成规范 JSON(内存对象)。
// · Kotlin 侧:先确保 Kotlin/JS 的 node 产物是最新的(gradlew compileKotlinJs +
// jsDevelopmentExecutableCompileSync),再用 TANKIC_CONTAINER_DUMP=1 运行它;Kotlin 把
// 同批帧解码结果写成规范 JSON,并用 <<<TANKIC_KOTLIN_JSON>>> 标记包住(见 ContainerDump.kt)。
//
// 比对规范(与任务要求一致):
// · 对象键排序后比较;
// · 数字一律按「原始十进制字符串」比(i8..i64/f32/f64 都是;f32/f64 两侧都用 JS 的
// String(x),所以不会出现「Kotlin 输出 0.07 / JS 输出 0.07000000029802322」这类格式化分叉);
// · null 与「键缺失」区分开(键不存在报「<缺失>」,键存在但值为 null 报 null);
// · 只跳过两处「非线数据」:根上的 Tool,以及帧/记录两级的 Note(两侧是各自语言写的诊断文案,
// 不是线上的字节内容;记录是否失败由 Bad 字段判定)。
//
// 用法:
// node client/tools/CompareContainerDecode.mjs # 会先构建再比对
// node client/tools/CompareContainerDecode.mjs --no-build # 直接用现有 Kotlin 产物
'use strict';
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
const Here = path.dirname(url.fileURLToPath(import.meta.url));
const ClientDir = path.resolve(Here, '..');
const RepoDir = path.resolve(ClientDir, '..');
const NamesDir = path.join(RepoDir, '3DTank', 'names');
const ServerDir = path.join(RepoDir, '3DTank', 'tools', 'server');
const PacketsDir = path.join(RepoDir, '3DTank', 'packets', 'plain');
const NodeBundle = path.join(ClientDir, 'build', 'js', 'packages', 'TankIcClient', 'kotlin', 'TankIcClient.js');
const NoBuild = process.argv.includes('--no-build');
// 必测帧(都在 3DTank/packets/plain/ 下,**只读**)
const Corpus = [
'space-100045260/s2c_0000.bin', // zt,负载 1782B(精确吃满)
'space-100045260/s2c_0001.bin', // pt,负载 448B
'space-100073070/s2c_0002.bin', // lt,126B = nullmap(1) + 5×(16B 头 + 9B 负载)
'space-100073070/s2c_0004.bin', // 多记录帧:1 条 pt + 28 条 fields(M1 会丢后面的记录)
'space-920007382011/s2c_0001.bin', // pt 记录后面还有别的记录
];
// ---------------------------------------------------------------- 服务端实现(只读 require)
const require = createRequire(import.meta.url);
const Link = require(path.join(ServerDir, 'link.js'));
const { MakeContainerCodec } = require(path.join(ServerDir, 'container.js'));
const { LoadTables, MakeReader } = require(path.join(RepoDir, '3DTank', 'tools', 'lib_space_codec.js'));
const { MakeWriter } = require(path.join(ServerDir, 'codec.js'));
const Semantic = require(path.join(ServerDir, 'semantic.js'));
const Tables = LoadTables(NamesDir);
const Container = MakeContainerCodec(Tables, MakeReader(Tables, { EnumBytes: 4 }), MakeWriter(Tables, { EnumBytes: 4 }));
const Sem = Semantic.Load(NamesDir);
// 断言 semantic 用的确实是 container.js 的这套布局表(不是另一份拷贝)
const SameLayoutTable = Sem.Container.LAYOUT_BY_METHOD === Container.LAYOUT_BY_METHOD;
// ---------------------------------------------------------------- 规范化
// 与 Kotlin 侧 CanonicalJson 同口径:数字 → 十进制字符串,对象键排序,null 保留
function Canon(V) {
if (V === undefined) return undefined;
if (V === null) return null;
if (typeof V === 'bigint') return V.toString();
if (typeof V === 'number') return String(V);
if (typeof V === 'boolean' || typeof V === 'string') return V;
if (Array.isArray(V)) return V.map(Canon);
if (typeof V === 'object') {
const Out = {};
for (const K of Object.keys(V).sort()) Out[K] = Canon(V[K]);
return Out;
}
return String(V);
}
function JsRecord(R) {
const Out = {
ObjectId: R.ObjectId,
MethodId: R.MethodId,
Kind: R.Kind,
Layout: R.Layout || '',
Bad: !!R.Bad,
Note: R.Note || '',
};
if (R.Kind === 'container') Out.Value = Canon(R.Value === undefined ? null : R.Value);
else if (R.Kind === 'fields') {
const F = {};
for (const X of R.Fields || []) F[X.Name] = Canon(X.Value);
Out.Fields = F;
}
return Out;
}
function JsDump(AbsPath) {
const Buf = fs.readFileSync(AbsPath);
const Frames = Link.ParseFrames(Buf);
if (!Frames) return { Path: AbsPath, Error: '整帧流解析失败(有残余字节)', Frames: [] };
const OutFrames = [];
for (const Fr of Frames) {
let Body;
try {
Body = Link.DecodeFrame(Fr);
} catch (Err) {
OutFrames.push({ BodyBytes: Fr.Body.length, Records: [], Bad: true, Note: String(Err.message) });
continue;
}
const D = Sem.DecodeFrameBody(Body);
OutFrames.push({
BodyBytes: Body.length,
Records: D.Records.map(JsRecord),
Bad: !!D.Bad,
Note: D.Note || '',
});
}
return { Path: AbsPath, Error: null, Frames: OutFrames };
}
// ---------------------------------------------------------------- 跑 Kotlin 侧
function BuildKotlin() {
if (NoBuild) return;
const Gradlew = path.join(ClientDir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew');
const R = spawnSync(Gradlew, ['compileKotlinJs', 'jsDevelopmentExecutableCompileSync', '--console=plain', '-q'], {
cwd: ClientDir,
encoding: 'utf8',
shell: process.platform === 'win32',
});
if (R.status !== 0) {
console.error('✗ Kotlin 构建失败:');
console.error((R.stdout || '') + (R.stderr || ''));
process.exit(1);
}
}
function RunKotlin(AbsFiles) {
const R = spawnSync('node', [NodeBundle], {
encoding: 'utf8',
maxBuffer: 512 * 1024 * 1024,
env: { ...process.env, TANKIC_CONTAINER_DUMP: '1', TANKIC_CONTAINER_FILES: AbsFiles.join(';') },
});
const Stdout = R.stdout || '';
const Start = Stdout.indexOf('<<<TANKIC_KOTLIN_JSON>>>');
const End = Stdout.indexOf('<<<TANKIC_KOTLIN_JSON_END>>>');
if (Start < 0 || End < 0) {
console.error('✗ 没能从 Kotlin 输出里取到 JSON 标记。stdout 末尾:');
console.error(Stdout.slice(-2000));
console.error(R.stderr || '');
process.exit(1);
}
return JSON.parse(Stdout.slice(Start + '<<<TANKIC_KOTLIN_JSON>>>'.length, End));
}
// ---------------------------------------------------------------- 逐字段比对
const SkipKeys = new Set(['Tool']);
function IsFrameNode(O) {
return !!O && typeof O === 'object' && !Array.isArray(O) && Array.isArray(O.Records) && 'BodyBytes' in O;
}
function IsRecordNode(O) {
return !!O && typeof O === 'object' && !Array.isArray(O) && 'ObjectId' in O && 'MethodId' in O;
}
function ShouldSkip(Key, Parent) {
if (SkipKeys.has(Key)) return true;
// 帧级/记录级的 Note 是各自语言写的诊断文案,不是线数据(失败与否看 Bad)
if (Key === 'Note' && (IsFrameNode(Parent) || IsRecordNode(Parent))) return true;
return false;
}
const Stat = { Match: 0, Miss: 0 };
const Diffs = [];
function Describe(V) {
if (V === undefined) return '<缺失>';
const S = JSON.stringify(V);
return S === undefined ? String(V) : S.length > 160 ? S.slice(0, 160) + '…' : S;
}
function Diff(Js, Kt, P) {
if (Js === undefined || Kt === undefined) {
Stat.Miss++;
Diffs.push({ Path: P, Js: Describe(Js), Kotlin: Describe(Kt) });
return;
}
if (Js === null || Kt === null || typeof Js !== 'object' || typeof Kt !== 'object') {
if (Js === Kt) Stat.Match++;
else {
Stat.Miss++;
Diffs.push({ Path: P, Js: Describe(Js), Kotlin: Describe(Kt) });
}
return;
}
if (Array.isArray(Js) || Array.isArray(Kt)) {
if (!Array.isArray(Js) || !Array.isArray(Kt)) {
Stat.Miss++;
Diffs.push({ Path: P, Js: Describe(Js), Kotlin: Describe(Kt) });
return;
}
if (Js.length !== Kt.length) {
Stat.Miss++;
Diffs.push({ Path: P + '(数组长度)', Js: String(Js.length), Kotlin: String(Kt.length) });
}
for (let I = 0; I < Math.min(Js.length, Kt.length); I++) Diff(Js[I], Kt[I], P + '[' + I + ']');
return;
}
const Keys = [...new Set([...Object.keys(Js), ...Object.keys(Kt)])].sort();
for (const K of Keys) {
if (ShouldSkip(K, Js)) continue;
Diff(Js[K], Kt[K], P + '.' + K);
}
}
// ---------------------------------------------------------------- 主流程
const AbsFiles = Corpus.map((F) => path.join(PacketsDir, F));
for (const F of AbsFiles) {
if (!fs.existsSync(F)) {
console.error('✗ 缺少必测帧:' + F);
process.exit(1);
}
}
console.log('M2 容器解码逐字段对照(JS = server/container.js,经 semantic.js 驱动)');
console.log(' 布局表同一份对象:' + (SameLayoutTable ? '是' : '否 ⚠'));
console.log(' 必测帧 ' + Corpus.length + ' 个');
BuildKotlin();
const JsResult = { Tool: 'TankIcClient/ContainerDump', Files: AbsFiles.map(JsDump) };
const KtResult = RunKotlin(AbsFiles);
KtResult.Tool = KtResult.Tool || 'TankIcClient/ContainerDump';
// 把两侧的规范 JSON 落盘(build/ 已被 gitignore),供人工核对
const DumpDir = path.join(ClientDir, 'build', 'CompareContainerDecode');
fs.mkdirSync(DumpDir, { recursive: true });
fs.writeFileSync(path.join(DumpDir, 'JsResult.json'), JSON.stringify(JsResult), 'utf8');
fs.writeFileSync(path.join(DumpDir, 'KotlinResult.json'), JSON.stringify(KtResult), 'utf8');
console.log(' 两侧规范 JSON 已落盘:' + DumpDir);
const JsFiles = JsResult.Files;
const KtFiles = KtResult.Files || [];
if (JsFiles.length !== KtFiles.length) {
console.error('✗ 两侧文件数不同:JS ' + JsFiles.length + ' / Kotlin ' + KtFiles.length);
process.exit(1);
}
console.log('');
console.log('逐帧记录数 / Bad 记录数(JS ↔ Kotlin):');
for (let I = 0; I < Corpus.length; I++) {
const J = JsFiles[I];
const K = KtFiles[I];
const Jf = (J.Frames || []).map((F) => (F.Records || []).length + ':' + (F.Records || []).filter((R) => R.Bad).length);
const Kf = (K.Frames || []).map((F) => (F.Records || []).length + ':' + (F.Records || []).filter((R) => R.Bad).length);
const Same = JSON.stringify(Jf) === JSON.stringify(Kf);
const Layers = [];
for (const F of K.Frames || []) for (const R of F.Records || []) if (R.Kind === 'container') Layers.push(R.Layout);
console.log(
' ' + Corpus[I] + ' 帧数 ' + (J.Frames || []).length + ' 容器布局 [' + Layers.join(',') + ']' +
' 记录:Bad JS [' + Jf.join(', ') + '] Kotlin [' + Kf.join(', ') + '] ' + (Same ? '✓' : '✗')
);
}
Diff(JsFiles, KtFiles, 'Files');
console.log('');
console.log('比对结果:一致叶子 ' + Stat.Match + ' / 不一致 ' + Stat.Miss);
if (Stat.Miss) {
console.log('差异明细(最多 40 条):');
for (const D of Diffs.slice(0, 40)) {
console.log(' ' + D.Path);
console.log(' JS = ' + D.Js);
console.log(' Kotlin = ' + D.Kotlin);
}
if (Diffs.length > 40) console.log(' …其余 ' + (Diffs.length - 40) + ' 条略');
}
console.log(Stat.Miss ? '✗ 存在不一致' : '✓ 逐字段一致');
process.exit(Stat.Miss ? 1 : 0);