更新时间:2026-03-27
这份文档用于沉淀 Alt+R 一键刷新功能的逆向过程、失败点和最终稳定实现路径。
相关补充:
- 如果你想看拍卖“查看展品”这条链路从误判、探针、时序试错到最终稳定方案的完整复盘,请继续看:拍卖刷新复盘与工程教训
- 如果你想看
v1.8.15阶段为了判断拍卖 reroll 到底卡在哪一层而整理出的断点、探针和验证清单,请继续看:拍卖刷新断点研究
这次工作的目标很明确:
- 不增加新的高频运行负担
- 不引入组件注入和后台扫描
- 只复用原生控制器已经存在的方法
- 让刷新逻辑在当前打开的界面中直接生效
换句话说,这不是“做一个万能 UI 工具层”,而是做一个足够轻、足够稳、方便后续继续扩展的最小实现。
1.8 版本引入了上下文快捷键:
=:插件 ON / OFFAlt+R:尝试刷新当前支持的界面
当前对外按三类能力描述:
- 突破刷新
- 拍卖刷新
- 锻造刷新
如果按代码入口拆分,实际命中的是多条原生控制器 / 流程入口:
SpeEnhanceEquipControllerBreakThroughController- 拍卖事件链路下的
PlotController/ChooseController CraftUIController/PlotController.FinishCraft()
插件的输入路径仍然沿用当前项目已验证稳定的方案:
Camera.FireOnPostRender+PollInput- 使用
GetKeyDown、防抖、单帧去重
这样做的原因是:
- 项目里已经证明这条路径比额外注入
MonoBehaviour更稳 - 不需要新建组件,不引入场景生命周期风险
- 只有按键发生时才会进一步走刷新分支
首版实现部署后,按键会有蜂鸣提示,但没有实际效果,日志里出现了典型告警:
AccessTools.Field: Could not find field for type SpeEnhanceEquipController and name speEnhanceEquipUIAccessTools.Field: Could not find field for type BreakThroughController and name breakThroughPanelAccessTools.Field: Could not find field for type AuctionController and name auctionPanel[RisingFame] Refresh skipped: no supported panel
这说明两个事实:
- 快捷键逻辑本身已经跑到了
- 问题出在“如何识别当前界面”和“如何调用原生刷新入口”
也就是说,故障不在输入,不在热键,不在蜂鸣,而在反射目标选错了。
这次定位没有继续靠猜字段名,而是直接对游戏的 interop 代理程序集做定向检查。
研究基线如下。
路径表述说明:
- 下文统一以游戏根目录为基准,记作:
LongYinLiZhiZhuan/BepInEx/interop/Assembly-CSharp.dll
实际使用的方法:
- 新建一个临时
net8.0控制台工程 - 直接引用游戏 interop 程序集
- 补上
Il2CppInterop.Runtime.dll - 用
typeof(...)+ 反射枚举控制器的属性、方法和签名 - 只记录结论,不把临时探针工具提交进仓库
这样做的好处是:
- 避开了 IL2CPP 运行时上下文里“不好直接全局扫类型”的问题
- 可以得到比猜测更可靠的“真实属性 / 方法签名”
- 结论足够精确,后续实现可以改成强类型调用
确认到的关键属性:
InstancespeEnhanceEquipUI
确认到的关键方法:
CanEnhance()ClearAllChoice()GenerateChoice()RefreshEnhanceButtonState()
结论:
- 特殊强化界面可以通过
Instance直接拿单例 - 当前界面是否打开,可以通过
speEnhanceEquipUI.activeInHierarchy判断 - 刷新逻辑并不需要自己重建 UI,只要调用原生“清空 -> 重生成 -> 刷新按钮状态”这条链
确认到的关键属性:
InstancebreakThroughPaneltargetSkill
确认到的关键方法:
StartShowBreakChoice()RefreshExtraRateInfo()
结论:
- 突破界面是否有效,不仅要看面板是否打开,还要看
targetSkill是否存在 - 这类刷新应直接复用原生“重新展示词条”的入口,而不是手写一个替代流程
确认到的关键属性:
InstanceauctionPanelheroListplayerSellItemendMatchCallPlotauctionDifficultyhavePlayerauctionKeeper
确认到的关键方法:
RestartAuction(List<HeroData>, ItemListData, ItemData, string, float, bool, string)
这里最重要的发现有两个:
RestartAuction(...)的第二个参数不是当前显示用的List<ItemData>,而是ItemListData- 控制器本身暴露的是
auctionItemList : List<ItemData>,不能直接拿来当重开参数
确认到的关键属性:
InstancetempPlotShop
结论:
- 当前拍卖流程使用的原始货池,实际更接近
PlotController.Instance.tempPlotShop - 它的类型正好是
ItemListData - 因此拍卖重开时,应把这个对象作为
RestartAuction(...)的第二个参数传回原生流程
这次问题本质上有三层。
初版实现使用的是:
AccessTools.Field(type, "speEnhanceEquipUI")AccessTools.Field(type, "breakThroughPanel")AccessTools.Field(type, "auctionPanel")
但对当前游戏这份 interop 代理来说,这些成员是属性,不是可直接反射到的普通字段。
因此:
- HarmonyX 日志会持续报
Could not find field - 面板活跃检测永远失败
- 结果就是逻辑最终落到
Refresh skipped: no supported panel
初版里拍卖分支曾把:
auctionKeeperauctionDifficultyhavePlayerendMatchCallPlot
这几个参数的位置传错。
即使界面检测正确,参数顺序不对也会让原生方法无法得到正确上下文。
AuctionController.auctionItemList 是当前显示用的 List<ItemData>,而 RestartAuction(...) 需要的是 ItemListData。
这不是简单的类型转换问题,而是“当前显示缓存”与“流程源数据”不是同一层对象。
最终确认后,正确的原始数据来源是:
PlotController.Instance.tempPlotShop
仍然保持轻量:
Camera.FireOnPostRender后置调用PollInputAlt按住 +R单次触发- 防抖 + 单帧去重
结论:
- 刷新逻辑只在用户按键时进入
- 平时没有后台巡检或额外场景扫描
最终逻辑:
SpeEnhanceEquipController.Instance != nullinstance.speEnhanceEquipUI.activeInHierarchyinstance.CanEnhance()- 依次调用:
ClearAllChoice()GenerateChoice()RefreshEnhanceButtonState()
优点:
- 完全复用原生控制器已有方法
- 不额外生成临时对象
- 逻辑非常接近玩家自己重新打开界面的原生结果
当前稳定实现(v1.8.16):
BreakThroughController.Instance != nullinstance.breakThroughPanel.activeInHierarchyinstance.targetSkill != null- 刷新前,只清理突破原生槽位下残留的
BreakThroughChoiceIcon(Clone) - 仅对本次刷新临时缩短原生展示粒子时长
- 调用:
StartShowBreakChoice()RefreshExtraRateInfo()
优点:
- 复用了原生词条展示入口
- 刷新后还能同步额外概率 / 加成信息
- 只清理突破界面自身的旧 clone,不会误删共享
ChoosePanel / ChooseItemList - 解决了“旧 icon 叠层”和“刷新后再打开阅读武学界面空白”这两个同源问题
为什么这次要多做“只清 clone”这一步:
- 运行态日志已经明确看到
BreakThroughIconPos下会出现成对的BreakThroughChoiceIcon(Clone),一个可见、一个隐藏 - 这说明旧 icon 没有被销毁,只是被留在原位,继续叠在新的按钮下面
- 如果只重跑展示入口而不先清理旧 clone,就会同时留下 UI 叠层和“点到旧结果”的风险
- 更早那种“顺手清共享选择容器”的做法虽然能暂时看起来更干净,但会误伤阅读武学等共用
ChooseController路径 - 因此最终收口回到了更小、更原生的范围:只清突破槽位里的旧 clone,再走原生刷新入口
最终逻辑:
- 仍然基于当前拍卖事件的原生
PlotController/ChooseController上下文 - 识别并复用
ShowAuctionItem对应的原生选择入口 - 在按键后只走一段很短的原生时序:重置展示容器、关闭选择面板、再确保展品界面最终重新打开
- 让拍品重新掷骰,同时避免“只复制旧展品”或“累计旧容器内容”
优点:
- 不重写拍卖业务链,而是复用当前事件上下文里的原生入口
- 重点解决了“随机数据”和“显示容器”不同步的问题
- 最终表现更接近玩家手动关闭再重新查看展品
拍卖这部分的完整逆向、误判与时序复盘已单独整理在:
最终逻辑:
CraftUIController.Instance != nullinstance.creaftUIPanel.activeInHierarchyinstance.craftResultList != null && instance.craftResultList.Count > 0PlotController.Instance != null- 先清空当前结果列表,再调用
plot.FinishCraft()
优点:
- 命中了“打造耗时结束后”的结果生成阶段
- 不会重新点击打造确认,不会再走一轮打造耗时
- 不会因为刷新而重复扣钱
- 逻辑足够轻,只在结果界面按键时触发
这次实现刻意避免了几个过去容易引发兼容风险的方向:
- 不新增 Harmony 高频 Patch
- 不往场景里注入自定义组件
- 不做后台扫描查找界面
- 不做反射式“盲猜路径”重建流程
- 不维护常驻后台状态机
目前的刷新系统只做三件事:
- 等用户按键
- 判断当前是否处在支持界面
- 调用对应原生控制器的现成方法,必要时只走一段短时序原生流程
因此它的运行成本几乎全部集中在“按键瞬间”,不会在平时持续吞性能。
这次逆向得到的经验,对后续功能扩展很有价值。
很多 interop 代理类会把游戏对象暴露成属性:
InstancexxxPanelxxxUItargetSkill
如果上来就用 AccessTools.Field(...) 猜,很容易在日志里踩一地告警。
比起自己重建逻辑,更稳的做法通常是:
- 找单例控制器
- 找它已经存在的业务方法
- 用原生数据重新走一次原生流程
这也是这次 Alt+R 最终能稳定落地的关键。
List<ItemData> 和 ItemListData 看起来都像“物品列表”,但职责完全不同。
后续如果做:
- 黑市刷新
- 商店刷新
- 藏经阁刷新
- 锻造 / 重铸类刷新
都要优先先分清:
- 当前 UI 上显示的缓存是谁
- 真正驱动流程重开的源数据是谁
如果是锻造 / 重铸这类系统,还要先确认:
- 随机结果是在“点击确认时”生成
- 还是在“耗时结束 / 结算完成时”生成
这个判断一旦错位,就会出现“刷新一次,重复扣一次钱 / 重走一次时间”的假成功实现。
这次刷新功能只做了蜂鸣和简短日志,没有接入更丰富的原生提示。
如果后续要做“更像游戏原生体验”的版本,建议继续研究:
InfoControllerSpeShowControllerWorldNews/ 世界消息相关管线
这部分可与现有文档联动参考:
doc/native-hook-research.mddoc/p0-qol-pipe-research.md
Alt+R 这次能稳定落地,核心并不是“多写了多少代码”,而是把调用点找对了。
最终结论可以概括成三句话:
- 特殊强化、突破、拍卖、锻造都存在可复用的原生控制器入口
- IL2CPP interop 下要优先按真实属性 / 方法签名做强类型调用
- 只在按键瞬间触发原生流程,才符合这个项目当前的稳定性原则
这套方法后续可以继续复用到更多“低风险提效”功能上,但每次都应该坚持同一个原则:
- 先确认真实原生入口
- 再决定是否值得接入
- 最后才写代码