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:
```gradle
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.