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