You are viewing a potentially older version of this package. View all versions.
HawChow-EquipmentTimers-1.0.9 icon

EquipmentTimers

在原版 GTFO 装备名左侧显示状态:READY、RELOAD、近战蓄力、BioTracker 冷却、GlueGun 蓄力与哨戒炮备弹。每个完成提示音都能自定义(原生事件、角色语音、本地 WAV)。客户端只读模组。

Date uploaded 8 hours ago
Version 1.0.9
Download link HawChow-EquipmentTimers-1.0.9.zip
Downloads 4
Dependency string HawChow-EquipmentTimers-1.0.9

This mod requires the following mods to function

BepInEx-BepInExPack_GTFO-3.2.3 icon
BepInEx-BepInExPack_GTFO

BepInEx pack for GTFO. Preconfigured and includes Unity Base DLLs.

Preferred version: 3.2.3

README

EquipmentTimers

在原版 GTFO 装备列表(右上角)内部绘制常驻状态与倒计时:紧贴每一件装备名称的左侧,所有行共用一条左对齐的列。武器弹匣满时显示绿色 READY,未满时显示黄色 RELOAD,空弹匣显示红色 RELOAD,换弹过程中显示剩余秒数。BioTracker 只要扫描可用就显示绿色 READY。准星上不添加任何内容,也没有独立面板。

计时对象 标签含义
主武器 / 副武器 绿色 READY(弹匣满)、黄色 RELOAD(未满)、红色 RELOAD(空);换弹过程中显示剩余秒数
BioTracker / 职业工具 就绪时绿色 READY;冷却中显示距离下次可扫描的秒数
近战武器 仅在 MWS_ChargeUp 内存在:显示到满伤害的秒数,满后绿色 READY,m_releaseWarning 为真时显示红色 !
GlueGun 仅在按住蓄力时显示:3x 1.2s——原生连发弹数与已累计的蓄力时间

当计时真正完成时,该行会变绿并短暂显示 READY,同时播放一次原版 UI 音效。仅仅倒数到零不算完成;弹匣满 / BioTracker 就绪这类原生状态也不会播放音效。

反馈:qingju.me | 小黑盒语音

功能特性

  • 原版外观:同一 TMP 字体资源、字型与排序层,镜像原生的 transform 缩放,并按实测字形高度适配。计时字形偏矮时至少放大到原生高度,并额外留 10% 可读性余量。
  • 同时锚定原生的两种名称状态,因此既能跟随选中的完整名称,也能跟随紧凑的 slim 名称;切换装备时会重新锚定。
  • 自动跟随 HUD 缩放、分辨率变化与原生槽位重排,因为标签与原生文本是同一个 canvas 内的兄弟节点。
  • 最多四行(主武器、副武器、工具、近战),每个槽位一个标签,从不重复。
  • 倒计时/未知状态使用中性色(继承原生名称颜色);真正的完成与原生「弹匣满 / BioTracker 就绪」状态为绿色;弹匣未满为黄色;空弹匣或需要立刻挥出的蓄力为红色。
  • 共用一条左列:所有标签都左对齐(TMP Left、左 pivot)在同一条左边缘上,并且该边缘还会预留最宽标签加配置间距,使整列结束于最左侧原生名称字形左边缘的左边,而不是从那里开始。各行在世界/屏幕空间比较,再各自投影回自己的父级(含父级 anchor 参考点),因此不同的父级缩放或偏移都不会破坏对齐。
  • 纯客户端、只读:不写任何游戏数值、不发送任何网络包,换弹速度、蓄力速度、伤害与扫描冷却都不受影响。
  • 有界日志:默认每局最多 40 行(VerboseLog 时 400 行),从不逐帧记录。

哨戒炮备弹行(1.0.9)

手持哨戒炮时,该行显示你自己的职业弹药池 当前/总。当你有哨戒炮已部署时,这个比例继续显示,并在主/副武器原生弹药百分比所在的同一列上额外显示一个百分比,数值来自该已部署炮台自身的 Ammo/AmmoMaxCap(实例值不可读时,只有数值改用同步快照 LastSyncData.ammo)。容量为零/非法、数值不可读,或无法证明该炮台属于你时,只隐藏百分比——你的比例照常显示,你的备弹也绝不会被当作炮台弹药。

完成提示音(1.0.9)

BepInEx/plugins/EquipmentTimers/Assets/Audio/ready-sounds.json 决定每个计时完成时播放什么声音。随包默认:BioTracker 播放当前角色自己的原生 Scan 台词(PLAY_CL_SCAN,经游戏语音系统),武器与近战 cue 沿用此前的原生 ReadySoundEvent。每个 cue 可设为:

  • native——配置中的 ReadySoundEvent(原有行为);
  • playerVoice——角色原生台词(PLAY_CL_SCAN);
  • wwise——白名单内的原版 Wwise 事件;
  • wav——该目录下你自己的文件(PCM 8/16/24/32-bit 或 float32,单/双声道,≤ 30 秒,≤ 32 MB);
  • none——静音(绿色 READY 标签仍会出现)。

自定义文件只从该目录读取、只在本地播放:不下载、不发网络包,也不修改游戏数据。文件损坏或缺失时只记一条警告并回退到原生事件。该目录的 README.txt 说明了格式;修改后请重启 GTFO。

安装

  1. 安装 BepInExPack_GTFO(唯一依赖)。
  2. 把 EquipmentTimers.dll 放进 BepInEx/plugins/(本包已使用 BepInEx/plugins/EquipmentTimers/EquipmentTimers.dll 这一布局)。
  3. 启动 GTFO。每个已占用且启用的装备行都会显示其原生状态;武器与 BioTracker 空闲时显示 READY/RELOAD,换弹/蓄力/冷却时显示倒计时。

配置

BepInEx/config/HawChow.EquipmentTimers.cfg(也可用配置编辑器):

段 键 默认 说明
General Enabled true 总开关;关闭后立刻隐藏全部标签
General ShowMeleeCharge true 近战蓄力倒计时
General ShowReload true 换弹倒计时
General ShowScanner true BioTracker 冷却倒计时
General ShowGlueGun true GlueGun 连发弹数与已累计蓄力时间
General ShowSentryAmmo true 哨戒炮行:手持显示职业弹药池 当前/总,已部署时同时显示炮台实例的弹药百分比
General PlayReadySound true 是否播放一次完成提示音
General ReadySoundEvent IMPLANTREADY 原版 Wwise 事件:IMPLANTREADY、IMPLANTREADYSMALL、HUD_SCAN_COMPLETE_INDICATION、BUTTONGENERICBLIPACTIVATED、BUTTONGENERICBLIPONE、None
General CustomReadySounds true 是否读取 Assets/Audio/ready-sounds.json;false 时全部 cue 都走 ReadySoundEvent
General ReadyHoldSeconds 0.6 READY 在屏幕上的保持时长(0.2–3 秒)
General OverrunGraceSeconds 2.5 原生动作迟迟不结束时的静默丢弃阈值(0.5–10 秒)
Layout GapRatio 0.22 与原生名称的间距,按原生字号的比例
Layout MinGap / MaxGap 2 / 40 该间距的上下限(canvas 单位)
Layout LabelWidthRatio 4 标签框宽度,字号倍数
Layout MinLabelWidth 24 该宽度的下限
Layout ReadyColor #6BF25A #RRGGBB 或 r,g,b(0-1 或 0-255)
Layout NeutralAlpha 0.92 中性色倒计时的透明度
Debug VerboseLog false 日志预算提高到 400 行

已知限制与精度

  • GTFO 没有暴露换弹时间戳:只有 IsReloading 与 ReloadTime。第一段换弹从 IsReloading 上升沿起按 ReloadTime 倒数;之后每一段用两次插入之间的实测间隔估算,并在文本前加 ~(例如 ~0.5s),因此估算值不会被误认为原生窗口。只有当原生会话结束、确实完成时该行才会变绿。
  • 换弹只有在弹匣已满或本段会话中弹匣增长且开始时非空的备弹现在确实为空(可证明的自然耗尽)时才算完成。因此换弹中途开火绝不会让该行变绿,读取不到的备弹也绝不会被假定为空。
  • 倒计时归零但原生动作仍在进行时显示 -- 而不是猜测值,同时继续观察;采样被取消/读取失败期间保持 --;被中断的有效动作在其终止帧显示 --,随后回到中性 idle READY。之后真正到来的完成仍会变绿并只响一次。
  • 主菜单中,原生 HUD 每秒会重建自身数千次(实测约 3.5 kHz),并且传出的 HUD 实例经常已经销毁。本 MOD 只把这些调用当作失效提示:从不缓存、从不解引用,而是通过限频探测重新获取 HUD。这正是 2026-10-25 启动崩溃的路径;修复后菜单可连续稳定 211–217 秒(见项目内 reports/runtime-startup-failure.md)。
  • 在 MOD 能观察到之前就已经开始的蓄力或换弹(关卡中途注入、关卡加载时正在换弹)仍会显示数字,但会静默结束——这是设计如此:MOD 没有亲眼看到开始的完成,绝不播放提示音。
  • 工具倒计时跟随扫描仪自身的 m_recharging / m_rechargeStartTime / m_rechargeDuration;不使用这些字段的模组工具不会显示倒计时。
  • GlueGun 读数只在原生蓄力状态运行时绘制(m_fireButtonDown && !m_reCharging && !m_firing && m_pressure > 0)。连发弹数取原生 GetShotCount() 对游戏所用同一归一化压力的回答;低于 m_pressureProgressToFire 时武器还不能开火,因此计数为 0。读不到计数(无 owner、分母退化、任何 interop 拒绝)时显示中性占位符,而不是编造数字。
  • GlueGun 的时钟是已经过去的蓄力时间(m_pressure x TimeToMaxpressure),绝不是剩余时间;蓄力一结束该行立即消失——没有 idle READY,也没有倒计时。
  • 近战行只在 MWS_ChargeUp 内存在;离开该状态(挥出、取消、换武器)时在同一帧隐藏,因此不会残留 --、idle READY 或过期绿色。
  • 每个装备行只读取自己的槽位:PlayerBackpackManager.TryGetItem(_player.Owner, slot, out BackpackItem bp),并以 bp.IsLoaded、存活的 bp.Instance 与 item.ItemDataBlock.inventorySlot == slot 作为身份证明,因此未手持的武器/工具行也能显示其原生状态。其中任一步失败,该行就脱离并不显示任何内容,而不是使用缓存猜测;近战行保留已验证的 FPItemHolder.m_meleeWeapon 兜底,用于背包实例缺失或不是装备的情况。
  • 近战「满蓄力保持」时间有意不显示:计时在满蓄力时停止。

验证状态(v1.0.9)

  • 固定 SDK 的 Release 构建、无头计时/状态映射/音频/生命周期测试(560 条断言)、图标、manifest、程序集元数据与源码守卫,均在已安装的 GTFO interop 集(Test profile 的 BepInEx)上通过。
  • 1.0.6 曾在 Test 中加载且标签出现,但实机检查发现标签比装备名小、空闲时不可见。1.0.7 镜像原生 TMP 缩放并按实测字形高度适配;1.0.8 增加了武器 READY/RELOAD 状态、常驻 BioTracker READY、近战蓄力状态门控与 GlueGun 弹数/蓄力行,全部在同一共享列左对齐。
  • 1.0.8 与 1.0.9 都没有声称通过实机验证:该构建仍需在 GTFO 内做安装后检查。对 1.0.9 而言,这包括原生 Scan 提示音与实际用户 WAV 的听感/音量,以及哨戒炮百分比在屏幕上的位置——这三项在此均为静态结论。
  • 1.0.0 的主菜单运行及其 211/217 秒稳定性结果属于历史记录,不能用于验证本构建。
  • 包内未附带实机截图。包图标是美术作品,不构成实机渲染证据。

致谢与许可

  • 行为参考:Zose 的 ReloadTimerPlus 与 long_walter 的 MeleeTimer。未使用 ReloadTimerPlus 的任何代码或素材(其发布时没有明确许可)。MeleeTimer 为 MIT 许可,在此致谢其「remaining = AutoAttackTime − elapsed,字体取自原生 HUD」的思路;本 MOD 自行实现 tracker、HUD 锚定与完成判定,并从实时蓄力状态读取 MWS_ChargeUp.m_maxDamageTime,而不是读取动画数据块。
  • 此处使用的名称、字段与事件均为游戏自身(PUI_InventoryItem、Gear.MWS_ChargeUp、ItemEquippable、Gear.EnemyScanner、AK.EVENTS);本包不重新分发任何游戏程序集。

CHANGELOG

更新日志

1.0.9

  • 哨戒炮工具行(TimerKind.SentryAmmo,独立行类型,因此永远不会被当成换弹):两项信息同时显示,绝不互相替代。只要可读,玩家自己的职业弹药池始终以 当前/总 显示(PlayerBackpackManager.GetBulletsInPack/GetAmmoMaxCap(AmmoType.Class, SNet_Player)),无论手持还是已部署。当该玩家的背包也报告了已放置的哨戒炮(PlayerBackpack.IsLocal、IsDeployed(GearClass)、TryGetDeployedItem(GearClass, out Item)、SentryGunInstance.m_isDeployed)时,第二个标签再以 NN% 显示该物件自身的弹药,数值来自它自己的 Ammo/AmmoMaxCap(实例值不可读时,只有数值改用同步快照 m_sync.LastSyncData.ammo),例如 40/80 与 72% 并存。容量为零或更低、负数/NaN、背包缺失或任何 getter 失败,都只隐藏百分比——只要玩家比例可读,它就继续显示。不回写任何内容:不写弹药字段、不写背包字段、不发 SNet 包。
  • 哨戒炮百分比是一条真实列,而不是猜出来的坐标:每次槽位扫描都会测量主/副武器行原生弹药总数 TMP,并把百分比左边缘经该行自己的父级投影到那个世界 X 上。测不到原生弹药文本时,百分比就不绘制。
  • 完成提示音现在可按 cue 在 BepInEx/plugins/EquipmentTimers/Assets/Audio/ready-sounds.json 中编辑:native(原有 ReadySoundEvent)、playerVoice(经 PlayerVoiceManager.WantToSay(playerID, eventID) 播放角色自己的原生台词)、wwise(白名单内的原版事件)、wav(JSON 旁的本地文件,PCM 8/16/24/32-bit 或 float32,单/双声道,≤ 30 秒且 ≤ 32 MB)或 none。随包默认:BioTracker 完成时播放当前角色的原生 PLAY_CL_SCAN 命令列表台词;武器与近战 cue 保持 1.0.8 的原生事件,因此除非玩家编辑该文件,其他行为不变。PlayReadySound 仍是总开关,ReadySoundEvent 仍是回退值;CustomReadySounds=false 会完全忽略 JSON。
  • 自定义 WAV 从磁盘解码,并在单一隔离适配层(Audio/ReadySoundPlayer.cs)中作为本地 2D 一次性音效播放;不使用 UnityWebRequest、不下载、不发自定义网络包、不修改游戏字段。绝对路径、盘符、.. 与流名一律拒绝;文件缺失或损坏时每个文件只记一条警告,并回退到原生事件。
  • 移除切装闪烁:被替换的原生槽位物件不再销毁该行的 TextMeshPro(RebindUiItem);重绑保持同一实例、同一文本,并在字体未变时保留其字形 mesh,而不是隐藏/清空;当持有的锚点不再渲染时,锚点 latch 可以立即切换(重绑不再需要重建)。背包条目或原生名称短暂缺失一两帧时,用有界宽限(0.25 秒 / 0.18 秒)桥接,之后该行照旧隐藏,因此旧物品文本绝不会残留。
  • 测试:新增 132 条纯逻辑断言(428 -> 560),覆盖哨戒炮双态策略(手持比例与部署百分比并存、容量与弹药为零/负数/NaN/Inf、钳制、显示映射)、按类型的 tracker/cue 分派、cue 映射(默认值、逐 cue 覆盖、未知类型、未知 cue、目录穿越/绝对路径/流名/非 wav 拒绝、音量钳制、截断与非对象文档)、WAV 解码器(16-bit 单/双声道、>2 声道、不支持的位深、截断的 data 块、缺失 RIFF、空数据)、音频退役策略以及有界宽限。源码守卫现在会校验音频适配层隔离、随包 cue 映射、哨戒炮双态读取、真实挂载点,以及重绑绝不清空或销毁标签。
  • 审查修复(P0):1.0.9 同时修复了挂载本身。Plugin.Load 注册了注入的 IL2CPP 类型却从未添加它,导致 Awake/Update 从不运行,也就没有标签、哨戒炮行或提示音。现在插件在 ClassInjector.RegisterTypeInIl2Cpp<...>() 之后立即调用 AddComponent<EquipmentTimerOverlay>(),通过单实例 MountOverlay() 辅助方法挂在插件自己的 DontDestroyOnLoad 对象上,并在 Unload() 中销毁它(其 OnDestroy 运行 TimerController.Shutdown,正是释放标签、哨戒炮百分比与原生缓存句柄的地方)。Verify.ps1 现在校验真实挂载点及其顺序,而不再只看注册。
  • 审查修复:已部署哨戒炮的查询使用该行自己的库存槽位(IsDeployed(slot)、TryGetDeployedItem(slot, ...)),而不是假定 GearClass,因为 KindOf 接受任意装备槽位中的 SentryGunFirstPerson;槽位无法识别的行不显示百分比。
  • 审查修复:哨戒炮百分比标签现在会在其行锚点的父级发生变化(HUD 重建 / 兄弟节点切换)时,先重挂到该锚点当前父级,再做任何 anchoredPosition 运算;并在字号/字型不同时随时复制锚点的实时值——不逐帧销毁,除非字体真的变了也不逐帧重建。
  • 审查修复:被淘汰的 AudioClip 以截止时间「退役」(ClipRetentionPolicy:音频长度 + 1 秒,带夹取),只有在截止时间已过且音源未在播放时才销毁,因此一次性音效绝不会被截断;Shutdown() 统一释放缓存与退役的音频。
  • 审查修复:如果无法确定插件的绝对目录,音效层拒绝解析任何路径(ReadySoundPaths.IsUsableRoot),因此 WAV 绝不会相对进程工作目录被拾取;cue 映射保持内置默认,播放回退到原生事件。
  • 版本载体(PluginInfo.cs、AssemblyInfo.cs、csproj、manifest)、包成员清单(现在还包含 Assets/Audio/ready-sounds.json 与 Assets/Audio/README.txt)、Verify.ps1、Build-Package.ps1、Install-TestProfile.ps1(资源放在 DLL 旁,已存在的用户 cue 映射 / WAV 永不覆盖)以及两个 README 都已更新到 1.0.9。
  • 未在游戏内验证:游戏为每个角色实际选择的台词、原生语音与用户 WAV 的听感响度/混音、以及百分比在实机 HUD 上的屏幕位置,均属静态结论,仍需一次实机验证。

1.0.8

  • 武器行现在读取原生弹匣,而不再只做倒数:ClipIsFull() 为真的满弹匣 -> 常驻绿色 READY;部分弹匣 -> 黄色 RELOAD;空弹匣 -> 红色 RELOAD;原生换弹期间仍显示倒计时。GetCurrentClip()/GetMaxClip() 任一读取失败时保持中性 --,绝不宣称任何状态。
  • 只要 m_recharging 为假,BioTracker 就是常驻绿色 READY,因为那正是原生「扫描可用」状态。它和满弹匣 READY 都不能播放提示音:cue 仍然只跟随 TimerTracker.Completions。
  • 近战行只在 MWS_ChargeUp 内存在。蓄力过程中显示蓄力倒计时,蓄力完成显示绿色 READY,m_releaseWarning 为真显示红色 !。离开蓄力状态(挥出、取消、换武器)会在同一帧隐藏该行,因此不会有 --、idle READY 或过期绿色残留在挥击之后。
  • GlueGun(Gear.GlueGun,已对照已安装 interop 验证:GetShotCount(float) 对应原生 RVA 0x139A060,Updatepressure 对应 0x139B300):仅在原生蓄力状态真正运行时(m_fireButtonDown && !m_reCharging && !m_firing && m_pressure > 0)显示一行 Nx T.Ts,其中 N 是原生 GetShotCount((m_pressure - m_pressureProgressToFire) / (1 - m_pressureProgressToFire)) 的返回值,T 是 m_pressure * TimeToMaxpressure——即已经过去的蓄力时间,绝不是剩余时间。低于 m_pressureProgressToFire 时计数为 0(武器还不能开火);读不到计数(Owner 为空 / 分母退化)时保留中性占位符。不回写任何内容,也从不调用 FireBurst。KindOf 现在会在 GetMaxClip 换弹兜底之前识别 GlueGun。
  • 所有标签使用 TMP Left 对齐与左 pivot,并且每帧共用同一条左列;该列由四个装备槽位的原生字形边缘推导,再投影回各自父级,因此不同父级缩放或偏移不会跨父级比较 anchoredPosition。
  • 同一 1.0.8 候选上的审查修复:共享列现在还会预留每个标签的实测文本宽度,使整列(sharedLeft + 最宽标签)结束于最左侧原生名称左边一个间距处,而不是从那里开始——长标签再也不会压到名称。世界坐标到父级的转换会折出每行自己的 anchor 参考点(rect.xMin + anchor.x * rect.width),因此非居中 pivot 或拉伸 anchor 不再让某行偏移,逐行兜底也保持同样的不重叠保证。每个装备行现在都通过原生背包读取自己的槽位:PlayerBackpackManager.TryGetItem(_player.Owner, slot, out BackpackItem bp),并以 bp.IsLoaded、存活的 bp.Instance 与 item.ItemDataBlock.inventorySlot == slot 作为身份证明,因此未手持的武器/工具行也能显示真实状态;任一步失败就脱离该行,而不是绘制缓存猜测;近战行保留已验证的 FirstPersonItemHolder.m_meleeWeapon 兜底,用于背包实例缺失或不是装备的情况。Install-TestProfile.ps1 的措辞/断言现在明确写出包布局副本(BepInEx/plugins/EquipmentTimers/EquipmentTimers.dll)与唯一的 r2modman 管理副本之间的关系,因此不可能出现两个可加载 DLL。
  • 测试:1.0.8 候选新增 135 条纯逻辑断言(293 -> 428),覆盖弹匣状态、BioTracker 就绪状态、近战蓄力状态门控(含 m_releaseWarning)、GlueGun 门控 / 归一化压力 / 开火阈值 / 已过蓄力时钟 / 含 NaN、Inf 与零分母的一行格式、共享列不等式(sharedLeft + textWidth <= nameLeft - gap)以及不同父级缩放、偏移与非居中 anchor 下的 anchor 参考点放置几何,另有逐槽位背包绑定矩阵(绑定 / 过期 / 已销毁 / 非装备 / 身份不匹配 / 近战 holder 兜底)。源码守卫已更新到新 API(TimerDisplayPolicy、SlotItemRules、TimerLayout.TryColumnLeftEdge/ColumnLimit/TryPlaceLeftEdge/AnchorReferenceLocalX、TextAlignmentOptions.Left)。
  • TimerRowTone 贯通 TimerRowView、TimerDisplayPolicy(新增、纯逻辑)、TimerStyle.Tone 与 TimerSlotRow,因此颜色只在一处由原生状态决定。

1.0.7

  • 让每个已占用且启用的装备行保持可见:空闲时中性 READY,换弹/蓄力/冷却时显示实时倒计时;采样被取消/失败或过冲不可信时显示中性 --;被中断动作在其终止帧显示 --。真正观察到的完成仍为绿色并播放一次 cue;idle READY 绝不播放。
  • 匹配原生装备名称高度:镜像原生 TMP 的 localScale,测量两侧实际渲染的字形高度(textBounds.size.y × lossyScale.y);当计时字形偏矮时,按实测比例放大并额外加 10% 可读性余量。适配只会放大,绝不缩小标签。
  • 将近战的 Done 沿视为终止状态,并在满蓄力时停止倒计时;继续按住满蓄力挥击不再重开会话,也不再产生重复的抑制完成。
  • 用用户提供的粉发 BioTracker/计时器美术替换生成的占位图标,居中并重采样为真正的 256×256 PNG。项目中保留 icon-source.png;包内只含缩放后的 icon.png。
  • 使用固定 ZIP 条目时间戳与固定的图标源/输出 SHA-256 值构建可复现归档。

1.0.6

  • 修复 1.0.5 全部隐藏的回归:Test 配置加载了 1.0.5,但活跃的 GearSpecial/GearClass 行反复报告 chars=-1 vtx=0,导致字形门控从未打开。
  • 在 AddComponent<TextMeshPro> 之前保持标签 GameObject 非激活,以防空的默认 quad。非空文本到达时,在禁用 MeshRenderer 的状态下激活它,让 TMP 执行 Awake/OnEnable,然后调用 ForceMeshUpdate;只有字符与顶点都有效后才启用渲染。
  • 在有界 mesh 重试期间保持带文本的 TMP 对象激活,同时其 renderer 保持禁用。这避免了重复的激活循环,也不可能暴露空 quad。
  • 审阅了本地缓存的 WeaponIconPlus 程序集:它的 Harmony postfix 修改已有的 PUI_InventoryItem.m_selected_icon SpriteRenderer(尺寸/启用/颜色),并不创建替代的 UI 标签。未复制任何代码或素材。

1.0.5

  • 没有文本与真实字形,任何标签都不允许上屏。 1.0.4 的日志显示四行始终只采样到 active=0 text='',mesh 从 chars=0 vtx=4 走到 chars=4 vtx=32,发生十一次 mesh 变更,material/shader 与原生 3D TMP 完全一致——同时屏幕上出现了一条短白色横线。
  • 1.0.4 的生命周期确实能产生这样一帧:标签 GameObject 以 ACTIVE 状态创建,AddComponent<TextMeshPro> 构建了 TMP 的默认(空)quad,而该对象直到创建路径最后才被停用;DestroyLabel 使用裸 Object.Destroy(延迟到帧末),且 renderer 仍处于启用状态;mesh 失败又被空 catch 吞掉。这一归因是假设:日志无法把某条采样行对应到白线出现的那一帧,因此修复的目标是让任何无文本/非法 quad 都不可达,而不是去修补某一特定帧。
  • 标签现在在证明可绘制之前保持惰性:
    • GameObject 以 INACTIVE 创建,并在 AddComponent<TextMeshPro> 之后立即禁用 renderer,因此 TMP 的默认 quad 会在任何东西看到它之前被 ClearMesh 抹掉;
    • 每次文本更新与每次锚点重绑都先关闭 renderer、再清 mesh;
    • DestroyLabel 先禁用 renderer,再 SetActive(false),再 ClearMesh(),最后才调用延迟的 Object.Destroy——标签被替换的那一帧不可能残留 quad;
    • 空/纯空白文本保持隐藏并清空 mesh(TMP_Text.ClearMesh(),已确认在随包 interop 中为 public,回退到 TextMeshPro.ClearMesh(bool));
    • 非空文本只有在 textInfo.characterCount > 0 && mesh.vertexCount > 0 时才显示;否则保持隐藏,最多做三次有界重建(havePropertiesChanged + Graphic.SetAllDirty() + TextMeshPro.ForceMeshUpdate(true, true))。单独的顶点数从不被当作可读文本,因为缓冲容量可能超过 4×字符数。
  • 诊断改为延迟且诚实:旧的一次性 drawn 行会在该行仍处于非激活且空的状态下触发。现在每行改为一条 visible sample 行,只在该行变为激活且 mesh 有效之后一帧发出,携带 text、activeInHierarchy、chars、vtx、renderer.enabled/isVisible、mesh.bounds、shader、matShared 与 matInstance。最多三条 label kept hidden; <reason> 行说明某行为何拒绝显示。逐状态的 mesh 行已移除,因此四行只花几条日志,而不是耗尽 40 行预算。
  • 测试:新增 24 条纯逻辑断言(225 → 249),覆盖可见性门控(空文本永不显示、必须同时有字符与顶点、重试预算恰为 3、延迟采样要求激活+有效+下一帧)以及源码守卫(先非激活再 AddComponent、先关 renderer 再 ClearMesh、先关 renderer 再 Destroy)。

1.0.4

  • 在 1.0.3 之上修复三个 code review 阻塞项(不改玩法、不加新依赖):
  • 首次构建过早应用 rect 样式。 CreateLabel 在 _labelRect 赋值之前就调用了 ApplyRectStyle,而该辅助方法在 rect 为空时提前返回——于是新建标签会一直保留默认 pivot/anchor/sizeDelta,直到之后的某次重绑。现在先解析 RectTransform(SurfaceSync.CanApplyLayout),再应用样式,使首次构建与重绑走同一条路径。Verify.ps1 中的源码顺序守卫会在该顺序回退时让构建失败。
  • 锚点采用是事务式的。 AnchorLatch.ShouldAdopt 不再自行提交:该行先证明候选锚点有可用父级、且 RebindAnchor 真的移动了标签,然后才调用 Commit。父级缺失、SetParent 抛异常或 wrapper 已销毁时,latch 保持当前锚点并在下一帧重试,而不是把该行锁在一个它从未到达的锚点上。
  • 表面同步现在会跟随同一个原生 TMP 上的替换。 以前只修复为空的 font/material,因此原生 font / fontSharedMaterial / shader 被替换(皮肤、语言、HUD 重建)时会留下过期材质与过期 mesh。SurfaceSync 比较 font、material 与 shader 的指针,重新为标签指定,并强制重建 mesh——创建/重绑之后立即执行,此后每第 15 帧(60 fps 下约 4 Hz),从不逐帧。
  • 测试:新增 23 条纯逻辑断言(202 → 225),覆盖事务式 latch(提供 ≠ 提交、失败的重绑可重试、取消/重新武装、重置)与表面规则(font、material、shader 的相同/替换/缺失、布局就绪、mesh 重建决策)。

1.0.3

  • 标签在原生名称切换后仍然存活,并且真正栅格化出字形。 1.0.2 已能解析槽位、绑定武器并打印 drawn … visible=yes,但游戏会在两个兄弟 TMP 之间交替每个槽位的原生名称(Archetype Name / Archetype Name (1)),而该行在每次指针变化时销毁并重建标签(4 行共 25 次重建)。这种反复还让 TextMeshPro 始终无法生成字形 mesh(textInfo.characterCount 为 0、mesh.vertexCount 为 0),这正是包围盒返回 size=-4294967296、屏幕上什么都没有的原因。
  • 标签现在每行只创建一次,之后重绑而不是重建:parent、font、material、尺寸与点 anchor 就地更新,只有原生父级真的变化时才重新挂载。DestroyLabel() 不再能从锚点指针变化到达,一次性 drawn 行也不再因重绑而重置,因此 4 行只花 4 条日志而不是 25 条。
  • 锚点选择是粘性的:Diagnostics/AnchorLatch.cs 在原生 TMP 仍渲染时保持当前锚点,只有当另一个锚点连续 0.35 秒不可用后才采用它,因此逐帧闪烁无法把标签抢走。
  • mesh 生成现在在每次文本/锚点变化后通过已验证的 interop 成员强制触发——havePropertiesChanged = true、Graphic.SetAllDirty()、TMP_Text.ForceMeshUpdate(true, true)——并逐帧修复(最多 3 次重试),同时维护 font、fontSharedMaterial、font.material 回退、renderer.enabled、原生 gameObject.layer 与原生 sortingLayerID/sortingOrder。
  • 诊断:一次性 drawn 行现在以 chars=<n> vtx=<n> rend=<0/1> vis=<0/1> font=<name> mat=<name> shader=<name> 结尾,并且只有该 mesh 状态变化时才另发一行——仍是每行每状态一条,从不逐帧。
  • 新增 17 条纯逻辑断言(185 → 202)固定 latch 行为:一对存活锚点翻转 40 次产生零次重新采用,失效锚点只有在保持窗口之后才被替换,空候选与重置行为符合预期。
  • 不读取或写入任何不同的玩法数值,无新依赖,配置结构不变。

1.0.2

  • 计时标签现在真正位于 HUD 内部。 1.0.1 已能解析并绑定全部四个槽位,甚至进入绘制路径([hud] slot Gear* drawn; … visible=yes),但标签在屏幕上不可见:它复制了原生名称的拉伸 anchor 范围(anchorMin/anchorMax),同时又设置固定 sizeDelta。当 anchorMin != anchorMax 时,Unity 会把 anchor 跨度加到 sizeDelta 上(得到父级宽的矩形),并从 Lerp(anchorMin, anchorMax, pivot) 量取 anchoredPosition——当 pivot 为 (1, 0.5) 时,该参考点是跨度的远端边缘,于是标签被放到最远达半个父级宽的位置。现在标签把原生锚点对折叠成单点 anchor anchorMin = anchorMax = Lerp(native.anchorMin, native.anchorMax, native.pivot),这正是原生 anchoredPosition 已经使用的参考点;标签保持固定尺寸,其右边缘位于原生 rect 左边缘左侧一个间距处、垂直居中。只有当原生 anchor 真的移动时才重新镜像该点 anchor。
  • 一次性 drawn 行现在携带能区分「跑到屏幕外」与「从未放置」的几何:anchor、pivot、anchoredPosition、sizeDelta、rect、lossyScale、activeInHierarchy、TMP enabled、canvas render mode、父级 CanvasGroup alpha、WorldToScreenPoint 与文本 bounds。仍然严格每行一条,从不逐帧。
  • 新增 18 条纯布局断言(167 → 185),覆盖拉伸 anchor、非居中 pivot、间距/宽度钳制与精确的点坐标。
  • 不读取或写入任何不同的玩法数值,无新依赖,配置结构不变。

1.0.1

  • HUD 现在真正出现了。 原生槽位表改用 ContainsKey + 索引器读取,而不是 Dictionary<InventorySlot, PUI_InventoryItem>.TryGetValue。在 1.0.0 中,每次槽位读取都会从该泛型 by-ref 调用中抛出原生 MissingMethodException([hud] slot GearStandard unavailable: MissingMethodException,2026-10-25 11:48 会话),因此从未创建任何行,也就什么都没绘制。TryGetValue 仅作为受保护的回退保留,并在本会话内记住可用的查找方式。
  • 诊断同样有界且从不逐帧:槽位读取失败时记录拒绝的 interop 成员及其 il2cpp 消息(每个槽位一次),而不只是异常类型;某行真正绘制时第一次记录 slot、kind、anchor、可见性与文本,从而把「没有 HUD」与「没有计时活动」区分开。
  • 不读取任何不同的玩法数值,无新依赖,配置结构不变。

1.0.0

  • 首个发布版本。
  • 到近战满蓄力的倒计时(MWS_ChargeUp.m_maxDamageTime - m_elapsed),在满蓄力时停止:不额外增加保持计时。
  • 主武器与副武器的换弹倒计时,由原生 IsReloading 沿与 ReloadTime 构建,每插入一发就重启,并与 GetCurrentClip() 增长或 ClipIsFull() 对齐。
  • BioTracker 冷却倒计时直接来自 m_recharging、m_rechargeStartTime 与 m_rechargeDuration。
  • 每次真正完成时给出一行绿色 READY 状态加恰好一次原版 Wwise cue。取消、中断、换武器、备弹耗尽,以及在 MOD 开始观察之前就已开始的会话,都保持静默。
  • 标签是原生 TMP 装备名称的兄弟节点,同时跟随选中名称与 slim 名称、HUD 缩放与原生槽位重排。最多四行,每槽一行。
  • 配置:按计时器启用、完成事件选择、READY 保持时长、过冲宽限、间距/宽度比例、READY 颜色、透明度以及有界日志预算。
  • 纯客户端且只读;不写任何玩法数值,也不增加任何网络流量。

审查加固(同一 1.0.0,发布前)

  • 已销毁的原生对象不再能把异常抛进 Harmony postfix:所有 HUD/行的原生访问都有保护,即使清理本身失败也会清空引用,一行损坏绝不阻断其他行,缓存获取按有界频率重试而不是永久失败。
  • 换弹完成现在要求弹匣已满或可证明的备弹耗尽;换弹中途开火不再让该行变绿或发声。
  • 在 READY 窗口内开始的新动作会在同一帧收回绿色,并从它自己的起点开始计时。
  • 近战完成也接受 m_elapsed >= m_maxDamageTime 的原生进度,用于蓄力状态翻转的那一帧,同时绝不凭空制造完成。
  • 标签 X 锚定到原生文本真实字形边缘(textBounds),并以 rect 兜底;尺寸/pivot/anchor 只写一次,而不是逐帧写。
  • 逐发换弹的分段由两次插入之间的实测间隔估算,并标记为近似值。
  • 过冲的倒计时只隐藏其数字,并继续观察真正的完成。
  • cue 投递尊重 None(完全不投递),不再被全局 0.05 秒窗口门控,同一帧完成的两行各自投递一次。
  • VerboseLog 现在真正选择 40/400 行日志预算,超出预算时连消息都不构造。
  • VISUAL-CONTRACT.md 中「行只画在 HUD 显示的某个名称旁」现在由 Empty 状态门控强制,行被移除时丢弃槽位缓存,安装脚本在未提供备份目录时回退到临时备份目录。

运行时启动崩溃修复(同一 1.0.0,发布前)

  • 用隔离探针定位了 2026-10-25 的启动崩溃(coreclr +0x1D1FDD、0xC0000005、15-21 秒、4/4 次复现):单独启用 Harmony 钩子(358,600 次 PUI_Inventory.Setup 调用,约 3.5 kHz)与单独启用注入 MonoBehavior 都能存活;崩溃需要那条逐帧 HUD postfix 路径——它采用了实例、随后又解引用其 gameObject。在这些探针中 __instance != null 返回 False,即菜单交出的实例往往已经销毁。
  • postfix 现在只是纯粹的失效提示:实例从不被缓存、比较或解引用,HUD 句柄通过 0.5 秒限频探测与受保护读取重新获取。
  • 注入的 MonoBehavior 现在是无字段泵;所有状态与逻辑都放在普通托管的 TimerController 中,这同时消除了四条「unsupported parameter」interop 警告。
  • ResetRows 在无内容可重置时是空操作,其日志行经 RepeatGate 去重,因此逐帧原生重建无法再耗掉日志预算。
  • 估算的逐发换弹窗口现在在 HUD 中以 ~ 前缀显示(ReloadClock.Estimated 真正到达标签),低估窗口测试已接入套件(157 断言)。
  • 证据与范围:reports/runtime-startup-failure.md(附录 A)。关卡内行为仍待实机验证。