Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions .github/workflows/hud-workshop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
KEY=$( { git rev-parse "HEAD:$HUD/panorama"
git hash-object "$HUD/build.sh" "$HUD/addoninfo.txt" ${PREVIEW:+"$PREVIEW"}
echo "${PANORAMA_COMPILER_REF:-main}"
echo "${{ inputs.item_id }}"
} | sha256sum | cut -c1-16 )
TAG="hud-published/$KEY"
echo "key=$KEY" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -91,6 +92,10 @@ jobs:
# into .tools on Linux, so nothing here needs Windows or a CS2 install.
- name: Build the addon
if: steps.changed.outputs.proceed == 'yes'
# The vpk must be named after the item it will live in, so an update
# needs the id at BUILD time, not just at upload time.
env:
WORKSHOP_ITEM_ID: ${{ inputs.item_id }}
run: ./apps/utility-sw/hud/build.sh

# An addon that mounts and resolves nothing is the failure mode with no
Expand All @@ -100,8 +105,20 @@ jobs:
if: steps.changed.outputs.proceed == 'yes'
run: |
set -euo pipefail
VPK=apps/utility-sw/hud/build/upload/5stack_utility_hud.vpk
[ -f "$VPK" ] || { echo "::error::no vpk was produced"; exit 1; }
ITEM_ID="${{ inputs.item_id }}"
VPK=$(ls apps/utility-sw/hud/build/upload/*.vpk 2>/dev/null | head -1)
[ -n "$VPK" ] && [ -f "$VPK" ] || { echo "::error::no vpk was produced"; exit 1; }

# An addon named anything but its id downloads and then fails to mount,
# and the mount error names a path rather than the mismatch.
if [ -n "$ITEM_ID" ] && [ "$(basename "$VPK")" != "$ITEM_ID.vpk" ]; then
echo "::error::packed $(basename "$VPK") but item $ITEM_ID needs $ITEM_ID.vpk"
exit 1
fi
if [ -z "$ITEM_ID" ]; then
echo "::warning::no item_id given, so the vpk is not named after an id."
echo "::warning::Only correct for a first publish; re-run with item_id afterwards."
fi

TREE=$(apps/utility-sw/hud/.tools/vpkeditcli --file-tree "$VPK" 2>&1 || true)
echo "$TREE"
Expand Down
15 changes: 15 additions & 0 deletions apps/swiftly/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ ENV METAMOD_CSGO_URL=https://mms.alliedmods.net/mmsdrop/1.12/mmsource-1.12.0-git
ENV SOURCEMOD_CSGO_URL=https://sm.alliedmods.net/smdrop/1.13/sourcemod-1.13.0-git7297-linux.tar.gz
ENV NO_LOBBY_RESERVATION_URL=https://github.com/nuxencs/NoLobbyReservation/releases/download/v0.0.1/NoLobbyReservation.zip

# The engine natively mounts exactly one addon -- the map -- so the HUD workshop
# item needs this to reach clients at all. Practice servers only; see setup.sh.
# Blank disables the mount: the plugin degrades to centre text without it.
ENV HUD_WORKSHOP_ID="3791548068"

# ARG, not ENV: the download below happens once at build time, so these are set
# with --build-arg. As ENV they read as runtime knobs beside HUD_WORKSHOP_ID and
# would silently do nothing when overridden at docker run.
ARG ADDONS_MANAGER_VERSION="v2.0.3"
ARG ADDONS_MANAGER_URL=https://github.com/SwiftlyS2-Plugins/AddonsManager/releases/download/${ADDONS_MANAGER_VERSION}/AddonsManager.zip

RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget locales procps jq ca-certificates curl unzip rsync \
Expand All @@ -128,6 +139,10 @@ RUN wget -q $SWIFTLYS2_URL -O /tmp/swiftlys2.zip && \
unzip -q /tmp/swiftlys2.zip -d /opt/swiftlys2 && \
rm /tmp/swiftlys2.zip

RUN wget -q $ADDONS_MANAGER_URL -O /tmp/addons-manager.zip && \
unzip -q /tmp/addons-manager.zip -d /opt/addons-manager && \
rm /tmp/addons-manager.zip

RUN mkdir -p /opt/metamod /opt/counterstrikesharp && \
wget -q $METAMOD_URL -O /tmp/metamod.tar.gz && \
tar -xz -C /opt/metamod -f /tmp/metamod.tar.gz && \
Expand Down
50 changes: 50 additions & 0 deletions apps/swiftly/scripts/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,56 @@ if $INSTALL_UTILITY_PRACTICE_PLUGIN = true ; then
else
echo "---Utility Practice: plugin dir already present, skipping /opt/utility-practice symlink---"
fi

# The HUD's Panorama layouts live in a workshop addon, and CS2 has no
# server-to-client file transfer -- AddonsManager names the id to connecting
# clients and Steam delivers it. Without this the plugin still works, falling
# back to centre text, so a failure here must not stop the server.
#
# AddonsManager reads its config with optional:false and validates it on
# start, so anything that is not a bare numeric id writes JSON that takes the
# plugin down at load instead of degrading -- a pasted quote, a trailing
# comma, a second id. That is a typo, not a reason to lose the server.
case "${HUD_WORKSHOP_ID}" in
"")
echo "---Utility Practice: HUD_WORKSHOP_ID unset, HUD falls back to centre text---"
;;
*[!0-9]*)
echo "---Utility Practice: HUD_WORKSHOP_ID '${HUD_WORKSHOP_ID}' is not a workshop id, HUD falls back to centre text---"
;;
*)
ADDONS_MANAGER_PLUGIN_DIR="${INSTANCE_SERVER_DIR}/game/csgo/addons/swiftlys2/plugins/AddonsManager"
if [ ! -e "$ADDONS_MANAGER_PLUGIN_DIR" ]; then
ln -s "/opt/addons-manager/AddonsManager" "$ADDONS_MANAGER_PLUGIN_DIR"
fi

# Rewritten every boot: the id is ours, and a stale copy from an older
# image would silently serve the wrong addon.
#
# RedownloadAddonOnMount matters because this addon changes -- unlike a
# map, which is published once. AddonsManager only checks that an item is
# installed, not that it is current, so without this a server that cached
# an older HUD keeps serving those layouts forever and never picks up a
# republish.
#
# Through materialize_for_write because configs is a symlink onto the
# node-wide volume: written straight through it, one practice server's
# addon id lands in front of every other server on the node -- and
# AddonsManager watches the file, so a running server swaps to it live.
ADDONS_MANAGER_CONFIG="$(materialize_for_write "${INSTANCE_SERVER_DIR}/game/csgo" "addons/swiftlys2/configs/plugins/AddonsManager/config.jsonc")"
cat > "$ADDONS_MANAGER_CONFIG" <<EOF
{
"Main": {
"Addons": [
"${HUD_WORKSHOP_ID}"
],
"RedownloadAddonOnMount": true
}
}
EOF
echo "---Utility Practice: HUD addon ${HUD_WORKSHOP_ID} via AddonsManager---"
;;
esac
fi

if [ ! -e "$INSTANCE_SERVER_DIR/game/csgo/addons/swiftlys2/configs/core.jsonc" ]; then
Expand Down
39 changes: 34 additions & 5 deletions apps/utility-sw/hud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,15 @@ By hand:

Clones and builds [PanoramaCompiler](https://github.com/nicedayzhu/PanoramaCompiler),
compiles `panorama/` into `build/stage/panorama/`, and packs
`build/upload/5stack_utility_hud.vpk`. PanoramaCompiler writes Source 2
`build/upload/<WORKSHOP_ITEM_ID>.vpk`.

**The vpk must be named after the workshop item id.** CS2 resolves an addon as
`steamapps/workshop/content/730/<id>/<id>.vpk`, so a friendly name downloads
correctly and then fails to mount, and the mount error names a path rather than
the mismatch. Set `WORKSHOP_ITEM_ID` when building anything destined for the
Workshop; it falls back to `5stack_utility_hud` only for the very first publish,
before an id exists. The CI workflow passes `item_id` through for this and
refuses to upload a vpk whose name does not match. PanoramaCompiler writes Source 2
containers directly and never invokes `resourcecompiler.exe`, so this needs only
the .NET 10 SDK — no Windows, no CS2 install.

Expand Down Expand Up @@ -124,8 +132,14 @@ text that has always worked, and `.hud` lets a player choose the text path
anyway.

- **Development** — point CS2 at `build/` as a local override via `gameinfo.gi`.
- **Production** — publish `build/` to the Steam Workshop and name its id in
[AddonsManager](https://github.com/SwiftlyS2-Plugins/AddonsManager).
- **Production** — already wired. `HUD_WORKSHOP_ID` in `apps/swiftly/Dockerfile`
holds the published id, and `setup.sh` installs
[AddonsManager](https://github.com/SwiftlyS2-Plugins/AddonsManager) and writes
its config on practice servers only. Set the variable empty to disable the
mount; the plugin then falls back to centre text. It must be a bare numeric
id — anything else is refused with a log line and treated as unset, because
AddonsManager validates its config on start and would otherwise fail to load
rather than degrade.

There is no way round the Workshop, and it is not a gap in SwiftlyS2. CS2 has no
server-to-client file transfer at all — no `sv_downloadurl`, no file netmessage;
Expand Down Expand Up @@ -255,5 +269,20 @@ App 730 does accept a SteamCMD workshop upload — verified on 2026-08-28, item
its `VpkDirectories` whitelist are therefore avoidable entirely, which is the
whole reason this path exists.

Practice servers first. A one-time addon download is a fair price for someone
who typed `.drill`; it is not something to ask ten people for before a match.
Practice servers first, which is why the install sits inside the
`INSTALL_UTILITY_PRACTICE_PLUGIN` branch of `setup.sh` rather than beside the
match plugin. A one-time addon download is a fair price for someone who typed
`.drill`; it is not something to ask ten people for before a match.

Verify a published item is actually downloadable before relying on it — this is
exactly what a joining client does, and it fails for reasons the Workshop page
does not show:

```sh
steamcmd +login anonymous +workshop_download_item 730 3791548068 +quit
```

A newly published item returns `Access Denied` for some minutes until Steam
finishes processing it, while the API already reports `result: 1` and
`visibility: 0`. That gap is not an error and not a review queue; it just
resolves on its own.
8 changes: 7 additions & 1 deletion apps/utility-sw/hud/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ DIST_DIR="${HUD_DIR}/dist"
COMPILER_REPO="https://github.com/nicedayzhu/PanoramaCompiler.git"
COMPILER_REF="${PANORAMA_COMPILER_REF:-main}"

ADDON_NAME="5stack_utility_hud"
# CS2 resolves a workshop addon by its published file id, not by a friendly
# name: AddonsManager (and the engine) look for
# steamapps/workshop/content/730/<id>/<id>.vpk. A differently-named vpk
# downloads fine and then fails to mount, with the error pointing at a path that
# looks unrelated to the name you chose. WORKSHOP_ITEM_ID is unset only for the
# very first publish, before an id exists.
ADDON_NAME="${WORKSHOP_ITEM_ID:-5stack_utility_hud}"

log() { printf '\033[36m==>\033[0m %s\n' "$1"; }
die() { printf '\033[31mx\033[0m %s\n' "$1" >&2; exit 1; }
Expand Down
7 changes: 7 additions & 0 deletions apps/utility-sw/hud/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ fi

[ -d "${CONTENT_DIR}" ] || die "build.sh produced no ${CONTENT_DIR} (is vpkeditcli installed?)"

# CS2 resolves an addon by its id: steamapps/workshop/content/730/<id>/<id>.vpk.
# build.sh reads WORKSHOP_ITEM_ID for exactly this, so a mismatch here means the
# build ran without it -- the item would download and then fail to mount.
if [ "${PUBLISHED_FILE_ID}" != "0" ] && [ ! -f "${CONTENT_DIR}/${PUBLISHED_FILE_ID}.vpk" ]; then
die "expected ${CONTENT_DIR}/${PUBLISHED_FILE_ID}.vpk; re-run build.sh with WORKSHOP_ITEM_ID=${PUBLISHED_FILE_ID}"
fi

# An item with no vpk publishes fine and does nothing, which is the failure this
# whole file exists to avoid.
find "${CONTENT_DIR}" -name '*.vpk' | grep -q . || die "no .vpk in ${CONTENT_DIR}"
Expand Down
36 changes: 22 additions & 14 deletions apps/utility-sw/src/Hud/PracticeHud.cs
Original file line number Diff line number Diff line change
Expand Up @@ -573,19 +573,16 @@ private List<LineupRecord> MenuRows(ulong steamId)
}
}

if (_reachableOnly.Contains(steamId))
{
Vec3? at = PracticeSystem.Where(_system.Find(steamId))?.feet_position;
Vec3? standing = Standing(steamId);

if (at != null)
{
var here = PracticeReplay
.SpotAt(_library.For(steamId), at.Value)
.Select(UtilityTargetCluster.Key)
.ToHashSet(StringComparer.Ordinal);
if (_reachableOnly.Contains(steamId) && standing != null)
{
var here = PracticeReplay
.SpotAt(_library.For(steamId), standing.Value)
.Select(UtilityTargetCluster.Key)
.ToHashSet(StringComparer.Ordinal);

lineups = lineups.Where(l => here.Contains(UtilityTargetCluster.Key(l)));
}
lineups = lineups.Where(l => here.Contains(UtilityTargetCluster.Key(l)));
}

List<LineupRecord> matched = lineups
Expand All @@ -599,8 +596,6 @@ private List<LineupRecord> MenuRows(ulong steamId)
)
.ToList();

Vec3? standing = PracticeSystem.Where(_system.Find(steamId))?.feet_position;

if (standing == null)
{
return matched;
Expand Down Expand Up @@ -692,7 +687,20 @@ private bool RenderMenu(IPlayer player)
}
}

return _reachableOnly.Contains(steamId) ? "Throwable from here" : null;
// Only while it is actually narrowing anything. A player who is dead or
// between pawns has nowhere to be throwing from, so the filter stands
// down -- and a full list under a heading that says otherwise reads as
// broken just as surely as an empty one with no heading at all.
return _reachableOnly.Contains(steamId) && Standing(steamId) != null
? "Throwable from here"
: null;
}

private Vec3? Standing(ulong steamId)
{
IPlayer? player = _system.Find(steamId);

return player == null ? null : PracticeSystem.Where(player)?.feet_position;
}

private string Tag(ulong steamId, int shown)
Expand Down
Loading