architecture
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/client — MineTunerMod (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:
| cadence | runs | who uses it |
|---|---|---|
PER_FRAME | every render frame, unconditionally | FPS, anything cheap enough not to matter |
PER_TICK | at most once per 50ms | most gameplay state — player vitals, position, world counts |
THROTTLED | at most once per 500ms | CpuStat's OperatingSystemMXBean poll — deliberately not polled every tick |
EVENT_PUSHED | never polled by the driver at all | tick 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()
- File missing → build fresh defaults (one
StatListConfig(0)), save, return. - File present, parses → run
clampGuiTuning(), null-guard every list-level collection, migrateanchorDx/anchorDylegacy pixel offsets into normalizedanchorFracX/Y(seeStatListConfig.backFill()), backfill any stat/threshold map keys added since the file was written. - File present, fails to parse (
IOException,JsonSyntaxException,JsonIOException) → copy the broken file aside asminetuner.json.bak-<currentTimeMillis>, log an error, fall through to fresh defaults. This used to silently discard the file; it doesn't anymore — check for a.bak-*sibling before assuming lists were actually lost.
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:
- Add a constant to
MineTunerConfig.Stat. - Implement
StatDefinitioninstat/stats/. CopyEntitiesStatfor a plain-text stat, orPingStatif you need graph/ threshold support. - Register an instance in
StatRegistry's static block. - Add lang keys (
stat.minetuner.<name>,minetuner.stat.<name>) toen_us.json. - If it reads live game/JVM state, source it from a
StatSourceinsample/sources/(orMineTunerDataHolderdirectly), keeping theStatDefinitiona thin delegate rather than having it poll anything itself. - Add its row to stats.html
No file outside this list needs touching. No switch(stat) anywhere else to update.