Skip to content

Add a native Android frontend (Android/) - #762

Open
999sian wants to merge 149 commits into
LIJI32:masterfrom
999sian:android-frontend
Open

999sian wants to merge 149 commits into
LIJI32:masterfrom
999sian:android-frontend

Conversation

@999sian

@999sian 999sian commented Jul 4, 2026

Copy link
Copy Markdown

A native Android frontend for SameBoy

This adds a standalone Android app under Android/. The portable C Core is reused
unmodified — it's driven through a thin C/JNI bridge, with the app layer in Java/Kotlin.
The diff is additive only: every file lives under Android/ or docs/, plus one new
CI workflow (.github/workflows/android.yml). No existing SameBoy source is touched.

Opening this for your consideration — happy to adjust scope, split it up, or maintain it out
of tree if you'd prefer it not live in the main repo.

What it does (parity program M1–M9)

  • Emulation — EGL/GLES2 integer-scaled render + AAudio (48 kHz) + touch controls + battery saves.
  • State & session — save states (4 slots + thumbnails), turbo, rewind, model switch.
  • Library — ROM folder scan (SAF), recents, background metadata read.
  • Settings — color correction, palette, audio, controls (SharedPreferences-backed).
  • Color & theme — DMG palette picker (built-ins + custom editor), light/dark app theme.
  • Physical input — remappable hardware gamepad, rumble, on-screen-control auto-hide.
  • Peripherals — Game Boy Printer feed (save/share) + Game Boy Camera (Camera2).
  • Link cable — TCP serial link between two instances (byte-level master/slave bridge).
  • UI — Cupertino-style Compose shell, adaptive width, optional GB console skin.

Architecture

Android/jni/ is the whole native surface:

  • ring_buffer.* — SPSC audio ring; a full ring blocks the producer, so audio paces emulation.
  • emulator.* — owns GB_gameboy_t, callbacks, double-buffered framebuffer.
  • audio_aaudio.* — AAudio output stream draining the ring.
  • render_gles.* — EGL/GLES2 render thread (integer-scaled blit from a locked copy).
  • session.* — ties emu thread + render + audio into one session.
  • sameboy_jni.c — JNI entry points.

Android.mk builds libsameboy_core.so from ../Core/*.c unmodified. Boot ROMs are
generated at build time from ../BootROMs/*.asm (build succeeds boot-ROM-less if rgbds is
absent). Native libs are 16 KB-page-aligned for Android 15+/Play.

Verification

  • Host unit tests (jni/test/run_host_tests.sh, no device): ring buffer (FIFO, underrun
    zero-fill, blocking-pacing, teardown), emulator-on-a-real-ROM (60 frames → opaque 160×144
    framebuffer + audio produced), link-cable two-core byte exchange.
  • On-device (Waydroid x86_64 + physical Samsung SM-T505 arm64 / Android 16): boots +
    renders GB/GBC, live AAudio, touch + hardware input; dmg-acid2 pixel-correct on arm64;
    commercial CGB titles run in color (Pokémon Crystal, Pokémon Pinball rumble cart, Tetris DX);
    save states persist; R8 release build runs on both ABIs; TCP link reaches Connected with
    graceful teardown. Also smoke-tested on a physical Moto G4 (short-screen portrait layout)
    and a Pixel 8 Pro.

Known limitations / follow-ups

  • Large-screen orientation policy (Android 15+) — portrait-optimized tablet layout is a UX follow-up.
  • Rumble motor unverified on hardware without a vibrator (callback path exercised safely).
  • Link cable is TCP-only; per-frame slave poll bounds throughput (fine for turn-based trades).
  • Audio-device-disconnect (headphone unplug) reconnection not yet wired.
  • No release keystore committed (signing sourced from env; falls back to debug key for local testing).

Note on one commit

The final commit (fix(android): hint true 59.7275fps cadence…) addresses scrolling stutter
on variable-refresh (LTPO/120 Hz) panels via Surface.setFrameRate(59.7275, FIXED_SOURCE).
It's API-verified against compileSdk 34 but not yet verified on the Pixel 8 Pro hardware
called out for transparency.

sian added 30 commits July 1, 2026 20:05
… SAF display-name saves, JNI/ROM guards, copy_front test
sian added 21 commits July 3, 2026 05:55
…ait play, 8-way d-pad, surface follows layout
…EmulatorActivity has no AppCompat night resources)
…o the d-pad clears the well (found on Moto G4, 360x580dp)
…n short screens (5x not 4x on 360x580dp); menu pill centers between Select/Start
The core is audio-clocked at the Game Boy's 59.7275fps; the render thread
free-runs into eglSwapBuffers at the panel rate. On fixed-60Hz panels
(Moto G4) 60/59.73 is ~1:1 and smooth, but on variable-refresh LTPO panels
(Pixel 8 Pro) SurfaceFlinger presents at 120Hz where 120/59.73 ~= 2.009 --
the 2:1 frame-doubling slips a frame ~once a second (scrolling judder) and
the panel rate oscillates hunting a match.

Call Surface.setFrameRate(59.7275, FIXED_SOURCE) on surface-ready (API 30+,
guarded) so the compositor locks to a clean multiple. API verified against
compileSdk 34; not yet verified on-device (Pixel 8 Pro).
…ate via env

The Decode keystore step used `if: ${{ secrets.SAMEBOY_KEYSTORE_BASE64 }}`.
The secrets context is not available in step if: conditionals, so the whole
workflow file was rejected at startup (startup_failure) — taking the build job
down with it, even on PRs. Map the secret into env and gate in shell instead;
export SAMEBOY_KEYSTORE via GITHUB_ENV only when present (else debug-key fallback).
CI had no rgbds, so generateBootRoms skipped and the APK shipped an empty
assets/bootroms/. Boot-ROM-less boot hangs some CGB titles (Pokémon Crystal)
on a white screen — emu thread runs but the ROM loops at startup. Install
rgbds (Ubuntu 24.04 ships 0.7.0) so boot ROMs build, and add a verify step
asserting the .bin assets are present so an empty bundle can never ship again.
…stutter)

Measured on a Pixel 8 Pro: the emu, render and audio-producer threads all ran
pinned to cpu0 (a 1.70GHz A510 little core) at default priority, while the
2.37GHz mid cores and 2.91GHz big core sat idle. Android's EAS scheduler parks
default-priority app threads on a little core and lets it idle/throttle between
the audio-clocked emu loop's bursty per-frame wakeups, so the loop wakes late —
underrunning the audio ring (crackle) AND missing the frame deadline (hitch) at
once. Homogeneous SoCs (Moto G4, all A53) show no penalty, which is why it was
Pixel-only.

Add sched_hint: raise the emu thread to URGENT_AUDIO (-19) and render to
DISPLAY (-4), and bias both off the slowest core tier (detected via cpufreq
sysfs; a no-op on homogeneous SoCs). No emulation-accuracy change — native
59.7275fps and correct pitch are preserved. Host session test links the new TU.
…he beat

Surface.setFrameRate is only a content hint; SurfaceFlinger's touch-boost
overrides it and kicks the LTPO panel to 120Hz during play, reintroducing the
120/59.7275 frame-duplication beat. WindowManager preferredMin/MaxDisplayRefreshRate
is a DisplayModeDirector vote that survives touch-boost — clamp both to 60 (the
lowest mode >= the GB's 59.7275fps). API 30+, no-op on fixed-60 panels (Moto G4).
Reasserted in onResume since window attrs can reset across pause.
…n't in SDK 34)

preferredMin/MaxDisplayRefreshRate aren't public in compileSdk 34 (build broke:
cannot find symbol). Use preferredDisplayModeId instead — it selects an exact
Display.Mode and is the strongest app-level refresh lever, surviving touch-boost.
Pick the mode at the current resolution with refresh closest to 59.7275fps (=60Hz)
so resolution never changes. API 23+, no-op on fixed-rate panels.
…e + min/max reflection)

preferredDisplayModeId alone pins the physical mode but LTPO render-rate governors
still drift: OnePlus Pad 2 idles the RENDER rate to 50Hz while the emu produces
59.73fps, dropping ~10 frames/s (steady video hitch). Stack all levers: modeId +
preferredRefreshRate (public) + preferredMin/MaxDisplayRefreshRate=[hz,hz] via
reflection (public since API 30, missing from some SDK stubs) to clamp the render
rate to exactly 60. No-op on fixed-rate panels.
…render to 50

setFrameRate(59.7275, FIXED_SOURCE) makes OnePlus's render governor map to a
50Hz render rate, dropping ~10 emu frames/s (steady video hitch). Request
60.0 DEFAULT instead — SurfaceFlinger picks the 60 render rate; emu still runs
59.7275fps (1 dup/3.7s, accurate).
… hitch)

The render thread free-ran and re-sampled the framebuffer at the panel's vsync,
beating against the emu's audio clock and posting duplicate frames. That made
SurfaceFlinger's content-rate detector see the panel rate (OnePlus: idles render
to 50Hz -> drops ~10 emu frames/s -> hitch).

Couple render to production: emu bumps frame_seq + signals frame_cv per completed
frame; the render thread blocks in sb_emu_wait_frame until a new frame exists
(100ms timeout bounds shutdown/pause), then presents exactly that frame. Now it
posts at the true ~59.73fps in-order, no duplicates, no re-sample beat — and SF
sees ~60fps content. sb_emu_wake() unblocks the waiter on stop.
On-device finding: targeting 60Hz is a trap — it's just above the GB's 59.7275,
so LTPO governors idle the render rate BELOW it (OnePlus Pad 2 → 50Hz), dropping
~10 emu frames/s (persistent video hitch that no 60-targeting lever fixed).
Retarget the mode selector to the HIGHEST near-integer-multiple refresh (≈120 on
120/144 panels; 60 fallback on 60-only). With present-on-produce the emu posts
59.7275 unique frames and every one is shown (rate ≥ GB rate → zero drops).
Reject 144 (2.41× → uneven scanout). Pin mode + request pinnedHz.
…-produce at 120Hz)

Measured on OnePlus Pad 2 with Game Space forcing 120Hz: present-on-produce posts
each frame at production time, async to the 120Hz vsync grid, so frames land on
1/2/3-tick boundaries unevenly (stdev 3.58ms, 15 hitches/6s). Free-running
re-sample posts every vsync and lets the compositor hold each unique emu frame a
consistent run of refreshes (2 at 120Hz) — Pixel-proven stdev 1.17ms. Correct
approach once the panel holds a rate >= the GB rate.
@LIJI32

LIJI32 commented Jul 10, 2026

Copy link
Copy Markdown
Owner

This will probably work better as a separate repo with SameBoy being a subrepo in it, since I won't be able to maintain and support this frontend.

sian added 8 commits August 31, 2026 20:20
…ixes, release CI hardening

Native (jni/):
- AAudio error callback + emu-loop stream reopen: headphone unplug / BT route
  change no longer permanently freezes emulation (ring flushed to unblock a
  stuck producer; reopen on new default device from the emu thread)
- Unchecked mallocs in sb_emu_create / sb_emu_set_boot_rom / sb_emu_save_battery
  (OOM crash + battery-save data-loss paths)

App:
- UAF: Printer/Link activities validate stale native ctx from Intent extras
  after EmulatorActivity destruction (activeCtx liveness token)
- ANR: SAF display-name binder query moved off the main thread
- Data-loss race: MainActivity onResume library reload no longer wipes an
  in-flight scan's unsaved adds
- Thread leak: io executors shut down in onDestroy

CI:
- Release job installs rgbds + verifies boot ROMs are bundled in the APK
  (release APK previously shipped boot-ROM-less: white screen on CGB titles)

Bump version.mk to 1.0.4 (versionName 1.0.4 / versionCode 10004)
…B, LCD filter

Targets physical-controller handhelds (e.g. Retroid Pocket Classic) where
on-screen touch controls waste space and D-pad navigation had no visible focus.

- Fill-screen: 'On-screen controls: Auto/Always/Never' setting. Auto hides the
  touch overlay when a physical controller is connected; the emulator screen
  then fills the display (aspect-correct, immersive bars). Renderer letterbox
  made fractional so the full panel is used (windowed console layouts stay
  bit-identical: their rects are exact 160/144 multiples). A gamepad button
  (default L1) opens the in-game menu so hidden controls never trap the user.
  Controller detection guards against Android's synthetic-keyboard DPAD.
- ROM picker: tiles are now focusable with a high-contrast focus ring + scale;
  first tile focused on show; D-pad traverses, A/Enter plays, Menu/Y = context.
- Super Game Boy: MODEL_SGB (0x004) selectable in Settings and the in-game
  model switcher, making the existing SGB border option render borders (core +
  boot ROMs already present; no native change needed).
- LCD dot-matrix filter: new GLES2 shader (step-based per-source-pixel grid,
  gated to >=2x, highp) with 'Screen filter: Off/LCD', plumbed via
  sb_session_set_filter / nativeSetFilter.

Bump version.mk to 1.0.5. Native host tests pass.
Borders became reachable in 1.0.5 (SGB model), but the layout still assumed
160:144 everywhere, so a bordered frame (256x224) was letterboxed inside a
wrongly-shaped surface: smaller image, black bars.

- Geometry now derives from the Core's actual output size. New cheap
  nativeScreenSize (packed (w<<16)|h, no frame copy) feeds GBLayout's
  fullscreen, landscape and portrait paths; 160x144 output stays
  bit-identical. Size is re-checked after start, resume, menu close and via
  the existing 2s battery poll, so a border appearing mid-boot re-lays out.
- TouchOverlayView tracks the source size so the console body/bezel follows
  the wider well.
- Settings: clearer border labels + a footnote that 'SGB games only' needs the
  SGB model, that 'Always' works on any model (console art, or the game's own
  SGB border), that the first load can pause while that border is extracted,
  and that a border shrinks the picture.
- In-game menu gains a Border switcher (live; no reboot needed --
  GB_set_border_mode applies between frames).

Native audit found the 256x224 path already correct end to end (buffers,
texture re-upload, save-state/rewind). Adds host tests for screen size:
default, live NEVER->ALWAYS->NEVER, full 256x224 coverage, save-state
roundtrip. All host tests pass.

Bump version.mk to 1.0.6.
- In-game menu: action-sheet rows and save/load slot cards now show a focus
  highlight and the first usable row takes focus on open, so a D-pad/A/B
  controller can drive every screen (previously the cursor was invisible and
  nothing was focused, so only touch worked). Gameplay consumes d-pad keys, which
  keeps the window in touch mode where Compose rows refuse focus; openMenu()
  leaves touch mode first when a gamepad is present.
- LCD filter: replace the home-grown 1px step grid (jittered at fractional
  fill-screen scales) with a straight GLES2 port of Shaders/MonoLCD.fsh incl.
  MasterShader gamma, i.e. the desktop 'Monochrome LCD' look. No scale gate.

Bump version.mk to 1.0.7.
Many pads (and Retroid firmware in Xbox mode) report the d-pad only as
AXIS_HAT_X/Y; only gameplay translated that, so the in-game menu, library,
settings, remap, link and printer screens ignored the d-pad entirely.
GamepadMapper.hatToDpadKeys synthesizes DPAD key events from hat edges;
GameMenuDialog and a new DpadActivity base (all non-gameplay activities)
route generic motion through it.
Activity/lifecycle
- EmulatorActivity survives every config change (BT pad pairing flips
  navigation/keyboard, dark-theme schedule flips uiMode, split-screen/unfold):
  previously each one recreated the Activity, freed the Core and rebooted the
  ROM from its last .sav. onConfigurationChanged re-themes the overlay.
- Hat/stick d-pad latch released on window focus loss (menu, shade): the
  centre MOVE goes to the new window, so the Core kept walking.
- Unbound gamepad buttons are swallowed: Generic.kcm's BUTTON_B->BACK fallback
  finish()ed the game while the ROM was loading or after a remap.
- Menu->Settings/Printer/Link handoff drops the battery poll so it can't
  briefly unpause behind the closing dialog; accessory sheet no longer shows
  a stale printer tick after hosting/joining a link.
- Camera permission: check the grant before the denied latch (a later grant
  in system Settings now works), and request exactly once per foreground.
- Game Boy Camera frames rotated to the display (SENSOR_ORIENTATION) and
  mirrored for a front lens; they were sideways in portrait.
- In-game menu honours the in-app Light/Dark override.
- Hat-switch d-pads now work inside Compose Dialog windows (Settings pickers,
  palette editor, library sheet) via a Window.Callback bridge.

Native
- Link: an unarmed slave answers every master byte with 0 bits (Core
  semantics) instead of leaving it unanswered; the master only latches dead
  after 4 consecutive failed exchanges. One side reaching link mode first, or
  the peer sitting in its menu, no longer kills the link after 1.5 s.
  Host test added (parked slave; fails on the old code).
- Link connect: AI_NUMERICHOST so a typo can't start an uncancellable DNS
  lookup that disconnect() then joins on the UI thread (ANR).
- AAudio: keep retrying open (~1/s) instead of staying silent for the session
  when the new route wasn't ready at the moment the old stream died.

Storage
- SaveStore.write is atomic (tmp+fsync+rename) and reports failure; a kill
  mid-write no longer destroys the previous state/battery, and a failed state
  write no longer leaves a thumbnail for a state that doesn't exist.
- Library survives MainActivity recreation mid-scan (process-scoped), so a
  theme toggle during import no longer loses the scanned entries.
- Re-importing a renamed/moved ROM refreshes its SAF URI on CRC match.
- Zips with non-UTF-8 (Windows OEM code page) entry names no longer throw
  MALFORMED and get dropped.
- Printer 'Save to Pictures' on API 26-28 notifies MediaStore.
… asserts

- GB_DISABLE_TIMEKEEPING: the blocking audio ring is now the only clock. With
  the Core's gettimeofday pacer also compiled in, the two fought — on a device
  whose audio HAL clock runs faster than the wall clock the ring never filled
  and every burst underran: a periodic click (confirmed on Pixel 8 Pro). The
  turbo speed cap moves into emu_loop, enforced against CLOCK_MONOTONIC.
- NDEBUG: upstream's release CONF sets it; our NDK build never did, so
  GB_CONTEXT_SAFETY and every Core assert() were live in the shipped APK and
  would SIGABRT instead of being no-ops. Host tests keep asserts on purpose.

Bump version.mk to 1.0.8.
- Auto-save: onPause writes a state to states/<rom>.auto; next launch asks
  'Continue where you left off?' (controller-navigable, non-cancelable). The
  emu stays parked behind the prompt; a failed load deletes the file.
- Fast forward: new gamepad binding (default R1, remappable next to Menu);
  hold to turbo, released on focus loss / pad unplug like the Core keys.
- Cheats: GameShark / Game Genie via GB_import_cheat; per-ROM list in
  cheats/<rom>.json; in-game menu > Cheats (toggle, add with inline
  validation, remove). Cheats live in the Core's unsaved section so they
  survive reset, model switch and state load (host-tested).
- MBC7 tilt: phone accelerometer -> GB_set_accelerometer_values, registered
  only while resumed with an MBC7 cart, axes mapped per display rotation.

Native: 5 JNI entries (cheats park; accelerometer direct — no Core assert).
Host tests cover add/reject/persistence/enable/remove-all and accel guards.
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.

2 participants