This commit is contained in:
iceBear67
2026-08-07 18:10:38 +00:00
commit 014b18f1ba
232 changed files with 16847 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.20, 1.20.1
#
# Baseline. GuiGraphics immediate mode, ToastComponent, synchronous Screenshot.grab.
minecraft_version=1.20.1
minecraft_range=>=1.20 <1.20.2
mc_java=17
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.92.11+1.20.1
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,90 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
return model.mouseScrolled(mouseX, mouseY, amount) || super.mouseScrolled(mouseX, mouseY, amount);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,114 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* This release's {@code blit} takes UVs in texels, not the 0..1 the bridge
* speaks, so the handle's own dimensions do the conversion. Later releases
* grew a normalised overload; see docs/PORTING.md.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
handle.id(),
x, y, width, height,
u0 * textureWidth, v0 * textureHeight,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,100 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
pixels.setPixelRGBA(x, y, abgr(argb[row + x]));
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/**
* NativeImage stores RGBA in memory order, so the int it wants back is
* 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else
* in this mod speaks.
*/
private static int abgr(int argb) {
return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16);
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,30 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick)));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.20.2, 1.20.3, 1.20.4
#
# 1.20.2 reworked GuiGraphics (+8/-5 methods) and added the blitSprite family.
minecraft_version=1.20.4
minecraft_range=>=1.20.2 <1.20.5
mc_java=17
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.97.3+1.20.4
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,114 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* This release's {@code blit} takes UVs in texels, not the 0..1 the bridge
* speaks, so the handle's own dimensions do the conversion. Later releases
* grew a normalised overload; see docs/PORTING.md.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
handle.id(),
x, y, width, height,
u0 * textureWidth, v0 * textureHeight,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,100 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
pixels.setPixelRGBA(x, y, abgr(argb[row + x]));
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/**
* NativeImage stores RGBA in memory order, so the int it wants back is
* 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else
* in this mod speaks.
*/
private static int abgr(int argb) {
return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16);
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,30 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick)));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.20.5, 1.20.6
#
# 1.20.5 raised the runtime to Java 21 and changed NativeImage and ResourceLocation.
minecraft_version=1.20.6
minecraft_range=>=1.20.5 <1.21
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.100.8+1.20.6
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,114 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* This release's {@code blit} takes UVs in texels, not the 0..1 the bridge
* speaks, so the handle's own dimensions do the conversion. Later releases
* grew a normalised overload; see docs/PORTING.md.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
handle.id(),
x, y, width, height,
u0 * textureWidth, v0 * textureHeight,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,100 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
pixels.setPixelRGBA(x, y, abgr(argb[row + x]));
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/**
* NativeImage stores RGBA in memory order, so the int it wants back is
* 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else
* in this mod speaks.
*/
private static int abgr(int argb) {
return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16);
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,30 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick)));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21, 1.21.1
#
# 1.21 reshuffled ResourceLocation construction (+6/-6) and Screen (+3/-5).
minecraft_version=1.21.1
minecraft_range=>=1.21 <1.21.2
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.116.15+1.21.1
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,114 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* This release's {@code blit} takes UVs in texels, not the 0..1 the bridge
* speaks, so the handle's own dimensions do the conversion. Later releases
* grew a normalised overload; see docs/PORTING.md.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
handle.id(),
x, y, width, height,
u0 * textureWidth, v0 * textureHeight,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,101 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
pixels.setPixelRGBA(x, y, abgr(argb[row + x]));
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = ResourceLocation.fromNamespaceAndPath(
"photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/**
* NativeImage stores RGBA in memory order, so the int it wants back is
* 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else
* in this mod speaks.
*/
private static int abgr(int argb) {
return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16);
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,39 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*
* <p>1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The
* bridge still wants the float, and {@code false} asks for the real one rather
* than the frozen-while-paused one, so notifications keep animating while the
* game is paused behind our own screen.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics,
window.getGuiScaledWidth(),
window.getGuiScaledHeight(),
delta.getGameTimeDeltaPartialTick(false))));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21.11
#
# 1.21.11 renamed ResourceLocation to Identifier and churned GuiGraphics +37/-27.
minecraft_version=1.21.11
minecraft_range=>=1.21.11 <1.22
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.141.6+1.21.11
@@ -0,0 +1,112 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.util.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
//
// 1.21.5 turned the readback asynchronous: takeScreenshot no longer
// returns the frame, it hands it to a callback once the GPU fence
// clears, possibly frames later. The name is still claimed up front so
// that captures land in the order they were asked for.
Runnable grab = () -> {
try {
Path file = reserveFile(fileNameSuffix);
Screenshot.takeScreenshot(game.getMainRenderTarget(),
frame -> Util.ioPool().execute(() -> write(frame, file, written)));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.util.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,119 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.CharacterEvent;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
/**
* 1.21.11 bundled the loose input arguments into records. The bridge keeps
* the loose form -- it is the shape eight of the nine buckets speak -- so
* this is where they get unpacked.
*
* <p>{@code doubleClick} is dropped: PhotoSync has no double-click gesture,
* and the second click of a pair arrives here as an ordinary one anyway.
*/
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
return model.mouseClicked(event.x(), event.y(), event.button())
|| super.mouseClicked(event, doubleClick);
}
@Override
public boolean mouseReleased(MouseButtonEvent event) {
return model.mouseReleased(event.x(), event.y(), event.button())
|| super.mouseReleased(event);
}
@Override
public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) {
return model.mouseDragged(event.x(), event.y(), event.button(), deltaX, deltaY)
|| super.mouseDragged(event, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(KeyEvent event) {
return model.keyPressed(event.key(), event.scancode(), event.modifiers())
|| super.keyPressed(event);
}
/**
* The event carries a code point rather than a char, so anything outside the
* basic plane arrives as a surrogate pair and reaches the model as two
* chars -- which is what a {@code String} would have held anyway.
*/
@Override
public boolean charTyped(CharacterEvent event) {
boolean handled = false;
for (char character : Character.toChars(event.codepoint())) {
handled |= model.charTyped(character, event.modifiers());
}
return handled || super.charTyped(event);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,45 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import net.minecraft.resources.Identifier;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/**
* Registers the binding. Called once, from the client entrypoint.
*
* <p>1.21.11 turned the category from a loose translation key into a
* registered id, and it derives its own label: {@code photosync:main}
* becomes {@code key.category.photosync.main}, which is why the language
* file carries that key alongside the older {@code key.categories.photosync}.
*/
public static OpenKey register() {
KeyMapping.Category category =
KeyMapping.Category.register(Identifier.fromNamespaceAndPath("photosync", "main"));
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
category)));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,122 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.renderer.RenderPipelines;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* 1.21.2 moved the destination size ahead of the source rectangle, and 1.21.6
* swapped the render-type lookup in front of it for a baked pipeline. The UVs
* are still texels, so the handle's own dimensions still do the conversion.
*
* <p>There is a normalised-UV overload as of 1.21.6 that would suit the
* bridge better on paper, but its inner argument order differs from the
* texel form's in ways that are easy to get subtly wrong; staying on the
* explicit form keeps this method identical to the four buckets below.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
RenderPipelines.GUI_TEXTURED,
handle.id(),
x, y,
u0 * textureWidth, v0 * textureHeight,
width, height,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,95 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
// 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB
// rather than memory-order bytes, which is what we already have.
pixels.setPixel(x, y, argb[row + x]);
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// 1.21.5 gave every GPU texture a debug label, so the id has to exist
// before the texture does. DynamicTexture still takes ownership of the
// image and closes it with itself; only the registration needs freeing.
Identifier id = Identifier.fromNamespaceAndPath(
"photosync", "thumb/" + sequence.incrementAndGet());
DynamicTexture texture = new DynamicTexture(id::toString, pixels);
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final Identifier id;
private final int width;
private final int height;
private boolean released;
private Handle(Identifier id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,39 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*
* <p>1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The
* bridge still wants the float, and {@code false} asks for the real one rather
* than the frozen-while-paused one, so notifications keep animating while the
* game is paused behind our own screen.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics,
window.getGuiScaledWidth(),
window.getGuiScaledHeight(),
delta.getGameTimeDeltaPartialTick(false))));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21.2, 1.21.3, 1.21.4
#
# 1.21.2 replaced ToastComponent with ToastManager and reworked TextureManager.
minecraft_version=1.21.4
minecraft_range=>=1.21.2 <1.21.5
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.119.4+1.21.4
@@ -0,0 +1,107 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
Runnable grab = () -> {
try {
NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget());
Path file = reserveFile(fileNameSuffix);
Util.ioPool().execute(() -> write(frame, file, written));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,117 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.renderer.RenderType;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* 1.21.2 moved the destination size ahead of the source rectangle and put a
* render-type lookup in front of everything, but the UVs are still texels,
* so the handle's own dimensions still do the conversion.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
RenderType::guiTextured,
handle.id(),
x, y,
u0 * textureWidth, v0 * textureHeight,
width, height,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,94 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
// 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB
// rather than memory-order bytes, which is what we already have.
pixels.setPixel(x, y, argb[row + x]);
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// DynamicTexture takes ownership of the image and closes it with itself,
// so the only thing left to free is the registration.
DynamicTexture texture = new DynamicTexture(pixels);
ResourceLocation id = ResourceLocation.fromNamespaceAndPath(
"photosync", "thumb/" + sequence.incrementAndGet());
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,39 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*
* <p>1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The
* bridge still wants the float, and {@code false} asks for the real one rather
* than the frozen-while-paused one, so notifications keep animating while the
* game is paused behind our own screen.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics,
window.getGuiScaledWidth(),
window.getGuiScaledHeight(),
delta.getGameTimeDeltaPartialTick(false))));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21.5
#
# 1.21.5 made Screenshot asynchronous (Consumer-based) and cut NativeImage by 9 methods.
minecraft_version=1.21.5
minecraft_range=>=1.21.5 <1.21.6
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.128.2+1.21.5
@@ -0,0 +1,112 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.mcapi.capture.ScreenshotService;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Takes screenshots on the mod's own terms.
*
* <p>Deliberately not routed through vanilla's {@code Screenshot.grab}: that one
* names the file itself, writes a chat message, and -- since we intercept its
* write to notice the player's own F2 presses -- would make auto-captures
* indistinguishable from manual ones. Grabbing the frame and writing it here
* keeps the two origins apart and gives the caller the path it asked for.
*/
@Slf4j
public final class CaptureAdapter implements ScreenshotService {
private static final DateTimeFormatter STAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT);
@Override
public Path directory() {
Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots");
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e);
}
return directory;
}
@Override
public CompletableFuture<Path> capture(String fileNameSuffix) {
CompletableFuture<Path> written = new CompletableFuture<>();
Minecraft game = Minecraft.getInstance();
// The framebuffer can only be read on the render thread; the PNG encode
// that follows must not happen there, so it hops to the IO pool.
//
// 1.21.5 turned the readback asynchronous: takeScreenshot no longer
// returns the frame, it hands it to a callback once the GPU fence
// clears, possibly frames later. The name is still claimed up front so
// that captures land in the order they were asked for.
Runnable grab = () -> {
try {
Path file = reserveFile(fileNameSuffix);
Screenshot.takeScreenshot(game.getMainRenderTarget(),
frame -> Util.ioPool().execute(() -> write(frame, file, written)));
} catch (IOException | RuntimeException e) {
written.completeExceptionally(e);
}
};
if (game.isSameThread()) {
grab.run();
} else {
game.execute(grab);
}
return written;
}
private void write(NativeImage frame, Path file, CompletableFuture<Path> written) {
try (NativeImage owned = frame) {
owned.writeToFile(file);
written.complete(file);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(file);
} catch (IOException cleanup) {
log.warn("Left an empty screenshot behind at {}", file, cleanup);
}
written.completeExceptionally(e);
}
}
/**
* Vanilla's naming scheme plus the configured suffix: two captures in the
* same second get {@code _1}, {@code _2}, and so on.
*
* <p>The name is claimed by creating the file empty rather than by testing
* for absence, because the write happens later on another thread -- two
* captures a moment apart would otherwise agree on a name and one would
* overwrite the other.
*/
private synchronized Path reserveFile(String fileNameSuffix) throws IOException {
Path directory = directory();
String stamp = LocalDateTime.now().format(STAMP);
String suffix = fileNameSuffix == null ? "" : fileNameSuffix;
for (int attempt = 0; ; attempt++) {
String name = attempt == 0
? stamp + suffix + ".png"
: stamp + "_" + attempt + suffix + ".png";
try {
return Files.createFile(directory.resolve(name));
} catch (FileAlreadyExistsException taken) {
// Somebody -- us a second ago, or vanilla's own F2 -- got there first.
}
}
}
}
@@ -0,0 +1,78 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.GameContext;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import java.nio.file.Files;
import java.nio.file.Path;
/** Ambient client state, the render thread, and the way out of the game. */
@Slf4j
public final class GameAdapter implements GameContext {
/**
* Both resolved from the loader rather than from Minecraft, because this is
* built while Minecraft is still constructing itself -- and because the
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
@Override
public boolean inWorld() {
return Minecraft.getInstance().level != null;
}
@Override
public boolean screenOpen() {
return Minecraft.getInstance().screen != null;
}
@Override
public Path configDirectory() {
return configDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
if (game.isSameThread()) {
task.run();
} else {
game.execute(task);
}
}
/**
* Opens the containing folder, not the file.
*
* <p>There is no cross-platform "reveal and select", and handing a PNG to
* the desktop opens an image viewer -- which the player already has, since
* they are looking at the screenshot in the queue screen. What they cannot
* get to is the folder.
*/
@Override
public void reveal(Path path) {
Path target = Files.isDirectory(path) ? path : path.getParent();
if (target == null) {
return;
}
Util.getPlatform().openFile(target.toFile());
}
@Override
public String minecraftVersion() {
return minecraftVersion;
}
@Override
public void quit() {
Minecraft.getInstance().stop();
}
}
@@ -0,0 +1,96 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenModel;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a
* {@link ScreenModel} wearing it.
*
* <p>Nothing is delegated to {@code super} except key handling, which is where
* Escape lives. In particular vanilla's {@code renderBackground} is not called:
* the model draws its own scrim and panel, and vanilla's would paint over the
* world underneath at the wrong moment.
*/
public final class ModelScreen extends Screen {
@Getter
@Accessors(fluent = true)
private final ScreenModel model;
ModelScreen(ScreenModel model) {
super(Component.literal(model.title()));
this.model = model;
}
@Override
protected void init() {
model.layout(width, height);
}
@Override
public void tick() {
model.tick();
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
/**
* 1.20.2 split scrolling into two axes. The bridge only has one, because a
* horizontal wheel is not something any PhotoSync screen reacts to, so the
* vertical delta is the one that gets through.
*/
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return model.mouseScrolled(mouseX, mouseY, scrollY)
|| super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int key, int scanCode, int modifiers) {
return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
}
@Override
public boolean charTyped(char character, int modifiers) {
return model.charTyped(character, modifiers) || super.charTyped(character, modifiers);
}
@Override
public boolean isPauseScreen() {
return model.pausesGame();
}
@Override
public boolean shouldCloseOnEsc() {
return model.closeOnEscape();
}
@Override
public void removed() {
model.closed();
}
}
@@ -0,0 +1,35 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.KeyMapping;
import org.lwjgl.glfw.GLFW;
/**
* The key that opens PhotoSync, bound to F6 until the player says otherwise.
*
* <p>F6 because it is unbound in vanilla and sits next to F2, which is the other
* key this mod is about.
*/
public final class OpenKey {
private final KeyMapping mapping;
private OpenKey(KeyMapping mapping) {
this.mapping = mapping;
}
/** Registers the binding. Called once, from the client entrypoint. */
public static OpenKey register() {
return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping(
"key.photosync.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_F6,
"key.categories.photosync")));
}
/** Takes one queued press, or false if there are none left. */
public boolean wasPressed() {
return mapping.consumeClick();
}
}
@@ -0,0 +1,117 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.renderer.RenderType;
/**
* The eleven drawing primitives, on top of {@link GuiGraphics}.
*
* <p>One of these is built per frame and thrown away; it holds the frame's
* {@code GuiGraphics}, which is not valid outside the render call that produced
* it. Coordinates are GUI-space, matching vanilla's, so nothing here scales.
*/
public final class RenderAdapter implements RenderBridge {
private final GuiGraphics graphics;
private final Font font;
private final int width;
private final int height;
private final float tickDelta;
public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) {
this.graphics = graphics;
this.font = Minecraft.getInstance().font;
this.width = width;
this.height = height;
this.tickDelta = tickDelta;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public float tickDelta() {
return tickDelta;
}
@Override
public void fill(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + height, argb);
}
@Override
public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) {
graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb);
}
@Override
public void border(int x, int y, int width, int height, int argb) {
graphics.fill(x, y, x + width, y + 1, argb);
graphics.fill(x, y + height - 1, x + width, y + height, argb);
graphics.fill(x, y + 1, x + 1, y + height - 1, argb);
graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb);
}
@Override
public void text(String text, int x, int y, int argb, boolean shadow) {
graphics.drawString(font, text, x, y, argb, shadow);
}
@Override
public int textWidth(String text) {
return font.width(text);
}
@Override
public int lineHeight() {
return font.lineHeight;
}
@Override
public void image(TextureHandle texture, int x, int y, int width, int height) {
image(texture, x, y, width, height, 0f, 0f, 1f, 1f);
}
/**
* 1.21.2 moved the destination size ahead of the source rectangle and put a
* render-type lookup in front of everything, but the UVs are still texels,
* so the handle's own dimensions still do the conversion.
*/
@Override
public void image(TextureHandle texture, int x, int y, int width, int height,
float u0, float v0, float u1, float v1) {
TextureAdapter.Handle handle = (TextureAdapter.Handle) texture;
int textureWidth = handle.width();
int textureHeight = handle.height();
graphics.blit(
RenderType::guiTextured,
handle.id(),
x, y,
u0 * textureWidth, v0 * textureHeight,
width, height,
Math.max(1, Math.round((u1 - u0) * textureWidth)),
Math.max(1, Math.round((v1 - v0) * textureHeight)),
textureWidth, textureHeight);
}
@Override
public void pushClip(int x, int y, int width, int height) {
graphics.enableScissor(x, y, x + width, y + height);
}
@Override
public void popClip() {
graphics.disableScissor();
}
}
@@ -0,0 +1,34 @@
package dev.photosync.platform.impl;
import dev.photosync.mcapi.screen.ScreenHost;
import dev.photosync.mcapi.screen.ScreenModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import java.util.Optional;
/** Puts screen models on screen, and reports which one is there. */
public final class ScreenAdapter implements ScreenHost {
@Override
public void open(ScreenModel screen) {
Minecraft.getInstance().setScreen(new ModelScreen(screen));
}
/**
* Closes only our own screen. If the player has since opened the pause menu
* or a chest, a late close from a finishing upload must not yank it away.
*/
@Override
public void close() {
if (current().isPresent()) {
Minecraft.getInstance().setScreen(null);
}
}
@Override
public Optional<ScreenModel> current() {
Screen screen = Minecraft.getInstance().screen;
return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty();
}
}
@@ -0,0 +1,95 @@
package dev.photosync.platform.impl;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.Getter;
import lombok.experimental.Accessors;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Turns decoded pixels into something the GPU will draw.
*
* <p>Every upload mints its own texture id rather than reusing a slot, because
* the browser holds many thumbnails alive at once and vanilla's texture manager
* is the only thing that knows how to free them. The id is opaque; nothing but
* {@link RenderAdapter} ever looks at it.
*/
public final class TextureAdapter implements TextureSink {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public TextureHandle upload(ThumbImage image) {
NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false);
int[] argb = image.argb();
for (int y = 0; y < image.height(); y++) {
int row = y * image.width();
for (int x = 0; x < image.width(); x++) {
// 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB
// rather than memory-order bytes, which is what we already have.
pixels.setPixel(x, y, argb[row + x]);
}
}
return register(pixels);
}
@Override
public TextureHandle decode(byte[] encoded) throws IOException {
return register(NativeImage.read(encoded));
}
private Handle register(NativeImage pixels) {
// 1.21.5 gave every GPU texture a debug label, so the id has to exist
// before the texture does. DynamicTexture still takes ownership of the
// image and closes it with itself; only the registration needs freeing.
ResourceLocation id = ResourceLocation.fromNamespaceAndPath(
"photosync", "thumb/" + sequence.incrementAndGet());
DynamicTexture texture = new DynamicTexture(id::toString, pixels);
Minecraft.getInstance().getTextureManager().register(id, texture);
return new Handle(id, pixels.getWidth(), pixels.getHeight());
}
/** A registered texture, freed when the browser drops it. */
public static final class Handle implements TextureHandle {
@Getter
@Accessors(fluent = true)
private final ResourceLocation id;
private final int width;
private final int height;
private boolean released;
private Handle(ResourceLocation id, int width, int height) {
this.id = id;
this.width = width;
this.height = height;
}
@Override
public int width() {
return width;
}
@Override
public int height() {
return height;
}
@Override
public void close() {
if (released) {
return;
}
released = true;
Minecraft.getInstance().getTextureManager().release(id);
}
}
}
@@ -0,0 +1,15 @@
/**
* This bucket's half of the adapter: every class whose body a Minecraft release
* in the supported range has changed.
*
* <p>Each {@code :platform:*} project supplies its own copy of this package
* under exactly these names, and {@link dev.photosync.platform} -- compiled once
* per bucket from a shared source root -- calls into it. So the duplication
* between buckets is deliberate: it is what lets nine incompatible Minecraft
* APIs be satisfied without a single {@code if (version >= ...)} anywhere.
*
* <p>Adding a version means copying the nearest bucket's copy of this package
* and fixing what the compiler objects to. {@code docs/PORTING.md} lists what
* that has been, release by release.
*/
package dev.photosync.platform.impl;
@@ -0,0 +1,38 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.NativeImage;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.mcapi.capture.ScreenshotBus;
import net.minecraft.client.Screenshot;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.io.File;
import java.io.IOException;
/**
* Notices the player pressing F2.
*
* <p>The redirect is on the write rather than on {@code grab}, because the write
* is the only point at which the final path is known -- and because on every
* supported version that call sits inside a synthetic lambda whose name changes
* from release to release. Matching {@code method = "*"} against the invocation
* sidesteps the name entirely; see docs/PORTING.md.
*
* <p>Announcing after the write, not before, means a listener that reads the
* file back finds it there.
*/
@Mixin(Screenshot.class)
public class CaptureMixin {
@Redirect(
method = "*",
at = @At(
value = "INVOKE",
target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V"))
private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException {
image.writeToFile(file);
ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL);
}
}
@@ -0,0 +1,39 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.PhotoSyncMod;
import dev.photosync.platform.impl.RenderAdapter;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiGraphics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Draws the corner notifications over the HUD.
*
* <p>At TAIL so they sit above the hotbar and chat rather than under them. This
* is the in-world HUD, so notifications are invisible on the title screen --
* acceptable, since the events that raise them all happen in a world.
*
* <p>1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The
* bridge still wants the float, and {@code false} asks for the real one rather
* than the frozen-while-paused one, so notifications keep animating while the
* game is paused behind our own screen.
*/
@Mixin(Gui.class)
public class HudMixin {
@Inject(method = "render", at = @At("TAIL"))
private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) {
Window window = Minecraft.getInstance().getWindow();
PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter(
graphics,
window.getGuiScaledWidth(),
window.getGuiScaledHeight(),
delta.getGameTimeDeltaPartialTick(false))));
}
}
@@ -0,0 +1,31 @@
package dev.photosync.platform.mixin;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Holds the door while uploads finish.
*
* <p>{@code stop()} is the single funnel for leaving the game -- Quit Game, the
* window close button and Alt+F4 all reach it -- and it is where the guard gets
* asked. Refusing here leaves the client running normally, so the dialog the
* guard puts up is interactive rather than a freeze.
*
* <p>The window button re-enters this every frame for as long as GLFW's close
* flag stays set, so the guard and the dialog it opens both have to tolerate
* being asked repeatedly.
*/
@Mixin(Minecraft.class)
public class QuitMixin {
@Inject(method = "stop", at = @At("HEAD"), cancellable = true)
private void photosync$confirmQuit(CallbackInfo callback) {
if (!QuitGuard.get().mayQuit()) {
callback.cancel();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Configured by the root build.gradle (see the platformProjects block).
// Bucket-specific settings belong in this directory's gradle.properties.
+10
View File
@@ -0,0 +1,10 @@
# Compatibility bucket: 1.21.6 through 1.21.10
#
# 1.21.6 landed the GPU pipeline rewrite: GuiGraphics churned +57/-50 methods.
minecraft_version=1.21.8
minecraft_range=>=1.21.6 <1.21.11
mc_java=21
deobfuscated=false
loader_version=0.19.3
fabric_api_version=0.136.1+1.21.8
@@ -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,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.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);
}
}
}

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