-
Notifications
You must be signed in to change notification settings - Fork 5
Sign In
Mirrored from
docs/design/sign-in.mdin the repository, which is the canonical copy. If this page and that file disagree, the file is right.
Status: discovery only. Nothing here is implemented. No credentials were used, entered, or created while writing this. This document exists to turn "there is no sign-in" from a known gap into a scoped, evidence-backed plan.
Related: docs/design/instances-and-launch.md (a related, independently
written and not yet verified theory about a roblox:// ticket-launch path —
referenced in §4, not duplicated).
Every claim below is tagged:
-
[verified: log] — read directly out of
docs/traces/waydroid-roblox-startup.log.gz, the capture of this exact APK running on real Android. This is the strongest evidence available in this project, perdocs/NEXT.md's standing rule. -
[verified: dex] — read directly out of the shipping APK's own dex
declarations (class names, method names, signatures) using
dexsig.py/dexsig_cls.py, or out oflibroblox.so's exported symbol table withreadelf. This is the host app's own contract, not a guess about it. - [verified: cordial] — read directly out of Cordial's current source.
- [inferred] — a plausible reading of the above that was not itself observed running. Flagged explicitly every time, with the reasoning shown so it can be checked rather than trusted.
The capture is of a logged-out cold start. Absence of some behaviour in it (no captcha, no full login POST) is not evidence that behaviour doesn't exist elsewhere in the app — only that this particular run didn't trigger it. Said again inline wherever it matters.
There are two separate channels the engine uses for identity, not one.
Cordial's docstring in native/android_classes.cpp only names the first.
native/android_classes.cpp:225-265 already stubs this correctly for a
logged-out user: getUserId → 0, getUsername/getDisplayName/
getAlternateName → "", getIsUnder13 → false, getMembershipType → 0,
getHasRobloxSubscription → false. The engine calls these when it needs to
know who's signed in (e.g. building a rbxthumb:// avatar URL), and Cordial's
existing comment is correct that a fabricated non-zero id would be worse than
an honest zero.
native/init_params.cpp:695-752 defines com.roblox.engine.jni.autovalue.StartAppParams,
the object handed to nativeAppBridgeV2StartAppWithParams when the engine's
app-shell actually starts. It already has the fields:
std::shared_ptr<String> username;
jlong appUserId = 0;
jboolean isUnder13 = false;
jint membershipType = 0;All four are currently hard-zeroed in StartAppParams::Create()
(init_params.cpp:730-732). This is a second, independent place identity has
to be threaded through — it is not automatically consistent with whatever
NativeUserJavaInterface answers, because it's a different call at a
different time. Any real implementation has to fill in both.
[verified: log] The logged-out capture shows the exact symptom the task description calls out:
1338: rbxthumb://type=AvatarHeadShot&id=&w=48&h=48&filters=circular → "invalid format"
1375: rbxthumb://type=AvatarHeadShot&id=-1&w=48&h=48&filters=circular → "The requested Ids are invalid, of an invalid type or missing."
This is an important correction to the premise in the task description.
The exact string "The requested Ids are invalid, of an invalid type or
missing" is what the real, logged-out Android client produces too, for
id=-1. It is not a Cordial-specific defect — it is the correct, faithful
behaviour of a client with no signed-in user. Cordial's getUserId() → 0
already reproduces the class of failure the real client has in this state
(the real client's sentinel appears to be -1, not 0 — a one-line, low-risk
alignment worth making so failure messages match exactly, but not
behaviourally different).
[verified: log] Also from the capture, three lines up the stack from the thumbnail failures:
1291: HTTP error url: https://apis.roblox.com/attribution/v1/events/post-authentication
1299: HttpResponse ... status:401 ... url: https://users.roblox.com/v1/users/authenticated/app-launch-info
1331: HTTP error body: {"errors":[{"code":9002,"subcode":0,"message":"Authentication token is missing"}]}
1337: HTTP error body: {"sessionId":"00000000-0000-0000-0000-000000000000","status":403,"message":"Unauthorized."}
So a real session changes things at the network layer, not primarily at
the NativeUserJavaInterface/StartAppParams layer. The engine calls
/v1/users/authenticated/app-launch-info and other authenticated/*
endpoints itself, over its own HTTP client, and those calls succeed or fail
based on the cookie the engine's HTTP client is carrying — not based on what
getUserId() returns. getUserId()/StartAppParams.appUserId look like they
mirror an already-established server-side session rather than establish one.
[inferred] This means the cookie (§2) is the actual source of truth, and
NativeUserJavaInterface/StartAppParams are secondary, presentation-layer
mirrors of it — filling them in without a real cookie would produce a client
that displays a fake identity while still getting 401s from every
authenticated/* endpoint, which is worse than the current honest state, not
better.
[verified: log] Two more facts from the same run that constrain the picture:
-
ActivityNativeMain.onResume(): IsLoggedIn = falseandnavigateToMainScreen: IsLoggedIn = false(lines 941, 961) — the real app has an explicitIsLoggedInboolean it branches the UI on, checked before any of the native calls above run. -
initializeLuaAppWithLoggedInUser(line 1031) anduserDidLogin(line 1191, an[FLog::SingleSurfaceApp]event) both fire unconditionally, even in this logged-out run. These names are misleading if read as proof of authentication — they are generic app-shell lifecycle stages the client reaches regardless of session state, not evidence that a real login happened. Do not treat either as a signal to test against.
com.roblox.engine.jni.NativeSettingsInterface (all confirmed as real
Java_com_roblox_engine_jni_NativeSettingsInterface_* exports in
libroblox.so, with signatures read from the dex):
nativeGetCookiesForDomain(String) → String
nativeGetCookiesInNetscapeFormat(String) → String
nativeSetMultipleCookies(String, String) → void
nativeSetUserId(String) → void (not previously documented anywhere in this repo)
nativeIsLuaLoginEnabled() → boolean
These are natives the engine implements and Java calls — the same
direction as the nativeSetFilesDirectory/nativeSetDeviceInfo family
Cordial already drives from native/init_params.cpp and
crates/cordial-runtime/src/bin/load.rs. Nothing here is currently called by
Cordial; grep confirms zero references to any of these five names anywhere in
native/ or crates/ before this document.
[verified: dex + readelf] com.roblox.universalapp.cookie.JNICookieProtocol.updateOnSetCookieHandler(OnSetCookieHandler)
is also a real exported native (Java_..._JNICookieProtocol_updateOnSetCookieHandler).
The real app's implementation of that handler,
CookieProtocol$OnSetCookieHandlerImpl.onSetCookie(String[], String), is a
plain Java class (not a native) — meaning the engine, on receiving a
Set-Cookie from its own HTTP client, calls back into Java through
whatever handler object was registered.
[verified: log] This is directly observed firing, twice, during the logged-out run:
1222: Flushed WebViewCookieHandler with Cookies from URL https://apis.roblox.com/browser-tracker-api/device/initialize
1223: OnSetCookieHandlerImpl.b(): Updated WebViewCookieHandler with Cookies from URL https://apis.roblox.com/browser-tracker-api/device/initialize
1355: Flushed WebViewCookieHandler with Cookies from URL https://apis.roblox.com/account-switcher/v1/getLoggedInUsersMetadata
1356: OnSetCookieHandlerImpl.b(): Updated WebViewCookieHandler with Cookies from URL https://apis.roblox.com/account-switcher/v1/getLoggedInUsersMetadata
So the contract has three legs, not one, and all three are exercised even with no user signed in (this traffic is device/tracking cookies, not auth cookies — but the plumbing is identical for both):
| Direction | Mechanism | Verified how |
|---|---|---|
| Java pulls cookies out of the engine |
nativeGetCookiesForDomain/nativeGetCookiesInNetscapeFormat
|
dex + readelf signatures; InitHelper: updateCookiesFromEngine log line (§2.3) |
| Engine pushes cookies out to Java |
JNICookieProtocol.updateOnSetCookieHandler registers a callback; engine CallMethods it |
dex + readelf; log lines 1222-1225, 1355-1356 |
| Java injects cookies into the engine | nativeSetMultipleCookies(domain, cookies) |
dex + readelf signature only — not observed firing in this (logged-out, no WebView-login) capture |
[inferred] The purpose of this three-way sync: the engine's own HTTP
client and Android's WebView/CookieManager are two separate cookie jars on
real Android, and the app keeps them consistent so that (a) a cookie the
engine's network layer receives (e.g. after login) is visible to any WebView
the app opens next (communities, captcha, help), and (b) a cookie a WebView
obtains (e.g. the user just typed a password into a login page) is fed back
into the engine so its own HTTP client starts authenticating too. This
explains why nativeSetMultipleCookies exists at all — it is the write path
for exactly that second case, and per the task's framing, is the one Cordial
would need to call to hand the engine a real session.
[verified: log] The earliest cookie-related line in the entire capture is
at ActivitySplash, before ActivityNativeMain is even created:
300: E InitHelper: [l0.S()-160]: updateCookiesFromEngine: Invalid cookie format: []
This fires right after crashpad initialization, well before
nativeAppBridgeSetInitParams. [inferred] The obfuscated caller
(l0.S(), logged under the InitHelper tag) is almost certainly calling one
of nativeGetCookiesForDomain/nativeGetCookiesInNetscapeFormat this early
and getting an empty result back ([]), which it logs as a format error
rather than treating as fatal — i.e. calling these natives before the
engine has anything to say is expected and handled gracefully, not a
precondition Cordial has to satisfy before calling them.
[verified: readelf, contradicted by dex] libroblox.so also exports
Java_com_roblox_universalapp_cookie_JNICookieManager_{getCookie,setCookie,setCookiesFromDisk,convertCookiesToNetscape}
— a different class from JNICookieProtocol. Searching all three dex files'
raw strings for JNICookieManager (not just via dexsig, but plain strings)
returns zero matches — the class does not exist anywhere in this APK's
dex. [inferred] This reads as vestigial: an older/renamed native symbol
table entry with no Java-side caller left in this build (native libraries
aren't proguard-stripped the way dex is, so dead exported symbols are
unsurprising). Treat JNICookieManager as dead code, not a second live
contract — the three NativeSettingsInterface methods plus
JNICookieProtocol are the live path.
Short answer: partially yes, and the part that's required is bigger than a
single captcha screen — but the core username/password submission may not be
one of the WebView parts. Both halves of that are evidenced below; this
refines rather than simply confirms docs/findings.md §5(d)/§3.6.
[verified: log] Line 235, well before ActivityNativeMain or any login
UI: WebViewFactory: Loading com.google.android.webview version 150.0.7871.181.
Android's system WebView is instantiated during ordinary app startup, not
lazily when a login button is pressed. This alone doesn't prove login needs
it, but it means "just don't implement WebView" is not free even before
touching sign-in — communities and captcha (below) already depend on it per
the existing framework inventory.
[verified: dex] com.roblox.client.captcha.ActivityFunCaptcha is a
declared Activity (confirmed previously in framework-api-inventory.md §3.5).
Its configuration classes resolve to a URL:
LoginCaptchaConfig.getUrl() → String
SignUpCaptchaConfig.getUrl() → String
CaptchaConfig.getUrl() → String (the common base type)
A config object that is just a URL, handed to an Activity whose only declared
purpose is captcha, is consistent with loading a challenge page in a WebView
— nothing here contradicts the existing finding that captcha is WebView-based.
This document did not find the internal fragment's layout (obfuscated names
like H0(LayoutInflater, ViewGroup, Bundle) → View don't reveal what view
they inflate without decompiling, which is off-limits per this project's
rules) but the URL-based config plus the WebView's unconditional early load
(§3.1) is corroborating rather than new doubt.
[verified: dex + readelf] NativeSettingsInterface.nativeIsLuaLoginEnabled() → boolean
is a real native. [inferred] Roblox's app shell (the "Landing", "Startup"
etc. stages visible throughout the capture) is rendered by the Lua-based
SingleSurfaceApp/UniversalApp, the same layer that renders the rest of the
UI without a WebView. A flag named IsLuaLoginEnabled reads most naturally as
"is the login form (username/password fields) rendered by the Lua app shell
rather than a WebView", talking to auth.roblox.com (DNS for which is
pre-warmed at startup — line 103: Pre-warmed DNS for auth.roblox.com)
directly over HTTPS, which Cordial already supports end-to-end.
If that reading is right, basic username/password login may not require a
WebView at all — only the captcha challenge and any federated (Google/Apple)
sign-in option would. This is not verified by running anything (the captured
run never reached a login screen), so treat it as the single most valuable
thing to confirm early if this work is picked up: instrument
nativeIsLuaLoginEnabled's return value and see whether the login form that
appears is native/Lua or a WebView.
[inferred] Roblox's captcha is risk-based and server-decided, not
something the client can predict or skip. A Linux host presenting as a
brand-new, unrecognized Android x86_64 device is close to the profile that
triggers step-up challenges most often. So even if password submission itself
turns out to be WebView-free, a real user attempting to actually sign in
through Cordial should be expected to hit ActivityFunCaptcha on a first
attempt, and a WebView implementation should be budgeted for, not treated as
optional-in-practice. This matches — and does not overturn —
framework-api-inventory.md's conclusion that WebView "is also on the login
path... not optional."
Three non-web-form surfaces are declared in the APK. None of them turned out to be a low-effort shortcut on inspection — stated plainly, since that's the honest answer to the question asked.
Covered in depth by framework-api-inventory.md §3.6 already: the platform
android.credentials.CredentialManager path is real and avoids GMS, but
still means implementing the platform credential-provider contract and
bridging to libfido2/xdg-desktop-portal — not smaller than WebView, just a
different large piece of work. Nothing new found here; not re-litigated.
[verified: dex strings] Distinct from JNIAccountProtocol's
getMagicLoginActionKey/getMagicLoginMethodName (small JS-bridge-style
constant getters, same pattern as FlagJniInterface), the dex contains a
cluster of unrelated-looking class names:
MagicLoginManager
MagicLoginNsdHelperClient / MagicLoginNsdHelperServer
MagicLoginSocketHelperClient / MagicLoginSocketHelperServer
MagicLoginSocketTimeout
Roblox-Magic-Login (an HTTP header name)
[inferred] Nsd is Android's Network Service Discovery (mDNS) API. This
shape — an NSD advertise/discover pair plus a raw socket helper plus an HTTP
header — reads as a "sign in using another device that's already logged
in" flow: an already-authenticated device (e.g. the user's phone) advertises
on the LAN, a new device discovers it and exchanges a session over the local
socket, likely with a server-mediated confirmation (the HTTP header suggests
a request is also sent to Roblox's servers as part of the handshake). This is
the shape of the "sign in with your phone" flow real Roblox ships on
console/TV-class clients.
This would let Cordial obtain a real session without ever presenting a password field or a WebView itself — the credential entry happens on the peer device, which the user already trusts. It is the most promising non-WebView lead this investigation found.
Caveat, stated as strongly as the evidence deserves: this was found by
strings-grepping obfuscated class names in a dex, not by observing it run.
The logged-out capture never triggers this flow (there is no reason it would,
on a cold start), so nothing here confirms it actually works, what the wire
protocol is, or whether it's gated behind a flag/feature that's off in this
build. It needs its own investigation — likely by triggering it on a real
device and capturing the traffic — before it can be relied on for a design.
docs/design/instances-and-launch.md §2 already describes (and explicitly
flags as unverified) a theory that pressing "Play" on roblox.com in an
external browser emits a roblox://-scheme URI carrying a short-lived
authentication ticket, and that ActivityProtocolLaunch consuming it is what
lets multiple Cordial instances each be signed in as a different account
without Cordial ever touching credentials.
[verified: dex strings] What this document found narrows that: the
roblox:// URIs actually declared in the dex are all game-join shaped —
roblox://placeId=%d&reservedServerAccessCode=%s&callId=%s,
roblox://experiences/start, roblox://placeId=%d&gameInstanceId=%s&callId=%s
— not a bare authentication URI. ActivityProtocolLaunch's own methods
(i2(String) → boolean, j2() → void) give no further signature detail
without decompiling.
[inferred] This is more consistent with the classic Roblox desktop-client
"join ticket" — scoped to launching into one specific place/server, minted by
a browser session that is already fully authenticated — than with a
general-purpose account sign-in mechanism. It may still be genuinely useful
(a join ticket plausibly carries enough for the client to also treat itself as
signed in as that account for the session, which is instances-and-launch.md's
working theory), but this document did not find evidence settling whether a
join ticket alone produces a durable, reusable .ROBLOSECURITY-equivalent
cookie, or only an ephemeral per-join credential. This is the same open
question instances-and-launch.md §8 already lists ("verify the roblox://
URI format and whether tickets are genuinely single-use and session-scoped")
— this investigation corroborates that it's worth answering, without
answering it.
No path was found that is both (a) confirmed to work and (b) avoids either a WebView or a second physical/logical device. If forced to rank by plausible effort: §4.2 (magic login) is the cheapest if it turns out to work as inferred, §4.3 (join ticket) is worth resolving because another design already depends on it, and §4.1 (passkeys) is real but not small. None of these should be assumed as the plan without running something first — every one of them is currently a string in a dex, not an observed behaviour.
This section assumes a real .ROBLOSECURITY-style session cookie has been
obtained by some means outside Cordial (e.g. a real browser login performed
by the user, independent of Cordial itself — consistent with instances-and-launch.md
§7's "Cordial stores no credentials" stance) and asks what the smallest change
to Cordial would be to make the engine actually use it. It does not
propose building any login UI.
[verified: cordial] crates/cordial-runtime/src/bin/load.rs already
drives this exact sequence (line numbers as of this session):
NativeSettingsInterface.nativeSetFilesDirectory / nativeSetCacheDirectory /
nativeSetExternalDirectory / nativeSetBaseDataDirectories (~652-669)
MainGameActivity.nativeSetAssetPath (~676)
NativeSettingsInterface.nativeSetRobloxVersion / nativeSetRobloxChannel (~681,690)
NativeSettingsInterface.nativeSetDeviceInfo (~699)
MainGameActivity.nativeAppBridgeSetInitParams (~766)
...
MainGameActivity.nativePreloadFlagOverrides (~957)
MainGameActivity.nativeRetryInit (~1001)
NativeGLInterface.nativeAppBridgeV2InitWithParams (~1071)
NativeGLInterface.nativeAppBridgeStartLuaAppDM (~1082)
NativeGLInterface.nativeAppBridgeV2StartAppWithParams (~1116)
-
Call
NativeSettingsInterface.nativeSetMultipleCookies(domain, cookies)once the real cookie is available, beforenativeAppBridgeSetInitParams(~766) — so the engine's own HTTP client is carrying the session before the app-bridge sequence that will immediately start hittingauthenticated/*endpoints (§1.3) begins.nativeSetUserId(String)(§2.1, previously undocumented) should be called in the same neighborhood — its exact required timing relative to the cookie call is not verified and would need to be checked against behaviour, not assumed. -
Register a real
OnSetCookieHandlerviaJNICookieProtocol.updateOnSetCookieHandler(§2.2) so cookies the engine's own HTTP client subsequently receives (e.g. a session refresh) are observed rather than silently dropped — this is the callback direction Cordial has no equivalent of yet, structurally the same shape as the existingNativeHelper::gameActivity_onFlagsFailedcallback pattern already used innative/android_classes.cpp. -
Fill in
StartAppParams(init_params.cpp:730-732) —appUserId,username,isUnder13,membershipType— from the same real account, right beforenativeAppBridgeV2StartAppWithParams(~1116). -
Fill in
NativeUserJavaInterface(android_classes.cpp:227-250) —getUserId,getUsername,getDisplayName,getMembershipType,getHasRobloxSubscription,getIsUnder13— consistently with the same account, since the engine can call these at any later point and a mismatch with whatStartAppParamssaid would be a self-contradicting client. -
Verify against the log, not by assumption: rerun with
CORDIAL_MONITOR=1, capture the engine's own FastLog ($CORDIAL_FILES_DIR/files/appData/logs/*.logper the project's standing diagnostic), and check thatauthenticated/*calls now return 200 instead of the 401/403 pattern in §1.3, and that the tworbxthumb://failures in §1.3 are replaced by a real avatar id.
Nothing in steps 1-4 requires new JNI plumbing beyond what already exists in
native/init_params.cpp's cordial_call_static_strings/cordial_call_bare
helper family (§2.1's natives all fit the "static native, up to three string
args" shape those helpers already handle) plus one new callback registration
(step 2) shaped like the existing NativeHelper pattern. This is a
small, mechanical change conditioned entirely on having a real cookie to
put in it — which is the actual blocker, per §4.
| Question | Answer | Confidence |
|---|---|---|
| What does the engine expect differently when logged in? | Two identity mirrors (NativeUserJavaInterface, StartAppParams) plus, more importantly, a real cookie flowing through the engine's own HTTP client — the mirrors alone don't unblock authenticated/* calls. |
Verified (log) for the HTTP-layer part; the mirrors' role is inferred but low-risk to fill in regardless. |
| Where does the session live? | Three-way cookie sync between the engine's HTTP client and Android's WebView CookieManager, via NativeSettingsInterface's three cookie natives plus JNICookieProtocol's callback. |
Verified (dex, readelf, log) for the contract shape; nativeSetMultipleCookies itself was not observed firing. |
| Is a WebView required? | For captcha: yes, and expect it to fire in practice — even a native (CaptchaNative) captcha screen was found, so this stays unsettled but the WebView path still needs building regardless (§7.5). For plain username/password: no — FIntLuaAppLoginMethod (default "1") makes this Lua-rendered by default, confirmed by calling the engine's own native directly and by a LoginNative screen with real username/password copy shipped in the app's own content (§7). |
Captcha: verified-adjacent, refined but not overturned. Password path: verified, resolved from §3.3's "possibly not, unconfirmed." |
| Is there a path that avoids it? | No confirmed one. Local-device "magic login" is the most promising unverified lead; a join-ticket path already assumed by another design doc remains unresolved. | Inferred, both. |
| What's the minimum viable step? | Five mechanical calls into existing/adjacent JNI plumbing, entirely gated on obtaining a real cookie by some means outside Cordial. | Verified for the mechanics; the precondition (§4) is the real blocker. |
The one thing this document changes about the project's understanding of
its own blocker: it is not "Cordial has no auth." It is "Cordial has no way
to obtain a session, and every way of obtaining one that was found requires
either a WebView, a second device, or an unresolved ticket flow." The stub
code in NativeUserJavaInterface is not the blocker — it is honestly
reporting a true fact (nobody is signed in) and would need real data fed to
it regardless of which acquisition path gets built.
§3.3 above flagged NativeSettingsInterface.nativeIsLuaLoginEnabled() as the
single most valuable open question, unresolved because the logged-out capture
never reaches a login screen. This section answers it by tracing the dex
bytecode that consumes the native's return value, isolating the exact
FastFlag that controls it, and running Cordial with and against that flag —
a real control, not a guess.
No account was created, no credentials were entered, and nothing here typed into or submitted a login form.
The dex declares the native (as §2.1 already established), but nothing in
this project had previously traced who calls it or what branches on the
result. Disassembling the dex directly (via androguard, reading actual
instructions rather than inferring from names) settles both:
Luk/c; a ()Z
invoke-static Luk/c;->b()Z ; b() just calls the native and returns it
move-result v0
if-nez v0, +0bh ; if native said true -> return true
invoke-static Lel/s;->i()Z ; else check a second condition...
move-result v0
if-eqz v0, +003h
goto +3h
const/4 v0, 0
return v0
const/4 v0, 1
return v0
Lel/s; i ()Z
const/4 v0, 0
return v0 ; ...which is hard-coded to `false` in this build
So uk.c.a() — the actual gate everything else calls — reduces to exactly
nativeIsLuaLoginEnabled() in this shipping build; the second disjunct is
dead weight (a debug/staging override compiled out here).
uk.c.a() has exactly two callers in the whole APK, both handleNotification()-style
methods on Activity subclasses sharing a common base (com/roblox/client/a),
both handling the logout event (code == 101):
com.roblox.client.ActivityNativeMain.V(int, Bundle)com.roblox.client.RobloxWebActivity.V(int, Bundle)
In both, when uk.c.a() is true, the handler additionally:
- Sets a one-shot static flag (
tj.b.p()→Ltj/b;->e = true), and - Spins up an
AsyncTask(ActivityNativeMain$l) that checks whether the engine's renderingSurfaceis still alive and, if so, reuses it (Vi/e.w/Vi/e.x) instead of tearing it down.
That one-shot flag is consumed later, in ActivityNativeMain.e0() — the
handler for the engine's own onAppStarted callback — via tj.b.e() (a
read-and-clear getter), which re-triggers the same surface-reuse task once
the Lua app shell reports it has restarted.
What this means: nativeIsLuaLoginEnabled does not gate "render form A
vs form B" directly. It gates a logout → re-entry rendering-continuity
optimization: when true, the app keeps its OpenGL rendering surface warm
across a logout and hands it straight back to the restarting Lua app shell,
instead of tearing the surface down and rebuilding it. That behaviour only
makes sense if whatever the user lands on next — the post-logout
landing/login screen — is rendered inside that same Lua/GL surface rather
than by launching a separate WebView-hosting Activity. It is corroborating
evidence for the Lua-login reading, not the direct proof §3.3 was hoping for;
§7.4 below supplies the direct proof.
Searching the client-settings document (clientsettings.orig.json, a
previously-captured copy) for anything matching *Login* and *Lua*Login*
turns up ten plausible-looking candidates, none named exactly
IsLuaLoginEnabled:
FFlagEnableLuaLoginRevamp6 = True
FFlagEnableLuaLoginRevamp8 = True
FFlagLuaAppUsingSecurityQuestionsForLuaLogin2 = True
FFlagDisableAndroidLogInReleaseBuilds_IXP = "1;...;flagbank" (an IXP bucket string, not a plain bool)
FIntLuaAppLoginMethod = "1"
FIntLuaAppLoginRollout = "100"
FIntLuaLoginGenderSelector = "1"
strings on libroblox.so finds only the exported symbol name itself
(Java_com_roblox_engine_jni_NativeSettingsInterface_nativeIsLuaLoginEnabled)
— no flag-name string literal near it, so which flag the native actually
reads could not be settled by inspection. It had to be settled by running it.
Instrumentation added (uncommitted, in this worktree only): a new
zero-argument boolean-returning JNI call helper —
cordial_call_static_bare_bool in native/init_params.cpp, wired through
crates/cordial-linker-sys/src/lib.rs as call_static_bare_bool, invoked
once in crates/cordial-runtime/src/bin/load.rs right after flags and
client settings have both been delivered to the engine. It calls
NativeSettingsInterface.nativeIsLuaLoginEnabled() directly and prints the
result — [sign-in probe] nativeIsLuaLoginEnabled() -> <bool> — nothing
else. This does not drive any UI and does not run any dex/Java bytecode
(Cordial doesn't execute the APK's dex at all — see the caveat in §7.3); it
only reads the engine's own boolean answer.
A methodology note, for anyone re-running this: the worktree this was
run from predated crates/cordial-runtime/src/flags.rs (the flags-to-client-settings
merge pipeline described in that file's own doc comment as "the mechanism
that demonstrably works," with its own independently-verified control using
DFFlagRbxTransportUseRtcioRna). That file, plus the four-line change to
client_settings.rs that calls it, existed already on main but not on this
branch, so a first attempt at "set a flag in flags.json and see if anything
changes" produced two identical true results — not because the flag has no
effect, but because the file was being silently ignored by a binary that had
no merge step at all. Once flags.rs and the matching client_settings.rs
hunk were carried over (uncommitted; both already exist verbatim on main,
this only makes this branch's binary match it) and serde_json added to
cordial-runtime's Cargo.toml, the pipeline worked and gave the results
below. This is exactly the kind of gap the task's own warning about
disassembly-derived conclusions is trying to prevent — the fix was to notice
the run wasn't testing what it claimed to, not to trust the first "no
effect" result.
Control, then bisection, each a full cordial-load --run 25 under
CORDIAL_MONITOR=1, reading [sign-in probe] from stdout:
~/.config/cordial/flags.json |
nativeIsLuaLoginEnabled() |
|---|---|
| absent (control) | true |
| all 10 candidates forced off/0 | false |
the 5 real FFlag*/IXP candidates only |
true (no change) |
the other 5 (3 guessed-name flags + 2 FInt*) |
false |
just the 3 guessed-name flags (FFlagIsLuaLoginEnabled, FFlagLuaLoginEnabled, DFFlagIsLuaLoginEnabled — none of which exist in the real client-settings document, so these are pure no-ops that happen to get merged in as new, unread keys) |
true (no change) |
just FIntLuaAppLoginMethod=0 + FIntLuaAppLoginRollout=0
|
false |
just FIntLuaAppLoginMethod=0 alone |
false |
just FIntLuaAppLoginRollout=0 alone (Method left at its default 1) |
true (no change) |
Isolated result: FIntLuaAppLoginMethod is the controlling flag. Its
shipped default is "1", which is why the honest, un-overridden control run
already returns true — Lua login is the default in this build, not
something that needs turning on. Setting it to 0 reproducibly flips
nativeIsLuaLoginEnabled() to false. FIntLuaAppLoginRollout (a
percentage-shaped name, default "100") does not independently affect it —
consistent with it being a server-side experiment-allocation knob rather
than something the client itself branches on directly. None of
FFlagEnableLuaLoginRevamp6/8, FFlagLuaAppUsingSecurityQuestionsForLuaLogin2,
or FFlagDisableAndroidLogInReleaseBuilds_IXP — despite all sounding
relevant by name — moved this particular native at all; they likely gate
sub-features of the Lua login experience (a UI "revamp," a security-questions
step) rather than the native/WebView choice itself. Ruling those out by
running them, rather than assuming from the name, is itself a finding.
The task asked to turn the flag and observe, meaning: see whether the
rendered UI changes. It doesn't, not yet, and here is exactly why: Cordial
does not execute the APK's dex bytecode. Everything this section traced in
§7.1 — uk.c.a(), ActivityNativeMain.V, RobloxWebActivity.V, tj.b —
is Java code that runs on Android's ART VM. Cordial only loads
libroblox.so natively and drives its exported entry points directly from
Rust/C++, standing in for the Java caller by hand (exactly the pattern
cordial_call_static_strings and friends already use, and the one this
section's new cordial_call_static_bare_bool also uses). There is no ART,
so there is no code path that would ever call uk.c.a(), branch on it, or
show a login screen as a consequence — regardless of what the native
answers.
So what was actually verified is narrower than "the UI changes": it is that
the engine's own native call, invoked directly and read directly, answers
true by default and can be flipped to false by a specific, isolated,
real FastFlag — a fully mechanical fact about the shipped binary,
independent of any UI. Whether flipping it would visibly change a rendered
screen is not something Cordial can currently observe, because Cordial has
no login-launching UI of its own yet and no dex execution to drive the real
one. That gap is orthogonal to this question and unchanged by this
investigation.
7.4 Is there a Lua-rendered login UI in the shipped content? Yes — directly, not just inferred [verified: rbxm strings]
assets/ExtraContent/models/UniversalApp/UniversalApp.rbxm — part of the
Lua app shell's own content, extracted from the APK — is a Roblox binary
file (<roblox! header, zstd-compressed chunks). Decompressing its chunks
(a from-scratch reader was needed; no existing tool in the environment
handles this format, and androguard/binary-format libraries don't apply to
it) and running strings over the ~46 MB decompressed property chunk finds
a complete, multi-language localization table for a full authentication
UI, including, among many others:
Authentication.Login.Heading.Login
Authentication.Login.Label.Username
Authentication.Login.Label.Password
Authentication.Login.Label.Email
Authentication.Login.Label.UsernameEmailOrPhoneNumber
Authentication.Login.Label.UsernameEmailPhone
Authentication.Login.Action.Next
Authentication.Login.Action.LogInEmailOneTimeCode
Authentication.Login.Action.SendVerificationEmail
Authentication.Login.Description.PasskeyDescription
Authentication.Login.LinkIllegalChildAccountLinking (mentions a QR code)
Authentication.CrossDevice.Label.LoginInstructions
Authentication.CrossDevice.Label.ConfirmAndLoginAs
Authentication.CrossDevice.Response.LoginSuccess
translated into at least French, Turkish, Italian, Polish, Spanish, and a
China-specific "Luobu"-branded variant. Authentication.CrossDevice.*
independently corroborates §4.2's "magic login" lead (an already-signed-in
device confirming a login for a new one) with real shipped copy, not just
suggestively-named dex classes.
More directly still, the same file contains what reads as the app shell's own registry of named, navigable screens — a flat array of Pascal-style strings, one screen name each:
...SinglePageSignUp MultiPageSignup LuobuSignUpPage Landing Birthday
LoginNative CaptchaNative ViewFriends SearchUsers ShareSheet SdkShare
ReportAbuse ReportScreen AddFriendsPage ConnectionsHub CoHubMyConnections
CoHubAddConnections ScanQrCode GenericOpaqueWebPage ChallengeHybridOverlayPage
ChallengeHybridWebView PhoneVerification EditUsername PassesPage ...
LoginNative is a real, first-class screen name the app shell's own
navigation system recognises — and it is the only login-shaped screen name
anywhere in this file; LoginWeb, LoginWebView, and WebViewLogin all
return zero matches. Screens that genuinely are WebView-hosted are named
accordingly right alongside it in the same list —
GenericWebPage/GenericOpaqueWebPage, AddFriendsWebView,
ChallengeHybridWebView — which is good corroboration that this naming
convention is real and consistently applied, not incidental.
This directly confirms the premise §3.3 could only infer: the shipped Lua
app shell has its own native login screen, LoginNative, with full
username/password copy, and it is a named peer of screens that really are
WebView-hosted, distinguished by the same naming convention. Combined with
§7.2 (Lua login is the shipped default) and §7.1 (the logout path's own
surface-continuity behaviour assumes the next screen is Lua-rendered), the
three independent lines of evidence now agree.
Caveat: finding the string LoginNative in a screen-name enum proves the
screen is defined and named, not that it is reachable, wired up, or free of
its own dependency on WebView-hosted sub-flows once inside it (e.g. a "log
in with Google" button on that screen could still open a WebView without
that changing the screen's own name or its username/password fields). No
navigation into any screen was attempted — that would require pressing UI
elements this task's constraints put out of scope for anything past
observation.
7.5 Does captcha still block it? Narrowed, not settled — and one part cuts against the convenient answer [verified: rbxm strings]
The same screen-name list contains CaptchaNative immediately next to
LoginNative — a native-rendered captcha screen, distinct from the
WebView-shaped ChallengeHybridWebView/ChallengeHybridOverlayPage entries
in the same list (which read as the natural landing spot for
ActivityFunCaptcha's URL-based LoginCaptchaConfig/SignUpCaptchaConfig,
per §3.2). Both LoginNative and CaptchaNative occur exactly once in the
file — as enum entries only; no second reference to either was found, so
there is no evidence here of which triggers when, or that CaptchaNative is
actually wired to anything live.
The part that cuts against the convenient reading: the same file also
contains Turnstile/turnstile (Cloudflare's captcha widget) and
CaptchaV2/captchav2 strings. Turnstile is, in every other context it
ships in, a web/JS widget — its presence alongside CaptchaNative reads
more like "this build supports more than one captcha backend, selected
server-side by risk assessment" than "captcha has moved off WebView." This
document already said (§3.4) that captcha is risk-based and server-decided,
not something the client predicts — that stands. CaptchaNative's existence
is real and new, and worth someone confirming by actually reaching a captcha
challenge on a real account, but it does not license "a WebView is no longer
needed for captcha." A WebView implementation should still be budgeted
for.
-
§3.3's open question is now answered, not just narrowed: plain
username/password login is Lua-rendered by default in this shipped build
(
FIntLuaAppLoginMethod = "1"), the engine's own native agrees when called directly, and the shipped Lua content itself independently confirms aLoginNativescreen exists with full username/password copy. §3.3's "possibly not" becomes "verified: no, not for the base form." - What is still true and unchanged: §3.2's WebView-based captcha finding stands; §7.5 adds a native captcha screen to the picture without replacing it. §4's verdict (no confirmed WebView-free path to a session, i.e. §2's actual blocker) is untouched — this section is about what renders the form, not about how Cordial would obtain a real cookie, which remains the real blocker per §4 and §6.
- What is newly true and worth carrying forward: if a login-driving UI is ever built in Cordial, the rendering-technology question that used to gate the whole plan (embed a browser, yes or no) has a real, verified answer for the base form: no, not for username/password entry itself. A WebView is still very likely needed somewhere in the full flow (captcha most likely, federated/passkey login certainly), so it is not removed as a dependency of the project — but it is no longer required to render the first, most common screen a user would see.
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