[READ-ONLY] Mirror of https://github.com/aaronateataco/AndroidHorizonNX. WIP
0

Configure Feed

Select the types of activity you want to include in your feed.

Makefile 61.0%
C++ 37.9%
C 1.1%
Shell 0.1%
74 1 1

Clone this repository

https://tangled.org/aaronworld.uk/AndroidHorizonNX https://tangled.org/did:plc:ne33cjpabnygfkpzq536iqmn
git@tangled.org:aaronworld.uk/AndroidHorizonNX git@tangled.org:did:plc:ne33cjpabnygfkpzq536iqmn

For self-hosted knots, clone URLs may differ based on your setup.



README.md
Android Horizon logo

Android Horizon#

Run Android games natively on Nintendo Switch Horizon OS — without Android

License: Source Available GitHub Stars Status Built with Claude AI Platform

Made by aaronworld.uk · Give it a star ⭐ if you find it interesting!


Disclaimer — Please Read

Approximately 99.8% or more of the code in this project was written by Anthropic's Claude AI (claude-sonnet-4-6). I (the author) am not a developer and am not capable of writing this myself. Claude and I are working on this together as an experiment. I handle testing on real hardware, describe problems, and decide direction; Claude writes and debugs the code.

This project is in extremely early stages. It may not run anything at all reliably yet. Use entirely at your own risk.


What Is This?#

Android Horizon is a compatibility / translation layer that lets Android native (NDK) games run directly on the Nintendo Switch's Horizon OS — the Switch's real operating system.

This is NOT Android running on the Switch. There is no Android OS, no emulator, no virtual machine. The game's ARM64 machine code runs directly on the Switch's Tegra X1 processor, the same chip Android phones use. A thin shim layer fakes just enough of the Android runtime (libc, OpenGL ES, JNI, asset management) that the game's native .so library doesn't notice it isn't on Android.

Think of it like Wine on Linux — not emulation, but translation.

Current Focus#

We are currently only attempting simple, old, 2D Android games — specifically games that:

  • Ship an arm64-v8a native library (.so file)
  • Use OpenGL ES 2.0 or 3.0 for rendering
  • Have no Google Play Services dependency
  • Save locally (no cloud saves required)
  • Do not require network/online connectivity

Our test target is Hill Climb Racing 1.x by Fingersoft — a simple 2D physics game with no online requirement, widely used in compatibility testing.

What We've Achieved So Far#

Hill Climb Racing is playable on real Switch hardware. Not "boots" — playable: touch-steering the car up the hill, engine and music audio, a locked 60 fps, in-game HUD, coins and gems. This is real Android NDK game code, unmodified, running directly on the Tegra X1 through switch-mesa — no Android OS anywhere in the loop.

The full pipeline that works today:

  • The NRO launches from hbmenu (or an application-mode forwarder) with a themed APK browser UI
  • APK extraction — native libraries and assets unpacked from the .apk onto the SD card
  • All three ELF binaries (libapplovin-native-crash-reporter.so, libquack.so, libgame.so) load, relocate, and link against the shim table with zero unresolved symbols
  • All 417 native C++ constructors run clean, JNI_OnLoad completes, and real libnx threads run the game's background asset loader alongside the main render/game thread
  • Touch input drives the car (steering/gas/brake) via the game's own registered touch natives — this is genuinely playable, not just rendering
  • Audio works — engine sound, music, and effects via a custom SDL2_mixer backend reading OGG/MP3/Opus/FLAC straight from the APK's assets
  • A locked 60 fps in normal play
  • The fake JNI layer answers hundreds of thousands of calls (UserDefaults, market queries, AES encrypt/decrypt passthrough, audio engine hooks, real Switch connectivity status)
  • Live on-screen log feed during loading, full diagnostics in sdmc:/AndroidHorizonNX/compat_log.txt, and automatic screenshots of key moments saved to sdmc:/AndroidHorizonNX/screenshots/
  • Crash forensics: symbolized abort/exit backtraces with full frame-pointer backtraces, unrecovered-fault PC/region capture, and the game's own debug output routed into the log

Known issues from hardware playtesting (see Current Blockers): the Shop screen crashes back out to the launcher, and save persistence needs another confirmed round-trip test (the mechanism is implemented and the log shows it loading/saving, but a full-restart verification is still outstanding).

The road here, each step root-caused on real hardware: JIT data pages needed RW after the executable transition (417 ctor faults) → std::random_device aborted because /dev/urandom doesn't exist (now served by the Switch CSRNG) → the asset-loader thread froze the game because pthread_create ran it inline (now real libnx threads) → a vorbis ABI mismatch corrupted the heap on the first sound effect (now uses the Tremor decoder SDL2_mixer actually expects) → an async Java callback (fetchCountryCode) left the post-EULA screen spinning forever (now answered immediately) → the engine sound played at full volume forever because its per-channel volume control was silently dropped (now wired through).


Screenshots#

Every screenshot below was captured automatically by Android Horizon itself, on real Switch hardware — the launcher saves a PNG of each UI screen to sdmc:/AndroidHorizonNX/screenshots/, and the game loop snapshots the actual GL framebuffer at milestone frames to prove what really rendered. None of these are mockups or emulator captures.

APK browser Loading screen
APK browser — starfield, planet horizon, HOS button glyphs Live loading screen with real-time compat log
Launch result screen Fingersoft splash
Launch diagnostics — confirms a clean load Fingersoft splash animating (game frame 30)
Game loading screen Gameplay — driving up the hill
Hill Climb Racing loading screen rendering on Horizon OS (frame 300) Actual gameplay — touch-steering the jeep, live HUD, coins & gems, all on Switch

Controls#

Handheld mode only. Android games use touch screen input. The Switch touchscreen only works in handheld mode — in docked mode there is no touch input, so games will be uncontrollable. Android Horizon detects docked mode and shows a warning in the footer.

Android Horizon launcher controls (in the APK browser):

Button Action
D-pad / Left stick Navigate APK list
A Launch selected APK
X Reinstall + Launch (re-extracts APK)
Y Rescan APK folder
About screen
+ Quit
B Back (on result screen)

Setup#

  1. Copy AndroidHorizonNX.nro to sdmc:/switch/
  2. Place .apk files in sdmc:/AndroidHorizonNX/apks/
  3. Launch from hbmenu (Atmosphere CFW required)
  4. Navigate with D-pad or left stick, press A to launch
  5. If a launch fails, check sdmc:/AndroidHorizonNX/compat_log.txt for the full error log

Building#

Requires devkitPro with devkitA64 and libnx installed.

export DEVKITPRO=/opt/devkitpro
make

Output: AndroidHorizonNX.nro

Dependencies (via pacman/devkitPro)#

switch-sdl2 switch-sdl2_image switch-sdl2_ttf
switch-libpng switch-libjpeg-turbo switch-minizip
switch-mesa switch-glad switch-curl switch-mbedtls

Current Blockers#

These are the known issues preventing Hill Climb Racing from running:

1. Code pages not executableRESOLVED#

The ELF loader now uses the SplitMap technique: two svcCreateCodeMemory handles placed at adjacent virtual addresses. The code segment is mapped MapSlave (Rx) and the data segment MapOwner (Rw). ARM64 ADRP instructions can address ±4GB, so adjacent placement lets executed code reach GOT/data without any permission transitions. The old 0xD801 error no longer occurs.

2. JMPREL unresolvedRESOLVED (all 403 entries)#

All 403 JMPREL entries in the crash reporter library resolve successfully. The test log confirms JMPREL: all 403 entries processed and ELF: jmprel done.

3. SplitMap memcpy crashRESOLVED#

After JMPREL, the ELF loader was crashing on memcpy(code_heap_buf, ...) because svcCreateCodeMemory was called too early, making the backing buffer inaccessible before we wrote to it. Fixed: svcCreateCodeMemory + svcControlCodeMemory are now deferred until after the memcpy.

4. Native constructorsRESOLVED (all 417 run clean)#

Root cause: jitTransitionToExecutable made the entire JIT region RX, including the data segment, so the very first constructor writing a C++ global hit a permission fault. Fix: data-segment pages are flipped back to RW after the executable transition, matching the Android linker's layout. ctors done ok=417 failed=0.

5. ~Game calls abort() after 165sRESOLVED#

Root cause: std::random_device's constructor opens /dev/urandom, which doesn't exist on Horizon OS; the -fno-exceptions build turns that failure into an instant abort(). Fixed by serving /dev/urandom as a virtual file backed by the Switch's hardware RNG.

6. No audioRESOLVED#

A custom SDL2_mixer backend now serves every SimpleAudioEngine JNI call — background music and sound effects (OGG/MP3/Opus/FLAC) decode straight from the extracted APK assets. Per-channel effect volume (used for the engine sound) is wired through; per-channel pitch/rate is not — SDL_mixer has no API for changing an individual channel's playback speed without a full custom resampling engine, so the engine note doesn't rise and fall with RPM yet (volume does).

7. Background threads run synchronouslyRESOLVED#

pthread_create now spawns real libnx threads (pinned off the render core), with real mutexes/condvars/semaphores/rwlocks backing the game's synchronization primitives.

8. Touch input not deliveredRESOLVED#

Confirmed on hardware: SDL finger events drive the game's own touch natives, and the car is fully steerable.

9. Shop screen crashes back to the launcher — ACTIVE INVESTIGATION#

Opening the in-game Shop crashes out to the Android Horizon menu. No fault or abort was captured in the log from the crash session (the log simply stops mid-stream with no exit marker), which pointed at a possible deadlock in the crash-forensics logger itself: if the crashing thread happened to fault while already holding the log's mutex (e.g. mid-format inside an ordinary log call), the old fault handler would try to take that same mutex to report the crash and hang forever — a real bug, now fixed with a lock-free emergency logger for exactly this scenario. The Shop is also the most network/IAP-heavy screen, and until this build isNetworkAvailable always claimed "online" regardless of the Switch's actual connection — an offline Switch reporting itself online could easily send the game into a real network call that has no chance of succeeding. Both fixes are in; needs a fresh test + log to confirm.

10. Save persistence — needs a confirmed round-trip test#

The UserDefault store now loads from and saves to <game>/userdefaults.bin (ints, floats, bools, strings), and the compat log shows it loading and saving correctly during play. However this hasn't yet been confirmed by a full app-restart-and-relaunch test on hardware, so treat it as "implemented, not yet verified" rather than "working."

11. One game session per app launch#

Launching a second game session in the same Android Horizon process reads garbage ("not an ARM binary") — leftover JIT memory regions and threads from the first session aren't unloaded. Currently guarded with an on-screen notice instead of a crash; a real unload path is future work.


Performance Expectations (Hill Climb Racing 1.67.0)#

We're testing the .apk release of Hill Climb Racing 1.67.0 specifically — the current Play Store release ships as a .xapk. Android Horizon's APK parser only understands plain .apk files right now, so .xapk support is out of scope until a later phase.

Measured numbers from hardware:

  • A locked 60 fps during actual gameplay — the theoretical ceiling is also the measured result. (Earlier builds measured ~18 fps in the menu before a logging bottleneck was found and fixed — see the 0.1.65 changelog entry — real gameplay performance is native-class.)
  • Loading time has come down substantially since the early ~143 s measurement, once JNI log spam (thousands of save-key reads, each fsyncing to the SD card) was throttled — see 0.1.65.
  • Hill Climb Racing was tuned for 2012-era phones far weaker than the Tegra X1, so there's little pressure on performance work right now; focus is on correctness (Shop crash, save verification) over speed.

TODO / Roadmap#

Items are roughly ordered by priority. "Phase 0" is the current work.

Phase 0 — Make any game do something (in progress)#

  • APK browser UI with icon extraction
  • APK extraction (libs + assets) onto SD card
  • Custom ARM64 ELF loader with RELA relocation
  • JNI environment shim (fake JavaVM / JNIEnv)
  • EGL setup (GLES 2 + 3 shim table passthrough)
  • On-screen progress display during launch stages
  • Full diagnostic result screen with error details
  • Docked mode detection — footer warns when not in handheld mode
  • Early abort when code pages are not executable — shows diagnostic screen instead of crashing Switch
  • SplitMap JIT — adjacent RW+Rx dual-mapping via two svcCreateCodeMemory handles; 0xD801 blocker resolved
  • All 120+ unresolved symbols shimmed
  • Per-constructor logging — crash site shows in compat_log.txt
  • Load all .so files — all three libs loaded smallest-first; cross-library symbols available before constructors run
  • 40+ additional shims — signal handling, thread naming, memory, barriers, etc.
  • Live progress screen — loader runs on a background thread; main thread renders at ~60fps with animated scan bar and live compat_log.txt tail (13 lines, colour-coded)
  • Elapsed time per stage — stage label shows "(Xs)" so you can tell how long each step is taking
  • All 403 JMPREL entries resolved — confirmed in hardware test
  • All 417 constructors run clean — JIT data pages flipped back to RW after executable transition
  • Game boots and renders — splash animation, full loading screen, menu logic, ~18 fps
  • Crash forensics — symbolized abort/exit backtraces, unrecovered-fault PC capture, game stderr + debug strings routed to compat log
  • Automatic screenshots — launcher screens + GL framebuffer at milestone frames saved to sdmc:/AndroidHorizonNX/screenshots/
  • Fixed the ~165s abort() — root cause /dev/urandom missing on Horizon OS, served by the Switch CSRNG
  • Touch input delivered — SDL finger events → Cocos2dxRenderer touch natives (1:1 coords); B button → Android BACK key; confirmed steering the car on hardware
  • Audio playback — SDL2_mixer backend for SimpleAudioEngine (music + effects, OGG/MP3/Opus/FLAC); confirmed working on hardware (engine, music)
  • GAME IS PLAYABLE — driving, touch, audio, 60fps, confirmed on Switch hardware
  • Shop screen crash (active investigation — see Current Blockers #9)
  • Confirm save persistence survives a full app restart

Phase 1 — Touch input#

  • Map Switch touchscreen events to the game's touch natives (direct Java_...nativeTouches* calls rather than the AInputQueue/ALooper path — simpler and confirmed working)
  • Docked-mode detection — footer shows warning when not in handheld mode

Phase 2 — Stability#

  • Real pthread support using libnx Thread — real threads, real mutex/condvar/rwlock/semaphore backing
  • Load all .so files in dependency order (smallest-first)
  • Implement dl_iterate_phdr so stack unwinders work
  • Save/load state — UserDefaults persist to <game>/userdefaults.bin (needs a confirmed restart test, see blockers)
  • Real .so/JIT unload so a second game session doesn't require restarting the app
  • Engine/effect pitch control (needs a custom audio resampler — SDL_mixer has no per-channel rate API)

Phase 3 — Polish#

  • NRO icon (Android Horizon themed — green planet with curved text)
  • Icon-themed UI — animated starfield, planet-horizon scenery, borealis-style glowing focus card, HOS button glyphs from the system font
  • GitHub avatar on About screen (bundled static image — no runtime fetch)
  • About screen (press )
  • Reinstall button (X in APK list)
  • Version bump system — NACP + in-app version both track the build number (0.1.<build>)
  • Per-APK settings overlay (resolution, framerate cap)
  • APK delete / manage from the UI

Not Planned (for now)#

  • Online / multiplayer games (Roblox, Fortnite, etc.)
  • Google Play Services (GMS) stub
  • ARM32 (armeabi-v7a) game support — Switch is 64-bit only

Changelog#

Most recent first.

0.1.75 — Pixel-fingerprint overlay timing + static avatar#

  • Branding overlay was showing during the Fingersoft logo animation, not just the loading screensplashScreenHasCompleted only fires once the entire splash+loading sequence ends, so it doesn't distinguish the early logo-only phase from the later "HILL CLIMB RACING / LOADING..." screen. Fixed with a pixel fingerprint: each frame, 3 fixed screen points are sampled (glReadPixels) and compared against colours estimated from a real handheld screenshot (dark vignette corners + the white loading bar); the overlay only draws when all 3 match. Actual vs. expected values are logged once a second so a wrong estimate is a one-log-file fix, not more guesswork.
  • About screen avatar is now static — no more background HTTPS fetch, no cache file, no worker thread. The real GitHub avatar is bundled directly into the NRO (romfs:/avatar.png) and loaded once at startup.

0.1.73 — Branding overlay black-screen fix + repositioning#

  • Fixed the branding overlay turning the screen black — cocos2d-x keeps its own internal cache of GL state (current shader/buffer/texture/attributes) and skips re-setting things it thinks are unchanged. Our raw GL draw call changed real state behind its back, desyncing that cache; the game's very next draw call fed the GPU stale attribute data, producing a solid black frame. Fixed by saving every piece of global GL state we touch (program, bound buffer/texture, blend func, depth/cull enables) and restoring it exactly afterward, and moving our vertex attributes to indices 8/9 that cocos2d-x never touches.
  • Repositioned next to (not above) the game's own version text, matching a real handheld-mode screenshot — bolded our font to better match its weight. Position is an estimate (no way to pixel-measure a pasted screenshot without the underlying file); expect a follow-up nudge once seen live on hardware.

0.1.71 — Android Horizon branding on the game's loading screen#

  • Branding overlay — while the game shows its own loading screen, Android Horizon now draws "Android Horizon v0.1.71" (white/green, matching the launcher's palette) in the bottom-left corner, right above the game's own version text. Implemented as a small standalone GLES2 textured quad composited directly into the game's rendered frame (drawn after nativeRender(), before the buffer swap) — not a hack of the game's own font/UI, just an independent overlay layered on top.
  • The overlay hides itself automatically the moment the game calls splashScreenHasCompleted (a real JNI hook, not a frame-count guess), so it never appears over menus or gameplay.
  • Shop crash: real forensics captured for the first timefar=0xfffffffffffffff8 (address -8), a classic null-object/empty-container null+small-offset dereference, firing right as the game calls trackPage(...) (almost certainly "Shop") and starts decoding shop item images. Leading theory: the shop looks up an in-app product our compat layer doesn't provide data for, and an unchecked "not found" path indexes a null/empty container. Not yet fixed — the crash site itself is deep in stripped game code we can't disassemble from here — but any future crash will now log with this same level of detail.

0.1.69 — Engine volume fix + crash-logger deadlock hardening#

  • Fixed the engine sound never stopping/fading — the game continuously calls setEffectVolume(id, volume) to ramp the looping engine sound with RPM and to silence it on crash/pause/menu; this call was falling through to the generic no-op logger and being silently dropped, so the engine played at its initial volume forever. Now wired to a real per-channel Mix_Volume. (setEffectRate, the pitch counterpart, is a deliberate no-op — SDL_mixer has no per-channel playback-rate API without a custom resampler.)
  • Real Switch connectivity reported to the gameisNetworkAvailable now queries nifm instead of always claiming online. Matters for the Shop investigation: an offline Switch previously told the game it was online, which could push it into a real (doomed) network call.
  • Crash-logger deadlock fixed — the Shop-crash log had no fault/abort line at all, just silence. Root cause: if a thread faults while it already holds the normal logger's mutex (e.g. mid-format inside an ordinary log call), the old fault handler tried to take that same mutex to report the crash — permanent deadlock, no diagnostics, looks like a frozen game until force-quit. Added a lock-free emergency logger used only by the crash-forensics paths, so a fault can now always be recorded no matter what the crashing thread was doing.
  • Added the driving-gameplay and launch-result screenshots to this README — first real in-game screenshots from hardware.

0.1.68 — IT'S PLAYABLE + persistent saves#

  • HILL CLIMB RACING IS PLAYABLE ON SWITCH — driving with touch steering, engine sounds, music, 60 fps. Confirmed on hardware. Known issues from the playtest below.
  • Saves now persist — the UserDefault store was RAM-only, so every launch looked like a first run (ToS screen again, progress lost). It now loads from <game>/userdefaults.bin before the game starts and saves on every UserDefault.flush and at exit. Ints, floats, bools, and strings all covered (floats/bools weren't even stored before).
  • Real connectivity reportedisNetworkAvailable now asks the Switch (nifm) instead of always claiming "online". An offline Switch gets Android's normal offline code paths — the game handles that natively, and it likely defuses the shop's network/IAP flows (the playtest's only crash).
  • Effect volume fixedplayEffect's gain parameter was discarded, so looping effects (the engine!) played at 100%. Gain is now applied per channel. (Pitch modulation isn't supported by SDL_mixer, so the engine is volume-correct but monotone for now.)
  • Relaunch guard — launching a second game session in one app run crashes the loader (leftover JIT regions/threads from the first session; "not an ARM binary"). Until real unloading exists, a second launch shows a "restart Android Horizon to play again" notice instead of crashing.
  • Async Java callback pattern established (compatFindGameSym) — used for returnCountryCode, ready for setServerTime/returnMissionJson/returnFileDownloadResult if the game stalls on them.

0.1.66 — Touch confirmed + post-EULA spinner fixed#

  • TOUCH CONFIRMED WORKING — the Terms-of-Service screen was accepted by tapping on hardware. Also: the logging diet pushed the game loop to a solid 60 fps (frame counter: 300 frames per 5 s) — the SD-card fsyncs were throttling rendering too.
  • Post-EULA spinner root-caused — after accepting, the game calls fetchCountryCode(); on Android that's an async Java web request answered via the native callback returnCountryCode(jstring). No reply = infinite spinner. The JNI layer now invokes the game's registered callback immediately with "US" (which also keeps the game on the simpler non-GDPR consent path).
  • Music no longer keeps playing after quitting the game — the mixer is silenced when the game loop exits back to the APK browser.

0.1.65 — Audio works! + loading-screen speedup#

  • AUDIO CONFIRMED WORKING ON HARDWARE — build 64's Tremor fix holds: menu music (bgmusic00.ogg) loops from the Switch speakers while the game renders. First sound ever from an Android game on Horizon OS via this layer.
  • "Stuck" loading screen root-caused: it wasn't stuck — the game reads thousands of per-vehicle/per-stage save keys, and every read produced 3 compat-log lines each fsync'd to the SD card (~90 keys/second measured). The run was minutes from finishing when it looked frozen.
  • JNI logging diet — class/method/field lookups and repeated calls log once per unique message; save-key reads are silent with a periodic progress counter; writes log once per key. Loading should now be dramatically faster.

0.1.64 — The real frame-2 killer: vorbis ABI mismatch#

  • Frame-2 crash fully root-caused — build 63 still crashed, and the forensics narrowed it to _free_r freeing SDL_mixer's own sdl_seek_func (an OGG callback function). Disassembling portlibs' libSDL2_mixer.a showed its OGG decoder calls ov_read with 4 arguments — the Tremor (libvorbisidec) ABI — while we linked regular libvorbisfile, whose ov_read takes 7 arguments and whose OggVorbis_File struct is a different size. ov_open_callbacks scribbled past the mixer's smaller struct on the first preloadEffect(*.ogg), corrupting the heap.
  • Fix: link -lvorbisidec (Tremor) instead of -lvorbisfile -lvorbis, matching what SDL2_mixer was compiled against.

0.1.63 — Allocator crash root-caused: JNI string constants#

  • Build 59/60 frame-2 crash root-caused via the new forensicssvcQueryMemory + host-symbol anchoring resolved the fault to newlib's _free_r writing a free-list link into our NRO's read-only segment: the game free()d a JNI string we handed it. On real Android, GetStringUTFChars returns a malloc'd copy (so game code that free()s it instead of calling ReleaseStringUTFChars gets away with it); ours returned the string constant itself.
  • Fix 1: GetStringUTFChars now returns a real heap copy (ART-compatible) and ReleaseStringUTFChars frees it.
  • Fix 2: guarded allocator — free/realloc verify the pointer is actually heap (svcQueryMemory) before touching it, and log the symbolized caller of any non-heap free instead of corrupting the allocator.
  • Mixer init now happens before the game loop starts (was lazily mid-frame).
  • New crash forensics stay in: fault pc/far region type+permissions, host addresses anchored to a known symbol for offline resolution against the build's .elf.

0.1.59 — Touch + audio#

  • Real threads worked — build 56 reached the game's Terms-of-Service / privacy screen: fully loaded, rendering, interactive (2,280 frames over 339 s in the test run, clean exit via +). The remaining gaps were input and sound.
  • Touch input — SDL finger events are forwarded to the game's registered Cocos2dxRenderer natives: nativeTouchesBegin/End per finger, nativeTouchesMove with real JNI arrays (the fake JNI array layer now implements GetArrayLength and all typed Get/SetArrayRegion calls instead of returning zeros). Touchscreen coordinates map 1:1 to the game's 1280×720 surface.
  • B button → Android BACK key via nativeKeyDown(AKEYCODE_BACK) — backs out of game menus like on a phone.
  • Audio — new SDL2_mixer backend (source/compat/audio.cpp) behind all SimpleAudioEngine JNI calls: background music (play/stop/pause/resume/rewind/volume) and sound effects (preload/play/stop/volume, cached chunks), OGG/MP3/Opus/FLAC decoding, reading straight from the extracted APK assets.

0.1.56 — Real threads#

  • The urandom fix worked — build 55 sailed past the old ~170s abort: std::random_device now reads the Switch CSRNG, and the game went on to initialize vehicles, achievements, and event assets. New blocker: it spawned a persistent asset-loader thread, and the old "run thread functions synchronously" shim froze the game on the HCR loading screen.
  • pthread_create now creates real threads (libnx threadCreate, 1 MB stack, pinned to cores 1/2 so workers can't starve the render loop on core 0), each with its own fake Bionic TLS block. pthread_self/pthread_equal/pthread_join are consistent between creator and thread.
  • Real synchronization — mutexes/condvars/rwlocks/semaphores are now backed by newlib/libnx primitives embedded inside the game's larger Bionic structs (recursive semantics; Bionic's static recursive-initializer bit pattern sanitized). pthread_cond_timedwait converts Bionic absolute timeouts and returns Bionic's ETIMEDOUT.
  • Thread-safety hardening — the compat logger and the JNI UserDefault store are mutex-guarded; crash recovery only longjmps on the thread that armed it (worker faults get the symbolized unrecovered-fault log instead).

0.1.55 — Abort root-caused: /dev/urandom#

  • The ~170s abort is fully root-caused — build 54's symbolized backtrace shows std::random_device ctor → __throw_system_errorabort(). The ctor opens /dev/urandom through bionic's fortified __open_2 (which our logging never saw), the path doesn't exist on Horizon OS, and the -fno-exceptions build turns the throw into an abort.
  • Fix: /dev/urandom virtual fdopen/__open_2 on /dev/urandom (or /dev/random) now return a magic fd backed by the Switch's hardware CSRNG; read/__read_chk/close handle it transparently.
  • Screenshots confirmed working on hardware — launcher UI, live loading screen, Fingersoft splash (frame 30), and the HCR loading bar (frame 300) all captured automatically.

0.1.53+ — The game renders!#

  • Hill Climb Racing boots on hardware — Fingersoft splash animates, loading screen completes, menu logic runs at ~18 fps. Root cause of the constructor crashes: the JIT transition made data pages RX; they're now flipped back to RW, and all 417 ctors pass.
  • Crash identified & instrumented — the game calls abort() at ~165s from libc++'s __throw_system_error path (confirmed by symbolized caller: libgame.so error_category::equivalent+0xec). Added: frame-pointer backtrace with per-frame symbolization on abort/exit, unrecovered-fault PC/LR capture in the exception handler, recovery window widened over the whole game loop (incl. eglSwapBuffers).
  • Game output captureddebugStringOnAndroid JNI payloads, writes to the game's stdout/stderr (including through the fake Bionic __sF), and android_set_abort_message all land in compat_log.txt now.
  • getrandom implementedsyscall(__NR_getrandom), getentropy, and getrandom are backed by the Switch CSRNG (std::random_device suspect for the abort).
  • Automatic screenshots — launcher saves ui_menu/ui_loading/ui_result/ui_about.png; the game loop saves the GL framebuffer at frames 30/300/900. All in sdmc:/AndroidHorizonNX/screenshots/.
  • Epic UI overhaul — icon-matched theme: gradient space sky, 110 twinkling/drifting stars, green planet horizon with glowing rim (cached to a texture), borealis-style eased focus card with pulsing glow, real HOS button glyphs (, , …) from the NintendoExt system font with chip fallback, translucent header/footer, 60 fps menu.
  • Version system — NACP version (shown in hbmenu) and the in-app version both derive from the auto-incrementing build number; the stale .nacp is regenerated every build.

Loader bring-up#

  • SplitMap crash fixed — root cause: svcCreateCodeMemory was called before memcpy, making the backing buffer inaccessible at userspace. Fixed by deferring svcCreateCodeMemory + svcControlCodeMemory to after the memcpy. All three .so files should now load past the ELF copy stage.
  • RELA/JMPREL logging dramatically reduced — removed per-entry log lines (one fflush per entry on FAT32 was taking ~38 seconds for libapplovin alone). Now only unresolved symbols, WARN lines, and the end-of-table summary are logged — applyRela goes from ~8000 lines to ~30 per library.
  • + button exits progress screen — press + at any time during ELF loading to stop waiting and return to the APK list
  • Renamed to Android Horizon / AndroidHorizonNX — reflects the project's purpose (Android on HorizonOS) more clearly
  • Live animated progress screen — loader now runs on a background libnx thread; main thread renders at ~60fps with: animated scan bar (always moving, independent of load progress), live tail of compat_log.txt (13 lines, colour-coded for errors/warnings), elapsed time display per stage, "still working" notice after 30s
  • Log timestamps — every compat_log.txt entry prefixed with [Xs] seconds-since-launch
  • Immediate constructor logging — each ELF: ctor[k/417] @ptr is force-flushed to disk and the live display the instant before the constructor runs, so a hanging constructor is immediately identifiable
  • "Please wait" patience notice — log and screen show total constructor count and estimated time before the phase begins
  • Avatar fixsocketInitializeDefault() added so curl/BSD sockets work in homebrew; GitHub URL corrected to Aaronateataco
  • Expanded log ring buffer (5×92 → 20×128 bytes) for richer in-memory log feed
  • All 403 JMPREL entries resolve — hardware-confirmed; constructors are now the active frontier
  • Android Horizon icon — green planet with "ANDROID HORIZON" curved above the horizon, space background with stars
  • About screen (press ) with GitHub avatar (bundled static image) + project info
  • Reinstall button (X) — re-extracts APK without needing to delete the game folder

[Previous builds]#

  • Performance Expectations section added to README
  • APK chooser QoL: WebP icon decoding, linear icon scaling, colored monogram placeholders, larger icons, file size shown per APK
  • Heap staging buffer for ELF loading — fixed hard crash on first write to JIT-writable memory
  • Per-relocation-entry logging + bounds checks in applyRela() — bounds-checks sym.st_name against DT_STRSZ, caps R_AARCH64_COPY size at 64KB
  • Per-constructor loggingelfRunCtors() logs each constructor address + index and flushes before calling it
  • Load all .so files — replaced findMainSo with findAllSos; all three libs are loaded smallest-first
  • 40+ new shimssigaction, signal sets, prctl, gettid, getpid, getuid, getgid, getauxval, mprotect, mmap, munmap, pipe, dup, dup2, ioctl, access, chmod, fchmod, lstat, pthread_setname_np, pthread_getname_np, pthread_attr_setstack, pthread_barrier_*, kill, raise, pthread_kill, sleep, usleep, clock_nanosleep, strtod_l, strtof_l, __register_atfork
  • ELF loader: SplitMap JITMapSlave (Rx) + MapOwner (Rw) at adjacent VAs; 0xD801 blocker resolved
  • 120+ shims: setjmp/longjmp, sem_*, all time functions, all wide-char and locale functions, all Bionic fortified string wrappers, networking stubs, android_set_abort_message, sincosf, stpcpy, vasprintf, vsscanf, strerror, strtold, puts/putchar, rename/remove, __sF, __stack_chk_guard, pthread_mutexattr_*, __cxa_finalize
  • Capture svc result codes from ELF loader and surface on diagnostic screen
  • Abort launch early when code pages not executable — diagnostic screen instead of Switch crash
  • Add LaunchResult struct — structured error info instead of bare bool
  • Add ProgressCb callback — UI shows each launch stage on-screen in real time
  • Docked mode detection — footer turns amber and warns

0.1.0 — Initial release#

  • APK browser UI (SDL2, 1280×720)
  • APK icon + metadata extraction (AndroidManifest.xml + resources.arsc parser)
  • Full APK extraction to SD card (libs + assets)
  • Custom ARM64 ELF loader with RELA relocation processing
  • JNI / JavaVM fake environment
  • GLES 2 + GLES 3 shim table (400+ functions)
  • EGL setup via switch-mesa
  • SD card logging (log.txt + compat_log.txt)

Launch Video (Draft Script — Unreleased)#

This is a script for a showcase video Aaron recorded, releasing later. Kept here for reference only — the bugs and limitations it describes are not being actioned from this script; they're tracked (or not) through the normal Current Blockers section based on separate hardware testing.

[Intro & The Hook]

Hi, my name's Aaron, and for the past few weeks, I've been working on a native way to play Android games directly inside of Horizon OS on the Nintendo Switch. To be clear: this isn't running through Lineage OS. This is a custom translation layer running straight through the Switch's native operating system. I mostly had AI "vibe code" this for me, but it's honestly amazing how far it's gotten. Let me show you how it works.

[The Setup & Demo]

When you first load in, you start here and drop in your APK file. The installation is super quick. A quick heads-up: I've only really tested Hill Climb Racing version 1.67. I didn't use the absolute latest version because it uses an XAPK format with multiple files, which might cause issues right now.

If you try other games or versions, they might crash or just be completely broken. But for this specific version, once you get past a little bit of loading lag at the start, it boots right in and is highly playable. Oh, and it actually supports saves now, which my last build didn't!

[Current Limitations & Bugs]

Since this is the very first playable build, there is some jank. Here is what you need to know if you test it:

  • Audio: There's no sound in this capture, but the app does output audio. Fair warning: there's a bug where the engine noise is super loud and gets stuck playing. I'll fix that in a future update.
  • Controls: Touch controls are slightly buggy. Sometimes inputs drop, which is why you see me accidentally neck-flipping in the video while holding the buttons down.
  • No Docked Mode: I disabled docked mode because there's no controller support yet. Handheld touch-controls only for now, which is why the video lighting is a bit rough — you can't easily use a capture card.
  • Performance: Hitting the "play" button is a bit laggy since it's not optimized yet, and the frame rate drops when you use certain power-ups.
  • Crashes: Don't try to open the shop or use online features. Hitting the share button just gives an empty achievement, and going online might crash it.

The good news? When it does crash, it just safely drops you back to the Android Horizon app. It actually handles the crashes natively, so you don't get kicked out to that scary Atmosphere error screen with all the binaries!

[Tech Specs & Outro]

For those wondering about the setup, I'm running this on a modded V1 Switch — not an OLED — using firmware version 22.1 and the latest version of Atmosphere. I'm running this through sysCFW instead of emuMMC to save SD card space, mostly because I made a silly mistake with Tinfoil a few years ago and am already banned from Nintendo servers anyway.

The whole project is open-source and up on GitHub. If you know what you're doing, please send a pull request! I would love some help developing this further, adding controller support, and getting more games to run.

Thanks for watching, go test it out, and have fun!


License#

This project is licensed under the Android Horizon NX Free & Source-Available License v1.0 - see LICENSE for the full text.

In plain English: This project is 100% free for the community to use, copy, modify, and share. However, you are strictly forbidden from selling this code, its ports, or any derivative works. If you fork or modify this project, you must prominently credit Aaronateataco, keep your source code publicly available, and distribute your version under this exact same license.

Voluntary donations/tips are perfectly fine, but the software itself must always remain free.

There is no warranty — if it breaks your Switch, that's on you (please use CFW responsibly).

The license does not cover the Android games themselves — those belong to their respective developers. Android Horizon only provides the compatibility layer.


About#

Made by Aaron — a non-developer who wanted to see Android games on their modded Switch, and is figuring it out one step at a time with the help of Claude AI.

~99.8% of all code in this repo was written by Claude (claude-sonnet-4-6 model). I describe the problem, test on real hardware, and point out what's broken. Claude writes the fixes. This is an honest experiment in AI-assisted hardware hacking.

If this interests you, star the repo and check back. Progress will be slow but real.