496 lines
26 KiB
Markdown
496 lines
26 KiB
Markdown
# 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, 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.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()` | ✓ | ✓ | ✓ | ✓ |
|
||
| `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.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.
|