工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
565 lines
30 KiB
JavaScript
565 lines
30 KiB
JavaScript
// 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);
|
||
});
|