Skip to content

feature: nade QoL enhancements - #177

Merged
lukepolo merged 33 commits into
mainfrom
feature/nade-qol
Aug 28, 2026
Merged

feature: nade QoL enhancements#177
lukepolo merged 33 commits into
mainfrom
feature/nade-qol

Conversation

@lukepolo

@lukepolo lukepolo commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Pairs with api#401 and web#568. All feedback from live use of the practice servers.

Parity: the HUD, colours, spawns, bots and crosshair are Swiftly only — CS2's HUD entity has no CounterStrikeSharp equivalent. utility-css gets callout reporting, the two scoring bugs and the CSS bump.

A Panorama HUD (apps/utility-sw/hud/), driven from src/Hud/ via CCSCustomHudLayout: guidance, the .menu picker, a .map minimap, the .playbook execute view and .edit. custom_hud_layout fails silently in both directions, so every slot name lives in HudSlots.cs and HudLayoutContractTests asserts the C# against the actual xml/css — rename one and the tests fail. Best-effort: if the addon isn't mounted everything falls back to centre text, and .hud picks that path anyway. CS2 has no server-to-client file transfer, so the addon has to go through the Workshop; build.sh needs only the .NET SDK.

A colour per throw. Ten rehearsals of one lineup drew ten identical arcs. Each throw takes the next colour off the player's cycle (announced before you pull the pin); a running execute overrides it so the colour belongs to the step. The smoke cloud itself wears it too, via CSmokeGrenadeProjectile.SmokeColor — thirty seconds later the trails are gone and four grey clouds is exactly when you want to know which was yours. Palette avoids green and red, which belong to the aim ramp. .colors turns it off.

The crosshair is earned away. .crosshair hides it; a drill fades it across reps. Fading by rep number took it off the player who'd missed everything, so it's landing/missing that moves it now, a step at a time.

.spawns, walk-to-spot on use, .bot/.nobots. Spawn rings read the game rules' selected list rather than every info_player_* on the map. Bots report flash duration and grenade damage — using ActualDmgHealth and reporting raw damage, since the question is what the grenade would do, not what was left of the bot.

Map callouts in both plugins via shared CalloutLookup.cs (ported verbatim from web/api — a name the HUD gives and a name the site gives must not disagree), with MapCalloutsReporter POSTing them for maps the extract doesn't cover.

In-world fixes: markers blinked out because ShowLibrary rebuilt every entity on any player's .next; executes drew the whole library instead of the execute; labels rendered mirrored; the Ancient InstaMid crosshair hit the CT buy-zone trigger; teleports wedged players in the Ancient rubble; the practice camera closed before smokes finished blooming.

Scoring: meta throws were never scored and a rejected result jammed the retry queue at the head forever. A re-tested draft replayed the old throw. .load never reported finishing.

Update workflows named the match plugin's csproj literally, so the practice plugins drifted — already live on main (SwiftlyS2 1.4.5 runtime against a 1.4.4 build). A plugin compiled against a different ABI fails in game, not at build time. The list is discovered from the package reference now, and every sed is verified.

lukepolo and others added 21 commits August 27, 2026 17:56
Both update workflows bumped the runtime and the match plugin's csproj but
named those paths literally, so the utility practice plugins -- which pin the
same package independently -- were left behind. This already happened on main:
the runtime ships SwiftlyS2 v1.4.5 while apps/utility-sw was still built
against 1.4.4, and CounterStrikeSharp 1.0.373 against 1.0.371. A plugin
compiled against a different ABI than the runtime it is loaded into fails at a
call boundary in game rather than at build time.

The csproj list is now discovered from the package reference itself, so a new
plugin cannot silently drift again, and every sed is verified rather than
trusted -- a non-matching sed -i is how this went unnoticed in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
…ed the old throw

Four defects, all reported from a live session.

A meta spot or an unsaved draft is sent over as a scratch throw, which is
deliberately given a non-uuid id so nothing downstream mistakes it for a row.
PracticeScore only skipped a lineup with NO id, so it reported the scratch one
anyway, the panel rejected it as not-a-lineup, and the player was told "not
scored; the panel did not answer" -- which reads as a broken panel while every
number needed to judge the throw was already on the server. Scratch throws are
now judged here, with a real hit or miss and no streak, because there is
nothing to persist one against.

Worse, a rejected result went onto the retry queue and Drain replayed it at the
head forever, so one meta throw stopped every later result from being
delivered. The queue now tells a refusal apart from a failure and drops it.

A panel-pushed load resolved against the cached library and only re-read on a
miss. A draft re-tested from the website keeps the same client id on purpose,
so every test after the first replayed the first version of the throw -- move
the points, press test again, land in the same place. The panel pushing a load
is itself the signal that something changed, so it always re-reads now.

.load told the player "loaded 14 lineup(s) -- try .load again" instead of
finishing once the fetch landed, which is why the first .load of a session
never loaded anything and .next answered "load something first" in between.

Also: Index is -1 before anything is loaded, and running that through the
carousel modulo made the first .prev land two short of the end and skip the
last lineup; and the HUD now says [3/24] beside the name, since where you are
in the walk is the one thing .next and .prev cannot tell you themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
… sat on a buy zone

Four in-world defects from the same session's feedback.

The circle disappearing "from some angles" was not an angle at all. ShowLibrary
despawns and respawns every marker on the map, and it runs on every .load,
.next, .prev, .rethrow, drill step and playbook beat -- fired by any player
against one shared set of entities. So one person walking their library blinked
out the circle somebody else was standing in, which is why it only showed up
with two people on different lineups. The library layer now carries a signature
of what it has drawn -- geometry included, so an edited lineup still redraws --
and rebuilds only when that changes.

The stance reticle was drawn 1.5 units off the floor, which put it inside the
water at Ancient T spawn and inside the rubble anywhere else. It sits at knee
height now; standing on the spot is judged on XY alone, so lifting it changes
nothing about what counts as being there.

Labels came out mirrored because the text plane was spawned with a zero yaw and
presented its back face to the reader, and because the angle was only set
before DispatchSpawn, which re-derives the transform from the entity's own
keyvalues and threw it away. Note that reorient only turns around the up axis,
so a label read from directly underneath is still foreshortened -- that part is
the entity type, not a bug we can fix here.

The Ancient InstaMid crosshair landing in mid-air was the CT buy zone: a
trigger is an invisible volume with no surface to aim at, and the trace treated
it as a wall. The reporter diagnosed this one exactly right. Triggers now pass
through, and because the aim trace is memoised per lineup for the life of the
map, the cache is dropped on a sweep or every already-wrong point would outlive
the fix.

Teleporting used the exact surface a line trace reported, and a line finds the
floor between things a player hull cannot fit between -- the rubble at Ancient
ruins, where the pawn arrived wedged in the stones. Players are now put down
just above it and let the engine resolve the standing position, which it does
correctly and a trace here cannot. Markers still use the true floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
… their relay

Starting an execute drew every smoke on the map. A playbook step goes through
the same Load a typed .load does, and Load draws the whole library -- so the
four throws the execute is about were buried in the hundred it is not. The
library layer now takes an optional restriction, which a run sets to its own
steps and clears when it stops or the map changes.

Worth saying plainly about the other half of that request: "coloured if you
throw it, grey if a teammate does" is per-VIEWER, and every marker is one world
entity that all clients receive. Doing it properly needs per-player transmit
blocking, which this plugin does not have. Colouring by assignment instead
would paint the same marker grey for the person who has to throw it, which is
worse than not doing it, so it is left alone.

Also carries the relay account id on the occupancy heartbeat, which is what
lets a practice pod be reachable over Steam's relay at all -- the match plugin's
ping is what maintains that everywhere else and a practice pod does not run the
match plugin. SteamIdUtility moves to shared/ for it; both match plugins glob
that directory, so it had to move rather than be copied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
…hrow at

The four things the feedback asked for beyond the bugs.

.spawns draws a ring and a facing needle on every spawn point, and .spawn
next/prev walks them -- a spawn has no name, so walking is the only way to find
the one you mean. The rings are their own entity layer rather than part of the
library: ShowLibrary rebuilds everything it owns, and a toggle somebody asked
for must not blink out because another player ran .next. Coarser ring and a cap
of 32, because this is a "where can I start from" overview and not something to
line a throw up on.

Standing near a circle and pressing use puts you on it. Walking in tells you
where to stand but not exactly where, and exactly is the whole point of a
stance. What the crosshair is pointing at wins over what is nearest, since with
two stances in reach nothing else says which one is meant; there is a reach
limit and a no-op when you are already on the spot, because use is also how a
player picks a weapon up.

.bot puts a frozen bot where you stand and .nobots clears them, with the flash
duration and the grenade damage each one takes reported in chat -- a smoke says
where it landed by itself, but a flash and an HE say nothing at all without
somebody standing there. bot_quota 0 / bot_kick had to come out of the practice
cfg first: that list re-runs on every map change and twice on load, so it swept
away any bot within a second of it being placed. Flash duration is read before
NoFlash zeroes it, and damage is clamped against the bot's remaining health so
an overkill does not read as a better grenade than it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
An execute puts four or five grenades up at once from four or five spots.
Drawn in the utility's own colour they are all the same white, so the only way
to know which one landed where is to have watched it the whole way -- which is
exactly what the player rehearsing it cannot do, because they are looking at
their own crosshair.

Each step now wears its own colour, by position in the execute. Both ends of
the throw wear it -- the needle where you stand and the diamond where it lands,
so the line between them is not guesswork -- and so does the glowing grenade
model over the spot, which is the marker players actually look at. The player
is told which one is theirs twice: on the HUD title while they are standing on
it, and in chat, which is what is still readable once four smokes are in the
air.

This is the request I turned down last time as impossible, and I had it wrong.
I read it as "whose throw is this", which is per-viewer and does need transmit
blocking. Which smoke is which is not: the colour is a fact about the step, so
one shared entity set is exactly right and everybody sees the cyan one as the
cyan one.

The palette avoids green and red on purpose -- those are the aim reticle's
ramp, where green means inside tolerance -- and it is ordinal rather than
random so the same execute comes up the same way twice. Colours are asserted
distinct and off the aim ramp in tests, because both of those are silent
failures on a map rather than anything that breaks a build.

Restriction ordering moved inside ShowLibrary while doing this: it was applied
at one call site, so a refresh or a .save arriving mid-execute quietly put the
whole map back on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
…wrong number

EventPlayerHurt.DmgHealth and .Health are deprecated because the declared type
does not necessarily match the value behind it; ActualDmgHealth and ActualHealth
are the correctly-typed accessors. Both have existed since 1.4.5.

The arithmetic was wrong too. Reconstructing the health a bot had as
Health + DmgHealth cannot undo an overkill: both terms are inflated by the same
overkill, so the clamp it fed did nothing.

Reporting the RAW damage instead, which is the opposite of what the match
plugin does and deliberately so. There, overkill must not be credited to a
player's stats. Here the question is "what would this grenade do to somebody",
and clamping to whatever was left of a half-dead bot answers a question about
the bot rather than about the throw. The bot is topped back up afterwards so
two throws at the same target produce comparable numbers -- without that a
second HE onto a hurt bot reads lower than the first, which is the one thing
the readout exists to let you compare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
…ows nobody wanted

Mirage ships dozens of info_player_* entities for deathmatch and casual, so
enumerating them all buried the ten a competitive round can actually start
from. The game has already made this choice -- game rules keep the selected
list (TerroristSpawnPoints / CTSpawnPoints) separately from the master list of
every spawn entity -- so this reads the selection rather than re-deriving it
from priorities and guessing at the mode. Falls back to enumerating entities
only when the rules are not populated yet, since every spawn beats no spawn.

The facing needle is gone too. Which way a spawn points is not something
anybody practising utility cares about, and thirty arrows pointing thirty ways
read as clutter rather than as information.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
… execute

The engine's practice trail is coloured by the thrower's TEAM. On a practice
server everybody is usually on the same side, so four smokes in an execute left
four identical arcs -- and colouring the markers achieves nothing the moment
the grenades are actually in the air, which is exactly when you are trying to
work out which one is going where.

The trail is drawn from the samples the recorder already takes: same points the
recording is built from, on the same schedule, so there is no second sampler
and no second answer to where a grenade was. The arc's expiry is pushed out as
it flies, so the whole flight fades together once it lands instead of the start
of it disappearing while the smoke is still up.

The engine trail is switched off for the length of a run and put back when it
ends -- two arcs down the same flight, one of them in a colour that means
nothing, is worse than either alone. Outside an execute nothing changes: the
engine's trail is good and costs us no entities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
…re you pull the pin

Rehearsing one lineup means throwing it ten times, and until now all ten arcs
looked identical -- so the one thing a player is actually trying to do, compare
this throw against the last one, was the one thing the trail could not help
with. Each throw now takes the next colour off the player's own cycle, and the
centre panel says which one is coming while the grenade is still in hand. After
the throw it would only be a label on something already in the air.

A running execute still wins: there the colour belongs to the STEP, because it
has to mean the same thing across attempts and across players. The per-player
cycle owns everything else.

The engine's practice trail is off for good rather than only during a run, now
that the plugin draws one for every throw. Two arcs down one flight, one of
them coloured by a team nobody is playing for, is worse than either alone. The
pip preview is independent and stays.

Trail cost is bounded by distance rather than by time: the recorder samples at
32Hz, which is ~96 beams for a single three second flight and thousands with a
few players rehearsing -- a server falling over because the feature works. A
grenade flies a smooth parabola, so a beam every 26 units is the same curve for
a third of the entities, and a grenade that has stopped rolling stops costing
anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
… colour

A trail says where a grenade went. The bloom is what it DID -- and thirty
seconds later the trails have faded and all that is left on the map is four
identical grey clouds, which is the moment a player most wants to know which
one was theirs. CSmokeGrenadeProjectile carries its own SmokeColor, so the
cloud can wear the same colour the trail and the marker already do.

Set at creation rather than at detonation: the colour is read when the smoke
starts blooming, so there is nothing to change afterwards. It is also set after
the recorder has seen the projectile, because that is the moment the throw
claims its colour -- before it, the cursor still points at what the NEXT
grenade will be, and the cloud would come out a colour the player was never
shown. The schema write is followed by SmokeColorUpdated() for the same reason
the beams use the Color input: a bare assignment never reaches clients.

.colors turns the whole thing off per player. On by default, because telling
your throws apart is the point -- but somebody judging a smoke's coverage wants
the vanilla cloud, and that is a fair thing to want on a practice server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
… red, and misses were reported in units

The reticle was created hard-coded fully red -- ColorForBucket(MissBuckets - 1)
-- no matter where the player was actually aiming, with Aim.Bucket left at -1.
Draw one while somebody is already lined up and the panel says LINED UP beside
a red crosshair, which is precisely the disagreement between colour and text
this scheme exists to prevent. It is now born from the same predicate the tint
tick uses, and remembers which bucket that was, so the tick agrees instead of
correcting it a frame later.

Separately: "2171u, needs 96u" is two numbers in a unit nobody thinks in. It
does not say whether a throw was close or nowhere near, which is the only thing
the line is for. A source unit is three quarters of an inch, so that throw is
41m out needing 1.8m -- and 41 against 1.8 is a verdict rather than a
measurement. Under ten metres the decimal stays, because half a metre off is a
good smoke and three metres off is not and both round to the same whole number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
The HUD work needs to show a player which colour their next throw is, and the
rule it has to follow -- a running execute owns the colour, the player's own
cycle owns it everywhere else -- lived in two private methods. Left there, the
second surface reimplements the precedence and the two disagree the first time
somebody starts an execute.

No behaviour change; the methods are the ones the centre panel already uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
…blooming

Self-inflicted, earlier this session. sv_grenade_trajectory_prac_trailtime does
not only govern the trail line -- it is what keeps the practice camera up after
the grenade lands, and the pip cvar beside it only decides whether that camera
exists at all. Setting it to 0 to suppress the engine's team-coloured trail, so
the plugin's own coloured one had the flight to itself, took the bloom view
with it. A smoke detonates and then takes a couple of seconds to fill, and
watching the end of that is most of what a practice server is for.

Restored, and longer than the ten it was before, because the complaint was that
it went away too early rather than that it had gone entirely.

The plugin's own trail steps back behind DrawTrail, off, alongside the other
feature flags. Two arcs down one flight is worse than either alone, and between
the two the engine's wins on the merits: it carries the camera, it draws
bounces, and it costs no entities. Nothing is lost that matters -- the throw's
colour still reaches the player where it does the most work, on the smoke cloud
itself, and on the markers and the panel. The flag makes it one const to
reverse if the coloured arc turns out to be worth more than the bloom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
…fades it out

The aim crosshair is the answer written on the wall. A throw made with it up
says nothing about whether the player could make it without, which is the only
question "have I got this yet" is asking -- so .crosshair hides it and lets
them throw blind. It redraws on the spot rather than waiting for the player to
step off the spot and back on, because a toggle that appears to do nothing gets
pressed again.

A drill fades it across its reps instead of switching it: full help on the
first, none on the last. Reps of one throw with the crosshair equally loud
throughout trains somebody to read the crosshair rather than the map, and the
last rep is the only one that says anything about whether they have learned it.
A single-rep run keeps full help -- one throw is practice, not a test.

The fade is a property of the run rather than of the service, so it is
unit-tested with the rest of the drill rather than needing a live server. Two
things it has to get right and now asserts: it never leaves 0..1, and a
one-rep run does not read as a final rep.

Fading also had to be tracked per crosshair. The tint tick skips a reticle
whose miss bucket has not changed, which is most of the time while somebody
holds an angle -- so a fade that moved when the rep advanced would have been
swallowed and the crosshair would have stayed at whatever brightness it had
when the rep started. Visibility is now part of what "unchanged" means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
Fading the aim crosshair by rep number took it off the player who had missed
every attempt -- exactly who still needs it -- and handed a clean last rep to
somebody who had not landed one. It is earned now: landing the throw fades it a
step, missing gives a step back.

A miss steps back rather than resetting. One bad throw at the end of a good run
is a bad throw, not evidence the player never knew it, and a full reset there
makes the drill feel like it is punishing them. Moving to the next lineup does
start over, because a different throw is a different thing to have learned. A
throw the panel never scored moves nothing in either direction -- it says
nothing about whether they know it.

The curve lives on the run rather than the service so it is testable without a
server, and the tests cover the properties that matter rather than the
arithmetic: missing everything never removes the crosshair, a miss costs one
step and not all of them, an unscored throw is inert, and a new lineup arrives
with full help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh
…ed the player who asked

Three from the review of this branch.

Report ran once, from the map-load handler, and gave up if it found nothing.
env_cs_place entities are not guaranteed to have spawned by then, so a map that
was slow to load stayed unnamed for the whole session -- silently: no HUD lands
kicker, .save falling through to the chat prompt on every throw, and nothing
POSTed for the workshop maps that endpoint exists to cover. It is asked again on
the per-second tick now, and bounded so a map with genuinely no places is not
walked for ever.

Pending HUD prompts were keyed by slot id and never cleared on disconnect, so
the next player into that slot answered a question they were never asked -- and
their first chat line was swallowed rather than reaching the server. A prompt
carries the steam id of whoever asked for it now, and the disconnect handler
cancels it either way.

Both update workflows combined set -euo pipefail with a grep -rl command
substitution, which aborts the step before the friendly "the pin pattern must
have changed" error it exists to print.
# Conflicts:
#	.github/workflows/hud-workshop.yaml
@lukepolo
lukepolo merged commit 36f587b into main Aug 28, 2026
3 checks passed
@lukepolo
lukepolo deleted the feature/nade-qol branch August 28, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant