// selftest.js - MakeWriter 往返自检:拿抓包里的真实空间帧,逐字段解码 → 再编码 → 与原字节比对 // // 这是写包能力的唯一验收手段:真服务器要能发 s2c,前提是「解出来再写回去」必须逐字节一致。 // 覆盖不到的地方(容器 lt/pt/zt、模块内建 codec 如 Ea)会被单独计数并列出,作为 M2 的待补清单。 // // 用法: node server/selftest.js [--dir ../packets/plain] [--limit 0] [--show 5] 'use strict'; const fs = require('fs'); const path = require('path'); const { LoadTables, MakeReader } = require('../lib_space_codec'); const Link = require('./link'); const { MakeWriter, ResolveFields } = require('./codec'); const { MakeContainerCodec } = require('./container'); 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 PROJECT = path.resolve(__dirname, '..', '..'); const Root = path.resolve(Arg('dir', path.join(PROJECT, 'packets', 'plain'))); const Limit = Number(Arg('limit', '0')) || 0; const Show = Number(Arg('show', '5')) || 5; const Tables = LoadTables(path.join(PROJECT, 'names')); const Reader = MakeReader(Tables, { EnumBytes: 4 }); const Writer = MakeWriter(Tables, { EnumBytes: 4 }); const Container = MakeContainerCodec(Tables, Reader, Writer); // 容器帧(methodId 不在普通 codec / 上行表里):用容器三布局判定,要求恰好吃到帧尾 const BoxStat = { Tried: 0, Detected: 0, Exact: 0, Bad: 0, ByLayout: new Map(), FailSamples: [] }; function TryContainer(Body, At, File) { if (At + 16 > Body.length) return; const MethodId = Body.readBigInt64BE(At + 8).toString(); if (Tables.CodecByMethod.has(MethodId) || Tables.UplinkByMethod.has(MethodId)) return; BoxStat.Tried++; const Off = At + 16; const Hits = ['zt', 'pt', 'lt'].filter((L) => { const R = Container.Read(L, Body, Off); return !R.Bad && R.Next === Body.length; }); if (!Hits.length) { BoxStat.Bad++; if (BoxStat.FailSamples.length < 5) BoxStat.FailSamples.push(File + ' methodId=' + MethodId); return; } BoxStat.Detected++; const Layout = Hits[0]; BoxStat.ByLayout.set(Layout, (BoxStat.ByLayout.get(Layout) || 0) + 1); const R = Container.Read(Layout, Body, Off); try { if (Container.Write(Layout, R.Value).equals(Body.slice(Off))) BoxStat.Exact++; else Note('容器写回不一致', File + ' ' + Layout); } catch (Err) { Note('容器写侧抛错', File + ' ' + Layout + ' ' + Err.message); } } // 首字节是第一个 id 的最高位,且 8 字节后的 methodId 在「下行 codec 表」或「上行命令表」里 → 认作空间记录流 // (实测两张表的 methodId 基本不重合:上行 360 条只在 uplink_table,下行 codec 只在 codec_fields) function IsSpaceLike(Body, At) { if (At >= Body.length) return false; const B0 = Body[At]; if (B0 !== 0x00 && B0 !== 0xff) return false; if (At + 16 > Body.length) return false; const M = Body.readBigInt64BE(At + 8).toString(); return Tables.CodecByMethod.has(M) || Tables.UplinkByMethod.has(M); } const Stats = { Dirs: 0, Files: 0, Frames: 0, SpaceFrames: 0, Records: 0, Exact: 0, Bad: 0, Unsupported: 0, Mismatch: 0 }; const UnsupportedKinds = new Map(); const BadSamples = []; const MismatchSamples = []; function Note(Kind, Msg) { UnsupportedKinds.set(Kind, (UnsupportedKinds.get(Kind) || 0) + 1); if (BadSamples.length < 20) BadSamples.push(Kind + ' ' + Msg); } function ScanFile(File) { Stats.Files++; const Buf = fs.readFileSync(File); const Frames = Link.ParseFrames(Buf); if (!Frames) return; for (const Frame of Frames) { Stats.Frames++; let Body; try { Body = Link.DecodeFrame(Frame); } catch (Err) { continue; } const At = Link.NullmapSize(Body, 0); if (!IsSpaceLike(Body, At)) { TryContainer(Body, At, path.basename(File)); continue; } Stats.SpaceFrames++; let Off = At + 8; while (Off + 8 <= Body.length) { const MethodId = Body.readBigInt64BE(Off).toString(); const { Codec, Fields } = ResolveFields(Tables, MethodId, Tables.UplinkByMethod.get(MethodId)); if (!Codec && !Fields.length) break; const Label = Codec ? Codec.ModelName || Codec.ModelClassReal || '?' : '上行命令'; const MethodRaw = Body.slice(Off, Off + 8); const Start = Off; Off += 8; const Values = []; let Bad = false; for (const F of Fields) { const R = Reader.ReadValue(Body, Off, F.Type, F.TypeCodec, 0); Values.push([F, R.Value]); if (R.Bad || R.Next <= Off) { Bad = true; break; } Off = R.Next; } Stats.Records++; if (Bad) { Stats.Bad++; Note('解码中断', Label + ' methodId=' + MethodId); continue; } const Original = Body.slice(Start, Off); let Encoded = null; try { const Parts = [MethodRaw]; for (const [F, V] of Values) Parts.push(Writer.WriteValue(F.Type, F.TypeCodec, V, 0)); Encoded = Buffer.concat(Parts); } catch (Err) { Stats.Unsupported++; Note(String(Err.message).split(':')[0], Label + ' methodId=' + MethodId); continue; } if (Encoded.equals(Original)) Stats.Exact++; else { Stats.Mismatch++; if (MismatchSamples.length < Show) { MismatchSamples.push( Label + ' methodId=' + MethodId + '\n 原 ' + Original.toString('hex').slice(0, 160) + '\n 写 ' + Encoded.toString('hex').slice(0, 160), ); } } } } } function ScanDir(Dir) { for (const E of fs.readdirSync(Dir, { withFileTypes: true })) { const P = path.join(Dir, E.name); if (E.isDirectory()) { Stats.Dirs++; ScanDir(P); } else if (/^[cs]2[cs]_\d+\.bin$/.test(E.name)) { if (Limit && Stats.Files >= Limit) return; ScanFile(P); } } } ScanDir(Root); console.log('MakeWriter 往返自检(' + Root + ')'); console.log(' 目录 ' + Stats.Dirs + ' 文件 ' + Stats.Files + ' 帧 ' + Stats.Frames + ' 空间帧 ' + Stats.SpaceFrames); console.log(' 记录 ' + Stats.Records + ''); console.log(' ✓ 逐字节一致 ' + Stats.Exact); console.log(' ✗ 不一致 ' + Stats.Mismatch); console.log(' ⚠ 解码中断 ' + Stats.Bad); console.log(' ⚠ 写侧不支持 ' + Stats.Unsupported + '(类型/编码还没做)'); if (UnsupportedKinds.size) { console.log(' 写侧不支持的原因(Top 10):'); for (const [K, V] of [...UnsupportedKinds].sort((A, B) => B[1] - A[1]).slice(0, 10)) console.log(' ' + String(V).padStart(6) + ' ' + K); } if (BadSamples.length) { console.log(' 样本(前 10):'); for (const S of BadSamples.slice(0, 10)) console.log(' ' + S); } if (MismatchSamples.length) { console.log(' 不一致样例:'); for (const S of MismatchSamples) console.log(' ' + S); } console.log(' 容器帧(methodId 不在普通 codec / 上行表里): 候选 ' + BoxStat.Tried + ' 判定出布局 ' + BoxStat.Detected + ' 逐字节一致 ' + BoxStat.Exact + ' 判定失败 ' + BoxStat.Bad); if (BoxStat.ByLayout.size) console.log(' 布局分布: ' + JSON.stringify([...BoxStat.ByLayout])); for (const S of BoxStat.FailSamples) console.log(' 判定失败样例: ' + S);