Files
TankIC/3DTank/tools/server/index.js
T
TermiNexus b6f2cb71ee 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

565 lines
30 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.
// index.js - 真·替代服务器(M1 骨架):自己握手、自己管空间、把客户端的诉求照实打出来
//
// 用法: node server/index.js [--port 9091] [--log ../server-logs] [--label 名字] [--keepalive 10000] [--store-preempt]
// 客户端挂载: local/config.xml → <server address="localhost" wsProtocol="WS"><port>9091</port>
//
// M1 已实现(入口层全量):
// Hash Request → 回 Hash Response(随机 hash + 明文)与 Open Space(入口空间)
// Auth/EnterSpace → 建/取空间实例,记 spaceId 与类别
// ClientLog → 解出类型/名称/正文(正文常含 UI 事件 JSON,按钮点击的来源)
// 保活 ping(负载为空的帧)
// M1 刻意留空(下一步 M2):
// 空间层的语义 —— 收到空间命令只做「识别 + 解码 + 记未实现」,一个字节都不伪造。
//
// 空间层的应答(本文件的下半部分,优先级自上而下):
// ① 查库定向应答(responder.js + store.js):ba.lcr / ls.xbl / G.q1wd 三条上行按 MySQL 里的数据
// 自己组帧发出去(库里没数据就返回 null,让位给 ②);每次命中都打一行「→ 查库应答:…」日志。
// ② 会话回放(session.js):真实服大部分 s2c 是「答某条上行」的,会话文件里记着 After 清单。
// ③ 都没命中 ⇒ dispatch 内部把这条命令记进未实现清单。
// --store-preempt:严格优先(命中查库后不再让回放器发那条帧);默认非严格,回放行为与升级前一致。
//
// 会话重放(本文件里的 SpaceReplays / session.js):真实服的空间层是「一问一答」,
// names/space-init-<id>.json 只装得下"进空间时主动推的那几帧"(车库 5 帧 / 会话里 39 帧),
// 所以有 names/space-session-<id>.json 的空间改用它:After 为空的帧进空间即发,
// 其余等客户端发到"换它出来的那条上行"时补发(同一帧只发一次),命中后不再计入未实现。
'use strict';
const fs = require('fs');
const http = require('http');
const path = require('path');
const { LoadTables, MakeReader } = require('../lib_space_codec');
const Link = require('./link');
const Entry = require('./entry');
const { MakeWriter } = require('./codec');
const { MakeContainerCodec } = require('./container');
const Space = require('./space');
const SemanticModule = require('./semantic');
const SessionReplay = require('./session');
const ModelService = require('./modelservice');
const { MakeDispatcher } = require('./dispatch');
const { MakeWorld, ENTRANCE_SPACE, SPACE_KIND } = require('./world');
const Store = require('./store');
const StoreResponder = require('./responder');
const Db = require('./db');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PROJECT = path.resolve(__dirname, '..', '..');
const PORT = Number(Arg('port', '9091')) || 9091;
const KeepaliveMs = Number(Arg('keepalive', '10000')) || 0;
const LABEL = Arg('label', new Date().toISOString().replace(/[:.]/g, '-'));
const LOG_DIR = path.resolve(Arg('log', path.join(PROJECT, 'server-logs', LABEL)));
// 严格优先模式(--store-preempt):命中查库定向应答的上行不再交给会话回放器发帧(回放器只推进顺序)。
// 默认关 —— 查库应答照发,回放器一字不改(现状已知能跑通,不拿它冒险)。
const STORE_PREEMPT = Argv.indexOf('--store-preempt') >= 0;
// 查库接管范围:默认**只接管 ba.lcr**(理由见 responder.js 头注:模板/回放里已经播发过的东西不能重复发,
// 否则客户端 nap() 会抛 "Model already subscribed!")。--store-all 才把 ls.xbl / G.q1wd 一起打开,仅供实验。
const STORE_ALL = Argv.indexOf('--store-all') >= 0;
const STORE_SCOPE = STORE_ALL
? { BaLcr: true, LsXbl: true, GQ1wd: true }
: { BaLcr: true, LsXbl: false, GQ1wd: false };
// ws 模块:优先本项目解析,回落 sockets/(唯一装过 ws 的地方)
function RequireWs() {
try {
return require('ws');
} catch (Err) {
return require(path.resolve(PROJECT, '..', 'sockets', 'node_modules', 'ws'));
}
}
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Reader = MakeReader(Tables, { EnumBytes: 4 });
const Writer = MakeWriter(Tables, { EnumBytes: 4 });
const Container = MakeContainerCodec(Tables, Reader, Writer);
// 语义层:模板里的「字段 JSON」记录(space-init-*.json 的 Fields 形态)由它现编成字节
const Semantic = SemanticModule.Load(path.join(PROJECT, 'names'));
const World = MakeWorld();
// 派生命名词典(不是客户端原始名):让日志里出现 LoginCommand.SendLogin 这类可读标注
let Alias = null;
try {
Alias = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'command_alias.json'), 'utf8'));
} catch (Err) {
Alias = null;
}
const Dispatch = MakeDispatcher(Tables, Reader, World, { Alias });
// ============================================================
// 会话与落盘(沿用 relay 的产物形状:log.txt + c2s_XXXX.bin / s2c_XXXX.bin)
// ============================================================
fs.mkdirSync(LOG_DIR, { recursive: true });
function NewSession() {
const Used = fs
.readdirSync(LOG_DIR, { withFileTypes: true })
.filter((D) => D.isDirectory() && D.name.startsWith('session_'))
.map((D) => Number(D.name.slice('session_'.length)))
.filter((N) => !Number.isNaN(N));
const Next = (Used.length ? Math.max.apply(null, Used) : 0) + 1;
const Dir = path.join(LOG_DIR, 'session_' + String(Next).padStart(3, '0'));
fs.mkdirSync(Dir, { recursive: true });
const State = { Dir, Stream: fs.createWriteStream(path.join(Dir, 'log.txt'), { flags: 'w' }), C2s: 0, S2c: 0 };
State.Log = (Msg) => {
const Line = '[' + new Date().toISOString() + '] ' + Msg;
console.log(Line);
State.Stream.write(Line + '\n');
};
return State;
}
function SaveFrame(State, Direction, Buf) {
const Seq = Direction === 'c2s' ? State.C2s++ : State.S2c++;
fs.writeFileSync(path.join(State.Dir, Direction + '_' + String(Seq).padStart(4, '0') + '.bin'), Buf);
State.Log(
(Direction === 'c2s' ? '>>>' : '<<<') + ' ' + Direction + '_' + String(Seq).padStart(4, '0') + '.bin ' + Buf.length + ' 字节',
);
}
function Send(State, Client, Buf) {
SaveFrame(State, 's2c', Buf);
Client.send(Buf);
}
// ============================================================
// 收帧
// ============================================================
function HandleHashRequest(State, Client, Conn, Body, At) {
const H = Entry.ParseHashRequest(Body, At);
State.Log(' Hash Request 键 ' + H.Count + ' 条 / 值 ' + H.CountValues + ' 条' + (H.Ok ? '' : '(没吃满帧体,可能还有别的段)'));
for (let I = 0; I < H.Keys.length; I++) {
State.Log(' ' + String(H.Keys[I]).padEnd(24) + ' = ' + JSON.stringify(H.Values[I] === undefined ? '<缺>' : H.Values[I]));
}
const Hash = Entry.MakeHash();
Conn.Hash = Hash;
// 新的 Hash Request = 新的客户端会话。同机重连时远端地址(::1)与上一轮相同,
// 必须重置「已宣告空间」集合,否则第二轮起会一直判「已宣告过,跳过」,客户端一个空间都收不到。
// 实测(server-logs/loginframes-01/session_012:54,73):920007382011 / 100119080 都被跳过。
AnnouncedByPeer.set(Conn.Peer, new Set([ENTRANCE_SPACE])); // 入口空间已在握手帧里宣告,之后别再重复宣告(客户端会销毁重建)
EntryConns.set(Client, State); // 这条就是控制通道:入口层命令(Open space)只从它下发
// 真实服务器把 Hash Response 与第一条 Open Space 放在同一帧里(实测 46B)
const Reply = Buffer.concat([Entry.BuildHashResponse(Hash, true), Entry.BuildOpenSpace(ENTRANCE_SPACE)]);
Send(State, Client, Link.EncodeFrame(Reply));
State.Log(' → Hash Response(hash=' + Hash.toString('hex').slice(0, 16) + '…, 明文) + Open Space(' + ENTRANCE_SPACE + ')');
}
function HandleEnterSpace(State, Client, Conn, Body, At) {
const E = Entry.ParseEnterSpace(Body, At);
if (!E) {
State.Log(' ⚠ Auth/EnterSpace 负载不足 40B:' + Body.slice(At).toString('hex').slice(0, 96));
return;
}
Conn.Space = World.EnsureSpace(E.SpaceId);
SpaceConns.set(E.SpaceId, { Client, State }); // 之后该空间的 s2c(含登录态补帧)都走这条连接
State.Log(' 空间鉴权: digest=' + E.Digest.slice(0, 16) + '… spaceId=' + E.SpaceId + ' 类别=' + Conn.Space.Kind);
// 查库定向应答器(按空间维度):ba.lcr / ls.xbl / G.q1wd 的应答与「X → 已发出的 zt 批次」表都挂在它身上。
// 与回放器同级、无条件建;库不可用时它每条上行都返回「让位」(store.js 只记一行日志)。
const Responder = StoreResponder.MakeResponder({
SpaceId: E.SpaceId,
Preempt: STORE_PREEMPT,
Scope: STORE_SCOPE,
Log: (M) => State.Log(' ' + M),
});
SpaceResponders.set(String(E.SpaceId), Responder);
if (!Store.Available()) State.Log(' (查库定向应答不可用:本条空间的应答全部走会话回放)');
// 会话回放模板优先。为什么:真实服的绝大部分 s2c 不是"进空间就推",而是**答客户端的那条上行**
//(车库实测:space-init-100073070.json 只装得下前 5 帧,另外 34 帧全靠 G.q1wd / ba.lcr / M_.hcn /
// ls.xbl / M_.fcn / Qh.ycw 换出来;只发初始化帧 ⇒ 客户端发问却零应答,武器列表一动不动)。
// space-init-*.json 模板本身保留不动(本分支只是"有会话文件时优先用它")。
const Session = SessionReplay.Load(path.join(PROJECT, 'names'), E.SpaceId);
if (Session) {
const Replay = SessionReplay.MakeReplay(Session.Frames, { Container, Semantic, Log: (M) => State.Log(M) });
SpaceReplays.set(String(E.SpaceId), Replay);
const Push = Session.Frames.filter((F) => !(F.After || []).length).length;
State.Log(' 会话回放: names/space-session-' + E.SpaceId + '.json 帧 ' + Session.Frames.length +
'(纯推送 ' + Push + ' 帧,其余 ' + (Session.Frames.length - Push) + ' 帧等客户端上行触发)');
// 发帧走现成路径:session.js 里 Space.BuildInitFrames → space.js → Link.EncodeFrame
const Sent = Replay.OnEnter(MakeReplaySender(State, Client, Responder));
State.Log(' → 纯推送帧已发 ' + Sent + ' 条');
return;
}
// 认证后必须下发该空间的初始化数据,否则客户端静默等数据(实测:一字节上行都不会发)
const Template = Space.LoadInitTemplate(path.join(PROJECT, 'names'), E.SpaceId);
if (!Template) {
State.Log(' ⚠ 没有 names/space-init-' + E.SpaceId + '.json:客户端会停在等数据的状态');
return;
}
const Frames = Space.BuildInitFrames(Template, Container, Semantic);
const Tags = new Map();
for (const T of Template.Frames || []) {
Tags.set(T.File, (T.Records || []).map((R) => ModelService.Name(R.MethodId) || '#' + R.MethodId).join(' + '));
}
State.Log(' → 下发初始化 ' + Frames.length + ' 帧(container.js/space.js 现场编码,非原始字节)');
for (const F of Frames) {
const Tpl = (Template.Frames || []).find((X) => X.File === F.File);
const Tag = Tags.get(F.File) || '';
const Deliver = () => {
if (Client.readyState !== Ws.OPEN) return;
if (Tpl) Responder.NoteSentFrame(Tpl); // 帧里的 zt 要记进 X → 批次身份表
State.Log(' ← ' + F.File + ' ' + F.Frame.length + 'B @+' + F.DelayMs + 'ms' + (Tag ? ' [' + Tag + ']' : ''));
Send(State, Client, F.Frame);
};
if (F.DelayMs > 0) setTimeout(Deliver, F.DelayMs);
else Deliver();
}
}
// ============================================================
// 按钮驱动的空间下发
// ============================================================
// 真实服是按客户端上报的 button_click 才回 Open Space,不是自己乱推。
// 实测(旧 wss_server.js 注释引用的 garage-01/session_001):button_click "GARAGE" → Open Space(100073070)。
// 客户端上报格式见 ClientLog 正文:{"screen":"Lobby.Home","button":"GARAGE"}
const CLICK_SPACES = { GARAGE: 100073070 };
// 登录后下发的空间列表。实测(packets/relay-logout/session_007):真实服在客户端 log_in 前后
// 按秒级节奏推这几个空间,客户端随后就切到 Lobby.Home。
// 顺序与相对时刻:920007382011 +0s、100119080 +2s、100072911 +4s、100045720 +4s(后两个同帧一条)。
const LOGIN_SPACES = [
{ Space: 920007382011, DelayMs: 0 },
{ Space: 100119080, DelayMs: 2000 },
{ Space: 100072911, DelayMs: 4000 },
{ Space: 100045720, DelayMs: 4000 },
];
// 用户行为命令 dn.gdk(src/module_25377.js:1516-1526):6 个字符串参数 = (eventName, menuItemName, …)
// methodId 由源码 `new Q(69409237, 587263982)` 按 value = B<<32|A 算得。
// 客户端在进入/离开大厅菜单屏时发它(src/module_18334.js:3895-3919 → uaw().gdk(...)),
// 菜单项名就是 "GARAGE" 这类值——比 button_click 更可靠的"打开了某屏"信号。
const USER_ACTIVITY_METHOD = '2522279596878141909';
// 入口层命令(Hash Response / Open space)只能走「控制通道」——即发过 Hash Request 的那条连接。
// 实测(packets/relay-logout/session_007):真实服的 4 条 Open space 都发在控制通道上;
// 空间连接上只跑空间命令帧([nullmap][spaceId][(methodId)(payload)])。
// 教训:曾经把空间列表推在登录命令所在的空间连接上,客户端按空间帧去解 → BufferUnderflowException
// (抛点 src/module_72834.js:224,ByteBuffer 读越界)。
const EntryConns = new Map(); // Client → State(控制通道)
const SpaceConns = new Map(); // spaceId → { Client, State }(该空间的空间连接)
const AnnouncedByPeer = new Map(); // 远端地址 → 已宣告空间集合(同一个客户端的多条连接共享)
const SpaceReplays = new Map(); // spaceId → 会话重放器(names/space-session-<id>.json,session.js)
const SpaceResponders = new Map(); // spaceId → 查库定向应答器(responder.js;回放之外的「查库自己组帧」那条路)
// 会话重放器的发帧出口:s2c 走**收到那条上行的连接**(客户端就在这条连接上等应答)。
// 返回 true = 真的发出去了;连接已断返回 false,重放器据此不把该帧标记成"已发"。
// 顺带把发出去的 zt(回放器发的也算)记进查库应答器的 X → 批次身份表(G.q1wd 靠它做定向应答)。
function MakeReplaySender(State, Client, Responder) {
return (F, Buf) => {
if (Client.readyState !== Ws.OPEN) return false;
if (Responder) Responder.NoteSentFrame(F);
Send(State, Client, Buf);
return true;
};
}
// 登录态补帧:真实服在客户端登录成功后,在入口空间上还会额外下发几帧
// (实测 packets/relay-logout/session_008:17B / 88B(含账号) / 17B / 17B / 106B(长 token),
// 原始相对时刻 +0 / +9.5s / +14s / +19.5s / +26s —— 那其实是应答客户端那几条入口命令的节奏)。
// 这些帧是**空间命令**,必须走该空间的「空间连接」,不能走控制通道(否则就是 BufferUnderflow 那个坑)。
const LOGIN_FRAME_GAP_MAX = 1500;
function SendLoginStateFrames(State, SpaceId) {
const Template = Space.LoadInitTemplate(path.join(PROJECT, 'names'), 'login-' + SpaceId);
if (!Template) {
State.Log(' (没有 space-init-login-' + SpaceId + '.json,跳过登录态补帧)');
return;
}
const Target = SpaceConns.get(String(SpaceId));
if (!Target || Target.Client.readyState !== Ws.OPEN) {
State.Log(' ⚠ 找不到空间 ' + SpaceId + ' 的空间连接,登录态补帧发不出去');
return;
}
const Frames = Space.BuildInitFrames(Template, Container, Semantic);
State.Log(' 登录态补帧 ' + Frames.length + ' 帧 → 空间 ' + SpaceId + ' 的连接');
for (const F of Frames) {
const At = Math.min(F.DelayMs, LOGIN_FRAME_GAP_MAX); // 原始间隔是"应答节奏",这里压到 1.5s 内尽快给全
setTimeout(() => {
if (Target.Client.readyState !== Ws.OPEN) return;
Target.State.Log(' ← 登录态 ' + F.File + ' ' + F.Frame.length + 'B @+' + At + 'ms');
Send(Target.State, Target.Client, F.Frame);
}, At);
}
}
function PeerSet(Peer) {
let S = AnnouncedByPeer.get(Peer);
if (!S) {
S = new Set();
AnnouncedByPeer.set(Peer, S);
}
return S;
}
function AnnounceSpace(State, Peer, Space, Reason) {
const Set2 = PeerSet(Peer);
if (Set2.has(Space)) {
State.Log(' 空间 ' + Space + ' 已宣告过,跳过(重复宣告会让客户端销毁重建)');
return;
}
Set2.add(Space);
const Targets = [...EntryConns.entries()].filter(([C]) => C.readyState === Ws.OPEN);
if (!Targets.length) {
State.Log(' ⚠ 没有活着的控制通道,Open Space(' + Space + ') 发不出去');
return;
}
for (const [C, S] of Targets) {
S.Log(' ' + Reason + ' → 下发 Open Space(' + Space + ')');
Send(S, C, Link.EncodeFrame(Entry.BuildOpenSpace(Space)));
}
}
// 登录命令 Bn.wh3(src/module_10283.js:2315,字段 = 账号 string / 密码 string / rememberMe bool)。
// methodId 由源码 `new R(-1924305606, 25286687)` 算得。
// 源码结论(module_10283 + module_18334):登录命令**发完不等应答**(无 future、无超时),
// 账号密码登录模型 `Dn` 只有三个分支且全是错误/2FA(语义见下),**没有"登录成功"命令**——
// 客户端的"成功了"是靠服务端推进空间/对象(新空间对象让 UI store 反应)实现的。
// 所以我们收到登录命令就直接推空间列表。
const LOGIN_METHOD = '108605496059850042';
// 登录命令 Hn.wh4 = SendLoginViaHash(src/module_10283.js:2368,字段 = 64 字符 token)。
// 真客户端很多时候**不走账号密码**,而是用 remember-me/localStorage 里的 token 直接发这条
// (回填别名词典后 src_named/module_18334.js:5608 的调用点可读 / 改名前的 src/module_18334.js 同位置)。
// 实测(server-logs/alias-01/session_002):客户端只发 wh4,我们当时没有这个分支 → 零响应,
// 客户端等空间列表等到 3 小时后断开。真实服对 wh4 的反应与 wh3 完全一致:
// 0.45s 后就推第 1 个 Open Space(packets/relay-logout/session_008:79 的 wh4 @12:49:58.718
// → session_007:44 的 Open Space(920007382011) @12:49:59.192)。
const LOGIN_VIA_HASH_METHOD = '7981625407112679465';
// `Dn` 的三个 s2c 分支(负载为空),备查;目前不发:
// 3777380964115009138 需要 2FA(客户端跳 Entrance.Login2FA)
// 6941935892500153516 2FA 码错误
// 6160917066910786241 密码错误(登录框显红)
function PushLoginSpaces(State, Conn, Reason) {
State.Log(' ' + Reason + ' → 推送空间列表 ' + LOGIN_SPACES.map((X) => X.Space).join(', '));
for (const X of LOGIN_SPACES) {
const Deliver = () => AnnounceSpace(State, Conn.Peer, X.Space, '空间列表 +' + X.DelayMs + 'ms');
if (X.DelayMs > 0) setTimeout(Deliver, X.DelayMs);
else Deliver();
}
}
// 上行记录里找登录命令 / 用户行为命令 → 触发空间下发
function HandleUplinkRecords(State, Conn, Info) {
for (const R of Info.Records || []) {
if (R.MethodId === LOGIN_METHOD || R.MethodId === LOGIN_VIA_HASH_METHOD) {
const Account = R.MethodId === LOGIN_METHOD && R.Values[0] ? R.Values[0][2] : '?';
const Who = R.MethodId === LOGIN_METHOD ? 'Bn.wh3(账号密码)' : 'Hn.wh4(hash token)';
State.Log(' 登录请求 ' + Who + ': 首字段=' + JSON.stringify(Account) + '(源码里没有"登录成功"应答,靠推空间推进)');
PushLoginSpaces(State, Conn, '收到登录命令 ' + Who);
SendLoginStateFrames(State, Info.TargetId); // 入口空间上的登录态补帧(真实服登录后会补发几帧)
continue;
}
if (R.MethodId !== USER_ACTIVITY_METHOD) continue;
const Menu = R.Values[1] ? R.Values[1][2] : null;
State.Log(' 用户行为: 菜单项 = ' + JSON.stringify(Menu));
const Space = CLICK_SPACES[Menu];
if (Space) AnnounceSpace(State, Conn.Peer, Space, '用户行为 ' + Menu);
}
}
function HandleLogIn(State, Conn, Log) {
if (Log.Name !== 'log_in') return;
PushLoginSpaces(State, Conn, '客户端 log_in');
}
function HandleButtonClick(State, Conn, Log) {
if (Log.Name !== 'button_click') return;
let Event = null;
try {
Event = JSON.parse(Log.Text);
} catch (Err) {
return;
}
const Space = CLICK_SPACES[Event.button];
if (!Space) return;
AnnounceSpace(State, Conn.Peer, Space, '按钮 ' + Event.button + '(屏幕 ' + Event.screen + ')');
}
function HandleFrame(State, Client, Conn, Frame) {
if (Link.IsPingFrame(Frame)) {
State.Log(' 保活 ping');
return;
}
const Body = Link.DecodeFrame(Frame);
const At = Link.NullmapSize(Body, 0);
if (At >= Body.length) {
State.Log(' 空负载帧');
return;
}
if (Dispatch.IsSpaceFrame(Body, At)) {
// 空间层的应答优先级:**查库定向应答 → 会话回放 → 记未实现**。
// ① 查库定向应答(responder.js):ba.lcr / ls.xbl / G.q1wd 三条上行,库里有数据就自己组帧发出去。
// 同步判定「能不能答」(小表在内存里;G.q1wd 看的是「之前发过 X=n 的 zt」这张表),
// 真正的查库/组帧在 thunk 里异步做;失败一律显式回落回放。
// ② 会话回放(session.js):回放器先问它这条上行换来哪几帧 s2c。命中并发出去了 ⇒ 不该再记进未实现清单
// (钩子在 dispatch 里,因为 NoteUnimplemented 就是在 Dispatch.Handle 内部记的)。
// ③ 都没命中 ⇒ dispatch 内部照旧记未实现。
const SpaceId = Conn.Space ? String(Conn.Space.Id) : '';
const Replay = SpaceReplays.get(SpaceId);
const Responder = SpaceResponders.get(SpaceId);
const Sender = MakeReplaySender(State, Client, Responder); // 回放器的发帧出口
const StoreSender = (Buf) => {
if (Client.readyState !== Ws.OPEN) return false;
Send(State, Client, Buf); // 查库应答的帧也照常落盘 + 打日志(Send 里会写 s2c_XXXX.bin)
return true;
};
const Pending = [];
const OnRecord = (R, TargetId) => {
const Ctx = { Sender: StoreSender, Replay, ReplaySender: Sender };
const Task = Responder ? Responder.Prepare(R, TargetId, Ctx) : null;
if (Task) {
// 查库应答先发;非严格模式下再让回放器补发它那条帧(顺序:查库 → 回放)。
// 之所以把回放放进同一个 thunk 里排队:Dispatch.Handle 是同步的,不等它就会让回放帧抢在查库帧前面。
const Follow = !(Responder && Responder.Preempt) && Replay ? Replay : null;
const Record = { MethodId: R.MethodId, Target: String(TargetId), Name: R.Name };
Pending.push(async () => {
await Task();
if (!Follow) return;
const Hits = Follow.OnUplink(Record, Sender);
if (Hits) State.Log(' → 会话回放同时命中:发出 ' + Hits + ' 帧');
});
return true; // 这条上行已由查库路径接管,不再计入未实现
}
if (!Replay) return false;
const Hits = Replay.OnUplink({ MethodId: R.MethodId, Target: String(TargetId), Name: R.Name }, Sender);
if (Hits) State.Log(' → 会话重放命中:发出 ' + Hits + ' 帧(该上行不再计入未实现)');
return Hits > 0;
};
const Info = Dispatch.Handle(State, Body, At, Conn.Space ? Conn.Space.Kind : '未进空间', OnRecord);
HandleUplinkRecords(State, Conn, Info);
// 异步的查库应答(失败自己回落回放):按记录顺序排队,保证「查库应答先于回放帧」这条优先级
if (Pending.length) {
(async () => {
for (const T of Pending) {
try {
await T();
} catch (Err) {
State.Log(' ⚠ 查库应答任务异常(已忽略,回放不受影响):' + (Err && Err.message ? Err.message : Err));
}
}
})();
}
return;
}
const Id = Body[At];
const PAt = At + 1;
if (Id === 1) return HandleHashRequest(State, Client, Conn, Body, PAt);
if (Id === 3) return HandleEnterSpace(State, Client, Conn, Body, PAt);
if (Id === 32) {
const L = Entry.ParseClientLog(Body, PAt);
State.Log(' ClientLog 类型=' + L.Type + ' 名称=' + JSON.stringify(L.Name) + ' 正文=' + JSON.stringify(L.Text));
HandleButtonClick(State, Conn, L);
HandleLogIn(State, Conn, L);
return;
}
// 首字节像空间帧的第一个 id,但 methodId 不在上行表里 → 表缺项,如实报出来
if ((Id === 0x00 || Id === 0xff) && At + 16 <= Body.length) {
const Hex = Body.slice(At + 8, At + 16).toString('hex');
State.Log(' ⚠ 疑似空间帧,但 methodId 不在上行表:' + Hex + '(id=' + Body.readBigInt64BE(At).toString() + ')');
return;
}
State.Log(' ⚠ 未知入口命令 ID=' + Id + ' 负载=' + Body.slice(PAt).toString('hex').slice(0, 96));
}
// ============================================================
// 服务
// ============================================================
const Server = http.createServer((Req, Res) => {
Res.writeHead(426, { 'Content-Type': 'text/plain; charset=utf-8' }).end('WebSocket only\n');
});
const Ws = RequireWs();
const WsServer = new Ws.Server({ server: Server });
WsServer.on('connection', (Client, Req) => {
const State = NewSession();
const Conn = { Hash: null, Space: null, Peer: Req.socket.remoteAddress || '?' };
State.Log('=== 新客户端 ===');
State.Log('来源: ' + (Req.socket.remoteAddress || '?') + ' Origin: ' + (Req.headers.origin || '无'));
State.Log('模式: real(真服务器,M1:入口层全量 / 空间层只记录 + 有会话模板的空间按上行重放)');
const Timer = KeepaliveMs ? setInterval(() => {
if (Client.readyState !== Ws.OPEN) return;
Send(State, Client, Link.PingFrame());
}, KeepaliveMs) : null;
Client.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 'c2s', Buf);
const Frames = Link.ParseFrames(Buf);
if (!Frames) {
State.Log(' ⚠ 不是整帧流(可能是加密流或被分片):' + Buf.toString('hex').slice(0, 96));
return;
}
for (const F of Frames) HandleFrame(State, Client, Conn, F);
});
Client.on('close', (Code) => {
if (Timer) clearInterval(Timer);
EntryConns.delete(Client);
for (const [Id, V] of SpaceConns) if (V.Client === Client) SpaceConns.delete(Id);
State.Log('客户端断开 code=' + Code);
LogResponderSummary(State);
const S = World.UnimplementedSummary();
State.Log('未实现命令累计:' + S.Total + ' 次 / ' + S.Distinct + ' 种');
for (const R of S.Rows.slice(0, 30)) {
State.Log(' ' + String(R.Count).padStart(5) + ' ' + R.Kind.padEnd(10) + R.Name + ' methodId=' + R.MethodId + ' 目标=' + R.Targets.join(','));
}
});
Client.on('error', (Err) => State.Log('客户端错误: ' + Err.message));
});
// 查库命中 / 回落回放的计数口径:每条上行记录只走一条路(详见 responder.js 头注)——
// Hit = 查库路径接管并成功发出帧;Miss = 库可用但查不到;Disabled = 该上行不在接管范围内;
// Unavailable = 库不可用;Fallback = 已接管但后续查库/组帧/发帧失败 → 显式回放补发(FallbackFrames = 这些帧数)
function LogResponderSummary(State) {
if (!SpaceResponders.size) return;
for (const [SpaceId, R] of SpaceResponders) {
const C = R.Counters();
State.Log('查库定向应答(空间 ' + SpaceId + '):命中 ' + C.Hit + ' / 库里查不到(Miss) ' + C.Miss +
' / 不在接管范围 ' + C.Disabled + ' / 库不可用 ' + C.Unavailable + ' / 回落回放 ' + C.Fallback + '(补发帧 ' + C.FallbackFrames + ')' +
(C.Preempt ? ' / 严格优先(已抑制回放) ' + C.Suppressed : '(非严格:回放照旧)') + ' / X 表 ' + C.X + ' 条');
State.Log(' 按上行分:' + (Object.keys(C.ByMethod).length ? Object.entries(C.ByMethod).map(([K, V]) => K + '=' + V).join(' ') : '(无)'));
}
}
Server.listen(PORT, () => {
console.log('真服务器(real / M1)已监听 ws://localhost:' + PORT + '/');
console.log(' 协议表: codec ' + Tables.CodecByKey.size + ' 个 / 上行命令 ' + Tables.UplinkByMethod.size + ' 条' + (Tables.CodecError ? ' ⚠ ' + Tables.CodecError : ''));
console.log(' 入口空间: ' + ENTRANCE_SPACE + '(握手帧里下发)');
console.log(' 已知空间类别: ' + Object.entries(SPACE_KIND).map(([K, V]) => K + '=' + V).join(' '));
const SessionFiles = fs.readdirSync(path.join(PROJECT, 'names')).filter((F) => /^space-session-.*\.json$/.test(F));
console.log(' 会话回放模板: ' + (SessionFiles.length ? SessionFiles.join(', ') : '(无:空间层只记录不重放)'));
console.log(' 查库定向应答: ' + (STORE_PREEMPT ? '严格优先(命中即不发回放帧)' : '非严格(查库应答 + 回放并存,回放行为与升级前一致)') +
' 接管范围=' + Object.entries(STORE_SCOPE).filter(([, V]) => V).map(([K]) => K).join(',') +
(STORE_ALL ? '(--store-all:实验性,已知会与模板/回放里已播发的帧撞车)' : '(默认只接管 ba.lcr,理由见 responder.js 头注)'));
Store.SetLog((M) => console.log(M));
Store.Open().then((Pool) => {
Store.SetSemantic(Semantic); // 复用本进程这份表,别重复读 names/
console.log(' MySQL: ' + (Pool ? '已连上(' + Db.LoadConfig().Database + ')' : '不可用 → 查库定向应答全部回落会话回放/模板'));
});
console.log(' 日志: ' + LOG_DIR);
console.log(' 客户端挂载: local/config.xml 指向 ws://localhost:' + PORT + '/');
});
process.on('SIGINT', () => {
const S = World.UnimplementedSummary();
console.log('\n未实现命令累计:' + S.Total + ' 次 / ' + S.Distinct + ' 种(Top 30)');
for (const R of S.Rows.slice(0, 30)) {
console.log(' ' + String(R.Count).padStart(5) + ' ' + R.Kind.padEnd(10) + R.Name + ' methodId=' + R.MethodId);
}
for (const [SpaceId, R] of SpaceResponders) console.log('查库定向应答(空间 ' + SpaceId + '):' + JSON.stringify(R.Counters()));
process.exit(0);
});