Files
TankIC/3DTank/tools/server/store.js
T
TermiNexus b6f2cb71ee feat(3dtank): 协议逆向工程与自研服务器入库(工具链 + 协议表 + 空间回放 + MySQL)
工具链(3DTank/tools/):extract(模块提取)、mine_evidence/apply_names(命名证据与别名回填)、extract_codec_fields(字段表 + 全库槽位类型 + PrimClassWire/ResourceClasses)、mine_downlink(下行字段表)、extract_space_init/rebase_space_init/audit_space_init(空间模板与记录切分审计)、extract_space_session(把真实会话切成按上行触发的回放模板)、pair_flow(请求→应答配对)、probe_containers、ingest/decrypt_frames(抓包入库与解密)、serve/fetch_assets(本地站点镜像)、import_db(抓包对象仓库导入 MySQL)。

服务器(3DTank/tools/server/):link/entry/codec/container/dispatch/world/space/semantic(协议层与 lt/pt/zt 三个容器布局)、session(会话回放,顺序推进)、db + schema.sql(MySQL 连接层与对象仓库表结构)、store/responder(查库组帧与定向应答;接管范围默认只开 ba.lcr)。

同时修 pair_flow 的时间轴偏移:timeline_c2s 与 timeline_s2c 的 0 点相差 144~4453ms,改前配对整体偏约 2 秒(实测 space-100073070 偏 2084ms),改后 22.4% 的触发者被纠正。

src/、src_named/、packets/、3DTank/local/(含数据库口令)不进库,可用上述工具重新生成。
2026-09-17 20:00:41 +08:00

729 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<slot>, BySlot: Map<slot, string[]>, Mounted: Map<slot, string|null> }
Flags: new Map(), // SpaceId|Kind → string[]
Order: new Map(), // SpaceId → Map<ObjectId, 首次出现序号>
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 <dir>] [--names <dir>]
// ============================================================
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);
});
}