Files
nfsmw-online/docs/ARCHITECTURE.md
T
megboyzzandClaude e7c76fc2dd docs: bring 1.8 MB of project documentation under version control
These files had never been tracked anywhere - they lived in a plain directory
with no git at all, which is also where the whole reverse-engineering record
sat. Code already committed refers to them by name (opponent_substitution.h
cites "ANALYSIS.md section 6hh", DebugMenuOverlay.kt cites "DEBUG_MENU.md
section 3"), so until now a fresh clone carried references to documents it did
not contain.

  ANALYSIS.md                       the RE record, and the reason the rest works
  ARCHITECTURE.md                   how the mod's pieces fit together
  ARM64_TRANSLATION_LAYER.md        the translation layer's running log
  PROGRESS.md                       chronological progress across both chats
  BETA_TELEMETRY_PLAN.md            how crash/telemetry reporting is meant to work
  LOBBY_UI_DESIGN.md + .html        lobby design and its clickable prototype
  DEBUG_MENU.md                     debug panel design
  STATIC_RECOMPILATION_FALLBACK.md  the plan if translation had not panned out
  evidence/                         font atlas capture from the glyph-corruption bug
  save_backups/                     saves at known milestones, for reproducing state

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-22 23:29:12 +03:00

47 KiB
Raw Blame History

ARCHITECTURE.md — Multiplayer Mod for NFS Most Wanted (2012) Android

Living document. Revisit and edit whenever a design decision changes — do not let this drift out of sync with reality.


1. Goal recap

Layer local + dedicated-server multiplayer onto the existing singleplayer game, without breaking singleplayer. Lobby-based flow (map marker → enter lobby → player list → native car-select → ready-up → countdown → race), LAN discovery (SAMP-style broadcast) + internet servers, RakNet-based network layer with a separate headless C++ server (not a repurposed game runtime).


2. Integration layers (top to bottom)

┌───────────────────────────────────────────────────────────────────┐
│ launcher (Android APK wrapper)                                    │
│  - ships the original game_cache unmodified                       │
│  - ships modified native libraries (or hooks them at runtime)      │
│  - hosts the lobby UI (approach TBD-per-screen, see §4)            │
└───────────────────────────────────────────────────────────────────┘
                              │ loads
                              ▼
┌───────────────────────────────────────────────────────────────────┐
│ mpcore (native module, JNI_OnLoad entry point)                    │
│  - loaded AFTER libapp.so and friends (per task brief)             │
│  - resolves libapp.so base via FindLibrary()                      │
│  - installs inline hooks (Thumb ARM32, armhook.cpp toolkit)        │
│  - owns the RakNet CLIENT instance (peer, connects to a server)    │
│  - bridges native <-> Java for lobby UI + car-select result        │
└───────────────────────────────────────────────────────────────────┘
                              │ hooks into
                              ▼
┌───────────────────────────────────────────────────────────────────┐
│ libapp.so (game engine, Iron Monkey, C++)                         │
│  - RaceLoaderTask / OpponentCollection / RaceStartingGrid /        │
│    TrackNavigator / InRaceState (race lifecycle — see ANALYSIS.md) │
│  - Flow-machine (SB-scripted UI/state flow)                       │
│  - EAMText/GLES native text+UI renderer                           │
│  - PlayerCarController / AICarController family (car physics/AI)  │
└───────────────────────────────────────────────────────────────────┘
                              │ (future, separate process/machine)
                              ▼
┌───────────────────────────────────────────────────────────────────┐
│ Dedicated server (standalone, NOT libapp.so)                      │
│  - headless C++, links RakNet                                     │
│  - authoritative-ish relay: lobby state, ready-check, position sync│
│  - local discovery: UDP broadcast responder (SAMP-style)           │
│  - internet: direct connect / master-list registration (TBD)       │
└───────────────────────────────────────────────────────────────────┘

Key architectural principle carried over from ANALYSIS.md §1.1: we never originate RaceLoaderTask/race-start ourselves. The player always starts a race through the game's own menu → Flow-machine → RaceLoaderTask path (any existing event works — see subtask plan). Our hooks intercept that already-in-context flow at specific points (opponent list build, starting grid, track navigator) to substitute multiplayer data. This keeps singleplayer's own code paths completely intact when no lobby session is active — hooks should be written to no-op/pass-through cleanly when there's no active multiplayer session.


3. Where hooks live and how they're installed

  • Injection point: mpcore's JNI_OnLoad, invoked after libapp.so/libfmodex.so/libfmodevent.so/libnimble.so are already loaded (confirmed as the intended design in the task brief and matches the existing mpcore draft's FindLibrary("libapp.so") call).
  • Hook mechanism: reuse and extend launcher/mpcore/src/main/cpp/util/armhook.cpp. It already implements Thumb-mode (16-bit) inline hooking (InstallHook/InstallJMPHook/InstallBLXHook), vtable-slot overwrite (InstallMethodHook), and a register-copy code-injection stub (CodeInject), backed by a fixed trampoline region inside libapp.so's own address space (APP_ADDR(0x000A1B44) sized 0x1A36) plus an mmap'd PROT_RWX scratch page for original-bytes preservation. This is a working foundation, not a rewrite target — see ANALYSIS.md §6 for what's already verified.
  • Addressing: APP_ADDR(offset) = libapp_base + offset, and the IDA database's addresses are already base-0x0-relative (i.e., IDA addresses == file offsets == APP_ADDR argument, no translation needed). Verified this session via the 0xC8C9D8 string anchor (see ANALYSIS.md §6).
  • Two hook strategies available depending on the subsystem (decide per hook site once its real vtable/function is located):
    1. Inline/trampoline hook on a free-function or a specific vtable-slot's target — for functions we want to wrap (call original, then modify results), e.g. OpponentCollection::Build.
    2. Direct vtable-slot replacement (InstallMethodHook) — simpler when we want full control and don't need the original implementation, e.g. potentially TrackNavigator's position resolver if we decide to fully own opponent positioning instead of post-processing it.

3a. Working hook reference: RaceLoaderTask_BuildTrackScenePath (arbitrary track substitution)

The first fully working, validated hook (mechanically and visually confirmed — see ANALYSIS.md §6g–§6o and PROGRESS.md's 2026-08-05 entries). Lives entirely in launcher/mpcore/src/main/cpp/main.cpp, ~95 lines, no changes anywhere else. Use this as the template for future hooks (opponent list, starting grid, coordinate sync) — same trampoline pattern, same "read the struct, repoint a couple of pointers, call through to the original" shape.

What it does: whichever race event the player starts through the normal menu flow, this hook makes it load a different, hardcoded track's geometry instead of the event's real one — while everything else about the event (rewards, opponent count, car-class restriction, HUD) stays as designed. Confirmed with a live A/B test: same event, same car, same opponents — different city, different road layout, different time of day, purely by flipping the hook on/off.

Target function: RaceLoaderTask_BuildTrackScenePath, libapp_base + 0x2a8424 (BUILDTRACKSCENEPATH_OFFSET). ARM-mode (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), both displaced instructions are position-independent, so it uses a trampoline hook: an mmap'd RWX page holds the two displaced instructions followed by a jump back to target+8, and the function's first 8 bytes are overwritten with LDR PC,[PC,#-4] + the hook's address. Hook_BuildTrackScenePath runs first, then always calls through to orig_BuildTrackScenePath — this is a wrap, not a replace.

RaceDefinition struct offsets used (confirmed live, not guessed — see ANALYSIS.md §6j/§6n for how each was found):

Offset from raceDef Field Notes
(a1+32 → deref) raceDef pointer itself a1 is the RaceLoaderTask; raceDef = *(void**)(a1+32)
+72 / +76 track name {begin,end} char pointers e.g. "region1_foothills_track4" — this hook overwrites both
+100 / +104 environment name {begin,end} char pointers e.g. "foothills"-style — not the same string as the track name; see naming convention below
+164 / +168 m_StartLine actor name {begin,end} generic across tracks ("start") — safe
+180 / +184 m_FinishLine actor name {begin,end} generic for regular races ("finish"), but per-event custom for time-trial events (e.g. "event_02_finish") — this is why time-trial events are out of scope, see below
+196 / +200 m_EndOfTrack actor name {begin,end} generic ("end_of_track")
+212 / +216 checkpoint-container actor name {begin,end} empty for regular races; per-event custom for time-trial (e.g. "checkpoints_timetrial_event_2")

Only +72/+76 and +100/+104 are overridden by this hook. The original string buffers are deliberately leaked (never freed) — BuildTrackScenePath only reads them, and avoiding a free sidesteps needing to know an unconfirmed capacity/allocator-owner field.

Environment-name gotcha: environment prefabs are per-track-numbered files (chicago1.prefabs.sbchicago6.prefabs.sb, matching region4_chicago_track1track6), not one generic <region>.prefabs.sb per city. Setting the env name to just "chicago" fails to open the prefab database and crashes immediately. The env-name string must match the specific track number, not just the region ("chicago4" for region4_chicago_track4). The one region that doesn't follow this convention (region3/colorado, shipped as a single un-numbered colorado.prefabs.sb) turned out to be cut/incomplete content with no loadable geometry at all — see ANALYSIS.md §6n before picking a new target track.

Picking a different target track: edit kOverrideTrackName/kOverrideEnvName near the top of main.cpp to any region{N}_{city}_track{M} / {city}{M} pair that's confirmed to exist under game_cache/published/prefabs/tracks/ and game_cache/published/prefabs/environments/ respectively (and has a game_cache/published/models/environments/{city}/ folder — the colorado lesson).

Toggle flag: kEnableTrackSubstitutionHook (constexpr bool, just above JNI_OnLoad) — true installs the hook, false runs the game completely unmodified. Flip, rebuild (./gradlew :app:assembleDebug), reinstall — no other code changes needed. Exists specifically so before/after comparisons don't require repeated edits to JNI_OnLoad itself.

Known limitation — regular races only (explicit project-scope decision, not a bug): time-trial and single-opponent/pursuit-style events reference their finish-line and checkpoint-group actors by the per-event custom names shown in the table above, which only exist in that event's original scene. Substituting the track for those event types either does nothing useful or crashes on a missing-null-check in RaceLoaderTask_ResetStartingLine (GetComponent<Checkpoint>() on a NULL actor lookup — a genuine pre-existing engine bug, not ours). This hook is only intended to run for regular point-to-point races. See the track-substitution-scope memory and ANALYSIS.md §6n for the full trace.

Standing gap, not yet closed: this hook currently fires for every race load, gated only by the build-time kEnableTrackSubstitutionHook flag (default false since PROGRESS.md cont.66's fresh-save prologue crash) — it has no runtime check for "is a multiplayer session actually active." Before this can run as part of normal multiplayer play, it needs to be re-scoped to only substitute the track when a real lobby session is active, not left as an always-on-or-always-off toggle.


3b. Subtask 2 — opponent substitution, starting grid, and traffic removal

Scoped 2026-08-25; all four open questions answered via RE 2026-08-26 (ANALYSIS.md §6hh — full addresses/offsets/decompiles there, this section is the implementation-facing summary). [RE complete, no code written/tested yet] — this is the actual "make N real players race together" mechanism; everything proven so far (§3a's track substitution, the car/color/class/upgrade capture in car_selection.h/mod_slot_tracking.h) is supporting infrastructure around a single local player's own car-select flow, not this.

Mode scope: street race only, consistent with §3a's "regular races only" decision.

2.1 — Opponent substitution [mechanism CONFIRMED LIVE — cont.70]

OpponentCollection::PopulateFromProperties (sub_2B649C) builds a vector of Opponent objects (80 bytes each) from RaceDefinition SB data, one per AI opponent (always 5 for a regular race — confirmed across 15+ events, every track). Live Opponent layout: +20/+24/+28 = CarDescriptionName (eastl string, same idiom as RaceEvent.TrackName), +36 = ColourIndex (plain int). Implemented and live-tested (opponent_substitution.h): hooking sub_2B649C, letting it build the normal AI list unmodified, then overwriting an entry's CarDescriptionName/ColourIndex is confirmed to reach the actual spawned, rendered car at the starting grid — screenshotted a forced Ford Focus RS500 replacing a real opponent's original car on a real (non-synthetic) event replay. This holds despite StreetRaceStartingGrid's own placement vector (2.2) being confirmed, live, to hold different heap objects than OpponentCollection's vector — there's an unidentified intermediate spawn step between them, but it reads car/color off these same Opponent objects (or a copy taken after Populate already ran), so hooking here is sufficient; the intermediate step itself doesn't need to be found. See ANALYSIS.md §6ii for the full live-test writeup. What's left: this is currently a blanket test (forces opponent-slot-0 of every race to the same fixed test car, gated off by default via g_enableBlanketOpponentSubstitutionTest) — turning it into the real feature needs a lobby/session data source (which race is this, which real player's car/color goes in which slot), which doesn't exist yet since no lobby UI/data model is built.

2.2 — Starting grid [RE complete — turned out simpler than expected]

Both open questions resolved, and favorably: the grid is not per-track dataStreetRaceStartingGrid's 5 spacing/speed parameters (MinDistanceBetweenRacers/MaxDistanceBetweenRacers/MaxTrackWidthFraction/PlayerStartingSpeed/OpponentsStartingSpeed) are hardcoded C++ constants, identical for every regular street race regardless of track (confirmed: no event's SB data ever overrides them). Each track only supplies a single "start" locator actor (confirmed via two full track scenes, no per-slot/numbered spawn actors exist) — TrackNavigator resolves that plus a computed (distance, lateralOffset) into a world position (see 2.5 below), so the same code naturally adapts to any track's geometry with zero special-casing needed.

"Player always last" is not a fixed index — it's a structural side effect of the placement algorithm (sub_2B88BC): it loops over the opponents vector first (accumulating randomized spacing between each), then places the player once, at the tail, in a separate call. Practical upshot: multiplayer doesn't need to fight this rule at all. Real players substituted into the opponents vector (2.1) get completely normal, correctly-spaced slots via the same code path as any AI opponent — only each device's own local player keeps the dedicated "always last" slot, which is fine since the local player is already a distinct, privileged entity (input/camera) regardless of multiplayer.

Cont.73 pitfall, worth remembering: a test reimplementation that inserted the player mid-sequence (instead of at the tail) reproduced a real spawn-inside-another-car collision live. Root cause: TrackNavigator::Resolve's lane bounds are symmetric around 0, so the player's hardcoded lateral offset (0, dead center) exactly coincides with an opponent's own lane-cycle position whenever that opponent's index is ≡ 0 (mod 3). Vanilla is safe only because the player is always maximally far (past the whole accumulated distance) from every opponent — never adjacent. Any future code that changes where the player is inserted into this sequence must widen the longitudinal gap around that insertion point, not just reuse the normal opponent-to-opponent spacing (sized for adjacent-different-lane cars, not same-lane).

2.3 — Spawn order policy [unchanged — still a design decision, not an RE question]

Since 2.1/2.2 both resolve to "just insert real players as opponent-vector entries," the policy question becomes simpler: which N of the lobby's players get inserted, and their relative order within the vector (which determines lane-zigzag assignment and grid-row spacing, not "who's on the grid at all" — everyone inserted gets a normal slot). Default for the first version: alphabetical by display name, implemented as an isolated ResolveGridOrder(lobbyPlayers) -> orderedList function, swappable later without touching the hook itself.

2.4 — Remove police and civilian traffic [CONFIRMED LIVE, both cops and traffic]

Cops — CONFIRMED LIVE (cont.74): the original sub_F7E9C hook missed a second spawn path. Real shape: sub_F5EA4 is a per-tick dispatcher (called only from sub_F5BB4, CopManager's broader Update - too broad to skip wholesale since it also runs unrelated bust-timer bookkeeping) that branches on a live flag to either sub_F7E9C (the originally-hooked leaf) or sub_F8078 (a second, previously-unhooked leaf - a distance-sorted-candidate-list scheduler). Both leaves call the same SpawnCopCar/sub_F85B8 (confirmed exactly 2 callers via xrefs_to). Hooking the dispatcher (sub_F5EA4) itself covers both leaves with one hook, without touching sub_F5BB4's other per-tick maintenance. Live A/B proof: same event replayed 3x - flag off hit a full marked police roadblock mid-race (screenshotted: light bars, "POLICE" livery, cops standing in the road) on a plain street race with no Most-Wanted branding; flag on (dispatcher hook), same route, dispatcher fired 7x in a tight burst at the same point and the road was completely clear. Gated off by default (g_enableCopSpawnSkipTest).

Civilian traffic — CONFIRMED LIVE, zero traffic achieved (cont.75, resolving cont.74's open item): the sub_33D734 candidate-list hook (below) can only reduce traffic to a structural minimum, never zero, so the real fix needed the actor's own placement path, not its candidate-list population. Traced sub_C26A0 (CarReset - own assert strings confirm "foundTrackInfo"/"Reset") - resolves a spline distance + lateral offset to a world position and writes it via the same sub_10B09C/sub_D5138 transform-write pair used by PlaceCar (2.2) and SpawnCopCar (2.4/cops) - i.e. the actual moment a traffic car's position becomes real, analogous to those two. It has exactly two callers: sub_2A0470 (a "ResetLine" event handler, registered per traffic car via sub_31AAEC - same idiom SpawnCopCar uses for its own "EndOfTrack" event) and sub_C201C, the traffic car's own per-tick controller (second parameter carries a delta-time value; calls sub_C26A0 once when an idle/wander timer expires, once to retry a previously-deferred reset stored in fields sub_C26A0 itself writes on its "can't resolve yet" fallback path - i.e. genuinely traffic-car-specific, not a generic car utility shared with the player/opponents). First hooked sub_2A0470 on the assumption it also handled initial placement - live-tested and disproven: its own unconditional diagnostic log never fired once across a full race replay, yet traffic was clearly visible and moving the whole time, so it isn't on the path that matters for a normal-length race (left in, harmless, but not the fix). Hooking sub_C201C instead (skipping its body wholesale, g_enableTrafficControllerSkipTest) is the fix: live-confirmed zero traffic across a full 2+ minute drive on the same route that previously showed a persistent red pickup truck and a blue sedan - clean road throughout, no crash, hook fired continuously (~50+ times, log budget exhausted) as expected for a per-tick controller.

sub_33D734 do-while structural-minimum hook (superseded as the primary fix by the above, but harmless to keep): TrafficCarSpawner's populate function (own assert string confirms the class name), called twice per race from sub_33C020 (once per traffic direction), max-count read from a different object (*(a1[3]+16)/*(a1[3]+20)) than RaceEvent.TrafficCarCount. Forcing that max-count to 0 (g_enableTrafficSpawnerZeroTest) can't reach zero alone because the populate loop is a do { ... } while, unconditionally queuing at least one candidate per track waypoint regardless of the max-count parameter - this is what motivated tracing one level further to sub_C26A0/sub_C201C above.

2.5 — Bonus finding: TrackNavigator's real spline→world resolver (relevant to subtask 4)

Found while tracing grid placement: PlaceCar (sub_2914DC) calls sub_3261B0(navigator, outBuffer, distanceAlongSpline, lateralOffset) — the real, working TrackNavigator::Resolve implementation §5 already predicted subtask 4 would need. Only the forward direction (spline→world) is confirmed; the inverse (world position → spline distance, needed to read a moving car's current position for network sync) hasn't been located — worth checking TrackNavigator's other vtable slots when subtask 4 starts. See ANALYSIS.md §6hh Q4.

Remaining before implementation

  1. Directly confirm sub_2B88BC's opponent-vector argument is really OpponentCollection's own built vector (high confidence from matching shapes, not yet byte-traced).
  2. Only if a substituted real-player slot needs it: map Opponent's AI-tuning sub-object (+40..+75) to explicitly suppress AI behavior — may not be necessary if a real player's own input naturally overrides it.

4. Lobby UI & flow — see LOBBY_UI_DESIGN.md

Everything about the lobby overlay's screens, flow, data schema, player identity/profile, IP:port addressing, rewards validation, and ready-state UX now lives in a dedicated file — LOBBY_UI_DESIGN.md — kept separate so this document stays focused on native/engine integration. Clickable prototype: LOBBY_PROTOTYPE.html.

One decision from that doc is load-bearing here too, since it's really a native-engine question: the wait for slow-loading peers happens at the engine level, not on a custom Compose screen — a client that's ready sits in the game's own native pre-race starting-grid scene (looping, not our UI) with only a thin overlay status strip on top, and the real 3-2-1-GO countdown is held until everyone's in (or the 45s hard timeout). See LOBBY_UI_DESIGN.md §7 for the full reasoning and the still-open hook work this needs.

Archived: original §4-4c content (superseded by LOBBY_UI_DESIGN.md, kept here for history)

4. Lobby UI decision (original)

Revised recommendation (updated after correcting ANALYSIS.md §3.3): use a hybrid. Full interactive lobby screens (player list, ready-up, countdown) still go through option (a) — an overlay Android View on top of the game's GLSurfaceView — because that pipeline has no native concept of touch-driven widgets. But simple in-world/in-menu indicators (the green/red map markers for active lobbies) should go through the BitmapGraphics native text/atlas bridge instead of a separate overlay, since it's verified, already trusted by the game's own renderer, and requires no new Flow/SB authoring.

Corrected reasoning (this session confirmed, via decompiling the actual JNI call sites, that com.ea.ironmonkey.BitmapGraphics — already reverse-engineered as launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt — is a real, live native→Java upcall bridge: Canvas.drawText into an offscreen Bitmap, read back via AndroidBitmap_lockPixels, and blitted into a native GLES font-atlas texture every time UI text is drawn. See ANALYSIS.md §3.3 for the full call chain and renamed IDA functions):

Concern Map markers → BitmapGraphics extension Full lobby screens → Android overlay View
Mechanism Add a method (e.g. drawMarker(x, y, color) via canvas.drawCircle) to BitmapGraphics.kt; call it from a hook alongside the existing drawString JNI call site (0x5625a0) Standard Android View/Compose overlay added to the activity's view hierarchy above/around the GLSurfaceView
Implementation cost Low — one new Kotlin method, one new hook call from mpcore at the point we know a lobby is active Low — standard Android UI, fast iteration
Visual integration Genuinely native — rendered through the exact same atlas-blit path as the game's own UI text, in the game's own coordinate space Good but technically a separate layer composited over/under the GL surface
Touch/interaction support None — this pipeline only produces pixels in a texture; no input handling Full Android input handling, exactly what's needed for player list + ready buttons
Risk to singleplayer Low — purely additive draw call, no-ops when no lobby is active None — overlay is purely additive, toggled only when a multiplayer session exists
Car/upgrade display integration N/A We don't render the car ourselves either way — invoke the game's existing native car-select screen (same one singleplayer uses) as a sub-flow, our overlay only wraps around it

Both are additive and don't touch Flow/SB authoring, so neither destabilizes the shared menu system. This split (markers via the verified BitmapGraphics path, interactive screens via overlay View) is still a preliminary call — revisit once the "Choose Car" native hand-off and a first marker hook are actually implemented. Record any reversal here with reasoning.

Still open: whether EAMText/GlyphMeshGLES/FreeType (also present in the binary, see ANALYSIS.md §3.3) plays any role that would interfere with or duplicate a BitmapGraphics-based marker — not yet determined, low priority until the marker hook is actually attempted.


4a. Lobby flow decision and data schema

Flow decision: Scenario 1 — Compose/View overlay for lobby creation and management, native car-select (filtered by the lobby's class setting) as a sub-flow, ready-checkmark to confirm. The "lobbies as cards on real street pins" idea (Scenario 2) is shelved: it depends on hooking into visible-street MapTrack event population, which the visible-streets-investigation found no viable path for after exhausting AddEvent/Career/Progression/property-registry. Scenario 1 needs no further RE — arbitrary track loading (§3a) and the native car-select screen already work or are known-reachable.

Data schema (pure data shape, no UI/transport concerns):

Lobby {
  id: string
  hostPlayerId: string
  trackId: string              // arbitrary track, see §3a BuildTrackScenePath hook
  classRestriction: int | null // car class filter applied to native car-select; null = any
  rewards: {                   // reuses the CashReward shape found on RaceEvent, ANALYSIS.md §6aa
    bronze: int
    silver: int
    gold: int
  }
  players: [LobbyPlayer]
}

LobbyPlayer {
  playerId: string
  displayName: string             // from the local PlayerProfile, see §4c — sent to peers on join
  avatarId: string                 // preset id ("color+icon"), see §4c — not a photo/upload
  isLeader: bool                 // explicit flag, not inferred from hostPlayerId match —
                                  // keeps leader-ness a first-class, UI-visible fact instead of
                                  // something every screen has to re-derive by comparison
  ready: ReadyState
  carId: string
  colourIndex: int
  mods: [int | null, int | null] // exactly 2 upgrade slots; each is a CarMod ModType id
                                  // (or null = "НЕТ"/empty). Purely for display — other players
                                  // resolve name/icon/description client-side from the car's own
                                  // CarDescription.Mods catalog, already loaded for the garage
                                  // screen. No numeric value/balance computation server-side.
}

enum ReadyState {
  SetsUpLobby   // leader only — track/class/rewards configuration
  ChoosingCar
  ChoosingRace
  Loading       // race-start fired, client is loading its own RaceLoaderTask
  NotReady
  Ready
}

Confirmed against the live in-game upgrade screen (2 slots, ВЫБОР УЛУЧШЕНИЯ list) and the CarMod struct in alfaromeo_4c_2012_desc.prefabs.sb — 7/7 checked entries matched by price (ModType 1/4/5/6/7/8 confirmed, TYRES_REINFLATING/CHASSIS_REINFORCED/BODY_IMPACT/POWERTRAIN_POWERPACK/NITROUS_BURN/NITROUS_EARN). ModType is a unique per-option id, not a shared category grouping — an earlier hypothesis to that effect is superseded. The separate im::app::car::CarPart/UpgradeParts catalog (with skipCost/orderTimeMinutes timer-gating fields) has no live instance data in any checked file and appears unused in this build — not part of this schema.

Resolved (2026-08-18, see §4b for reasoning):

  • Host-leaves-lobby: auto-promote the longest-connected remaining LobbyPlayer to leader; dissolve only when the lobby becomes empty. Keeps a session alive across a flaky host connection instead of punishing everyone else.
  • classRestriction: enforced explicitly, at the overlay layer, not the native carousel (revised 2026-08-19) — car_select has no known native class-filter hook (RACEEVENT_CATEGORYTAG_OFFSET was tried and is a confirmed false lead, PROGRESS.md cont.58), and rather than leave that as an excuse to only show the restriction as a label, the client-side flow explicitly checks the chosen carId's class against classRestriction right after onCarSelected fires: a mismatch blocks ready (visible error state, forces the player back into car_select) instead of just being advisory. Revisit as a true hard filter — blocking the native carousel itself before a mismatched pick is even possible — if the real class-filter mechanism is ever found (cont.59/60's open leads).

Still open, needs a product decision: the straggler/timeout policy during the loading phase — see §4b step 5.


4b. Full lobby flow (designed 2026-08-18)

Every step below is tagged [proven] (already implemented and live-tested on device per PROGRESS.md), [wired, needs new UI] (the native hook/JNI plumbing exists, only the lobby overlay code calling it is missing), or [open] (real engineering work not started). This distinction matters more than usual here because two of this project's biggest proven results — the track-substitution hook (§3a) and the car/color/upgrade capture (§3a of PROGRESS.md cont.58-63b) — were each built against a different entry point (a real, normal race event) than the one the synthetic "LAN: <lobby>" event currently reaches, and those two paths are not yet joined. See step 5 for why that matters.

Phase 0 — Entry point [open, trivial]

A small persistent button (e.g. bottom-corner Compose overlay, always present once onMapLoaded has fired at least once) opens the lobby overlay full-screen on top of GameGLSurfaceView. Same FrameLayout-stacking pattern already used for buildCarSelectionOverlay() (GameActivityMain.kt) — the GL surface keeps rendering underneath (map stays visible/animated), our Compose content just takes over touch input while open.

Phase 1 — Create or join [open — no networking yet]

  • Create: leader picks lobby name, track (any region{N}_{city}_track{M} confirmed to exist per §3a's own caveats — reuse that same validated list, do not hand-author a new one), optional classRestriction, reward tier (CashReward, ANALYSIS.md §6aa — real engine struct, not invented). Leader becomes LobbyPlayer{isLeader=true, ready=SetsUpLobby}.
  • Join: LAN broadcast discovery (ARCHITECTURE §5, SAMP-style UDP) or direct IP:port. Neither transport exists yet — RakNet integration hasn't started (§5). The prototype (see chat) stands this phase up with local mock state only (no real second device) until RakNet lands.

Phase 2 — Player list / waiting room [open UI, wired data underneath]

Real-time roster: name, ready-state icon, chosen car icon + colour swatch (same visual as the existing CarSelectionBadge, just one per row instead of a single corner badge), leader crown. Leader additionally sees "Начать гонку", disabled until every LobbyPlayer.ready == Ready.

Phase 3 — Native car-select hand-off [wired, needs new UI]

This is the one phase where the hard native work is already done:

  • Tapping "Выбрать машину" calls MultiplayerCore.triggerTrueDirectCarSelectJump()[proven], jumps straight into the real native car_select screen for our synthetic event, no map navigation needed (PROGRESS.md cont.48/57). Fallback to MultiplayerCore.triggerCarSelectTest() (opens EventDetails first, then auto-skips — also [proven]) if the true-jump's own warm-up precondition (g_realEventDetailsVisitHappened, i.e. at least one real event_detail→car_select transition already happened this session) hasn't been satisfied yet — the "cold session" gap noted in cont.55/57, still open. The lobby overlay should shrink to a small corner badge (not fully dismiss) while this runs, matching how CarSelectionBadge already coexists with the game's own UI.
  • Player interacts with the fully native, unmodified car_select + loadout screens (real rendering, real touch — by design, ARCHITECTURE §4 already decided this stays native).
  • onCarSelected(carId, colorName, r,g,b,a)[proven, wired to Kotlin] — fires the moment car_select's own CONTINUE is tapped (cont.61/63b).
  • onUpgradesAccepted(slotIds, carMods)[proven, wired to Kotlin] — fires when the loadout screen's controlled BACK×3 exit lands back on the map (cont.39-41).
  • The lobby overlay listens for onUpgradesAccepted (or, if the player has no mods to set and the loadout screen is skipped, the underlying "back on map" signal that same hook chain already detects) as the "player is done, back to lobby" trigger, restores itself to full view, and fills that player's roster row from the already-captured CarSelectionState. Player then taps a "Готов" toggle → ready = Ready.

Phase 4 — Ready-check & start [open — needs RakNet]

Leader's "Начать гонку" broadcasts the final race definition (track id, per-player car/colour/mods) once every player is Ready. No transport exists yet to carry this — it's a data-shape problem only right now (the Lobby/LobbyPlayer schema above already covers what needs to go over the wire).

Phase 5 — Loading, and the "someone's still loading" problem [mostly open — the real gap in this whole flow]

Two unresolved issues sit here, and they're different in kind:

  1. Which race event actually carries the load. The synthetic RaceEvent used to reach car_select on demand (step 3) has its loadout-confirm deliberately redirected back to the map today, specifically because it has no real track/scene reference — letting it proceed into RaceLoaderTask crashes on a NULL start-line lookup (PROGRESS.md cont.36, confirmed root-caused via cont.41's A/B: a real event's loadout-confirm sails through to an actual playable race with zero crashes, using the exact same code path). Giving the synthetic event a real track/scene reference is flagged in cont.37/41 as "a materially bigger investigation than anything solved so far." Recommended path, not yet implemented: don't fight that — have the lobby's "start" instead re-target a real, existing race event as the vessel (any regular point-to-point event works, since §3a's track-substitution hook already proves the geometry can be swapped after the fact), and let that real event's own loadout-confirm proceed normally instead of being redirected. This sidesteps the missing-track-scene crash entirely instead of solving it. Needs its own short investigation (mostly wiring, since both halves — real-event loadout-confirm passthrough, and track substitution — already independently work) before it can be marked proven.
  2. Holding a fast-loading client at the start line. Nothing hooks the moment between "this client's RaceLoaderTask finished" (candidate signal: InRaceState construction, RTTI-confirmed §3.1, or RaceLoaderTask_DispatchInitialFSMEvents's final event, §6b stage 8 — either is a real, already-located anchor, just not yet hooked for this purpose) and "gameplay actually starts ticking." This needs new hook work: freeze input/sim on that client once loaded, show a small "Ожидание игроков (2/4)" overlay (same Compose layer, minimized), and only release once the host broadcasts "everyone's in." RaceLoaderTask_ExecuteLoadSequence's own SetLoadProgress(this, N) calls (§6a slot 4, fractions 0.10.8) are a good source for a real, granular per-client progress percentage to report to peers, rather than inventing a synthetic one.
  3. Straggler/timeout policy — resolved 2026-08-18: hard timeout, auto-start. Default 45s (configurable) counted from when the first client reports fully loaded; whoever hasn't reported loaded by then is left behind and the race starts without them. Simplest to implement/reason about, no host-facing decision UI needed — accepted tradeoff is occasionally starting a race short a player on a slow device. Still needs the actual per-client "loaded" signal (point 2 above) and a decision on how a left-behind player is represented (spectator vs. rubber-band-in-when-ready) — lower priority than the timeout mechanism itself.

4c. Player identity (name + avatar) — added 2026-08-18

Raised as a gap while prototyping the lobby screens: every player row needs something to display before any of them have picked a car, and it can't come from an EA account — ARCHITECTURE §8 already rules out the Synergy backend for anything multiplayer-related, and this project has no login system of its own.

PlayerProfile, stored locally on-device only (Android SharedPreferences/DataStore — no server round-trip, no RE work, plain new Kotlin), edited once on first run and editable any time after from a chip in the lobby browser header:

PlayerProfile {
  displayName: string   // free text, local default e.g. "ROOKIE_01"
  avatarId: string       // one of a small fixed set of {color, glyph} presets
}

Avatar is a preset, not a photo — a small fixed palette of colour+glyph combinations (e.g. 🏎️//🔥/🏁/★/🌙/⚙/💀 over a themed accent colour), picked from a grid. Deliberately not a camera/gallery photo upload: no permissions prompt, no image storage/transport concerns once RakNet is carrying lobby state, and it matches the HUD-badge visual language already established by CarSelectionBadge/the roster rows. Revisit only if the game ever needs to show a real player photo somewhere — nothing in the current design requires it.

PlayerProfile is copied into LobbyPlayer.displayName/avatarId (schema above) at join/create time and travels with the rest of the lobby state once RakNet exists — no separate sync mechanism needed.


4d. Debug menu (Compose) — see DEBUG_MENU.md

An on-map, developer-only debug overlay, added 2026-08-19 — full detail (activation mechanism, enable flag, panel inventory) now lives in a dedicated file, DEBUG_MENU.md, kept separate for the same reason as the lobby UI split (§4). One decision worth surfacing here: the enable/disable switch (DebugFeatures.debugMenuEnabled) deliberately lives in mpcore, not app — a direct instruction, so debug-tooling gating stays in the shared module rather than duplicated per-Activity.


5. Network layer (RakNet) — high-level plan (later stages, laid out now)

  • Client: embedded inside mpcore (same process as the game). Owns a RakNet RakPeerInterface, connects to either a LAN-discovered server or a manually-entered/internet server address.
  • Server: standalone headless C++ binary/service, links RakNet directly — explicitly not a copy of libapp.so's game runtime. Responsibilities: lobby membership, ready-check aggregation, countdown trigger, relaying position/state updates between clients (topology TBD: pure relay vs. light authority — default to relay first, revisit if cheating/desync becomes a concern).
  • LAN discovery: SAMP-style UDP broadcast query/response on a fixed port, server replies with lobby metadata (name, player count, track/event). Reference implementation pattern: GTASA_examples/ (any version — wrappers differ, core discovery/RakNet integration pattern is what matters).
  • Internet servers: direct-connect by IP:port initially; a master-server/list mechanism is a later refinement (not required for first working version).
  • Position sync: builds directly on subtask 4 (coordinate read/write hook on the car/track-navigator layer) — the RakNet layer's job is just to move (distance_along_spline, lateral_offset, height, or raw Vector3+rotation) tuples between peers at some tick rate; the actual "make the opponent car appear at X" mechanism is the native hook, not the network code.

No RakNet integration work has started yet — this section exists so later native-layer decisions (e.g., what a hooked position-update struct looks like) are made with the eventual network payload shape in mind.


6. Build & deploy pipeline for native patches

Confirmed this session:

  • native_lib/ (top-level) is not under any version control (git rev-parse fails there — the repo root itself has no .git). It is the sole safety net for "what the game originally shipped" and the file the IDA .i64 was built from. Rule: native_lib/libapp.so (and siblings) must never be overwritten in place. Any patched binary is a new file.
  • launcher/ is its own separate git repository, and — checked via git ls-fileslauncher/app/src/main/jniLibs/armeabi-v7a/*.so is tracked in git (not gitignored), including libapp.so. Currently byte-identical to native_lib/libapp.so (same SHA-256). This means the copy that actually feeds the APK build has its own independent safety net (git diff/git log/git checkout inside launcher/), on top of our manual changelog.
  • Workflow for every native patch, going forward:
    1. Produce the patched bytes into a new file under native_lib/patched/ (created this session, empty for now — no patches exist yet), never touching native_lib/libapp.so itself.
    2. Log the change in PROGRESS.md (offset, old/new bytes or hook description, why, which patched-file snapshot it corresponds to).
    3. Copy the patched file to launcher/app/src/main/jniLibs/armeabi-v7a/libapp.so (overwriting the build input — safe, since that copy is git-tracked and diffable).
    4. Build the APK (Gradle, already configured per the task brief) — user installs/tests on their own device or WayDroid (see §7).
  • mpcore itself is the other half of "the magic": it does no static patching of libapp.so — instead, at JNI_OnLoad time, it resolves libapp.so's runtime base address and installs hooks against base + offset (the APP_ADDR() macro, already in armhook.cpp). So there are two independent things to track in PROGRESS.md, and they should be logged distinctly: (a) actual byte patches to the shipped .so file itself (rare — e.g. removing an anti-tamper check), and (b) runtime hooks installed by mpcore (the common case for all the race/opponent/coordinate hooks in the subtask plan) — these don't modify the .so on disk at all, only memory at runtime, but should still be documented (what address, what it does, why) since they're just as capable of breaking singleplayer if wrong.

7. On-device testing capability (WayDroid)

Checked this session: adb and waydroid CLI are both installed on this machine and reachable from this session's shell. Attempted waydroid session startit failed: Wayland socket '/run/user/1000/wayland-0' doesn't exist; are you running a Wayland compositor?. Confirmed cause: the active desktop session here is GNOME-on-Xorg (DISPLAY=:1, real Xorg/gnome-shell processes, WAYLAND_DISPLAY unset) — WayDroid requires a genuine Wayland compositor and cannot render under plain X11.

Fix (user action needed): log out and pick a Wayland session at the GDM login screen (e.g. "GNOME" without the "(X11)" suffix), then waydroid session start should succeed.

Once a Wayland session is active, this session can plausibly: waydroid app install <apk>, adb connect/adb shell for logcat and file access, adb shell screencap piped to a file and viewed via the image-reading tool for visual verification, and even blind UI interaction via adb shell input tap/swipe driven off those screenshots (same pattern as the browser-automation tool, just for Android). This would close most of the test loop without needing the user present for every check — still recommend the user do final verification on real hardware, since WayDroid's GPU/driver stack can behave differently than a real device for this kind of engine.

8. Non-goals / explicit constraints carried from the task brief

  • Singleplayer must keep working with the mod installed and multiplayer inactive — every hook must be conditional/pass-through when no session is active.
  • The dedicated server is a separate C++ service, not a headless instance of the Android game runtime.
  • No use of any legacy EA "Synergy" backend endpoints (leaderboards/IAP/DRM) for multiplayer — confirmed in ANALYSIS.md §3.4 this is unrelated infrastructure and likely defunct server-side anyway.

9. ARM64-only device support (Android 14+ AArch32-less hardware) — see ARM64_TRANSLATION_LAYER.md

Everything about running libapp.so (ARM32) on newer devices whose CPU cores dropped AArch32 execution entirely now lives in a dedicated file — ARM64_TRANSLATION_LAYER.md — kept separate so this document stays focused on the mod's own integration architecture. Pure theory as of 2026-08-19, nothing implemented: the short version is an in-process, dynamic CPU-level translation layer (Unicorn/QEMU-TCG-class embeddable ARM32 core, Hangover/Box64-style API-boundary breakout for libc/JNI/GLES/audio) rather than a full-VM (VMOS-style) or a static ARM32→ARM64 recompile — the latter would invalidate every offset in ANALYSIS.md/§3a's RE work, the former discards all of this project's native-process integration for no reason.