# 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:

```sh
./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:

```sh
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: 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.2** — `RenderPipeline` 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 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:

* `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 `char`s — 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 `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* 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()` | ✓ | ✓ | ✓ | ✓ |
| 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:

1. **Probe before writing anything.**

   ```sh
   ./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 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.
