780 lines
46 KiB
C#
780 lines
46 KiB
C#
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 <= 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><=</c>/<c>>=</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
|
||
""";
|
||
}
|
||
}
|