PlunderEngine
PlunderEngine is the core mod of the PlunderPixels Link suite. I build the PlunderPixels Shader (link), and along the way I kept hitting things a shader alone cannot know: where the wind actually is, where a water body starts and ends, what the grass texture the player installed really looks like. So I built a client-side engine that reads the live game and hands that data to shaders and feature mods through clean, versioned channels. This jar is that engine.
It does nothing visible on its own. It is the required dependency for the Link feature mods, and WindLink (link) is the first of them. Install PlunderEngine together with a Link.
This is the first public release, 0.1.0. The suite is under serious active development, and what you tell me directly shapes what gets built next.
One core, so the suite never turns into config soup
Every Link plugs into this same core. That does three major things:
- One config file.
config/plunderlink.json, auto-saved with a debounce, with export and import. Each installed Link keeps its settings in its own section of the same file, encouraged distribution of these settings by shader devs for their default configs (native shader hook WIP). - One settings screen. Open it from a keybind, ModMenu, or the Video Settings button (vanilla and Sodium both supported). Every installed Link adds its own tab and the engine renders all of them, so five Links still feel like one mod.
- One status command.
/plunderengineprints a live report: every installed Link, its shader channel bindings, per-system frame timings, and the config summary, plus config reload and export from chat.
However many Links you run, you configure the suite in one place and debug it with one command.
An API you can actually build from
The other half of PlunderEngine is a public API. This is not just my suite's private plumbing. If you make mods, you can depend on plunderengine and get:
- Module registration that survives any load order. Your mod implements the
plunderengineentrypoint and registers a module. The engine collects every registration first and loads config exactly once, after all of them, so settings never silently reset because one jar loaded before another. - Config persistence for free. Your module declares its config section; the engine handles saving, loading, export and import. You never touch the file.
- A settings tab for free. Declare toggles, sliders and cyclers through a small widget vocabulary; the engine builds the screen and tracks dirty state for you.
- Texture parsing via SpriteMetrics. Measure any texture in the player's active resource pack stack: where the visible art starts and ends, opaque coverage, average color. Cached, fail-soft, refreshed on resource reload. This is how WindLink measures grass sprites and publishes real blade heights, so the shader bends the blade the player actually sees, on any pack.
- The Iris channel bridge pattern. The conventions for publishing live game data as shader-readable channels, plus PlunderData, a machine-readable catalog of every channel the suite publishes. Iris stays optional; without it everything degrades cleanly.
There is also a registry-driven render takeover for mods that suppress a block's vanilla rendering and draw their own replacement geometry.
The API version is declared in the mod metadata. Breaking changes bump it; the plan is additive growth, not churn.
The shader connection
The Links use this engine to feed the PlunderPixels Shader (link) live data channels: wind and gusts, terrain flow, water bodies and shores, light, heat, seasons. That is the point of the whole suite: the shader gets to know things a shader alone cannot know. Nothing hard-depends on the pairing, though. Without the mods, the pack falls back to its own math. Without a shader, the Link mods keep their in-game effects. The engine itself is shader-agnostic.
Compatibility
Client-side only and vanilla-server safe. PlunderEngine adds nothing to the world, sends nothing to any server, and can be added or removed at any time. Fabric, Minecraft 26.1.x, Java 25 or newer, Fabric API required. Sodium, Iris and ModMenu are optional; Sodium and Iris are recommended.
Where this is going
The core is solid enough that WindLink ships on it today, but this is still a first release. More Links are in development (water, light, heat, ground cover) and each will plug into this same core as its own separate mod, the engine will grow and I will be providing more visual data channels that shaders and other mods can pull from to create more.
I am building the whole thing to be accessible: sane defaults, one screen, everything optional, clean fallbacks when a piece is missing. If you build against the API, or you just run the suite and something feels wrong please tell me. Feedback from the community will keep me motivated and excited to keep this ball rolling.
Documentation
Part 1: For players
Requirements
- Minecraft 26.1.x, Fabric Loader 0.19.2 or newer, Fabric API, Java 25 or newer.
- Sodium and Iris are optional and recommended.
- At least one Link mod, or the engine has nothing to run. WindLink (link) is the one that exists today.
The config file: config/plunderlink.json
One JSON file for the whole suite. Each installed Link owns one section in it, keyed by its module id (WindLink adds "wind" and "windViz", for example). The file is written out with defaults on first run so you can find and edit it.
How it behaves:
- Changes made in the settings screen save automatically, so dragging a slider produces one write.
- Writes go to a temp file first and are moved into place, so a crash mid-write can never corrupt a config.
- Hand-editing is supported. Missing keys fall back to defaults, unknown keys are ignored, and out-of-range values are clamped on load. A corrupt file is left on disk untouched; defaults are applied in memory only.
- Exports live under
config/plunderlink/exports/as timestamped copies (plunderlink-20260722-153000.jsonstyle).
The file name says plunderlink, not plunderengine. That is historical and frozen for compatibility; your settings survive updates because the name never changes.
The settings screen
One screen for the whole suite. Tabs across the top, one per installed Link, plus an ENGINE tab for config utilities. Hovering a control shows its description in a footer strip. Ways in:
- Keybind. A "PlunderEngine" category in Controls. Unbound by default so it never collides with your existing binds; set it to whatever you like.
- ModMenu. If you have ModMenu installed, the mod's config button opens the screen.
- Video Settings, with Sodium. Sodium's Video Settings gets a PlunderEngine entry with the brand icon; opening it lands on this screen and Done returns you to Video Settings.
- Video Settings, without Sodium. The vanilla Video Settings screen gets a small "PlunderEngine…" button in the top-right corner.
Exactly one of the two Video Settings entries shows, depending on whether Sodium is installed.
One extra: when the active Iris shader pack is a PlunderPixels pack, the screen also shows a curated set of the pack's own options (wind, sky, water, post effects, performance) and writes them to the pack's normal settings file, then asks Iris to reload. It only ever does this for PlunderPixels packs, never a third-party pack.
The /plunderengine command
Client-only, never sent to the server.
/plunderengineor/plunderengine statusprints the engine version plus the detected Minecraft, Iris and Sodium versions, then every installed Link's own status lines (including its shader channel ids when Iris is present), a timings snapshot, and the config file state./plunderengine config reloadre-readsconfig/plunderlink.jsonand applies it live. Useful after hand-editing the file./plunderengine config exportwrites a timestamped copy of the current settings underconfig/plunderlink/exports/.
If status says modules: none installed, the engine is running but no Link mod is present. Install one; WindLink (link) is the place to start.
Part 2: For modders (the API)
PlunderEngine is not just my suite's plumbing. It is a public API surface a third-party mod can depend on and build from. Register a module and you get config persistence, a settings tab, and status reporting for free; the texture parsing and shader channel layers are there when you need them. Everything below is client-side and fail-soft by doctrine.
The API version rides the "plunderengine:module_api" custom key in the core's fabric.mod.json (currently v1). Breaking changes bump it; additive growth is the norm.
Depending on the engine
In your fabric.mod.json:
{
"depends": {
"fabricloader": ">=0.19.2",
"fabric-api": "*",
"minecraft": "~26.1",
"java": ">=25",
"plunderengine": ">=0.1.0"
}
}
For your dev environment, add the released jar with modImplementation. There is no Maven endpoint yet (this is release one); use a local file dependency or the Modrinth maven once the project page is live. On Modrinth/CurseForge, list PlunderEngine as a required dependency so launchers auto-install it.
The "plunderengine" entrypoint
The engine finds your mod through a custom Fabric entrypoint. Declare it alongside your normal client entrypoint:
"entrypoints": {
"client": [ "com.example.mylink.MyLinkClient" ],
"plunderengine": [ "com.example.mylink.MyLinkInit" ]
}
and implement com.plunderpixels.engine.module.PlunderEngineInit:
public final class MyLinkInit implements PlunderEngineInit {
@Override
public void registerModules() {
EngineModules.register(new MyModule());
}
}
Why a custom entrypoint and not your client init: the engine's bootstrap iterates every installed mod's "plunderengine" entrypoint first, and only then loads config/plunderlink.json. Custom entrypoint iteration is load-order independent across mods, so the module registry is always full before the config applies. Without this, a mod whose client entrypoint happened to run after the config load would silently reset its settings to defaults on every boot.
Two hard rules:
registerModules()is registration-only. No events, no textures, no world access, no dependence on any other mod having initialized. Your runtime wiring belongs in your own"client"entrypoint.- Never load the config yourself. The core loads it exactly once, after all registrations. If your entrypoint throws, the engine logs it and carries on; one broken jar must never take the suite down.
Implementing EngineModule
com.plunderpixels.engine.module.EngineModule is the contract, one instance per config section you own:
public interface EngineModule {
String id(); // config section key AND tab id, lowercase, frozen once shipped
default String displayName() { ... } // human name, defaults to id()
void save(JsonObject section); // write your LIVE state into your section
void load(JsonObject section); // apply your section to live state; clamp everything
default List<String> status() { ... } // 0..n lines for /plunderengine status
default String screenTab() { ... } // settings tab id, or null for no screen presence
default void buildScreen(EnginePanel panel) { } // lay out your controls
}
What each piece means in practice:
id()is the JSON section key inconfig/plunderlink.jsonand is back-compat frozen the moment you ship. Pick it carefully.save(JsonObject section)writes your live values with plainaddPropertycalls.load(JsonObject section)may receivenullor a partial object (older or hand-edited file). Default anything absent, clamp everything, never throw on a bad value. The core shipscom.plunderpixels.engine.module.JsonCfgfor exactly this:optBool,optInt,optLong,optFloat,optDouble,optString(null section, missing key, or wrong JSON kind all return your default) plusclampInt,clampLong,clampFloat,clampDouble. A load method should read as a flat list offield = clamp(opt(section, key, DEFAULT), lo, hi)lines.status()feeds/plunderengine status. Include your Iris channel GL ids here, but build those lines in a separate method guarded byFabricLoader.getInstance().isModLoaded("iris")so Iris-typed classes never class-load when Iris is absent.screenTab()returns the tab your panels stack under (lang keyplunderengine.tab.<value>). Modules sharing a value stack on one tab in registration order, each under aplunderengine.section.<id>header. Returnnullfor no screen presence.buildScreen(EnginePanel)lays out your controls through the panel vocabulary below.
Registration lives in com.plunderpixels.engine.module.EngineModules: register(EngineModule) (call once at client init; null and duplicate ids are ignored), all(), byId(String), count(). Registration order is display order for tabs and status.
The EnginePanel widget vocabulary
com.plunderpixels.engine.module.EnginePanel is how your module contributes settings UI without referencing a single GUI class. The core screen implements it and hands it to your buildScreen; deliberately no Minecraft types appear in the interface, so your module stays unit-testable.
public interface EnginePanel {
enum Format { INT, ONE_DP, TWO_DP, PERCENT }
void toggle(int row, int col, String key, BooleanSupplier getter, Consumer<Boolean> setter);
void slider(int index, String key, double min, double max, double step, Format format,
DoubleSupplier getter, DoubleConsumer setter);
void cycler(int row, int col, String key, Supplier<String> stateLangKey, Runnable next);
interface Track {
double toTrack(double real); // real value -> normalized 0..1 track position
double fromTrack(double norm); // and back
String format(double real); // display string
}
void curved(int index, String key, Track track, DoubleSupplier getter, DoubleConsumer setter);
}
The layout contract: two columns. toggle and cycler place at an explicit (row, col) local to your section; slider and curved take a running index (col = index % 2, row = index / 2). The screen offsets everything by your section base so stacked module panels never collide. Values flow through primitive suppliers and consumers that mutate your live state directly; the screen marks the config dirty after every mutation, and the debounced save handles the rest. You never touch persistence from a panel.
curved exists for non-linear sliders: your Track maps between real values and track position, so you can ship a piecewise curve (a fine band and a coarse band on one slider) without the core knowing the curve.
Labels are lang keys: plunderengine.opt.<key> for the label and plunderengine.opt.<key>.tip for the hover description in the footer.
Lang files
Ship your own slice of assets/plunderlink/lang/en_us.json. Lang files merge across jars, so your file only carries your keys:
plunderengine.tab.<tab>for your tabplunderengine.section.<moduleId>for your section headersplunderengine.opt.<key>andplunderengine.opt.<key>.tipfor your controls
SpriteMetrics: measuring the art the player actually sees
com.plunderpixels.engine.texture.SpriteMetrics measures any texture in the active resource stack and hands back cached, fail-soft metrics. This is how a mod learns things a shader alone cannot know: where a sprite's visible art starts and ends, how solid a texture is, what color it averages to, on whatever resource pack the player is running. WindLink (link) uses it to measure grass sprites and publish blade heights, so the shader's wind bends the visible blade on every pack, not the quad.
The result is a record, in resolution-agnostic fractions:
public record Metrics(boolean valid, int width, int height,
float visibleTopFraction, float visibleBottomFraction,
float opaqueFraction, int averageColor) { }
visibleTopFraction is how far up the art reaches from the sprite bottom, 0..1 (a blade whose pixels top out 13px up a 16px sprite reads 0.8125). visibleBottomFraction is where the art starts from the bottom (0 means it touches the bottom row). opaqueFraction is the share of pixels at or above the alpha threshold (128 of 255). averageColor is 0xRRGGBB over the visible pixels.
Usage:
import com.plunderpixels.engine.texture.SpriteMetrics;
import net.minecraft.resources.Identifier;
Identifier id = Identifier.withDefaultNamespace("textures/block/short_grass.png");
SpriteMetrics.Metrics m = SpriteMetrics.get(id);
if (m.valid()) {
float bladeTop = m.visibleTopFraction(); // feed this into your channel or geometry
}
The contract: get is client-thread, lazy and cached; the first call reads and measures, later calls are a map hit. The whole cache drops on resource reload, F3+A, and video re-init (the core wires one InvalidateRenderStateCallback to invalidateAll() for every consumer), so just re-query and you always see the active pack. Any failure returns Metrics.INVALID (valid() == false, full-extent fully-opaque defaults) and the failure is cached too, so there is no per-frame retry hammering. Animated strips measure the first frame only. The pure measure(int[] argbPixels, int width, int height, int alphaThreshold) is public and unit-testable if you want the math without the resource manager.
Iris channels: publishing live data to a shader pack
A Link publishes live data to shaders as small textures bound to named samplers. The engine's role is the convention and the catalog; the registration itself is a small mixin you own.
The registration pattern. Copy WindLink's: mixin into the tail of Iris's CustomTextureManager constructor and putIfAbsent your texture's access object into irisCustomTextures:
@Mixin(CustomTextureManager.class)
public class MyCustomTextureManagerMixin {
@Shadow @Final private Object2ObjectMap<String, TextureAccess> irisCustomTextures;
@Inject(method = "<init>", at = @At("TAIL"), require = 0)
private void mylink$registerChannels(CallbackInfo ci) {
irisCustomTextures.putIfAbsent(MyFieldTexture.SAMPLER_NAME, MyFieldTexture.INSTANCE.access());
}
}
The details all matter:
require = 0, so a shifted Iris internal never hard-crashes; your mod stays usable without the binding.- Gate the whole mixin behind a mixin plugin that checks
FabricLoader.getInstance().isModLoaded("iris"). Iris is a compile-only dependency; none of its types may class-load when it is absent. putIfAbsent, neverput. Multiple Links registering at the same insertion point is safe, and you never clobber a pack that legitimately declared the same name.- The manager is rebuilt on every shader reload and dimension change, so registration re-runs automatically.
- A binding is only consumed by a program that declares the sampler (
uniform sampler2D plunderMyField;), so registration is inert for every other pack.
The catalog. Also register your channel's metadata with com.plunderpixels.engine.data.PlunderChannels so /plunderengine status, the settings screen, and other mods can discover it:
PlunderChannels.register("my-field", "plunderMyField", "MyConvention v1", "one line saying what it carries");
A Channel is pure metadata (name, sampler uniform, convention version, description); the live GL texture stays in your module. Sampler ids are a frozen shader contract: describe them, never rename them. com.plunderpixels.engine.data.PlunderData holds the known catalog of the channels the current suite publishes; use it as the reference for the naming style.
The pairing rule. If you ship a mod paired with a shader pack, the mod must register every channel the paired pack declares. A pack source that reads a sampler no running jar registers is a broken contract: that read has no defined feed, and no fallback can save it. Ship the registration first, the shader read second, always. The inverse rule is just as hard: a channel's mere registration must not change shader behavior. It has to be inert until fed. I learned this the hard way with the season channel, which wrote a default alpha and silently overrode the shader's own season calendar; a registered-but-idle channel must read as absent, not as a value.
Render takeover
com.plunderpixels.engine.render.BlockRenderTakeover is for modules that draw their own replacement geometry. register(String name, Predicate<BlockState> hidden) at client init; any matching block is meshed as invisible (the one getRenderShape seam both the vanilla mesher and Sodium consult), and your module draws it itself. Render-only: collision, drops and behavior are untouched. The predicate runs per-block at mesh time, so keep it a cheap short-circuit and gate it on your own enable flags; a disabled module must hide nothing, and the set you hide must equal the set you redraw.
The doctrine
Every Link, mine or yours, holds to the same rules the engine holds to:
- Client-side only. Zero server contact, vanilla-server safe.
- Mod-removal safe: uninstalling a Link leaves nothing broken behind.
- Iris and Sodium are optional. Compile-only dependencies, entrypoint or mixin-plugin gated, so their absence can never crash a class load.
- Fail-soft everywhere. Degrade, never crash.
- Keep the engine pure and tested first; wire the game in second.
Feedback
This is release 0.1.0, the first public cut, and the suite around it is under serious active development. WindLink (link) is the living reference for everything above, and the PlunderPixels Shader (link) is the deep end of what the channels can do. If you build a Link, hit an API gap, or just have an opinion on what the engine should learn next, say so on the project page. The roadmap is genuinely steered by what people ask for.

