// space.js - 空间帧组装 + 空间初始化模板 // // s2c 空间帧 = [nullmap][(objectId int64)(methodId int64)(负载)…](出处见 dispatch.js 头注 + semantic.js 头注) // 注意是**每条记录自带 objectId**(src/module_61860.js:2119-2120),不是「帧头一个 spaceId」; // 单记录帧里两者恰好相同,所以旧的「帧级 spaceId」写法仍然等价(见 BuildSpaceFrame 的兼容路径)。 // // 「初始化模板」= names/space-init-.json。它存的是**结构化模板**而不是原始帧字节: // 容器记录存「解出来的对象树」(发送时由 container.js 重新编码), // 普通字段记录存「字段 JSON」(发送时由 semantic.js 现编), // 只有解不动的记录才存 hex。 // 这样模板里的每个 id / modelId / 项 / 字段都可以被替换成我们自己的数据(M2 的后续工作), // 而不是把抓包字节原样回放。 'use strict'; const fs = require('fs'); const path = require('path'); const Link = require('./link'); // Records = [{ ObjectId?, MethodId, Payload: Buffer }] // NullmapHex:nullmap 原样字节(十六进制)。注意它不是「可有可无的 0」—— // 实测有帧的 nullmap 是 2 字节且带内容(如 `20 3c`),含义是「哪些字段为空、负载里因此没有它们」, // 写成 0x00 会让客户端按「非空」去读,导致错位。所以能拿到原 nullmap 就必须原样带上。 // (EncodeFrame 自己会拼 nullmap,所以这里 Parts 里不能再放一个。) // ObjectId 缺省 → 回退用帧级 SpaceId:这是**兼容路径**,只为让「每条记录 objectId 相同」这类旧调用 // (以及还没升级出 ObjectId 的老模板)继续能跑;新模板里每条记录都应带自己的 ObjectId。 function BuildSpaceFrame(SpaceId, Records, NullmapHex) { const Nm = Buffer.from(NullmapHex || '00', 'hex'); const Parts = []; for (const R of Records) { const Id = Buffer.alloc(8); Id.writeBigInt64BE(BigInt(R.ObjectId === undefined || R.ObjectId === null ? SpaceId : R.ObjectId), 0); const Method = Buffer.alloc(8); Method.writeBigInt64BE(BigInt(R.MethodId), 0); Parts.push(Id, Method, R.Payload); } const Body = Buffer.concat(Parts); return Link.EncodeFrame(Body, Nm); } function LoadInitTemplate(NamesDir, SpaceId) { const File = path.join(NamesDir, 'space-init-' + SpaceId + '.json'); if (!fs.existsSync(File)) return null; return JSON.parse(fs.readFileSync(File, 'utf8')); } // 模板 → [{ 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) => { // 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)), }); } return Out; } module.exports = { BuildSpaceFrame, LoadInitTemplate, BuildInitFrames };