Files
photosync/docs/PORTING.md
T
2026-08-07 18:10:38 +00:00

26 KiB
Raw Blame History

Porting PhotoSync to a new Minecraft version

PhotoSync supports Minecraft 1.20 through 26.2 from one source tree. This document explains how that is arranged, what actually changes between versions, and what you have to do when a new one comes out.

Everything in the breakpoint table below is measured, not remembered. It is produced by tools/probe-api.py, which javaps the Minecraft jars Loom has already downloaded and prints the signatures this mod depends on:

./gradlew buildAllPlatforms          # once, so Loom caches every jar
python3 tools/probe-api.py > /tmp/api.txt

Re-run it whenever you add a bucket. A member that vanishes from the output is itself the finding — that is how the 26.x GuiGraphics removal surfaced.

probe-api.py carries a fixed member list, so for anything not already in it there is tools/probe-class.py, which takes classes and an optional filter and javaps them across every cached bucket:

python3 tools/probe-class.py net.minecraft.client.Screenshot takeScreenshot
python3 tools/probe-class.py net.minecraft.client.gui.GuiGraphics,net.minecraft.client.gui.Hud blit

1. Buckets, not versions

There are 23 Minecraft releases in the supported range and nine :platform projects. A bucket is a contiguous run of versions whose client API is identical in every respect PhotoSync touches. We compile and test against one representative version per bucket; the jar it produces runs on the whole range, which each bucket declares in its own gradle.properties:

Bucket Representative minecraft_range Java Mappings
1 1.20.1 >=1.20 <1.20.2 17 Mojang
2 1.20.4 >=1.20.2 <1.20.5 17 Mojang
3 1.20.6 >=1.20.5 <1.21 21 Mojang
4 1.21.1 >=1.21 <1.21.2 21 Mojang
5 1.21.4 >=1.21.2 <1.21.5 21 Mojang
6 1.21.5 >=1.21.5 <1.21.6 21 Mojang
7 1.21.8 >=1.21.6 <1.21.11 21 Mojang
8 1.21.11 >=1.21.11 <1.22 21 Mojang
9 26.2 >=26.1 <26.3 25 identity

The boundaries are not aesthetic. Each one is a signature in section 3 that changed, and merging two buckets means one of those adapters stops compiling.

2. Module layers

shared/core       no Minecraft, no GUI.  Providers, upload queue, config,
                  timeline paging, ThumbHash decoding.  Unit-testable.
shared/mc-api     the seam.  Interfaces describing what the mod needs from the
                  game.  No Minecraft imports -- it is the shape of the
                  dependency, not the dependency.
shared/ui         every screen and widget, drawn through mc-api only.
shared/client     wiring: builds the queue, providers and screens from config.
                  Owns the language file and the rest of assets/photosync.
platform/common   the compat layer.  Imports net.minecraft, but only members
                  that are identical on all nine buckets: the entrypoint, the
                  bridge assembly, Component.literal, the clipboard.
platform/<v>      one bucket's adapters, and its mixins.  Everything whose
                  signature moved lives here.

platform/common is a source root, not a project: the root build wires platform/common/src/main/{java,resources} into every bucket's sourceSets.main. It exists because roughly two thirds of the platform code does not vary, and copying it nine times would mean nine places to fix a bug. The split rule is mechanical — a class goes in common until the day it stops compiling on some bucket, and then it moves down into all nine.

The nine copies under platform/<v> share fully-qualified names on purpose (dev.photosync.platform.impl.RenderAdapter exists nine times). Only one is ever on a classpath, platform/common can name them without indirection, and diffing two buckets is a plain diff -r.

shared/* compiles to Java 17 bytecode (shared_java in gradle.properties), because buckets 1 and 2 run on a Java 17 JVM. That is a constraint on the code, not just a compiler flag: no virtual threads, no Java 21 pattern matching in shared code. The platform modules compile at their own bucket's level, so 26.2's adapter may use Java 25 freely.

Shared classes are folded into each platform jar by jar { from ... } in the root build. There is no shading and no jar-in-jar — these are our own classes and they carry no third-party dependencies. Gson and slf4j-api are compileOnly because Minecraft and Fabric Loader supply both at runtime on every supported version.

Why the seam is only eleven drawing calls

The seam is deliberately narrow. RenderBridge exposes eleven primitives — fill, gradient, border, text, textWidth, lineHeight, two image overloads, pushClip/popClip, and the frame's width/height/tickDelta. PhotoSync draws every button, scrollbar, text field and tile itself out of those.

That is a real cost — we reimplement widgets Minecraft already has — and it buys the thing that matters: vanilla's widget classes churn far more than its drawing primitives do. Button's constructor, AbstractWidget's render signature and the whole Layout package changed repeatedly across this range, while fill and enableScissor did not change once in six years. Section 3 is the evidence: nine versions, and the drawing primitives collapse to three adapter shapes.

This is also why the mod does not need imgui, which PROMPT.md raised as the fallback if every version needed its own render logic. Every version does need its own adapter, but each adapter is a few dozen lines of delegation, not a GUI toolkit.

A method belongs in mc-api only if its Minecraft implementation genuinely differs across versions. Nothing there exposes a Minecraft concept under another name.

3. Measured API breakpoints

means "same as the column to its left".

Drawing

1.20.1 1.20.4 1.20.6 1.21.1 1.21.4 1.21.5 1.21.8 1.21.11 26.2
host class GuiGraphics GuiGraphicsExtractor
fill / fillGradient
enableScissor / disableScissor
text drawString(Font,String,int,int,int,boolean) text(…), same args
blit pixel UVs + ResourceLocation Function<RL,RenderType> first arg, dest size moves RenderPipeline first arg Identifier rename

Three adapter shapes, then. All of them use the overload that carries an explicit source rectangle, since RenderBridge.image has to be able to draw part of a texture:

  • 1.20.1 1.21.1blit(ResourceLocation, int x, int y, int w, int h, float u, float v, int uw, int vh, int texW, int texH).
  • 1.21.4 1.21.5blit(Function<ResourceLocation,RenderType>, ResourceLocation, int x, int y, float u, float v, int w, int h, int uw, int vh, int texW, int texH). Two things changed: a Function is prepended (RenderType::guiTextured), and the destination width/height moved from before the UV offsets to after them. The tail — uw, vh, texW, texH — is unchanged, which makes the swap easy to miss when reading a diff.
  • 1.21.8 26.2RenderPipeline replaces the function (RenderPipelines.GUI_TEXTURED); argument order is otherwise the 1.21.4 one.

UVs are texel coordinates in every version, so TextureHandle's own dimensions convert them from the normalised u0..u1 the bridge takes. That conversion is the same three lines on all nine adapters.

The reorder is at least loud when you get it wrong: u/v are float and the sizes are int, so passing the old order to the new method fails to compile rather than drawing garbage. Do not "fix" that by casting.

A normalised-UV overload blit(Identifier, x, y, w, h, u0, v0, u1, v1) appears at 1.21.8 and would suit RenderBridge.image better on paper. The adapters deliberately do not use it: its argument order differs again from the texel form's, and buckets 15 have no equivalent, so adopting it would buy a shorter method on four buckets in exchange for two spellings to keep straight. All nine adapters stay on the explicit texel form.

26.2 is the interesting one and it is less disruptive than it looks. GuiGraphics is gone entirely, replaced by an extract/render-state pipeline: Screen.render becomes extractRenderState(GuiGraphicsExtractor, int, int, float). But GuiGraphicsExtractor still exposes fill, fillGradient, enableScissor, disableScissor, text and blit with recognisable signatures, so all eleven primitives survive the transition. The adapter binds to the extractor instead of the graphics object; nothing above the seam notices.

Screen

1.20.1 1.20.4 → 1.21.8 1.21.11 26.2
render entry render(GuiGraphics,int,int,float) extractRenderState(GuiGraphicsExtractor,…)
background renderBackground(GuiGraphics) 4 args extractBackground(…)
init / resize (Minecraft,int,int) (int,int) — no Minecraft
tick, removed, isPauseScreen, shouldCloseOnEsc

HUD — where notifications are drawn

1.20.1 → 1.20.6 1.21.1 → 1.21.11 26.2
class Gui Hud
hook render(GuiGraphics, float) render(GuiGraphics, DeltaTracker) extractRenderState(GuiGraphicsExtractor, DeltaTracker)
partial tick the float argument delta.getGameTimeDeltaPartialTick(false)

false asks for the real partial tick rather than the frozen-while-paused one, so notifications keep animating while the game is paused behind a PhotoSync screen.

26.1 split Gui in two: the in-world overlay moved to Hud, and Gui kept an outer extractRenderState that draws the current screen after the HUD. Injecting into Gui's would put notifications underneath our own GUI, so HudMixin targets Hud. Grep for the class before assuming the method name is the whole change.

Because the injection point is the in-world HUD on every bucket, notifications are invisible on the title screen and inside our own screens. That is by design — an upload that finishes while the player is in the queue screen shows up in the queue list, which is better feedback than a corner toast — but it does mean a capture taken from a menu reports nothing until the player is back in a world.

Input — GuiEventListener

1.20.1 1.20.4 → 1.21.8 1.21.11 → 26.2
mouseClicked (double,double,int) (MouseButtonEvent, boolean)
mouseReleased (double,double,int) (MouseButtonEvent)
mouseDragged (double,double,int,double,double) (MouseButtonEvent,double,double)
mouseScrolled (double,double,double) 4 args (double,double,double,double)
keyPressed (int,int,int) (KeyEvent)
charTyped (char,int) (CharacterEvent)

ScreenModel keeps the flat 1.20-era signatures because they carry every field the event objects do; the 1.21.11+ adapters unpack. mouseScrolled gaining a fourth argument at 1.20.4 is the only reason buckets 1 and 2 are separate — that plus renderBackground above.

Two details the table cannot hold:

  • CharacterEvent is (int codepoint, int modifiers) at 1.21.11 and (int codepoint) at 26.2 — the modifiers were dropped. The 26.2 adapter passes 0, which costs nothing: nothing downstream reads them (TextField filters on the character alone, and the shortcuts that care about Ctrl arrive through keyPressed).
  • The event carries a code point, not a char, so anything outside the basic plane arrives as a surrogate pair. Both adapters loop over Character.toChars(...) and deliver two chars — which is what a String would have held anyway.

Textures

1.20.1 → 1.21.1 1.21.4 1.21.5 → 1.21.8 1.21.11 → 26.2
DynamicTexture(NativeImage) (Supplier<String>, NativeImage)
NativeImage.setPixelRGBA setPixel
TextureManager.register(String, DynamicTexture) → id removed
TextureManager.register(id, AbstractTexture)
TextureManager.release(id)
id type ResourceLocation Identifier

Because the auto-naming register(String, DynamicTexture) overload disappears at 1.21.4 while register(id, AbstractTexture) exists everywhere, all nine adapters mint their own id and use the two-argument form. One code shape, no breakpoint — worth knowing so nobody "simplifies" the older buckets back onto the overload.

The setPixelRGBAsetPixel rename at 1.21.4 is not just a rename: the channel order changed with it. setPixelRGBA takes ABGR despite the name; setPixel takes plain ARGB, which is what ThumbImage already holds. That is why buckets 14 have a private abgr() swap in TextureAdapter and buckets 59 do not. Copying an adapter across that boundary in either direction produces images with red and blue exchanged — it renders, so nothing tells you but your eyes.

At 1.21.5 DynamicTexture gained a Supplier<String> label, which means the id has to be minted before the texture rather than after. The texture still takes ownership of the NativeImage and closes it with itself; only the registration needs releasing.

Key bindings

1.20.1 → 1.21.8 1.21.11 26.2
category translation key String KeyMapping.Category.register(Identifier)
Fabric module fabric-key-binding-api-v1 fabric-key-mapping-api-v1
Fabric helper KeyBindingHelper.registerKeyBinding KeyMappingHelper.registerKeyMapping

The 1.21.11 category derives its own label: photosync:main becomes key.category.photosync.main. The older buckets use whatever string you hand them, which here is key.categories.photosync. en_us.json carries both keys — they are two lines, and the alternative is a missing-translation string in the controls screen on half the buckets.

Screenshots

1.20.1 → 1.21.4 1.21.5 1.21.8 → 26.2
Screenshot._grab(File,String,RenderTarget,Consumer<Component>) removed
Screenshot.takeScreenshot(RenderTarget)NativeImage (RenderTarget, Consumer<NativeImage>)
Screenshot.grab(File,String,RenderTarget,…,Consumer<Component>) extra int (downscale)
NativeImage.writeToFile(File) / (Path)

26.2 additionally gains grab(Minecraft, boolean). Minecraft.getMainRenderTarget() also moved at 26.1 — the target now hangs off gameRenderer.mainRenderTarget().

writeToFile is the anchor: it is the one member in this entire table that is byte-for-byte identical on all nine versions. The capture mixin therefore redirects NativeImage.writeToFile rather than trying to intercept grab, whose shape changes three times.

The mixin declares method = "*" deliberately. On every bucket the call site is a private static helper rather than grab itself, and the helper is named after nothing:

private static void <helper>(NativeImage, File, Consumer<Component>)
  1.20.1  1.21.4    method_1661
  1.21.5  1.21.11   method_22691
  26.2               lambda$grab$3

Those names are unspellable in a Mojmap bucket — Mojang's mappings do not name synthetics, so the intermediary name is what survives, and it changes. Matching every method in the class and letting the redirect's target descriptor pick the call site is the only formulation that survives all nine.

Two facts make redirecting the write safe rather than merely convenient:

  • NativeImage.writeToFile(File) delegates to writeToFile(Path) on every bucket (checked in bytecode, not assumed), so redirecting the File overload catches vanilla's F2 exactly once and never fires for the mod's own writeToFile(Path) calls. Redirect the Path overload instead and every automatic capture publishes twice.
  • The exact Path is known synchronously, at the moment the file exists. No directory watching, no guessing at the filename, no race with the player taking a second screenshot.

Automatic captures never go through this path. They are taken by ScreenshotService, which builds its own filename (that is where the configurable _auto suffix comes from) and publishes to ScreenshotBus itself, so the mixin only ever reports vanilla F2 presses and reports them as MANUAL. The origin of a capture is known by construction rather than by a thread-local flag.

CaptureAdapter does not call Screenshot.grab for the same reason: grab names the file itself and writes a chat message, and since we intercept its write to notice F2, routing automatic captures through it would make the two origins indistinguishable. It grabs the frame and writes the PNG itself — on the render thread for the readback, on Util.ioPool() for the encode.

That readback turned asynchronous at 1.21.5: takeScreenshot no longer returns the frame, it hands it to a callback once the GPU fence clears, possibly several frames later. The filename is therefore reserved before the grab on those buckets, so captures land in the order they were requested. Reservation is a synchronized Files.createFile — claiming the name by creating the file empty, not by testing for absence — because two captures a second apart would otherwise agree on a name and one would overwrite the other. A failed write deletes the placeholder.

Client

1.20.1 → 1.21.1 1.21.4 → 1.21.8 1.21.11 26.2
current screen Minecraft.screen field Minecraft.gui.screen()
set screen Minecraft.setScreen Minecraft.gui.setScreen
render target Minecraft.getMainRenderTarget() gameRenderer.mainRenderTarget()
Minecraft.stop(), getWindow()
execute(Runnable), isSameThread() inherited from BlockableEventLoop, unchanged
Util package net.minecraft.Util net.minecraft.util.Util
Util.ioPool() ExecutorService TracingExecutor

26.1 moved screen ownership off Minecraft and onto Gui, which holds it now. Minecraft.setScreenAndShow still exists and would also work, but it forces a synchronous frame on top of the switch; Gui.setScreen is where the plain setter went, and it is what ScreenAdapter uses.

Util.ioPool() returning TracingExecutor rather than ExecutorService from 1.21.4 is invisible at the call site — both have execute(Runnable) — but it is a hard break if you ever assign the result to a typed local. Don't; call it inline.

4. Mappings: Mojang for 1.201.21, identity for 26.x

Buckets 18 use loom.officialMojangMappings(). Minecraft 26.x ships already deobfuscated, so there is nothing to remap — but Loom still requires a mappings artifact that declares a named namespace. The root build writes a header-only tiny-v2 jar for those buckets:

tiny	2	0	official	intermediary	named

Three namespaces, zero rows, which Loom reads as "every name maps to itself". It is generated during configuration rather than by a task because Loom resolves the mappings configuration while configuring the project, so a task output would not exist yet. It is deterministic and 47 bytes; deobfuscated=true in a bucket's gradle.properties turns it on.

Consequence when reading code: in a 26.x adapter the names you see are the runtime names. In a 1.201.21 adapter they are Mojmap names that Loom remaps on build.

5. Adding a new Minecraft version

Most releases need no work at all — if the new version falls inside an existing bucket's minecraft_range, widen the range and ship. Do that check first.

When it does not:

  1. Probe before writing anything.

    ./gradlew :platform:<nearest-existing>:build
    python3 tools/probe-api.py > /tmp/api.txt
    

    Add the new version to a scratch platform project first so Loom caches its jar, then diff its section against the nearest existing bucket. If nothing in section 3 moved, you do not need a new bucket.

  2. Copy the nearest bucket. cp -r platform/26.2 platform/<new>, then edit its gradle.properties: minecraft_version, minecraft_range, mc_java, deobfuscated, loader_version, fabric_api_version. Add include 'platform:<new>' to settings.gradle, and narrow the previous bucket's upper bound so the two ranges do not overlap.

    Copy the newest bucket, not the nearest by number — a new release is almost always a continuation of the most recent one, and starting from an older bucket means re-doing every change made since.

  3. Fix what the compiler complains about. This is the whole point of the layering: the errors are confined to platform/<new> and they are the diff in section 3. Nothing in shared/ should need to change. If it does, that is a signal the seam is leaking a Minecraft concept and is worth pushing back on before working around.

    If a class in platform/common is among the failures, it has stopped being common: move it down into all buckets (for v in platform/*/; do ...), then change the one copy that needed changing. Do not add version checks to common — that is the thing the bucket layout exists to avoid.

    tools/probe-class.py <class[,class...]> [regex] javaps one class across every cached bucket jar at once, which is usually faster than reading release notes. For argument order questions the notes are useless anyway; javap the jar under ~/.gradle/caches/fabric-loom/minecraftMaven/.

  4. Update this document. Add a column to the affected tables in section 3 and a row to the bucket table in section 1. The tables are the reason step 1 is cheap next time. If a new API needs a translation key that older buckets do not (as KeyMapping.Category did at 1.21.11), add it to shared/client/src/main/resources/assets/photosync/lang/en_us.json alongside the old one rather than replacing it — the file is shared by all nine jars.

  5. Run it. ./gradlew :platform:<new>:runClient, then walk the checklist below.

Manual checklist

The seam is not unit-testable — its whole job is to talk to the game — so these are checked by hand once per bucket:

  • Screens open, close on Escape, and survive a window resize
  • Mouse wheel scrolls the timeline; drag scrolls the scrollbar
  • Text field accepts typing, and paste works (an Immich API key is not typed by hand)
  • Thumbnails decode and draw, and are released when the screen closes — watch for a texture leak across repeated opens
  • Clipping: the timeline's tiles do not draw outside their viewport
  • F2 produces a queue entry with the correct path
  • Automatic capture produces a file with the configured suffix
  • Quitting with an upload in flight shows the progress dialog, and "quit anyway" actually quits

6. Things that will bite you

lombok.config changes need a clean build. Gradle does not track it as a compile input, so changing it leaves stale generated accessors on the classpath and produces "cannot find symbol" errors on methods you can see in the source. ./gradlew :shared:core:clean first.

Compile against the oldest Gson, not the newest. gson_api_version=2.10 is what 1.20.1 bundles; 26.2 has 2.14. Compiling against 2.14 would let code reference API that is missing at runtime on older buckets, and the failure would appear only in the field. Same reasoning for slf4j_api_version=2.0.1.

Minecraft's image decoder is stb_image, which reads PNG and JPEG only — not WebP, not AVIF. Immich's default thumbnail format is WebP, so ImmichProvider sniffs the first thumbnail's magic bytes and falls back to previews (JPEG) for the rest of the session if it sees a RIFF/WEBP header. Any new provider has to honour the same contract, which PhotoProvider.thumbnail states explicitly.

Everything touching a texture, screen or framebuffer must go through GameContext.submit. Uploads, thumbnail fetches and the capture timer all run on worker threads; the render thread is the only one allowed to touch GPU state.

Mixin annotations are remapped in place; there is no refmap. Loom's non-legacy path rewrites @Mixin/@Inject targets into intermediary with tiny-remapper, so no *-refmap.json is produced and none should be configured. If you want to confirm a mixin actually resolved, unzip the built jar and javap -p -v the mixin class — the annotation holds the remapped target (method_1592 on a Mojmap bucket, the plain name on 26.x). A mixin that silently does nothing usually has a target that remapped to something that no longer exists, and this is the only place that shows.

@Redirect on an overloaded method needs the delegation checked, not assumed. The writeToFile(File)writeToFile(Path) delegation above is the example: get it backwards and the mod publishes every automatic capture twice, with no error anywhere.

26.x buckets are already deobfuscated, so runClient output names differ. A stack trace from bucket 9 reads like source; one from buckets 18 reads like Mojmap only because Loom mapped it on the way in. Do not paste a 26.x trace into a search for an older bucket's symbol.