A Xiaomi 14 capture showed viewport 2136x960 against a 2670x1200 surface and
raised the question of whether the engine was feeding the game a wrong size -
the shape of task #39, where the UI projection used 2000x1000 instead of the
real 2400x1080.
Logging each DISTINCT viewport with its bound framebuffer and the real EGL
surface size answers it. Pixel 6a:
VIEWPORT [0,0 2400x1080] fb=0 | EGL surface 2400x1080 <- screen, exact
VIEWPORT [0,0 1920x864] fb=3 | EGL surface 2400x1080 <- an FBO
VIEWPORT [0,0 512x512] fb=1 | EGL surface 2400x1080 <- another FBO
The default framebuffer matches the surface exactly; the smaller viewport is
an offscreen target. And the ratio is decisive:
Pixel 6a 1920/2400 = 0.80 864/1080 = 0.80
Xiaomi 14 2136/2670 = 0.80 960/1200 = 0.80
Exactly 0.8 on both devices and both axes - the game's own render scale,
drawing the 3D scene into a reduced FBO and upscaling while the HUD stays at
full resolution. Nothing is broken; the engine reproduces it faithfully.
The probe stays in the tree behind kLogViewportChanges (default off, per the
standing rule that diagnostics are opt-in), because "which framebuffer was
bound" is exactly the context whose absence made the original observation
ambiguous.
Co-Authored-By: Claude <noreply@anthropic.com>
CreateConfiguredEngine mapped the whole guest region with one
uc_mem_map_ptr(UC_PROT_ALL). UC_PROT_ALL includes EXEC, and QEMU's
notdirty_write (qemu/accel/tcg/cputlb.c:1199) does this on every write:
mr = uc->memory_mapping(uc, ...); // region lookup
if (mr && (mr->perms & UC_PROT_EXEC) != 0) {
page_collection_lock(...);
tb_invalidate_phys_page_fast(...);
}
So marking memory executable makes EVERY WRITE to it pay a region lookup
plus a translated-block invalidation check. With the whole region
executable, ordinary stack and data writes all took that path.
Found by elimination rather than guesswork. Two counters were written and
both answered zero: writes into .text (across two sessions) and writes into
the trampoline/stub arenas. Since no guest code and no engine-written stub
bytes were dirtying code pages, the writes driving tb_invalidate had to be
ordinary data writes that merely lived in an executable mapping.
The first split (heap RW, everything above heap_end_ RWX) moved the three
symbols by 0.5 points - i.e. nothing - because it left THREAD STACKS
executable. The heap is 768MB, but stores go overwhelmingly to the stack,
once per call frame. "Biggest region" and "most written region" were not the
same region.
Final layout: image RWX; heap RW; trampoline+stub arenas RWX; control and
thread stacks RW; mmap arena RWX (LoadSecondaryImage maps real code there).
tb_invalidate_phys_page_fast_arm 4.52% -> 0.04%
time to first OnCarLoaded, 3 runs each:
single RWX 40.95 / 39.06 / 38.96 s (mean 39.66)
split 36.31 / 35.87 / 36.88 s (mean 36.35)
Ranges do not overlap: loading is 8.3% faster. Verified end-to-end with an
A/B switch rather than inferred from the profile share, because task #48
established that a share drop is not a speedup. Zero FETCH_PROT/WRITE_PROT
faults, so nothing executes from the now-non-executable spans.
flatview_translate_arm (5.25%) and find_memory_mapping_arm (2.99%) barely
moved, so they have callers beyond notdirty_write - task #53 stays open.
Co-Authored-By: Claude <noreply@anthropic.com>
CallGuestFunction passed count=5000000 to uc_emu_start. Any non-zero count
makes Unicorn install a GLOBAL UC_HOOK_CODE internally (uc.c:1202):
uc_hook_add(uc, &uc->count_hook, UC_HOOK_CODE, hook_count_cb, NULL, 1, 0)
begin=1 > end=0, which HOOK_BOUND_CHECK treats as matching EVERY address. So
helper_uc_tracecode, a gen_set_pc_im PC sync, and check_exit_request were
emitted and executed for EVERY GUEST INSTRUCTION - not per shim call.
That is what the in-race profile had been showing:
helper_uc_tracecode 34.54%
helper_check_exit_request_arm 5.33%
against 0.04% in MiscStubDispatch at ~65,000 stub dispatches/sec, which
worked out to an impossible ~5.4us per dispatch. The arithmetic never fit
because the divisor was wrong: tracecode was per instruction, not per
dispatch.
It also explains task #48's null result. Removing 17 probe hooks shortened
the hook LIST but left this global entry in it, so the per-instruction call
and the per-instruction PC sync - which also prevents TCG from chaining
blocks - both remained.
Measured on the Pixel 6a with the race's own clock, same method throughout:
helper_uc_tracecode 34.54% -> absent from the top symbols
stub dispatches 65k/s -> 76-93k/s (more guest work done)
game time vs wall clock 3.99x -> 2.73x
27s of wall time advanced the race timer 30.13 -> 40.02 (9.89s); the
sub-intervals gave 2.75x and 2.71x. Today's full progression is 7.2x ->
3.99x -> 2.73x.
Trade-off, taken deliberately: a runaway guest loop inside one call now
hangs instead of returning after 5M instructions to be caught by
kMaxCallIterations. That net was already documented in this function as "not
a security boundary", and since the 2026-09-05 stub fix a normal call
completes in a single uc_emu_start anyway. A future watchdog should be one
long-lived thread calling uc_emu_stop, NOT uc_emu_start's `timeout`
argument, which spawns a thread per call (uc.c:1036).
Also turns off the two temporary counters (kCountStubDispatches,
kCountTextWrites) added to find this; the .text write counter answered its
question with a flat zero, killing the self-modifying-code theory behind
tb_invalidate_phys_page_fast_arm.
Co-Authored-By: Claude <noreply@anthropic.com>
Channel::isPlaying answered FALSE for a channel System::playSound had just
handed out with a success code. The guest polled it, concluded the track
had finished, and started the next one - forever. Each cycle reopened the
OBB asset filesystem ON THE GLThread, stalling frames directly.
Found from a user observation ("треки меняются очень быстро в меню паузы"),
then measured: a different track roughly every 1.5s, picked
non-sequentially (DEAD SARA, DEAD SARA, GREEN DAY, SKRILLEX, GREEN DAY,
CROSSES, ICONA POP, JOY FORMIDABLE), so not a walk through playlists.sb but
a fresh selection each time.
Now tracks the channels playSound hands out and reports those as playing
until Channel::stop drops them, so normal transitions still work. A
one-time log states plainly that there is no audio backend and playback is
simulated, instead of leaving that only in a comment.
Measured on the Pixel 6a with the race's own clock, same method both times:
game time vs wall clock 7.69x -> 3.99x
useAssetsFileSystem 1.00/s -> 0.00/s (in-race, fully stopped)
faults 0 -> 0
27s of wall time advanced the race timer 18.82 -> 25.58 (6.76s); the two
sub-intervals gave 4.18x and 3.81x.
Third instance today of one failure shape: a shim reporting SUCCESS while
handing back an empty or negative value. See AndroidBitmap_lockPixels
(task #41) and the synthetic-swap premise (task #47).
Co-Authored-By: Claude <noreply@anthropic.com>
De-registers every diagnostic probe belonging to closed tasks #35, #39 and
#41; the functions stay defined, matching this file's existing convention.
Registrations drop from 20 to 9. What remains is functional only:
MiscStubDispatch, FnvHashAccelHookCb, five zlib_accel hooks, two trampoline
hooks.
Seven of the removed probes sat on the TEXT rendering path (QuadGeometry at
four addresses, TextClipDispatch at two, MeasureAdvance at two), so they
fired once per HUD glyph per frame - continuously during a race.
Motivation was the first in-race profile: helper_uc_tracecode at 63.03% of
CPU. Unicorn's helper (uc.c:2129) walks the entire UC_HOOK_CODE list on
every hit with no early exit, and a hooked instruction also breaks the
translation block.
RESULT - the hypothesis is REFUTED as a performance fix:
helper_uc_tracecode 63.03% -> 41.66%
game time vs wall clock 7.2x -> 7.69x (no improvement)
Measured with the race's own clock: 27s of wall time advanced the race timer
14.97 -> 18.48, i.e. 3.51s; the two sub-intervals gave 7.78x and 7.60x. So
hook-list length was NOT the binding constraint - the freed time simply
redistributed, visible in neighbouring shares rising
(tb_invalidate_phys_page_fast 1.19->3.15%, helper_lookup_tb_ptr 1.58->3.01%,
JIT "unknown" 7.17->9.98%).
Caveat: the before/after races are different sections with different scene
load. That cannot hide a difference of this size, but it is not a controlled
comparison either.
Kept anyway: removing spent diagnostics is worth doing on its own terms, and
a profile no longer dominated 63% by one symbol is readable.
Co-Authored-By: Claude <noreply@anthropic.com>
The engine synthesized an eglSwapBuffers on each detected frame boundary.
That duplicated the present Android's GLSurfaceView already performs after
every onDrawFrame return, so every frame reached the screen twice - the
second time from a buffer still holding the PREVIOUS frame.
The whole mechanism rested on a premise recorded 2026-09-18 and never
verified live: that nativeOnResume runs the guest's persistent loop on the
GLThread and never returns, leaving the framework unable to present. An
entry/exit counter added to GameRenderer.onDrawFrame shows that is false -
it returns every frame (entries=698 exits=697, the gap being the call in
flight). Rates match 1:1:
synthetic swaps 12.98/s (SWAPMARK, one line per swap)
onDrawFrame 12.69/s (so the framework swapped 12.69/s too)
The user saw this as the car jumping forward then back along its path.
Frame-by-frame the race clock stepped 1:10.06 -> 1:10.03 -> 1:10.09 ->
1:10.06, with presents arriving in pairs 10-20ms apart at a 218ms period -
exactly the synthetic swap period.
Letting the framework present is also what the real game does: libapp.so
cannot present at all (only EGL import is eglGetProcAddress; the string
"eglSwapBuffers" is absent from the binary), and on the A9 all 2,736
eglSwapBuffers calls in 46s came from Android's own framework.
Measured after, on the Pixel 6a:
frame interval median 89ms -> 17ms
p90 217ms -> 19ms
max 316ms -> 24ms
synthetic swaps 0 (verified, not merely absent from view)
onDrawFrame 12.69 -> 12.39/s (throughput deliberately unchanged)
User verdict: "Сейчас очень плавно". Throughput is untouched - this fixes
presentation, not the ~7x simulation deficit.
Kept behind kSynthesizeSwap rather than deleted: the 2026-09-18 symptom it
was built for (screen cycling between stale loading screen, black, and the
real scene) was diagnosed under the false premise above, so its actual
cause is still open.
Co-Authored-By: Claude <noreply@anthropic.com>
Shim_AndroidBitmap_lockPixels handed the guest a NULL pixel pointer
whenever the real buffer lived outside the guest region, on the
documented assumption that "texture decode FROM a Bitmap ... reads
pixels via other means, not by writing through this specific pointer".
Measurement disproved that assumption. The glyph atlas path is exactly
a read through this pointer:
AndroidBitmap_lockPixels rc=0 hostAddr=0x... -> guest=0x0
bitmap 1024x1024 stride=4096 format=1 (RGBA_8888)
1024x1024 is precisely the atlas size independently derived from the
glyph UVs (u1 = 35/1024 for a 35px glyph), which is why glyph
positions, sizes, pen advance and UVs all measured correct while the
pixels were coloured noise: BitmapGraphics rendered the glyphs fine,
the guest asked for them, got nothing, and uploaded guest address 0
as the font texture.
Fixed with a bounce buffer - real pixels copied into a guest-visible
buffer on lock, copied back and freed on unlock. The copy-back also
closes the write direction the original comment called a known gap.
Costs one height*stride copy each way, a handful of times per session.
Verified in both directions on the Pixel 6a: text renders correctly,
and the added "no bounce buffer was made" warning fired zero times,
so every lock genuinely got a buffer rather than silently falling
back to the old NULL path.
Co-Authored-By: Claude <noreply@anthropic.com>
First piece of actual verification infrastructure, rather than
symptom-driven debugging. Off by default (kHeapDebugChecks), so a normal
build is byte-for-byte the same arithmetic as before: kRedZoneBytes is 0 and
every guard branch folds away at compile time.
With checks on:
* each block carries a 16-byte guard immediately after the bytes the caller
asked for - deliberately not at the end of the block, where slack left by
a reused larger block would hide a small overflow - verified on Free and
reported with the offending block's address and size;
* freed payloads are poisoned, which matters more since Alloc stopped
zeroing: without it a freed block keeps plausible stale contents and a
use-after-free silently keeps "working".
The check is deliberately not fatal. A loud, precise report is the point;
aborting mid-run would make the engine harder to debug than the corruption
being reported.
BlockHeader's former alignment-only `pad` field now carries the user-visible
size, maintained whether or not checks are on, so BlockSize() reports what
the caller actually got and realloc copies the right amount.
Verified in both directions before trusting it: a deliberate one-byte
overflow was caught and attributed to the right block ("guard byte 0 of 16
is 0x41, expected 0xbe"), while a clean neighbouring block produced no
report. Then a full run to the race with checks on: zero violations, zero
rejected frees, zero faults. That is partial evidence - the guard is only
checked when a block is freed, so blocks that live to process exit are never
examined - but it is the first evidence of its kind this project has.
Co-Authored-By: Claude <noreply@anthropic.com>
sub_4F3704 (resource lookup by name) is 18.8% of load-time samples and does
a linear strcmp scan over 6232 entries because the game's own hash cache is
disabled (*(self+8) == 0, confirmed by a live probe: 320,000+ calls per load).
Attempt 1 crashed the process: guest strings were read through G2H and
scanned for a NUL with no bounds check, so a bad offset walked off the end
of the mapped region (SIGSEGV, SEGV_ACCERR at a host address).
Attempt 2 was memory-safe (uc_mem_read everywhere, length and count caps,
every cache hit verified against live guest memory, falls through to the
guest on any doubt) but caused a visible frame-rate drop on the prologue
loading screen. The cause was a design flaw the earlier probe log had
already shown and I misread: the cache was keyed on the table ADDRESS, yet
this game reuses one address for different tables (35 and 59 entries
alternating in the log). The descriptor comparison therefore marked the
index stale on nearly every call, and each rebuild re-read all ~6232 entry
strings byte-by-byte - far more work than the scan it replaced.
Kept in the tree, unregistered, with both mistakes documented. The fix for a
third attempt is to key the cache on the DESCRIPTOR CONTENTS rather than the
address, so alternating tables each keep their own index, plus bulk string
reads instead of per-byte.
Co-Authored-By: Claude <noreply@anthropic.com>
Verified live on a Pixel 6a: the game passes its EULA, loads the prologue
and renders real 3D gameplay, with zero heap exhaustion, zero faults and
zero rejected frees over a full session.
Root causes fixed in this state, each backed by a measurement (details and
the list of refuted theories live in ARM64_TRANSLATION_LAYER.md):
* JNI varargs float->double promotion. C promotes float to double in any
varargs call and every Call*Method form is varargs, so reading one 4-byte
slot yielded the double's always-zero low half. EVERY float argument
passed to Java was silently becoming 0; text was just where it showed.
* GuestHeap ~4x memory overhead. Power-of-two size classes carving the full
class, plus segregated free lists that could never share memory between
sizes. Reworked to exact sizing with O(log n) best-fit reuse and splitting
(deliberately not a linear scan - this allocator already had an O(n) perf
cliff in its history). Peak live now 207MB against the real A9's 199MB,
fragmentation ~2.5MB. Also fixed: realloc reading past the old block on
shrink, a 32-bit overflow in calloc, and drifting payload alignment.
* Unbounded FMOD fake-handle leak into the never-freeing permanent arena,
which is why enlarging that arena had not helped.
* Frame presentation, corrected against A9 ground truth: the real frame has
three default-framebuffer colour clears and ONE present at the end; this
engine had been presenting on each of them.
Load-time acceleration (zlib_accel.cpp): host zlib now serves inflate and
crc32, the latter measured by the block profiler as the single hottest guest
routine at 17.7%. Streams are only taken over when this layer saw their own
inflateInit2_, so unknown streams (libpng's, among others) still run the
original emulated path.
name_lookup_accel.cpp is present but its hook is NOT registered - it crashed
on bad assumptions about guest table lifetime and is kept as a starting
point, with both mistakes recorded in its comments.
Co-Authored-By: Claude <noreply@anthropic.com>
GuestHeap previously bump-allocated any request >64KiB and never
reclaimed it on free() - a real, sustained gameplay session eventually
exhausted the arena (traced live to two separate crashes: onCreate
never completing, and a NULL-vector dereference in sub_4BA588 caused
by a failed ~175KB allocation going unchecked by the real EA code).
Widen the existing O(1) size-class free list (already used for <=64KiB
requests) to cover the whole arena instead of adding a second reuse
mechanism next to it - oversized allocations now pool and reclaim the
same way small ones already did. Add matching desktop tests
(guest_heap_test.cpp, run via run_heap_tests.sh) covering the exact
failing size from the crash log and a sustained alloc/free cycle in an
arena too small to survive without reclaim.
Verified live on device: the crash is gone across a full 10-minute
session with sustained GLES rendering throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Hardcode car class enum for lobby car selection (fixed, small set by design)
- Fix GetCurrentCarColor() crashing on an unvalidated color index from an
unzeroed hashmap-miss fallback record on a genuinely fresh save
- Default kEnableTrackSubstitutionHook to false: it substituted every race's
track unconditionally, crashing the game's own scripted prologue race
- Remove GetComponentNameSkipHook from JNI_OnLoad: it unconditionally
replaced a real name used by the same cache-context the per-event medal
progress record resolves through, silently breaking medal/street-completion
tracking for every real race
- Split lan_event_injection.h (3149 lines) into car_selection.h,
crash_workarounds.h, mod_slot_tracking.h, and a shared
util/hook_install.h trampoline helper; delete ~490 lines of confirmed-dead
experimental code; trim comment-heavy sections to essential context
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes the synthetic car_select jump for cold sessions, makes the loadout
exit chain safe for real (non-synthetic) events, and adds a native->Kotlin
GameEvents bridge (onMapLoaded/onRaceStarted/onRaceEnded/onUpgradesAccepted/
onCarSelected) so both the UI layer and a future native RatNet client can
learn what the player picked - car id, accepted upgrades, and paint color
(name + RGBA) are all resolved live from the game's own engine state
rather than a static extracted table, so they stay correct for any car
added later. Includes a Jetpack Compose overlay as a worked example of a
UI-side GameEventListener consumer.
Full investigation history, root causes, and the several dead ends ruled
out along the way are documented in PROGRESS.md (cont. 30-63b).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
Lets the track-substitution hook be enabled/disabled by flipping one
constexpr bool in main.cpp instead of commenting/uncommenting code, so
before/after comparisons don't need back-and-forth edits.