From 2299b0996b8ff18fc81d5dc3284b30055112b7d0 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 18 Sep 2026 13:38:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E7=BB=84=E5=B8=A7=E6=BC=8F?= =?UTF-8?q?=E5=86=99=E5=B8=A7=E7=BA=A7=20nullmap=20=E4=BD=8D=EF=BC=8Copt?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=E6=94=B9=E5=80=BC=E4=BC=9A=E4=BA=A7?= =?UTF-8?q?=E5=87=BA=E9=94=99=E4=BD=8D=E5=B8=A7=EF=BC=88M10=20=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E7=9A=84=E7=9C=9F=E5=9B=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit space.js 的 BuildInitFrames 自己逐条调 Container.Write / Semantic.EncodeRecord 出负载,再把模板的 NullmapHex 原样带上。但帧级 nullmap 是所有记录共用的一串位(每条记录 1 个形态位 + 每个 opt 字段 1 个空/非空位,按编码顺序排),只有 semantic.EncodeFrameBody 持位游标(容器与字段两条路径都会推进 Ctx)。于是「把 opt 由空改成有值」时负载多出 T 的字节数而 nullmap 位仍是 1(=空),解码侧按位读到空、把那多出来的字节当成下一条记录头 —— 实测 devicesLoaded 的 mountedDevice 由 null 改成设备 id 时帧 255848B→255856B 且 Bad(29 条记录只解出 7 条)。M7 覆盖 mountedItems 时没暴露,是因为 list 改长度不占 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。 --- 3DTank/tools/server/index.js | 33 +++++++++++----- 3DTank/tools/server/responder.js | 49 +++++++++++++++++++++-- 3DTank/tools/server/space.js | 47 +++++++++++++++++----- 3DTank/tools/server/store.js | 67 ++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 23 deletions(-) diff --git a/3DTank/tools/server/index.js b/3DTank/tools/server/index.js index 90ab80a..96afb1c 100644 --- a/3DTank/tools/server/index.js +++ b/3DTank/tools/server/index.js @@ -56,11 +56,14 @@ const LOG_DIR = path.resolve(Arg('log', path.join(PROJECT, 'server-logs', LABEL) // 默认关 —— 查库应答照发,回放器一字不改(现状已知能跑通,不拿它冒险)。 const STORE_PREEMPT = Argv.indexOf('--store-preempt') >= 0; // 查库接管范围:默认**只接管 ba.lcr**(理由见 responder.js 头注:模板/回放里已经播发过的东西不能重复发, -// 否则客户端 nap() 会抛 "Model already subscribed!")。--store-all 才把 ls.xbl / G.q1wd 一起打开,仅供实验。 +// 否则客户端 nap() 会抛 "Model already subscribed!")。 +// M10 追加 DeviceInsert(dh.hcu,把设备装到挂载位):它是一条**写**上行,必须由查库路径接管(回放里没有它), +// 且它的应答 devicesLoaded 与 ba.lcr 同源(客户端走 srg 覆盖写,重复消费幂等),所以默认开。 +// --store-all 才把 ls.xbl / G.q1wd 一起打开,仅供实验。 const STORE_ALL = Argv.indexOf('--store-all') >= 0; const STORE_SCOPE = STORE_ALL - ? { BaLcr: true, LsXbl: true, GQ1wd: true } - : { BaLcr: true, LsXbl: false, GQ1wd: false }; + ? { BaLcr: true, DeviceInsert: true, LsXbl: true, GQ1wd: true } + : { BaLcr: true, DeviceInsert: true, LsXbl: false, GQ1wd: false }; // ws 模块:优先本项目解析,回落 sockets/(唯一装过 ws 的地方) function RequireWs() { @@ -176,14 +179,24 @@ function HandleEnterSpace(State, Client, Conn, Body, At) { if (Session) { // M7 回显:进空间时顺手刷一次「已挂载物品」快照,保证下发的是库里此刻的真实状态 Store.LoadMountedFlags().catch(() => {}); - // 字段级覆盖钩子:只改 mountedItems 这一条记录的内容(库里 Items.Mounted=1 的集合), - // 其余记录/字段一字不动;集合无差异时返回原数组 ⇒ 帧字节仍与抓包逐字节一致。 + // 字段级覆盖钩子:只改 mountedItems(库 Items.Mounted=1 集合)与 devicesLoaded 的 mountedDevice + // (库 Mounted.DeviceId)这两处记录的内容,其余记录/字段一字不动;无差异时返回原数组 + // ⇒ 帧字节仍与抓包逐字节一致。M10 的 devicesLoaded 覆盖与 M7 同一套机制。 const RecordOverride = (F, Records) => { - const R = Store.OverrideMountedRecord(Records); - if (!R || !R.Changed) return Records; - State.Log(' → 回显覆盖:mountedItems 由模板 ' + R.Before.length + ' 项改为库中 ' + R.After.length + - ' 项(' + F.File + ';库值 [' + R.After.join(', ') + '])'); - return R.Records; + let Out = Records; + const R = Store.OverrideMountedRecord(Out); + if (R && R.Changed) { + State.Log(' → 回显覆盖:mountedItems 由模板 ' + R.Before.length + ' 项改为库中 ' + R.After.length + + ' 项(' + F.File + ';库值 [' + R.After.join(', ') + '])'); + Out = R.Records; + } + const D = Store.OverrideDevicesLoadedRecords(Out); + if (D && D.Changed) { + State.Log(' → 回显覆盖:devicesLoaded 的 mountedDevice 由模板 [' + D.Before.join(', ') + + '] 改为库中 [' + D.After.join(', ') + '](' + F.File + ')'); + Out = D.Records; + } + return Out; }; const Replay = SessionReplay.MakeReplay(Session.Frames, { Container, Semantic, Log: (M) => State.Log(M), RecordOverride, diff --git a/3DTank/tools/server/responder.js b/3DTank/tools/server/responder.js index 2db00e5..bd85a02 100644 --- a/3DTank/tools/server/responder.js +++ b/3DTank/tools/server/responder.js @@ -7,9 +7,15 @@ // 客户端 nap() 里 `s2c_1.has(t)` 命中 → module_10849.js:440 抛 "Model already subscribed!"。 // (`nap` 是**模型订阅**,同一模型订阅两次直接抛;这跟 devicesLoaded 那种「覆盖写设备行」不一样。) // -// 接管范围由 Scope 决定,默认**只接管 ba.lcr**: +// 接管范围由 Scope 决定,默认**只接管 ba.lcr**(+ M10 的 DeviceInsert): // BaLcr(默认开) ba.lcr 7806260029063678398 靶 = 槽位对象 id → 一条 devicesLoaded // 安全原因:客户端消费它走 srg(按设备 id 覆盖写 + 置 mounted 位),重复下发幂等,不会抛异常。 +// DeviceInsert(默认开) dh.hcu 3804107380183105968 靶 = 槽位对象 id,负载 i64 = 设备对象 id +// → 先落库(Mounted.DeviceId 覆盖写,见 store.js 的 SetDeviceMounted),再回一条该槽位的 devicesLoaded。 +// 这是 M10 查清的「把某个设备装到某个挂载位」的上行;逐行源码链见 store.js 的 SetDeviceMounted 头注 +// 与客户端 Uplink.kt 的 DH_HCU 头注。**真实抓包里没有这条上行的样本**(会话里没做过这个动作), +// 所以这里没有「逐字节对照」这一层保证,只有源码链 + uplink_table.json 的字段表。 +// 默认开的原因与 ba.lcr 同源:devicesLoaded 走 srg 覆盖写,客户端重复消费幂等。 // LsXbl(默认关) ls.xbl 2528403217084400809 靶 = 要卸载的对象 id → 一条 _unloadObjects([靶]) // 关的原因:模板空间在进空间时已经把「对 ls.xbl 的应答」一起播发了(模板是照抄真实会话的帧、 // 不区分触发者),再答一遍属于重复卸载,没验证过客户端会不会有副作用。 @@ -36,11 +42,13 @@ const Store = require('./store'); const METHOD = { BaLcr: '7806260029063678398', // ba.lcr + DeviceInsert: '3804107380183105968', // dh.hcu(把设备装到挂载位;负载 i64 = 设备 id) LsXbl: '2528403217084400809', // ls.xbl GQ1wd: '1816792453857564692', // G.q1wd(客户端请求对象数据,负载 i32) }; const TAG = {}; TAG[METHOD.BaLcr] = 'ba.lcr'; +TAG[METHOD.DeviceInsert] = 'dh.hcu'; TAG[METHOD.LsXbl] = 'ls.xbl'; TAG[METHOD.GQ1wd] = 'G.q1wd'; @@ -52,8 +60,8 @@ function MakeResponder(Deps) { // 默认关(false):查库应答照发,回放器行为与今天一模一样 —— 现状已知能跑通,不拿它冒险; // index.js 的 --store-preempt 打开严格模式。 const Preempt = !!D.Preempt; - // 接管范围:默认只开 ba.lcr(理由见文件头注) - const Scope = Object.assign({ BaLcr: true, LsXbl: false, GQ1wd: false }, D.Scope || {}); + // 接管范围:默认只开 ba.lcr 与 M10 的 DeviceInsert(理由见文件头注) + const Scope = Object.assign({ BaLcr: true, DeviceInsert: true, LsXbl: false, GQ1wd: false }, D.Scope || {}); const XTable = new Map(); const Count = { Hit: 0, Miss: 0, Disabled: 0, Unavailable: 0, Fallback: 0, FallbackFrames: 0, Suppressed: 0, ByMethod: {} }; @@ -84,6 +92,12 @@ function MakeResponder(Deps) { return Payload.readInt32BE(0); } + // 上行的 i64 负载(M10 的 dh.hcu:8 字节大端,设备对象 id)。不足 8 字节 → null(不猜) + function ReadI64(Payload) { + if (!Buffer.isBuffer(Payload) || Payload.length < 8) return null; + return Payload.readBigInt64BE(0).toString(); + } + // 同步判定:返回 thunk(查库路径**已接管**这条上行)或 null(让位给回放 / 未实现清单) // Ctx = { Sender, Replay, ReplaySender }:Sender(Buf)→bool 是发字节的出口;Replay 是会话回放器(可为 null) function Prepare(R, TargetId, Ctx) { @@ -96,12 +110,39 @@ function MakeResponder(Deps) { return null; } // 接管范围之外的上行直接让位(默认的 ls.xbl / G.q1wd 走这里,见文件头注) - const ScopeKey = Method === METHOD.BaLcr ? 'BaLcr' : Method === METHOD.LsXbl ? 'LsXbl' : 'GQ1wd'; + const ScopeKey = + Method === METHOD.BaLcr ? 'BaLcr' : + Method === METHOD.DeviceInsert ? 'DeviceInsert' : + Method === METHOD.LsXbl ? 'LsXbl' : 'GQ1wd'; if (!Scope[ScopeKey]) { Bump('Disabled', Tag); return null; } + // M10:把设备装到挂载位(dh.hcu)。这是一条**写**上行:先落库 Mounted.DeviceId,再用库里的值组一条 + // devicesLoaded 回显(客户端 srg 覆盖写该槽位的设备行 + mountedDevice,重复下发幂等)。 + if (Method === METHOD.DeviceInsert) { + const Device = ReadI64(R.Payload); + if (Device === null) { + Bump('Miss', Tag); + Log('→ 查库未命中:dh.hcu 负载不足 8 字节(解不出设备 id)→ 回落回放'); + return null; + } + return async () => { + try { + await Store.SetDeviceMounted(String(TargetId), Device); + const Rec = Store.DevicesLoaded(SpaceId, String(TargetId)); + if (!Rec) throw new Error('库里的 DeviceSlots 没有挂载位 ' + TargetId); + Log('→ 查库应答:devicesLoaded(槽位 ' + TargetId + ') ' + Rec.Fields.devices.length + + ' 个设备 / mountedDevice=' + (Rec.Fields.mountedDevice === null ? 'null' : Rec.Fields.mountedDevice)); + return Emit([Rec], Tag, R, TargetId, C); + } catch (Err) { + Log(' ⚠ 设备落库路径未成(' + Err.message + ')→ 回落会话回放'); + return Fallback(R, TargetId, C, Tag); + } + }; + } + if (Method === METHOD.BaLcr) { const Rec = Store.DevicesLoaded(SpaceId, String(TargetId)); if (!Rec) { diff --git a/3DTank/tools/server/space.js b/3DTank/tools/server/space.js index 2ac2875..26678d8 100644 --- a/3DTank/tools/server/space.js +++ b/3DTank/tools/server/space.js @@ -44,20 +44,49 @@ function LoadInitTemplate(NamesDir, SpaceId) { // 模板 → [{ File, DelayMs, Frame }];负载由 Container / Semantic 重新编码 // 记录的三种形态:① { Layout, Value } 容器(container.js)② { Fields } 普通字段(semantic.js)③ { Hex } 未解原样透传 +// +// 【为什么把记录原样交给 Semantic.EncodeFrameBody,而不是在这里逐条编成字节】 +// 帧级 nullmap 是**所有记录共用的一串位**:每条记录先占 1 个「形态位」,随后它负载里每个 +// opt 字段再占 1 个「空/非空位」,按编码顺序排下去(见 semantic.js 头注的 gap A)。 +// 所以「改一个 opt 字段的值」= 写出负载 **且** 把那位由 1 改 0 —— 两件事必须一起做。 +// 只有 semantic.EncodeFrameBody 持有那个位游标(它对容器与字段两条路径都会推进 Ctx; +// container.js 的 Write/Read 都接受 Ctx)。旧写法(本函数自己 EncodeRecord 不带游标 + +// nullmap 原样带上)只写负载、不改位,产出的是**错位帧**:负载多出 T 的字节数,而解码侧按 +// 位读到「空」,于是把那多出来的字节当成下一条记录的头(实测 devicesLoaded 的 mountedDevice +// 由空改成有值时,帧从 255848B 变 255856B 且 Bad)。 +// 注意 list 字段改长度不碰 nullmap 位(长度在负载里编),所以 M7 覆盖 mountedItems 时没暴露。 function BuildInitFrames(Template, Container, Semantic) { const Out = []; for (const F of Template.Frames || []) { + // 没有语义层实例时退回旧路径(逐条编字节 + nullmap 原样带上):它只适用于「记录类型全是 + // 容器/Hex,或改动的只是 list 这类不占位的字段」。空模板就别在这里抛错,交给语义层报。 + if (!Semantic) { + const Old = F.Records.map((R) => { + let Payload; + if (R.Layout !== undefined) Payload = Container.Write(R.Layout, R.Value); + else Payload = Buffer.from(R.Hex || '', 'hex'); + return { ObjectId: R.ObjectId, MethodId: R.MethodId, Payload }; + }); + Out.push({ File: F.File, DelayMs: F.DelayMs || 0, Frame: BuildSpaceFrame(F.SpaceId, Old, F.NullmapHex) }); + continue; + } const Records = F.Records.map((R) => { - let Payload; - if (R.Layout !== undefined) Payload = Container.Write(R.Layout, R.Value); - else if (R.Fields !== undefined) { - // 字段 JSON 必须由语义层现编:它才知道该 methodId 的有序字段与类型 - if (!Semantic) throw new Error('模板记录是 Fields 形态(methodId=' + R.MethodId + '),但没传 semantic.js 实例'); - Payload = Semantic.EncodeRecord(R.MethodId, R); - } else Payload = Buffer.from(R.Hex || '', 'hex'); - return { ObjectId: R.ObjectId, MethodId: R.MethodId, Payload }; + // objectId 缺省 → 回退帧级 SpaceId(与 BuildSpaceFrame 的兼容路径同规则) + const ObjectId = R.ObjectId === undefined || R.ObjectId === null ? F.SpaceId : R.ObjectId; + if (R.Hex !== undefined) return { ObjectId, MethodId: R.MethodId, Hex: R.Hex }; + if (R.Layout !== undefined) return { ObjectId, MethodId: R.MethodId, Layout: R.Layout, Value: R.Value }; + if (R.Fields !== undefined) return { ObjectId, MethodId: R.MethodId, Fields: R.Fields }; + // 旧模板可能直接存了编好的负载:原样透传(语义层把它当不可重建记录,之后的位保持原值) + return { ObjectId, MethodId: R.MethodId, Payload: R.Payload }; + }); + // EncodeFrameBody 返回的就是完整帧体 [nullmap][记录…];再用 EncodeFrame 补长度头 + const Body = Semantic.EncodeFrameBody({ Nullmap: F.NullmapHex || '00', Records }); + const NmSize = Link.NullmapSize(Body, 0); + Out.push({ + File: F.File, + DelayMs: F.DelayMs || 0, + Frame: Link.EncodeFrame(Body.slice(NmSize), Body.slice(0, NmSize)), }); - Out.push({ File: F.File, DelayMs: F.DelayMs || 0, Frame: BuildSpaceFrame(F.SpaceId, Records, F.NullmapHex) }); } return Out; } diff --git a/3DTank/tools/server/store.js b/3DTank/tools/server/store.js index c20da58..faf4072 100644 --- a/3DTank/tools/server/store.js +++ b/3DTank/tools/server/store.js @@ -246,6 +246,71 @@ function OverrideMountedRecord(Records) { 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 还没连库'); } @@ -807,6 +872,8 @@ module.exports = { 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) {