Reliability retrofit: thread-safe WebSockets, getPlayer hardening, request validation, dedicated-server packaging#298
Open
coredmp95 wants to merge 37 commits into
Conversation
getHazards
…ckage.sh compile stage - Symlinked repo into /home/fabrice/dev/SatisfactoryModLoader/Mods/GameFeatures/FicsitRemoteMonitoring - Manually applied SML header patch (FGServerAPIManager.h private:->public: at line 116) after discovering the build.cs auto-apply's line-index check silently skipped it - Added build/package.sh (compile stage, env-var parameterized via UE_DIR/SML_PROJECT) - Added build/RUNBOOK.md skeleton documenting symlink -> patch-verify -> compile - RunUBT.sh FactoryEditor Linux Development exits 0 with both FicsitRemoteMonitoring and FicsitRemoteMonitoringServer modules compiled
- Appends RunUAT.sh PackagePlugin invocation scoped to Win64 client + Linux server (D-01/D-02), matching the CI-verified command shape from .github/workflows/build.yml, adapted for RunUAT.sh on Linux - Deliberately omits -serverplatform=Win64+Linux, -installed, and -merge - First execution confirmed RunUAT.sh flag parity with RunUAT.bat (no flag parsing errors) — Assumption A1 from 01-RESEARCH.md resolved - Cook stage failed on missing ArduinoKit script package reference from Content/Subsystems/FicsitRemoteMonitoring_BP.uasset (RESEARCH.md Pitfall 3), exactly the anticipated gated-fallback scenario — human approval required before cloning ArduinoKit per plan Task 2, not yet resolved in this commit
…come - Human approved cloning github.com/porisius/ArduinoKit (per CONTRIBUTING.md) into SatisfactoryModLoader/Mods/ArduinoKit (not Mods/GameFeatures/) after Task 1's cook failed on missing /Script/ArduinoKit Blueprint references. DiscIt was NOT cloned — it never surfaced in the cook failure. - After cloning, the cook failed a second time with "Plugin 'ArduinoKit' failed to load because module 'ArduinoKit' could not be found" since the package stage uses -nocompileeditor and ArduinoKit's module was only source, never built. Re-ran the compile stage (build/package.sh compile) to build ArduinoKit's module, then re-ran the package stage successfully. - Both platform zips now produced under Saved/ArchivedPlugins/FicsitRemoteMonitoring/: FicsitRemoteMonitoring-Windows.zip and FicsitRemoteMonitoring-LinuxServer.zip. Win64 zip's Binaries/Win64/ contains uv.dll and zlib1.dll via automatic RuntimeDependencies staging — no manual DLL copy step needed. - Updates build/RUNBOOK.md Step 4 from placeholder to documented outcome, including the ArduinoKit dependency and required recompile-before-package order; updates Known Pitfalls accordingly.
Root cause: FRM is a Game Feature mod; its pak is cooked with a baked-in
mount point of Mods/GameFeatures/FicsitRemoteMonitoring/. Deploying the
plugin to Mods/FicsitRemoteMonitoring/ (one level too shallow) made the
plugin content root mismatch the pak mount point, so Content/InitGameWorld
(the GameWorldModule) never resolved and SML's root-module scan missed FRM
("Discovered 2 world modules", 0 endpoints). Fix: deploy under the
GameFeatures segment, matching the official layout. RUNBOOK Step 5 (Deploy)
and Step 6 (Smoke test) updated with the correct path + verification guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fall - Document the canonical :8080/api/getWorldInv smoke test to match the phase's stated verification bar, with a note for overriding uWS.Port when 8080 is occupied (e.g. a colocated game client) and that uWS.Autostart=1 is required for the port to open. - Add the confirmed GameFeatures-vs-Mods deploy-path pitfall to the Known pitfalls section (Discovered 2 world modules / 0 endpoints when deployed one level too shallow).
…connect churn)
- Node ESM script using built-in global WebSocket (zero npm install, ws
package audited and rejected per RESEARCH.md Package Legitimacy Audit)
- Drives N concurrent virtual clients through churn loop against FRM's
uWS endpoint, matching ProcessClientRequest's subscribe/unsubscribe
message shape ({"action":..., "endpoints":[...]})
- Performs an HTTP liveness probe (built-in fetch) after the run; exits
non-zero on probe failure or unexpected socket error pattern, turning
a server crash/hang into an automated failure signal
- Smoke-tested against the live dedicated server (:8091): 99 clean
churn cycles, liveness probe 200, exit 0; server log confirms
"Remaining connections: 0" after each cycle (no subscriber/client leak)
…UNBOOK - New "WebSocket stress harness (Phase 2 verification)" section: prerequisite (deploy + uWS.Autostart=1), run command/flags, and the D-01 pass criterion (no crash + no leak) - No-leak criterion documented as reading ConnectedClients/EndpointSubscribers counts from the server's existing LogHttpServer connection-count log lines (no new endpoint added), with a live confirmation example from this machine's smoke run - States the harness is a throwaway verification artifact, run manually, not wired into build/package.sh
- Add ClientGenerations (game-thread-owned ws->ClientID generation map) - Add NextClientIDCounter, LoopLiveSockets, CapturedLoop (loop-thread-only bookkeeping for Plan 03's outbound path) - Declare IsClientCurrent(ws, ClientID) game-thread validity helper - No FCriticalSection introduced; FWebSocketUserData unchanged (THRD-01 prep)
- open: mint ClientID + record LoopLiveSockets synchronously on the loop
thread, then fire-and-forget AsyncTask(GameThread) adds ws to
ConnectedClients/ClientGenerations (weak-ptr capture, bShouldStop guard)
- close: last safe loop-thread touch removes ws from LoopLiveSockets, then
a marshaled game-thread task removes ws from ConnectedClients,
ClientGenerations, and every EndpointSubscribers entry, guarded by
IsClientCurrent generation-tag validation (D-03)
- Retire OnClientDisconnected: its EndpointSubscribers cleanup is folded
into close's marshaled block so no loop-thread mutation of that
registry remains
- Fix copy-paste log text in open ("Client Disconnected" -> "Client
Connected")
- Add IsClientCurrent(ws, ClientID) game-thread validity helper
implementation
…e game thread (THRD-01) - ProcessClientRequest now extracts Action/endpoint names/(ws,ClientID) on the loop thread only, then performs all EndpointSubscribers Add/Remove and the StartWebSocketPushDataLoop trigger inside a single AsyncTask(GameThread) block, guarded by TWeakObjectPtr + bShouldStop and re-validated via IsClientCurrent (drops stale/not-yet-registered messages per Assumption A2 rather than treating them as errors) - Unifies the endpoints-array and endpoints-string subscribe/unsubscribe forms behind one loop body; unsubscribe now consistently checks EndpointSubscribers.Contains() first, avoiding a pre-existing quirk where TMap::operator[] on the string-form could spuriously create an empty EndpointSubscribers entry (Rule 1 auto-fix) - StartWebSocketPushDataLoop: bHasRunningPushDataLoop=true is now set synchronously by its caller (the game-thread marshaled subscribe branch, its only call site) instead of inside the push-pacing thread; the terminal =false write is marshaled back via AsyncTask(GameThread) with a TWeakObjectPtr capture, so both reads/writes are game-thread owned end-to-end - OnMessageReceived's malformed-JSON drop path and the app.post/ res->onData HTTP dispatch path are unchanged (D-04 scope)
…ocketServer CapturedLoop is now assigned via uWS::Loop::get() once, from inside the loop-thread lambda (thread-local — must be called from the thread running app.run()), and reset to nullptr when that thread exits (success or exception) so no caller can defer() onto a stale/torn-down loop. Enables Task 2/3's outbound Loop::defer() work (THRD-02).
…game-thread snapshot
Reworks PushUpdatedData (THRD-02) so it no longer reads EndpointSubscribers
or calls Client->send() on the push-pacing thread. The recipient list and
per-endpoint JSON are now built inside a game-thread AsyncTask hop that
snapshots {ws, ClientID, payload} tuples (closing the read-side race,
RESEARCH Pitfall 3); each entry is then routed through
CapturedLoop->defer(), which re-validates ws against the loop-thread-only
LoopLiveSockets set and ClientID generation tag before calling ws->send()
(D-03 use-after-free defense). The UTF-8 payload is copied into an owning
std::string per deferred callback instead of referencing the stack-scoped
FTCHARToUTF8 buffer.
…loop thread Resolves RESEARCH Open Question #1 (D-04 shutdown safety): ws->close() is a socket-owning call and the same thread-ownership violation class as PushUpdatedData's send(), so it must run on the uWS loop thread rather than the game thread. Snapshots (ws, ClientID) pairs from the still-safe game-thread ConnectedClients/ClientGenerations maps, then defers the actual close() loop onto CapturedLoop, re-validating each ws against LoopLiveSockets before touching it. Safe no-op when CapturedLoop is already null (loop already torn down). bShouldStop set, listener close, and the registry Empty() calls are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Guard Cast<AFGCharacterPlayer> with IsValid() before dereferencing any dependent getter (defense in depth; array is class-filtered). - Guard GetInventory() before GetInventoryStacks() (former crash site :22); default to an empty Inventory array when null. - Guard GetHealthComponent() before GetCurrentHealth()/IsDead() (former crash site :34/:35); default PlayerHP to 0 and Dead to false when null. - Remove stale "//TODO: Find way to get player's name when they are offline" comment and the dead commented-out PlayerID line, superseded by the name-cache design landing in plan 03-02. - No fgcheck()/check() introduced; every null here is a legitimate runtime condition (player mid-disconnect), so it degrades to a safe default per D-02, never asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Capture the object returned by getActorJSON(Player) into a local and add a "pitch" number key on it before assigning "location", keeping the shared getActorJSON/CreateBaseJsonObject helpers unmodified (D-03 blast-radius limit; only PlayerLibrary.cpp changes). - Pitch is read from GetFGPlayerController()->GetControlRotation() (camera/look pitch), never GetActorRotation() (body rotation). Normalize a >180 raw value by subtracting 360, then clamp to [-90, 90]. Defaults to 0.0f behind an IsValid() controller guard. - Add #include "FGPlayerController.h". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- TMap<FString,FString> PlayerNameCache on the subsystem, keyed by AFGPlayerState::GetUserID(), session-persistent, no eviction (D-04) - InitPlayerConnectCache() installs SUBSCRIBE_UOBJECT_METHOD_AFTER on AGameModeBase::PostLogin, guards NewPlayer/PlayerState/empty UserID, no AsyncTask hop (PostLogin already runs on the game thread) - Wired into BeginPlay() alongside InitAPIRegistry() — the sibling Init* hooks are dead code precisely because they were never called Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- getPlayer records each live player's stable GetUserID() into a VisitedIDs set and opportunistically refreshes PlayerNameCache with the name already computed (A1 mitigation for late-populated names) - Second loop over PlayerNameCache emits a hand-built offline entry (Name from cache, Online:false, zeroed/empty defaults incl. location.pitch:0) for every cached ID not in VisitedIDs, dedupe by stable ID (Pitfall 4) — never via getActorJSON/CreateBaseJsonObject/ getActorFeaturesJSON with a null actor (Pattern 3/Pitfall 2) - Subsystem pointer IsValid()-guarded, not fgcheck() Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live-verification of the PostLogin name-cache hook (Task 3 checkpoint) crashed the dedicated server mid-connect: AFGPlayerState::GetUserID() dereferences the player's unique-net-id TSharedPtr, which is not yet populated when PostLogin fires on a dedicated server, so GetUserID() asserts (SharedPointer.h IsValid) and SIGSEGVs inside the hook. Fix (per user decision, reliability-first): drop the PostLogin connect hook (InitPlayerConnectCache + BeginPlay wiring + hook-only includes) entirely and populate PlayerNameCache from the getPlayer live loop, which only calls GetUserID() on fully-connected players where the net id is populated (the proven-safe path). Additionally gate the live-loop GetUserID()/cache-refresh on IsPlayerOnline()==true so a poll landing in the narrow mid-login window can never hit the unsafe path either. Deviation from D-04: cache is 'populated on first poll while online' rather than 'on connect'. PLYR-03's observable outcome (name survives disconnect) is preserved for any player observed online at least once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause (from engine source): FUniqueNetIdWrapper::IsValid() and ::ToString() null-check the underlying net-id internally, but the asserting operator-> / GetV1() path does not. FactoryGame's AFGPlayerState::GetUserID() uses that unchecked path, so it SIGSEGVs (SharedPointer.h IsValid assert) on a dedicated server whenever the net-id is unset — which is any time getPlayer runs with a player present. This crashed the server both from the PostLogin hook and, after that was removed, from the getPlayer live loop itself. Fix (elegant, honors D-04's stable-online-id intent): read the stable id via the ENGINE accessor APlayerState::GetUniqueId() (a plain member ref) guarded by FUniqueNetIdRepl::IsValid(), then ::ToString() — both null-check internally, so the deref can never fault. When no valid id is available ToString yields empty and we skip caching-by-id (never crash). Removed the earlier bOnline hack. No AFGPlayerState cast needed anymore. ToString() is COREONLINE_API, so link the first-party CoreOnline engine module (build.cs) — an engine module, not a vendored/third-party dependency, so the no-new-external-deps constraint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… actors Live verification exposed a duplicate: on this dedicated server a disconnected player keeps their character actor, so the live loop emits them (keyed by actor name) AND the cache emits them (keyed by stable net-id). The net-id dedupe could not bridge the two because a disconnected persisted actor has lost its net-id. Add a VisitedNames set (display names of every live actor, online or persisted- offline) and skip a cache entry whose name is already shown. Name is the one identifier consistent across the live-actor and cache representations. Research had assumed offline players have no actor (Pitfall 4 net-id dedupe only) — false on this server. 'Exactly one entry' is now honored in both states. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New build/tools/ws-validation-probe.mjs mirroring ws-stress-harness.mjs conventions (Node built-in WebSocket/fetch, zero new dependency) - Encodes all VALD-01 test-map cases: unknown endpoint name, POST-only endpoint (setSwitches) rejection, missing/unknown action, wrong-typed endpoints field, mixed valid/invalid partial-success (D-03), unparseable JSON with open-connection assertion (D-02), and the well-formed regression case - Exit-code contract: 0 all-as-expected, 1 validation regression, 2 connection/protocol error
- Declare private ValidateWSSubscriptionEnvelope on AFicsitRemoteMonitoring, co-located with AddErrorJson (VALD-01) - Define it in FicsitRemoteMonitoring.cpp: strict TryGetStringField/ TryGetArrayField + EJson::String extraction, GET-only registry match via ContainsByPredicate, D-03 partial-success problem collection capped at 20 entries with a "...and N more" suffix (T-04-03 backstop) - Not yet wired into ProcessClientRequest (Task 2)
…ure feedback
- ProcessClientRequest now calls ValidateWSSubscriptionEnvelope synchronously
on the loop thread before the existing AsyncTask(GameThread) hop; only
surviving ValidNames feed the unchanged subscribe/unsubscribe mutation body
(D-01/SC#3)
- Any non-empty ErrorMessage (whole-request reject or D-03 partial-success
report) is sent as a direct ws->send({"error":...}, TEXT) frame; connection
is never closed on validation failure (D-02)
- OnMessageReceived's JSON-parse-failure branch now also sends a descriptive
{"error":...} frame instead of log-only, keeping the connection open (D-02)
- Verified Config/PluginSettings.ini already bundles Icons and www via [StageSettings] +AdditionalNonUSFDirectories (no edit needed) - Added explanatory comment to FilterPlugin.ini noting it is never read by build/package.sh's Alpakit PackagePlugin (BuildCookRun) pipeline - No functional filter entries added (would mislead maintainers per 05-RESEARCH.md Pitfall 1)
- New "Icon release workflow (PKG-01)" section between package and deploy steps: generate icons via /frm icon on a client (dedicated servers can't generate icons - no UE textures loaded), copy PNGs into source Icons/, package, verify via unzip -l against the real packaged zip contents (not which .ini was edited) - Names Config/PluginSettings.ini's [StageSettings] +AdditionalNonUSFDirectories=Icons as the real mechanism and notes FilterPlugin.ini is inert for this pipeline - Includes a live /Icons/<ClassName>_C.png curl check expecting 200
- Declare SendFallbackPage on UFRM_RequestLibrary beside SendErrorJson/SendErrorMessage - Implement as an inline FString::Printf HTML body (503, text/html, AddResponseHeaders(res, false)) - Body carries a DocsURL anchor and an /api/ still-live note
- Add file-scope DocsURL constant (matches FicsitRemoteMonitoring.uplugin) - /* catch-all else branch: route empty/html/htm extensions to SendFallbackPage, keep HandleApiRequest for other extensions (sub-resource 404 JSON unchanged) - HandleGetRequest !FileLoaded branch: route html/htm Extension to SendFallbackPage, keep the existing 500 SendErrorMessage for other extensions - /Icons/* handler untouched
- Document rename-www -> curl battery -> restore-www procedure
- Covers all five carve-out cases: /index.html 503 + body check,
missing sub-resource 404 JSON, /Icons/* 404, /api/* JSON, restored 200
- Follows the existing -w 'HTTP %{http_code}\n' curl idiom and port caveat
The web-UI page fallback added in 05-02 classified any extensionless GET path as a page navigation. Bare-form API endpoints (e.g. /getWorldInv, /getModList) are also extensionless, so every unprefixed API call was diverted to the 503 branded HTML page instead of returning JSON — even on a healthy server — breaking the documented RUNBOOK smoke test and the plugin's core value (the remote API returning accurate game state). Fix: consult the endpoint registry before extension alone decides page vs API. Add AFicsitRemoteMonitoring::IsRegisteredEndpointName and gate the catch-all page discriminator on it, so any request naming a registered endpoint (or a mistyped one) keeps its existing HandleApiRequest JSON contract (200/401/404/405). The /api/* route, /Icons/*, and missing sub-resources are unaffected. Adds a bare-form regression guard case to the RUNBOOK PKG-02 battery. Compiles clean (bash build/package.sh compile). Found by gsd-code-reviewer (CR-01) during phase 05 code-review gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MSVC elevates C4459 (declaration hides global declaration) to an error under warnings-as-errors. In the unity build FRM_Request.cpp and FicsitRemoteMonitoring.cpp share a translation unit, so the SendFallbackPage 'DocsURL' parameter shadowed the file-scope 'static const FString DocsURL'. Linux/Clang did not emit the warning, so the server-only compile passed while the Windows client target failed to package. Rename the parameter to InDocsURL (matching the codebase In- prefix convention). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner/reviewer-facing summary of the WebSocket threading, getPlayer, request-validation, and dedicated-server packaging changes, with a file map and the live-verification evidence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Coexists with the generic actor-rotation 'pitch' field the upstream owner adds in porisius#297 (added inside the shared getActorJSON helper). Our field is the player camera/aim pitch (GetControlRotation), which is the only pitch meaningful for an upright player body; keeping a distinct 'lookPitch' key avoids shadowing upstream's 'pitch' (actor body rotation, meaningful for vehicles) when both land. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r file map Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
A targeted reliability retrofit of the HTTP/WebSocket monitoring API so it returns accurate,
real-time game state without crashing or corrupting the dedicated server — reliability of the
monitoring channel over feature breadth.
No new external dependencies. SML
^3.12.0compatibility preserved. All public API responseshapes are unchanged or additive-only (see Compatibility).
getPlayerlocation.lookPitch, persistent player names across disconnect.Changes
1. Thread-safe WebSocket request handling
FicsitRemoteMonitoring.cpp/.h— inboundopen/close/message callbacks and the push loop nolonger touch shared subsystem state (
ConnectedClients,EndpointSubscribers, subscription maps)from the uWS background thread. Introduces generation-tagged client identity
(
ClientGenerations+IsClientCurrent), marshals all mutation to the game thread viaAsyncTask, and defers outboundsend()/close()onto the captured uWS loop with liveness +generation re-checks. Retires the old
OnClientDisconnectedpath; fixes a connect/disconnect logbug. No client-visible behavior change — purely where the work runs.
2.
getPlayerendpoint reliabilityPlayerLibrary.cpp,FicsitRemoteMonitoring.h,FicsitRemoteMonitoring.build.cs—IsValid()guards on the two known crash sites (
GetInventory(),GetHealthComponent()) with safe fallbacks;new
location.lookPitch(camera/aim pitch, clamped[-90,90]); a session-persistentPlayerNameCache(keyed by the crash-safe
APlayerState::GetUniqueId(), notAFGPlayerState::GetUserID()whichSIGSEGVs on dedicated servers) so offline players are emitted without dereferencing despawned
actors. Links the
CoreOnlinemodule.3. WebSocket request validation (VALD-01)
FicsitRemoteMonitoring.cpp/.h— newValidateWSSubscriptionEnvelopeperforms strict envelopevalidation before any endpoint logic runs (action present/known;
endpointsmust be a string orarray of strings; GET-only registry match; capped problem reporting). Wired into
ProcessClientRequestandOnMessageReceived; rejected input returns an{"error": …}frame andnever reaches dispatch. HTTP
CallEndpointpath untouched (no REST regression).4. Dedicated-server asset packaging & fallback
FicsitRemoteMonitoring.cpp,FRM_Request.cpp/.h,Config/FilterPlugin.ini— a branded 503fallback page (
SendFallbackPage) when thewww/web UI bundle isn't deployed, with carve-outs sosub-resources /
/Icons/*//api/*keep their normal404/JSON content-type contract, and aguard so extensionless bare-form API calls (e.g.
/getWorldInv) keep returning JSON. Confirmedthat
Config/PluginSettings.ini(unchanged) is what stages theIcons/directory into a packagedbuild — verified against a real LinuxServer zip;
FilterPlugin.inimarked inert.Developer tooling (non-shipping)
build/package.sh(reproducible compile/package),build/RUNBOOK.md(deploy/verify runbook),build/tools/ws-stress-harness.mjs+ws-validation-probe.mjs(zero-dependency Node testinstruments),
.gitignore, and thisCHANGELOG.md.Compatibility
Unchanged:
Config/PluginSettings.ini(already correct); HTTPCallEndpointdispatch; SML^3.12.0; no new external C++/JS libraries (uses already-linked UnrealJson+ vendored uWS).Additive-only:
getPlayergainslocation.lookPitchand may now include offline players; WSsubscribe/unsubscribe now returns an
{"error": …}frame for malformed envelopes (previouslyundefined behavior). Well-formed requests are unaffected.
Note on upstream #297 (
feat: Actor Pitch)This PR intentionally exposes the player pitch as
location.lookPitch, notpitch, so itcoexists cleanly with the generic actor-rotation
pitchfield added in#297 (which lives in the shared
getActorJSONhelper). The two arecomplementary and use distinct keys:
pitch(from feat: Actor Pitch #297) = actor body rotation — meaningful for vehicles/trains tilting onterrain; ~0 for an upright player.
location.lookPitch(this PR) = the player's camera/aim pitch — the only pitch thatcarries information for a player.
Different files, so there is no merge conflict, and no key shadowing when both land.
Verification
No automated test framework exists (by design). Verified live on a deployed Linux dedicated
server:
Remaining connections: 0after each cycle, no crash/leak.
ws-validation-probe.mjs— 8/8 malformed-payload cases rejected; 25-client churn,no regression for valid clients.
LinuxServer.zipinspected (Icons/staged); PKG-02 six-case curl battery(503 fallback + carve-outs + bare-form JSON guard) all green.
See
CHANGELOG.mdfor the full per-area detail and reviewer file map.🤖 Generated with Claude Code