init
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
**/build/**
|
||||||
|
.idea
|
||||||
|
**/__pycache__/**
|
||||||
|
.gradle
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
你可以先尝试做一些实验来研究可行性,然后问我有什么需要补充的信息。
|
||||||
@@ -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 | 48–192 |
|
||||||
|
| 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
@@ -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
@@ -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 1–5 have no equivalent, so adopting it would buy a shorter
|
||||||
|
method on four buckets in exchange for two spellings to keep straight. All nine
|
||||||
|
adapters stay on the explicit texel form.
|
||||||
|
|
||||||
|
26.2 is the interesting one and it is less disruptive than it looks. `GuiGraphics`
|
||||||
|
is gone entirely, replaced by an extract/render-state pipeline: `Screen.render`
|
||||||
|
becomes `extractRenderState(GuiGraphicsExtractor, int, int, float)`. But
|
||||||
|
`GuiGraphicsExtractor` still exposes `fill`, `fillGradient`, `enableScissor`,
|
||||||
|
`disableScissor`, `text` and `blit` with recognisable signatures, so all eleven
|
||||||
|
primitives survive the transition. The adapter binds to the extractor instead of
|
||||||
|
the graphics object; nothing above the seam notices.
|
||||||
|
|
||||||
|
### Screen
|
||||||
|
|
||||||
|
| | 1.20.1 | 1.20.4 → 1.21.8 | 1.21.11 | 26.2 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| render entry | `render(GuiGraphics,int,int,float)` | ✓ | ✓ | **`extractRenderState(GuiGraphicsExtractor,…)`** |
|
||||||
|
| background | `renderBackground(GuiGraphics)` | **4 args** | ✓ | **`extractBackground(…)`** |
|
||||||
|
| `init` / `resize` | `(Minecraft,int,int)` | ✓ | **`(int,int)`** — no `Minecraft` | ✓ |
|
||||||
|
| `tick`, `removed`, `isPauseScreen`, `shouldCloseOnEsc` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
|
### HUD — where notifications are drawn
|
||||||
|
|
||||||
|
| | 1.20.1 → 1.20.6 | 1.21.1 → 1.21.11 | 26.2 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| class | `Gui` | ✓ | **`Hud`** |
|
||||||
|
| hook | `render(GuiGraphics, float)` | **`render(GuiGraphics, DeltaTracker)`** | **`extractRenderState(GuiGraphicsExtractor, DeltaTracker)`** |
|
||||||
|
| partial tick | the `float` argument | `delta.getGameTimeDeltaPartialTick(false)` | ✓ |
|
||||||
|
|
||||||
|
`false` asks for the real partial tick rather than the frozen-while-paused one,
|
||||||
|
so notifications keep animating while the game is paused behind a PhotoSync
|
||||||
|
screen.
|
||||||
|
|
||||||
|
26.1 split `Gui` in two: the in-world overlay moved to `Hud`, and `Gui` kept an
|
||||||
|
outer `extractRenderState` that draws the current screen *after* the HUD.
|
||||||
|
Injecting into `Gui`'s would put notifications underneath our own GUI, so
|
||||||
|
`HudMixin` targets `Hud`. Grep for the class before assuming the method name is
|
||||||
|
the whole change.
|
||||||
|
|
||||||
|
Because the injection point is the in-world HUD on every bucket, **notifications
|
||||||
|
are invisible on the title screen and inside our own screens.** That is by
|
||||||
|
design — an upload that finishes while the player is in the queue screen shows up
|
||||||
|
in the queue list, which is better feedback than a corner toast — but it does
|
||||||
|
mean a capture taken from a menu reports nothing until the player is back in a
|
||||||
|
world.
|
||||||
|
|
||||||
|
### Input — `GuiEventListener`
|
||||||
|
|
||||||
|
| | 1.20.1 | 1.20.4 → 1.21.8 | 1.21.11 → 26.2 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `mouseClicked` | `(double,double,int)` | ✓ | **`(MouseButtonEvent, boolean)`** |
|
||||||
|
| `mouseReleased` | `(double,double,int)` | ✓ | **`(MouseButtonEvent)`** |
|
||||||
|
| `mouseDragged` | `(double,double,int,double,double)` | ✓ | **`(MouseButtonEvent,double,double)`** |
|
||||||
|
| `mouseScrolled` | `(double,double,double)` | **4 args** | `(double,double,double,double)` |
|
||||||
|
| `keyPressed` | `(int,int,int)` | ✓ | **`(KeyEvent)`** |
|
||||||
|
| `charTyped` | `(char,int)` | ✓ | **`(CharacterEvent)`** |
|
||||||
|
|
||||||
|
`ScreenModel` keeps the flat 1.20-era signatures because they carry every field
|
||||||
|
the event objects do; the 1.21.11+ adapters unpack. `mouseScrolled` gaining a
|
||||||
|
fourth argument at 1.20.4 is the *only* reason buckets 1 and 2 are separate —
|
||||||
|
that plus `renderBackground` above.
|
||||||
|
|
||||||
|
Two details the table cannot hold:
|
||||||
|
|
||||||
|
* `CharacterEvent` is `(int codepoint, int modifiers)` at 1.21.11 and
|
||||||
|
**`(int codepoint)`** at 26.2 — the modifiers were dropped. The 26.2 adapter
|
||||||
|
passes `0`, which costs nothing: nothing downstream reads them (`TextField`
|
||||||
|
filters on the character alone, and the shortcuts that care about Ctrl arrive
|
||||||
|
through `keyPressed`).
|
||||||
|
* The event carries a *code point*, not a `char`, so anything outside the basic
|
||||||
|
plane arrives as a surrogate pair. Both adapters loop over
|
||||||
|
`Character.toChars(...)` and deliver two `char`s — which is what a `String`
|
||||||
|
would have held anyway.
|
||||||
|
|
||||||
|
### Textures
|
||||||
|
|
||||||
|
| | 1.20.1 → 1.21.1 | 1.21.4 | 1.21.5 → 1.21.8 | 1.21.11 → 26.2 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `DynamicTexture(NativeImage)` | ✓ | ✓ | **`(Supplier<String>, NativeImage)`** | ✓ |
|
||||||
|
| `NativeImage.setPixelRGBA` | ✓ | **`setPixel`** | ✓ | ✓ |
|
||||||
|
| `TextureManager.register(String, DynamicTexture)` → id | ✓ | **removed** | — | — |
|
||||||
|
| `TextureManager.register(id, AbstractTexture)` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `TextureManager.release(id)` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| id type | `ResourceLocation` | ✓ | ✓ | **`Identifier`** |
|
||||||
|
|
||||||
|
Because the auto-naming `register(String, DynamicTexture)` overload disappears at
|
||||||
|
1.21.4 while `register(id, AbstractTexture)` exists everywhere, all nine adapters
|
||||||
|
mint their own id and use the two-argument form. One code shape, no breakpoint —
|
||||||
|
worth knowing so nobody "simplifies" the older buckets back onto the overload.
|
||||||
|
|
||||||
|
The `setPixelRGBA` → `setPixel` rename at 1.21.4 is not just a rename: **the
|
||||||
|
channel order changed with it.** `setPixelRGBA` takes ABGR despite the name;
|
||||||
|
`setPixel` takes plain ARGB, which is what `ThumbImage` already holds. That is
|
||||||
|
why buckets 1–4 have a private `abgr()` swap in `TextureAdapter` and buckets 5–9
|
||||||
|
do not. Copying an adapter across that boundary in either direction produces
|
||||||
|
images with red and blue exchanged — it renders, so nothing tells you but your
|
||||||
|
eyes.
|
||||||
|
|
||||||
|
At 1.21.5 `DynamicTexture` gained a `Supplier<String>` label, which means the id
|
||||||
|
has to be minted *before* the texture rather than after. The texture still takes
|
||||||
|
ownership of the `NativeImage` and closes it with itself; only the registration
|
||||||
|
needs releasing.
|
||||||
|
|
||||||
|
### Key bindings
|
||||||
|
|
||||||
|
| | 1.20.1 → 1.21.8 | 1.21.11 | 26.2 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| category | translation key `String` | **`KeyMapping.Category.register(Identifier)`** | ✓ |
|
||||||
|
| Fabric module | `fabric-key-binding-api-v1` | ✓ | **`fabric-key-mapping-api-v1`** |
|
||||||
|
| Fabric helper | `KeyBindingHelper.registerKeyBinding` | ✓ | **`KeyMappingHelper.registerKeyMapping`** |
|
||||||
|
|
||||||
|
The 1.21.11 category derives its own label: `photosync:main` becomes
|
||||||
|
`key.category.photosync.main`. The older buckets use whatever string you hand
|
||||||
|
them, which here is `key.categories.photosync`. `en_us.json` carries **both**
|
||||||
|
keys — they are two lines, and the alternative is a missing-translation string in
|
||||||
|
the controls screen on half the buckets.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
| | 1.20.1 → 1.21.4 | 1.21.5 | 1.21.8 → 26.2 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `Screenshot._grab(File,String,RenderTarget,Consumer<Component>)` | ✓ | **removed** | — |
|
||||||
|
| `Screenshot.takeScreenshot(RenderTarget)` → `NativeImage` | ✓ | **`(RenderTarget, Consumer<NativeImage>)`** | ✓ |
|
||||||
|
| `Screenshot.grab(File,String,RenderTarget,…,Consumer<Component>)` | ✓ | ✓ | **extra `int` (downscale)** |
|
||||||
|
| `NativeImage.writeToFile(File)` / `(Path)` | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
|
26.2 additionally gains `grab(Minecraft, boolean)`. `Minecraft.getMainRenderTarget()`
|
||||||
|
also moved at 26.1 — the target now hangs off `gameRenderer.mainRenderTarget()`.
|
||||||
|
|
||||||
|
`writeToFile` is the anchor: it is the one member in this entire table that is
|
||||||
|
byte-for-byte identical on all nine versions. The capture mixin therefore
|
||||||
|
redirects `NativeImage.writeToFile` rather than trying to intercept `grab`, whose
|
||||||
|
shape changes three times.
|
||||||
|
|
||||||
|
The mixin declares `method = "*"` deliberately. On **every** bucket the call site
|
||||||
|
is a private static helper rather than `grab` itself, and the helper is named
|
||||||
|
after nothing:
|
||||||
|
|
||||||
|
```
|
||||||
|
private static void <helper>(NativeImage, File, Consumer<Component>)
|
||||||
|
1.20.1 – 1.21.4 method_1661
|
||||||
|
1.21.5 – 1.21.11 method_22691
|
||||||
|
26.2 lambda$grab$3
|
||||||
|
```
|
||||||
|
|
||||||
|
Those names are unspellable in a Mojmap bucket — Mojang's mappings do not name
|
||||||
|
synthetics, so the intermediary name is what survives, and it changes. Matching
|
||||||
|
every method in the class and letting the redirect's target descriptor pick the
|
||||||
|
call site is the only formulation that survives all nine.
|
||||||
|
|
||||||
|
Two facts make redirecting the write safe rather than merely convenient:
|
||||||
|
|
||||||
|
* `NativeImage.writeToFile(File)` *delegates* to `writeToFile(Path)` on every
|
||||||
|
bucket (checked in bytecode, not assumed), so redirecting the `File` overload
|
||||||
|
catches vanilla's F2 exactly once and never fires for the mod's own
|
||||||
|
`writeToFile(Path)` calls. Redirect the `Path` overload instead and every
|
||||||
|
automatic capture publishes twice.
|
||||||
|
* The exact `Path` is known synchronously, at the moment the file exists. No
|
||||||
|
directory watching, no guessing at the filename, no race with the player
|
||||||
|
taking a second screenshot.
|
||||||
|
|
||||||
|
Automatic captures never go through this path. They are taken by
|
||||||
|
`ScreenshotService`, which builds its own filename (that is where the
|
||||||
|
configurable `_auto` suffix comes from) and publishes to `ScreenshotBus` itself,
|
||||||
|
so the mixin only ever reports vanilla F2 presses and reports them as `MANUAL`.
|
||||||
|
The origin of a capture is known by construction rather than by a thread-local
|
||||||
|
flag.
|
||||||
|
|
||||||
|
`CaptureAdapter` does not call `Screenshot.grab` for the same reason: `grab`
|
||||||
|
names the file itself and writes a chat message, and since we intercept its write
|
||||||
|
to notice F2, routing automatic captures through it would make the two origins
|
||||||
|
indistinguishable. It grabs the frame and writes the PNG itself — on the render
|
||||||
|
thread for the readback, on `Util.ioPool()` for the encode.
|
||||||
|
|
||||||
|
That readback turned **asynchronous at 1.21.5**: `takeScreenshot` no longer
|
||||||
|
returns the frame, it hands it to a callback once the GPU fence clears, possibly
|
||||||
|
several frames later. The filename is therefore reserved *before* the grab on
|
||||||
|
those buckets, so captures land in the order they were requested. Reservation is
|
||||||
|
a `synchronized` `Files.createFile` — claiming the name by creating the file
|
||||||
|
empty, not by testing for absence — because two captures a second apart would
|
||||||
|
otherwise agree on a name and one would overwrite the other. A failed write
|
||||||
|
deletes the placeholder.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
| | 1.20.1 → 1.21.1 | 1.21.4 → 1.21.8 | 1.21.11 | 26.2 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| current screen | `Minecraft.screen` field | ✓ | ✓ | **`Minecraft.gui.screen()`** |
|
||||||
|
| set screen | `Minecraft.setScreen` | ✓ | ✓ | **`Minecraft.gui.setScreen`** |
|
||||||
|
| render target | `Minecraft.getMainRenderTarget()` | ✓ | ✓ | **`gameRenderer.mainRenderTarget()`** |
|
||||||
|
| `Minecraft.stop()`, `getWindow()` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `execute(Runnable)`, `isSameThread()` | inherited from `BlockableEventLoop`, unchanged | ✓ | ✓ | ✓ |
|
||||||
|
| `Util` package | `net.minecraft.Util` | ✓ | **`net.minecraft.util.Util`** | ✓ |
|
||||||
|
| `Util.ioPool()` | `ExecutorService` | **`TracingExecutor`** | ✓ | ✓ |
|
||||||
|
|
||||||
|
26.1 moved screen ownership off `Minecraft` and onto `Gui`, which holds it now.
|
||||||
|
`Minecraft.setScreenAndShow` still exists and would also work, but it forces a
|
||||||
|
synchronous frame on top of the switch; `Gui.setScreen` is where the plain setter
|
||||||
|
went, and it is what `ScreenAdapter` uses.
|
||||||
|
|
||||||
|
`Util.ioPool()` returning `TracingExecutor` rather than `ExecutorService` from
|
||||||
|
1.21.4 is invisible at the call site — both have `execute(Runnable)` — but it is
|
||||||
|
a hard break if you ever assign the result to a typed local. Don't; call it
|
||||||
|
inline.
|
||||||
|
|
||||||
|
## 4. Mappings: Mojang for 1.20–1.21, identity for 26.x
|
||||||
|
|
||||||
|
Buckets 1–8 use `loom.officialMojangMappings()`. Minecraft 26.x ships already
|
||||||
|
deobfuscated, so there is nothing to remap — but Loom still requires a mappings
|
||||||
|
artifact that declares a `named` namespace. The root build writes a header-only
|
||||||
|
tiny-v2 jar for those buckets:
|
||||||
|
|
||||||
|
```
|
||||||
|
tiny 2 0 official intermediary named
|
||||||
|
```
|
||||||
|
|
||||||
|
Three namespaces, zero rows, which Loom reads as "every name maps to itself". It
|
||||||
|
is generated during configuration rather than by a task because Loom resolves
|
||||||
|
the mappings configuration while configuring the project, so a task output would
|
||||||
|
not exist yet. It is deterministic and 47 bytes; `deobfuscated=true` in a
|
||||||
|
bucket's `gradle.properties` turns it on.
|
||||||
|
|
||||||
|
Consequence when reading code: in a 26.x adapter the names you see *are* the
|
||||||
|
runtime names. In a 1.20–1.21 adapter they are Mojmap names that Loom remaps on
|
||||||
|
build.
|
||||||
|
|
||||||
|
## 5. Adding a new Minecraft version
|
||||||
|
|
||||||
|
Most releases need no work at all — if the new version falls inside an existing
|
||||||
|
bucket's `minecraft_range`, widen the range and ship. Do that check first.
|
||||||
|
|
||||||
|
When it does not:
|
||||||
|
|
||||||
|
1. **Probe before writing anything.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./gradlew :platform:<nearest-existing>:build
|
||||||
|
python3 tools/probe-api.py > /tmp/api.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the new version to a scratch platform project first so Loom caches its
|
||||||
|
jar, then diff its section against the nearest existing bucket. If nothing in
|
||||||
|
section 3 moved, you do not need a new bucket.
|
||||||
|
|
||||||
|
2. **Copy the nearest bucket.** `cp -r platform/26.2 platform/<new>`, then edit
|
||||||
|
its `gradle.properties`: `minecraft_version`, `minecraft_range`, `mc_java`,
|
||||||
|
`deobfuscated`, `loader_version`, `fabric_api_version`. Add
|
||||||
|
`include 'platform:<new>'` to `settings.gradle`, and narrow the *previous*
|
||||||
|
bucket's upper bound so the two ranges do not overlap.
|
||||||
|
|
||||||
|
Copy the newest bucket, not the nearest by number — a new release is almost
|
||||||
|
always a continuation of the most recent one, and starting from an older
|
||||||
|
bucket means re-doing every change made since.
|
||||||
|
|
||||||
|
3. **Fix what the compiler complains about.** This is the whole point of the
|
||||||
|
layering: the errors are confined to `platform/<new>` and they are the diff
|
||||||
|
in section 3. Nothing in `shared/` should need to change. If it does, that is
|
||||||
|
a signal the seam is leaking a Minecraft concept and is worth pushing back on
|
||||||
|
before working around.
|
||||||
|
|
||||||
|
If a class in `platform/common` is among the failures, it has stopped being
|
||||||
|
common: move it down into **all** buckets (`for v in platform/*/; do ...`),
|
||||||
|
then change the one copy that needed changing. Do not add version checks to
|
||||||
|
`common` — that is the thing the bucket layout exists to avoid.
|
||||||
|
|
||||||
|
`tools/probe-class.py <class[,class...]> [regex]` javaps one class across
|
||||||
|
every cached bucket jar at once, which is usually faster than reading release
|
||||||
|
notes. For argument *order* questions the notes are useless anyway; javap the
|
||||||
|
jar under `~/.gradle/caches/fabric-loom/minecraftMaven/`.
|
||||||
|
|
||||||
|
4. **Update this document.** Add a column to the affected tables in section 3
|
||||||
|
and a row to the bucket table in section 1. The tables are the reason step 1
|
||||||
|
is cheap next time. If a new API needs a translation key that older buckets
|
||||||
|
do not (as `KeyMapping.Category` did at 1.21.11), add it to
|
||||||
|
`shared/client/src/main/resources/assets/photosync/lang/en_us.json` alongside
|
||||||
|
the old one rather than replacing it — the file is shared by all nine jars.
|
||||||
|
|
||||||
|
5. **Run it.** `./gradlew :platform:<new>:runClient`, then walk the checklist
|
||||||
|
below.
|
||||||
|
|
||||||
|
### Manual checklist
|
||||||
|
|
||||||
|
The seam is not unit-testable — its whole job is to talk to the game — so these
|
||||||
|
are checked by hand once per bucket:
|
||||||
|
|
||||||
|
- [ ] Screens open, close on Escape, and survive a window resize
|
||||||
|
- [ ] Mouse wheel scrolls the timeline; drag scrolls the scrollbar
|
||||||
|
- [ ] Text field accepts typing, and **paste works** (an Immich API key is not
|
||||||
|
typed by hand)
|
||||||
|
- [ ] Thumbnails decode and draw, and are released when the screen closes —
|
||||||
|
watch for a texture leak across repeated opens
|
||||||
|
- [ ] Clipping: the timeline's tiles do not draw outside their viewport
|
||||||
|
- [ ] F2 produces a queue entry with the correct path
|
||||||
|
- [ ] Automatic capture produces a file with the configured suffix
|
||||||
|
- [ ] Quitting with an upload in flight shows the progress dialog, and
|
||||||
|
"quit anyway" actually quits
|
||||||
|
|
||||||
|
## 6. Things that will bite you
|
||||||
|
|
||||||
|
**`lombok.config` changes need a clean build.** Gradle does not track it as a
|
||||||
|
compile input, so changing it leaves stale generated accessors on the classpath
|
||||||
|
and produces "cannot find symbol" errors on methods you can see in the source.
|
||||||
|
`./gradlew :shared:core:clean` first.
|
||||||
|
|
||||||
|
**Compile against the oldest Gson, not the newest.** `gson_api_version=2.10` is
|
||||||
|
what 1.20.1 bundles; 26.2 has 2.14. Compiling against 2.14 would let code
|
||||||
|
reference API that is missing at runtime on older buckets, and the failure would
|
||||||
|
appear only in the field. Same reasoning for `slf4j_api_version=2.0.1`.
|
||||||
|
|
||||||
|
**Minecraft's image decoder is stb_image, which reads PNG and JPEG only** — not
|
||||||
|
WebP, not AVIF. Immich's default thumbnail format is WebP, so `ImmichProvider`
|
||||||
|
sniffs the first thumbnail's magic bytes and falls back to previews (JPEG) for
|
||||||
|
the rest of the session if it sees a RIFF/WEBP header. Any new provider has to
|
||||||
|
honour the same contract, which `PhotoProvider.thumbnail` states explicitly.
|
||||||
|
|
||||||
|
**Everything touching a texture, screen or framebuffer must go through
|
||||||
|
`GameContext.submit`.** Uploads, thumbnail fetches and the capture timer all run
|
||||||
|
on worker threads; the render thread is the only one allowed to touch GPU state.
|
||||||
|
|
||||||
|
**Mixin annotations are remapped in place; there is no refmap.** Loom's
|
||||||
|
non-legacy path rewrites `@Mixin`/`@Inject` targets into intermediary with
|
||||||
|
tiny-remapper, so no `*-refmap.json` is produced and none should be configured.
|
||||||
|
If you want to confirm a mixin actually resolved, unzip the built jar and
|
||||||
|
`javap -p -v` the mixin class — the annotation holds the remapped target
|
||||||
|
(`method_1592` on a Mojmap bucket, the plain name on 26.x). A mixin that silently
|
||||||
|
does nothing usually has a target that remapped to something that no longer
|
||||||
|
exists, and this is the only place that shows.
|
||||||
|
|
||||||
|
**`@Redirect` on an overloaded method needs the delegation checked, not
|
||||||
|
assumed.** The `writeToFile(File)` → `writeToFile(Path)` delegation above is the
|
||||||
|
example: get it backwards and the mod publishes every automatic capture twice,
|
||||||
|
with no error anywhere.
|
||||||
|
|
||||||
|
**26.x buckets are already deobfuscated, so `runClient` output names differ.** A
|
||||||
|
stack trace from bucket 9 reads like source; one from buckets 1–8 reads like
|
||||||
|
Mojmap only because Loom mapped it on the way in. Do not paste a 26.x trace into
|
||||||
|
a search for an older bucket's symbol.
|
||||||
@@ -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
|
||||||
|
|
||||||
Vendored
BIN
Binary file not shown.
+9
@@ -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
|
||||||
@@ -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
@@ -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%
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Configured by the root build.gradle (see the platformProjects block).
|
||||||
|
// Bucket-specific settings belong in this directory's gradle.properties.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Configured by the root build.gradle (see the platformProjects block).
|
||||||
|
// Bucket-specific settings belong in this directory's gradle.properties.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Configured by the root build.gradle (see the platformProjects block).
|
||||||
|
// Bucket-specific settings belong in this directory's gradle.properties.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Configured by the root build.gradle (see the platformProjects block).
|
||||||
|
// Bucket-specific settings belong in this directory's gradle.properties.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Configured by the root build.gradle (see the platformProjects block).
|
||||||
|
// Bucket-specific settings belong in this directory's gradle.properties.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Configured by the root build.gradle (see the platformProjects block).
|
||||||
|
// Bucket-specific settings belong in this directory's gradle.properties.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Configured by the root build.gradle (see the platformProjects block).
|
||||||
|
// Bucket-specific settings belong in this directory's gradle.properties.
|
||||||
@@ -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
Reference in New Issue
Block a user