diff --git a/src/main/java/net/wurstclient/mixin/FabricTestMinecraftClientMixin.java b/src/main/java/net/wurstclient/mixin/FabricTestMinecraftClientMixin.java new file mode 100644 index 0000000000..5ec972ce07 --- /dev/null +++ b/src/main/java/net/wurstclient/mixin/FabricTestMinecraftClientMixin.java @@ -0,0 +1,198 @@ +/** + * Copied from + * https://github.com/FabricMC/fabric/blob/453d4f91c7a4e0e7f34116755c8acb2c20c53aea/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/mixin/MinecraftClientMixin.java + * which at the time of writing is not yet part of the API. + */ +/* + * 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.wurstclient.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.google.common.base.Preconditions; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.resource.ResourcePackManager; +import net.minecraft.server.SaveLoader; +import net.minecraft.world.level.storage.LevelStorage; +import net.wurstclient.test.WurstE2ETestClient; +import net.wurstclient.test.fabric.ThreadingImpl; + +@Mixin(MinecraftClient.class) +public class FabricTestMinecraftClientMixin +{ + @Unique + private Runnable deferredTask = null; + + @WrapMethod(method = "run") + private void onRun(Operation original) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + if(ThreadingImpl.isClientRunning) + throw new IllegalStateException("Client is already running"); + + ThreadingImpl.isClientRunning = true; + ThreadingImpl.PHASER.register(); + } + + try + { + original.call(); + }finally + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + ThreadingImpl.clientCanAcceptTasks = false; + ThreadingImpl.PHASER.arriveAndDeregister(); + ThreadingImpl.isClientRunning = false; + } + } + } + + @Inject(method = "render", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/MinecraftClient;runTasks()V")) + private void preRunTasks(CallbackInfo ci) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_SERVER_TASKS); + // server tasks happen here + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_CLIENT_TASKS); + } + } + + @Inject(method = "render", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/MinecraftClient;runTasks()V", + shift = At.Shift.AFTER)) + private void postRunTasks(CallbackInfo ci) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + ThreadingImpl.clientCanAcceptTasks = true; + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TEST); + + if(ThreadingImpl.testThread != null) + while(true) + { + try + { + ThreadingImpl.CLIENT_SEMAPHORE.acquire(); + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + + if(ThreadingImpl.taskToRun == null) + break; + ThreadingImpl.taskToRun.run(); + } + + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TICK); + + Runnable deferredTask = this.deferredTask; + this.deferredTask = null; + + if(deferredTask != null) + deferredTask.run(); + } + } + + @Inject(method = "startIntegratedServer", + at = @At("HEAD"), + cancellable = true) + private void deferStartIntegratedServer(LevelStorage.Session session, + ResourcePackManager dataPackManager, SaveLoader saveLoader, + boolean newWorld, CallbackInfo ci) + { + if(WurstE2ETestClient.IS_AUTO_TEST && ThreadingImpl.taskToRun != null) + { + // don't start the integrated server (which busywaits) inside a task + deferredTask = + () -> MinecraftClient.getInstance().startIntegratedServer( + session, dataPackManager, saveLoader, newWorld); + ci.cancel(); + } + } + + @Inject(method = "startIntegratedServer", + at = @At(value = "INVOKE", + target = "Ljava/lang/Thread;sleep(J)V", + remap = false)) + private void onStartIntegratedServerBusyWait(CallbackInfo ci) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + // give the server a chance to tick too + preRunTasks(ci); + postRunTasks(ci); + } + } + + @Inject(method = "disconnect(Lnet/minecraft/client/gui/screen/Screen;Z)V", + at = @At("HEAD"), + cancellable = true) + private void deferDisconnect(Screen disconnectionScreen, + boolean transferring, CallbackInfo ci) + { + if(WurstE2ETestClient.IS_AUTO_TEST + && MinecraftClient.getInstance().getServer() != null + && ThreadingImpl.taskToRun != null) + { + // don't disconnect (which busywaits) inside a task + deferredTask = () -> MinecraftClient.getInstance() + .disconnect(disconnectionScreen, transferring); + ci.cancel(); + } + } + + @Inject(method = "disconnect(Lnet/minecraft/client/gui/screen/Screen;Z)V", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/MinecraftClient;render(Z)V", + shift = At.Shift.AFTER)) + private void onDisconnectBusyWait(CallbackInfo ci) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + // give the server a chance to tick too + preRunTasks(ci); + postRunTasks(ci); + } + } + + @Inject(method = "getInstance", at = @At("HEAD")) + private static void checkThreadOnGetInstance( + CallbackInfoReturnable cir) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + // TODO: add suggestion of runOnClient etc when API methods are + // added + Preconditions.checkState( + Thread.currentThread() != ThreadingImpl.testThread, + "MinecraftClient.getInstance() cannot be called from the test thread"); + } +} diff --git a/src/main/java/net/wurstclient/mixin/FabricTestMinecraftServerMixin.java b/src/main/java/net/wurstclient/mixin/FabricTestMinecraftServerMixin.java new file mode 100644 index 0000000000..925a7b132a --- /dev/null +++ b/src/main/java/net/wurstclient/mixin/FabricTestMinecraftServerMixin.java @@ -0,0 +1,107 @@ +/** + * Copied from + * https://github.com/FabricMC/fabric/blob/453d4f91c7a4e0e7f34116755c8acb2c20c53aea/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/mixin/MinecraftServerMixin.java + * which at the time of writing is not yet part of the API. + */ +/* + * 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.wurstclient.mixin; + +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; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; + +import net.minecraft.server.MinecraftServer; +import net.wurstclient.test.WurstE2ETestClient; +import net.wurstclient.test.fabric.ThreadingImpl; + +@Mixin(MinecraftServer.class) +public class FabricTestMinecraftServerMixin +{ + @WrapMethod(method = "runServer") + private void onRunServer(Operation original) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + if(ThreadingImpl.isServerRunning) + throw new IllegalStateException("Server is already running"); + + ThreadingImpl.isServerRunning = true; + ThreadingImpl.PHASER.register(); + } + + try + { + original.call(); + }finally + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + ThreadingImpl.serverCanAcceptTasks = false; + ThreadingImpl.PHASER.arriveAndDeregister(); + ThreadingImpl.isServerRunning = false; + } + } + } + + @Inject(method = "runServer", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/server/MinecraftServer;runTasksTillTickEnd()V")) + private void preRunTasks(CallbackInfo ci) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_SERVER_TASKS); + } + + @Inject(method = "runServer", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/server/MinecraftServer;runTasksTillTickEnd()V", + shift = At.Shift.AFTER)) + private void postRunTasks(CallbackInfo ci) + { + if(WurstE2ETestClient.IS_AUTO_TEST) + { + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_CLIENT_TASKS); + // client tasks happen here + + ThreadingImpl.serverCanAcceptTasks = true; + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TEST); + + if(ThreadingImpl.testThread != null) + while(true) + { + try + { + ThreadingImpl.SERVER_SEMAPHORE.acquire(); + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + + if(ThreadingImpl.taskToRun == null) + break; + ThreadingImpl.taskToRun.run(); + } + + ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TICK); + } + } +} diff --git a/src/main/java/net/wurstclient/test/ModifyCmdTest.java b/src/main/java/net/wurstclient/test/ModifyCmdTest.java index 8b155aecdf..c36aa4e652 100644 --- a/src/main/java/net/wurstclient/test/ModifyCmdTest.java +++ b/src/main/java/net/wurstclient/test/ModifyCmdTest.java @@ -41,7 +41,7 @@ public static void testModifyCmd() throw new RuntimeException("NBT data is incorrect"); }); runWurstCommand("viewnbt"); - takeScreenshot("modify_command_result"); + takeScreenshot("modify_command_result", 3); // Clean up runChatCommand("clear"); diff --git a/src/main/java/net/wurstclient/test/WurstClientTestHelper.java b/src/main/java/net/wurstclient/test/WurstClientTestHelper.java index b949028a50..718846ae10 100644 --- a/src/main/java/net/wurstclient/test/WurstClientTestHelper.java +++ b/src/main/java/net/wurstclient/test/WurstClientTestHelper.java @@ -8,17 +8,18 @@ package net.wurstclient.test; import java.io.File; -import java.time.Duration; -import java.time.LocalDateTime; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; -import java.util.function.Function; import java.util.function.Predicate; +import org.apache.commons.lang3.function.FailableConsumer; +import org.apache.commons.lang3.function.FailableFunction; +import org.apache.commons.lang3.mutable.MutableObject; + import com.mojang.brigadier.ParseResults; import com.mojang.brigadier.exceptions.CommandSyntaxException; import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.SharedConstants; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.Drawable; import net.minecraft.client.gui.screen.GameMenuScreen; @@ -38,6 +39,7 @@ import net.minecraft.item.Item; import net.minecraft.item.ItemStack; import net.wurstclient.WurstClient; +import net.wurstclient.test.fabric.ThreadingImpl; public enum WurstClientTestHelper { @@ -49,35 +51,34 @@ public enum WurstClientTestHelper * Runs the given consumer on Minecraft's main thread and waits for it to * complete. */ - public static void submitAndWait(Consumer consumer) + public static void submitAndWait( + FailableConsumer action) throws E { - MinecraftClient mc = MinecraftClient.getInstance(); - mc.submit(() -> consumer.accept(mc)).join(); + ThreadingImpl + .runOnClient(() -> action.accept(MinecraftClient.getInstance())); } /** * Runs the given function on Minecraft's main thread, waits for it to * complete, and returns the result. */ - public static T submitAndGet(Function function) + public static T submitAndGet( + FailableFunction action) throws E { - MinecraftClient mc = MinecraftClient.getInstance(); - return mc.submit(() -> function.apply(mc)).join(); + MutableObject result = new MutableObject<>(); + submitAndWait(client -> result.setValue(action.apply(client))); + return result.getValue(); } - /** - * Waits for the given duration. - */ - public static void wait(Duration duration) + public static void runTick() { - try - { - Thread.sleep(duration.toMillis()); - - }catch(InterruptedException e) - { - throw new RuntimeException(e); - } + runTicks(1); + } + + public static void runTicks(int ticks) + { + for(int i = 0; i < ticks; i++) + ThreadingImpl.runTick(); } /** @@ -85,30 +86,21 @@ public static void wait(Duration duration) * reached. */ public static void waitUntil(String event, - Predicate condition, Duration maxDuration) + Predicate condition, int timeoutTicks) { - LocalDateTime startTime = LocalDateTime.now(); - LocalDateTime timeout = startTime.plus(maxDuration); System.out.println("Waiting until " + event); + int ticksPassed = 0; - while(true) - { - if(submitAndGet(condition::test)) - { - double seconds = - Duration.between(startTime, LocalDateTime.now()).toMillis() - / 1000.0; - System.out.println( - "Waiting until " + event + " took " + seconds + "s"); - break; - } - - if(startTime.isAfter(timeout)) - throw new RuntimeException( - "Waiting until " + event + " took too long"); - - wait(Duration.ofMillis(50)); - } + for(; ticksPassed < timeoutTicks + && !submitAndGet(condition::test); ticksPassed++) + runTick(); + + if(ticksPassed >= timeoutTicks && !submitAndGet(condition::test)) + throw new RuntimeException( + "Waiting until " + event + " took too long"); + + System.out.println( + "Waiting until " + event + " took " + ticksPassed + " ticks"); } /** @@ -117,7 +109,7 @@ public static void waitUntil(String event, public static void waitUntil(String event, Predicate condition) { - waitUntil(event, condition, Duration.ofSeconds(10)); + waitUntil(event, condition, 10 * SharedConstants.TICKS_PER_SECOND); } /** @@ -149,8 +141,23 @@ public static void waitForTitleScreenFade() */ public static void waitForResourceLoading() { + // When the client is not ticking and can't accept tasks, waitUntil + // doesn't work, so we'll do this until then + while(!ThreadingImpl.clientCanAcceptTasks) + { + runTick(); + + try + { + Thread.sleep(50); + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + } + waitUntil("loading is complete", mc -> mc.getOverlay() == null, - Duration.ofMinutes(5)); + 5 * SharedConstants.TICKS_PER_MINUTE); } public static void waitForWorldLoad() @@ -158,15 +165,7 @@ public static void waitForWorldLoad() waitUntil("world is loaded", mc -> mc.world != null && !(mc.currentScreen instanceof LevelLoadingScreen), - Duration.ofMinutes(30)); - } - - public static void waitForWorldTicks(int ticks) - { - long startTicks = submitAndGet(mc -> mc.world.getTime()); - waitUntil(ticks + " world ticks have passed", - mc -> mc.world.getTime() >= startTicks + ticks, - Duration.ofMillis(ticks * 100).plusMinutes(5)); + 30 * SharedConstants.TICKS_PER_MINUTE); } /** @@ -174,16 +173,16 @@ public static void waitForWorldTicks(int ticks) */ public static void takeScreenshot(String name) { - takeScreenshot(name, Duration.ofMillis(50)); + takeScreenshot(name, 1); } /** * Waits for the given delay and then takes a screenshot with the given * name. */ - public static void takeScreenshot(String name, Duration delay) + public static void takeScreenshot(String name, int delayTicks) { - wait(delay); + runTicks(delayTicks); String count = String.format("%02d", screenshotCounter.incrementAndGet()); @@ -372,7 +371,7 @@ public static void runChatCommand(String command) // Command is valid, send it netHandler.sendChatCommand(command); }); - waitForWorldTicks(1); + runTick(); } /** @@ -399,7 +398,7 @@ public static void runWurstCommand(String command) public static void rightClickInGame() { submitAndWait(MinecraftClient::doItemUse); - waitForWorldTicks(1); + runTick(); } public static void assertOneItemInSlot(int slot, Item item) diff --git a/src/main/java/net/wurstclient/test/WurstE2ETestClient.java b/src/main/java/net/wurstclient/test/WurstE2ETestClient.java index 480a13e740..5c810ce006 100644 --- a/src/main/java/net/wurstclient/test/WurstE2ETestClient.java +++ b/src/main/java/net/wurstclient/test/WurstE2ETestClient.java @@ -9,8 +9,6 @@ import static net.wurstclient.test.WurstClientTestHelper.*; -import java.time.Duration; - import org.spongepowered.asm.mixin.MixinEnvironment; import net.fabricmc.api.ModInitializer; @@ -19,20 +17,20 @@ import net.minecraft.client.gui.screen.TitleScreen; import net.minecraft.client.gui.screen.world.CreateWorldScreen; import net.minecraft.client.gui.screen.world.SelectWorldScreen; +import net.wurstclient.test.fabric.ThreadingImpl; public final class WurstE2ETestClient implements ModInitializer { + public static final boolean IS_AUTO_TEST = + System.getProperty("wurst.e2eTest") != null; + @Override public void onInitialize() { - if(System.getProperty("wurst.e2eTest") == null) + if(!IS_AUTO_TEST) return; - Thread.ofVirtual().name("Wurst End-to-End Test") - .uncaughtExceptionHandler((t, e) -> { - e.printStackTrace(); - System.exit(1); - }).start(this::runTests); + ThreadingImpl.runTestThread(this::runTests); } private void runTests() @@ -51,7 +49,7 @@ private void runTests() waitForScreen(TitleScreen.class); waitForTitleScreenFade(); System.out.println("Reached title screen"); - takeScreenshot("title_screen", Duration.ZERO); + takeScreenshot("title_screen", 0); submitAndWait(AltManagerTest::testAltManagerButton); // TODO: Test more of AltManager @@ -64,7 +62,8 @@ private void runTests() System.out.println("World list is not empty. Waiting for it"); waitForScreen(SelectWorldScreen.class); System.out.println("Reached select world screen"); - takeScreenshot("select_world_screen"); + // TODO: Wait until the world list is actually loaded + takeScreenshot("select_world_screen", 20); clickButton("selectWorld.create"); } @@ -84,10 +83,10 @@ private void runTests() waitForWorldLoad(); dismissTutorialToasts(); - waitForWorldTicks(200); + runTicks(200); runChatCommand("seed"); System.out.println("Reached singleplayer world"); - takeScreenshot("in_game", Duration.ZERO); + takeScreenshot("in_game", 0); clearChat(); System.out.println("Opening debug menu"); diff --git a/src/main/java/net/wurstclient/test/fabric/ThreadingImpl.java b/src/main/java/net/wurstclient/test/fabric/ThreadingImpl.java new file mode 100644 index 0000000000..eb34401308 --- /dev/null +++ b/src/main/java/net/wurstclient/test/fabric/ThreadingImpl.java @@ -0,0 +1,258 @@ +/** + * Copied from + * https://github.com/FabricMC/fabric/blob/453d4f91c7a4e0e7f34116755c8acb2c20c53aea/fabric-api-base/src/testmodClient/java/net/fabricmc/fabric/test/base/client/ThreadingImpl.java + * which at the time of writing is not yet part of the API. + */ +/* + * 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.wurstclient.test.fabric; + +import java.util.concurrent.Phaser; +import java.util.concurrent.Semaphore; + +import org.apache.commons.lang3.function.FailableRunnable; +import org.apache.commons.lang3.mutable.MutableObject; +import org.jetbrains.annotations.Nullable; + +import com.google.common.base.Preconditions; + +/** + *

Implementation notes

+ * + *

+ * When a client test is running, ticks are run in a much more controlled way + * than in vanilla. A tick is split into 4 + * phases: + *

    + *
  1. {@linkplain #PHASE_TICK} - The client and server threads run a single + * tick in parallel, if they exist. The test thread waits.
  2. + *
  3. {@linkplain #PHASE_SERVER_TASKS} - The server runs its task queue, if the + * server exists. The other threads wait.
  4. + *
  5. {@linkplain #PHASE_CLIENT_TASKS} - The client runs its task queue, if the + * client exists. The other threads wait.
  6. + *
  7. {@linkplain #PHASE_TEST} - The test thread runs test code while the + * client and server threads wait for tasks to be handed off.
  8. + *
+ * + *

+ * In {@code PHASE_TEST}, the client and server threads (if they exist) are + * blocked on semaphores waiting for tasks + * to be handed to them from the test thread. When the test thread wants to send + * one of the other threads a task to run, + * it sets {@linkplain #taskToRun} to the task runnable and releases the + * semaphore of the thread that should run the + * task. It then blocks on its own semaphore until the task is complete, at + * which point the thread which completed the + * task will release the test thread semaphore and re-block on its own semaphore + * again and the cycle continues. When the + * test phase is over (i.e. when the test thread wants to wait a tick), the + * client and server semaphores will be + * released while leaving {@linkplain #taskToRun} as {@code null}, which they + * will interpret to mean they are to + * continue into {@linkplain #PHASE_TICK}. + * + *

+ * The reason these phases were chosen are to make client-server interaction in + * singleplayer as consistent as + * possible. The task queues are when most packets are handled, and without them + * being run in sequence it would be + * unspecified whether a packet would be handled on the current tick until the + * next one. The server task queue is before + * the client so that changes on the server appear on the client more readily. + * The test phase is run after the task + * queues rather than at the end of the physical tick (i.e. + * {@code MinecraftClient}'s and {@code MinecraftServer}'s + * {@code tick} methods), for no particular reason other than to avoid needing a + * 5th phase, and having a power of 2 + * number of phases is convenient when using {@linkplain Phaser}, as it doesn't + * break when the phase counter overflows. + * + *

+ * Other challenges include that a client or server can be started during + * {@linkplain #PHASE_TEST} but haven't + * reached their semaphore code yet meaning they are unable to accept tasks. + * This is solved by setting a flag to true + * when the client/server is ready to accept tasks. Also the client will block + * on the integrated server starting and + * stopping. This is solved by first deferring those operations until + * {@linkplain #PHASE_TICK} if they are being run + * inside a test phase task (which is a minor difference from vanilla), and then + * ensuring the client is still running + * the phase logic and is able to accept tasks while it is waiting for the + * server. + */ +public final class ThreadingImpl +{ + private ThreadingImpl() + {} + + public static final int PHASE_TICK = 0; + public static final int PHASE_SERVER_TASKS = 1; + public static final int PHASE_CLIENT_TASKS = 2; + public static final int PHASE_TEST = 3; + private static final int PHASE_MASK = 3; + + public static final Phaser PHASER = new Phaser(); + + public static volatile boolean isClientRunning = false; + public static volatile boolean clientCanAcceptTasks = false; + public static final Semaphore CLIENT_SEMAPHORE = new Semaphore(0); + + public static volatile boolean isServerRunning = false; + public static volatile boolean serverCanAcceptTasks = false; + public static final Semaphore SERVER_SEMAPHORE = new Semaphore(0); + + @Nullable + public static Thread testThread = null; + public static final Semaphore TEST_SEMAPHORE = new Semaphore(0); + + @Nullable + public static Runnable taskToRun = null; + + public static void enterPhase(int phase) + { + while((PHASER.getPhase() & PHASE_MASK) != phase) + PHASER.arriveAndAwaitAdvance(); + + PHASER.arriveAndAwaitAdvance(); + } + + public static void runTestThread(Runnable test) + { + Preconditions.checkState(testThread == null, + "There is already a test thread running"); + + testThread = new Thread(() -> { + PHASER.register(); + enterPhase(PHASE_TEST); + + try + { + test.run(); + }catch(Throwable e) + { + e.printStackTrace(); + System.exit(1); + }finally + { + PHASER.arriveAndDeregister(); + + if(clientCanAcceptTasks) + CLIENT_SEMAPHORE.release(); + + if(serverCanAcceptTasks) + SERVER_SEMAPHORE.release(); + + testThread = null; + } + }); + testThread.setName("Test thread"); + testThread.start(); + } + + @SuppressWarnings("unchecked") + public static void runOnClient( + FailableRunnable action) throws E + { + Preconditions.checkNotNull(action, "action"); + Preconditions.checkState(Thread.currentThread() == testThread, + "runOnClient can only be called from the test thread"); + Preconditions.checkState(clientCanAcceptTasks, + "runOnClient called when no client is running"); + + MutableObject thrown = new MutableObject<>(); + taskToRun = () -> { + try + { + action.run(); + }catch(Throwable e) + { + thrown.setValue((E)e); + }finally + { + taskToRun = null; + TEST_SEMAPHORE.release(); + } + }; + + CLIENT_SEMAPHORE.release(); + + try + { + TEST_SEMAPHORE.acquire(); + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + + if(thrown.getValue() != null) + throw thrown.getValue(); + } + + @SuppressWarnings("unchecked") + public static void runOnServer( + FailableRunnable action) throws E + { + Preconditions.checkNotNull(action, "action"); + Preconditions.checkState(Thread.currentThread() == testThread, + "runOnServer can only be called from the test thread"); + Preconditions.checkState(serverCanAcceptTasks, + "runOnServer called when no server is running"); + + MutableObject thrown = new MutableObject<>(); + taskToRun = () -> { + try + { + action.run(); + }catch(Throwable e) + { + thrown.setValue((E)e); + }finally + { + taskToRun = null; + TEST_SEMAPHORE.release(); + } + }; + + SERVER_SEMAPHORE.release(); + + try + { + TEST_SEMAPHORE.acquire(); + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + + if(thrown.getValue() != null) + throw thrown.getValue(); + } + + public static void runTick() + { + Preconditions.checkState(Thread.currentThread() == testThread, + "runTick can only be called from the test thread"); + + if(clientCanAcceptTasks) + CLIENT_SEMAPHORE.release(); + + if(serverCanAcceptTasks) + SERVER_SEMAPHORE.release(); + + enterPhase(PHASE_TEST); + } +} diff --git a/src/main/resources/wurst.mixins.json b/src/main/resources/wurst.mixins.json index b4aa6e713d..01fe55c6dd 100644 --- a/src/main/resources/wurst.mixins.json +++ b/src/main/resources/wurst.mixins.json @@ -36,6 +36,8 @@ "EntityMixin", "EntityRenderDispatcherMixin", "EntityRendererMixin", + "FabricTestMinecraftClientMixin", + "FabricTestMinecraftServerMixin", "FluidRendererMixin", "GameMenuScreenMixin", "GameRendererMixin",