ANALYSIS.md/ARCHITECTURE.md/PROGRESS.md and the rest of this project's living docs have always lived one directory above this repo's root (NFSMW_Online_Claude_workdir/*.md), so they were never actually part of this git history despite being the authoritative record of every hook, offset, and RE finding this branch's code is built on. Mirrors the docs/ layout already used on native-arm32-trace-harness so both branches reference the same file set by name, pending the actual code merge (see PROGRESS.md's own "Repo merge pending" note). Co-Authored-By: Claude <noreply@anthropic.com>
300 lines
14 KiB
Markdown
300 lines
14 KiB
Markdown
# Closed beta: diagnostics, crash reports and tester workflow
|
|
|
|
Plan for shipping a preview build to a small group of testers and getting back
|
|
reports we can actually act on. Written 2026-09-21, the day the first playable
|
|
build appeared.
|
|
|
|
Decisions already taken (owner's call):
|
|
|
|
- **Delivery: a "send report" button using the system share sheet.** No backend,
|
|
no background upload. The tester sees the file and sends it. This keeps us out
|
|
of collecting data from other people's devices, which would otherwise need
|
|
consent handling, storage and a retention policy.
|
|
- **Scope: crashes and bug reports, performance counters, unhonoured shim
|
|
contracts, device model and OS version.**
|
|
|
|
## Why this needs building at all
|
|
|
|
Everything the engine currently reports goes to `__android_log_write` and
|
|
nowhere else (`util.cpp`'s `Log()`). That fails for beta in three separate ways,
|
|
each of which already bit us during development:
|
|
|
|
1. **logcat wraps.** Twice on 2026-09-21 a measurement was lost to it - once the
|
|
whole startup sequence, once the frame-counter history, which made a
|
|
frame-rate figure look far better than it was.
|
|
2. **Testers cannot retrieve logcat.** It needs a cable and a developer setup.
|
|
3. **A hang produces no log at all.** The black-screen bug that day left the
|
|
process alive and silent; the only evidence was three lines that had already
|
|
scrolled past.
|
|
|
|
## Part 1 - what the build must produce
|
|
|
|
### 1.1 In-memory log ring (the foundation)
|
|
|
|
`Log()` keeps writing to logcat, and additionally appends into a fixed-size
|
|
in-memory ring buffer (suggest 2 MB, tunable). **Nothing is written to disk
|
|
during normal play** - the log rate is high (per-frame GLES sampling alone
|
|
produces thousands of lines) and per-line file I/O would show up in the frame
|
|
time we just spent the day reducing.
|
|
|
|
The ring is allocated once at startup. It must be usable from a signal handler,
|
|
so: a plain pre-allocated byte array, no `malloc`, no locks that a crashed
|
|
thread might hold. A per-record sequence number plus a short spinlock-free
|
|
write is enough; a torn last record in a crash dump is acceptable.
|
|
|
|
### 1.2 Native crash handler
|
|
|
|
A handler for `SIGSEGV`, `SIGBUS`, `SIGABRT`, `SIGILL`, `SIGFPE` that writes a
|
|
report file and then chains to the previous handler so the normal tombstone
|
|
still happens.
|
|
|
|
**Async-signal-safety is not optional here and we have a recorded incident:**
|
|
calling `mmap()` inside a `SIGSEGV` handler has deadlocked on bionic in this
|
|
project before. The handler must use only a pre-allocated buffer, a
|
|
pre-opened-or-`open()`ed fd, and `write()`. No `malloc`, no `stdio`, no `Log()`,
|
|
no C++ allocation, no locks.
|
|
|
|
What to capture - this is the part specific to a translation layer, and it is
|
|
what made today's crash solvable in one reproduction:
|
|
|
|
- **The decoded guest address.** With the flat mapping, a host fault address is
|
|
`flat_map_base + guest_address`. On 2026-09-21 the fault at `0x6f464c459b`
|
|
decoded to guest `0x464c459b` by subtracting `x28`. The handler should do that
|
|
subtraction itself and print the guest address, since nobody reading a report
|
|
will do it by hand.
|
|
- **Which arena the guest address belongs to.** `guest_engine.cpp` already has
|
|
the classifier (`"thread-stacks arena"` and friends) - reuse it. "Fault in the
|
|
heap arena" and "fault 1.2 GB past the end of everything" are completely
|
|
different bugs and the report should say which.
|
|
- Full guest register set, and the host registers from `ucontext`.
|
|
- The last N KB of the log ring.
|
|
- Build stamp and device identity (below).
|
|
|
|
### 1.3 Hang detector
|
|
|
|
The black-screen bug was a **hang, not a crash** - no signal, no tombstone,
|
|
process alive at 2% CPU. A crash handler would have caught nothing.
|
|
|
|
A watchdog thread checks the `onDrawFrame` counter. If it has not advanced for
|
|
~10 seconds while the activity is resumed, it writes the same report the crash
|
|
handler would, tagged `HANG`, including a snapshot of every thread's state and
|
|
stack pointer. It should fire **once** per hang, not repeatedly.
|
|
|
|
### 1.4 Unhonoured-contract registry
|
|
|
|
Today's crash was found because a shim logged that it could not honour a
|
|
request, one line above the fault. That should be a first-class, structured
|
|
record rather than a log line we grep for.
|
|
|
|
A small registry: `ReportUnhonouredContract(area, detail)`, deduplicated by
|
|
string, counting occurrences. `rtti_shims.cpp`'s `use_facet`,
|
|
`jni_shim.cpp`'s silent zero-returns (task #51) and every other
|
|
"returning NULL because we do not implement this" path calls it. The report
|
|
carries the full deduplicated list.
|
|
|
|
This turns beta into a **gap-discovery mechanism**: the union of these lists
|
|
across testers is a prioritised work queue for what the game actually needs,
|
|
discovered from real play instead of guessed.
|
|
|
|
### 1.5 Session counters
|
|
|
|
Sampled once a second into a compact rolling summary, not one line per sample:
|
|
|
|
- frames per second - min, median, 10th percentile, and where the low ones
|
|
happened (which is what the median alone hides, as it did today)
|
|
- time from launch to first frame, and each level-load duration
|
|
- CPU time consumed by the process, and by the GLThread specifically
|
|
- peak CPU temperature
|
|
- guest heap: live, peak, arena exhaustion events
|
|
- thread-stack arena: peak in use, exhaustion events (the black-screen cause -
|
|
this must never again be discovered by reading a log tail)
|
|
|
|
### 1.6 Build and device identity
|
|
|
|
Every report starts with: build stamp (version plus a short git hash or build
|
|
timestamp baked in at compile time), device model, SoC, Android version, ABI,
|
|
available RAM, and screen size.
|
|
|
|
The build stamp matters more than it sounds. Twice on 2026-09-21 the wrong APK
|
|
was nearly measured - once a three-week-old release build installed by mistake.
|
|
With testers there is no chance to check by hand; the report must say which
|
|
build produced it.
|
|
|
|
### 1.7 The share button
|
|
|
|
A screen reachable from the pause menu: **"Report a problem"**. It bundles the
|
|
newest reports plus the current log ring into a single zip in the app's own
|
|
files directory and hands it to `ACTION_SEND`.
|
|
|
|
Before sharing it shows the tester a short plain-language summary of what is in
|
|
the file - log lines, device model, no personal data, no game account details.
|
|
They are sending it themselves; they should know what it is.
|
|
|
|
If the previous session ended in a crash or hang, offer to send that report on
|
|
the next launch, since the tester will not go looking for it.
|
|
|
|
## Part 2 - the tester-facing report form
|
|
|
|
Free-text bug reports from testers are usually unusable not because testers are
|
|
careless but because nobody told them which three facts matter. Keep it short -
|
|
a long form gets skipped.
|
|
|
|
**In-app, attached automatically:** build stamp, device, the log bundle. The
|
|
tester never types any of this.
|
|
|
|
**What we ask the tester for, in this order:**
|
|
|
|
1. **What were you doing?** One line. "Entered a race from the city map."
|
|
2. **What happened?** One line. "Black screen, music kept playing."
|
|
3. **What did you expect?** Only when it is not obvious.
|
|
4. **Can you make it happen again?** Every time / sometimes / happened once.
|
|
This single question decides whether we can chase it at all.
|
|
5. **Did you play for a while before it happened?** Yes/no. Specifically
|
|
included because the whole class of resource-exhaustion bugs - the
|
|
thread-stack arena, the guest heap - only shows up after a long session, and
|
|
testers do not think to mention it.
|
|
|
|
**Severity, defined by consequence rather than by feeling**, so it is not
|
|
argued about:
|
|
|
|
- **Blocker** - cannot continue playing; progress lost.
|
|
- **Major** - a feature does not work, but the session survives.
|
|
- **Minor** - visual or audio defect, gameplay unaffected.
|
|
|
|
**Ask them explicitly to send the report even when the game recovers.** A hang
|
|
that resolved itself still wrote a `HANG` report, and that is often the easier
|
|
one to diagnose.
|
|
|
|
## Part 3 - what we do with reports
|
|
|
|
Triage order, informed by what has actually been expensive to find:
|
|
|
|
1. **Unhonoured-contract list first, before reading the crash.** Today the
|
|
answer was in that list. It is cheap to check and frequently decisive.
|
|
2. **Decoded guest address and its arena.** Distinguishes a wild pointer from
|
|
arena exhaustion from a real logic bug, without any further work.
|
|
3. **Exhaustion counters.** If a thread-stack or heap arena hit its ceiling, the
|
|
crash is a symptom and the ceiling is the bug.
|
|
4. **Only then the register dump and the log tail.**
|
|
|
|
Group reports by build stamp before comparing anything. Mixing builds is how a
|
|
fixed bug looks like it is still present.
|
|
|
|
## Suggested order of work
|
|
|
|
Each step is independently useful, so the beta does not wait on the whole set.
|
|
|
|
1. Log ring + report file + share button, with device and build identity.
|
|
**Minimum shippable** - a tester can send something useful.
|
|
2. Crash handler with guest-address decoding.
|
|
3. Unhonoured-contract registry, with `use_facet` and the JNI zero-returns as
|
|
the first callers.
|
|
4. Hang detector.
|
|
5. Session counters.
|
|
|
|
## Deliberately out of scope
|
|
|
|
- **No backend, no automatic upload.** Chosen above. Revisit only if the manual
|
|
path proves too lossy in practice.
|
|
- **No unique device or user identifier.** Grouping by build stamp and device
|
|
model is enough at this scale and avoids tracking individuals.
|
|
- **No gameplay telemetry** - what cars, which races, how long played. It is not
|
|
needed to fix defects, and collecting it would change what this file is.
|
|
|
|
---
|
|
|
|
## Status 2026-09-21: the crash handler is built (plan section 1.2 + 1.7 partial)
|
|
|
|
Implemented and verified end to end on the Pixel 6a.
|
|
|
|
**Native** (`mpcore/src/main/cpp/crash_handler.cpp`). Hooks SIGSEGV, SIGBUS,
|
|
SIGABRT, SIGILL, SIGFPE with `SA_SIGINFO | SA_ONSTACK`, on a pre-allocated
|
|
alternate stack so a stack-overflow crash is still reportable. Everything the
|
|
handler needs - the output path, the build stamp - is built at install time;
|
|
inside the handler only `open`/`write`/`close` and hand-written integer
|
|
formatters run. No malloc, no snprintf, no JNI. It chains to the previous
|
|
handler afterwards, so Android still writes its own tombstone.
|
|
|
|
The report decodes the fault address: a host address inside the guest window is
|
|
also printed as the **guest** address, and flagged when it is past the end of
|
|
the mapped region ("a wild pointer, not a real guest object"). That is the
|
|
number worth reading, and nobody will subtract the base by hand from a tester's
|
|
report.
|
|
|
|
**Java**. `CrashReportActivity` renames the pending report (the handler writes a
|
|
fixed name, since it cannot safely format a timestamp), zips it with device and
|
|
build details, shows it, and offers ACTION_SEND through a FileProvider scoped to
|
|
the crash directory only. Reports live in
|
|
`Android/data/<pkg>/files/crashes` - reachable over USB with no permission.
|
|
|
|
**Verified**: handler installs; `kill -11` produces a report with the right
|
|
signal, registers and a correct "outside the guest window" verdict; the next
|
|
launch detects it, renames it, builds the zip, and `CrashReportActivity` becomes
|
|
the resumed activity. Files land where intended.
|
|
|
|
**Not verified**: what the screen actually looks like. The test device locked
|
|
itself, so every screenshot was of a sleeping or locked display - which is also
|
|
why an early "black screen" reading was wrong and led to one unnecessary fix
|
|
(explicit colours, harmless and kept). The layout needs a human to unlock the
|
|
phone and look.
|
|
|
|
### Three failures on the way, all worth keeping
|
|
|
|
1. **Installed too early.** The call sat at the top of `onCreate`, but
|
|
`libmpcore.so` is only loaded later by `loadCore()` -
|
|
`UnsatisfiedLinkError`, caught and logged. Moved to immediately after
|
|
`loadCore()`.
|
|
2. **Missing `extern "C"`.** The JNI function was C++-mangled
|
|
(`_Z65Java_...`), so the JVM could not find it. The symptom was identical
|
|
to the load-order bug above, which cost a wrong fix before the symbol table
|
|
was actually read.
|
|
3. **Blocked activity start.** Checking for a pending report in
|
|
`GameActivityMain.onCreate` - which starts the report screen and finishes
|
|
itself - was refused by the platform (`BAL_ALLOW_GRACE_PERIOD`) and dumped
|
|
the tester on the home screen. The check belongs in `PermissionsActivity`,
|
|
the visible launcher entry.
|
|
|
|
---
|
|
|
|
## Status 2026-09-22: game data ships inside the APK
|
|
|
|
A tester now installs one file and plays. No separate .obb download, no file
|
|
manager, no instructions about where to put anything.
|
|
|
|
**How.** The ~595 MB archive ships as `assets/game_data.obb`, and
|
|
`androidResources { noCompress += "obb" }` keeps it **stored** rather than
|
|
deflated - it is already compressed, so re-compressing would cost build and
|
|
install time for nothing. On first launch `GameDataUnpackActivity` copies it to
|
|
`getObbDir()/main.<versionCode>.<package>.obb`, which is exactly the path
|
|
`GameActivityMain.obbFullPath` already builds, so no other code knows this
|
|
happened.
|
|
|
|
The copy writes to a `.part` file and renames only on success. A half-written
|
|
archive that merely *exists* would pass a naive check and send the game off to
|
|
read truncated data - failing far from the cause, which is the failure mode this
|
|
project keeps paying for. Free space is checked before starting rather than
|
|
500 MB in.
|
|
|
|
**Measured on the Pixel 6a**, with the real OBB renamed aside to simulate a
|
|
clean device:
|
|
|
|
| | |
|
|
|---|---|
|
|
| APK size | 615 MB (was 22 MB) |
|
|
| build time | 16 s - aapt2 handles the stored asset without trouble |
|
|
| `adb install` | 31 s |
|
|
| unpack | under 8 s - it finished before the first progress poll |
|
|
| result | **md5 identical** to the original OBB |
|
|
| game afterwards | 2,893 frames, 0 faults, `mAssetLocationType=OBB` |
|
|
|
|
**Costs worth stating.** The device needs the APK plus the unpacked copy at
|
|
once: about 1.2 GB free at install time, ~600 MB after. And the data exists
|
|
twice on disk permanently, since Android keeps the APK.
|
|
|
|
**The alternative not taken.** Because the asset is stored uncompressed, its
|
|
bytes sit contiguously in the APK, so the engine's own `Shim_open`/`Shim_read`
|
|
could serve the OBB path straight out of the APK at an offset - no copy, no
|
|
duplication. That is a real option if the 600 MB ever matters, but it adds a new
|
|
failure surface in file I/O right before a beta, and the ask here was explicitly
|
|
for self-extraction.
|