Add per-street targeted LAN event injection by group-name

Replaces the "inject into every pin" visual-confirmation mode with
matching against MapTrack's own event-group-name vector (offsets
0xE0/0xE4), which is available as soon as HandleEvent(evtype=1025)
fires regardless of whether any RaceEvent has resolved yet. Injection
now targets exactly one street (kTargetGroupName) and the card's
displayed name is an explicit parameter instead of a fixed constant.

Live-tested: single-target injection hits exactly the intended widget
with zero effect on the other 11 reachable MapTrack instances. The 12
reachable group names are all region/career-progression placeholders
(region{1,2,4,5}_{foothills,desert,chicago,newyork}_track{1,2,3}) -
none correspond to the currently visible/playable on-screen streets,
which a live AddEvent-level trace confirmed never call AddEvent during
normal play at all (see PROGRESS.md 2026-08-08 cont. 9/10 for the full
investigation, including the now-reverted diagnostic hooks used to
establish this).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 18:44:29 +03:00
co-authored by Claude Sonnet 5
parent ca55b2ea97
commit 1c6324b589
+72 -31
View File
@@ -54,6 +54,21 @@ extern void* libapp_base;
#define MAPTRACK_EVENTVEC_BEGIN_OFFSET 0x240 #define MAPTRACK_EVENTVEC_BEGIN_OFFSET 0x240
#define MAPTRACK_EVENTVEC_END_OFFSET 0x244 #define MAPTRACK_EVENTVEC_END_OFFSET 0x244
// {char* begin, char* end} vector of event-*group-name* C-strings (each a
// plain null-terminated string, confirmed by RefreshEvents' own strlen-style
// scan over it - sub_369040, static offsets 0xE0/0xE4). Populated from the
// map screen's layout/prefab data at widget-setup time, independent of
// whether any RaceEvent has actually resolved into the runtime hash-table
// cache yet - unlike RACEEVENT_TRACKNAME_OFFSET (which needs a resolved
// event, so reads as empty for a still-locked/0-event street), this vector
// is available the moment HandleEvent(evtype=1025) first fires. RefreshEvents
// uses each entry as the lookup key into the per-group handle cache
// (sub_242778) that ultimately feeds MapTrack::AddEvent - i.e. this is the
// same stable per-street identifier the real engine itself keys off of, not
// a guessed substitute.
#define MAPTRACK_GROUPNAMEVEC_BEGIN_OFFSET 0xE0
#define MAPTRACK_GROUPNAMEVEC_END_OFFSET 0xE4
// ---- Function pointer types, matching each function's real (unmangled, // ---- Function pointer types, matching each function's real (unmangled,
// register-convention) signature from its Hex-Rays decompile ---- // register-convention) signature from its Hex-Rays decompile ----
typedef void* (*RaceEventCtorFn)(); typedef void* (*RaceEventCtorFn)();
@@ -131,19 +146,30 @@ static AddEventFn AddEvent = nullptr;
static HandleEventFn orig_MapTrackHandleEvent = nullptr; static HandleEventFn orig_MapTrackHandleEvent = nullptr;
// Set to true to actually fabricate and inject a synthetic event once, into // Set to true to fabricate and inject a synthetic event into exactly one
// the first MapTrack pin observed with at least one real event already // chosen street (matched via MAPTRACK_GROUPNAMEVEC_BEGIN_OFFSET - see comment
// attached (a pin with real events is a lower-risk test target than an // above - against kTargetGroupName below), instead of every pin. Set to false
// all-locked one - see the plan's verification notes). Set to false to run in // to run in observe-only mode: every pin's group-name and (if available)
// observe-only mode: every pin's own TrackName gets logged (via its first real // TrackName get logged without touching anything - use this first to read the
// event, since that's already-known-safe to read) without touching anything - // real group-name values off a live device and pick kTargetGroupName from
// useful for confirming which street is which before narrowing the target. // them, the same "observation-only first step" the original plan called for.
static constexpr bool kInjectSyntheticEvent = true; static constexpr bool kInjectSyntheticEvent = true;
// The event-group name to match against MAPTRACK_GROUPNAMEVEC_BEGIN_OFFSET's
// first entry - i.e. which street gets the synthetic card. These are internal
// engine identifiers (region+area+slot), not the localized on-screen street
// names - live-observed 2026-08-07 on this device/save: exactly 12 group
// names exist, all "region{1,2,4,5}_{foothills,desert,chicago,newyork}_track{1,2,3}".
// Picked track1 of region1_foothills as the target for this pass - which
// on-screen street (if any) that corresponds to is exactly what the
// visual-confirmation test below is for.
static const char* kTargetGroupName = "region1_foothills_track1";
// Display name shown on the injected card - the "arbitrary name" half of the
// request; change freely, no offset/layout implications.
static const char* kSyntheticEventName = "LAN: Test Lobby"; static const char* kSyntheticEventName = "LAN: Test Lobby";
static bool g_syntheticEventInjected = false; static void InjectSyntheticEvent(void* mapTrackThis, const char* eventName) {
static void InjectSyntheticEvent(void* mapTrackThis) {
void* ev = RaceEventCtor(); void* ev = RaceEventCtor();
if (!ev) { if (!ev) {
Log("LAN injection: RaceEventCtor failed"); Log("LAN injection: RaceEventCtor failed");
@@ -151,7 +177,7 @@ static void InjectSyntheticEvent(void* mapTrackThis) {
} }
void* internedName = nullptr; void* internedName = nullptr;
InternString(&internedName, kSyntheticEventName); InternString(&internedName, eventName);
*(void**)((uint8_t*)ev + RACEEVENT_EVENTNAME_OFFSET) = internedName; *(void**)((uint8_t*)ev + RACEEVENT_EVENTNAME_OFFSET) = internedName;
void* reward = CashRewardCtor(); void* reward = CashRewardCtor();
@@ -188,7 +214,7 @@ static void InjectSyntheticEvent(void* mapTrackThis) {
AddEvent(mapTrackThis, &key); AddEvent(mapTrackThis, &key);
Log("LAN injection: added synthetic event '%s' to MapTrack %p (key=0x%08x)", Log("LAN injection: added synthetic event '%s' to MapTrack %p (key=0x%08x)",
kSyntheticEventName, mapTrackThis, key); eventName, mapTrackThis, key);
} }
extern "C" int Hook_MapTrackHandleEvent(void* mapTrackThis, void* event) { extern "C" int Hook_MapTrackHandleEvent(void* mapTrackThis, void* event) {
@@ -196,6 +222,20 @@ extern "C" int Hook_MapTrackHandleEvent(void* mapTrackThis, void* event) {
int evType = *(int*)((uint8_t*)event + 4); int evType = *(int*)((uint8_t*)event + 4);
if (evType == EVENT_TYPE_FLOW_SET_LAYOUT_SCREEN) { if (evType == EVENT_TYPE_FLOW_SET_LAYOUT_SCREEN) {
// Read the street's stable group-name identifier (see
// MAPTRACK_GROUPNAMEVEC_BEGIN_OFFSET comment) - available regardless
// of whether any RaceEvent has resolved yet, unlike the
// TrackName-from-first-event logging below.
char** groupNameBegin = *(char***)((uint8_t*)mapTrackThis + MAPTRACK_GROUPNAMEVEC_BEGIN_OFFSET);
char** groupNameEnd = *(char***)((uint8_t*)mapTrackThis + MAPTRACK_GROUPNAMEVEC_END_OFFSET);
bool groupVecLooksValid = (uintptr_t)groupNameBegin > 0x10000 && groupNameEnd >= groupNameBegin
&& (groupNameEnd - groupNameBegin) < 64;
const char* groupName = nullptr;
if (groupVecLooksValid && groupNameEnd != groupNameBegin) {
groupName = groupNameBegin[0];
}
Log("MapTrack %p: groupName='%s'", mapTrackThis, groupName ? groupName : "<none>");
uint32_t* vecBegin = *(uint32_t**)((uint8_t*)mapTrackThis + MAPTRACK_EVENTVEC_BEGIN_OFFSET); uint32_t* vecBegin = *(uint32_t**)((uint8_t*)mapTrackThis + MAPTRACK_EVENTVEC_BEGIN_OFFSET);
uint32_t* vecEnd = *(uint32_t**)((uint8_t*)mapTrackThis + MAPTRACK_EVENTVEC_END_OFFSET); uint32_t* vecEnd = *(uint32_t**)((uint8_t*)mapTrackThis + MAPTRACK_EVENTVEC_END_OFFSET);
// Sanity-check before trusting these enough to dereference vecBegin[0] // Sanity-check before trusting these enough to dereference vecBegin[0]
@@ -241,26 +281,27 @@ extern "C" int Hook_MapTrackHandleEvent(void* mapTrackThis, void* event) {
Log("MapTrack %p: 0 events (locked/no events for this street)", mapTrackThis); 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 // Targeted injection: only the one street whose group-name matches
// this street's FIRST evtype==1025 firing usually finds its own // kTargetGroupName gets the synthetic card - not gated on count>0,
// RaceEvent-hash registry still empty (a real race between the map // since AddEvent push_backs onto the event vector regardless of its
// screen's layout pass and background registry population - masked // current size, and this street's FIRST evtype==1025 firing can find
// during breakpoint-based debugging, since pausing the process gives // its own RaceEvent-hash registry still empty (a real race between
// the background loading thread extra wall-clock time to finish). // the map screen's layout pass and background registry population,
// AddEvent push_backs onto the vector regardless of its current size, // live-tested 2026-08-07 - AddEvent still succeeds either way).
// 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 // Known limitation (2026-08-08): the group names reachable through
// the first) for visual confirmation: it's not yet known which of the // this hook are all region{1,2,4,5}_{foothills,desert,chicago,
// ~12 MapTrack instances that fire through this hook correspond to // newyork}_track{1,2,3} career-progression placeholders - none of
// the handful of on-screen streets the player actually sees - the 3 // them correspond to the currently-visible/playable on-screen
// visible streets each showed a real event already, so their own // streets. A live AddEvent-level trace confirmed those visible
// FlowSetLayoutScreenEvent may be dispatched through a different path // streets never call AddEvent during normal play at all (their
// this hook doesn't intercept. Broadening to "every pin" for this // events are most likely already baked into the save data rather
// pass answers that empirically instead of guessing further. // than resolved through this runtime prefab-cache path), so
if (kInjectSyntheticEvent) { // kTargetGroupName can only currently target one of the
InjectSyntheticEvent(mapTrackThis); // career-progression slots, not an arbitrary on-screen street name.
// See PROGRESS.md 2026-08-08 (cont. 9) for the full investigation.
if (kInjectSyntheticEvent && groupName && strcmp(groupName, kTargetGroupName) == 0) {
InjectSyntheticEvent(mapTrackThis, kSyntheticEventName);
} }
} }