Files
TermiNexus 2299b0996b fix(server): 组帧漏写帧级 nullmap 位,opt 字段改值会产出错位帧(M10 回显的真因)
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。
2026-09-18 13:38:28 +08:00

247 lines
12 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.
// 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 };