Add synthetic LAN event injection PoC + Soak Test disable fix

Hooks MapTrack::HandleEvent to fabricate a RaceEvent+CashReward+fake
Actor and insert it via the engine's own AddEvent, making a synthetic
"LAN: <lobby>" card appear on an already-loaded street entirely at
runtime (no game_cache/OBB/native_lib changes). Also hooks the
per-frame MapScreen tick to neutralize a QA-only "Soak Test" feature
whose index-based scan of a parallel, unsynced list was the root
cause of a delayed crash on injected entries.

Verified live on Galaxy A9 (2018): zero crashes across all reachable
pins, and a 10-minute soak test with zero crashes after the Soak Test
fix, confirmed against a genuinely responsive post-test map screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 14:16:29 +03:00
co-authored by Claude Sonnet 5
parent d8a1f34a1c
commit ca55b2ea97
2 changed files with 400 additions and 0 deletions
+387
View File
@@ -0,0 +1,387 @@
#pragma once
// ---- Synthetic "LAN: <lobby>" event injection (see ANALYSIS.md §6s-§6dd, plan
// at /home/megboyzz/.claude/plans/dreamy-giggling-hearth.md) ----
//
// Goal: make a discovered LAN lobby appear as an extra race-event card under an
// already-loaded street pin, purely in memory at runtime - no game_cache/OBB/
// native_lib changes. Reuses the engine's own object model and bookkeeping
// (MapTrack::AddEvent) rather than hand-building UI nodes, per the "Вариант Б"
// (fabricate-from-scratch) strategy the user chose.
//
// The whole chain below was static-analysis-derived (§6v-§6y) and then
// live-verified end-to-end on real hardware (§6dd, Galaxy A9/Snapdragon 660):
// MapTrack::HandleEvent(evtype==1025/FlowSetLayoutScreenEvent) -> RefreshEvents
// -> AddEvent(this, &hashHandle), where hashHandle resolves through a generic
// prefab-instance cache (sub_173350 read side / sub_7D638 insert side) to a
// {RaceEvent*, tag, Actor*} triple.
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#include <sys/mman.h>
#include <unistd.h>
#include "util/util.h"
extern void* libapp_base;
// ---- Static IDA offsets (all relative to libapp_base) ----
#define MAPTRACK_HANDLEEVENT_OFFSET 0x368DFC
#define MAPTRACK_ADDEVENT_OFFSET 0x369AB0
#define RESOLVE_HANDLE_OFFSET 0x173350
#define GET_CACHE_CONTEXT_OFFSET 0x7566C
#define HASH_INSERT_OFFSET 0x7D638
#define RACEEVENT_CTOR_OFFSET 0x2A4B58
#define CASHREWARD_CTOR_OFFSET 0x23E2C4
#define STRING_APPEND_OFFSET 0x7B524
#define INTERN_STRING_OFFSET 0x406644
// FlowSetLayoutScreenEvent's event-type ID (checked inside MapTrack::HandleEvent
// itself, confirmed live via a conditional breakpoint - §6dd).
#define EVENT_TYPE_FLOW_SET_LAYOUT_SCREEN 1025
// ---- RaceEvent field offsets (228-byte object, ANALYSIS.md §6y) ----
#define RACEEVENT_TRACKNAME_OFFSET 72 // {begin,end,capacity} eastl-style string
#define RACEEVENT_EVENTNAME_OFFSET 88 // single interned string pointer
// ---- CashReward field offsets (28-byte object, ANALYSIS.md §6aa) ----
#define CASHREWARD_BRONZE_OFFSET 16
#define CASHREWARD_SILVER_OFFSET 20
#define CASHREWARD_GOLD_OFFSET 24
// ---- MapTrack field offsets (ANALYSIS.md §6v) ----
#define MAPTRACK_EVENTVEC_BEGIN_OFFSET 0x240
#define MAPTRACK_EVENTVEC_END_OFFSET 0x244
// ---- Function pointer types, matching each function's real (unmangled,
// register-convention) signature from its Hex-Rays decompile ----
typedef void* (*RaceEventCtorFn)();
typedef void* (*CashRewardCtorFn)();
typedef void* (*StringAppendFn)(void* stringObj, const char* begin, const char* end);
typedef void (*InternStringFn)(void* outSlot, const char* cstr);
typedef int (*GetCacheContextFn)(int anyComponentPtr);
typedef int (*HandleEventFn)(void* mapTrackThis, void* event);
typedef void (*AddEventFn)(void* mapTrackThis, uint32_t* handle);
// sub_173350(out, context, &key) -> fills out with {RaceEvent*, tag, Actor*}
struct ResolveResult {
void* raceEventPtr;
uint32_t tag;
void* actorPtr;
};
typedef void (*ResolveHandleFn)(ResolveResult* out, void* context, uint32_t* key);
// sub_7D638(out, context, &key) -> find-or-insert; out gets {node, bucketSlot,
// insertedFlag}. Node is a 28-byte (0x1C) hash-map entry: {key, +4, +8,
// +12=RaceEvent*, +16=tag, +20=Actor*, +24=next}.
struct HashInsertResult {
uint32_t* node;
void* bucketSlot;
uint8_t inserted;
uint8_t pad[3];
};
typedef void (*HashInsertFn)(HashInsertResult* out, void* context, uint32_t* key);
// Minimal stand-in for a real Actor - only the fields known code paths
// actually read are populated. Everything else about Actor's real layout is
// unmapped; if other game code ever reads a different field on this same fake
// object, that's the one known residual risk of this approach (see the plan
// file's "Key risk to flag explicitly" section).
//
// +4 must be non-null: live-tested 2026-08-07, AddEvent (sub_369AB0) checks
// *(actorPtr+4) and logs "Dereferencing a component pointer whose actor has
// been deleted." when it reads zero there - an "is this actor still alive"
// pointer field. Filled with a self-pointer since the real meaning isn't
// known, just that it must be a valid, non-null, dereferenceable address.
//
// +8 is a refcount: sub_173350 (the handle resolver AddEvent calls) bumps it
// by 1 on every successful resolve (borrowing a temporary reference), and
// AddEvent itself releases that same reference before returning - if the
// release brings it to exactly 0, it calls a virtual "release/destroy" method
// through the object's own vtable (*(int*)(*(int*)actorPtr + 12))(actorPtr).
// Live-tested 2026-08-07: with refcount starting at 0, that net -1 makes it
// hit 0 and the call crashes (fault addr 0xc = NULL vtable + 12, since this
// fake object has no real vtable at +0). Fix: start the refcount at 1, so the
// resolve-then-release cycle nets back to 1, never reaching 0 and never
// triggering that call - safe for any number of future resolves as long as
// each is a matched borrow+release pair, which this engine's pattern always is.
//
// +20/+24 (componentsBegin/componentsEnd, DWORD-indices [5]/[6]) are the
// fields GetComponent<T>() actually iterates.
struct FakeActor {
void* selfPtrForAliveCheck; // +0..+3 unused, but +4 below is read, so lay
// this out explicitly rather than leaving a
// gap that could be misread as two fields.
void* alivePtr; // +4: must be non-null (see comment above).
uint32_t refcount; // +8: must start at 1 (see comment above).
uint8_t unused[8]; // +12..+19: unmapped, still zeroed.
void** componentsBegin; // +20
void** componentsEnd; // +24
};
static RaceEventCtorFn RaceEventCtor = nullptr;
static CashRewardCtorFn CashRewardCtor = nullptr;
static StringAppendFn StringAppend = nullptr;
static InternStringFn InternString = nullptr;
static GetCacheContextFn GetCacheContext = nullptr;
static ResolveHandleFn ResolveHandle = nullptr;
static HashInsertFn HashInsert = nullptr;
static AddEventFn AddEvent = nullptr;
static HandleEventFn orig_MapTrackHandleEvent = nullptr;
// Set to true to actually fabricate and inject a synthetic event once, into
// the first MapTrack pin observed with at least one real event already
// attached (a pin with real events is a lower-risk test target than an
// all-locked one - see the plan's verification notes). Set to false to run in
// observe-only mode: every pin's own TrackName gets logged (via its first real
// event, since that's already-known-safe to read) without touching anything -
// useful for confirming which street is which before narrowing the target.
static constexpr bool kInjectSyntheticEvent = true;
static const char* kSyntheticEventName = "LAN: Test Lobby";
static bool g_syntheticEventInjected = false;
static void InjectSyntheticEvent(void* mapTrackThis) {
void* ev = RaceEventCtor();
if (!ev) {
Log("LAN injection: RaceEventCtor failed");
return;
}
void* internedName = nullptr;
InternString(&internedName, kSyntheticEventName);
*(void**)((uint8_t*)ev + RACEEVENT_EVENTNAME_OFFSET) = internedName;
void* reward = CashRewardCtor();
if (!reward) {
Log("LAN injection: CashRewardCtor failed");
return;
}
void** components = (void**)malloc(2 * sizeof(void*));
components[0] = ev;
components[1] = reward;
FakeActor* actor = (FakeActor*)calloc(1, sizeof(FakeActor));
actor->alivePtr = actor;
actor->refcount = 1;
actor->componentsBegin = components;
actor->componentsEnd = components + 2;
static uint32_t nextId = 0xC0FFEE00;
uint32_t key = nextId++;
int ctxBase = GetCacheContext((int)(uintptr_t)mapTrackThis);
void* ctx = (void*)(uintptr_t)(ctxBase + 320);
HashInsertResult insertResult{};
HashInsert(&insertResult, ctx, &key);
if (!insertResult.node) {
Log("LAN injection: HashInsert returned no node, aborting");
return;
}
insertResult.node[3] = (uint32_t)(uintptr_t)ev; // +12
insertResult.node[4] = 0; // +16 tag
insertResult.node[5] = (uint32_t)(uintptr_t)actor; // +20
AddEvent(mapTrackThis, &key);
Log("LAN injection: added synthetic event '%s' to MapTrack %p (key=0x%08x)",
kSyntheticEventName, mapTrackThis, key);
}
extern "C" int Hook_MapTrackHandleEvent(void* mapTrackThis, void* event) {
int result = orig_MapTrackHandleEvent(mapTrackThis, event);
int evType = *(int*)((uint8_t*)event + 4);
if (evType == EVENT_TYPE_FLOW_SET_LAYOUT_SCREEN) {
uint32_t* vecBegin = *(uint32_t**)((uint8_t*)mapTrackThis + MAPTRACK_EVENTVEC_BEGIN_OFFSET);
uint32_t* vecEnd = *(uint32_t**)((uint8_t*)mapTrackThis + MAPTRACK_EVENTVEC_END_OFFSET);
// Sanity-check before trusting these enough to dereference vecBegin[0]
// below: live-tested 2026-08-07, this vector got read mid-update once
// (crashed dereferencing a garbage vecBegin=0x100, paired with a small
// *plausible-looking* count) - this is read from a background
// GLThread, and AddEvent's push_back isn't atomic (realloc+memmove+3
// separate pointer writes), so a torn read is possible if this hook's
// dispatch happens to race a concurrent update. Every real heap
// pointer observed on this device this session was well above 0x10000
// (typical mmap'd region, e.g. 0xb5xxxxxx/0xb8xxxxxx), so gate on that
// rather than trusting the count alone.
bool vecLooksValid = (uintptr_t)vecBegin > 0x10000 && vecEnd >= vecBegin;
int count = vecLooksValid ? (int)(vecEnd - vecBegin) : 0;
if (count > 64) count = 0; // real per-street counts are small; anything
// wildly outside that range is more likely
// a torn read than a real value.
if (count > 0) {
int ctxBase = GetCacheContext((int)(uintptr_t)mapTrackThis);
void* ctx = (void*)(uintptr_t)(ctxBase + 320);
uint32_t firstHandle = vecBegin[0];
ResolveResult res{};
ResolveHandle(&res, ctx, &firstHandle);
if (res.raceEventPtr) {
char* nameBegin = *(char**)((uint8_t*)res.raceEventPtr + RACEEVENT_TRACKNAME_OFFSET);
char* nameEnd = *(char**)((uint8_t*)res.raceEventPtr + RACEEVENT_TRACKNAME_OFFSET + 4);
int len = (int)(nameEnd - nameBegin);
if (len > 0 && len < 120) {
char buf[128];
memcpy(buf, nameBegin, len);
buf[len] = 0;
Log("MapTrack %p: %d event(s), TrackName='%s'", mapTrackThis, count, buf);
} else {
Log("MapTrack %p: %d event(s), TrackName=<empty>", mapTrackThis, count);
}
} else {
Log("MapTrack %p: %d event(s), first handle 0x%08x did not resolve",
mapTrackThis, count, firstHandle);
}
} else {
Log("MapTrack %p: 0 events (locked/no events for this street)", mapTrackThis);
}
// Injection no longer gated on count>0: live testing (2026-08-07) showed
// this street's FIRST evtype==1025 firing usually finds its own
// RaceEvent-hash registry still empty (a real race between the map
// screen's layout pass and background registry population - masked
// during breakpoint-based debugging, since pausing the process gives
// the background loading thread extra wall-clock time to finish).
// AddEvent push_backs onto the vector regardless of its current size,
// so injecting unconditionally on the first pin encountered is just as
// valid a test and doesn't depend on winning that race.
//
// Temporarily injecting into every pin this dispatches to (not just
// the first) for visual confirmation: it's not yet known which of the
// ~12 MapTrack instances that fire through this hook correspond to
// the handful of on-screen streets the player actually sees - the 3
// visible streets each showed a real event already, so their own
// FlowSetLayoutScreenEvent may be dispatched through a different path
// this hook doesn't intercept. Broadening to "every pin" for this
// pass answers that empirically instead of guessing further.
if (kInjectSyntheticEvent) {
InjectSyntheticEvent(mapTrackThis);
}
}
return result;
}
static bool InstallMapTrackHandleEventHook() {
RaceEventCtor = (RaceEventCtorFn)((uintptr_t)libapp_base + RACEEVENT_CTOR_OFFSET);
CashRewardCtor = (CashRewardCtorFn)((uintptr_t)libapp_base + CASHREWARD_CTOR_OFFSET);
StringAppend = (StringAppendFn)((uintptr_t)libapp_base + STRING_APPEND_OFFSET);
InternString = (InternStringFn)((uintptr_t)libapp_base + INTERN_STRING_OFFSET);
GetCacheContext = (GetCacheContextFn)((uintptr_t)libapp_base + GET_CACHE_CONTEXT_OFFSET);
ResolveHandle = (ResolveHandleFn)((uintptr_t)libapp_base + RESOLVE_HANDLE_OFFSET);
HashInsert = (HashInsertFn)((uintptr_t)libapp_base + HASH_INSERT_OFFSET);
AddEvent = (AddEventFn)((uintptr_t)libapp_base + MAPTRACK_ADDEVENT_OFFSET);
uintptr_t target = (uintptr_t)libapp_base + MAPTRACK_HANDLEEVENT_OFFSET;
uint32_t* target32 = (uint32_t*)target;
// Target confirmed ARM-mode, position-independent prologue this session
// (PUSH {R4,R5,R11,LR}; ADD R11,SP,#8 - live-disassembled, ANALYSIS.md §6y
// intro), same trampoline pattern as the other hooks in this file.
void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (tramp == MAP_FAILED) {
Log("MapTrackHandleEvent hook: mmap trampoline failed");
return false;
}
uint32_t* tramp32 = (uint32_t*)tramp;
tramp32[0] = target32[0];
tramp32[1] = target32[1];
tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4]
tramp32[3] = (uint32_t)(target + 8);
orig_MapTrackHandleEvent = (HandleEventFn)tramp;
uintptr_t page = target & ~((uintptr_t)getpagesize() - 1);
if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) {
Log("MapTrackHandleEvent hook: mprotect target failed: %s", strerror(errno));
return false;
}
target32[0] = 0xE51FF004; // LDR PC, [PC, #-4]
target32[1] = (uint32_t)(uintptr_t)&Hook_MapTrackHandleEvent;
__builtin___clear_cache((char*)target, (char*)(target + 8));
__builtin___clear_cache((char*)tramp, (char*)tramp + 16);
Log("Installed MapTrack::HandleEvent hook at %p, trampoline=%p", (void*)target, tramp);
return true;
}
// ---- Soak Test disable hook (see ANALYSIS.md §6ff/§6gg) ----
//
// MapScreen's per-frame tick (sub_17C120) contains a QA-only "Soak Test"
// auto-race feature: every ~4s of active gameplay it index-walks a *separate,
// parallel* list of resource-path strings (obtained via sub_242904, not the
// same hash-table sub_7D638/sub_173350 operate on) and builds a debug log
// line from a "random" (index-based) entry. Our injection only inserts into
// the primary hash-table cache, never this parallel list, which desyncs
// their sizes - eventually the Soak Test's index-based lookup reads
// misaligned memory. Live-tested 2026-08-07: this crashed the process ~5
// minutes after injection, faulting inside sub_406CAC (a "build a string
// from a C-string pointer" helper) on our raw injected key instead of a
// real string pointer.
//
// This is QA/debug-only instrumentation with no player-facing purpose (it
// auto-picks and logs/launches random cached races purely for automated
// soak testing) - rather than reverse-engineering and keeping the second,
// not-yet-identified parallel list in sync with every injected entry, the
// clean fix is to prevent this branch from ever running at all: force its
// trigger accumulator (a global float, flt_AD417C) back to 0 immediately
// before every tick, so it can never reach the >4.0 threshold that fires it.
// This also protects against future injected entries, not just the ones
// present at fix time, and doesn't change any of sub_17C120's other
// legitimate per-frame behavior (camera clamping, unlock-reveal animation,
// touch dispatch), which still runs normally via the real function.
#define MAPSCREEN_TICK_OFFSET 0x17C120
#define SOAK_TEST_ACCUMULATOR_OFFSET 0xAD417C
typedef void (*MapScreenTickFn)(int a1, int* deltaMs);
static MapScreenTickFn orig_MapScreenTick = nullptr;
extern "C" void Hook_MapScreenTick(int a1, int* deltaMs) {
*(float*)((uintptr_t)libapp_base + SOAK_TEST_ACCUMULATOR_OFFSET) = 0.0f;
orig_MapScreenTick(a1, deltaMs);
}
static bool InstallSoakTestDisableHook() {
uintptr_t target = (uintptr_t)libapp_base + MAPSCREEN_TICK_OFFSET;
uint32_t* target32 = (uint32_t*)target;
// Confirmed ARM-mode, position-independent prologue this session
// (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the
// other hooks in this file.
void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (tramp == MAP_FAILED) {
Log("SoakTestDisable hook: mmap trampoline failed");
return false;
}
uint32_t* tramp32 = (uint32_t*)tramp;
tramp32[0] = target32[0];
tramp32[1] = target32[1];
tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4]
tramp32[3] = (uint32_t)(target + 8);
orig_MapScreenTick = (MapScreenTickFn)tramp;
uintptr_t page = target & ~((uintptr_t)getpagesize() - 1);
if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) {
Log("SoakTestDisable hook: mprotect target failed: %s", strerror(errno));
return false;
}
target32[0] = 0xE51FF004; // LDR PC, [PC, #-4]
target32[1] = (uint32_t)(uintptr_t)&Hook_MapScreenTick;
__builtin___clear_cache((char*)target, (char*)(target + 8));
__builtin___clear_cache((char*)tramp, (char*)tramp + 16);
Log("Installed Soak Test disable hook at %p, trampoline=%p", (void*)target, tramp);
return true;
}
+13
View File
@@ -16,6 +16,7 @@
#include <iomanip>
#include "util/armhook.h"
#include "util/armhooks.h"
#include "lan_event_injection.h"
void* libapp_base = NULL;
@@ -176,6 +177,12 @@ static bool InstallBuildTrackScenePathHook() {
// baseline/"before" comparison) - true installs the track-substitution hook.
// Just edit this and rebuild, no need to touch anything else.
static constexpr bool kEnableTrackSubstitutionHook = true;
static constexpr bool kEnableLanEventInjectionHook = true;
// See ANALYSIS.md §6ff/§6gg: prevents a QA-only "Soak Test" auto-race feature
// from eventually crashing the process on entries our injection adds to the
// prefab cache. Independent of kEnableLanEventInjectionHook so it can be kept
// on even if injection itself is toggled off for testing.
static constexpr bool kEnableSoakTestDisableHook = true;
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
@@ -183,6 +190,12 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
if (kEnableTrackSubstitutionHook) {
InstallBuildTrackScenePathHook();
}
if (kEnableLanEventInjectionHook) {
InstallMapTrackHandleEventHook();
}
if (kEnableSoakTestDisableHook) {
InstallSoakTestDisableHook();
}
}
return JNI_VERSION_1_6;