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

EquipmentTimers

Countdowns left of the native GTFO equipment names: melee charge, primary/secondary reload and BioTracker recharge, with one green READY cue. Client-side, read-only, no gameplay changes.

Date uploaded a day ago
Version 1.0.7
Download link HawChow-EquipmentTimers-1.0.7.zip
Downloads 12
Dependency string HawChow-EquipmentTimers-1.0.7

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

Persistent status and countdowns drawn inside the native GTFO equipment list (top right), immediately to the left of each equipment name. Occupied, enabled slots stay visible: neutral READY while idle, a live countdown during reload/charge/recharge, neutral -- while a sample is canceled/failed or an overrun is untrustworthy, and -- on an interrupted action's terminal frame. Nothing is added to the crosshair, and there is no separate panel.

Timed item What the number means
Melee weapon Seconds until the charge-up reaches full damage (m_chargeUpDone)
Primary weapon Seconds left in the current reload
Secondary weapon Seconds left in the current reload
BioTracker / class tool Seconds until the scanner can be used again

When a timer really completes, the row turns green and shows READY for a moment, and one vanilla UI sound is played. A countdown that merely reaches zero never counts as a completion.

Feedback: qingju.me | 小黑盒语音

Features

  • Native look: same TMP font asset/style/sorting layer, mirrored native transform scale, and measured glyph-height fit. A shorter timer glyph is scaled to at least native height with a 10% readability margin.
  • Anchored to both native name states, so it follows the selected full name and the compact slim name, and re-anchors when you switch gear.
  • Follows HUD scaling, resolution changes and native slot repositioning, because the label is a sibling of the native text inside the same canvas.
  • At most four rows (primary, secondary, tool, melee), one label per slot, no duplicates.
  • Neutral colour for idle/countdown/unknown status (inherits the native name colour), green only for the completion cue, so green always means "ready now".
  • Client-side and read-only: no gameplay value is written, no packet is sent, reload speed, charge speed, damage and scan cooldown are untouched.
  • Bounded logging: at most 40 lines per session by default (400 with VerboseLog), never per frame.

Installation

  1. Install BepInExPack_GTFO (this is the only dependency).
  2. Drop EquipmentTimers.dll into BepInEx/plugins/ (the package already uses BepInEx/plugins/EquipmentTimers/EquipmentTimers.dll).
  3. Start GTFO. Every occupied, enabled gear row remains visible; its number counts down during reload/charge/recharge.

Configuration

BepInEx/config/HawChow.EquipmentTimers.cfg (or a config editor):

Section Key Default Meaning
General Enabled true Master switch; off hides every label immediately
General ShowMeleeCharge true Melee charge-up countdown
General ShowReload true Reload countdown
General ShowScanner true BioTracker recharge countdown
General PlayReadySound true Play the one-shot ready cue
General ReadySoundEvent IMPLANTREADY Vanilla Wwise event: IMPLANTREADY, IMPLANTREADYSMALL, HUD_SCAN_COMPLETE_INDICATION, BUTTONGENERICBLIPACTIVATED, BUTTONGENERICBLIPONE, None
General ReadyHoldSeconds 0.6 How long READY stays on screen (0.2–3 s)
General OverrunGraceSeconds 2.5 Drop a countdown silently if the native action never ends (0.5–10 s)
Layout GapRatio 0.22 Gap to the native name, as a fraction of the native font size
Layout MinGap / MaxGap 2 / 40 Clamps for that gap, in canvas units
Layout LabelWidthRatio 4 Label box width as a multiple of the font size
Layout MinLabelWidth 24 Lower clamp for that width
Layout ReadyColor #6BF25A #RRGGBB or r,g,b (0-1 or 0-255)
Layout NeutralAlpha 0.92 Opacity of the neutral countdown
Debug VerboseLog false Raise the log budget to 400 lines

Known limits and accuracy

  • GTFO exposes no reload timestamp: only IsReloading and ReloadTime. The first reload segment counts down ReloadTime from the IsReloading rising edge; each further segment is estimated from the observed interval between two inserts and is prefixed with ~ (for example ~0.5s), so an estimate can never be mistaken for the native window. The row only turns green once, on the native end of the session.
  • A reload counts as completed only when the magazine is full or the clip grew during the session and the reserve that was non-empty at the start is now empty (provable natural exhaustion). Firing mid-reload therefore never turns the row green, and an unreadable reserve is never assumed empty.
  • A countdown that reaches zero while the native action is still running shows -- instead of a guessed value but keeps watching; canceled/failed samples remain -- while reads fail, and a valid interrupted action shows -- on its terminal frame before returning to neutral idle READY. The real completion that lands later still turns green and beeps exactly once.
  • The native HUD rebuilds itself thousands of times per second in the main menu (measured ~3.5 kHz) and hands out HUD instances that are frequently already destroyed. The mod treats those calls as an invalidation hint only: it never caches or dereferences them, and re-acquires the HUD through a throttled probe. This was the 2026-10-25 startup-crash path; with the fix the menu stays stable for 211-217 s in a row (see the project's reports/runtime-startup-failure.md).
  • A charge or reload that started before the mod could observe it (attaching mid-level, reloading while a level loads) still shows a number but ends silently, by design: the ready cue is never played for a completion the mod did not watch from the start.
  • The tool countdown follows the scanner's own m_recharging / m_rechargeStartTime / m_rechargeDuration; modded tools that do not use those fields will not show a countdown.
  • Melee "max charge hold" time is intentionally not shown: the timer stops at full charge.

Verification status (v1.0.7)

  • The fixed-SDK Release build, head-less timer/state-mapping tests, icon, manifest, assembly metadata and source guards pass against the installed GTFO interop set.
  • 1.0.6 was loaded in Test and its labels appeared, but the in-game check found them smaller than equipment names and hidden while idle. 1.0.7 mirrors native TMP scale, fits measured glyph height, keeps idle rows visible, and distinguishes live countdowns, terminal --, and confirmed green READY.
  • The exact 1.0.7 build still needs its post-install in-game visual recheck; no visual pass is claimed here. The 1.0.0 main-menu run and its 211/217-second stability result are historical and do not verify this build.
  • No 1.0.7 gameplay screenshot is bundled. The package icon is artwork, not evidence of in-game rendering.

Credits and licence

  • Behaviour reference: ReloadTimerPlus by Zose and MeleeTimer by long_walter. No code or asset from ReloadTimerPlus is used (it ships without an explicit licence). MeleeTimer is MIT licensed and is credited as the source of the "remaining = AutoAttackTime − elapsed, font taken from the native HUD" idea; this mod implements its own tracker, HUD anchoring and completion policy and reads MWS_ChargeUp.m_maxDamageTime from the live charge state instead of the animation data block.
  • All names, fields and events used here are the game's own (PUI_InventoryItem, Gear.MWS_ChargeUp, ItemEquippable, Gear.EnemyScanner, AK.EVENTS); no game assembly is redistributed with this package.

CHANGELOG

Changelog

1.0.8

  • Weapon rows now read the native magazine instead of only counting down: full ClipIsFull() magazine -> persistent green READY; partial magazine -> yellow RELOAD; empty magazine -> red RELOAD; a native reload still shows its countdown. A failed GetCurrentClip()/GetMaxClip() pair stays the neutral -- and never claims a state.
  • The BioTracker is a persistent green READY whenever m_recharging is false, because that is the native "scan available" state. Neither this nor the full-magazine READY can play the ready cue: the cue still follows TimerTracker.Completions only.
  • The melee row exists only inside MWS_ChargeUp. Winding up shows the charge countdown, a completed charge shows green READY, and m_releaseWarning shows a red !. Leaving the charge state (release, cancel, weapon swap) hides the row on that same frame, so no --, idle READY or stale green can survive the swing.
  • GlueGun (Gear.GlueGun, verified against the installed interop: GetShotCount(float) maps to native RVA 0x139A060, Updatepressure to 0x139B300): a Nx T.Ts line while the native charge state really runs (m_fireButtonDown && !m_reCharging && !m_firing && m_pressure > 0), where N is the native GetShotCount((m_pressure - m_pressureProgressToFire) / (1 - m_pressureProgressToFire)) answer and T is m_pressure * TimeToMaxpressure — the charge time already elapsed, never a remaining time. Below m_pressureProgressToFire the count is 0 (the weapon cannot fire); an unreadable count (Owner null / degenerate denominator) keeps the neutral placeholder. Nothing is written back and FireBurst is never called. KindOf now identifies GlueGun before the GetMaxClip reload fallback.
  • All labels use TMP Left alignment with a left pivot and share one left column per frame, derived from the four gear slots' native glyph edges and projected back into each row's own parent, so different parent scales or offsets never compare anchoredPosition across parents.
  • Review fixes on top of the same 1.0.8 candidate: the shared column now also reserves every label's measured text width, so the whole column (sharedLeft + widest label) ends one gap left of the leftmost native name instead of starting there - long labels can no longer run into a name. The world-to-parent conversion folds out each row's own anchor reference point (rect.xMin + anchor.x * rect.width), so a non-centred pivot or a stretch anchor no longer shifts a row, and the per-row fallback keeps the same no-overlap guarantee. Every gear row now reads its OWN slot through the native backpack: PlayerBackpackManager.TryGetItem(_player.Owner, slot, out BackpackItem bp) with bp.IsLoaded, a live bp.Instance and item.ItemDataBlock.inventorySlot == slot as the identity proof, so non-wielded weapon/tool rows show their real state; any failed step detaches the row instead of painting a cached guess, and the melee row keeps the verified FirstPersonItemHolder.m_meleeWeapon fallback for a missing or non-gear backpack instance. Install-TestProfile.ps1 wording/assertions now spell out the package-layout copy (BepInEx/plugins/EquipmentTimers/EquipmentTimers.dll) versus the single r2modman-managed copy, so two loadable DLLs are impossible.
  • Tests: 135 new pure assertions across the 1.0.8 candidate (293 -> 428) for the magazine states, the BioTracker ready state, the melee charge-state gate (including m_releaseWarning), the GlueGun gate / normalized pressure / fire threshold / elapsed clock / one-line format with NaN, Inf and zero-denominator cases, the shared-column inequality (sharedLeft + textWidth <= nameLeft - gap) and the anchor-reference placement geometry under different parent scales, offsets and non-centred anchors, plus the per-slot backpack binding matrix (bind / stale / dead / non-gear / identity mismatch / melee holder fallback). Source guards updated to the new API (TimerDisplayPolicy, SlotItemRules, TimerLayout.TryColumnLeftEdge/ColumnLimit/TryPlaceLeftEdge/AnchorReferenceLocalX, TextAlignmentOptions.Left).
  • TimerRowTone is wired through TimerRowView, TimerDisplayPolicy (new, pure), TimerStyle.Tone and TimerSlotRow, so colour is decided in one place from native state.

1.0.7

  • Keep every occupied, enabled equipment row visible: neutral READY while idle, the live countdown during reload/charge/recharge, neutral -- while a sample is canceled/failed or an overrun is untrustworthy, and -- on an interrupted action's terminal frame. A real observed completion remains green and plays one cue; idle READY never does.
  • Match the native equipment-name height: mirror the native TMP localScale, measure both rendered glyph heights (textBounds.size.y × lossyScale.y), and, when the timer glyph is shorter, scale it by the measured ratio plus a 10% readability margin. The fit never shrinks the label.
  • Treat the melee Done edge as terminal and stop the countdown at full charge; holding a charged swing no longer reopens a session or produces repeated suppressed completions.
  • Replace the generated placeholder icon with the user-provided pink-haired BioTracker/timer artwork, centered and resampled to a true 256×256 PNG. Preserve icon-source.png in the project; the package contains only the resized icon.png.
  • Build reproducible archives with fixed ZIP entry timestamps and pinned icon source/output SHA-256 values.

1.0.6

  • Fix the 1.0.5 all-hidden regression: the Test profile loaded 1.0.5, but active GearSpecial/GearClass rows repeatedly reported chars=-1 vtx=0, so the glyph gate never opened.
  • Keep the label GameObject inactive before AddComponent<TextMeshPro> to prevent the empty default quad. When non-empty text arrives, activate it with MeshRenderer disabled so TMP runs Awake/OnEnable, then call ForceMeshUpdate; enable rendering only after characters and vertices are valid.
  • Keep a text-bearing TMP object active during bounded mesh retries while its renderer stays disabled. This avoids repeating the activation cycle and cannot expose an empty quad.
  • Reviewed the locally cached WeaponIconPlus assembly: its Harmony postfix edits the existing PUI_InventoryItem.m_selected_icon SpriteRenderer (size/enabled/color); it does not create a replacement UI label. No code or assets were copied.

1.0.5

  • No label may reach the screen without text and real glyphs. 1.0.4's log showed four rows that only ever sampled active=0 text='', a mesh that went chars=0 vtx=4 → chars=4 vtx=32, eleven mesh changes, and material/shader identical to the native 3D TMP - plus a single short white horizontal line on screen. The 1.0.4 lifecycle could produce exactly such a frame: the label GameObject was created ACTIVE, AddComponent<TextMeshPro> built TMP's default (empty) quad, and the object was only deactivated at the very end of the create path; DestroyLabel used a bare Object.Destroy (deferred to the end of the frame) with the renderer still enabled; and mesh failures were swallowed by empty catches. This attribution is a hypothesis: the log cannot tie one sample line to the frame the line appeared in, so the fix is written to make any textless/invalid quad unreachable rather than to patch a specific frame.
  • Labels are now inert until proven drawable:
    • the GameObject is created INACTIVE and the renderer is disabled immediately after AddComponent<TextMeshPro>, so TMP's default quad is wiped (ClearMesh) before anything can see it;
    • every text update and every anchor rebind switches the renderer off and clears the mesh FIRST;
    • DestroyLabel disables the renderer, then SetActive(false), then ClearMesh(), and only then calls the deferred Object.Destroy - no residual quad can survive the frame a label is replaced;
    • empty/whitespace text keeps the label hidden with an empty mesh (TMP_Text.ClearMesh(), verified public in the shipped interop, with TextMeshPro.ClearMesh(bool) as fallback);
    • non-empty text is shown only when textInfo.characterCount > 0 && mesh.vertexCount > 0; otherwise it stays hidden with at most three bounded rebuild attempts (havePropertiesChanged + Graphic.SetAllDirty() + TextMeshPro.ForceMeshUpdate(true, true)). A vertex count alone is never treated as readable text, because the buffer capacity can exceed 4×characters.
  • Diagnostics are delayed and honest: the old one-shot drawn line fired while the row was still inactive and empty. It is replaced by ONE visible sample line per row, emitted only one frame after the row became active with a valid mesh, carrying text, activeInHierarchy, chars, vtx, renderer.enabled/isVisible, mesh.bounds, shader, matShared and matInstance. At most three label kept hidden; <reason> lines document why a row refused to show. The per-state mesh line is gone, so four rows cost a handful of lines instead of spending the 40-line budget.
  • Tests: 24 new pure assertions (225 → 249) for the visibility gate (empty text never shows, characters AND vertices required, retry budget exactly 3, delayed sample requires active+valid+next frame) plus source guards (inactive-before-AddComponent, renderer-off-before-ClearMesh, renderer-off-before-Destroy).

1.0.4

  • Three code-review blockers fixed on top of 1.0.3 (no gameplay change, no new dependency):
  • First build applied the rect style too early. CreateLabel called ApplyRectStyle before _labelRect was assigned, and that helper returns early when the rect is null — so a freshly created label kept the default pivot/anchors/sizeDelta until some later rebind. The RectTransform is now resolved first (SurfaceSync.CanApplyLayout), then the style is applied, so first build and rebind go through the same path. A source-order guard in Verify.ps1 fails the build if the order ever regresses.
  • Anchor adoption is transactional. AnchorLatch.ShouldAdopt no longer commits by itself: the row first proves the candidate has a usable parent and that RebindAnchor really moved the label, and only then calls Commit. A missing parent, a throwing SetParent or a dead wrapper leaves the latch on the current anchor and the next frame retries, instead of locking the row onto an anchor it never reached.
  • Surface sync now follows swaps on the same native TMP. Previously only null font/material were repaired, so a native font / fontSharedMaterial / shader replacement (skin, language, HUD rebuild) left a stale material and a stale mesh. SurfaceSync compares pointers for font, material and shader, re-points the label, and forces a mesh rebuild — immediately after a create/rebind and then every 15th frame (~4 Hz at 60 fps), never per frame.
  • Tests: 23 new pure assertions (202 → 225) for the transactional latch (offer ≠ commit, failed rebind may retry, cancel/re-arm, reset) and the surface rules (equal/replaced/absent font, material, shader, layout readiness, mesh-rebuild decision).

1.0.3

  • The label survives the native name swap and actually rasterises glyphs. 1.0.2 already resolved the slots, bound the weapons and logged drawn … visible=yes, but the game alternates each slot's native name between two sibling TMPs (Archetype Name / Archetype Name (1)), and the row destroyed and rebuilt the label on every pointer change (25 rebuilds for 4 rows). That churn also kept TextMeshPro from ever building a glyph mesh (textInfo.characterCount 0, mesh.vertexCount 0), which is why the bounding box came back as size=-4294967296 and nothing appeared on screen.
  • The label is now created once per row and rebound instead of recreated: parent, font, material, size and point-anchor are updated in place, and the transform is only re-parented when the native parent really changed. DestroyLabel() is no longer reachable from an anchor pointer change, and the one-shot drawn line is not reset by a rebind, so 4 rows cost 4 lines instead of 25.
  • Anchor choice is sticky: Diagnostics/AnchorLatch.cs holds the current native TMP while it still renders and only adopts the other one after it stayed unusable for 0.35 s, so per-frame flicker cannot steal the label.
  • Mesh generation is now forced through verified interop members after every text/anchor change — havePropertiesChanged = true, Graphic.SetAllDirty(), TMP_Text.ForceMeshUpdate(true, true) — and repaired per frame (bounded to 3 retries) together with font, fontSharedMaterial, font.material fallback, renderer.enabled, the native gameObject.layer and the native sortingLayerID/sortingOrder.
  • Diagnostics: the one-shot drawn line now ends with chars=<n> vtx=<n> rend=<0/1> vis=<0/1> font=<name> mat=<name> shader=<name>, and a separate line is emitted only when that mesh state changes — still one line per row per state, never per frame.
  • 17 new pure assertions (185 → 202) pin the latch: 40 flips of a live pair cause zero re-adoptions, a dead anchor is replaced only after the hold window, null candidates and resets behave.
  • No gameplay value is read or written differently, no new dependency, config layout unchanged.

1.0.2

  • The timer labels are actually inside the HUD now. 1.0.1 resolved and bound all four slots and even reached the drawing path ([hud] slot Gear* drawn; … visible=yes), but the label was invisible on screen: it copied the native name's stretch anchor range (anchorMin/anchorMax) while setting a fixed sizeDelta. With anchorMin != anchorMax Unity adds the anchor span to sizeDelta (a parent-wide rect) and measures anchoredPosition from Lerp(anchorMin, anchorMax, pivot) — with our pivot of (1, 0.5) that reference is the far edge of the span, so the label was placed up to half a parent width away from the native name. The label now collapses the native pair into a single-point anchor anchorMin = anchorMax = Lerp(native.anchorMin, native.anchorMax, native.pivot), which is exactly the reference point the native anchoredPosition is already expressed in, keeps a fixed size, and puts its right edge one gap left of the native rect's left edge, vertically centred. The point anchor is re-mirrored only when the native anchor actually moves.
  • The one-shot drawn line now carries the geometry that tells "off screen" apart from "never placed": anchors, pivot, anchoredPosition, sizeDelta, rect, lossyScale, activeInHierarchy, TMP enabled, canvas render mode, parent CanvasGroup alpha, WorldToScreenPoint and the text bounds. Still exactly one line per row, never per frame.
  • 18 new pure layout assertions (167 → 185) for stretch anchors, non-centre pivots, gap/width clamping and the exact point coordinates.
  • No gameplay value is read or written differently, no new dependency, config layout unchanged.

1.0.1

  • The HUD now actually appears. The native slot table is read with ContainsKey + the indexer instead of Dictionary<InventorySlot, PUI_InventoryItem>.TryGetValue. In 1.0.0 every slot read threw a native MissingMethodException out of that generic by-ref invoke ([hud] slot GearStandard unavailable: MissingMethodException, 2026-10-25 11:48 session), so no row was ever created and nothing was drawn. TryGetValue is kept only as a guarded fallback and the surviving lookup is remembered for the session.
  • Diagnostics, both bounded and never per frame: a failed slot read logs the interop member that refused plus the il2cpp message, once per slot, instead of only the exception type; the first time a row really draws it logs slot, kind, anchor, visibility and text once, so "no HUD" can be told apart from "no timer activity".
  • No gameplay value is read differently, no new dependency, config layout unchanged.

1.0.0

  • First release.
  • Countdown to the full melee charge (MWS_ChargeUp.m_maxDamageTime - m_elapsed), stopping at full charge: no hold timer is added.
  • Reload countdown for the primary and secondary weapons, built from the native IsReloading edge and ReloadTime, restarted per inserted shell and reconciled with GetCurrentClip() growth or ClipIsFull().
  • BioTracker recharge countdown straight from m_recharging, m_rechargeStartTime and m_rechargeDuration.
  • One green READY state plus exactly one vanilla Wwise cue per real completion. Cancels, interrupts, weapon switches, empty reserves and sessions that started before the mod was watching stay silent.
  • Labels are siblings of the native TMP equipment names, following both the selected and the slim name, the HUD scale and native slot repositioning. At most four rows, one per slot.
  • Config: enable per timer, ready event choice, ready hold time, overrun grace, gap/width ratios, ready colour, opacity and a bounded log budget.
  • Client-side and read-only; no gameplay value is written and no network traffic is added.

Review hardening (same 1.0.0, pre-publication)

  • Destroyed native objects can no longer throw into a Harmony postfix: every HUD/row native touch is guarded, references are cleared even when cleanup itself fails, one broken row never stops the others, and cache acquisition retries at a bounded rate instead of failing forever.
  • Reload completion now requires a full magazine or provable reserve exhaustion; firing mid-reload no longer turns the row green or beeps.
  • A new action inside the READY window retracts the green on the same frame and is timed from its own start.
  • Melee completion also accepts native progress at m_elapsed >= m_maxDamageTime for the frame where the charge state flips, without ever fabricating a completion.
  • Label X is anchored to the native text's real glyph edge (textBounds) with a rect fallback, and the size/pivot/anchors are written once instead of every frame.
  • Shell-by-shell segments are estimated from the observed insert interval and reported as approximate.
  • A countdown that overshoots hides only its number and keeps watching for the real completion.
  • Cue posting respects None (no post at all), is no longer gated by a global 0.05 s window, and two rows completing on the same frame each post once.
  • VerboseLog now really selects the 40/400 log budget, and messages are not even built past it.
  • VISUAL-CONTRACT.md "row only next to a name the HUD shows" is now enforced by the Empty state gate, the slot cache is dropped when a row is removed, and the install script falls back to a temp backup directory when none is supplied.

Runtime startup-crash fix (same 1.0.0, pre-publication)

  • Traced the 2026-10-25 startup crash (coreclr +0x1D1FDD, 0xC0000005, 15-21 s, 4/4 runs) with isolated probes: the Harmony hooks alone (358,600 PUI_Inventory.Setup calls, ~3.5 kHz) and the injected MonoBehaviour alone both survive; the crash needed the per-frame HUD postfix path that adopted the instance and later dereferenced its gameObject. __instance != null came back False in those probes, i.e. the menu hands out already-destroyed instances.
  • The postfix is now a pure invalidation hint: the instance is never cached, compared or dereferenced, and the HUD handle is re-acquired through a 0.5 s throttled probe with guarded reads.
  • The injected MonoBehaviour is now a field-free pump; all state and logic live in the plain managed TimerController, which also removes the four "unsupported parameter" interop warnings.
  • ResetRows is a no-op when there is nothing to reset and its log line is deduplicated (RepeatGate), so the per-frame native rebuild can no longer spend the log budget.
  • Estimated shell-reload windows are now prefixed with ~ in the HUD (ReloadClock.Estimated really reaches the label), and the underestimated-window test is wired into the suite (157 assertions).
  • Evidence and scope: reports/runtime-startup-failure.md (appendix A). In-level behaviour is still pending.