This commit is contained in:
iceBear67
2026-08-07 18:10:38 +00:00
commit 014b18f1ba
232 changed files with 16847 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
**/build/**
.idea
**/__pycache__/**
.gradle
+14
View File
@@ -0,0 +1,14 @@
写一个照片同步 Mod, 要求如下:
1. 支持多版本(从 1.20 开始支持到现在最新的版本,目前是 26.2)和 fabric, 并且提供完善的文档描述兼容新版本的常规流程,注意事项。要有一个合理的模块层级把有必要跟mc代码纠缠在一起的部分隔离开,而无关的部分抽出来。允许兼容层的出现
2. 支持把截图上传到 immich,并且还可以设置是哪个 album。记得留一个抽象,未来可能会加入新的 provider.
3. 要有 GUI 用于管理这些截图,并且 gui 也要覆盖模组的设置。模组的 UI/UX 和交互应该设计的易于使用,用户配置好后只需要管截图即可。GUI 还要能按照时间线排列浏览(模拟网页端虚拟列表懒加载)album 上其他时间的图片,单位是天。不要支持视频功能,如果视频有 preview 可用就用 preview 图片然后叠一层视频标记
4. 要处理好用户退出游戏时截图仍在上传的情况
5. 开发时,使用编译 classpath 里已有的 Gson,不要自己编写 Json 解析器,也不要自己编写其他已有库中的内容。然而,要注意控制模组的体积,无必要不要 shade 或者 jij 依赖。如果必须这么做,配置 proguard 做 minify.
6. 你不是在做 MVP, 你是在落地一个具体的产品,因此代码风格上不要到处随便飞 static 和常量以及各种 util。用可用的 Java 的特性合理的,恰当的组织你的代码,使他们符合面向对象和略微函数式一些的 Modern Java Paradiam,但注意不要过度抽象. 允许使用 Lombok 改进代码可读性。
7. 要有一个可以配置的,默认关闭的自动截图功能,截图出来的图片需要有一个可配置的文件名后缀(如\_auto)
8. 自动截图/截图上传成功后可以在画面左下角提醒玩家(短短一行字停留一会即可,不要太影响体验)
你的 gui 可能需要使用 mc screen 的那一套 gui, 最坏的情况你要为了所有版本都重新编写 screen 渲染逻辑。如果已经到了这个地步,那么请考虑 imgui.
你可以先尝试做一些实验来研究可行性,然后问我有什么需要补充的信息。
+201
View File
@@ -0,0 +1,201 @@
# PhotoSync
A Fabric client mod that sends your Minecraft screenshots to [Immich](https://immich.app)
and lets you browse the album back inside the game.
Take a screenshot with F2 and it uploads in the background. Press F6 to see what
is uploading, scroll through the album's timeline, or change how any of it
behaves. Nothing about the vanilla screenshot flow changes — F2 still saves a PNG
to `screenshots/` and still works with the mod misconfigured or offline.
Supports **Minecraft 1.20 through 26.2**, client side only. Requires
[Fabric API](https://modrinth.com/mod/fabric-api).
---
## Install
1. Install Fabric Loader 0.19.3 or newer and Fabric API for your Minecraft
version.
2. Drop the jar matching your version into `mods/`:
| Your Minecraft version | Jar |
| --- | --- |
| 1.20 1.20.1 | `photosync-1.20.1-1.0.0.jar` |
| 1.20.2 1.20.4 | `photosync-1.20.4-1.0.0.jar` |
| 1.20.5 1.20.6 | `photosync-1.20.6-1.0.0.jar` |
| 1.21 1.21.1 | `photosync-1.21.1-1.0.0.jar` |
| 1.21.2 1.21.4 | `photosync-1.21.4-1.0.0.jar` |
| 1.21.5 | `photosync-1.21.5-1.0.0.jar` |
| 1.21.6 1.21.10 | `photosync-1.21.8-1.0.0.jar` |
| 1.21.11 1.21.x | `photosync-1.21.11-1.0.0.jar` |
| 26.1 26.2 | `photosync-26.2-1.0.0.jar` |
Each jar declares its own supported range, so Fabric will refuse to load the
wrong one rather than crashing later.
3. Launch the game. PhotoSync does nothing until you connect it to a server.
The mod is client-side. Servers neither know nor care that you are running it,
and it works in single player.
## Connect it to Immich
Press **F6** (rebindable in Options → Controls → PhotoSync), open **Settings**,
and fill in two fields:
- **Server URL** — `https://photos.example.com`. Typing just `photos.example.com`
works too; so does pasting a URL that already ends in `/api`.
- **API key** — Immich's web UI, under Account Settings → API Keys. The field is
masked, and the key is never written to the log or copied to the clipboard.
Press **Test**. It reports the account name and server version on success, and
the actual failure on the way there — a wrong key and an unreachable host say
different things.
Then pick an **Album**. "Whole library" uploads without adding to any album; the
picker also creates one if you type a name and press Create. The chosen album is
both where uploads go and what the Browse tab shows.
Settings save when you close the screen. **Revert** undoes everything since you
opened it.
## The screens
**Uploads** is the queue: everything still in flight plus the last hundred that
finished, newest first, each with its state — queued, uploading, retrying, done,
failed — and a preview of the selected one. Failed uploads get a **Retry**;
anything at all gets **Show in folder**, which opens `screenshots/` in your file
manager. **Remove** takes an entry out of the list without touching the file, and
**Clear finished** empties the completed ones in one go.
Uploads that fail retry on their own — five attempts by default, with the wait
doubling each time, so the last one comes 80 seconds after the fourth. Only the
final failure raises a corner message; the retries in between are visible here
for anyone who wants to watch them.
**Browse** is the album, newest first, with a heading per day. It loads the way a
photo website does: the per-day counts arrive before any image does, so the
scrollbar is honest about the album's real size from the first frame, and the
thumbnails for a day are fetched only when you scroll near them. Click a photo to
open it larger, Escape to go back. It is read-only — PhotoSync will not delete,
move or favourite anything on your server.
Videos in the album show their preview frame with a video badge in the corner.
There is no playback; this is a screenshot mod.
**Settings** covers everything below.
## Automatic screenshots
Off by default, and it stays off until you turn it on. Once enabled, PhotoSync
takes a screenshot every five minutes (adjustable from 5 seconds to an hour) and
puts it through the same queue as a manual one.
- **Name suffix** — appended before `.png`, `_auto` by default, so an automatic
capture lands as `2026-08-07_14.31.02_auto.png` and is easy to spot or filter.
Leave it blank for none.
- **Only while in a world** — on by default. No point photographing the main menu.
- **Skip while a screen is open** — on by default. Your inventory is not a
landscape.
Automatic captures are recorded separately from manual ones, so the two are
distinguishable everywhere they appear.
## Messages in the corner
One line, bottom left, gone in three seconds. Separately switchable for
screenshot taken, upload finished, and upload failed, with an adjustable
duration — the setting that suits an automatic capture every thirty seconds is
not the one that suits two screenshots an hour.
These draw on the in-world HUD, so they do not appear on the title screen or on
top of an open screen. An upload that finishes while you are looking at the
Uploads tab updates the list instead, which is better feedback than a toast.
## Quitting with uploads still running
If you quit while something is uploading, PhotoSync stops the shutdown and shows
a progress dialog: how many are left, and two buttons. **Keep playing** returns
you to the game. **Quit anyway** quits immediately.
Either way nothing is lost. The queue is on disk and is written through on every
state change, so unfinished uploads resume the next time you play — the dialog
exists to save you the wait, not to prevent a loss. An upload that was mid-flight
when the game died is simply tried again, and Immich recognises the retry as a
duplicate rather than storing a second copy.
You can turn the dialog off under Settings → Uploads → *Ask before quitting
mid-upload*, in which case the game gets a three-second grace period to finish
whatever is nearly done and then quits regardless.
## Everything else in Settings
| Setting | Default | |
| --- | --- | --- |
| Upload screenshots automatically | on | Off makes the mod a browser and a manual queue |
| Uploads at once | 2 | Up to 4 |
| Attempts before giving up | 5 | Up to 20 |
| Wait between attempts | 5s | Doubles each attempt: 5s, 10s, 20s, 40s |
| Delete the local file after upload | off | Only ever after the server confirms it |
| Thumbnail size | 96px | 48192 |
| Thumbnails kept in memory | 256 | The mod's largest memory knob |
| Mark videos in the grid | on | |
## Files it writes
Everything lives in `config/photosync/` in your game directory:
- `photosync.json` — settings, including your API key. Back it up accordingly.
- `uploads.json` — the upload queue, so it survives a crash or a quit.
Screenshots themselves go where they always did, in `screenshots/`. PhotoSync
never moves them, and only deletes one if you asked it to and the upload was
confirmed.
## Troubleshooting
**"Not connected" on the Browse tab** — the server URL or API key is missing or
wrong. Settings → Test says which.
**Screenshots are taken but nothing queues** — either *Upload screenshots
automatically* is off, or there are no credentials yet. PhotoSync deliberately
does not queue against a server it cannot reach, because the result is a list of
failures you cannot act on.
**Thumbnails are blank in Browse** — Minecraft's image decoder reads PNG and JPEG
only, and Immich's default thumbnail format is WebP. PhotoSync detects this and
switches to JPEG previews for the rest of the session, so this should self-correct
after the first few tiles.
**A jar refuses to load** — check the table above. Fabric enforces each jar's
declared version range, so this means the jar is for a different bucket.
## Building from source
```sh
./gradlew buildAllPlatforms
```
Jars land in `platform/<version>/build/libs/`. Nothing is shaded or bundled —
each jar is around 270 KB and contains only this mod's classes. To run one
version in a dev client:
```sh
./gradlew :platform:1.21.11:runClient
```
The source tree is one shared implementation plus nine thin per-version
adapters. If you are adding a Minecraft version or wondering why the layout is
the way it is, [`docs/PORTING.md`](docs/PORTING.md) is the document for that: it
covers the bucket scheme, the measured API breakpoints between every supported
version, and the procedure for a new release.
Adding a photo service other than Immich means implementing `PhotoProvider` and
`ProviderFactory` in `shared/core` and registering it with `ProviderCatalog`. No
UI or platform code needs to change — the settings screen builds its connection
fields from what the provider declares.
## Licence
MIT.
+207
View File
@@ -0,0 +1,207 @@
plugins {
id 'java'
id 'fabric-loom' apply false
}
/**
* Compatibility buckets, not Minecraft versions.
*
* Every project under :platform is compiled against one representative version
* and declares -- in its own gradle.properties -- the full range of Minecraft
* versions that the resulting jar actually runs on. The grouping comes from
* measured API breakpoints, documented in docs/PORTING.md.
*/
ext.platformProjects = { subprojects.findAll { it.path.startsWith(':platform:') } }
ext.sharedProjects = { subprojects.findAll { it.path.startsWith(':shared:') } }
subprojects {
apply plugin: 'java'
group = rootProject.mod_group
version = rootProject.mod_version
repositories {
maven { name = 'Fabric'; url = 'https://maven.fabricmc.net/' }
mavenCentral()
}
dependencies {
compileOnly "org.projectlombok:lombok:${rootProject.lombok_version}"
annotationProcessor "org.projectlombok:lombok:${rootProject.lombok_version}"
testCompileOnly "org.projectlombok:lombok:${rootProject.lombok_version}"
testAnnotationProcessor "org.projectlombok:lombok:${rootProject.lombok_version}"
testImplementation platform("org.junit:junit-bom:${rootProject.junit_version}")
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
}
// ---------------------------------------------------------------------------
// Shared modules: zero Minecraft on the classpath.
//
// These compile to Java 17 bytecode because 1.20-1.20.4 run on a Java 17 JVM.
// That is a real constraint on the code, not just a flag: no virtual threads
// and no Java 21 pattern matching in shared code.
// ---------------------------------------------------------------------------
configure(sharedProjects()) {
// java-library so a module can re-export what its consumers legitimately
// need: :shared:ui hands RenderBridge types straight to platform code.
apply plugin: 'java-library'
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
tasks.withType(JavaCompile).configureEach {
options.release = rootProject.shared_java as int
}
dependencies {
// Gson ships inside Minecraft's own dependency set on every supported
// version, so we compile against it but never bundle it. See
// docs/PORTING.md for the version range this has to stay within.
compileOnly "com.google.code.gson:gson:${rootProject.gson_api_version}"
compileOnly "org.slf4j:slf4j-api:${rootProject.slf4j_api_version}"
testImplementation "com.google.code.gson:gson:${rootProject.gson_api_version}"
testImplementation "org.slf4j:slf4j-api:${rootProject.slf4j_api_version}"
testRuntimeOnly "org.slf4j:slf4j-simple:${rootProject.slf4j_api_version}"
}
}
// ---------------------------------------------------------------------------
// Platform modules: the only code allowed to touch Minecraft types.
// ---------------------------------------------------------------------------
configure(platformProjects()) {
apply plugin: 'fabric-loom'
def mcVersion = project.property('minecraft_version')
def mcJava = project.property('mc_java') as int
def deobfuscated = project.property('deobfuscated').toBoolean()
// Minecraft 26.x ships already deobfuscated, so Loom has nothing to remap.
// It still insists on a mappings artifact carrying a 'named' namespace, so
// we hand it a header-only tiny-v2 file: three namespaces, zero rows, which
// Loom reads as "every name maps to itself".
//
// This is written during configuration rather than by a task because Loom
// resolves the mappings configuration while it configures the project --
// a task output would not exist yet. It is 47 bytes and deterministic.
def identityMappings = null
if (deobfuscated) {
identityMappings = layout.buildDirectory
.file("identity-mappings/identity-mappings-${mcVersion}.jar").get().asFile
if (!identityMappings.exists()) {
identityMappings.parentFile.mkdirs()
new java.util.zip.ZipOutputStream(identityMappings.newOutputStream()).withCloseable { zip ->
zip.putNextEntry(new java.util.zip.ZipEntry('mappings/mappings.tiny'))
zip.write("tiny\t2\t0\tofficial\tintermediary\tnamed\n".getBytes('UTF-8'))
zip.closeEntry()
}
}
}
java {
// javac can always read older bytecode, so one toolchain covers every
// bucket except 26.x, whose Minecraft classes are Java 25.
toolchain { languageVersion = JavaLanguageVersion.of(Math.max(mcJava, 21)) }
withSourcesJar()
}
// The compatibility layer. platform/common holds the adapter code that is
// textually identical on all nine buckets -- the Fabric entrypoint, the
// ClientBridge assembly, and the two seams (I18n, KeyboardHandler) that have
// not moved since 1.20. It is a source root rather than a project because it
// imports net.minecraft, so it has to be compiled once per bucket against
// that bucket's Minecraft; there is no single jar it could produce.
//
// Everything it calls that *does* differ lives in dev.photosync.platform.impl,
// which each bucket supplies from its own src/main/java under the same
// package. Common code names those classes directly and the bucket's source
// root satisfies them -- so the invariant half is written once, and the half
// that churns is a per-bucket file the compiler checks.
sourceSets.main.java.srcDir rootProject.file('platform/common/src/main/java')
sourceSets.main.resources.srcDir rootProject.file('platform/common/src/main/resources')
tasks.withType(JavaCompile).configureEach {
options.release = mcJava
}
dependencies {
minecraft "com.mojang:minecraft:${mcVersion}"
if (deobfuscated) {
mappings project.files(identityMappings)
} else {
mappings loom.officialMojangMappings()
}
modImplementation "net.fabricmc:fabric-loader:${project.property('loader_version')}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.property('fabric_api_version')}"
// Shared code is compiled once per bucket's bytecode target and folded
// into the platform jar below. No shading, no jar-in-jar: these are our
// own classes and they carry no third-party dependencies.
rootProject.sharedProjects().each { implementation it }
}
loom {
runs {
client {
client()
ideConfigGenerated = true
}
}
}
processResources {
def tokens = [
mod_id : rootProject.mod_id,
mod_name : rootProject.mod_name,
mod_version : rootProject.mod_version,
mod_description : rootProject.mod_description,
mod_license : rootProject.mod_license,
mod_sources : rootProject.mod_sources,
minecraft_range : project.property('minecraft_range'),
loader_version : project.property('loader_version'),
java_version : String.valueOf(mcJava),
// Mixin refuses to apply a class whose bytecode is newer than the
// declared level, and platform classes are compiled at the
// bucket's own level -- so this has to follow mc_java.
mixin_compat : "JAVA_${mcJava}",
]
inputs.properties(tokens)
filesMatching(['fabric.mod.json', '*.mixins.json']) { expand(tokens) }
}
jar {
rootProject.sharedProjects().each { from it.sourceSets.main.output }
manifest {
attributes(
'Implementation-Title': rootProject.mod_name,
'Implementation-Version': rootProject.mod_version,
'Minecraft-Version': mcVersion,
'Minecraft-Range': project.property('minecraft_range')
)
}
}
// Give every bucket's artifact a distinguishable name.
base.archivesName = "${rootProject.mod_id}-${mcVersion}"
}
// Convenience: build every bucket's shippable jar in one go.
tasks.register('buildAllPlatforms') {
group = 'build'
description = 'Builds the remapped mod jar for every compatibility bucket.'
dependsOn platformProjects().collect { "${it.path}:build" }
}
+495
View File
@@ -0,0 +1,495 @@
# 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 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 `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 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.**
```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 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.
+34
View File
@@ -0,0 +1,34 @@
org.gradle.jvmargs=-Xmx3G
org.gradle.parallel=true
org.gradle.caching=true
# Loom does not support the configuration cache yet.
org.gradle.configuration-cache=false
mod_id=photosync
mod_name=PhotoSync
mod_version=1.0.0
mod_group=dev.photosync
mod_description=Sync your Minecraft screenshots to Immich, and browse your albums in game.
mod_license=MIT
mod_sources=https://github.com/example/photosync
# Single Loom version for every bucket. This works for the deobfuscated 26.x
# jars too, via the generated identity mappings -- see buildSrc.
loom_version=1.17.19
# Lowest bytecode level shared code must be readable at (1.20-1.20.4 run on 17).
shared_java=17
lombok_version=1.18.46
junit_version=5.11.4
# Compile-time only. Minecraft bundles Gson itself: 2.10 on 1.20.1 through
# 2.14 on 26.2. We compile against the OLDEST of those so we cannot
# accidentally reach for API that is missing on an older target at runtime.
gson_api_version=2.10
# Also compile-time only. Fabric Loader puts slf4j-api on the runtime classpath
# on every supported version (2.0.1 on 1.20.1 through 2.0.17 on 26.2).
slf4j_api_version=2.0.1
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+7
View File
@@ -0,0 +1,7 @@
# Lombok's generated accessors follow the record style the rest of the codebase
# uses -- `job.state()`, not `job.getState()` -- so that a value being a record
# or a class is an implementation detail rather than something call sites have
# to know about.
config.stopBubbling = true
lombok.accessors.fluent = true
lombok.accessors.chain = false
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.20, 1.20.1
#
# Baseline. GuiGraphics immediate mode, ToastComponent, synchronous Screenshot.grab.
minecraft_version=1.20.1
minecraft_range=>=1.20 <1.20.2
mc_java=17
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.92.11+1.20.1
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,90 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
return model.mouseScrolled(mouseX, mouseY, amount) || super.mouseScrolled(mouseX, mouseY, amount);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,114 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* This release's {@code blit} takes UVs in texels, not the 0..1 the bridge
* speaks, so the handle's own dimensions do the conversion. Later releases
* grew a normalised overload; see docs/PORTING.md.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
handle.id(),
x, y, width, height,
u0 * textureWidth, v0 * textureHeight,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,100 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
pixels.setPixelRGBA(x, y, abgr(argb[row + x]));
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/**
* NativeImage stores RGBA in memory order, so the int it wants back is
* 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else
* in this mod speaks.
*/
private static int abgr(int argb) {
return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16);
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,30 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick)));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.20.2, 1.20.3, 1.20.4
#
# 1.20.2 reworked GuiGraphics (+8/-5 methods) and added the blitSprite family.
minecraft_version=1.20.4
minecraft_range=>=1.20.2 <1.20.5
mc_java=17
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.97.3+1.20.4
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,114 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* This release's {@code blit} takes UVs in texels, not the 0..1 the bridge
* speaks, so the handle's own dimensions do the conversion. Later releases
* grew a normalised overload; see docs/PORTING.md.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
handle.id(),
x, y, width, height,
u0 * textureWidth, v0 * textureHeight,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,100 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
pixels.setPixelRGBA(x, y, abgr(argb[row + x]));
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/**
* NativeImage stores RGBA in memory order, so the int it wants back is
* 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else
* in this mod speaks.
*/
private static int abgr(int argb) {
return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16);
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,30 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick)));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.20.5, 1.20.6
#
# 1.20.5 raised the runtime to Java 21 and changed NativeImage and ResourceLocation.
minecraft_version=1.20.6
minecraft_range=>=1.20.5 <1.21
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.100.8+1.20.6
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,114 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* This release's {@code blit} takes UVs in texels, not the 0..1 the bridge
* speaks, so the handle's own dimensions do the conversion. Later releases
* grew a normalised overload; see docs/PORTING.md.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
handle.id(),
x, y, width, height,
u0 * textureWidth, v0 * textureHeight,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,100 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
pixels.setPixelRGBA(x, y, abgr(argb[row + x]));
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/**
* NativeImage stores RGBA in memory order, so the int it wants back is
* 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else
* in this mod speaks.
*/
private static int abgr(int argb) {
return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16);
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,30 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick)));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21, 1.21.1
#
# 1.21 reshuffled ResourceLocation construction (+6/-6) and Screen (+3/-5).
minecraft_version=1.21.1
minecraft_range=>=1.21 <1.21.2
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.116.15+1.21.1
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,114 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* This release's {@code blit} takes UVs in texels, not the 0..1 the bridge
* speaks, so the handle's own dimensions do the conversion. Later releases
* grew a normalised overload; see docs/PORTING.md.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
handle.id(),
x, y, width, height,
u0 * textureWidth, v0 * textureHeight,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,101 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
pixels.setPixelRGBA(x, y, abgr(argb[row + x]));
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = ResourceLocation.fromNamespaceAndPath(
"photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/**
* NativeImage stores RGBA in memory order, so the int it wants back is
* 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else
* in this mod speaks.
*/
private static int abgr(int argb) {
return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16);
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,39 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*
* <p>1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The
* bridge still wants the float, and {@code false} asks for the real one rather
* than the frozen-while-paused one, so notifications keep animating while the
* game is paused behind our own screen.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics,
window.getGuiScaledWidth(),
window.getGuiScaledHeight(),
delta.getGameTimeDeltaPartialTick(false))));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21.11
#
# 1.21.11 renamed ResourceLocation to Identifier and churned GuiGraphics +37/-27.
minecraft_version=1.21.11
minecraft_range=>=1.21.11 <1.22
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.141.6+1.21.11
@@ -0,0 +1,112 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.util.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
//
// 1.21.5 turned the readback asynchronous: takeScreenshot no longer
// returns the frame, it hands it to a callback once the GPU fence
// clears, possibly frames later. The name is still claimed up front so
// that captures land in the order they were asked for.
Runnable grab = () -> {
try {
Path file = reserveFile(fileNameSuffix);
Screenshot.takeScreenshot(game.getMainRenderTarget(),
frame -> Util.ioPool().execute(() -> write(frame, file, written)));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.util.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,119 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.CharacterEvent;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
/**
* 1.21.11 bundled the loose input arguments into records. The bridge keeps
* the loose form -- it is the shape eight of the nine buckets speak -- so
* this is where they get unpacked.
*
* <p>{@code doubleClick} is dropped: PhotoSync has no double-click gesture,
* and the second click of a pair arrives here as an ordinary one anyway.
*/
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
return model.mouseClicked(event.x(), event.y(), event.button())
|| super.mouseClicked(event, doubleClick);
}
@Override
public boolean mouseReleased(MouseButtonEvent event) {
return model.mouseReleased(event.x(), event.y(), event.button())
|| super.mouseReleased(event);
}
@Override
public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) {
return model.mouseDragged(event.x(), event.y(), event.button(), deltaX, deltaY)
|| super.mouseDragged(event, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(KeyEvent event) {
return model.keyPressed(event.key(), event.scancode(), event.modifiers())
|| super.keyPressed(event);
}
/**
* The event carries a code point rather than a char, so anything outside the
* basic plane arrives as a surrogate pair and reaches the model as two
* chars -- which is what a {@code String} would have held anyway.
*/
@Override
public boolean charTyped(CharacterEvent event) {
boolean handled = false;
for (char character : Character.toChars(event.codepoint())) {
handled |= model.charTyped(character, event.modifiers());
}
return handled || super.charTyped(event);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,45 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import net.minecraft.resources.Identifier;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/**
* Registers the binding. Called once, from the client entrypoint.
*
* <p>1.21.11 turned the category from a loose translation key into a
* registered id, and it derives its own label: {@code photosync:main}
* becomes {@code key.category.photosync.main}, which is why the language
* file carries that key alongside the older {@code key.categories.photosync}.
*/
public static OpenKey register() {
KeyMapping.Category category =
KeyMapping.Category.register(Identifier.fromNamespaceAndPath("photosync", "main"));
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
category)));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,122 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.renderer.RenderPipelines;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* 1.21.2 moved the destination size ahead of the source rectangle, and 1.21.6
* swapped the render-type lookup in front of it for a baked pipeline. The UVs
* are still texels, so the handle's own dimensions still do the conversion.
*
* <p>There is a normalised-UV overload as of 1.21.6 that would suit the
* bridge better on paper, but its inner argument order differs from the
* texel form's in ways that are easy to get subtly wrong; staying on the
* explicit form keeps this method identical to the four buckets below.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
RenderPipelines.GUI_TEXTURED,
handle.id(),
x, y,
u0 * textureWidth, v0 * textureHeight,
width, height,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,95 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
// 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB
// rather than memory-order bytes, which is what we already have.
pixels.setPixel(x, y, argb[row + x]);
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// 1.21.5 gave every GPU texture a debug label, so the id has to exist
// before the texture does. DynamicTexture still takes ownership of the
// image and closes it with itself; only the registration needs freeing.
Identifier id = Identifier.fromNamespaceAndPath(
"photosync", "thumb/" + sequence.incrementAndGet());
DynamicTexture texture = new DynamicTexture(id::toString, pixels);
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final Identifier id;
private final int width;
private final int height;
private boolean released;
private Handle(Identifier id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,39 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*
* <p>1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The
* bridge still wants the float, and {@code false} asks for the real one rather
* than the frozen-while-paused one, so notifications keep animating while the
* game is paused behind our own screen.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics,
window.getGuiScaledWidth(),
window.getGuiScaledHeight(),
delta.getGameTimeDeltaPartialTick(false))));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21.2, 1.21.3, 1.21.4
#
# 1.21.2 replaced ToastComponent with ToastManager and reworked TextureManager.
minecraft_version=1.21.4
minecraft_range=>=1.21.2 <1.21.5
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.119.4+1.21.4
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,117 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.renderer.RenderType;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* 1.21.2 moved the destination size ahead of the source rectangle and put a
* render-type lookup in front of everything, but the UVs are still texels,
* so the handle's own dimensions still do the conversion.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
RenderType::guiTextured,
handle.id(),
x, y,
u0 * textureWidth, v0 * textureHeight,
width, height,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,94 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
// 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB
// rather than memory-order bytes, which is what we already have.
pixels.setPixel(x, y, argb[row + x]);
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = ResourceLocation.fromNamespaceAndPath(
"photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,39 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*
* <p>1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The
* bridge still wants the float, and {@code false} asks for the real one rather
* than the frozen-while-paused one, so notifications keep animating while the
* game is paused behind our own screen.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics,
window.getGuiScaledWidth(),
window.getGuiScaledHeight(),
delta.getGameTimeDeltaPartialTick(false))));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21.5
#
# 1.21.5 made Screenshot asynchronous (Consumer-based) and cut NativeImage by 9 methods.
minecraft_version=1.21.5
minecraft_range=>=1.21.5 <1.21.6
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.128.2+1.21.5
@@ -0,0 +1,112 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
//
// 1.21.5 turned the readback asynchronous: takeScreenshot no longer
// returns the frame, it hands it to a callback once the GPU fence
// clears, possibly frames later. The name is still claimed up front so
// that captures land in the order they were asked for.
Runnable grab = () -> {
try {
Path file = reserveFile(fileNameSuffix);
Screenshot.takeScreenshot(game.getMainRenderTarget(),
frame -> Util.ioPool().execute(() -> write(frame, file, written)));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,117 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.renderer.RenderType;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* 1.21.2 moved the destination size ahead of the source rectangle and put a
* render-type lookup in front of everything, but the UVs are still texels,
* so the handle's own dimensions still do the conversion.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
RenderType::guiTextured,
handle.id(),
x, y,
u0 * textureWidth, v0 * textureHeight,
width, height,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,95 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
// 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB
// rather than memory-order bytes, which is what we already have.
pixels.setPixel(x, y, argb[row + x]);
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// 1.21.5 gave every GPU texture a debug label, so the id has to exist
// before the texture does. DynamicTexture still takes ownership of the
// image and closes it with itself; only the registration needs freeing.
ResourceLocation id = ResourceLocation.fromNamespaceAndPath(
"photosync", "thumb/" + sequence.incrementAndGet());
DynamicTexture texture = new DynamicTexture(id::toString, pixels);
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}

Some files were not shown because too many files have changed in this diff Show More