ref:main

Findings: Embedding Alacritty in Minecraft (Huorn)

Hard-won lessons from building a native terminal emulator inside Minecraft Java Edition.

1. Fabric’s Knot Classloader Breaks JNI Name-Based Lookup

The problem: Standard JNI relies on function name mangling (Java_com_example_MyClass_myMethod) to resolve native methods. Fabric Loader uses a custom classloader (KnotClassLoader) that loads mod classes. When System.load() is called, JNI registers native methods against the classloader that initiated the load. If the class declaring native methods was loaded by a different classloader than the one that called System.load(), JNI function lookup fails with UnsatisfiedLinkError — even though the symbol IS exported from the .dylib.

The fix: Implement JNI_OnLoad in Rust and call env.RegisterNatives() to explicitly bind function pointers to the Java class. This bypasses name-based lookup entirely. The jni crate’s NativeMethod struct + register_native_methods() makes this straightforward.

Implication: Any Minecraft mod using JNI via Fabric MUST use RegisterNatives. Name-based JNI will silently fail. This is not documented anywhere in Fabric’s docs.

2. Block Entity Renderer Quads Need z > 0, Not z < 0

The problem: Common wisdom says to render BER quads “slightly in front of” the block face (z = -0.01 for a north face at z = 0). This doesn’t work in MC 1.20.1. The quad is invisible despite correct vertex format, winding, and texture.

The fix: Render at z = 0.001 (slightly INSIDE the block) and remove the corresponding face from the block model. The text() render type with this z-position reliably renders on top of where the block face would be.

Why: The text() render type renders in the translucent pass. The solid block face (if present) writes to the depth buffer first. Even though z=-0.01 is mathematically closer, the depth precision and render pass ordering conspire to hide it. Rendering inside the block (z=0.001) with no competing block face avoids the issue entirely.

3. UV Coordinates Are Mirrored on North-Facing Quads

The problem: When a player looks at a block’s north face, the world X axis runs right-to-left from their perspective. If you map UV u=0 to x=0 and u=1 to x=1, the texture appears horizontally mirrored.

The fix: Flip the U coordinates: u=1 maps to x0 (left world edge), u=0 maps to x1 (right world edge). This applies to any quad rendered on a north face and rotated to other faces via Y-rotation.

4. MC Movement Uses Key State Polling, Not Events

The problem: Intercepting key press EVENTS via Architectury’s ClientRawInputEvent.KEY_PRESSED does NOT prevent WASD movement. Minecraft’s movement system polls key state via InputConstants.isKeyDown() every tick, bypassing the event system entirely.

The fix: Open a Screen subclass. When any Screen is open, Minecraft skips movement key polling in handleKeybinds(). A transparent Screen (no renderBackground(), isPauseScreen() returns false) captures input while showing the game world behind it. This is the same pattern Minecraft’s ChatScreen uses.

5. RenderType.text() Uses POSITION_COLOR_TEX_LIGHTMAP Format

The correct vertex chain for RenderType.text() (which is what MapRenderer uses for dynamic textures) is:

vertex(matrix, x, y, z).color(r, g, b, a).uv(u, v).uv2(light).endVertex()

Do NOT add .overlayCoords() or .normal() — those belong to NEW_ENTITY format (entityCutout, entityTranslucent). Adding extra vertex attributes to the wrong format produces invisible geometry with no error.

6. DynamicTexture + TextureManager.register() Auto-Generates Unique IDs

TextureManager.register("myprefix", dynamicTexture) returns a unique ResourceLocation like minecraft:dynamic/myprefix_0, _1, etc. Multiple TerminalTexture instances can coexist safely — each gets its own GPU texture and resource location. No need for manual ID management.

7. NativeImage Uses ABGR Format, Not RGBA

NativeImage.setPixelRGBA(x, y, value) is misleadingly named. The value is packed as ABGR: (alpha << 24) | (blue << 16) | (green << 8) | red. If you pass RGBA-packed values, colors will be wrong (red and blue swapped).

8. alacritty_terminal’s PTY Reader May Block

alacritty_terminal::tty::Pty::reader().read() can block on macOS in test contexts. In production (Minecraft game loop), this is fine because clientTick runs at 20Hz and the reader returns WouldBlock when no data is available. In unit tests without a game loop, avoid calling poll_pty() in a tight loop — use headless terminals with feed_bytes() instead.

9. The godot-alacritty → Minecraft Port Path

Porting from godot-alacritty to Minecraft was straightforward for the Rust side:

  • glyph_cache.rs: Zero changes needed (pure fontdue, no framework deps)
  • renderer.rs: Replace godot::Color with a simple Color struct, replace Gd<Image> with &[u8] pixel buffer
  • terminal.rs: Replace Godot signals with event channels, replace Godot key constants with GLFW constants

The Java/Minecraft side required entirely new code, but the architecture pattern is identical: Rust renders to pixel buffer → copy via FFI → upload to GPU texture → render on surface.

10. Automated Visual Testing in Minecraft

Minecraft has no built-in visual testing. We built one:

  1. Register a ClientTickEvents.END_CLIENT_TICK handler gated by -Dhuorn.visualtest=true
  2. Phase machine: wait for title → create world → place block → interact → screenshot
  3. Use Screenshot.takeScreenshot(renderTarget) to capture the framebuffer
  4. Analyze center pixels: terminal background (25,25,30) vs sky (49,55,64) vs grass (77,100,47)
  5. Save PNG for manual inspection, log PASS/FAIL

Key challenge: block entities aren’t immediately available after setblock — need a retry loop checking getBlockEntity() every 10 ticks until it appears.

11. @ExpectPlatform Naming Convention Is Rigid

The problem: Architectury’s @ExpectPlatform requires the implementation class to follow an exact naming pattern: {OriginalClass}Impl in the platform’s equivalent package. If the common class is io.fangorn.huorn.permissions.HuornPermissionsImpl, the Fabric implementation MUST be io.fangorn.huorn.fabric.permissions.HuornPermissionsImplImpl. Double “Impl” looks silly but is mandatory.

The fix: Name the common stub HuornPermissionsImpl and accept the Impl doubling. Alternatively, name the common stub without “Impl” suffix, but then the platform class still gets “Impl” appended. The convention is: {package}.{platform}.{subpackage}.{ClassName}Impl.

Implication: When designing cross-platform abstractions, plan class names around this convention upfront. Renaming later requires moving files in all platform modules.

12. Fabric Permissions API Version Pinning

The problem: The spec called for me.lucko:fabric-permissions-api:0.3-SNAPSHOT. That artifact doesn’t exist. The actual release is 0.3.1 on Maven Central.

The fix: Use modImplementation include("me.lucko:fabric-permissions-api:0.3.1") in fabric/build.gradle. The include() bundles it in the JAR so end users don’t need it as a separate download. Add the Sonatype snapshots repo only if actually using snapshots.

Implication: Always verify dependency coordinates against the actual Maven repository before specifying them in specs. SNAPSHOT artifacts from third parties are unreliable.

13. Pluggable Backend Architecture: Keep VTE in TerminalState

The problem: When extracting PTY spawning into a TerminalBackend trait, the question is where the VTE parser and Term grid live. Putting them inside the session would make the trait unwieldy. Keeping them in the calling code means the session is just a raw byte pipe.

The fix: TerminalSession is a raw I/O interface: read(&mut [u8]) and write(&[u8]). TerminalState keeps owning Term, Processor (VTE), and Renderer. It calls session.read() to get bytes, feeds them through VTE into Term, then renders. This means the Docker backend (or any future backend) just needs to provide a bidirectional byte stream — it doesn’t need to know about terminal emulation.

Why this matters: Docker’s attach stream, Firecracker’s virtio-console, and Hyper-V’s hvsock all provide bidirectional byte streams. By keeping the terminal emulation layer separate, any transport that can shuttle bytes is a valid backend.

14. Server-Side Permission Enforcement Requires Flipping the Interaction Model

The problem: The original TerminalBlockEntity.onPlayerInteract() had if (!level.isClientSide()) return; — it only ran client-side. LuckPerms permission checks require a ServerPlayer with server-side context.

The fix: Move permission checking to TerminalBlock.use(), which runs on both sides. Client returns InteractionResult.SUCCESS (optimistic). Server checks enableOnServers config, then HuornPermissions.hasPermission(), then proceeds. The server-side handler calls onPlayerInteract().

Implication: Any Minecraft mod adding server-side permission checks to block interactions needs to handle the dual-side nature of use(). The client must return SUCCESS optimistically (for animation), while the server does the actual validation. Denial messages are sent from server to client via sendSystemMessage().

15. ConcurrentHashMap<UUID, AtomicInteger> for Per-Player Limits

The problem: The original activeTerminalCount was a plain static int — no per-player tracking, not thread-safe. Adding maxTerminalsPerPlayer and maxTerminalsTotal requires concurrent-safe counters that multiple server threads can update.

The fix: ConcurrentHashMap<UUID, AtomicInteger> for per-player counts, standalone AtomicInteger for total. computeIfAbsent() for lazy initialization. Both checked before terminal creation, both decremented on session end (normal close, timeout, admin kill).

Gotcha: The AtomicInteger inside the map needs get() > 0 check before decrementAndGet() to avoid going negative if unregister is called twice (e.g., both stopTerminal and onBlockRemoved fire for the same terminal).

16. Native Library Must Be Rebuilt After JNI Class Path Changes

The problem: Renaming Java packages (e.g., io.fangorn.alacrittymc -> io.fangorn.huorn) changes the JNI class path used in JNI_OnLoad‘s RegisterNatives. But renaming the .dylib/.so file and updating lib.rs source isn’t enough — the compiled binary in common/src/main/resources/natives/ still contains the old class path. The GameTest crashes with ClassNotFoundException: io.fangorn.alacrittymc.nativelib.NativeTerminal.

The fix: After any change to JNI class paths in lib.rs, you MUST cargo build --release and copy the new binary to natives/. This is easy to miss because cargo test passes (Rust tests don’t use JNI class paths) and ./gradlew build passes (Java compilation doesn’t check native binary contents).

Implication: The CI pipeline must always rebuild natives before building JARs. Never ship pre-built native libraries after JNI-related code changes.

17. NativeTerminal Class Loading Must Not Happen During Server Init

The problem: Calling any static method on NativeTerminal (like nativeInitAudit) triggers Java’s class initialization, which runs the static initializer that calls System.load(). If this happens during HuornMod.init() (which runs on both client and server), it crashes dedicated servers / GameTest servers where the native library shouldn’t load.

The fix: Defer all NativeTerminal usage to the point where a terminal is actually created (client-side only). Use a volatile boolean auditInitialized flag to initialize audit logging on first terminal creation rather than mod init.

Implication: In Fabric/Forge mods, ModInitializer.onInitialize() runs on both client and server. Never reference classes with native-loading static initializers from shared init code.

18. Transitive Dependencies Can Silently Upgrade Fabric Loader

The problem: Adding me.lucko:fabric-permissions-api:0.3.1 as a dependency pulled net.fabricmc:fabric-loader:0.15.10 transitively, upgrading from our specified 0.15.3. The newer loader brought a Mixin subsystem that crashed with NoSuchFieldError: JAVA_22 during initialization.

The fix: Exclude the transitive loader dependency:

modImplementation(include("me.lucko:fabric-permissions-api:0.3.1")) {
exclude group: "net.fabricmc", module: "fabric-loader"
}

Implication: Always check ./gradlew :fabric:dependencies after adding new mod dependencies. Fabric Loader version upgrades can introduce Mixin compatibility breaks that only manifest at runtime, not compile time.