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>
329 lines
47 KiB
Markdown
329 lines
47 KiB
Markdown
# 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.sb` … `chicago6.prefabs.sb`, matching `region4_chicago_track1`…`track6`), 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 data** — `StreetRaceStartingGrid`'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](LOBBY_UI_DESIGN.md)** — kept separate so this document stays focused on native/engine integration. Clickable prototype: **[LOBBY_PROTOTYPE.html](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.
|
||
|
||
<details>
|
||
<summary>Archived: original §4-4c content (superseded by LOBBY_UI_DESIGN.md, kept here for history)</summary>
|
||
|
||
### 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.1–0.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.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## 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](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-files` — `launcher/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 start` — **it 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](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.
|