// store.js - 查库组帧层:从本机 MySQL(tanki 库)查出对象/模型/依赖/挂载数据,组装成可直接下发的 s2c 记录 // // 定位:这是「查库定向应答」的底层,上层是 responder.js(ba.lcr / ls.xbl / G.q1wd 三条上行的定向应答) // 与 index.js(按优先级 查库 → 会话回放 → 记未实现 接线)。本文件**只管查库与组帧**,不发字节。 // // 与回放(session.js)的关系:本层只做加法。库里缺表、缺行、缺字段一律返回 null(不抛错、不猜), // 调用方拿到 null 就原样回落到会话回放。所以「库不可用」在协议行为上等价于「没有本文件」。 // // 口径(与 schema.sql 头注一一对应,不许自行发明): // * id 一律当十进制字符串(db.js 开了 supportBigNumbers + bigNumberStrings;负数合成 id 不许转 Number) // * Containers/ObjectModels/Dependency* 的 JSON 列是 LONGTEXT,读出后自己 JSON.parse(不经 MySQL 的 double) // * Items 的三个旗标 = Items 表的 InDepot / InMarket / Mounted(itemsOnDepot / itemsOnMarket / mountedItems) // * 依赖批次的身份是「哪一条 zt 记录」:主键 (SpaceId, SourceFile, RecordIndex, Ord),BatchId(=zt 首字段 X) // 只是列 + 索引 —— 同一个 X 在抓包里被多条 zt 重发,绝不能拿 X 当唯一键 // * SpaceObjects.GameTypeId:zt 的批次里那个 i64 就是「类型 id」(= 实例对象的 GameTypeId), // 实例本身在 SpaceObjects 里(SpaceObjectsGameTypeIdx 就是为这条展开建的索引) // // 内存缓存口径("不要把整个库读进内存"): // * 小表整表缓存:DeviceSlots + SlotDevices + Mounted(28 + 515 + 28 行)——DevicesLoaded 因此**同步**可答 // * Items 的三种旗标清单按 (SpaceId, Kind) 缓存一次 // * 对象「出现序」按空间缓存一次(ObjectModels 的 MIN(Seq),见下) // * ObjectModels 切片按 (SpaceId, ObjectId) 逐对象缓存;DependencyBatches/Items 按批次身份缓存 // // ---------------------------------------------------------------- 关于「顺序」的一条重要事实 // 抓包里的**列表顺序**(pt.Objects 的对象序、itemsOnDepot/mountedItems/itemsOnMarket 的项序、 // s2c_0001/s2c_0004 里那 28 条 devicesLoaded 的记录序)在下面这些表里**没有对应的列**,是服务端 // 自己的排布。能对上的是: // * ObjectModels.Seq 是 AUTO_INCREMENT,导入顺序 = 抓包扫描顺序 ⇒ 同一空间里对象「首次出现」的 // 先后(MIN(Seq))就是抓包 s2c_0001 里 pt.Objects 的顺序(实测 1371 项逐项相同,见 --selfcheck) // * 因此本层提供 SpaceObjectIds()(库侧能给的规范顺序),而 ObjectsData() 的 ObjectIds 由调用方给 // (与任务口径一致:Objects 从 SpaceObjects **按传入顺序**取);运行时传的是批次的实例 id 列表, // 自检时传抓包那一帧自己的顺序(自检报告里把这一点如实写出来,并对库序做独立对账) 'use strict'; const fs = require('fs'); const path = require('path'); const Db = require('./db'); const Link = require('./link'); const SemanticModule = require('./semantic'); const PROJECT = path.resolve(__dirname, '..', '..'); const NAMES_DIR = path.join(PROJECT, 'names'); const PLAIN_DIR = path.join(PROJECT, 'packets', 'plain'); // 本层要用的方法号(十进制字符串;名字出处 names/uplink_table.json + names/downlink_table.json) const METHOD = { DevicesLoaded: '7615142300796401818', UnloadObjects: '9038756763225879447', ObjectsData: '7640916300855664666', Dependencies: '3216143066888387731', ItemsOnDepot: '3996477900528000389', ItemsOnMarket: '5236393599344825589', MountedItems: '3692495084100053385', }; const LAYOUT = { Lt: 'lt', Pt: 'pt', Zt: 'zt' }; // 三种物品旗标:Kind → table 列 / 字段名 / methodId const FLAG = { Depot: { Column: 'InDepot', Field: 'itemsOnDepot', MethodId: METHOD.ItemsOnDepot }, Market: { Column: 'InMarket', Field: 'itemsOnMarket', MethodId: METHOD.ItemsOnMarket }, Mounted: { Column: 'Mounted', Field: 'mountedItems', MethodId: METHOD.MountedItems }, }; let Pool = null; let Enabled = false; let Opening = null; let Logger = () => {}; let Sem = null; // semantic.js 实例(组帧要用;index.js 会把自己那份塞进来,避免重复读表) const Cache = { Devices: null, // { Slots: Set, BySlot: Map, Mounted: Map } Flags: new Map(), // SpaceId|Kind → string[] Order: new Map(), // SpaceId → Map OrderList: new Map(), // SpaceId → string[](首次出现序) Slices: new Map(), // SpaceId|ObjectId → [{Id, Value}] BatchIds: new Map(), // SpaceId|SourceFile|RecordIndex|BatchId → string[](批次里的 id,按 Ord) BatchInst: new Map(), // 同上 → string[](展开成实例 id) Deps: new Map(), // 同上 → zt 记录 }; function Log(Msg) { Logger('[store] ' + Msg); } function SetLog(Fn) { Logger = typeof Fn === 'function' ? Fn : () => {}; } function SetSemantic(Inst) { Sem = Inst; } function Semantic() { if (!Sem) Sem = SemanticModule.Load(NAMES_DIR); return Sem; } // ---------------------------------------------------------------- 连接 // 连不上不重试太勤(Available 每条上行都会问一次),但也不能一次失败就永久关掉这条路径 —— // 服务器可能跑很久,MySQL 只是晚起来一会儿的话应当自己接上。节流间隔:30s。 const RETRY_GAP_MS = 30000; let RetryAfter = 0; // 懒建连接池(单例)。失败只记一行日志并把 Enabled 置 false —— 整条查库路径静默让位给回放,绝不抛错。 async function Open(Config) { if (Pool) return Pool; if (Opening) return Opening; Opening = (async () => { try { const P = Db.Connect(Db.LoadConfig(Config)); await P.query('SELECT 1'); Pool = P; Enabled = true; await LoadDeviceTables(); Log('已连库(' + Db.LoadConfig(Config).Database + '@' + Db.LoadConfig(Config).Host + '):挂载位 ' + (Cache.Devices ? Cache.Devices.Slots.size : 0) + ' 个 / 可用设备 ' + (Cache.Devices ? [...Cache.Devices.BySlot.values()].reduce((A, B) => A + B.length, 0) : 0) + ' 条'); } catch (Err) { Enabled = false; Pool = null; Opening = null; // 允许以后重试(见 Available 的节流) RetryAfter = Date.now() + RETRY_GAP_MS; Log('库不可用(' + (Err && Err.message ? Err.message : Err) + ')→ 空间层整条走会话回放'); } return Pool; })(); return Opening; } // 是否可用。没开过(或上次失败已过节流窗口)就顺手发起一次 Open(不阻塞、不抛错) function Available() { if (Pool) return Enabled; if (!Opening && Date.now() >= RetryAfter) Open().catch(() => {}); return false; } async function Close() { if (Pool) { const P = Pool; Pool = null; Enabled = false; Opening = null; await P.end(); } } // 小表整表缓存:DeviceSlots / SlotDevices / Mounted async function LoadDeviceTables() { const Slots = await Db.Many(Pool, 'SELECT SlotObjectId FROM DeviceSlots'); const Rows = await Db.Many(Pool, 'SELECT SlotObjectId, Ord, DeviceId FROM SlotDevices ORDER BY SlotObjectId, Ord'); const Mounted = await Db.Many(Pool, 'SELECT SlotObjectId, DeviceId FROM Mounted'); const BySlot = new Map(); for (const R of Rows) { const S = String(R.SlotObjectId); if (!BySlot.has(S)) BySlot.set(S, []); BySlot.get(S).push(String(R.DeviceId)); } const M = new Map(); for (const R of Mounted) M.set(String(R.SlotObjectId), R.DeviceId === null || R.DeviceId === undefined ? null : String(R.DeviceId)); Cache.Devices = { Slots: new Set(Slots.map((R) => String(R.SlotObjectId))), BySlot, Mounted: M }; } function Require() { if (!Pool) throw new Error('store 还没连库'); } // ---------------------------------------------------------------- 空间内的对象「首次出现序」 // 库侧能给的规范顺序:ObjectModels 的 MIN(Seq)(= 抓包扫描顺序 = pt.Objects 的顺序,见文件头) async function LoadObjectOrder(SpaceId) { const Key = String(SpaceId); if (!Cache.Order.has(Key)) { const Rows = await Db.Many( Pool, 'SELECT ObjectId, MIN(Seq) AS First FROM ObjectModels WHERE SpaceId = ? AND ObjectId IS NOT NULL GROUP BY ObjectId ORDER BY First', [Key], ); const Rank = new Map(); const List = []; Rows.forEach((R, I) => { const Id = String(R.ObjectId); Rank.set(Id, I); List.push(Id); }); Cache.Order.set(Key, Rank); Cache.OrderList.set(Key, List); } return Cache.Order.get(Key); } // 该空间在库里的对象序(供自检对账与文档化;运行时 ObjectsData 的顺序由调用方给) async function SpaceObjectIds(SpaceId) { Require(); await LoadObjectOrder(SpaceId); return Cache.OrderList.get(String(SpaceId)).slice(); } // ---------------------------------------------------------------- 记录构造 // devicesLoaded:从 DeviceSlots/SlotDevices/Mounted 三张表来(整表缓存在内存,故同步返回) // 库里没有这个挂载位 → null(调用方回落回放) function DevicesLoaded(SpaceId, SlotObjectId) { if (!Enabled || !Cache.Devices) return null; const Slot = String(SlotObjectId); if (!Cache.Devices.Slots.has(Slot)) return null; const Devices = (Cache.Devices.BySlot.get(Slot) || []).slice(); if (!Devices.length) return null; const Mounted = Cache.Devices.Mounted.has(Slot) ? Cache.Devices.Mounted.get(Slot) : null; return { ObjectId: Slot, MethodId: METHOD.DevicesLoaded, Fields: { devices: Devices, mountedDevice: Mounted }, }; } // _unloadObjects:lt 容器,负载 = id 列表(**id 来自客户端上行** ls.xbl 的靶,不是库里的数据) function UnloadObjects(SpaceId, Ids) { return { ObjectId: String(SpaceId), MethodId: METHOD.UnloadObjects, Layout: LAYOUT.Lt, Value: { Ids: (Ids || []).map(String) }, }; } // 对象的模型切片(ObjectModels 按 Ord 升序),按 (SpaceId, ObjectId) 缓存 async function LoadSlices(SpaceId, ObjectIds) { const Key = String(SpaceId); const Need = []; for (const Id of ObjectIds) { if (!Cache.Slices.has(Key + '|' + Id)) Need.push(String(Id)); } for (let I = 0; I < Need.length; I += 200) { const Chunk = Need.slice(I, I + 200); const Rows = await Db.Many( Pool, 'SELECT ObjectId, Ord, ModelId, ValueJson FROM ObjectModels WHERE SpaceId = ? AND ObjectId IN (' + Chunk.map(() => '?').join(',') + ') ORDER BY ObjectId, Ord', [Key].concat(Chunk), ); const By = new Map(); for (const R of Rows) { const K = String(R.ObjectId); if (!By.has(K)) By.set(K, []); // LtItem 形态:Id = modelId(0 时 Value 是 i64 字符串),Value = 就地展开的字段/字面量 By.get(K).push({ Id: String(R.ModelId), Value: JSON.parse(R.ValueJson) }); } for (const Id of Chunk) Cache.Slices.set(Key + '|' + Id, By.get(Id) || []); } } // _loadObjectsData:pt 容器 = 对象引用表 + 模型数据表 // Objects 从 SpaceObjects **按传入顺序**取;Items 把每个对象的 ObjectModels 切片按对象顺序拼成扁平序列。 // 任何一项缺(对象不在 SpaceObjects / 该对象没有模型行)→ 返回 null(宁可不答,也不发一条错的 pt) async function ObjectsData(SpaceId, ObjectIds) { Require(); const Ids = (ObjectIds || []).map(String); if (!Ids.length) return null; const Found = new Map(); for (let I = 0; I < Ids.length; I += 200) { const Chunk = Ids.slice(I, I + 200); const Rows = await Db.Many( Pool, 'SELECT ObjectId, GameTypeId FROM SpaceObjects WHERE ObjectId IN (' + Chunk.map(() => '?').join(',') + ')', Chunk, ); for (const R of Rows) Found.set(String(R.ObjectId), String(R.GameTypeId)); } const Missing = Ids.filter((X) => !Found.has(X)); if (Missing.length) { Log('ObjectsData 缺对象(前 3:' + Missing.slice(0, 3).join(',') + ',共 ' + Missing.length + '/' + Ids.length + ')→ 不答'); return null; } await LoadSlices(SpaceId, Ids); const Objects = []; const Items = []; for (const Id of Ids) { Objects.push({ ObjectId: Id, GameTypeId: Found.get(Id) }); const Slice = Cache.Slices.get(String(SpaceId) + '|' + Id) || []; if (!Slice.length) { Log('ObjectsData 对象 ' + Id + ' 没有模型切片 → 不答'); return null; } for (const It of Slice) Items.push(It); } return { ObjectId: String(SpaceId), MethodId: METHOD.ObjectsData, Layout: LAYOUT.Pt, Value: { Objects, Items } }; } // _loadDependencies:zt 容器 = 批次表(对象/类型 → 模型 id 列表)+ 资源项表 // BatchId 不给时按 (SpaceId, SourceFile, RecordIndex) 从库反查(一个 zt 记录只有一批,X 是同一个) async function Dependencies(SpaceId, SourceFile, RecordIndex, BatchId) { Require(); const Key = [SpaceId, SourceFile, RecordIndex, BatchId === undefined || BatchId === null ? '' : BatchId].join('|'); if (Cache.Deps.has(Key)) return Cache.Deps.get(Key); const B = await Db.Many( Pool, 'SELECT Ord, BatchId, ObjectId, ModelIds FROM DependencyBatches WHERE SpaceId = ? AND SourceFile = ? AND RecordIndex = ? ORDER BY Ord', [String(SpaceId), String(SourceFile), Number(RecordIndex)], ); if (!B.length) { Log('Dependencies 库里没有这条 zt(' + SpaceId + '/' + SourceFile + ' 第 ' + RecordIndex + ' 条)→ 不答'); return null; } const X = Number(B[0].BatchId); if (BatchId !== undefined && BatchId !== null && Number(BatchId) !== X) { Log('Dependencies 批次号不符:请求 X=' + BatchId + ',库里该记录 X=' + X + ' → 不答'); return null; } const Items0 = await Db.Many( Pool, 'SELECT Ord, `R`, `E`, `U`, `S`, `O`, Skipped FROM DependencyItems WHERE SpaceId = ? AND SourceFile = ? AND RecordIndex = ? ORDER BY Ord', [String(SpaceId), String(SourceFile), Number(RecordIndex)], ); if (!Items0.length) { Log('Dependencies 库里没有这条 zt 的资源项 → 不答'); return null; } const Batches = B.map((R) => ({ ObjectId: String(R.ObjectId), ModelIds: JSON.parse(R.ModelIds).map(String) })); const Items = Items0.map((R) => ({ R: String(R.R), E: Number(R.E), U: Number(R.U), S: String(R.S), O: Number(R.O), Skipped: JSON.parse(R.Skipped).map(String), })); const Rec = { ObjectId: String(SpaceId), MethodId: METHOD.Dependencies, Layout: LAYOUT.Zt, Value: { X, Batches, Items } }; Cache.Deps.set(Key, Rec); return Rec; } // 某个依赖批次里的 id 列表(按 Ord 取;这就是 zt 的 Batches[].ObjectId,语义上是**类型 id**) async function BatchObjectIds(SpaceId, SourceFile, RecordIndex, BatchId) { Require(); const Key = [SpaceId, SourceFile, RecordIndex, BatchId === undefined || BatchId === null ? '' : BatchId].join('|'); if (!Cache.BatchIds.has(Key)) { const B = await Db.Many( Pool, 'SELECT Ord, ObjectId FROM DependencyBatches WHERE SpaceId = ? AND SourceFile = ? AND RecordIndex = ? ORDER BY Ord', [String(SpaceId), String(SourceFile), Number(RecordIndex)], ); Cache.BatchIds.set(Key, B.map((R) => String(R.ObjectId))); } const Ids = Cache.BatchIds.get(Key); return Ids.length ? Ids : null; } // 批次里那些「类型 id」在空间里的**实例 id**(SpaceObjects.GameTypeId 索引展开)。 // ★ 为什么必须有这一步:zt 的批次里那个 i64 是类型 id(如 -2147482522),SpaceObjects 里存的是 // 实例(如 920005132720 → GameTypeId=-2147482522,见 schema.sql 头注 1/4)。客户端 G.q1wd(n) 要的是 // 「这批对象的数据」,所以发给它的 pt.Objects 必须是实例 id。 async function BatchInstances(SpaceId, SourceFile, RecordIndex, BatchId) { Require(); const Key = [SpaceId, SourceFile, RecordIndex, BatchId === undefined || BatchId === null ? '' : BatchId].join('|'); if (Cache.BatchInst.has(Key)) return Cache.BatchInst.get(Key); const Types = await BatchObjectIds(SpaceId, SourceFile, RecordIndex, BatchId); if (!Types) return null; const Ph = Types.map(() => '?').join(','); let Rows = await Db.Many( Pool, 'SELECT ObjectId FROM SpaceObjects WHERE GameTypeId IN (' + Ph + ') AND SourceSpace = ? ORDER BY GameTypeId, ObjectId', Types.concat([String(SpaceId)]), ); if (!Rows.length) { // 兜底:SourceSpace 是「首次出现的空间号」,实例可能是在别处首次出现的 —— 去掉空间限定再查一次 Rows = await Db.Many(Pool, 'SELECT ObjectId FROM SpaceObjects WHERE GameTypeId IN (' + Ph + ') ORDER BY GameTypeId, ObjectId', Types); } const Out = []; const Seen = new Set(); for (const R of Rows) { const Id = String(R.ObjectId); if (Seen.has(Id)) continue; Seen.add(Id); Out.push(Id); } Cache.BatchInst.set(Key, Out.length ? Out : null); return Out.length ? Out : null; } // 物品旗标记录(itemsOnDepot / itemsOnMarket / mountedItems)。 // 顺序口径:按该空间对象「首次出现序」(ObjectModels 的 MIN(Seq))—— 实测 itemsOnMarket 992 项与抓包 // s2c_0001 完全一致;itemsOnDepot(15)/mountedItems(5) 的抓包顺序库里没有对应列(如实写进 --selfcheck 报告) async function FlaggedItems(SpaceId, Kind) { Require(); const Spec = FLAG[Kind]; if (!Spec) throw new Error('未知的物品旗标: ' + Kind); const Key = String(SpaceId) + '|' + Kind; if (!Cache.Flags.has(Key)) { const Rows = await Db.Many(Pool, 'SELECT ItemId FROM Items WHERE `' + Spec.Column + '` = 1'); const Rank = await LoadObjectOrder(SpaceId); const Ids = Rows.map((R) => String(R.ItemId)); Ids.sort((A, B) => { const RA = Rank.has(A) ? Rank.get(A) : Number.MAX_SAFE_INTEGER; const RB = Rank.has(B) ? Rank.get(B) : Number.MAX_SAFE_INTEGER; if (RA !== RB) return RA - RB; return BigInt(A) < BigInt(B) ? -1 : BigInt(A) > BigInt(B) ? 1 : 0; }); Cache.Flags.set(Key, Ids); } const Ids = Cache.Flags.get(Key); if (!Ids.length) return null; const Fields = {}; Fields[Spec.Field] = Ids.slice(); return { ObjectId: String(SpaceId), MethodId: Spec.MethodId, Fields }; } // ---------------------------------------------------------------- 组帧 // 一条记录要吃掉几个 nullmap 位 = 1 个形态位 + 该记录负载里的所有 opt 位。 // 数法:拿一个够大的零位数组当帧级位游标,把记录按**编码顺序**走一遍(semantic.EncodeRecord / // container.Write 都接受 Ctx 并把消费掉的位数写回 Ctx.Index)—— 与读侧同一顺序,见 container.js 头注三。 function CountBits(Records) { for (let Cap = 1 << 12; ; Cap <<= 1) { const Ctx = { Bits: new Array(Cap).fill(0), Index: 0 }; try { for (const R of Records) { Ctx.Index++; // 形态位(长记录 = 0) Semantic().EncodeRecord(R.MethodId, R, Ctx); } return Ctx.Index; } catch (Err) { if (Cap < (1 << 24) && /用完|容量/.test(String(Err.message))) continue; throw Err; } } } // 自造 nullmap:位容量恰好 ≥ Bits,形式与 link.js 的 NullmapSize 一一对应(短形态 8L-3 位 / 长形态 8L 位)。 // 位图全 0 —— 真正的位由 EncodeFrameBody 按编码顺序就地写回(照 space.js 的现编路径) function MakeNullmap(Bits) { if (Bits <= 29) { const L = Math.max(1, Math.ceil((Bits + 3) / 8)); // L ∈ 1..4 const Out = Buffer.alloc(L); Out[0] = ((L - 1) & 0x03) << 5; return Out; } const L = Math.ceil(Bits / 8); if (L <= 0x3f) { const Out = Buffer.alloc(1 + L); Out[0] = 0x80 | L; return Out; } const Out = Buffer.alloc(3 + L); Out[0] = 0xc0 | ((L >> 16) & 0x3f); Out[1] = (L >> 8) & 0xff; Out[2] = L & 0xff; return Out; } // 记录 → 整帧字节。NullmapHex 不给 ⇒ 按 CountBits 自造(运行时用);给了 ⇒ 原样当帧级 nullmap 用 // (自检时给抓包那一帧的 nullmap,才能逐字节比较;这也是 space.js/space-init 模板一贯的口径) function BuildFrame(Records, NullmapHex) { const Nm = NullmapHex ? Buffer.from(String(NullmapHex), 'hex') : MakeNullmap(CountBits(Records)); const Body = Semantic().EncodeFrameBody({ Nullmap: Nm, Records }); const N = Link.NullmapSize(Body, 0); return Link.EncodeFrame(Body.slice(N), Body.slice(0, N)); } // ============================================================ // --selfcheck / --verify:用上面这套查库能力把抓包帧从库里重建出来,逐字节比 // 用法: node tools/server/store.js --selfcheck [--space 100073070] [--plain ] [--names ] // ============================================================ const PRINT = (Msg) => console.log(Msg); function DiffOffsets(A, B, Limit) { const Out = []; for (let I = 0; I < Math.max(A.length, B.length) && Out.length < Limit; I++) if (A[I] !== B[I]) Out.push(I); return Out; } // 差异报告:前 32 个差异偏移 + 差异总字节数 + 「落在哪一条记录里」——即任务要求的「卡在哪条、差多少」 function DiffReport(Built, Body, Dec) { const Out = []; const Total = DiffOffsets(Built, Body, Number.MAX_SAFE_INTEGER).length; const Offs = DiffOffsets(Built, Body, 32); Out.push(' 差异偏移(前 32 个): ' + JSON.stringify(Offs) + ',差异字节合计 ' + Total + '(现编 ' + Built.length + 'B / 原始 ' + Body.length + 'B)'); // 偏移 → 第几条记录(记录头 16B + 负载) const At2Rec = (At) => { for (let I = 0; I < Dec.Records.length; I++) { const R = Dec.Records[I]; const End = R.At + 16 + (R.Consumed || 0); if (At >= R.At && At < End) return I; if (R.Consumed === undefined && At >= R.At) return I; } return -1; }; const Seen = new Set(); for (const At of Offs) { const I = At2Rec(At); if (Seen.has(I)) continue; Seen.add(I); const R = Dec.Records[I] || {}; const Where = I < 0 ? '记录之外(nullmap / 尾部)' : '第 ' + (I + 1) + ' 条记录(methodId=' + R.MethodId + (R.Layout ? ' 容器 ' + R.Layout : ' 字段 ' + (R.Fields || []).length + ' 个') + ',负载 ' + R.At + '..' + (R.At + 16 + (R.Consumed || 0)) + ')'; Out.push(' 首个差异落在 ' + Where + ' @' + At); const S = Math.max(0, At - 8); const E = Math.min(Body.length, At + 8); Out.push(' @' + At + ' 现编 ' + Built.slice(S, E).toString('hex') + ' 原始 ' + Body.slice(S, E).toString('hex')); } return Out; } // 读抓包明文帧(一个 .bin = 恰好一整帧的帧体) function ReadBody(PlainDir, SpaceId, File) { const P = path.join(PlainDir, 'space-' + SpaceId, File); const Parsed = Link.ParseFrames(fs.readFileSync(P)); return Link.DecodeFrame(Parsed[0]); } function SessionAfter(SpaceId, File) { const P = path.join(NAMES_DIR, 'space-session-' + SpaceId + '.json'); if (!fs.existsSync(P)) return null; const D = JSON.parse(fs.readFileSync(P, 'utf8')); const F = (D.Frames || []).find((X) => X.File === File); return F ? F.After || [] : null; } async function SelfCheck() { 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 SpaceId = String(Arg('space', '100073070')); const PlainDir = path.resolve(Arg('plain', PLAIN_DIR)); const Frames = ['s2c_0002.bin', 's2c_0003.bin', 's2c_0004.bin', 's2c_0001.bin']; PRINT('store --selfcheck 空间=' + SpaceId + ' 抓包=' + PlainDir); setLogFn(); const P = await Open(); if (!P) { PRINT('✗ 连不上库,自检无法进行'); return 1; } PRINT('库已连上;下面每帧都是「库里查出来的记录 → 现编组帧」,与原始帧体逐字节比。'); PRINT(' 输入口径:pt.Objects 的顺序、devicesLoaded 的记录序、ls.xbl 的靶 id 在库里**没有对应列**,'); PRINT(' 这三处按抓包帧自己的顺序/上行靶传入(自检报告里逐帧标注),其余字段全部来自库。'); let Fail = 0; const Lines = []; for (const File of Frames) { const Body = ReadBody(PlainDir, SpaceId, File); const Dec = Semantic().DecodeFrameBody(Body); const N = Link.NullmapSize(Body, 0); const NmHex = Body.slice(0, N).toString('hex'); const Notes = []; let Records = null; const T0 = Date.now(); try { Records = await BuildFrameRecords(SpaceId, File, Dec, Notes); } catch (Err) { Notes.push('组记录抛出:' + Err.message); } Lines.push(''); Lines.push('== ' + File + ' 原始帧体 ' + Body.length + 'B nullmap ' + N + 'B 记录 ' + Dec.Records.length + ' 条'); if (!Records) { Lines.push(' ✗ 重建失败:' + Notes.join(';')); Fail++; continue; } let Built = null; let Err = null; try { Built = Link.DecodeFrame(Link.ParseFrames(BuildFrame(Records, NmHex))[0]); } catch (E) { Err = E.message; } const Same = !!Built && Built.equals(Body); if (!Same) Fail++; Lines.push(' 组帧记录 ' + Records.length + ' 条 ' + (Same ? '✓ 逐字节一致' : '✗ 不一致(现编 ' + (Built ? Built.length : '?') + 'B vs 原始 ' + Body.length + 'B)') + ' ' + (Date.now() - T0) + 'ms'); if (Err) Lines.push(' 组帧抛错:' + Err); if (Built && !Same) { for (const L of DiffReport(Built, Body, Dec)) Lines.push(L); } for (const T of Notes) Lines.push(' · ' + T); // nullmap 位用量:现编这条帧据库里数据吃掉多少 bit vs 原始 nullmap 的容量 try { const Bits = CountBits(Records); Lines.push(' 记录形状: ' + Dec.Records.map((R) => (R.Layout || 'fields') + '#' + (R.Layout === 'pt' ? R.Value.Objects.length + '/' + R.Value.Items.length : R.Layout === 'zt' ? 'X=' + R.Value.X + '/' + R.Value.Batches.length + '/' + R.Value.Items.length : R.Layout === 'lt' ? 'Ids=' + R.Value.Ids.length : (R.Fields || []).length)).join(' ') + ' nullmap 位用量 ' + Bits + '/' + (N * 8 - (Body[0] & 0x80 ? (Body[0] & 0x40 ? 24 : 8) : 3))); } catch (E2) { Lines.push(' 记录形状: ' + Dec.Records.length + ' 条;位用量统计失败:' + E2.message); } // 库里能独立对账的部分 const Diag = await DbDiagnostics(SpaceId, File, Dec); for (const D of Diag) Lines.push(' · ' + D); } for (const L of Lines) PRINT(L); PRINT(''); PRINT(Fail ? '✗ ' + Fail + ' 帧未能逐字节重建(详见上方每帧的差异偏移)' : '✓ 四帧全部逐字节重建'); await Close(); return Fail ? 1 : 0; } function setLogFn() { SetLog((M) => PRINT(' ' + M)); } // 按帧把「查库记录」拼出来。Note 里如实记下哪些输入不来自库 async function BuildFrameRecords(SpaceId, File, Dec, Notes) { if (File === 's2c_0002.bin') { const After = SessionAfter(SpaceId, File); if (!After) throw new Error('没有 names/space-session-' + SpaceId + '.json 的 ' + File + ' After 清单,拿不到 ls.xbl 的靶 id'); const Ids = After.filter((A) => String(A.MethodId) === '2528403217084400809').map((A) => String(A.Target)); Notes.push('5 条 _unloadObjects 的 id 来自客户端上行 ls.xbl 的靶(库里的 UnloadObjects 是纯函数,无表可查)'); Notes.push('抓包帧里的 Ids=' + JSON.stringify(Dec.Records.map((R) => R.Value.Ids.join(','))) + ';上行靶=' + JSON.stringify(Ids)); return Ids.map((Id) => UnloadObjects(SpaceId, [Id])); } if (File === 's2c_0003.bin') { Notes.push('28 条 _loadDependencies:SourceFile/RecordIndex 来自帧内位置,BatchId(X) 由库按 (SpaceId,SourceFile,RecordIndex) 反查'); const Out = []; for (let I = 0; I < Dec.Records.length; I++) { const R = await Dependencies(SpaceId, File, I, null); if (!R) throw new Error('库里查不到 s2c_0003 第 ' + I + ' 条 zt'); Out.push(R); } return Out; } if (File === 's2c_0004.bin') { const PtIds = Dec.Records[0].Value.Objects.map((O) => String(O.ObjectId)); Notes.push('pt 的 ObjectIds 顺序按抓包帧自身的 pt.Objects 传入(库里无此列);类型与开销数据全部来自 SpaceObjects/ObjectModels'); Notes.push('28 条 devicesLoaded 的记录序按抓包帧自身的记录序传入(库里无此列);设备清单与 mountedDevice 全部来自 SlotDevices/Mounted'); const Pt = await ObjectsData(SpaceId, PtIds); if (!Pt) throw new Error('ObjectsData 查库失败'); const Out = [Pt]; for (const R of Dec.Records.slice(1)) { const Rec = DevicesLoaded(SpaceId, String(R.ObjectId)); if (!Rec) throw new Error('库里没有挂载位 ' + R.ObjectId); Out.push(Rec); } return Out; } if (File === 's2c_0001.bin') { const PtIds = Dec.Records[0].Value.Objects.map((O) => String(O.ObjectId)); Notes.push('pt 的 ObjectIds 顺序按抓包帧自身的 pt.Objects 传入(库里无此列)'); Notes.push('3 条物品旗标来自 Items 表;最后一条 methodId=1936068342294351641 是 0 字段记录,直接按 methodId 组'); const Pt = await ObjectsData(SpaceId, PtIds); if (!Pt) throw new Error('ObjectsData 查库失败'); const Out = [Pt]; for (const Kind of ['Depot', 'Mounted', 'Market']) { const Rec = await FlaggedItems(SpaceId, Kind); if (!Rec) throw new Error('Items 表里没有 ' + Kind + ' 旗标'); Out.push(Rec); } Out.push({ ObjectId: String(SpaceId), MethodId: '1936068342294351641', Fields: {} }); return Out; } throw new Error('没有为 ' + File + ' 准备重建路径'); } // 库里能**独立**验证的那部分:集合是否一致、库侧规范顺序与抓包顺序差在哪 async function DbDiagnostics(SpaceId, File, Dec) { const Out = []; try { if (File === 's2c_0001.bin') { const PtIds = Dec.Records[0].Value.Objects.map((O) => String(O.ObjectId)); const DbOrder = await SpaceObjectIds(SpaceId); let At = -1; for (let I = 0; I < PtIds.length; I++) if (PtIds[I] !== DbOrder[I]) { At = I; break; } Out.push('库侧对账:该空间 ObjectModels 里共 ' + DbOrder.length + ' 个对象;抓包 pt 的 ' + PtIds.length + ' 项是库序(MIN(Seq))的前缀? ' + (At < 0 ? '是' : '否(首个不同 @' + At + ')')); // 类型对账:SpaceObjects 是**全局**表(主键只有 ObjectId,KeepFirst),同一个对象在别的空间首次出现 // 会把它在那边的 GameTypeId 带进来 —— 这是库的口径,不是本层能修的,如实报出来 const Rows = await Db.Many(Pool, 'SELECT ObjectId, GameTypeId, SourceSpace FROM SpaceObjects WHERE ObjectId IN (' + PtIds.map(() => '?').join(',') + ')', PtIds); const T = new Map(Rows.map((R) => [String(R.ObjectId), R])); const Bad = []; Dec.Records[0].Value.Objects.forEach((O, I) => { const R = T.get(String(O.ObjectId)); if (!R) Bad.push('ptIndex=' + I + ' ' + O.ObjectId + ' 库里没有这行'); else if (String(R.GameTypeId) !== String(O.GameTypeId)) { Bad.push('ptIndex=' + I + ' ObjectId=' + O.ObjectId + ' 帧=' + O.GameTypeId + ' 库=' + R.GameTypeId + '(SourceSpace=' + R.SourceSpace + ')'); } }); Out.push('库侧对账:GameTypeId 与帧不一致的对象 ' + Bad.length + ' / ' + PtIds.length + (Bad.length ? '(' + Bad.slice(0, 3).join(';') + ')——SpaceObjects 全局唯一键 + KeepFirst 的口径所致' : '')); for (const Kind of ['Depot', 'Mounted', 'Market']) { const Frame = (Dec.Records.find((R) => (R.Fields || []).some((F) => F.Name === FLAG[Kind].Field)) || { Fields: [] }) .Fields.find((F) => F.Name === FLAG[Kind].Field); const Db = (await FlaggedItems(SpaceId, Kind)).Fields[FLAG[Kind].Field]; const F = Frame ? Frame.Value.map(String) : []; let A = -1; for (let I = 0; I < Math.max(Db.length, F.length); I++) if (Db[I] !== F[I]) { A = I; break; } Out.push('库侧对账:' + FLAG[Kind].Field + ' 库里 ' + Db.length + ' 项 / 帧内 ' + F.length + ' 项;集合一致 ' + (Db.slice().sort().join() === F.slice().sort().join()) + ';库序与帧序一致 ' + (A < 0) + (A < 0 ? '' : '(首个不同 @' + A + ':库 ' + Db[A] + ' vs 帧 ' + F[A] + ')')); } } if (File === 's2c_0004.bin') { const PtIds = Dec.Records[0].Value.Objects.map((O) => String(O.ObjectId)); const Devs = []; for (const R of Dec.Records.slice(1)) for (const D of R.Fields.find((F) => F.Name === 'devices').Value) Devs.push(String(D)); Out.push('库侧对账:帧内 28 条 devicesLoaded 拼出的设备 ' + Devs.length + ' 个;pt.Objects ' + PtIds.length + ' 个;顺序相同 ' + (JSON.stringify(Devs) === JSON.stringify(PtIds))); const All = []; for (const [, V] of Cache.Devices.BySlot) for (const D of V) All.push(D); Out.push('库侧对账:SlotDevices 共 ' + All.length + ' 条;与 pt.Objects 集合一致 ' + (All.slice().sort().join() === PtIds.slice().sort().join()) + '(顺序不一致:库里没有「槽位先后」这一列)'); let At = -1; for (let I = 0; I < PtIds.length; I++) if (PtIds[I] !== All[I]) { At = I; break; } Out.push('库侧对账:若按 (SlotObjectId, Ord) 取库序,首个不同 @' + At + '(库 ' + (At >= 0 ? All[At] : '-') + ' vs 帧 ' + (At >= 0 ? PtIds[At] : '-') + ')'); } } catch (Err) { Out.push('对账时出错:' + Err.message); } return Out; } module.exports = { METHOD, Open, Available, Close, SetLog, SetSemantic, DevicesLoaded, UnloadObjects, ObjectsData, Dependencies, BatchObjectIds, BatchInstances, FlaggedItems, SpaceObjectIds, BuildFrame, MakeNullmap, CountBits, Cache, }; if (process.argv.indexOf('--selfcheck') >= 0 || process.argv.indexOf('--verify') >= 0) { SelfCheck() .then((Code) => process.exit(Code)) .catch((Err) => { console.error('自检失败:' + (Err && Err.stack ? Err.stack : Err)); process.exit(1); }); }