客户端:Uplink 加 M_.fcn 构造(6 条抓包样本逐字节相同)、仓库每行加「装备」按钮、Equip() 入队 + 本地乐观标记(对齐真实客户端的 jU reducer)。服务端:store.js 加 LoadMountedFlags/SetMounted(UPDATE ... WHERE Mounted<>1,幂等)/OverrideMountedRecord;session.js 加可选 RecordOverride 钩子;index.js 进车库时刷新快照、收 M_.fcn 落库。回显只在库与模板有差异时覆盖并打日志(装备前帧仍与抓包逐字节一致 775441B;装备后 775449B,+8B = 多一个 i64)。验收:浏览器点装备 → 服务端落盘 c2s_0064.bin 与抓包 c2s_0141.bin 逐字节相同;MySQL Items.Mounted=1 共 6 行;重启 9092 后启动日志显示 Items.Mounted=1 共 6 件、重进车库 UI 6 个装备按钮置灰与库一致。回归:selftest 1108/9/90、CompareContainerDecode 5 帧 0 不一致、三构建全绿。未实现并如实记录:类别互斥(物品类别来源未解出,不硬猜)、挂载位设备 mountedDevice 未处理。
198 lines
10 KiB
JavaScript
198 lines
10 KiB
JavaScript
// session.js - 会话重放应答器:把真实官方服会话里「上行 → 下行」的对应关系搬到我们服务器上
|
||
//
|
||
// 为什么要有它:车库空间(100073070)在真实服里是**一问一答**的——
|
||
// 模板 names/space-init-<id>.json 只装得下"进空间时服务器主动推的那几帧"(车库 5 帧),
|
||
// 而客户端进车库后发的 G.q1wd / ba.lcr / M_.hcn / ls.xbl / M_.fcn / Qh.ycw 换来的 s2c
|
||
// (对象数据、资源、容器增量)原来一条都没回,于是武器列表/切换全都不动。
|
||
// 本模块读的是 names/space-session-<id>.json(由 tools/extract_space_session.js 从真实抓包抽出):
|
||
// 每条 s2c 帧带一个 After = 「换它出来的上行清单」({MethodId, Target})。
|
||
//
|
||
// 约定(与产物文件一一对应):
|
||
// After 为空 → 纯推送帧(真实服在客户端进空间后无人请求就推的那几帧);
|
||
// DelayMs=0 的进空间立刻发,DelayMs>0 的按真实时延定时发
|
||
// (实测车库那 2 帧:s2c_0000 立刻,s2c_0040 在 +8.7s)
|
||
// After 非空 → 等客户端发到清单里的上行时发(同一帧只发一次)
|
||
// 命中判定 → MethodId 相同,且(After 项没有 Target 或 Target 相同)
|
||
//
|
||
// 顺序推进(关键,别再改回「任一命中即发」):
|
||
// 真实服是一问一答、且**成批**——s2c_0003 要等 28 条 ba.lcr 齐了才发一条含 28 条
|
||
// LoadDependencies 的帧,s2c_0004 要等 28 条 G.q1wd 齐了才发。
|
||
// 所以每次上行**只推进会话顺序里最靠前的那个「还没发的被触发帧」**,一帧一上行。
|
||
// 早期实现用「任一 After 项命中就发」:因为会话里 30 多帧的 After 都含 `G.q1wd @100073070`,
|
||
// 客户端第一个 G.q1wd(1) 就把 21 帧全扑了出去(server-logs/session-02/session_007/log.txt:59),
|
||
// 客户端拿到乱序数据当场抛错重载,第二轮起车库再也进不去 —— 务必保持顺序推进。
|
||
// Frame.Hex → 形态表达不了(Short 命令号 / 解不出 objectId)时整帧原样的兜底:
|
||
// 直接按帧体重组,一个字节都不改(extract_space_session.js 里写明原因)
|
||
//
|
||
// 发送走的是现成的组帧路径(Space.BuildInitFrames → space.js → Link.EncodeFrame),
|
||
// 与 space-init-*.json 那条路完全同源,所以「模板里存的记录能逐字节重建原帧」这条保证直接继承过来。
|
||
//
|
||
// 用法:
|
||
// const Session = require('./session');
|
||
// const Doc = Session.Load(namesDir, spaceId); // 没有产物文件 → null
|
||
// const Replay = Session.MakeReplay(Doc.Frames, { Container, Semantic, Log });
|
||
// Replay.OnEnter(SendFrames); // 进空间时
|
||
// const Hits = Replay.OnUplink({ MethodId, Target }, SendFrames); // 每条上行都问一次;返回本次发出的帧数
|
||
// const Hits2 = Replay.OnUplink({ MethodId, Target }, SendFrames, { Suppress: true });
|
||
// // 该上行已由查库定向应答接管 → 命中的帧只标记已发、不发字节
|
||
// SendFrames(Frame, Bytes) 返回 true 表示**真的发出去了**(连接已断就返回 false,这时不标记已发)
|
||
'use strict';
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const Link = require('./link');
|
||
const Space = require('./space');
|
||
const SemanticModule = require('./semantic');
|
||
|
||
const NAMES_DIR = path.resolve(__dirname, '..', '..', 'names');
|
||
|
||
// 卡住看门狗阈值:连续这么多条上行都匹配不上「会话顺序里最靠前的待触发帧」就跳过它并大声记日志。
|
||
// 取值偏大(我们的客户端与抓包那份是同一个构建,正常不该触发),只是为了不把整条会话卡死。
|
||
const STALL_LIMIT = 40;
|
||
|
||
// 读 names/space-session-<SpaceId>.json;不存在返回 null(调用方回落到 space-init-<id>.json)
|
||
function Load(NamesDir, SpaceId) {
|
||
const File = path.join(NamesDir || NAMES_DIR, 'space-session-' + SpaceId + '.json');
|
||
if (!fs.existsSync(File)) return null;
|
||
return JSON.parse(fs.readFileSync(File, 'utf8'));
|
||
}
|
||
|
||
// After 清单里有没有命中这条上行记录
|
||
function Match(After, Record) {
|
||
for (const A of After || []) {
|
||
if (String(A.MethodId) !== String(Record.MethodId)) continue;
|
||
if (A.Target === undefined || A.Target === null || A.Target === '') return true;
|
||
if (String(A.Target) === String(Record.Target)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function MakeReplay(Frames, Deps) {
|
||
const Opts = Deps || {};
|
||
const Log = typeof Opts.Log === 'function' ? Opts.Log : () => {};
|
||
// 没给 Container / Semantic 时自己加载一份(用法上 index.js 会传现成的实例,避免重复读表)
|
||
const Semantic = Opts.Semantic || SemanticModule.Load(NAMES_DIR);
|
||
const Container = Opts.Container || Semantic.Container;
|
||
// 可选的「字段级覆盖」钩子(M7 回显用):(FrameSpec, Records) → Records。
|
||
// 只在帧是结构化形态(有 Records、非 Hex 透传)时调用;返回的新数组会替换原记录再组帧。
|
||
// 返回原数组(或 null)⇒ 一字不改,帧字节与抓包保持逐字节一致(对照验收因此不受污染)。
|
||
const RecordOverride = typeof Opts.RecordOverride === 'function' ? Opts.RecordOverride : null;
|
||
const All = Frames || [];
|
||
const Sent = new Set();
|
||
const Waited = new Set();
|
||
const Timers = [];
|
||
const BytesByFile = new Map();
|
||
const State = { Stall: 0 };
|
||
|
||
// 纯推送帧 = After 为空的帧(真实服没人问就推)
|
||
function IsPush(F) {
|
||
return !(F.After || []).length;
|
||
}
|
||
|
||
// 帧字节按需构建(车库那两帧是 58KB / 775KB,进空间时没必要全编一遍)
|
||
function BytesOf(F) {
|
||
if (BytesByFile.has(F.File)) return BytesByFile.get(F.File);
|
||
let Buf;
|
||
if (F.Hex !== undefined) {
|
||
// 形态表达不了的整帧原样兜底:帧体 → [nullmap][payload] 交给 Link.EncodeFrame(长度头由它补)
|
||
const Body = Buffer.from(String(F.Hex), 'hex');
|
||
const NmSize = Link.NullmapSize(Body, 0);
|
||
Buf = Link.EncodeFrame(Body.slice(NmSize), Body.slice(0, NmSize));
|
||
} else {
|
||
// 字段级覆盖(可选):只在结构化帧上生效;没差异时 RecordOverride 会返回原数组
|
||
let Spec = F;
|
||
if (RecordOverride && Array.isArray(F.Records)) {
|
||
const Out = RecordOverride(F, F.Records);
|
||
if (Out && Array.isArray(Out) && Out !== F.Records) Spec = Object.assign({}, F, { Records: Out });
|
||
}
|
||
Buf = Space.BuildInitFrames({ Frames: [Spec] }, Container, Semantic)[0].Frame;
|
||
}
|
||
BytesByFile.set(F.File, Buf);
|
||
return Buf;
|
||
}
|
||
|
||
function Send(F, SendFrames, Why) {
|
||
const Buf = BytesOf(F);
|
||
Log(' ← 重放:发 ' + F.File + ' ' + Buf.length + 'B(' + Why + ')');
|
||
const Ok = SendFrames ? SendFrames(F, Buf) !== false : true;
|
||
if (Ok) Sent.add(F.File);
|
||
else Log(' ⚠ 重放 ' + F.File + ' 没真正发出(连接已断),不标记已发');
|
||
return Ok;
|
||
}
|
||
|
||
// 进空间时:只有纯推送帧走这条(DelayMs=0 立刻发,>0 的按真实时延定时发)
|
||
function OnEnter(SendFrames) {
|
||
let Count = 0;
|
||
for (const F of All) {
|
||
if (Sent.has(F.File)) continue;
|
||
if (!IsPush(F)) continue;
|
||
const Delay = Number(F.DelayMs) || 0;
|
||
if (Delay <= 0) {
|
||
if (Send(F, SendFrames, '纯推送帧,进空间即发')) Count++;
|
||
continue;
|
||
}
|
||
const T = setTimeout(() => Send(F, SendFrames, '纯推送帧,按真实时延 +' + Delay + 'ms'), Delay);
|
||
if (T && typeof T.unref === 'function') T.unref();
|
||
Timers.push(T);
|
||
}
|
||
return Count;
|
||
}
|
||
|
||
// 会话顺序里最靠前的「还没发的、需要上行触发」的帧;跳过错过的纯推送帧
|
||
function NextGated() {
|
||
for (const F of All) {
|
||
if (Sent.has(F.File)) continue;
|
||
if (IsPush(F)) continue;
|
||
return F;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// After 清单的可读摘要(只用于日志:让人一眼看出这帧在等什么)
|
||
function Describe(After) {
|
||
const Count = new Map();
|
||
for (const A of After || []) {
|
||
const Key = String(A.MethodId) + (A.Target === undefined || A.Target === null || A.Target === '' ? '' : '@' + A.Target);
|
||
Count.set(Key, (Count.get(Key) || 0) + 1);
|
||
}
|
||
return Array.from(Count.entries()).map(([K, N]) => K + (N > 1 ? '×' + N : '')).join(' + ');
|
||
}
|
||
|
||
// 收到一条上行记录:只推进一帧(会话顺序里最靠前的那个待触发帧),返回本次发出的帧数
|
||
// Options.Suppress = true(可选,查库定向应答用):这条上行已经被查库路径接管,
|
||
// 命中的那帧**只标记已发、不发字节**——顺序照旧推进,避免后面所有待触发帧被堵住(看门狗要 40 条才跳)。
|
||
function OnUplink(Record, SendFrames, Options) {
|
||
const F = NextGated();
|
||
if (!F) return 0;
|
||
if (Match(F.After, Record)) {
|
||
State.Stall = 0;
|
||
if (Options && Options.Suppress) {
|
||
Sent.add(F.File);
|
||
Log(' (' + F.File + ' 本由 ' + Record.MethodId + ' 触发,但该上行已由查库定向应答接管 → 跳过重放)');
|
||
return 0;
|
||
}
|
||
const Why = '被 ' + Record.MethodId + ' 触发' + (Record.Name ? ',' + Record.Name : '');
|
||
return Send(F, SendFrames, Why) ? 1 : 0;
|
||
}
|
||
State.Stall++;
|
||
// 每条待触发帧只提醒一次,避免刷屏
|
||
if (!Waited.has(F.File)) {
|
||
Waited.add(F.File);
|
||
Log(' ⚠ 重放等待:' + F.File + ' 需要 [' + Describe(F.After) + '],本条上行是 ' +
|
||
Record.MethodId + (Record.Target === undefined ? '' : '@' + Record.Target));
|
||
}
|
||
// 卡住看门狗:会话顺序本该与我们的客户端一致;连续多次对不上说明抓包与本次客户端行为不一致,
|
||
// 这时**大声**跳过这一帧继续(不静默兜底——跳过意味着这一步的下行缺失,必须能在日志里看见)
|
||
if (State.Stall >= STALL_LIMIT) {
|
||
Log(' ⚠ 重放卡住:' + F.File + ' 连续 ' + State.Stall + ' 条上行都没等到 [' +
|
||
Describe(F.After) + '],跳过它继续(该帧的下行缺失)');
|
||
Sent.add(F.File);
|
||
State.Stall = 0;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
return { OnEnter, OnUplink };
|
||
}
|
||
|
||
module.exports = { Load, MakeReplay, NAMES_DIR };
|