Skip to content

Ship Swappy's Java classes to stop in-memory DEX loading - #50

Open
cameronaaron wants to merge 1 commit into
OpenBubbles:wordhuntfrom
cameronaaron:fix/swappy-dcl-in-memory-dex
Open

Ship Swappy's Java classes to stop in-memory DEX loading#50
cameronaaron wants to merge 1 commit into
OpenBubbles:wordhuntfrom
cameronaaron:fix/swappy-dcl-in-memory-dex

Conversation

@cameronaaron

Copy link
Copy Markdown

The bug

On hardened Android builds (GrapheneOS with the per-app "DCL via memory" restriction), the :godot process aborts on the render thread:

DCL denial type: InMemoryDexFile
process: com.openbubbles.openpigeon:godot   thread: VkThread
java.lang.SecurityException
  at android.ext.dcl.DynCodeLoading.checkInMemoryDexFileOpen(DynCodeLoading.java:115)
  at dalvik.system.InMemoryDexClassLoader.<init>(InMemoryDexClassLoader.java:63)
  at org.godotengine.godot.GodotLib.step(Native Method)
  at org.godotengine.godot.vulkan.VkRenderer.onVkDrawFrame(VkRenderer.kt:86)

Root cause

Not our code — Godot statically links Google's Swappy frame pacer and enables it by default (display/window/frame_pacing/android/enable_frame_pacing, which project.godot does not override).

  1. On swapchain creation, inside step() on the render thread, rendering_device_driver_vulkan.cpp calls SwappyVk_initAndGetRefreshCycleDuration().
  2. SwappyCommon's constructor gates the display manager on usesMinSdkOrLater() (SDK >= 28) rather than useSwappyDisplayManager() (which excludes SDK >= 31), so it constructs a SwappyDisplayManager even on current Android.
  3. That calls gamesdk::loadClass(), which tries the app's class loader first and falls back to a classes.dex blob linked into libgodot_android.so, loaded via InMemoryDexClassLoader.
  4. Godot's AAR ships no com.google.androidgamesdk classes, so step 3 always takes the fallback.

That fallback is dynamic code loading. GrapheneOS rejects it, and Swappy calls CallObjectMethod on the null result without checking for the pending exception — so the process aborts rather than degrading.

Confirmed against the binary, not inferred: libgodot_android.so (4.7.0.stable) contains the literal dalvik/system/InMemoryDexClassLoader plus swappy:: symbols, and an embedded DEX at offset 71136592 holding exactly SwappyDisplayManager, ChoreographerCallback, and GameSdkDeviceInfoJni.

The fix

Swappy checks the app class loader first, so shipping the two classes it asks for makes the DEX fallback unreachable. Frame pacing keeps working — this is not a workaround that disables it.

  • ChoreographerCallback.java / SwappyDisplayManager.java — vendored from AGDK (Apache-2.0), byte-identical to upstream below the added header comments, signatures cross-checked against the DEX decompiled out of Godot's own .so. GameSdkDeviceInfoJni is referenced only inside that DEX and never by native code, so two classes is the complete set.
  • proguard-rules.pro — the build already referenced this file but it did not exist. R8 cannot see JNI RegisterNatives bindings, so keep rules prevent a future isMinifyEnabled = true from silently reintroducing the bug.
  • SwappyClassLoadingTest.kt — asserts the exact property Swappy branches on: the slash-separated names it passes resolve through the app class loader, with the bound members intact.

Verification

Run on an emulator (SDK 36, arm64), reading Swappy's own log:

Swappy log line
without these classes I SwappyDisplayManager: Using internal com/google/androidgamesdk/SwappyDisplayManager class from dex bytes.
with these classes (absent) — only Starting looper thread

That line is the ALOGI inside the InMemoryDexClassLoader branch itself, so this is direct observation of the crashing path being switched off.

The instrumented test passes 2/2 with the classes and fails 2/2 without them, with ClassNotFoundException: Didn't find class "com/google/androidgamesdk/SwappyDisplayManager" — verbatim the exception gamesdk::loadClass catches to enter the fallback.

Only SwappyDisplayManager is loaded on modern Android, as the source predicts (the NDK choreographer wins on SDK >= 30); ChoreographerCallback is included for the pre-30 devices minSdk 26 still allows.

Not verified: I confirmed the fallback no longer fires on stock Android, but had no GrapheneOS device on hand to watch the memory_DCL denial not happen.

Upstream notes

Two AGDK bugs worth reporting separately: the missing ExceptionCheck between NewObject and CallObjectMethod in gamesdk::loadClass (turns a recoverable denial into an abort), and the usesMinSdkOrLater / useSwappyDisplayManager mismatch that constructs a display manager Swappy itself declares unnecessary on SDK >= 31.

Testing notes

./gradlew :app:connectedDebugAndroidTest -PskipGodot -Pandroid.testInstrumentationRunnerArguments.class=com.openbubbles.openpigeon.godot.SwappyClassLoadingTest

🤖 Generated with Claude Code

Godot statically links Google's Swappy frame pacer and enables it by
default. On every swapchain creation -- on the render thread, inside
GodotLib.step() -- SwappyCommon constructs a SwappyDisplayManager, whose
native constructor calls gamesdk::loadClass(). That helper asks the app's
class loader first and, only when it throws, falls back to a classes.dex
blob linked into libgodot_android.so, loaded via InMemoryDexClassLoader.

Godot's AAR ships no com.google.androidgamesdk classes, so the fallback
fired on every launch. That is dynamic code loading: hardened Android
builds (GrapheneOS "DCL via memory") reject it with a SecurityException,
and Swappy calls CallObjectMethod on the null result without checking for
the pending exception, aborting the render thread.

Vendoring the two classes Swappy looks up makes its first lookup succeed,
so the DEX fallback is never reached and frame pacing keeps working. Both
files are byte-identical to upstream AGDK below their added headers, and
their signatures were checked against the DEX extracted from Godot's own
.so. Keep rules are added because R8 cannot see JNI RegisterNatives
bindings, and the build already referenced a proguard-rules.pro that did
not exist.

Verified on an emulator by reading Swappy's own log: without these classes
it prints "Using internal com/google/androidgamesdk/SwappyDisplayManager
class from dex bytes."; with them that line is gone. The instrumented test
asserts the same branch condition and fails with ClassNotFoundException if
the classes are dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ty8447

ty8447 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Reviewing the files in this PR it seems like most of the changes were made for testing/compatibility but are not needed in the PR. These files would be ChoreographerCallback.java and SwappyClassLoadingTest.kt and proguard-rules.pro. The real fix seems to be in SwappyDisplayManager.java and its associated ThirdPartyNotice update.

@ty8447

ty8447 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

You also note that you haven't tested this at all on a GrapheneOS device which you should probably do before this PR is merged

@willh34

willh34 commented Sep 7, 2026

Copy link
Copy Markdown

if y'all build an apk I can test it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants