Skip to content

Findings

Cordial docs edited this page Aug 1, 2026 · 1 revision

Mirrored from docs/findings.md in the repository, which is the canonical copy. If this page and that file disagree, the file is right.

Bootstrap findings

Session: §16 bootstrap analysis Date: 2026-07-31 Status: Tasks A, B, C, D complete. All §16.6 deliverables exist. Phase 0 done (base-evaluation.md). Phase 1 has begun — libroblox.so now loads and initialises; see §8.


1. Task A verdict — no CPU architecture translation is required

Roblox ships a complete x86-64 Android build. On x86-64 hosts, Cordial executes Roblox's own native code directly. Only CPU feature gaps require emulation.

This is the tractable outcome described in §16.2. Phase 1 is a loader, a bionic shim, syscall translation and a framework layer — not a dynamic binary translator. The project does not need to be re-scoped.

Confidence: certain. This was verified directly against the shipping APK (§1.1), and five further lines of evidence agree. The one source that disagrees is internally contradicted by its own artifacts (§1.2).

1.0 Direct verification — the §16.2 check, run

Roblox for Android 2.732.1043 (versionCode 2814, minSdk 26, targetSdk 35), pulled over adb from a Waydroid instance where Google Play itself served the x86_64 split. dumpsys package com.roblox.client reports primaryCpuAbi=x86_64.

Split-APK delivery. base.apk (98.0 MB) contains no lib/ directory at all; all native code is in split_config.x86_64.apk (52.8 MB), whose only ABI directory is lib/x86_64/, containing 11 objects:

115995376  lib/x86_64/libroblox.so          <- the entire engine
  5896832  lib/x86_64/libbacktrace-native.so
   726088  lib/x86_64/libzstd-jni-1.5.7-6.so
   388520  lib/x86_64/librenderscript-toolkit.so
   249184  lib/x86_64/libeigen_blas.so
      ... 6 more, all small JNI helpers

libroblox.so is 116 MB of x86-64 machine code, and file identifies it as ELF 64-bit LSB shared object, x86-64, for Android 26, built by NDK r28c, stripped.

That is not a stub. It is the game engine, compiled for x86-64 by a current Android NDK. Task A is settled: lib/x86_64/ present and complete.

1.1 The supporting evidence

(a) libbadcpu only makes sense if Roblox's own x86-64 code runs on the host CPU.

This is the decisive argument, and it is structural rather than circumstantial. libbadcpu.so installs a SIGILL handler, decodes the x86-64 instruction at the faulting RIP, emulates it, and advances the instruction pointer. It emulates POPCNT, MOVBE, LZCNT, TZCNT, ANDN, BLSI, BLSMSK, BLSR — all x86-64-v2 / BMI1 instructions — and its user-facing error string is This program requires a CPU with x86-64-v2 features.

A dynamic binary translator emits every byte of the code it runs. It would query CPUID once at startup and emit only instructions the host supports. Trapping SIGILL to retroactively patch up its own output would be incoherent. The only reading that fits is the obvious one: the x86-64 machine code being executed was not generated by Sober. It is Roblox's own precompiled x86-64 code, built against an x86-64-v2 baseline, running directly on the host CPU — and on pre-v2 CPUs some of those instructions fault.

(b) libloader.so is a native ELF loader, not a translator. Per the reference repository's own analysis: parse ELF headers and program headers, mmap64 segments at their virtual addresses, mprotect to set protections, perform relocations, resolve DT_NEEDED, then jump to the mapped entry point. That is ld.so behaviour. Nothing in the described control flow builds, caches, or dispatches translated code blocks.

(c) VinegarHQ state it directly. The Sober FAQ, explaining why Quest is unsupported: "the Quest build does not ship with x86 code like Android does. Doing this have to resort with ARM64 to x86 translation, which is slow and problematic." The subordinate clause is the answer to Task A — the Android build does ship x86 code — and the main clause states that translation is a path they have deliberately not taken.

(d) Sober's stated CPU requirement is an application baseline, not a translator's. The FAQ requires SSE4.1 and SSE4.2. A translator would impose the requirements of its own code generator and would run on essentially any x86-64 CPU. A hard floor at x86-64-v2 is what you get when you execute a pre-built binary compiled to that baseline.

(e) Roblox publishes x86_64 Android variants. Public APK distribution listings show Roblox Corporation shipping x86_64, arm64-v8a + x86_64, and universal (arm64-v8a + armeabi-v7a + x86_64) variants, alongside ARM-only ones. Secondary descriptions of Sober consistently describe it as a runtime for "the Android x86_64 Roblox APK", which it downloads on first launch.

1.2 The contradicting source is wrong, and its own repository shows why

sober-oss/README.md asserts that Sober "runs the Roblox Android APK natively on x86 64 Linux via binary translation" and that sober "translates the Android ARM64 Roblox binary to run natively on x86 64". The stated basis is the presence of mcl/oaknut (an ARM64 assembler) and dyncall in the binary.

Two problems.

The claim is not supported by that repository's own evidence. Searching every strings dump it ships (strings -a -n 4, 142,791 lines across four binaries) for oaknut, dyncall, aarch64, arm64, armeabi, armv8 or neon returns zero genuine matches. The only mcl hits are substrings inside a concatenated symbol blob (...getrandomclock_gettime...). No ABI directory name — arm64-v8a, armeabi-v7a, x86_64 — appears anywhere. The BINARY_ANALYSIS.md "Embedded Libraries" list that names mcl/oaknut and dyncall cites no artifact in the repository.

Even if oaknut is linked, it does not imply ARM64→x86 translation. oaknut is an ARM64 emitter — it produces ARM64 machine code. That is what you need to run on an ARM host, not to run ARM code on x86. VinegarHQ's FAQ confirms an in-progress ARM host port ("We are currently experimenting with ARM support for Sober"). The spec's §16.2 hypothesis about the reverse-engineering author's inference appears to be exactly right, and the arrow was pointed the wrong way.

Treat sober-oss's architectural prose as unreliable. Its behavioural observations (syscalls used, strings present, library linkage) held up well against checking; its conclusions did not.

1.3 Nothing outstanding on Task A

The §16.2 check has been run in full (§1.0). Worth noting that the structural argument in §1.1(a) reached the right answer before the APK was available, and the APK then confirmed it exactly — the reasoning is sound and can be trusted on the next question of this shape.


2. Task B — multi-arch strategy

Decision recorded in multiarch.md. Summary: execute natively when the host ABI matches an ABI the APK ships; do not build a translation layer. x86-64 is the only supported target for Phases 1–2. ARM64 hosts are a later build-flag concern.


3. Task D — libbadcpu, built and vendored

Vendored at third_party/libbadcpu/ from sober-oss commit e48a905efdffa1ad49a3ebb873895bcff73aa935, MIT, attribution in third_party/libbadcpu/README.md.

Built as part of Cordial's native subtree (native/CMakeLists.txt), which Cargo drives via cordial-linker-sys's build script. Meson was dropped when the bionic linker arrived: that subtree is CMake-and-Clang-only, and two build systems for one repository was not worth the small independence libbadcpu had.

Honest scope, per §16.5 and §16.7. This is roughly 1% of Phase 1: eight emulated instructions behind a SIGILL handler, 904 lines including headers and tests.

Honest test coverage — weaker than "tests pass" implies. The suite is 85 lines with exactly two real assertions (SIGILL handler installs; SIGILL handler removes). The decode section prints its results and asserts nothing, and there is no test of emulation correctness at all — nothing executes a faulting instruction and checks the emulated register result against the hardware. The upstream commit message ("fix: correct REX/VEX decoding, popcnt /r check, 64-bit movbe, r8-r15 support") indicates the decoder has already had real bugs in exactly the areas that are untested.

It is not yet wired into the load path — nothing installs its SIGILL handler in the Roblox process. That happens when instances become real processes.

Before this is trusted in a shipping runtime it needs a differential test: for each of the eight instructions, execute it natively on a capable CPU, execute it through the emulator, and compare the full register file and flags. That is a small, well-defined task and a good first piece of real Phase 1 work.


4. Unexpected finding — there is open-source prior art for the hard part

§16.7 states "There is no open-source prior art for the hard part." That is too pessimistic, and the correction is load-bearing for scoping.

Sober's own attribution notice credits ChristopherHX and MCMrARM (MIT) plus the Android Open Source Project (Apache 2.0). Those are the authors of the minecraft-linux stack, which solves precisely Cordial's Phase 1–2 problem for a different Android app:

Component What it is Maps to
mcpelauncher-linker Android linker ported to Linux (MIT) Runtime — ELF loader
android_bionic bionic sources adapted for host use Runtime — bionic shim
libjnivm (ChristopherHX) a JNI virtual machine implementation Framework — JNI boundary
fake-jni JNI/framework stubbing Framework — API stubs

In other words, Sober is very plausibly this stack plus a graphics/audio/input layer and Roblox-specific work — which is consistent with everything observed and would explain how a small team got it working.

This does not make Phases 1–2 easy. Minecraft is not Roblox, the graphics and input paths are Cordial's own problem, and integrating a foreign linker is real work. But "port and adapt a working MIT-licensed Android linker and JNI VM" is a materially different — and smaller — task than "write an Android linker from scratch", and it should be the first thing evaluated when Phase 1 starts. Read those repositories before writing a line of loader code.


5. Task C — framework API inventory, populated

Full result in framework-api-inventory.md; raw enumeration output in analysis/. Phase 2 is now scoped. The four things that changed the picture:

(a) The runtime surface is small. 13 Android libraries in the DT_NEEDED union, 644 unique undefined symbols across all native objects — of which 490 are libc/libm/libdl. Vulkan is dlopened, not linked, so GLES2 + EGL is the mandatory graphics path and Vulkan is an optional upgrade. Audio is OpenSL ES, not AAudio. This is a bounded list, not an open-ended one.

(b) Roblox uses AGDK GameActivity. Java_com_google_androidgamesdk_GameActivity_initializeNativeCode is exported and the GameActivity and gametextinput classes are in the dex. GameActivity is Apache-2.0 open source, so the entire activity/surface/input/IME contract Cordial must satisfy is readable rather than inferred. This is the largest single de-risking of Phase 2 and it should shape the order of work.

(c) JNI registration is static. 693 Java_* exports (518 in libroblox.so), so the Java→native direction resolves by symbol name with no RegisterNatives table to recover. Notably com.roblox.client.flags.FlagJniInterfaceFastFlags are a documented JNI interface, not a config format to reverse-engineer.

(d) §15's communities question is answered, and the answer costs more than expected. There is no communities Activity. Communities is web content in com.roblox.client.RobloxWebActivity, a WebView-hosted Activity — which is why it becomes a separate window. Fixing it needs both activity/window management and a WebView implementation (WebView, WebViewClient, WebChromeClient, WebSettings, CookieManager, JavascriptInterface). Sober uses WebKitGTK for the analogous problem.

The WebView requirement is not in the architecture spec anywhere, and it is not optional — the captcha flow (ActivityFunCaptcha) is also a WebView, which puts it on the login path. Spec §4.2 lists "Communities opens separate window" as an activity-lifecycle fix; it is that plus an embedded browser. Phase 2 is bigger than §4.2 implies, and this is the reason.

One more assumption worth retesting early: spec §4.3 assumes the passkey call arrives at androidx.credentials.CredentialManager. The APK carries both the platform android.credentials.CredentialManager (12 classes) and the AndroidX Play Services backend (33 classes). Target SDK 35 means the platform path should win, but that depends on the API level Cordial reports. Confirm by instrumenting the call, not by reasoning about it.


6. What this session did not establish

Stated plainly, so nothing here reads as more settled than it is.

  • Nothing has been run. Cordial launches no Roblox process. Nothing beyond libbadcpu is written. The inventory is a map, not an implementation.
  • The API surface is enumerated, not understood. §5 lists which APIs Roblox references. It does not establish what behaviour each one needs, which is only knowable by running the thing and watching it fail. Every implement row is an estimate.
  • The bionic/glibc gap is counted, not analysed. 490 libc/libm/libdl symbols is a number; the hard part is symbol versioning, TLS layout and pthread ABI differences, none of which a symbol list reveals.
  • The graphics path is only bounded, not examined. GLES2 + EGL is confirmed as the mandatory path and 91 gl*/egl* symbols are listed, but nothing establishes what Roblox's renderer actually demands of Mesa — extensions, context versions, format support. Historically this is where Android-on-desktop projects lose the most time.
  • Whether libeigen_blas, libmediandk and libOpenMAXAL are reached at runtime. They are linked; linked is not used. Cheap to settle later with a stub that logs.
  • libbadcpu's emulation is unverified (§3).
  • libstanpreg.so — referenced by Sober's loader with the string "libstanpreg.so is real." and unexplained by the reference analysis. Unknown, probably minor, noted so it is not forgotten.

8. Phase 1 progress — the engine loads

cordial-load maps Roblox's libroblox.so with the AOSP bionic linker, resolves every relocation, and runs all of its static constructors to completion in ~35 ms. 105.6 MB of code, JNI_OnLoad resolved, zero stubs called, exit 0.

That is the loader, the relocations and the TLS layout proven against the real object. It is not Roblox running: nothing has a JavaVM, a window, or a frame.

Three bionic/glibc ABI divergences had to be fixed first. All three share a shape worth remembering — each failed a long way from its cause:

Divergence bionic glibc How it presented
sysconf selectors _SC_PAGESIZE = 39 39 is _SC_BC_STRING_MAX Roblox asked for the page size, was told 1000, and an allocator handed a non-power-of-two page size aborted. Nothing pointed at sysconf. 145 of 147 constants differ, so the table is generated.
pthread_cond_t 32 bytes 48 bytes pthread_cond_init writes 16 bytes past the object, during static construction, into whatever is adjacent.
sem_t 16 bytes 32 bytes As above.
__sF (legacy stdio) array of FILE Stubbed as a function pointer; glibc walks its stream list at exit and reported an invalid handle after everything else had succeeded.

The lesson generalises beyond these four: shared symbol names do not imply shared ABI, and the divergences that matter are the silent ones. This is exactly the caveat recorded in base-evaluation.md §4 — 89% of libc symbol names resolving from the host is not 89% of the work — now demonstrated rather than asserted.

8.1 Instrumentation, because the obvious tools were absent

There is no strace in this environment, and libroblox has no frame pointers, so lldb's backtrace past the innermost frame was guesswork that sent the first investigation to the wrong function entirely.

What worked was owning the symbol table: any import can be replaced with a wrapper. CORDIAL_TRACE=1 logs the libc calls Roblox makes, which is what turned an anonymous abort into sysconf(39) = 1000 in one run. abort, __stack_chk_fail and __android_log_assert are wrapped unconditionally — a silent abort costs more time than the wrapper costs anything.

8.2 JNI_OnLoad — reached, and it marks the Phase 2 boundary

With libjnivm standing up a JavaVM, cordial-load --jni-onload calls Roblox's JNI_OnLoad. It does not return. Two worker threads — neither the calling thread — immediately call back through the JNI invocation interface with a null JavaVM, and libjnivm throws.

The VM itself is sound: its JavaVM::functions->reserved0 points at the VM, which is exactly what libjnivm needs to recover itself, and it is verified at creation. The null pointer is Roblox's own — threads that already existed before JNI_OnLoad (six gettid calls happen during static construction) reading a global the real Android bring-up would have populated by now.

That is the finding, and it is a scoping one: JNI_OnLoad is not an entry point that can be called in isolation. On Android the sequence is a VM, then the app's Java side, then GameActivity.initializeNativeCode invoked from Java with a real Activity. Reaching a frame means implementing enough of that sequence for Roblox's own globals to be set — which is Phase 2 proper, not a finishing touch on Phase 1.

GameActivity being Apache-2.0 (§5(b)) is what makes this tractable: the sequence is readable rather than inferred.

A practical note for whoever picks this up: an exception escaping a thread Cordial did not start cannot be caught anywhere, so the default outcome is std::terminate and a core dump carrying no information at all. native/jni_shim.cpp installs a terminate handler that prints the thread, the what(), and a backtrace. Without it this took several runs to see; with it, one.

8.3 Still true, and worth not overstating

  • Nothing renders. There is a JavaVM now, but no window, no frame and no input.
  • JNI_OnLoad does not complete (§8.2).
  • --host-libc is a diagnostic, not the design. libc is currently the host's where the ABI happens to agree. The divergences above are the ones found so far, by running until something broke; the file, directory and signal structures have not been exercised at all yet because nothing has opened a file.
  • 49 symbols remain stubbed, down from 85. libandroid.so is complete (31/31): assets, configuration, looper and window. What is left is libmediandk (33 — video decode, off the startup path), libjnigraphics (3) and 13 libc residuals.

7. Sources

Clone this wiki locally