diff --git a/3DTank/tools/analyze_item_trace.js b/3DTank/tools/analyze_item_trace.js new file mode 100644 index 0000000..a2b02bd --- /dev/null +++ b/3DTank/tools/analyze_item_trace.js @@ -0,0 +1,164 @@ +// analyze_item_trace.js —— 用客户端 pt 项打点(module_45686::lt.t7h)核对「每个模型到底消费几个 opt 位」 +// +// 打点行(names/item_trace.json):{ own, b0, b1, p0, p1 } +// own = modelId 对象(Kotlin Long)的自有属性串,如 "f20_1=0;g20_1=123;" +// b0/b1 = 本项负载起止字节;p0/p1 = 本项消费的绝对位区间(p1-p0 就是该项的 opt 位数) +// +// 做法:把 own 还原成 modelId(用 model_binding 校验哪种高低位组合能命中已知 modelId), +// 再按 modelId 汇总「客户端实测位数」,与字段表递归算出的位数对比,打印不一致的模型。 +// +// 用法: node analyze_item_trace.js [--top 30] +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const PROJECT = path.resolve(__dirname, '..'); +const Argv = process.argv.slice(2); +const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; }; +const Top = Number(Arg('top', '30')) || 30; + +const J = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'codec_fields.json'), 'utf8')); +const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8')); +const KeyByModelId = new Map(); +const KnownIds = new Set(); +for (const E of Binding.Entries) { + KeyByModelId.set(String(E.ModelId), E.Key); + KnownIds.add(String(E.ModelId)); +} + +// 字段表递归数 opt 位(与 audit_bit_budget 同口径;list/map 不计元素,元素数由数据决定) +function OptBitsOfKey(Key, Depth) { + if (Depth > 8) return null; + const C = J.Codecs[Key]; + if (!C) return null; + let N = 0; + for (const F of C.Fields || []) { + const V = OptBitsOfType(F.Type, Depth); + if (V === null) return null; + N += V; + } + return N; +} +function OptBitsOfType(Type, Depth) { + if (!Type) return null; + let T = String(Type).trim(); + let N = 0; + if (T.startsWith('opt<')) { + N++; + T = T.slice(4, -1).trim(); + } + const M = /^(list|enum|map)<([\s\S]*)>$/.exec(T); + if (M) { + // 列表/映射的元素在运行时才知道有几个 → 这里只算元素类型自身的「固定」部分 + if (M[1] === 'map') { + const P = M[2].split(','); + const A = OptBitsOfType(P[0], Depth + 1); + const B = OptBitsOfType(P[1], Depth + 1); + return A === null || B === null ? N : N + A + B; + } + const Inner = OptBitsOfType(M[2], Depth + 1); + return Inner === null ? N : N + Inner; + } + const K = FindKey(T); + if (!K) return N; // 基础类型 / 未知:只算自身的 opt 位 + const Sub = OptBitsOfKey(K, Depth + 1); + if (Sub === null) return N; + return N + Sub; +} +function FindKey(Name) { + if (J.Codecs[Name]) return Name; + for (const K of Object.keys(J.Codecs)) if (K.endsWith('::' + Name)) return K; + // 字段里写的是「模型真名」(如 ParticleEffectsEntity)而不是 codec 键 + for (const [K, C] of Object.entries(J.Codecs)) if (C.ModelName === Name) return K; + return null; +} + +// own → modelId +function ParseId(Own) { + if (!Own) return null; + const Pairs = Own.split(';').filter(Boolean).map((P) => P.split('=')); + const Nums = Pairs.map((P) => Number(P[1])).filter((V) => Number.isFinite(V)); + if (Nums.length === 1) return String(Nums[0]); + if (Nums.length !== 2) return null; + const [A, B] = Nums; + const Cand = [ + String(BigInt(A) * 4294967296n + BigInt(B >>> 0)), // (high, low) + String(BigInt(B) * 4294967296n + BigInt(A >>> 0)), // (low, high) + ]; + for (const C of Cand) if (KnownIds.has(C)) return C; + return Cand[0]; +} + +const Raw = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'item_trace.json'), 'utf8')); +const Rows = Raw.trace || Raw.rows || []; +console.log('pt 项打点 ' + Rows.length + ' 行(at ' + Raw.at + ')'); + +// --frames N:按「b0 回落」切帧,打印每项 modelId / 位区间 / 字节区间,便于和本地 packets 对照 +const WantFrames = Number(Arg('frames', '0')) || 0; +if (WantFrames) { + const Frames = []; + let Cur = null; + let PrevB0 = -1; + for (const R of Rows) { + if (!Cur || R.b0 < PrevB0) { Cur = []; Frames.push(Cur); } + Cur.push(R); + PrevB0 = R.b0; + } + console.log('切出 ' + Frames.length + ' 帧'); + const PerFrame = Number(Arg('per-frame', '40')) || 40; + Frames.slice(0, WantFrames).forEach((F, I) => { + console.log('帧#' + (I + 1) + ' 共 ' + F.length + ' 项:'); + let PrevBuf = null; + let PrevNm = null; + for (const R of F.slice(0, PerFrame)) { + const Id = ParseId(R.own) || '?'; + const Key = KeyByModelId.get(Id) || ''; + // buf 变化 = 换了位流(嵌套消息新开一条);nm = 位流总位数 + const Sw = PrevBuf !== null && R.buf !== undefined && R.buf !== PrevBuf; + const NmSw = PrevNm !== null && R.nm !== undefined && R.nm !== PrevNm; + if (Sw || NmSw) console.log(' ---- 位流切换:buf ' + PrevBuf + '→' + R.buf + ',nm ' + PrevNm + '→' + R.nm + ' ----'); + PrevBuf = R.buf; + PrevNm = R.nm; + console.log(' id=' + Id.padStart(20) + ' ' + Key.padEnd(22) + ' byte ' + R.b0 + '→' + R.b1 + + '(' + (R.b1 - R.b0) + ') 位 ' + R.p0 + '→' + R.p1 + '(' + (R.p1 - R.p0) + ')' + + (R.nm === undefined ? '' : ' 流长 ' + R.nm + ' buf#' + R.buf)); + } + if (F.length > PerFrame) console.log(' …还有 ' + (F.length - PerFrame) + ' 项'); + }); + process.exit(0); +} + + +const Stats = new Map(); // modelId -> { Count, Bits: Map } +let UnknownId = 0; +for (const R of Rows) { + const Id = ParseId(R.own); + if (!Id) { UnknownId++; continue; } + const Key = KeyByModelId.get(Id); + if (!Key) { UnknownId++; continue; } + const Bits = R.p1 - R.p0; + let S = Stats.get(Id); + if (!S) { S = { Id, Key, Count: 0, Bits: new Map() }; Stats.set(Id, S); } + S.Count++; + S.Bits.set(Bits, (S.Bits.get(Bits) || 0) + 1); +} +console.log('可识别项 ' + [...Stats.values()].reduce((A, S) => A + S.Count, 0) + ',不可识别 ' + UnknownId); + +const Diffs = []; +const Same = []; +for (const S of Stats.values()) { + const Our = OptBitsOfKey(S.Key, 0); + const BitsList = [...S.Bits.entries()].sort((A, B) => B[1] - A[1]); + const Main = BitsList[0][0]; + const Rec = { Key: S.Key, Id: S.Id, Count: S.Count, Our, Main, BitsList }; + if (Our === null || Main !== Our) Diffs.push(Rec); + else Same.push(Rec); +} +Diffs.sort((A, B) => B.Count - A.Count); +console.log('一致 ' + Same.length + ' 个模型;不一致 ' + Diffs.length + ' 个:'); +for (const D of Diffs.slice(0, Top)) { + console.log(' ' + D.Key + '(modelId ' + D.Id + ')出现 ' + D.Count + ' 次:客户端实测位 ' + + JSON.stringify(D.BitsList) + ',字段表递归算 ' + D.Our); +} +const SameTop = Same.sort((A, B) => B.Count - A.Count).slice(0, 10); +console.log('一致的模型(前 10):' + SameTop.map((S) => S.Key + '×' + S.Count + '(' + S.Main + '位)').join(', ')); diff --git a/3DTank/tools/audit_bit_budget.js b/3DTank/tools/audit_bit_budget.js new file mode 100644 index 0000000..7a242cd --- /dev/null +++ b/3DTank/tools/audit_bit_budget.js @@ -0,0 +1,153 @@ +// audit_bit_budget.js —— 用 nullmap 长度形态反推「真实 opt 位总数」,校验字段表的 opt 判定 +// +// 原理(编码端选最长?不,选最短):nullmap 的长度形态由编码端按本次用到的位数选最短—— +// 短形态(bit7=0):总长 1..4 字节,容量 5/13/21/29(扣掉 3 位长度标签) +// 长形态(bit7=1,bit6=0):1 字节标签 + L 字节,容量 8L +// 长形态(bit7=1,bit6=1):3 字节标签 + L 字节,容量 8L +// 所以「实际容量」必然等于 minimalCapacity(真实位数)。若和我们算出的位数不一致, +// 说明字段表在某些模型上少算/多算了 opt 位——这类错误**看不出来**:opt 位为 0(有值)时字节完全一样。 +// +// 两种模式: +// --calib 只看「不含容器、无短命令」的帧:这些帧 semantic 的位游标就是我们的位数,直接比容量 +// --pt 只看向量记录(pt)是最后一帧、且前面记录都能解:容器项解完后位游标应正好用满容量 +// +// 用法: +// node audit_bit_budget.js --calib +// node audit_bit_budget.js --pt [--top 20] +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { LoadTables, MakeReader } = require('./lib_space_codec'); +const Link = require('./server/link'); +const Semantic = require('./server/semantic').Load(path.join(__dirname, '..', 'names')); + +const PROJECT = path.resolve(__dirname, '..'); +const Argv = process.argv.slice(2); +const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; }; +const Mode = Argv.indexOf('--pt') >= 0 ? 'pt' : 'calib'; +const Top = Number(Arg('top', '20')) || 20; +// --body:文件即帧体(无长度头),配合 wss_server.js --dump-plain 的产物 +const BodyMode = Argv.indexOf('--body') >= 0; +const PT_METHOD = '7640916300855664666'; + +function CapacityOf(Count) { + if (Count <= 29) return 8 * Math.ceil((Count + 3) / 8) - 3; + if (Count <= 504) return 8 * (Math.ceil((Count + 8) / 8) - 1); + return 8 * Math.ceil(Count / 8); +} + +const Tables = LoadTables(path.join(PROJECT, 'names')); +const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8')); +const CodecKeyByModelId = new Map(); +for (const E of Binding.Entries) CodecKeyByModelId.set(String(E.ModelId), E.Key); +const RESOURCE_CLASS_WIRE = { + Ea: 'i64', nh: 'i64', W_: 'i64', uo: 'i64', pv: 'i64', B_: 'i64', Da: 'i64', + qs: 'i64', uh: 'i64', Gf: 'i64', kv: 'i64', Pc: 'i64', +}; +const Alias = {}; +for (const [N, R] of Object.entries(Tables.ResourceClasses || {})) Alias[N] = R.Wire; +for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) Alias[N] = W; +const Reader = MakeReader(Tables, { + EnumBytes: 4, EnumInList: 4, OptBits: true, TypeAlias: Alias, StringLiteralOnly: true, +}); + +function NullmapBits(Bytes) { + const Bits = []; + const Push = (B) => { for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1); }; + if (Bytes.length && Bytes[0] & 0x80) { + for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]); + } else { + for (const B of Bytes) Push(B); + Bits.splice(0, 3); + } + return Bits; +} + +const Dir = path.resolve(Arg('plain', path.join(PROJECT, 'packets', 'plain'))); +const Files = []; +function Walk(P) { + for (const E of fs.readdirSync(P, { withFileTypes: true })) { + const Q = path.join(P, E.name); + if (E.isDirectory()) Walk(Q); + else if (/^s2c_\d+\.bin$/.test(E.name)) Files.push(Q); + } +} +Walk(Dir); + +let Checked = 0; +const Delta = new Map(); +const Rows = []; +for (const File of Files) { + // --body:文件即帧体(无长度头) + const Bodies = []; + if (BodyMode) { + try { Bodies.push(fs.readFileSync(File)); } catch (E) { continue; } + } else { + let Parsed; + try { Parsed = Link.ParseFrames(fs.readFileSync(File)); } catch (E) { continue; } + for (const F of Parsed || []) { + try { Bodies.push(Link.DecodeFrame(F)); } catch (E) {} + } + } + for (const Body of Bodies) { + let Dec; + try { Dec = Semantic.DecodeFrameBody(Body); } catch (E) { continue; } + const Recs = Dec.Records || []; + if (!Recs.length) continue; + const NmSize = Link.NullmapSize(Body, 0); + if (NmSize <= 0 || NmSize > Body.length) continue; + const Pb = Body[0]; + const ActualCap = Pb & 0x80 ? 8 * (NmSize - (Pb & 0x40 ? 3 : 1)) : 8 * NmSize - 3; + + let Our = null; + let Tail = ''; + if (Mode === 'calib') { + // 纯字段表帧:所有记录都不走容器、不是短命令 + if (Dec.Bad) continue; + if (Recs.some((R) => R.Layout || R.Short || R.Bad)) continue; + if (typeof Dec.Bits !== 'number') continue; + Our = Dec.Bits; + Tail = String(Recs[Recs.length - 1].MethodId); + } else { + // 容器帧:最后一条记录是 pt,前面没有解不动的 + const R = Recs[Recs.length - 1]; + if (String(R.MethodId) !== PT_METHOD || R.At === undefined) continue; + if (Recs.slice(0, -1).some((X) => X.Bad)) continue; + const Bits = NullmapBits(Body.slice(0, NmSize)); + Reader.AttachBits(Bits, R.BitStart + 1); + let At = R.At + 16; + let Bad = false; + try { + const C = Body.readInt32BE(At); + At += 4 + 16 * C; + const N = Body.readInt32BE(At); + At += 4; + for (let I = 0; I < N; I++) { + const Id = Body.readBigInt64BE(At).toString(); + At += 8; + if (Id === '0') { At += 8; continue; } + const Key = CodecKeyByModelId.get(Id) || '未注册'; + Tail = Key; + const R2 = Reader.ReadFields(Body, At, Tables.CodecByKey.get(Key), 0); + At = R2.Next; + if (R2.Bad) { Bad = true; break; } + } + } catch (E) { Bad = true; } + Our = Reader.BitCursor().Index; + Reader.DetachBits(); + if (Bad) continue; + } + Checked++; + const Want = CapacityOf(Our); + Delta.set(ActualCap - Want, (Delta.get(ActualCap - Want) || 0) + 1); + if (Want !== ActualCap) Rows.push({ File: path.relative(Dir, File), Our, ActualCap, Want, Tail }); + } +} +console.log('[' + Mode + '] 可核对帧 ' + Checked + ',容量差分布(实际 - 期望):' + + JSON.stringify([...Delta.entries()].sort((A, B) => B[1] - A[1]))); +Rows.sort((A, B) => (B.ActualCap - B.Our) - (A.ActualCap - A.Our)); +console.log('不匹配 ' + Rows.length + ' 帧:'); +for (const R of Rows.slice(0, Top)) { + console.log(' ' + R.File + ' 我们 ' + R.Our + ' 位(期望容量 ' + R.Want + ') 实际容量 ' + R.ActualCap + + ' 差 ' + (R.ActualCap - R.Our) + ' 末项 ' + R.Tail); +} diff --git a/3DTank/tools/audit_pt_boundaries.js b/3DTank/tools/audit_pt_boundaries.js index 91c1e41..6ef6279 100644 --- a/3DTank/tools/audit_pt_boundaries.js +++ b/3DTank/tools/audit_pt_boundaries.js @@ -27,6 +27,8 @@ const Arg = (Name, Def) => { const PROJECT = path.resolve(__dirname, '..'); const PlainDir = path.resolve(Arg('plain', path.join(PROJECT, 'packets', 'plain'))); const Limit = Number(Arg('limit', '0')) || 0; +// --body:文件即帧体(无长度头),配合 wss_server.js --dump-plain 的产物 +const BodyMode = Argv.indexOf('--body') >= 0; const Tables = LoadTables(path.join(PROJECT, 'names')); const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8')); @@ -59,6 +61,7 @@ if (!Number.isFinite(EnumBytes) || !Number.isFinite(EnumInList)) throw new Error const Reader = MakeReader(Tables, { EnumBytes, EnumInList, + EnumListOptional: Argv.indexOf('--enum-list-optional') >= 0, OptBits: true, TypeAlias: Alias, StringLiteralOnly: true, @@ -210,20 +213,20 @@ function AuditPt(Body, Off, File, BitStart, Label) { function ScanFile(File) { Stats.Files++; - let Frames = null; - try { - Frames = Link.ParseFrames(fs.readFileSync(File)); - } catch (E) { - return; - } - if (!Frames) return; - for (const F of Frames) { - let Body; - try { - Body = Link.DecodeFrame(F); - } catch (E) { - continue; + // --body:文件内容就是「解密 + 解压后的帧体」(wss_server.js --dump-plain 的产物), + // 没有 2/4 字节长度头,不能再走 Link.ParseFrames。 + const Bodies = []; + if (BodyMode) { + try { Bodies.push(fs.readFileSync(File)); } catch (E) { return; } + } else { + let Frames = null; + try { Frames = Link.ParseFrames(fs.readFileSync(File)); } catch (E) { return; } + if (!Frames) return; + for (const F of Frames) { + try { Bodies.push(Link.DecodeFrame(F)); } catch (E) {} } + } + for (const Body of Bodies) { // 用 semantic 顺序解完整帧:它维护跨记录的位游标,并给每条记录标出 BitStart。 // 只审计 pt 记录;其它记录的负载长度由 semantic 负责推进(短命令会中断整帧,属已知限制)。 let Decoded = null; diff --git a/3DTank/tools/dump_codec_src.js b/3DTank/tools/dump_codec_src.js new file mode 100644 index 0000000..8e6ffa1 --- /dev/null +++ b/3DTank/tools/dump_codec_src.js @@ -0,0 +1,40 @@ +// _dump_codec_src.js —— 打印指定 codec 的 u7h / t7h 源码体(临时诊断用) +// 用法: node _dump_codec_src.js 85183::Tl 85183::Rl ... +'use strict'; +const fs = require('fs'); +const path = require('path'); +const SRC = path.join(__dirname, '..', 'src'); + +function MatchBrace(Src, Start) { + let Depth = 0; + for (let I = Start; I < Src.length; I++) { + if (Src[I] === '{') Depth++; + else if (Src[I] === '}') { Depth--; if (Depth === 0) return I; } + } + return -1; +} + +function ClassMethod(Src, Cls, Kind) { + const Head = new RegExp('\\(\\s*' + Cls.replace(/\$/g, '\\$') + '\\s*\\)\\.' + Kind + '\\s*=\\s*function\\s*\\([^)]*\\)\\s*\\{', 'g'); + const Out = []; + let M; + while ((M = Head.exec(Src))) { + const Start = M.index + M[0].length - 1; + const End = MatchBrace(Src, Start); + if (End > 0) Out.push(Src.slice(Start, End + 1)); + } + return Out; +} + +for (const Key of process.argv.slice(2)) { + const [Mod, Cls] = Key.split('::'); + const File = path.join(SRC, (Mod.startsWith('module_') ? Mod : 'module_' + Mod) + '.js'); + if (!fs.existsSync(File)) { console.log('!! 无源码 ' + File); continue; } + const Src = fs.readFileSync(File, 'utf8'); + console.log('=== ' + Key + ' ==='); + for (const Kind of ['u7h', 't7h']) { + const Bodies = ClassMethod(Src, Cls, Kind); + if (!Bodies.length) { console.log(' [' + Kind + '] 未找到'); continue; } + Bodies.forEach((B, I) => console.log(' [' + Kind + '#' + I + '] ' + B.replace(/\s+/g, ' '))); + } +} diff --git a/3DTank/tools/lib_space_codec.js b/3DTank/tools/lib_space_codec.js index fbfb1e5..505082a 100644 --- a/3DTank/tools/lib_space_codec.js +++ b/3DTank/tools/lib_space_codec.js @@ -270,6 +270,16 @@ function MakeReader(Tables, Options) { return { Value: Pairs, Next: At, Bad }; } + // 定长二进制块:Type = bytes + // 出处:module_87072.js:53876 `(Zy(T5).t7h = function (t) { this.j101_1.v100(t.g7h_1, 23); … })` + // —— TankState 的线上形态是一个 23 字节的打包姿态 blob(内部按 19/13/13/13 位量化 4 个向量)。 + // 我们不解释内容,只按定长吃掉,保证后续字节对得上。 + const Fixed = /^bytes(\d+)$/.exec(T); + if (Fixed) { + const N = Number(Fixed[1]); + if (Off + N > Buf.length) return { Value: '<越界>', Next: Off, Bad: true }; + return { Value: '0x' + Buf.slice(Off, Off + N).toString('hex'), Next: Off + N }; + } switch (T) { case 'bool': if (Off >= Buf.length) return { Value: '<越界>', Next: Off, Bad: true }; diff --git a/3DTank/tools/probe_pt_record.js b/3DTank/tools/probe_pt_record.js new file mode 100644 index 0000000..c43ca76 --- /dev/null +++ b/3DTank/tools/probe_pt_record.js @@ -0,0 +1,144 @@ +// _probe_record.js —— 列出某个 pt 记录里每一项的 modelId / codec / 字节区间 / opt 位数 +// +// 用途:定位「位游标与客户端不一致」时,究竟是前面哪一项少算了 opt 位。 +// 用法: node _probe_record.js <相对 packets/plain 的文件> [记录序号=1] [最多项=20] +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { LoadTables, MakeReader } = require('./lib_space_codec'); +const Link = require('./server/link'); +const Semantic = require('./server/semantic').Load(path.join(__dirname, '..', 'names')); + +const PROJECT = path.resolve(__dirname, '..'); +const File = path.resolve(PROJECT, 'packets', 'plain', process.argv[2]); +const WantRecord = Number(process.argv[3] || '1'); +const MaxItems = Number(process.argv[4] || '20'); +// 可选:只打印 codec 键包含该子串的项(其余项照常推进,只是不打印) +const KeyFilter = process.argv[5] || ''; +// 可选:<项序号>:<位数> —— 在第 N 项(1 基)开始前额外推进位游标(模拟「前面的项多读了几位」) +const InsertArg = (process.argv[6] || '').split(':'); +const InsertAt = InsertArg.length === 2 ? Number(InsertArg[0]) - 1 : -1; +const InsertBits = InsertArg.length === 2 ? Number(InsertArg[1]) : 0; +// 可选:--tree 打印每一项的字段树(含嵌套模型 / 列表元素),用来核对「嵌套里到底有几个元素」 +const Tree = process.argv.indexOf('--tree') >= 0; + +const Tables = LoadTables(path.join(PROJECT, 'names')); +const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8')); +const CodecKeyByModelId = new Map(); +for (const E of Binding.Entries) CodecKeyByModelId.set(String(E.ModelId), E.Key); + +const RESOURCE_CLASS_WIRE = { + Ea: 'i64', nh: 'i64', W_: 'i64', uo: 'i64', pv: 'i64', B_: 'i64', Da: 'i64', + qs: 'i64', uh: 'i64', Gf: 'i64', kv: 'i64', Pc: 'i64', +}; +const Alias = {}; +for (const [N, R] of Object.entries(Tables.ResourceClasses || {})) Alias[N] = R.Wire; +for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) Alias[N] = W; + +let TreeOn = false; +const Reader = MakeReader(Tables, { + EnumBytes: 4, EnumInList: 4, OptBits: true, TypeAlias: Alias, StringLiteralOnly: true, + OnField: (I) => { + if (!TreeOn) return; + console.log(' ' + ' '.repeat(I.Depth) + I.Name + ' <' + I.Type + '> ' + I.Start + '→' + I.Next + + ' bit ' + I.BitBefore + '→' + I.BitAfter + (I.Bad ? ' BAD ' + I.Why : '')); + }, +}); + +// 位流(与 audit 同口径) +function NullmapBits(Bytes) { + const Bits = []; + const Push = (B) => { for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1); }; + if (Bytes.length && Bytes[0] & 0x80) { + for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]); + } else { + for (const B of Bytes) Push(B); + Bits.splice(0, 3); + } + return Bits; +} +// 一个 codec(含嵌套)会消耗的 opt 位数(只看字段表) +function CountOptBits(Key, Depth) { + if (Depth > 6) return 0; + const C = Tables.CodecByKey.get(Key); + if (!C) return 0; + let N = 0; + for (const F of C.Fields || []) N += CountTypeOptBits(F.Type, Depth); + return N; +} +function CountTypeOptBits(Type, Depth) { + if (!Type) return 0; + let N = 0; + let T = Type.trim(); + if (T.startsWith('opt<')) { N++; T = T.slice(4, -1).trim(); } + const M = /^(list|enum|map)<([\s\S]*)>$/.exec(T); + if (M) { + const Inner = M[2].trim(); + if (M[1] === 'map') { + const P = Inner.split(','); + N += CountTypeOptBits(P[0], Depth + 1) + CountTypeOptBits(P[1], Depth + 1); + } else N += CountTypeOptBits(Inner, Depth + 1); + return N; + } + const K = FindCodecKey(T); + if (K) N += CountOptBits(K, Depth + 1); + return N; +} +function FindCodecKey(Name) { + if (Tables.CodecByKey.has(Name)) return Name; + for (const [K] of Tables.CodecByKey) if (K.endsWith('::' + Name)) return K; + return null; +} + +const Body = process.argv.indexOf('--body') >= 0 + ? fs.readFileSync(File) + : Link.DecodeFrame(Link.ParseFrames(fs.readFileSync(File))[0]); +const NmSize = Link.NullmapSize(Body, 0); +const Bits = NullmapBits(Body.slice(0, NmSize)); +console.log('帧体 ' + Body.length + ' 字节,nullmap ' + NmSize + ' 字节 = ' + Bits.length + ' 位'); + +const Decoded = Semantic.DecodeFrameBody(Body); +let Seen = 0; +for (const R of Decoded.Records || []) { + if (String(R.MethodId) !== '7640916300855664666') continue; + Seen++; + if (Seen !== WantRecord) continue; + const At0 = R.At + 16; + const C = Body.readInt32BE(At0); + let At = At0 + 4 + 16 * C; + const D = Body.readInt32BE(At); + At += 4; + Reader.AttachBits(Bits, R.BitStart + 1); + console.log('记录#' + WantRecord + ' 项数 ' + D + ' 起始 byte=' + At + ' bit=' + (R.BitStart + 1)); + for (let I = 0; I < D && I < MaxItems; I++) { + if (InsertBits && I === InsertAt) { + Reader.AttachBits(Bits, Reader.BitCursor().Index + InsertBits); + console.log(' --- 第 ' + (I + 1) + ' 项前额外推进 ' + InsertBits + ' 位 ---'); + } + const AtId = At; + const Id = Body.readBigInt64BE(At).toString(); + const Key = CodecKeyByModelId.get(Id) || '?'; + At += 8; + const Bit0 = Reader.BitCursor().Index; + const B0 = At; + if (Id === '0') { + At += 8; + console.log(' [' + String(I + 1).padStart(3) + '] <内联> id@' + AtId + ' byte ' + B0 + '→' + At + ' bit ' + Bit0 + '→' + Reader.BitCursor().Index); + continue; + } + if (Tree && Key && Key.includes(KeyFilter || '::')) { + console.log(' [树] 第 ' + (I + 1) + ' 项 ' + Key); + TreeOn = true; + } + const Res = Reader.ReadFields(Body, At, Tables.CodecByKey.get(Key), 0); + TreeOn = false; + At = Res.Next; + const Bit1 = Reader.BitCursor().Index; + const Opt = Key && Key !== '?' ? CountOptBits(Key, 0) : -1; + if (KeyFilter && !String(Key).includes(KeyFilter)) continue; + console.log(' [' + String(I + 1).padStart(3) + '] ' + Key + ' model=' + Id + ' id@' + AtId + + ' byte ' + B0 + '→' + At + '(' + (At - B0) + ') bit ' + Bit0 + '→' + Bit1 + + '(+' + (Bit1 - Bit0) + ') 字段表 opt 共 ' + Opt + (Res.Bad ? ' BAD ' + Res.Why : '')); + } + break; +} diff --git a/3DTank/tools/server/semantic.js b/3DTank/tools/server/semantic.js index 80f3c9d..1d79bfb 100644 --- a/3DTank/tools/server/semantic.js +++ b/3DTank/tools/server/semantic.js @@ -342,6 +342,8 @@ function Load(NamesDir) { } Off += 16 + R.Consumed; } + // 诊断用:本帧消费到的位索引(nullmap 容量自检见 tools/_nullmap_capacity.js) + Out.Bits = Ctx.Index; return Out; } diff --git a/3DTank/tools/wss_server.js b/3DTank/tools/wss_server.js index 37dd912..9f57bd2 100644 --- a/3DTank/tools/wss_server.js +++ b/3DTank/tools/wss_server.js @@ -64,6 +64,10 @@ const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets', LABEL)) // --no-hex 1 log.txt 不写十六进制 // --quiet 1 只写 log.txt,不刷控制台 const DUMP_EVERY = Number(Arg('dump-every', '1')); +// 额外落一份「解密 + 解压后的帧体」到 /plain/s2c_%04d.bin: +// 审计工具(audit_pt_boundaries / audit_bit_budget)要的是这种裸帧体, +// 原始 .bin 落的是收到的原始字节(加密段里是密文),离线没法直接解。 +const DUMP_PLAIN = Arg('dump-plain', '1') === '1'; const WRITE_LOG = Arg('log', '1') === '1'; const WRITE_HEX = Arg('no-hex', '0') !== '1'; const QUIET = Arg('quiet', '0') === '1'; @@ -500,6 +504,16 @@ class StreamDecoder { } } const At = NullmapSize(Body, 0); + // 明文帧体落盘(审计用,见文件头 --dump-plain):/plain/s2c_%04d.bin + if (DUMP_PLAIN && this.Direction === 's2c' && this.State.Dir) { + this.PlainSeq = (this.PlainSeq || 0) + 1; + const P = path.join(this.State.Dir, 'plain', 's2c_' + String(this.PlainSeq).padStart(4, '0') + '.bin'); + EnqueueSave(Body.length, (Done) => { + fs.mkdir(path.dirname(P), { recursive: true }, () => { + fs.writeFile(P, Body, () => Done()); + }); + }); + } if (At >= Body.length) { this.State.Log(Tag + '保活 ping (' + Buf.length + 'B)' + Note); continue; diff --git a/3dtank.com/play/static/js/unpacked/module_24635.js b/3dtank.com/play/static/js/unpacked/module_24635.js index 8407e29..a5ab2e2 100644 --- a/3dtank.com/play/static/js/unpacked/module_24635.js +++ b/3dtank.com/play/static/js/unpacked/module_24635.js @@ -413,22 +413,23 @@ null == n ? t.f7h_1.b7h(!0) : (t.f7h_1.b7h(!1), this.r7h_1.s7h(t, n)); }), (z(Ht).t7h = function (t) { - // [Hornet 调试] 可选读器打点(全量 opt,按帧分组):记录 - // rid = 位读取器实例 id(一帧内所有记录共享同一个位读取器 → 同一 rid 即同一帧) - // pos = 读位前的绝对位索引;bit = 读到的位 - // slots = 内层读器的槽位名(模型字段 → 槽位名全局唯一,可离线反查是哪个模型) - // 目的:直接看客户端对「某个模型」到底消费了几个 opt 位,从而定位我们少算的那一位。 - // 位只读一次;打点异常不影响解码。 + // [Hornet 调试] 可选读器全量打点:**每一次** opt 读位都记一条(不再按槽位过滤)。 + // pos = 读位前的绝对位索引;bit = 读到的位;off = 读位时的字节游标(g2t) + // keys = 内层读器自有槽位(模型/集合可据此反查);src = 内层 t7h 源码前 72 字符 + // elemKeys / elemSrc = 内层的列表元素读器(opt 用) + // 为什么要 src:opt<基础类型>(除 string 外)和 opt<无槽位模型> 的内层读器没有任何自有属性, + // 旧版按「槽位非空」过滤会把它们全部丢掉,导致打点里只剩 opt。 var Inner = this.r7h_1; var BR = t && t.f7h_1; - if (BR && typeof BR.xy === "function" && BR.y7g_1 !== undefined) { - var B0 = BR.xy(); - try { + var B0 = false; + try { + if (BR && typeof BR.xy === "function" && BR.y7g_1 !== undefined) { + B0 = BR.xy(); if (typeof window !== "undefined") { var T0 = (window.__HornetOptTrace__ = window.__HornetOptTrace__ || { - rows: [], timer: 0, nextRid: 1, + rows: [], timer: 0, push: function (Row) { - if (this.rows.length >= 8000) return; + if (this.rows.length >= 60000) return; this.rows.push(Row); if (!this.timer) { var Me = this; @@ -444,31 +445,30 @@ } }, }); - var Slots = []; + var Keys = []; + var Src = ""; var ElemKeys = []; + var ElemSrc = ""; + var Off = -1; + try { Keys = Inner ? Object.keys(Inner) : []; } catch (E5) {} + try { Src = Inner && Inner.t7h ? String(Inner.t7h).slice(0, 72) : ""; } catch (E6) {} try { - Slots = Inner ? Object.keys(Inner) : []; - ElemKeys = Inner && Inner.z7h_1 ? Object.keys(Inner.z7h_1) : []; - } catch (E5) {} - // 只记「能唯一定位」的:模型槽位 或 列表元素槽位;基础类型 opt 太碎且无指纹,跳过 - if (Slots.length > 0 || ElemKeys.length > 0) { - if (!BR.__hornetRid) { - BR.__hornetRid = T0.nextRid || 1; - T0.nextRid = BR.__hornetRid + 1; + var Elem = Inner && Inner.z7h_1; + if (Elem) { + ElemKeys = Object.keys(Elem); + ElemSrc = Elem.t7h ? String(Elem.t7h).slice(0, 72) : ""; } - T0.push({ - rid: BR.__hornetRid, - pos: BR.y7g_1 - 1, - bit: B0, - slots: Slots, - elemKeys: ElemKeys, - }); - } + } catch (E7) {} + try { + var S = t.e7h_1; + Off = S && typeof S.g2t === "function" ? S.g2t() : -1; + } catch (E8) {} + T0.push({ pos: BR.y7g_1 - 1, bit: !!B0, off: Off, keys: Keys, src: Src, elemKeys: ElemKeys, elemSrc: ElemSrc }); } - } catch (E4) {} - return B0 ? null : Inner.t7h(t); - } - return t.f7h_1.xy() ? null : this.r7h_1.t7h(t); + } + } catch (E4) {} + if (!BR || typeof BR.xy !== "function" || BR.y7g_1 === undefined) return t.f7h_1.xy() ? null : this.r7h_1.t7h(t); + return B0 ? null : Inner.t7h(t); }), (z(Ht).u7h = function (t) { this.r7h_1.u7h(t); diff --git a/3dtank.com/play/static/js/unpacked/module_45686.js b/3dtank.com/play/static/js/unpacked/module_45686.js index 1360213..d0339cb 100644 --- a/3dtank.com/play/static/js/unpacked/module_45686.js +++ b/3dtank.com/play/static/js/unpacked/module_45686.js @@ -485,12 +485,79 @@ }), (x(lt).s7h = function (t, n) {}), (x(lt).t7h = function (t) { + // [Hornet 调试] pt 项打点:记录每一项的 modelId 与「字节/位」区间。 + // b0/b1 = 本项负载起止字节游标(g2t);p0/p1 = 本项消费的绝对位区间(y7g_1) + // own = id 对象(Kotlin Long)的自有属性,离线可还原 modelId + // 目的:直接看客户端对「每一项」读了多少 opt 位,定位我们字段表少算的那几位。 + var H0 = -1; + var HBit0 = -1; + var HNm = -1; + var HBuf = -1; + try { + H0 = t && t.e7h_1 && typeof t.e7h_1.g2t === "function" ? t.e7h_1.g2t() : -1; + HBit0 = t && t.f7h_1 ? t.f7h_1.y7g_1 : -1; + // nm = 当前位流总位数;buf = 位缓冲数组的身份 id(换流就换 id)—— + // 用来区分「同一条位流里的下一个记录」与「嵌套消息新开的一条位流」 + var HBr = t && t.f7h_1; + if (HBr) { + HNm = typeof HBr.w7g_1 === "number" ? HBr.w7g_1 : -1; + if (HBr.x7g_1 && typeof window !== "undefined") { + var Hw = (window.__HornetBitBufs__ = window.__HornetBitBufs__ || { map: new WeakMap(), next: 1 }); + HBuf = Hw.map.get(HBr.x7g_1); + if (HBuf === undefined) { + HBuf = Hw.next++; + Hw.map.set(HBr.x7g_1, HBuf); + } + } + } + } catch (Eh) {} var n = ct(this).t7h(t), i = n instanceof w ? n : p(); - if (d(i, new w(0, 0))) return I(T(ct(this).t7h(t)), i); - var r = at().a1x2().o7o(i), - e = T(r.t7h(t)); - return I(e, i); + var HRet; + if (d(i, new w(0, 0))) HRet = I(T(ct(this).t7h(t)), i); + else { + var r = at().a1x2().o7o(i), + e = T(r.t7h(t)); + HRet = I(e, i); + } + try { + if (typeof window !== "undefined") { + var T0 = (window.__HornetItemTrace__ = window.__HornetItemTrace__ || { + rows: [], timer: 0, + push: function (Row) { + if (this.rows.length >= 40000) return; + this.rows.push(Row); + if (!this.timer) { + var Me = this; + this.timer = setTimeout(function () { + Me.timer = 0; + try { + fetch("/__dump__/item_trace.json", { + method: "POST", + body: JSON.stringify({ at: new Date().toISOString(), count: Me.rows.length, trace: Me.rows }), + }); + } catch (E2) {} + }, 1500); + } + }, + }); + var Own = ""; + try { + var Nk = Object.keys(i); + for (var Ik = 0; Ik < Nk.length; Ik++) Own += Nk[Ik] + "=" + i[Nk[Ik]] + ";"; + } catch (Eh2) {} + T0.push({ + own: Own, + b0: H0, + b1: t && t.e7h_1 && typeof t.e7h_1.g2t === "function" ? t.e7h_1.g2t() : -1, + p0: HBit0, + p1: t && t.f7h_1 ? t.f7h_1.y7g_1 : -1, + nm: HNm, + buf: HBuf, + }); + } + } catch (Eh3) {} + return HRet; }), (x(vt).e1x2 = function () { var t = a; diff --git a/TankiOffline/net/src/jsMain/kotlin/Net/Generated/Models/Module85183.kt b/TankiOffline/net/src/jsMain/kotlin/Net/Generated/Models/Module85183.kt index d651f5d..6c87a29 100644 --- a/TankiOffline/net/src/jsMain/kotlin/Net/Generated/Models/Module85183.kt +++ b/TankiOffline/net/src/jsMain/kotlin/Net/Generated/Models/Module85183.kt @@ -7246,7 +7246,7 @@ data class TankCC( val LogicState: Int, val Field4: Int, val Field5: Int, - val TankState: TankState?, + val TankState: TankStateM87072T5?, val Team: Int, ) @@ -7257,7 +7257,7 @@ object TankCCCodec : ModelCodec { val LogicState = R.ReadInt() val Field4 = R.ReadInt() val Field5 = R.ReadInt() - val TankState = Wire.ReadOpt(R, N) { TankStateCodec.Decode(R, N) } + val TankState = Wire.ReadOpt(R, N) { TankStateM87072T5Codec.Decode(R, N) } val Team = R.ReadInt() return TankCC(Field1, Local, LogicState, Field4, Field5, TankState, Team) } @@ -7268,7 +7268,7 @@ object TankCCCodec : ModelCodec { W.WriteInt(V.LogicState) W.WriteInt(V.Field4) W.WriteInt(V.Field5) - Wire.WriteOpt(N, V.TankState) { TankStateCodec.Encode(W, N, it) } + Wire.WriteOpt(N, V.TankState) { TankStateM87072T5Codec.Encode(W, N, it) } W.WriteInt(V.Team) } } diff --git a/TankiOffline/net/src/jsMain/kotlin/Net/Generated/Models/Module87072.kt b/TankiOffline/net/src/jsMain/kotlin/Net/Generated/Models/Module87072.kt index e740452..bfc05ea 100644 --- a/TankiOffline/net/src/jsMain/kotlin/Net/Generated/Models/Module87072.kt +++ b/TankiOffline/net/src/jsMain/kotlin/Net/Generated/Models/Module87072.kt @@ -17,13 +17,17 @@ import Net.Protocol.ZtCodec import Net.Protocol.AliasCodec // TankState|module_87072 -class TankStateM87072T5 +data class TankStateM87072T5( + val Packed: ByteArray, +) object TankStateM87072T5Codec : ModelCodec { override fun Decode(R: ByteReader, N: NullmapCursor): TankStateM87072T5 { - return TankStateM87072T5() + val Packed = R.ReadRaw(23) + return TankStateM87072T5(Packed) } override fun Encode(W: ByteWriter, N: NullmapWriter, V: TankStateM87072T5) { + W.WriteRaw(V.Packed) } } diff --git a/TankiOffline/tools/GenerateProtocol.py b/TankiOffline/tools/GenerateProtocol.py index 45aa892..f79dcc2 100644 --- a/TankiOffline/tools/GenerateProtocol.py +++ b/TankiOffline/tools/GenerateProtocol.py @@ -129,6 +129,12 @@ class Resolver: if S.startswith("map<") and S.endswith(">"): K, V = SplitTopLevel(S[4:-1]) return ("map", self.Parse(K, TypeCodec), self.Parse(V, TypeCodec)) + # 定长二进制块 bytes:出处 module_87072.js:53876 的 T5.t7h —— + # TankState 在线上是 23 字节打包姿态(内部按 19/13/13/13 位量化 4 个向量), + # 我们只按定长吃掉、原样写回,不解释内容。 + Fixed = re.fullmatch(r"bytes(\d+)", S) + if Fixed: + return ("bytes", int(Fixed.group(1))) if S in PRIMS: return (S,) if TypeCodec and TypeCodec in self.CodecKeyToName: @@ -148,6 +154,8 @@ def KotlinType(Node) -> str: return f"Map<{KotlinType(Node[1])}, {KotlinType(Node[2])}>" if Kind == "enum": return "Int" + if Kind == "bytes": + return "ByteArray" if Kind == "model": return Node[1] return PRIMS[Kind][0] @@ -168,6 +176,8 @@ def DecodeExpr(Node) -> str: # · 实测 B:Item3DCC 之外有 21 个模型在 enum=0 时整体错位(P_ 的 category 必须 4 字节)。 # 全量审计:enum=4 → 6 处未命中 / 127 项;enum=0 → 21 处。先保留 4,等客户端位痕迹定案。 return "R.ReadInt()" + if Kind == "bytes": + return f"R.ReadRaw({Node[1]})" if Kind == "model": return Node[1] + "Codec.Decode(R, N)" return PRIMS[Kind][1] @@ -184,6 +194,8 @@ def EncodeExpr(Node, Value: str) -> str: EncodeExpr(Node[2], "it") + " })") if Kind == "enum": return "W.WriteInt(" + Value + ")" + if Kind == "bytes": + return "W.WriteRaw(" + Value + ")" if Kind == "model": return Node[1] + "Codec.Encode(W, N, " + Value + ")" return PRIMS[Kind][2].replace("{}", Value)