// 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,并用 <<>> 标记包住(见 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('<<>>'); const End = Stdout.indexOf('<<>>'); 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 + '<<>>'.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);