工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
809 lines
42 KiB
JavaScript
809 lines
42 KiB
JavaScript
// import_db.js - 把抓包里解出来的「对象仓库」全量导入本机 MySQL,并自证可逆
|
||
//
|
||
// 数据来源:packets/plain/<目录>/s2c_*.bin(每个文件 = 恰好一整帧的帧体)。
|
||
// 解码范式:逐字照 tools/rebase_space_init.js:100-146 ——
|
||
// Link.ParseFrames(readFileSync(F)) → Link.DecodeFrame(Parsed[0]) → Sem.DecodeFrameBody(Body)。
|
||
// 容器记录 → { Layout, Value };字段记录 → { Fields };解不动 → Bad(**丢弃并统计**,不猜)。
|
||
//
|
||
// 落库口径(与 tools/server/schema.sql 的头注一一对应):
|
||
// Containers 每条容器记录(pt/zt/lt)的原文:Layout + ValueJson(zt 另填 X)
|
||
// SpaceObjects pt.Value.Objects = (ObjectId, GameTypeId)
|
||
// ObjectModels pt.Value.Items 按对象分组后的切片(分组规则见下),Ord 从 0 保序
|
||
// DependencyBatches / DependencyItems zt.Value.Batches / zt.Value.Items(身份 = 该条 zt 记录,BatchId = zt 首字段 X)
|
||
// Items itemsOnDepot(3996477900528000389) / itemsOnMarket(5236393599344825589)
|
||
// / mountedItems(3692495084100053385) 三个旗标的并集
|
||
// DeviceSlots / SlotDevices / Mounted devicesLoaded(7615142300796401818) 的三块
|
||
// Accounts 抓包账号(Uid 的出处见 ACCOUNT_UID 的注释)
|
||
//
|
||
// ---------------------------------------------------------------------------
|
||
// 分组规则(**按源码,不许猜**):src/module_45686.js:420-434 的 `x(_t).i1wd`
|
||
// ★ 结论先说:**组首项 = Id 恰为 0 的那一项**(container.js 里 Id === '0'),它的 Value 是**单一个 i64**
|
||
// (= 对象 id,即 container.js 的 Value 就是 i64 十进制字符串),等于 Objects[i].ObjectId;
|
||
// 组尾 = 下一个 Id === 0 的项之前。**不是 (objId, gameTypeId) 对** —— 帧里根本没随组首项下发 gameTypeId。
|
||
// i1wd 遍历 pt.Value.Items(源码里的 n.h1wc(),元素是 lt.p7g 解出来的 ModelData,即 I(data, id)):
|
||
// u = item(e).b85() ← 组首项的 data = I 的第 1 个构造参数 = container.js 的 Value
|
||
// s = u instanceof p ? u : w() p = module_37036.$_$.qj,是「i64 包装类」(new p(0,0) 就是 0,
|
||
// 且它有 q3_1/r3_1 两半 + 8 字节/64 位的 hashCode,见 module_37036.js:11119);
|
||
// 不在 p 上就调 w()(= 37036.$_$.vj = Jr,直接抛)→ 组首项的 Value 必须是 i64
|
||
// o = r.x7o(s) ← 用这个 i64 在空间注册表里取对象 ⇒ 它只能是该对象自己的 ObjectId
|
||
// _ = e + 1;while (_ < len && item(_).o1wc_1 != new p(0,0)) _++
|
||
// ← 内层扫描到「下一项的 id(= I 的第 2 个构造参数 = container.js 的 Id)
|
||
// 等于 0」的哨兵为止;e.._-1 这些项按 a.o1wc_1(= modelId)挂到 o 上
|
||
// 硬断言因此写作:「组数 == Objects.length」+「组首项 Value === Objects[i].ObjectId」(实测全部相等)。
|
||
// ⚠ 那个 (objId, gameTypeId) 对**无法断言**:i1wd 只把组首项的 Value 当 i64 用,不读第二个 id。
|
||
// 断言不过的容器**不硬塞**:该容器的 Items 全部以 ObjectId = NULL 落库(Ord 保序),并统计占比。
|
||
//
|
||
// 幂等 & 主键冲突口径:
|
||
// 写入一律 INSERT ... ON DUPLICATE KEY UPDATE,且**统一 KeepFirst** —— 同一主键在抓包里出现多份
|
||
// 不同内容时保留**首次出现**的版本(ObjectModels 的 ObjectId IS NULL 那批不受唯一键保护,先 DELETE 再插)。
|
||
// 撞主键的行数一律用 CountKeyCollisions 在主键上自己算;被丢的行**不在库里**,所以「到底丢没丢数据」
|
||
// 在同一处再按**整行**去重算一次,报告里拆成两列:原样重发(整行逐字节相同)/ 内容不同(真冲突)。
|
||
// 依赖批次的身份 = **哪一条容器记录**,不是 X:主键 (SpaceId, SourceFile, RecordIndex, Ord),与 Containers
|
||
// 同一套自然键;BatchId(zt 首字段 X)只是列 + 索引 DependencyBatchesLookupIdx,按 X 查走索引即可。
|
||
// ObjectModels 的唯一键是 (SpaceId, ObjectId, Ord)(同一对象在不同空间可以内容不同,见 schema.sql 头注 9)。
|
||
// 本次实测(推入 → 入库 / 撞主键,其中「原样重发」= 整行与已入库那份逐字节相同):
|
||
// ObjectModels 28835 → 28827 / 8(原样重发 8 / 内容不同 0)—— 残余 8 行是「同一空间内同一对象
|
||
// 被原样重发」,整行逐字节相同 ⇒ 唯一键不允许重复是**正确行为**,没丢数据;
|
||
// DependencyBatches 528 → 528 / 0 —— 按记录身份落库后不再丢行;
|
||
// DependencyItems 4501 → 4501 / 0 —— 同上。
|
||
// ⚠ schema.sql 头注 8 的「zt 的 X 只在空间内唯一」**不成立**:实测空间 100073070 的 X=1 被 11 条 zt
|
||
// 以互不相同的对象表重发(s2c_0000#0 带 8 个 modelId、s2c_0003#0 带 16 个、s2c_0006#0 带 4 个……)。
|
||
// 所以 X **不能**当唯一键 —— 旧版 (SpaceId, BatchId, Ord) 只能 KeepFirst,白丢 46 + 66 行;
|
||
// 现在批次身份是容器记录,这些 zt 一条不丢地各自成行,原文也一字不少地留在 Containers。
|
||
// 反过来说,ObjectModels 上不存在「同空间内同对象不同切片」(实测 0 个),故验证② 的拼回必然逐项相等。
|
||
//
|
||
// 持久化精度口径(本次已改,不再是边界):Containers.ValueJson / ObjectModels.ValueJson /
|
||
// DependencyBatches.ModelIds / DependencyItems.Skipped 四列一律 **LONGTEXT + CHECK(JSON_VALID(...))**,
|
||
// 写入传 JSON.stringify 的原文、读出自己 JSON.parse —— 全程只走文本,不经过 MySQL 的 binary JSON,
|
||
// 所以 double 往返逐位可逆(旧版用 JSON 列时实测 -10.321807861328125 → -10.321807861328123,差 1 个 ulp,
|
||
// 见 schema.sql 头注 7a);大 i64 也只以 JSON 字符串形态存在,不丢精度。
|
||
//
|
||
// 用法: node import_db.js [--reset] [--space 100073070] [--owner <uid>] [--dry] [--verify] [--plain <dir>]
|
||
// --reset 先清本次要写的表再导入(默认 upsert,重复跑不产生重复行)
|
||
// --space 只导一个空间(调试用);默认导 packets/plain 下全部含 s2c_*.bin 的目录
|
||
// --owner 覆盖抓包账号 uid(默认用下面确定的那个)
|
||
// --dry 只解析与统计(含分组断言与撞主键分析),不连库、不写库
|
||
// --verify 不导入,只跑四项验证(逐字节重建 / 切片可重组 / 行数 / MySQL 侧核对)
|
||
// 不带 --dry / --verify 时:解析 → 写库 → 依次打印上面四项验证的结果。
|
||
'use strict';
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const Crypto = require('crypto');
|
||
const Link = require('./server/link');
|
||
const SemanticModule = require('./server/semantic');
|
||
const Db = require('./server/db');
|
||
|
||
// ---------------------------------------------------------------- 命令行
|
||
|
||
const Argv = process.argv.slice(2);
|
||
function Arg(Name, Def) {
|
||
const I = Argv.indexOf('--' + Name);
|
||
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
|
||
}
|
||
function Has(Name) {
|
||
return Argv.indexOf('--' + Name) >= 0;
|
||
}
|
||
|
||
const ToolsDir = __dirname;
|
||
const PlainDir = path.resolve(ToolsDir, Arg('plain', path.join('..', 'packets', 'plain')));
|
||
const NamesDir = path.resolve(ToolsDir, Arg('names', path.join('..', 'names')));
|
||
const OnlySpace = Arg('space', null);
|
||
const Dry = Has('dry');
|
||
const Reset = Has('reset');
|
||
const VerifyOnly = Has('verify');
|
||
const Sem = SemanticModule.Load(NamesDir);
|
||
|
||
// 抓包账号的 uid:以「hash 登录」那条链上最权威的一处确定 ——
|
||
// packets/plain-relay/session_008/s2c_0006.bin(就是 names/space-init-login-100045260.json 的 s2c_0006.bin)
|
||
// 一帧两条记录:RememberLoginModel.RememberUsersHash(字段 hash,64 字符 token)
|
||
// + EntranceRequestModelCC.ReceiveLong(唯一字段是一个匿名 i64 = 3074457345817168740)
|
||
// 依据三条:① 该 i64 与登录 token 同帧、紧随其后,是服务端告诉客户端「你是哪个对象」;
|
||
// ② tools/server/dispatch.js:49 独立把它记成「用户对象 id(实测 3074457345817168740 = 0x2AAAAAAAAAB685CB64)」;
|
||
// ③ 它在 packets/plain/space-100072911 与 space-3074457345817190050 里大量作为**记录头 objectId** 出现
|
||
// (客户端拿它当目标 id 发空间命令),与「本机玩家的对象 id」这个角色吻合。
|
||
// LoginHash 留 NULL(真登录时再绑);所有 OwnerUid 列都填这个 uid —— 这是「抓包账号」的归属,
|
||
// **不是多账号隔离**:本次抓包只对应这一个账号,任何 OwnerUid 上的差异都只是数据来源的区别。
|
||
const DEFAULT_ACCOUNT_UID = '3074457345817168740';
|
||
const AccountUid = String(Arg('owner', DEFAULT_ACCOUNT_UID));
|
||
|
||
// 要用的字段型下行 methodId(名字以 names/downlink_table.json 为准)
|
||
const METHOD = {
|
||
ItemsOnDepot: '3996477900528000389', // itemsOnDepot: list<i64>
|
||
ItemsOnMarket: '5236393599344825589', // itemsOnMarket: list<i64>
|
||
MountedItems: '3692495084100053385', // mountedItems: list<i64>
|
||
DevicesLoaded: '7615142300796401818', // devices: list<i64> + mountedDevice: opt<i64>
|
||
};
|
||
|
||
// 本次要写的 9 张表(Accounts 不在内:它是 upsert 的账号行,--reset 不清它,免得把已绑的 LoginHash 抹掉)
|
||
const TARGET_TABLES = [
|
||
'Containers', 'SpaceObjects', 'ObjectModels', 'DependencyBatches', 'DependencyItems',
|
||
'Items', 'DeviceSlots', 'SlotDevices', 'Mounted',
|
||
];
|
||
|
||
// ---------------------------------------------------------------- 小工具
|
||
|
||
function FirstDiffOffset(A, B) {
|
||
for (let I = 0; I < Math.max(A.length, B.length); I++) if (A[I] !== B[I]) return I;
|
||
return -1;
|
||
}
|
||
|
||
// 逐项比较用的规范化序列化。列现在是 LONGTEXT,文本里的键序本就原样保留,不再像 JSON 列那样被重排;
|
||
// 这里仍统一按键序排序,只是为了让比较结果**不依赖任何一方的键序**(键序不是 JSON 语义,规范化是安全的)。
|
||
function StableJson(V) {
|
||
if (Array.isArray(V)) return '[' + V.map(StableJson).join(',') + ']';
|
||
if (V && typeof V === 'object') {
|
||
const Keys = Object.keys(V).sort();
|
||
return '{' + Keys.map((K) => JSON.stringify(K) + ':' + StableJson(V[K])).join(',') + '}';
|
||
}
|
||
return JSON.stringify(V === undefined ? null : V);
|
||
}
|
||
|
||
// 四个 JSON 列现在是 LONGTEXT + CHECK(JSON_VALID(...)):写进去的是 JSON.stringify 的原文,
|
||
// mysql2 读回来一定是**文本**(不再像 JSON 列那样自动解析),所以这里一律自己 JSON.parse。
|
||
// 为什么可以无条件 parse:文本本身就是 JSON 的完整编码 —— 字符串值带引号(ObjectModels 里 Id==0 那种
|
||
// "100073070" 在库里是带引号的 `"100073070"`,parse 回来仍是不丢精度的十进制字符串)、
|
||
// 数字值不带引号(parse 回来是 double,逐位还原)。
|
||
function JsonValue(V) {
|
||
return typeof V === 'string' ? JSON.parse(V) : V;
|
||
}
|
||
|
||
// 批量 upsert。db.js 的 InsertMany 是纯 INSERT,不满足「重复跑不产生重复行」,所以在工具内自备一份。
|
||
// KeepFirst=true ⇒ 主键已存在时**不改**(`ON DUPLICATE KEY UPDATE 首列 = VALUES(首列)` 是空操作):
|
||
// 同一主键在抓包里出现多份不同内容时以**首次出现**为准。为什么不用 INSERT IGNORE:它连
|
||
// 「字段值非法」这类错误也一起吞掉并塞默认值,那是静默改数据,不允许。
|
||
// ⚠ 不要用 affectedRows 判断「撞了多少主键」:mysql2 默认带 CLIENT_FOUND_ROWS,
|
||
// ON DUPLICATE KEY UPDATE 的 affectedRows 返回的是**匹配行数**(实测 = 推入行数,撞了也数 1),
|
||
// 所以撞主键的行数一律用 CountKeyCollisions 在主键上自己算(见下)。
|
||
async function UpsertMany(Pool, Table, Columns, Values, KeepFirst, ChunkSize) {
|
||
if (!Values.length) return 0;
|
||
const Size = ChunkSize || 200;
|
||
const Cols = Columns.map((C) => '`' + C + '`').join(', ');
|
||
const Sets = KeepFirst
|
||
? '`' + Columns[0] + '` = VALUES(`' + Columns[0] + '`)'
|
||
: Columns.map((C) => '`' + C + '` = VALUES(`' + C + '`)').join(', ');
|
||
let Done = 0;
|
||
for (let I = 0; I < Values.length; I += Size) {
|
||
const Chunk = Values.slice(I, I + Size);
|
||
const Placeholders = Chunk.map(() => '(' + Columns.map(() => '?').join(', ') + ')').join(', ');
|
||
const Flat = [];
|
||
for (const Row of Chunk) for (const V of Row) Flat.push(V);
|
||
await Pool.query(
|
||
'INSERT INTO `' + Table + '` (' + Cols + ') VALUES ' + Placeholders + ' ON DUPLICATE KEY UPDATE ' + Sets,
|
||
Flat,
|
||
);
|
||
Done += Chunk.length;
|
||
}
|
||
return Done;
|
||
}
|
||
|
||
// 主键去重统计:KeepFirst 下真正入库的行数 = 去重后的主键个数(+ 主键带 NULL 的行,MySQL 的唯一键管不住 NULL)。
|
||
// KeyIndexes = 主键在行数组里的列下标;NullKeyIndex = 允许为 NULL 的那一列(ObjectModels.ObjectId)。
|
||
// ★ 撞主键被丢的行**不在库里**,所以「到底丢没丢数据」只能在这里算:同时按**整行**(全部列)去重
|
||
// (DistinctFull),于是 Collisions 可拆成两半 ——
|
||
// Pushed - DistinctFull = 原样重发(整行与已入库那份逐字节相同,丢的不是数据)
|
||
// DistinctFull - Distinct = 内容不同(真冲突,只留了首次出现那份)
|
||
function CountKeyCollisions(Rows2, KeyIndexes, NullKeyIndex) {
|
||
const Seen = new Set();
|
||
const Full = new Set();
|
||
let Nulls = 0;
|
||
for (const R of Rows2) {
|
||
Full.add(StableJson(R.map((V) => (V === null ? null : String(V)))));
|
||
if (NullKeyIndex !== undefined && R[NullKeyIndex] === null) {
|
||
Nulls++;
|
||
continue;
|
||
}
|
||
Seen.add(KeyIndexes.map((I) => String(R[I])).join('\u0000'));
|
||
}
|
||
return {
|
||
Distinct: Seen.size + Nulls,
|
||
Collisions: Rows2.length - Nulls - Seen.size,
|
||
Nulls,
|
||
DistinctFull: Full.size,
|
||
SameRowDropped: Rows2.length - Full.size,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 扫描目标目录
|
||
|
||
// 目录名 space-<真实空间号> ⇒ SpaceId = 那个号;其它目录(lobby-session_*)用**目录名**当 SpaceId
|
||
// (那些帧里没有可解码的空间号,编一个号只会更难查;目录名恰好满足 (SpaceId, SourceFile) 的唯一性)。
|
||
function TargetDirs() {
|
||
const Out = [];
|
||
for (const E of fs.readdirSync(PlainDir, { withFileTypes: true })) {
|
||
if (!E.isDirectory()) continue;
|
||
const Full = path.join(PlainDir, E.name);
|
||
const Files = fs.readdirSync(Full).filter((F) => /^s2c_.*\.bin$/.test(F)).sort();
|
||
if (!Files.length) continue;
|
||
const SpaceId = E.name.indexOf('space-') === 0 ? E.name.slice('space-'.length) : E.name;
|
||
if (OnlySpace && SpaceId !== String(OnlySpace)) continue;
|
||
Out.push({ Dir: Full, Name: E.name, SpaceId, Files });
|
||
}
|
||
return Out;
|
||
}
|
||
|
||
// 一个 .bin = 恰好一整帧;不是就返回 null(不猜、不切)
|
||
function ReadFrameBody(File) {
|
||
const Parsed = Link.ParseFrames(fs.readFileSync(File));
|
||
if (!Parsed || Parsed.length !== 1) return null;
|
||
return Link.DecodeFrame(Parsed[0]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 分组(见文件头「分组规则」)
|
||
|
||
function GroupPtItems(Items, Objects) {
|
||
const Groups = [];
|
||
let At = 0;
|
||
while (At < Items.length) {
|
||
const Head = Items[At];
|
||
if (String(Head.Id) !== '0') {
|
||
return { Ok: false, Why: '第 ' + At + ' 项本应是组首(Id 应为 0),实际 Id=' + Head.Id, Groups: null };
|
||
}
|
||
let End = At + 1;
|
||
while (End < Items.length && String(Items[End].Id) !== '0') End++;
|
||
Groups.push({ Ref: String(Head.Value), Items: Items.slice(At, End) });
|
||
At = End;
|
||
}
|
||
if (Groups.length !== Objects.length) {
|
||
return { Ok: false, Why: '组数 ' + Groups.length + ' ≠ Objects 个数 ' + Objects.length, Groups: null };
|
||
}
|
||
for (let I = 0; I < Groups.length; I++) {
|
||
if (Groups[I].Ref !== String(Objects[I].ObjectId)) {
|
||
return {
|
||
Ok: false,
|
||
Why: '第 ' + I + ' 组组首 Value=' + Groups[I].Ref + ' ≠ Objects[' + I + '].ObjectId=' + Objects[I].ObjectId,
|
||
Groups: null,
|
||
};
|
||
}
|
||
}
|
||
return { Ok: true, Groups, Why: '' };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 导入
|
||
|
||
const Stat = {
|
||
Dirs: 0, Files: 0, NotSingleFrame: 0, Frames: 0, FramesPartBad: 0,
|
||
Records: 0, ContainerRecords: 0, FieldRecords: 0, BadRecords: 0,
|
||
Pt: 0, Zt: 0, Lt: 0,
|
||
PtOk: 0, PtFail: 0, NullModelItems: 0, PtFailExamples: [],
|
||
ObjSliceVersions: new Map(), // ObjectId → Set(切片指纹):同一个对象在多条 pt 里有几份不同内容
|
||
FlagRecords: 0, DevicesLoadedRecords: 0, ZtBatchIds: new Set(),
|
||
SpaceObjConflicts: 0, JsonBytes: 0,
|
||
PerDir: [],
|
||
};
|
||
|
||
const Rows = {
|
||
Containers: [],
|
||
SpaceObjects: new Map(), // ObjectId → [ObjectId, GameTypeId, OwnerUid, SourceSpace]
|
||
ObjectModels: [],
|
||
DependencyBatches: [],
|
||
DependencyItems: [],
|
||
Items: new Map(), // ItemId → { Depot, Market, Mounted }
|
||
DeviceSlots: new Map(), // SlotObjectId → [SlotObjectId, OwnerUid]
|
||
SlotDevices: new Map(), // SlotObjectId|DeviceId → [SlotObjectId, Ord, DeviceId]
|
||
Mounted: new Map(), // SlotObjectId → [SlotObjectId, OwnerUid, DeviceId|null]
|
||
};
|
||
|
||
function MarkItem(Id, Flag) {
|
||
const Key = String(Id);
|
||
let Row = Rows.Items.get(Key);
|
||
if (!Row) {
|
||
Row = { Depot: 0, Market: 0, Mounted: 0 };
|
||
Rows.Items.set(Key, Row);
|
||
}
|
||
Row[Flag] = 1;
|
||
}
|
||
|
||
function PutContainer(SpaceId, File, Index, R) {
|
||
Stat.ContainerRecords++;
|
||
const IsZt = R.Layout === 'zt';
|
||
if (IsZt) Stat.Zt++;
|
||
else if (R.Layout === 'pt') Stat.Pt++;
|
||
else Stat.Lt++;
|
||
const Json = JSON.stringify(R.Value);
|
||
Stat.JsonBytes += Json.length;
|
||
Rows.Containers.push([
|
||
SpaceId, File, Index, String(R.ObjectId), String(R.MethodId), R.Layout,
|
||
IsZt ? R.Value.X : null, Json,
|
||
]);
|
||
}
|
||
|
||
function PutPt(SpaceId, File, Index, R) {
|
||
const V = R.Value;
|
||
// SpaceObjects:同一 ObjectId 可能出现在多个空间/多条容器里,取首次出现(冲突只统计,不覆盖)
|
||
for (const O of V.Objects) {
|
||
const Key = String(O.ObjectId);
|
||
const Prev = Rows.SpaceObjects.get(Key);
|
||
if (Prev && String(Prev[1]) !== String(O.GameTypeId)) Stat.SpaceObjConflicts++;
|
||
if (!Prev) Rows.SpaceObjects.set(Key, [Key, String(O.GameTypeId), AccountUid, SpaceId]);
|
||
}
|
||
const G = GroupPtItems(V.Items, V.Objects);
|
||
if (G.Ok) {
|
||
Stat.PtOk++;
|
||
for (let I = 0; I < G.Groups.length; I++) {
|
||
const ObjId = String(V.Objects[I].ObjectId);
|
||
const Slice = G.Groups[I].Items;
|
||
// 记录「这个对象的切片指纹」:唯一键是 (SpaceId, ObjectId, Ord),**同一空间内**同一对象若被多条 pt
|
||
// 重发且内容不同,只能留住首次出现的那份;指纹用于在报告里如实报出受影响的到底是哪几个对象。
|
||
const Fp = Crypto.createHash('sha1').update(JSON.stringify(Slice)).digest('hex');
|
||
const FpKey = SpaceId + '/' + ObjId;
|
||
if (!Stat.ObjSliceVersions.has(FpKey)) Stat.ObjSliceVersions.set(FpKey, new Set());
|
||
Stat.ObjSliceVersions.get(FpKey).add(Fp);
|
||
for (let M = 0; M < Slice.length; M++) {
|
||
Rows.ObjectModels.push([SpaceId, ObjId, M, String(Slice[M].Id), JSON.stringify(Slice[M].Value)]);
|
||
}
|
||
}
|
||
} else {
|
||
Stat.PtFail++;
|
||
Stat.NullModelItems += V.Items.length;
|
||
if (Stat.PtFailExamples.length < 3) {
|
||
Stat.PtFailExamples.push({ SpaceId, File, Index, Why: G.Why, Items: V.Items.length, Objects: V.Objects.length });
|
||
}
|
||
// 不硬塞:整条容器的 Items 以 ObjectId = NULL 落库,Ord 保序
|
||
for (let M = 0; M < V.Items.length; M++) {
|
||
Rows.ObjectModels.push([SpaceId, null, M, String(V.Items[M].Id), JSON.stringify(V.Items[M].Value)]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// zt 的 X(客户端回调号)**不是唯一键**(会被复用),一条依赖批次的身份是「哪一条容器记录」:
|
||
// 主键 = (SpaceId, SourceFile, RecordIndex, Ord),与 Containers 同一套自然键;X 只是列 + 索引。
|
||
// RecordIndex = 该 zt 记录在帧内是第几条记录(从 0 开始,与 Containers.RecordIndex 同一口径)。
|
||
function PutZt(SpaceId, File, Index, R) {
|
||
const V = R.Value;
|
||
const BatchId = V.X;
|
||
Stat.ZtBatchIds.add(SpaceId + '/' + BatchId);
|
||
for (let I = 0; I < (V.Batches || []).length; I++) {
|
||
const B = V.Batches[I];
|
||
Rows.DependencyBatches.push([
|
||
SpaceId, File, Index, I, BatchId, String(B.ObjectId), JSON.stringify((B.ModelIds || []).map(String)),
|
||
]);
|
||
}
|
||
for (let I = 0; I < (V.Items || []).length; I++) {
|
||
const X = V.Items[I];
|
||
Rows.DependencyItems.push([
|
||
SpaceId, File, Index, I, BatchId, String(X.R), X.E, X.U, String(X.S), X.O,
|
||
JSON.stringify((X.Skipped || []).map(String)),
|
||
]);
|
||
}
|
||
}
|
||
|
||
function PutFields(R) {
|
||
Stat.FieldRecords++;
|
||
const Get = (Name) => {
|
||
const F = (R.Fields || []).find((X) => X.Name === Name);
|
||
return F ? F.Value : undefined;
|
||
};
|
||
if (R.MethodId === METHOD.ItemsOnDepot) {
|
||
Stat.FlagRecords++;
|
||
const V = Get('itemsOnDepot');
|
||
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Depot');
|
||
} else if (R.MethodId === METHOD.ItemsOnMarket) {
|
||
Stat.FlagRecords++;
|
||
const V = Get('itemsOnMarket');
|
||
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Market');
|
||
} else if (R.MethodId === METHOD.MountedItems) {
|
||
Stat.FlagRecords++;
|
||
const V = Get('mountedItems');
|
||
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Mounted');
|
||
} else if (R.MethodId === METHOD.DevicesLoaded) {
|
||
Stat.DevicesLoadedRecords++;
|
||
const Slot = String(R.ObjectId);
|
||
Rows.DeviceSlots.set(Slot, [Slot, AccountUid]);
|
||
const Devices = Get('devices');
|
||
if (Array.isArray(Devices)) {
|
||
for (let I = 0; I < Devices.length; I++) {
|
||
const Key = Slot + '|' + String(Devices[I]);
|
||
Rows.SlotDevices.set(Key, [Slot, I, String(Devices[I])]);
|
||
}
|
||
}
|
||
const M = Get('mountedDevice');
|
||
Rows.Mounted.set(Slot, [Slot, AccountUid, M === undefined || M === null ? null : String(M)]);
|
||
}
|
||
}
|
||
|
||
function ScanAll(Dirs) {
|
||
for (const D of Dirs) {
|
||
Stat.Dirs++;
|
||
const Cnt = { Files: 0, Containers: 0, Fields: 0, Bad: 0, Pt: 0, Zt: 0, Lt: 0 };
|
||
for (const File of D.Files) {
|
||
Stat.Files++;
|
||
Cnt.Files++;
|
||
const Body = ReadFrameBody(path.join(D.Dir, File));
|
||
if (!Body) {
|
||
Stat.NotSingleFrame++;
|
||
continue;
|
||
}
|
||
Stat.Frames++;
|
||
const Decoded = Sem.DecodeFrameBody(Body);
|
||
if (Decoded.Bad) Stat.FramesPartBad++;
|
||
for (let I = 0; I < Decoded.Records.length; I++) {
|
||
const R = Decoded.Records[I];
|
||
Stat.Records++;
|
||
// Bad / 短命令形态:没有可用的布局或字段,**丢弃并统计**(不写 Containers,避免把猜的东西当原文)
|
||
if (R.Bad || R.ObjectId === undefined) {
|
||
Stat.BadRecords++;
|
||
Cnt.Bad++;
|
||
continue;
|
||
}
|
||
if (R.Kind === 'container') {
|
||
PutContainer(D.SpaceId, File, I, R);
|
||
Cnt.Containers++;
|
||
if (R.Layout === 'pt') {
|
||
Cnt.Pt++;
|
||
PutPt(D.SpaceId, File, I, R);
|
||
} else if (R.Layout === 'zt') {
|
||
Cnt.Zt++;
|
||
PutZt(D.SpaceId, File, I, R);
|
||
} else {
|
||
Cnt.Lt++;
|
||
}
|
||
} else {
|
||
PutFields(R);
|
||
Cnt.Fields++;
|
||
}
|
||
}
|
||
}
|
||
Stat.PerDir.push({ Dir: D.Name, SpaceId: D.SpaceId, ...Cnt });
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 写库
|
||
|
||
// 逐表的落库计划(列序 / 主键列 / 允许 NULL 的主键列)。写库与行数统计共用同一份,避免两处口径漂移。
|
||
// 全表统一 KeepFirst:同一主键在抓包里出现多份不同内容时保留**首次出现**的版本(撞主键的行数在报告里给出)。
|
||
function TablePlans() {
|
||
return [
|
||
{ Table: 'Containers', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'ObjectId', 'MethodId', 'Layout', 'X', 'ValueJson'], Keys: [0, 1, 2], Values: Rows.Containers },
|
||
{ Table: 'SpaceObjects', Columns: ['ObjectId', 'GameTypeId', 'OwnerUid', 'SourceSpace'], Keys: [0], Values: [...Rows.SpaceObjects.values()] },
|
||
{ Table: 'ObjectModels', Columns: ['SpaceId', 'ObjectId', 'Ord', 'ModelId', 'ValueJson'], Keys: [0, 1, 2], NullKey: 1, Values: Rows.ObjectModels },
|
||
{ Table: 'DependencyBatches', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'Ord', 'BatchId', 'ObjectId', 'ModelIds'], Keys: [0, 1, 2, 3], Values: Rows.DependencyBatches },
|
||
{ Table: 'DependencyItems', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'Ord', 'BatchId', 'R', 'E', 'U', 'S', 'O', 'Skipped'], Keys: [0, 1, 2, 3], Values: Rows.DependencyItems },
|
||
{ Table: 'Items', Columns: ['ItemId', 'OwnerUid', 'InDepot', 'InMarket', 'Mounted'], Keys: [0], Values: [...Rows.Items.entries()].map(([K, V]) => [K, AccountUid, V.Depot, V.Market, V.Mounted]) },
|
||
{ Table: 'DeviceSlots', Columns: ['SlotObjectId', 'OwnerUid'], Keys: [0], Values: [...Rows.DeviceSlots.values()] },
|
||
{ Table: 'SlotDevices', Columns: ['SlotObjectId', 'Ord', 'DeviceId'], Keys: [0, 2], Values: [...Rows.SlotDevices.values()] },
|
||
{ Table: 'Mounted', Columns: ['SlotObjectId', 'OwnerUid', 'DeviceId'], Keys: [0], Values: [...Rows.Mounted.values()] },
|
||
];
|
||
}
|
||
|
||
// 每张表的「推入 / 入库(主键去重后)/ 撞主键被留住原值」——全部在主键上自己算(见 UpsertMany 的注释)
|
||
function RowsReport() {
|
||
return TablePlans().map((P) => Object.assign({}, P, CountKeyCollisions(P.Values, P.Keys, P.NullKey), { Pushed: P.Values.length }));
|
||
}
|
||
|
||
async function WriteAll(Pool) {
|
||
if (Reset) {
|
||
const Order = ['ObjectModels', 'DependencyItems', 'DependencyBatches', 'SlotDevices', 'Mounted', 'DeviceSlots', 'Items', 'SpaceObjects', 'Containers'];
|
||
for (const T of Order) await Pool.query('DELETE FROM `' + T + '`');
|
||
console.log('--reset:已清空 ' + Order.join(' / '));
|
||
}
|
||
// ObjectModels 里 ObjectId IS NULL 那批不受唯一键保护(MySQL 允许唯一键上多个 NULL)→ 先删再插,保证幂等
|
||
const [DelNull] = await Pool.query('DELETE FROM ObjectModels WHERE ObjectId IS NULL');
|
||
if (DelNull.affectedRows) console.log('清理上一轮的 ObjectId IS NULL 行:' + DelNull.affectedRows);
|
||
|
||
const Out = RowsReport();
|
||
for (const P of Out) P.Written = await UpsertMany(Pool, P.Table, P.Columns, P.Values, true);
|
||
await Pool.query('INSERT INTO Accounts (Uid, LoginHash) VALUES (?, NULL) ON DUPLICATE KEY UPDATE Uid = VALUES(Uid)', [AccountUid]);
|
||
return Out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 验证
|
||
|
||
// ① 逐字节重建:从 Containers 表读出 ValueJson → container.Write(Layout, Value) → 与帧里那条负载比
|
||
async function VerifyRebuild(Pool, Dirs) {
|
||
let Same = 0;
|
||
let Diff = 0;
|
||
const Worst = [];
|
||
for (const D of Dirs) {
|
||
for (const File of D.Files) {
|
||
const DbRows = await Db.Many(Pool,
|
||
'SELECT Seq, RecordIndex, Layout, ValueJson FROM Containers WHERE SpaceId = ? AND SourceFile = ? ORDER BY RecordIndex',
|
||
[D.SpaceId, File]);
|
||
if (!DbRows.length) continue;
|
||
const Body = ReadFrameBody(path.join(D.Dir, File));
|
||
if (!Body) continue;
|
||
const Decoded = Sem.DecodeFrameBody(Body);
|
||
const ByIndex = new Map(DbRows.map((R) => [Number(R.RecordIndex), R]));
|
||
for (let I = 0; I < Decoded.Records.length; I++) {
|
||
const R = Decoded.Records[I];
|
||
const Row = ByIndex.get(I);
|
||
if (!Row || R.Kind !== 'container' || R.Bad) continue;
|
||
const Orig = Body.slice(R.At + 16, R.At + 16 + R.Consumed);
|
||
let Built = null;
|
||
let Err = null;
|
||
try {
|
||
Built = Sem.Container.Write(R.Layout, JsonValue(Row.ValueJson));
|
||
} catch (E) {
|
||
Err = E.message;
|
||
}
|
||
if (Built && Built.equals(Orig)) Same++;
|
||
else {
|
||
Diff++;
|
||
if (Worst.length < 3) {
|
||
Worst.push({
|
||
Seq: Row.Seq, SpaceId: D.SpaceId, File, Index: I, Layout: R.Layout,
|
||
DiffAt: Built ? FirstDiffOffset(Built, Orig) : -1, Below: Built ? Built.length : -1, Orig: Orig.length, Err,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { Same, Diff, Worst };
|
||
}
|
||
|
||
// ② 对象切片可重组:随机取 3 个 pt 容器,用 ObjectModels 的切片拼回 Items 全序列,再整体重编码比字节。
|
||
// ⚠ 必须按 SpaceId 限定:同一个对象在不同空间可以各有一份不同内容,不限定就会把别的空间的切片拼进来。
|
||
async function LoadSlices(Pool, SpaceId, ObjectIds) {
|
||
const Out = new Map();
|
||
for (let I = 0; I < ObjectIds.length; I += 200) {
|
||
const Chunk = ObjectIds.slice(I, I + 200);
|
||
const Rows2 = await Db.Many(Pool,
|
||
'SELECT ObjectId, Ord, ModelId, ValueJson FROM ObjectModels WHERE SpaceId = ? AND ObjectId IN (' +
|
||
Chunk.map(() => '?').join(',') + ') ORDER BY ObjectId, Ord', [SpaceId].concat(Chunk));
|
||
for (const R of Rows2) {
|
||
const Key = String(R.ObjectId);
|
||
if (!Out.has(Key)) Out.set(Key, []);
|
||
Out.get(Key).push({ Id: String(R.ModelId), Value: JsonValue(R.ValueJson) });
|
||
}
|
||
}
|
||
return Out;
|
||
}
|
||
|
||
// 找出两棵 JSON 树的第一处差异(路径 + 两侧值)。用途:验证② 里「Items 逐项相等 ✗」时
|
||
// 把差异定位到**具体字段**,而不是只丢一句 ✗。
|
||
// 历史:旧版四个 JSON 列用 MySQL 的 JSON 类型,double 往返差 1 个 ulp,这里就是当初用来定位它的;
|
||
// 现在列是 LONGTEXT(见文件头「持久化精度口径」),这条路径预期不会再被触发,保留作为定位工具。
|
||
function FirstJsonDiff(A, B, At) {
|
||
const P = At || '';
|
||
if (Array.isArray(A) && Array.isArray(B)) {
|
||
if (A.length !== B.length) return { Path: P, A: A.length, B: B.length };
|
||
for (let I = 0; I < A.length; I++) {
|
||
const R = FirstJsonDiff(A[I], B[I], P + '/' + I);
|
||
if (R) return R;
|
||
}
|
||
return null;
|
||
}
|
||
if (A && B && typeof A === 'object' && typeof B === 'object') {
|
||
for (const K of [...new Set([...Object.keys(A), ...Object.keys(B)])].sort()) {
|
||
const R = FirstJsonDiff(A[K], B[K], P + '/' + K);
|
||
if (R) return R;
|
||
}
|
||
return null;
|
||
}
|
||
return StableJson(A) === StableJson(B) ? null : { Path: P, A, B };
|
||
}
|
||
|
||
// 随机取样用的 PRNG(固定种子,mulberry32):**随机取 3 条**,但同一次抓包每次跑都抽到同样 3 条,
|
||
// 报告里的三个样本号才可复算 —— 用 Math.random 的话这份报告就没法被第二个人重现。
|
||
function SeededRandom(Seed) {
|
||
let A = Seed >>> 0;
|
||
return function () {
|
||
A = (A + 0x6d2b79f5) >>> 0;
|
||
let T = A;
|
||
T = Math.imul(T ^ (T >>> 15), T | 1);
|
||
T ^= T + Math.imul(T ^ (T >>> 7), T | 61);
|
||
return ((T ^ (T >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
async function VerifyReassemble(Pool, Dirs) {
|
||
const Total = Number((await Db.One(Pool, "SELECT COUNT(*) AS N FROM Containers WHERE Layout = 'pt'")).N);
|
||
const Rand = SeededRandom(20260917);
|
||
const Picked = [];
|
||
const Seen = new Set();
|
||
while (Picked.length < Math.min(3, Total)) {
|
||
const Off = Math.floor(Rand() * Total);
|
||
if (Seen.has(Off)) continue;
|
||
Seen.add(Off);
|
||
const R = await Db.One(Pool,
|
||
"SELECT Seq, SpaceId, SourceFile, RecordIndex FROM Containers WHERE Layout = 'pt' ORDER BY Seq LIMIT 1 OFFSET " + Off);
|
||
if (R) Picked.push(R);
|
||
}
|
||
const Out = [];
|
||
for (const P of Picked) {
|
||
const D = Dirs.find((X) => X.SpaceId === String(P.SpaceId));
|
||
const File = D ? path.join(D.Dir, String(P.SourceFile)) : null;
|
||
const Rec = { Seq: P.Seq, SpaceId: P.SpaceId, File: P.SourceFile, Index: Number(P.RecordIndex) };
|
||
const Body = File && fs.existsSync(File) ? ReadFrameBody(File) : null;
|
||
if (!Body) {
|
||
Out.push(Object.assign(Rec, { Ok: false, Why: '找不到原始帧' }));
|
||
continue;
|
||
}
|
||
const Decoded = Sem.DecodeFrameBody(Body);
|
||
const R = Decoded.Records[Number(P.RecordIndex)];
|
||
if (!R || R.Kind !== 'container' || R.Layout !== 'pt') {
|
||
Out.push(Object.assign(Rec, { Ok: false, Why: '帧里第 ' + P.RecordIndex + ' 条不是 pt 容器记录' }));
|
||
continue;
|
||
}
|
||
const V = R.Value;
|
||
const ObjIds = V.Objects.map((O) => String(O.ObjectId));
|
||
const Slices = await LoadSlices(Pool, P.SpaceId, ObjIds);
|
||
const Rebuilt = [];
|
||
let Missing = 0;
|
||
for (const Id of ObjIds) {
|
||
const Slice = Slices.get(Id);
|
||
if (!Slice) {
|
||
Missing++;
|
||
continue;
|
||
}
|
||
for (const It of Slice) Rebuilt.push(It);
|
||
}
|
||
const ItemsSame = StableJson(Rebuilt) === StableJson(V.Items);
|
||
// 逐对象定位:若某对象的切片与帧里不一致,把对象 id 点出来。唯一键是 (SpaceId, ObjectId, Ord),
|
||
// 所以只有「同一空间内同一对象被多条 pt 重发且内容不同」才会对不上(实测为 0,见报告)。
|
||
const Bad = [];
|
||
const G = GroupPtItems(V.Items, V.Objects);
|
||
if (G.Ok) {
|
||
for (let K = 0; K < G.Groups.length; K++) {
|
||
const Id = ObjIds[K];
|
||
const Stored = Slices.get(Id) || [];
|
||
if (StableJson(Stored) !== StableJson(G.Groups[K].Items)) {
|
||
Bad.push(Object.assign(
|
||
{ ObjectId: Id, Ord: K, InFrame: G.Groups[K].Items.length, InDb: Stored.length },
|
||
FirstJsonDiff(G.Groups[K].Items, Stored) || {},
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let BytesSame = false;
|
||
let Err = null;
|
||
try {
|
||
const Built = Sem.Container.Write('pt', { Objects: V.Objects, Items: Rebuilt });
|
||
BytesSame = Built.equals(Body.slice(R.At + 16, R.At + 16 + R.Consumed));
|
||
} catch (E) {
|
||
Err = E.message;
|
||
}
|
||
Out.push(Object.assign(Rec, {
|
||
Objects: V.Objects.length, Items: V.Items.length, Rebuilt: Rebuilt.length,
|
||
MissingObjects: Missing, ItemsSame, BytesSame, Err, Bad,
|
||
Loaded: [...Slices.values()].reduce((A, B) => A + B.length, 0),
|
||
}));
|
||
}
|
||
return Out;
|
||
}
|
||
|
||
// ③ 行数统计(含派生的那 9 个数)
|
||
async function CountAll(Pool) {
|
||
const Count = {};
|
||
for (const T of TARGET_TABLES.concat(['Accounts'])) {
|
||
Count[T] = Number((await Db.One(Pool, 'SELECT COUNT(*) AS N FROM `' + T + '`')).N);
|
||
}
|
||
const One2 = async (Sql) => Number((await Db.One(Pool, Sql)).N);
|
||
Count.DepotCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE InDepot = 1');
|
||
Count.MarketCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE InMarket = 1');
|
||
Count.MountedCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE Mounted = 1');
|
||
Count.DeviceSlotCount = Count.DeviceSlots;
|
||
Count.DeviceCount = Count.SlotDevices;
|
||
Count.ObjectCount = Count.SpaceObjects;
|
||
Count.ModelItemCount = Count.ObjectModels;
|
||
Count.BatchCount = Count.DependencyBatches;
|
||
Count.ContainerCount = Count.Containers;
|
||
return Count;
|
||
}
|
||
|
||
// ④ MySQL 侧核对(直接查,原始输出原样贴报告)
|
||
async function VerifyMysql(Pool) {
|
||
const A = await Db.One(Pool, 'SELECT COUNT(*) AS N, MIN(ObjectId) AS Lo, MAX(ObjectId) AS Hi FROM SpaceObjects');
|
||
const B = await Db.One(Pool, 'SELECT COUNT(*) AS N FROM Mounted WHERE DeviceId IS NOT NULL');
|
||
const C = await Db.One(Pool, 'SELECT COUNT(*) AS N FROM ObjectModels WHERE ObjectId IS NULL');
|
||
return { SpaceObjects: A, MountedWithDevice: B, NullObjectModels: C };
|
||
}
|
||
|
||
function Report() {
|
||
console.log('');
|
||
console.log('=== 解析统计 ===');
|
||
console.log('目录 ' + Stat.Dirs + ' / 文件 ' + Stat.Files + '(非整帧 ' + Stat.NotSingleFrame + ')→ 帧 ' + Stat.Frames +
|
||
'(含解不动记录的帧 ' + Stat.FramesPartBad + ')');
|
||
console.log('记录 ' + Stat.Records + ' 条:容器 ' + Stat.ContainerRecords +
|
||
'(pt ' + Stat.Pt + ' / zt ' + Stat.Zt + ' / lt ' + Stat.Lt + ')/ 字段 ' + Stat.FieldRecords +
|
||
' / 丢弃(Bad·短命令) ' + Stat.BadRecords);
|
||
console.log('ValueJson 合计 ' + (Stat.JsonBytes / 1048576).toFixed(2) + ' MB');
|
||
console.log('pt 分组:通过 ' + Stat.PtOk + ' / 失败 ' + Stat.PtFail +
|
||
(Stat.PtFail ? '(占 ' + (100 * Stat.PtFail / Math.max(1, Stat.PtOk + Stat.PtFail)).toFixed(2) + '%,NULL 落库项 ' + Stat.NullModelItems + ')' : ''));
|
||
for (const E of Stat.PtFailExamples) {
|
||
console.log(' 例:' + E.SpaceId + '/' + E.File + ' 第 ' + E.Index + ' 条 Items=' + E.Items + ' Objects=' + E.Objects + ' → ' + E.Why);
|
||
}
|
||
const MultiVersion = [...Stat.ObjSliceVersions.entries()].filter(([, S]) => S.size > 1);
|
||
console.log('同空间内切片有多份不同内容的「空间/对象」:' + MultiVersion.length + ' 个' +
|
||
(MultiVersion.length ? '(' + MultiVersion.slice(0, 3).map(([K, S]) => K + '×' + S.size).join(',') + ')' : '') +
|
||
' —— 只有这些才会在 ObjectModels 上撞 (SpaceId, ObjectId, Ord) 而只留首次出现的那份');
|
||
console.log('字段记录用到:三旗标 ' + Stat.FlagRecords + ' 条 / devicesLoaded ' + Stat.DevicesLoadedRecords + ' 条');
|
||
console.log('');
|
||
console.log('=== 目录明细 ===');
|
||
for (const D of Stat.PerDir) {
|
||
console.log(' ' + D.Dir.padEnd(26) + ' SpaceId=' + String(D.SpaceId).padEnd(22) +
|
||
' 文件 ' + String(D.Files).padStart(5) + ' 容器 ' + String(D.Containers).padStart(5) +
|
||
'(pt ' + D.Pt + ' / zt ' + D.Zt + ' / lt ' + D.Lt + ')字段 ' + String(D.Fields).padStart(4) + ' 丢弃 ' + D.Bad);
|
||
}
|
||
console.log('');
|
||
console.log('=== 待写入行数(KeepFirst:撞主键保留首次出现;被丢的行按整行是否原样重发再分一次)===');
|
||
console.log(' ' + '表'.padEnd(20) + '推入'.padEnd(8) + '入库'.padEnd(8) + '撞主键'.padEnd(8) + '原样重发'.padEnd(10) + '内容不同');
|
||
for (const P of RowsReport()) {
|
||
console.log(' ' + P.Table.padEnd(20) + String(P.Pushed).padEnd(8) + String(P.Distinct).padEnd(8) +
|
||
String(P.Collisions).padEnd(8) + String(P.SameRowDropped).padEnd(10) + (P.DistinctFull - P.Distinct));
|
||
}
|
||
console.log(' zt 的 (SpaceId, X) 去重后 ' + Stat.ZtBatchIds.size + ' 个');
|
||
console.log(' 账号 Uid=' + AccountUid);
|
||
if (Stat.SpaceObjConflicts) console.log(' ⚠ SpaceObjects 同 ObjectId 撞不同 GameTypeId:' + Stat.SpaceObjConflicts + ' 次(保留首次出现)');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 主流程
|
||
|
||
async function Main() {
|
||
const Dirs = TargetDirs();
|
||
console.log('import_db.js 明文目录=' + PlainDir + ' 名字目录=' + NamesDir);
|
||
console.log('目标目录 ' + Dirs.length + ' 个' + (OnlySpace ? '(--space ' + OnlySpace + ')' : '') + (Dry ? '(--dry:不连库、不写库)' : ''));
|
||
|
||
if (!VerifyOnly) {
|
||
ScanAll(Dirs);
|
||
Report();
|
||
if (Dry) return;
|
||
}
|
||
|
||
const Pool = Db.Connect(Db.LoadConfig());
|
||
try {
|
||
const Tables = await Db.EnsureSchema(Pool);
|
||
console.log('');
|
||
console.log('库 ' + Db.LoadConfig().Database + ' 已有表 ' + Tables.length + ' 张:' + Tables.join(', '));
|
||
|
||
if (!VerifyOnly) {
|
||
const Written = await WriteAll(Pool);
|
||
console.log('');
|
||
console.log('=== 已写入(KeepFirst upsert;被丢的行按整行是否原样重发再分一次)===');
|
||
console.log(' ' + '表'.padEnd(20) + '推入'.padEnd(8) + '入库'.padEnd(8) + '撞主键'.padEnd(8) + '原样重发'.padEnd(10) + '内容不同');
|
||
for (const P of Written) {
|
||
console.log(' ' + P.Table.padEnd(20) + String(P.Pushed).padEnd(8) + String(P.Distinct).padEnd(8) +
|
||
String(P.Collisions).padEnd(8) + String(P.SameRowDropped).padEnd(10) + (P.DistinctFull - P.Distinct));
|
||
}
|
||
console.log(' ' + 'Accounts'.padEnd(20) + String(1).padEnd(8) + String(1).padEnd(8) + String(0).padEnd(8) + String(0).padEnd(10) + 0 + ' (Uid=' + AccountUid + ',LoginHash=NULL)');
|
||
}
|
||
|
||
console.log('');
|
||
console.log('=== 验证① 逐字节重建(Containers.ValueJson → container.Write 现编 vs 帧里原始负载)===');
|
||
const V1 = await VerifyRebuild(Pool, Dirs);
|
||
console.log(' 一致 ' + V1.Same + ' / 不一致 ' + V1.Diff);
|
||
for (const W of V1.Worst) {
|
||
console.log(' 不一致:Seq=' + W.Seq + ' ' + W.SpaceId + '/' + W.File + ' 第 ' + W.Index + ' 条 Layout=' + W.Layout +
|
||
' 差异偏移=' + W.DiffAt + ' 现编 ' + W.Below + 'B vs 原始 ' + W.Orig + 'B' + (W.Err ? ' 抛错:' + W.Err : ''));
|
||
}
|
||
|
||
console.log('');
|
||
console.log('=== 验证② 对象切片可重组(随机 3 个 pt 容器,种子固定 20260917 → 可复算)===');
|
||
const V2 = await VerifyReassemble(Pool, Dirs);
|
||
for (const R of V2) {
|
||
console.log(' Seq=' + R.Seq + ' ' + R.SpaceId + '/' + R.File + ' 第 ' + R.Index + ' 条 对象 ' + R.Objects +
|
||
' Items ' + R.Items + ' → 拼回 ' + R.Rebuilt + '(从库读 ' + R.Loaded + ',缺对象 ' + R.MissingObjects + ')' +
|
||
' Items 逐项相等 ' + (R.ItemsSame ? '✓' : '✗') + ' 整段 Write 逐字节一致 ' + (R.BytesSame ? '✓' : '✗') +
|
||
(R.Why ? ' ' + R.Why : '') + (R.Err ? ' 抛错:' + R.Err : ''));
|
||
for (const B of (R.Bad || []).slice(0, 3)) {
|
||
console.log(' 不一致对象 ObjectId=' + B.ObjectId + '(第 ' + B.Ord + ' 组):帧里 ' + B.InFrame + ' 项 vs 库里 ' + B.InDb + ' 项' +
|
||
(B.Path !== undefined ? ';首处差异 ' + (B.Path || '/') + ':帧 ' + JSON.stringify(B.A) + ' vs 库 ' + JSON.stringify(B.B) : ''));
|
||
}
|
||
}
|
||
|
||
console.log('');
|
||
console.log('=== 验证③ 行数统计 ===');
|
||
const C = await CountAll(Pool);
|
||
for (const T of TARGET_TABLES.concat(['Accounts'])) console.log(' ' + T.padEnd(20) + C[T]);
|
||
console.log(' ---- 派生 ----');
|
||
for (const K of ['DepotCount', 'MarketCount', 'MountedCount', 'DeviceSlotCount', 'DeviceCount', 'ObjectCount', 'ModelItemCount', 'BatchCount', 'ContainerCount']) {
|
||
console.log(' ' + K.padEnd(20) + C[K]);
|
||
}
|
||
|
||
console.log('');
|
||
console.log('=== 验证④ MySQL 侧核对(原始输出)===');
|
||
const M = await VerifyMysql(Pool);
|
||
console.log(' SELECT COUNT(*), MIN(ObjectId), MAX(ObjectId) FROM SpaceObjects; → ' +
|
||
M.SpaceObjects.N + ' | ' + M.SpaceObjects.Lo + ' | ' + M.SpaceObjects.Hi);
|
||
console.log(' SELECT COUNT(*) FROM Mounted WHERE DeviceId IS NOT NULL; → ' + M.MountedWithDevice.N);
|
||
console.log(' SELECT COUNT(*) FROM ObjectModels WHERE ObjectId IS NULL; → ' + M.NullObjectModels.N + '(分组失败的兜底行,应为 0)');
|
||
} finally {
|
||
await Pool.end();
|
||
}
|
||
}
|
||
|
||
Main().catch((Err) => {
|
||
console.error('失败:' + (Err && Err.stack ? Err.stack : Err));
|
||
process.exit(1);
|
||
});
|