From 402fdb4dcc77b711006663754ac84791448a94d6 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Thu, 16 Jul 2026 18:41:33 +0200 Subject: [PATCH 01/36] feat(01-01): symlink plugin into SML host, verify patch, add build/package.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 --- build/RUNBOOK.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++ build/package.sh | 58 +++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 build/RUNBOOK.md create mode 100755 build/package.sh diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md new file mode 100644 index 00000000..1ed19d2a --- /dev/null +++ b/build/RUNBOOK.md @@ -0,0 +1,148 @@ +# Build Runbook + +Reproducible steps for compiling and packaging the FicsitRemoteMonitoring plugin +against the Satisfactory Mod Loader (SML) / Unreal Engine 5 toolchain on this +dev machine. This runbook explains each step; `build/package.sh` re-runs the +compile/package commands directly. + +Status: this is a skeleton, filled in incrementally across this phase's plans. +Currently documents the **symlink → verify patch → compile** segment (plan +01-01). Packaging, deploy, and smoke-test steps are placeholders below, +completed by plans 01-02 and 01-03. + +## Local environment facts (this machine) + +- Unreal Engine (Satisfactory CSS fork): `/mnt/data/UE` +- SML host project (Starter Project): `/home/fabrice/dev/SatisfactoryModLoader` +- `wwise-cli`: on `PATH`; Wwise SDK already downloaded and integrated into the + host project's Wwise project (`SatisfactoryModLoader_WwiseProject`). This is + a one-time, already-completed provisioning step — it is **not** part of the + repeatable compile/package sequence below and `build/package.sh` never + invokes it. +- `wine` + `msvc-wine`: already configured (`UE_WINE_MSVC` exported in + `~/.bashrc`, pointing at `/opt/msvc-wine`). Provides the MSVC toolchain UBT + needs to produce real Win64 binaries when cross-compiling the client from + Linux. + +## Step 1 — Symlink the plugin into the host project + +SML mods are developed as standalone git repos symlinked (never copied — this +must stay a live dev loop against this repo's working tree) into the host +Starter Project's `Mods/GameFeatures//` directory. It **must** be +directly under `Mods/GameFeatures/` (not plain `Mods/`, not +`Mods/GameFeatures/GameFeatures/`) — Alpakit's packaging automation +(`IsGameFeatureDLC()`) keys off this exact path to decide how to stage the mod +and mark it as a Game Feature; getting the location wrong produces a mod that +either isn't recognized or mounts its content at the wrong runtime path. + +```bash +ln -s /home/fabrice/dev/FicsitRemoteMonitoring \ + /home/fabrice/dev/SatisfactoryModLoader/Mods/GameFeatures/FicsitRemoteMonitoring +``` + +Verify it resolves correctly: + +```bash +readlink -f /home/fabrice/dev/SatisfactoryModLoader/Mods/GameFeatures/FicsitRemoteMonitoring +# expected: /home/fabrice/dev/FicsitRemoteMonitoring +``` + +## Step 2 — Verify the SML header patch is applicable + +`Source/FicsitRemoteMonitoringServer/FicsitRemoteMonitoringServer.build.cs` +auto-applies `Patches/FGServerAPIManager-FRM-04162025.patch` (flips a +`private:` to `public:` before `friend class UFGServerAPIManager;` at line +~116 of the host project's `FGServerAPIManager.h`, so the server module can +access `FFGRequestData`). It does this by shelling out to `patch -N -s` at UBT +module-rules construction time, **before** the first compile. + +**Caveat — silent-fail path:** the patch-apply logic catches failures and only +logs to stdout; it does not fail the UBT invocation. A missing/broken `patch` +binary, or an already-diverged header, would otherwise surface later as a +confusing C++ compile error instead of a clear "patch failed" message. +De-risk this before the first compile with a dry-run: + +```bash +patch -N -s --dry-run \ + /home/fabrice/dev/SatisfactoryModLoader/Source/FactoryDedicatedServer/Public/Networking/FGServerAPIManager.h \ + /home/fabrice/dev/FicsitRemoteMonitoring/Patches/FGServerAPIManager-FRM-04162025.patch +``` + +Exit code `0` means the patch can apply cleanly (or is already applied). Do +not apply it manually — the build.cs applies it automatically on the first +`RunUBT.sh` invocation in Step 3. After that first compile, sanity-check it +actually took: + +```bash +sed -n '116p' /home/fabrice/dev/SatisfactoryModLoader/Source/FactoryDedicatedServer/Public/Networking/FGServerAPIManager.h +# expected: public: (was private: before the patch applied) +``` + +## Step 3 — Compile + +Compiles the `FactoryEditor` target for Linux, which builds both plugin +modules (`FicsitRemoteMonitoring` runtime + `FicsitRemoteMonitoringServer` +server-only) and triggers the patch from Step 2. + +```bash +bash build/package.sh compile +``` + +Equivalent to running directly: + +```bash +UE_DIR=/mnt/data/UE +SML_PROJECT=/home/fabrice/dev/SatisfactoryModLoader/FactoryGame.uproject + +"$UE_DIR/Engine/Build/BatchFiles/RunUBT.sh" FactoryEditor Linux Development -project="$SML_PROJECT" +``` + +Override `UE_DIR` / `SML_PROJECT` environment variables if your paths differ +from this machine's defaults. + +Confirm both modules were reported as built in the RunUBT.sh output, and +re-run the Step 2 sanity check to confirm the patch applied. + +## Step 4 — Package (placeholder — added in plan 01-02) + +TODO: `RunUAT.sh -ScriptsForProject=... PackagePlugin -DLCName=FicsitRemoteMonitoring +-build -clientconfig=Shipping -serverconfig=Shipping -platform=Win64 -server +-serverplatform=Linux -nocompileeditor -utf8output`, packaging both the Win64 +client (via wine + msvc-wine) and the Linux dedicated-server target. + +## Step 5 — Deploy (placeholder — added in plan 01-03) + +TODO: unzip the packaged artifact(s) from `Saved/ArchivedPlugins/FicsitRemoteMonitoring/` +into a running `FactoryServer` (Linux) instance's `Mods/` directory (and/or +`FactoryGameEGS.exe` under wine for the client). + +## Step 6 — Smoke test (placeholder — added in plan 01-03) + +TODO: `curl -s http://localhost:8080/api/getWorldInv` against the launched +packaged instance — confirms the mod loaded with no errors and the monitoring +API is live end-to-end. + +## Known pitfalls + +- **Plugin not yet linked into the host project** — the very first build + attempt fails with "plugin not found" if Step 1 was skipped. +- **SML header patch silent-fail** — see Step 2's caveat; always dry-run + before trusting a green compile as proof the patch applied. +- **Missing optional `ArduinoKit`/`DiscIt` Blueprint dependencies** — may + cause the *package* (cook) stage to fail on `Client_DiscIT_*` Blueprint + references. Not needed for compile (Step 3); only clone them per + `CONTRIBUTING.md` if the packaging step (Step 4) demonstrably needs them. +- **`-nocompileeditor` before the editor has ever built this plugin** — always + run the explicit compile (Step 3) before packaging with `-nocompileeditor`, + or the package step will use stale/missing module binaries. +- **Disk space** — the root filesystem (`/`) on this machine is at ~95% + capacity with a limited margin free; UE's cook → stage → archive pipeline + creates multiple intermediate copies per platform. Monitor free space + during packaging; if it becomes a blocker, this is a pre-existing + environment constraint outside this phase's scope to fix. +- **DLL copy for the Windows client** — per `CONTRIBUTING.md`'s troubleshooting + section, if the packaged Win64 client fails to load with a module-load + error, copy `zlib.dll` and `uv.dll` from `Source/ThirdParty/uWebSockets/lib` + to the packaged mod's `Binaries/Win64` directory. Try without this manual + copy first — `RuntimeDependencies` staging declared in the build.cs files + should already handle it. diff --git a/build/package.sh b/build/package.sh new file mode 100755 index 00000000..33858040 --- /dev/null +++ b/build/package.sh @@ -0,0 +1,58 @@ +set -euo pipefail + +# build/package.sh — FicsitRemoteMonitoring build/package pipeline +# +# Reproducible wrapper around this project's verified UBT/UAT invocations +# (see build/RUNBOOK.md and .planning/phases/01-build-environment-configuration/01-RESEARCH.md). +# +# IMPORTANT: run this script with an explicit bash interpreter, e.g.: +# bash build/package.sh compile +# (there is intentionally no shebang line — invoking it directly via +# `./build/package.sh` on a system where /bin/sh is not bash, e.g. Ubuntu's +# dash, will fail because `set -o pipefail` is a bash-only option). +# +# Stages (first positional argument; defaults to "compile"): +# compile Compiles FactoryEditor (Linux, Development). Builds both plugin +# modules (FicsitRemoteMonitoring + FicsitRemoteMonitoringServer) +# and triggers the SML header patch auto-apply. This is the only +# stage implemented so far — see build/RUNBOOK.md for status. +# package Not yet implemented (added in a later plan of this phase — +# packages both the Win64 client and Linux server via RunUAT.sh +# PackagePlugin). +# +# Environment variables (override the this-machine defaults below): +# UE_DIR Path to the Unreal Engine (Satisfactory CSS fork) checkout. +# Default: /mnt/data/UE +# SML_PROJECT Path to the SatisfactoryModLoader host project's .uproject. +# Default: /home/fabrice/dev/SatisfactoryModLoader/FactoryGame.uproject +# +# No secrets are embedded or logged by this script. It only invokes local, +# fixed-path engine tooling already installed on the build machine. + +UE_DIR="${UE_DIR:-/mnt/data/UE}" +SML_PROJECT="${SML_PROJECT:-/home/fabrice/dev/SatisfactoryModLoader/FactoryGame.uproject}" + +STAGE="${1:-compile}" + +stage_compile() { + echo "==> [compile] RunUBT.sh FactoryEditor Linux Development -project=${SML_PROJECT}" + "${UE_DIR}/Engine/Build/BatchFiles/RunUBT.sh" FactoryEditor Linux Development -project="${SML_PROJECT}" +} + +stage_package() { + echo "Package stage not yet implemented — see build/RUNBOOK.md (added by a later plan in this phase)." >&2 + exit 1 +} + +case "${STAGE}" in + compile) + stage_compile + ;; + package) + stage_package + ;; + *) + echo "Unknown stage: ${STAGE} (expected: compile|package)" >&2 + exit 1 + ;; +esac From e27ceb3debac552fd6151787f958b01f4339d59b Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Thu, 16 Jul 2026 21:14:37 +0200 Subject: [PATCH 02/36] feat(01-02): add PackagePlugin package stage to build/package.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- build/package.sh | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/build/package.sh b/build/package.sh index 33858040..1fd38790 100755 --- a/build/package.sh +++ b/build/package.sh @@ -40,8 +40,25 @@ stage_compile() { } stage_package() { - echo "Package stage not yet implemented — see build/RUNBOOK.md (added by a later plan in this phase)." >&2 - exit 1 + echo "==> [package] RunUAT.sh PackagePlugin -DLCName=FicsitRemoteMonitoring -platform=Win64 -server -serverplatform=Linux" + # D-01/D-02 scope: Win64 client + Linux server only. Deliberately OMITS + # -serverplatform=Win64+Linux (no WindowsServer target), -installed (this + # is a source-built engine, not an Epic-installed one), and -merge (keep + # separate per-platform zips). Shipping config for both — the only + # supported/tested configuration per Alpakit's own default. Relies on + # -nocompileeditor since the compile stage above already built the + # editor with both plugin modules linked in. + "${UE_DIR}/Engine/Build/BatchFiles/RunUAT.sh" \ + -ScriptsForProject="${SML_PROJECT}" \ + PackagePlugin \ + -project="${SML_PROJECT}" \ + -DLCName=FicsitRemoteMonitoring \ + -build \ + -clientconfig=Shipping -serverconfig=Shipping \ + -platform=Win64 \ + -server -serverplatform=Linux \ + -nocompileeditor \ + -utf8output } case "${STAGE}" in From 0b4e41426be2b3071c1143b9e89de26d28059bff Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Thu, 16 Jul 2026 21:45:42 +0200 Subject: [PATCH 03/36] docs(01-02): resolve ArduinoKit cook gate, document package stage outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- build/RUNBOOK.md | 74 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index 1ed19d2a..77605eb7 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -103,12 +103,60 @@ from this machine's defaults. Confirm both modules were reported as built in the RunUBT.sh output, and re-run the Step 2 sanity check to confirm the patch applied. -## Step 4 — Package (placeholder — added in plan 01-02) +## Step 4 — Package -TODO: `RunUAT.sh -ScriptsForProject=... PackagePlugin -DLCName=FicsitRemoteMonitoring --build -clientconfig=Shipping -serverconfig=Shipping -platform=Win64 -server --serverplatform=Linux -nocompileeditor -utf8output`, packaging both the Win64 -client (via wine + msvc-wine) and the Linux dedicated-server target. +```bash +bash build/package.sh package +``` + +Equivalent to running directly: + +```bash +"$UE_DIR/Engine/Build/BatchFiles/RunUAT.sh" \ + -ScriptsForProject="$SML_PROJECT" \ + PackagePlugin \ + -project="$SML_PROJECT" \ + -DLCName=FicsitRemoteMonitoring \ + -build \ + -clientconfig=Shipping -serverconfig=Shipping \ + -platform=Win64 \ + -server -serverplatform=Linux \ + -nocompileeditor \ + -utf8output +``` + +Produces both required artifacts under +`/Saved/ArchivedPlugins/FicsitRemoteMonitoring/`: +`FicsitRemoteMonitoring-Windows.zip` (client) and +`FicsitRemoteMonitoring-LinuxServer.zip` (server). The Win64 zip's +`Binaries/Win64/` already contains `uv.dll` and `zlib1.dll` via the +`RuntimeDependencies` staging declared in the build.cs files — no manual DLL +copy step was needed on this machine. + +**Required external dependency — ArduinoKit:** this repo's +`Content/Subsystems/FicsitRemoteMonitoring_BP.uasset` hard-references +Blueprint nodes (serial/RS232 I/O) from the `ArduinoKit` plugin. Without it, +the cook stage fails outright (`ExitCode=25`, `Error_UnknownCookFailure`, +log mentions `/Script/ArduinoKit`). Per `CONTRIBUTING.md`, clone it into the +host project's `Mods/` folder (note: `Mods/`, not `Mods/GameFeatures/`): + +```bash +git clone https://github.com/porisius/ArduinoKit \ + /home/fabrice/dev/SatisfactoryModLoader/Mods/ArduinoKit +``` + +Then **re-run Step 3 (compile)** before packaging again — `ArduinoKit`'s own +C++ module is only source after cloning; `-nocompileeditor` in the package +stage means the newly-added module must already be built, or the cook fails +a second time with `Plugin 'ArduinoKit' failed to load because module +'ArduinoKit' could not be found`. This machine did not need `DiscIt` (it did +not surface in the cook failure, unlike ArduinoKit); do not pre-emptively +clone it — only add it if a future cook run demonstrably references it. + +ArduinoKit's clone lives in the sibling `SatisfactoryModLoader` checkout, not +in this repo — it is a build-environment dependency of the *host project*, +not a code change to this plugin, so it is not (and should not be) tracked by +this repo's git. ## Step 5 — Deploy (placeholder — added in plan 01-03) @@ -128,13 +176,15 @@ API is live end-to-end. attempt fails with "plugin not found" if Step 1 was skipped. - **SML header patch silent-fail** — see Step 2's caveat; always dry-run before trusting a green compile as proof the patch applied. -- **Missing optional `ArduinoKit`/`DiscIt` Blueprint dependencies** — may - cause the *package* (cook) stage to fail on `Client_DiscIT_*` Blueprint - references. Not needed for compile (Step 3); only clone them per - `CONTRIBUTING.md` if the packaging step (Step 4) demonstrably needs them. -- **`-nocompileeditor` before the editor has ever built this plugin** — always - run the explicit compile (Step 3) before packaging with `-nocompileeditor`, - or the package step will use stale/missing module binaries. +- **Missing `ArduinoKit` Blueprint dependency** — confirmed on this machine: + the cook stage fails outright without it (see Step 4). `DiscIt` did not + surface as a failure in this run; do not pre-emptively clone it. +- **`-nocompileeditor` before the editor has ever built this plugin (or a + newly-cloned dependency plugin like `ArduinoKit`)** — always run the + explicit compile (Step 3) after cloning `ArduinoKit` and before packaging + with `-nocompileeditor`, or the package step fails with `Plugin + 'ArduinoKit' failed to load because module 'ArduinoKit' could not be + found` — confirmed on this machine. - **Disk space** — the root filesystem (`/`) on this machine is at ~95% capacity with a limited margin free; UE's cook → stage → archive pipeline creates multiple intermediate copies per platform. Monitor free space From c48e4c62cdcdff67c8383f5016d31939e06a7fe5 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 08:18:25 +0200 Subject: [PATCH 04/36] fix: deploy FRM under Mods/GameFeatures to match baked pak mount point 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 --- build/RUNBOOK.md | 99 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 8 deletions(-) diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index 77605eb7..8b4cec74 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -158,17 +158,100 @@ in this repo — it is a build-environment dependency of the *host project*, not a code change to this plugin, so it is not (and should not be) tracked by this repo's git. -## Step 5 — Deploy (placeholder — added in plan 01-03) +## Step 5 — Deploy -TODO: unzip the packaged artifact(s) from `Saved/ArchivedPlugins/FicsitRemoteMonitoring/` -into a running `FactoryServer` (Linux) instance's `Mods/` directory (and/or -`FactoryGameEGS.exe` under wine for the client). +Unzip the packaged `-LinuxServer.zip` artifact from +`Saved/ArchivedPlugins/FicsitRemoteMonitoring/` into the dedicated server's mod +tree. **Critical:** FRM must land under `Mods/GameFeatures/`, not plain `Mods/`: -## Step 6 — Smoke test (placeholder — added in plan 01-03) +```bash +SRV=/mnt/data/satisfactory-server/SatisfactoryDedicatedServer +unzip -o \ + /home/fabrice/dev/SatisfactoryModLoader/Saved/ArchivedPlugins/FicsitRemoteMonitoring/FicsitRemoteMonitoring-LinuxServer.zip \ + -d "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring" +``` + +Resulting layout (the `.uplugin` and `Content/Paks/` land directly under this dir): + +``` +FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/FicsitRemoteMonitoring.uplugin +FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/Content/Paks/LinuxServer/FicsitRemoteMonitoringFactoryGame-LinuxServer.{pak,utoc,ucas} +``` + +**Why `Mods/GameFeatures/` and not `Mods/`** (this is a real, confirmed failure +mode — see `.planning/debug/resolved/frm-worldmodule-not-cooked.md`): FRM is a +Game Feature mod. Its pak is cooked with a baked-in mount point of +`../../../FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/` (Step 1's symlink +location drives this — Alpakit bakes the plugin's `Mods/GameFeatures/` path +into the pak). If you deploy the plugin one level too shallow at +`Mods/FicsitRemoteMonitoring/`, the runtime plugin content root no longer matches +the pak's baked mount point, so the cooked packages — including +`Content/InitGameWorld` (FRM's `GameWorldModule` blueprint) — never resolve under +the `FicsitRemoteMonitoring` plugin. SML's root-module asset-registry scan then +misses FRM entirely: the log reads `Discovered 2 world modules` (SML + ArduinoKit +only), **no** `AFicsitRemoteMonitoring*` subsystem spawns, `Registered API +Endpoint` stays `0`, and `/frm` reports "Unknown command" — even though the pak +mounts cleanly and SML still logs `FicsitRemoteMonitoring: 1.5.2` (that version +line comes from the C++ Binaries, not the content pak, which is what masks the +failure). Contrast: ArduinoKit is a plain mod cooked with mount point +`Mods/ArduinoKit/`, so it deploys to `Mods/ArduinoKit/` and matches. + +Verify the deploy path before launching: + +```bash +test -f "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/FicsitRemoteMonitoring.uplugin" \ + && echo "FRM deployed under GameFeatures (correct)" \ + || { echo "WRONG PATH — FRM not under Mods/GameFeatures/"; exit 1; } +``` + +Launch the server (output captured for the smoke test in Step 6): + +```bash +cd "$SRV" +./FactoryServer.sh -log -unattended > /mnt/data/satisfactory-server/server-run.log 2>&1 & +``` + +Confirm discovery succeeded once a session loads: + +```bash +grep 'Discovered .* world modules' /mnt/data/satisfactory-server/server-run.log | tail -1 +# expected: "Discovered 3 world modules of class GameWorldModule" (SML + ArduinoKit + FRM) +grep -c 'Registered API Endpoint' /mnt/data/satisfactory-server/server-run.log +# expected: > 0 (93 on this build) +``` + +## Step 6 — Smoke test + +FRM's uWS HTTP server is **off by default** on a dedicated server (`uWS.Autostart` +defaults to `false`) and binds `uWS.Port` (default `8080`). On this dev box `8080` +is already held by the local wine game client's own FRM, so enable autostart on a +free port via the server's `GameUserSettings.ini` before launching (settings live +under the `FicsitRemoteMonitoring.Server.` prefix; booleans are stored in +`mIntValues` as `0/1`): + +```ini +[/Script/FactoryGame.FGGameUserSettings] +mIntValues=(("FicsitRemoteMonitoring.Server.uWS.Port", 8091),("FicsitRemoteMonitoring.Server.uWS.Autostart", 1)) +``` + +After launch + session load, confirm the listener and query live data (note: FRM +endpoints are served at the path root, e.g. `/getWorldInv`, not under `/api/`): + +```bash +ss -tlnp | grep ':8091' # expect a FactoryServer LISTEN +curl -s http://localhost:8091/getModList # expect JSON incl. "Ficsit Remote Monitoring" 1.5.2 +curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/getWorldInv # expect HTTP 200 +``` + +A `200` with well-formed JSON confirms the monitoring API is live end-to-end on the +dedicated server. -TODO: `curl -s http://localhost:8080/api/getWorldInv` against the launched -packaged instance — confirms the mod loaded with no errors and the monitoring -API is live end-to-end. +> Note: the alternate dedicated-server route — the game Server API on `:7777` via a +> custom `Frm` function (`UFRM_Controller::Handler_Frm`) — currently returns +> `bad_function` even after the FRM server subsystem spawns; the mod's +> `RegisterRequestHandler` does not surface in the `:7777` dispatch table. Use the +> uWS route above for smoke testing until that separate issue is resolved. ## Known pitfalls From be9e1ac16d83be5ee49003d6641fa97af068e900 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 08:24:12 +0200 Subject: [PATCH 05/36] docs(01-03): finalize build/RUNBOOK.md smoke-test and deploy-path pitfall - 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). --- build/RUNBOOK.md | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index 8b4cec74..d16c2dac 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -223,19 +223,32 @@ grep -c 'Registered API Endpoint' /mnt/data/satisfactory-server/server-run.log ## Step 6 — Smoke test FRM's uWS HTTP server is **off by default** on a dedicated server (`uWS.Autostart` -defaults to `false`) and binds `uWS.Port` (default `8080`). On this dev box `8080` -is already held by the local wine game client's own FRM, so enable autostart on a -free port via the server's `GameUserSettings.ini` before launching (settings live -under the `FicsitRemoteMonitoring.Server.` prefix; booleans are stored in -`mIntValues` as `0/1`): +defaults to `false`) and binds `uWS.Port` (default `8080`). `uWS.Autostart=1` (or +running `/frm http start` in-game) is required before the server opens the port — +without it the port never binds and the curl below will fail to connect regardless +of whether the mod loaded correctly. + +The canonical smoke test, matching this phase's stated verification bar, is: + +```bash +curl -sf http://localhost:8080/api/getWorldInv +``` + +Expect HTTP 200 with a JSON body. Both `/getWorldInv` and `/api/getWorldInv` are +served by the uWS router; either path form works against the same port. + +**If port `8080` is already occupied** (e.g. a colocated Satisfactory game client +also running FRM on this machine, as on this dev box), set `uWS.Port` to a free +port before launching and curl that port instead. Settings live under the +`FicsitRemoteMonitoring.Server.` prefix in the server's `GameUserSettings.ini`; +booleans are stored in `mIntValues` as `0/1`: ```ini [/Script/FactoryGame.FGGameUserSettings] mIntValues=(("FicsitRemoteMonitoring.Server.uWS.Port", 8091),("FicsitRemoteMonitoring.Server.uWS.Autostart", 1)) ``` -After launch + session load, confirm the listener and query live data (note: FRM -endpoints are served at the path root, e.g. `/getWorldInv`, not under `/api/`): +After launch + session load, confirm the listener and query live data: ```bash ss -tlnp | grep ':8091' # expect a FactoryServer LISTEN @@ -279,3 +292,14 @@ dedicated server. to the packaged mod's `Binaries/Win64` directory. Try without this manual copy first — `RuntimeDependencies` staging declared in the build.cs files should already handle it. +- **Deployed one level too shallow (`Mods/FicsitRemoteMonitoring/` instead of + `Mods/GameFeatures/FicsitRemoteMonitoring/`)** — confirmed failure mode on + this machine, see Step 5. FRM's pak is cooked with a baked-in mount point + under `Mods/GameFeatures/`; deploying to plain `Mods/` deceptively looks + fine (SML still logs `FicsitRemoteMonitoring: 1.5.2` from the C++ Binaries, + and the pak still mounts) but the content pak's `GameWorldModule` never + resolves, so SML's log reads `Discovered 2 world modules` instead of `3`, + `Registered API Endpoint` stays at `0`, and `/frm` reports "Unknown + command". Always verify with the Step 5 `test -f ... .uplugin` deploy-path + guard and the `Discovered 3 world modules` / endpoint-count check before + moving on to the smoke test. From c048e11ff5e5a87046d63fcd413aa01ccb104c46 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 08:32:20 +0200 Subject: [PATCH 06/36] docs(01): remove stale skeleton note from build/RUNBOOK.md after phase verification --- build/RUNBOOK.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index d16c2dac..a7727960 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -5,10 +5,9 @@ against the Satisfactory Mod Loader (SML) / Unreal Engine 5 toolchain on this dev machine. This runbook explains each step; `build/package.sh` re-runs the compile/package commands directly. -Status: this is a skeleton, filled in incrementally across this phase's plans. -Currently documents the **symlink → verify patch → compile** segment (plan -01-01). Packaging, deploy, and smoke-test steps are placeholders below, -completed by plans 01-02 and 01-03. +This runbook documents the full pipeline end-to-end: **symlink → verify patch → +compile → package → deploy → smoke-test**, plus a Known pitfalls section. Every +step below has been run and verified live on this machine. ## Local environment facts (this machine) From c67226d4fb7b98bd1701b753f7c2008181bd74df Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 08:50:34 +0200 Subject: [PATCH 07/36] docs(01): document Windows client package validation in RUNBOOK Step 6 --- build/RUNBOOK.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index a7727960..fd61523d 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -259,6 +259,34 @@ curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ A `200` with well-formed JSON confirms the monitoring API is live end-to-end on the dedicated server. +### Validating the Windows client package (`-Windows.zip`) + +The Linux server package is the primary target, but the Windows client build is +validated the same way — deploy it into a game **client** install and load a +**single-player** world (no dedicated server needed; the world is ready immediately). +On the client, `uWS.Autostart` is typically already enabled and the options live +under the `FicsitRemoteMonitoring.` prefix (no `Server.` segment). Deploy path is the +same `Mods/GameFeatures/FicsitRemoteMonitoring/`: + +```bash +# with the game client CLOSED, back up any existing install first, then: +unzip -o FicsitRemoteMonitoring-Windows.zip \ + -d "/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring" +``` + +Launch the client, load a single-player world, then: + +```bash +curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:8080/getWorldInv +``` + +Expect HTTP 200 (the body may be `[]` on a brand-new save with no inventory +containers — an empty JSON array is still a valid 200). Confirm in the client's +`FactoryGame.log` that the mod loaded with **no** module-load error (the vendored +`uv.dll`/`zlib1.dll` in the package's `Binaries/Win64/` mean the CONTRIBUTING.md +DLL-copy fallback is normally unnecessary) and that SML logged +`Discovered N world modules of class GameWorldModule` counting your FRM install. + > Note: the alternate dedicated-server route — the game Server API on `:7777` via a > custom `Frm` function (`UFRM_Controller::Handler_Frm`) — currently returns > `bad_function` even after the FRM server subsystem spawns; the mod's From c808b7711d10bf48a06e812287991ef8b2aa5076 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 09:53:38 +0200 Subject: [PATCH 08/36] feat(02-01): add WS stress harness (connect/subscribe/unsubscribe/disconnect 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) --- build/tools/ws-stress-harness.mjs | 357 ++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 build/tools/ws-stress-harness.mjs diff --git a/build/tools/ws-stress-harness.mjs b/build/tools/ws-stress-harness.mjs new file mode 100644 index 00000000..ca93b668 --- /dev/null +++ b/build/tools/ws-stress-harness.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node +/** + * ws-stress-harness.mjs + * + * Throwaway D-01 verification instrument for Phase 2 (thread-safe-websocket-request-handling). + * Not shipped plugin code, not wired into build/package.sh — run manually per build/RUNBOOK.md. + * + * Drives N concurrent virtual WebSocket clients through a tight + * connect -> subscribe -> unsubscribe -> disconnect churn loop against FRM's + * live uWS endpoint, then performs an HTTP liveness probe. A server crash or + * hang under the churn turns into a non-zero process exit code, so this can + * be used as an automated pass/fail signal (see build/RUNBOOK.md "WebSocket + * stress harness" section for the full pass criterion, including the + * subscriber/client-count baseline check read from server logs). + * + * Zero new dependencies: uses Node's built-in global `WebSocket` (available + * since Node 21+, confirmed present on this machine's Node v24.15.0 via + * `node -e "console.log(typeof WebSocket)"`) and the built-in global `fetch`. + * Do NOT `npm install ws` — the npm `ws` package was audited and REJECTED + * for this harness (see .planning/phases/02-thread-safe-websocket-request-handling/02-RESEARCH.md, + * "Package Legitimacy Audit"). + * + * Usage: + * node build/tools/ws-stress-harness.mjs --help + * node build/tools/ws-stress-harness.mjs --clients 25 --duration 60 + * node build/tools/ws-stress-harness.mjs --host 127.0.0.1 --port 8091 --clients 50 --duration 300 + */ + +'use strict'; + +const DEFAULTS = { + host: '127.0.0.1', + port: 8080, + clients: 25, + duration: 60, // seconds + endpoint: 'getWorldInv', + probePath: null, // derived from --endpoint if not set + holdMs: 250, // time a client stays subscribed before unsubscribing + reconnectDelayMs: 50, // brief pause between disconnect and reconnect + openTimeoutMs: 5000, + probeTimeoutMs: 5000, + settleMs: 2000, // wait after closing all sockets before probing +}; + +function printUsage() { + const lines = [ + 'ws-stress-harness.mjs — Phase 2 WebSocket stress/churn verification harness', + '', + 'Opens N concurrent WebSocket clients against FicsitRemoteMonitoring\'s uWS', + 'endpoint and loops each one through connect -> subscribe -> unsubscribe ->', + 'disconnect for --duration seconds. After the run, performs an HTTP liveness', + 'probe; exits non-zero if the server did not answer (crash/hang signal).', + '', + 'Usage:', + ' node build/tools/ws-stress-harness.mjs [options]', + '', + 'Options:', + ` --host Server host (default: ${DEFAULTS.host})`, + ` --port uWS port (default: ${DEFAULTS.port}; dedicated server default is 8080, this box currently runs 8091)`, + ` --clients Concurrent virtual clients (default: ${DEFAULTS.clients})`, + ` --duration Total churn run duration in seconds (default: ${DEFAULTS.duration})`, + ` --endpoint Endpoint name to subscribe/unsubscribe (default: ${DEFAULTS.endpoint})`, + ' --probe-path HTTP liveness probe path (default: derived from --endpoint, e.g. /getWorldInv)', + ` --hold-ms Time a client stays subscribed before unsubscribing (default: ${DEFAULTS.holdMs})`, + ' --help Print this usage and exit 0', + '', + 'Exit codes:', + ' 0 All sockets closed cleanly AND the liveness probe returned HTTP 200.', + ' 1 Liveness probe failed or timed out (server crashed or hung).', + ' 2 Unexpected socket error pattern occurred during the churn run.', + '', + 'Example:', + ' node build/tools/ws-stress-harness.mjs --host 127.0.0.1 --port 8091 --clients 25 --duration 60', + ]; + console.log(lines.join('\n')); +} + +function parseArgs(argv) { + const opts = { ...DEFAULTS }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '--help': + case '-h': + opts.help = true; + break; + case '--host': + opts.host = argv[++i]; + break; + case '--port': + opts.port = parseInt(argv[++i], 10); + break; + case '--clients': + opts.clients = parseInt(argv[++i], 10); + break; + case '--duration': + opts.duration = parseInt(argv[++i], 10); + break; + case '--endpoint': + opts.endpoint = argv[++i]; + break; + case '--probe-path': + opts.probePath = argv[++i]; + break; + case '--hold-ms': + opts.holdMs = parseInt(argv[++i], 10); + break; + default: + console.error(`Unknown argument: ${arg} (see --help)`); + process.exit(2); + } + } + if (!opts.probePath) { + opts.probePath = `/${opts.endpoint}`; + } + return opts; +} + +// --- Shared run-wide counters ------------------------------------------------- + +function makeCounters() { + return { + opens: 0, + cleanCloses: 0, + unexpectedCloses: 0, + subscribesSent: 0, + unsubscribesSent: 0, + messagesReceived: 0, + connectErrors: 0, + otherErrors: 0, + }; +} + +/** + * Runs a single virtual client's connect -> subscribe -> unsubscribe -> + * disconnect churn loop repeatedly until `isRunning()` returns false. + */ +async function runClientLoop(clientId, opts, counters, isRunning, activeSockets) { + const url = `ws://${opts.host}:${opts.port}/`; + + while (isRunning()) { + let ws; + try { + ws = await openSocket(url, opts.openTimeoutMs); + } catch (err) { + counters.connectErrors++; + await sleep(opts.reconnectDelayMs); + continue; + } + + activeSockets.add(ws); + counters.opens++; + + ws.addEventListener('message', () => { + counters.messagesReceived++; + }); + + let unexpectedClose = false; + const closePromise = new Promise((resolve) => { + ws.addEventListener('close', (evt) => { + activeSockets.delete(ws); + // 1000 (normal) and 1005 (no status, common on a deliberate close()) + // are both treated as clean for this harness's purposes. + if (evt.code !== 1000 && evt.code !== 1005) { + unexpectedClose = true; + } + resolve(); + }); + ws.addEventListener('error', () => { + unexpectedClose = true; + }); + }); + + try { + // Subscribe frame — matches ProcessClientRequest's expected shape: + // { "action": "subscribe", "endpoints": [...] } + ws.send(JSON.stringify({ action: 'subscribe', endpoints: [opts.endpoint] })); + counters.subscribesSent++; + + await sleep(opts.holdMs); + + if (!isRunning()) { + // Duration elapsed while holding subscribed — still unsubscribe/close + // cleanly rather than abandoning the socket mid-loop. + } + + ws.send(JSON.stringify({ action: 'unsubscribe', endpoints: [opts.endpoint] })); + counters.unsubscribesSent++; + } catch (err) { + counters.otherErrors++; + } + + try { + ws.close(1000, 'churn-cycle-complete'); + } catch (err) { + counters.otherErrors++; + } + + await closePromise; + if (unexpectedClose) { + counters.unexpectedCloses++; + } else { + counters.cleanCloses++; + } + + await sleep(opts.reconnectDelayMs); + } +} + +function openSocket(url, timeoutMs) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + try { + ws.close(); + } catch (_) { + /* ignore */ + } + reject(new Error(`open timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(ws); + }); + ws.addEventListener('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * HTTP liveness probe using Node's built-in fetch. Returns true only on a + * 200 response within the timeout; false (never throws) otherwise so the + * caller can turn a crashed/hung server into a clear non-zero exit. + */ +async function livenessProbe(opts) { + const url = `http://${opts.host}:${opts.port}${opts.probePath}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.probeTimeoutMs); + try { + const res = await fetch(url, { signal: controller.signal }); + clearTimeout(timer); + return { ok: res.status === 200, status: res.status, url }; + } catch (err) { + clearTimeout(timer); + return { ok: false, status: null, url, error: err.message || String(err) }; + } +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.help) { + printUsage(); + process.exit(0); + } + + if (!Number.isInteger(opts.clients) || opts.clients <= 0) { + console.error('--clients must be a positive integer'); + process.exit(2); + } + if (!Number.isInteger(opts.duration) || opts.duration <= 0) { + console.error('--duration must be a positive integer (seconds)'); + process.exit(2); + } + if (typeof WebSocket === 'undefined') { + console.error( + 'Global WebSocket is not available in this Node runtime. This harness ' + + 'requires Node 21+ (built-in WebSocket global). Detected: ' + process.version + ); + process.exit(2); + } + + const counters = makeCounters(); + const activeSockets = new Set(); + const startTime = Date.now(); + const endTime = startTime + opts.duration * 1000; + const isRunning = () => Date.now() < endTime; + + console.log( + `[ws-stress-harness] starting: host=${opts.host} port=${opts.port} ` + + `clients=${opts.clients} duration=${opts.duration}s endpoint=${opts.endpoint} ` + + `probe=${opts.probePath}` + ); + + const clientPromises = []; + for (let i = 0; i < opts.clients; i++) { + clientPromises.push(runClientLoop(i, opts, counters, isRunning, activeSockets)); + } + + await Promise.all(clientPromises); + + // Force-close any sockets still open past the duration window (defensive; + // the per-client loop already closes cleanly on each cycle boundary). + for (const ws of activeSockets) { + try { + ws.close(1000, 'harness-shutdown'); + } catch (_) { + /* ignore */ + } + } + + await sleep(opts.settleMs); + + const probeResult = await livenessProbe(opts); + + const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(1); + console.log('[ws-stress-harness] run summary:'); + console.log(` elapsed: ${elapsedSec}s`); + console.log(` opens: ${counters.opens}`); + console.log(` clean closes: ${counters.cleanCloses}`); + console.log(` unexpected closes: ${counters.unexpectedCloses}`); + console.log(` subscribes sent: ${counters.subscribesSent}`); + console.log(` unsubscribes sent: ${counters.unsubscribesSent}`); + console.log(` messages received: ${counters.messagesReceived}`); + console.log(` connect errors: ${counters.connectErrors}`); + console.log(` other errors: ${counters.otherErrors}`); + console.log( + ` liveness probe: ${probeResult.url} -> ${ + probeResult.status !== null ? `HTTP ${probeResult.status}` : `FAILED (${probeResult.error})` + }` + ); + + if (!probeResult.ok) { + console.error( + '[ws-stress-harness] FAIL: liveness probe did not return HTTP 200 — server ' + + 'may have crashed or hung under load.' + ); + process.exit(1); + } + + const hadUnexpectedSocketErrors = + counters.unexpectedCloses > 0 || counters.otherErrors > 0 || counters.connectErrors > 0; + + if (hadUnexpectedSocketErrors) { + console.error( + '[ws-stress-harness] FAIL: unexpected socket error pattern occurred during the churn run ' + + `(unexpectedCloses=${counters.unexpectedCloses}, otherErrors=${counters.otherErrors}, ` + + `connectErrors=${counters.connectErrors}).` + ); + process.exit(2); + } + + console.log('[ws-stress-harness] PASS: no crash, liveness probe returned HTTP 200, all sockets closed cleanly.'); + process.exit(0); +} + +main().catch((err) => { + console.error('[ws-stress-harness] fatal error:', err); + process.exit(2); +}); From eabe0f47e67b8772ff1ddbe0850f97fa432034b1 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 09:54:06 +0200 Subject: [PATCH 09/36] docs(02-01): document WS stress harness usage and pass criterion in RUNBOOK - 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 --- build/RUNBOOK.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index fd61523d..32b99165 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -293,6 +293,86 @@ DLL-copy fallback is normally unnecessary) and that SML logged > `RegisterRequestHandler` does not surface in the `:7777` dispatch table. Use the > uWS route above for smoke testing until that separate issue is resolved. +## WebSocket stress harness (Phase 2 verification) + +`build/tools/ws-stress-harness.mjs` is a **throwaway verification artifact** +for Phase 2 (thread-safe-websocket-request-handling) — consistent with this +project's no-test-framework convention (manual/compile-time verification +per `.claude/CLAUDE.md`), it is **not** a committed permanent test and is +**not** wired into `build/package.sh`. Run it manually, by hand, when you +need to soak-test the WebSocket marshaling changes (THRD-01/THRD-02). + +It uses Node's built-in global `WebSocket` and `fetch` (Node v24+) — no +`npm install` required, no new dependency added to the repo. + +### Prerequisite + +Deploy a build and start the server with the uWS listener enabled +(`uWS.Autostart=1`), per Steps 3–6 above. Confirm the listener is up before +running the harness: + +```bash +ss -tlnp | grep ':8080' # or the configured uWS.Port, e.g. 8091 on this box +``` + +### Run command + +```bash +node build/tools/ws-stress-harness.mjs --help + +# Short smoke run: +node build/tools/ws-stress-harness.mjs --host 127.0.0.1 --port 8091 --clients 25 --duration 60 + +# Full sustained soak run (target ~5-10 min per D-01): +node build/tools/ws-stress-harness.mjs --host 127.0.0.1 --port 8091 --clients 25 --duration 600 +``` + +Flags: `--host` (default `127.0.0.1`), `--port` (default `8080`, matching +`uWS.Port`; use `8091` on this dev box where the dedicated server and the +game client are colocated — see Step 6's port note), `--clients` (default +`25`), `--duration` (seconds, default `60`), `--endpoint` (subscribe target, +default `getWorldInv`), `--probe-path` (liveness probe path, default derived +from `--endpoint`). + +Each virtual client loops connect → subscribe → unsubscribe → disconnect +against the uWS endpoint for the full run duration, then the harness closes +all sockets, waits a short settle period, and performs an HTTP liveness +probe (`GET /getWorldInv` by default). It prints a summary line with +connect/close/error counters and exits `0` only if every socket closed +cleanly **and** the probe returned HTTP 200. + +### Pass criterion + +A sustained run (~5–10 min target) passes when **both** of the following +hold: + +1. **No crash / no hang** — the harness itself exits `0` (liveness probe + stayed `200` throughout; no unexpected socket error pattern was + detected). +2. **No leak** — `ConnectedClients` / `EndpointSubscribers` counts return to + baseline after all harness clients disconnect. No live endpoint exposes + these counts directly, so read them from the server's existing + `LogHttpServer` connection-count log lines (the `open`/`close` handlers + already log `ConnectedClients.Num()` on every connect/disconnect): + + ```bash + tail -f /mnt/data/satisfactory-server/server-run.log | grep -E 'Connections|Remaining connections' + # expect "Remaining connections: 0" after each churn cycle settles, + # and no runaway growth in the "Connections: N" values while the + # harness is running + ``` + + A clean run shows `Remaining connections: 0` recurring after each + client's disconnect — confirmed live on this machine with a short smoke + run (3 clients / 5s / 99 churn cycles): the harness exited `0`, the + liveness probe returned HTTP 200, and `Remaining connections: 0` appeared + after every cycle in the server log, with no count drifting away from + baseline. + +This is a manual verification step (per `02-VALIDATION.md`'s Manual-Only +Verifications table) — no automated CI hook exists for it; run it before +`/gsd-verify-work` for this phase and whenever the marshaling code changes. + ## Known pitfalls - **Plugin not yet linked into the host project** — the very first build From 41b8366f82d403af4b856273b5a5d5dd8c8051e7 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 10:01:52 +0200 Subject: [PATCH 10/36] feat(02-02): declare thread-safe WS registry members and validity helper - 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) --- .../Public/FicsitRemoteMonitoring.h | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h index dc86ff60..7e148aad 100644 --- a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h +++ b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h @@ -114,10 +114,22 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem private: TFuture WebServer{}; - + bool bShouldStop = false; bool bHasRunningPushDataLoop = false; + // Loop-thread-only monotonic counter used to mint each connection's ClientID in wsBehavior.open. + int32 NextClientIDCounter{}; + + // Loop-thread-only liveness bookkeeping (ws -> ClientID). Written exclusively from wsBehavior.open/close + // and from inside Loop::defer() callbacks; never touched from the game thread. Consumed by Plan 03's + // deferred outbound sends. + TMap*, int32> LoopLiveSockets{}; + + // Captured once, on the uWS loop thread, inside StartWebSocketServer. Consumed by Plan 03's outbound + // Loop::defer() calls. + uWS::Loop* CapturedLoop{ nullptr }; + FString AuthenticationToken{}; friend class UFGPowerCircuitGroup; @@ -172,6 +184,11 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem TSet*> ConnectedClients{}; + // Game-thread-owned authoritative (ws -> ClientID) liveness/generation map. A marshaled task or deferred + // send must validate its (ws, ClientID) pair against this map (via IsClientCurrent) before touching + // ConnectedClients/EndpointSubscribers or dereferencing ws — defends against ABA/pointer-reuse (D-03). + TMap*, int32> ClientGenerations{}; + UFUNCTION(BlueprintImplementableEvent, Category = "Ficsit Remote Monitoring") void InitSerialDevice(); @@ -194,6 +211,10 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem void OnMessageReceived(uWS::WebSocket* ws, std::string_view message, uWS::OpCode opCode); void ProcessClientRequest(uWS::WebSocket* ws, const TSharedPtr& JsonRequest); + /** Game-thread ws-validity helper: true only if ws is still registered in ClientGenerations with a + * matching ClientID. ws is used only as an opaque map key here — never dereferenced. */ + bool IsClientCurrent(uWS::WebSocket* ws, int32 ClientID) const; + void PushUpdatedData(); void HandleGetRequest(uWS::HttpResponse* res, uWS::HttpRequest* req, FString FilePath); From 91602776fe8fc9b6094e31f5e00f41e8fbfbe336 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 10:05:25 +0200 Subject: [PATCH 11/36] feat(02-02): marshal wsBehavior.open/close to the game thread (THRD-01) - 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 --- .../Private/FicsitRemoteMonitoring.cpp | 68 ++++++++++++++++--- .../Public/FicsitRemoteMonitoring.h | 1 - 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 6b0350a3..999604ca 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -223,11 +223,40 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) wsBehavior.compression = uWS::SHARED_COMPRESSOR; - // Close handler (for when a client disconnects) + // Close handler (for when a client disconnects). Runs on the uWS loop thread; per uWS's + // open->close validity guarantee, ws and its userdata are still safely dereferenceable here + // (closeHandler fires before ~WebSocketData()). This is the last safe touch of ws on this + // thread. All shared-state mutation (ConnectedClients/EndpointSubscribers/ClientGenerations) + // is marshaled to the game thread and re-validated by generation tag (THRD-01, D-02/D-03). wsBehavior.close = [this](uWS::WebSocket* ws, int code, std::string_view message) { - ConnectedClients.Remove(ws); - UE_LOG(LogHttpServer, Log, TEXT("Client Disconnected. Remaining connections: %d"), ConnectedClients.Num()); - OnClientDisconnected(ws, code, message); // Ensure this signature matches + const int32 ClosedClientID = ws->getUserData()->ClientID; + + // Loop-thread-only bookkeeping, consumed by the outbound deferred-send path (Plan 03). + LoopLiveSockets.Remove(ws); + + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::GameThread, [WeakThis, ws, ClosedClientID]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self || Self->bShouldStop) return; // shutdown/teardown race guard (D-04) + + // Re-validate against the generation map (D-03) rather than trusting submission + // order (Pitfall 2) — a stale/reused ws must not mutate the wrong client's state. + if (Self->IsClientCurrent(ws, ClosedClientID)) + { + Self->ConnectedClients.Remove(ws); + Self->ClientGenerations.Remove(ws); + + // Folded from the retired OnClientDisconnected: remove ws from every + // subscription so no EndpointSubscribers mutation remains on the loop thread. + for (auto& Elem : Self->EndpointSubscribers) + { + Elem.Value.Remove(ws); + } + } + + UE_LOG(LogHttpServer, Log, TEXT("Client Disconnected. Remaining connections: %d"), Self->ConnectedClients.Num()); + }); }; // Message handler (for when a client sends a message) @@ -235,10 +264,26 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) OnMessageReceived(ws, message, opCode); // Make sure this signature matches }; + // Open handler (for when a client connects). Runs on the uWS loop thread; ws is provably + // valid here (freshly opened). Mint the ClientID generation tag synchronously (safe: this is + // the loop-thread-only counter/bookkeeping), then marshal the registry mutation to the game + // thread — fire-and-forget, no spin-wait (D-02). wsBehavior.open = [this](uWS::WebSocket* ws) { - ConnectedClients.Add(ws); - UE_LOG(LogHttpServer, Log, TEXT("Client Disconnected. Connections: %d"), ConnectedClients.Num()); + const int32 NewClientID = NextClientIDCounter++; + ws->getUserData()->ClientID = NewClientID; + LoopLiveSockets.Add(ws, NewClientID); + + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::GameThread, [WeakThis, ws, NewClientID]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self || Self->bShouldStop) return; // shutdown/teardown race guard (D-04) + + Self->ConnectedClients.Add(ws); + Self->ClientGenerations.Add(ws, NewClientID); // ws used only as an opaque map key + UE_LOG(LogHttpServer, Log, TEXT("Client Connected. Connections: %d"), Self->ConnectedClients.Num()); + }); }; app.get("/getCoffee", [this](auto* res, auto* req) { @@ -473,11 +518,12 @@ std::unordered_map ParseQueryString(const std::string& return QueryPairs; } -void AFicsitRemoteMonitoring::OnClientDisconnected(uWS::WebSocket* ws, int code, std::string_view message) { - // Remove the client from all endpoint subscriptions - for (auto& Elem : EndpointSubscribers) { - Elem.Value.Remove(ws); - } +bool AFicsitRemoteMonitoring::IsClientCurrent(uWS::WebSocket* ws, int32 ClientID) const +{ + // Game-thread-only. ws is used strictly as an opaque map key here — never dereferenced — so this is + // safe to call even for a ws that has since been closed/freed/reused (D-03 generation-tag defense). + const int32* FoundClientID = ClientGenerations.Find(ws); + return FoundClientID != nullptr && *FoundClientID == ClientID; } void AFicsitRemoteMonitoring::OnMessageReceived(uWS::WebSocket* ws, std::string_view message, uWS::OpCode opCode) { diff --git a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h index 7e148aad..0926c5db 100644 --- a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h +++ b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h @@ -207,7 +207,6 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem UFUNCTION(BlueprintCallable, Category = "Ficsit Remote Monitoring") FArduinoConfig GetSerialConfig(); - void OnClientDisconnected(uWS::WebSocket* ws, int code, std::string_view message); void OnMessageReceived(uWS::WebSocket* ws, std::string_view message, uWS::OpCode opCode); void ProcessClientRequest(uWS::WebSocket* ws, const TSharedPtr& JsonRequest); From b84d197dbf3831749ef20db3ccf2abcc720f8885 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 10:09:29 +0200 Subject: [PATCH 12/36] feat(02-02): marshal ProcessClientRequest subscribe/unsubscribe to the 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) --- .../Private/FicsitRemoteMonitoring.cpp | 99 ++++++++++++------- 1 file changed, 61 insertions(+), 38 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 999604ca..58fb6852 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -102,10 +102,15 @@ void AFicsitRemoteMonitoring::BeginPlay() void AFicsitRemoteMonitoring::StartWebSocketPushDataLoop() { if (bHasRunningPushDataLoop) return; - + + // This function is only ever invoked from ProcessClientRequest's marshaled game-thread subscribe + // branch, so this read/write of bHasRunningPushDataLoop is already game-thread owned. Set it here, + // synchronously, before spawning the push-pacing thread (D-04: all writes/reads of this flag must be + // game-thread owned end-to-end — do not move this into the Async(Thread,...) lambda below). + bHasRunningPushDataLoop = true; + Async(EAsyncExecution::Thread, [this]() { - bHasRunningPushDataLoop = true; UE_LOGFMT(LogHttpServer, Log, "Starting PushUpdatedData loop"); while (SocketRunning && !bShouldStop) { @@ -115,7 +120,17 @@ void AFicsitRemoteMonitoring::StartWebSocketPushDataLoop() FPlatformProcess::Sleep(PushCycle); } UE_LOGFMT(LogHttpServer, Log, "Stopped PushUpdatedData loop"); - bHasRunningPushDataLoop = false; + + // Marshal the terminal write back to the game thread (D-04) instead of writing it directly from + // this push-pacing thread. + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::GameThread, [WeakThis]() + { + if (AFicsitRemoteMonitoring* Self = WeakThis.Get()) + { + Self->bHasRunningPushDataLoop = false; + } + }); }); } @@ -546,60 +561,68 @@ void AFicsitRemoteMonitoring::OnMessageReceived(uWS::WebSocket* ws, const TSharedPtr& JsonRequest) { - FString Action = JsonRequest->GetStringField(TEXT("action")); + // Loop-thread-only: extract plain values while ws is still safely dereferenceable — this runs + // synchronously inside wsBehavior.message's own call stack (per uWS's open->close validity + // guarantee). No shared-state mutation happens here; the actual EndpointSubscribers/ + // bHasRunningPushDataLoop work is marshaled to the game thread below (THRD-01). + const FString Action = JsonRequest->GetStringField(TEXT("action")); + const int32 RequestClientID = ws->getUserData()->ClientID; + + TArray EndpointNames; const TArray>* EndpointsArray; - FString Endpoint; + FString SingleEndpoint; if (JsonRequest->TryGetArrayField(TEXT("endpoints"), EndpointsArray)) { for (const TSharedPtr& EndpointValue : *EndpointsArray) { - Endpoint = EndpointValue->AsString(); + EndpointNames.Add(EndpointValue->AsString()); + } + } + else if (JsonRequest->TryGetStringField(TEXT("endpoints"), SingleEndpoint)) + { + EndpointNames.Add(SingleEndpoint); + } + + if (EndpointNames.Num() == 0) return; + + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::GameThread, [WeakThis, ws, RequestClientID, Action, EndpointNames]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self || Self->bShouldStop) return; // shutdown/teardown race guard (D-04) + + // Drop stale/not-yet-registered messages rather than treat as an error (Assumption A2): a + // message for a ws whose ClientID isn't (or is no longer) in ClientGenerations is a valid + // ignorable state, not a crash — also covers Pitfall 2 (AsyncTask ordering isn't guaranteed; + // self-validate instead of trusting submission order). + if (!Self->IsClientCurrent(ws, RequestClientID)) return; + for (const FString& Endpoint : EndpointNames) + { if (Action == "subscribe") { - - if (!EndpointSubscribers.Contains(Endpoint)) { - EndpointSubscribers.Add(Endpoint, TSet*>()); + if (!Self->EndpointSubscribers.Contains(Endpoint)) + { + Self->EndpointSubscribers.Add(Endpoint, TSet*>()); } - if (!bHasRunningPushDataLoop) { - StartWebSocketPushDataLoop(); - } + if (!Self->bHasRunningPushDataLoop) + { + Self->StartWebSocketPushDataLoop(); + } - EndpointSubscribers[Endpoint].Add(ws); + Self->EndpointSubscribers[Endpoint].Add(ws); UE_LOG(LogHttpServer, Warning, TEXT("Client subscribed to endpoint: %s"), *Endpoint); } - else if (Action == "unsubscribe" && EndpointSubscribers.Contains(Endpoint)) + else if (Action == "unsubscribe" && Self->EndpointSubscribers.Contains(Endpoint)) { - EndpointSubscribers[Endpoint].Remove(ws); + Self->EndpointSubscribers[Endpoint].Remove(ws); UE_LOG(LogHttpServer, Warning, TEXT("Client unsubscribed from endpoint: %s"), *Endpoint); } } - } - else if (JsonRequest->TryGetStringField(TEXT("endpoints"), Endpoint)) { - - if (Action == "subscribe") - { - if (!EndpointSubscribers.Contains(Endpoint)) { - EndpointSubscribers.Add(Endpoint, TSet*>()); - } - - if (!bHasRunningPushDataLoop) { - StartWebSocketPushDataLoop(); - } - - EndpointSubscribers[Endpoint].Add(ws); - - UE_LOG(LogHttpServer, Warning, TEXT("Client subscribed to endpoint: %s"), *Endpoint); - } - else if (Action == "unsubscribe") - { - EndpointSubscribers[Endpoint].Remove(ws); - UE_LOG(LogHttpServer, Warning, TEXT("Client unsubscribed from endpoint: %s"), *Endpoint); - } - } + }); } void AFicsitRemoteMonitoring::PushUpdatedData() { From 144297864b49d688c9d77928b9f6a76008835632 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 10:17:25 +0200 Subject: [PATCH 13/36] feat(02-03): capture uWS loop pointer on the loop thread in StartWebSocketServer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../Private/FicsitRemoteMonitoring.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 58fb6852..1cf21483 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -216,6 +216,12 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) WebServer = Async(EAsyncExecution::Thread, [this]() { try { auto app = uWS::App(); + + // Captured once, on the uWS loop thread (uWS::Loop::get() is thread-local — calling it + // from any other thread returns a different, unrelated loop). Consumed by PushUpdatedData + // and StopWebSocketServer to defer() outbound send()/close() onto this thread (THRD-02). + CapturedLoop = uWS::Loop::get(); + auto World = GetWorld(); const int32 port = UFRMConfigManager::GetConfigOrDefault(TEXT("uWS.Port"), 8080); @@ -492,6 +498,11 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) } catch (...) { UE_LOG(LogHttpServer, Error, TEXT("Unknown Exception in WebSocket Server")); } + + // Teardown safety (RESEARCH Open Question #2): clear the captured loop pointer once this + // thread's app.run() returns (normally or via exception) so no push-pacing/game-thread caller + // can defer() onto a stale/torn-down loop after this thread exits. + CapturedLoop = nullptr; }); } From a7b9c5513bcb23d581b8d58feae460a8df37cb8f Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 10:19:49 +0200 Subject: [PATCH 14/36] feat(02-03): defer PushUpdatedData sends onto the loop thread with a 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. --- .../Private/FicsitRemoteMonitoring.cpp | 125 +++++++++++++++--- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 1cf21483..3b4300ba 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -638,29 +638,120 @@ void AFicsitRemoteMonitoring::ProcessClientRequest(uWS::WebSocketsend() here would violate uWS's single- + // thread socket affinity. So: (1) hop to the game thread to build each endpoint's JSON and snapshot + // its recipients as plain {ws, ClientID} pairs (never hand a live TSet/ws reference across threads), + // then (2) back on the pacing thread, defer() each send onto the loop thread, re-validating liveness + // against the loop-thread-only LoopLiveSockets set (D-03) before touching ws. + + struct FPushRecipient + { + uWS::WebSocket* Ws; + int32 ClientID; + }; + + struct FPushSnapshotEntry + { + FString Payload; + TArray Recipients; + }; + + TArray Snapshot; + + TWeakObjectPtr WeakThis(this); + FThreadSafeBool bSnapshotComplete = false; + + AsyncTask(ENamedThreads::GameThread, [WeakThis, &Snapshot, &bSnapshotComplete]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (Self && !Self->bShouldStop) + { + for (auto& Elem : Self->EndpointSubscribers) + { + if (Elem.Value.Num() == 0) + { + continue; + } + + bool bSuccess = false; + int32 ErrorCode = 404; + + FRequestData RequestData = FRequestData(); + RequestData.bIsAuthorized = true; + + FString Json; + Self->HandleEndpoint(Elem.Key, RequestData, bSuccess, ErrorCode, Json, EInterfaceType::Socket); + + FPushSnapshotEntry Entry; + Entry.Payload = MoveTemp(Json); + + for (uWS::WebSocket* Client : Elem.Value) + { + // Re-validate against the authoritative generation map (D-03) rather than trusting + // that everything in EndpointSubscribers is still current. + if (const int32* FoundClientID = Self->ClientGenerations.Find(Client)) + { + Entry.Recipients.Add({ Client, *FoundClientID }); + } + } + + if (Entry.Recipients.Num() > 0) + { + Snapshot.Add(MoveTemp(Entry)); + } + } } + bSnapshotComplete = true; + }); - bool bSuccess = false; - int32 ErrorCode = 404; + // The pacing thread's whole job is this periodic snapshot-then-send cycle, so it is expected to wait + // synchronously for the result (same fire-and-wait shape as CallEndpoint's existing game-thread hop, + // FicsitRemoteMonitoring.cpp CallEndpoint) — unlike the inbound WS callbacks (D-02), nothing here is + // blocking the uWS loop thread. + while (!bSnapshotComplete) + { + FPlatformProcess::Sleep(0.0001f); + } - FRequestData RequestData = FRequestData(); - RequestData.bIsAuthorized = true; + if (!CapturedLoop) + { + // Loop already torn down (shutdown race) — nothing safe to defer onto. + return; + } - FString Json; + for (const FPushSnapshotEntry& Entry : Snapshot) + { + // Owning UTF-8 copy: the deferred callback may run well after this stack frame (and this + // function's FTCHARToUTF8 buffer) is gone, so a plain char* into a stack-scoped converter must + // never be captured — copy into an owning std::string instead. + FTCHARToUTF8 Converted(*Entry.Payload); + const std::string PayloadUtf8(Converted.Get(), Converted.Length()); - this->HandleEndpoint(Endpoint, RequestData, bSuccess, ErrorCode, Json, EInterfaceType::Socket); + for (const FPushRecipient& Recipient : Entry.Recipients) + { + uWS::WebSocket* Ws = Recipient.Ws; + const int32 ClientID = Recipient.ClientID; + + CapturedLoop->defer([WeakThis, Ws, ClientID, PayloadUtf8]() + { + // Now executing on the loop thread. Re-validate ws against the loop-thread-only liveness + // set before dereferencing it — the client may have disconnected (and its ws* memory + // possibly reused) between the game-thread snapshot above and this callback running. + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self) + { + return; + } - FTCHARToUTF8 Converted(*Json); - const char* UWSOutput = Converted.Get(); - - // Broadcast updated data to all clients subscribed to this endpoint - for (uWS::WebSocket* Client : Elem.Value) { - Client->send(UWSOutput, uWS::OpCode::TEXT); + const int32* FoundClientID = Self->LoopLiveSockets.Find(Ws); + if (FoundClientID && *FoundClientID == ClientID) + { + Ws->send(PayloadUtf8, uWS::OpCode::TEXT); + } + }); } } } From eff79580409bda93242cdf72e9eb28db46ca7e28 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 10:22:02 +0200 Subject: [PATCH 15/36] feat(02-03): defer StopWebSocketServer's per-client close() onto the 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. --- .../Private/FicsitRemoteMonitoring.cpp | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 3b4300ba..5916c0d8 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -159,10 +159,46 @@ void AFicsitRemoteMonitoring::StopWebSocketServer() SocketListener = nullptr; UE_LOG(LogHttpServer, Log, TEXT("Closing all %d connections"), ConnectedClients.Num()); - for (const auto ConnectedClient : ConnectedClients) + + // D-04 shutdown safety / resolves RESEARCH Open Question #1: ws->close() is a socket-owning call, + // the same thread-ownership violation class as PushUpdatedData's send() — it must run on the uWS + // loop thread, not here on the game thread. Snapshot (ws, ClientID) pairs now (game-thread-owned + // ConnectedClients/ClientGenerations are still safe to read here), then defer the actual close() + // onto the loop thread; the deferred callback re-validates against LoopLiveSockets before + // touching ws. Safe no-op if CapturedLoop is already null (loop thread already torn down). + if (CapturedLoop) { - ConnectedClient->close(); + TArray*, int32>> ClientsToClose; + ClientsToClose.Reserve(ConnectedClients.Num()); + + for (const auto ConnectedClient : ConnectedClients) + { + if (const int32* FoundClientID = ClientGenerations.Find(ConnectedClient)) + { + ClientsToClose.Emplace(ConnectedClient, *FoundClientID); + } + } + + TWeakObjectPtr WeakThis(this); + CapturedLoop->defer([WeakThis, ClientsToClose]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self) + { + return; + } + + for (const auto& ClientPair : ClientsToClose) + { + const int32* FoundClientID = Self->LoopLiveSockets.Find(ClientPair.Key); + if (FoundClientID && *FoundClientID == ClientPair.Value) + { + ClientPair.Key->close(); + } + } + }); } + ConnectedClients.Empty(); } From ac4f0950e42190ce998e6672c2c845c9562bd0d8 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 10:47:19 +0200 Subject: [PATCH 16/36] chore: gitignore GSD ephemeral research cache Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index adf4ba89..05a95010 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ Binaries/* Intermediate/* -Saved/* \ No newline at end of file +Saved/* +# GSD ephemeral research cache (not tracked) +.planning/research/.cache/ From 9503b496f4a17289556932674041f0c09a83fd57 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 14:24:30 +0200 Subject: [PATCH 17/36] fix(03-01): null-safety-harden getPlayer live-actor loop - Guard Cast 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 --- .../Private/Endpoints/World/PlayerLibrary.cpp | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp index 863c5ee8..5309a4c9 100644 --- a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp +++ b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp @@ -17,23 +17,43 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, AFGCharacterPlayer* PlayerCharacter = Cast(Player); - // get player inventory - TArray InventoryStacks; - PlayerCharacter->GetInventory()->GetInventoryStacks(InventoryStacks); - TMap, int32> PlayerInventory = GetGroupedInventoryItems(InventoryStacks); - - //TODO: Find way to get player's name when they are offline FString PlayerName = GetPlayerName(PlayerCharacter); + // Safe defaults for every field that depends on a component that can + // legitimately be null while a player is mid-disconnect (PLYR-02/D-02). + TArray> InventoryJsonArray; + float Speed = 0.0f; + bool bOnline = false; + float PlayerHP = 0.0f; + bool bDead = false; + + if (IsValid(PlayerCharacter)) { + Speed = PlayerCharacter->GetVelocity().Length() * 0.036; + bOnline = PlayerCharacter->IsPlayerOnline(); + + UFGInventoryComponent* PlayerInventoryComponent = PlayerCharacter->GetInventory(); + if (IsValid(PlayerInventoryComponent)) { + TArray InventoryStacks; + PlayerInventoryComponent->GetInventoryStacks(InventoryStacks); + TMap, int32> PlayerInventory = GetGroupedInventoryItems(InventoryStacks); + InventoryJsonArray = GetInventoryJSON(PlayerInventory); + } + + UFGHealthComponent* PlayerHealthComponent = PlayerCharacter->GetHealthComponent(); + if (IsValid(PlayerHealthComponent)) { + PlayerHP = PlayerHealthComponent->GetCurrentHealth(); + bDead = PlayerHealthComponent->IsDead(); + } + } + JPlayer->Values.Add("Name", MakeShared(PlayerName)); JPlayer->Values.Add("ClassName", MakeShared(Player->GetClass()->GetName())); - JPlayer->Values.Add("location", MakeShared(getActorJSON(Player))); - //JPlayer->Values.Add("PlayerID", MakeShared(PlayerState->GetUserID())); - JPlayer->Values.Add("Speed", MakeShared(PlayerCharacter->GetVelocity().Length() * 0.036)); - JPlayer->Values.Add("Online", MakeShared(PlayerCharacter->IsPlayerOnline())); - JPlayer->Values.Add("PlayerHP", MakeShared(PlayerCharacter->GetHealthComponent()->GetCurrentHealth())); - JPlayer->Values.Add("Dead", MakeShared(PlayerCharacter->GetHealthComponent()->IsDead())); - JPlayer->Values.Add("Inventory", MakeShared(GetInventoryJSON(PlayerInventory))); + JPlayer->Values.Add("location", MakeShared(getActorJSON(Player))); + JPlayer->Values.Add("Speed", MakeShared(Speed)); + JPlayer->Values.Add("Online", MakeShared(bOnline)); + JPlayer->Values.Add("PlayerHP", MakeShared(PlayerHP)); + JPlayer->Values.Add("Dead", MakeShared(bDead)); + JPlayer->Values.Add("Inventory", MakeShared(InventoryJsonArray)); JPlayer->Values.Add("features", MakeShared(getActorFeaturesJSON(Player, PlayerName, "Player"))); OutJsonArray.Add(MakeShared(JPlayer)); From ad5c622557896e6d8c768d2ae1f423df1ac5821e Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 14:26:22 +0200 Subject: [PATCH 18/36] feat(03-01): inject location.pitch from control rotation - 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 --- .../Private/Endpoints/World/PlayerLibrary.cpp | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp index 5309a4c9..e30f3529 100644 --- a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp +++ b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp @@ -2,6 +2,7 @@ #include "FGCharacterPlayer.h" #include "FGCreatureSubsystem.h" +#include "FGPlayerController.h" #include "FGSporeFlower.h" #include "FicsitRemoteMonitoring.h" #include "RemoteMonitoringLibrary.h" @@ -27,6 +28,11 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, float PlayerHP = 0.0f; bool bDead = false; + // Camera/look pitch (PLYR-01/D-03), signed -90..90, sourced from the + // PlayerController's control rotation — NOT actor body rotation. + // Defaults to 0.0 when there is no valid controller (mid-disconnect). + float Pitch = 0.0f; + if (IsValid(PlayerCharacter)) { Speed = PlayerCharacter->GetVelocity().Length() * 0.036; bOnline = PlayerCharacter->IsPlayerOnline(); @@ -44,11 +50,25 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, PlayerHP = PlayerHealthComponent->GetCurrentHealth(); bDead = PlayerHealthComponent->IsDead(); } + + AFGPlayerController* PlayerController = PlayerCharacter->GetFGPlayerController(); + if (IsValid(PlayerController)) { + float RawPitch = PlayerController->GetControlRotation().Pitch; + if (RawPitch > 180.f) { + RawPitch -= 360.f; + } + Pitch = FMath::Clamp(RawPitch, -90.f, 90.f); + } } + // getPlayer-local injection into the object getActorJSON returns — the + // shared helper itself is NOT modified (D-03 blast-radius limit). + TSharedPtr LocationJson = getActorJSON(Player); + LocationJson->Values.Add("pitch", MakeShared(Pitch)); + JPlayer->Values.Add("Name", MakeShared(PlayerName)); JPlayer->Values.Add("ClassName", MakeShared(Player->GetClass()->GetName())); - JPlayer->Values.Add("location", MakeShared(getActorJSON(Player))); + JPlayer->Values.Add("location", MakeShared(LocationJson)); JPlayer->Values.Add("Speed", MakeShared(Speed)); JPlayer->Values.Add("Online", MakeShared(bOnline)); JPlayer->Values.Add("PlayerHP", MakeShared(PlayerHP)); From a2b44b1c2523930d0c78ba19a31ff8cacdbf45e2 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 14:32:02 +0200 Subject: [PATCH 19/36] feat(03-02): add player name cache + PostLogin connect hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TMap 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 --- .../Private/FicsitRemoteMonitoring.cpp | 33 +++++++++++++++++-- .../Public/FicsitRemoteMonitoring.h | 5 +++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 5916c0d8..83d91781 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -28,6 +28,8 @@ #include "Endpoints/World/Resources.h" #include "Endpoints/World/Session.h" #include "Engine/World.h" +#include "FGPlayerState.h" +#include "GameFramework/GameModeBase.h" #include "Libraries/Validation.h" #include "Misc/FileHelper.h" #include "Patching/NativeHookManager.h" @@ -64,7 +66,10 @@ void AFicsitRemoteMonitoring::BeginPlay() // Load FRM's API Endpoints InitAPIRegistry(); - + + // Populate the player display-name cache on connect (PLYR-03, D-04). + InitPlayerConnectCache(); + const FString AuthToken = UFRMConfigManager::GetConfigOrDefault(TEXT("uWS.AuthenticationToken"), ""); // Debug log to verify token retrieval -Porisius @@ -1070,7 +1075,7 @@ void AFicsitRemoteMonitoring::InitTrainDerailNotification() { if (!WITH_EDITOR) {/* auto World = GetWorld(); - + // void OnCollided( AFGRailroadVehicle* ourVehicle, float ourVelocity, AFGRailroadVehicle* otherVehicle, float otherVelocity, bool shouldDerail ); SUBSCRIBE_METHOD_AFTER(AFGRailroadSubsystem::OnTrainsCollided, [this](AFGTrain* PriTrain, AFGTrain* SecTrain) { @@ -1079,6 +1084,30 @@ if (!WITH_EDITOR) {/* } } +// Populates PlayerNameCache on connect (PLYR-03, D-04). First-time integration of a +// SUBSCRIBE_UOBJECT_METHOD_AFTER hook that is actually wired into BeginPlay (unlike the dead +// InitOutageNotification/InitTrainDerailNotification siblings above) — treat as new, unverified +// runtime surface until live-connect-verified (03-02 Task 3). +void AFicsitRemoteMonitoring::InitPlayerConnectCache() { + #if (!WITH_EDITOR) + { + SUBSCRIBE_UOBJECT_METHOD_AFTER(AGameModeBase, PostLogin, [this](AGameModeBase* GameMode, APlayerController* NewPlayer) + { + if (!IsValid(NewPlayer)) { return; } + + const AFGPlayerState* PlayerState = Cast(NewPlayer->PlayerState); + if (!IsValid(PlayerState)) { return; } + + const FString UserID = PlayerState->GetUserID(); + if (UserID.IsEmpty()) { return; } // defensive: never cache an empty key + + // PostLogin already runs on the game thread — no AsyncTask hop needed here. + PlayerNameCache.Add(UserID, PlayerState->GetPlayerName()); + }); + } + #endif +} + void AFicsitRemoteMonitoring::RegisterEndpoint(const FAPIEndpoint& Endpoint) { diff --git a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h index 0926c5db..66c442a1 100644 --- a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h +++ b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h @@ -189,6 +189,10 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem // ConnectedClients/EndpointSubscribers or dereferencing ws — defends against ABA/pointer-reuse (D-03). TMap*, int32> ClientGenerations{}; + // Player display-name cache, keyed by AFGPlayerState::GetUserID() (stable across reconnect). + // Populated on connect via InitPlayerConnectCache(); never evicted (cleared only on server restart). + TMap PlayerNameCache{}; + UFUNCTION(BlueprintImplementableEvent, Category = "Ficsit Remote Monitoring") void InitSerialDevice(); @@ -200,6 +204,7 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem void InitAPIRegistry(); void InitOutageNotification(); void InitTrainDerailNotification(); + void InitPlayerConnectCache(); void StartWebSocketServer(bool bSkipIfRunning = false); void StopWebSocketServer(); From ddaf247c041338e2a95f0810eec335658b7e955b Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 14:35:22 +0200 Subject: [PATCH 20/36] feat(03-02): emit offline players with dedupe + opportunistic refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Private/Endpoints/World/PlayerLibrary.cpp | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp index e30f3529..3c7f5dad 100644 --- a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp +++ b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp @@ -3,15 +3,82 @@ #include "FGCharacterPlayer.h" #include "FGCreatureSubsystem.h" #include "FGPlayerController.h" +#include "FGPlayerState.h" #include "FGSporeFlower.h" #include "FicsitRemoteMonitoring.h" #include "RemoteMonitoringLibrary.h" #include "Kismet/GameplayStatics.h" +namespace { + // Hand-built offline player entry (PLYR-03/D-01). The shared JSON helpers + // (CreateBaseJsonObject / getActorJSON / getActorFeaturesJSON) all dereference + // their actor argument unconditionally on the first line, so a disconnected + // player (no actor) must be assembled field-by-field here instead of calling + // them with a null/placeholder actor (03-RESEARCH.md Pattern 3 / Pitfall 2). + // + // Default convention (documented for the SUMMARY): no last-known state is + // cached or replayed — every live-only field gets a neutral zero/false/empty + // default, matching the live entry's field names/types exactly. Name is the + // only field sourced from real data (the connect-time cache). + TSharedPtr BuildOfflinePlayerJson(const FString& UserID, const FString& CachedName) { + TSharedPtr JPlayer = MakeShared(); + JPlayer->Values.Add("ID", MakeShared(UserID)); + JPlayer->Values.Add("Name", MakeShared(CachedName)); + JPlayer->Values.Add("ClassName", MakeShared(AFGCharacterPlayer::StaticClass()->GetName())); + + TSharedPtr Location = MakeShared(); + Location->Values.Add("x", MakeShared(0)); + Location->Values.Add("y", MakeShared(0)); + Location->Values.Add("z", MakeShared(0)); + Location->Values.Add("rotation", MakeShared(0)); + Location->Values.Add("pitch", MakeShared(0)); + JPlayer->Values.Add("location", MakeShared(Location)); + + JPlayer->Values.Add("Speed", MakeShared(0)); + JPlayer->Values.Add("Online", MakeShared(false)); + JPlayer->Values.Add("PlayerHP", MakeShared(0)); + JPlayer->Values.Add("Dead", MakeShared(false)); + JPlayer->Values.Add("Inventory", MakeShared(TArray>{})); + + // features: mirror getActorFeaturesJSON's shape (RemoteMonitoringLibrary.cpp:215-244) + // field-for-field, with zeroed/defaulted coordinates instead of a live actor location. + TSharedPtr Properties = MakeShared(); + Properties->Values.Add("name", MakeShared(CachedName)); + Properties->Values.Add("type", MakeShared(TEXT("Player"))); + + TSharedPtr Coordinates = MakeShared(); + Coordinates->Values.Add("x", MakeShared(0)); + Coordinates->Values.Add("y", MakeShared(0)); + Coordinates->Values.Add("z", MakeShared(0)); + + TSharedPtr Geometry = MakeShared(); + Geometry->Values.Add("coordinates", MakeShared(Coordinates)); + Geometry->Values.Add("type", MakeShared(TEXT("Point"))); + + TSharedPtr Features = MakeShared(); + Features->Values.Add("properties", MakeShared(Properties)); + Features->Values.Add("geometry", MakeShared(Geometry)); + JPlayer->Values.Add("features", MakeShared(Features)); + + return JPlayer; + } +} + void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, TArray>& OutJsonArray) { TArray FoundActors; + // Stable GetUserID()s already emitted by the live loop below — the offline + // enumeration loop (PlayerNameCache) skips any ID present here so a + // live-and-cached player appears exactly once (PLYR-03/Pitfall 4). + TSet VisitedIDs; + + // Subsystem pointer used both for the opportunistic in-loop cache refresh and + // the offline-enumeration loop after it. IsValid()-guarded (never fgcheck) — + // the subsystem can legitimately be null during world teardown/startup. + AFicsitRemoteMonitoring* ModSubsystem = AFicsitRemoteMonitoring::Get(WorldContext->GetWorld()); + const bool bHasValidSubsystem = IsValid(ModSubsystem); + UGameplayStatics::GetAllActorsOfClass(WorldContext->GetWorld(), AFGCharacterPlayer::StaticClass(), FoundActors); for (AActor* Player : FoundActors) { TSharedPtr JPlayer = CreateBaseJsonObject(Player); @@ -59,6 +126,24 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, } Pitch = FMath::Clamp(RawPitch, -90.f, 90.f); } + + // Dedupe (Pitfall 4) + opportunistic name-cache refresh (A1 mitigation, + // Open Question 3): record this player's stable ID so the offline + // enumeration loop below skips them, and refresh PlayerNameCache with + // the name already computed here in case it was empty at PostLogin time. + const APlayerState* PlayerStateBase = PlayerCharacter->GetPlayerState(); + if (IsValid(PlayerStateBase)) { + const AFGPlayerState* PlayerState = Cast(PlayerStateBase); + if (IsValid(PlayerState)) { + const FString UserID = PlayerState->GetUserID(); + if (!UserID.IsEmpty()) { + VisitedIDs.Add(UserID); + if (bHasValidSubsystem) { + ModSubsystem->PlayerNameCache.Add(UserID, PlayerName); + } + } + } + } } // getPlayer-local injection into the object getActorJSON returns — the @@ -78,6 +163,21 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, OutJsonArray.Add(MakeShared(JPlayer)); }; + + // Offline enumeration (PLYR-03/D-01): every cached player NOT visited by the + // live loop above gets a hand-built offline entry (Pattern 3) — never via + // CreateBaseJsonObject/getActorJSON/getActorFeaturesJSON, which would + // dereference a null actor. + if (bHasValidSubsystem) { + for (const TPair& CachedPlayer : ModSubsystem->PlayerNameCache) { + const FString& UserID = CachedPlayer.Key; + if (VisitedIDs.Contains(UserID)) { + continue; + } + + OutJsonArray.Add(MakeShared(BuildOfflinePlayerJson(UserID, CachedPlayer.Value))); + } + } }; void UPlayerLibrary::getDoggo(UObject* WorldContext, FRequestData RequestData, TArray>& OutJsonArray) { From a8f10762b78840d4134372631e8e06af21394d27 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 15:08:35 +0200 Subject: [PATCH 21/36] fix(03-02): remove crashing PostLogin hook; cache name from live loop 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 --- .../Private/Endpoints/World/PlayerLibrary.cpp | 34 ++++++++++++------- .../Private/FicsitRemoteMonitoring.cpp | 30 ---------------- .../Public/FicsitRemoteMonitoring.h | 6 ++-- 3 files changed, 25 insertions(+), 45 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp index 3c7f5dad..7572c282 100644 --- a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp +++ b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp @@ -127,19 +127,27 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, Pitch = FMath::Clamp(RawPitch, -90.f, 90.f); } - // Dedupe (Pitfall 4) + opportunistic name-cache refresh (A1 mitigation, - // Open Question 3): record this player's stable ID so the offline - // enumeration loop below skips them, and refresh PlayerNameCache with - // the name already computed here in case it was empty at PostLogin time. - const APlayerState* PlayerStateBase = PlayerCharacter->GetPlayerState(); - if (IsValid(PlayerStateBase)) { - const AFGPlayerState* PlayerState = Cast(PlayerStateBase); - if (IsValid(PlayerState)) { - const FString UserID = PlayerState->GetUserID(); - if (!UserID.IsEmpty()) { - VisitedIDs.Add(UserID); - if (bHasValidSubsystem) { - ModSubsystem->PlayerNameCache.Add(UserID, PlayerName); + // Dedupe (Pitfall 4) + name-cache population: record this player's stable + // ID so the offline enumeration loop below skips them, and cache the name + // keyed by that ID so it survives a later disconnect (PLYR-03). + // + // GATED ON bOnline: AFGPlayerState::GetUserID() dereferences the player's + // unique-net-id TSharedPtr, which is NOT populated until the player is fully + // connected. Calling it any earlier (mid-login) crashes the dedicated server + // — this is exactly why the PostLogin connect-hook was removed. IsPlayerOnline() + // == true is a safe, source-available signal that the net id is ready, so it + // gates every GetUserID() call on the proven-safe fully-online path. + if (bOnline) { + const APlayerState* PlayerStateBase = PlayerCharacter->GetPlayerState(); + if (IsValid(PlayerStateBase)) { + const AFGPlayerState* PlayerState = Cast(PlayerStateBase); + if (IsValid(PlayerState)) { + const FString UserID = PlayerState->GetUserID(); + if (!UserID.IsEmpty()) { + VisitedIDs.Add(UserID); + if (bHasValidSubsystem) { + ModSubsystem->PlayerNameCache.Add(UserID, PlayerName); + } } } } diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 83d91781..be29b85c 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -28,8 +28,6 @@ #include "Endpoints/World/Resources.h" #include "Endpoints/World/Session.h" #include "Engine/World.h" -#include "FGPlayerState.h" -#include "GameFramework/GameModeBase.h" #include "Libraries/Validation.h" #include "Misc/FileHelper.h" #include "Patching/NativeHookManager.h" @@ -67,9 +65,6 @@ void AFicsitRemoteMonitoring::BeginPlay() // Load FRM's API Endpoints InitAPIRegistry(); - // Populate the player display-name cache on connect (PLYR-03, D-04). - InitPlayerConnectCache(); - const FString AuthToken = UFRMConfigManager::GetConfigOrDefault(TEXT("uWS.AuthenticationToken"), ""); // Debug log to verify token retrieval -Porisius @@ -1084,31 +1079,6 @@ if (!WITH_EDITOR) {/* } } -// Populates PlayerNameCache on connect (PLYR-03, D-04). First-time integration of a -// SUBSCRIBE_UOBJECT_METHOD_AFTER hook that is actually wired into BeginPlay (unlike the dead -// InitOutageNotification/InitTrainDerailNotification siblings above) — treat as new, unverified -// runtime surface until live-connect-verified (03-02 Task 3). -void AFicsitRemoteMonitoring::InitPlayerConnectCache() { - #if (!WITH_EDITOR) - { - SUBSCRIBE_UOBJECT_METHOD_AFTER(AGameModeBase, PostLogin, [this](AGameModeBase* GameMode, APlayerController* NewPlayer) - { - if (!IsValid(NewPlayer)) { return; } - - const AFGPlayerState* PlayerState = Cast(NewPlayer->PlayerState); - if (!IsValid(PlayerState)) { return; } - - const FString UserID = PlayerState->GetUserID(); - if (UserID.IsEmpty()) { return; } // defensive: never cache an empty key - - // PostLogin already runs on the game thread — no AsyncTask hop needed here. - PlayerNameCache.Add(UserID, PlayerState->GetPlayerName()); - }); - } - #endif -} - - void AFicsitRemoteMonitoring::RegisterEndpoint(const FAPIEndpoint& Endpoint) { APIEndpoints.Add(Endpoint); diff --git a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h index 66c442a1..9a77a685 100644 --- a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h +++ b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h @@ -190,7 +190,10 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem TMap*, int32> ClientGenerations{}; // Player display-name cache, keyed by AFGPlayerState::GetUserID() (stable across reconnect). - // Populated on connect via InitPlayerConnectCache(); never evicted (cleared only on server restart). + // Populated opportunistically from the getPlayer live loop while a player is online (a + // PostLogin connect-hook was removed: GetUserID() dereferences the not-yet-populated + // unique-net-id TSharedPtr at connect time and crashes the dedicated server). Never + // evicted (cleared only on server restart). TMap PlayerNameCache{}; UFUNCTION(BlueprintImplementableEvent, Category = "Ficsit Remote Monitoring") @@ -204,7 +207,6 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem void InitAPIRegistry(); void InitOutageNotification(); void InitTrainDerailNotification(); - void InitPlayerConnectCache(); void StartWebSocketServer(bool bSkipIfRunning = false); void StopWebSocketServer(); From 36da789a4333675bc084a2af52d99a491711adbf Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 15:36:40 +0200 Subject: [PATCH 22/36] fix(03-02): key name cache by crash-safe engine net-id, not GetUserID() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../FicsitRemoteMonitoring.build.cs | 1 + .../Private/Endpoints/World/PlayerLibrary.cpp | 40 +++++++++---------- .../Public/FicsitRemoteMonitoring.h | 11 ++--- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs b/Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs index 4d518a8f..edbebb4c 100644 --- a/Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs +++ b/Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs @@ -40,6 +40,7 @@ public FicsitRemoteMonitoring(ReadOnlyTargetRules Target) : base(Target) new string[] { "Core", "CoreUObject", + "CoreOnline", "Engine", "InputCore", "Json", diff --git a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp index 7572c282..7b4be5f3 100644 --- a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp +++ b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp @@ -127,27 +127,27 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, Pitch = FMath::Clamp(RawPitch, -90.f, 90.f); } - // Dedupe (Pitfall 4) + name-cache population: record this player's stable - // ID so the offline enumeration loop below skips them, and cache the name - // keyed by that ID so it survives a later disconnect (PLYR-03). + // Stable-ID keying + dedupe (Pitfall 4) + name-cache population: record this + // player's stable online ID so the offline enumeration loop below skips them, + // and cache the name keyed by that ID so it survives disconnect (PLYR-03/D-04). // - // GATED ON bOnline: AFGPlayerState::GetUserID() dereferences the player's - // unique-net-id TSharedPtr, which is NOT populated until the player is fully - // connected. Calling it any earlier (mid-login) crashes the dedicated server - // — this is exactly why the PostLogin connect-hook was removed. IsPlayerOnline() - // == true is a safe, source-available signal that the net id is ready, so it - // gates every GetUserID() call on the proven-safe fully-online path. - if (bOnline) { - const APlayerState* PlayerStateBase = PlayerCharacter->GetPlayerState(); - if (IsValid(PlayerStateBase)) { - const AFGPlayerState* PlayerState = Cast(PlayerStateBase); - if (IsValid(PlayerState)) { - const FString UserID = PlayerState->GetUserID(); - if (!UserID.IsEmpty()) { - VisitedIDs.Add(UserID); - if (bHasValidSubsystem) { - ModSubsystem->PlayerNameCache.Add(UserID, PlayerName); - } + // Crash-safe stable ID: use the ENGINE accessor APlayerState::GetUniqueId() + // (returns the FUniqueNetIdRepl member by ref) guarded by FUniqueNetIdRepl:: + // IsValid() + ToString(), BOTH of which null-check the underlying net-id + // internally (Engine/CoreOnline.h). This deliberately replaces FactoryGame's + // AFGPlayerState::GetUserID(), which dereferences the net-id via the asserting + // operator-> WITHOUT an IsValid() check and SIGSEGVs on a dedicated server + // whenever the id is not populated. When no valid net id is available, ToString + // yields empty and we simply skip caching-by-id — never crash. + const APlayerState* PlayerStateBase = PlayerCharacter->GetPlayerState(); + if (IsValid(PlayerStateBase)) { + const FUniqueNetIdRepl& NetId = PlayerStateBase->GetUniqueId(); + if (NetId.IsValid()) { + const FString UserID = NetId.ToString(); + if (!UserID.IsEmpty()) { + VisitedIDs.Add(UserID); + if (bHasValidSubsystem) { + ModSubsystem->PlayerNameCache.Add(UserID, PlayerName); } } } diff --git a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h index 9a77a685..518a60ff 100644 --- a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h +++ b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h @@ -189,11 +189,12 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem // ConnectedClients/EndpointSubscribers or dereferencing ws — defends against ABA/pointer-reuse (D-03). TMap*, int32> ClientGenerations{}; - // Player display-name cache, keyed by AFGPlayerState::GetUserID() (stable across reconnect). - // Populated opportunistically from the getPlayer live loop while a player is online (a - // PostLogin connect-hook was removed: GetUserID() dereferences the not-yet-populated - // unique-net-id TSharedPtr at connect time and crashes the dedicated server). Never - // evicted (cleared only on server restart). + // Player display-name cache, keyed by the stable online id from + // APlayerState::GetUniqueId().ToString() (consistent across reconnect). Populated from + // the getPlayer live loop via the crash-safe engine accessor (FUniqueNetIdRepl::IsValid + // + ToString null-check internally) — NOT FactoryGame's AFGPlayerState::GetUserID(), + // which asserts/SIGSEGVs on a dedicated server when the net id is unset. Never evicted + // (cleared only on server restart). TMap PlayerNameCache{}; UFUNCTION(BlueprintImplementableEvent, Category = "Ficsit Remote Monitoring") From 761f52e9f5e5c59a9c3f6ba19d74f4fdc2fa8ec5 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 15:51:39 +0200 Subject: [PATCH 23/36] fix(03-02): dedupe offline entries by name for persisted disconnected actors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Private/Endpoints/World/PlayerLibrary.cpp | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp index 7b4be5f3..51f48351 100644 --- a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp +++ b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp @@ -68,10 +68,17 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, TArray FoundActors; - // Stable GetUserID()s already emitted by the live loop below — the offline - // enumeration loop (PlayerNameCache) skips any ID present here so a - // live-and-cached player appears exactly once (PLYR-03/Pitfall 4). + // Dedupe sets so the offline enumeration loop (PlayerNameCache) never emits a + // player already produced by the live loop — "exactly one entry" (PLYR-03/Pitfall 4). + // - VisitedIDs: stable net-ids of ONLINE players (net-id readable only while online). + // - VisitedNames: display names of ALL live actors, including a DISCONNECTED-but- + // persisted player whose actor lingers in the world with its name but has lost its + // net-id. On this dedicated server disconnected players keep their character actor, + // so the live loop already shows them; without the name check the cache would emit a + // second, net-id-keyed copy of the same player. The name is the one identifier that + // is consistent across the live-actor and cache representations. TSet VisitedIDs; + TSet VisitedNames; // Subsystem pointer used both for the opportunistic in-loop cache refresh and // the offline-enumeration loop after it. IsValid()-guarded (never fgcheck) — @@ -87,6 +94,12 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, FString PlayerName = GetPlayerName(PlayerCharacter); + // Every live actor (online or disconnected-but-persisted) suppresses a same-name + // cache duplicate in the offline loop below. + if (!PlayerName.IsEmpty()) { + VisitedNames.Add(PlayerName); + } + // Safe defaults for every field that depends on a component that can // legitimately be null while a player is mid-disconnect (PLYR-02/D-02). TArray> InventoryJsonArray; @@ -179,11 +192,15 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, if (bHasValidSubsystem) { for (const TPair& CachedPlayer : ModSubsystem->PlayerNameCache) { const FString& UserID = CachedPlayer.Key; - if (VisitedIDs.Contains(UserID)) { + const FString& CachedName = CachedPlayer.Value; + // Skip if the same player is already shown by the live loop — matched by + // net-id (online) OR by display name (disconnected-but-persisted actor, + // whose net-id is no longer readable). Prevents the offline duplicate. + if (VisitedIDs.Contains(UserID) || VisitedNames.Contains(CachedName)) { continue; } - OutJsonArray.Add(MakeShared(BuildOfflinePlayerJson(UserID, CachedPlayer.Value))); + OutJsonArray.Add(MakeShared(BuildOfflinePlayerJson(UserID, CachedName))); } } }; From 3927e8a59b7b4e6a2c6ca3184e3209cf900e6b00 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 21:07:40 +0200 Subject: [PATCH 24/36] feat(04-01): add Wave-0 WS validation probe (VALD-01) - 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 --- build/tools/ws-validation-probe.mjs | 461 ++++++++++++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 build/tools/ws-validation-probe.mjs diff --git a/build/tools/ws-validation-probe.mjs b/build/tools/ws-validation-probe.mjs new file mode 100644 index 00000000..aad52b78 --- /dev/null +++ b/build/tools/ws-validation-probe.mjs @@ -0,0 +1,461 @@ +#!/usr/bin/env node +/** + * ws-validation-probe.mjs + * + * Wave-0 VALD-01 verification instrument for Phase 4 (request-validation-hardening). + * Not shipped plugin code, not wired into build/package.sh — run manually per + * build/RUNBOOK.md. This is the failing end-to-end test that exists BEFORE the + * C++ validator (Plan 02): it defines the concrete acceptance target, and is + * the instrument the live-verification checkpoint (Plan 03) runs against a + * deployed dedicated server. + * + * Drives a battery of malformed and well-formed WebSocket subscribe/unsubscribe + * payloads against FRM's live uWS endpoint and asserts each produces the + * expected `{"error": ...}` frame (or, for the well-formed regression case, + * the expected push data with NO error frame). See 04-VALIDATION.md's + * "Per-Task Verification Map" (SC#1a/SC#1b/SC#2/SC#3) and 04-RESEARCH.md's + * "Phase Requirements -> Test Map" for the exact payloads and expected + * outcomes this script encodes. + * + * Zero new dependencies: uses Node's built-in global `WebSocket` (available + * since Node 21+) and the built-in global `fetch`. Do NOT `npm install ws` — + * the npm `ws` package was audited and REJECTED for this project's probes + * (see .planning/phases/02-thread-safe-websocket-request-handling/02-RESEARCH.md, + * "Package Legitimacy Audit"; reaffirmed for Phase 4 in 04-RESEARCH.md). + * + * Usage: + * node build/tools/ws-validation-probe.mjs --help + * node build/tools/ws-validation-probe.mjs + * node build/tools/ws-validation-probe.mjs --host 127.0.0.1 --port 8091 + * + * Exit codes: + * 0 All cases behaved as expected (malformed cases produced error frames, + * well-formed regression case succeeded). + * 1 A validation case regressed (a malformed case produced no error frame, + * or the well-formed case failed). + * 2 Connection/protocol error (could not reach the server, or an + * unexpected exception occurred during the run). + */ + +'use strict'; + +const DEFAULTS = { + host: '127.0.0.1', + port: 8080, + timeoutMs: 3000, // per-case receive timeout + openTimeoutMs: 5000, +}; + +function printUsage() { + const lines = [ + 'ws-validation-probe.mjs — Phase 4 VALD-01 malformed-payload WS validation probe', + '', + 'Opens WebSocket connections against FicsitRemoteMonitoring\'s uWS endpoint and', + 'sends a battery of malformed and well-formed subscribe/unsubscribe payloads,', + 'asserting each produces the expected {"error": ...} frame (or, for the', + 'well-formed regression case, the expected push data with no error frame).', + '', + 'Usage:', + ' node build/tools/ws-validation-probe.mjs [options]', + '', + 'Options:', + ` --host Server host (default: ${DEFAULTS.host})`, + ` --port uWS port (default: ${DEFAULTS.port}; dedicated server default is 8080)`, + ` --timeout-ms Per-case receive timeout in ms (default: ${DEFAULTS.timeoutMs})`, + ' --help Print this usage and exit 0', + '', + 'Exit codes:', + ' 0 All cases behaved as expected (malformed -> error frame, well-formed -> success).', + ' 1 A validation case regressed.', + ' 2 Connection/protocol error.', + '', + 'Example:', + ' node build/tools/ws-validation-probe.mjs --host 127.0.0.1 --port 8091', + ]; + console.log(lines.join('\n')); +} + +function parseArgs(argv) { + const opts = { ...DEFAULTS }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '--help': + case '-h': + opts.help = true; + break; + case '--host': + opts.host = argv[++i]; + break; + case '--port': + opts.port = parseInt(argv[++i], 10); + break; + case '--timeout-ms': + opts.timeoutMs = parseInt(argv[++i], 10); + break; + default: + console.error(`Unknown argument: ${arg} (see --help)`); + process.exit(2); + } + } + return opts; +} + +// --- Socket helpers ------------------------------------------------------ + +function openSocket(url, timeoutMs) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + try { + ws.close(); + } catch (_) { + /* ignore */ + } + reject(new Error(`open timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(ws); + }); + ws.addEventListener('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Collects frames received on `ws` for `windowMs`, parsing each as JSON where + * possible (raw string kept alongside on parse failure). Resolves with the + * list of frames observed in that window; never rejects on timeout (an empty + * list is a valid, meaningful result — "no frame arrived"). + */ +function collectFrames(ws, windowMs) { + return new Promise((resolve) => { + const frames = []; + let closed = false; + + const onMessage = (evt) => { + let parsed = null; + try { + parsed = JSON.parse(evt.data); + } catch (_) { + parsed = null; + } + frames.push({ raw: evt.data, json: parsed }); + }; + const onClose = () => { + closed = true; + }; + + ws.addEventListener('message', onMessage); + ws.addEventListener('close', onClose); + + setTimeout(() => { + ws.removeEventListener('message', onMessage); + ws.removeEventListener('close', onClose); + resolve({ frames, closed }); + }, windowMs); + }); +} + +function isErrorFrame(frame) { + return !!(frame && frame.json && typeof frame.json === 'object' && 'error' in frame.json); +} + +function isPushFrameFor(frame, endpointName) { + return !!( + frame && + frame.json && + typeof frame.json === 'object' && + frame.json.endpoint === endpointName && + !('error' in frame.json) + ); +} + +// --- Case runner ----------------------------------------------------------- + +/** + * Runs a single probe case: opens a fresh connection, sends `payload` (a + * pre-serialized string, so unparseable-JSON cases can be exercised too), + * collects frames for `timeoutMs`, and hands them to `assert(frames, closed)` + * which must return `{ pass: boolean, detail: string }`. + */ +async function runCase(name, opts, payload, assertFn) { + const url = `ws://${opts.host}:${opts.port}/`; + let ws; + try { + ws = await openSocket(url, opts.openTimeoutMs); + } catch (err) { + return { name, pass: false, detail: `connect failed: ${err.message || err}`, connectError: true }; + } + + try { + ws.send(payload); + const { frames, closed } = await collectFrames(ws, opts.timeoutMs); + const result = assertFn(frames, closed); + return { name, pass: result.pass, detail: result.detail }; + } catch (err) { + return { name, pass: false, detail: `unexpected error: ${err.message || err}`, connectError: true }; + } finally { + try { + ws.close(1000, 'probe-case-complete'); + } catch (_) { + /* ignore */ + } + } +} + +// --- Probe cases (one per VALD-01 test-map row) ----------------------------- + +async function caseUnknownEndpoint(opts) { + return runCase( + 'unknown-endpoint-name', + opts, + JSON.stringify({ action: 'subscribe', endpoints: ['definitelyNotARealEndpoint'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame, got none' }; + } + const msg = JSON.stringify(errorFrame.json.error); + if (!msg.includes('definitelyNotARealEndpoint')) { + return { pass: false, detail: `error frame did not mention the offending name: ${msg}` }; + } + return { pass: true, detail: `got error frame mentioning offending name: ${msg}` }; + } + ); +} + +async function caseGetOnlyRejection(opts) { + return runCase( + 'get-only-rejection-setSwitches', + opts, + JSON.stringify({ action: 'subscribe', endpoints: ['setSwitches'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an immediate error frame for POST-only endpoint, got none' }; + } + const pushFrame = frames.find((f) => isPushFrameFor(f, 'setSwitches')); + if (pushFrame) { + return { pass: false, detail: 'unexpected push frame for setSwitches arrived (should be rejected at subscribe time)' }; + } + return { pass: true, detail: 'got error frame, no setSwitches push frame arrived' }; + } + ); +} + +async function caseMissingAction(opts) { + return runCase( + 'missing-action', + opts, + JSON.stringify({ endpoints: ['getWorldInv'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame for missing action, got none' }; + } + return { pass: true, detail: `got error frame: ${JSON.stringify(errorFrame.json.error)}` }; + } + ); +} + +async function caseUnknownAction(opts) { + return runCase( + 'unknown-action', + opts, + JSON.stringify({ action: 'frobnicate', endpoints: ['getWorldInv'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame for unknown action, got none' }; + } + return { pass: true, detail: `got error frame: ${JSON.stringify(errorFrame.json.error)}` }; + } + ); +} + +async function caseWrongTypedEndpoints(opts) { + return runCase( + 'wrong-typed-endpoints', + opts, + JSON.stringify({ action: 'subscribe', endpoints: 5 }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame for wrong-typed endpoints field, got none' }; + } + return { pass: true, detail: `got error frame: ${JSON.stringify(errorFrame.json.error)}` }; + } + ); +} + +async function caseMixedValidInvalid(opts) { + return runCase( + 'mixed-valid-invalid-partial-success', + opts, + JSON.stringify({ action: 'subscribe', endpoints: [123, 'getWorldInv'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame listing the invalid entry, got none' }; + } + const msg = JSON.stringify(errorFrame.json.error); + if (msg.includes('getWorldInv')) { + return { pass: false, detail: `error frame should list only the invalid entry, but mentions getWorldInv: ${msg}` }; + } + const pushFrame = frames.find((f) => isPushFrameFor(f, 'getWorldInv')); + if (!pushFrame) { + return { pass: false, detail: `got error frame (${msg}) but no getWorldInv push data arrived (partial success failed)` }; + } + return { pass: true, detail: `got error frame for invalid entry (${msg}) AND getWorldInv push data (partial success)` }; + } + ); +} + +async function caseUnparseableJson(opts) { + const url = `ws://${opts.host}:${opts.port}/`; + let ws; + try { + ws = await openSocket(url, opts.openTimeoutMs); + } catch (err) { + return { name: 'unparseable-json', pass: false, detail: `connect failed: ${err.message || err}`, connectError: true }; + } + + try { + ws.send('{not json'); + const { frames, closed } = await collectFrames(ws, opts.timeoutMs); + if (closed) { + return { name: 'unparseable-json', pass: false, detail: 'socket closed on unparseable JSON (D-02 requires it stay open)' }; + } + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { name: 'unparseable-json', pass: false, detail: 'expected an error frame for unparseable JSON, got none' }; + } + // Confirm the socket is genuinely still usable, not just un-closed-yet. + let stillOpen = true; + try { + ws.send(JSON.stringify({ action: 'unsubscribe', endpoints: ['getWorldInv'] })); + } catch (_) { + stillOpen = false; + } + if (!stillOpen || ws.readyState !== WebSocket.OPEN) { + return { name: 'unparseable-json', pass: false, detail: 'socket not usable/open after unparseable JSON error frame' }; + } + return { + name: 'unparseable-json', + pass: true, + detail: `got error frame (${JSON.stringify(errorFrame.json.error)}), socket stayed open`, + }; + } catch (err) { + return { name: 'unparseable-json', pass: false, detail: `unexpected error: ${err.message || err}`, connectError: true }; + } finally { + try { + ws.close(1000, 'probe-case-complete'); + } catch (_) { + /* ignore */ + } + } +} + +async function caseWellFormedRegression(opts) { + return runCase( + 'well-formed-regression', + opts, + JSON.stringify({ action: 'subscribe', endpoints: ['getWorldInv'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (errorFrame) { + return { pass: false, detail: `well-formed request produced an unexpected error frame: ${JSON.stringify(errorFrame.json.error)}` }; + } + const pushFrame = frames.find((f) => isPushFrameFor(f, 'getWorldInv')); + if (!pushFrame) { + return { pass: false, detail: 'expected getWorldInv push data, got none (and no error frame either)' }; + } + return { pass: true, detail: 'got getWorldInv push data, no error frame (regression check passed)' }; + } + ); +} + +// --- Main -------------------------------------------------------------- + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.help) { + printUsage(); + process.exit(0); + } + + if (typeof WebSocket === 'undefined') { + console.error( + 'Global WebSocket is not available in this Node runtime. This probe ' + + 'requires Node 21+ (built-in WebSocket global). Detected: ' + process.version + ); + process.exit(2); + } + + console.log(`[ws-validation-probe] starting: host=${opts.host} port=${opts.port} timeoutMs=${opts.timeoutMs}`); + + const cases = [ + caseUnknownEndpoint, + caseGetOnlyRejection, + caseMissingAction, + caseUnknownAction, + caseWrongTypedEndpoints, + caseMixedValidInvalid, + caseUnparseableJson, + caseWellFormedRegression, + ]; + + const results = []; + let hadConnectError = false; + + for (const caseFn of cases) { + let result; + try { + result = await caseFn(opts); + } catch (err) { + result = { name: caseFn.name, pass: false, detail: `fatal error: ${err.message || err}`, connectError: true }; + } + results.push(result); + if (result.connectError) hadConnectError = true; + // Brief pause between cases to avoid overlapping push-loop timing artifacts. + await sleep(150); + } + + console.error('[ws-validation-probe] case results:'); + for (const r of results) { + const status = r.pass ? 'PASS' : 'FAIL'; + console.error(` [${status}] ${r.name}: ${r.detail}`); + } + + const failCount = results.filter((r) => !r.pass).length; + const passCount = results.length - failCount; + console.error(`[ws-validation-probe] summary: ${passCount}/${results.length} passed`); + + if (hadConnectError) { + console.error('[ws-validation-probe] FAIL: connection/protocol error occurred during the run.'); + process.exit(2); + } + + if (failCount > 0) { + console.error('[ws-validation-probe] FAIL: one or more validation cases regressed.'); + process.exit(1); + } + + console.error('[ws-validation-probe] PASS: all validation cases behaved as expected.'); + process.exit(0); +} + +main().catch((err) => { + console.error('[ws-validation-probe] fatal error:', err); + process.exit(2); +}); From a1deffe01bdb4011593ef0dc51ae3ebca21328f3 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 21:16:18 +0200 Subject: [PATCH 25/36] feat(04-02): add ValidateWSSubscriptionEnvelope validator - 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) --- .../Private/FicsitRemoteMonitoring.cpp | 100 ++++++++++++++++++ .../Public/FicsitRemoteMonitoring.h | 12 +++ 2 files changed, 112 insertions(+) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index be29b85c..8e93a224 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -1188,6 +1188,106 @@ void AFicsitRemoteMonitoring::AddErrorJson(TArray>& JsonA JsonArray.Add(MakeShared(JsonObject)); } +// VALD-01: strict WS subscribe/unsubscribe envelope validator. Runs synchronously on the uWS loop +// thread (called from ProcessClientRequest's synchronous prefix, before the AsyncTask(GameThread, +// ...) hop) — reads only the immutable-after-BeginPlay APIEndpoints registry, never +// EndpointSubscribers (that map remains exclusively game-thread-owned; see Pitfall 4 — a +// registered-but-not-currently-subscribed name is valid input for "unsubscribe"). +// +// Uses TryGetStringField/TryGetArrayField + explicit EJson::String checks throughout (never +// GetStringField/AsString, which silently default/coerce instead of failing) so malformed input is +// rejected instead of silently swallowed. +bool AFicsitRemoteMonitoring::ValidateWSSubscriptionEnvelope(const TSharedPtr& JsonRequest, FString& OutAction, TArray& OutValidNames, FString& OutErrorMessage) const +{ + // D-04: strict `action` extraction — TryGetStringField fails closed on a missing or + // wrong-typed field instead of GetStringField's silent "" default. + FString RawAction; + if (!JsonRequest->TryGetStringField(TEXT("action"), RawAction) + || (RawAction != TEXT("subscribe") && RawAction != TEXT("unsubscribe"))) + { + OutErrorMessage = FString::Printf( + TEXT("Invalid or missing 'action' field (got '%s'); expected 'subscribe' or 'unsubscribe'."), + *RawAction); + return false; // whole-request reject — nothing to mutate + } + OutAction = RawAction; + + // D-04/D-05: strict `endpoints` extraction — array-of-strings or single string only. A + // non-string array entry is a per-entry problem (D-03 partial success), NOT a whole-request + // reject; a missing/wrong-typed top-level `endpoints` field IS a whole-request reject. + TArray RawNames; + TArray Problems; + + const TArray>* EndpointsArray; + FString SingleEndpoint; + + if (JsonRequest->TryGetArrayField(TEXT("endpoints"), EndpointsArray)) + { + for (int32 Index = 0; Index < EndpointsArray->Num(); ++Index) + { + const TSharedPtr& EndpointValue = (*EndpointsArray)[Index]; + if (!EndpointValue.IsValid() || EndpointValue->Type != EJson::String) + { + Problems.Add(FString::Printf(TEXT("endpoints[%d] is not a string"), Index)); + continue; + } + RawNames.Add(EndpointValue->AsString()); + } + } + else if (JsonRequest->TryGetStringField(TEXT("endpoints"), SingleEndpoint)) + { + RawNames.Add(SingleEndpoint); + } + else + { + // Covers both "missing"/"null" and "wrong top-level type" (e.g. endpoints: 5) — D-04/D-05. + OutErrorMessage = TEXT("'endpoints' field is required and must be a string or an array of strings."); + return false; // whole-request reject + } + + // D-01/Pattern 2: GET-only registry match. PushUpdatedData always builds a default FRequestData + // (Method == "GET", FRM_RequestData.h) and never overrides it, so a WS client can never actually + // receive push data for a non-GET name — accepting one here would just move today's + // silent-garbage-forever bug one step later. APIEndpoints is populated once in InitAPIRegistry() + // (BeginPlay, before StartWebSocketServer) and never mutated after, so it is safe to read + // cross-thread here without marshaling. + for (const FString& Name : RawNames) + { + const bool bIsRegisteredGet = APIEndpoints.ContainsByPredicate([&Name](const FAPIEndpoint& Endpoint) + { + return Endpoint.APIName == Name && Endpoint.Method == TEXT("GET"); + }); + + if (bIsRegisteredGet) + { + OutValidNames.AddUnique(Name); + } + else + { + Problems.Add(FString::Printf(TEXT("Unknown endpoint: %s"), *Name)); + } + } + + // D-03: partial success — combine every per-entry problem (type errors first, then unknown + // names, in array-index order) into ONE error frame, but still return true: the envelope shape + // itself (action/endpoints) was valid, so surviving OutValidNames still feed the mutation. + if (Problems.Num() > 0) + { + // Backstop (T-04-03): cap the reflected problem list so a maliciously large all-invalid + // `endpoints` array cannot drive an unbounded error frame. + constexpr int32 MaxReportedProblems = 20; + if (Problems.Num() > MaxReportedProblems) + { + const int32 Remainder = Problems.Num() - MaxReportedProblems; + Problems.SetNum(MaxReportedProblems); + Problems.Add(FString::Printf(TEXT("...and %d more"), Remainder)); + } + OutErrorMessage = FString::Join(Problems, TEXT("; ")); + } + + return true; +} + void AFicsitRemoteMonitoring::HandleEndpoint(FString InEndpoint, FRequestData RequestData, bool& bSuccess, int32& ErrorCode, FString& Out_Data, EInterfaceType Interface) { bSuccess = false; diff --git a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h index 518a60ff..7a13f44a 100644 --- a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h +++ b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h @@ -228,6 +228,18 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem bool IsAuthorizedRequest(uWS::HttpRequest* req, FString RequiredToken); void AddResponseHeaders(uWS::HttpResponse* res, bool bIncludeContentType); void AddErrorJson(TArray>& JsonArray, const FString& ErrorMessage); + + /** Loop-thread-only, synchronous WS subscribe/unsubscribe envelope validator (VALD-01). Strictly + * validates `action` (must be "subscribe"/"unsubscribe") and `endpoints` (string or array of + * strings, GET-only registry match) using the TryGet-family plus explicit EJson::String checks + * -- never the silently coercing GetStringField/AsString accessors. Reads only the + * immutable-after-BeginPlay APIEndpoints registry; never touches EndpointSubscribers (that + * stays game-thread-only). Returns false only on a whole-request reject (invalid/missing + * action, missing/wrong-typed endpoints field) -- OutErrorMessage is empty only when the + * envelope was fully valid with no invalid entries. OutValidNames always carries every entry + * that passed both the string-type and registered-GET checks, even when OutErrorMessage is + * non-empty (D-03 partial success). */ + bool ValidateWSSubscriptionEnvelope(const TSharedPtr& JsonRequest, FString& OutAction, TArray& OutValidNames, FString& OutErrorMessage) const; TArray Flavor_Battery{}; TArray Flavor_Doggo{}; From 8f386c66c03ead175deb99ff482fafe61594a252 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Fri, 17 Jul 2026 21:18:41 +0200 Subject: [PATCH 26/36] feat(04-02): wire validator into ProcessClientRequest, add parse-failure 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) --- .../Private/FicsitRemoteMonitoring.cpp | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 8e93a224..83791495 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -603,6 +603,15 @@ void AFicsitRemoteMonitoring::OnMessageReceived(uWS::WebSocketsend() here needs no Loop::defer()/TWeakObjectPtr hop. + const FString ErrorJson = UFRM_RequestLibrary::JsonObjectToString( + UFRM_RequestLibrary::GenerateError(TEXT("Malformed request: payload is not valid JSON.")), /*JSONDebugMode=*/false); + FTCHARToUTF8 Converted(*ErrorJson); + ws->send(std::string_view(Converted.Get(), Converted.Length()), uWS::OpCode::TEXT); } } @@ -612,29 +621,35 @@ void AFicsitRemoteMonitoring::ProcessClientRequest(uWS::WebSocketclose validity // guarantee). No shared-state mutation happens here; the actual EndpointSubscribers/ // bHasRunningPushDataLoop work is marshaled to the game thread below (THRD-01). - const FString Action = JsonRequest->GetStringField(TEXT("action")); const int32 RequestClientID = ws->getUserData()->ClientID; - TArray EndpointNames; - const TArray>* EndpointsArray; - FString SingleEndpoint; + FString Action; + TArray ValidNames; + FString ErrorMessage; - if (JsonRequest->TryGetArrayField(TEXT("endpoints"), EndpointsArray)) - { - for (const TSharedPtr& EndpointValue : *EndpointsArray) - { - EndpointNames.Add(EndpointValue->AsString()); - } - } - else if (JsonRequest->TryGetStringField(TEXT("endpoints"), SingleEndpoint)) + // VALD-01: strict envelope validation (action, endpoints shape/type, GET-only registry match) + // runs synchronously here, before the AsyncTask(GameThread) hop below — see + // ValidateWSSubscriptionEnvelope for the strict TryGet*-family + EJson::String checks that + // replace the previous silent-coercion GetStringField/AsString reads. + const bool bWholeRequestValid = ValidateWSSubscriptionEnvelope(JsonRequest, Action, ValidNames, ErrorMessage); + + if (!ErrorMessage.IsEmpty()) { - EndpointNames.Add(SingleEndpoint); + // D-02: direct, synchronous send — no Loop::defer()/TWeakObjectPtr hop needed here, since + // this code is not crossing a thread boundary (unlike PushUpdatedData, which originates on + // the push-pacing thread). Never ws->close() on validation failure — the connection stays + // open (D-02) regardless of whether this was a whole-request reject or a partial-success + // report (D-03). + const FString ErrorJson = UFRM_RequestLibrary::JsonObjectToString( + UFRM_RequestLibrary::GenerateError(ErrorMessage), /*JSONDebugMode=*/false); + FTCHARToUTF8 Converted(*ErrorJson); + ws->send(std::string_view(Converted.Get(), Converted.Length()), uWS::OpCode::TEXT); } - if (EndpointNames.Num() == 0) return; + if (!bWholeRequestValid || ValidNames.Num() == 0) return; // nothing valid left to mutate TWeakObjectPtr WeakThis(this); - AsyncTask(ENamedThreads::GameThread, [WeakThis, ws, RequestClientID, Action, EndpointNames]() + AsyncTask(ENamedThreads::GameThread, [WeakThis, ws, RequestClientID, Action, ValidNames]() { AFicsitRemoteMonitoring* Self = WeakThis.Get(); if (!Self || Self->bShouldStop) return; // shutdown/teardown race guard (D-04) @@ -645,7 +660,7 @@ void AFicsitRemoteMonitoring::ProcessClientRequest(uWS::WebSocketIsClientCurrent(ws, RequestClientID)) return; - for (const FString& Endpoint : EndpointNames) + for (const FString& Endpoint : ValidNames) { if (Action == "subscribe") { From f98a481ca47f0f337c2e6c75fbbe1b27c727ac23 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sat, 18 Jul 2026 18:54:00 +0200 Subject: [PATCH 27/36] docs(05-01): mark FilterPlugin.ini inert for packaging pipeline - 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) --- Config/FilterPlugin.ini | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Config/FilterPlugin.ini b/Config/FilterPlugin.ini index ccebca2f..7711b585 100644 --- a/Config/FilterPlugin.ini +++ b/Config/FilterPlugin.ini @@ -6,3 +6,9 @@ ; /README.txt ; /Extras/... ; /Binaries/ThirdParty/*.dll +; +; INERT FOR THIS PROJECT'S PACKAGING PIPELINE: build/package.sh runs Alpakit's PackagePlugin +; (which extends BuildCookRun), and BuildCookRun never reads FilterPlugin.ini. Raw folders +; (Icons, www, JSON, Resources, Debug) are actually bundled into the packaged artifact via +; Config/PluginSettings.ini's [StageSettings] +AdditionalNonUSFDirectories= entries, which +; already list Icons and www. Adding entries here has zero effect on the packaged zip. From 0df21d045ef7ffbb74b87769103af71ace342889 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sat, 18 Jul 2026 18:54:54 +0200 Subject: [PATCH 28/36] docs(05-01): document icon release workflow and zip verification - 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/_C.png curl check expecting 200 --- build/RUNBOOK.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index 32b99165..094081ea 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -157,6 +157,51 @@ in this repo — it is a build-environment dependency of the *host project*, not a code change to this plugin, so it is not (and should not be) tracked by this repo's git. +## Icon release workflow (PKG-01) + +Icon generation is **client-only** — a dedicated server has no UE textures +loaded, so `/frm icon` cannot run there. The real bundling mechanism is +`Config/PluginSettings.ini`'s `[StageSettings] +AdditionalNonUSFDirectories=Icons` +(already present in this repo); `Config/FilterPlugin.ini` is inert for this +pipeline (Alpakit's `PackagePlugin` extends `BuildCookRun`, which never reads +it — see the comment added to that file). Verification for PKG-01 is therefore +against the **packaged zip's contents**, not against which `.ini` was edited. + +1. On a **game client** (not the dedicated server), run the in-game chat + command: + + ``` + /frm icon + ``` + + This generates icon PNGs into that client's local `Icons/` folder. + +2. Copy the generated PNGs from the client's `Icons/` folder into this repo's + source `Icons/` folder, then package: + + ```bash + bash build/package.sh package + ``` + +3. Verify the icons actually landed in the packaged artifact — an empty + source `Icons/` folder still ships the (empty) directory harmlessly, so + check the real zip contents, not the `.ini` edited: + + ```bash + unzip -l Saved/ArchivedPlugins/FicsitRemoteMonitoring/FicsitRemoteMonitoring-LinuxServer.zip \ + | grep 'Icons/' + # expected: the populated PNG files under .../Icons/, not just the bare directory entry + ``` + +4. After deploying (Step 5) and starting the HTTP server (Step 6), confirm a + known icon renders live: + + ```bash + curl -s -o /dev/null -w '%{http_code}\n' \ + http://:/Icons/_C.png + # expected: 200 once Icons/ is populated and deployed (404 when absent) + ``` + ## Step 5 — Deploy Unzip the packaged `-LinuxServer.zip` artifact from From f9d4282c32138444e1eefdac9499edd768e286a5 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sat, 18 Jul 2026 18:59:46 +0200 Subject: [PATCH 29/36] feat(05-02): add SendFallbackPage inline 503 emitter - 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 --- .../Private/FRM_Request.cpp | 16 ++++++++++++++++ .../FicsitRemoteMonitoring/Public/FRM_Request.h | 1 + 2 files changed, 17 insertions(+) diff --git a/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp b/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp index e23839dd..87bf678d 100644 --- a/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp @@ -22,6 +22,22 @@ void UFRM_RequestLibrary::SendErrorMessage(uWS::HttpResponse* res, const SendErrorJson(res, Status, JsonObjectToString(JsonObject, false)); } +void UFRM_RequestLibrary::SendFallbackPage(uWS::HttpResponse* res, const FString& DocsURL) +{ + const FString Html = FString::Printf(TEXT( + "FRM - Web UI Unavailable" + "

FicsitRemoteMonitoring: Web UI files not found

" + "

This is common on dedicated servers when the web UI bundle was not deployed alongside " + "the mod. The monitoring API itself is still live at /api/.

" + "

See the setup documentation for how to deploy the web UI.

" + ""), *DocsURL); + + res->writeStatus("503 Service Unavailable"); + res->writeHeader("Content-Type", "text/html"); + AddResponseHeaders(res, false); // false: Content-Type already set above — do not pass true (see Pitfall 3) + res->end(TCHAR_TO_UTF8(*Html)); +} + void UFRM_RequestLibrary::AddResponseHeaders(uWS::HttpResponse* res, const bool bIncludeContentType) { res diff --git a/Source/FicsitRemoteMonitoring/Public/FRM_Request.h b/Source/FicsitRemoteMonitoring/Public/FRM_Request.h index 4fc7b483..d534c582 100644 --- a/Source/FicsitRemoteMonitoring/Public/FRM_Request.h +++ b/Source/FicsitRemoteMonitoring/Public/FRM_Request.h @@ -22,6 +22,7 @@ class FICSITREMOTEMONITORING_API UFRM_RequestLibrary : public UFGBlueprintFuncti public: static void SendErrorJson(uWS::HttpResponse* res, const FString& Status, const FString& Json); static void SendErrorMessage(uWS::HttpResponse* res, const FString& Status, const FString& Message); + static void SendFallbackPage(uWS::HttpResponse* res, const FString& DocsURL); static void AddResponseHeaders(uWS::HttpResponse* res, const bool bIncludeContentType); From 13d4bc02f60dfc4a859c101d331c7ef36d40da00 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sat, 18 Jul 2026 19:01:46 +0200 Subject: [PATCH 30/36] feat(05-02): wire DocsURL constant and both fallback call sites - 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 --- .../Private/FicsitRemoteMonitoring.cpp | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 83791495..a687fb57 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -37,6 +37,9 @@ us_listen_socket_t* SocketListener; bool SocketRunning = false; +// PKG-02: fallback-page docs link, matches FicsitRemoteMonitoring.uplugin's DocsURL field. +static const FString DocsURL = TEXT("https://docs.ficsit.app/ficsitremotemonitoring/latest/index.html"); + AFicsitRemoteMonitoring* AFicsitRemoteMonitoring::Get(UWorld* WorldContext) { for (TActorIterator It(WorldContext, StaticClass(), EActorIteratorFlags::AllActors); It; ++It) { @@ -493,9 +496,16 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) HandleGetRequest(res, req, FilePath); } else { - FRequestData RequestData; - RequestData.bIsAuthorized = IsAuthorizedRequest(req, AuthToken); - HandleApiRequest(World, res, req, RelativePath, RequestData); + FString ReqExt = FPaths::GetExtension(RelativePath).ToLower(); + bool bIsPageRequest = ReqExt.IsEmpty() || ReqExt == "html" || ReqExt == "htm"; + if (bIsPageRequest) { + UFRM_RequestLibrary::SendFallbackPage(res, DocsURL); + } + else { + FRequestData RequestData; + RequestData.bIsAuthorized = IsAuthorizedRequest(req, AuthToken); + HandleApiRequest(World, res, req, RelativePath, RequestData); + } } }); @@ -880,7 +890,12 @@ void AFicsitRemoteMonitoring::HandleGetRequest(uWS::HttpResponse* res, uW if (!FileLoaded) { UE_LOG(LogHttpServer, Error, TEXT("Failed to load file: %s"), *FilePath); - UFRM_RequestLibrary::SendErrorMessage(res, "500 Internal Server Error", "Failed to load file."); + if (Extension == "html" || Extension == "htm") { + UFRM_RequestLibrary::SendFallbackPage(res, DocsURL); + } + else { + UFRM_RequestLibrary::SendErrorMessage(res, "500 Internal Server Error", "Failed to load file."); + } } } From a11ed3c9ca510aa3ec4b096486a2ff2eb637eaa6 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sat, 18 Jul 2026 19:02:15 +0200 Subject: [PATCH 31/36] docs(05-02): add PKG-02 fallback-page smoke sequence to RUNBOOK - 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 --- build/RUNBOOK.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index 094081ea..79a3b252 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -304,6 +304,58 @@ curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ A `200` with well-formed JSON confirms the monitoring API is live end-to-end on the dedicated server. +### PKG-02 fallback-page smoke test + +Verifies the branded 503 fallback page (`UFRM_RequestLibrary::SendFallbackPage`) +appears when the web UI's files are missing, while every other route keeps its +existing, unchanged behavior (missing sub-resources stay 404 JSON, `/Icons/*` +stays 404, `/api/*` stays JSON). This is a **manual, restorable** procedure — +temporarily rename the deployed `www/` folder aside, run the curl battery below, +then restore it. Use the same port as Step 6 (`8091` in these examples if you +overrode the default, or `8080` otherwise). + +1. Rename `www/` aside so the web UI files are missing: + + ```bash + mv "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www" \ + "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www.bak" + ``` + +2. Run the five-case curl battery: + + ```bash + # (1) page request with www/ missing -> expect 503, branded HTML body + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/index.html # expect HTTP 503 + curl -s http://localhost:8091/index.html | grep -q 'api' \ + && echo "fallback body OK (carries /api/ note)" + + # (2) missing sub-resource -> unchanged 404 JSON, NOT the HTML fallback + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/_next/static/nonexistent.js # expect HTTP 404 + curl -s http://localhost:8091/_next/static/nonexistent.js # expect a JSON body, not HTML + + # (3) /Icons/* stays on its documented, unmodified behavior + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/Icons/Nonexistent_C.png # expect HTTP 404 + + # (4) /api/* stays JSON, unaffected by the page fallback + curl -s http://localhost:8091/api/nonexistentEndpoint # expect a JSON body (unchanged, not HTML) + ``` + +3. Restore `www/` and confirm normal service resumes: + + ```bash + mv "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www.bak" \ + "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www" + + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/index.html # expect HTTP 200 again + ``` + +**If port `8091` is not yours** (i.e. you did not override the default per Step +6's colocated-client note), substitute `8080` throughout the battery above. + ### Validating the Windows client package (`-Windows.zip`) The Linux server package is the primary target, but the Windows client build is From 77b6c27de9a4344fc1e14af9d7b35c23f9e792f3 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sat, 18 Jul 2026 21:23:08 +0200 Subject: [PATCH 32/36] fix(05): stop diverting bare-form API endpoints to the 503 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Private/FicsitRemoteMonitoring.cpp | 20 ++++++++++++++++++- .../Public/FicsitRemoteMonitoring.h | 6 ++++++ build/RUNBOOK.md | 9 ++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index a687fb57..3ae0d9c6 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -497,7 +497,13 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) } else { FString ReqExt = FPaths::GetExtension(RelativePath).ToLower(); - bool bIsPageRequest = ReqExt.IsEmpty() || ReqExt == "html" || ReqExt == "htm"; + // Bare-form API endpoints (e.g. "getWorldInv") are also extensionless, so the + // extension alone cannot tell a web UI page navigation apart from an API call. + // Only serve the branded fallback when the request looks like a page AND does not + // name a registered endpoint; anything that maps to an endpoint (or a mistyped + // endpoint name) keeps its existing JSON contract via HandleApiRequest. + bool bIsPageRequest = (ReqExt.IsEmpty() || ReqExt == "html" || ReqExt == "htm") + && !IsRegisteredEndpointName(RelativePath); if (bIsPageRequest) { UFRM_RequestLibrary::SendFallbackPage(res, DocsURL); } @@ -970,6 +976,18 @@ void AFicsitRemoteMonitoring::HandleApiRequest(UObject* World, uWS::HttpResponse } } +bool AFicsitRemoteMonitoring::IsRegisteredEndpointName(const FString& InName) const +{ + for (const FAPIEndpoint& EndpointInfo : APIEndpoints) + { + if (EndpointInfo.APIName == InName) + { + return true; + } + } + return false; +} + void AFicsitRemoteMonitoring::InitAPIRegistry() { //Registering Endpoints: API Name, bRequireGameThread, FunctionPtr diff --git a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h index 7a13f44a..a97e224e 100644 --- a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h +++ b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h @@ -153,6 +153,12 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem FCallEndpointResponse CallEndpoint(UObject* WorldContext, FString InEndpoint, FRequestData RequestData, bool& bSuccess, int32& ErrorCode); + /** True if InName exactly matches a registered API endpoint name (any method). Used by the + * catch-all GET handler to tell bare-form API requests (which are extensionless, e.g. + * "getWorldInv") apart from web UI page navigations before serving the missing-web-UI + * fallback page. */ + bool IsRegisteredEndpointName(const FString& InName) const; + UFUNCTION(BlueprintImplementableEvent, Category = "Ficsit Remote Monitoring") void GetDropPodInfo_BIE(const AFGDropPod* Droppod, TSubclassOf& ItemClass, int32& Amount, float& Power); diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md index 79a3b252..a2d9118c 100644 --- a/build/RUNBOOK.md +++ b/build/RUNBOOK.md @@ -321,7 +321,7 @@ overrode the default, or `8080` otherwise). "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www.bak" ``` -2. Run the five-case curl battery: +2. Run the six-case curl battery: ```bash # (1) page request with www/ missing -> expect 503, branded HTML body @@ -341,6 +341,13 @@ overrode the default, or `8080` otherwise). # (4) /api/* stays JSON, unaffected by the page fallback curl -s http://localhost:8091/api/nonexistentEndpoint # expect a JSON body (unchanged, not HTML) + + # (5) REGRESSION GUARD: bare-form API endpoints are extensionless like a page + # request, but must NOT be diverted to the 503 fallback even while www/ is + # missing -- the catch-all consults the endpoint registry first. + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/getWorldInv # expect HTTP 200 (JSON, not the fallback) + curl -s http://localhost:8091/getModList # expect a JSON body, not the HTML fallback ``` 3. Restore `www/` and confirm normal service resumes: From 7c67a4801f09bcbb52563009f02ecaa1f6ca2657 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sun, 19 Jul 2026 08:06:03 +0200 Subject: [PATCH 33/36] fix(05): rename SendFallbackPage param to stop shadowing global DocsURL 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 --- Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp | 4 ++-- Source/FicsitRemoteMonitoring/Public/FRM_Request.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp b/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp index 87bf678d..e2cd889d 100644 --- a/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp @@ -22,7 +22,7 @@ void UFRM_RequestLibrary::SendErrorMessage(uWS::HttpResponse* res, const SendErrorJson(res, Status, JsonObjectToString(JsonObject, false)); } -void UFRM_RequestLibrary::SendFallbackPage(uWS::HttpResponse* res, const FString& DocsURL) +void UFRM_RequestLibrary::SendFallbackPage(uWS::HttpResponse* res, const FString& InDocsURL) { const FString Html = FString::Printf(TEXT( "FRM - Web UI Unavailable" @@ -30,7 +30,7 @@ void UFRM_RequestLibrary::SendFallbackPage(uWS::HttpResponse* res, const "

This is common on dedicated servers when the web UI bundle was not deployed alongside " "the mod. The monitoring API itself is still live at /api/.

" "

See the setup documentation for how to deploy the web UI.

" - ""), *DocsURL); + ""), *InDocsURL); res->writeStatus("503 Service Unavailable"); res->writeHeader("Content-Type", "text/html"); diff --git a/Source/FicsitRemoteMonitoring/Public/FRM_Request.h b/Source/FicsitRemoteMonitoring/Public/FRM_Request.h index d534c582..53b4cda4 100644 --- a/Source/FicsitRemoteMonitoring/Public/FRM_Request.h +++ b/Source/FicsitRemoteMonitoring/Public/FRM_Request.h @@ -22,7 +22,7 @@ class FICSITREMOTEMONITORING_API UFRM_RequestLibrary : public UFGBlueprintFuncti public: static void SendErrorJson(uWS::HttpResponse* res, const FString& Status, const FString& Json); static void SendErrorMessage(uWS::HttpResponse* res, const FString& Status, const FString& Message); - static void SendFallbackPage(uWS::HttpResponse* res, const FString& DocsURL); + static void SendFallbackPage(uWS::HttpResponse* res, const FString& InDocsURL); static void AddResponseHeaders(uWS::HttpResponse* res, const bool bIncludeContentType); From 26a2df88044076ecc514e9055151fcc50b32aa05 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sun, 19 Jul 2026 08:42:12 +0200 Subject: [PATCH 34/36] docs: add CHANGELOG for the reliability-retrofit milestone 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 --- CHANGELOG.md | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..e9a7fc40 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,204 @@ +# Changelog + +All notable changes to FicsitRemoteMonitoring are documented here. +Format is loosely based on [Keep a Changelog](https://keepachangelog.com/). + +--- + +## [Unreleased] — Reliability Retrofit + +> **Baseline:** v1.5.2 · **Focus:** reliability of the monitoring channel, not new features. +> A targeted retrofit hardening the HTTP/WebSocket API so it returns accurate, real-time +> game state without crashing or corrupting the dedicated server. +> +> **No new external dependencies.** SML `^3.12.0` compatibility preserved. All public API +> response shapes are unchanged or additive-only (see *Compatibility* below). + +### TL;DR for reviewers + +| Area | What changed | Why it matters | +|------|--------------|----------------| +| **WebSocket threading** | All inbound callbacks and outbound sends are now marshaled through the game thread. | Removes a whole class of race-condition crashes / shared-state corruption. | +| **`getPlayer`** | Crash-safe reads, new `location.pitch`, persistent player names across disconnect. | Endpoint no longer crashes the server; richer, more reliable data. | +| **WS request validation** | Malformed/malicious subscribe envelopes are rejected with a clear error *before* dispatch. | Bad input can no longer reach endpoint logic on the now-guaranteed game thread. | +| **Dedicated-server packaging** | Branded 503 fallback when the web UI is missing; icon staging verified against a real packaged build. | Servers without the web UI bundle degrade gracefully instead of breaking silently. | +| **Dev tooling** | Reproducible build/package pipeline, WS stress + validation harnesses, deploy/verify runbook. | Makes the plugin buildable and verifiable on Linux (no test framework exists). | + +Because the project has **no automated test framework**, every runtime change was verified +**live against a deployed Satisfactory dedicated server** (see *Verification* below), not just +by compiling. + +--- + +## Shipping changes (plugin runtime) + +### 1. Thread-safe WebSocket request handling + +**Files:** `Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp`, +`Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h` + +**Problem.** Inbound WebSocket callbacks (`open`/`close`/message) and the outbound push loop +touched shared subsystem state — `ConnectedClients`, `EndpointSubscribers`, subscription maps — +directly from the uWS background thread. Concurrent access with the game thread caused +race-condition crashes and state corruption. + +**Change.** +- Introduced generation-tagged client identity: a game-thread-owned `ClientGenerations` map, + loop-thread-only `NextClientIDCounter` / `LoopLiveSockets` bookkeeping, and an + `IsClientCurrent(ws, ClientID)` validity helper — **no new `FWebSocketUserData` field and no + `FCriticalSection`** (correctness comes from thread confinement + generation checks). +- `wsBehavior.open` mints the `ClientID` on the loop thread (where `ws` is provably fresh), + then marshals `ConnectedClients` / `ClientGenerations` registration to the game thread via a + `bShouldStop`-guarded `AsyncTask(GameThread, …)`. Also fixed a pre-existing copy-paste log bug + ("Client Disconnected" was logged on connect). +- `wsBehavior.close` removes `ws` from `LoopLiveSockets` on the loop thread, then a marshaled, + `IsClientCurrent`-guarded game-thread task removes it from `ConnectedClients`, + `ClientGenerations`, and every `EndpointSubscribers` entry. The old `OnClientDisconnected` + path was **retired** and folded in here. +- `ProcessClientRequest` now extracts the action / endpoint list / `(ws, ClientID)` on the loop + thread only, then performs **all** subscribe/unsubscribe mutation inside one + `AsyncTask(GameThread, …)` block, dropping stale/not-yet-registered messages instead of erroring. +- `PushUpdatedData` no longer reads `EndpointSubscribers` or calls `send()` on the push-pacing + thread. It hops to the game thread to build JSON and snapshot recipients (re-validated against + `ClientGenerations`), then **defers each `send()` onto the captured uWS loop** (`CapturedLoop`), + where the callback re-checks `LoopLiveSockets` + the generation tag before sending. Payloads are + copied into owning `std::string`s (no dangling stack buffers). +- `StopWebSocketServer` reworked identically: snapshot recipients on the game thread, defer the + `close()` calls onto `CapturedLoop` with the same liveness guard. `CapturedLoop` is captured + once on the loop thread before `app.run()` and reset to `nullptr` on teardown. +- `bHasRunningPushDataLoop` discipline moved fully onto the game thread. + +**Client impact:** none functionally — message shapes and push behavior are unchanged; the fix is +purely about *where* the work runs. + +--- + +### 2. `getPlayer` endpoint reliability + +**Files:** `Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp`, +`Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h` (name cache), +`Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs` (`CoreOnline` module) + +- **Crash-safety:** the two known crash sites — unchecked `GetInventory()->GetInventoryStacks(…)` + and `GetHealthComponent()->GetCurrentHealth()/IsDead()` — are now `IsValid()`-guarded with + fallbacks (`Inventory:[]`, `PlayerHP:0`, `Dead:false`). The `Cast` result is + guarded too (defense in depth). +- **New field:** `location.pitch` on every live player entry — signed camera/look pitch from + `AFGPlayerController::GetControlRotation().Pitch`, clamped to `[-90, 90]` (never actor body + rotation). +- **Persistent player names:** a session-persistent `PlayerNameCache` (`net-id → name`, keyed by + the stable `APlayerState::GetUniqueId().ToString()`) lets `getPlayer` emit offline players from + cache via a hand-built JSON path that never dereferences a despawned actor. +- **Dedicated-server-safe identity:** names use `APlayerState::GetUniqueId()`, **deliberately not** + `AFGPlayerState::GetUserID()` (which SIGSEGVs on dedicated servers). `CoreOnline` was linked so + `FUniqueNetIdWrapper::ToString()` resolves. + +--- + +### 3. WebSocket request validation (VALD-01) + +**Files:** `Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp`, +`Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h` + +- New private `ValidateWSSubscriptionEnvelope(...)` performs strict envelope validation on + subscribe/unsubscribe payloads **before** any endpoint logic runs: `action` present and known; + `endpoints` must be a string or an array of strings (rejects missing/`null`/object/mixed-typed); + names matched **GET-only** against the endpoint registry + (`APIName == Name && Method == "GET"`). Unknown/invalid names are collected and reported with a + **partial-success** contract; the reported-problems list is capped (`MaxReportedProblems = 20` + + `"…and N more"`) so an adversarial all-invalid array can't drive an oversized reflected frame. +- Wired into `ProcessClientRequest` (subscribe path) and `OnMessageReceived` (JSON parse-failure). + Rejected input returns an `{"error": …}` frame and **never reaches dispatch or shared-state + mutation**; the socket stays open and usable. +- The HTTP `CallEndpoint` path is untouched (zero diff) — no regression for existing REST clients. + +--- + +### 4. Dedicated-server asset packaging & fallback page + +**Files:** `Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp` (catch-all + +`/Icons/*` serving), `Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp` + +`Source/FicsitRemoteMonitoring/Public/FRM_Request.h` (`SendFallbackPage`), +`Config/FilterPlugin.ini` (inert marker) + +- **Branded 503 fallback (PKG-02):** when the web UI bundle (`www/`) isn't deployed — common on + dedicated servers — a page navigation now returns a branded `503` HTML page + (`SendFallbackPage`) pointing at `/api/` and the docs, instead of a bare error. The body is a + fixed compile-time string (no request data echoed → no injection surface). +- **Carve-outs:** the fallback fires **only** for extensionless / `.html` / `.htm` page requests + **and** only when the path does not name a registered endpoint. Sub-resources (`.js/.css/.avif/ + .woff2`), `/Icons/*`, and `/api/*` keep their normal `404`/JSON content-type contract. +- **Bare-form API guard (CR-01):** the catch-all consults the endpoint registry first, so + extensionless API calls (e.g. `/getWorldInv`, `/getModList`) keep returning **JSON**, not the + fallback page, even while `www/` is missing. +- **Icon staging (PKG-01):** confirmed that `Config/PluginSettings.ini` + (`[StageSettings] +AdditionalNonUSFDirectories=Icons`) is the real mechanism that stages the + `Icons/` directory into a packaged build — **verified against a real LinuxServer zip**, not just + in-editor. `Config/FilterPlugin.ini` was marked *inert* with a comment so no maintainer mistakes + it for the packaging lever. `PluginSettings.ini` itself was **not modified** (already correct). +- Fixed an MSVC-only unity-build compile break (`C4459`: a `SendFallbackPage` parameter shadowed a + file-scope `DocsURL`), which only surfaced on the Windows client target during full packaging. + +--- + +## Developer tooling & repo scaffolding (non-shipping) + +None of these ship in the packaged plugin; they make it buildable and verifiable. + +- **`build/package.sh`** — reproducible `compile` / `package` wrapper around the verified + RunUBT / RunUAT (`PackagePlugin`) invocations (Win64 client + Linux server, Shipping). +- **`build/RUNBOOK.md`** — deploy/verify runbook: WS stress procedure, PKG-02 fallback smoke + battery, and the icon release workflow (generate on a client → copy into `Icons/` → package → + verify via `unzip -l`). +- **`build/tools/ws-stress-harness.mjs`** — zero-dependency Node script driving N concurrent WS + clients through connect→subscribe→unsubscribe→disconnect churn; exits non-zero on crash/leak. +- **`build/tools/ws-validation-probe.mjs`** — zero-dependency Node probe encoding the VALD-01 + malformed-payload cases; exits non-zero on any validation regression. +- **`.gitignore`** — ignore the ephemeral planning research cache. + +--- + +## Compatibility + +**Deliberately unchanged:** +- `Config/PluginSettings.ini` — already staged `Icons`/`www` correctly. +- HTTP `CallEndpoint` dispatch path — no behavior change for REST clients. +- SML `^3.12.0` dependency; **no new external C++/JS libraries** (uses already-linked Unreal `Json` + and the vendored uWebSockets). + +**Additive-only API changes** (no breaking changes to existing consumers): +- `getPlayer` entries gain `location.pitch`. +- `getPlayer` may now include offline players (from the persistent name cache). +- WebSocket subscribe/unsubscribe now returns an `{"error": …}` frame for malformed envelopes + (previously undefined behavior); well-formed requests are unaffected. + +--- + +## Verification + +No automated test framework exists in this project (by design — see `.claude/CLAUDE.md`). +Each phase was verified **live on a deployed Satisfactory Linux dedicated server**: + +- **Threading:** `ws-stress-harness.mjs` under connection churn — clean closes, `Remaining + connections: 0` after each cycle, no crash/leak. +- **Validation:** `ws-validation-probe.mjs` — 8/8 malformed-payload cases rejected correctly; + 25-client churn showed no regression for valid clients. +- **Packaging:** real `LinuxServer.zip` inspected via `unzip -l` (Icons/ staged); PKG-02 six-case + curl battery (503 fallback + carve-outs + bare-form JSON guard) all green. +- Per-phase `*-VERIFICATION.md` and `*-SECURITY.md` (threats_open: 0) accompany the work in + `.planning/phases/`. + +--- + +## Reviewer file map + +| File | Area | Nature | +|------|------|--------| +| `Source/…/FicsitRemoteMonitoring.cpp` | threading + validation + fallback/Icons serving | core logic (+506/−92) | +| `Source/…/FicsitRemoteMonitoring.h` | declarations, `ClientGenerations`, `PlayerNameCache`, validator | header | +| `Source/…/Endpoints/World/PlayerLibrary.cpp` | `getPlayer` hardening + pitch + offline names | endpoint (+191) | +| `Source/…/FRM_Request.cpp` / `.h` | `SendFallbackPage` (503 page) | helper | +| `Source/…/FicsitRemoteMonitoring.build.cs` | `+CoreOnline` module | build | +| `Config/FilterPlugin.ini` | inert marker comment | config (no functional effect) | +| `build/package.sh`, `build/RUNBOOK.md`, `build/tools/*.mjs` | build + verification tooling | non-shipping | +| `.gitignore` | repo scaffolding | non-shipping | From 95d41e16e614dba30c30d944644c5184661aba98 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sun, 19 Jul 2026 09:12:06 +0200 Subject: [PATCH 35/36] refactor(getPlayer): rename location.pitch to lookPitch Coexists with the generic actor-rotation 'pitch' field the upstream owner adds in porisius/FicsitRemoteMonitoring#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 --- CHANGELOG.md | 12 ++++++++---- .../Private/Endpoints/World/PlayerLibrary.cpp | 10 +++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a7fc40..76da9ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Format is loosely based on [Keep a Changelog](https://keepachangelog.com/). | Area | What changed | Why it matters | |------|--------------|----------------| | **WebSocket threading** | All inbound callbacks and outbound sends are now marshaled through the game thread. | Removes a whole class of race-condition crashes / shared-state corruption. | -| **`getPlayer`** | Crash-safe reads, new `location.pitch`, persistent player names across disconnect. | Endpoint no longer crashes the server; richer, more reliable data. | +| **`getPlayer`** | Crash-safe reads, new `location.lookPitch`, persistent player names across disconnect. | Endpoint no longer crashes the server; richer, more reliable data. | | **WS request validation** | Malformed/malicious subscribe envelopes are rejected with a clear error *before* dispatch. | Bad input can no longer reach endpoint logic on the now-guaranteed game thread. | | **Dedicated-server packaging** | Branded 503 fallback when the web UI is missing; icon staging verified against a real packaged build. | Servers without the web UI bundle degrade gracefully instead of breaking silently. | | **Dev tooling** | Reproducible build/package pipeline, WS stress + validation harnesses, deploy/verify runbook. | Makes the plugin buildable and verifiable on Linux (no test framework exists). | @@ -83,9 +83,12 @@ purely about *where* the work runs. and `GetHealthComponent()->GetCurrentHealth()/IsDead()` — are now `IsValid()`-guarded with fallbacks (`Inventory:[]`, `PlayerHP:0`, `Dead:false`). The `Cast` result is guarded too (defense in depth). -- **New field:** `location.pitch` on every live player entry — signed camera/look pitch from +- **New field:** `location.lookPitch` on every live player entry — signed camera/look pitch from `AFGPlayerController::GetControlRotation().Pitch`, clamped to `[-90, 90]` (never actor body - rotation). + rotation). Named `lookPitch` (not `pitch`) so it coexists with upstream's generic actor-rotation + `pitch` field (porisius/FicsitRemoteMonitoring#297) instead of shadowing it: `pitch` = body/actor + pitch (meaningful for vehicles), `lookPitch` = the player's camera/aim pitch (the only pitch + meaningful for an upright player body). - **Persistent player names:** a session-persistent `PlayerNameCache` (`net-id → name`, keyed by the stable `APlayerState::GetUniqueId().ToString()`) lets `getPlayer` emit offline players from cache via a hand-built JSON path that never dereferences a despawned actor. @@ -167,7 +170,8 @@ None of these ship in the packaged plugin; they make it buildable and verifiable and the vendored uWebSockets). **Additive-only API changes** (no breaking changes to existing consumers): -- `getPlayer` entries gain `location.pitch`. +- `getPlayer` entries gain `location.lookPitch` (camera/aim pitch; distinct from the generic actor + `pitch` field added upstream in porisius/FicsitRemoteMonitoring#297). - `getPlayer` may now include offline players (from the persistent name cache). - WebSocket subscribe/unsubscribe now returns an `{"error": …}` frame for malformed envelopes (previously undefined behavior); well-formed requests are unaffected. diff --git a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp index 51f48351..102dad08 100644 --- a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp +++ b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp @@ -31,7 +31,7 @@ namespace { Location->Values.Add("y", MakeShared(0)); Location->Values.Add("z", MakeShared(0)); Location->Values.Add("rotation", MakeShared(0)); - Location->Values.Add("pitch", MakeShared(0)); + Location->Values.Add("lookPitch", MakeShared(0)); JPlayer->Values.Add("location", MakeShared(Location)); JPlayer->Values.Add("Speed", MakeShared(0)); @@ -168,9 +168,13 @@ void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, } // getPlayer-local injection into the object getActorJSON returns — the - // shared helper itself is NOT modified (D-03 blast-radius limit). + // shared helper itself is NOT modified (D-03 blast-radius limit). Named + // "lookPitch" (not "pitch") so it coexists with getActorJSON's generic + // actor-rotation pitch instead of shadowing it: "pitch" = body/actor + // pitch (meaningful for vehicles), "lookPitch" = the player's camera/aim + // pitch (the only pitch that is meaningful for an upright player body). TSharedPtr LocationJson = getActorJSON(Player); - LocationJson->Values.Add("pitch", MakeShared(Pitch)); + LocationJson->Values.Add("lookPitch", MakeShared(Pitch)); JPlayer->Values.Add("Name", MakeShared(PlayerName)); JPlayer->Values.Add("ClassName", MakeShared(Player->GetClass()->GetName())); From d030eaf27b2e879ef0f82ce6af7e876b8ff2f897 Mon Sep 17 00:00:00 2001 From: Fabrice DIDIERJEAN Date: Sun, 19 Jul 2026 09:18:40 +0200 Subject: [PATCH 36/36] docs(changelog): fix stale 'pitch' -> 'location.lookPitch' in reviewer file map Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76da9ddf..ec858cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -200,7 +200,7 @@ Each phase was verified **live on a deployed Satisfactory Linux dedicated server |------|------|--------| | `Source/…/FicsitRemoteMonitoring.cpp` | threading + validation + fallback/Icons serving | core logic (+506/−92) | | `Source/…/FicsitRemoteMonitoring.h` | declarations, `ClientGenerations`, `PlayerNameCache`, validator | header | -| `Source/…/Endpoints/World/PlayerLibrary.cpp` | `getPlayer` hardening + pitch + offline names | endpoint (+191) | +| `Source/…/Endpoints/World/PlayerLibrary.cpp` | `getPlayer` hardening + `location.lookPitch` + offline names | endpoint (+191) | | `Source/…/FRM_Request.cpp` / `.h` | `SendFallbackPage` (503 page) | helper | | `Source/…/FicsitRemoteMonitoring.build.cs` | `+CoreOnline` module | build | | `Config/FilterPlugin.ini` | inert marker comment | config (no functional effect) |