Skip to content

Kotlin-first codegen for Minecraft internals — access the inaccessible, inject behavior, and write clean Kotlin. Enchant your mod with powerful extensions — that’s the magic of Lapis!

License

Notifications You must be signed in to change notification settings

Recrafter/lapis

Repository files navigation

Lapis

Kotlin-first codegen for Minecraft internals — access the inaccessible, inject behavior, and write clean Kotlin. Enchant your mod with powerful extensions — that’s the magic of Lapis!

Maven Central License: MIT


Installation

dependencies {
    // Add Lapis as a compileOnly dependency via KSP
    // Check latest version on Maven Central badge above
    ksp("io.github.recrafter:lapis:$version")
}

ksp {
    // Required: specify your mod ID
    arg("lapis.modId", "cobbble")

    // Required: specify your mod package name (maven group)
    arg("lapis.packageName", "io.github.recrafter.cobbble")
}

⚠️ Required: You must provide your mod ID using ksp.arg("lapis.modId", ...) This is used to namespace generated bridge methods and extensions.

Overview

💎 Kotlin Extensions — The Heart of Lapis

Lapis turns your annotations into clean, type-safe, inline Kotlin APIs, tailored for Minecraft modding. These Kotlin extensions are the primary developer-facing feature generated by Lapis.

Feature Annotation Purpose
Runtime patching @Patcher Inject custom logic and state into existing code
JVM access @Accessor Access private/protected fields and methods
Kotlin aliases @Alias Rename & wrap accessible members for clarity

@Patch — Add State & Logic to Existing Classes

Use @Patch to attach custom state and behavior to an existing class at runtime.
This is ideal for scenarios where you need per-instance logic—like tracking UI state, extending functionality, or hooking into lifecycle events.

Unlike @Accessor, @Patch doesn't just expose internals—it adds new Kotlin logic directly to target instances using Mixins, bridges, and auto-generated extension APIs.


Example — Track UI Overlay State

In this example, we patch MinecraftClient to track whether a custom overlay is shown, and reset it when a new screen opens.

@Patch(target = MinecraftClient::class)
abstract class MinecraftClientPatch : LapisPatch<MinecraftClient>() {

    var somePublicVar: Int = -1
    
    private var isOverlayShown: Boolean = false

    fun openOverlay() {
        isOverlayShown = true
    }

    fun closeOverlay() {
        isOverlayShown = false
    }

    @Hook(Kind.Method)
    private fun onSetScreen(
        @Function function: (MinecraftClient.(Screen?) -> Unit)? = MinecraftClient::setScreen,
        screen: Screen?
    ) {
        if (screen != null) {
            closeOverlay()
        }
    }
}

This is an abstract Kotlin class, not a mixin. It behaves like a component that is automatically attached to each instance of the target class (MinecraftClient).

  • You write regular Kotlin methods and fields.
  • @Hook (WIP) can be used to intercept target methods.
  • The Patch<T> superclass ensures the patch is scoped to the target instance.

What Lapis Generates

Lapis transforms your patch into a fully integrated runtime extension, consisting of:

✅ Patch Implementation

class MinecraftClientPatch_Impl(
    override val target: MinecraftClient
) : MinecraftClientPatch()

This holds your patch state and methods.
Each MinecraftClient instance will lazily attach its own ClientPatch_Impl.

✅ Java Mixin + Bridge

@Mixin(MinecraftClient.class)
public class MinecraftClientPatch_Mixin implements MinecraftClientPatch_Bridge {

    @Unique
    private MinecraftClientPatch_Impl patch;

    @Unique
    private MinecraftClientPatch_Impl getOrInitPatch() {
        if (patch == null) {
            patch = new MinecraftClientPatch_Impl(((MinecraftClient) ((Object) this)));
        }
        return patch;
    }

    @Override
    public int modid_getSomePublicVar() {
        return getOrInitPatch().getSomePublicVar();
    }

    @Override
    public void modid_setSomePublicVar(int newValue) {
        getOrInitPatch().setSomePublicVar(newValue);
    }

    @Override
    public void modid_openOverlay() {
        getOrInitPatch().openOverlay();
    }

    @Override
    public void modid_closeOverlay() {
        getOrInitPatch().closeOverlay();
    }
    
    // TODO Example of generated @Hook-based mixin injection
}

The mixin is injected into the target class.
It holds the patch instance and forwards calls to it.

✅ Kotlin Extensions

inline var MinecraftClient.somePublicVar: Int
    get() = (this as MinecraftClientPatch_Bridge).modid_getSomePublicVar()
    set(newValue) {
        (this as MinecraftClientPatch_Bridge).modid_setSomePublicVar(newValue)
    }

inline fun MinecraftClient.openOverlay() {
    (this as MinecraftClientPatch_Bridge).modid_openOverlay(s)
}

inline fun MinecraftClient.closeOverlay() {
    (this as MinecraftClientPatch_Bridge).modid_closeOverlay(s)
}

You can now write:

client.somePublicVar = 100
client.openOverlay()

…with no knowledge of the patch class, bridge, or mixin.
It's just Kotlin—and it's type-safe, inline, and zero-cost.


Under the Hood — How It Works

  1. You write a patch class that extends Patch<T>.
  2. Lapis generates:
    • A Kotlin *_Impl class (your patch logic)
    • A Java Mixin class injected into the target
    • A *_Bridge interface to define exposed members
    • Kotlin extensions that forward through the bridge
  3. Each target instance (e.g., MinecraftClient) lazily attaches a patch instance.
  4. Extensions resolve the correct patch via the mixin bridge and call its methods.

Notes

  • @Hook support is still WIP, but the structure is already in place.
  • Patch members must be non-private to be accessible through extensions.
  • You can use properties, methods, and even lifecycle handlers—everything behaves like normal Kotlin.

Summary

Feature Purpose
@Patch Define new behavior or state for existing classes
*_Impl class Your actual patch logic, in idiomatic Kotlin
Java Mixin + Bridge Inject patch and expose it to Kotlin extensions
Kotlin extensions Clean, inline APIs with no reflection or boilerplate

@Accessor — Open Private & Protected Members with Clean Kotlin

Use @Accessor when you need access to private or protected fields, methods, or constructors in Minecraft classes.
Lapis generates all the necessary Mixin boilerplate and exposes everything through safe, inline Kotlin extensions.

This eliminates the need for reflection or unsafe casting, while keeping your code clean and idiomatic.


Example — Access Hidden APIs

This accessor exposes:

  • a private field (window),
  • a private method (doTick()),
  • a private constructor.
@Accessor(
    widener = "net.minecraft.client.MinecraftClient",
    target = MinecraftClient::class,
)
interface MinecraftClientAccessor {

    @AccessField
    val window: Window

    @AccessMethod
    fun doTick()

    @AccessConstructor
    fun create()
}

Each member is abstract and annotated to indicate what it accessed.
Lapis automatically handles the Mixin wiring and Kotlin API generation.


What Lapis Generates

✅ Java Mixin

This interface is injected at runtime using Sponge Mixin.

@Mixin(MinecraftClient.class)
public interface MinecraftClientAccessor_Mixin {
    @Accessor("window")
    Window getWindow();

    @Invoker("doTick")
    void invokeDoTick();

    @Invoker("<init>")
    static MinecraftClient create();
}

These accessors/invokers provide raw JVM access to hidden fields, methods, and constructors.


✅ Kotlin Extensions

Lapis wraps the accessors with inline Kotlin extension functions:

inline val MinecraftClient.window: Window
inline fun MinecraftClient.doTick()

These are safe and feel like native APIs—no need for reflection or casting.


✅ Factory Object (for Static Access & Constructors)

Static methods and constructors are accessed via a generated Kotlin object:

object MinecraftClientKFactory {
    val window: Window
    fun create(): MinecraftClient
}

This ensures clean separation of static vs instance APIs.

You can now write:

val client = MinecraftClientKFactory.create()
client.doTick()
client.window

Just like you would with regular public APIs.


Wideners — Making Classes Accessible

When the target class is not public, you must provide a widener string.
Lapis uses this to generate entries in:

META-INF/lapis/wideners.txt

Nested accessors combine wideners automatically:

@Accessor(widener = "net.minecraft.Outer")
interface OuterAccessor {

    @Accessor(widener = ".Inner")
    interface InnerAccessor
}

➡️ Result: net.minecraft.Outer$Inner


Summary

Feature Purpose
@AccessField Exposes private or protected fields
@AccessMethod Exposes private or protected methods
@AccessConstructor Exposes hidden constructors
Kotlin extensions Inline, type-safe access to accessed members
Factory objects Clean APIs for static fields and constructors
widener Enables access to internal or package-private classes

Lapis turns internals into idiomatic, safe, Kotlin-first APIs—with no reflection and no boilerplate.


@Alias — Clean Kotlin Wrappers for Existing Public APIs

Use @Alias when the target class is already accessible, and you want to wrap or rename its members using clean, idiomatic Kotlin.

Unlike @Accessor, @Alias does not use Mixins.
It simply generates inline Kotlin extension functions and properties that redirect to public fields and methods, making your code more readable and maintainable.


Example — Renaming Public APIs

Here we define a new name for a public field and method:

@Alias(target = MinecraftClient::class)
interface MinecraftClientAlias {

    @FieldAlias("window")
    val mainWindow: Window

    @MethodAlias("setScreen")
    fun openScreen(screen: Screen)
}

This doesn’t change behavior—it just gives you a more expressive Kotlin-facing API.


Rules & Requirements

To ensure predictable code generation, the following rules apply:

  • The annotated type must be an interface
  • It must be a top-level (root) interface
  • Each property must use @FieldAlias
  • Each function must use @MethodAlias
  • All members must be abstract
  • The target class must be already visible (i.e., no @Accessor needed)

✅ What Lapis Generates

Given the example above, Lapis generates the following in:

MinecraftClientExt.kt
inline val MinecraftClient.mainWindow: Window
inline fun MinecraftClient.openScreen(screen: Screen)

These extensions redirect to the original member (window, setScreen) but use your alias names (mainWindow, openScreen).

They’re inline, type-safe, and require no reflection or runtime overhead.


Optional: typeAlias Support

If you specify the typeAlias parameter on @Alias, Lapis will also generate a Kotlin typealias for the target class:

typealias GameClient = MinecraftClient

This is useful when you want to abstract implementation details or unify references across modules.


Summary

Use Case Use
Access private or protected @Accessor
Rename or wrap public members @Alias
Add new state and behavior @Patch

Lapis gives you ergonomic, Kotlin-first access to existing APIs—no Mixins, no reflection, no mess.


License

This project is licensed under the MIT License.

About

Kotlin-first codegen for Minecraft internals — access the inaccessible, inject behavior, and write clean Kotlin. Enchant your mod with powerful extensions — that’s the magic of Lapis!

Topics

Resources

License

Stars

Watchers

Forks

Languages