// container.js - 容器命令(modelService 那几条)的读/写 // // 出处:src/module_45686.js 的三个容器 codec + src/module_29554.js 的 4 条命令封装, // 再逐条用 `packets/plain/space-/*.bin` 的真实明文帧「解出来再写回去」逐字节标定。 // // --------------------------------------------------------------------------- // 一、哪个方法用哪个布局:**由 methodId 决定**,不再靠「哪个布局恰好吃到帧尾」 // 依据(三处互相印证): // src/module_29554.js:79-83 4 条 modelService 方法的长整型常量(x1wc_1/y1wd_1/…) // src/module_29554.js:239-244 taw() 里给每条方法挂的负载 codec: // _loadDependencies(3216143066888387731) → new _(o(K), !1) K = ObjectsDependencies // _loadObjectsData (7640916300855664666) → new _(o(W), !1) W = ObjectsData // _unloadObjects (9038756763225879447) → new a(new _(o(h), !1), !1) // src/module_45686.js:600 把三个 codec 注册给三个模型类: // m(Q)=ModelData → lt,m(tt)=ObjectsData → pt,m(nt)=ObjectsDependencies → zt // names/downlink_table.json 的 Containers 段与方法表一致(Independent 第二证据)。 // ★ 旧实现按「三布局都试、取恰好吃到帧尾的那个」判定,这在**多记录帧**上必然失败: // 容器记录的负载只占帧体的一段,后面还有别的记录(semantic.js 传给我们的 End 是帧尾)。 // 实例:space-920007382011/s2c_0001.bin = nullmap(2B) + pt 记录(16+286) + 第 3 条记录(16+16), // pt 负载 286B 而帧体 336B,「吃到帧尾」永远不成立 → 旧实现把整帧当 hex 留着。 // // 二、三个布局的负载形状(源码行号逐个给出) // lt(_unloadObjects)= [varint 个数][个数 × i64 对象 id] // 依据:module_29554.js:244 的 `new a(new _(o(h), !1), !1)`:a = ListCodecInfo // (module_24635.js:877-882 的 wn,`$_$.c`),元素类型 = 类 h 的 codec; // h = r.$_$.r 而 r = i(61860)(module_29554.js 尾部依赖表)→ module_61860 的导出 r = xr // (module_61860.js 导出表 `t.$_$.r = xr`),xr 的 codec 只写一次 i64 // (extract_codec_fields 的 WireClasses:`"xr":"i64"`,来自 module_61860.js 的 `m7f(G(xr), new cr())`)。 // 标定:space-100073070/s2c_0002.bin 帧体 126B = nullmap(1) + 5 × (16B 头 + 9B 负载), // 每个负载 = `01` + 8B 对象 id(如 01 ffffffff80000744)→ 5×25+1 = 126 精确吃满。 // pt(_loadObjectsData)= [i32 C][C × (i64 objId, i64 gameTypeId)][i32 D][D × LtItem] // 依据:module_45686.js:531-556 的 pt.p7g:g1wc_1 = ft 列表(module_45686.js:111-120, // 两次 a7g_1.q2u)、f1wc_1 = lt.p7g 列表;两处计数都是 c7g_1.p2u(= i32, // module_72834.js:278-280 p2u → t2t → getInt32)。 // LtItem(pt 里的一项,= ModelData 的 lt codec,注意与上面 lt 布局同名但不同物) // = [i64 id];id == 0 → 再读一个 i64 当值;否则 id 是 modelId, // 就地展开该模型的字段。依据 module_45686.js:487-494 的 lt.p7g。 // zt(_loadDependencies)= [i32 X][i32 C][C × {i64 ObjId, i32 N, N × i64 ModelId}] // [i32 D][D × {i64 R, i16 E, i16 U, i64 S, i8 O, i8 Skip, Skip × i64}] // 依据:module_45686.js:563-596 的 zt.p7g(X = c7g_1.p2u;kt = module_45686.js:190-205; // $t = module_45686.js:213-228 依次 q2u/o2u/o2u/q2u/布尔位;跳过个数是 i.n2u = 1 字节 // (module_72834.js:272-274 n2u → xy → getInt8),不是 varint)。 // // 三、opt 字段与记录形态位共用 nullmap 比特流(这是旧实现解不动的第二个根因) // 位图 = 「一条记录 1 个形态位」+「负载里每个 opt 字段 1 位」,按同一顺序消费: // src/module_61860.js:2116-2119 形态位(b7g_1.xy()) // src/module_24635.js:412-417 opt 位(Ht.p7g/o7g) // src/module_24635.js:809-845 nullmap 灌进位读取器(r7f → j7h(c, n.b7g_1)) // src/module_61860.js:1022-1038 记录链循环(有字节就一直解下一条、逐条派发) // 所以容器负载里的叶子要按 **bit** 读 opt,且必须先把「本条记录之前」的位消费掉 // (见 ResolveCursor 的注释)。位游标拿不到时,容器负载里的 opt 一律如实报不可解, // 绝不退回「按字节猜标志位」——那会把负载整体解错位。 // // 四、codec_fields 里推不出嵌套 codec 的裸类名(16 个 token)里,资源类按「资源引用 = i64」解 // 依据:module_71105.js 的资源类型注册块(`r.u83(typeId, si(类), 工厂)`,typeId 4/7/8/9/10/11/17/25/26) // + module_71105.js `i.m7f(si(Gf), i.p7f(si(pv)))`(Gf 与 pv 共用同一个 codec); // 资源实例是 `Zu(i16 typeId, …, i64 key, …)`(module_71105.js 的 Zu 构造器 = module_45686.js:226 // 的 L,读序 i64/i16/i16/i64/bool),查资源用 i64 key(module_45686.js:209 qt() → resourceRegistry.x83)。 // 实测标定:space-920007382011/s2c_0001.bin 的 MatchmakingLayoutCC 按 pv=i64 解出的字段完全自洽 // (两个 opt 取到同一个资源 key、holidayDescription/holidayTitle 是长度精确吻合的 UTF-8、 // matchmakingModeRanks 的枚举 0..10 递增),且负载恰好止于 286B、下一条记录头紧随其后。 // 没进这张表的裸类名(Pc / Ea / nh …)**一律不猜**:解到就如实报「无 codec」。 'use strict'; const Link = require('./link'); const { MakeReader } = require('../lib_space_codec'); const { MakeWriter } = require('./codec'); const LAYOUT = { Lt: 'lt', Pt: 'pt', Zt: 'zt' }; // methodId(十进制字符串,与 names/downlink_table.json 的 Containers 完全一致)→ 负载布局 const LAYOUT_BY_METHOD = { '3216143066888387731': LAYOUT.Zt, // ModelService.LoadDependencies '7640916300855664666': LAYOUT.Pt, // ModelService.LoadObjectsData '9038756763225879447': LAYOUT.Lt, // ModelService.UnloadObjects }; // 资源类(module_71105.js 的资源类型注册块 + Gf 复用 pv 的 codec)→ 线上一律是 i64 引用。 // 只列有源码出处的那几个;表外的一律不猜。 const RESOURCE_CLASS_WIRE = { Ea: 'i64', nh: 'i64', W_: 'i64', uo: 'i64', pv: 'i64', B_: 'i64', Da: 'i64', qs: 'i64', uh: 'i64', Gf: 'i64', kv: 'i64', // Pc:**源码侧没定位到 codec**(codec_fields 里 Pc 没有 codec;module_47243.js:180 的 // `ji = u.$_$.l2` 指向 module_52943 的 Pc,而那是 `function Pc() {}` 空壳,没有 q7g/p7g)。 // 这里是**实测标定**:space-100045260/s2c_0001.bin 的 LoaderBackgroundModelCC // 三个字段按 (Gf=i64, Pc=i64, Pc=i64) 解出的值分别是 // 1788178032155 / 1790283600000 / 1788469200000 // —— 全是 13 位毫秒时间戳,且 startDate < endDate、区间与帧采集日期自洽;同时 pt 负载 // 精确止于 448B(第 5..11 项、共 11 项全部落在合法边界上)。按 4 字节解会在下一项 id 处错位。 // 结论:Pc 是毫秒时间戳引用 → i64。这条不是源码证据,单独标出来。 Pc: 'i64', }; // nullmap 的位图(与 semantic.js:46-58 同一口径,依据 src/module_24635.js:640-695 的 j7h): // 长形态(首字节 bit7=1):位图 = 标签之后的字节按 MSB 先读 // 短形态:整串按 MSB 先读、跳过前 3 个 bit(那 3 位是长度标签) function NullmapBits(Bytes) { const Bits = []; const Push = (B) => { for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1); }; if (Bytes.length && Bytes[0] & 0x80) { for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]); } else { for (const B of Bytes) Push(B); Bits.splice(0, 3); } return Bits; } function MakeContainerCodec(Tables, Reader, Writer, Options) { const Opts = Options || {}; const MaxCount = Opts.MaxCount || 1 << 20; // 计数上限,超出即认定布局不成立 // 容器负载专用的读写器:opt 按 nullmap 的 bit 读/写(见文件头第三节), // 并带上「裸类名 → 线上基础类型」直译表。语义层原来给的那两个实例保持原样不动 // (它们服务的是字段表那条路径,口径本来就不同)。 // 直译表 = codec_fields.json 的 ResourceClasses(由源码 u83 注册块导出,见 extract_codec_fields.js) // + 上面手写的 RESOURCE_CLASS_WIRE(手写表优先:里面有 Pc 这种「实测标定」的条目)。 // 为什么要合并:原来只认手写的 10 个资源类,漏了 typeId 13 的 no(与 nh/B_/pv/kv/Da/W_/uo/Ea/qs/uh 同块注册), // 于是 space-100072911/s2c_0001 的 DonationAlertCC.imageWithEmail 报「类型 no 没有对应 codec」。 const Alias = {}; for (const [N, R] of Object.entries(Tables.ResourceClasses || {})) Alias[N] = R.Wire; for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) Alias[N] = W; const LeafReader = MakeReader(Tables, { EnumBytes: Reader.EnumBytes, OptBits: true, TypeAlias: Alias, StringLiteralOnly: true, }); const LeafWriter = MakeWriter(Tables, { EnumBytes: Writer.EnumBytes, OptBits: true, TypeAlias: Alias, StringLiteralOnly: true, }); const CodecByMethod = Tables.CodecByMethod || new Map(); // ---------- nullmap 位游标 ---------- function MethodIdAt(Buf, Off) { return Off >= 8 && Off + 8 <= Buf.length ? Buf.readBigInt64BE(Off - 8).toString() : ''; } // 本条记录负载的起始位游标。**必须把前面的记录走一遍**,因为位图是共用的: // 位游标(负载) = Σ前面每条记录的形态位 + Σ前面每条记录负载里的 opt 位 // 走不动(短命令形态 / 前面某条方法的字段表不在 codec_fields / 走完对不齐 Off)→ 返回 null, // 上层就按「拿不到位」如实报不可解(不猜)。 function ResolveCursor(Buf, Off) { const NmSize = Link.NullmapSize(Buf, 0); if (NmSize > Buf.length || NmSize > Off) return null; const Bits = NullmapBits(Buf.slice(0, NmSize)); let At = NmSize; let Bit = 0; for (;;) { // 走到目标记录的负载 → 位游标就是「已消费的形态位 + 前面负载里的 opt 位」 if (At === Off) return { Bits, Index: Bit }; if (At > Off || Bit >= Bits.length) return null; if (Bits[Bit++]) return null; // 短命令形态:命令号 → (objectId, methodId) 的查表数据还没挖 if (At + 16 > Buf.length) return null; const MethodId = Buf.readBigInt64BE(At + 8).toString(); At += 16; if (At === Off) return { Bits, Index: Bit }; // 目标负载紧跟它自己的 16 字节记录头 const Layout = LAYOUT_BY_METHOD[MethodId]; let R; const Ctx = { Bits, Index: Bit }; if (Layout) { R = Readers[Layout](Buf, At, Ctx); } else { const C = CodecByMethod.get(MethodId); if (!C) return null; // 前面那条方法的字段表不在 codec_fields:数不出它耗掉几个位 R = ReadFieldsBits(Ctx, Buf, At, C, 0); } Bit = Ctx.Index; if (R.Bad || !(R.Next > At)) return null; At = R.Next; } } // 把当前位游标临时接到 LeafReader 上跑一次字段读;读完把新游标写回 Ctx function ReadFieldsBits(Ctx, Buf, Off, Codec, Depth) { if (!Ctx.Bits) return { Value: null, Next: Off, Bad: true, Bit: Ctx.Index, Why: '没有 nullmap 比特流,opt 字段无法判定(不按字节猜)' }; LeafReader.AttachBits(Ctx.Bits, Ctx.Index); let R; try { R = LeafReader.ReadFields(Buf, Off, Codec, Depth); } finally { Ctx.Index = LeafReader.BitCursor().Index; LeafReader.DetachBits(); } return { Value: R.Value, Next: R.Next, Bad: R.Bad, Bit: Ctx.Index, Why: R.Why }; } // ---------- 读 ---------- // pt 里的一项 = ModelData 的 lt codec(module_45686.js:487-494) function ReadLtItem(Buf, At, Ctx) { const Id = Buf.readBigInt64BE(At).toString(); let Next = At + 8; if (Id === '0') { if (Next + 8 > Buf.length) return { Value: null, Next: At, Bad: true, Why: 'lt 项 id=0 但没有紧跟的 i64 值' }; return { Value: { Id, Value: Buf.readBigInt64BE(Next).toString() }, Next: Next + 8, Bit: Ctx.Index }; } const Codec = Tables.CodecByModelId.get(Id); if (!Codec) { return { Value: null, Next: At, Bad: true, Why: 'modelId=' + Id + ' 在 codec_fields 里查不到 codec(叶子 codec 缺失)' }; } const R = ReadFieldsBits(Ctx, Buf, Next, Codec, 1); if (R.Bad) { return { Value: null, Next: At, Bad: true, Why: 'modelId=' + Id + '(' + Codec.Module + '::' + Codec.Codec + ')的字段解不动:' + (R.Why || '未知类型/越界') }; } return { Value: { Id, Value: R.Value }, Next: R.Next, Bit: Ctx.Index }; } // lt:_unloadObjects 的负载 = list function ReadLt(Buf, Off, Ctx) { const N = LeafReader.ReadVarint(Buf, Off); if (!N || N.Bad) return { Value: null, Next: Off, Bad: true, Why: '流变量计数读不动' }; let At = N.Next; if (N.Value > MaxCount) return { Value: null, Next: Off, Bad: true, Why: '个数 ' + N.Value + ' 超上限' }; if (At + 8 * N.Value > Buf.length) return { Value: null, Next: Off, Bad: true, Why: '个数 ' + N.Value + ' 但只剩 ' + (Buf.length - At) + ' 字节' }; const Ids = []; for (let I = 0; I < N.Value; I++) { Ids.push(Buf.readBigInt64BE(At).toString()); At += 8; } return { Value: { Ids }, Next: At, Bit: Ctx.Index }; } // pt:对象引用表 + ModelData 表 function ReadPt(Buf, Off, Ctx) { if (Off + 4 > Buf.length) return { Value: null, Next: Off, Bad: true, Why: '不足 4 字节读对象表个数' }; const C = Buf.readInt32BE(Off); let At = Off + 4; if (C < 0 || C > MaxCount) return { Value: null, Next: Off, Bad: true, Why: '对象表个数 C=' + C + ' 不合理' }; if (At + 16 * C + 4 > Buf.length) return { Value: null, Next: Off, Bad: true, Why: '对象表 C=' + C + ' 超出负载长度' }; const Objects = []; for (let I = 0; I < C; I++) { Objects.push({ ObjectId: Buf.readBigInt64BE(At).toString(), GameTypeId: Buf.readBigInt64BE(At + 8).toString() }); At += 16; } const D = Buf.readInt32BE(At); At += 4; if (D < 0 || D > MaxCount) return { Value: null, Next: Off, Bad: true, Why: 'ModelData 表个数 D=' + D + ' 不合理' }; const Items = []; for (let I = 0; I < D; I++) { if (At + 8 > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + '/' + D + ' 项读 id 越界' }; const R = ReadLtItem(Buf, At, Ctx); if (R.Bad) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + '/' + D + ' 项:' + R.Why }; Items.push(R.Value); At = R.Next; } return { Value: { Objects, Items }, Next: At, Bit: Ctx.Index }; } // zt:模型归属表 + 资源引用项表 function ReadZt(Buf, Off, Ctx) { if (Off + 8 > Buf.length) return { Value: null, Next: Off, Bad: true, Why: '不足 8 字节读 X/批次个数' }; const X = Buf.readInt32BE(Off); const C = Buf.readInt32BE(Off + 4); let At = Off + 8; if (C < 0 || C > MaxCount) return { Value: null, Next: Off, Bad: true, Why: '批次个数 C=' + C + ' 不合理' }; const Batches = []; for (let I = 0; I < C; I++) { if (At + 12 > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + '/' + C + ' 批读头部越界' }; const ObjectId = Buf.readBigInt64BE(At).toString(); const N = Buf.readInt32BE(At + 8); At += 12; if (N < 0 || N > MaxCount) return { Value: null, Next: At - 4, Bad: true, Why: '第 ' + (I + 1) + ' 批 modelId 个数 N=' + N + ' 不合理' }; if (At + 8 * N > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + ' 批 N=' + N + ' 超出负载长度' }; const ModelIds = []; for (let K = 0; K < N; K++) { ModelIds.push(Buf.readBigInt64BE(At).toString()); At += 8; } Batches.push({ ObjectId, ModelIds }); } if (At + 4 > Buf.length) return { Value: null, Next: At, Bad: true, Why: '批次之后不足 4 字节读项个数' }; const D = Buf.readInt32BE(At); At += 4; if (D < 0 || D > MaxCount) return { Value: null, Next: At - 4, Bad: true, Why: '项个数 D=' + D + ' 不合理' }; const Items = []; for (let I = 0; I < D; I++) { if (At + 22 > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + '/' + D + ' 项越界' }; const R = Buf.readBigInt64BE(At).toString(); const E = Buf.readInt16BE(At + 8); const U = Buf.readInt16BE(At + 10); const S = Buf.readBigInt64BE(At + 12).toString(); const O = Buf[At + 20]; const Skip = Buf.readInt8(At + 21); // n2u = 1 字节(module_72834.js:272-274) At += 22; if (Skip < 0 || Skip > MaxCount) return { Value: null, Next: At - 1, Bad: true, Why: '第 ' + (I + 1) + ' 项跳过个数 ' + Skip + ' 不合理' }; if (At + 8 * Skip > Buf.length) return { Value: null, Next: At, Bad: true, Why: '第 ' + (I + 1) + ' 项跳过 ' + Skip + ' 个 id 越界' }; const Skipped = []; for (let K = 0; K < Skip; K++) { Skipped.push(Buf.readBigInt64BE(At).toString()); At += 8; } Items.push({ R, E, U, S, O, Skipped }); } return { Value: { X, Batches, Items }, Next: At, Bit: Ctx.Index }; } const Readers = { lt: ReadLt, pt: ReadPt, zt: ReadZt }; // 第 4 个参数是**可选的帧级位游标** Ctx = { Bits, Index }(由 semantic.DecodeFrameBody 顺序推进后传进来): // 传了 ⇒ 直接用它,不再自己 ResolveCursor。为什么必须这样:ResolveCursor 要把「本条记录之前」的 // 每条记录重走一遍,多记录帧上是 O(n²)(space-100073070/s2c_0001 的容器负载 766KB)。 // 没传 ⇒ 保持既有行为(按 methodId 判定要不要算游标)。selftest.js:40/51、probe_containers.js:91/95、 // extract_space_init.js:50 走的都是这条。 // (原来这里是没用的 Depth 形参:三个布局的读者都按 (Buf, Off, Ctx) 调用,Depth 从来没被读过。) function Read(Layout, Buf, Off, Ctx) { if (!Readers[Layout]) throw new Error('未知容器布局: ' + Layout); // 只有「确认是 modelService 容器记录」才去算位游标:其它调用(自检把可疑帧拿来试布局) // 不该触发走路,也不该让 opt 走位路径。 const Known = !!LAYOUT_BY_METHOD[MethodIdAt(Buf, Off)]; const Use = Ctx && Ctx.Bits ? Ctx : (Known ? ResolveCursor(Buf, Off) : null) || { Bits: null, Index: 0 }; const R = Readers[Layout](Buf, Off, Use); if (Ctx) Ctx.Index = Use.Index; // 把本条负载消费掉的 opt 位数交回给调用方(缺口 A) if (!R.Bad) return { Value: R.Value, Next: R.Next }; return { Value: null, Next: R.Next, Bad: true, Why: R.Why || '' }; } // 逐个试布局,取「恰好吃到 End」的那个;都不精确则返回 null(不猜)。 // 已知的 modelService 方法直接按 methodId 定布局(多记录帧必须走这条)。 function Detect(Buf, Off, End) { const MethodId = MethodIdAt(Buf, Off); const Known = LAYOUT_BY_METHOD[MethodId]; if (Known) { const R = Read(Known, Buf, Off); return R.Bad ? null : Known; } const Hits = []; for (const Layout of [LAYOUT.Zt, LAYOUT.Pt, LAYOUT.Lt]) { const R = Read(Layout, Buf, Off); if (!R.Bad && R.Next === End) Hits.push(Layout); } return Hits.length ? Hits[0] : null; } // ---------- 写(由读侧反推,逐字节验证过) ---------- function WriteVarint(N) { return Writer.WriteVarint(N); } // pt 里的一项 function WriteLtItem(Item) { if (!Item || Item.Id === undefined || Item.Id === null) throw new Error('lt 项缺少 Id'); const Id = Buffer.alloc(8); Id.writeBigInt64BE(BigInt(Item.Id), 0); if (String(Item.Id) === '0') { const V = Buffer.alloc(8); V.writeBigInt64BE(BigInt(Item.Value), 0); return Buffer.concat([Id, V]); } const Codec = Tables.CodecByModelId.get(String(Item.Id)); if (!Codec) throw new Error('modelId=' + Item.Id + ' 在 codec_fields 里查不到 codec,无法展开'); return Buffer.concat([Id, LeafWriter.WriteFields(Codec, Item.Value, 0)]); } function WriteLt(Obj) { if (!Obj || !Array.isArray(Obj.Ids)) throw new Error('lt 结构缺少 Ids'); const Parts = [WriteVarint(Obj.Ids.length)]; for (const Id of Obj.Ids) { const B = Buffer.alloc(8); B.writeBigInt64BE(BigInt(Id), 0); Parts.push(B); } return Buffer.concat(Parts); } function WritePt(Obj) { if (!Obj || !Array.isArray(Obj.Objects) || !Array.isArray(Obj.Items)) throw new Error('pt 结构缺少 Objects / Items'); const Head = Buffer.alloc(4); Head.writeInt32BE(Obj.Objects.length, 0); const Pairs = Obj.Objects.map((X) => { const B = Buffer.alloc(16); B.writeBigInt64BE(BigInt(X.ObjectId), 0); B.writeBigInt64BE(BigInt(X.GameTypeId), 8); return B; }); const Count = Buffer.alloc(4); Count.writeInt32BE(Obj.Items.length, 0); return Buffer.concat([Head, ...Pairs, Count, ...Obj.Items.map(WriteLtItem)]); } function WriteZt(Obj) { if (!Obj || !Array.isArray(Obj.Batches) || !Array.isArray(Obj.Items)) throw new Error('zt 结构缺少 Batches / Items'); const Head = Buffer.alloc(8); Head.writeInt32BE(Obj.X, 0); Head.writeInt32BE(Obj.Batches.length, 4); const Batches = Obj.Batches.map((B) => { const Id = Buffer.alloc(8); Id.writeBigInt64BE(BigInt(B.ObjectId), 0); const N = Buffer.alloc(4); N.writeInt32BE(B.ModelIds.length, 0); const Ids = B.ModelIds.map((M) => { const Buf = Buffer.alloc(8); Buf.writeBigInt64BE(BigInt(M), 0); return Buf; }); return Buffer.concat([Id, N, ...Ids]); }); const Count = Buffer.alloc(4); Count.writeInt32BE(Obj.Items.length, 0); const Items = Obj.Items.map((X) => { const Buf = Buffer.alloc(22); Buf.writeBigInt64BE(BigInt(X.R), 0); Buf.writeInt16BE(X.E, 8); Buf.writeInt16BE(X.U, 10); Buf.writeBigInt64BE(BigInt(X.S), 12); Buf[20] = X.O; Buf.writeInt8((X.Skipped || []).length, 21); const Skipped = (X.Skipped || []).map((S) => { const B = Buffer.alloc(8); B.writeBigInt64BE(BigInt(S), 0); return B; }); return Buffer.concat([Buf, ...Skipped]); }); return Buffer.concat([Head, ...Batches, Count, ...Items]); } const Writers = { lt: WriteLt, pt: WritePt, zt: WriteZt }; // 第 3 个参数是**可选的帧级位游标** Ctx = { Bits, Index }:语义层重建 nullmap 时传进来, // 叶子里每个 opt 字段的「空/非空」位就按编码顺序写回该比特流(codec.js:PutBit)。 // 不传 ⇒ 保持既有行为:只保证「空值一个字节都不写」,位由帧级 NullmapHex 原样带上 // (selftest.js:53、probe_containers 走的都是这条,口径一字不变)。 function Write(Layout, Obj, Ctx) { const F = Writers[Layout]; if (!F) throw new Error('未知容器布局: ' + Layout); if (!(Ctx && Ctx.Bits)) return F(Obj); LeafWriter.AttachBits(Ctx.Bits, Ctx.Index); try { return F(Obj); } finally { Ctx.Index = LeafWriter.BitCursor().Index; LeafWriter.DetachBits(); } } return { Read, Write, Detect, LAYOUT, LAYOUT_BY_METHOD }; } module.exports = { MakeContainerCodec, LAYOUT, LAYOUT_BY_METHOD };