客户端实现三种容器布局的读,与 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。
281 lines
11 KiB
JavaScript
281 lines
11 KiB
JavaScript
// 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);
|