feature: nade QoL enhancements - #177
Merged
Merged
Conversation
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
This was referenced Aug 28, 2026
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-cssgets callout reporting, the two scoring bugs and the CSS bump.A Panorama HUD (
apps/utility-sw/hud/), driven fromsrc/Hud/viaCCSCustomHudLayout: guidance, the.menupicker, a.mapminimap, the.playbookexecute view and.edit.custom_hud_layoutfails silently in both directions, so every slot name lives inHudSlots.csandHudLayoutContractTestsasserts 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.hudpicks that path anyway. CS2 has no server-to-client file transfer, so the addon has to go through the Workshop;build.shneeds 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..colorsturns it off.The crosshair is earned away.
.crosshairhides 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 everyinfo_player_*on the map. Bots report flash duration and grenade damage — usingActualDmgHealthand 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), withMapCalloutsReporterPOSTing them for maps the extract doesn't cover.In-world fixes: markers blinked out because
ShowLibraryrebuilt 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.
.loadnever 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 everysedis verified.