26 KiB
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. 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.1 —
blit(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.5 —
blit(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: aFunctionis 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.2 —
RenderPipelinereplaces 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 1–5 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:
CharacterEventis(int codepoint, int modifiers)at 1.21.11 and(int codepoint)at 26.2 — the modifiers were dropped. The 26.2 adapter passes0, which costs nothing: nothing downstream reads them (TextFieldfilters on the character alone, and the shortcuts that care about Ctrl arrive throughkeyPressed).- The event carries a code point, not a
char, so anything outside the basic plane arrives as a surrogate pair. Both adapters loop overCharacter.toChars(...)and deliver twochars — which is what aStringwould 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 setPixelRGBA → setPixel 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 1–4 have a private abgr() swap in TextureAdapter and buckets 5–9
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 towriteToFile(Path)on every bucket (checked in bytecode, not assumed), so redirecting theFileoverload catches vanilla's F2 exactly once and never fires for the mod's ownwriteToFile(Path)calls. Redirect thePathoverload instead and every automatic capture publishes twice.- The exact
Pathis 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() |
✓ | ✓ | ✓ | ✓ |
| current server | Minecraft.getCurrentServer() → ServerData.ip |
✓ | ✓ | ✓ |
| window title | Window.setTitle(String), recorded by TitleMixin |
✓ | ✓ | ✓ |
execute(Runnable), isSameThread() |
inherited from BlockableEventLoop, unchanged |
✓ | ✓ | ✓ |
| camera entity | Minecraft.getCameraEntity() → world.entity.Entity |
✓ | ✓ | ✓ |
| camera pose | Entity.getX/getY/getZ/getYRot/getXRot |
✓ | ✓ | ✓ |
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.20–1.21, identity for 26.x
Buckets 1–8 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.20–1.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:
-
Probe before writing anything.
./gradlew :platform:<nearest-existing>:build python3 tools/probe-api.py > /tmp/api.txtAdd 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.
-
Copy the nearest bucket.
cp -r platform/26.2 platform/<new>, then edit itsgradle.properties:minecraft_version,minecraft_range,mc_java,deobfuscated,loader_version,fabric_api_version. Addinclude 'platform:<new>'tosettings.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.
-
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 inshared/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/commonis 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 tocommon— 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/. -
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.Categorydid at 1.21.11), add it toshared/client/src/main/resources/assets/photosync/lang/en_us.jsonalongside the old one rather than replacing it — the file is shared by all nine jars. -
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 1–8 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.