-
Notifications
You must be signed in to change notification settings - Fork 5
Where To Start
Mirrored from
docs/NEXT.mdin the repository, which is the canonical copy. If this page and that file disagree, the file is right.
Roblox does not render under Cordial. It does run under Waydroid on this
machine, and that capture is committed — see docs/traces/.
Grep the trace before disassembling anything. Over one long session, every conclusion drawn by reading the stripped binary was wrong — nine in a row — and every conclusion drawn by running something held up. The trace exists so that "what does the engine expect here?" is a lookup, not an investigation.
The rule held again this session. The futex was identified without disassembling a single instruction, and the root cause underneath it was found by reading a log file the engine had been writing all along.
Roblox writes its own FastLog to appData/logs/<version>_<timestamp>_Player_*.log,
relative to the working directory. Every run produces one. It is far and away
the best diagnostic in the project — it names subsystems, stages, file paths and
exceptions in the engine's own words.
Two comments in this repo claimed the opposite ("FLog is not routed anywhere
visible in this build"). Both were wrong and are now corrected. Nobody had
looked in appData/.
So: before anything else, read the newest file in appData/logs/.
cat "appData/logs/$(ls -t appData/logs | head -1)"Enabling extra channels via client settings (adding FLog<Channel> keys to the
cached settings document) was tried and produced no additional output; the
channels that matter are on by default anyway. Not worth a second attempt.
The previous handoff said the driving thread parks in a futex that is "most likely an EGL/GBM surface handshake that never completes". That was wrong. There is no graphics primitive involved.
What it actually is, established by observation:
- The futex word lives in an anonymous heap arena, at offset +0x0C of a
64-byte-aligned engine object. Every one of the 16 idle
RBX Workerthreads parks on the same class of object at the same offset through the same call site. It is the engine's ordinary internal wait primitive, nothing special. - The wait is
FUTEX_WAIT_BITSET|FUTEX_PRIVATE, expected value 2, timeout NULL — indefinite. - It is a completion handshake: the JNI thread dispatches work to an engine thread and waits for that thread to signal. The engine thread segfaults before signalling, so the wait can never end.
The block and the crash were one bug, not two. The previous handoff treated them as independent ("it dies because a different engine thread segfaults"). They are cause and effect.
Proven causally rather than argued: at the SIGSEGV, stepping the faulting thread
over its null dereferences and continuing made the futex resolve immediately,
StartAppWithParams return, ANativeWindow_* calls follow, and the process run
its full 12 seconds and exit 0.
The blocking call is nativeAppBridgeStartLuaAppDM
(load.rs:821), not nativeAppBridgeV2StartAppWithParams. The previous handoff
named the wrong one. This matches the capture, where StartLuaAppDM is exactly
the call that hands work to the SingleSurfaceApp thread and waits.
lldb cannot symbolise libroblox, but it stops the process fine, and while it
is stopped /proc/<pid>/task/<tid>/syscall gives the syscall number and all six
arguments directly — no register-shuffle guesswork about glibc's syscall()
wrapper. Combine with /proc/<pid>/maps to place the address. That is how the
above was established, and it is the technique to reuse.
The engine's log named it:
[FLog::Output] setAssetFolder ~/.cache/cordial/assets
[FLog::CreatorError] Error: boost::filesystem::canonical:
No such file or directory: ".../.cache/cordial/android"
The capture says what the path should be:
[FLog::Output] setAssetFolder /data/user/0/com.roblox.client/app_assets/content
[FLog::Output] setExtraAssetFolder /data/user/0/com.roblox.client/app_assets/ExtraContent
The engine is handed the content subdirectory and resolves its siblings —
android/, ssl/, fonts/, ExtraContent/ — relative to the parent. Cordial
handed it the unpack root, so every sibling lookup landed a level too high, the
canonical call threw, and SingleSurfaceApp initialisation aborted before
setStage: (stage:Native) and before it instantiated its controllers. The later
initializeLuaAppWithLoggedInUser then ran at (stage:None) and made a virtual
call through a controller that had never been constructed — the null dereference
at libroblox+0x2ccd912.
Fixed in asset_folder() in load.rs. The same string also feeds
PlatformParams.assetFolderPath via nativeAppBridgeSetInitParams, which was
still being handed the raw .apk file path; that is fixed too.
The engine now gets all the way through the sequence it was failing at. Against
docs/traces/render-bringup-sequence.log, Cordial now reproduces:
initializeWithAppStarter / initializeSingleton
setAssetFolder + setExtraAssetFolder (correct paths)
registerForForceOTAUpdateAvailableConnection <- new
setStage: (stage:Native) <- new
instantiate controllers <- new
SurfaceController[_:1]::SurfaceController <- new
instantiate experience coordinator <- new
initializeLuaAppWithLoggedInUser: (stage:Native). (was (stage:None))
applyLocale
DataModelPatchConfigurer ... deserializeAndVerifyPatch with blake3
[FLog::Output] Hello world, CLI-208683! ... <- Lua is running
Lua executes. That is a long way past where this was stuck.
struct addrinfo is not the same in bionic and glibc — the last two
pointers are transposed, and the AI_* constants disagree outright (bionic's
AI_DEFAULT sets a bit glibc rejects with EAI_BADFLAGS). That is why every
request failed with Could not resolve host. Translated in
native/netdb_compat.cpp; put addrinfo on the list next to stat,
pthread_mutex_t, DIR, FILE and sigset_t.
Then curl failed on error adding trust anchors from file: ./exe/cacert.pem.
The engine builds several paths from a root it was never given and resolves them
against the working directory — ./exe/cacert.pem, http/, sounds/, cache/,
ContentProvider_<pid>. Cordial now gives the process its own run directory
with the APK's CA bundle linked into exe/, which also stops the engine
littering whatever directory you launched from.
With those two in, the engine reaches APP_READY (Landing) and flags FAILED
drops to zero — the static-flag problem below resolved itself once HTTPS
worked, so it was a symptom, not a cause. Left recorded because the measurement
technique is the reusable part.
realpath(path, NULL) is a GNU extension where glibc allocates the result and
the caller frees it. Roblox statically links its own allocator — malloc,
free, operator new and operator delete are not undefined symbols in
libroblox.so at all — so when it released a buffer that came from the host
allocator, the free ran inside mimalloc, whose arena lookup is keyed on the
pointer's own address. A host pointer was never registered there, the first
level came back null, and the next dereference was unconditional:
movq (%rax,%rcx), %rdi with rax=0.
Only reachable once HTTPS completes a request, because that is when the cURL layer re-resolves the CA bundle path per connection — which is why it appeared the moment networking started working.
Cordial's s_realpath no longer forwards the allocating form. It returns
NULL/ENOTSUP, the documented POSIX failure, and the caller falls back to the
path string it already had. There is no buffer Cordial could hand back safely,
because Roblox's allocator is not reachable from outside it.
Measured 5/16 before and 16/16 after by the agent that found it, then 10/10 independently on a checkout carrying every other change.
Disproved on the way there: a pthread_create override skipping per-thread
setup (there is no such override — it is a plain passthrough), a
pthread_mutex_t/pthread_attr_t ABI mismatch, and the same cross-allocator
theory applied to malloc/free directly.
Deterministic signature, same every time:
thread 'HttpClient', SIGSEGV at libroblox+0x1cb7cc6, fault address 0xe000
movq (%rax,%rcx), %rdi rax = 0 rcx = 0xe000
A table indexed 0xe000 bytes off a null base. It only started appearing once HTTPS began working, so it is newly reached rather than newly introduced — verified by A/B against the previous commit, which fails at the same rate.
rax being null on an HttpClient thread, for a large fixed-offset table,
smells like per-thread state that was never set up on a thread the engine
created through Cordial's pthread_create override. Check the TLS block before
anything else. Do not assume it is the HTTP code just because the thread is
named HttpClient.
Note this invalidates any earlier claim in the history that the client "stays up for twelve seconds" — that was measured on a run of successes and the failure rate was not sampled.
Vulkan renders at about 27 fps, steady — 656, 656 and 655 presents over 24 s, unchanged by injected input, so it is a continuous loop and not render-on-demand.
The "1 fps" that this file previously called the blocker was measured with
eglSwapBuffers. Two things were wrong with that:
- Once Vulkan landed it became the default renderer, and a Vulkan session leaves every GLES counter at zero. Reading zero as "nothing is drawing" is exactly the mistake the counter was added to prevent, and it was made anyway.
- The
vkQueuePresentKHRcounter added to replace it also read zero, because device-level entry points are resolved throughvkGetDeviceProcAddr, notvkGetInstanceProcAddr. The shim only intercepted the instance getter. Fixed.
What survives as a real problem: the GLES path genuinely was about 1 fps (20 swaps in 20 s, repeatedly). That matters for any host without Vulkan, since GLES is the fallback. The investigation below was not wasted — it was aimed at the right symptom on the wrong renderer.
Every engine thread sits in futex_do_wait and wakes once a second; 13% CPU
over thirty seconds; exactly 20 swaps in 20s and 30 in 30s. It is waiting, not
working.
Disconfirmed: that the engine was throttling for lack of window focus.
onWindowFocusChangedNative(true) and onContentRectChangedNative are now sent
after the surface handover — both are part of the AGDK contract and Android does
send them, so they are kept — and the frame rate did not move at all.
Also disconfirmed: frame-callback starvation. AChoreographer_* is not
imported by libroblox.
Also disconfirmed: FIntReactSchedulerMinFrameRate. The client-settings
document carries FIntReactSchedulerMinFrameRate_IXP = 1, and the app shell UI
runs on a React-style deferred scheduler, so a minimum frame rate of 1 looked
like an exact match for the symptom. Setting the plain
FIntReactSchedulerMinFrameRate to 60 changed nothing — still exactly 20 swaps
in 20 s. Either the engine only honours the IXP-delivered form (and we get no
experiment assignment without a session) or it is the wrong knob.
Also ruled out: that the render job never binds a DataModel.
RenderJob::stepDataModelJob: No DM yet and scheduleRender: No data model
appear exactly twice, at ~2.0 s, and are transient — by ~3.1 s the log shows
onGameLoaded, then APP_READY for PlatformAccountRouter, Startup and
Landing in sequence. The DataModel binds fine.
Live possibility, and it needs a session to test: the client is sitting on the logged-out landing screen, and Roblox's app shell may legitimately idle there. Nothing yet distinguishes "Cordial fails to drive the render loop" from "the landing screen has nothing to animate". The cheapest discriminator is input: if a click produces a burst of swaps, the loop is fine and the idle is the app's own choice.
The app shell logs Register rendering frequency during startup and later
Restoring rendering frequency to normal, and renders on demand. Still the best
theory, still unproven.
The discriminator now exists but needs a display. Input landed, so a click
should produce a burst of frames if the render loop is healthy and the idle is
the app's own choice. Measure vkQueuePresentKHR (Vulkan) or eglSwapBuffers
(GLES) with and without input — note that a Vulkan session leaves every GLES
counter at zero, so use the right one.
Do not measure this with XTestFake* on a desktop someone is using: it
injects into the real session and takes over their cursor. Use a nested server
(Xephyr, Xvfb) or a dedicated seat.
Every engine thread sits in futex_do_wait and wakes once a second; 13% CPU
over thirty seconds; exactly 30 swaps in 30s. It is waiting, not working.
The app shell logs Register rendering frequency during startup and later
Restoring rendering frequency to normal, and renders on demand rather than
continuously. Working theory, not proven: nothing in Cordial delivers a
frame or input signal to drive that, so it falls back to a one-second heartbeat.
Note AChoreographer_* is not imported by libroblox, so it is not simple
frame-callback starvation — that was checked.
Measured against the capture, same 139 names, same call:
| resolved | not found | |
|---|---|---|
| Real client | 74 | 67 |
| Cordial | 0 | 68 |
The not found sets agree — those flags are genuinely absent from the engine's
registry on both. The 74 that should resolve return nothing here. That is what
onFlagsFailed is reporting, and the real client never calls it once.
It is not that client settings are ignored. That was tested with a control:
setting DFFlagRbxTransportUseRtcioRna=False removes
Initialized RtcIoRna with 1 event loop threads from the engine's log, and the
control run with the document unmodified has it. Dynamic flags apply. So the
defect is specific to the static (FFlag) path that
nativeInitializeNativeFlags looks up — 64 of the 139 names are present in the
client-settings document as FFlag<name> and still report not found.
Do not use FLog channels to test whether flags apply. Setting FLogAndroidGLView=7
through client settings or nativePreloadFlagOverrides produces no output even
though flags demonstrably work, so it is a broken instrument — it produced a
confident wrong conclusion ("no FastFlag reaches the engine") that survived
several experiments. Use a flag with an observable behavioural effect, and run
the control.
This most likely gates rendering: the surface handler returns early with
nativeActivity_onSurfaceChanged: ... Flags-Not-Received. Return., and the
client draws at about 1 fps at 8% CPU — waiting, not working.
Every request fails with Could not resolve host: apis.roblox.com, so no remote
content arrives and glTexImage2D stays at zero. This is Cordial's, not the
environment's: getent hosts apis.roblox.com and curl both succeed from a
shell on this machine, and getaddrinfo/gethostbyname resolve to the host's
libc (confirmed with --verbose). Suspect the resolver thread rather than the
lookup — curl's default backend spawns a thread per lookup, and thread creation
goes through Cordial's pthread overrides.
[LOGCHANNELS + 1] RBXCRASH: UnhandledException (St13runtime_error Path does not exist: "")
Thrown ~0.2 s after deserializeAndVerifyPatch with blake3, on the DataModel/Lua
thread — the same thread that prints the Hello world lines. The path is
empty, not merely missing.
Facts about it, all from running:
- It happens with
CORDIAL_SKIP_LUA_DM=1too, so it is not driven byStartLuaAppDM. The patch configurer is started duringV2Init. -
No JNI upcall precedes it. Cordial's
[JNIVM]log stops atPlatformParams.assetFolderPath. So the empty path was supplied earlier or is computed internally — the engine is not asking the host for it. - The subsequent SIGSEGV is secondary: it lands in
_IO_fflushwith a nullFILE*, i.e. inside Roblox's own crash reporter. Do not chase that address; it is the handler tripping over glibc/bionicFILElayout, not the defect. - The content is not missing: all 1839 asset files extract, including
ExtraContent/models/UniversalApp/UniversalApp.rbxmandExtraContent/places/Mobile.rbxl(which is what the real client loads next).
-
The engine ignores the storage directory Cordial gives it.
initStorageManagerNativeV3is passed$XDG_DATA_HOME/cordial/instances/default/data— twice, the same string for both arguments — and that directory did not even exist. Creating it changed nothing, and the engine keeps writing to a CWD-relativeappData/, which is its unconfigured fallback. So the storage root is very likely still unset inside the engine, and an unset root is a plausible source of an empty path. Find out what the two arguments actually are before guessing again — the dex in the APK names them. -
InitParams.baseURLishttps://www.roblox.comanduserAgentisRoblox/Android. The capture sayswww.roblox.com/(trailing slash) and the long real UA (... ROBLOX Android App 2.732.1043 Tablet Hybrid GooglePlayStore RobloxApp/2.732.1043). Cheap to align, andsetBaseUrl()is visible in the capture. -
DeviceParams.appVersionis"". If any path is built as<root>/<version>/...that is a candidate.
- The flags verdict does not gate rendering.
onFlagsFailedis a complaint, not a gate. Confirmed again this session: the verdict is stillFAILEDwhile the engine happily instantiates controllers and runs Lua. - It is not an unserviced ALooper.
- The 139 flag names and the corrected bring-up order are already in the tree.
Decompilation reconstructs expression — you end up reading a reconstruction
of their source and writing code from it, which is where derivative-work risk
lives. That is why decompiled/ stays off-limits (§16.1, ADR-001).
A debugger on a running process yields behaviour — which libraries it loads, which natives it calls, in what order, with what arguments. Those are facts and interfaces, not expression, and black-box observation for interoperability is the ordinary basis for this kind of work.
So the line is not the tool, it is what you take away:
- Fine: the call sequence, the load order, argument shapes, which symbols get resolved, timing, syscalls.
- Not fine: stepping into its routines to read how it implements something and transcribing that logic. At that point the debugger is just a slower decompiler.
One rule, applied to any binary including Roblox: observe freely, do not transcribe. Sober was built by observing Roblox, and nobody treats Sober as tainted for it.
Sober remains the better reference for the render path specifically — it runs the same APK natively against the host GPU, which is Cordial's shape. It was not needed this session; the engine's own log was enough.
Cordial runs natively on the host (X11/Mesa), not inside the container. The Waydroid capture is trustworthy for call order, names and contract — which is what it was taken for — but not for timing or render behaviour.
-
Read
appData/logs/first. See the top of this file. -
lldb breakpoints inside
libroblox.sodo not work. Cordialmmaps it outside the system linker, so lldb never lists the image and every breakpoint stays unresolved with hit count 0 — silently. Usememory writeof0xCC, then rewind$pcand restore the byte on trap. Crash-stop backtraces and breakpoints in Cordial's own code are unaffected. -
Read syscall arguments from
/proc/<pid>/task/<tid>/syscallwhile lldb has the process stopped, not from registers. -
Stepping a thread over its faults is a legitimate causality experiment. On
SIGSEGV, advance
$pcby the instruction length, zero the destination register, and continue. Five skips were enough to prove the futex was downstream of the crash. Debugger-only; nothing shipped. -
There are three threads named
Main. Usethread backtrace all. CORDIAL_SKIP_AGDK=1skips the flag and app-bridge calls entirely.- The whole run lives and dies in ~120 ms. Sampling
/procfrom a shell loop is too slow to catch it; drive it under lldb. - Roblox's launcher activity is
com.roblox.client/.startup.ActivitySplash.
agent/wt-agdk has the per-callback GameActivity work and the worker-thread
restructure; it is not merged. agent/ordering and agent/flags are merged.
Canonical source: https://github.com/luohoa97/cordial · GPL-3.0-or-later · Not affiliated with Roblox Corporation
- Where To Start
- Findings
- Framework API Inventory
- Path To A Frame
- Answering Not Patching
- Instances And Launch
- ADR 001 No In Process Hooking
- ADR 002 Core Shell And UI Handoff
- ADR 003 Plugin Isolation
- Base Evaluation
- Multi Architecture
- ADR 004 No Asset Overrides
- ADR 005 Flag Service
- ADR 006 Plugin Events
- Sign In
- Writing Plugins