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
""";
}
}