using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// ★★ GATE Lua 通道:在客户端进程里执行任意 Lua(2026-09-30 新增)。 /// /// 原理(照 v0.5-2 捐赠端 GameRouter.LixiPolice.cs:378-382 的注释): /// /// 4229938 MasterToClientImpl.SyncDoToLuaMaster (0x1281DDE0) 调用内置的 /// gCsToLuaHandler.GmRunLua —— 注册在 **Gate** 上 (0x14883B90)。 /// /// ⇒ 客户端本来就自带「服务端叫它跑一段 Lua」的能力,只是必须发在 /// GATE 那条连接上,发在 game 连接上客户端不认。 /// /// 主线此前缺的不是链路,是调用: /// PrivateServerApplication.cs:21-23 已经建好 GateSessionHub 并注入两个 router, /// LoginGateRouter.cs:114 每次登录都 _gateSessions.Touch() /// (日志 [GATE] authenticated session registered), /// GameRouter.cs:14 也拿到了 _gateSessions —— 但全仓零使用。 /// 这里补上「绑定 + 下发」两步。 /// /// ⛑ 三个必须遵守的坑(都是捐赠端踩出来的): /// /// 必须在 Gate 会话上发 SyncDoToLuaMaster(45437825); /// 客户端 GmUtils.ParseUlongToLua(0x14677AC0) 用 /// ulong.new(low32, high32) ⇒ uid 必须拆成两个 32 位传, /// 直接写十进制数字会被 float64 四舍五入(8300000000000000001 这种 id 必炸); /// 脚本一律 pcall 包裹 + print_error,否则报错静默, /// 且客户端有 print_debug / print_error 可直接写进 Player.log 做回执。 /// /// /// ⛑ 主线 TcpSession 没有 ClosedToken(捐赠端 13 处都在用), /// 这里统一用 Client.Connected + CancellationToken.None。 /// internal sealed partial class GameRouter { /// game 会话上存 GATE 会话的 Items 键(照捐赠端 "lixi-police-gate")。 internal const string GateLuaSessionKey4229938 = "drmk-gate-lua"; /// /// 在 LoginGame 时把当前 GATE 会话绑到 game 会话上(照捐赠端 /// Endpoints.cs:16-17)。⛑ 这是实例方法 —— 只有它能碰到 _gateSessions。 /// 只绑一次:登录时的那个 gate,不是后来"全局当前"的某个 socket。 /// private void BindGateSession4229938(TcpSession session) { if (_gateSessions?.Current is { } gate && gate.Client.Connected) { // ⛑⛑ 两个 key 都必须写 —— 同一个 gate 会话,两条读取链各认各的: // · GateLuaSessionKey4229938("drmk-gate-lua") = 主线 GM「客户端 Lua」面板在用; // · PoliceGateSessionKey4229938("lixi-police-gate") = 逐字搬来的捐赠端警察链在读 // (PoliceButtons / PolicePrison / PoliceAnimationHeight / PoliceBoardingFacing … 共 10 处)。 // ★ 2026-10-02 修「生成 NPC 后 F 不亮」:之前只写了主线那个 key ⇒ // SendPoliceButtons4229938 在 TryGetValue 失败处**静默 return**,F 的 Lua 一次都没发出去。 session.Items[GateLuaSessionKey4229938] = gate; session.Items[PoliceGateSessionKey4229938] = gate; session.Log.Info($"[GATE-LUA] bound gate session (game={session.Id}, gate={gate.Id}) keys=drmk-gate-lua,lixi-police-gate"); return; } session.Log.Warn("[GATE-LUA] no live gate session at LoginGame; client Lua unavailable until re-login"); } /// 取绑定好的 GATE 会话;没绑或已断都返回 false。 internal static bool TryGetGateSession4229938(TcpSession session, out TcpSession? gate) { if (session.Items.TryGetValue(GateLuaSessionKey4229938, out var raw) && raw is TcpSession g && g.Client.Connected) { gate = g; return true; } gate = null; return false; } /// /// ★ 在客户端进程里执行一段 Lua。wire = UxString(script)(客户端签名就是 /// SyncDoToLuaMaster(string content),见 ux_game_all.dump.cs:15196)。 /// internal static async Task<(bool Ok, string Message)> RunClientLuaAsync(TcpSession session, string script) { if (string.IsNullOrWhiteSpace(script)) return (false, "脚本是空的。"); if (!TryGetGateSession4229938(session, out var gate) || gate is null) return (false, "当前没有可用的 GATE 连接:GateSessionHub 没抓到会话,或那条连接已断。" + "请完全退出客户端重进一次(要让它重新走 Login),再执行。"); var body = new UxWriter().UxString(script).ToArray(); await gate.NotifyAsync(MethodId.SyncDoToLuaMaster, body, CancellationToken.None); session.Log.Info($"[GATE-LUA] sent {body.Length}B (script {script.Length} chars) via gate={gate.Id}"); return (true, $"已下发 {body.Length} 字节(脚本 {script.Length} 字符)到客户端。" + "结果看客户端 Player.log 里的 print_debug / print_error 输出。"); } // ------------------------------------------------------------------ F 交互按钮 /// /// 枚举服务端账本里的 NPC(三本:Ambient 行人 / 地盘敌人 / 功能 NPC),按离玩家由近到远排序。 /// ⛑ 城里绝大多数路人是客户端本地实体,服务端没有它们的 id /// (GameRouter.PoliceArrest.cs:66-79 写死这个结论)⇒ 那些 NPC 挂不了 F, /// 只能对 GM 召唤 / 服务端 spawn 出来的目标操作。 /// internal static GateLuaUnit[] EnumLedgerUnits4229938(TcpSession session) { var list = new List(); var state = GetStateIfExists(session); var player = state?.LastReportedPlayerPosition ?? default; bool hasPlayer = state is not null && float.IsFinite(player.X); static float Dist(Vec3 a, Vec3 b) { var dx = a.X - b.X; var dy = a.Y - b.Y; var dz = a.Z - b.Z; return MathF.Sqrt(dx * dx + dy * dy + dz * dz); } void Take(List<(ulong Id, Vec3 Pos)> source, string ledger) { foreach (var (id, pos) in source) list.Add(new GateLuaUnit(id, ledger, pos, hasPlayer ? Dist(pos, player) : float.NaN)); } var amb = AmbientState(session); lock (amb.SyncRoot) Take(amb.Peds.Select(p => (p.Id, new Vec3(p.X, p.Y, p.Z))).ToList(), "行人"); var turf = EnemyTerritoryStateOf(session); lock (turf.SyncRoot) Take(turf.Active.Select(kv => (kv.Key, new Vec3(kv.Value.X, kv.Value.Y, kv.Value.Z))).ToList(), "地盘敌人"); var fn = FunctionalNpcStateOf(session); lock (fn.SyncRoot) Take(fn.Active.Select(kv => (kv.Key, new Vec3(kv.Value.X, kv.Value.Y, kv.Value.Z))).ToList(), "功能NPC"); return list.Count == 0 ? [] : [.. list.OrderBy(u => float.IsFinite(u.Distance) ? u.Distance : float.MaxValue)]; } /// 给面板用的账本条目。 internal readonly record struct GateLuaUnit(ulong Uid, string Ledger, Vec3 Position, float Distance); /// /// 固定按钮索引(捐赠端 PoliceButtonIndex4229938):Source Interrogate=7, /// 且要落在官方 table/task 按钮区间之外。⛑ 移除时也要传这个原始索引(不是 8770512)。 /// internal const int ClientButtonIndex4229938 = 8700512; /// /// 一条 story MessageCommand 的裸字节(供 Lua 用 writer:WriteByte 原样回吐): /// FF + Int7(1) + U8(4=MessageCommand) + U32(nid) + UxString(name) + /// U8(44=ULongPayload) + U64(value) + I64(0) + Bool(false)。 /// /// ⛑⛑ payload mark 必须用 44(ULongPayload)。捐赠端用的是 42(StringPayload), /// 但主线 StoryVehicleCodec4229938.ParsePayload 只认 5/6/12/44/35/36/37, /// 遇到别的 mark 会 throw InvalidDataException ⇒ 整条命令在 /// commands.Add 之前就被丢弃 ⇒ 服务端什么都看不到。 /// 44 还能顺带把 uid 原样带回来,正好用。 /// internal static byte[] StoryMessageBytes4229938(uint nid, string name, ulong value) => new UxWriter().U8(255).Int7(1).U8(4).U32(nid).UxString(name) .U8(44).U64(value).I64(0).Bool(false).ToArray(); /// /// ★ 回执探针:脚本一旦被客户端执行,就经 /// gClientToGameSceneDelegate:Notify(67130745, …) 把一个 story command 发回服务端, /// 名字固定为 DrmkLuaRx。主线 GameRouter.VehicleStory.cs:45 的 handler 会把 /// command 名打进日志([VEHICLE-STORY] cmd … name=DrmkLuaRx …) /// ⇒ 服务端日志一出现 DrmkLuaRx,就铁证客户端真的执行了 Lua。 /// /// 这是 GATE Lua 通不通的唯一硬判据:不依赖 print_debug /// 到底往不往 Player.log 写,也不依赖按钮有没有显示出来。 /// internal static string BuildReceiptScript4229938(string tag) { var bytes = StoryMessageBytes4229938(10, "DrmkLuaRx", tag.GetHashCode() == 0 ? 1UL : (ulong)(uint)tag.GetHashCode()); var arr = string.Join(",", bytes); return $$""" -- [DRMK-LUA] 回执探针:执行了就会往服务端发一个 DrmkLuaRx。 local bytes = { {{arr}} } local function emit() local okE, errE = pcall(function() gClientToGameSceneDelegate:Notify(67130745, function(writer) for i = 1, #bytes do writer:WriteByte(bytes[i]) end end) end) if not okE then print_error('[DRMK-LUA] notify failed: ' .. tostring(errE)) end end emit() local okP, errP = pcall(function() print_debug('[DRMK-LUA] alive tag={{tag}}') end) if not okP then print_error('[DRMK-LUA] print_debug unusable: ' .. tostring(errP)) end local okG, gErr = pcall(function() local u = gCS and gCS.MyPlayerManager and gCS.MyPlayerManager.PlayerUnit print_debug('[DRMK-LUA] myPid=' .. tostring(u and u.Pid)) end) if not okG then print_error('[DRMK-LUA] gCS probe failed: ' .. tostring(gErr)) end """; } /// /// ★ 生成一个「点了就开里希盘查」的 F 按钮(按钮文字默认「盘查」)。 /// click 回调把 uid 经 67130745 的 DrmkExamRequest 回给服务端, /// 由 GameRouter.VehicleStory.cs 的 handler 接住再调 OpenExamAsync。 /// internal static string BuildExamButtonScript4229938(ulong uid, string text) { var low = (uint)uid; var high = uid >> 32; var label = (string.IsNullOrWhiteSpace(text) ? "盘查" : text) .Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "\\'", StringComparison.Ordinal); var click = string.Join(",", StoryMessageBytes4229938(10, "DrmkExamRequest", uid)); var rx = string.Join(",", StoryMessageBytes4229938(10, "DrmkLuaRx", uid)); return $$""" local ok, err = pcall(function() local mgr = L50.L50App.L50Game.InteractBtnMgr if not mgr then print_error('[DRMK] InteractBtnMgr missing'); return end local pid = ulong.new({{low}}, {{high}}) local npc = gCS.SceneDataMgr.GetUnit(pid) if not npc then print_error('[DRMK] unit not found pid=' .. tostring(pid)); return end mgr:RemoveBtnByType(pid, LX6.Interact.BtnTargetType.Unit, {{ClientButtonIndex4229938}}) local clickBytes = { {{click}} } local rxBytes = { {{rx}} } local function visible() local player = gCS.MyPlayerManager.PlayerUnit return player ~= nil and gCS.SceneDataMgr.GetUnit(pid) == npc end local function click() if not visible() then return end local okN, errN = pcall(function() gClientToGameSceneDelegate:Notify(67130745, function(writer) for i = 1, #clickBytes do writer:WriteByte(clickBytes[i]) end end) end) if not okN then print_error('[DRMK] exam notify failed: ' .. tostring(errN)) end end local btn = LX6.Interact.UnitBtnInfo.New(7, pid, {{ClientButtonIndex4229938}}, click, visible, 0, 0) btn.exText = '{{label}}' btn.Dis = 3 btn.pcHideText = false mgr:AddBtn(btn) -- ⛑⛑ **加完再设一次**(2026-10-01 19:3x 用户报「F 里显示的是 NPC 名字」): -- `AddBtn` 内部会跑 `InitBtnInfo(unit)`,很可能把 exText 重置成单位名。 -- `exText` 是 BaseBtnInfo 上 [ExportToLua] 的字段(捐赠端 PoliceButtonsScript -- 用的就是它),设置本身没问题 ⇒ 保险起见 AddBtn 之后再压一次。 pcall(function() btn.exText = '{{label}}' end) -- 取证:把最终解析结果打进 Player.log(print_debug 不进日志,必须 print_error) pcall(function() print_error('[DRMK] exam F text pid=' .. tostring(pid) .. ' exText=' .. tostring(btn.exText) .. ' text=' .. tostring(btn.text) .. ' taskInfo=' .. tostring(btn.TaskInfo)) end) -- 顺带回一发回执:服务端看到 DrmkLuaRx 就知道这段 Lua 真的跑起来了。 pcall(function() gClientToGameSceneDelegate:Notify(67130745, function(writer) for i = 1, #rxBytes do writer:WriteByte(rxBytes[i]) end end) end) print_error('[DRMK] exam F added pid=' .. tostring(pid) .. ' text={{label}}') end) if not ok then print_error('[DRMK] exam F failed: ' .. tostring(err)) end """; } /// /// optionId → reactionId。照捐赠端 PoliceReaction4229938 /// (LixiPolice.cs:432-440)。 /// ⛑ 默认 15002 是 **Detach(下车)**,**不是**上车;20 才是押送上车(20001)。 /// internal static uint PoliceReactionForOption(uint optionId) => optionId switch { 1 => 1001, 2 => 2001, 3 => 3001, 4 => 4001, 5 => 5001, 6 => 6001, 7 => 7001, 8 => 8001, 9 => 9001, 10 => 10001, 11 or 12 => 12003, // 无服务端证据账本 ⇒ 拒开罚单,而不是假成功 13 => 13001, 14 => 14001, 15 => 15001, 16 => 16001, 17 => 17001, 18 => 18001, 19 => 19001, 20 => 20001, 21 => 21001, 22 => 22001, 23 => 23001, 24 => 24001, 30 => 30001, _ => 15002, }; /// /// ★★ 修「盘查面板里点任何选项都没反应」。 /// /// 根因(2026-10-01 实机日志取证)——客户端原生 /// LX6/Manager/GamePlay/PoliceJob/PoliceExamineManager.lua 的两个入口都有反编译翻错: /// /// :190 ClickOption = function(self, optionId) /// if optionId ~= PoliceCfg.Ask then ← 翻错的比较 /// if self.attachData.askDialogId and self.attachData.askDialogId <= 0 then /// ... ShowGeneralDialog ... /// end ← 没有 else ⇒ 静默 /// else /// self.ClickOption_Story(self, optionId) ← 只有 Ask 才走得到 /// end /// end /// :1040 ClickOption_Story = function(self, optionId) /// if self.BlockInteract or self.unit ~= nil then return end ← 翻错的比较 /// ... gPoliceJobManager.cs:ClickOption_Story(self.unit.Pid, optionId) /// /// ⛑⛑ 这个文件是 luajit 反编译产物,**比较符整体不可信**(~=/==、 /// <=/>= 都翻过)。别照抄它的判断,直接换掉函数本体。 /// 日志佐证:[DRMK-EXAM] ClickOption_Story optionId=8 unit=true … block=nil, /// unit=true 正好命中第二处早退条件。 /// /// ⛑ 面板的唯一点击入口是 PoliceInquiryPanelStore:OnListBtnClick /// (self.mgr:ClickOption(itemData.optionCfgId),self.mgr = gPoliceJobManager.examineMgr) /// ⇒ **必须换 ClickOption 本身**,只换 ClickOption_Story 有可能整条链都走不到。 /// 两个都换,双保险。 /// /// 修法:真的调 cs:ClickOption_Story,同时把 optionId 回执给服务端; /// 服务端再推 SyncPoliceExamReaction(64480222)(主线注释里"唯一能让面板有反应的帧")。 /// ⛑ 必须先写 self.PreOptionId —— 客户端 OnReceiveReaction 靠它取 option 配置。 /// /// /// ★★ 直接把「对方反应」喂给客户端 —— 绕开 SyncPoliceExamReaction(64480222)。 /// /// ⛑⛑ 为什么不能用那条 RPC(2026-10-01 15:0x 实机取证): /// /// 客户端 RPCDeserializeAuto.lua:350 把它标成 LuaOnly,也有 reader(:3281), /// 但**全仓没有任何 Lua 分发器**会把它交给 PoliceJobManager.OnReceiveReaction_Story; /// 官方链路里 OnReceiveReaction_Story 是**被 L50 story(S002) 调**的 /// (捐赠端 StoryS002Codec4229938.Reaction(child, section, reaction)), /// 主线没有 S002 codec ⇒ 这条 RPC 发了等于石沉大海。 /// 实机证据:推 reaction 后 Player.log 里 [DRMK] OnReceiveReaction **一行都没有**。 /// /// ⇒ 反正 GATE Lua 能在客户端进程里跑任意代码,**直接调那个函数**即可,不必移植 S002。 /// internal static string BuildReactionScript4229938(uint reaction, uint optionId) => $$""" -- [DRMK-LUA] 直接触发盘查反应(SyncPoliceExamReaction 走 RPC 到不了 lua) local ok, err = pcall(function() local mgr = gPoliceJobManager if mgr == nil then print_error('[DRMK] reaction: gPoliceJobManager nil'); return end if type(mgr.OnReceiveReaction_Story) ~= 'function' then print_error('[DRMK] reaction: OnReceiveReaction_Story missing') return end mgr:OnReceiveReaction_Story({{reaction}}) print_error('[DRMK] reaction fired rx={{reaction}} optionId={{optionId}}') pcall(function() local em = mgr.examineMgr local pre = em and em.PreOptionId local tr = nil if em and em.attachData and em.attachData.triggeredOptions and pre then tr = em.attachData.triggeredOptions[pre] end print_error('[DRMK] reaction state: pre=' .. tostring(pre) .. ' triggered=' .. tostring(tr)) -- ⛔⛔ **这里曾经无条件 `RefreshList(false, true, true)`,2026-10-01 17:4x 撤掉。** -- `force=true` ⇒ `list:SetSimpleList` 重建 ⇒ 重跑 `OnRenderItem:394` -- ⇒ 把带 subOptions 那一项的子列表**无条件展开**(`subOptionId` 默认 0)。 -- 那 4 个子按键"点什么都会冒出来"**就是这一行造成的**,不是客户端的问题。 -- ⇒ 列表刷新交回原生:**只在演出(Show*Content)的关闭回调里刷**, -- 那正是官方 `VMSingal_Story` 各分支的收尾动作。 -- ================= 演出 / NPC 反应 ================= local P = LTConfig.PoliceConfig local id = {{optionId}} local panel = em and em.panel local cfg = nil pcall(function() cfg = P.GetConfig(id) end) local cond, msg, otype, dlg = nil, nil, nil, nil if cfg then pcall(function() cond = cfg.Condition end) pcall(function() msg = cfg.Message end) pcall(function() otype = cfg.OptionType end) pcall(function() dlg = cfg.DialogId end) end print_error('[DRMK] option cfg id=' .. tostring(id) .. ' message=' .. tostring(msg) .. ' type=' .. tostring(otype) .. ' cond=' .. tostring(cond) .. ' dialog=' .. tostring(dlg)) -- ① 姿态命令(命令按钮 + 它下面 4 个子按键): -- `VMSingal_Story` 的 CommandFinish 分支就是 `self:Set`, -- Condition 形如 NotHandsOnHead / NotCrouching / NotProne / NotFree -- (注意反编译把名字也翻了:`SetNotHandsOnHead` 实际是**举手**)。 if cond ~= nil and cond ~= '' and em and type(em.RunCode) == 'function' then pcall(function() em.RunCode(em, 'self:Set' .. tostring(cond)) end) print_error('[DRMK] pose set ' .. tostring(cond)) end -- ② 四项检查的结果演出(官方 `VMSingal_Story` 各分支调的就是这几个) local shown = false if panel then local cb = function() pcall(function() local GE = nil pcall(function() GE = MuGenStates.Logic.GameplayEvent end) if GE and id == P.IdCheck then em:SendGameplayEvent(GE.PoliceExitIDCheck) elseif GE and id == P.AlcoholTest then em:SendGameplayEvent(GE.PoliceExitAlcoholTest) elseif GE and id == P.DrugTest then em:SendGameplayEvent(GE.PoliceExitDrugTest) end end) -- ⛑ 只在这里刷列表:这是官方 `VMSingal_Story` 各分支的**收尾动作**。 pcall(function() panel:RefreshList(false, true, true) end) end if id == P.IdCheck and type(panel.ShowCheckIdContent) == 'function' then panel:ShowCheckIdContent(cb); shown = true print_error('[DRMK] show ID content') elseif id == P.BodySearch and type(panel.ShowSearchBodyContent) == 'function' then panel:ShowSearchBodyContent(cb); shown = true print_error('[DRMK] show search-body content') elseif id == P.AlcoholTest and type(panel.ShowBreathCheckContent) == 'function' then panel:ShowBreathCheckContent(cb); shown = true print_error('[DRMK] show breath content') elseif id == P.DrugTest and type(panel.ShowDrugTestContent) == 'function' then panel:ShowDrugTestContent(cb); shown = true print_error('[DRMK] show drug content') end end -- ③ **NPC 反应(AI 对话)**:`PoliceExamineManager.ShowAiDialogByFineId(optionId, fineId)` -- 会按 `PoliceExamReactionConfig.Question` + NPC 情绪/性格 取一句台词, -- 内部回调还会自己 `panel:RefreshList()` ⇒ 交给原生,我们不掺和。 -- 只有"没走①/②演出"的选项才补对话,别和结果面板抢屏。 if not shown and em and type(em.ShowAiDialogByFineId) == 'function' then local fineId = 0 pcall(function() local fl = em.attachData and em.attachData.fineList if type(fl) == 'table' and fl[1] ~= nil then fineId = fl[1] end end) pcall(function() em:ShowAiDialogByFineId(id, fineId) end) print_error('[DRMK] ai dialog fired fineId=' .. tostring(fineId)) end -- ④ 取证:把 reaction 配置行掏出来 ⇒ 据此接 3D 动画 / 表情 -- ⛑ xlua 下配置行很可能是 **userdata**,`pairs()` 会抛异常, -- 而且 `pcall` 吞掉异常后**一条日志都没有**(上一轮就是这样白等)。 -- ⇒ 先试 `pairs`(失败把原因打出来),再按候选字段名逐个 pcall 取值。 pcall(function() local rc = nil pcall(function() rc = LTConfig.PoliceExamReactionConfig.GetConfig({{reaction}}) end) if not rc then print_error('[DRMK] reaction cfg nil rx={{reaction}}'); return end local okP, errP = pcall(function() local keys = {} for k, v in pairs(rc) do keys[#keys + 1] = tostring(k) .. '=' .. tostring(v) end table.sort(keys) for i = 1, #keys, 8 do local chunk = {} for j = i, math.min(i + 7, #keys) do chunk[#chunk + 1] = keys[j] end print_error('[DRMK] reaction cfg: ' .. table.concat(chunk, ' | ')) end end) if not okP then print_error('[DRMK] reaction cfg pairs failed: ' .. tostring(errP)) local names = { 'Id', 'Exit', 'ExitType', 'Section', 'SectionId', 'Anim', 'AnimId', 'Animation', 'Action', 'Dialog', 'DialogId', 'Emotion', 'Type', 'Message', 'VMSignal', 'Signal', 'Event', 'EventId', 'Result', 'Timeline', 'PlayType', 'Name' } for i = 1, #names do local okF, val = pcall(function() return rc[names[i]] end) if okF and val ~= nil then print_error('[DRMK] reaction cfg.' .. names[i] .. '=' .. tostring(val)) end end end end) end) end) if not ok then print_error('[DRMK] reaction failed: ' .. tostring(err)) end """; internal static string BuildPatchClickOptionScript4229938() { // 回执字节没法在 Lua 里现编(varint 编码),但**随 optionId 变的只有 u64 那 8 个字节** // ⇒ 预生成一份 value=0 的模板 + 值下标,Lua 端就地改那 8 字节(LE)。 // ⛑ 别再写死 1..N 的表:实机点到的 optionId 除了 1..30,还有 **111 / 112** 这种高位 id。 // 尾部固定是 I64(0) 8B + Bool(false) 1B ⇒ u64 起始偏移 = len - 17(0 基)。 var tpl = StoryMessageBytes4229938(10, "DrmkExamOption", 0); var valueIndex0 = tpl.Length - 17; // 0 基 var arr = string.Join(",", tpl); var valueIndexLua = valueIndex0 + 1; // Lua 表是 1 基 return $$""" -- [DRMK-LUA] 修盘查面板「点选项没反应」:ClickOption / ClickOption_Story 两个入口都换掉 local BASE = { {{arr}} } local VOFF = {{valueIndexLua}} -- u64 低字节在 BASE 里的 1 基下标 local ok, err = pcall(function() local mgr = gPoliceJobManager if mgr == nil then print_error('[DRMK] gPoliceJobManager nil'); return end local em = mgr.examineMgr if type(em) ~= 'table' then print_error('[DRMK] examineMgr missing'); return end local askId = nil pcall(function() askId = LTConfig.PoliceConfig.Ask end) print_error('[DRMK] PoliceConfig.Ask=' .. tostring(askId)) local notifyOption = function(optionId) local okN, errN = pcall(function() local bytes = {} for i = 1, #BASE do bytes[i] = BASE[i] end local v = tonumber(optionId) or 0 for k = 0, 7 do bytes[VOFF + k] = math.floor(v / (256 ^ k)) % 256 end gClientToGameSceneDelegate:Notify(67130745, function(writer) for i = 1, #bytes do writer:WriteByte(bytes[i]) end end) end) if not okN then print_error('[DRMK] option notify failed: ' .. tostring(errN)) end end local runOption = function(self, optionId) self.PreOptionId = optionId local okC, errC = pcall(function() if self.unit ~= nil then gPoliceJobManager.cs:ClickOption_Story(self.unit.Pid, optionId) end end) if not okC then print_error('[DRMK] cs:ClickOption_Story: ' .. tostring(errC)) end notifyOption(optionId) -- ⛑⛑ 2026-10-04 补回:原生 ClickOption_Story(PoliceExamineManager.lua:1049-1058) -- 点完选项会遍历 PoliceFineConfig,把 IsDefault 且 Type~=Npc 的罪名 TryAddFine 进去。 -- 主线把整个 ClickOption_Story 换成 runOption 时**漏了这段** ⇒ 面板选项点什么都没有罪名。 -- 闸门是 module.Crimes(只有 SyncAgentCrimes 68251759 能填), -- 不在白名单里的 fineId 会被 TryAddFine 静默丢掉。 local okF, errF = pcall(function() local FC = LTConfig.PoliceFineConfig if FC == nil then print_error('[DRMK] PoliceFineConfig nil'); return end local npcType = nil pcall(function() npcType = FC.TypeType.Npc end) local defaultCrimes = {} local rawCrimes = (self.module ~= nil) and self.module.Crimes or nil if rawCrimes ~= nil then local okT, tbl = pcall(function() return rawCrimes:ToTable() end) if okT and type(tbl) == 'table' then defaultCrimes = tbl end end local cnt = 0 if type(defaultCrimes) == 'table' then for _ in pairs(defaultCrimes) do cnt = cnt + 1 end end print_error('[DRMK] crimes whitelist=' .. tostring(cnt)) local total = FC.count or 0 local added = 0 for i = 0, total - 1 do local fineCfg = nil pcall(function() fineCfg = FC.LoadAt(i) end) if fineCfg ~= nil and fineCfg.IsDefault ~= false and (npcType == nil or fineCfg.Type ~= npcType) then -- 原生闸门:白名单里有才加。白名单为空时兜底直加, -- 否则 SyncAgentCrimes 没被接收 ⇒ 永远一条罪名都出不来。 if cnt > 0 then pcall(function() self.TryAddFine(self, fineCfg.Id, true, defaultCrimes) end) else pcall(function() self.AddFine(self, fineCfg.Id, true) end) end added = added + 1 end end print_error('[DRMK] fines scanned=' .. tostring(total) .. ' candidates=' .. tostring(added)) end) if not okF then print_error('[DRMK] fine loop failed: ' .. tostring(errF)) end print_error('[DRMK] option ran optionId=' .. tostring(optionId) .. ' unit=' .. tostring(self.unit ~= nil) .. ' ask=' .. tostring(askId)) end if not em.__drmkClickOptionPatched then em.ClickOption = function(self, optionId) return runOption(self, optionId) end em.__drmkClickOptionPatched = true end if not em.__drmkClickStoryPatched then em.ClickOption_Story = function(self, optionId) return runOption(self, optionId) end em.__drmkClickStoryPatched = true end -- ⛑ 诊断探针:推了 SyncPoliceExamReaction 却看不出反应时, -- 靠这一段分清是「帧没收到」还是「reactionId 在 PoliceExamReactionConfig 里没有这一行」。 if not em.__drmkReactionProbed then local origReaction = rawget(em, 'OnReceiveReaction') if type(origReaction) == 'function' then rawset(em, 'OnReceiveReaction', function(self, reactionId) print_error('[DRMK] OnReceiveReaction rx=' .. tostring(reactionId) .. ' pre=' .. tostring(self.PreOptionId)) local oc, rc, cnt = nil, nil, -1 pcall(function() oc = LTConfig.PoliceConfig.GetConfig(self.PreOptionId) end) pcall(function() rc = LTConfig.PoliceExamReactionConfig.GetConfig(reactionId) end) pcall(function() cnt = LTConfig.PoliceExamReactionConfig.count end) print_error('[DRMK] optionCfg=' .. tostring(oc ~= nil) .. ' reactionCfg=' .. tostring(rc ~= nil) .. ' reactionCount=' .. tostring(cnt)) local okR, errR = pcall(origReaction, self, reactionId) if not okR then print_error('[DRMK] OnReceiveReaction threw: ' .. tostring(errR)) end if rc == nil then -- 一次把合法的 reaction id 全打出来,省得再猜一轮 local ids = {} pcall(function() for i = 0, math.min(cnt, 400) - 1 do local row = LTConfig.PoliceExamReactionConfig.LoadAt(i) if row then ids[#ids + 1] = tostring(row.Id) end end end) for i = 1, #ids, 24 do local chunk = {} for j = i, math.min(i + 23, #ids) do chunk[#chunk + 1] = ids[j] end print_error('[DRMK] reaction ids: ' .. table.concat(chunk, ',')) end end end) em.__drmkReactionProbed = true print_error('[DRMK] OnReceiveReaction probe installed') else print_error('[DRMK] OnReceiveReaction not found') end -- ================= Timer 助手(硬规则 113:Timer.New 只造对象,必须 :Start()) ================= local function After(delay, fn) if type(_G.Timer) ~= 'table' or type(_G.Timer.New) ~= 'function' then pcall(fn) return end local okT, t = pcall(_G.Timer.New, fn, delay) if not okT or not t then pcall(fn) return end if type(t.Start) == 'function' then pcall(function() t:Start() end) end end -- ⛔⛔ **这里曾经打过 `C_PoliceInquiryPanelStore.OnRenderItem` 的补丁,2026-10-01 17:4x 撤掉。** -- 理由:命令按钮下面那 4 个子按键的显隐是**纯原生**行为 -- (`OnClickItem:334`:带 subOptions ⇒ 展开并写 `subOptionId`;否则 ⇒ `subList = nil` + 收起)。 -- 它之所以"点什么都会冒出来",不是原生逻辑坏了,而是**我每次点击后 -- `RefreshList(_, _, force=true)` 强制重建列表**,重跑 `OnRenderItem:394` -- 又无条件把子列表展开了一次 —— 是我自己造的症状。 -- ⇒ 正确做法是**别去碰渲染层**:撤掉补丁 + 不强制重建,让原生自己管。 -- (顺带:`OnRenderItem` 里那个 `data.optionId ~= self.subOptionId` 未必是翻反的, -- 别再拿"反编译比较符都翻了"当万能解释去改客户端 UI 代码。) -- ================= 右下角「长按 F 离开」 ================= -- 面板 store 里 `M.Leave = function(self) self.mgr:ClickOption(LTConfig.PoliceConfig.Leave) end`, -- 但 `bindData.exitButton` 的回调在原生里没人接 ⇒ 点了/长按了都没反应。 -- -- ⛑⛑ **回调名踩过一次坑**(18:0x 实机:`exit long-press installed` 打出来了,但按住 F 没有任何反应): -- SGUI 长按按钮的配对是 **`luaBeginLongPress` / `luaEndLongPress`** -- (`BaikePetInfoPanelStore.lua:28-32` 就是这个组合); -- `luaLongPress` 是另一类控件"按住重复触发"的回调,绑了不报错但**永不触发**。 -- ⇒ 三个都绑(Begin 起计时、End 取消、LongPress 兜底),并把触发打出来,便于确认到底哪个生效。 pcall(function() local HOLD = 0.8 local holdStart = nil local function InstallExit(attempt) local p = em.panel if not (p and p.bindData and p.bindData.exitButton) then if attempt < 8 then After(0.5, function() InstallExit(attempt + 1) end) else print_error('[DRMK] exitButton 始终拿不到') end return end if p.__drmkExitPatched then return end p.__drmkExitPatched = true local btn = p.bindData.exitButton local function tick() if holdStart == nil then return end if (os.clock() - holdStart) >= HOLD then holdStart = nil print_error('[DRMK] long-F exit -> Leave()') pcall(function() p:Leave() end) return end After(0.1, tick) end local function beginHold(which) print_error('[DRMK] exit hold begin (' .. which .. ')') if holdStart == nil then holdStart = os.clock() After(0.1, tick) end end btn.luaBeginLongPress = function() beginHold('begin') end btn.luaLongPress = function() beginHold('long') end btn.luaEndLongPress = function() if holdStart ~= nil then print_error('[DRMK] exit hold end') end holdStart = nil end print_error('[DRMK] exit long-press installed (hold ' .. tostring(HOLD) .. 's)') end InstallExit(1) end) end print_error('[DRMK] ClickOption patched') end) if not ok then print_error('[DRMK] patch failed: ' .. tostring(err)) end """; } /// /// ★★ 初始化原生盘查数据(照捐赠端 PolicePrepareScript4229938, /// LixiPolice.cs:390-412)。 /// /// 捐赠端注释的要点:调**原始** PrepareData_Story(就像 /// S002PlayerNet.EnterExam 做的那样),不用自造追问都不拷南瓜 WM 的替身函数。 /// 这是"盘查面板里所有选项点了没反应"的核心补救: /// 面板 UI 出得来,但 gPoliceJobManager.examineMgr.enterExamData 是空的, /// 所以每个选项都点不动。 /// /// 回执 DrmkExamPrepared 的 payload:1=ok,0=failed。 /// internal static string BuildPrepareExamScript4229938(ulong uid) { var low = (uint)uid; var high = uid >> 32; var okBytes = string.Join(",", StoryMessageBytes4229938(10, "DrmkExamPrepared", uid)); var failBytes = string.Join(",", StoryMessageBytes4229938(10, "DrmkExamPreparedFail", uid)); return $$""" local okBytes = { {{okBytes}} } local failBytes = { {{failBytes}} } local function send(t) local okS, errS = pcall(function() gClientToGameSceneDelegate:Notify(67130745, function(writer) for i = 1, #t do writer:WriteByte(t[i]) end end) end) if not okS then print_error('[DRMK] prepared notify failed: ' .. tostring(errS)) end end local ok, result = pcall(function() local npc = gCS.SceneDataMgr.GetUnit(ulong.new({{low}}, {{high}})) if not npc then return false end if gPoliceJobManager == nil then return 'no gPoliceJobManager' end gPoliceJobManager:PrepareData_Story(npc.Pid) local data = gPoliceJobManager.examineMgr and gPoliceJobManager.examineMgr.enterExamData return data ~= nil and data.npcUnit == npc end) if ok and result == true then print_debug('[DRMK] PrepareData_Story ok pid=' .. tostring(ulong.new({{low}}, {{high}}))) send(okBytes) else print_error('[DRMK] PrepareData_Story result=' .. tostring(result) .. ' err=' .. tostring(err)) send(failBytes) end """; } /// /// 生成「给 uid 挂 / 摘 F 按钮」的 Lua。⛑ uid 必须拆成 /// ulong.new(low32, high32)(捐赠端注释:十进制 Lua number 会被 float64 四舍五入)。 /// internal static string BuildUnitButtonScript4229938(ulong uid, string text, bool add) { var low = (uint)uid; var high = uid >> 32; var label = text.Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("'", "\\'", StringComparison.Ordinal); return $$""" local ok, err = pcall(function() local mgr = L50.L50App.L50Game.InteractBtnMgr if not mgr then print_error('[DRMK] InteractBtnMgr missing'); return end local pid = ulong.new({{low}}, {{high}}) local npc = gCS.SceneDataMgr.GetUnit(pid) if not npc then print_error('[DRMK] unit not found pid=' .. tostring(pid)); return end mgr:RemoveBtnByType(pid, LX6.Interact.BtnTargetType.Unit, {{ClientButtonIndex4229938}}) if {{(add ? "true" : "false")}} then local function visible() local player = gCS.MyPlayerManager.PlayerUnit return player ~= nil and gCS.SceneDataMgr.GetUnit(pid) == npc end local function click() if not visible() then return end print_debug('[DRMK] F clicked pid=' .. tostring(pid)) end local btn = LX6.Interact.UnitBtnInfo.New(7, pid, {{ClientButtonIndex4229938}}, click, visible, 0, 0) btn.exText = '{{label}}' btn.Dis = 3 btn.pcHideText = false mgr:AddBtn(btn) print_debug('[DRMK] F added pid=' .. tostring(pid) .. ' text={{label}}') else print_debug('[DRMK] F removed pid=' .. tostring(pid)) end -- ⛑ AddBtn/RemoveBtnByType 已经让原生列表失效。 -- SetBtnDirty 和 UnitBtnInfo 的 C# 位移属性在这个版本的 Lua 包装里没导出,别调。 end) if not ok then print_error('[DRMK] F button failed: ' .. tostring(err)) end """; } }