Files
TermiNexus b6f2cb71ee feat(3dtank): 协议逆向工程与自研服务器入库(工具链 + 协议表 + 空间回放 + MySQL)
工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
2026-09-17 20:00:41 +08:00

456 lines
23 KiB
JavaScript
Raw Permalink 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.
// container.js - 容器命令(modelService 那几条)的读/写
//
// 出处:src/module_45686.js 的三个容器 codec + src/module_29554.js 的 4 条命令封装,
// 再逐条用 `packets/plain/space-<id>/*.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<pv> 取到同一个资源 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<i64 对象 id>
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 };