7.6 KiB
Working on PhotoSync
A Fabric client mod that uploads Minecraft screenshots to Immich and browses the
album in game, built from one source tree for nine Minecraft versions
(1.20 → 26.2). Read README.md for what it does and
docs/PORTING.md before touching anything that imports
net.minecraft.
This file is the orientation an agent needs before its first edit: where code goes, what may import what, and which oddities are deliberate.
Commands
./gradlew test # the shared modules' unit tests
./gradlew :platform:1.21.8:build # one bucket, remapped jar included
./gradlew buildAllPlatforms # all nine — the real check before finishing
./gradlew :platform:1.21.8:runClient # dev client for one bucket
buildAllPlatforms takes a few minutes and is the only thing that proves a
change compiles everywhere. A change that only touches shared/ still has to
pass it, because each bucket compiles the shared sources at its own bytecode
level. If the machine has no network but a populated Gradle cache, add
--offline.
Two probes answer "what does this method look like on every version", by running
javap across the cached Minecraft jars:
python3 tools/probe-api.py # the fixed list of members the bridge depends on
python3 tools/probe-class.py net.minecraft.client.gui.GuiGraphics '(?i)blit'
Use them instead of recalling a signature. Every table in docs/PORTING.md was
produced this way, and the ones that were not were wrong.
The module map
shared/core no Minecraft, no GUI. Config, uploads, providers, timeline,
ThumbHash. Where behaviour that can be unit-tested lives.
shared/mc-api the seam: interfaces the game must implement, no bodies.
shared/ui every screen and widget, drawn through mc-api only.
shared/client wiring — screenshot becomes upload becomes notification.
platform/common a source root, not a project: the adapter code that is
textually identical on all nine buckets.
platform/<v> one bucket's adapters (dev.photosync.platform.impl) and mixins.
The dependency arrow points one way: platform → client → ui → mc-api →
core. Each module's package-info.java states its own rule and is worth
reading before adding a file to it. The two that get violated by accident:
- Nothing under
shared/may importnet.minecraft,net.fabricmcororg.lwjgl. If shared code needs something from the game, the answer is a new method on anmc-apiinterface plus nine adapter implementations — never an import. Adding to that seam is expensive on purpose, so first check whether the thing can be computed from whatRenderBridgealready exposes. shared/compiles to Java 17 bytecode (shared_javaingradle.properties), because 1.20–1.20.4 run on a Java 17 JVM. No virtual threads, no Java 21 pattern matching, noHttpClient.close(). Platform modules compile at their own bucket's level, which is why the same idiom can be legal inplatform/26.2and rejected inshared/core.
Where a change goes
| You want to | Put it in |
|---|---|
| change upload, retry or queue behaviour | shared/core/upload |
| add a setting | shared/core/config (a record + normalized()), then SettingsScreen and en_us.json |
| support another photo service | shared/core/provider/<name>, implementing PhotoProvider + ProviderFactory, added to the list in PhotoSync's one-argument constructor. No UI or platform change needed — the settings screen builds its fields from ProviderDescriptor |
| change a screen | shared/ui/screen |
| add a drawing primitive | RenderBridge and all nine RenderAdapters. Read docs/PORTING.md §3 first |
| fix something on one Minecraft version | that bucket's platform/<v>/…/impl |
| fix something on every Minecraft version | platform/common — but only if it compiles on all nine |
The platform/common rule has no third case: a class lives there while all nine
compilations accept it, and the day one of them stops, it moves down into all
nine copies of impl. Do not add a version check to keep it in common.
Conventions
The compiler does not enforce these; reviewers do.
- Lombok with fluent accessors.
lombok.configat the repo root setslombok.accessors.fluent = true, so a@Getteronkindgenerateskind(), notgetKind(). Records are the default for data;@Builder(toBuilder = true)for config records. - No utility classes, no scattered constants. A constant belongs to the class
that uses it, as a
private static finalnext to that use. If a static helper is tempting, it usually means the behaviour belongs on one of the objects. - No new dependencies. Gson and SLF4J are
compileOnlybecause Minecraft and Fabric Loader already ship them; nothing is shaded and nothing is jar-in-jar'd, which is why each jar is ~270 KB. Do not write a JSON parser, an HTTP client or a base64 encoder — the platform has all three. - Comments explain why, not what. The existing ones record measurements and decisions ("Immich's default thumbnail format is WebP, which the game's decoder cannot read"). Match that; a comment restating the code is noise.
- Don't over-abstract. One provider interface exists because a second provider is a stated goal. An interface with one implementation and no second in sight is not the house style.
- User-visible strings are translation keys, in
shared/client/src/main/resources/assets/photosync/lang/en_us.json. Adding keys is safe on every version; renaming them is not.
Things that are the way they are on purpose
Verify before "fixing" any of these — each one cost a measurement.
- No refmap. Loom's non-legacy mixin remapping rewrites annotations in place.
There is no
refmap.jsonand none is needed;docs/PORTING.md§6 shows how to confirm it withjavap -p -von a built jar. CaptureMixintargetsmethod = "*"because the private helper that writes the screenshot has three different names across the range, and it redirects theFileoverload rather than thePathone because the two delegate — redirecting both publishes every capture twice.- 26.x uses a generated 47-byte identity mappings jar (root
build.gradle), because those Minecraft jars ship deobfuscated and Loom still demands a mappings artifact. - The UI draws its own widgets. Minecraft's drawing primitives have been stable since 1.20; its widget constructors have not.
- Immich renditions are a ladder, not a constant (
ImmichProvider.thumbnail): the grid asks forthumbnailthenpreview, an opened photo asks forfullsizethenpreview, and a rendition that answers with WebP, a 404 or a 403 is retired for the session. Minecraft decodes PNG and JPEG only — stb_image reads neither WebP nor AVIF — so a rendition's format is a correctness concern, not a preference. ThumbnailCachefloors capacity at 1, not 8. The grid's real floor comes fromBrowserSettings.normalized(); the detail cache wants two, because full-resolution textures are tens of megabytes each.
Testing
shared/core is the only module with meaningful test coverage, and it is thin —
ThumbHashTest is what exists. Anything you add to core that can be tested
without a game should come with tests; ui and platform are verified by
compiling all nine buckets and running a dev client.
There is no test that catches a broken adapter. If you change anything under
platform/, run runClient on that bucket and look at the screen.