space.js 的 BuildInitFrames 自己逐条调 Container.Write / Semantic.EncodeRecord 出负载,再把模板的 NullmapHex 原样带上。但帧级 nullmap 是所有记录共用的一串位(每条记录 1 个形态位 + 每个 opt 字段 1 个空/非空位,按编码顺序排),只有 semantic.EncodeFrameBody 持位游标(容器与字段两条路径都会推进 Ctx)。于是「把 opt<T> 由空改成有值」时负载多出 T 的字节数而 nullmap 位仍是 1(=空),解码侧按位读到空、把那多出来的字节当成下一条记录头 —— 实测 devicesLoaded 的 mountedDevice 由 null 改成设备 id 时帧 255848B→255856B 且 Bad(29 条记录只解出 7 条)。M7 覆盖 mountedItems 时没暴露,是因为 list<T> 改长度不占 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。
247 lines
12 KiB
JavaScript
247 lines
12 KiB
JavaScript
// responder.js - 查库定向应答:把上行接到 store.js 上
|
||
//
|
||
// ★ 优先级口径(2026-09-17 真机教训,别再反过来):
|
||
// **已播发的帧(空间模板 / 会话回放)优先,查库只补它们没覆盖的东西。**
|
||
// 早先按「查库优先」接管,真机在**入口空间**当场抛异常:
|
||
// 模板在 EnterSpace 时已经发过那条 _loadObjectsData 了,查库路径又按 G.q1wd(1) 发了第二遍,
|
||
// 客户端 nap() 里 `s2c_1.has(t)` 命中 → module_10849.js:440 抛 "Model already subscribed!"。
|
||
// (`nap` 是**模型订阅**,同一模型订阅两次直接抛;这跟 devicesLoaded 那种「覆盖写设备行」不一样。)
|
||
//
|
||
// 接管范围由 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 的应答」一起播发了(模板是照抄真实会话的帧、
|
||
// 不区分触发者),再答一遍属于重复卸载,没验证过客户端会不会有副作用。
|
||
// GQ1wd(默认关) G.q1wd 1816792453857564692 负载 i32 = n → 按库里的批次组一条 _loadObjectsData(pt)
|
||
// 关的原因有两条:① 与模板/回放里那条**逐字节正确**的 pt 撞车(就是上面那个异常);
|
||
// ② zt 批次里存的是**类型 id**,展开成实例 id 只能靠 SpaceObjects 反查(实测抓包 1371/20475,
|
||
// 展开出来 1368/20461),这一步是推测,不该拿它覆盖已知正确的字节。
|
||
// 要实验请显式 `--store-all`。
|
||
// 开关见 index.js 的 --store-all。
|
||
//
|
||
// 「X → 批次身份」表(按空间维度,本对象持有):只有 GQ1wd 接管时才用得上
|
||
// 每发一条含 zt 的帧(回放器发的也算)就更新 X → { SourceFile, RecordIndex, BatchId } —— X 是客户端侧
|
||
// 回调号、会被复用(schema.sql 头注 8),所以身份必须记成「哪一条 zt 记录」,跟 Containers/Dependency* 同一套自然键。
|
||
//
|
||
// 计数口径(每一条上行记录只走一条路,见 Counters()):
|
||
// Hit 查库路径接管并**成功发出**了帧
|
||
// Miss 库可用但这条查不到(缺行/缺字段/没发过 X=n)→ 交给回放
|
||
// Disabled 该上行类型未在接管范围内(默认的 ls.xbl / G.q1wd 走这里)→ 交给回放
|
||
// Unavailable 库不可用(没连上 / 表缺)→ 交给回放(行为与「没有本文件」完全一致)
|
||
// Fallback 已接管但后续查库/组帧/发帧失败 → 显式回放补发
|
||
// FallbackFrames 这些回落补发出来的帧数(不统计 index.js 那条正常回放路径发的帧)
|
||
'use strict';
|
||
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';
|
||
|
||
function MakeResponder(Deps) {
|
||
const D = Deps || {};
|
||
const SpaceId = String(D.SpaceId);
|
||
const Log = typeof D.Log === 'function' ? D.Log : () => {};
|
||
// 严格优先级:命中查库的这条上行**不再交给回放器发帧**(回放器只推进顺序)。
|
||
// 默认关(false):查库应答照发,回放器行为与今天一模一样 —— 现状已知能跑通,不拿它冒险;
|
||
// index.js 的 --store-preempt 打开严格模式。
|
||
const Preempt = !!D.Preempt;
|
||
// 接管范围:默认只开 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: {} };
|
||
|
||
function Bump(Kind, Tag) {
|
||
Count[Kind]++;
|
||
const K = Kind + '/' + Tag;
|
||
Count.ByMethod[K] = (Count.ByMethod[K] || 0) + 1;
|
||
}
|
||
|
||
// 我们(含会话回放器)每发一帧就调一次:把帧里每条 zt 的 X 记下来
|
||
function NoteSentFrame(F) {
|
||
const Records = (F && F.Records) || [];
|
||
for (let I = 0; I < Records.length; I++) {
|
||
const R = Records[I];
|
||
if (R.Layout !== 'zt' || !R.Value || !Array.isArray(R.Value.Batches)) continue;
|
||
const X = Number(R.Value.X);
|
||
XTable.set(String(X), {
|
||
SourceFile: F.File,
|
||
RecordIndex: I,
|
||
BatchId: X,
|
||
ObjectIds: R.Value.Batches.map((B) => String(B.ObjectId)),
|
||
});
|
||
}
|
||
}
|
||
|
||
function ReadI32(Payload) {
|
||
if (!Buffer.isBuffer(Payload) || Payload.length < 4) return null;
|
||
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) {
|
||
const C = Ctx || {};
|
||
const Method = String(R.MethodId);
|
||
const Tag = TAG[Method];
|
||
if (!Tag) return null;
|
||
if (!Store.Available()) {
|
||
Bump('Unavailable', Tag); // 库不可用:整条查库路径静默让位(store.js 只记一行日志)
|
||
return null;
|
||
}
|
||
// 接管范围之外的上行直接让位(默认的 ls.xbl / G.q1wd 走这里,见文件头注)
|
||
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) {
|
||
Bump('Miss', Tag);
|
||
return null;
|
||
}
|
||
return () => {
|
||
Log('→ 查库应答:devicesLoaded(槽位 ' + TargetId + ') ' + Rec.Fields.devices.length + ' 个设备 / mountedDevice=' +
|
||
(Rec.Fields.mountedDevice === null ? 'null' : Rec.Fields.mountedDevice));
|
||
return Emit([Rec], Tag, R, TargetId, C);
|
||
};
|
||
}
|
||
|
||
if (Method === METHOD.LsXbl) {
|
||
// UnloadObjects 是纯函数(id 来自上行靶),不需要查表:库不可用时才是真查不到
|
||
const Rec = Store.UnloadObjects(SpaceId, [String(TargetId)]);
|
||
return () => {
|
||
Log('→ 查库应答:unloadObjects([靶 ' + TargetId + ']) 1 条(与 ls.xbl 1:1)');
|
||
return Emit([Rec], Tag, R, TargetId, C);
|
||
};
|
||
}
|
||
|
||
// G.q1wd
|
||
const N = ReadI32(R.Payload);
|
||
const X = N === null ? null : XTable.get(String(N));
|
||
if (!X) {
|
||
Bump('Miss', Tag);
|
||
Log('→ 查库未命中:G.q1wd(' + (N === null ? '负载不是 4 字节 i32' : N) + ') 没发过这个 X 的 zt → 回落回放');
|
||
return null;
|
||
}
|
||
Log('→ 查库应答:G.q1wd(' + N + ') 命中已发出的 zt 批次(' + X.SourceFile + ' 第 ' + (X.RecordIndex + 1) +
|
||
' 条,' + X.ObjectIds.length + ' 个类型 id)');
|
||
return async () => {
|
||
try {
|
||
const Ids = await Store.BatchInstances(SpaceId, X.SourceFile, X.RecordIndex, X.BatchId);
|
||
if (!Ids) throw new Error('批次的实例 id 展开为空');
|
||
const Rec = await Store.ObjectsData(SpaceId, Ids);
|
||
if (!Rec) throw new Error('ObjectsData 查库失败');
|
||
Log('→ 查库应答:objectsData(' + Rec.Value.Objects.length + ' 个对象 / ' + Rec.Value.Items.length + ' 项模型数据)');
|
||
return Emit([Rec], Tag, R, TargetId, C);
|
||
} catch (Err) {
|
||
Log(' ⚠ 查库路径未成(' + Err.message + ')→ 回落会话回放');
|
||
return Fallback(R, TargetId, C, Tag);
|
||
}
|
||
};
|
||
}
|
||
|
||
// 组帧 + 发帧;失败一律显式回落回放(不发半条帧)
|
||
function Emit(Records, Tag, R, TargetId, C) {
|
||
let Buf;
|
||
try {
|
||
Buf = Store.BuildFrame(Records);
|
||
} catch (Err) {
|
||
Log(' ⚠ 组帧失败(' + Err.message + ')→ 回落会话回放');
|
||
return Fallback(R, TargetId, C, Tag);
|
||
}
|
||
const Ok = typeof C.Sender === 'function' ? C.Sender(Buf) !== false : false;
|
||
if (!Ok) {
|
||
Log(' ⚠ 查库应答没发出去(连接已断)→ 不计命中');
|
||
return false;
|
||
}
|
||
Bump('Hit', Tag);
|
||
if (Preempt && C.Replay && typeof C.Replay.OnUplink === 'function') {
|
||
// 严格模式:这条上行已经由查库应答接管 —— 回放器只推进自己的顺序,不再重复发那条帧
|
||
const Suppressed = C.Replay.OnUplink(
|
||
{ MethodId: R.MethodId, Target: String(TargetId), Name: R.Name },
|
||
C.ReplaySender,
|
||
{ Suppress: true },
|
||
);
|
||
Count.Suppressed++;
|
||
Log(' (严格优先:回放器跳过这条上行对应的帧,只推进顺序)');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function Fallback(R, TargetId, C, Tag) {
|
||
Bump('Fallback', Tag);
|
||
if (!C.Replay || typeof C.Replay.OnUplink !== 'function') return false;
|
||
const Hits = C.Replay.OnUplink(
|
||
{ MethodId: R.MethodId, Target: String(TargetId), Name: R.Name },
|
||
C.ReplaySender,
|
||
);
|
||
if (Hits) {
|
||
Count.FallbackFrames += Hits;
|
||
Log(' → 回落回放:发出 ' + Hits + ' 帧');
|
||
}
|
||
return Hits > 0;
|
||
}
|
||
|
||
function Counters() {
|
||
return {
|
||
Hit: Count.Hit, Miss: Count.Miss, Disabled: Count.Disabled, Unavailable: Count.Unavailable,
|
||
Fallback: Count.Fallback, FallbackFrames: Count.FallbackFrames, Suppressed: Count.Suppressed,
|
||
ByMethod: Object.assign({}, Count.ByMethod), X: XTable.size, Preempt, Scope: Object.assign({}, Scope),
|
||
};
|
||
}
|
||
|
||
return { SpaceId, Preempt, Scope, Prepare, NoteSentFrame, Counters, XTable };
|
||
}
|
||
|
||
module.exports = { MakeResponder, METHOD, TAG };
|