Skip to content

Development Diary - July 2026


2026-07-06: Android port Phases 0-2 — build system, packaging, headless gate

Ported the engine to Android (arm64-v8a) through three gates: renderer-route decision (Phase 0), a launching app (Phase 1), and a passing headless engine verification (Phase 2). Full build/packaging/asset instructions: docs/BUILD/ANDROID.md. This entry covers what was done and the root causes worth remembering.

Phase 0 — renderer route decision

Read through the full port-relevant codebase first (docs/WORKDIR/planning/ANDROID_PORT_FINDINGS_2026-07-06.md) to confirm platform isolation is real (single-digit platform-guarded files in GameLogic/GameClient/WW3D2) before committing to a plan. Then researched the Android GPU driver landscape (docs/WORKDIR/planning/ANDROID_RENDERER_RESEARCH_2026-07.md) and decided the renderer route: require-turnip — native-arm64 dxvk-native (mainline DXVK 2.x, SDL3 WSI) with bundled Mesa Turnip loaded rootless via libadrenotools, targeting Qualcomm Adreno flagships on Vulkan 1.3.

The device survey done to close this gate produced a concrete correction to the research itself: the research doc's [CLAIM] that Snapdragon 865/Adreno 650 exposes textureCompressionBC on its stock driver turned out to be false. Profiled two real devices:

  • Galaxy S22 (Exynos SM-S908B, Xclipse 920): Vulkan 1.3, textureCompressionBC=FALSE, no BCn, not an Adreno part so no Turnip either — asset-transcode fallback territory.
  • Galaxy Tab S7+ (SM-T970, Snapdragon 865 / Adreno 650, Android 13): Vulkan 1.3, textureCompressionBC=FALSE on the stock driver — this is the empirical correction. It makes require-turnip necessary, not merely preferred, for BCn support even on the exact SoC the [CLAIM] was about. The Tab S7+ (Adreno 650, Turnip's rock-solid 6xx tier) is the primary Phase 3 target as a result.

Also fixed a real (if currently harmless) bug found during the read-through: Dependencies/Utility/Utility/endian_compat.h's letohHelper<Type,4>/letohHelper<Type,8> and the float/double letoh/betoh specializations were calling le16toh where the width implies le32toh/le64toh. Identity on every little-endian host we build for today, but wrong on big-endian and wrong as documentation of intent — fixed to use the width-correct call.

Phase 1 — build system, packaging, app launches

New CMake preset android-vulkan (arm64-v8a, API 29+, arm64-android vcpkg overlay triplet, SDL3 + OpenAL + FFmpeg). The single biggest build-system delta versus the iOS port: SDL3's Android model runs the game as a shared library (libmain.so) loaded by SDLActivity, not a standalone executable — required restructuring the Main entry point and CMake target type for Android specifically.

Compile-fix sweep (Task 8, .superpowers/sdd/task-8-report.md) to get libmain.so to link clean, four real root causes:

  1. pthread_cancel doesn't exist on bionic. Fixed GeneralsMD/Code/CompatLib/Source/thread_compat.cpp (TerminateThread() returns "unsupported" on Android instead of calling it) and neutralized the FetchContent'd gamespy dependency's own pthread_cancel call site via target_compile_options in cmake/gamespy.cmake (a function-like macro passed through target_compile_definitions is silently dropped by CMake — cost one full build iteration to find).
  2. FreeType/fontconfig wiring reached Win32 GDI font stubs on Android because SAGE_USE_FREETYPE's platform list in Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt didn't include Android. Added it, and widened the bundled-font guard in render2dsentence.cpp/.h from TARGET_OS_IPHONE-only to also cover __ANDROID__ — Android resolves fonts from a fonts/ directory under CWD exactly like iOS, no Fontconfig involved.
  3. <sys/timeb.h> doesn't exist in bionic — dropped from Core/Libraries/Source/WWVegas/WWDownload/FTP.cpp under #if !defined(__ANDROID__); the header was already unused in that translation unit.
  4. NDK libc++ ships std::from_chars for integers but deletes the float overload. Core/GameEngine/Source/Common/INI/INI.cpp extended the existing USE_STD_FROM_CHARS_PARSING Apple exclusion to also exclude Android, falling back to the same sscanf-based path Apple already uses.

Then the Gradle shell app (Task 9): SDLActivity subclass (GeneralsXZHActivity), manifest (app id com.generalsx.generalszh, landscape-locked, Vulkan 1.3 required feature), and scripts/build/android/package-android-zh.sh — verifies libmain.so is real arm64 exporting SDL_main (via readelf -h + nm -D, not a trusted exit code — this port has been burned by silent wrong-build artifacts before), embeds the runtime .sos + SDL3 Java glue, runs gradle assembleDebug, optionally installs.

Root cause worth flagging for anyone sideloading on Samsung hardware: adb install failed with INSTALL_FAILED_VERIFICATION_FAILURE. Two independent blockers, both needed clearing: Play Protect's verifier_verify_adb_installs global setting, and Samsung's own Auto Blocker toggle (a separate, on-device-only control that isn't touched by the adb setting at all). Documented in docs/BUILD/ANDROID.md.

Confirmed Phase 1's gate on-device: adb logcat shows Running main function SDL_main from library .../libmain.so — the app launches and reaches the engine entry point.

Phase 2 — headless verification

Asset delivery: scripts/get-assets.sh is Steam-only, so for this EA-retail-copy setup, assets were copied from USB media to ~/GeneralsX/GeneralsZH/ (ZH .big archives + Data/) with the base game under ~/GeneralsX/GeneralsZH/ZH_Generals/. Fonts staged via the existing scripts/build/ios/stage-fonts.sh. scripts/build/android/push-assets-android.sh pushes the filtered ~2.9 GB payload to /sdcard/GeneralsZH in about 67 seconds over USB.

scripts/build/android/run-headless-replay.sh (Task 11, .superpowers/sdd/task-11-report.md) pushes a .rep, launches -headless -replay <path>, and polls for completion via two independent signals: a completion marker on stderr routed to logcat (gxRedirectStdioToLogcat() in GeneralsMD/Code/Main/SDL3Main.cpp — Android discards an app's stdout/stderr by default, so without this the engine's own progress and completion messages would be invisible) and an exit-code file written after GameMain() returns.

First run against a macOS-recorded retail replay reached frame 300 (10 s of game time, ~0.7 s wall time) with a clean map/INI/asset load — proof CompatLib, both filesystems, .big parsing, and INI loading all work on Android — but then hit a replay CRC mismatch. Root cause: SAGE_USE_DETERMINISTIC_MATH (the fdlibm cross-platform math scaffold) is off in every committed preset, so each platform's replay validation runs against its own native libm. Android and macOS arm64 both build with -ffp-contract=off, ruling out FMA contraction as the cause — the residual drift is transcendental functions differing by ~1 ULP between bionic libm and Apple libm, compounding over hundreds of frames until the state hash diverges at the first CRC checkpoint. Confirmed internally deterministic (identical local CRC across 4 consecutive runs) — this is a genuine cross-platform-replay finding, not an Android engine defect, and not something papered over.

To close the gate without waiting on a full deterministic-math backport, added a headless AI-vs-AI skirmish recorder (Task 11b, .superpowers/sdd/task-11b-report.md): new CLI flags -skirmishReplay <map> and -skirmishFrames <n> (GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp), a file-scope startHeadlessSkirmishRecording() in GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp that builds a 2-AI free-for-all slot list on a named map with a fixed seed and fires MSG_NEW_GAME(GAME_SKIRMISH, ...), and a frame-cap finalizer that stops recording and quits cleanly. The existing recorder (Recorder.cpp) already auto-records any non-shell/non-single-player game, so no recorder changes were needed; the one real engine fix was extending the load-screen-skip guard in GameLogic.cpp to also fire when headless (it previously only fired for replay playback).

Recorded and played back on the Galaxy Tab S7+ with -headless -skirmishReplay Maps/Whiteout.map -skirmishFrames 1500: exit 0, 1500 frames simulated (00:50 game time), no CRC mismatch — Android-recorded, Android-played, same bionic libm on both ends, closing the Phase 2 gate cleanly.

Validation performed

  • P0: device-profiled Galaxy S22 and Galaxy Tab S7+ Vulkan/BCn capability bits directly (not from vendor docs).
  • P1: on-device adb logcat confirms SDL_main runs.
  • P2: on-device headless replay via run-headless-replay.sh, both the macOS-recorded replay (frame-300 CRC divergence, root-caused above) and the Android-recorded skirmish replay (exit 0, full 1500-frame duration, no CRC mismatch).
  • libmain.so gate-checked with NDK readelf/nm: ELF64 AArch64, SDL_main exported, no undefined symbols outside of libmediandk.so/libandroid.so (AMediaCodec_*, AMediaFormat_*, ANativeWindow_*) which are legitimate DT_NEEDED imports.

Phase 3+ (renderer bring-up on the require-turnip route, touch/lifecycle, audio/video, parity polish) is the next plan, to be written against this verified baseline.