工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
206 lines
9.7 KiB
JavaScript
206 lines
9.7 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**:
|
||
// BaLcr(默认开) ba.lcr 7806260029063678398 靶 = 槽位对象 id → 一条 devicesLoaded
|
||
// 安全原因:客户端消费它走 srg(按设备 id 覆盖写 + 置 mounted 位),重复下发幂等,不会抛异常。
|
||
// 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
|
||
LsXbl: '2528403217084400809', // ls.xbl
|
||
GQ1wd: '1816792453857564692', // G.q1wd(客户端请求对象数据,负载 i32)
|
||
};
|
||
const TAG = {};
|
||
TAG[METHOD.BaLcr] = 'ba.lcr';
|
||
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(理由见文件头注)
|
||
const Scope = Object.assign({ BaLcr: 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);
|
||
}
|
||
|
||
// 同步判定:返回 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.LsXbl ? 'LsXbl' : 'GQ1wd';
|
||
if (!Scope[ScopeKey]) {
|
||
Bump('Disabled', Tag);
|
||
return null;
|
||
}
|
||
|
||
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 };
|