index architecture stat-reference template-mode config-schema hardware-sensors build+ci | repo↗ homepage↗

architecture

on this page:

source sets / env split

Loom's splitEnvironmentSourceSets() is on, so src/main and src/client are genuinely separate compile units, not a convention. Almost everything lives in src/clientMineTunerMod (the common entrypoint) is the only class in src/main, and it does nothing but hold a logger. This mod has no server component full stop; fabric.mod.json declares "environment": "client". Don't go looking for a server-side data source, there isn't one — MSPT and TPS on multiplayer are read from the client's own ClientboundTickingStatePacket, not queried from the server.

sampling pipeline

Three-layer indirection, entirely intentional, don't try to collapse it:

StatSource.sample(ctx)  →  MineTunerDataHolder (static fields)  →  StatDefinition.format()/rawValue()

StatSource — one interface, 11 implementations in sample/sources/. Each source pulls one raw value or a small related cluster (WorldStateSource covers weather/difficulty/chunk-pos/distance-from-spawn together, since they're all cheap reads off the same ClientLevel/LocalPlayer) and writes straight into MineTunerDataHolder's public static fields. No source reads another source's output.

Cadence — every source declares one of four:

cadencerunswho uses it
PER_FRAMEevery render frame, unconditionallyFPS, anything cheap enough not to matter
PER_TICKat most once per 50msmost gameplay state — player vitals, position, world counts
THROTTLEDat most once per 500msCpuStat's OperatingSystemMXBean poll — deliberately not polled every tick
EVENT_PUSHEDnever polled by the driver at alltick rate — pushed by ClientPacketListenerMixin on packet receipt, see below

SamplingDriver.sampleAll() runs once per frame from MineTunerRenderer.render(). It timestamps against two module-level lastTickMs/lastThrottledMs longs (System.currentTimeMillis(), not a tick counter — this fires off wall-clock time, so it's not coupled to actual game-tick cadence) and iterates SourceRegistry.all(), checking isAvailable(ctx) before calling sample(). Every call is wrapped:

try {
    src.sample(ctx);
} catch (Exception e) {
    if (WARNED_SOURCE_IDS.add(src.id())) {
        MineTunerMod.LOGGER.error("StatSource \"{}\" threw during sample() — ...", src.id(), e);
    }
}

One throwing source degrades to stale values for its own stat(s) only — it does not take down the frame, and does not stop being retried next frame (only the log line is deduped, via a static Set<String> of source ids already warned about; sampling itself keeps retrying every single frame indefinitely).

MineTunerDataHolder is a plain static field bag, no synchronization beyond what's inherent to primitives — this is safe specifically because everything writing to it runs on the render thread except HardwareSensorPoller's five GPU/VRAM doubles, which are written from its own daemon thread. Those five fields are the only ones a background thread touches; if you add a new cross-thread field, you're on your own for the visibility story (in practice: individual double/boolean field writes/reads are what LHM's fields rely on, there's no lock).

Frametime is the one deliberate exception to this whole pipeline. SamplingDriver.sampleAll()'s only call site is inside MineTunerRenderer.render(), which early-returns — skipping sampling entirely — whenever the overlay is disabled, F3 is open, or the MineTuner editor GUI is open. That's harmless for a "current value" stat (it just goes stale until the next real sample), but frame-to-frame timing can't tolerate a gap like that: the next sample after any such gap would read as one enormous fake spike, since real elapsed time would have gone uncounted. So FRAMETIME is not a StatSource at all — MineTunerClient registers LevelRenderEvents.START_MAIN directly, which fires once per actually rendered frame regardless of HUD/overlay/GUI/F3 state, calling MineTunerDataHolder.recordFrametime(long) straight from there. Everything downstream (FrametimeStat, its history ring buffer, its color thresholds) is completely ordinary and looks just like CpuStat to the rest of the codebase; only the sampling trigger differs.

FPS_1PCT_LOW/FPS_01PCT_LOW (PercentileLowSource, THROTTLED) split the difference cleanly: their raw input — a second, dedicated ring buffer of unsmoothed per-frame deltas, deliberately separate from FRAMETIME's own smoothed history, since a percentile-low stat exists specifically to catch individual stutter frames a smoothed average would wash out — is pushed from that same always-on recordFrametime(long) call, so it never has an overlay/F3/GUI gap to worry about. Only the periodic sort-and-slice recomputation of the percentile itself goes through the ordinary, gated StatSource/SamplingDriver path — which is fine, since that recomputation is exactly the "current value" case the note above already covers: it just pauses and resumes, never corrupts.

stat registry pattern

StatDefinition is the single source of truth per stat — formatting, decimals, graph/threshold support, and coloring all live in one implementation class, one file per stat in stat/stats/. StatRegistry's static initializer block registers all 52 by hand (no classpath scanning, no annotations) into two LinkedHashMaps: one keyed by MineTunerConfig.Stat enum constant, one keyed by lowercase token string for Template Mode lookups.

The point of this pattern: nothing else in the codebase — GUI, HUD renderer, Template Engine, Cloth Config screen — contains a switch (stat) { ... } that enumerates individual stats for formatting/coloring/graph logic. They all read through StatRegistry.get(stat) or .byToken(name) and call interface methods. Adding a 47th stat means adding a file and a registration line, not touching five other files that happen to have hardcoded stat lists. See adding a stat below for the literal steps.

StatDefinition methods are almost entirely default-implemented to false/ zero/identity — a stat that's plain text with no graph/threshold support (e.g. EntitiesStat) overrides maybe two methods total. Copy the smallest existing stat as a template, not a "full" one.

render pipeline

Per frame, per list, roughly:

LineCache.getCachedLines(listCfg)
  → (classic mode) LineBuilder builds ColoredRun rows from enabled stats in statOrder
  → (template mode) TemplateEngine.getParsedLines() + .renderRuns() per line
  → MineTunerRenderer.drawRows() walks RowKind.TEXT / RowKind.GRAPH rows, draws via Font/GuiGraphicsExtractor
  → GraphRenderer.drawGraph() for any row that's a graph

MineTunerRenderer is registered via Fabric API's HudElementRegistry.attachElementBefore (VanillaHudElements.CHAT, ...) in MineTunerClient.onInitializeClient() — this is what guarantees minetuner doesn't stomp other HUD-attaching mods; it's an ordered slot in the vanilla HUD element chain, not a raw render-event hook drawing over everything.

Per-list scale isn't baked into cached geometry — MineTunerRenderer.render() checks listCfg.textScale and, if not exactly 1f, pushes/pops a pose matrix and calls drawRows at local origin (0,0) inside the scaled space, rather than scaling every draw call's coordinates by hand.

Guard clause worth knowing if the overlay "disappears" during debugging: render() bails immediately if !overlayEnabled, if the vanilla F3 debug screen is open (showDebugScreen()), if there's no active connection, or if the minetuner editor screen itself is open (MineTunerGuiScreen renders its own preview, the live overlay doesn't double-draw underneath it).

HudMotion.pulseFor(...) drives the small per-line settle animation you see when a value changes — purely cosmetic, keyed by (listId, lineIndex), computed against System.nanoTime(), blended into the run color via ColorMath.blend. Not gameplay-relevant, ignore it if you're just trying to trace a stat's value.

config lifecycle

MineTunerConfig is a singleton (getInstance(), lazy-inited) backed by <config-dir>/minetuner.json, read/written with Gson, pretty-printed.

load()

Gson's default behavior is what makes backfill mostly automatic: it only overwrites a field when its key exists in the JSON, so an old minetuner.json missing a newer primitive field (e.g. hardwareSensorPollIntervalMs) just keeps that field's = <default> initializer value. backFill() exists for the cases Gson's default behavior doesn't cover cleanly — null collections, enum-keyed maps that need every current key present, and one explicit field rename.

save()

Atomic write: serialize to minetuner.json.tmp, then Files.move(tmp, target, REPLACE_EXISTING, ATOMIC_MOVE), falling back to a non-atomic move if the filesystem doesn't support atomic moves (AtomicMoveNotSupportedException). clampGuiTuning() runs before every save too, not just on load — so a value pushed out of range via Cloth Config gets clamped before it ever hits disk. On write failure, the leftover .tmp file is best-effort deleted so it doesn't confuse the next save attempt.

the one mixin

ClientPacketListenerMixin is the entire mixin surface of this mod — one @Inject, TAIL of ClientPacketListener#handleTickingState:

@Inject(method = "handleTickingState", at = @At("TAIL"))
private void minetuner$onTickingState(ClientboundTickingStatePacket packet, CallbackInfo ci) {
    MineTunerDataHolder.tickRate = packet.tickRate();
}

This is why TPS works correctly on remote multiplayer without polling anything server-side — the vanilla client already receives this packet whenever tick rate changes, minetuner just also grabs it. minetuner.mixins.json has "required": false and an empty top-level mixins array (the actual mixin is registered in the client-only config, minetuner.client.mixins.json, loaded conditionally per fabric.mod.json's "environment": "client" mixin entry) — consistent with this being a client-only mod where a mixin failing to apply on a dedicated server should never be fatal.

adding a stat, mechanically

Straight from the README, reproduced here because it's the fastest way to understand the registry pattern by example:

  1. Add a constant to MineTunerConfig.Stat.
  2. Implement StatDefinition in stat/stats/. Copy EntitiesStat for a plain-text stat, or PingStat if you need graph/ threshold support.
  3. Register an instance in StatRegistry's static block.
  4. Add lang keys (stat.minetuner.<name>, minetuner.stat.<name>) to en_us.json.
  5. If it reads live game/JVM state, source it from a StatSource in sample/sources/ (or MineTunerDataHolder directly), keeping the StatDefinition a thin delegate rather than having it poll anything itself.
  6. Add its row to stats.html

No file outside this list needs touching. No switch(stat) anywhere else to update.