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
BepInEx pack for GTFO. Preconfigured and includes Unity Base DLLs.
Preferred version: 3.2.3README
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.
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
- Install BepInExPack_GTFO (this is the only dependency).
- Drop
EquipmentTimers.dllintoBepInEx/plugins/(the package already usesBepInEx/plugins/EquipmentTimers/EquipmentTimers.dll). - 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
IsReloadingandReloadTime. The first reload segment counts downReloadTimefrom theIsReloadingrising 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_maxDamageTimefrom 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 greenREADY; partial magazine -> yellowRELOAD; empty magazine -> redRELOAD; a native reload still shows its countdown. A failedGetCurrentClip()/GetMaxClip()pair stays the neutral--and never claims a state. - The BioTracker is a persistent green
READYwheneverm_rechargingis false, because that is the native "scan available" state. Neither this nor the full-magazineREADYcan play the ready cue: the cue still followsTimerTracker.Completionsonly. - The melee row exists only inside
MWS_ChargeUp. Winding up shows the charge countdown, a completed charge shows greenREADY, andm_releaseWarningshows a red!. Leaving the charge state (release, cancel, weapon swap) hides the row on that same frame, so no--, idleREADYor stale green can survive the swing. - GlueGun (
Gear.GlueGun, verified against the installed interop:GetShotCount(float)maps to native RVA0x139A060,Updatepressureto0x139B300): aNx T.Tsline while the native charge state really runs (m_fireButtonDown && !m_reCharging && !m_firing && m_pressure > 0), whereNis the nativeGetShotCount((m_pressure - m_pressureProgressToFire) / (1 - m_pressureProgressToFire))answer andTism_pressure * TimeToMaxpressure— the charge time already elapsed, never a remaining time. Belowm_pressureProgressToFirethe count is 0 (the weapon cannot fire); an unreadable count (Owner null / degenerate denominator) keeps the neutral placeholder. Nothing is written back andFireBurstis never called.KindOfnow identifiesGlueGunbefore theGetMaxClipreload fallback. - All labels use TMP
Leftalignment 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 compareanchoredPositionacross 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)withbp.IsLoaded, a livebp.Instanceanditem.ItemDataBlock.inventorySlot == slotas 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 verifiedFirstPersonItemHolder.m_meleeWeaponfallback for a missing or non-gear backpack instance.Install-TestProfile.ps1wording/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). TimerRowToneis wired throughTimerRowView,TimerDisplayPolicy(new, pure),TimerStyle.ToneandTimerSlotRow, so colour is decided in one place from native state.
1.0.7
- Keep every occupied, enabled equipment row visible: neutral
READYwhile 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
Doneedge 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.pngin the project; the package contains only the resizedicon.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 withMeshRendererdisabled so TMP runsAwake/OnEnable, then callForceMeshUpdate; 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
WeaponIconPlusassembly: its Harmony postfix edits the existingPUI_InventoryItem.m_selected_iconSpriteRenderer(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 wentchars=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;DestroyLabelused a bareObject.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;
DestroyLabeldisables the renderer, thenSetActive(false), thenClearMesh(), and only then calls the deferredObject.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, withTextMeshPro.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.
- the GameObject is created INACTIVE and the renderer is disabled immediately after
- Diagnostics are delayed and honest: the old one-shot
drawnline fired while the row was still inactive and empty. It is replaced by ONEvisible sampleline per row, emitted only one frame after the row became active with a valid mesh, carryingtext,activeInHierarchy,chars,vtx,renderer.enabled/isVisible,mesh.bounds,shader,matSharedandmatInstance. At most threelabel 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.
CreateLabelcalledApplyRectStylebefore_labelRectwas assigned, and that helper returns early when the rect is null — so a freshly created label kept the default pivot/anchors/sizeDeltauntil 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 inVerify.ps1fails the build if the order ever regresses. - Anchor adoption is transactional.
AnchorLatch.ShouldAdoptno longer commits by itself: the row first proves the candidate has a usable parent and thatRebindAnchorreally moved the label, and only then callsCommit. A missing parent, a throwingSetParentor 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.SurfaceSynccompares 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.characterCount0,mesh.vertexCount0), which is why the bounding box came back assize=-4294967296and 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-shotdrawnline is not reset by a rebind, so 4 rows cost 4 lines instead of 25. - Anchor choice is sticky:
Diagnostics/AnchorLatch.csholds 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 withfont,fontSharedMaterial,font.materialfallback,renderer.enabled, the nativegameObject.layerand the nativesortingLayerID/sortingOrder. - Diagnostics: the one-shot
drawnline now ends withchars=<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 fixedsizeDelta. WithanchorMin != anchorMaxUnity adds the anchor span tosizeDelta(a parent-wide rect) and measuresanchoredPositionfromLerp(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 anchoranchorMin = anchorMax = Lerp(native.anchorMin, native.anchorMax, native.pivot), which is exactly the reference point the nativeanchoredPositionis 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
drawnline 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 ofDictionary<InventorySlot, PUI_InventoryItem>.TryGetValue. In 1.0.0 every slot read threw a nativeMissingMethodExceptionout 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.TryGetValueis 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
IsReloadingedge andReloadTime, restarted per inserted shell and reconciled withGetCurrentClip()growth orClipIsFull(). - BioTracker recharge countdown straight from
m_recharging,m_rechargeStartTimeandm_rechargeDuration. - One green
READYstate 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_maxDamageTimefor 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. VerboseLognow 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 theEmptystate 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.Setupcalls, ~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 itsgameObject.__instance != nullcame 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. ResetRowsis 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.Estimatedreally 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.