Spectator mode: permission-gated ghost-pawn join + broadcast overlay - #48
Merged
Conversation
Adds a spectator role (ga_user_roles), a -spectate chat command to list and join live instances, and an optional team argument that assigns PRI.r_TaskForce/Team purely so the client's own same-team HUD checks resolve, without ever spawning a pawn (no model, no collision, no scoreboard entry, no world interaction). Also fixes a real crash this uncovered: HandlePlayerConnected unconditionally dereferenced Controller->Pawn for post-join bookkeeping (GPawnSessions, skill reapply, spawn GAME_EVENT), which never mattered before because every prior code path eventually got a pawn -- a genuine spectator is the first caller that keeps Pawn null permanently. Includes investigation notes (docs/claude/spawn-camera-position-handoff.md) on a separate, pre-existing camera/ViewTarget timing question raised during this work, for whoever picks that up next. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hook Live per-instance health + active-effect feed for a browser-source overlay: a rate-limited per-pawn push from the DLL (Actor__Tick, filtered to real-player TgPawn_Character only), an in-memory-only state store on the control server, and a token-gated read-only HTTP endpoint (GET /overlay?instance_id=N&token=X) that a local HTML page polls. Effect ids are raw UTgEffectGroup ids for now -- name/icon mapping is a follow-up once the effect-id catalog table lands. Also includes a currently-DISABLED client-side diagnostic hook (TgPawn::IsFriendlyWithLocalPawn, address confirmed via Ghidra) from investigating why spectators can't see friendly/enemy health bars. Installing it alongside two older, unverified diagnostic hooks (DrawActorOverlays/TGPostRenderFor) crashed a real Steam client with STATUS_ILLEGAL_INSTRUCTION; the address and prologue for this specific hook were independently re-verified as correct, so the crash cause is still unresolved. Kept disabled (commented out in dllmainclient.cpp) for whoever picks this investigation back up -- not required for the overlay feature above, which reads pre-existing pawn state and needs none of this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…spectate - Only push MSG_PAWN_HEALTH_SNAPSHOT when a spectator is actually connected to the instance (GActiveSpectatorCount), instead of every instance all the time. Rate-limit map now keys on r_nPawnId (not ATgPawn*) and is pruned on disconnect; SpectatorOverlayState gets a periodic Sweep() so a spectated- then-abandoned instance doesn't hold stale snapshots forever. - TgGamePostLogin now defaults to non-spectator when NewPlayer->Player can't be resolved yet, instead of skipping the flag-clear outright. - New -unspectate chat command: force-drops the spectating connection via PLAYER_CLOSE and routes home, without going through route_from_mission_instance's queue-continuation logic (which is only meaningful for a real queued player, not a spectator). Also includes the overlay HTML/icon iteration (drag/resize editor, demo mode, icon catalog) from local GUI testing.
- Relocate overlay/spectator-overlay.html into docker/spectator-overlay/ (only the html sits there for now — nothing else references the old overlay/ path, it was purely a local-testing artifact of the file's location). - Remove DEMO_MODE/DEMO_ROSTER/DEMO_EFFECT_CATALOG and the "Demo Mode" button along with the 37 placeholder icon files that only existed to feed it. EFFECT_CATALOG stays as the (currently empty) real hookup point for whenever the effect-id catalog work lands.
…lay, toggle raw HP display
New docker/spectator-overlay/skill-tree-overlay.html shows live per-player
skill trees for all 6 match players (2 stacked team rows, independently
sorted Assault/Medic/Robotics/Recon per row -- an earlier shared-slot-grid
design reserved a placeholder card for any class missing from a team, which
showed up as an ugly stretched-empty box on any real roster whose two teams
didn't have matching classes tree-for-tree). Backed by new
SkillTreeCatalog.{hpp,cpp} (build-summary computation + cached read) and a
new GET /overlay/skilltree endpoint. Main spectator-overlay.html gets the
same "P/T1/T2" build notation via GET /overlay/builds, a shared class-order
sort so both teams list players in the same Assault/Medic/Robotics/Recon
order, a restyled health-pill (square-edged, bordered, gradient "depth"
fill), vertical centering, an H-key admin-bar hide toggle for clean OBS
capture, and a toggle to hide the raw HP number entirely (players only ever
see the bar in-game, never a number -- off by default, persisted via
localStorage). New OverlayIdCatalog.{hpp,cpp} whitelists which effect/skill
group ids the DLL's raw dump is allowed to forward, and IpcServer now dedupes
before filtering through it.
Also removes the disabled, never-working IsFriendlyWithLocalPawn diagnostic
hook and its abandoned dinput8-client-diagnostic-build Makefile target
(crashed on load, address never resolved -- see prior commit's investigation
notes) -- dead weight left over from the original spectator-overlay
"friendly" health-bar investigation.
Polling/performance (control server runs on a single shared ASIO
io_context -- every listener, chat, matchmaking, and TCP session included --
so a slow synchronous call here stalls everything else, not just this one
request; the live box is not a resource powerhouse):
- GET /overlay (300ms, unchanged): health/effects only, served straight out
of the in-memory SpectatorOverlayState -- no DB hit at all. Cheap enough to
poll fast because there's nothing to query.
- GET /overlay/skilltree (new, 2s): the skill-tree page's live point-by-point
breakdown genuinely needs per-poll DB reads (GetSkillsForCharacter), so
this is deliberately ~7x slower than the health poll to bound it to a
couple of SQLite reads every 2s for a 2-6 player match -- negligible, but
kept off the fast path on principle.
- GET /overlay/builds (new, 6s): the "P/T1/T2" summary reads through
SkillTreeCatalog::GetCachedBuildSummary, a 30s-TTL cache that's also
event-driven invalidated the instant a build actually changes
(profile_switch/skill_save/skill_respec all call InvalidateCache
immediately) -- so a build change shows up within one 6s poll tick, not
after waiting out the cache, while a steady-state match does at most one
DB read per player per 30s regardless of poll frequency.
- The DLL-side health/effect push (SpectatorOverlayFeed, unchanged) stays
gated on GActiveSpectatorCount > 0, so none of this -- IPC push, HTTP
polling, DB reads -- costs anything when nobody is actually spectating.
zaxxx
approved these changes
Jul 28, 2026
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.
Summary
ga_user_roles-gated-spectate <instance_id> [attack|defense]chat command that joins a live instance as a permanent no-pawn viewer — no model, no collision, no scoreboard entry, no world interaction. The optional team argument assignsPRI.r_TaskForce/Teampurely so the client's own same-team HUD checks resolve; it never spawns a pawn.-unspectatereverses it and routes the player back home.HandlePlayerConnectedunconditionally dereferencedController->Pawnfor post-join bookkeeping (GPawnSessions, skill reapply, spawn GAME_EVENT). Never mattered before — every prior code path eventually got a pawn. A real spectator is the first caller that keepsPawnnull permanently, which crashed the DLL the first time it was actually exercised.GActiveSpectatorCount > 0— the DLL push, the HTTP polling, and the DB reads it triggers all cost nothing when nobody is actually spectating.OverlayIdCatalogwhitelist and split into effect ids (rendered as an icon tile) vs. skill ids (forwarded but never rendered on the main card) before ever reaching the overlay page.skill-tree-overlay.html) shows all 6 players' full skill trees side by side, backed by a newSkillTreeCatalog+GET /overlay/skilltree/GET /overlay/buildsendpoints. The main overlay's health card now also shows a compact "P/T1/T2" build notation (Balanced/Tree1/Tree2 point totals) per player.TgPawn::IsFriendlyWithLocalPawndiagnostic hook (address was Ghidra-confirmed correct, but installing it alongside two older unverified hooks crashed a real Steam client withSTATUS_ILLEGAL_INSTRUCTION, cause never root-caused) and the abandoneddinput8-flavored client diagnostic build target it needed. Left commented-out in a prior commit while under investigation; now deleted outright since it was never required by the overlay feature (which reads pre-existing pawn state server-side and needs none of this) and wasn't going anywhere.Explicitly out of scope for this PR: an in-progress
-nextplayer/-prevplayerspectator camera-cycling command was prototyped alongside this work and deliberately held back — not included in any commit here, left for a follow-up PR.Performance note for review
The control server runs a single shared ASIO
io_contextacross every listener — logins, inventory, beacons, chat, matchmaking, TCP sessions, and now the overlay HTTP server all share it. A slow synchronous call anywhere stalls everything else, not just that one request, so each new poll loop was sized with that in mind rather than left at whatever felt convenient during ad-hoc testing:GET /overlay(300ms): health + effect/skill ids, served straight out of an in-memory map (SpectatorOverlayState) populated by the DLL's push — no DB access at all on this path, which is why it can afford to poll fast.GET /overlay/skilltree(2s, new): the skill-tree page's live point-by-point breakdown genuinely needs a per-player DB read (GetSkillsForCharacter) every tick, so this is deliberately ~7x slower than the health poll — a couple of cheap SQLite reads every 2s for a small match, kept off the fast path on principle rather than because it's measured to be expensive.GET /overlay/builds(6s, new): the "P/T1/T2" summary reads through a 30s-TTL cache (SkillTreeCatalog::GetCachedBuildSummary) that's also event-driven invalidated the instant a build actually changes (profile-slot swap, skill save/respec all callInvalidateCacheimmediately) — so a real build change shows up within one 6s poll tick rather than waiting out the cache, while a steady-state match costs at most one DB read per player per 30s regardless of how often the page polls.IpcClient::Send()path (enqueue + async post, same as every other IPC message) and stays rate-limited to ~3 pushes/sec per real player pawn, filtered out immediately for bots/deployables/projectiles.Test plan
spectatorrole,-spectatea live instance, confirm: no pawn/model/collision/scoreboard entry, free-flight camera works-spectate <id> attack|defensedoesn't affect matchmaking/team-balance (ga_instance_playersunaffected)/overlay/buildspoll tick (not delayed by the 30s cache TTL)