space.js 的 BuildInitFrames 自己逐条调 Container.Write / Semantic.EncodeRecord 出负载,再把模板的 NullmapHex 原样带上。但帧级 nullmap 是所有记录共用的一串位(每条记录 1 个形态位 + 每个 opt 字段 1 个空/非空位,按编码顺序排),只有 semantic.EncodeFrameBody 持位游标(容器与字段两条路径都会推进 Ctx)。于是「把 opt<T> 由空改成有值」时负载多出 T 的字节数而 nullmap 位仍是 1(=空),解码侧按位读到空、把那多出来的字节当成下一条记录头 —— 实测 devicesLoaded 的 mountedDevice 由 null 改成设备 id 时帧 255848B→255856B 且 Bad(29 条记录只解出 7 条)。M7 覆盖 mountedItems 时没暴露,是因为 list<T> 改长度不占 nullmap 位(长度编在负载里)。改法:BuildInitFrames 把记录按模板形态原样交给 Semantic.EncodeFrameBody(容器 → Layout+Value、字段 → Fields、解不动 → Hex、旧模板的现成 Payload 仍透传),由它按编码顺序重建 nullmap 位;没传 semantic 实例时保留旧路径。验证:字节中性 —— names/ 下全部模板(init+session,61 帧)改动前后逐字节相同 61/61;修复有效 —— 把 s2c_0004 槽位 920009631046 的 mountedDevice 由 null 改为设备 id 后,帧 +8B、解码 29 条记录 Bad=false、nullmap 长度不变且恰好 1 个字节变化、其余 27 个槽位 0 处变化;回归 —— semantic.js --demo 全部断言通过(含「space.js 组帧 == 参考字节」那条)、selftest 逐字节一致 1108/不一致 9/解码中断 90、CompareContainerDecode 5 帧/26950/0。
887 lines
43 KiB
JavaScript
887 lines
43 KiB
JavaScript
// 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 EnumNames = require('./enum_names');
|
||
|
||
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[]
|
||
MountedFlags: null, // Items.Mounted=1 的 ItemId 列表(按 ItemId 升序);null = 还没读到库
|
||
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();
|
||
await LoadMountedFlags(); // 回显覆盖是**同步**路径(组帧),连库时先整表读进内存
|
||
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) + ' 条' +
|
||
' / Items.Mounted=1 共 ' + (Cache.MountedFlags ? Cache.MountedFlags.length : 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 };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 已挂载物品(M7:装备落库 + 回显覆盖)
|
||
|
||
// Items.Mounted=1 的内存快照(整表很小,几十行级别)。回显覆盖发生在**同步**的组帧路径里,
|
||
// 所以像 DeviceSlots 一样连库时读进内存,落库后再刷新。
|
||
async function LoadMountedFlags() {
|
||
if (!Pool) return null;
|
||
const Rows = await Db.Many(Pool, 'SELECT ItemId FROM Items WHERE Mounted = 1 ORDER BY ItemId');
|
||
Cache.MountedFlags = Rows.map((R) => String(R.ItemId));
|
||
return Cache.MountedFlags;
|
||
}
|
||
|
||
// 同步取快照(没读到库返回 null —— 调用方据此「不覆盖」,绝不用空集合冒充"库里没有")
|
||
function MountedFlagsSnapshot() {
|
||
return Cache.MountedFlags ? Cache.MountedFlags.slice() : null;
|
||
}
|
||
|
||
// 装备落库:**先卸下同类别里的其它件,再置该件为已挂载**(M8 同类互斥)。
|
||
// ★ 类别来源(M8 查清,逐行证据见 schema.sql 的 Items.Category 注释):物品对象在 pt 里的模型项
|
||
// modelId = 6117306895090150422(module_85196::Lo),唯一字段 `category`(prop ici_1,线型 enum<ja>),
|
||
// 已由 backfill_category.js 回填进 Items.Category。真实客户端挂载 reducer IB.yr9(module_10032.js:29845-29883)
|
||
// 走的就是这个类别:先对「入参涉及类别里已挂载的物品」置 mounted=false,再按入参把新物品置 true。
|
||
// ★ 幂等:两条 UPDATE 都带「只在值确实要变时才动」的条件 —— 重复收到同一件时(该件已 1、同类已 0)
|
||
// 两条都不改任何行、不产生副作用,日志里能看出「同时卸下 0 件同类 / 该件已是 1」。
|
||
// ★ Category 为 NULL(该项没解出类别)⇒ 只置位、不做互斥,并打一行日志说明(不硬猜类别)。
|
||
async function SetMounted(ItemId) {
|
||
Require();
|
||
const Id = String(ItemId);
|
||
const Row = await Db.One(Pool, 'SELECT Category FROM Items WHERE ItemId = ?', [Id]);
|
||
const Category = Row && Row.Category !== null && Row.Category !== undefined ? String(Row.Category) : null;
|
||
let Unmounted = 0;
|
||
if (Category === null) {
|
||
Log(' ⚠ 落库:ItemId=' + Id + ' 库里没有类别(Items.Category 为空)→ 只置位,不做同类互斥');
|
||
} else {
|
||
// 同类别、当前已挂载、且不是本件 → 全部置 0(条件 Mounted=1 保证第二次调用 affectedRows=0)
|
||
const [Res2] = await Pool.query(
|
||
'UPDATE Items SET Mounted = 0 WHERE Category = ? AND Mounted = 1 AND ItemId <> ?',
|
||
[Category, Id],
|
||
);
|
||
Unmounted = Res2.affectedRows;
|
||
}
|
||
const [Res] = await Pool.query('UPDATE Items SET Mounted = 1 WHERE ItemId = ? AND Mounted <> 1', [Id]);
|
||
const Changed = Res.affectedRows > 0;
|
||
Log(
|
||
'→ 落库:Items.Mounted=1(ItemId=' + Id + ',类别=' + EnumNames.CategoryLabel(Category) +
|
||
',同时卸下 ' + Unmounted + ' 件同类)' + (Changed ? '' : '(该件已是 1,幂等跳过)'),
|
||
);
|
||
if (Changed || Unmounted > 0) {
|
||
await LoadMountedFlags(); // 刷新快照,下一次进车库的回显覆盖就能看到新的集合
|
||
Cache.Flags.clear(); // 查库路径(FlaggedItems)的旗标缓存一并失效
|
||
} else {
|
||
const Row2 = await Db.One(Pool, 'SELECT Mounted FROM Items WHERE ItemId = ?', [Id]);
|
||
if (!Row2) Log(' ⚠ 库里没有 ItemId=' + Id + ' 这一行,落库未生效');
|
||
}
|
||
return Changed;
|
||
}
|
||
|
||
// 字段级覆盖:把一组记录里的 mountedItems(methodId 3692495084100053385)换成库里 Items.Mounted=1 的集合,
|
||
// 其余记录与字段一字不动。返回 { Records, Changed, Before, After };没有该记录 / 快照未就绪 → null。
|
||
// ★ 「只在确实有差异时生效」:集合相同 ⇒ Changed=false 且 Records 原样返回,帧字节与抓包保持逐字节一致。
|
||
// ★ 顺序口径:原有项按模板顺序保留(仍在库里的),库里新增的按 ItemId 升序接在后面 ——
|
||
// 列表顺序在这条协议里没有语义(库里也没有对应列),这样能让「只加了一件」的差异最小。
|
||
function OverrideMountedRecord(Records) {
|
||
if (!Array.isArray(Records)) return null;
|
||
const DbIds = MountedFlagsSnapshot();
|
||
if (!DbIds) return null; // 还没读到库 → 不覆盖
|
||
const Idx = Records.findIndex(
|
||
(R) => String(R.MethodId) === METHOD.MountedItems && R.Fields && Array.isArray(R.Fields.mountedItems),
|
||
);
|
||
if (Idx < 0) return null;
|
||
const Before = Records[Idx].Fields.mountedItems.map(String);
|
||
const DbSet = new Set(DbIds);
|
||
const BeforeSet = new Set(Before);
|
||
const SameSet = DbSet.size === BeforeSet.size && Before.every((X) => DbSet.has(X));
|
||
if (SameSet) return { Records, Changed: false, Before, After: Before };
|
||
const Kept = Before.filter((X) => DbSet.has(X));
|
||
const Added = DbIds
|
||
.filter((X) => !BeforeSet.has(X))
|
||
.sort((A, B) => (BigInt(A) < BigInt(B) ? -1 : BigInt(A) > BigInt(B) ? 1 : 0));
|
||
const After = Kept.concat(Added);
|
||
const New = Records.map((R, I) =>
|
||
I === Idx ? Object.assign({}, R, { Fields: Object.assign({}, R.Fields, { mountedItems: After }) }) : R,
|
||
);
|
||
return { Records: New, Changed: true, Before, After };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 挂载位当前设备(M10:设备落库 + devicesLoaded 回显覆盖)
|
||
|
||
// 设备落库:把「某挂载位当前装备」写进 Mounted.DeviceId(M10 查清的口径,依据见 schema.sql 与 responder.js)。
|
||
// ★ 为什么是 Mounted.DeviceId:devicesLoaded 的 mountedDevice 就是 store.js 的 DevicesLoaded() 从
|
||
// Cache.Devices.Mounted 取的,而那份缓存整表来自 `SELECT SlotObjectId, DeviceId FROM Mounted`。
|
||
// SlotDevices 只是「该槽位可用设备清单」,跟"当前装着谁"无关。
|
||
// ★ 幂等/覆盖:同一槽位换设备 = 同一行 UPDATE 覆盖;值没变时 UPDATE 不匹配任何行(不产生副作用)。
|
||
// ★ 两道校验(不猜):槽位必须在 DeviceSlots 里、设备必须在 DeviceSlots→SlotDevices 的清单里;
|
||
// 否则只打日志、不写库 —— 上行负载是不可信输入,写错槽位比不写更难查。
|
||
async function SetDeviceMounted(SlotObjectId, DeviceId) {
|
||
Require();
|
||
const Slot = String(SlotObjectId);
|
||
const Dev = String(DeviceId);
|
||
if (!Cache.Devices || !Cache.Devices.Slots.has(Slot)) {
|
||
Log(' ⚠ 落库失败:槽位 ' + Slot + ' 不在 DeviceSlots 里 → 不写(不猜槽位)');
|
||
return false;
|
||
}
|
||
const Avail = Cache.Devices.BySlot.get(Slot) || [];
|
||
if (Avail.indexOf(Dev) < 0) {
|
||
Log(' ⚠ 落库失败:设备 ' + Dev + ' 不在槽位 ' + Slot + ' 的可用设备清单里(清单 ' + Avail.length + ' 个)→ 不写(不猜设备)');
|
||
return false;
|
||
}
|
||
const Before = Cache.Devices.Mounted.has(Slot) ? Cache.Devices.Mounted.get(Slot) : null;
|
||
if (Before === Dev) {
|
||
Log('→ 落库:槽位 ' + Slot + ' 的装备改为 设备 ' + Dev + '(原 ' + (Before === null ? '无' : Before) + ')(值未变,幂等跳过)');
|
||
return false;
|
||
}
|
||
const [Res] = await Pool.query(
|
||
'UPDATE Mounted SET DeviceId = ? WHERE SlotObjectId = ? AND (DeviceId IS NULL OR DeviceId <> ?)',
|
||
[Dev, Slot, Dev],
|
||
);
|
||
Cache.Devices.Mounted.set(Slot, Dev); // 内存快照同步(DevicesLoaded 是同步路径,必须立刻可见)
|
||
Log('→ 落库:槽位 ' + Slot + ' 的装备改为 设备 ' + Dev + '(原 ' + (Before === null ? '无' : Before) + ')' +
|
||
(Res.affectedRows ? '(UPDATE 1 行)' : '(库里没有这一行:仅内存生效,DB 未变)'));
|
||
return Res.affectedRows > 0;
|
||
}
|
||
|
||
// 字段级覆盖:把帧里所有 devicesLoaded(methodId 7615142300796401815/…1818)记录的 mountedDevice
|
||
// 换成库里 Mounted.DeviceId 的值,其余记录与字段一字不动。
|
||
// 与 M7 的 OverrideMountedRecord 同一套口径:**只在确实有差异时**返回新数组(集合/值相同 ⇒ 原样返回 ⇒
|
||
// 帧字节仍与抓包逐字节一致)。返回 { Records, Changed, Before, After };帧里没有 devicesLoaded → null。
|
||
// 覆盖范围:只覆盖**库里 Mounted 表里有这一行**的槽位(没有这一行 ⇒ 不动,绝不用 null 冒充"库里没装")。
|
||
function OverrideDevicesLoadedRecords(Records) {
|
||
if (!Array.isArray(Records) || !Cache.Devices) return null;
|
||
const Before = [];
|
||
const After = [];
|
||
let Changed = false;
|
||
const New = Records.map((R) => {
|
||
if (String(R.MethodId) !== METHOD.DevicesLoaded || !R.Fields) return R;
|
||
const Slot = String(R.ObjectId);
|
||
if (!Cache.Devices.Mounted.has(Slot)) return R;
|
||
const Db = Cache.Devices.Mounted.get(Slot);
|
||
const Cur = R.Fields.mountedDevice === undefined || R.Fields.mountedDevice === null
|
||
? null
|
||
: String(R.Fields.mountedDevice);
|
||
Before.push(Cur);
|
||
After.push(Db);
|
||
if (Cur === Db) return R;
|
||
Changed = true;
|
||
return Object.assign({}, R, { Fields: Object.assign({}, R.Fields, { mountedDevice: Db }) });
|
||
});
|
||
if (!Before.length) return null;
|
||
return { Records: Changed ? New : Records, Changed, Before, After };
|
||
}
|
||
|
||
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,
|
||
// M7:装备落库 + mountedItems 回显覆盖
|
||
SetMounted, LoadMountedFlags, MountedFlagsSnapshot, OverrideMountedRecord,
|
||
// M10:挂载位设备落库 + devicesLoaded 回显覆盖
|
||
SetDeviceMounted, OverrideDevicesLoadedRecords,
|
||
};
|
||
|
||
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);
|
||
});
|
||
}
|