mxbmrp3

AI Development Context for MXBMRP3

Read This First

This is a racing simulator HUD plugin for PiBoSo racing games (MX Bikes, GP Bikes, WRS, KRP). It’s a DLL plugin written in C++ using each game’s proprietary API, with a shared core that works across all supported games.

For deep technical details: See ARCHITECTURE.md (detailed documentation with mermaid diagrams, component descriptions, dependency graphs, multi-game architecture). This file is a quick-start guide.

How this file earns its length. It is read in full before every task, so its cost is paid on every task - and a rule that has quietly gone stale is worse than no rule, because it is followed anyway. So the rule here is: state what the code cannot state about itself, and state it once.

Reach for standard tooling before writing your own

Default to the off-the-shelf tool. Before adding a script that lints, runs, reports or gates something, check whether a standard tool already does it, and use it even when the homemade version looks shorter today. This is the single largest source of wasted effort this project has had: a hand-written test runner (deleted in the CTest/CMake/gcovr migration) grew a stage table, tool checks, skip semantics and an exit-code policy, then needed a lint to verify the skip semantics and a self-test to verify the lint - five commits reimplementing what CTest already ships, down to the exit 3 = skip convention. CMakeLists.txt’s header has the full post-mortem.

The pattern to recognise: you are writing tests for your tooling. One test for a gate is normal; a lint checking your lint means a maintained implementation exists elsewhere.

Bespoke is right when nothing off the shelf does the job, and the bar is “I looked and there isn’t one”. Real examples here: the callback recorder/replay path (proprietary API), hud_sw_renderer, the .fnt generator, the minidump analyser, and check_docs.py’s invariant-label and budget checks. When you add one, say in its header what you evaluated and why it fell short - that sentence is what lets the next person delete it when a standard tool catches up.

Quick Architecture

Game Engine (MX Bikes / GP Bikes / WRS / KRP)
    ↓ (callbacks via plugin API)
mxb_api.cpp / gpb_api.cpp (per-game DLL exports)
    ↓ (converts to unified types via adapters)
PluginManager (receives unified types only)
    ↓
PluginData (singleton - caches all game state)
    ↓ (notifies on data changes)
HudManager (singleton - owns all HUD instances)
    ↓
Individual HUDs (IdealLap, Standings, Map, etc.)
    ↓ (build render primitives)
Game Engine (renders quads/strings)

PluginData ──(notifies on data changes)──→ HttpServer
    ↓ (builds JSON snapshot on game thread)
SSE stream → Web Overlay (browser/OBS)

HudManager ──(2nd frame via collectSurface, if enabled)──→ CompanionWindow
    ↓ (submit quads/strings; own window thread)
hud_gpu/sw_renderer → standalone OS window (2nd monitor)

Key Singletons, not the full set: grep -l getInstance mxbmrp3/core/*.h is, and it cannot go stale. Listed are the ones whose ROLE the name does not give away. Enforced: check_docs.py fails on a name here that no longer exists, so this may go incomplete but never wrong.

Multi-Game Support

The plugin supports multiple PiBoSo games from a single codebase. The game is the target, not the configuration (Debug/Release are plain):

Game Target Output Status
MX Bikes mxbmrp3 mxbmrp3.dlo ✅ Full support
GP Bikes mxbmrp3_gpb mxbmrp3_gpb.dlo ✅ Core features
Kart Racing Pro mxbmrp3_krp mxbmrp3_krp.dlo ✅ Core features (no FMX)
WRS - wrsmrp3.dlo ⏳ Stubbed

Translation Layer:

Build & Test

Full build/test details, prerequisites, and both build tracks live in DEVELOPMENT.md. The essentials for working here:

⚠️ Build Environment:

⚠️ IMPORTANT - Shell Commands:

Testing Discipline

Tests are not optional scaffolding - this project has a real, CI-gated suite that runs on Linux with no game (see DEVELOPMENT.md). When you change behavior, change a test:

Where the test goes (pick the fastest one that can exercise the change):

What you changed Where the test goes
A pure helper (formatting, color, parsing, header-only math) tests/unit/ unit test (doctest) - compiles the real header, ~1s
Standings / gaps / penalties / session logic / anything in the JSON snapshot add/extend a doctest in tests/integration/tests/ using PluginHost + checkStandings (drives real callbacks under Wine, asserts /api/state) - see TESTING.md
A settings / persistence change tests/integration/run_persist_test.sh (load→save round-trip)
A new DLL-boundary callback or array-size/count handling tests/integration/callback_fuzzer.cpp
Config parsing / a new INI or JSON field tests/integration/run_fuzz.sh corpus
A hot-path change (Draw / telemetry / rebuild) confirm tests/integration/run_perf.sh didn’t regress
Installer / packaging (packaging/mxbmrp3.nsi) tests/integration/run_installer_test.sh (makensis + Wine: asserts install/uninstall/registry/data-wipe outcomes)
Web overlay rendering (mxbmrp3_data/web/ - js/overlay-*.js/style.css/index.html) add/extend a Playwright test in tests/web/tests/ driving ?demo (asserts the rendered DOM) - see TESTING.md. ./tests/web/lint.sh (the eslint gate) runs in a second and catches the dead-code class
Anything that renames/moves a file the docs name, or adds a test python3 tools/check_docs.py (paths resolve, invariants labelled, catalogue complete, CLAUDE.md within budget)

These run headless - most via mingw + Wine, the web-overlay tests via Node + Playwright. Manual in-game testing on Windows stays the final check for rendering/input, but it does not excuse skipping an automated test when the logic is testable headless.

Important Patterns & Constraints

Performance Target: 480fps

The plugin must run efficiently at 480fps (2.08ms frame budget). Many competitive players use high refresh rate monitors. Avoid per-frame allocations, unnecessary string operations, and complex calculations in hot paths like Draw() and RunTelemetry(). run_perf.sh gates the average and both p99s against that budget; BenchmarkWidget’s in-game warning colours are tied to it too, so changing the target changes what players are shown.

DO:

DON’T:

Maintenance Invariants (touch X → also update Y)

Regression traps where changing one thing silently rots another. Each is the rule; the mechanism’s own detail lives next to the mechanism, and the bug it prevents lives in the test that pins it. Enforced = a check fails if you get it wrong, so read the failure rather than memorizing the rule. Pinned = a test covers it. Convention = nothing catches it but review.

Design Decisions (Don’t “Fix” These)

Singletons Everywhere Required by plugin API - we get one global entry point, everything branches from there.

Settings panel helpers are members, not lambdas This entry said the opposite until the “8+ parameters” behind it was measured: 2. Measure before inheriting a claim.

HUD config is open to SettingsHud - friend class SettingsHud or public members Configuration data, not encapsulated state. Both shapes are in the tree (some HUDs use both); friend is the majority - prefer it for new HUDs. Counts are deliberately not quoted here: they moved every time a HUD landed.

HUDs pull from PluginData - except the track-position push HUDs cache formatted render data (m_quads, m_strings), not raw game state, so PluginData stays authoritative. Exception: HudManager::updateRiderPositions pushes raw Unified::TrackPositionData into Map/Radar (world coords PluginData drops) + GapBar (convenience). Enforced: check_hud_raw_cache.sh - a new Unified:: member in a HUD header needs // raw-cache:.

Settings reset reuses save/load serialization (don’t add a third list) “Reset to defaults” replays a startup snapshot through the same applier loadSettings() uses, never a hand-maintained list of per-setting resets, the per-TAB buttons included. Two snapshots back this, deliberately separate:

m_hudDefaults (sparse-save baseline + base-section edits) is not a clean factory snapshot - don’t point reset at m_hudDefaults or merge the two caches; that reintroduces stale-default-on-reset bugs (an upgraded default not taking effect). A new setting is covered for free once wired into save/load. Pinned by reset_test.cpp + reset_tab_test.cpp (a tab’s Reset restores all that tab can change).

Widget vs HUD Distinction Widgets (grep _widget.h for the set) are simplified HUD components with:

Full HUDs (StandingsHud, LapLogHud, PitboardHud, TimingHud, NoticesHud, StatsHud, etc.) have:

Helmet Overlay (HelmetOverlayHud) Full-screen immersion overlay - neither a widget nor a typical HUD:

Companion Window (CompanionWindow + hud_sw_renderer) Standalone OS window that renders the HUD on a second monitor - not a network mirror and unrelated to the web overlay:

Handler-to-API Event Mapping Each handler corresponds to game API callback(s), but receives unified types:

Layered automated tests + manual in-game There is a real, CI-gated test suite, all runnable on Linux with no game engine. TESTING.md is the guide (layers, harness, philosophy, how to add a test); the short version: pure-logic unit tests (tests/unit/, doctest); the integration layer (tests/integration/tests/, doctest) that cross-compiles the whole plugin, loads it under Wine, drives the real callbacks via PluginHost, and asserts the plugin’s computed state - read via snapshot() (built directly, no HTTP server) or typed MXBMRP3_Test_* hooks for internal state, plus real-data golden masters that replay actual in-game callback captures (the in-plugin recorder [Recorder] enabled=1 → tape → replayTape); Playwright web-overlay tests (tests/web/); and specialized runners (persistence, fuzz, perf). They run in CI on demand and as the release gate (no push trigger - see .github/workflows/tests.yml), plus automatically on pull requests in the free public mirror. Rendering is not a headless blind spot: companion_demo.sh screenshots the real HUD via the software renderer, so visual changes are pixel-diffable (TESTING.md). In-game testing stays the final check for input and game-specific behavior.

Logger has an internal mutex Logger::log() is called from the game thread and ~10 background threads (HttpServer, UpdateChecker, UpdateDownloader, DiscordManager, RecordsFetcher, CompanionWindow, SteamFriends, Analytics, XInput). The mutex serializes concurrent writes so log lines don’t interleave. Don’t remove it. The SEH crash filter deliberately doesn’t call Logger to avoid deadlocking on this mutex.

Common Tasks

Adding a New HUD

  1. Create class inheriting from BaseHud (.h and .cpp files in mxbmrp3/hud/)
  2. Nothing to register. mxbmrp3/CMakeLists.txt globs the four product directories with CONFIGURE_DEPENDS, so a new file is picked up by every toolchain on the next build. (This used to mean hand-editing the vcxproj and its .filters, with a checker guarding the drift; one definition removed the whole class.)
  3. Implement rebuildRenderData() - builds vectors of quads/strings
  4. Register in HudManager: add the member pointer + getter, and one createHud(m_pX, "harness_id") line in initialize() (registration order = draw order). Nulling in clear() is automatic - createHud/bindHudSlot enroll the pointer for it, so a forgotten-null dangling pointer can’t happen.
  5. Add tab in SettingsHud for configuration: a Tab enum value (settings_hud.h), a renderTab<Name>/optional handleClickTab<Name> in a new settings/settings_tab_*.cpp, and one row in the per-tab descriptor registry s_tabRegistry (settings_hud_render.cpp) - the row drives display order, name, tooltip id, the tab-list checkbox, game gating, render/click routing, and the per-tab reset (no switches to edit). Plain numeric steppers (“value = applyAccelerated*; mark dirty”) should use ctx.addSteppedControl + a SteppedControl descriptor instead of new ClickRegion::Type enum pairs.
  6. Add save/load via the per-HUD serializer registry (settings_hud_registry.{h,cpp}, whose header explains why one table drives capture + apply + serialize): write a cap_<Name>/app_<Name> (declared in settings_hud_registry_decls.inc, defined in the .cpp) and add one row to hudSectionRegistry(). Reset stays automatic via the factory snapshots. For a global single-value setting, use writeGlobalSettings()/applyGlobalLine() in settings_manager_global.cpp instead. Game-gated HUDs wrap decls, definitions and registry row in the same #if GAME_HAS_*. Pinned by settings_sections_test.cpp (every captured section is actually serialized).

Working with Game API Events

When implementing event handlers or debugging timing/lap data:

Working with the Web Overlay

The embedded HTTP server (core/http_server.cpp) streams race data to browser overlays over SSE. The client is in mxbmrp3_data/web/js/overlay-*.js; each file’s header describes its own area, so read there for mechanism.

The rules that span the C++/JS boundary, which no single file can state:

Adding Support for a New Game Feature

  1. Add field to appropriate Unified:: struct in game/unified_types.h
  2. Add conversion in each adapter (game/adapters/*_adapter.h)
  3. Add feature flag to game/game_config.h if game-specific
  4. Update handlers/HUDs to use the new field

Disabling a Feature Per-Game

When an entire feature (HUD, manager, integration) doesn’t apply to one or more games - e.g. FMX freestyle tricks on karts, Discord Rich Presence on non-MXB, the records provider on non-MXB:

  1. Add a GAME_HAS_X flag to game/game_config.h. Examples already in the file: GAME_HAS_DISCORD, GAME_HAS_HTTP_SERVER, GAME_HAS_FMX, GAME_HAS_RECORDS_PROVIDER. Pattern:
    #if defined(GAME_MXBIKES) || defined(GAME_GPBIKES)
        #define GAME_HAS_FMX 1
    #else
        #define GAME_HAS_FMX 0
    #endif
    
  2. Gate the HUD registration in HudManager::initialize(). Leave the member pointer as nullptr; existing null-checks downstream (if (m_pFmxHud)) will fall through silently.
  3. Gate the settings tab in SettingsHud - set gameGated = true on the tab’s row in s_tabRegistry (settings_hud_render.cpp). isTabAvailable() then skips the tab whenever its hud getter returns the nullptr you set up in step 2 - no #if block needed (runtime null-check pattern, like TAB_RECORDS/TAB_FMX/TAB_FRIENDS).
  4. Gate the hotkey row in settings_tab_hotkeys.cpp. The hotkey action itself can stay in the enum (the handler in HudManager::processHotkeys is already null-safe), but the row should be hidden so users don’t see a binding that does nothing.
  5. Gate handler entry points that feed the disabled manager (run_telemetry_handler.cpp, race_session_handler.cpp, etc.). Skip the singleton calls entirely so the binary doesn’t pull them in.
  6. Gate SettingsManager save/load if the disabled HUD has its own profile section. Crucial when HudManager::getXxxHud() returns a Hud& with assert(m_pXxxHud) - calling it with a null member crashes in debug and null-derefs in release.
  7. Gate the installer (packaging/mxbmrp3.nsi) if the feature has supporting data files (e.g. web/ for HTTP server) so they don’t ship to a build that can’t use them.

If a .cpp file’s GAME_HAS_X reference is in a file that doesn’t transitively include game_config.h, add #include "../../game/game_config.h" (path from the file). The handlers’ plugin_data.h already pulls it in; hud_manager.h pulls it in; isolated tab files like settings_tab_hotkeys.cpp may need the explicit include.

Reference implementations to copy from: FMX (commit deba67f), Discord (GAME_HAS_DISCORD), Records provider (GAME_HAS_RECORDS_PROVIDER).

Where Things Live

The tree is the index - this is the map plus the parts that aren’t guessable from a filename.

Path What’s there
mxbmrp3/core/ Singletons and services: plugin_data (state cache, split into _standings/_trackpos/_livegaps; pure pieces pulled out to blue_flag_detect.h + proximity_tuning.h and friends - ARCHITECTURE.md lists them and what is deliberately left in), hud_manager, settings_*, http_server, companion_window + hud_sw_renderer, stats_manager, fmx_manager, crash_handler, analytics_manager, event_recorder, spotter_* (map: tools/spottergen/README.md)
mxbmrp3/hud/ Every HUD and widget, all deriving from base_hud. Settings UI is settings_hud*.cpp + hud/settings/settings_tab_*.cpp
mxbmrp3/handlers/ Callback handlers; run-prefixed = player-only, race-prefixed = all riders
mxbmrp3/game/ unified_types.h, game_config.h (compile-time game + GAME_HAS_*), adapters/
mxbmrp3/vendor/piboso/ Per-game DLL exports (*_api.cpp) and api_guard.h
mxbmrp3_data/gamepads/, pitboards/, gauges/ Asset packs: <name>/ = art + a fixed <type>.ini placing content on it (a pad’s 17 buttons, a board’s rows, a dial’s range and sweep). Same nested shape as themes/, same sync code
mxbmrp3_data/web/ Web overlay. Root holds index.html/sw.js/style.css/custom.css; assets live in js/ fonts/ icons/ logos/
tests/ unit/ (pure logic), integration/ (real DLL under Wine + the check_*.sh invariant lints), web/ (Playwright), asan/
tools/ Standalone dev tools, each documented in its own header/README

Reading order for a new area: the type’s header comment first (mechanism), then the test that pins it (behavior + the bug it prevents). Between them they are more current than any prose here.

Non-obvious placements:

Regenerating a shipped font

The shipped .fnt files are GENERATED from the .ttf in mxbmrp3_data/web/fonts/, normalized so every font renders numbers identically - so a font swap cannot reflow a HUD. Rebuild all: tools/fontgen/regen_shipped.sh, whose header documents the normalisation, the atlas-resolution rule and how to add just one.


Git & Development Workflow

Commit Message Conventions

Branch Naming

Version Management

Peer Reviews

Development Style