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=FALSEon the stock driver — this is the empirical correction. It makesrequire-turnipnecessary, 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:
pthread_canceldoesn't exist on bionic. FixedGeneralsMD/Code/CompatLib/Source/thread_compat.cpp(TerminateThread()returns "unsupported" on Android instead of calling it) and neutralized the FetchContent'd gamespy dependency's ownpthread_cancelcall site viatarget_compile_optionsincmake/gamespy.cmake(a function-like macro passed throughtarget_compile_definitionsis silently dropped by CMake — cost one full build iteration to find).- FreeType/fontconfig wiring reached Win32 GDI font stubs on Android because
SAGE_USE_FREETYPE's platform list inCore/Libraries/Source/WWVegas/WW3D2/CMakeLists.txtdidn't include Android. Added it, and widened the bundled-font guard inrender2dsentence.cpp/.hfromTARGET_OS_IPHONE-only to also cover__ANDROID__— Android resolves fonts from afonts/directory under CWD exactly like iOS, no Fontconfig involved. <sys/timeb.h>doesn't exist in bionic — dropped fromCore/Libraries/Source/WWVegas/WWDownload/FTP.cppunder#if !defined(__ANDROID__); the header was already unused in that translation unit.- NDK libc++ ships
std::from_charsfor integers but deletes the float overload.Core/GameEngine/Source/Common/INI/INI.cppextended the existingUSE_STD_FROM_CHARS_PARSINGApple exclusion to also exclude Android, falling back to the samesscanf-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 logcatconfirmsSDL_mainruns. - 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.sogate-checked with NDKreadelf/nm: ELF64 AArch64,SDL_mainexported, no undefined symbols outside oflibmediandk.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.