Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.GateLua.cs
T

780 lines
46 KiB
C#
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.
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// ★★ <b>GATE Lua 通道</b>:在客户端进程里执行任意 Lua(2026-09-30 新增)。
///
/// <para><b>原理(照 v0.5-2 捐赠端 <c>GameRouter.LixiPolice.cs:378-382</c> 的注释)</b>:</para>
/// <code>
/// 4229938 MasterToClientImpl.SyncDoToLuaMaster (0x1281DDE0) 调用内置的
/// gCsToLuaHandler.GmRunLua —— 注册在 **Gate** 上 (0x14883B90)。
/// </code>
/// ⇒ 客户端<b>本来就自带</b>「服务端叫它跑一段 Lua」的能力,只是必须发在
/// <b>GATE 那条连接</b>上,发在 game 连接上客户端不认。
///
/// <para><b>主线此前缺的不是链路,是调用</b>:
/// <c>PrivateServerApplication.cs:21-23</c> 已经建好 <c>GateSessionHub</c> 并注入两个 router,
/// <c>LoginGateRouter.cs:114</c> 每次登录都 <c>_gateSessions.Touch()</c>
/// (日志 <c>[GATE] authenticated session registered</c>),
/// <c>GameRouter.cs:14</c> 也拿到了 <c>_gateSessions</c> —— 但<b>全仓零使用</b>。
/// 这里补上「绑定 + 下发」两步。</para>
///
/// <para>⛑ <b>三个必须遵守的坑</b>(都是捐赠端踩出来的):</para>
/// <list type="number">
/// <item>必须在 <b>Gate 会话</b>上发 <c>SyncDoToLuaMaster</c>(45437825);</item>
/// <item>客户端 <c>GmUtils.ParseUlongToLua</c>(0x14677AC0) 用
/// <c>ulong.new(low32, high32)</c> ⇒ uid 必须拆成两个 32 位传,
/// <b>直接写十进制数字会被 float64 四舍五入</b>(8300000000000000001 这种 id 必炸);</item>
/// <item>脚本一律 <c>pcall</c> 包裹 + <c>print_error</c>,否则报错静默,
/// 且客户端有 <c>print_debug</c> / <c>print_error</c> 可直接写进 Player.log 做回执。</item>
/// </list>
///
/// <para>⛑ 主线 <c>TcpSession</c> <b>没有</b> <c>ClosedToken</c>(捐赠端 13 处都在用),
/// 这里统一用 <c>Client.Connected</c> + <c>CancellationToken.None</c>。</para>
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>game 会话上存 GATE 会话的 Items 键(照捐赠端 <c>"lixi-police-gate"</c>)。</summary>
internal const string GateLuaSessionKey4229938 = "drmk-gate-lua";
/// <summary>
/// 在 <c>LoginGame</c> 时把当前 GATE 会话绑到 game 会话上(照捐赠端
/// <c>Endpoints.cs:16-17</c>)。⛑ 这是实例方法 —— 只有它能碰到 <c>_gateSessions</c>。
/// 只绑一次:登录时的那个 gate,不是后来"全局当前"的某个 socket。
/// </summary>
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");
}
/// <summary>取绑定好的 GATE 会话;没绑或已断都返回 false。</summary>
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;
}
/// <summary>
/// ★ 在客户端进程里执行一段 Lua。wire = <c>UxString(script)</c>(客户端签名就是
/// <c>SyncDoToLuaMaster(string content)</c>,见 <c>ux_game_all.dump.cs:15196</c>)。
/// </summary>
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 交互按钮
/// <summary>
/// 枚举服务端账本里的 NPC(三本:Ambient 行人 / 地盘敌人 / 功能 NPC),按离玩家由近到远排序。
/// ⛑ <b>城里绝大多数路人是客户端本地实体,服务端没有它们的 id</b>
/// (<c>GameRouter.PoliceArrest.cs:66-79</c> 写死这个结论)⇒ 那些 NPC 挂不了 F,
/// 只能对 GM 召唤 / 服务端 spawn 出来的目标操作。
/// </summary>
internal static GateLuaUnit[] EnumLedgerUnits4229938(TcpSession session)
{
var list = new List<GateLuaUnit>();
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)];
}
/// <summary>给面板用的账本条目。</summary>
internal readonly record struct GateLuaUnit(ulong Uid, string Ledger, Vec3 Position, float Distance);
/// <summary>
/// 固定按钮索引(捐赠端 <c>PoliceButtonIndex4229938</c>):Source Interrogate=7,
/// 且要落在官方 table/task 按钮区间之外。⛑ 移除时也要传这个<b>原始</b>索引(不是 8770512)。
/// </summary>
internal const int ClientButtonIndex4229938 = 8700512;
/// <summary>
/// 一条 story MessageCommand 的裸字节(供 Lua 用 <c>writer:WriteByte</c> 原样回吐):
/// <c>FF + Int7(1) + U8(4=MessageCommand) + U32(nid) + UxString(name) +
/// U8(44=ULongPayload) + U64(value) + I64(0) + Bool(false)</c>。
///
/// <para>⛑⛑ <b>payload mark 必须用 44(ULongPayload)</b>。捐赠端用的是 42(StringPayload),
/// 但主线 <c>StoryVehicleCodec4229938.ParsePayload</c> <b>只认 5/6/12/44/35/36/37</b>,
/// 遇到别的 mark 会 <c>throw InvalidDataException</c> ⇒ 整条命令在
/// <c>commands.Add</c> 之前就被丢弃 ⇒ 服务端<b>什么都看不到</b>。
/// 44 还能顺带把 uid 原样带回来,正好用。</para>
/// </summary>
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();
/// <summary>
/// ★ <b>回执探针</b>:脚本一旦被客户端执行,就经
/// <c>gClientToGameSceneDelegate:Notify(67130745, …)</c> 把一个 story command 发回服务端,
/// 名字固定为 <c>DrmkLuaRx</c>。主线 <c>GameRouter.VehicleStory.cs:45</c> 的 handler 会把
/// command 名打进日志(<c>[VEHICLE-STORY] cmd … name=DrmkLuaRx …</c>)
/// ⇒ <b>服务端日志一出现 DrmkLuaRx,就铁证客户端真的执行了 Lua</b>。
///
/// <para>这是 GATE Lua 通不通的<b>唯一硬判据</b>:不依赖 <c>print_debug</c>
/// 到底往不往 Player.log 写,也不依赖按钮有没有显示出来。</para>
/// </summary>
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
""";
}
/// <summary>
/// ★ 生成一个「点了就开里希盘查」的 F 按钮(按钮文字默认「盘查」)。
/// click 回调把 uid 经 67130745 的 <c>DrmkExamRequest</c> 回给服务端,
/// 由 <c>GameRouter.VehicleStory.cs</c> 的 handler 接住再调 <c>OpenExamAsync</c>。
/// </summary>
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
""";
}
/// <summary>
/// <c>optionId → reactionId</c>。照捐赠端 <c>PoliceReaction4229938</c>
/// (<c>LixiPolice.cs:432-440</c>)。
/// ⛑ 默认 15002 是 **Detach(下车)**,**不是**上车;20 才是押送上车(20001)。
/// </summary>
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,
};
/// <summary>
/// ★★ <b>修「盘查面板里点任何选项都没反应」</b>。
///
/// <para>根因(2026-10-01 实机日志取证)——客户端原生
/// <c>LX6/Manager/GamePlay/PoliceJob/PoliceExamineManager.lua</c> 的<b>两个入口都有反编译翻错</b>:</para>
/// <code>
/// :190 ClickOption = function(self, optionId)
/// if optionId ~= PoliceCfg.Ask then ← 翻错的比较
/// if self.attachData.askDialogId and self.attachData.askDialogId &lt;= 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)
/// </code>
/// <para>⛑⛑ 这个文件是 luajit 反编译产物,**比较符整体不可信**(<c>~=</c>/<c>==</c>、
/// <c>&lt;=</c>/<c>&gt;=</c> 都翻过)。别照抄它的判断,直接换掉函数本体。</para>
/// <para>日志佐证:<c>[DRMK-EXAM] ClickOption_Story optionId=8 unit=true … block=nil</c>,
/// <c>unit=true</c> 正好命中第二处早退条件。</para>
///
/// <para>⛑ 面板的唯一点击入口是 <c>PoliceInquiryPanelStore:OnListBtnClick</c>
/// (<c>self.mgr:ClickOption(itemData.optionCfgId)</c>,<c>self.mgr = gPoliceJobManager.examineMgr</c>)
/// ⇒ **必须换 <c>ClickOption</c> 本身**,只换 <c>ClickOption_Story</c> 有可能整条链都走不到。
/// 两个都换,双保险。</para>
///
/// <para>修法:真的调 <c>cs:ClickOption_Story</c>,同时把 optionId 回执给服务端;
/// 服务端再推 <c>SyncPoliceExamReaction</c>(64480222)(主线注释里"唯一能让面板有反应的帧")。
/// ⛑ 必须先写 <c>self.PreOptionId</c> —— 客户端 <c>OnReceiveReaction</c> 靠它取 option 配置。</para>
/// </summary>
/// <summary>
/// ★★ <b>直接把「对方反应」喂给客户端</b> —— 绕开 <c>SyncPoliceExamReaction</c>(64480222)。
///
/// <para>⛑⛑ 为什么不能用那条 RPC(2026-10-01 15:0x 实机取证):</para>
/// <list type="bullet">
/// <item>客户端 <c>RPCDeserializeAuto.lua:350</c> 把它标成 <c>LuaOnly</c>,也有 reader(:3281),
/// 但**全仓没有任何 Lua 分发器**会把它交给 <c>PoliceJobManager.OnReceiveReaction_Story</c>;</item>
/// <item>官方链路里 <c>OnReceiveReaction_Story</c> 是**被 L50 story(S002) 调**的
/// (捐赠端 <c>StoryS002Codec4229938.Reaction(child, section, reaction)</c>),
/// 主线没有 S002 codec ⇒ 这条 RPC 发了等于石沉大海。</item>
/// <item>实机证据:推 reaction 后 Player.log 里 <c>[DRMK] OnReceiveReaction</c> **一行都没有**。</item>
/// </list>
/// ⇒ 反正 GATE Lua 能在客户端进程里跑任意代码,**直接调那个函数**即可,不必移植 S002。
/// </summary>
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>`,
-- 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
""";
}
/// <summary>
/// ★★ <b>初始化原生盘查数据</b>(照捐赠端 <c>PolicePrepareScript4229938</c>,
/// <c>LixiPolice.cs:390-412</c>)。
///
/// <para>捐赠端注释的要点:调**原始** <c>PrepareData_Story</c>(就像
/// <c>S002PlayerNet.EnterExam</c> 做的那样),不用自造追问都不拷南瓜 WM 的替身函数。
/// 这是"盘查面板里所有选项点了没反应"的核心补救:
/// 面板 UI 出得来,但 <c>gPoliceJobManager.examineMgr.enterExamData</c> 是空的,
/// 所以每个选项都点不动。</para>
///
/// <para>回执 <c>DrmkExamPrepared</c> 的 payload:1=ok,0=failed。</para>
/// </summary>
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
""";
}
/// <summary>
/// 生成「给 uid 挂 / 摘 F 按钮」的 Lua。⛑ uid 必须拆成
/// <c>ulong.new(low32, high32)</c>(捐赠端注释:十进制 Lua number 会被 float64 四舍五入)。
/// </summary>
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
""";
}
}