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.
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_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 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.
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.
Thunderstore development is made possible with ads. Please consider making an exception to your adblock.