From ca55b2ea97eef0c514b4767d2fa7721630cddb6b Mon Sep 17 00:00:00 2001 From: megboyzz Date: Fri, 7 Aug 2026 14:16:29 +0300 Subject: [PATCH] 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: " 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 --- mpcore/src/main/cpp/lan_event_injection.h | 387 ++++++++++++++++++++++ mpcore/src/main/cpp/main.cpp | 13 + 2 files changed, 400 insertions(+) create mode 100644 mpcore/src/main/cpp/lan_event_injection.h diff --git a/mpcore/src/main/cpp/lan_event_injection.h b/mpcore/src/main/cpp/lan_event_injection.h new file mode 100644 index 0000000..9998961 --- /dev/null +++ b/mpcore/src/main/cpp/lan_event_injection.h @@ -0,0 +1,387 @@ +#pragma once + +// ---- Synthetic "LAN: " 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 +#include +#include +#include +#include +#include +#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() 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=", 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; +} diff --git a/mpcore/src/main/cpp/main.cpp b/mpcore/src/main/cpp/main.cpp index 0e8b430..4ede01c 100644 --- a/mpcore/src/main/cpp/main.cpp +++ b/mpcore/src/main/cpp/main.cpp @@ -16,6 +16,7 @@ #include #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;