diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..09e0c8a
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 35eb1dd..119e010 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,5 +2,6 @@
+
\ No newline at end of file
diff --git a/Phosphophyllite b/Phosphophyllite
index d1b37c8..24c67ee 160000
--- a/Phosphophyllite
+++ b/Phosphophyllite
@@ -1 +1 @@
-Subproject commit d1b37c8d53a79a897de0c219bc12411ea57d809a
+Subproject commit 24c67ee51f099ef1fb182079854577e8ead6d027
diff --git a/build.gradle b/build.gradle
index 0b96d99..ff94241 100644
--- a/build.gradle
+++ b/build.gradle
@@ -54,6 +54,7 @@ runs {
systemProperty 'forge.logging.console.level', 'debug'
modSource project.sourceSets.main
+ modSource project.sourceSets.test
modSource project(':Phosphophyllite').sourceSets.main
dependencies {
@@ -94,6 +95,7 @@ dependencies {
implementation "net.neoforged:neoforge:${neo_version}"
compileOnly project(':Phosphophyllite')
+ testCompileOnly project(':Phosphophyllite')
compileOnly("org.lwjgl:lwjgl-vulkan:3.3.1") {
transitive(false)
}
diff --git a/runTests.py b/runTests.py
new file mode 100644
index 0000000..ffe60cc
--- /dev/null
+++ b/runTests.py
@@ -0,0 +1,44 @@
+import os
+import shutil
+import subprocess
+
+runDirectory = "run/client/"
+configDirectory = runDirectory + "config/phosphophyllite/"
+
+runMode = os.environ.get("QUARTZ_TEST_RUN_MODE")
+if runMode is None:
+ runMode = "Automatic"
+ pass
+
+mainConfigName = "quartz-client.json5"
+mainConfig = f"""
+{{
+ debug: false,
+ mode: "{runMode}",
+}}
+"""
+
+testConfigName = "quartz-testing-client.json5"
+testConfig = """
+{
+ Enabled: true,
+ AutoRun: true,
+}
+"""
+
+if __name__ == '__main__':
+ print(mainConfig)
+ print(testConfig)
+ shutil.rmtree(runDirectory)
+ os.makedirs(configDirectory)
+
+ mainConfigFile = open(configDirectory + mainConfigName, "w")
+ mainConfigFile.write(mainConfig)
+ mainConfigFile.close()
+
+ testConfigFile = open(configDirectory + testConfigName, "w")
+ testConfigFile.write(testConfig)
+ testConfigFile.close()
+
+ subprocess.call(["./gradlew", ":runClient"])
+ pass
diff --git a/settings.gradle b/settings.gradle
index 6a5620f..48a23ce 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -15,4 +15,6 @@ plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.5.0'
}
-include 'Phosphophyllite'
\ No newline at end of file
+include 'Phosphophyllite'
+
+startParameter.excludedTaskNames << ':Phosphophyllite:runClient'
\ No newline at end of file
diff --git a/src/main/java/net/roguelogix/quartz/QuartzConfig.java b/src/main/java/net/roguelogix/quartz/QuartzConfig.java
index 9d3deaa..bfb26a4 100644
--- a/src/main/java/net/roguelogix/quartz/QuartzConfig.java
+++ b/src/main/java/net/roguelogix/quartz/QuartzConfig.java
@@ -42,7 +42,7 @@ private static boolean isValidPhosLoaading() {
}
private static boolean setup() {
- if (!QuartzDebug.doesForgeExist()) {
+ if (!QuartzDebug.Util.doesForgeExist()) {
// loading without forge present
return false;
}
diff --git a/src/main/java/net/roguelogix/quartz/QuartzEvent.java b/src/main/java/net/roguelogix/quartz/QuartzEvent.java
index fcfd31d..833ec1f 100644
--- a/src/main/java/net/roguelogix/quartz/QuartzEvent.java
+++ b/src/main/java/net/roguelogix/quartz/QuartzEvent.java
@@ -19,7 +19,8 @@ public static class ResourcesReloaded extends ResourcesLoaded {
}
public static class FrameStart extends QuartzEvent {
- public FrameStart() {
- }
+ }
+
+ public static class FrameEnd extends QuartzEvent {
}
}
diff --git a/src/main/java/net/roguelogix/quartz/internal/QuartzCore.java b/src/main/java/net/roguelogix/quartz/internal/QuartzCore.java
index 2648a3a..c0b1c73 100644
--- a/src/main/java/net/roguelogix/quartz/internal/QuartzCore.java
+++ b/src/main/java/net/roguelogix/quartz/internal/QuartzCore.java
@@ -29,22 +29,51 @@
import net.roguelogix.quartz.internal.world.WorldEngine;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.Nullable;
import org.joml.Matrix4f;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
import java.lang.ref.Cleaner;
import java.util.List;
-import static net.roguelogix.quartz.internal.QuartzDebug.doesForgeExist;
-
@ClientOnly
@NonnullDefault
public abstract class QuartzCore {
public static final Logger LOGGER = LogManager.getLogger("Quartz");
- @Nonnull
+ // this should help the JIT with removing the code used for testing, and the branch testing that goes along with it
+ public static final boolean TESTING_ALLOWED;
+
+ static {
+ boolean testingAllowed = false;
+ try {
+ final var testingClass = QuartzCore.class.getClassLoader().loadClass("net.roguelogix.quartz.testing.QuartzTestingConfig");
+ final var instanceField = testingClass.getField("INSTANCE");
+ final var enabledField = testingClass.getField("Enabled");
+ final var isEnabled = enabledField.get(instanceField.get(null));
+ testingAllowed = (Boolean)isEnabled;
+ } catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException e) {
+ e.printStackTrace();
+ }
+ TESTING_ALLOWED = testingAllowed;
+ }
+
+ private static boolean TESTING_RUNNING = false;
+
+ private static void onTestingStatusEvent(QuartzInternalEvent.TestingStatus testingStatus) {
+ if(!TESTING_ALLOWED){
+ return;
+ }
+ TESTING_RUNNING = testingStatus.running;
+ }
+
+ public static boolean isTestingRunning() {
+ if(!TESTING_ALLOWED){
+ return false;
+ }
+ return TESTING_RUNNING;
+ }
+
public static final QuartzCore INSTANCE;
public static final Cleaner CLEANER = Cleaner.create();
public static final WorkQueue deletionQueue = new WorkQueue();
@@ -54,8 +83,9 @@ public static void mainThreadClean(Object referent, Runnable cleanFunc) {
}
static {
+ @Nullable
QuartzCore instance = null;
- if (!DatagenModLoader.isRunningDataGen() && doesForgeExist()) {
+ if (!DatagenModLoader.isRunningDataGen() && QuartzDebug.Util.doesForgeExist()) {
if (!Thread.currentThread().getStackTrace()[2].getClassName().equals(EventListener.class.getName())) {
throw new IllegalStateException("Attempt to init quartz before it is ready");
}
@@ -142,6 +172,7 @@ public static void onModelRegisterEvent(ModelEvent.RegisterAdditional event) {
@OnModLoad
private static void onModLoad() {
+ Quartz.EVENT_BUS.addListener(QuartzCore::onTestingStatusEvent);
ModLoadingContext.get().getActiveContainer().getEventBus().addListener(QuartzCore::onModelRegisterEvent);
}
@@ -216,6 +247,8 @@ public DrawBatch getEntityBatcher() {
public abstract void waitIdle();
+ public abstract void fullSyncWait();
+
public abstract int frameInFlight();
public abstract void sectionDirty(int x, int y, int z);
diff --git a/src/main/java/net/roguelogix/quartz/internal/QuartzDebug.java b/src/main/java/net/roguelogix/quartz/internal/QuartzDebug.java
index d380061..07ad266 100644
--- a/src/main/java/net/roguelogix/quartz/internal/QuartzDebug.java
+++ b/src/main/java/net/roguelogix/quartz/internal/QuartzDebug.java
@@ -8,26 +8,28 @@ public class QuartzDebug {
public static final boolean DEBUG;
static {
- if (!doesForgeExist() || runningDatagen()) {
+ if (!Util.doesForgeExist() || Util.runningDatagen()) {
DEBUG = false;
} else {
- DEBUG = QuartzConfig.INSTANCE.debug;
+ DEBUG = QuartzCore.TESTING_ALLOWED || QuartzConfig.INSTANCE.debug;
}
}
- public static boolean runningDatagen(){
- try{
- return DatagenModLoader.isRunningDataGen();
- } catch (Throwable e){
- return false;
+ public static class Util {
+ public static boolean runningDatagen() {
+ try {
+ return DatagenModLoader.isRunningDataGen();
+ } catch (Throwable e) {
+ return false;
+ }
}
- }
-
- public static boolean doesForgeExist(){
- try{
- return FMLLoader.getLoadingModList() != null;
- } catch (Throwable e){
- return false;
+
+ public static boolean doesForgeExist() {
+ try {
+ return FMLLoader.getLoadingModList() != null;
+ } catch (Throwable e) {
+ return false;
+ }
}
}
}
diff --git a/src/main/java/net/roguelogix/quartz/internal/QuartzInternalEvent.java b/src/main/java/net/roguelogix/quartz/internal/QuartzInternalEvent.java
new file mode 100644
index 0000000..46c6dd5
--- /dev/null
+++ b/src/main/java/net/roguelogix/quartz/internal/QuartzInternalEvent.java
@@ -0,0 +1,36 @@
+package net.roguelogix.quartz.internal;
+
+import net.minecraft.client.renderer.RenderType;
+import net.neoforged.bus.api.Event;
+import net.roguelogix.phosphophyllite.util.NonnullDefault;
+import net.roguelogix.phosphophyllite.util.Pair;
+import net.roguelogix.quartz.internal.util.PointerWrapper;
+
+import java.util.List;
+import java.util.Map;
+
+@NonnullDefault
+public class QuartzInternalEvent extends Event {
+ public static class CreateTests extends QuartzInternalEvent {
+ }
+
+ public static class TestingStatus extends QuartzInternalEvent {
+ public final boolean running;
+
+ public TestingStatus(boolean running) {
+ this.running = running;
+ }
+ }
+
+ public static class FeedbackCollected extends QuartzInternalEvent {
+
+ public final List renderTypes;
+ // note: readOnly pointers
+ public final Map> feedbackBuffers;
+
+ public FeedbackCollected(List renderTypes, Map> feedbackBuffers) {
+ this.renderTypes = renderTypes;
+ this.feedbackBuffers = feedbackBuffers;
+ }
+ }
+}
diff --git a/src/main/java/net/roguelogix/quartz/internal/gl33/GL33Core.java b/src/main/java/net/roguelogix/quartz/internal/gl33/GL33Core.java
index b22cee8..a7a59cd 100644
--- a/src/main/java/net/roguelogix/quartz/internal/gl33/GL33Core.java
+++ b/src/main/java/net/roguelogix/quartz/internal/gl33/GL33Core.java
@@ -211,6 +211,11 @@ public void waitIdle() {
// no need to wait for GPU idle
}
+ @Override
+ public void fullSyncWait() {
+ // no syncing needed
+ }
+
@Override
public int frameInFlight() {
return 0;
diff --git a/src/main/java/net/roguelogix/quartz/internal/gl46/GL46Core.java b/src/main/java/net/roguelogix/quartz/internal/gl46/GL46Core.java
index 7218606..7fb2673 100644
--- a/src/main/java/net/roguelogix/quartz/internal/gl46/GL46Core.java
+++ b/src/main/java/net/roguelogix/quartz/internal/gl46/GL46Core.java
@@ -11,6 +11,8 @@
import net.minecraft.client.renderer.RenderType;
import net.roguelogix.phosphophyllite.util.NonnullDefault;
import net.roguelogix.quartz.DrawBatch;
+import net.roguelogix.quartz.Quartz;
+import net.roguelogix.quartz.QuartzEvent;
import net.roguelogix.quartz.internal.Buffer;
import net.roguelogix.quartz.internal.IrisDetection;
import net.roguelogix.quartz.internal.QuartzCore;
@@ -20,7 +22,7 @@
import java.util.List;
-import static org.lwjgl.opengl.GL46C.glFinish;
+import static org.lwjgl.opengl.GL45C.*;
@NonnullDefault
public class GL46Core extends QuartzCore {
@@ -112,6 +114,8 @@ public void frameStart(PoseStack pMatrixStack, float pPartialTicks, long pFinish
drawInfo.deltaNano = deltaNano;
drawInfo.partialTicks = pPartialTicks;
+ Quartz.EVENT_BUS.post(new QuartzEvent.FrameStart());
+
GL46FeedbackDrawing.beginFrame();
}
@@ -184,7 +188,7 @@ public void endOpaque() {
@Override
public void endTranslucent() {
-
+ Quartz.EVENT_BUS.post(new QuartzEvent.FrameEnd());
}
@Override
@@ -192,6 +196,12 @@ public void waitIdle() {
glFinish();
}
+ @Override
+ public void fullSyncWait() {
+ glMemoryBarrier(GL_ALL_BARRIER_BITS);
+ glFinish();
+ }
+
@Override
public int frameInFlight() {
return frameInFlight;
diff --git a/src/main/java/net/roguelogix/quartz/internal/gl46/GL46FeedbackDrawing.java b/src/main/java/net/roguelogix/quartz/internal/gl46/GL46FeedbackDrawing.java
index 24e9cad..74df861 100644
--- a/src/main/java/net/roguelogix/quartz/internal/gl46/GL46FeedbackDrawing.java
+++ b/src/main/java/net/roguelogix/quartz/internal/gl46/GL46FeedbackDrawing.java
@@ -3,10 +3,13 @@
import com.mojang.blaze3d.systems.RenderSystem;
import it.unimi.dsi.fastutil.objects.*;
import net.minecraft.client.renderer.RenderType;
+import net.roguelogix.phosphophyllite.util.Pair;
import net.roguelogix.quartz.DrawBatch;
+import net.roguelogix.quartz.Quartz;
import net.roguelogix.quartz.internal.*;
import net.roguelogix.quartz.internal.common.B3DStateHelper;
import net.roguelogix.quartz.internal.gl46.batching.GL46DrawBatch;
+import net.roguelogix.quartz.internal.util.PointerWrapper;
import net.roguelogix.quartz.internal.util.VertexFormatOutput;
import org.joml.Matrix4f;
@@ -46,8 +49,8 @@ public static void dirtyAll() {
private record FeedbackBuffer(int buffer, int size) {
private FeedbackBuffer(int size) {
this(glCreateBuffers(), roundUpPo2(size));
- // no flags, only used on the server side
- glNamedBufferStorage(buffer, this.size, 0);
+ // no flags, only used on the server side, unless testing
+ glNamedBufferStorage(buffer, this.size, QuartzCore.TESTING_ALLOWED ? GL_MAP_READ_BIT | GL_MAP_PERSISTENT_BIT : 0);
}
void delete() {
@@ -63,7 +66,8 @@ private static int roundUpPo2(int minSize) {
}
}
- private static final Object2ObjectMap renderTypeFeedbackBuffers = new Object2ObjectArrayMap<>();
+ private static final Reference2ObjectMap renderTypeFeedbackBuffers = new Reference2ObjectArrayMap<>();
+ private static final Reference2LongMap renderTypeFeedbackBufferMappings = new Reference2LongArrayMap<>();
private static Buffer.CallbackHandle rebuildCallbackHandle;
@@ -274,6 +278,14 @@ public static void collectAllFeedback(boolean shadowsEnabled) {
drawBatch.setFrameSync(frameSync);
}
prevousFrameSyncs[frameInFlight] = frameSync;
+
+ if(QuartzCore.TESTING_ALLOWED && QuartzCore.isTestingRunning()){
+ var buffers = new Object2ObjectOpenHashMap>();
+ for (RenderType renderType : inUseRenderTypes) {
+ buffers.put(renderType, new Pair<>(null, 0));
+ }
+ Quartz.EVENT_BUS.post(new QuartzInternalEvent.FeedbackCollected(inUseRenderTypes, buffers));
+ }
}
private static Matrix4f projection;
diff --git a/src/main/java/net/roguelogix/quartz/internal/vk/VKCore.java b/src/main/java/net/roguelogix/quartz/internal/vk/VKCore.java
index 68164bc..5f6df15 100644
--- a/src/main/java/net/roguelogix/quartz/internal/vk/VKCore.java
+++ b/src/main/java/net/roguelogix/quartz/internal/vk/VKCore.java
@@ -93,6 +93,11 @@ public void waitIdle() {
}
+ @Override
+ public void fullSyncWait() {
+
+ }
+
@Override
public int frameInFlight() {
return 0;
diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg
index de35715..6408f96 100644
--- a/src/main/resources/META-INF/accesstransformer.cfg
+++ b/src/main/resources/META-INF/accesstransformer.cfg
@@ -61,4 +61,9 @@ public net.minecraft.client.renderer.RenderStateShard NO_TEXTURE
public net.minecraft.client.renderer.RenderStateShard NO_TRANSPARENCY
-public net.minecraft.client.renderer.LightTexture lightTexture
\ No newline at end of file
+public net.minecraft.client.renderer.LightTexture lightTexture
+
+public com.mojang.blaze3d.platform.GlDebug printDebugLog(IIIIIJJ)V
+public com.mojang.blaze3d.platform.NativeImage pixels
+public net.minecraft.client.Camera setPosition(DDD)V
+public net.minecraft.client.Camera setRotation(FF)V
\ No newline at end of file
diff --git a/src/test/java/net/roguelogix/quartz/testing/AutomaticTesting.java b/src/test/java/net/roguelogix/quartz/testing/AutomaticTesting.java
new file mode 100644
index 0000000..c313252
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/AutomaticTesting.java
@@ -0,0 +1,110 @@
+package net.roguelogix.quartz.testing;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.mojang.serialization.Dynamic;
+import com.mojang.serialization.JsonOps;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.screens.GenericDirtMessageScreen;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.RegistryOps;
+import net.minecraft.world.Difficulty;
+import net.minecraft.world.level.GameRules;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.LevelSettings;
+import net.minecraft.world.level.WorldDataConfiguration;
+import net.minecraft.world.level.levelgen.FlatLevelSource;
+import net.minecraft.world.level.levelgen.WorldDimensions;
+import net.minecraft.world.level.levelgen.WorldOptions;
+import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
+import net.minecraft.world.level.levelgen.presets.WorldPresets;
+import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent;
+import net.neoforged.neoforge.client.event.ScreenEvent;
+import net.neoforged.neoforge.common.NeoForge;
+import net.roguelogix.phosphophyllite.registry.OnModLoad;
+import net.roguelogix.phosphophyllite.threading.Queues;
+import net.roguelogix.quartz.Quartz;
+import net.roguelogix.quartz.internal.QuartzCore;
+import net.roguelogix.quartz.internal.QuartzInternalEvent;
+
+import java.util.Optional;
+import java.util.function.Function;
+
+public class AutomaticTesting {
+ @OnModLoad
+ private static void onModLoad() {
+ if (!QuartzTestingConfig.INSTANCE.AutoRun) {
+ return;
+ }
+ NeoForge.EVENT_BUS.addListener(AutomaticTesting::onStartupEvent);
+ NeoForge.EVENT_BUS.addListener(AutomaticTesting::onLogin);
+ Quartz.EVENT_BUS.addListener(AutomaticTesting::onTestingStatus);
+ }
+
+ private static boolean recursing = false;
+
+ private static void onStartupEvent(ScreenEvent.Opening event) {
+ if (recursing) {
+ return;
+ }
+ recursing = true;
+ final var gameRules = new GameRules();
+ gameRules.getRule(GameRules.RULE_DAYLIGHT).set(false, null);
+ gameRules.getRule(GameRules.RULE_WEATHER_CYCLE).set(false, null);
+ gameRules.getRule(GameRules.RULE_DO_PATROL_SPAWNING).set(false, null);
+ gameRules.getRule(GameRules.RULE_DO_TRADER_SPAWNING).set(false, null);
+ gameRules.getRule(GameRules.RULE_DO_WARDEN_SPAWNING).set(false, null);
+ gameRules.getRule(GameRules.RULE_DOBLOCKDROPS).set(false, null);
+ gameRules.getRule(GameRules.RULE_DO_VINES_SPREAD).set(false, null);
+
+ final var levelSettings = new LevelSettings("QuartzAutomaticTesting", GameType.CREATIVE, false, Difficulty.PEACEFUL, true, gameRules, WorldDataConfiguration.DEFAULT);
+
+ Function dimensionFunc = registryAccess -> {
+
+ var generatorSettings = JsonParser.parseString("""
+ {
+ "biome": "minecraft:the_void",
+ "features": true,
+ "lakes": false,
+ "layers": [
+ {
+ "block": "minecraft:air",
+ "height": 1
+ }
+ ],
+ "structure_overrides": []
+ }
+ """);
+ RegistryOps registryops = RegistryOps.create(JsonOps.INSTANCE, registryAccess);
+ Optional optional = FlatLevelGeneratorSettings.CODEC
+ .parse(new Dynamic<>(registryops, generatorSettings))
+ .resultOrPartial(QuartzCore.LOGGER::error);
+
+ var worldDimensions = WorldPresets.createNormalWorldDimensions(registryAccess);
+ return worldDimensions.replaceOverworldGenerator(registryAccess, new FlatLevelSource(optional.get()));
+ };
+
+ Minecraft.getInstance().createWorldOpenFlows().createFreshLevel("QuartzAutomaticTesting", levelSettings, WorldOptions.defaultWithRandomSeed(), dimensionFunc, new GenericDirtMessageScreen(Component.translatable("selectWorld.data_read")));
+ }
+
+ public static void onLogin(ClientPlayerNetworkEvent.LoggingIn event) {
+ Queues.offThread.enqueueUntracked(() -> {
+ System.out.println("Testing starting in 3 seconds");
+ // wait a little bit to start the tests, make sure everything has loaded in fully
+ try {
+ Thread.sleep(3000);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ Queues.clientThread.enqueue(QuartzTestRegistry::runAllTests);
+ });
+ }
+
+ public static void onTestingStatus(QuartzInternalEvent.TestingStatus event) {
+ if (!event.running) {
+ System.out.println("Testing completed, goodbye");
+ System.exit(0);
+ }
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/QuartzTest.java b/src/test/java/net/roguelogix/quartz/testing/QuartzTest.java
new file mode 100644
index 0000000..8d72cdb
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/QuartzTest.java
@@ -0,0 +1,47 @@
+package net.roguelogix.quartz.testing;
+
+import net.roguelogix.phosphophyllite.util.NonnullDefault;
+import net.roguelogix.quartz.internal.QuartzInternalEvent;
+
+import static net.roguelogix.quartz.testing.tests.Util.sendChatMessage;
+
+@NonnullDefault
+public abstract class QuartzTest {
+
+ public final String id;
+
+ protected QuartzTest(String id) {
+ this.id = id.intern();
+ }
+
+ public abstract void setup();
+
+ public abstract void cleanup();
+
+ public abstract boolean running();
+
+ public abstract void frameStart();
+
+ public abstract void frameEnd();
+
+ public abstract void feedbackCollected(QuartzInternalEvent.FeedbackCollected event);
+
+ private boolean passed = true;
+
+ public final boolean passed(){
+ return passed;
+ }
+
+ public final void reset() {
+ passed = true;
+ }
+
+ protected final void message(String message) {
+ sendChatMessage(message);
+ }
+
+ protected final void fail(String message) {
+ sendChatMessage(message);
+ passed = false;
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/QuartzTestRegistry.java b/src/test/java/net/roguelogix/quartz/testing/QuartzTestRegistry.java
new file mode 100644
index 0000000..3173848
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/QuartzTestRegistry.java
@@ -0,0 +1,116 @@
+package net.roguelogix.quartz.testing;
+
+import com.mojang.brigadier.Command;
+import com.mojang.brigadier.arguments.StringArgumentType;
+import it.unimi.dsi.fastutil.objects.Reference2ReferenceMap;
+import it.unimi.dsi.fastutil.objects.Reference2ReferenceOpenHashMap;
+import it.unimi.dsi.fastutil.objects.ReferenceArrayList;
+import net.minecraft.CrashReport;
+import net.minecraft.client.Minecraft;
+import net.minecraft.commands.Commands;
+import net.minecraft.network.chat.Component;
+import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
+import net.neoforged.neoforge.common.NeoForge;
+import net.roguelogix.phosphophyllite.registry.OnModLoad;
+import net.roguelogix.quartz.Quartz;
+import net.roguelogix.quartz.internal.QuartzCore;
+import net.roguelogix.quartz.internal.QuartzInternalEvent;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+import static net.roguelogix.quartz.testing.QuartzTesting.AUTOMATED;
+import static net.roguelogix.quartz.testing.tests.Util.*;
+
+public class QuartzTestRegistry {
+
+ @OnModLoad
+ public static void onModLoad() {
+ NeoForge.EVENT_BUS.addListener(QuartzTestRegistry::registerCommands);
+ Quartz.EVENT_BUS.addListener(QuartzTestRegistry::onTestingStatusEvent);
+ registerTests();
+ }
+
+ public static void registerCommands(RegisterClientCommandsEvent event) {
+ event.getDispatcher().register(Commands.literal("quartz").then(Commands.literal("recreate_tests").executes(ctx -> {
+ registerTests();
+ sendChatMessage("Tests recreated " + testRegistry.size());
+ return Command.SINGLE_SUCCESS;
+ })));
+ event.getDispatcher().register(Commands.literal("quartz").then(Commands.literal("run_all_tests").executes(ctx -> {
+ runAllTests();
+ return Command.SINGLE_SUCCESS;
+ })));
+ event.getDispatcher().register(Commands.literal("quartz").then(Commands.literal("run_test").then(Commands.argument("test_name", StringArgumentType.string()).executes(ctx -> {
+ runSpecificTest(ctx.getArgument("test_name", String.class));
+ return Command.SINGLE_SUCCESS;
+ }))));
+ }
+
+ public static void registerTests() {
+ testRegistry.clear();
+ Quartz.EVENT_BUS.post(new QuartzInternalEvent.CreateTests());
+ QuartzCore.resourcesReloaded();
+ }
+
+ public static void runAllTests() {
+ Minecraft.getInstance().player.sendSystemMessage(Component.literal("Quartz running all tests"));
+ QuartzTesting.runTests(testRegistry.values());
+ }
+
+ public static void runSpecificTest(String testName) {
+ @Nullable final var test = testRegistry.get(testName.intern());
+ if (test == null) {
+ Minecraft.getInstance().player.sendSystemMessage(Component.literal("Quartz test " + testName + " not found"));
+ } else {
+ Minecraft.getInstance().player.sendSystemMessage(Component.literal("Quartz running test " + testName));
+ QuartzTesting.runTests(List.of(test));
+ }
+ }
+
+ private static void onTestingStatusEvent(QuartzInternalEvent.TestingStatus testingStatus) {
+ if (testingStatus.running) {
+ failedTests.clear();
+ savePlayerState();
+ System.out.println("##teamcity[testSuiteStarted name='Quartz']");
+ } else {
+ System.out.println("##teamcity[testSuiteFinished name='Quartz']");
+ restorePlayerState();
+ sendChatMessage(failedTests.size() + " tests failed");
+ for (QuartzTest failedTest : failedTests) {
+ sendChatMessage(failedTest.id);
+ }
+ }
+ }
+
+ private static final Reference2ReferenceMap testRegistry = new Reference2ReferenceOpenHashMap<>();
+ private static final ReferenceArrayList failedTests = new ReferenceArrayList<>();
+
+ public static void registerTest(QuartzTest test, boolean runnable) {
+ testRegistry.put(test.id, test);
+ }
+
+ public static void testStarted(QuartzTest test) {
+ sendChatMessage("Test " + test.id + " started");
+ if (AUTOMATED) {
+ System.out.println("##teamcity[testStarted name='" + test.id + "']");
+ }
+ }
+
+ public static void testCompleted(QuartzTest test) {
+ if (!test.passed()) {
+ failedTests.add(test);
+ if (AUTOMATED) {
+ System.out.println("##teamcity[testFailed name='" + test.id + "' message='']");
+ }
+ }
+ sendChatMessage("Test " + test.id + " " + (test.passed() ? "passed" : "failed"));
+ if (AUTOMATED) {
+ System.out.println("##teamcity[testFinished name='" + test.id + "']");
+ }
+ }
+
+ public static void fatalTestFailure(String message) {
+ Minecraft.getInstance().emergencySaveAndCrash(new CrashReport("Quartz testing caught fatal failure", new IllegalStateException(message)));
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/QuartzTesting.java b/src/test/java/net/roguelogix/quartz/testing/QuartzTesting.java
new file mode 100644
index 0000000..5393c75
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/QuartzTesting.java
@@ -0,0 +1,144 @@
+package net.roguelogix.quartz.testing;
+
+import com.mojang.blaze3d.platform.DebugMemoryUntracker;
+import com.mojang.blaze3d.platform.GLX;
+import com.mojang.blaze3d.platform.GlDebug;
+import it.unimi.dsi.fastutil.objects.ReferenceArrayList;
+import net.minecraft.CrashReport;
+import net.minecraft.client.Minecraft;
+import net.neoforged.fml.ModLoadingContext;
+import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
+import net.neoforged.neoforge.common.NeoForge;
+import net.roguelogix.phosphophyllite.registry.OnModLoad;
+import net.roguelogix.quartz.Quartz;
+import net.roguelogix.quartz.QuartzEvent;
+import net.roguelogix.quartz.internal.QuartzInternalEvent;
+import org.jetbrains.annotations.Nullable;
+import org.lwjgl.opengl.GL;
+import org.lwjgl.opengl.GLDebugMessageCallback;
+import org.lwjgl.opengl.GLDebugMessageCallbackI;
+import org.lwjgl.opengl.KHRDebug;
+
+import java.util.Collection;
+
+import static net.roguelogix.quartz.testing.tests.Util.sendChatMessage;
+import static org.lwjgl.opengl.GL46C.*;
+
+public class QuartzTesting {
+
+ public static boolean TESTING_RUNNING = false;
+ public static final boolean AUTOMATED = QuartzTestingConfig.INSTANCE.AutoRun;
+
+ @OnModLoad
+ private static void onModLoad() {
+ if (!QuartzTestingConfig.INSTANCE.Enabled) {
+ return;
+ }
+ Quartz.EVENT_BUS.addListener(QuartzTesting::onTestingStatusEvent);
+ Quartz.EVENT_BUS.addListener(QuartzTesting::onFrameStart);
+ Quartz.EVENT_BUS.addListener(QuartzTesting::onFrameEnd);
+ Quartz.EVENT_BUS.addListener(QuartzTesting::onFeedbackCollected);
+ ModLoadingContext.get().getActiveContainer().getEventBus().addListener(QuartzTesting::clientSetupEvent);
+ }
+
+ private static void onTestingStatusEvent(QuartzInternalEvent.TestingStatus testingStatus) {
+ TESTING_RUNNING = testingStatus.running;
+ sendChatMessage("Quartz testing " + (TESTING_RUNNING ? "started" : "finished"));
+ }
+
+ private static int nextTestIndex = 0;
+ private static final ReferenceArrayList runningTests = new ReferenceArrayList<>();
+ @Nullable
+ private static QuartzTest activeTest = null;
+
+ @Nullable
+ public static QuartzTest runningTest() {
+ return activeTest;
+ }
+
+ private static void clientSetupEvent(FMLClientSetupEvent setupEvent) {
+ setupEvent.enqueueWork(() -> {
+ var glCaps = GL.getCapabilities();
+
+ if (!glCaps.GL_KHR_debug) {
+ Minecraft.getInstance().emergencySaveAndCrash(new CrashReport("Fatal test failure", new IllegalStateException("GL_KHR_debug required to run Quartz tests")));
+ }
+
+ // inject into the debug callback to fatal fail on an error
+ var myCallback = new GLDebugMessageCallbackI() {
+ @Override
+ public void invoke(int source, int type, int id, int severity, int length, long message, long userParam) {
+ GlDebug.printDebugLog(source, type, id, severity, length, message, userParam);
+ if (source == GL_DEBUG_SOURCE_APPLICATION) {
+ throw new RuntimeException("Application GL error caught");
+ }
+ switch (type) {
+ case GL_DEBUG_TYPE_ERROR:
+ case GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR:
+ case GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR: {
+ QuartzTestRegistry.fatalTestFailure(GLDebugMessageCallback.getMessage(length, message));
+ }
+ case GL_DEBUG_TYPE_PORTABILITY: {
+
+ }
+ }
+ }
+ };
+
+ glEnable(GL_DEBUG_OUTPUT);
+ glEnable(GL_DEBUG_OUTPUT_SYNCHRONOUS);
+ KHRDebug.glDebugMessageCallback(GLX.make(GLDebugMessageCallback.create(myCallback), DebugMemoryUntracker::untrack), 0L);
+ });
+ }
+
+ public static void runTests(Collection testList) {
+ if (TESTING_RUNNING) {
+ throw new IllegalStateException("Testing already running");
+ }
+ runningTests.clear();
+ runningTests.addAll(testList);
+ nextTestIndex = 0;
+ Quartz.EVENT_BUS.post(new QuartzInternalEvent.TestingStatus(true));
+ }
+
+ private static void onFrameStart(QuartzEvent.FrameStart frameStart) {
+ if (!TESTING_RUNNING) {
+ return;
+ }
+ if (activeTest == null) {
+ if (nextTestIndex < runningTests.size()) {
+ activeTest = runningTests.get(nextTestIndex);
+ QuartzTestRegistry.testStarted(activeTest);
+ activeTest.reset();
+ activeTest.setup();
+ nextTestIndex++;
+ } else {
+ Quartz.EVENT_BUS.post(new QuartzInternalEvent.TestingStatus(false));
+ }
+ }
+ if (activeTest == null) {
+ return;
+ }
+ activeTest.frameStart();
+ }
+
+ private static void onFeedbackCollected(QuartzInternalEvent.FeedbackCollected feedbackEvent) {
+ if (activeTest == null) {
+ return;
+ }
+ activeTest.feedbackCollected(feedbackEvent);
+ }
+
+ private static void onFrameEnd(QuartzEvent.FrameEnd frameEnd) {
+ if (activeTest == null) {
+ return;
+ }
+ activeTest.frameEnd();
+ if (activeTest.running()) {
+ return;
+ }
+ activeTest.cleanup();
+ QuartzTestRegistry.testCompleted(activeTest);
+ activeTest = null;
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/QuartzTestingConfig.java b/src/test/java/net/roguelogix/quartz/testing/QuartzTestingConfig.java
new file mode 100644
index 0000000..7de216c
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/QuartzTestingConfig.java
@@ -0,0 +1,37 @@
+package net.roguelogix.quartz.testing;
+
+import net.roguelogix.phosphophyllite.Phosphophyllite;
+import net.roguelogix.phosphophyllite.config.ConfigManager;
+import net.roguelogix.phosphophyllite.config.ConfigType;
+import net.roguelogix.phosphophyllite.config.ConfigValue;
+import net.roguelogix.phosphophyllite.registry.IgnoreRegistration;
+import net.roguelogix.phosphophyllite.registry.RegisterConfig;
+import net.roguelogix.quartz.QuartzConfig;
+
+public class QuartzTestingConfig {
+ @IgnoreRegistration
+ @RegisterConfig(folder = Phosphophyllite.modid, name = "quartz-testing", type = ConfigType.CLIENT)
+ public static final QuartzTestingConfig INSTANCE = new QuartzTestingConfig();
+
+ static {
+ if (QuartzConfig.INIT_COMPLETED) {
+ try {
+ // this needs to be registered extra extra early, so it can be read at quartz init
+ ConfigManager.registerConfig(INSTANCE, "blockstates/quartz", QuartzTestingConfig.class.getField("INSTANCE").getAnnotation(RegisterConfig.class));
+ } catch (NoSuchFieldException e) {
+ e.printStackTrace();
+ throw new IllegalStateException();
+ }
+ }
+ }
+
+ @ConfigValue
+ public final boolean Enabled;
+ @ConfigValue
+ public final boolean AutoRun;
+
+ {
+ Enabled = false;
+ AutoRun = false;
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/block/QuartzTestBlock.java b/src/test/java/net/roguelogix/quartz/testing/block/QuartzTestBlock.java
new file mode 100644
index 0000000..f35c365
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/block/QuartzTestBlock.java
@@ -0,0 +1,49 @@
+package net.roguelogix.quartz.testing.block;
+
+import net.minecraft.MethodsReturnNonnullByDefault;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.BlockItem;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.EntityBlock;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.BlockHitResult;
+import net.roguelogix.phosphophyllite.registry.CreativeTabBlock;
+import net.roguelogix.phosphophyllite.registry.RegisterBlock;
+
+import javax.annotation.Nullable;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+@MethodsReturnNonnullByDefault
+@ParametersAreNonnullByDefault
+public class QuartzTestBlock extends Block implements EntityBlock {
+
+ @CreativeTabBlock
+ @RegisterBlock(name = "quartz_test_runner_block", tileEntityClass = QuartzTestBlockTile.class)
+ public static final QuartzTestBlock INSTANCE = new QuartzTestBlock();
+
+ public QuartzTestBlock() {
+ super(Properties.of().noLootTable().destroyTime(3.0F).explosionResistance(3.0F));
+ }
+
+ @Nullable
+ @Override
+ public BlockEntity newBlockEntity(BlockPos pPos, BlockState pState) {
+ return QuartzTestBlockTile.SUPPLIER.create(pPos, pState);
+ }
+
+ @Override
+ public InteractionResult use(BlockState state, Level worldIn, BlockPos pos, Player player, InteractionHand handIn, BlockHitResult hit) {
+ final var item = player.getMainHandItem().getItem();
+ final var te = worldIn.getBlockEntity(pos);
+ if (te instanceof QuartzTestBlockTile tile && item instanceof BlockItem blockItem) {
+ tile.setBlock(blockItem.getBlock());
+ return InteractionResult.SUCCESS;
+ }
+ return super.use(state, worldIn, pos, player, handIn, hit);
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/block/QuartzTestBlockTile.java b/src/test/java/net/roguelogix/quartz/testing/block/QuartzTestBlockTile.java
new file mode 100644
index 0000000..6b4fa53
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/block/QuartzTestBlockTile.java
@@ -0,0 +1,65 @@
+package net.roguelogix.quartz.testing.block;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.entity.BlockEntityType;
+import net.minecraft.world.level.block.state.BlockState;
+import net.roguelogix.phosphophyllite.modular.tile.PhosphophylliteTile;
+import net.roguelogix.phosphophyllite.registry.RegisterTile;
+import net.roguelogix.phosphophyllite.util.NonnullDefault;
+import net.roguelogix.quartz.DrawBatch;
+import net.roguelogix.quartz.Mesh;
+import net.roguelogix.quartz.Quartz;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3i;
+
+@NonnullDefault
+public class QuartzTestBlockTile extends PhosphophylliteTile {
+
+ @RegisterTile("quartz_test_runner_block")
+ public static final BlockEntityType.BlockEntitySupplier SUPPLIER = new RegisterTile.Producer<>(QuartzTestBlockTile::new);
+
+ public QuartzTestBlockTile(BlockEntityType> TYPE, BlockPos pWorldPosition, BlockState pBlockState) {
+ super(TYPE, pWorldPosition, pBlockState);
+ }
+
+ @Nullable
+ private Mesh mesh;
+ @Nullable
+ private DrawBatch.Instance instance = null;
+
+ @Override
+ public void onAdded() {
+ assert level != null;
+ if (!level.isClientSide()) {
+ level.sendBlockUpdated(getBlockPos(), getBlockState(), getBlockState(), 0);
+ return;
+ }
+ if (mesh != null && level.isClientSide()) {
+ final var modelPos = new Vector3i(getBlockPos().getX(), getBlockPos().getY() + 2, getBlockPos().getZ());
+ final var batcher = Quartz.getDrawBatcherForBlock(modelPos);
+ instance = batcher.createInstance(modelPos, mesh, null, null, null);
+ }
+ }
+
+ @Override
+ public void onRemoved(boolean chunkUnload) {
+ assert level != null;
+ if (mesh != null && level.isClientSide()) {
+ if (instance != null) {
+ instance.delete();
+ }
+ instance = null;
+ }
+ }
+
+ void setBlock(Block block) {
+ assert level != null;
+ if(level.isClientSide()) {
+ onRemoved(false);
+ mesh = Quartz.createStaticMesh(block.defaultBlockState());
+ mesh.rebuild();
+ onAdded();
+ }
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/events/TestingEvent.java b/src/test/java/net/roguelogix/quartz/testing/events/TestingEvent.java
new file mode 100644
index 0000000..baa57d1
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/events/TestingEvent.java
@@ -0,0 +1,15 @@
+package net.roguelogix.quartz.testing.events;
+
+public class TestingEvent {
+ public static class Started extends TestingEvent {
+ }
+
+ public static class Stopped extends TestingEvent {
+ }
+
+ public static class FrameStart extends TestingEvent {
+ }
+
+ public static class FrameEnd extends TestingEvent {
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/package-info.java b/src/test/java/net/roguelogix/quartz/testing/package-info.java
new file mode 100644
index 0000000..f7bcd78
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/package-info.java
@@ -0,0 +1,7 @@
+// this package is dev mode only
+@ClientOnly
+@IgnoreRegistration(ignoreInDev = false)
+package net.roguelogix.quartz.testing;
+
+import net.roguelogix.phosphophyllite.registry.ClientOnly;
+import net.roguelogix.phosphophyllite.registry.IgnoreRegistration;
\ No newline at end of file
diff --git a/src/test/java/net/roguelogix/quartz/testing/tests/Util.java b/src/test/java/net/roguelogix/quartz/testing/tests/Util.java
new file mode 100644
index 0000000..f7ef8c9
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/tests/Util.java
@@ -0,0 +1,197 @@
+package net.roguelogix.quartz.testing.tests;
+
+import com.mojang.blaze3d.platform.NativeImage;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.Screenshot;
+import net.minecraft.commands.arguments.EntityAnchorArgument;
+import net.minecraft.core.BlockPos;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.util.Mth;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.neoforge.client.event.ViewportEvent;
+import net.neoforged.neoforge.common.NeoForge;
+import net.roguelogix.phosphophyllite.registry.OnModLoad;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3f;
+import org.joml.Vector3ic;
+import org.lwjgl.glfw.GLFW;
+
+public final class Util {
+ private static final Minecraft minecraft = Minecraft.getInstance();
+
+ private Util() {
+ }
+
+ @OnModLoad
+ public static void onModLoadFOV(){
+ NeoForge.EVENT_BUS.register(Util.class);
+ }
+
+ @Nullable
+ private static Vec3 savedPosition;
+ private static float savedXRot;
+ private static float savedYRot;
+ private static int savedFOV;
+ private static boolean savedHUDHidden;
+ private static boolean savedFlying;
+ private static boolean savedBob;
+ private static ResourceKey savedLevelID;
+ private static int windowWidth;
+ private static int windowHeight;
+
+ public static void savePlayerState() {
+ assert minecraft.player != null;
+ savedPosition = minecraft.player.position();
+ savedXRot = minecraft.player.getXRot();
+ savedYRot = minecraft.player.getYRot();
+ savedFOV = minecraft.options.fov().get();
+ savedHUDHidden = minecraft.options.hideGui;
+ savedFlying = minecraft.player.getAbilities().flying;
+ savedLevelID = minecraft.player.level().dimension();
+ savedBob = minecraft.options.bobView().get();
+ windowWidth = minecraft.getWindow().getWidth();
+ windowHeight = minecraft.getWindow().getHeight();
+ minecraft.getWindow().updateVsync(false);
+ }
+
+ public static void restorePlayerState() {
+ assert minecraft.player != null;
+ minecraft.options.fov().set(savedFOV);
+ minecraft.options.hideGui = savedHUDHidden;
+ minecraft.player.getAbilities().flying = savedFlying;
+ minecraft.options.bobView().set(savedBob);
+
+ var server = minecraft.getSingleplayerServer();
+ assert server != null;
+ final var serverPlayer = server.getPlayerList().getPlayer(minecraft.player.getUUID());
+ assert serverPlayer != null;
+ var level = server.getLevel(savedLevelID);
+ assert level != null;
+ assert savedPosition != null;
+ serverPlayer.teleportTo(level, savedPosition.x, savedPosition.y, savedPosition.z, savedXRot, savedYRot);
+ minecraft.player.setPos(savedPosition.x, savedPosition.y, savedPosition.z);
+ minecraft.player.setXRot(savedXRot);
+ minecraft.player.setXRot(savedYRot);
+ setWindowSize(windowWidth, windowHeight);
+ minecraft.getWindow().updateVsync(true);
+ }
+
+ public static void hideHUD() {
+ minecraft.options.hideGui = true;
+ }
+
+ public static void disableBob() {
+ minecraft.options.bobView().set(false);
+ }
+
+ public static void setFOV(int fov) {
+ minecraft.options.fov().set(fov);
+ }
+
+ @SubscribeEvent
+ public static void onFOVEvent(ViewportEvent.ComputeFov event){
+ event.setFOV(minecraft.options.fov().get());
+ }
+
+ public static void setFlying() {
+ assert minecraft.player != null;
+ minecraft.player.getAbilities().flying = true;
+ }
+
+ public static void setWindowSize(int w, int h) {
+ final var window = minecraft.getWindow().getWindow();
+ GLFW.glfwRestoreWindow(window);
+ GLFW.glfwSetWindowSize(window, w, h);
+ }
+
+// public static void setEyePosAndLook(Vector3ic pos, Vector3ic look) {
+//
+// }
+
+ public static void setEyePos(Vector3ic pos) {
+ setEyePos(pos.x(), pos.y(), pos.z());
+ }
+
+ public static void setEyePos(int x, int y, int z) {
+// minecraft.gameRenderer.getMainCamera().setPosition(x + 0.5, y + 0.5, z + 0.5);
+ minecraft.player.absMoveTo(x + 0.5, y - minecraft.player.getEyeHeight() + 0.5, z + 0.5);
+ }
+
+ public static void setLookCenter(int x, int y, int z) {
+ final var camera = minecraft.gameRenderer.getMainCamera();
+ final var cameraPosition = camera.getPosition();
+ final var lookPosition = new Vec3(x + 0.5, y + 0.5, z + 0.5);
+
+ double diffX = lookPosition.x - cameraPosition.x;
+ double diffY = lookPosition.y - cameraPosition.y;
+ double diffZ = lookPosition.z - cameraPosition.z;
+ double XZDiffDot = Math.sqrt(diffX * diffX + diffZ * diffZ);
+ final var xRot = Mth.wrapDegrees((float)(-(Mth.atan2(diffY, XZDiffDot) * 180.0F / (float)Math.PI)));
+ final var yRot = Mth.wrapDegrees((float)(Mth.atan2(diffZ, diffX) * 180.0F / (float)Math.PI) - 90.0F);
+// minecraft.gameRenderer.getMainCamera().setRotation(xRot, yRot);
+ minecraft.player.lookAt(EntityAnchorArgument.Anchor.EYES, new Vec3(x + 0.5, y + 0.5, z + 0.5));
+ }
+
+ public static void teleportToInOverworld(int x, int y, int z) {
+ var server = minecraft.getSingleplayerServer();
+ assert server != null;
+ assert minecraft.player != null;
+ final var serverPlayer = server.getPlayerList().getPlayer(minecraft.player.getUUID());
+ assert serverPlayer != null;
+ var level = server.getLevel(Level.OVERWORLD);
+ assert level != null;
+ serverPlayer.teleportTo(level, x + 0.5, z + 0.5, z + 0.5, 0, 0);
+ }
+
+ public static void setBlock(int x, int y, int z, Block block) {
+ setBlock(new BlockPos(x, y, z), block);
+ }
+
+ public static void setBlock(BlockPos pos, Block block) {
+ setBlock(pos, block.defaultBlockState());
+ }
+
+ public static void setBlock(int x, int y, int z, BlockState blockState) {
+ setBlock(new BlockPos(x, y, z), blockState);
+ }
+
+ public static void setBlock(BlockPos pos, BlockState blockState) {
+ var player = minecraft.player;
+ assert player != null;
+ player.level().setBlockAndUpdate(pos, blockState);
+ }
+
+ public static void setVolume(BlockPos starting, BlockPos ending, Block block) {
+ setVolume(starting, ending, block.defaultBlockState());
+ }
+
+ public static void setVolume(BlockPos starting, BlockPos ending, BlockState blockState) {
+ final var mutable = starting.mutable();
+ for (int i = starting.getX(); i <= ending.getX(); i++) {
+ for (int j = starting.getY(); j < ending.getY(); j++) {
+ for (int k = starting.getZ(); k < ending.getZ(); k++) {
+ mutable.set(i, j, k);
+ setBlock(mutable, blockState);
+ }
+ }
+ }
+ }
+
+ public static NativeImage screenshot() {
+ if (false) {
+ Screenshot.grab(minecraft.gameDirectory, minecraft.getMainRenderTarget(), p_90917_ -> minecraft.execute(() -> minecraft.gui.getChat().addMessage(p_90917_)));
+ }
+ return Screenshot.takeScreenshot(minecraft.getMainRenderTarget());
+ }
+
+ public static void sendChatMessage(String message) {
+ assert minecraft.player != null;
+ minecraft.player.sendSystemMessage(Component.literal(message));
+
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/tests/common/BlockMimicryTest.java b/src/test/java/net/roguelogix/quartz/testing/tests/common/BlockMimicryTest.java
new file mode 100644
index 0000000..3c088d4
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/tests/common/BlockMimicryTest.java
@@ -0,0 +1,128 @@
+package net.roguelogix.quartz.testing.tests.common;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.Blocks;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.fml.common.Mod;
+import net.roguelogix.phosphophyllite.registry.OnModLoad;
+import net.roguelogix.phosphophyllite.util.NonnullDefault;
+import net.roguelogix.quartz.DrawBatch;
+import net.roguelogix.quartz.Mesh;
+import net.roguelogix.quartz.Quartz;
+import net.roguelogix.quartz.internal.QuartzInternalEvent;
+import net.roguelogix.quartz.testing.tests.Util;
+import org.joml.Vector3i;
+
+import javax.annotation.Nullable;
+
+import static net.roguelogix.quartz.testing.QuartzTestRegistry.registerTest;
+
+
+@NonnullDefault
+public class BlockMimicryTest extends ScreenshotCompareTest {
+
+ private Block block;
+ private Mesh mesh;
+ @Nullable
+ private DrawBatch.Instance instance;
+
+ public BlockMimicryTest(Block block) {
+ super("block_mimicry{" + BuiltInRegistries.BLOCK.getKey(block) + "}", new Vector3i(1, 1, 0));
+ this.block = block;
+ mesh = Quartz.createStaticMesh(block.defaultBlockState());
+ }
+
+ @Override
+ protected void setupReference() {
+ Util.setBlock(testPositionBlockPos, block);
+ }
+
+ @Override
+ protected void cleanupReference() {
+ Util.setBlock(testPositionBlockPos, Blocks.AIR);
+ }
+
+ @Override
+ protected void setupQuartz() {
+ instance = Quartz.getDrawBatcherForBlock(testPosition).createInstance(testPosition, mesh, null, null, null);
+ if(instance == null){
+ fail("Instance creation failed");
+ }
+ }
+
+ @Override
+ protected void cleanupQuartz() {
+ if (instance != null) {
+ instance.delete();
+ }
+ }
+
+ @OnModLoad
+ public static void onModLoad() {
+ Quartz.EVENT_BUS.addListener(BlockMimicryTest::createTestsEvent);
+ }
+
+ public static void createTestsEvent(QuartzInternalEvent.CreateTests event){
+ registerTest(new BlockMimicryTest(Blocks.STONE), true);
+ registerTest(new BlockMimicryTest(Blocks.DIORITE), true);
+ registerTest(new BlockMimicryTest(Blocks.POLISHED_DIORITE), true);
+ registerTest(new BlockMimicryTest(Blocks.ANDESITE), true);
+ registerTest(new BlockMimicryTest(Blocks.POLISHED_ANDESITE), true);
+// registerTest(new BlockMimicryTest(Blocks.GRASS_BLOCK), true); // biome coloration issues
+ registerTest(new BlockMimicryTest(Blocks.DIRT), true);
+ registerTest(new BlockMimicryTest(Blocks.COARSE_DIRT), true);
+ registerTest(new BlockMimicryTest(Blocks.PODZOL), true);
+ registerTest(new BlockMimicryTest(Blocks.COBBLESTONE), true);
+ registerTest(new BlockMimicryTest(Blocks.OAK_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.SPRUCE_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.BIRCH_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.JUNGLE_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.ACACIA_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.CHERRY_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.DARK_OAK_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.MANGROVE_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.BAMBOO_PLANKS), true);
+ registerTest(new BlockMimicryTest(Blocks.BAMBOO_MOSAIC), true);
+ // ambient occlusion is force enabled by Quartz
+ // saplings have it force disabled because their quads aren't axis aligned
+// registerTest(new BlockMimicryTest(Blocks.OAK_SAPLING), true);
+// registerTest(new BlockMimicryTest(Blocks.SPRUCE_SAPLING), true);
+// registerTest(new BlockMimicryTest(Blocks.BIRCH_SAPLING), true);
+// registerTest(new BlockMimicryTest(Blocks.JUNGLE_SAPLING), true);
+// registerTest(new BlockMimicryTest(Blocks.ACACIA_SAPLING), true);
+// registerTest(new BlockMimicryTest(Blocks.CHERRY_SAPLING), true);
+// registerTest(new BlockMimicryTest(Blocks.DARK_OAK_SAPLING), true);
+// registerTest(new BlockMimicryTest(Blocks.MANGROVE_PROPAGULE), true);
+// registerTest(new BlockMimicryTest(Blocks.BEDROCK), true);
+ // there are some fall blocks here, they are ignored
+ registerTest(new BlockMimicryTest(Blocks.GOLD_ORE), true);
+ registerTest(new BlockMimicryTest(Blocks.DEEPSLATE_GOLD_ORE), true);
+ registerTest(new BlockMimicryTest(Blocks.IRON_ORE), true);
+ registerTest(new BlockMimicryTest(Blocks.DEEPSLATE_IRON_ORE), true);
+ registerTest(new BlockMimicryTest(Blocks.COAL_ORE), true);
+ registerTest(new BlockMimicryTest(Blocks.DEEPSLATE_COAL_ORE), true);
+ registerTest(new BlockMimicryTest(Blocks.NETHER_GOLD_ORE), true);
+ registerTest(new BlockMimicryTest(Blocks.OAK_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.SPRUCE_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.BIRCH_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.JUNGLE_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.ACACIA_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.CHERRY_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.DARK_OAK_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.MANGROVE_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.MANGROVE_ROOTS), true);
+ registerTest(new BlockMimicryTest(Blocks.MUDDY_MANGROVE_ROOTS), true);
+ registerTest(new BlockMimicryTest(Blocks.BAMBOO_BLOCK), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_SPRUCE_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_BIRCH_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_JUNGLE_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_ACACIA_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_CHERRY_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_DARK_OAK_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_OAK_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_MANGROVE_LOG), true);
+ registerTest(new BlockMimicryTest(Blocks.STRIPPED_BAMBOO_BLOCK), true);
+ }
+}
diff --git a/src/test/java/net/roguelogix/quartz/testing/tests/common/ScreenshotCompareTest.java b/src/test/java/net/roguelogix/quartz/testing/tests/common/ScreenshotCompareTest.java
new file mode 100644
index 0000000..e9d7904
--- /dev/null
+++ b/src/test/java/net/roguelogix/quartz/testing/tests/common/ScreenshotCompareTest.java
@@ -0,0 +1,170 @@
+package net.roguelogix.quartz.testing.tests.common;
+
+import com.mojang.blaze3d.platform.NativeImage;
+import it.unimi.dsi.fastutil.objects.ReferenceArrayList;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.Screenshot;
+import net.minecraft.core.BlockPos;
+import net.minecraft.network.chat.Component;
+import net.roguelogix.phosphophyllite.debug.DebugInfo;
+import net.roguelogix.phosphophyllite.util.NonnullDefault;
+import net.roguelogix.quartz.DrawBatch;
+import net.roguelogix.quartz.internal.QuartzCore;
+import net.roguelogix.quartz.internal.QuartzInternalEvent;
+import net.roguelogix.quartz.internal.gl46.GL46Core;
+import net.roguelogix.quartz.testing.QuartzTest;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3i;
+import org.joml.Vector3ic;
+import org.lwjgl.opengl.GL46C;
+import org.lwjgl.system.MemoryUtil;
+
+import java.util.List;
+
+import static net.roguelogix.quartz.testing.tests.Util.*;
+
+@NonnullDefault
+public abstract class ScreenshotCompareTest extends QuartzTest {
+
+
+
+
+ protected final Vector3ic testPosition;
+ protected final BlockPos testPositionBlockPos;
+
+ private int frame;
+
+ protected ScreenshotCompareTest(String id, Vector3ic testPosition) {
+ super(id);
+ this.testPosition = new Vector3i(testPosition);
+ this.testPositionBlockPos = new BlockPos(testPosition.x(), testPosition.y(), testPosition.z());
+
+ for (Vector3ic screenshotPosition : screenshotPositions) {
+ screenshotLoop.add(() -> {setEyePos(screenshotPosition);setLookCenter(testPosition.x(), testPosition.y(), testPosition.z());});
+ screenshotLoop.add(this::takeScreenshot);
+ }
+
+ frameActions.add(WAIT_FRAME);
+ frameActions.add(this::setupQuartz);
+ frameActions.add(() -> currentScreenshotList = quartzScreenshots);
+ frameActions.add(WAIT_FRAME);
+ frameActions.addAll(screenshotLoop);
+ frameActions.add(this::cleanupQuartz);
+ frameActions.add(this::setupReference);
+ frameActions.add(() -> currentScreenshotList = referenceScreenshots);
+ // wait a few frames for the chunk rebuild
+// frameActions.add(WAIT_FRAME);
+ frameActions.addAll(screenshotLoop);
+ frameActions.add(this::cleanupReference);
+ frameActions.add(WAIT_FRAME);
+ }
+
+ @Override
+ public void setup() {
+
+ hideHUD();
+ setFOV(40);
+ setFlying();
+
+
+ setWindowSize(256, 256);
+
+ teleportToInOverworld(0, 0, 0);
+
+ frame = 0;
+ }
+
+ @Override
+ public void cleanup() {
+
+ quartzScreenshots.forEach(NativeImage::close);
+ referenceScreenshots.forEach(NativeImage::close);
+ quartzScreenshots.clear();
+ referenceScreenshots.clear();
+ }
+
+ @Override
+ public boolean running() {
+ return frame < frameActions.size();
+ }
+
+ @Override
+ public void frameStart() {
+
+ }
+
+ private final List quartzScreenshots = new ReferenceArrayList<>();
+ private final List referenceScreenshots = new ReferenceArrayList<>();
+ @Nullable
+ private List currentScreenshotList;
+
+ private static final Runnable WAIT_FRAME = () -> {
+ };
+
+ private static final List screenshotPositions = new ReferenceArrayList<>();
+ private final List screenshotLoop = new ReferenceArrayList<>();
+ private final List frameActions = new ReferenceArrayList<>();
+
+ static {
+// screenshotPositions.add(new Vector3i(-5, -5, -5));
+// screenshotPositions.add(new Vector3i(-5, -5, +5));
+// screenshotPositions.add(new Vector3i(-5, +5, -5));
+// screenshotPositions.add(new Vector3i(-5, +5, +5));
+// screenshotPositions.add(new Vector3i(+5, -5, -5));
+// screenshotPositions.add(new Vector3i(+5, -5, +5));
+// screenshotPositions.add(new Vector3i(+5, +5, -5));
+ screenshotPositions.add(new Vector3i(+5, +5, +5));
+ }
+
+ private void takeScreenshot() {
+ if (currentScreenshotList != null) {
+ currentScreenshotList.add(screenshot());
+ }
+ }
+
+ @Override
+ public void frameEnd() {
+ frameActions.get(frame).run();
+ frame++;
+ if (frame == frameActions.size()) {
+ compareFrames();
+ }
+ }
+
+ public void compareFrames() {
+ for (int i = 0; i < referenceScreenshots.size(); i++) {
+ var quartzScreenshot = quartzScreenshots.get(i);
+ var referenceScreenshot = referenceScreenshots.get(i);
+ var width = referenceScreenshot.getWidth();
+ var height = referenceScreenshot.getHeight();
+ var totalPixels = width * height;
+ var quartzPixels = quartzScreenshot.pixels;
+ var referencePixels = referenceScreenshot.pixels;
+ var totalBytes = totalPixels * 4;
+ for (int j = 0; j < totalBytes; j++) {
+ final var quartzByte = Byte.toUnsignedInt(MemoryUtil.memGetByte(quartzPixels + (j)));
+ final var referenceByte = Byte.toUnsignedInt(MemoryUtil.memGetByte(referencePixels + (j)));
+ final var difference = Math.abs(quartzByte - referenceByte);
+ // ~1.5% error allowed, there can be tiny differences
+ if (difference > 4) {
+ var pixelIndex = j >> 2;
+ var w = pixelIndex % width;
+ var h = pixelIndex / width;
+ fail("Pixel (" + w + ", " + h + ") different in screenshot " + i + " difference " + difference + " in byte " + (pixelIndex & 0b11));
+ return;
+ }
+ }
+ }
+ }
+
+ @Override
+ public void feedbackCollected(QuartzInternalEvent.FeedbackCollected event) {
+
+ }
+
+ protected abstract void setupReference();
+ protected abstract void cleanupReference();
+
+ protected abstract void setupQuartz();
+ protected abstract void cleanupQuartz();
+}
diff --git a/src/test/resources/assets/quartz/blockstates/quartz_test_runner_block.json b/src/test/resources/assets/quartz/blockstates/quartz_test_runner_block.json
new file mode 100644
index 0000000..41ef07a
--- /dev/null
+++ b/src/test/resources/assets/quartz/blockstates/quartz_test_runner_block.json
@@ -0,0 +1,7 @@
+{
+ "variants": {
+ "": {
+ "model": "phosphophyllite:block/phosphophyllite_ore"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/assets/quartz/models/item/quartz_test_runner_block.json b/src/test/resources/assets/quartz/models/item/quartz_test_runner_block.json
new file mode 100644
index 0000000..6ed0ade
--- /dev/null
+++ b/src/test/resources/assets/quartz/models/item/quartz_test_runner_block.json
@@ -0,0 +1,3 @@
+{
+ "parent": "phosphophyllite:block/phosphophyllite_ore"
+}
\ No newline at end of file