Skip to content

Spectator mode: permission-gated ghost-pawn join + broadcast overlay - #48

Merged
zaxxx merged 7 commits into
commonwealthga:masterfrom
Jeronyx:spectator_mode
Jul 28, 2026
Merged

Spectator mode: permission-gated ghost-pawn join + broadcast overlay#48
zaxxx merged 7 commits into
commonwealthga:masterfrom
Jeronyx:spectator_mode

Conversation

@Jeronyx

@Jeronyx Jeronyx commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Spectator mode: a new 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 assigns PRI.r_TaskForce/Team purely so the client's own same-team HUD checks resolve; it never spawns a pawn. -unspectate reverses it and routes the player back home.
  • Crash fix uncovered along the way: HandlePlayerConnected unconditionally dereferenced Controller->Pawn for 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 keeps Pawn null permanently, which crashed the DLL the first time it was actually exercised.
  • Spectator broadcast overlay: a rate-limited per-pawn health + active-effect/skill-id feed (DLL → in-memory control-server state → token-gated HTTP endpoints → a local browser-source HTML page meant for OBS). Gated end-to-end on GActiveSpectatorCount > 0 — the DLL push, the HTTP polling, and the DB reads it triggers all cost nothing when nobody is actually spectating.
    • Raw ids the DLL reports are filtered through a new OverlayIdCatalog whitelist 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.
    • A companion skill-tree overlay page (skill-tree-overlay.html) shows all 6 players' full skill trees side by side, backed by a new SkillTreeCatalog + GET /overlay/skilltree/GET /overlay/builds endpoints. The main overlay's health card now also shows a compact "P/T1/T2" build notation (Balanced/Tree1/Tree2 point totals) per player.
    • Main overlay visual pass: restyled health bar (squared-off, bordered, gradient "depth" fill), vertical centering, a shared left-to-right class order (Assault/Medic/Robotics/Recon) applied independently to both teams so the two rows read consistently, an H-key admin-bar hide toggle for clean OBS capture, and a toggle to hide the raw HP number entirely (off by default — in-game, players only ever see the bar, never a number).
  • Removed: the disabled TgPawn::IsFriendlyWithLocalPawn diagnostic hook (address was Ghidra-confirmed correct, but installing it alongside two older unverified hooks crashed a real Steam client with STATUS_ILLEGAL_INSTRUCTION, cause never root-caused) and the abandoned dinput8-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/-prevplayer spectator 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_context across 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 call InvalidateCache immediately) — 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.
  • DLL-side push is unchanged from the original review round: reuses the existing non-blocking 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.
  • The HTTP polling endpoints have no keep-alive (one full TCP round-trip per poll) — worth keeping an eye on under real match volume with several spectated instances at once, but the tiered intervals above (300ms/2s/6s, matched to what each endpoint actually needs to read) are the main lever already pulled to keep this affordable on a box that isn't a resource powerhouse.

Test plan

  • Grant spectator role, -spectate a live instance, confirm: no pawn/model/collision/scoreboard entry, free-flight camera works
  • Confirm -spectate <id> attack|defense doesn't affect matchmaking/team-balance (ga_instance_players unaffected)
  • Confirm both overlay pages render correctly for a real 3v3+ match: health/effects on the main overlay, full skill trees on the skill-tree page, consistent class ordering across both teams on both pages
  • Confirm the HP-number toggle defaults off and correctly swaps between showing/hiding the raw number without affecting the health bar fill itself
  • Confirm a mid-match loadout-profile swap updates the "P/T1/T2" build notation within one /overlay/builds poll tick (not delayed by the 30s cache TTL)
  • Load-check control-server CPU/latency on logins/inventory/beacons while both overlay pages are actively polling a live match

Jeronyx and others added 7 commits July 22, 2026 11:45
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
zaxxx merged commit 05a0166 into commonwealthga:master Jul 28, 2026
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.

2 participants