How the pieces fit together, and what breaks if you remove any one of them.
For the evidence behind every claim here — crash logs, measurements, the experiments that distinguished one cause from another — see findings.md.
Voxy is a level-of-detail renderer. It needs desktop OpenGL 4.6: compute shaders writing
indirect draw buffers, glMultiDrawElementsIndirectCount, gl_DrawID, large SSBOs. Android
provides OpenGL ES, which has none of that, and Android's EGL will not even hand out a desktop
GL context.
So the stack has to supply desktop GL itself, on top of Vulkan, on a mobile GPU — and then survive the places where a desktop-oriented mod meets a phone.
Minecraft + Fabric + Sodium + Voxy Java
─────────────────────────────────────────
LWJGL -> libglxshim.so GL entry points
─────────────────────────────────────────
Kopper Zink (libgallium_dri.so) desktop GL 4.6 -> Vulkan
libEGL_mesa.so Android present path
─────────────────────────────────────────
Turnip (libvulkan_freedreno.so) Vulkan -> Adreno
─────────────────────────────────────────
KGSL kernel driver Android
Zink translates OpenGL to Vulkan; Kopper is the part that gets the result onto an Android
surface. Turnip is Mesa's open Vulkan driver for Adreno. Both ship as launcher plugin APKs
because Android will not dlopen a library out of an app's writable data directory — it must come
from an installed package's nativeLibraryDir.
The renderer plugin is built from the AngelAuraMC / Swung0x48 Mesa fork, branch
25.0-kopper-android, not from upstream. Upstream is missing three things, each fatal:
- The Android desktop-GL gate. Upstream
eglcurrent.hrefuses to create a desktop GL context on Android at all. Without lifting it you get ES 3.1 and Minecraft dies on the first desktop-only enum. droid_swap_buffers_kopperand the Android Kopper surface. Upstream has only the generic present path. Missing this renders correctly and presents nothing — a black screen with a fully interactive game.VULKAN_PTR. The launcher loads Turnip through a linker-namespace bypass and passes the loader handle in this environment variable. Without it Zink dlopens the system loader and binds the proprietary driver instead of Turnip.
Porting these to a newer Mesa was attempted and abandoned; the fourth missing piece turned out to be a whole subsystem. Build the fork.
The proprietary Vulkan driver renders correctly, and for a while it was the only thing that did.
But its shader compiler (libllvm-qgl.so) segfaults compiling Voxy's fused compute/indirect
shader, and Voxy ships only one section-renderer backend, so there is no way around it in config.
Turnip's compiler handles the same shader.
That made Turnip mandatory rather than preferable — and its own blocker (a scrambled, tiled image)
had to be solved rather than worked around. It turned out to be a build-configuration problem on
this side, not an upstream driver bug: with the gralloc metadata backends compiled out, Mesa handed
Turnip DRM_FORMAT_MOD_INVALID for the window buffers, so Turnip picked its own tiled layout while
the display scanned them out linear.
The plugin APKs use in.alexkarl.zalithvoxy.<role>.<name> — the reverse of alexkarl.in, a domain
this project controls:
| Component | Package |
|---|---|
| Turnip driver | in.alexkarl.zalithvoxy.driver.turnip |
| Kopper Zink renderer | in.alexkarl.zalithvoxy.renderer.kopperzink |
| RocksDB natives | in.alexkarl.zalithvoxy.nativeplugin.rocksdb |
| Zink env (debug aid) | in.alexkarl.zalithvoxy.renderer.zinkenv |
com.movtery.zalithlauncher.*, copied from the launcher's own plugin
examples. That is the launcher author's namespace, and using it implied they published these
and risked an ID collision with anything they might ship. Renamed before the first release, which
was the last moment it was free: the package name is the plugin ID that goes in
version.config, so changing it later would strand every existing install with no upgrade path.
The 26 was dropped from kopperzink26 at the same time — the renderer is built from the Mesa
25.0.7 fork, so the name was simply wrong.
For a v1 renderer plugin the launcher skips its own Mesa environment block, so the plugin must
declare everything itself. This is the whole contract, carried in the plugin manifest's pojavEnv:
| Variable | Why |
|---|---|
LIBGL_ES=3, MESA_LOADER_DRIVER_OVERRIDE=zink, LIB_MESA_NAME=libglxshim.so |
basic Zink wiring; without LIBGL_ES the launcher derives a nonsense value from the renderer id |
MESA_GL_VERSION_OVERRIDE=4.6, MESA_GLSL_VERSION_OVERRIDE=460, allow_higher_compat_version=true |
advertise desktop GL 4.6 |
FD_GRALLOC_FORCE_LINEAR=1 |
stops Turnip guessing the window buffer layout — fixes the tiled image |
ZINK_MAX_SSBO_SIZE_MB=128 |
caps the advertised SSBO size; Voxy otherwise asks for a 4 GiB geometry buffer and fails |
ZINK_DEBUG=norp |
disables renderpass tracking, which crashes under a shaderpack |
Two of these — FD_GRALLOC_FORCE_LINEAR and ZINK_MAX_SSBO_SIZE_MB — are not upstream Mesa
options. They are added by this project's patches in patches/.
POJAV_ZINK_PREFER_SYSTEM_DRIVER has two sources: the plugin manifest and the launcher
setting AllSettings.zinkPreferSystemDriver, which is applied separately and works even for v1
plugins. Both must be off or you silently land on the proprietary driver.
Voxy stores LOD sections through a pluggable backend, chosen per world in
saves/<world>/voxy/config.json — and per server in .voxy/saves/<host>/config.json. Voxy
writes that file the first time it sees a world, defaulting to CompressionAdaptor(ZSTD) → RocksDB.
On Android, every backend Voxy ships fails:
| Backend | Why it fails |
|---|---|
| RocksDB | the rocksdbjni jar carries no Android natives |
| LMDB, ZSTD compressor | LWJGL natives built for linux/windows only |
| Redis | needs a server |
| Memory | works, but never evicts — see below |
Memory was the obvious stopgap and turned out to be a trap. It retains every ingested section in
its own ~64 KiB native allocation. That size sits just past the largest class Android's scudo
allocator serves from its primary pool, so each section becomes an individual mmap. At around
29,000 live sections the process exceeds vm.max_map_count (65530) and the next allocation
anywhere in the JVM fails — with gigabytes of RAM still free. In practice: a hard crash after one
to two minutes of play, immune to heap tuning.
The fix is rocksdb-native-plugin, which supplies the natives RocksDB was missing. Nothing patches
Voxy or rocksdbjni to achieve it: RocksDB's own loader tries System.loadLibrary("rocksdbjni")
against java.library.path before falling back to extracting from the jar, and Zalith's native
plugin API puts a plugin's nativeLibraryDir on that path. So the library simply has to exist in
the right kind of package.
voxy-android-compat/ is two mixins:
ThreadUtilsMixin— Voxy'sThreadUtils.<clinit>loadslibc.so.6, the glibc soname, which does not exist on Bionic. Voxy means to tolerate the failure, but catchesExceptionwhile LWJGL throwsUnsatisfiedLinkError, anError. A mixin cannot widen acatch— an exception table is structure, not instructions — so the mixin redirects the call and rethrows as an uncheckedException, letting Voxy's own handler run.VoxyClientInstanceMixin— replaces only the default storage config, so new worlds and servers get RocksDB. Existing configs are read and honoured, deliberately: a config the user chose is never overwritten. That also means worlds created before this mod keep whatever they had and needscripts/fix_voxy_storage.sh.
It is deliberately not built with fabric-loom. Every mixin target is a Voxy or LWJGL class, and those names are stable, so nothing needs remapping and no Minecraft mappings are involved — which sidesteps loom not tracking this Minecraft version. If a future mixin needs a Minecraft class, it has to move to loom.
Voxy ships real Iris integration, and it is enabled. But MixinShaderPackSourceNames registers
three extra source names that the shaderpack must provide — voxy.json, voxy_opaque.glsl,
voxy_translucent.glsl. Without them Voxy has no program to draw its geometry through in Iris's
pipeline, so LODs are simply absent while everything else still renders.
Support is per-pack and recent. The log line at world load says which path is in use:
NormalRenderPipeline (no Voxy under shaders) or IrisVoxyRenderPipeline.