From 5d7e6d03675d35d327d1a441eba67562c8f91c5d Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 25 Dec 2024 21:59:19 +0000 Subject: [PATCH 1/8] Instant screenshots, with counter and more configurability --- .../gametest/v1/ClientGameTestContext.java | 10 +-- .../gametest/v1/TestScreenshotOptions.java | 40 +++++++++++ .../gametest/ClientGameTestContextImpl.java | 60 +++++++++++++--- .../client/gametest/ClientGameTestImpl.java | 1 + .../gametest/TestScreenshotOptionsImpl.java | 70 +++++++++++++++++++ .../RenderTickCounterConstantAccessor.java | 30 ++++++++ .../fabric-client-gametest-api-v1.mixins.json | 3 +- .../client/gametest/ClientGameTestTest.java | 8 +-- 8 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestScreenshotOptions.java create mode 100644 fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestScreenshotOptionsImpl.java create mode 100644 fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/RenderTickCounterConstantAccessor.java diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/ClientGameTestContext.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/ClientGameTestContext.java index 5c943fff89..6e1462542e 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/ClientGameTestContext.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/ClientGameTestContext.java @@ -111,19 +111,13 @@ public interface ClientGameTestContext { boolean tryClickScreenButton(String translationKey); /** - * Takes a screenshot after waiting 1 tick (for a frame to render) and saves it in the screenshots directory. + * Takes a screenshot and saves it in the screenshots directory. * * @param name The name of the screenshot */ Path takeScreenshot(String name); - /** - * Takes a screnshot after waiting {@code delay} ticks and saves it in the screenshots directory. - * - * @param name The name of the screenshot - * @param delay The delay in ticks before taking the screenshot - */ - Path takeScreenshot(String name, int delay); + Path takeScreenshot(TestScreenshotOptions options); /** * Gets the input handler used to simulate inputs to the client. diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestScreenshotOptions.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestScreenshotOptions.java new file mode 100644 index 0000000000..26f046ce74 --- /dev/null +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestScreenshotOptions.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.fabric.api.client.gametest.v1; + +import java.nio.file.Path; + +import com.google.common.base.Preconditions; +import org.jetbrains.annotations.ApiStatus; + +import net.fabricmc.fabric.impl.client.gametest.TestScreenshotOptionsImpl; + +@ApiStatus.NonExtendable +public interface TestScreenshotOptions { + static TestScreenshotOptions of(String name) { + Preconditions.checkNotNull(name, "name"); + return new TestScreenshotOptionsImpl(name); + } + + TestScreenshotOptions disableCounterPrefix(); + + TestScreenshotOptions withTickDelta(float tickDelta); + + TestScreenshotOptions withSize(int width, int height); + + TestScreenshotOptions withDestinationDir(Path destinationDir); +} diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/ClientGameTestContextImpl.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/ClientGameTestContextImpl.java index 04e0b5a1b6..1a4fbfdabd 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/ClientGameTestContextImpl.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/ClientGameTestContextImpl.java @@ -16,9 +16,12 @@ package net.fabricmc.fabric.impl.client.gametest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; @@ -41,6 +44,7 @@ import net.minecraft.client.option.CloudRenderMode; import net.minecraft.client.option.GameOptions; import net.minecraft.client.option.SimpleOption; +import net.minecraft.client.texture.NativeImage; import net.minecraft.client.tutorial.TutorialStep; import net.minecraft.client.util.ScreenshotRecorder; import net.minecraft.sound.SoundCategory; @@ -48,9 +52,11 @@ import net.minecraft.util.Nullables; import net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.TestScreenshotOptions; import net.fabricmc.fabric.api.client.gametest.v1.TestWorldBuilder; import net.fabricmc.fabric.mixin.client.gametest.CyclingButtonWidgetAccessor; import net.fabricmc.fabric.mixin.client.gametest.GameOptionsAccessor; +import net.fabricmc.fabric.mixin.client.gametest.RenderTickCounterConstantAccessor; import net.fabricmc.fabric.mixin.client.gametest.ScreenAccessor; import net.fabricmc.loader.api.FabricLoader; @@ -262,22 +268,56 @@ private static boolean pressMatchingButton(ClickableWidget widget, String text) public Path takeScreenshot(String name) { ThreadingImpl.checkOnGametestThread("takeScreenshot"); Preconditions.checkNotNull(name, "name"); - return takeScreenshot(name, 1); + return takeScreenshot(TestScreenshotOptions.of(name)); } @Override - public Path takeScreenshot(String name, int delay) { + public Path takeScreenshot(TestScreenshotOptions options) { ThreadingImpl.checkOnGametestThread("takeScreenshot"); - Preconditions.checkNotNull(name, "name"); - Preconditions.checkArgument(delay >= 0, "delay cannot be negative"); + Preconditions.checkNotNull(options, "options"); - waitTicks(delay); - runOnClient(client -> { - ScreenshotRecorder.saveScreenshot(FabricLoader.getInstance().getGameDir().toFile(), name + ".png", client.getFramebuffer(), (message) -> { - }); - }); + TestScreenshotOptionsImpl optionsImpl = (TestScreenshotOptionsImpl) options; + return computeOnClient(client -> { + int prevWidth = client.getWindow().getFramebufferWidth(); + int prevHeight = client.getWindow().getFramebufferHeight(); + + if (optionsImpl.size != null) { + client.getWindow().setFramebufferWidth(optionsImpl.size.x); + client.getWindow().setFramebufferHeight(optionsImpl.size.y); + client.getFramebuffer().resize(optionsImpl.size.x, optionsImpl.size.y); + } + + try { + client.gameRenderer.render(RenderTickCounterConstantAccessor.create(optionsImpl.tickDelta), true); + + // The vanilla panorama screenshot code has a Thread.sleep(10) here, is this needed? + + Path destinationDir = Objects.requireNonNullElseGet(optionsImpl.destinationDir, () -> FabricLoader.getInstance().getGameDir().resolve("screenshots")); - return FabricLoader.getInstance().getGameDir().resolve("screenshots").resolve(name + ".png"); + try { + Files.createDirectories(destinationDir); + } catch (IOException e) { + throw new AssertionError("Failed to create screenshots directory", e); + } + + String counterPrefix = optionsImpl.counterPrefix ? "%04d_".formatted(ClientGameTestImpl.screenshotCounter++) : ""; + Path screenshotFile = destinationDir.resolve(counterPrefix + optionsImpl.name + ".png"); + + try (NativeImage screenshot = ScreenshotRecorder.takeScreenshot(client.getFramebuffer())) { + screenshot.writeTo(screenshotFile); + } catch (IOException e) { + throw new AssertionError("Failed to write screenshot file", e); + } + + return screenshotFile; + } finally { + if (optionsImpl.size != null) { + client.getWindow().setFramebufferWidth(prevWidth); + client.getWindow().setFramebufferHeight(prevHeight); + client.getFramebuffer().resize(prevWidth, prevHeight); + } + } + }); } @Override diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/ClientGameTestImpl.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/ClientGameTestImpl.java index 2e771f6257..af5a540c6c 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/ClientGameTestImpl.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/ClientGameTestImpl.java @@ -31,6 +31,7 @@ public final class ClientGameTestImpl { public static final Logger LOGGER = LoggerFactory.getLogger("fabric-client-gametest-api-v1"); + public static int screenshotCounter = 0; private ClientGameTestImpl() { } diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestScreenshotOptionsImpl.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestScreenshotOptionsImpl.java new file mode 100644 index 0000000000..ae698ee35b --- /dev/null +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestScreenshotOptionsImpl.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.fabric.impl.client.gametest; + +import java.nio.file.Path; + +import com.google.common.base.Preconditions; +import org.jetbrains.annotations.Nullable; +import org.joml.Vector2i; + +import net.fabricmc.fabric.api.client.gametest.v1.TestScreenshotOptions; + +public class TestScreenshotOptionsImpl implements TestScreenshotOptions { + public final String name; + public boolean counterPrefix = true; + public float tickDelta = 1; + @Nullable + public Vector2i size; + @Nullable + public Path destinationDir; + + public TestScreenshotOptionsImpl(String name) { + this.name = name; + } + + @Override + public TestScreenshotOptions disableCounterPrefix() { + counterPrefix = false; + return this; + } + + @Override + public TestScreenshotOptions withTickDelta(float tickDelta) { + Preconditions.checkArgument(tickDelta >= 0 && tickDelta <= 1, "tickDelta should be between 0 and 1"); + + this.tickDelta = tickDelta; + return this; + } + + @Override + public TestScreenshotOptions withSize(int width, int height) { + Preconditions.checkArgument(width > 0, "width must be positive"); + Preconditions.checkArgument(height > 0, "height must be positive"); + + this.size = new Vector2i(width, height); + return this; + } + + @Override + public TestScreenshotOptions withDestinationDir(Path destinationDir) { + Preconditions.checkNotNull(destinationDir, "destinationDir"); + + this.destinationDir = destinationDir; + return this; + } +} diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/RenderTickCounterConstantAccessor.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/RenderTickCounterConstantAccessor.java new file mode 100644 index 0000000000..a836615b17 --- /dev/null +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/RenderTickCounterConstantAccessor.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.fabric.mixin.client.gametest; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +import net.minecraft.client.render.RenderTickCounter; + +@Mixin(RenderTickCounter.Constant.class) +public interface RenderTickCounterConstantAccessor { + @Invoker("") + static RenderTickCounter.Constant create(float constant) { + throw new UnsupportedOperationException("Implemented via mixin"); + } +} diff --git a/fabric-client-gametest-api-v1/src/client/resources/fabric-client-gametest-api-v1.mixins.json b/fabric-client-gametest-api-v1/src/client/resources/fabric-client-gametest-api-v1.mixins.json index 93213cb446..7e6f8a142c 100644 --- a/fabric-client-gametest-api-v1/src/client/resources/fabric-client-gametest-api-v1.mixins.json +++ b/fabric-client-gametest-api-v1/src/client/resources/fabric-client-gametest-api-v1.mixins.json @@ -26,6 +26,7 @@ "ClientChunkMapAccessor", "ClientWorldAccessor", "CreateWorldScreenAccessor", - "CreateWorldScreenMixin" + "CreateWorldScreenMixin", + "RenderTickCounterConstantAccessor" ] } diff --git a/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java b/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java index eb941111ec..94e57b57e3 100644 --- a/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java +++ b/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java @@ -37,7 +37,7 @@ public class ClientGameTestTest implements FabricClientGameTest { public void runTest(ClientGameTestContext context) { { waitForTitleScreenFade(context); - context.takeScreenshot("title_screen", 0); + context.takeScreenshot("title_screen"); } TestWorldSave spWorldSave; @@ -48,7 +48,7 @@ public void runTest(ClientGameTestContext context) { { enableDebugHud(context); singleplayer.getClientWorld().waitForChunksRender(); - context.takeScreenshot("in_game_overworld", 0); + context.takeScreenshot("in_game_overworld"); } { @@ -56,7 +56,7 @@ public void runTest(ClientGameTestContext context) { context.waitTick(); context.getInput().typeChars("Hello, World!"); context.getInput().pressKey(InputUtil.GLFW_KEY_ENTER); - context.takeScreenshot("chat_message_sent", 5); + context.takeScreenshot("chat_message_sent"); } MixinEnvironment.getCurrentEnvironment().audit(); @@ -83,7 +83,7 @@ public void runTest(ClientGameTestContext context) { try (TestDedicatedServerContext server = context.worldBuilder().createServer()) { try (TestServerConnection connection = server.connect()) { connection.getClientWorld().waitForChunksRender(); - context.takeScreenshot("server_in_game", 0); + context.takeScreenshot("server_in_game"); { // Test that we can enter and exit configuration final GameProfile profile = context.computeOnClient(MinecraftClient::getGameProfile); From e010b2d92d8abc9339fc72b3aa0c81da0990a293 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 26 Dec 2024 15:03:27 +0000 Subject: [PATCH 2/8] Force consistent window size across all systems and stretch framebuffer to fit the physical window --- .../api/client/gametest/v1/TestInput.java | 10 + .../gametest/FabricClientGameTestRunner.java | 15 +- .../impl/client/gametest/TestInputImpl.java | 9 + .../impl/client/gametest/WindowHooks.java | 26 +++ .../client/gametest/FramebufferMixin.java | 52 +++++ .../client/gametest/MinecraftClientMixin.java | 14 ++ .../client/gametest/MonitorTrackerMixin.java | 39 ++++ .../mixin/client/gametest/WindowMixin.java | 192 +++++++++++++++++- .../fabric-client-gametest-api-v1.mixins.json | 2 + 9 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/WindowHooks.java create mode 100644 fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/FramebufferMixin.java create mode 100644 fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/MonitorTrackerMixin.java diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestInput.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestInput.java index 7da281777d..9fd8e8da4a 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestInput.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestInput.java @@ -325,4 +325,14 @@ public interface TestInput { * @see #setCursorPos(double, double) */ void moveCursor(double deltaX, double deltaY); + + /** + * Resizes the window to match the given size. Also attempts to resize the physical window, but whether the physical + * window was successfully resized or not, the window size accessible by the game will always be changed to the + * value specified, causing widget layouts and screenshots to work as expected. + * + * @param width The new window width + * @param height The new window height + */ + void resizeWindow(int width, int height); } diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/FabricClientGameTestRunner.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/FabricClientGameTestRunner.java index 2f24f847e9..ea14fa6116 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/FabricClientGameTestRunner.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/FabricClientGameTestRunner.java @@ -38,17 +38,24 @@ public static void start() { ClientGameTestContextImpl context = new ClientGameTestContextImpl(); for (FabricClientGameTest gameTest : gameTests) { - context.restoreDefaultGameOptions(); + setupInitialGameTestState(context); gameTest.runTest(context); - context.getInput().clearKeysDown(); - checkFinalGameTestState(context, gameTest.getClass().getName()); + setupAndCheckFinalGameTestState(context, gameTest.getClass().getName()); } }); } - private static void checkFinalGameTestState(ClientGameTestContext context, String testClassName) { + private static void setupInitialGameTestState(ClientGameTestContext context) { + context.restoreDefaultGameOptions(); + } + + private static void setupAndCheckFinalGameTestState(ClientGameTestContextImpl context, String testClassName) { + context.getInput().clearKeysDown(); + context.runOnClient(client -> ((WindowHooks) (Object) client.getWindow()).fabric_resetSize()); + context.getInput().setCursorPos(context.computeOnClient(client -> client.getWindow().getWidth()) * 0.5, context.computeOnClient(client -> client.getWindow().getHeight()) * 0.5); + if (ThreadingImpl.isServerRunning) { throw new AssertionError("Client gametest %s finished while a server is still running".formatted(testClassName)); } diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestInputImpl.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestInputImpl.java index 7eaf062bd9..58400ebfaa 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestInputImpl.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestInputImpl.java @@ -321,6 +321,15 @@ public void moveCursor(double deltaX, double deltaY) { }); } + @Override + public void resizeWindow(int width, int height) { + ThreadingImpl.checkOnGametestThread("resizeWindow"); + Preconditions.checkArgument(width > 0, "width must be positive"); + Preconditions.checkArgument(height > 0, "height must be positive"); + + context.runOnClient(client -> ((WindowHooks) (Object) client.getWindow()).fabric_resize(width, height)); + } + private static InputUtil.Key getBoundKey(KeyBinding keyBinding, String action) { InputUtil.Key boundKey = ((KeyBindingAccessor) keyBinding).getBoundKey(); diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/WindowHooks.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/WindowHooks.java new file mode 100644 index 0000000000..7c4890aa80 --- /dev/null +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/WindowHooks.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.fabric.impl.client.gametest; + +public interface WindowHooks { + int fabric_getRealWidth(); + int fabric_getRealHeight(); + int fabric_getRealFramebufferWidth(); + int fabric_getRealFramebufferHeight(); + void fabric_resetSize(); + void fabric_resize(int width, int height); +} diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/FramebufferMixin.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/FramebufferMixin.java new file mode 100644 index 0000000000..288b2d7e6f --- /dev/null +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/FramebufferMixin.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.fabric.mixin.client.gametest; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gl.Framebuffer; +import net.minecraft.client.util.Window; + +import net.fabricmc.fabric.impl.client.gametest.WindowHooks; + +@Mixin(Framebuffer.class) +public class FramebufferMixin { + @ModifyVariable(method = {"draw", "drawInternal"}, at = @At("HEAD"), ordinal = 0, argsOnly = true) + private int modifyWidth(int width) { + Window window = MinecraftClient.getInstance().getWindow(); + + if ((Object) this == MinecraftClient.getInstance().getFramebuffer() && width == window.getFramebufferWidth()) { + return ((WindowHooks) (Object) window).fabric_getRealFramebufferWidth(); + } + + return width; + } + + @ModifyVariable(method = {"draw", "drawInternal"}, at = @At("HEAD"), ordinal = 1, argsOnly = true) + private int modifyHeight(int height) { + Window window = MinecraftClient.getInstance().getWindow(); + + if ((Object) this == MinecraftClient.getInstance().getFramebuffer() && height == window.getFramebufferHeight()) { + return ((WindowHooks) (Object) window).fabric_getRealFramebufferHeight(); + } + + return height; + } +} diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/MinecraftClientMixin.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/MinecraftClientMixin.java index bdd21f682c..02432f5bd0 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/MinecraftClientMixin.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/MinecraftClientMixin.java @@ -17,9 +17,11 @@ package net.fabricmc.fabric.mixin.client.gametest; import com.google.common.base.Preconditions; +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; @@ -31,12 +33,14 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.Overlay; import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.util.Window; import net.minecraft.resource.ResourcePackManager; import net.minecraft.server.SaveLoader; import net.minecraft.world.level.storage.LevelStorage; import net.fabricmc.fabric.impl.client.gametest.FabricClientGameTestRunner; import net.fabricmc.fabric.impl.client.gametest.ThreadingImpl; +import net.fabricmc.fabric.impl.client.gametest.WindowHooks; @Mixin(MinecraftClient.class) public class MinecraftClientMixin { @@ -49,6 +53,10 @@ public class MinecraftClientMixin { @Nullable private Overlay overlay; + @Shadow + @Final + private Window window; + @WrapMethod(method = "run") private void onRun(Operation original) throws Throwable { if (ThreadingImpl.isClientRunning) { @@ -162,6 +170,12 @@ private static void checkThreadOnGetInstance(CallbackInfoReturnable fullscreenVideoMode; + + @Shadow + protected abstract void updateWindowRegion(); + + @Unique + private int defaultWidth; + @Unique + private int defaultHeight; + @Unique + private int realWidth; + @Unique + private int realHeight; + @Unique + private int realFramebufferWidth; + @Unique + private int realFramebufferHeight; + + @Inject(method = "", at = @At("RETURN")) + private void onInit(WindowEventHandler eventHandler, MonitorTracker monitorTracker, WindowSettings settings, @Nullable String fullscreenVideoMode, String title, CallbackInfo ci) { + this.defaultWidth = settings.width; + this.defaultHeight = settings.height; + this.realWidth = this.width; + this.realHeight = this.height; + this.realFramebufferWidth = this.framebufferWidth; + this.realFramebufferHeight = this.framebufferHeight; + + this.width = this.windowedWidth = this.framebufferWidth = defaultWidth; + this.height = this.windowedHeight = this.framebufferHeight = defaultHeight; + } + + @Inject(method = {"onWindowFocusChanged", "onCursorEnterChanged", "onMinimizeChanged"}, at = @At("HEAD"), cancellable = true) private void cancelEvents(CallbackInfo ci) { ci.cancel(); } + + @Inject(method = "onWindowSizeChanged", at = @At("HEAD"), cancellable = true) + private void cancelWindowSizeChanged(long window, int width, int height, CallbackInfo ci) { + realWidth = width; + realHeight = height; + ci.cancel(); + } + + @Inject(method = "onFramebufferSizeChanged", at = @At("HEAD"), cancellable = true) + private void cancelFramebufferSizeChanged(long window, int width, int height, CallbackInfo ci) { + realFramebufferWidth = width; + realFramebufferHeight = height; + ci.cancel(); + } + + @WrapMethod(method = "updateWindowRegion") + private void wrapUpdateWindowRegion(Operation original) { + int prevWidth = this.width; + int prevHeight = this.height; + int prevWindowedWidth = this.windowedWidth; + int prevWindowedHeight = this.windowedHeight; + int prevFramebufferWidth = this.framebufferWidth; + int prevFramebufferHeight = this.framebufferHeight; + + original.call(); + + this.realWidth = this.width; + this.realHeight = this.height; + this.realFramebufferWidth = this.framebufferWidth; + this.realFramebufferHeight = this.framebufferHeight; + + this.width = prevWidth; + this.height = prevHeight; + this.windowedWidth = prevWindowedWidth; + this.windowedHeight = prevWindowedHeight; + this.framebufferWidth = prevFramebufferWidth; + this.framebufferHeight = prevFramebufferHeight; + } + + @Inject(method = "setWindowedSize", at = @At("HEAD"), cancellable = true) + private void setWindowedSize(int width, int height, CallbackInfo ci) { + this.fullscreen = false; + fabric_resize(width, height); + ci.cancel(); + } + + @Override + public int fabric_getRealWidth() { + return realWidth; + } + + @Override + public int fabric_getRealHeight() { + return realHeight; + } + + @Override + public int fabric_getRealFramebufferWidth() { + return realFramebufferWidth; + } + + @Override + public int fabric_getRealFramebufferHeight() { + return realFramebufferHeight; + } + + @Override + public void fabric_resetSize() { + fabric_resize(defaultWidth, defaultHeight); + } + + @Override + public void fabric_resize(int width, int height) { + if (width == this.width && width == this.windowedWidth && width == this.framebufferWidth && height == this.height && height == this.windowedHeight && height == this.framebufferHeight) { + return; + } + + // Move the top left corner of the window so that the window expands/contracts from its center, while also + // trying to keep the window within the monitor's bounds + Monitor monitor = this.monitorTracker.getMonitor((Window) (Object) this); + + if (monitor != null) { + VideoMode videoMode = monitor.findClosestVideoMode(this.fullscreenVideoMode); + + this.x += (this.windowedWidth - width) / 2; + this.y += (this.windowedHeight - height) / 2; + + if (this.x + width > monitor.getViewportX() + videoMode.getWidth()) { + this.x = monitor.getViewportX() + videoMode.getWidth() - width; + } + + if (this.x < monitor.getViewportX()) { + this.x = monitor.getViewportX(); + } + + if (this.y + height > monitor.getViewportY() + videoMode.getHeight()) { + this.y = monitor.getViewportY() + videoMode.getHeight() - height; + } + + if (this.y < monitor.getViewportY()) { + this.y = monitor.getViewportY(); + } + + this.windowedX = this.x; + this.windowedY = this.y; + } + + this.width = this.windowedWidth = this.framebufferWidth = width; + this.height = this.windowedHeight = this.framebufferHeight = height; + + updateWindowRegion(); + this.eventHandler.onResolutionChanged(); + } } diff --git a/fabric-client-gametest-api-v1/src/client/resources/fabric-client-gametest-api-v1.mixins.json b/fabric-client-gametest-api-v1/src/client/resources/fabric-client-gametest-api-v1.mixins.json index 7e6f8a142c..c0a4f5a024 100644 --- a/fabric-client-gametest-api-v1/src/client/resources/fabric-client-gametest-api-v1.mixins.json +++ b/fabric-client-gametest-api-v1/src/client/resources/fabric-client-gametest-api-v1.mixins.json @@ -27,6 +27,8 @@ "ClientWorldAccessor", "CreateWorldScreenAccessor", "CreateWorldScreenMixin", + "FramebufferMixin", + "MonitorTrackerMixin", "RenderTickCounterConstantAccessor" ] } From 2c01e6f4ad97322b9fb5d411da5577d70e136669 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 26 Dec 2024 18:04:11 +0000 Subject: [PATCH 3/8] Docs --- .../gametest/v1/ClientGameTestContext.java | 7 ++++ .../gametest/v1/TestScreenshotOptions.java | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/ClientGameTestContext.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/ClientGameTestContext.java index 6e1462542e..2450dd056f 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/ClientGameTestContext.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/ClientGameTestContext.java @@ -114,9 +114,16 @@ public interface ClientGameTestContext { * Takes a screenshot and saves it in the screenshots directory. * * @param name The name of the screenshot + * @return The {@link Path} to the screenshot */ Path takeScreenshot(String name); + /** + * Takes a screenshot with the given options. + * + * @param options The {@link TestScreenshotOptions} to take the screenshot with + * @return The {@link Path} to the screenshot + */ Path takeScreenshot(TestScreenshotOptions options); /** diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestScreenshotOptions.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestScreenshotOptions.java index 26f046ce74..d1e52f0a07 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestScreenshotOptions.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/api/client/gametest/v1/TestScreenshotOptions.java @@ -23,18 +23,55 @@ import net.fabricmc.fabric.impl.client.gametest.TestScreenshotOptionsImpl; +/** + * Options to customize a screenshot. + */ @ApiStatus.NonExtendable public interface TestScreenshotOptions { + /** + * Creates a {@link TestScreenshotOptions} with the given screenshot name. + * + * @param name The name of the screenshot + * @return The new screenshot options instance + */ static TestScreenshotOptions of(String name) { Preconditions.checkNotNull(name, "name"); return new TestScreenshotOptionsImpl(name); } + /** + * By default, screenshot file names will be prefixed by a counter so that the screenshots appear in sequence in the + * screenshots directory. Use this method to disable this behavior. + * + * @return This screenshot options instance + */ TestScreenshotOptions disableCounterPrefix(); + /** + * Changes the tick delta to take this screenshot with. Tick delta controls interpolation between the previous tick and the + * current tick to make objects appear to move more smoothly when there are multiple frames in a tick. Defaults to + * {@code 1}, which renders all objects as their appear in the current tick. + * + * @param tickDelta The tick delta to take this screenshot with + * @return This screenshot options instance + */ TestScreenshotOptions withTickDelta(float tickDelta); + /** + * Changes the resolution of the screenshot, which defaults to the resolution of the Minecraft window. + * + * @param width The width of the screenshot + * @param height The height of the screenshot + * @return This screenshot options instance + */ TestScreenshotOptions withSize(int width, int height); + /** + * Changes the directory in which this screenshot is saved, which defaults to the {@code screenshots} directory in + * the game's run directory. + * + * @param destinationDir The directory in which to save the screenshot + * @return This screenshot options instance + */ TestScreenshotOptions withDestinationDir(Path destinationDir); } From e650fa7ab471531c7f88c7826328d98315417872 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 26 Dec 2024 18:16:54 +0000 Subject: [PATCH 4/8] Wait ticks appropriately for the screenshots in the gametest test --- .../fabric/test/client/gametest/ClientGameTestTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java b/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java index 94e57b57e3..14bdb86d75 100644 --- a/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java +++ b/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java @@ -56,6 +56,7 @@ public void runTest(ClientGameTestContext context) { context.waitTick(); context.getInput().typeChars("Hello, World!"); context.getInput().pressKey(InputUtil.GLFW_KEY_ENTER); + context.waitTick(); // wait for the server to receive the chat message context.takeScreenshot("chat_message_sent"); } @@ -70,6 +71,7 @@ public void runTest(ClientGameTestContext context) { { context.getInput().pressKey(options -> options.inventoryKey); + context.waitTicks(2); // allow the client to process the key press, and then the server to receive the request context.takeScreenshot("in_game_inventory"); context.setScreen(() -> null); } From d73132bfc02fd0e1d838a0e5597a2923e3bf7b3f Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 26 Dec 2024 23:56:31 +0000 Subject: [PATCH 5/8] Should -> must --- .../fabric/impl/client/gametest/TestScreenshotOptionsImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestScreenshotOptionsImpl.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestScreenshotOptionsImpl.java index ae698ee35b..cd01444273 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestScreenshotOptionsImpl.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/impl/client/gametest/TestScreenshotOptionsImpl.java @@ -45,7 +45,7 @@ public TestScreenshotOptions disableCounterPrefix() { @Override public TestScreenshotOptions withTickDelta(float tickDelta) { - Preconditions.checkArgument(tickDelta >= 0 && tickDelta <= 1, "tickDelta should be between 0 and 1"); + Preconditions.checkArgument(tickDelta >= 0 && tickDelta <= 1, "tickDelta must be between 0 and 1"); this.tickDelta = tickDelta; return this; From 59cc9fc5762030976290e5c53dfcab1333605145 Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 28 Dec 2024 16:49:30 +0000 Subject: [PATCH 6/8] Fix window resizing for different display scales --- .../fabric/mixin/client/gametest/WindowMixin.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/WindowMixin.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/WindowMixin.java index 43cf9787c2..979bc3fef7 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/WindowMixin.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/WindowMixin.java @@ -125,22 +125,16 @@ private void wrapUpdateWindowRegion(Operation original) { int prevHeight = this.height; int prevWindowedWidth = this.windowedWidth; int prevWindowedHeight = this.windowedHeight; - int prevFramebufferWidth = this.framebufferWidth; - int prevFramebufferHeight = this.framebufferHeight; original.call(); this.realWidth = this.width; this.realHeight = this.height; - this.realFramebufferWidth = this.framebufferWidth; - this.realFramebufferHeight = this.framebufferHeight; this.width = prevWidth; this.height = prevHeight; this.windowedWidth = prevWindowedWidth; this.windowedHeight = prevWindowedHeight; - this.framebufferWidth = prevFramebufferWidth; - this.framebufferHeight = prevFramebufferHeight; } @Inject(method = "setWindowedSize", at = @At("HEAD"), cancellable = true) @@ -211,8 +205,8 @@ public void fabric_resize(int width, int height) { this.windowedY = this.y; } - this.width = this.windowedWidth = this.framebufferWidth = width; - this.height = this.windowedHeight = this.framebufferHeight = height; + this.width = this.windowedWidth = width; + this.height = this.windowedHeight = height; updateWindowRegion(); this.eventHandler.onResolutionChanged(); From ab0189c420f153338fbfef50ec1db40989f132a7 Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 28 Dec 2024 16:53:01 +0000 Subject: [PATCH 7/8] Fix framebuffer size not being changed at all --- .../fabricmc/fabric/mixin/client/gametest/WindowMixin.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/WindowMixin.java b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/WindowMixin.java index 979bc3fef7..73294b0483 100644 --- a/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/WindowMixin.java +++ b/fabric-client-gametest-api-v1/src/client/java/net/fabricmc/fabric/mixin/client/gametest/WindowMixin.java @@ -205,8 +205,8 @@ public void fabric_resize(int width, int height) { this.windowedY = this.y; } - this.width = this.windowedWidth = width; - this.height = this.windowedHeight = height; + this.width = this.windowedWidth = this.framebufferWidth = width; + this.height = this.windowedHeight = this.framebufferHeight = height; updateWindowRegion(); this.eventHandler.onResolutionChanged(); From 0441c3037517d8104372cb1bf7fc1f72f78efca0 Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 28 Dec 2024 17:10:54 +0000 Subject: [PATCH 8/8] Add test for window resizing --- .../client/gametest/ClientGameTestTest.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java b/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java index 14bdb86d75..88bdade8d8 100644 --- a/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java +++ b/fabric-client-gametest-api-v1/src/testmodClient/java/net/fabricmc/fabric/test/client/gametest/ClientGameTestTest.java @@ -16,13 +16,20 @@ package net.fabricmc.fabric.test.client.gametest; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + import com.mojang.authlib.GameProfile; import org.spongepowered.asm.mixin.MixinEnvironment; import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gl.WindowFramebuffer; import net.minecraft.client.gui.screen.ReconfiguringScreen; import net.minecraft.client.gui.screen.world.WorldCreator; import net.minecraft.client.option.Perspective; +import net.minecraft.client.texture.NativeImage; import net.minecraft.client.util.InputUtil; import net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestContext; @@ -40,6 +47,14 @@ public void runTest(ClientGameTestContext context) { context.takeScreenshot("title_screen"); } + { + testScreenSize(context, WindowFramebuffer.DEFAULT_WIDTH, WindowFramebuffer.DEFAULT_HEIGHT); + context.getInput().resizeWindow(1000, 500); + context.waitTick(); + testScreenSize(context, 1000, 500); + context.getInput().resizeWindow(WindowFramebuffer.DEFAULT_WIDTH, WindowFramebuffer.DEFAULT_HEIGHT); + } + TestWorldSave spWorldSave; try (TestSingleplayerContext singleplayer = context.worldBuilder() .adjustSettings(creator -> creator.setGameMode(WorldCreator.Mode.CREATIVE)).create()) { @@ -113,4 +128,26 @@ private static void enableDebugHud(ClientGameTestContext context) { private static void setPerspective(ClientGameTestContext context, Perspective perspective) { context.runOnClient(client -> client.options.setPerspective(perspective)); } + + private static void testScreenSize(ClientGameTestContext context, int expectedWidth, int expectedHeight) { + context.runOnClient(client -> { + if (client.getWindow().getWidth() != expectedWidth || client.getWindow().getHeight() != expectedHeight) { + throw new AssertionError("Expected window size to be (%d, %d) but was (%d, %d)".formatted(expectedWidth, expectedHeight, client.getWindow().getWidth(), client.getWindow().getHeight())); + } + + if (client.getWindow().getFramebufferWidth() != expectedWidth || client.getWindow().getFramebufferHeight() != expectedHeight) { + throw new AssertionError("Expected framebuffer size to be (%d, %d) but was (%d, %d)".formatted(expectedWidth, expectedHeight, client.getWindow().getFramebufferWidth(), client.getWindow().getFramebufferHeight())); + } + }); + + Path screenshotPath = context.takeScreenshot("screenshot_size_test"); + + try (NativeImage screenshot = NativeImage.read(Files.newInputStream(screenshotPath))) { + if (screenshot.getWidth() != expectedWidth || screenshot.getHeight() != expectedHeight) { + throw new AssertionError("Expected screenshot size to be (%d, %d) but was (%d, %d)".formatted(expectedWidth, expectedHeight, screenshot.getWidth(), screenshot.getHeight())); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } }