How it works
Mods ship as Java bytecode. Horizon has no Java in it, so the translator turns that bytecode into Rust ahead of time, and the Rust compiles into the game like any other part of it.
The translator reads every class in the jar, plus the libraries it bundles (Create carries Registrate, Flywheel and Ponder). Each method becomes a Rust function: one match arm per bytecode instruction, with lambdas, exceptions and records handled. Differential tests run the same code on the JVM and in Rust, and all 78 results match.
The runtime gives that code what Java gave it: objects, strings, static fields, a garbage collector, and method dispatch by class id.
horizon-compat answers the calls a mod makes into Minecraft, NeoForge and Java's libraries, in native Rust wired to Horizon's own hooks: blocks, block entities, rendering, events. A call that isn't mapped yet is logged by the game, and the most-called gaps get mapped first.
One method, three ways
// Create's DirectionalKineticBlock: which way a placed part faces
public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction preferred = getPreferredFacing(context);
if (preferred == null
|| (context.getPlayer() != null && context.getPlayer().isShiftKeyDown())) {
Direction nearestLookingDirection = context.getNearestLookingDirection();
return defaultBlockState().setValue(FACING,
context.getPlayer() != null && context.getPlayer().isShiftKeyDown()
? nearestLookingDirection
: nearestLookingDirection.getOpposite());
}
return defaultBlockState().setValue(FACING, preferred.getOpposite());
}
// javap -c on Create's jar, all 41 instructions (package names shortened)
0: aload_0
1: aload_1
2: invokevirtual getPreferredFacing:(BlockPlaceContext)Direction
5: astore_2
6: aload_2
7: ifnull 27
10: aload_1
11: invokevirtual BlockPlaceContext.getPlayer:()Player
14: ifnull 71
17: aload_1
18: invokevirtual BlockPlaceContext.getPlayer:()Player
21: invokevirtual Player.isShiftKeyDown:()Z
24: ifeq 71
27: aload_1
28: invokevirtual BlockPlaceContext.getNearestLookingDirection:()Direction
31: astore_3
32: aload_0
33: invokevirtual defaultBlockState:()BlockState
36: getstatic FACING:DirectionProperty
39: aload_1
40: invokevirtual BlockPlaceContext.getPlayer:()Player
43: ifnull 60
46: aload_1
47: invokevirtual BlockPlaceContext.getPlayer:()Player
50: invokevirtual Player.isShiftKeyDown:()Z
53: ifeq 60
56: aload_3
57: goto 64
60: aload_3
61: invokevirtual Direction.getOpposite:()Direction
64: invokevirtual BlockState.setValue:(Property, Comparable)Object
67: checkcast BlockState
70: areturn
71: aload_0
72: invokevirtual defaultBlockState:()BlockState
75: getstatic FACING:DirectionProperty
78: aload_2
79: invokevirtual Direction.getOpposite:()Direction
82: invokevirtual BlockState.setValue:(Property, Comparable)Object
85: checkcast BlockState
88: areturn
// What the translator wrote for it (excerpt; long lines wrapped)
pub fn j_directionalkineticblock_getstateforplacement_43dc8b0a(rt: &mut Rt, args: &[V]) -> V {
let mut l = [V::Void; 4];
l[0] = args.first().copied().unwrap_or_default();
l[1] = args.get(1).copied().unwrap_or_default();
let mut s: Vec<V> = Vec::with_capacity(3);
let mut pc: usize = 0;
let mut thrown_at: usize = 0;
loop {
match pc {
0 => { s.push(l[0]); pc = 1; }
1 => { s.push(l[1]); pc = 2; }
2 => {
let at = s.len().saturating_sub(2);
let args: Vec<V> = s.split_off(at);
let r = {
let cid = rt.recv_cid(&args);
d_getpreferredfacing_93e35498(rt, cid,
"net/minecraft/world/level/block/Block.getPreferredFacing.(Lnet/minecraft/world/item/context/BlockPlaceContext;)Lnet/minecraft/core/Direction;",
&args)
};
if rt.thrown.is_some() { thrown_at = 2; pc = 18446744073709551614; } else { s.push(r); pc = 5; }
}
5 => { l[2] = s.pop().unwrap_or_default(); pc = 6; }
6 => { s.push(l[2]); pc = 7; }
7 => { let a = s.pop().unwrap_or_default().r(); if a == 0 { pc = 27; } else { pc = 10; } }
// 34 more arms like these, one per instruction, pc 10 to pc 85
88 => { return s.pop().unwrap_or_default(); }
18446744073709551614 => {
let e = rt.thrown.unwrap_or_default();
rt.unwound("com/simibubi/create/content/kinetics/base/DirectionalKineticBlock.getStateForPlacement");
return V::Void;
}
_ => return V::Void,
}
}
}
The rule that picks the way a placed part faces. The translator never sees the Java: it reads the bytecode and writes the Rust, one match arm per instruction. The Rust isn't pretty, but it's native and it compiles with the game.
Why not run Java next to the game?
Horizon exists to be fast. A JVM shipped inside it to run mods would bring back the cost Horizon was written to remove. Translated code compiles into the same native binary as the game and calls it directly.
Why Create first
Create is a hard case: kinetic networks, custom renderers, moving contraptions, trains, and three bundled libraries. Most mods use a smaller part of the same API. Nothing in the translator is written for Create in particular. The same steps take any NeoForge jar, and Macaw's Fences already went through them.
Modpacks
Drop a pack in and every jar in it is translated today. Next, a pack becomes one engine, so mods that call each other do it directly and the game is built once.