Files
TankIC/3DTank/tools/server/responder.js
T
TermiNexus 9f6dcb0eba 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

206 lines
9.7 KiB
JavaScript
Raw 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**:
// 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 };