Files
TankIC/3DTank/tools/server/store.js
T
TermiNexus 2299b0996b fix(server): 组帧漏写帧级 nullmap 位,opt 字段改值会产出错位帧(M10 回显的真因)
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。
2026-09-18 13:38:28 +08:00

887 lines
43 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 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);
});
}