// dispatch.js - 空间层:识别空间帧 → 逐条记录解码 → 落日志 → 把「未实现」记进世界状态 // // 帧体 = [nullmap][第一个 int64][(methodId int64)(负载)…] // 第一个 int64 的语义按方向不同(出处:module_61860 的 SpaceCommand codec,fr.o7g 依次写 f2u(第一个)、f2u(methodId)): // c2s → objectId(目标对象:实测有空间自身 id,也有实体 id) // s2c → spaceId // 判据(两道门,与 relay 模式一致,实测 171/171 命中): // ① 首字节必须是第一个 id 的最高位:0x00 = 小正数,0xff = 负数(握手/日志帧首字节是命令 ID 1/3/32) // ② 8 字节之后必须命中 names/uplink_table.json 的 methodId // 负载长度不靠猜:按名字/codec 表逐字段解码推进;解不动就停在原地并把剩余字节原样打出(fail-fast)。 // // Handle 的第 5 个参数 OnRecord 是可选钩子(index.js 用它接会话重放):返回真值 = 这条上行已被处理, // 不再记进未实现清单。 'use strict'; const { FormatValue } = require('../lib_space_codec'); const { ResolveFields } = require('./codec'); const ModelService = require('./modelservice'); function MakeDispatcher(Tables, Reader, World, Options) { const Opts = Options || {}; const Classes = (Opts.Alias && Opts.Alias.Classes) || {}; const Methods = (Opts.Alias && Opts.Alias.Methods) || {}; const Fields = (Opts.Alias && Opts.Alias.Fields) || {}; // 别名词典(names/command_alias.json)是**派生命名**,不是客户端原始名;键 = "::<短名>" const Mod2 = (M) => String(M || '').replace(/^module_/, ''); const AliasOfClass = (M, N) => { const E = Classes[M + '::' + N]; return E ? E.Alias : null; }; const AliasOfMethod = (M, O, F) => { const E = Methods[M + '::' + O + '.' + F]; return E ? E.Alias : null; }; const AliasOfField = (M, O, F) => { const E = Fields[M + '::' + O + '.' + F]; return E ? E.Alias : null; }; // methodId(8 字节大端 hex)→ 上行命令 const UplinkByHex = new Map(); for (const [MethodId, Cmd] of Tables.UplinkByMethod) { const B = Buffer.alloc(8); B.writeBigInt64BE(BigInt(MethodId)); UplinkByHex.set(B.toString('hex'), Cmd); } // 判定「空间层帧」的唯一可靠依据 = 偏移 At+8 处的 8 字节 methodId 在上行表里。 // 不要再加「目标 id 首字节必须是 0x00 / 0xff」这类启发式:目标 id 不一定是空间号, // 也可能是用户对象 id(实测 3074457345817168740 = 0x2AAAAAAAAAB685CB64,首字节 0x2A)。 // 加了那条会把合法空间命令整条误判成入口命令整包丢弃(server-logs/hashlogin-01/session_010:17,19,46 // 的 "未知入口命令 ID=42" 就是它:ID=42 其实是 0x2A,目标 id 的第一个字节)。 // 反方向的误判风险可忽略:上行表 360 条,8 字节随机值撞上任一条的概率约 360/2^64。 function IsSpaceFrame(Body, At) { if (At + 16 > Body.length) return false; return UplinkByHex.has(Body.slice(At + 8, At + 16).toString('hex')); } // At = 第一个 id 的起始偏移(已跳过 nullmap) function Describe(Body, At) { const TargetId = Body.readBigInt64BE(At).toString(); const Records = []; let Off = At + 8; while (Off + 8 <= Body.length) { const Cmd = UplinkByHex.get(Body.slice(Off, Off + 8).toString('hex')); if (!Cmd) break; const MethodId = Body.readBigInt64BE(Off).toString(); Off += 8; const PayloadStart = Off; const { Codec, Fields } = ResolveFields(Tables, MethodId, Cmd); const Values = []; let Bad = false; for (const F of Fields) { const R = Reader.ReadValue(Body, Off, F.Type, F.TypeCodec, 0); Values.push([F.Name || '(匿名)', F.Type, R.Value]); if (R.Bad || R.Next <= Off) { Bad = true; break; } Off = R.Next; } Records.push({ MethodId, Name: (Cmd.OwnerName || Cmd.OwnerClass || '?') + '.' + (Cmd.SendFn || '?'), Module: Cmd.Module, CodecName: Codec ? Codec.ModelName || Codec.ModelClassReal || '' : '', Payload: Body.slice(PayloadStart, Off), Values, Bad, }); } return { TargetId, Records, Next: Off, Tail: Body.slice(Off) }; } // 落盘 + 记未实现;返回 Describe 的结果供调用方继续用 // OnRecord(可选)= 调用方插进来的「这条上行我自己处理」钩子,返回真值表示**不算未实现**。 // 为什么钩子要放在这里而不是调用方:NoteUnimplemented 就在本函数里记, // 调用方(index.js)拿到 Info 时"未实现"已经记完了。会话重放(session.js)命中并发帧后 // 必须能拦住这一笔记账,否则断开时统计里还是"这些命令没人管"。 function Handle(State, Body, At, SpaceKind, OnRecord) { const Info = Describe(Body, At); if (!Info.Records.length) { State.Log(' 空间帧,但 methodId 没解出记录:' + Body.slice(At).toString('hex').slice(0, 120)); return Info; } State.Log(' 目标 id=' + Info.TargetId + ' 命令 ' + Info.Records.length + ' 条'); for (const R of Info.Records) { const Ms = ModelService.Name(R.MethodId); const Ma = Mod2(R.Module); const Parts = String(R.Name).split('.'); const Owner = Parts[0]; const Fn = Parts[1]; const ClassAlias = AliasOfClass(Ma, Owner); const MethodAlias = AliasOfMethod(Ma, Owner, Fn); const AliasTag = ClassAlias || MethodAlias ? ' 【' + (ClassAlias || Owner) + '.' + (MethodAlias || Fn) + '】' : ''; State.Log( ' ' + R.Name + ' (' + R.Module + ')' + (R.CodecName ? ' [' + R.CodecName + ']' : '') + AliasTag + (Ms ? ' ← modelService: ' + Ms : '') + ' methodId=' + R.MethodId + (R.Bad ? ' ⚠ 负载解码中断' : ''), ); if (Ms && Ms.indexOf('G.q1wd') === 0) State.Log(' → 请求内容: ' + FormatValue(ModelService.DescribeRequest(R.Payload || Buffer.alloc(0)))); for (const [K, T, V] of R.Values) { const Fa = AliasOfField(Ma, Owner, K); State.Log(' ' + String(Fa ? Fa + ' (' + K + ')' : K).padEnd(28) + String(T).padEnd(14) + ' = ' + FormatValue(V)); } if (!(typeof OnRecord === 'function' && OnRecord(R, Info.TargetId))) { World.NoteUnimplemented(SpaceKind, R.Name, R.MethodId, Info.TargetId); } } if (Info.Tail.length) { State.Log(' ⚠ 尾部剩 ' + Info.Tail.length + 'B 没解出:' + Info.Tail.toString('hex').slice(0, 96)); } return Info; } return { IsSpaceFrame, Describe, Handle, UplinkByHex }; } module.exports = { MakeDispatcher };