Native image (GraalVM / Liberica NIK)
Nexira’s launch pipeline (auth, pack resolve, download/verify, JRE provisioning,
runtime resolve, game launch) is Compose-free and lives in :client-core /
:client-launcher / :client-auth*. The :client-cli module is a headless
entrypoint over that pipeline, built to a GraalVM / Liberica-NIK native binary
for Linux. The Compose GUI (:client-ui) stays on the JVM – see
Why not the GUI.
What you get
Section titled “What you get”A single self-contained nexira-cli executable: no JVM needed at runtime,
~10 ms cold start (vs ~130 ms for the JVM launcher), ~55 MB. It drives the same
launcher core the GUI does, including the OS keyring over D-Bus via libvault’s
Panama bindings.
nexira-cli list # installed pack instancesnexira-cli launch <packId> # offline launchnexira-cli launch <packId> --provider smartycraft # reuse the GUI's stored accountnexira-cli launch <packId> --dry-run # resolve + print the plan, no spawnnexira-cli version | helpPrerequisites
Section titled “Prerequisites”- A GraalVM 25 distribution with
native-image. Liberica NIK is the documented choice; any GraalVM 25 works (the headless CLI uses no vendor-specific flags).- SDKMAN (Liberica NIK):
sdk install nik-> installs under~/.sdkman/candidates/nik/current. - Or a distro package / tarball (e.g. Arch
jdk25-graalvm-bin).
- SDKMAN (Liberica NIK):
- A C toolchain for the final link:
gcc+zlibheaders (base-develon Arch,build-essential zlib1g-devon Debian/Ubuntu). - ~4 GB free RAM and a couple of minutes –
native-imageis its own process, separate from the Gradle/Kotlin daemons.
The build resolves native-image vendor-agnostically: the nativeImage { graalvmHome }
property, else GRAALVM_HOME, else NATIVE_IMAGE_HOME, else a scan of
/usr/lib/jvm (preferring a Liberica NIK install when several are present),
else PATH. Set GRAALVM_HOME if auto-detect picks the wrong one:
export GRAALVM_HOME="$HOME/.sdkman/candidates/nik/current"Build and run
Section titled “Build and run”GRAALVM_HOME=... ./gradlew :client-cli:nativeImage./client-cli/build/nativeImage/nexira-cli listThe binary is client-cli/build/nativeImage/nexira-cli. Linux x86-64 only;
native-image does not cross-compile, so build on the platform you target.
CPU target (-march)
Section titled “CPU target (-march)”native-image bakes the target CPU instruction set at build time (unlike the JVM
JIT, which adapts at runtime). The default is x86-64-v3 (needs AVX2, ~2013+).
Override with -PnativeMarch=<value>:
./gradlew :client-cli:nativeImage -PnativeMarch=native # fastest, THIS CPU only./gradlew :client-cli:nativeImage -PnativeMarch=x86-64-v2 # portable floor to distribute-march=native SIGILLs on any CPU older than the build host – never distribute
it; pick a baseline (x86-64-v2/v3) for that. native-image -march=list lists
the options. For an I/O-bound launcher the delta is small; PGO and startup matter
more than -march.
Reachability metadata
Section titled “Reachability metadata”native-image is built with --no-fallback: anything reflective, resource-,
ServiceLoader-, or FFM-based must be declared, or it fails (at build for most
kinds, at first call for unregistered FFM downcalls). The committed metadata
lives at client-cli/src/main/resources/META-INF/native-image/hivens/nexira-cli/
and is harvested with GraalVM’s tracing agent rather than written by hand.
Workflow (the agent merges across runs, so exercise several code paths):
GRAALVM_HOME=... ./gradlew :client-cli:nativeImageAgentRun # default: 'list'GRAALVM_HOME=... ./gradlew :client-cli:nativeImageAgentRun -PnativeAgentArgs="launch <id> --dry-run"# smartycraft dry-run drives the libvault keyring -> captures the FFM downcalls:GRAALVM_HOME=... ./gradlew :client-cli:nativeImageAgentRun -PnativeAgentArgs="launch <id> --provider smartycraft --dry-run"
./gradlew :client-cli:nativeImageMetadataCopy # build/native/agent-output -> committed resourcesgit diff client-cli/src/main/resources/META-INF/native-image # review, then commitRe-harvest when a new reachable path appears: a first real game launch
(GameCommandBuilder, RuntimeProvisioner, full JavaManagerService download)
exercises code the dry-runs do not, and may surface a few more entries. The
libraries carry their own metadata where it matters – OkHttp ships an
OkHttpFeature, kotlinx.serialization uses statically-generated serializers –
so the harvest is mostly coroutines internals, security providers, a handful of
resources, and the libvault FFM downcalls.
The plumbing
Section titled “The plumbing”nexira.native-image is a buildSrc convention plugin (hivens.nativeimage),
the same shape as nexira.packaging: typed ExecOperations tasks, no
third-party Gradle plugin, configuration-cache clean. It registers, under the
native-image group:
nativeImage– build the binary.nativeImageAgentRun– run under the tracing agent to harvest metadata.nativeImageMetadataCopy– promote harvested metadata into the resources.
Defaults (overridable via the nativeImage { } block): --no-fallback,
-H:+UnlockExperimentalVMOptions, --enable-native-access=ALL-UNNAMED,
-Djava.awt.headless=true. No --gc=G1 – that is Oracle-GraalVM-only and
would break on Liberica NIK (Community-based, serial GC).
Why not the GUI
Section titled “Why not the GUI”Compose Desktop renders through Skiko (Skia + AWT). Vanilla Compose-AWT does not
build into a working native image: Skiko loads libjawt/Skia at runtime by
paths that are unset in a native binary (java.home is absent), and the AWT
windowing path is not native-image-ready. This is an upstream limitation
(SKIKO-580,
skiko#925), not a config gap.
The only known workaround replaces AWT windowing with the JWM library, which is
proof-of-concept grade and does not carry our stack (skinema, FileKit, Coil,
libtray).
So :client-ui stays on the JVM. The nexira.native-image plugin is not
CLI-specific – if Skiko gains native-image support (static jawt linkage) or the
JWM path matures, the same plugin can target the GUI module. Until then the
headless CLI is the native surface, and the launcher core is deliberately kept
Compose-free so it can be.
Limits
Section titled “Limits”- Linux x86-64 only; no cross-compilation.
- The keyring path needs
--enable-native-access=ALL-UNNAMED(baked into the build defaults) and a running Secret Service / D-Bus session at runtime. - A real game launch beyond
--dry-runis not yet exercised in CI; harvest once against a real launch before relying on it for production.