diff --git a/README.MD b/README.MD
index ac54ca1..3ca2d2d 100644
--- a/README.MD
+++ b/README.MD
@@ -1,5 +1,5 @@
-
+
Saturn Client
diff --git a/build.gradle b/build.gradle
index 54819cd..fea5081 100644
--- a/build.gradle
+++ b/build.gradle
@@ -10,6 +10,9 @@ subprojects {
dependencies {
implementation "de.javagl:obj:0.4.0"
implementation "com.formdev:svgSalamander:1.1.4"
+ implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0'
+ implementation 'com.fasterxml.jackson.core:jackson-annotations:2.17.0'
+ implementation 'com.fasterxml.jackson.core:jackson-core:2.17.0'
}
}
diff --git a/common b/common
index e78a6ce..215aaf0 160000
--- a/common
+++ b/common
@@ -1 +1 @@
-Subproject commit e78a6ced1f8d19ae04e2727abfc1fba871e2dc1a
+Subproject commit 215aaf0fd0c03d58c6f05f1f572c023003c3d326
diff --git a/versions/1.21.1/build.gradle b/versions/1.21.1/build.gradle
new file mode 100644
index 0000000..bca2b04
--- /dev/null
+++ b/versions/1.21.1/build.gradle
@@ -0,0 +1,96 @@
+plugins {
+ id 'net.fabricmc.fabric-loom-remap' version "${loom_version}"
+ id 'maven-publish'
+}
+
+version = project.mod_version
+group = project.maven_group
+
+base {
+ archivesName = project.archives_base_name
+}
+
+repositories {
+ maven { name = "KoSmX Maven"; url = 'https://maven.kosmx.dev/' }
+}
+
+dependencies {
+ // To change the versions see the gradle.properties file
+ minecraft "com.mojang:minecraft:${project.minecraft_version}"
+ mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
+ modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
+
+ // Fabric API. This is technically optional, but you probably want it anyway.
+ modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
+
+ // Player Animation Library
+ include modImplementation(
+ "dev.kosmx.player-anim:player-animation-lib-fabric:${project.player_anim}"
+ )
+
+ // Optional: BendyLib
+ // include modRuntimeOnly(
+ // "io.github.kosmx.bendy-lib:bendy-lib-fabric:${project.bendylib_version}"
+ // )
+}
+
+/*
+ * ================================================================
+ * Resource Processing
+ * ================================================================
+ * Injects version values into fabric.mod.json.
+ */
+processResources {
+ inputs.property "version", project.version
+ inputs.property "minecraft_version", project.minecraft_version
+ inputs.property "loader_version", project.loader_version
+ filteringCharset "UTF-8"
+
+ filesMatching("fabric.mod.json") {
+ expand(
+ "version": project.version,
+ "minecraft_version": project.minecraft_version,
+ "loader_version": project.loader_version
+ )
+ }
+}
+
+tasks.withType(JavaCompile).configureEach {
+ it.options.release = 21
+}
+
+java {
+ // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
+ // if it is present.
+ // If you remove this line, sources will not be generated.
+ withSourcesJar()
+
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+}
+
+jar {
+ inputs.property "archivesName", project.base.archivesName
+
+ from("LICENSE") {
+ rename { "${it}_${inputs.properties.archivesName}"}
+ }
+}
+
+// configure the maven publication
+publishing {
+ publications {
+ create("mavenJava", MavenPublication) {
+ artifactId = project.archives_base_name
+ from components.java
+ }
+ }
+
+ // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
+ repositories {
+ // Add repositories to publish to here.
+ // Notice: This block does NOT have the same function as the block in the top level.
+ // The repositories here will be used for publishing your artifact, not for
+ // retrieving dependencies.
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/gradle.properties b/versions/1.21.1/gradle.properties
new file mode 100644
index 0000000..01be582
--- /dev/null
+++ b/versions/1.21.1/gradle.properties
@@ -0,0 +1,23 @@
+# Done to increase the memory available to gradle.
+org.gradle.jvmargs=-Xmx1G
+org.gradle.parallel=true
+
+# IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349
+org.gradle.configuration-cache=false
+
+# Fabric Properties
+# check these on https://fabricmc.net/develop
+minecraft_version=1.21.1
+yarn_mappings=1.21.1+build.3
+loader_version=0.18.4
+loom_version=1.15-SNAPSHOT
+
+# Mod Properties
+mod_version=1.0.0
+maven_group=org.saturnclient
+archives_base_name=saturn-client
+
+# Dependencies
+fabric_api_version=0.116.9+1.21.1
+
+player_anim = 2.0.4+1.21.1
diff --git a/versions/1.21.1/settings.gradle b/versions/1.21.1/settings.gradle
new file mode 100644
index 0000000..75c4d72
--- /dev/null
+++ b/versions/1.21.1/settings.gradle
@@ -0,0 +1,10 @@
+pluginManagement {
+ repositories {
+ maven {
+ name = 'Fabric'
+ url = 'https://maven.fabricmc.net/'
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/CloakFeatureRenderer.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/CloakFeatureRenderer.java
new file mode 100644
index 0000000..1b5bc6a
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/CloakFeatureRenderer.java
@@ -0,0 +1,411 @@
+package org.saturnclient.impl.cosmetics;
+
+import java.util.Arrays;
+
+import org.saturnclient.client.player.SaturnPlayer;
+import org.saturnclient.config.Config;
+import org.saturnclient.cosmetics.Cloaks;
+
+import net.minecraft.client.network.AbstractClientPlayerEntity;
+import net.minecraft.client.render.OverlayTexture;
+import net.minecraft.client.render.RenderLayer;
+import net.minecraft.client.render.VertexConsumer;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.entity.feature.FeatureRenderer;
+import net.minecraft.client.render.entity.feature.FeatureRendererContext;
+import net.minecraft.client.render.entity.model.PlayerEntityModel;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.math.MathHelper;
+import net.minecraft.util.math.RotationAxis;
+import net.minecraft.util.math.Vec3d;
+
+/**
+ * The cloak renderer
+ * - **Efficient**: uses a image only consisting of the cloak texture, 16x8
+ * whitespace.
+ * - **Curving**: Curves when player velocity increases.
+ * - **Wavey**: Makes the cloak feel like wind, when jump sprinting.
+ */
+public class CloakFeatureRenderer
+ extends FeatureRenderer> {
+ private static final int PARTS = 24;
+ private final float[] segmentValues = new float[PARTS];
+
+ private static final float TEX_W = 176f;
+ private static final float TEX_H = 138f;
+
+ private static final class PxRect {
+ final int x, y, w, h;
+
+ PxRect(int x, int y, int w, int h) {
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ }
+
+ float u1() {
+ return x / TEX_W;
+ }
+
+ float v1() {
+ return y / TEX_H;
+ }
+
+ float u2() {
+ return (x + w) / TEX_W;
+ }
+
+ float v2() {
+ return (y + h) / TEX_H;
+ }
+ }
+
+ private static final PxRect FRONT_RECT = new PxRect(8, 8, 80, 128);
+ private static final PxRect BACK_RECT = new PxRect(96, 8, 80, 128);
+ private static final PxRect LEFT_EDGE_RECT = new PxRect(0, 8, 8, 128);
+ private static final PxRect RIGHT_EDGE_RECT = new PxRect(88, 8, 8, 128);
+ private static final PxRect TOP_RECT = new PxRect(8, 0, 80, 8);
+ private static final PxRect BOTTOM_RECT = new PxRect(88, 0, 80, 8);
+
+ public CloakFeatureRenderer(
+ FeatureRendererContext> featureRendererContext) {
+ super(featureRendererContext);
+ Arrays.fill(segmentValues, 0.0f);
+ }
+
+ private void renderCapeQuad(VertexConsumer vertexConsumer, MatrixStack matrixStack,
+ Vec3d bottomLeft, Vec3d bottomRight, Vec3d topRight, Vec3d topLeft,
+ float u1, float v1, float u2, float v2, int light, int overlay,
+ float normalX, float normalY, float normalZ, boolean flipWinding) {
+ MatrixStack.Entry entry = matrixStack.peek();
+
+ if (!flipWinding) {
+ // CCW
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) bottomLeft.x, (float) bottomLeft.y,
+ (float) bottomLeft.z)
+ .color(255, 255, 255, 255).texture(u1, v2).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) bottomRight.x, (float) bottomRight.y,
+ (float) bottomRight.z)
+ .color(255, 255, 255, 255).texture(u2, v2).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) topRight.x, (float) topRight.y,
+ (float) topRight.z)
+ .color(255, 255, 255, 255).texture(u2, v1).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) topLeft.x, (float) topLeft.y,
+ (float) topLeft.z)
+ .color(255, 255, 255, 255).texture(u1, v1).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ } else {
+ // CW
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) topLeft.x, (float) topLeft.y,
+ (float) topLeft.z)
+ .color(255, 255, 255, 255).texture(u1, v1).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) topRight.x, (float) topRight.y,
+ (float) topRight.z)
+ .color(255, 255, 255, 255).texture(u2, v1).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) bottomRight.x, (float) bottomRight.y,
+ (float) bottomRight.z)
+ .color(255, 255, 255, 255).texture(u2, v2).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) bottomLeft.x, (float) bottomLeft.y,
+ (float) bottomLeft.z)
+ .color(255, 255, 255, 255).texture(u1, v2).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ }
+ }
+
+ private void renderCape(MatrixStack matrixStack, VertexConsumer vertexConsumer, int light, int overlay) {
+ float capeWidth = 10.0f / 16.0f;
+ float capeHeight = 16.0f / 16.0f;
+ float capeDepth = 1.0f / 16.0f;
+ float capePartHeight = capeHeight / PARTS;
+
+ float x1 = -capeWidth / 2.0f;
+ float x2 = capeWidth / 2.0f;
+
+ final float frontU1 = FRONT_RECT.u1();
+ final float frontU2 = FRONT_RECT.u2();
+ final float frontV1 = FRONT_RECT.v1();
+ final float frontV2 = FRONT_RECT.v2();
+ final float frontPartV = (frontV2 - frontV1) / PARTS;
+ final float backU1 = BACK_RECT.u1();
+ final float backU2 = BACK_RECT.u2();
+ final float backV1 = BACK_RECT.v1();
+ final float backV2 = BACK_RECT.v2();
+ final float backPartV = (backV2 - backV1) / PARTS;
+ final float leftU1 = LEFT_EDGE_RECT.u1();
+ final float leftU2 = LEFT_EDGE_RECT.u2();
+ final float leftV1 = LEFT_EDGE_RECT.v1();
+ final float leftV2 = LEFT_EDGE_RECT.v2();
+ final float leftPartV = (leftV2 - leftV1) / PARTS;
+ final float rightU1 = RIGHT_EDGE_RECT.u1();
+ final float rightU2 = RIGHT_EDGE_RECT.u2();
+ final float rightV1 = RIGHT_EDGE_RECT.v1();
+ final float rightV2 = RIGHT_EDGE_RECT.v2();
+ final float rightPartV = (rightV2 - rightV1) / PARTS;
+ final float topU1 = TOP_RECT.u1();
+ final float topU2 = TOP_RECT.u2();
+ final float topV1 = TOP_RECT.v1();
+ final float topV2 = TOP_RECT.v2();
+ final float bottomU1 = BOTTOM_RECT.u1();
+ final float bottomU2 = BOTTOM_RECT.u2();
+ final float bottomV1 = BOTTOM_RECT.v1();
+ final float bottomV2 = BOTTOM_RECT.v2();
+
+ float curY = 0.0f;
+ float curZ = 0.0f;
+
+ for (int i = 0; i < PARTS; i++) {
+ float angle = (2.0f - segmentValues[i]) * ((float) Math.PI / 2f);
+ float dirY = -(float) Math.cos(angle);
+ float dirZ = (float) Math.sin(angle);
+
+ // 1. Thickness at the START of this segment (Must match PREVIOUS segment's end)
+ float prevAngle = (i > 0) ? (2.0f - segmentValues[i - 1]) * ((float) Math.PI / 2f) : angle;
+ float startAvgAngle = (angle + prevAngle) / 2.0f;
+ float thickYStart = (float) Math.sin(startAvgAngle) * capeDepth;
+ float thickZStart = (float) Math.cos(startAvgAngle) * capeDepth;
+
+ // 2. Thickness at the END of this segment (Must match NEXT segment's start)
+ float nextAngle = (i < PARTS - 1) ? (2.0f - segmentValues[i + 1]) * ((float) Math.PI / 2f) : angle;
+ float endAvgAngle = (angle + nextAngle) / 2.0f;
+ float thickYEnd = (float) Math.sin(endAvgAngle) * capeDepth;
+ float thickZEnd = (float) Math.cos(endAvgAngle) * capeDepth;
+
+ float nextY = curY + dirY * capePartHeight;
+ float nextZ = curZ + dirZ * capePartHeight;
+
+ // Inner Vertices (The spine of the cape)
+ Vec3d innerTopLeft = new Vec3d(x2, curY, curZ);
+ Vec3d innerTopRight = new Vec3d(x1, curY, curZ);
+ Vec3d innerBotLeft = new Vec3d(x2, nextY, nextZ);
+ Vec3d innerBotRight = new Vec3d(x1, nextY, nextZ);
+
+ // Outer Vertices (Offset by the specific joint thickness)
+ Vec3d outerTopLeft = new Vec3d(x2, curY - thickYStart, curZ - thickZStart);
+ Vec3d outerTopRight = new Vec3d(x1, curY - thickYStart, curZ - thickZStart);
+ Vec3d outerBotLeft = new Vec3d(x2, nextY - thickYEnd, nextZ - thickZEnd);
+ Vec3d outerBotRight = new Vec3d(x1, nextY - thickYEnd, nextZ - thickZEnd);
+
+ // FRONT (facing camera)
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ outerBotRight, outerBotLeft, outerTopLeft, outerTopRight,
+ frontU2, frontV1 + (frontPartV * i), frontU1, frontV1 + (frontPartV * (i + 1)),
+ light, overlay, 0, 0, 1, false);
+
+ // BACK (Facing player)
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ innerBotLeft, innerBotRight, innerTopRight, innerTopLeft,
+ backU2, backV1 + (backPartV * i), backU1, backV1 + (backPartV * (i + 1)),
+ light, overlay, 0, 0, -1, false);
+
+ // LEFT EDGE
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ innerBotLeft, outerBotLeft, outerTopLeft, innerTopLeft,
+ leftU1, leftV1 + (leftPartV * i), leftU2, leftV1 + (leftPartV * (i + 1)),
+ light, overlay, 1, 0, 0, false);
+
+ // RIGHT EDGE
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ outerBotRight, innerBotRight, innerTopRight, outerTopRight,
+ rightU1, rightV1 + (rightPartV * i), rightU2, rightV1 + (rightPartV * (i + 1)),
+ light, overlay, -1, 0, 0, false);
+
+ // TOP FACE (Only on first segment)
+ if (i == 0) {
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ outerTopLeft, outerTopRight, innerTopRight, innerTopLeft,
+ topU1, topV1, topU2, topV2, light, overlay, 0, 1, 0, false);
+ }
+
+ // BOTTOM FACE (Only on last segment)
+ if (i == PARTS - 1) {
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ innerBotLeft, innerBotRight, outerBotRight, outerBotLeft,
+ bottomU1, bottomV1, bottomU2, bottomV2, light, overlay, 0, -1, 0, false);
+ }
+
+ curY = nextY;
+ curZ = nextZ;
+ }
+ }
+
+ // Shift array left and insert new value at the start
+ private void updateSegmentValues(float value) {
+ // Shift all elements one position to the right (from end to start)
+ for (int i = segmentValues.length - 1; i > 0; i--) {
+ segmentValues[i] = segmentValues[i - 1];
+ }
+
+ // Insert the new value at index 0
+ segmentValues[0] = value;
+ }
+
+ private long lastUpdate = 0;
+
+ @Override
+ public void render(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light,
+ AbstractClientPlayerEntity entity, float limbAngle, float limbDistance, float tickDelta,
+ float animationProgress, float headYaw, float headPitch) {
+ if (entity.isInvisible() || entity.getSkinTextures().capeTexture() != null) {
+ return;
+ }
+
+ SaturnPlayer player = SaturnPlayer.get(entity.getUuid());
+
+ if (player == null) {
+ return;
+ }
+
+ ItemStack chestStack = entity.getInventory().armor.get(1);
+
+ Identifier customCape = (Identifier) (Object) Cloaks.getCurrentCloakTexture(player.uuid);
+ if (customCape == null || chestStack.itemMatches(Items.ELYTRA.getRegistryEntry())) {
+ return;
+ }
+
+ int minBrightness = 7;
+ int blockLight = (light >> 4) & 0xF;
+ int skyLight = (light >> 20) & 0xF;
+ blockLight = Math.max(blockLight, minBrightness);
+ skyLight = Math.max(skyLight, minBrightness);
+ light = (skyLight << 20) | (blockLight << 4);
+
+ VertexConsumer vertexConsumer = vertexConsumers.getBuffer(RenderLayer.getEntityAlpha(customCape));
+
+ matrices.push();
+
+ matrices.translate(0.0f, 0.0f, 0.12f);
+
+ if (!chestStack.isEmpty()) {
+ matrices.translate(0.0F, -0.053125F, 0.06875F);
+ }
+
+ if (entity.isInSneakingPose()) {
+ matrices.translate(0, 0.16f, 0.0f);
+ matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(29.0f));
+ }
+
+ long now = System.currentTimeMillis();
+ if (now - lastUpdate >= 20) {
+ float velX = (float) (MathHelper.lerp((double) tickDelta, entity.prevCapeX,
+ entity.capeX)
+ - MathHelper.lerp((double) tickDelta, entity.prevX, entity.getX()));
+ float velZ = (float) (MathHelper.lerp((double) tickDelta, entity.prevCapeZ,
+ entity.capeZ)
+ - MathHelper.lerp((double) tickDelta, entity.prevZ, entity.getZ()));
+ float rawVelY = (float) (MathHelper.lerp((double) tickDelta, entity.prevCapeY, entity.capeY)
+ - MathHelper.lerp((double) tickDelta, entity.prevY, entity.getY()));
+
+ float velHorizontal = Math.min(0.6f, velZ > velX ? velZ : velX);
+ float velY = (rawVelY > 0.25f ? rawVelY : 0.0f);
+
+ float value = entity.isSwimming() ? 0.0f : Math.max(0.0f, Math.min(2.0f, velHorizontal + velY));
+
+ if (Config.cloakPhysics.value) {
+ updateSegmentValues(value);
+ } else {
+ float last = segmentValues[0];
+ float maxStep = 0.02f;
+ float next = value;
+
+ if (next > last)
+ next = Math.min(next, last + maxStep);
+ else if (next < last)
+ next = Math.max(next, last - maxStep);
+
+ Arrays.fill(segmentValues, next);
+ }
+ lastUpdate = now;
+ }
+
+ renderCape(matrices, vertexConsumer, light, OverlayTexture.DEFAULT_UV);
+
+ matrices.pop();
+ }
+
+ // public void render(MatrixStack matrixStack, VertexConsumerProvider
+ // vertexConsumerProvider, int light,
+ // PlayerEntityRenderState playerEntityRenderState, float f, float g) {
+ // if (playerEntityRenderState.invisible || !playerEntityRenderState.capeVisible
+ // || playerEntityRenderState.skinTextures.capeTexture() != null) {
+ // return;
+ // }
+
+ // SaturnPlayer player = SaturnPlayer.get(playerEntityRenderState.name);
+
+ // if (player == null) {
+ // return;
+ // }
+
+ // Identifier customCape = (Identifier) (Object)
+ // Cloaks.getCurrentCloakTexture(player.uuid);
+ // if (customCape == null
+ // || this.hasCustomModelForLayer(playerEntityRenderState.equippedChestStack,
+ // LayerType.WINGS)) {
+ // return;
+ // }
+
+ // int minBrightness = 7;
+ // int blockLight = (light >> 4) & 0xF;
+ // int skyLight = (light >> 20) & 0xF;
+ // blockLight = Math.max(blockLight, minBrightness);
+ // skyLight = Math.max(skyLight, minBrightness);
+ // light = (skyLight << 20) | (blockLight << 4);
+
+ // VertexConsumer vertexConsumer = vertexConsumerProvider
+ // .getBuffer(RenderLayer.getEntityAlpha(customCape));
+
+ // matrixStack.push();
+
+ // matrixStack.translate(0.0f, 0.0f, 0.12f);
+
+ // if (this.hasCustomModelForLayer(playerEntityRenderState.equippedChestStack,
+ // LayerType.HUMANOID)) {
+ // matrixStack.translate(0.0F, -0.053125F, 0.06875F);
+ // }
+
+ // if (playerEntityRenderState.isInSneakingPose) {
+ // matrixStack.translate(0, 0.16f, 0.0f);
+ // matrixStack.multiply(RotationAxis.POSITIVE_X.rotationDegrees(29.0f));
+ // }
+
+ // long now = System.currentTimeMillis();
+ // if (now - lastUpdate >= 20) {
+ // float velX = Math.min(1.0f, playerEntityRenderState.field_53537 / 108.0f);
+ // float rawVelY = playerEntityRenderState.field_53536;
+ // float velY = (rawVelY > 4.0f ? rawVelY : 0.0f) / 16;
+
+ // float value = playerEntityRenderState.isSwimming ? 0.0f : velX + velY;
+
+ // if (Config.cloakPhysics.value) {
+ // updateSegmentValues(value);
+ // } else {
+ // float last = segmentValues[0];
+ // float maxStep = 0.02f;
+ // float next = value;
+
+ // if (next > last)
+ // next = Math.min(next, last + maxStep);
+ // else if (next < last)
+ // next = Math.max(next, last - maxStep);
+
+ // Arrays.fill(segmentValues, next);
+ // }
+ // lastUpdate = now;
+ // }
+
+ // renderCape(matrixStack, vertexConsumer, playerEntityRenderState, light,
+ // OverlayTexture.DEFAULT_UV);
+
+ // matrixStack.pop();
+ // }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/HatFeatureRenderer.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/HatFeatureRenderer.java
new file mode 100644
index 0000000..a9b2b5d
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/HatFeatureRenderer.java
@@ -0,0 +1,43 @@
+package org.saturnclient.impl.cosmetics;
+
+import org.saturnclient.client.player.SaturnPlayer;
+import org.saturnclient.impl.cosmetics.obj.MtlLoader;
+import org.saturnclient.impl.cosmetics.obj.ObjModel;
+
+import net.minecraft.client.network.AbstractClientPlayerEntity;
+import net.minecraft.client.render.OverlayTexture;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.entity.feature.FeatureRenderer;
+import net.minecraft.client.render.entity.feature.FeatureRendererContext;
+import net.minecraft.client.render.entity.model.PlayerEntityModel;
+import net.minecraft.client.util.math.MatrixStack;
+
+public class HatFeatureRenderer
+ extends FeatureRenderer> {
+
+ public HatFeatureRenderer(
+ FeatureRendererContext> featureRendererContext) {
+ super(featureRendererContext);
+ }
+
+ @Override
+ public void render(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light,
+ AbstractClientPlayerEntity entity, float limbAngle, float limbDistance, float tickDelta,
+ float animationProgress, float headYaw, float headPitch) {
+ SaturnPlayer player = SaturnPlayer.get(entity.getUuid());
+
+ if (entity.isInvisible() || player == null || player.hat.isEmpty()) {
+ return;
+ }
+
+ matrices.push();
+
+ this.getContextModel().head.rotate(matrices);
+
+ ObjModel.cosmetic("hat", player.hat).render(MtlLoader.cosmetic(player.hat),
+ matrices, vertexConsumers, light,
+ OverlayTexture.DEFAULT_UV);
+
+ matrices.pop();
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/MtlLoader.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/MtlLoader.java
new file mode 100644
index 0000000..11b06d3
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/MtlLoader.java
@@ -0,0 +1,56 @@
+package org.saturnclient.impl.cosmetics.obj;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import de.javagl.obj.*;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.util.Identifier;
+
+public class MtlLoader {
+ static Map> loadedMtls = new HashMap<>();
+
+ static Map loadMtl(Identifier mtlId) throws IOException {
+ try (InputStream is = MinecraftClient.getInstance().getResourceManager()
+ .getResource(mtlId).get().getInputStream()) {
+ List mtlList = MtlReader.read(is);
+ Map mtlMap = new java.util.LinkedHashMap<>();
+ for (Mtl mtl : mtlList) {
+ mtlMap.put(mtl.getName(), mtl);
+ }
+ return mtlMap;
+ }
+ }
+
+ public static Map of(Identifier mtlId) {
+ if (loadedMtls.containsKey(mtlId))
+ return loadedMtls.get(mtlId);
+
+ try {
+ Map mtl = loadMtl(mtlId);
+ loadedMtls.put(mtlId, mtl);
+
+ return mtl;
+ } catch (IOException e) {
+ e.printStackTrace();
+
+ return null;
+ }
+
+ }
+
+ public static Map cosmetic(String cosmeticId) {
+ int index = cosmeticId.indexOf('_');
+
+ String mtlId = (index == -1) ? "" : cosmeticId.substring(index + 1);
+
+ if (mtlId.isEmpty())
+ mtlId = "white";
+
+ return of(Identifier.of("saturnclient", "models/cosmetic/materials/" + mtlId + ".mtl"));
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjModel.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjModel.java
new file mode 100644
index 0000000..3e7237e
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjModel.java
@@ -0,0 +1,102 @@
+package org.saturnclient.impl.cosmetics.obj;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.javagl.obj.Mtl;
+import de.javagl.obj.Obj;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.resource.Resource;
+import net.minecraft.resource.ResourceManager;
+import net.minecraft.util.Identifier;
+
+public class ObjModel {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ public static final Map loadedObjModels = new HashMap<>();
+
+ public record Config(
+ @JsonProperty("translate") Vec3d transform,
+ @JsonProperty("scale") Vec3d scale,
+ @JsonProperty("rotation") Vec3d rotation) {
+
+ public Config {
+ transform = transform == null ? new Vec3d(0, 0, 0) : transform;
+ scale = scale == null ? new Vec3d(1, 1, 1) : scale;
+ rotation = rotation == null ? new Vec3d(0, 0, 0) : rotation;
+ }
+
+ @JsonFormat(shape = JsonFormat.Shape.ARRAY)
+ public record Vec3d(float x, float y, float z) {
+ }
+ }
+
+ Obj obj;
+ Config config;
+
+ public static ObjModel cosmetic(String cosmeticKind, String id) {
+ return of(Identifier.of("saturnclient", "models/cosmetic/" + cosmeticKind + "/" + id.split("_")[0] + "/model"));
+ }
+
+ public static ObjModel of(Identifier obj) {
+ if (loadedObjModels.containsKey(obj))
+ return loadedObjModels.get(obj);
+
+ ObjModel o = new ObjModel(obj);
+ loadedObjModels.put(obj, o);
+ return o;
+ }
+
+ ObjModel(Identifier identifier) {
+ Identifier objId = Identifier.of(identifier.getNamespace(), identifier.getPath() + ".obj");
+ Identifier jsonId = Identifier.of(identifier.getNamespace(), identifier.getPath() + ".json");
+
+ try {
+ this.obj = ObjRenderer.loadObj(objId);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ // Try loading JSON config if it exists
+ ResourceManager resourceManager = MinecraftClient.getInstance().getResourceManager();
+ Optional resource = resourceManager.getResource(jsonId);
+
+ if (resource.isPresent()) {
+ try (InputStream stream = resource.get().getInputStream()) {
+ this.config = MAPPER.readValue(stream, Config.class);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ public void render(Map mtlMap, MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light,
+ int overlay) {
+ matrices.push();
+
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_X.rotationDegrees(180.0f));
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_Y.rotationDegrees(180.0f));
+
+ if (this.config != null) {
+ matrices.translate(config.transform.x, config.transform.y, config.transform.z);
+
+ matrices.scale(config.scale.x, config.scale.y, config.scale.z);
+
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_X.rotationDegrees(config.rotation.x));
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_Y.rotationDegrees(config.rotation.y));
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_Z.rotationDegrees(config.rotation.z));
+ }
+
+ ObjRenderer.renderObj(this.obj, mtlMap, matrices, vertexConsumers, light, overlay);
+
+ matrices.pop();
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjRenderer.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjRenderer.java
new file mode 100644
index 0000000..27635a2
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjRenderer.java
@@ -0,0 +1,111 @@
+package org.saturnclient.impl.cosmetics.obj;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+
+import de.javagl.obj.*;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.render.RenderLayer;
+import net.minecraft.client.render.VertexConsumer;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.util.Identifier;
+
+public class ObjRenderer {
+ public static Obj loadObj(Identifier objId) throws IOException {
+ try (InputStream is = MinecraftClient.getInstance().getResourceManager()
+ .getResource(objId).get().getInputStream()) {
+ return ObjReader.read(is);
+ }
+ }
+
+ public static void renderObj(Obj obj, Map mtlMap, MatrixStack matrices,
+ VertexConsumerProvider vertexConsumers, int light, int overlay) {
+ MatrixStack.Entry entry = matrices.peek();
+
+ Map materialGroups = ObjSplitting.splitByMaterialGroups(obj);
+
+ for (Map.Entry group : materialGroups.entrySet()) {
+ String materialName = group.getKey();
+ Obj groupObj = group.getValue();
+
+ Mtl mtl = mtlMap != null ? mtlMap.get(materialName) : null;
+
+ // Resolve texture identifier using same logic as ObjUnbakedModelModel
+ Identifier texture = Identifier.of("minecraft:textures/misc/white.png");
+
+ if (mtl != null) {
+ String mapKd = mtl.getMapKd();
+ if (mapKd != null) {
+ // "#key" references are not resolvable at runtime without a texture map,
+ // so we only support direct resource location strings here
+ if (!mapKd.startsWith("#")) {
+ texture = Identifier.of(mapKd);
+ }
+ }
+ }
+
+ // Diffuse color (Kd)
+ float r = 1f, g = 1f, b = 1f;
+ if (mtl != null) {
+ FloatTuple kd = mtl.getKd();
+ if (kd != null) {
+ r = kd.getX();
+ g = kd.getY();
+ b = kd.getZ();
+ }
+ }
+
+ VertexConsumer consumer = vertexConsumers.getBuffer(RenderLayer.getEntityAlpha(texture));
+
+ int faces = groupObj.getNumFaces();
+ final float fr = r, fg = g, fb = b;
+
+ for (int f = 0; f < faces; f++) {
+ ObjFace face = groupObj.getFace(f);
+ int verts = face.getNumVertices();
+
+ // Your original winding order logic, untouched
+ for (int i = verts - 1; i >= 0; i--) {
+ writeVertex(groupObj, face, i, consumer, entry, light, overlay, fr, fg, fb);
+ }
+ }
+ }
+ }
+
+ private static void writeVertex(Obj obj, ObjFace face, int index, VertexConsumer consumer,
+ MatrixStack.Entry matrix, int light, int overlay,
+ float r, float g, float b) {
+
+ FloatTuple pos = obj.getVertex(face.getVertexIndex(index));
+ float x = pos.getX();
+ float y = pos.getY();
+ float z = pos.getZ();
+
+ float nx = 0f, ny = 1f, nz = 0f;
+ int normalIndex = face.getNormalIndex(index);
+ if (normalIndex >= 0) {
+ FloatTuple n = obj.getNormal(normalIndex);
+ nx = n.getX();
+ ny = n.getY();
+ nz = n.getZ();
+ }
+
+ float u = 0f, v = 0f;
+ int texIndex = face.getTexCoordIndex(index);
+ if (texIndex >= 0) {
+ FloatTuple tc = obj.getTexCoord(texIndex);
+ u = tc.getX();
+ v = 1f - tc.getY(); // Flip V: OBJ bottom-left origin -> Minecraft top-left
+ }
+
+ consumer.vertex(matrix, x, y, z)
+ .color(r, g, b, 1f)
+ .texture(u, v)
+ .overlay(overlay)
+ .light(light)
+ .normal(matrix, nx, ny, nz);
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/utils/IdentifierUtils.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/utils/IdentifierUtils.java
new file mode 100644
index 0000000..7da48e6
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/cosmetics/utils/IdentifierUtils.java
@@ -0,0 +1,70 @@
+package org.saturnclient.impl.cosmetics.utils;
+
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.saturnclient.SaturnClient;
+
+import net.minecraft.client.texture.NativeImage;
+import net.minecraft.client.texture.NativeImageBackedTexture;
+import net.minecraft.util.Identifier;
+
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.util.Map;
+
+public class IdentifierUtils {
+
+ public static void registerBufferedImageTextureFast(IdentifierRef identifierRef, BufferedImage bufferedImage) {
+ try {
+ Identifier identifier = (Identifier) (Object) identifierRef;
+
+ int width = bufferedImage.getWidth();
+ int height = bufferedImage.getHeight();
+
+ NativeImage nativeImage = new NativeImage(NativeImage.Format.RGBA, width, height, false);
+
+ if (bufferedImage.getType() == BufferedImage.TYPE_INT_ARGB) {
+ int[] pixels = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData();
+
+ int index = 0;
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ nativeImage.setColor(x, y, pixels[index++]);
+ }
+ }
+
+ } else {
+
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ nativeImage.setColor(x, y, bufferedImage.getRGB(x, y));
+ }
+ }
+
+ }
+
+ NativeImageBackedTexture texture = new NativeImageBackedTexture(nativeImage);
+
+ SaturnClient.client.execute(() ->
+ SaturnClient.client.getTextureManager().registerTexture(identifier, texture)
+ );
+
+ } catch (Exception e) {
+ SaturnClient.LOGGER.error("Failed to register texture: {}", identifierRef, e);
+ }
+ }
+
+ public static void registerBufferedImageTexturesBulk(Map textures) {
+
+ long start = System.currentTimeMillis();
+
+ textures.forEach(IdentifierUtils::registerBufferedImageTextureFast);
+
+ long end = System.currentTimeMillis();
+
+ SaturnClient.LOGGER.info(
+ "Registered {} textures in {}ms",
+ textures.size(),
+ (end - start)
+ );
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/IdentifierMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/IdentifierMixin.java
new file mode 100644
index 0000000..edfcdb9
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/IdentifierMixin.java
@@ -0,0 +1,13 @@
+package org.saturnclient.impl.mixins;
+
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+import net.minecraft.util.Identifier;
+
+@Mixin(Identifier.class)
+public abstract class IdentifierMixin implements IdentifierRef {
+ @Shadow
+ public abstract String toString();
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/ItemStackMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/ItemStackMixin.java
new file mode 100644
index 0000000..9a69341
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/ItemStackMixin.java
@@ -0,0 +1,19 @@
+package org.saturnclient.impl.mixins;
+
+import org.saturnclient.common.ref.game.ItemStackRef;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+import net.minecraft.item.ItemStack;
+
+@Mixin(ItemStack.class)
+public abstract class ItemStackMixin implements ItemStackRef {
+ @Shadow
+ public abstract boolean isEmpty();
+
+ @Shadow
+ public abstract int getMaxDamage();
+
+ @Shadow
+ public abstract int getDamage();
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/MatrixStackMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/MatrixStackMixin.java
new file mode 100644
index 0000000..d8e88a4
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/MatrixStackMixin.java
@@ -0,0 +1,59 @@
+package org.saturnclient.impl.mixins;
+
+import org.joml.Quaternionf;
+import org.saturnclient.common.ref.render.MatrixStackRef;
+import org.saturnclient.common.ref.render.QuaternionfRef;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.gen.Invoker;
+
+import net.minecraft.client.util.math.MatrixStack;
+
+@Mixin(MatrixStack.class)
+public abstract class MatrixStackMixin implements MatrixStackRef {
+
+ @Shadow
+ public abstract void translate(double x, double y, double z);
+
+ @Shadow
+ public abstract void translate(float x, float y, float z);
+
+ @Shadow
+ public abstract void scale(float x, float y, float z);
+
+ @Shadow
+ public abstract void multiply(Quaternionf quaternion);
+
+ @Shadow
+ public abstract void multiply(Quaternionf quaternion, float originX, float originY, float originZ);
+
+ @Shadow
+ public abstract void push();
+
+ @Shadow
+ public abstract void pop();
+
+ @Invoker("peek")
+ public abstract MatrixStack.Entry peekM();
+
+ @Shadow
+ public abstract boolean isEmpty();
+
+ @Shadow
+ public abstract void loadIdentity();
+
+ @Override
+ public void multiply(QuaternionfRef quaternion) {
+ this.multiply((Quaternionf) quaternion);
+ }
+
+ @Override
+ public void multiply(QuaternionfRef quaternion, float originX, float originY, float originZ) {
+ this.multiply((Quaternionf) quaternion, originX, originY, originZ);
+ }
+
+ @Override
+ public Object peek() {
+ return this.peekM();
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/MinecraftClientMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/MinecraftClientMixin.java
new file mode 100644
index 0000000..328917d
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/MinecraftClientMixin.java
@@ -0,0 +1,130 @@
+package org.saturnclient.impl.mixins;
+
+import java.io.File;
+import java.io.InputStream;
+import java.util.UUID;
+
+import org.jetbrains.annotations.Nullable;
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.game.MinecraftClientRef;
+import org.saturnclient.common.ref.render.WindowRef;
+import org.saturnclient.impl.ui.SaturnScreenFabric;
+import org.saturnclient.ui.SaturnScreen;
+import org.saturnclient.ui.screens.TitleMenu;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen;
+import net.minecraft.client.gui.screen.option.OptionsScreen;
+import net.minecraft.client.gui.screen.world.SelectWorldScreen;
+import net.minecraft.client.option.GameOptions;
+import net.minecraft.client.session.Session;
+import net.minecraft.client.util.Window;
+import net.minecraft.resource.ReloadableResourceManagerImpl;
+import net.minecraft.util.Identifier;
+
+@Mixin(MinecraftClient.class)
+public abstract class MinecraftClientMixin implements MinecraftClientRef {
+ @Shadow
+ public File runDirectory;
+
+ @Shadow
+ private Window window;
+
+ @Shadow
+ private ReloadableResourceManagerImpl resourceManager;
+
+ @Shadow
+ public GameOptions options;
+
+ @Shadow
+ private Session session;
+
+ @Shadow
+ public abstract void setScreen(@Nullable Screen screen);
+
+ @Shadow
+ @Override
+ public abstract void scheduleStop();
+
+ @Override
+ public File getRunDirectory() {
+ return runDirectory;
+ }
+
+ @Override
+ public InputStream getResource(IdentifierRef identifier) {
+ try {
+ return resourceManager.getResource((Identifier) (Object) identifier).get().getInputStream();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ @Override
+ public WindowRef getWindow() {
+ return (WindowRef) (Object) this.window;
+ }
+
+ @Override
+ public void setScreen(SaturnScreen screen) {
+ this.setScreen(new SaturnScreenFabric(screen));
+ }
+
+ @Override
+ public void setScreen(MinecraftScreen screen) {
+ SaturnScreenFabric exitTarget = new SaturnScreenFabric(new TitleMenu());
+
+ switch (screen) {
+ case SelectWorld:
+ this.setScreen(new SelectWorldScreen(exitTarget));
+ break;
+
+ case Multiplayer:
+ this.setScreen(new MultiplayerScreen(exitTarget));
+ break;
+
+ case Options:
+ this.setScreen(new OptionsScreen(exitTarget, this.options));
+ break;
+ }
+ }
+
+ @Override
+ public String getAccessToken() {
+ if (session == null) {
+ return null;
+ }
+
+ return this.session.getAccessToken();
+ }
+
+ @Override
+ public UUID getUuid() {
+ if (session == null) {
+ return null;
+ }
+ return this.session.getUuidOrNull();
+ }
+
+ @Override
+ public String getUsername() {
+ if (session == null) {
+ return null;
+ }
+ return this.session.getUsername();
+ }
+
+ @Override
+ public void onClientStopping(Runnable handler) {
+ ClientLifecycleEvents.CLIENT_STOPPING.register(_o -> handler.run());
+ }
+
+ @Override
+ public void executeOnThread(Runnable runnable) {
+ ((java.util.concurrent.Executor) (Object) this).execute(runnable);
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/QuaternionfMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/QuaternionfMixin.java
new file mode 100644
index 0000000..628e433
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/QuaternionfMixin.java
@@ -0,0 +1,9 @@
+package org.saturnclient.impl.mixins;
+
+import org.joml.Quaternionf;
+import org.saturnclient.common.ref.render.QuaternionfRef;
+import org.spongepowered.asm.mixin.Mixin;
+
+@Mixin(Quaternionf.class)
+public abstract class QuaternionfMixin implements QuaternionfRef {
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/SpriteMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/SpriteMixin.java
new file mode 100644
index 0000000..6cf07bc
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/SpriteMixin.java
@@ -0,0 +1,9 @@
+package org.saturnclient.impl.mixins;
+
+import net.minecraft.client.texture.Sprite;
+import org.saturnclient.common.ref.asset.SpriteRef;
+import org.spongepowered.asm.mixin.Mixin;
+
+@Mixin(Sprite.class)
+public abstract class SpriteMixin implements SpriteRef {
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/TextMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/TextMixin.java
new file mode 100644
index 0000000..3e1a8b8
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/TextMixin.java
@@ -0,0 +1,24 @@
+package org.saturnclient.impl.mixins;
+
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.render.TextRef;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.spongepowered.asm.mixin.Mixin;
+
+import net.minecraft.text.Style;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+
+@Mixin(Text.class)
+public interface TextMixin extends TextRef {
+
+ @Override
+ public default TextRef withFont(IdentifierRef font) {
+ return (TextRef) (((Text) this).copy().setStyle(Style.EMPTY.withFont((Identifier) (Object) font)));
+ }
+
+ @Override
+ public default int getWidth() {
+ return SaturnClient.client.textRenderer.getWidth((Text) this);
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/WindowMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/WindowMixin.java
new file mode 100644
index 0000000..5629068
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/mixins/WindowMixin.java
@@ -0,0 +1,22 @@
+package org.saturnclient.impl.mixins;
+
+import org.saturnclient.common.ref.render.WindowRef;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+import net.minecraft.client.util.Window;
+
+@Mixin(Window.class)
+public abstract class WindowMixin implements WindowRef {
+ @Shadow
+ public abstract int getWidth();
+
+ @Shadow
+ public abstract int getHeight();
+
+ @Shadow
+ public abstract int getFramebufferWidth();
+
+ @Shadow
+ public abstract int getFramebufferHeight();
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/EntityModuleFabric.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/EntityModuleFabric.java
new file mode 100644
index 0000000..aa42d4f
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/EntityModuleFabric.java
@@ -0,0 +1,80 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.entity.Entity;
+import net.minecraft.util.hit.EntityHitResult;
+import net.minecraft.util.hit.HitResult;
+import net.minecraft.entity.LivingEntity;
+
+import org.saturnclient.common.module.EntityModule;
+
+
+/**
+ * Fabric implementation of {@link EntityModule}.
+ *
+ * Crosshair targeting is resolved via {@link MinecraftClient#crosshairTarget}.
+ * Entity state for nametag rendering is supplied at render time by
+ * {@link org.saturnclient.modules.mixins.LivingEntityRendererMixin} which
+ * writes into the render state; {@link NametagsMixin} then constructs
+ * a {@link RenderStateEntityState} from the already-extracted snapshot.
+ */
+public class EntityModuleFabric implements EntityModule {
+
+ private final MinecraftClient mc;
+
+ public EntityModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ @Override
+ public boolean isTargetingLivingEntity() {
+ HitResult target = mc.crosshairTarget;
+ if (target == null || target.getType() != HitResult.Type.ENTITY)
+ return false;
+ Entity hit = ((EntityHitResult) target).getEntity();
+ return hit instanceof LivingEntity;
+ }
+
+ // ---------------------------------------------------------------
+ // Render-state adapter
+ // ---------------------------------------------------------------
+
+ /**
+ * Wraps a {@link HealthRenderState} (already injected into the
+ * Minecraft render-state object) as a platform-neutral
+ * {@link EntityState} for use by {@link NametagsFeature}.
+ *
+ * This is constructed inside {@link NametagsMixin} where both the
+ * render-state mixin interface and the entity name are available.
+ */
+ public static final class RenderStateEntityState implements EntityState {
+
+ private final String customName;
+ private final HealthRenderState hrs;
+
+ public RenderStateEntityState(String customName, HealthRenderState hrs) {
+ this.customName = customName;
+ this.hrs = hrs;
+ }
+
+ @Override
+ public String getCustomName() {
+ return customName;
+ }
+
+ @Override
+ public float getHealth() {
+ return hrs.saturn$getHealth();
+ }
+
+ @Override
+ public float getMaxHealth() {
+ return hrs.saturn$getMaxHealth();
+ }
+
+ @Override
+ public EntityType getEntityType() {
+ return hrs.saturn$getEntityType();
+ }
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/HealthRenderState.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/HealthRenderState.java
new file mode 100644
index 0000000..d5a063b
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/HealthRenderState.java
@@ -0,0 +1,20 @@
+package org.saturnclient.impl.modules;
+
+import org.saturnclient.common.module.EntityModule;
+
+/**
+ * Injected interface on {@link net.minecraft.client.render.entity.state.LivingEntityRenderState}.
+ *
+ * Carries the entity's health snapshot and classification into the render
+ * pipeline so that {@link NametagsMixin} can read them without touching
+ * the live entity again.
+ */
+public interface HealthRenderState {
+
+ float saturn$getHealth();
+ float saturn$getMaxHealth();
+ void saturn$setHealth(float health, float maxHealth);
+
+ EntityModule.EntityType saturn$getEntityType();
+ void saturn$setEntityType(EntityModule.EntityType type);
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/NetworkModuleFabric.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/NetworkModuleFabric.java
new file mode 100644
index 0000000..61b1b9c
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/NetworkModuleFabric.java
@@ -0,0 +1,37 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.PlayerListEntry;
+
+import org.saturnclient.common.module.NetworkModule;
+
+/**
+ * Fabric implementation of {@link NetworkModule}.
+ *
+ * Connection presence is checked via the network handler.
+ * Ping is read from the player's {@link PlayerListEntry} which the
+ * server populates; returns {@code -1} when unavailable.
+ */
+public class NetworkModuleFabric implements NetworkModule {
+
+ private final MinecraftClient mc;
+
+ public NetworkModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ @Override
+ public boolean hasNetwork() {
+ return mc.getNetworkHandler() != null;
+ }
+
+ @Override
+ public int getPing() {
+ if (mc.player == null || mc.getNetworkHandler() == null)
+ return -1;
+
+ PlayerListEntry entry = mc.getNetworkHandler().getPlayerListEntry(mc.player.getUuid());
+
+ return entry != null ? entry.getLatency() : -1;
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/PlayerModuleFabric.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/PlayerModuleFabric.java
new file mode 100644
index 0000000..68be4b9
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/PlayerModuleFabric.java
@@ -0,0 +1,302 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.entity.effect.StatusEffectInstance;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+
+import org.saturnclient.common.module.PlayerModule;
+import org.saturnclient.common.ref.asset.SpriteRef;
+import org.saturnclient.common.ref.game.ItemStackRef;
+import org.saturnclient.feature.features.StatusEffectsFeature;
+import org.saturnclient.saturnclient.SaturnClient;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Fabric implementation of {@link PlayerModule}.
+ *
+ * All access to {@link MinecraftClient} and {@link ClientPlayerEntity}
+ * is encapsulated here. Features only ever see the {@link PlayerModule}
+ * interface; they never import Minecraft classes directly.
+ *
+ * Every method guards against a missing player and returns a safe
+ * default so features are free of null checks.
+ */
+public class PlayerModuleFabric implements PlayerModule {
+
+ private final MinecraftClient mc;
+
+ public PlayerModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ // ---------------------------------------------------------------
+ // Internal helper — never returns null
+ // ---------------------------------------------------------------
+
+ /** Returns the local player, or {@code null} if one is not loaded. */
+ private ClientPlayerEntity player() {
+ return mc.player;
+ }
+
+ // ---------------------------------------------------------------
+ // Presence
+ // ---------------------------------------------------------------
+
+ @Override
+ public boolean hasPlayer() {
+ return mc.player != null;
+ }
+
+ // ---------------------------------------------------------------
+ // Position
+ // ---------------------------------------------------------------
+
+ @Override
+ public int getX() {
+ return hasPlayer() ? (int) player().getX() : 0;
+ }
+
+ @Override
+ public int getY() {
+ return hasPlayer() ? (int) player().getY() : 0;
+ }
+
+ @Override
+ public int getZ() {
+ return hasPlayer() ? (int) player().getZ() : 0;
+ }
+
+ // ---------------------------------------------------------------
+ // Input / movement
+ // ---------------------------------------------------------------
+
+ @Override
+ public boolean isForwardPressed() {
+ return mc.options.forwardKey.isPressed();
+ }
+
+ @Override
+ public boolean isBackPressed() {
+ return mc.options.backKey.isPressed();
+ }
+
+ @Override
+ public boolean isLeftPressed() {
+ return mc.options.leftKey.isPressed();
+ }
+
+ @Override
+ public boolean isRightPressed() {
+ return mc.options.rightKey.isPressed();
+ }
+
+ @Override
+ public boolean isJumpPressed() {
+ return mc.options.jumpKey.isPressed();
+ }
+
+ @Override
+ public boolean isAttackPressed() {
+ return mc.options.attackKey.isPressed();
+ }
+
+ @Override
+ public boolean isUsePressed() {
+ return mc.options.useKey.isPressed();
+ }
+
+ @Override
+ public boolean isSneaking() {
+ return hasPlayer() && player().isSneaking();
+ }
+
+ @Override
+ public boolean isUsingItem() {
+ return hasPlayer() && player().isUsingItem();
+ }
+
+ @Override
+ public boolean hasHorizontalCollision() {
+ return hasPlayer() && player().horizontalCollision;
+ }
+
+ @Override
+ public boolean isOnGround() {
+ return hasPlayer() && player().isOnGround();
+ }
+
+ @Override
+ public void setSprinting(boolean sprinting) {
+ if (hasPlayer())
+ player().setSprinting(sprinting);
+ }
+
+ // ---------------------------------------------------------------
+ // Velocity
+ // ---------------------------------------------------------------
+
+ @Override
+ public Velocity getVelocity() {
+ if (!hasPlayer())
+ return Velocity.ZERO;
+ var v = player().getVelocity();
+ return new Velocity(v.x, v.y, v.z);
+ }
+
+ // ---------------------------------------------------------------
+ // Stats
+ // ---------------------------------------------------------------
+
+ @Override
+ public float getHealth() {
+ return hasPlayer() ? player().getHealth() : 0f;
+ }
+
+ // ---------------------------------------------------------------
+ // Equipment
+ // ---------------------------------------------------------------
+
+ @Override
+ public ItemStackRef getMainHand() {
+ return wrap(hasPlayer() ? player().getMainHandStack() : ItemStack.EMPTY);
+ }
+
+ @Override
+ public ItemStackRef getHelmet() {
+ return wrap(hasPlayer() ? player().getInventory().armor.get(3) : ItemStack.EMPTY);
+ }
+
+ @Override
+ public ItemStackRef getChestplate() {
+ return wrap(hasPlayer() ? player().getInventory().armor.get(2) : ItemStack.EMPTY);
+ }
+
+ @Override
+ public ItemStackRef getLeggings() {
+ return wrap(hasPlayer() ? player().getInventory().armor.get(1) : ItemStack.EMPTY);
+ }
+
+ @Override
+ public ItemStackRef getBoots() {
+ return wrap(hasPlayer() ? player().getInventory().armor.get(0) : ItemStack.EMPTY);
+ }
+
+ // ---------------------------------------------------------------
+ // Dummy equipment
+ // ---------------------------------------------------------------
+
+ @Override
+ public ItemStackRef getDummyMainHand() {
+ return wrap(new ItemStack(Items.DIAMOND_SWORD));
+ }
+
+ @Override
+ public ItemStackRef getDummyHelmet() {
+ return wrap(new ItemStack(Items.DIAMOND_HELMET));
+ }
+
+ @Override
+ public ItemStackRef getDummyChestplate() {
+ return wrap(new ItemStack(Items.DIAMOND_CHESTPLATE));
+ }
+
+ @Override
+ public ItemStackRef getDummyLeggings() {
+ return wrap(new ItemStack(Items.DIAMOND_LEGGINGS));
+ }
+
+ @Override
+ public ItemStackRef getDummyBoots() {
+ return wrap(new ItemStack(Items.DIAMOND_BOOTS));
+ }
+
+ // ---------------------------------------------------------------
+ // Status effects
+ // ---------------------------------------------------------------
+
+ @Override
+ public List extends StatusEffectsFeature.EffectView> getActiveEffects() {
+ if (!hasPlayer())
+ return Collections.emptyList();
+
+ var effects = player().getActiveStatusEffects().values();
+ List result = new ArrayList<>(effects.size());
+ for (StatusEffectInstance instance : effects) {
+ result.add(new FabricEffectView(instance));
+ }
+ return result;
+ }
+
+ @Override
+ public List extends StatusEffectsFeature.EffectView> getDummyEffects() {
+ if (!hasPlayer())
+ return Collections.emptyList();
+
+ var effects = player().getActiveStatusEffects().values();
+ List result = new ArrayList<>(effects.size());
+ for (StatusEffectInstance instance : effects) {
+ result.add(new FabricEffectView(instance));
+ }
+ return result;
+ }
+
+ // ---------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------
+
+ private static ItemStackRef wrap(ItemStack stack) {
+ return (ItemStackRef) (Object) stack;
+ }
+
+ // ---------------------------------------------------------------
+ // Inner — EffectView adapter
+ // ---------------------------------------------------------------
+
+ /**
+ * Bridges a Fabric {@link StatusEffectInstance} to the
+ * platform-neutral {@link StatusEffectsFeature.EffectView}.
+ */
+ public static final class FabricEffectView implements StatusEffectsFeature.EffectView {
+
+ private final StatusEffectInstance instance;
+
+ public FabricEffectView(StatusEffectInstance instance) {
+ this.instance = instance;
+ }
+
+ @Override
+ public boolean shouldShowIcon() {
+ return instance.shouldShowIcon();
+ }
+
+ /**
+ * Returns the Minecraft sprite identifier for this effect's icon.
+ * The platform-specific renderer knows how to draw a
+ * {@link net.minecraft.util.Identifier}.
+ */
+ @Override
+ public SpriteRef getIcon() {
+ return (SpriteRef) SaturnClient.client.getStatusEffectSpriteManager().getSprite(instance.getEffectType());
+ }
+
+ @Override
+ public boolean isInfinite() {
+ return instance.isInfinite();
+ }
+
+ @Override
+ public String getInfiniteText() {
+ return "∞";
+ }
+
+ @Override
+ public int getDurationSeconds() {
+ return instance.getDuration() / 20; // ticks → seconds
+ }
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/RenderModuleFabric.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/RenderModuleFabric.java
new file mode 100644
index 0000000..bfde729
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/RenderModuleFabric.java
@@ -0,0 +1,64 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.option.Perspective;
+
+import org.saturnclient.common.module.RenderModule;
+
+/**
+ * Fabric implementation of {@link RenderModule}.
+ *
+ * Window dimensions and FPS are read directly from
+ * {@link MinecraftClient}. Camera perspective control delegates to
+ * {@link net.minecraft.client.option.GameOptions}.
+ */
+public class RenderModuleFabric implements RenderModule {
+
+ private final MinecraftClient mc;
+
+ public RenderModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ // ---------------------------------------------------------------
+ // Window
+ // ---------------------------------------------------------------
+
+ @Override
+ public int getScaledWindowWidth() {
+ return mc.getWindow().getScaledWidth();
+ }
+
+ @Override
+ public int getScaledWindowHeight() {
+ return mc.getWindow().getScaledHeight();
+ }
+
+ // ---------------------------------------------------------------
+ // Performance
+ // ---------------------------------------------------------------
+
+ @Override
+ public int getFps() {
+ return mc.getCurrentFps();
+ }
+
+ // ---------------------------------------------------------------
+ // Camera perspective
+ // ---------------------------------------------------------------
+
+ @Override
+ public boolean isFirstPerson() {
+ return mc.options.getPerspective() == Perspective.FIRST_PERSON;
+ }
+
+ @Override
+ public void setFirstPerson() {
+ mc.options.setPerspective(Perspective.FIRST_PERSON);
+ }
+
+ @Override
+ public void setThirdPersonBack() {
+ mc.options.setPerspective(Perspective.THIRD_PERSON_BACK);
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/WorldModuleFabric.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/WorldModuleFabric.java
new file mode 100644
index 0000000..b555a23
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/WorldModuleFabric.java
@@ -0,0 +1,36 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+
+import org.saturnclient.common.module.WorldModule;
+
+/**
+ * Fabric implementation of {@link WorldModule}.
+ *
+ * Reads the client world's time directly from {@link MinecraftClient}.
+ * Returns safe defaults when no world is loaded.
+ */
+public class WorldModuleFabric implements WorldModule {
+
+ private final MinecraftClient mc;
+
+ public WorldModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ @Override
+ public boolean hasWorld() {
+ return mc.world != null;
+ }
+
+ /**
+ * Returns the total world age in ticks, or {@code 0} when no world
+ * is loaded. Used by {@link TpsFeature#onTimePacket} (which is
+ * called from the mixin and stores its own copy) and by
+ * {@link DayCounterFeature} for the day count.
+ */
+ @Override
+ public long getWorldAge() {
+ return hasWorld() ? mc.world.getTime() : 0L;
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/GameRendererMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/GameRendererMixin.java
new file mode 100644
index 0000000..3dfd659
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/GameRendererMixin.java
@@ -0,0 +1,37 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.render.GameRenderer;
+
+import org.saturnclient.feature.features.ZoomFeature;
+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.CallbackInfoReturnable;
+
+/**
+ * Overrides the game's field-of-view when {@link ZoomFeature} is active.
+ *
+ * The old code checked both {@code Zoom.isZooming} and
+ * {@code Zoom.shouldZoom()} separately. The refactored
+ * {@link ZoomFeature#shouldZoom()} already ORs both conditions
+ * ({@code enabled.value && isZooming}), so only one check is needed.
+ *
+ * No {@link org.saturnclient.common.module.ModuleProvider} is required
+ * here because zooming is a pure render-time override with no per-tick
+ * engine queries.
+ */
+@Mixin(GameRenderer.class)
+public class GameRendererMixin {
+
+ /**
+ * After the vanilla FOV has been computed, divide it by the zoom
+ * level to produce the narrowed field-of-view.
+ */
+ @Inject(method = "getFov", at = @At("RETURN"), cancellable = true)
+ private void onGetFov(CallbackInfoReturnable cir) {
+ if (ZoomFeature.shouldZoom()) {
+ float zoomedFov = cir.getReturnValue() / ZoomFeature.getZoomLevel();
+ cir.setReturnValue(zoomedFov);
+ }
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/LightmapTextureManagerMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/LightmapTextureManagerMixin.java
new file mode 100644
index 0000000..564e781
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/LightmapTextureManagerMixin.java
@@ -0,0 +1,35 @@
+package org.saturnclient.impl.modules.mixins;
+
+// Code approach adapted from Gamma Utils: https://github.com/Sjouwer/gamma-utils
+
+import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
+import net.minecraft.client.render.LightmapTextureManager;
+
+import org.saturnclient.feature.features.FullbrightFeature;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+
+/**
+ * Overrides the lightmap's gamma computation when
+ * {@link FullbrightFeature} is active, replacing the vanilla
+ * brightness with a user-configured value.
+ *
+ * The old import was {@code org.saturnclient.feature.features.Fullbright};
+ * it is now {@link FullbrightFeature}. The injection target and
+ * strategy are unchanged.
+ */
+@Mixin(LightmapTextureManager.class)
+public class LightmapTextureManagerMixin {
+
+ /**
+ * If fullbright is active, substitute the feature's brightness
+ * value for whatever vanilla calculated.
+ */
+ @ModifyExpressionValue(method = "update", at = @At(value = "INVOKE", target = "Ljava/lang/Math;max(FF)F", ordinal = 0))
+ private float modifyGamma(float original) {
+ if (FullbrightFeature.shouldOverrideBrightness()) {
+ return FullbrightFeature.getBrightnessValue();
+ }
+ return original;
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/LivingEntityRendererMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/LivingEntityRendererMixin.java
new file mode 100644
index 0000000..f67bc95
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/LivingEntityRendererMixin.java
@@ -0,0 +1,55 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.render.entity.LivingEntityRenderer;
+// import net.minecraft.client.render.entity.state.LivingEntityRenderState;
+import net.minecraft.entity.LivingEntity;
+import net.minecraft.entity.mob.HostileEntity;
+import net.minecraft.entity.passive.PassiveEntity;
+import net.minecraft.entity.player.PlayerEntity;
+
+import org.saturnclient.common.module.EntityModule;
+import org.saturnclient.impl.modules.HealthRenderState;
+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;
+
+/**
+ * Captures each living entity's health and type into the render state
+ * during {@code updateRenderState}, so that nametag rendering can read
+ * them without touching the live entity again.
+ *
+ * Previously imported {@code NametagsInterface.EntityType}; now uses
+ * {@link EntityModule.EntityType} — the canonical enum shared across
+ * the whole module system.
+ *
+ * The injection target and strategy are unchanged from the original.
+ */
+@Mixin(LivingEntityRenderer.class)
+public abstract class LivingEntityRendererMixin {
+}
+// public abstract class LivingEntityRendererMixin {
+
+// @Inject(method = "updateRenderState", at = @At("TAIL"))
+// private void saturn$captureHealth(T entity, S state, float tickDelta,
+// CallbackInfo ci) {
+// if (!(state instanceof HealthRenderState hrs))
+// return;
+
+// hrs.saturn$setHealth(entity.getHealth(), entity.getMaxHealth());
+
+// // Classify entity type for the nametag feature's filtering logic
+// EntityModule.EntityType type;
+// if (entity instanceof PlayerEntity)
+// type = EntityModule.EntityType.PLAYER;
+// else if (entity instanceof HostileEntity)
+// type = EntityModule.EntityType.HOSTILE;
+// else if (entity instanceof PassiveEntity)
+// type = EntityModule.EntityType.PASSIVE;
+// else
+// type = EntityModule.EntityType.OTHER;
+
+// hrs.saturn$setEntityType(type);
+// }
+// }
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/NametagsMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/NametagsMixin.java
new file mode 100644
index 0000000..a721d04
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/NametagsMixin.java
@@ -0,0 +1,94 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.entity.EntityRenderer;
+// import net.minecraft.client.render.entity.state.EntityRenderState;
+// import net.minecraft.client.render.entity.state.LivingEntityRenderState;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.text.Text;
+
+import org.saturnclient.feature.features.NametagsFeature;
+import org.saturnclient.impl.modules.EntityModuleFabric;
+import org.saturnclient.impl.modules.HealthRenderState;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+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;
+
+/**
+ * Intercepts nametag rendering and replaces the displayed text with
+ * the string produced by {@link NametagsFeature#getNametagString}.
+ *
+ * Key changes from the original:
+ *
+ *
+ * - The old {@code NametagsFabric.EntityStateImpl} bridge class is
+ * replaced by {@link EntityModuleFabric.RenderStateEntityState},
+ * which wraps the {@link HealthRenderState} already injected into
+ * the render state by {@link LivingEntityRenderStateMixin}.
+ * - The feature import changes from {@code Nametags} to
+ * {@link NametagsFeature}; the two static methods
+ * ({@code shouldReplaceName} / {@code getNametagString}) are
+ * unchanged in signature.
+ * - The entity's display name is extracted from the render state's
+ * {@code nameVisible} / text fields where available, falling back
+ * to the plain text string passed to this method.
+ *
+ *
+ * The re-entrancy guard ({@code saturn$rendering}) is kept intact to
+ * prevent infinite recursion when we call {@code renderLabelIfPresent}
+ * with the replacement text.
+ */
+@Mixin(EntityRenderer.class)
+public abstract class NametagsMixin {
+
+ /** Guards against the recursive call we make with the replacement text. */
+ @Unique
+ private static final ThreadLocal saturn$rendering = ThreadLocal.withInitial(() -> false);
+
+ // @Shadow
+ // protected abstract void renderLabelIfPresent(
+ // S state, Text text,
+ // MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light);
+
+ // @Inject(method = "renderLabelIfPresent", at = @At("HEAD"), cancellable = true)
+ // private void saturn$replaceNametag(
+ // S state, Text text,
+ // MatrixStack matrices, VertexConsumerProvider vertexConsumers,
+ // int light, CallbackInfo ci) {
+
+ // // Skip our own recursive call
+ // if (saturn$rendering.get())
+ // return;
+
+ // if (!NametagsFeature.shouldReplaceName())
+ // return;
+
+ // // Only living entities carry health data
+ // if (!(state instanceof LivingEntityRenderState living))
+ // return;
+ // if (!(living instanceof HealthRenderState hrs))
+ // return;
+
+ // // Build the platform-neutral EntityState from the render-state snapshot
+ // // (health/type were written by LivingEntityRendererMixin at extract time).
+ // String customName = text != null ? text.getString() : null;
+ // EntityModuleFabric.RenderStateEntityState entityState = new EntityModuleFabric.RenderStateEntityState(
+ // customName, hrs);
+
+ // // Ask the feature for the replacement string
+ // String replacement = NametagsFeature.getNametagString(entityState);
+ // if (replacement == null)
+ // return; // feature decided not to replace this entity
+
+ // ci.cancel();
+ // saturn$rendering.set(true);
+ // try {
+ // renderLabelIfPresent(state, Text.literal(replacement), matrices, vertexConsumers, light);
+ // } finally {
+ // saturn$rendering.set(false);
+ // }
+ // }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/RenderMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/RenderMixin.java
new file mode 100644
index 0000000..7b45975
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/RenderMixin.java
@@ -0,0 +1,85 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.hud.InGameHud;
+import net.minecraft.client.render.RenderTickCounter;
+
+import org.saturnclient.feature.Feature;
+import org.saturnclient.feature.FeatureLayout;
+import org.saturnclient.feature.FeatureManager;
+import org.saturnclient.feature.HudFeature;
+import org.saturnclient.impl.ui.RenderScopeImpl;
+import org.saturnclient.impl.ui.SaturnScreenFabric;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.saturnclient.saturnclient.mixin.DrawContextAccessor;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.screens.HudEditor;
+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;
+
+/**
+ * Drives per-frame rendering for all registered features.
+ *
+ * This mixin is intentionally kept thin: it creates a {@link RenderScope},
+ * iterates the enabled feature list from {@link FeatureManager}, and
+ * dispatches either {@link HudFeature#renderHud} (for HUD elements) or
+ * {@link Feature#render} (for world-overlay features like Crosshair).
+ *
+ * No feature-specific logic lives here — all decisions about what to
+ * draw are made inside each feature class.
+ *
+ * Changes from the original:
+ * - Package imports updated to the refactored feature names (no
+ * more {@code feature.features.*}).
+ * - The HUD-editor screen check still uses the existing
+ * {@link SaturnScreenFabric} / {@link HudEditor} pair; that is
+ * unchanged infrastructure.
+ */
+@Mixin(InGameHud.class)
+public class RenderMixin {
+
+ @Inject(method = "renderChat", at = @At("TAIL"))
+ public void renderMods(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) {
+ TextRenderer textRenderer = SaturnClient.client.textRenderer;
+
+ // Skip rendering while the HUD editor is open so the editor
+ // can draw its own preview layout without double-drawing.
+ if (textRenderer == null)
+ return;
+ if (SaturnClient.client.currentScreen instanceof SaturnScreenFabric f
+ && f.screen instanceof HudEditor)
+ return;
+
+ RenderScope scope = new RenderScopeImpl(
+ context.getMatrices(),
+ ((DrawContextAccessor) context).getVertexConsumers());
+
+ for (Feature feature : FeatureManager.ENABLED_MODS) {
+ if (!feature.isEnabled())
+ continue;
+
+ if (feature instanceof HudFeature hud) {
+ FeatureLayout dim = hud.getDimensions();
+
+ scope.getMatrixStack().push();
+ scope.getMatrixStack().translate(dim.x.value, (float) dim.y.value, 0f);
+ scope.getMatrixStack().scale(dim.scale.value, dim.scale.value, 1.0f);
+
+ if (dim.renderBackground) {
+ scope.drawRoundedRectangle(0, 0, dim.width, dim.height,
+ dim.radius.value, dim.bgColor.value);
+ }
+
+ hud.renderHud(scope);
+ scope.getMatrixStack().pop();
+
+ } else {
+ // Non-HUD features (e.g. CrosshairFeature) use the plain render hook.
+ feature.render(scope);
+ }
+ }
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/TpsMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/TpsMixin.java
new file mode 100644
index 0000000..0e452a7
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/modules/mixins/TpsMixin.java
@@ -0,0 +1,30 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.network.ClientPlayNetworkHandler;
+import net.minecraft.network.packet.s2c.play.WorldTimeUpdateS2CPacket;
+
+import org.saturnclient.feature.features.TpsFeature;
+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;
+
+/**
+ * Feeds world-time packets into {@link TpsFeature}'s TPS estimator.
+ *
+ * This is the only place in the entire codebase that reads a network
+ * packet and passes data to a feature. The feature's
+ * {@link TpsFeature#onTimePacket(long)} method handles all averaging
+ * logic; the mixin is intentionally reduced to a single forwarding call.
+ *
+ * The only change from the original is renaming the import from
+ * {@code feature.features.Tps} to {@link TpsFeature}.
+ */
+@Mixin(ClientPlayNetworkHandler.class)
+public class TpsMixin {
+
+ @Inject(method = "onWorldTimeUpdate", at = @At("HEAD"))
+ private void onWorldTimeUpdate(WorldTimeUpdateS2CPacket packet, CallbackInfo ci) {
+ TpsFeature.onTimePacket(packet.getTime());
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/GLFWProviderImpl.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/GLFWProviderImpl.java
new file mode 100644
index 0000000..f8a896f
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/GLFWProviderImpl.java
@@ -0,0 +1,18 @@
+package org.saturnclient.impl.provider;
+
+import org.lwjgl.glfw.GLFW;
+import org.saturnclient.common.provider.GLFWProvider;
+import org.saturnclient.saturnclient.SaturnClient;
+
+public class GLFWProviderImpl implements GLFWProvider {
+ @Override
+ public boolean isKeyPressed(int key) {
+ return GLFW.glfwGetKey(SaturnClient.client.getWindow().getHandle(), key) == GLFW.GLFW_PRESS
+ && SaturnClient.client.currentScreen == null;
+ }
+
+ @Override
+ public String getKeyName(int key) {
+ return GLFW.glfwGetKeyName(key, GLFW.glfwGetKeyScancode(key));
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/ModuleProviderFabric.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/ModuleProviderFabric.java
new file mode 100644
index 0000000..b74f7c6
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/ModuleProviderFabric.java
@@ -0,0 +1,68 @@
+package org.saturnclient.impl.provider;
+
+import net.minecraft.client.MinecraftClient;
+
+import org.saturnclient.common.provider.ModuleProvider;
+import org.saturnclient.impl.modules.EntityModuleFabric;
+import org.saturnclient.impl.modules.NetworkModuleFabric;
+import org.saturnclient.impl.modules.PlayerModuleFabric;
+import org.saturnclient.impl.modules.RenderModuleFabric;
+import org.saturnclient.impl.modules.WorldModuleFabric;
+import org.saturnclient.common.module.*;
+
+/**
+ * Fabric implementation of {@link ModuleProvider}.
+ *
+ * Instantiated once during client startup (e.g. in the mod initialiser)
+ * and passed to every feature constructor. All five sub-modules share
+ * the same {@link MinecraftClient} reference so there is no
+ * inconsistency between modules reading state within the same tick.
+ *
+ * {@code
+ * ModuleProvider provider = new ModuleProviderFabric(MinecraftClient.getInstance());
+ * FeatureManager.register(new AutoSprintFeature(provider));
+ * FeatureManager.register(new CoordinatesFeature(provider));
+ * // …
+ * }
+ */
+public class ModuleProviderFabric implements ModuleProvider {
+
+ private final PlayerModule player;
+ private final WorldModule world;
+ private final EntityModule entity;
+ private final RenderModule render;
+ private final NetworkModule network;
+
+ public ModuleProviderFabric(MinecraftClient mc) {
+ this.player = new PlayerModuleFabric(mc);
+ this.world = new WorldModuleFabric(mc);
+ this.entity = new EntityModuleFabric(mc);
+ this.render = new RenderModuleFabric(mc);
+ this.network = new NetworkModuleFabric(mc);
+ }
+
+ @Override
+ public PlayerModule player() {
+ return player;
+ }
+
+ @Override
+ public WorldModule world() {
+ return world;
+ }
+
+ @Override
+ public EntityModule entity() {
+ return entity;
+ }
+
+ @Override
+ public RenderModule render() {
+ return render;
+ }
+
+ @Override
+ public NetworkModule network() {
+ return network;
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/RefConstructorImpl.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/RefConstructorImpl.java
new file mode 100644
index 0000000..cf59bd7
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/RefConstructorImpl.java
@@ -0,0 +1,25 @@
+package org.saturnclient.impl.provider;
+
+import org.saturnclient.common.provider.RefConstructorProvider;
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.render.TextRef;
+
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+
+public class RefConstructorImpl implements RefConstructorProvider {
+ @Override
+ public IdentifierRef identifier(String fullPath) {
+ return (IdentifierRef) (Object) Identifier.of(fullPath);
+ }
+
+ @Override
+ public IdentifierRef identifier(String namespace, String path) {
+ return (IdentifierRef) (Object) Identifier.of(namespace, path);
+ }
+
+ @Override
+ public TextRef text(String text) {
+ return (TextRef) Text.literal(text);
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/SaturnProviderImpl.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/SaturnProviderImpl.java
new file mode 100644
index 0000000..61238c9
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/provider/SaturnProviderImpl.java
@@ -0,0 +1,72 @@
+package org.saturnclient.impl.provider;
+
+import java.awt.image.BufferedImage;
+import java.util.UUID;
+
+import org.saturnclient.common.provider.SaturnProvider;
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.game.MinecraftClientRef;
+import org.saturnclient.impl.cosmetics.utils.IdentifierUtils;
+import org.saturnclient.saturnclient.SaturnClient;
+
+import dev.kosmx.playerAnim.api.layered.AnimationStack;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
+import net.minecraft.client.network.AbstractClientPlayerEntity;
+import net.minecraft.util.Identifier;
+
+public class SaturnProviderImpl implements SaturnProvider {
+ @Override
+ public MinecraftClientRef getClient() {
+ return (MinecraftClientRef) SaturnClient.client;
+ }
+
+ @Override
+ public void playEmote(UUID fromPlayerUuid, String emoteIdOrNull) {
+ if (SaturnClient.client == null || SaturnClient.client.world == null) {
+ return;
+ }
+
+ for (AbstractClientPlayerEntity player : SaturnClient.client.world.getPlayers()) {
+ if (!player.getUuid().equals(fromPlayerUuid)) {
+ continue;
+ }
+
+ AnimationStack animationStack = PlayerAnimationAccess.getPlayerAnimLayer(player);
+
+ if (emoteIdOrNull != null && !emoteIdOrNull.isEmpty()) {
+ if (animationStack.isActive()) {
+ animationStack.removeLayer(1000);
+ }
+ animationStack.addAnimLayer(1000,
+ PlayerAnimationRegistry
+ .getAnimation(Identifier.of("saturnclient", emoteIdOrNull))
+ .playAnimation());
+ } else {
+ animationStack.removeLayer(1000);
+ }
+
+ break;
+ }
+ }
+
+ @Override
+ public void logInfo(String message) {
+ SaturnClient.LOGGER.info(message);
+ }
+
+ @Override
+ public void logError(String message) {
+ SaturnClient.LOGGER.error(message);
+ }
+
+ @Override
+ public void logError(String message, Throwable throwable) {
+ SaturnClient.LOGGER.error(message, throwable);
+ }
+
+ @Override
+ public void registerBufferedImageTexture(IdentifierRef i, BufferedImage bi) {
+ IdentifierUtils.registerBufferedImageTextureFast(i, bi);
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/EntityDrawerImpl.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/EntityDrawerImpl.java
new file mode 100644
index 0000000..d30b6f5
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/EntityDrawerImpl.java
@@ -0,0 +1,85 @@
+package org.saturnclient.impl.ui;
+
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaternionf;
+import org.joml.Vector3f;
+import org.saturnclient.common.ref.render.QuaternionfRef;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.saturnclient.ui.ElementContext;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.components.SkinPreview.EntityDrawer;
+
+import net.minecraft.client.render.DiffuseLighting;
+import net.minecraft.client.render.entity.EntityRenderDispatcher;
+import net.minecraft.entity.LivingEntity;
+
+public class EntityDrawerImpl implements EntityDrawer {
+ public static void drawEntity(RenderScope renderScope, int x1, int y1, int x2, int y2, int size, float f,
+ float mouseX,
+ float mouseY, LivingEntity entity, float angle, boolean negativeAngle) {
+ float g = (float) (x1 + x2) / 2.0F;
+ float h = (float) (y1 + y2) / 2.0F;
+ renderScope.enableScissor(0, 6, x2, y2);
+ float i = (float) Math.atan((double) ((g - mouseX) / 40.0F));
+ float j = (float) Math.atan((double) ((h - mouseY) / 40.0F));
+ Quaternionf quaternionf = (new Quaternionf()).rotateZ(3.1415927F);
+ float k = entity.bodyYaw;
+ float l = entity.getYaw();
+ float m = entity.getPitch();
+ float n = entity.prevHeadYaw;
+ float o = entity.headYaw;
+ entity.setYaw(angle);
+ entity.bodyYaw = angle;
+ entity.setPitch(-(j * 20.0F));
+ entity.headYaw = negativeAngle ? (i * 40.0F) + 180f : -(i * 40.0F);
+ entity.prevHeadYaw = entity.getHeadYaw();
+ entity.setCustomNameVisible(false);
+
+ float p = entity.getScale();
+ Vector3f vector3f = new Vector3f(0.0F, entity.getHeight() / 2.0F + f * p, 0.0F);
+ float q = (float) size / p;
+ drawEntity(renderScope, g, h, q, vector3f, quaternionf, null, entity);
+
+ entity.setCustomNameVisible(true);
+ entity.bodyYaw = k;
+ entity.setYaw(l);
+ entity.setPitch(m);
+ entity.prevHeadYaw = n;
+ entity.headYaw = o;
+ renderScope.disableScissor();
+ }
+
+ public static void drawEntity(RenderScope renderScope, float x, float y, float size, Vector3f vector3f,
+ Quaternionf quaternionf, @Nullable Quaternionf quaternionf2, LivingEntity entity) {
+ renderScope.getMatrixStack().push();
+ renderScope.getMatrixStack().translate((double) x, (double) y, 50.0);
+ renderScope.getMatrixStack().scale(size, size, -size);
+ renderScope.getMatrixStack().translate(vector3f.x, vector3f.y, vector3f.z);
+ renderScope.getMatrixStack().multiply((QuaternionfRef) quaternionf);
+ renderScope.draw();
+ DiffuseLighting.method_34742();
+ EntityRenderDispatcher entityRenderDispatcher = SaturnClient.client.getEntityRenderDispatcher();
+
+ entityRenderDispatcher.setRenderShadows(false);
+ if (renderScope instanceof RenderScopeImpl r) {
+ r.draw((vertexConsumers) -> {
+ entityRenderDispatcher.render(entity, 0.0, 0.0, 0.0, 1.0F, 1.0f,
+ r.matrices, vertexConsumers,
+ 15728880);
+ });
+ }
+ renderScope.draw();
+ entityRenderDispatcher.setRenderShadows(true);
+ renderScope.getMatrixStack().pop();
+ DiffuseLighting.enableGuiDepthLighting();
+ }
+
+ @Override
+ public void render(RenderScope renderScope, boolean negativeAngle, float angle, float scale, ElementContext ctx) {
+ LivingEntity entity = SaturnClient.client.player;
+ if (entity != null) {
+ drawEntity(renderScope, 0, 0, 75, 78, 30, 0.0625F, ctx.mouseX / scale, ctx.mouseY / scale, entity, angle,
+ negativeAngle);
+ }
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/RenderScopeImpl.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/RenderScopeImpl.java
new file mode 100644
index 0000000..1acc300
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/RenderScopeImpl.java
@@ -0,0 +1,501 @@
+package org.saturnclient.impl.ui;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import org.jetbrains.annotations.Nullable;
+import org.joml.Matrix4f;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+
+import org.saturnclient.common.provider.Providers;
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.asset.SpriteRef;
+import org.saturnclient.common.ref.game.ItemStackRef;
+import org.saturnclient.common.ref.render.MatrixStackRef;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.saturnclient.saturnclient.mixin.DrawContextAccessor;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.resources.Fonts;
+import org.saturnclient.ui.resources.SvgTexture;
+
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.font.TextRenderer.TextLayerType;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.ScreenRect;
+import net.minecraft.client.render.BufferBuilder;
+import net.minecraft.client.render.BufferRenderer;
+import net.minecraft.client.render.DiffuseLighting;
+import net.minecraft.client.render.GameRenderer;
+import net.minecraft.client.render.OverlayTexture;
+import net.minecraft.client.render.RenderLayer;
+import net.minecraft.client.render.Tessellator;
+import net.minecraft.client.render.VertexConsumer;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.VertexFormat.DrawMode;
+import net.minecraft.client.render.VertexFormats;
+import net.minecraft.client.render.model.BakedModel;
+import net.minecraft.client.render.model.json.ModelTransformationMode;
+import net.minecraft.client.texture.Sprite;
+import net.minecraft.client.util.Window;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.entity.LivingEntity;
+import net.minecraft.item.ItemStack;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.crash.CrashException;
+import net.minecraft.util.crash.CrashReport;
+import net.minecraft.util.crash.CrashReportSection;
+import net.minecraft.util.math.RotationAxis;
+import net.minecraft.world.World;
+
+public class RenderScopeImpl implements RenderScope {
+ public MatrixStack matrices;
+ public VertexConsumerProvider.Immediate vertexConsumers;
+ private ScissorStack scissorStack = new ScissorStack();
+ private int opacity = 255 << 24;
+
+ public RenderScopeImpl(DrawContext context) {
+ this(context.getMatrices(), ((DrawContextAccessor) context).getVertexConsumers());
+ }
+
+ public RenderScopeImpl(MatrixStack matrices, VertexConsumerProvider.Immediate vertexConsumers) {
+ this.matrices = matrices;
+ this.vertexConsumers = vertexConsumers;
+ this.scissorStack = new ScissorStack();
+ }
+
+ @Override
+ public MatrixStackRef getMatrixStack() {
+ return (MatrixStackRef) matrices;
+ }
+
+ @Override
+ public void setOpacity(float alpha) {
+ this.opacity = (int) (alpha * 255) << 24;
+ }
+
+ @Override
+ public int getColor(int color) {
+ int originalAlpha = (color >>> 24) & 0xFF;
+ int newAlpha = (opacity >>> 24) & 0xFF;
+ int mixedAlpha = (originalAlpha * newAlpha) / 255;
+ return (mixedAlpha << 24) | (color & 0x00FFFFFF);
+ }
+
+ @Override
+ public void drawRect(int x, int y, int width, int height, int color) {
+ if (color == 0)
+ return;
+ color = getColor(color);
+ Matrix4f matrix4f = this.matrices.peek().getPositionMatrix();
+
+ int x1 = x;
+ int x2 = x + width;
+ int y1 = y;
+ int y2 = y + height;
+
+ int i;
+ if (x1 < x2) {
+ i = x1;
+ x1 = x2;
+ x2 = i;
+ }
+
+ if (y1 < y2) {
+ i = y1;
+ y1 = y2;
+ y2 = i;
+ }
+
+ VertexConsumer vertexConsumer = this.vertexConsumers.getBuffer(RenderLayer.getGui());
+ vertexConsumer.vertex(matrix4f, (float) x1, (float) y1, 0).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x1, (float) y2, 0).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x2, (float) y2, 0).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x2, (float) y1, 0).color(color);
+ }
+
+ @Override
+ public void drawText(String text, int x, int y, int font, int color) {
+ drawText(1.0f, text, x, y, font, color);
+ }
+
+ @Override
+ public void drawText(float scale, String text, int x, int y, int font, int color) {
+ if (color == 0)
+ return;
+ if (font == 0) {
+ scale *= 2;
+ }
+ int i = 0;
+ for (String line : text.split("\n")) {
+ color = getColor(color);
+ matrices.push();
+ matrices.translate(x, y + (i * Fonts.getHeight()), 0);
+ matrices.scale(scale, scale, 1.0f);
+ TextRenderer textRenderer = SaturnClient.client.textRenderer;
+ textRenderer.draw((Text) Fonts.setFont(line, font), 0,
+ font == 0 ? 1 : 7, color, false,
+ this.matrices.peek().getPositionMatrix(),
+ this.vertexConsumers, TextLayerType.NORMAL, 0, 15728880);
+ matrices.pop();
+ i++;
+ }
+ }
+
+ private void drawRoundedCorner(int width, int height, int radius, int color) {
+ int w = width * 20;
+ int h = height * 20;
+
+ for (int y = 0; y < h; y++) {
+ int startX = 0;
+
+ if (y < radius) {
+ double dy = radius - y - 0.5;
+ double dx = Math.sqrt(Math.max(0, radius * radius - dy * dy));
+ startX = radius - (int) dx;
+ }
+
+ this.drawRect(startX, y, w - startX, 1, color);
+ }
+ }
+
+ private void drawRoundedSide(int cornerWidth, int cornerHeight, int radius, int color) {
+ // Top
+ this.matrices.push();
+
+ this.matrices.scale(0.05f, 0.05f, 1.0f);
+
+ this.drawRoundedCorner(cornerWidth, cornerHeight, radius * 10, color);
+
+ this.matrices.pop();
+
+ // Bottom
+ this.matrices.push();
+
+ this.matrices.translate(cornerWidth, cornerHeight * 2, 0);
+
+ this.matrices.multiply(RotationAxis.NEGATIVE_Z.rotationDegrees(90));
+
+ this.matrices.translate(0, -cornerWidth, 0);
+
+ this.matrices.scale(0.05f, 0.05f, 1.0f);
+
+ this.drawRoundedCorner(cornerHeight, cornerWidth, radius * 10, color);
+
+ this.matrices.pop();
+ }
+
+ @Override
+ public void drawRoundedRectangle(int x, int y, int width, int height, int radius, int color) {
+ if (color == 0)
+ return;
+ radius = Math.min(radius, Math.min(width, height));
+ int cornerWidth = width / 2;
+ int cornerHeight = height / 2;
+
+ this.matrices.push();
+
+ this.matrices.translate(x, y, 0);
+
+ this.drawRoundedSide(cornerWidth, cornerHeight, radius, color);
+
+ this.matrices.translate(cornerWidth * 2, cornerHeight * 2, 0);
+
+ this.matrices.multiply(RotationAxis.NEGATIVE_Z.rotationDegrees(180));
+
+ this.matrices.translate(0, 0, 0);
+
+ this.drawRoundedSide(cornerWidth, cornerHeight, radius, color);
+
+ this.matrices.pop();
+ }
+
+ @Override
+ public int getScaledWindowWidth() {
+ return SaturnClient.client.getWindow().getScaledWidth();
+ }
+
+ @Override
+ public int getScaledWindowHeight() {
+ return SaturnClient.client.getWindow().getScaledHeight();
+ }
+
+ @Override
+ public void drawTexture(IdentifierRef sprite, int x, int y, float u, float v, int width, int height, int color) {
+ this.drawTexture(sprite, x, y, u, v, width, height, width, height, width, height, color);
+ }
+
+ @Override
+ public void drawTexture(IdentifierRef sprite, int x, int y, float u, float v, int width, int height) {
+ this.drawTexture(sprite, x, y, u, v, width, height, width, height, width, height);
+ }
+
+ @Override
+ public void drawTexture(IdentifierRef sprite, int x, int y, float u, float v, int width, int height,
+ int regionWith,
+ int regionHeight, int textureWidth, int textureHeight) {
+ this.drawTexture(sprite, x, y, u, v, width, height, regionWith, regionHeight, textureWidth, textureHeight, -1);
+ }
+
+ @Override
+ public void drawTexture(IdentifierRef sprite, int x, int y, float u, float v, int width, int height,
+ int regionWidth,
+ int regionHeight, int textureWidth, int textureHeight, int color) {
+ this.drawTexturedQuad((Identifier) (Object) sprite, x, x + width, y, y + height,
+ (u + 0.0F) / (float) textureWidth,
+ (u + (float) regionWidth) / (float) textureWidth, (v + 0.0F) / (float) textureHeight,
+ (v + (float) regionHeight) / (float) textureHeight, color);
+ }
+
+ /* Minecraft sprites */
+
+ public void drawTexture(Identifier sprite, int x, int y, float u, float v, int width, int height, int color) {
+ this.drawTexture(sprite, x, y, u, v, width, height, width, height, width, height, color);
+ }
+
+ public void drawTexture(Identifier sprite, int x, int y, float u, float v, int width, int height) {
+ this.drawTexture(sprite, x, y, u, v, width, height, width, height, width, height);
+ }
+
+ public void drawTexture(Identifier sprite, int x, int y, float u, float v, int width, int height, int regionWith,
+ int regionHeight, int textureWidth, int textureHeight) {
+ this.drawTexture(sprite, x, y, u, v, width, height, regionWith, regionHeight, textureWidth, textureHeight, -1);
+ }
+
+ public void drawTexture(Identifier sprite, int x, int y, float u, float v, int width, int height, int regionWidth,
+ int regionHeight, int textureWidth, int textureHeight, int color) {
+ this.drawTexturedQuad(sprite, x, x + width, y, y + height, (u + 0.0F) / (float) textureWidth,
+ (u + (float) regionWidth) / (float) textureWidth, (v + 0.0F) / (float) textureHeight,
+ (v + (float) regionHeight) / (float) textureHeight, color);
+ }
+
+ private void drawTexturedQuad(Identifier sprite, int x1, int x2, int y1, int y2, float u1, float u2, float v1,
+ float v2, int color) {
+
+ if (sprite.toString().endsWith(".svg")) {
+ sprite = (Identifier) (Object) SvgTexture.getSvg(Providers.saturn.getClient(),
+ (IdentifierRef) (Object) sprite,
+ (x2 - x1) * 2, (y2 - y1) * 2);
+ }
+
+ if (color == 0)
+ return;
+ x1 *= 4;
+ x2 *= 4;
+ y1 *= 4;
+ y2 *= 4;
+ color = getColor(color);
+
+ matrices.push();
+ matrices.scale(0.25f, 0.25f, 1.0f);
+
+ RenderSystem.setShaderTexture(0, sprite);
+ RenderSystem.setShader(GameRenderer::getPositionTexProgram);
+ Matrix4f matrix4f = this.matrices.peek().getPositionMatrix();
+ BufferBuilder bufferBuilder = Tessellator.getInstance().begin(DrawMode.QUADS, VertexFormats.POSITION_TEXTURE);
+ bufferBuilder.vertex(matrix4f, (float) x1, (float) y1, 0f).texture(u1, v1);
+ bufferBuilder.vertex(matrix4f, (float) x1, (float) y2, 0f).texture(u1, v2);
+ bufferBuilder.vertex(matrix4f, (float) x2, (float) y2, 0f).texture(u2, v2);
+ bufferBuilder.vertex(matrix4f, (float) x2, (float) y1, 0f).texture(u2, v1);
+ BufferRenderer.drawWithGlobalProgram(bufferBuilder.end());
+
+ matrices.pop();
+ }
+
+ @Override
+ public void enableScissor(int x1, int y1, int x2, int y2) {
+ this.setScissor(this.scissorStack.push(new ScreenRect(x1, y1, x2 - x1, y2 - y1)));
+ }
+
+ public void disableScissor() {
+ this.setScissor(this.scissorStack.pop());
+ }
+
+ public boolean scissorContains(int x, int y) {
+ return this.scissorStack.contains(x, y);
+ }
+
+ private void setScissor(@Nullable ScreenRect rect) {
+ if (rect != null) {
+ Window window = SaturnClient.client.getWindow();
+ int i = window.getFramebufferHeight();
+ double d = window.getScaleFactor();
+ double e = (double) rect.getLeft() * d;
+ double f = (double) i - (double) rect.getBottom() * d;
+ double g = (double) rect.width() * d;
+ double h = (double) rect.height() * d;
+ RenderSystem.enableScissor((int) e, (int) f, Math.max(0, (int) g), Math.max(0, (int) h));
+ } else {
+ RenderSystem.disableScissor();
+ }
+
+ }
+
+ @Override
+ public void draw() {
+ this.vertexConsumers.draw();
+ }
+
+ public void draw(Consumer drawer) {
+ drawer.accept(this.vertexConsumers);
+ this.vertexConsumers.draw();
+ }
+
+ static class ScissorStack {
+ private final Deque stack = new ArrayDeque<>();
+
+ ScissorStack() {
+ }
+
+ public ScreenRect push(ScreenRect rect) {
+ ScreenRect screenRect = (ScreenRect) this.stack.peekLast();
+ if (screenRect != null) {
+ ScreenRect screenRect2 = (ScreenRect) Objects.requireNonNullElse(rect.intersection(screenRect),
+ ScreenRect.empty());
+ this.stack.addLast(screenRect2);
+ return screenRect2;
+ } else {
+ this.stack.addLast(rect);
+ return rect;
+ }
+ }
+
+ @Nullable
+ public ScreenRect pop() {
+ if (this.stack.isEmpty()) {
+ throw new IllegalStateException("Scissor stack underflow");
+ } else {
+ this.stack.removeLast();
+ return (ScreenRect) this.stack.peekLast();
+ }
+ }
+
+ public boolean contains(int x, int y) {
+ return this.stack.isEmpty() ? true : ((ScreenRect) this.stack.peek()).contains(x, y);
+ }
+ }
+
+ @Override
+ public void drawItem(ItemStackRef item, int x, int y) {
+ this.drawItem(SaturnClient.client.player, SaturnClient.client.world, item, x, y, 0);
+ }
+
+ @Override
+ public void drawItem(ItemStackRef stack, int x, int y, int seed) {
+ this.drawItem(SaturnClient.client.player, SaturnClient.client.world, stack, x, y, seed);
+ }
+
+ @Override
+ public void drawItem(ItemStackRef stack, int x, int y, int seed, int z) {
+ this.drawItem(SaturnClient.client.player, SaturnClient.client.world, (ItemStack) (Object) stack, x, y, seed, z);
+ }
+
+ @Override
+ public void drawItemWithoutEntity(ItemStackRef stack, int x, int y) {
+ this.drawItemWithoutEntity(stack, x, y, 0);
+ }
+
+ @Override
+ public void drawItemWithoutEntity(ItemStackRef stack, int x, int y, int seed) {
+ this.drawItem((LivingEntity) null, SaturnClient.client.world, stack, x, y, seed);
+ }
+
+ private void drawItem(@Nullable LivingEntity entity, @Nullable World world, ItemStackRef stack, int x, int y,
+ int seed) {
+ this.drawItem(entity, world, (ItemStack) (Object) stack, x, y, seed, 0);
+ }
+
+ private void drawItem(@Nullable LivingEntity entity, @Nullable World world, ItemStack stack, int x, int y, int seed,
+ int z) {
+ if (!stack.isEmpty()) {
+ BakedModel bakedModel = SaturnClient.client.getItemRenderer().getModel(stack, world, entity, seed);
+ this.matrices.push();
+ this.matrices.translate((float) (x + 8), (float) (y + 8), (float) (150 + (bakedModel.hasDepth() ? z : 0)));
+
+ try {
+ this.matrices.scale(16.0F, -16.0F, 16.0F);
+ boolean bl = !bakedModel.isSideLit();
+ if (bl) {
+ DiffuseLighting.disableGuiDepthLighting();
+ }
+
+ SaturnClient.client.getItemRenderer().renderItem(stack, ModelTransformationMode.GUI, false,
+ this.matrices,
+ this.vertexConsumers, 15728880, OverlayTexture.DEFAULT_UV, bakedModel);
+ this.draw();
+ if (bl) {
+ DiffuseLighting.enableGuiDepthLighting();
+ }
+ } catch (Throwable throwable) {
+ CrashReport crashReport = CrashReport.create(throwable, "Rendering item");
+ CrashReportSection crashReportSection = crashReport.addElement("Item being rendered");
+ crashReportSection.add("Item Type", () -> String.valueOf(stack.getItem()));
+ crashReportSection.add("Item Components", () -> String.valueOf(stack.getComponents()));
+ crashReportSection.add("Item Foil", () -> String.valueOf(stack.hasGlint()));
+ throw new CrashException(crashReport);
+ }
+
+ this.matrices.pop();
+ }
+ }
+
+ @Override
+ public void drawSpriteStretched(SpriteRef sprite, int x, int y, int width, int height) {
+ this.drawSpriteStretched(sprite, x, y, width, height, -1);
+ }
+
+ @Override
+ public void drawSpriteStretched(SpriteRef saturnSprite, int x, int y, int width, int height, int color) {
+ Sprite sprite = (Sprite) saturnSprite;
+ if (color == 0)
+ return;
+ if (width != 0 && height != 0) {
+ this.drawTexturedQuad(sprite.getAtlasId(), x, x + width, y, y + height, sprite.getMinU(), sprite.getMaxU(),
+ sprite.getMinV(), sprite.getMaxV(), color);
+ }
+ }
+
+ public void fill(int x1, int y1, int x2, int y2, int color) {
+ this.fill(x1, y1, x2, y2, 0, color);
+ }
+
+ public void fill(int x1, int y1, int x2, int y2, int z, int color) {
+ this.fill(RenderLayer.getGui(), x1, y1, x2, y2, z, color);
+ }
+
+ public void fill(RenderLayer layer, int x1, int y1, int x2, int y2, int color) {
+ this.fill(layer, x1, y1, x2, y2, 0, color);
+ }
+
+ public void fill(RenderLayer layer, int x1, int y1, int x2, int y2, int z, int color) {
+ Matrix4f matrix4f = this.matrices.peek().getPositionMatrix();
+ int i;
+ if (x1 < x2) {
+ i = x1;
+ x1 = x2;
+ x2 = i;
+ }
+
+ if (y1 < y2) {
+ i = y1;
+ y1 = y2;
+ y2 = i;
+ }
+
+ VertexConsumer vertexConsumer = this.vertexConsumers.getBuffer(layer);
+ vertexConsumer.vertex(matrix4f, (float) x1, (float) y1, (float) z).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x1, (float) y2, (float) z).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x2, (float) y2, (float) z).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x2, (float) y1, (float) z).color(color);
+ }
+
+ public void drawBorder(int x, int y, int width, int height, int color) {
+ this.fill(x, y, x + width, y + 1, color);
+ this.fill(x, y + height - 1, x + width, y + height, color);
+ this.fill(x, y + 1, x + 1, y + height - 1, color);
+ this.fill(x + width - 1, y + 1, x + width, y + height - 1, color);
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/SaturnScreenFabric.java b/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/SaturnScreenFabric.java
new file mode 100644
index 0000000..face3af
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/impl/ui/SaturnScreenFabric.java
@@ -0,0 +1,161 @@
+package org.saturnclient.impl.ui;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import org.saturnclient.saturnclient.mixin.DrawContextAccessor;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.SaturnScreen;
+import org.saturnclient.ui.SaturnScreen.ScreenProvider;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gl.PostEffectProcessor;
+import net.minecraft.client.gui.CubeMapRenderer;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.RotatingCubeMapRenderer;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.texture.ResourceTexture;
+import net.minecraft.client.texture.TextureManager;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+
+public class SaturnScreenFabric extends Screen implements ScreenProvider {
+ protected static final Identifier PANORAMA = Identifier.of("saturnclient",
+ "textures/gui/title/background/panorama");
+ protected static final CubeMapRenderer PANORAMA_RENDERER = new CubeMapRenderer(PANORAMA);
+
+ public static final RotatingCubeMapRenderer ROTATING_PANORAMA_RENDERER;
+
+ static {
+ ROTATING_PANORAMA_RENDERER = new RotatingCubeMapRenderer(PANORAMA_RENDERER);
+ }
+
+ // private final Pool pool = new Pool(3);
+ public final SaturnScreen screen;
+
+ public static void preload(MinecraftClient client) {
+ TextureManager textureManager = client.getTextureManager();
+
+ // Force upload each texture to GPU
+ for (int i = 0; i < 6; i++) {
+ String var10001 = PANORAMA.getPath();
+ Identifier tex = PANORAMA.withPath(var10001 + "_" + i + ".png");
+ ResourceTexture resourceTexture = new ResourceTexture(tex);
+ textureManager.registerTexture(tex, resourceTexture);
+ }
+ }
+
+ public SaturnScreenFabric(SaturnScreen screen) {
+ super(Text.literal(screen.title));
+ screen.provider = this;
+ this.screen = screen;
+ }
+
+ @Override
+ protected void init() {
+ screen.init();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public void render(DrawContext context, int mouseX, int mouseY, float delta) {
+ if (screen.start == null) {
+ screen.start = Instant.now();
+ }
+ long elapsed = Duration.between(screen.start, Instant.now()).toMillis();
+
+ mouseX *= 2;
+ mouseY *= 2;
+
+ if (client.world == null && client.getCurrentServerEntry() == null) {
+ ROTATING_PANORAMA_RENDERER.render(context, this.width, this.height, screen.backgroundOpacity, delta);
+ }
+
+ // PostEffectProcessor postEffectProcessor = this.client.getShaderLoader().loadPostEffect(
+ // Identifier.ofVanilla("blur"),
+ // DefaultFramebufferSet.MAIN_ONLY);
+
+ // if (postEffectProcessor != null) {
+ // postEffectProcessor.setUniforms("Radius", screen.backgroundBlur * Math.min((float) elapsed / 700, 1.0f));
+ // postEffectProcessor.render(this.client.getFramebuffer(), this.pool);
+ // }
+
+ // We are using a Abstracted RenderScope because older minecraft versions don't
+ // use DrawContext
+ RenderScope renderScope = new RenderScopeImpl(context.getMatrices(),
+ ((DrawContextAccessor) context).getVertexConsumers());
+
+ screen.render(renderScope, mouseX, mouseY, delta, elapsed);
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (screen.mouseClicked(mouseX, mouseY, button)) {
+ return true;
+ }
+
+ return super.mouseClicked(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
+ if (screen.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) {
+ return true;
+ }
+
+ return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
+ }
+
+ @Override
+ public boolean mouseReleased(double mouseX, double mouseY, int button) {
+ if (screen.mouseReleased(mouseX, mouseY, button)) {
+ return true;
+ }
+
+ return super.mouseReleased(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
+ if (screen.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)) {
+ return true;
+ }
+
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (screen.keyPressed(keyCode, scanCode, modifiers)) {
+ return true;
+ }
+
+ return super.keyPressed(keyCode, scanCode, modifiers);
+ }
+
+ @Override
+ public void resize(MinecraftClient client, int width, int height) {
+ screen.resize(width, height);
+ super.resize(client, width, height);
+ }
+
+ // ------------------------------------------
+ // ScreenProvider implementations
+ // ------------------------------------------
+
+ @Override
+ public void close() {
+ this.screen.onClose();
+ super.close();
+ }
+
+ @Override
+ public int getWidth() {
+ return width * 2;
+ }
+
+ @Override
+ public int getHeight() {
+ return height * 2;
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/PlayerTracker.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/PlayerTracker.java
new file mode 100644
index 0000000..8e37f09
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/PlayerTracker.java
@@ -0,0 +1,52 @@
+package org.saturnclient.saturnclient;
+
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+
+import org.saturnclient.client.ServiceClient;
+
+import net.minecraft.client.network.PlayerListEntry;
+import net.minecraft.client.MinecraftClient;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+import com.mojang.authlib.GameProfile;
+
+public class PlayerTracker {
+ private static Set trackedPlayers = new HashSet<>();
+
+ public static void initialize() {
+ // When client joins a server, reset tracked players
+ ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
+ trackedPlayers.clear();
+ client.execute(() -> {
+ checkForNewPlayers(SaturnClient.client);
+ });
+ });
+
+ // Check for new players every tick
+ ClientTickEvents.END_CLIENT_TICK.register(client -> checkForNewPlayers(client));
+ }
+
+ private static void checkForNewPlayers(MinecraftClient client) {
+ if (client.getNetworkHandler() == null)
+ return;
+
+ for (PlayerListEntry player : client.getNetworkHandler().getPlayerList()) {
+ UUID playerUUID = player.getProfile().getId();
+
+ if (!trackedPlayers.contains(playerUUID)) {
+ // A new player has joined
+ onPlayerJoin(client, player);
+ trackedPlayers.add(playerUUID);
+ }
+ }
+ }
+
+ private static void onPlayerJoin(MinecraftClient client, PlayerListEntry player) {
+ GameProfile profile = player.getProfile();
+ ServiceClient.player(profile.getId(), profile.getName());
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/SaturnClient.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/SaturnClient.java
new file mode 100644
index 0000000..370b12c
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/SaturnClient.java
@@ -0,0 +1,72 @@
+package org.saturnclient.saturnclient;
+
+import net.fabricmc.api.ModInitializer;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+import net.minecraft.client.MinecraftClient;
+
+import org.saturnclient.client.ServiceClient;
+import org.saturnclient.cosmetics.Emotes;
+import org.saturnclient.cosmetics.Hats;
+import org.saturnclient.feature.FeatureManager;
+import org.saturnclient.cosmetics.Cloaks;
+import org.saturnclient.common.provider.Providers;
+import org.saturnclient.impl.provider.GLFWProviderImpl;
+import org.saturnclient.impl.provider.ModuleProviderFabric;
+import org.saturnclient.impl.provider.RefConstructorImpl;
+import org.saturnclient.impl.provider.SaturnProviderImpl;
+import org.saturnclient.impl.ui.EntityDrawerImpl;
+import org.saturnclient.impl.ui.SaturnScreenFabric;
+import org.saturnclient.saturnclient.event.KeyInputHandler;
+import org.saturnclient.config.Config;
+import org.saturnclient.config.manager.ConfigManager;
+import org.saturnclient.ui.components.SkinPreview;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Main class for the Saturn Client mod.
+ */
+public class SaturnClient implements ModInitializer {
+
+ public static final String MOD_ID = "saturnclient";
+ public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
+ public static MinecraftClient client;
+
+ @Override
+ public void onInitialize() {
+ LOGGER.info("Initializing " + MOD_ID);
+ client = MinecraftClient.getInstance();
+
+ Providers.saturn = new SaturnProviderImpl();
+ Providers.refConstructor = new RefConstructorImpl();
+ Providers.GLFW = new GLFWProviderImpl();
+ Providers.module = new ModuleProviderFabric(client);
+ SkinPreview.DRAWER = new EntityDrawerImpl();
+
+ Config.init();
+ FeatureManager.init();
+
+ client.execute(() -> {
+ SaturnScreenFabric.preload(client);
+ });
+
+ ClientLifecycleEvents.CLIENT_STOPPING.register(_o -> ConfigManager.save());
+
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ for (org.saturnclient.feature.Feature m : FeatureManager.ENABLED_MODS) {
+ m.tick();
+ }
+ });
+
+ KeyInputHandler.register();
+ Emotes.initialize();
+ if (ServiceClient.authenticate()) {
+ Cloaks.initialize();
+ Hats.initialize();
+ LOGGER.info(MOD_ID + " initialization complete");
+ } else {
+ LOGGER.error("Failed to authenticate with the server");
+ }
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/event/KeyInputHandler.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/event/KeyInputHandler.java
new file mode 100644
index 0000000..090aec8
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/event/KeyInputHandler.java
@@ -0,0 +1,57 @@
+package org.saturnclient.saturnclient.event;
+
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
+import net.minecraft.client.option.KeyBinding;
+import net.minecraft.client.util.InputUtil;
+
+import org.saturnclient.config.Config;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.saturnclient.client.ServiceClient;
+import org.saturnclient.common.provider.Providers;
+import org.saturnclient.ui.screens.EmoteWheel;
+import org.saturnclient.ui.screens.ShiftMenu;
+
+import dev.kosmx.playerAnim.api.layered.AnimationStack;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
+
+import org.lwjgl.glfw.GLFW;
+
+/**
+ * Handles keyboard input events for the Saturn Client.
+ * Manages keybindings and their associated actions.
+ */
+public class KeyInputHandler {
+ private static final String KEY_CATEGORY = "key.category.saturnclient";
+ private static final String MAIN_MENU_KEY_ID = "key.saturnclient.main_menu";
+ private static final int DEFAULT_MENU_KEY = GLFW.GLFW_KEY_RIGHT_SHIFT;
+ private static KeyBinding mainMenuKeyBinding;
+
+ public static void register() {
+ mainMenuKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
+ MAIN_MENU_KEY_ID,
+ InputUtil.Type.KEYSYM,
+ DEFAULT_MENU_KEY,
+ KEY_CATEGORY));
+
+ // Register the event handler for the main menu key
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ if (mainMenuKeyBinding.wasPressed()) {
+ Providers.saturn.getClient().setScreen(new ShiftMenu());
+ }
+
+ if (Config.openEmoteWheel.wasKeyPressed() && client.currentScreen == null
+ && !InputUtil.isKeyPressed(client.getWindow().getHandle(), GLFW.GLFW_KEY_F3)) {
+ Providers.saturn.getClient().setScreen(new EmoteWheel());
+ }
+
+ if (SaturnClient.client.player != null && SaturnClient.client.player.isSneaking()) {
+ AnimationStack animationStack = PlayerAnimationAccess.getPlayerAnimLayer(SaturnClient.client.player);
+ if (animationStack.isActive()) {
+ animationStack.removeLayer(1000);
+ ServiceClient.emote("");
+ }
+ }
+ });
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/DrawContextAccessor.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/DrawContextAccessor.java
new file mode 100644
index 0000000..f049e40
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/DrawContextAccessor.java
@@ -0,0 +1,12 @@
+package org.saturnclient.saturnclient.mixin;
+
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.render.VertexConsumerProvider;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@Mixin(DrawContext.class)
+public interface DrawContextAccessor {
+ @Accessor("vertexConsumers")
+ VertexConsumerProvider.Immediate getVertexConsumers();
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/NameTagMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/NameTagMixin.java
new file mode 100644
index 0000000..f23190c
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/NameTagMixin.java
@@ -0,0 +1,101 @@
+package org.saturnclient.saturnclient.mixin;
+
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.font.TextRenderer.TextLayerType;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.entity.EntityRenderDispatcher;
+import net.minecraft.client.render.entity.EntityRenderer;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.EntityAttachmentType;
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import net.minecraft.util.math.Vec3d;
+
+import org.saturnclient.client.player.Roles;
+import org.saturnclient.client.player.SaturnPlayer;
+import org.saturnclient.saturnclient.SaturnClient;
+
+import java.util.UUID;
+
+import org.joml.Matrix4f;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.Shadow;
+
+@Mixin(value = EntityRenderer.class, priority = 1800)
+public abstract class NameTagMixin {
+ @Shadow
+ public EntityRenderDispatcher dispatcher;
+
+ @Shadow
+ private TextRenderer textRenderer;
+
+ /**
+ * @author HexLeo
+ * @reason Adds the Saturn Client icon to the player's name if they are online
+ * with Saturn Client
+ */
+ @Inject(method = "renderLabelIfPresent(Lnet/minecraft/client/render/entity/state/EntityRenderState;Lnet/minecraft/text/Text;Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/render/VertexConsumerProvider;I)V", at = @At("HEAD"), cancellable = true)
+ protected void renderLabelIfPresent(T entity, Text text, MatrixStack matrices,
+ VertexConsumerProvider vertexConsumers, int light, float tickDelta, CallbackInfo ci) {
+
+ UUID uuid = isSaturn(entity);
+
+ if (uuid != null) {
+ MutableText iconText = Text.literal(Roles.getSaturnIndicator())
+ .styled(style -> style.withColor(Roles.getIconColor(uuid)));
+
+ Text nameText = text.copy().styled(style -> style.withColor(Formatting.WHITE));
+
+ renderLabel(entity, iconText.append(nameText), matrices, vertexConsumers, light, tickDelta);
+
+ ci.cancel();
+ }
+ }
+
+ private UUID isSaturn(T entity) {
+ if (entity instanceof PlayerEntity playerEntity) {
+ SaturnPlayer player = SaturnPlayer.get(playerEntity.getUuid());
+ if (player != null) {
+ return player.uuid;
+ }
+ }
+
+ return null;
+ }
+
+ private void renderLabel(T entity, Text text, MatrixStack matrices,
+ VertexConsumerProvider vertexConsumers, int light, float tickDelta) {
+ double d = this.dispatcher.getSquaredDistanceToCamera(entity);
+ if (!(d > (double) 4096.0F)) {
+ Vec3d vec3d = entity.getAttachments().getPointNullable(EntityAttachmentType.NAME_TAG, 0,
+ entity.getYaw(tickDelta));
+ if (vec3d != null) {
+ boolean bl = !entity.isSneaky();
+ int i = "deadmau5".equals(text.getString()) ? -10 : 0;
+ matrices.push();
+ matrices.translate(vec3d.x, vec3d.y + (double) 0.5F, vec3d.z);
+ matrices.multiply(this.dispatcher.getRotation());
+ matrices.scale(0.025F, -0.025F, 0.025F);
+ Matrix4f matrix4f = matrices.peek().getPositionMatrix();
+ float f = SaturnClient.client.options.getTextBackgroundOpacity(0.25F);
+ int j = (int) (f * 255.0F) << 24;
+ TextRenderer textRenderer = this.textRenderer;
+ float g = (float) (-textRenderer.getWidth(text) / 2);
+ textRenderer.draw(text, g, (float) i, 553648127, false, matrix4f, vertexConsumers,
+ bl ? TextLayerType.SEE_THROUGH : TextLayerType.NORMAL, j, light);
+ if (bl) {
+ textRenderer.draw(text, g, (float) i, -1, false, matrix4f, vertexConsumers, TextLayerType.NORMAL, 0,
+ light);
+ }
+
+ matrices.pop();
+ }
+ }
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/PlayerEntityRendererMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/PlayerEntityRendererMixin.java
new file mode 100644
index 0000000..f0e169d
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/PlayerEntityRendererMixin.java
@@ -0,0 +1,29 @@
+package org.saturnclient.saturnclient.mixin;
+
+import net.minecraft.client.network.AbstractClientPlayerEntity;
+import net.minecraft.client.render.entity.EntityRendererFactory;
+import net.minecraft.client.render.entity.LivingEntityRenderer;
+import net.minecraft.client.render.entity.PlayerEntityRenderer;
+import net.minecraft.client.render.entity.model.PlayerEntityModel;
+
+import org.saturnclient.impl.cosmetics.CloakFeatureRenderer;
+import org.saturnclient.impl.cosmetics.HatFeatureRenderer;
+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;
+
+@Mixin(PlayerEntityRenderer.class)
+public abstract class PlayerEntityRendererMixin
+ extends LivingEntityRenderer> {
+
+ protected PlayerEntityRendererMixin() {
+ super(null, null, 0.0f);
+ }
+
+ @Inject(method = "", at = @At("TAIL"))
+ private void addCustomFeatureRenderer(EntityRendererFactory.Context ctx, boolean slim, CallbackInfo ci) {
+ this.addFeature(new HatFeatureRenderer(this));
+ this.addFeature(new CloakFeatureRenderer(this));
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/SetScreenMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/SetScreenMixin.java
new file mode 100644
index 0000000..061d641
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/SetScreenMixin.java
@@ -0,0 +1,27 @@
+package org.saturnclient.saturnclient.mixin;
+
+import org.saturnclient.config.Config;
+import org.saturnclient.impl.ui.SaturnScreenFabric;
+import org.saturnclient.ui.screens.TitleMenu;
+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.gui.screen.Screen;
+import net.minecraft.client.gui.screen.TitleScreen;
+
+@Mixin(MinecraftClient.class)
+public class SetScreenMixin {
+ @ModifyVariable(method = "setScreen", at = @At("HEAD"), argsOnly = true)
+ private Screen replaceTitleScreen(Screen screen) {
+ if (Config.saturnTitleScreen.value) {
+ if (screen instanceof TitleScreen)
+ return new SaturnScreenFabric(new TitleMenu());
+ if (screen == null && ((MinecraftClient) (Object) this).world == null)
+ return new SaturnScreenFabric(new TitleMenu());
+ }
+
+ return screen;
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/SplashOverlayMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/SplashOverlayMixin.java
new file mode 100644
index 0000000..ecc4823
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/SplashOverlayMixin.java
@@ -0,0 +1,161 @@
+package org.saturnclient.saturnclient.mixin;
+
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import org.saturnclient.config.Theme;
+import org.saturnclient.impl.ui.RenderScopeImpl;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.resources.Textures;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Overwrite;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Final;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.screen.SplashOverlay;
+import net.minecraft.resource.ResourceReload;
+import net.minecraft.util.Util;
+import net.minecraft.util.math.MathHelper;
+
+@Mixin(SplashOverlay.class)
+public abstract class SplashOverlayMixin {
+
+ @Shadow
+ private MinecraftClient client;
+ @Shadow
+ private ResourceReload reload;
+
+ @Shadow
+ private long reloadCompleteTime;
+
+ @Shadow
+ private long reloadStartTime;
+
+ @Shadow
+ @Final
+ private boolean reloading;
+
+ @Shadow
+ @Final
+ private Consumer> exceptionHandler;
+
+ @Shadow
+ private float progress;
+
+ /**
+ * @author SaturnClient
+ * @reason Replace vanilla splash rendering completely but keep exit logic
+ */
+ @Overwrite
+ public void render(DrawContext context, int mouseX, int mouseY, float delta) {
+ int screenWidth = context.getScaledWindowWidth();
+ int screenHeight = context.getScaledWindowHeight();
+ long currentTime = Util.getMeasuringTimeMs();
+
+ // Initialize reload start time if not set
+ if (reloadStartTime == -1L) {
+ reloadStartTime = currentTime;
+ }
+
+ // Compute fade-out progress after reload completes
+ float fadeOutProgress = reloadCompleteTime > -1L
+ ? (float) (currentTime - reloadCompleteTime) / 1000f
+ : -1f;
+
+ int alpha = fadeOutProgress >= 0f
+ ? MathHelper.ceil((1.0f - MathHelper.clamp(fadeOutProgress - 1f, 0f, 1f)) * 255f)
+ : 255;
+
+ // Compute fade-in progress while reloading
+ float reloadProgress = reloadStartTime > -1L
+ ? (float) (currentTime - reloadStartTime) / 500f
+ : -1f;
+
+ // Get render scope for better UI
+ RenderScope scope = new RenderScopeImpl(context);
+
+ // Draw background overlay
+ if (fadeOutProgress >= 1.0F) {
+ renderCurrentScreen(context, mouseX, mouseY, delta);
+ int overlayAlpha = computeOverlayAlpha(fadeOutProgress - 1f);
+ scope.drawTexture(Textures.SPLASH, 0, 0, 0, 0, screenWidth, screenHeight,
+ Theme.withAlpha(overlayAlpha, 0xFFFFFFFF));
+ } else if (reloading) {
+ if (reloadProgress < 1.0F) {
+ renderCurrentScreen(context, mouseX, mouseY, delta);
+ }
+ scope.drawTexture(Textures.SPLASH, 0, 0, 0, 0, screenWidth, screenHeight,
+ Theme.withAlpha(alpha, 0xFFFFFFFF));
+ } else {
+ scope.drawTexture(Textures.SPLASH, 0, 0, 0, 0, screenWidth, screenHeight,
+ Theme.withAlpha(alpha, 0xFFFFFFFF));
+ }
+
+ float u = this.reload.getProgress();
+ this.progress = MathHelper.clamp(this.progress * 0.95F + u * 0.050000012F, 0.0F, 1.0F);
+
+ // Draw central logo
+ drawLogo(scope, screenWidth, screenHeight, alpha);
+ renderProgressBar(scope, context, screenWidth, screenHeight, alpha);
+
+ // Remove overlay after fade-out completes
+ if (fadeOutProgress >= 2.0F) {
+ client.setOverlay(null);
+ }
+
+ // Handle reload completion and exceptions
+ handleReloadCompletion(context);
+ }
+
+ private void renderCurrentScreen(DrawContext context, int mouseX, int mouseY, float delta) {
+ if (client.currentScreen != null) {
+ client.currentScreen.render(context, mouseX, mouseY, delta);
+ }
+ }
+
+ /** Draw the main logo centered */
+ private void drawLogo(RenderScope scope, int screenWidth, int screenHeight, int alpha) {
+ scope.drawTexture(Textures.LOGO, (screenWidth - 60) / 2, ((screenHeight - 60) / 2) - 20, 0, 0, 60, 60,
+ Theme.withAlpha(alpha, 0xFFFFFFFF));
+ }
+
+ private void renderProgressBar(RenderScope scope, DrawContext context, int screenWidth, int screenHeight,
+ int alpha) {
+ int progressY = ((screenHeight - 5) / 2) + 25;
+ context.drawBorder((screenWidth - 120) / 2, progressY, 120, 5, Theme.withAlpha(alpha, 0xFFFFFFFF));
+
+ int progressWidth = Math.min((int) (progress * 120), 120);
+ int progressX = (screenWidth - 120) / 2;
+
+ context.fill(progressX, progressY, progressX + progressWidth, progressY + 5,
+ Theme.withAlpha(alpha, 0xFFFFFFFF));
+ }
+
+ /** Compute overlay alpha from fade progress */
+ private int computeOverlayAlpha(float progress) {
+ return MathHelper.ceil((1.0F - MathHelper.clamp(progress, 0.0F, 1.0F)) * 255.0F);
+ }
+
+ /** Handle reload completion logic */
+ private void handleReloadCompletion(DrawContext context) {
+ float reloadProgress = reloadStartTime > -1L ? (float) (Util.getMeasuringTimeMs() - reloadStartTime) / 500f
+ : -1f;
+
+ if (reloadCompleteTime == -1L && reload.isComplete() && (!reloading || reloadProgress >= 2.0F)) {
+ try {
+ reload.throwException();
+ exceptionHandler.accept(Optional.empty());
+ } catch (Throwable ex) {
+ exceptionHandler.accept(Optional.of(ex));
+ }
+
+ reloadCompleteTime = Util.getMeasuringTimeMs();
+
+ if (client.currentScreen != null) {
+ client.currentScreen.init(client, context.getScaledWindowWidth(), context.getScaledWindowHeight());
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/TabListMixin.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/TabListMixin.java
new file mode 100644
index 0000000..5dd00f6
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/TabListMixin.java
@@ -0,0 +1,43 @@
+package org.saturnclient.saturnclient.mixin;
+
+import net.minecraft.client.network.PlayerListEntry;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+
+import java.util.UUID;
+
+import org.jetbrains.annotations.Nullable;
+import org.saturnclient.client.player.Roles;
+import org.saturnclient.client.player.SaturnPlayer;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Overwrite;
+import org.spongepowered.asm.mixin.Shadow;
+
+import com.mojang.authlib.GameProfile;
+
+@Mixin(PlayerListEntry.class)
+public abstract class TabListMixin {
+
+ @Shadow
+ private GameProfile profile;
+
+ /**
+ * @author HexLeo
+ * @reason Adds the Saturn Client icon to the player's name if they are online
+ * with Saturn Client
+ */
+ @Overwrite
+ @Nullable
+ public Text getDisplayName() {
+ GameProfile profile = ((PlayerListEntry) (Object) this).getProfile();
+ String name = profile.getName();
+ UUID uuid = profile.getId();
+
+ if (SaturnPlayer.get(uuid) != null) {
+ return Text.literal(Roles.getSaturnIndicator())
+ .styled(style -> style.withColor(Roles.getIconColor(uuid)))
+ .append(Text.literal(name).styled(style -> style.withColor(Formatting.WHITE)));
+ }
+ return Text.literal(name);
+ }
+}
diff --git a/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/WhoAmI.java b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/WhoAmI.java
new file mode 100644
index 0000000..041e0cf
--- /dev/null
+++ b/versions/1.21.1/src/main/java/org/saturnclient/saturnclient/mixin/WhoAmI.java
@@ -0,0 +1,32 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+/*
+ * Original code from FokshaWasTaken (GitHub),
+ * Repository: https://github.com/FokshaWasTaken/who-am-i/
+*/
+
+package org.saturnclient.saturnclient.mixin;
+
+import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
+import net.minecraft.client.render.entity.LivingEntityRenderer;
+import net.minecraft.entity.Entity;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+
+@Mixin(LivingEntityRenderer.class)
+public class WhoAmI {
+ @ModifyExpressionValue(method = "hasLabel(Lnet/minecraft/entity/LivingEntity;D)Z", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;getCameraEntity()Lnet/minecraft/entity/Entity;"))
+ public Entity obfuscateCameraEntity(Entity original) {
+ // hasLabel checks if the rendered entity is equal to the camera entity (aka our
+ // player),
+ // so by setting the camera entity to null in this instance makes the check
+ // always succeed
+ // this also allows to keep the other checks in mojang's code, such as checking
+ // if the hud is enabled etc.
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21.1/src/main/resources/fabric.mod.json b/versions/1.21.1/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..f447ac6
--- /dev/null
+++ b/versions/1.21.1/src/main/resources/fabric.mod.json
@@ -0,0 +1,28 @@
+{
+ "schemaVersion": 1,
+ "id": "saturnclient",
+ "version": "${version}",
+ "name": "saturnclient",
+ "description": "The ultimate open source client",
+ "authors": ["Saturn", "ORUS"],
+ "contact": {
+ "website": "https://selimaj.dev",
+ "sources": "https://github.com/saturnclientmc/saturnclient"
+ },
+ "license": "Apache-2.0",
+ "icon": "assets/saturnclient/icon.png",
+ "environment": "client",
+ "entrypoints": {
+ "main": ["org.saturnclient.saturnclient.SaturnClient"]
+ },
+ "mixins": [
+ "saturnclient.mixins.json",
+ "mods.mixins.json",
+ "impl.mixins.json"
+ ],
+ "depends": {
+ "fabricloader": ">=${loader_version}",
+ "fabric": "*",
+ "minecraft": "${minecraft_version}"
+ }
+}
diff --git a/versions/1.21.1/src/main/resources/impl.mixins.json b/versions/1.21.1/src/main/resources/impl.mixins.json
new file mode 100644
index 0000000..15e322f
--- /dev/null
+++ b/versions/1.21.1/src/main/resources/impl.mixins.json
@@ -0,0 +1,19 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "org.saturnclient.impl.mixins",
+ "compatibilityLevel": "JAVA_21",
+ "client": [
+ "IdentifierMixin",
+ "ItemStackMixin",
+ "MatrixStackMixin",
+ "MinecraftClientMixin",
+ "QuaternionfMixin",
+ "WindowMixin",
+ "TextMixin",
+ "SpriteMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
diff --git a/versions/1.21.1/src/main/resources/mods.mixins.json b/versions/1.21.1/src/main/resources/mods.mixins.json
new file mode 100644
index 0000000..c402565
--- /dev/null
+++ b/versions/1.21.1/src/main/resources/mods.mixins.json
@@ -0,0 +1,17 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "org.saturnclient.impl.modules.mixins",
+ "compatibilityLevel": "JAVA_21",
+ "client": [
+ "GameRendererMixin",
+ "LightmapTextureManagerMixin",
+ "LivingEntityRendererMixin",
+ "NametagsMixin",
+ "RenderMixin",
+ "TpsMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
diff --git a/versions/1.21.1/src/main/resources/saturnclient.mixins.json b/versions/1.21.1/src/main/resources/saturnclient.mixins.json
new file mode 100644
index 0000000..3bc9ccc
--- /dev/null
+++ b/versions/1.21.1/src/main/resources/saturnclient.mixins.json
@@ -0,0 +1,18 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "org.saturnclient.saturnclient.mixin",
+ "compatibilityLevel": "JAVA_21",
+ "client": [
+ "TabListMixin",
+ "NameTagMixin",
+ "PlayerEntityRendererMixin",
+ "WhoAmI",
+ "DrawContextAccessor",
+ "SplashOverlayMixin",
+ "SetScreenMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
diff --git a/versions/1.21/build.gradle b/versions/1.21/build.gradle
new file mode 100644
index 0000000..bca2b04
--- /dev/null
+++ b/versions/1.21/build.gradle
@@ -0,0 +1,96 @@
+plugins {
+ id 'net.fabricmc.fabric-loom-remap' version "${loom_version}"
+ id 'maven-publish'
+}
+
+version = project.mod_version
+group = project.maven_group
+
+base {
+ archivesName = project.archives_base_name
+}
+
+repositories {
+ maven { name = "KoSmX Maven"; url = 'https://maven.kosmx.dev/' }
+}
+
+dependencies {
+ // To change the versions see the gradle.properties file
+ minecraft "com.mojang:minecraft:${project.minecraft_version}"
+ mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
+ modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
+
+ // Fabric API. This is technically optional, but you probably want it anyway.
+ modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
+
+ // Player Animation Library
+ include modImplementation(
+ "dev.kosmx.player-anim:player-animation-lib-fabric:${project.player_anim}"
+ )
+
+ // Optional: BendyLib
+ // include modRuntimeOnly(
+ // "io.github.kosmx.bendy-lib:bendy-lib-fabric:${project.bendylib_version}"
+ // )
+}
+
+/*
+ * ================================================================
+ * Resource Processing
+ * ================================================================
+ * Injects version values into fabric.mod.json.
+ */
+processResources {
+ inputs.property "version", project.version
+ inputs.property "minecraft_version", project.minecraft_version
+ inputs.property "loader_version", project.loader_version
+ filteringCharset "UTF-8"
+
+ filesMatching("fabric.mod.json") {
+ expand(
+ "version": project.version,
+ "minecraft_version": project.minecraft_version,
+ "loader_version": project.loader_version
+ )
+ }
+}
+
+tasks.withType(JavaCompile).configureEach {
+ it.options.release = 21
+}
+
+java {
+ // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
+ // if it is present.
+ // If you remove this line, sources will not be generated.
+ withSourcesJar()
+
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+}
+
+jar {
+ inputs.property "archivesName", project.base.archivesName
+
+ from("LICENSE") {
+ rename { "${it}_${inputs.properties.archivesName}"}
+ }
+}
+
+// configure the maven publication
+publishing {
+ publications {
+ create("mavenJava", MavenPublication) {
+ artifactId = project.archives_base_name
+ from components.java
+ }
+ }
+
+ // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
+ repositories {
+ // Add repositories to publish to here.
+ // Notice: This block does NOT have the same function as the block in the top level.
+ // The repositories here will be used for publishing your artifact, not for
+ // retrieving dependencies.
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/gradle.properties b/versions/1.21/gradle.properties
new file mode 100644
index 0000000..71f5094
--- /dev/null
+++ b/versions/1.21/gradle.properties
@@ -0,0 +1,23 @@
+# Done to increase the memory available to gradle.
+org.gradle.jvmargs=-Xmx1G
+org.gradle.parallel=true
+
+# IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349
+org.gradle.configuration-cache=false
+
+# Fabric Properties
+# check these on https://fabricmc.net/develop
+minecraft_version=1.21
+yarn_mappings=1.21+build.9
+loader_version=0.18.4
+loom_version=1.15-SNAPSHOT
+
+# Mod Properties
+mod_version=1.0.0
+maven_group=org.saturnclient
+archives_base_name=saturn-client
+
+# Dependencies
+fabric_api_version=0.102.0+1.21
+
+player_anim = 2.0.4+1.21.1
diff --git a/versions/1.21/settings.gradle b/versions/1.21/settings.gradle
new file mode 100644
index 0000000..75c4d72
--- /dev/null
+++ b/versions/1.21/settings.gradle
@@ -0,0 +1,10 @@
+pluginManagement {
+ repositories {
+ maven {
+ name = 'Fabric'
+ url = 'https://maven.fabricmc.net/'
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/CloakFeatureRenderer.java b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/CloakFeatureRenderer.java
new file mode 100644
index 0000000..1b5bc6a
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/CloakFeatureRenderer.java
@@ -0,0 +1,411 @@
+package org.saturnclient.impl.cosmetics;
+
+import java.util.Arrays;
+
+import org.saturnclient.client.player.SaturnPlayer;
+import org.saturnclient.config.Config;
+import org.saturnclient.cosmetics.Cloaks;
+
+import net.minecraft.client.network.AbstractClientPlayerEntity;
+import net.minecraft.client.render.OverlayTexture;
+import net.minecraft.client.render.RenderLayer;
+import net.minecraft.client.render.VertexConsumer;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.entity.feature.FeatureRenderer;
+import net.minecraft.client.render.entity.feature.FeatureRendererContext;
+import net.minecraft.client.render.entity.model.PlayerEntityModel;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.math.MathHelper;
+import net.minecraft.util.math.RotationAxis;
+import net.minecraft.util.math.Vec3d;
+
+/**
+ * The cloak renderer
+ * - **Efficient**: uses a image only consisting of the cloak texture, 16x8
+ * whitespace.
+ * - **Curving**: Curves when player velocity increases.
+ * - **Wavey**: Makes the cloak feel like wind, when jump sprinting.
+ */
+public class CloakFeatureRenderer
+ extends FeatureRenderer> {
+ private static final int PARTS = 24;
+ private final float[] segmentValues = new float[PARTS];
+
+ private static final float TEX_W = 176f;
+ private static final float TEX_H = 138f;
+
+ private static final class PxRect {
+ final int x, y, w, h;
+
+ PxRect(int x, int y, int w, int h) {
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ }
+
+ float u1() {
+ return x / TEX_W;
+ }
+
+ float v1() {
+ return y / TEX_H;
+ }
+
+ float u2() {
+ return (x + w) / TEX_W;
+ }
+
+ float v2() {
+ return (y + h) / TEX_H;
+ }
+ }
+
+ private static final PxRect FRONT_RECT = new PxRect(8, 8, 80, 128);
+ private static final PxRect BACK_RECT = new PxRect(96, 8, 80, 128);
+ private static final PxRect LEFT_EDGE_RECT = new PxRect(0, 8, 8, 128);
+ private static final PxRect RIGHT_EDGE_RECT = new PxRect(88, 8, 8, 128);
+ private static final PxRect TOP_RECT = new PxRect(8, 0, 80, 8);
+ private static final PxRect BOTTOM_RECT = new PxRect(88, 0, 80, 8);
+
+ public CloakFeatureRenderer(
+ FeatureRendererContext> featureRendererContext) {
+ super(featureRendererContext);
+ Arrays.fill(segmentValues, 0.0f);
+ }
+
+ private void renderCapeQuad(VertexConsumer vertexConsumer, MatrixStack matrixStack,
+ Vec3d bottomLeft, Vec3d bottomRight, Vec3d topRight, Vec3d topLeft,
+ float u1, float v1, float u2, float v2, int light, int overlay,
+ float normalX, float normalY, float normalZ, boolean flipWinding) {
+ MatrixStack.Entry entry = matrixStack.peek();
+
+ if (!flipWinding) {
+ // CCW
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) bottomLeft.x, (float) bottomLeft.y,
+ (float) bottomLeft.z)
+ .color(255, 255, 255, 255).texture(u1, v2).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) bottomRight.x, (float) bottomRight.y,
+ (float) bottomRight.z)
+ .color(255, 255, 255, 255).texture(u2, v2).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) topRight.x, (float) topRight.y,
+ (float) topRight.z)
+ .color(255, 255, 255, 255).texture(u2, v1).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) topLeft.x, (float) topLeft.y,
+ (float) topLeft.z)
+ .color(255, 255, 255, 255).texture(u1, v1).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ } else {
+ // CW
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) topLeft.x, (float) topLeft.y,
+ (float) topLeft.z)
+ .color(255, 255, 255, 255).texture(u1, v1).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) topRight.x, (float) topRight.y,
+ (float) topRight.z)
+ .color(255, 255, 255, 255).texture(u2, v1).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) bottomRight.x, (float) bottomRight.y,
+ (float) bottomRight.z)
+ .color(255, 255, 255, 255).texture(u2, v2).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ vertexConsumer.vertex(entry.getPositionMatrix(), (float) bottomLeft.x, (float) bottomLeft.y,
+ (float) bottomLeft.z)
+ .color(255, 255, 255, 255).texture(u1, v2).overlay(overlay).light(light)
+ .normal(normalX, normalY, normalZ);
+ }
+ }
+
+ private void renderCape(MatrixStack matrixStack, VertexConsumer vertexConsumer, int light, int overlay) {
+ float capeWidth = 10.0f / 16.0f;
+ float capeHeight = 16.0f / 16.0f;
+ float capeDepth = 1.0f / 16.0f;
+ float capePartHeight = capeHeight / PARTS;
+
+ float x1 = -capeWidth / 2.0f;
+ float x2 = capeWidth / 2.0f;
+
+ final float frontU1 = FRONT_RECT.u1();
+ final float frontU2 = FRONT_RECT.u2();
+ final float frontV1 = FRONT_RECT.v1();
+ final float frontV2 = FRONT_RECT.v2();
+ final float frontPartV = (frontV2 - frontV1) / PARTS;
+ final float backU1 = BACK_RECT.u1();
+ final float backU2 = BACK_RECT.u2();
+ final float backV1 = BACK_RECT.v1();
+ final float backV2 = BACK_RECT.v2();
+ final float backPartV = (backV2 - backV1) / PARTS;
+ final float leftU1 = LEFT_EDGE_RECT.u1();
+ final float leftU2 = LEFT_EDGE_RECT.u2();
+ final float leftV1 = LEFT_EDGE_RECT.v1();
+ final float leftV2 = LEFT_EDGE_RECT.v2();
+ final float leftPartV = (leftV2 - leftV1) / PARTS;
+ final float rightU1 = RIGHT_EDGE_RECT.u1();
+ final float rightU2 = RIGHT_EDGE_RECT.u2();
+ final float rightV1 = RIGHT_EDGE_RECT.v1();
+ final float rightV2 = RIGHT_EDGE_RECT.v2();
+ final float rightPartV = (rightV2 - rightV1) / PARTS;
+ final float topU1 = TOP_RECT.u1();
+ final float topU2 = TOP_RECT.u2();
+ final float topV1 = TOP_RECT.v1();
+ final float topV2 = TOP_RECT.v2();
+ final float bottomU1 = BOTTOM_RECT.u1();
+ final float bottomU2 = BOTTOM_RECT.u2();
+ final float bottomV1 = BOTTOM_RECT.v1();
+ final float bottomV2 = BOTTOM_RECT.v2();
+
+ float curY = 0.0f;
+ float curZ = 0.0f;
+
+ for (int i = 0; i < PARTS; i++) {
+ float angle = (2.0f - segmentValues[i]) * ((float) Math.PI / 2f);
+ float dirY = -(float) Math.cos(angle);
+ float dirZ = (float) Math.sin(angle);
+
+ // 1. Thickness at the START of this segment (Must match PREVIOUS segment's end)
+ float prevAngle = (i > 0) ? (2.0f - segmentValues[i - 1]) * ((float) Math.PI / 2f) : angle;
+ float startAvgAngle = (angle + prevAngle) / 2.0f;
+ float thickYStart = (float) Math.sin(startAvgAngle) * capeDepth;
+ float thickZStart = (float) Math.cos(startAvgAngle) * capeDepth;
+
+ // 2. Thickness at the END of this segment (Must match NEXT segment's start)
+ float nextAngle = (i < PARTS - 1) ? (2.0f - segmentValues[i + 1]) * ((float) Math.PI / 2f) : angle;
+ float endAvgAngle = (angle + nextAngle) / 2.0f;
+ float thickYEnd = (float) Math.sin(endAvgAngle) * capeDepth;
+ float thickZEnd = (float) Math.cos(endAvgAngle) * capeDepth;
+
+ float nextY = curY + dirY * capePartHeight;
+ float nextZ = curZ + dirZ * capePartHeight;
+
+ // Inner Vertices (The spine of the cape)
+ Vec3d innerTopLeft = new Vec3d(x2, curY, curZ);
+ Vec3d innerTopRight = new Vec3d(x1, curY, curZ);
+ Vec3d innerBotLeft = new Vec3d(x2, nextY, nextZ);
+ Vec3d innerBotRight = new Vec3d(x1, nextY, nextZ);
+
+ // Outer Vertices (Offset by the specific joint thickness)
+ Vec3d outerTopLeft = new Vec3d(x2, curY - thickYStart, curZ - thickZStart);
+ Vec3d outerTopRight = new Vec3d(x1, curY - thickYStart, curZ - thickZStart);
+ Vec3d outerBotLeft = new Vec3d(x2, nextY - thickYEnd, nextZ - thickZEnd);
+ Vec3d outerBotRight = new Vec3d(x1, nextY - thickYEnd, nextZ - thickZEnd);
+
+ // FRONT (facing camera)
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ outerBotRight, outerBotLeft, outerTopLeft, outerTopRight,
+ frontU2, frontV1 + (frontPartV * i), frontU1, frontV1 + (frontPartV * (i + 1)),
+ light, overlay, 0, 0, 1, false);
+
+ // BACK (Facing player)
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ innerBotLeft, innerBotRight, innerTopRight, innerTopLeft,
+ backU2, backV1 + (backPartV * i), backU1, backV1 + (backPartV * (i + 1)),
+ light, overlay, 0, 0, -1, false);
+
+ // LEFT EDGE
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ innerBotLeft, outerBotLeft, outerTopLeft, innerTopLeft,
+ leftU1, leftV1 + (leftPartV * i), leftU2, leftV1 + (leftPartV * (i + 1)),
+ light, overlay, 1, 0, 0, false);
+
+ // RIGHT EDGE
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ outerBotRight, innerBotRight, innerTopRight, outerTopRight,
+ rightU1, rightV1 + (rightPartV * i), rightU2, rightV1 + (rightPartV * (i + 1)),
+ light, overlay, -1, 0, 0, false);
+
+ // TOP FACE (Only on first segment)
+ if (i == 0) {
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ outerTopLeft, outerTopRight, innerTopRight, innerTopLeft,
+ topU1, topV1, topU2, topV2, light, overlay, 0, 1, 0, false);
+ }
+
+ // BOTTOM FACE (Only on last segment)
+ if (i == PARTS - 1) {
+ this.renderCapeQuad(vertexConsumer, matrixStack,
+ innerBotLeft, innerBotRight, outerBotRight, outerBotLeft,
+ bottomU1, bottomV1, bottomU2, bottomV2, light, overlay, 0, -1, 0, false);
+ }
+
+ curY = nextY;
+ curZ = nextZ;
+ }
+ }
+
+ // Shift array left and insert new value at the start
+ private void updateSegmentValues(float value) {
+ // Shift all elements one position to the right (from end to start)
+ for (int i = segmentValues.length - 1; i > 0; i--) {
+ segmentValues[i] = segmentValues[i - 1];
+ }
+
+ // Insert the new value at index 0
+ segmentValues[0] = value;
+ }
+
+ private long lastUpdate = 0;
+
+ @Override
+ public void render(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light,
+ AbstractClientPlayerEntity entity, float limbAngle, float limbDistance, float tickDelta,
+ float animationProgress, float headYaw, float headPitch) {
+ if (entity.isInvisible() || entity.getSkinTextures().capeTexture() != null) {
+ return;
+ }
+
+ SaturnPlayer player = SaturnPlayer.get(entity.getUuid());
+
+ if (player == null) {
+ return;
+ }
+
+ ItemStack chestStack = entity.getInventory().armor.get(1);
+
+ Identifier customCape = (Identifier) (Object) Cloaks.getCurrentCloakTexture(player.uuid);
+ if (customCape == null || chestStack.itemMatches(Items.ELYTRA.getRegistryEntry())) {
+ return;
+ }
+
+ int minBrightness = 7;
+ int blockLight = (light >> 4) & 0xF;
+ int skyLight = (light >> 20) & 0xF;
+ blockLight = Math.max(blockLight, minBrightness);
+ skyLight = Math.max(skyLight, minBrightness);
+ light = (skyLight << 20) | (blockLight << 4);
+
+ VertexConsumer vertexConsumer = vertexConsumers.getBuffer(RenderLayer.getEntityAlpha(customCape));
+
+ matrices.push();
+
+ matrices.translate(0.0f, 0.0f, 0.12f);
+
+ if (!chestStack.isEmpty()) {
+ matrices.translate(0.0F, -0.053125F, 0.06875F);
+ }
+
+ if (entity.isInSneakingPose()) {
+ matrices.translate(0, 0.16f, 0.0f);
+ matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(29.0f));
+ }
+
+ long now = System.currentTimeMillis();
+ if (now - lastUpdate >= 20) {
+ float velX = (float) (MathHelper.lerp((double) tickDelta, entity.prevCapeX,
+ entity.capeX)
+ - MathHelper.lerp((double) tickDelta, entity.prevX, entity.getX()));
+ float velZ = (float) (MathHelper.lerp((double) tickDelta, entity.prevCapeZ,
+ entity.capeZ)
+ - MathHelper.lerp((double) tickDelta, entity.prevZ, entity.getZ()));
+ float rawVelY = (float) (MathHelper.lerp((double) tickDelta, entity.prevCapeY, entity.capeY)
+ - MathHelper.lerp((double) tickDelta, entity.prevY, entity.getY()));
+
+ float velHorizontal = Math.min(0.6f, velZ > velX ? velZ : velX);
+ float velY = (rawVelY > 0.25f ? rawVelY : 0.0f);
+
+ float value = entity.isSwimming() ? 0.0f : Math.max(0.0f, Math.min(2.0f, velHorizontal + velY));
+
+ if (Config.cloakPhysics.value) {
+ updateSegmentValues(value);
+ } else {
+ float last = segmentValues[0];
+ float maxStep = 0.02f;
+ float next = value;
+
+ if (next > last)
+ next = Math.min(next, last + maxStep);
+ else if (next < last)
+ next = Math.max(next, last - maxStep);
+
+ Arrays.fill(segmentValues, next);
+ }
+ lastUpdate = now;
+ }
+
+ renderCape(matrices, vertexConsumer, light, OverlayTexture.DEFAULT_UV);
+
+ matrices.pop();
+ }
+
+ // public void render(MatrixStack matrixStack, VertexConsumerProvider
+ // vertexConsumerProvider, int light,
+ // PlayerEntityRenderState playerEntityRenderState, float f, float g) {
+ // if (playerEntityRenderState.invisible || !playerEntityRenderState.capeVisible
+ // || playerEntityRenderState.skinTextures.capeTexture() != null) {
+ // return;
+ // }
+
+ // SaturnPlayer player = SaturnPlayer.get(playerEntityRenderState.name);
+
+ // if (player == null) {
+ // return;
+ // }
+
+ // Identifier customCape = (Identifier) (Object)
+ // Cloaks.getCurrentCloakTexture(player.uuid);
+ // if (customCape == null
+ // || this.hasCustomModelForLayer(playerEntityRenderState.equippedChestStack,
+ // LayerType.WINGS)) {
+ // return;
+ // }
+
+ // int minBrightness = 7;
+ // int blockLight = (light >> 4) & 0xF;
+ // int skyLight = (light >> 20) & 0xF;
+ // blockLight = Math.max(blockLight, minBrightness);
+ // skyLight = Math.max(skyLight, minBrightness);
+ // light = (skyLight << 20) | (blockLight << 4);
+
+ // VertexConsumer vertexConsumer = vertexConsumerProvider
+ // .getBuffer(RenderLayer.getEntityAlpha(customCape));
+
+ // matrixStack.push();
+
+ // matrixStack.translate(0.0f, 0.0f, 0.12f);
+
+ // if (this.hasCustomModelForLayer(playerEntityRenderState.equippedChestStack,
+ // LayerType.HUMANOID)) {
+ // matrixStack.translate(0.0F, -0.053125F, 0.06875F);
+ // }
+
+ // if (playerEntityRenderState.isInSneakingPose) {
+ // matrixStack.translate(0, 0.16f, 0.0f);
+ // matrixStack.multiply(RotationAxis.POSITIVE_X.rotationDegrees(29.0f));
+ // }
+
+ // long now = System.currentTimeMillis();
+ // if (now - lastUpdate >= 20) {
+ // float velX = Math.min(1.0f, playerEntityRenderState.field_53537 / 108.0f);
+ // float rawVelY = playerEntityRenderState.field_53536;
+ // float velY = (rawVelY > 4.0f ? rawVelY : 0.0f) / 16;
+
+ // float value = playerEntityRenderState.isSwimming ? 0.0f : velX + velY;
+
+ // if (Config.cloakPhysics.value) {
+ // updateSegmentValues(value);
+ // } else {
+ // float last = segmentValues[0];
+ // float maxStep = 0.02f;
+ // float next = value;
+
+ // if (next > last)
+ // next = Math.min(next, last + maxStep);
+ // else if (next < last)
+ // next = Math.max(next, last - maxStep);
+
+ // Arrays.fill(segmentValues, next);
+ // }
+ // lastUpdate = now;
+ // }
+
+ // renderCape(matrixStack, vertexConsumer, playerEntityRenderState, light,
+ // OverlayTexture.DEFAULT_UV);
+
+ // matrixStack.pop();
+ // }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/HatFeatureRenderer.java b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/HatFeatureRenderer.java
new file mode 100644
index 0000000..a9b2b5d
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/HatFeatureRenderer.java
@@ -0,0 +1,43 @@
+package org.saturnclient.impl.cosmetics;
+
+import org.saturnclient.client.player.SaturnPlayer;
+import org.saturnclient.impl.cosmetics.obj.MtlLoader;
+import org.saturnclient.impl.cosmetics.obj.ObjModel;
+
+import net.minecraft.client.network.AbstractClientPlayerEntity;
+import net.minecraft.client.render.OverlayTexture;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.entity.feature.FeatureRenderer;
+import net.minecraft.client.render.entity.feature.FeatureRendererContext;
+import net.minecraft.client.render.entity.model.PlayerEntityModel;
+import net.minecraft.client.util.math.MatrixStack;
+
+public class HatFeatureRenderer
+ extends FeatureRenderer> {
+
+ public HatFeatureRenderer(
+ FeatureRendererContext> featureRendererContext) {
+ super(featureRendererContext);
+ }
+
+ @Override
+ public void render(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light,
+ AbstractClientPlayerEntity entity, float limbAngle, float limbDistance, float tickDelta,
+ float animationProgress, float headYaw, float headPitch) {
+ SaturnPlayer player = SaturnPlayer.get(entity.getUuid());
+
+ if (entity.isInvisible() || player == null || player.hat.isEmpty()) {
+ return;
+ }
+
+ matrices.push();
+
+ this.getContextModel().head.rotate(matrices);
+
+ ObjModel.cosmetic("hat", player.hat).render(MtlLoader.cosmetic(player.hat),
+ matrices, vertexConsumers, light,
+ OverlayTexture.DEFAULT_UV);
+
+ matrices.pop();
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/MtlLoader.java b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/MtlLoader.java
new file mode 100644
index 0000000..11b06d3
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/MtlLoader.java
@@ -0,0 +1,56 @@
+package org.saturnclient.impl.cosmetics.obj;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import de.javagl.obj.*;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.util.Identifier;
+
+public class MtlLoader {
+ static Map> loadedMtls = new HashMap<>();
+
+ static Map loadMtl(Identifier mtlId) throws IOException {
+ try (InputStream is = MinecraftClient.getInstance().getResourceManager()
+ .getResource(mtlId).get().getInputStream()) {
+ List mtlList = MtlReader.read(is);
+ Map mtlMap = new java.util.LinkedHashMap<>();
+ for (Mtl mtl : mtlList) {
+ mtlMap.put(mtl.getName(), mtl);
+ }
+ return mtlMap;
+ }
+ }
+
+ public static Map of(Identifier mtlId) {
+ if (loadedMtls.containsKey(mtlId))
+ return loadedMtls.get(mtlId);
+
+ try {
+ Map mtl = loadMtl(mtlId);
+ loadedMtls.put(mtlId, mtl);
+
+ return mtl;
+ } catch (IOException e) {
+ e.printStackTrace();
+
+ return null;
+ }
+
+ }
+
+ public static Map cosmetic(String cosmeticId) {
+ int index = cosmeticId.indexOf('_');
+
+ String mtlId = (index == -1) ? "" : cosmeticId.substring(index + 1);
+
+ if (mtlId.isEmpty())
+ mtlId = "white";
+
+ return of(Identifier.of("saturnclient", "models/cosmetic/materials/" + mtlId + ".mtl"));
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjModel.java b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjModel.java
new file mode 100644
index 0000000..3e7237e
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjModel.java
@@ -0,0 +1,102 @@
+package org.saturnclient.impl.cosmetics.obj;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.javagl.obj.Mtl;
+import de.javagl.obj.Obj;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.resource.Resource;
+import net.minecraft.resource.ResourceManager;
+import net.minecraft.util.Identifier;
+
+public class ObjModel {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ public static final Map loadedObjModels = new HashMap<>();
+
+ public record Config(
+ @JsonProperty("translate") Vec3d transform,
+ @JsonProperty("scale") Vec3d scale,
+ @JsonProperty("rotation") Vec3d rotation) {
+
+ public Config {
+ transform = transform == null ? new Vec3d(0, 0, 0) : transform;
+ scale = scale == null ? new Vec3d(1, 1, 1) : scale;
+ rotation = rotation == null ? new Vec3d(0, 0, 0) : rotation;
+ }
+
+ @JsonFormat(shape = JsonFormat.Shape.ARRAY)
+ public record Vec3d(float x, float y, float z) {
+ }
+ }
+
+ Obj obj;
+ Config config;
+
+ public static ObjModel cosmetic(String cosmeticKind, String id) {
+ return of(Identifier.of("saturnclient", "models/cosmetic/" + cosmeticKind + "/" + id.split("_")[0] + "/model"));
+ }
+
+ public static ObjModel of(Identifier obj) {
+ if (loadedObjModels.containsKey(obj))
+ return loadedObjModels.get(obj);
+
+ ObjModel o = new ObjModel(obj);
+ loadedObjModels.put(obj, o);
+ return o;
+ }
+
+ ObjModel(Identifier identifier) {
+ Identifier objId = Identifier.of(identifier.getNamespace(), identifier.getPath() + ".obj");
+ Identifier jsonId = Identifier.of(identifier.getNamespace(), identifier.getPath() + ".json");
+
+ try {
+ this.obj = ObjRenderer.loadObj(objId);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ // Try loading JSON config if it exists
+ ResourceManager resourceManager = MinecraftClient.getInstance().getResourceManager();
+ Optional resource = resourceManager.getResource(jsonId);
+
+ if (resource.isPresent()) {
+ try (InputStream stream = resource.get().getInputStream()) {
+ this.config = MAPPER.readValue(stream, Config.class);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ public void render(Map mtlMap, MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light,
+ int overlay) {
+ matrices.push();
+
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_X.rotationDegrees(180.0f));
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_Y.rotationDegrees(180.0f));
+
+ if (this.config != null) {
+ matrices.translate(config.transform.x, config.transform.y, config.transform.z);
+
+ matrices.scale(config.scale.x, config.scale.y, config.scale.z);
+
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_X.rotationDegrees(config.rotation.x));
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_Y.rotationDegrees(config.rotation.y));
+ matrices.multiply(net.minecraft.util.math.RotationAxis.POSITIVE_Z.rotationDegrees(config.rotation.z));
+ }
+
+ ObjRenderer.renderObj(this.obj, mtlMap, matrices, vertexConsumers, light, overlay);
+
+ matrices.pop();
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjRenderer.java b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjRenderer.java
new file mode 100644
index 0000000..27635a2
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/obj/ObjRenderer.java
@@ -0,0 +1,111 @@
+package org.saturnclient.impl.cosmetics.obj;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+
+import de.javagl.obj.*;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.render.RenderLayer;
+import net.minecraft.client.render.VertexConsumer;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.util.Identifier;
+
+public class ObjRenderer {
+ public static Obj loadObj(Identifier objId) throws IOException {
+ try (InputStream is = MinecraftClient.getInstance().getResourceManager()
+ .getResource(objId).get().getInputStream()) {
+ return ObjReader.read(is);
+ }
+ }
+
+ public static void renderObj(Obj obj, Map mtlMap, MatrixStack matrices,
+ VertexConsumerProvider vertexConsumers, int light, int overlay) {
+ MatrixStack.Entry entry = matrices.peek();
+
+ Map materialGroups = ObjSplitting.splitByMaterialGroups(obj);
+
+ for (Map.Entry group : materialGroups.entrySet()) {
+ String materialName = group.getKey();
+ Obj groupObj = group.getValue();
+
+ Mtl mtl = mtlMap != null ? mtlMap.get(materialName) : null;
+
+ // Resolve texture identifier using same logic as ObjUnbakedModelModel
+ Identifier texture = Identifier.of("minecraft:textures/misc/white.png");
+
+ if (mtl != null) {
+ String mapKd = mtl.getMapKd();
+ if (mapKd != null) {
+ // "#key" references are not resolvable at runtime without a texture map,
+ // so we only support direct resource location strings here
+ if (!mapKd.startsWith("#")) {
+ texture = Identifier.of(mapKd);
+ }
+ }
+ }
+
+ // Diffuse color (Kd)
+ float r = 1f, g = 1f, b = 1f;
+ if (mtl != null) {
+ FloatTuple kd = mtl.getKd();
+ if (kd != null) {
+ r = kd.getX();
+ g = kd.getY();
+ b = kd.getZ();
+ }
+ }
+
+ VertexConsumer consumer = vertexConsumers.getBuffer(RenderLayer.getEntityAlpha(texture));
+
+ int faces = groupObj.getNumFaces();
+ final float fr = r, fg = g, fb = b;
+
+ for (int f = 0; f < faces; f++) {
+ ObjFace face = groupObj.getFace(f);
+ int verts = face.getNumVertices();
+
+ // Your original winding order logic, untouched
+ for (int i = verts - 1; i >= 0; i--) {
+ writeVertex(groupObj, face, i, consumer, entry, light, overlay, fr, fg, fb);
+ }
+ }
+ }
+ }
+
+ private static void writeVertex(Obj obj, ObjFace face, int index, VertexConsumer consumer,
+ MatrixStack.Entry matrix, int light, int overlay,
+ float r, float g, float b) {
+
+ FloatTuple pos = obj.getVertex(face.getVertexIndex(index));
+ float x = pos.getX();
+ float y = pos.getY();
+ float z = pos.getZ();
+
+ float nx = 0f, ny = 1f, nz = 0f;
+ int normalIndex = face.getNormalIndex(index);
+ if (normalIndex >= 0) {
+ FloatTuple n = obj.getNormal(normalIndex);
+ nx = n.getX();
+ ny = n.getY();
+ nz = n.getZ();
+ }
+
+ float u = 0f, v = 0f;
+ int texIndex = face.getTexCoordIndex(index);
+ if (texIndex >= 0) {
+ FloatTuple tc = obj.getTexCoord(texIndex);
+ u = tc.getX();
+ v = 1f - tc.getY(); // Flip V: OBJ bottom-left origin -> Minecraft top-left
+ }
+
+ consumer.vertex(matrix, x, y, z)
+ .color(r, g, b, 1f)
+ .texture(u, v)
+ .overlay(overlay)
+ .light(light)
+ .normal(matrix, nx, ny, nz);
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/utils/IdentifierUtils.java b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/utils/IdentifierUtils.java
new file mode 100644
index 0000000..7da48e6
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/cosmetics/utils/IdentifierUtils.java
@@ -0,0 +1,70 @@
+package org.saturnclient.impl.cosmetics.utils;
+
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.saturnclient.SaturnClient;
+
+import net.minecraft.client.texture.NativeImage;
+import net.minecraft.client.texture.NativeImageBackedTexture;
+import net.minecraft.util.Identifier;
+
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.util.Map;
+
+public class IdentifierUtils {
+
+ public static void registerBufferedImageTextureFast(IdentifierRef identifierRef, BufferedImage bufferedImage) {
+ try {
+ Identifier identifier = (Identifier) (Object) identifierRef;
+
+ int width = bufferedImage.getWidth();
+ int height = bufferedImage.getHeight();
+
+ NativeImage nativeImage = new NativeImage(NativeImage.Format.RGBA, width, height, false);
+
+ if (bufferedImage.getType() == BufferedImage.TYPE_INT_ARGB) {
+ int[] pixels = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData();
+
+ int index = 0;
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ nativeImage.setColor(x, y, pixels[index++]);
+ }
+ }
+
+ } else {
+
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ nativeImage.setColor(x, y, bufferedImage.getRGB(x, y));
+ }
+ }
+
+ }
+
+ NativeImageBackedTexture texture = new NativeImageBackedTexture(nativeImage);
+
+ SaturnClient.client.execute(() ->
+ SaturnClient.client.getTextureManager().registerTexture(identifier, texture)
+ );
+
+ } catch (Exception e) {
+ SaturnClient.LOGGER.error("Failed to register texture: {}", identifierRef, e);
+ }
+ }
+
+ public static void registerBufferedImageTexturesBulk(Map textures) {
+
+ long start = System.currentTimeMillis();
+
+ textures.forEach(IdentifierUtils::registerBufferedImageTextureFast);
+
+ long end = System.currentTimeMillis();
+
+ SaturnClient.LOGGER.info(
+ "Registered {} textures in {}ms",
+ textures.size(),
+ (end - start)
+ );
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/mixins/IdentifierMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/IdentifierMixin.java
new file mode 100644
index 0000000..edfcdb9
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/IdentifierMixin.java
@@ -0,0 +1,13 @@
+package org.saturnclient.impl.mixins;
+
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+import net.minecraft.util.Identifier;
+
+@Mixin(Identifier.class)
+public abstract class IdentifierMixin implements IdentifierRef {
+ @Shadow
+ public abstract String toString();
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/mixins/ItemStackMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/ItemStackMixin.java
new file mode 100644
index 0000000..9a69341
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/ItemStackMixin.java
@@ -0,0 +1,19 @@
+package org.saturnclient.impl.mixins;
+
+import org.saturnclient.common.ref.game.ItemStackRef;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+import net.minecraft.item.ItemStack;
+
+@Mixin(ItemStack.class)
+public abstract class ItemStackMixin implements ItemStackRef {
+ @Shadow
+ public abstract boolean isEmpty();
+
+ @Shadow
+ public abstract int getMaxDamage();
+
+ @Shadow
+ public abstract int getDamage();
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/mixins/MatrixStackMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/MatrixStackMixin.java
new file mode 100644
index 0000000..d8e88a4
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/MatrixStackMixin.java
@@ -0,0 +1,59 @@
+package org.saturnclient.impl.mixins;
+
+import org.joml.Quaternionf;
+import org.saturnclient.common.ref.render.MatrixStackRef;
+import org.saturnclient.common.ref.render.QuaternionfRef;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.gen.Invoker;
+
+import net.minecraft.client.util.math.MatrixStack;
+
+@Mixin(MatrixStack.class)
+public abstract class MatrixStackMixin implements MatrixStackRef {
+
+ @Shadow
+ public abstract void translate(double x, double y, double z);
+
+ @Shadow
+ public abstract void translate(float x, float y, float z);
+
+ @Shadow
+ public abstract void scale(float x, float y, float z);
+
+ @Shadow
+ public abstract void multiply(Quaternionf quaternion);
+
+ @Shadow
+ public abstract void multiply(Quaternionf quaternion, float originX, float originY, float originZ);
+
+ @Shadow
+ public abstract void push();
+
+ @Shadow
+ public abstract void pop();
+
+ @Invoker("peek")
+ public abstract MatrixStack.Entry peekM();
+
+ @Shadow
+ public abstract boolean isEmpty();
+
+ @Shadow
+ public abstract void loadIdentity();
+
+ @Override
+ public void multiply(QuaternionfRef quaternion) {
+ this.multiply((Quaternionf) quaternion);
+ }
+
+ @Override
+ public void multiply(QuaternionfRef quaternion, float originX, float originY, float originZ) {
+ this.multiply((Quaternionf) quaternion, originX, originY, originZ);
+ }
+
+ @Override
+ public Object peek() {
+ return this.peekM();
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/mixins/MinecraftClientMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/MinecraftClientMixin.java
new file mode 100644
index 0000000..328917d
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/MinecraftClientMixin.java
@@ -0,0 +1,130 @@
+package org.saturnclient.impl.mixins;
+
+import java.io.File;
+import java.io.InputStream;
+import java.util.UUID;
+
+import org.jetbrains.annotations.Nullable;
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.game.MinecraftClientRef;
+import org.saturnclient.common.ref.render.WindowRef;
+import org.saturnclient.impl.ui.SaturnScreenFabric;
+import org.saturnclient.ui.SaturnScreen;
+import org.saturnclient.ui.screens.TitleMenu;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen;
+import net.minecraft.client.gui.screen.option.OptionsScreen;
+import net.minecraft.client.gui.screen.world.SelectWorldScreen;
+import net.minecraft.client.option.GameOptions;
+import net.minecraft.client.session.Session;
+import net.minecraft.client.util.Window;
+import net.minecraft.resource.ReloadableResourceManagerImpl;
+import net.minecraft.util.Identifier;
+
+@Mixin(MinecraftClient.class)
+public abstract class MinecraftClientMixin implements MinecraftClientRef {
+ @Shadow
+ public File runDirectory;
+
+ @Shadow
+ private Window window;
+
+ @Shadow
+ private ReloadableResourceManagerImpl resourceManager;
+
+ @Shadow
+ public GameOptions options;
+
+ @Shadow
+ private Session session;
+
+ @Shadow
+ public abstract void setScreen(@Nullable Screen screen);
+
+ @Shadow
+ @Override
+ public abstract void scheduleStop();
+
+ @Override
+ public File getRunDirectory() {
+ return runDirectory;
+ }
+
+ @Override
+ public InputStream getResource(IdentifierRef identifier) {
+ try {
+ return resourceManager.getResource((Identifier) (Object) identifier).get().getInputStream();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ @Override
+ public WindowRef getWindow() {
+ return (WindowRef) (Object) this.window;
+ }
+
+ @Override
+ public void setScreen(SaturnScreen screen) {
+ this.setScreen(new SaturnScreenFabric(screen));
+ }
+
+ @Override
+ public void setScreen(MinecraftScreen screen) {
+ SaturnScreenFabric exitTarget = new SaturnScreenFabric(new TitleMenu());
+
+ switch (screen) {
+ case SelectWorld:
+ this.setScreen(new SelectWorldScreen(exitTarget));
+ break;
+
+ case Multiplayer:
+ this.setScreen(new MultiplayerScreen(exitTarget));
+ break;
+
+ case Options:
+ this.setScreen(new OptionsScreen(exitTarget, this.options));
+ break;
+ }
+ }
+
+ @Override
+ public String getAccessToken() {
+ if (session == null) {
+ return null;
+ }
+
+ return this.session.getAccessToken();
+ }
+
+ @Override
+ public UUID getUuid() {
+ if (session == null) {
+ return null;
+ }
+ return this.session.getUuidOrNull();
+ }
+
+ @Override
+ public String getUsername() {
+ if (session == null) {
+ return null;
+ }
+ return this.session.getUsername();
+ }
+
+ @Override
+ public void onClientStopping(Runnable handler) {
+ ClientLifecycleEvents.CLIENT_STOPPING.register(_o -> handler.run());
+ }
+
+ @Override
+ public void executeOnThread(Runnable runnable) {
+ ((java.util.concurrent.Executor) (Object) this).execute(runnable);
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/mixins/QuaternionfMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/QuaternionfMixin.java
new file mode 100644
index 0000000..628e433
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/QuaternionfMixin.java
@@ -0,0 +1,9 @@
+package org.saturnclient.impl.mixins;
+
+import org.joml.Quaternionf;
+import org.saturnclient.common.ref.render.QuaternionfRef;
+import org.spongepowered.asm.mixin.Mixin;
+
+@Mixin(Quaternionf.class)
+public abstract class QuaternionfMixin implements QuaternionfRef {
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/mixins/SpriteMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/SpriteMixin.java
new file mode 100644
index 0000000..6cf07bc
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/SpriteMixin.java
@@ -0,0 +1,9 @@
+package org.saturnclient.impl.mixins;
+
+import net.minecraft.client.texture.Sprite;
+import org.saturnclient.common.ref.asset.SpriteRef;
+import org.spongepowered.asm.mixin.Mixin;
+
+@Mixin(Sprite.class)
+public abstract class SpriteMixin implements SpriteRef {
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/mixins/TextMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/TextMixin.java
new file mode 100644
index 0000000..3e1a8b8
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/TextMixin.java
@@ -0,0 +1,24 @@
+package org.saturnclient.impl.mixins;
+
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.render.TextRef;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.spongepowered.asm.mixin.Mixin;
+
+import net.minecraft.text.Style;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+
+@Mixin(Text.class)
+public interface TextMixin extends TextRef {
+
+ @Override
+ public default TextRef withFont(IdentifierRef font) {
+ return (TextRef) (((Text) this).copy().setStyle(Style.EMPTY.withFont((Identifier) (Object) font)));
+ }
+
+ @Override
+ public default int getWidth() {
+ return SaturnClient.client.textRenderer.getWidth((Text) this);
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/mixins/WindowMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/WindowMixin.java
new file mode 100644
index 0000000..5629068
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/mixins/WindowMixin.java
@@ -0,0 +1,22 @@
+package org.saturnclient.impl.mixins;
+
+import org.saturnclient.common.ref.render.WindowRef;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+import net.minecraft.client.util.Window;
+
+@Mixin(Window.class)
+public abstract class WindowMixin implements WindowRef {
+ @Shadow
+ public abstract int getWidth();
+
+ @Shadow
+ public abstract int getHeight();
+
+ @Shadow
+ public abstract int getFramebufferWidth();
+
+ @Shadow
+ public abstract int getFramebufferHeight();
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/EntityModuleFabric.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/EntityModuleFabric.java
new file mode 100644
index 0000000..aa42d4f
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/EntityModuleFabric.java
@@ -0,0 +1,80 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.entity.Entity;
+import net.minecraft.util.hit.EntityHitResult;
+import net.minecraft.util.hit.HitResult;
+import net.minecraft.entity.LivingEntity;
+
+import org.saturnclient.common.module.EntityModule;
+
+
+/**
+ * Fabric implementation of {@link EntityModule}.
+ *
+ * Crosshair targeting is resolved via {@link MinecraftClient#crosshairTarget}.
+ * Entity state for nametag rendering is supplied at render time by
+ * {@link org.saturnclient.modules.mixins.LivingEntityRendererMixin} which
+ * writes into the render state; {@link NametagsMixin} then constructs
+ * a {@link RenderStateEntityState} from the already-extracted snapshot.
+ */
+public class EntityModuleFabric implements EntityModule {
+
+ private final MinecraftClient mc;
+
+ public EntityModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ @Override
+ public boolean isTargetingLivingEntity() {
+ HitResult target = mc.crosshairTarget;
+ if (target == null || target.getType() != HitResult.Type.ENTITY)
+ return false;
+ Entity hit = ((EntityHitResult) target).getEntity();
+ return hit instanceof LivingEntity;
+ }
+
+ // ---------------------------------------------------------------
+ // Render-state adapter
+ // ---------------------------------------------------------------
+
+ /**
+ * Wraps a {@link HealthRenderState} (already injected into the
+ * Minecraft render-state object) as a platform-neutral
+ * {@link EntityState} for use by {@link NametagsFeature}.
+ *
+ * This is constructed inside {@link NametagsMixin} where both the
+ * render-state mixin interface and the entity name are available.
+ */
+ public static final class RenderStateEntityState implements EntityState {
+
+ private final String customName;
+ private final HealthRenderState hrs;
+
+ public RenderStateEntityState(String customName, HealthRenderState hrs) {
+ this.customName = customName;
+ this.hrs = hrs;
+ }
+
+ @Override
+ public String getCustomName() {
+ return customName;
+ }
+
+ @Override
+ public float getHealth() {
+ return hrs.saturn$getHealth();
+ }
+
+ @Override
+ public float getMaxHealth() {
+ return hrs.saturn$getMaxHealth();
+ }
+
+ @Override
+ public EntityType getEntityType() {
+ return hrs.saturn$getEntityType();
+ }
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/HealthRenderState.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/HealthRenderState.java
new file mode 100644
index 0000000..d5a063b
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/HealthRenderState.java
@@ -0,0 +1,20 @@
+package org.saturnclient.impl.modules;
+
+import org.saturnclient.common.module.EntityModule;
+
+/**
+ * Injected interface on {@link net.minecraft.client.render.entity.state.LivingEntityRenderState}.
+ *
+ * Carries the entity's health snapshot and classification into the render
+ * pipeline so that {@link NametagsMixin} can read them without touching
+ * the live entity again.
+ */
+public interface HealthRenderState {
+
+ float saturn$getHealth();
+ float saturn$getMaxHealth();
+ void saturn$setHealth(float health, float maxHealth);
+
+ EntityModule.EntityType saturn$getEntityType();
+ void saturn$setEntityType(EntityModule.EntityType type);
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/NetworkModuleFabric.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/NetworkModuleFabric.java
new file mode 100644
index 0000000..61b1b9c
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/NetworkModuleFabric.java
@@ -0,0 +1,37 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.PlayerListEntry;
+
+import org.saturnclient.common.module.NetworkModule;
+
+/**
+ * Fabric implementation of {@link NetworkModule}.
+ *
+ * Connection presence is checked via the network handler.
+ * Ping is read from the player's {@link PlayerListEntry} which the
+ * server populates; returns {@code -1} when unavailable.
+ */
+public class NetworkModuleFabric implements NetworkModule {
+
+ private final MinecraftClient mc;
+
+ public NetworkModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ @Override
+ public boolean hasNetwork() {
+ return mc.getNetworkHandler() != null;
+ }
+
+ @Override
+ public int getPing() {
+ if (mc.player == null || mc.getNetworkHandler() == null)
+ return -1;
+
+ PlayerListEntry entry = mc.getNetworkHandler().getPlayerListEntry(mc.player.getUuid());
+
+ return entry != null ? entry.getLatency() : -1;
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/PlayerModuleFabric.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/PlayerModuleFabric.java
new file mode 100644
index 0000000..68be4b9
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/PlayerModuleFabric.java
@@ -0,0 +1,302 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.entity.effect.StatusEffectInstance;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+
+import org.saturnclient.common.module.PlayerModule;
+import org.saturnclient.common.ref.asset.SpriteRef;
+import org.saturnclient.common.ref.game.ItemStackRef;
+import org.saturnclient.feature.features.StatusEffectsFeature;
+import org.saturnclient.saturnclient.SaturnClient;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Fabric implementation of {@link PlayerModule}.
+ *
+ * All access to {@link MinecraftClient} and {@link ClientPlayerEntity}
+ * is encapsulated here. Features only ever see the {@link PlayerModule}
+ * interface; they never import Minecraft classes directly.
+ *
+ * Every method guards against a missing player and returns a safe
+ * default so features are free of null checks.
+ */
+public class PlayerModuleFabric implements PlayerModule {
+
+ private final MinecraftClient mc;
+
+ public PlayerModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ // ---------------------------------------------------------------
+ // Internal helper — never returns null
+ // ---------------------------------------------------------------
+
+ /** Returns the local player, or {@code null} if one is not loaded. */
+ private ClientPlayerEntity player() {
+ return mc.player;
+ }
+
+ // ---------------------------------------------------------------
+ // Presence
+ // ---------------------------------------------------------------
+
+ @Override
+ public boolean hasPlayer() {
+ return mc.player != null;
+ }
+
+ // ---------------------------------------------------------------
+ // Position
+ // ---------------------------------------------------------------
+
+ @Override
+ public int getX() {
+ return hasPlayer() ? (int) player().getX() : 0;
+ }
+
+ @Override
+ public int getY() {
+ return hasPlayer() ? (int) player().getY() : 0;
+ }
+
+ @Override
+ public int getZ() {
+ return hasPlayer() ? (int) player().getZ() : 0;
+ }
+
+ // ---------------------------------------------------------------
+ // Input / movement
+ // ---------------------------------------------------------------
+
+ @Override
+ public boolean isForwardPressed() {
+ return mc.options.forwardKey.isPressed();
+ }
+
+ @Override
+ public boolean isBackPressed() {
+ return mc.options.backKey.isPressed();
+ }
+
+ @Override
+ public boolean isLeftPressed() {
+ return mc.options.leftKey.isPressed();
+ }
+
+ @Override
+ public boolean isRightPressed() {
+ return mc.options.rightKey.isPressed();
+ }
+
+ @Override
+ public boolean isJumpPressed() {
+ return mc.options.jumpKey.isPressed();
+ }
+
+ @Override
+ public boolean isAttackPressed() {
+ return mc.options.attackKey.isPressed();
+ }
+
+ @Override
+ public boolean isUsePressed() {
+ return mc.options.useKey.isPressed();
+ }
+
+ @Override
+ public boolean isSneaking() {
+ return hasPlayer() && player().isSneaking();
+ }
+
+ @Override
+ public boolean isUsingItem() {
+ return hasPlayer() && player().isUsingItem();
+ }
+
+ @Override
+ public boolean hasHorizontalCollision() {
+ return hasPlayer() && player().horizontalCollision;
+ }
+
+ @Override
+ public boolean isOnGround() {
+ return hasPlayer() && player().isOnGround();
+ }
+
+ @Override
+ public void setSprinting(boolean sprinting) {
+ if (hasPlayer())
+ player().setSprinting(sprinting);
+ }
+
+ // ---------------------------------------------------------------
+ // Velocity
+ // ---------------------------------------------------------------
+
+ @Override
+ public Velocity getVelocity() {
+ if (!hasPlayer())
+ return Velocity.ZERO;
+ var v = player().getVelocity();
+ return new Velocity(v.x, v.y, v.z);
+ }
+
+ // ---------------------------------------------------------------
+ // Stats
+ // ---------------------------------------------------------------
+
+ @Override
+ public float getHealth() {
+ return hasPlayer() ? player().getHealth() : 0f;
+ }
+
+ // ---------------------------------------------------------------
+ // Equipment
+ // ---------------------------------------------------------------
+
+ @Override
+ public ItemStackRef getMainHand() {
+ return wrap(hasPlayer() ? player().getMainHandStack() : ItemStack.EMPTY);
+ }
+
+ @Override
+ public ItemStackRef getHelmet() {
+ return wrap(hasPlayer() ? player().getInventory().armor.get(3) : ItemStack.EMPTY);
+ }
+
+ @Override
+ public ItemStackRef getChestplate() {
+ return wrap(hasPlayer() ? player().getInventory().armor.get(2) : ItemStack.EMPTY);
+ }
+
+ @Override
+ public ItemStackRef getLeggings() {
+ return wrap(hasPlayer() ? player().getInventory().armor.get(1) : ItemStack.EMPTY);
+ }
+
+ @Override
+ public ItemStackRef getBoots() {
+ return wrap(hasPlayer() ? player().getInventory().armor.get(0) : ItemStack.EMPTY);
+ }
+
+ // ---------------------------------------------------------------
+ // Dummy equipment
+ // ---------------------------------------------------------------
+
+ @Override
+ public ItemStackRef getDummyMainHand() {
+ return wrap(new ItemStack(Items.DIAMOND_SWORD));
+ }
+
+ @Override
+ public ItemStackRef getDummyHelmet() {
+ return wrap(new ItemStack(Items.DIAMOND_HELMET));
+ }
+
+ @Override
+ public ItemStackRef getDummyChestplate() {
+ return wrap(new ItemStack(Items.DIAMOND_CHESTPLATE));
+ }
+
+ @Override
+ public ItemStackRef getDummyLeggings() {
+ return wrap(new ItemStack(Items.DIAMOND_LEGGINGS));
+ }
+
+ @Override
+ public ItemStackRef getDummyBoots() {
+ return wrap(new ItemStack(Items.DIAMOND_BOOTS));
+ }
+
+ // ---------------------------------------------------------------
+ // Status effects
+ // ---------------------------------------------------------------
+
+ @Override
+ public List extends StatusEffectsFeature.EffectView> getActiveEffects() {
+ if (!hasPlayer())
+ return Collections.emptyList();
+
+ var effects = player().getActiveStatusEffects().values();
+ List result = new ArrayList<>(effects.size());
+ for (StatusEffectInstance instance : effects) {
+ result.add(new FabricEffectView(instance));
+ }
+ return result;
+ }
+
+ @Override
+ public List extends StatusEffectsFeature.EffectView> getDummyEffects() {
+ if (!hasPlayer())
+ return Collections.emptyList();
+
+ var effects = player().getActiveStatusEffects().values();
+ List result = new ArrayList<>(effects.size());
+ for (StatusEffectInstance instance : effects) {
+ result.add(new FabricEffectView(instance));
+ }
+ return result;
+ }
+
+ // ---------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------
+
+ private static ItemStackRef wrap(ItemStack stack) {
+ return (ItemStackRef) (Object) stack;
+ }
+
+ // ---------------------------------------------------------------
+ // Inner — EffectView adapter
+ // ---------------------------------------------------------------
+
+ /**
+ * Bridges a Fabric {@link StatusEffectInstance} to the
+ * platform-neutral {@link StatusEffectsFeature.EffectView}.
+ */
+ public static final class FabricEffectView implements StatusEffectsFeature.EffectView {
+
+ private final StatusEffectInstance instance;
+
+ public FabricEffectView(StatusEffectInstance instance) {
+ this.instance = instance;
+ }
+
+ @Override
+ public boolean shouldShowIcon() {
+ return instance.shouldShowIcon();
+ }
+
+ /**
+ * Returns the Minecraft sprite identifier for this effect's icon.
+ * The platform-specific renderer knows how to draw a
+ * {@link net.minecraft.util.Identifier}.
+ */
+ @Override
+ public SpriteRef getIcon() {
+ return (SpriteRef) SaturnClient.client.getStatusEffectSpriteManager().getSprite(instance.getEffectType());
+ }
+
+ @Override
+ public boolean isInfinite() {
+ return instance.isInfinite();
+ }
+
+ @Override
+ public String getInfiniteText() {
+ return "∞";
+ }
+
+ @Override
+ public int getDurationSeconds() {
+ return instance.getDuration() / 20; // ticks → seconds
+ }
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/RenderModuleFabric.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/RenderModuleFabric.java
new file mode 100644
index 0000000..bfde729
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/RenderModuleFabric.java
@@ -0,0 +1,64 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.option.Perspective;
+
+import org.saturnclient.common.module.RenderModule;
+
+/**
+ * Fabric implementation of {@link RenderModule}.
+ *
+ * Window dimensions and FPS are read directly from
+ * {@link MinecraftClient}. Camera perspective control delegates to
+ * {@link net.minecraft.client.option.GameOptions}.
+ */
+public class RenderModuleFabric implements RenderModule {
+
+ private final MinecraftClient mc;
+
+ public RenderModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ // ---------------------------------------------------------------
+ // Window
+ // ---------------------------------------------------------------
+
+ @Override
+ public int getScaledWindowWidth() {
+ return mc.getWindow().getScaledWidth();
+ }
+
+ @Override
+ public int getScaledWindowHeight() {
+ return mc.getWindow().getScaledHeight();
+ }
+
+ // ---------------------------------------------------------------
+ // Performance
+ // ---------------------------------------------------------------
+
+ @Override
+ public int getFps() {
+ return mc.getCurrentFps();
+ }
+
+ // ---------------------------------------------------------------
+ // Camera perspective
+ // ---------------------------------------------------------------
+
+ @Override
+ public boolean isFirstPerson() {
+ return mc.options.getPerspective() == Perspective.FIRST_PERSON;
+ }
+
+ @Override
+ public void setFirstPerson() {
+ mc.options.setPerspective(Perspective.FIRST_PERSON);
+ }
+
+ @Override
+ public void setThirdPersonBack() {
+ mc.options.setPerspective(Perspective.THIRD_PERSON_BACK);
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/WorldModuleFabric.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/WorldModuleFabric.java
new file mode 100644
index 0000000..b555a23
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/WorldModuleFabric.java
@@ -0,0 +1,36 @@
+package org.saturnclient.impl.modules;
+
+import net.minecraft.client.MinecraftClient;
+
+import org.saturnclient.common.module.WorldModule;
+
+/**
+ * Fabric implementation of {@link WorldModule}.
+ *
+ * Reads the client world's time directly from {@link MinecraftClient}.
+ * Returns safe defaults when no world is loaded.
+ */
+public class WorldModuleFabric implements WorldModule {
+
+ private final MinecraftClient mc;
+
+ public WorldModuleFabric(MinecraftClient mc) {
+ this.mc = mc;
+ }
+
+ @Override
+ public boolean hasWorld() {
+ return mc.world != null;
+ }
+
+ /**
+ * Returns the total world age in ticks, or {@code 0} when no world
+ * is loaded. Used by {@link TpsFeature#onTimePacket} (which is
+ * called from the mixin and stores its own copy) and by
+ * {@link DayCounterFeature} for the day count.
+ */
+ @Override
+ public long getWorldAge() {
+ return hasWorld() ? mc.world.getTime() : 0L;
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/GameRendererMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/GameRendererMixin.java
new file mode 100644
index 0000000..3dfd659
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/GameRendererMixin.java
@@ -0,0 +1,37 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.render.GameRenderer;
+
+import org.saturnclient.feature.features.ZoomFeature;
+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.CallbackInfoReturnable;
+
+/**
+ * Overrides the game's field-of-view when {@link ZoomFeature} is active.
+ *
+ * The old code checked both {@code Zoom.isZooming} and
+ * {@code Zoom.shouldZoom()} separately. The refactored
+ * {@link ZoomFeature#shouldZoom()} already ORs both conditions
+ * ({@code enabled.value && isZooming}), so only one check is needed.
+ *
+ * No {@link org.saturnclient.common.module.ModuleProvider} is required
+ * here because zooming is a pure render-time override with no per-tick
+ * engine queries.
+ */
+@Mixin(GameRenderer.class)
+public class GameRendererMixin {
+
+ /**
+ * After the vanilla FOV has been computed, divide it by the zoom
+ * level to produce the narrowed field-of-view.
+ */
+ @Inject(method = "getFov", at = @At("RETURN"), cancellable = true)
+ private void onGetFov(CallbackInfoReturnable cir) {
+ if (ZoomFeature.shouldZoom()) {
+ float zoomedFov = cir.getReturnValue() / ZoomFeature.getZoomLevel();
+ cir.setReturnValue(zoomedFov);
+ }
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/LightmapTextureManagerMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/LightmapTextureManagerMixin.java
new file mode 100644
index 0000000..564e781
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/LightmapTextureManagerMixin.java
@@ -0,0 +1,35 @@
+package org.saturnclient.impl.modules.mixins;
+
+// Code approach adapted from Gamma Utils: https://github.com/Sjouwer/gamma-utils
+
+import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
+import net.minecraft.client.render.LightmapTextureManager;
+
+import org.saturnclient.feature.features.FullbrightFeature;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+
+/**
+ * Overrides the lightmap's gamma computation when
+ * {@link FullbrightFeature} is active, replacing the vanilla
+ * brightness with a user-configured value.
+ *
+ * The old import was {@code org.saturnclient.feature.features.Fullbright};
+ * it is now {@link FullbrightFeature}. The injection target and
+ * strategy are unchanged.
+ */
+@Mixin(LightmapTextureManager.class)
+public class LightmapTextureManagerMixin {
+
+ /**
+ * If fullbright is active, substitute the feature's brightness
+ * value for whatever vanilla calculated.
+ */
+ @ModifyExpressionValue(method = "update", at = @At(value = "INVOKE", target = "Ljava/lang/Math;max(FF)F", ordinal = 0))
+ private float modifyGamma(float original) {
+ if (FullbrightFeature.shouldOverrideBrightness()) {
+ return FullbrightFeature.getBrightnessValue();
+ }
+ return original;
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/LivingEntityRendererMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/LivingEntityRendererMixin.java
new file mode 100644
index 0000000..f67bc95
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/LivingEntityRendererMixin.java
@@ -0,0 +1,55 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.render.entity.LivingEntityRenderer;
+// import net.minecraft.client.render.entity.state.LivingEntityRenderState;
+import net.minecraft.entity.LivingEntity;
+import net.minecraft.entity.mob.HostileEntity;
+import net.minecraft.entity.passive.PassiveEntity;
+import net.minecraft.entity.player.PlayerEntity;
+
+import org.saturnclient.common.module.EntityModule;
+import org.saturnclient.impl.modules.HealthRenderState;
+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;
+
+/**
+ * Captures each living entity's health and type into the render state
+ * during {@code updateRenderState}, so that nametag rendering can read
+ * them without touching the live entity again.
+ *
+ * Previously imported {@code NametagsInterface.EntityType}; now uses
+ * {@link EntityModule.EntityType} — the canonical enum shared across
+ * the whole module system.
+ *
+ * The injection target and strategy are unchanged from the original.
+ */
+@Mixin(LivingEntityRenderer.class)
+public abstract class LivingEntityRendererMixin {
+}
+// public abstract class LivingEntityRendererMixin {
+
+// @Inject(method = "updateRenderState", at = @At("TAIL"))
+// private void saturn$captureHealth(T entity, S state, float tickDelta,
+// CallbackInfo ci) {
+// if (!(state instanceof HealthRenderState hrs))
+// return;
+
+// hrs.saturn$setHealth(entity.getHealth(), entity.getMaxHealth());
+
+// // Classify entity type for the nametag feature's filtering logic
+// EntityModule.EntityType type;
+// if (entity instanceof PlayerEntity)
+// type = EntityModule.EntityType.PLAYER;
+// else if (entity instanceof HostileEntity)
+// type = EntityModule.EntityType.HOSTILE;
+// else if (entity instanceof PassiveEntity)
+// type = EntityModule.EntityType.PASSIVE;
+// else
+// type = EntityModule.EntityType.OTHER;
+
+// hrs.saturn$setEntityType(type);
+// }
+// }
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/NametagsMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/NametagsMixin.java
new file mode 100644
index 0000000..a721d04
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/NametagsMixin.java
@@ -0,0 +1,94 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.entity.EntityRenderer;
+// import net.minecraft.client.render.entity.state.EntityRenderState;
+// import net.minecraft.client.render.entity.state.LivingEntityRenderState;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.text.Text;
+
+import org.saturnclient.feature.features.NametagsFeature;
+import org.saturnclient.impl.modules.EntityModuleFabric;
+import org.saturnclient.impl.modules.HealthRenderState;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+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;
+
+/**
+ * Intercepts nametag rendering and replaces the displayed text with
+ * the string produced by {@link NametagsFeature#getNametagString}.
+ *
+ * Key changes from the original:
+ *
+ *
+ * - The old {@code NametagsFabric.EntityStateImpl} bridge class is
+ * replaced by {@link EntityModuleFabric.RenderStateEntityState},
+ * which wraps the {@link HealthRenderState} already injected into
+ * the render state by {@link LivingEntityRenderStateMixin}.
+ * - The feature import changes from {@code Nametags} to
+ * {@link NametagsFeature}; the two static methods
+ * ({@code shouldReplaceName} / {@code getNametagString}) are
+ * unchanged in signature.
+ * - The entity's display name is extracted from the render state's
+ * {@code nameVisible} / text fields where available, falling back
+ * to the plain text string passed to this method.
+ *
+ *
+ * The re-entrancy guard ({@code saturn$rendering}) is kept intact to
+ * prevent infinite recursion when we call {@code renderLabelIfPresent}
+ * with the replacement text.
+ */
+@Mixin(EntityRenderer.class)
+public abstract class NametagsMixin {
+
+ /** Guards against the recursive call we make with the replacement text. */
+ @Unique
+ private static final ThreadLocal saturn$rendering = ThreadLocal.withInitial(() -> false);
+
+ // @Shadow
+ // protected abstract void renderLabelIfPresent(
+ // S state, Text text,
+ // MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light);
+
+ // @Inject(method = "renderLabelIfPresent", at = @At("HEAD"), cancellable = true)
+ // private void saturn$replaceNametag(
+ // S state, Text text,
+ // MatrixStack matrices, VertexConsumerProvider vertexConsumers,
+ // int light, CallbackInfo ci) {
+
+ // // Skip our own recursive call
+ // if (saturn$rendering.get())
+ // return;
+
+ // if (!NametagsFeature.shouldReplaceName())
+ // return;
+
+ // // Only living entities carry health data
+ // if (!(state instanceof LivingEntityRenderState living))
+ // return;
+ // if (!(living instanceof HealthRenderState hrs))
+ // return;
+
+ // // Build the platform-neutral EntityState from the render-state snapshot
+ // // (health/type were written by LivingEntityRendererMixin at extract time).
+ // String customName = text != null ? text.getString() : null;
+ // EntityModuleFabric.RenderStateEntityState entityState = new EntityModuleFabric.RenderStateEntityState(
+ // customName, hrs);
+
+ // // Ask the feature for the replacement string
+ // String replacement = NametagsFeature.getNametagString(entityState);
+ // if (replacement == null)
+ // return; // feature decided not to replace this entity
+
+ // ci.cancel();
+ // saturn$rendering.set(true);
+ // try {
+ // renderLabelIfPresent(state, Text.literal(replacement), matrices, vertexConsumers, light);
+ // } finally {
+ // saturn$rendering.set(false);
+ // }
+ // }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/RenderMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/RenderMixin.java
new file mode 100644
index 0000000..7b45975
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/RenderMixin.java
@@ -0,0 +1,85 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.hud.InGameHud;
+import net.minecraft.client.render.RenderTickCounter;
+
+import org.saturnclient.feature.Feature;
+import org.saturnclient.feature.FeatureLayout;
+import org.saturnclient.feature.FeatureManager;
+import org.saturnclient.feature.HudFeature;
+import org.saturnclient.impl.ui.RenderScopeImpl;
+import org.saturnclient.impl.ui.SaturnScreenFabric;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.saturnclient.saturnclient.mixin.DrawContextAccessor;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.screens.HudEditor;
+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;
+
+/**
+ * Drives per-frame rendering for all registered features.
+ *
+ * This mixin is intentionally kept thin: it creates a {@link RenderScope},
+ * iterates the enabled feature list from {@link FeatureManager}, and
+ * dispatches either {@link HudFeature#renderHud} (for HUD elements) or
+ * {@link Feature#render} (for world-overlay features like Crosshair).
+ *
+ * No feature-specific logic lives here — all decisions about what to
+ * draw are made inside each feature class.
+ *
+ * Changes from the original:
+ * - Package imports updated to the refactored feature names (no
+ * more {@code feature.features.*}).
+ * - The HUD-editor screen check still uses the existing
+ * {@link SaturnScreenFabric} / {@link HudEditor} pair; that is
+ * unchanged infrastructure.
+ */
+@Mixin(InGameHud.class)
+public class RenderMixin {
+
+ @Inject(method = "renderChat", at = @At("TAIL"))
+ public void renderMods(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) {
+ TextRenderer textRenderer = SaturnClient.client.textRenderer;
+
+ // Skip rendering while the HUD editor is open so the editor
+ // can draw its own preview layout without double-drawing.
+ if (textRenderer == null)
+ return;
+ if (SaturnClient.client.currentScreen instanceof SaturnScreenFabric f
+ && f.screen instanceof HudEditor)
+ return;
+
+ RenderScope scope = new RenderScopeImpl(
+ context.getMatrices(),
+ ((DrawContextAccessor) context).getVertexConsumers());
+
+ for (Feature feature : FeatureManager.ENABLED_MODS) {
+ if (!feature.isEnabled())
+ continue;
+
+ if (feature instanceof HudFeature hud) {
+ FeatureLayout dim = hud.getDimensions();
+
+ scope.getMatrixStack().push();
+ scope.getMatrixStack().translate(dim.x.value, (float) dim.y.value, 0f);
+ scope.getMatrixStack().scale(dim.scale.value, dim.scale.value, 1.0f);
+
+ if (dim.renderBackground) {
+ scope.drawRoundedRectangle(0, 0, dim.width, dim.height,
+ dim.radius.value, dim.bgColor.value);
+ }
+
+ hud.renderHud(scope);
+ scope.getMatrixStack().pop();
+
+ } else {
+ // Non-HUD features (e.g. CrosshairFeature) use the plain render hook.
+ feature.render(scope);
+ }
+ }
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/TpsMixin.java b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/TpsMixin.java
new file mode 100644
index 0000000..0e452a7
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/modules/mixins/TpsMixin.java
@@ -0,0 +1,30 @@
+package org.saturnclient.impl.modules.mixins;
+
+import net.minecraft.client.network.ClientPlayNetworkHandler;
+import net.minecraft.network.packet.s2c.play.WorldTimeUpdateS2CPacket;
+
+import org.saturnclient.feature.features.TpsFeature;
+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;
+
+/**
+ * Feeds world-time packets into {@link TpsFeature}'s TPS estimator.
+ *
+ * This is the only place in the entire codebase that reads a network
+ * packet and passes data to a feature. The feature's
+ * {@link TpsFeature#onTimePacket(long)} method handles all averaging
+ * logic; the mixin is intentionally reduced to a single forwarding call.
+ *
+ * The only change from the original is renaming the import from
+ * {@code feature.features.Tps} to {@link TpsFeature}.
+ */
+@Mixin(ClientPlayNetworkHandler.class)
+public class TpsMixin {
+
+ @Inject(method = "onWorldTimeUpdate", at = @At("HEAD"))
+ private void onWorldTimeUpdate(WorldTimeUpdateS2CPacket packet, CallbackInfo ci) {
+ TpsFeature.onTimePacket(packet.getTime());
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/provider/GLFWProviderImpl.java b/versions/1.21/src/main/java/org/saturnclient/impl/provider/GLFWProviderImpl.java
new file mode 100644
index 0000000..f8a896f
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/provider/GLFWProviderImpl.java
@@ -0,0 +1,18 @@
+package org.saturnclient.impl.provider;
+
+import org.lwjgl.glfw.GLFW;
+import org.saturnclient.common.provider.GLFWProvider;
+import org.saturnclient.saturnclient.SaturnClient;
+
+public class GLFWProviderImpl implements GLFWProvider {
+ @Override
+ public boolean isKeyPressed(int key) {
+ return GLFW.glfwGetKey(SaturnClient.client.getWindow().getHandle(), key) == GLFW.GLFW_PRESS
+ && SaturnClient.client.currentScreen == null;
+ }
+
+ @Override
+ public String getKeyName(int key) {
+ return GLFW.glfwGetKeyName(key, GLFW.glfwGetKeyScancode(key));
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/provider/ModuleProviderFabric.java b/versions/1.21/src/main/java/org/saturnclient/impl/provider/ModuleProviderFabric.java
new file mode 100644
index 0000000..b74f7c6
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/provider/ModuleProviderFabric.java
@@ -0,0 +1,68 @@
+package org.saturnclient.impl.provider;
+
+import net.minecraft.client.MinecraftClient;
+
+import org.saturnclient.common.provider.ModuleProvider;
+import org.saturnclient.impl.modules.EntityModuleFabric;
+import org.saturnclient.impl.modules.NetworkModuleFabric;
+import org.saturnclient.impl.modules.PlayerModuleFabric;
+import org.saturnclient.impl.modules.RenderModuleFabric;
+import org.saturnclient.impl.modules.WorldModuleFabric;
+import org.saturnclient.common.module.*;
+
+/**
+ * Fabric implementation of {@link ModuleProvider}.
+ *
+ * Instantiated once during client startup (e.g. in the mod initialiser)
+ * and passed to every feature constructor. All five sub-modules share
+ * the same {@link MinecraftClient} reference so there is no
+ * inconsistency between modules reading state within the same tick.
+ *
+ * {@code
+ * ModuleProvider provider = new ModuleProviderFabric(MinecraftClient.getInstance());
+ * FeatureManager.register(new AutoSprintFeature(provider));
+ * FeatureManager.register(new CoordinatesFeature(provider));
+ * // …
+ * }
+ */
+public class ModuleProviderFabric implements ModuleProvider {
+
+ private final PlayerModule player;
+ private final WorldModule world;
+ private final EntityModule entity;
+ private final RenderModule render;
+ private final NetworkModule network;
+
+ public ModuleProviderFabric(MinecraftClient mc) {
+ this.player = new PlayerModuleFabric(mc);
+ this.world = new WorldModuleFabric(mc);
+ this.entity = new EntityModuleFabric(mc);
+ this.render = new RenderModuleFabric(mc);
+ this.network = new NetworkModuleFabric(mc);
+ }
+
+ @Override
+ public PlayerModule player() {
+ return player;
+ }
+
+ @Override
+ public WorldModule world() {
+ return world;
+ }
+
+ @Override
+ public EntityModule entity() {
+ return entity;
+ }
+
+ @Override
+ public RenderModule render() {
+ return render;
+ }
+
+ @Override
+ public NetworkModule network() {
+ return network;
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/provider/RefConstructorImpl.java b/versions/1.21/src/main/java/org/saturnclient/impl/provider/RefConstructorImpl.java
new file mode 100644
index 0000000..cf59bd7
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/provider/RefConstructorImpl.java
@@ -0,0 +1,25 @@
+package org.saturnclient.impl.provider;
+
+import org.saturnclient.common.provider.RefConstructorProvider;
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.render.TextRef;
+
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+
+public class RefConstructorImpl implements RefConstructorProvider {
+ @Override
+ public IdentifierRef identifier(String fullPath) {
+ return (IdentifierRef) (Object) Identifier.of(fullPath);
+ }
+
+ @Override
+ public IdentifierRef identifier(String namespace, String path) {
+ return (IdentifierRef) (Object) Identifier.of(namespace, path);
+ }
+
+ @Override
+ public TextRef text(String text) {
+ return (TextRef) Text.literal(text);
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/provider/SaturnProviderImpl.java b/versions/1.21/src/main/java/org/saturnclient/impl/provider/SaturnProviderImpl.java
new file mode 100644
index 0000000..61238c9
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/provider/SaturnProviderImpl.java
@@ -0,0 +1,72 @@
+package org.saturnclient.impl.provider;
+
+import java.awt.image.BufferedImage;
+import java.util.UUID;
+
+import org.saturnclient.common.provider.SaturnProvider;
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.game.MinecraftClientRef;
+import org.saturnclient.impl.cosmetics.utils.IdentifierUtils;
+import org.saturnclient.saturnclient.SaturnClient;
+
+import dev.kosmx.playerAnim.api.layered.AnimationStack;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
+import net.minecraft.client.network.AbstractClientPlayerEntity;
+import net.minecraft.util.Identifier;
+
+public class SaturnProviderImpl implements SaturnProvider {
+ @Override
+ public MinecraftClientRef getClient() {
+ return (MinecraftClientRef) SaturnClient.client;
+ }
+
+ @Override
+ public void playEmote(UUID fromPlayerUuid, String emoteIdOrNull) {
+ if (SaturnClient.client == null || SaturnClient.client.world == null) {
+ return;
+ }
+
+ for (AbstractClientPlayerEntity player : SaturnClient.client.world.getPlayers()) {
+ if (!player.getUuid().equals(fromPlayerUuid)) {
+ continue;
+ }
+
+ AnimationStack animationStack = PlayerAnimationAccess.getPlayerAnimLayer(player);
+
+ if (emoteIdOrNull != null && !emoteIdOrNull.isEmpty()) {
+ if (animationStack.isActive()) {
+ animationStack.removeLayer(1000);
+ }
+ animationStack.addAnimLayer(1000,
+ PlayerAnimationRegistry
+ .getAnimation(Identifier.of("saturnclient", emoteIdOrNull))
+ .playAnimation());
+ } else {
+ animationStack.removeLayer(1000);
+ }
+
+ break;
+ }
+ }
+
+ @Override
+ public void logInfo(String message) {
+ SaturnClient.LOGGER.info(message);
+ }
+
+ @Override
+ public void logError(String message) {
+ SaturnClient.LOGGER.error(message);
+ }
+
+ @Override
+ public void logError(String message, Throwable throwable) {
+ SaturnClient.LOGGER.error(message, throwable);
+ }
+
+ @Override
+ public void registerBufferedImageTexture(IdentifierRef i, BufferedImage bi) {
+ IdentifierUtils.registerBufferedImageTextureFast(i, bi);
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/ui/EntityDrawerImpl.java b/versions/1.21/src/main/java/org/saturnclient/impl/ui/EntityDrawerImpl.java
new file mode 100644
index 0000000..d30b6f5
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/ui/EntityDrawerImpl.java
@@ -0,0 +1,85 @@
+package org.saturnclient.impl.ui;
+
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaternionf;
+import org.joml.Vector3f;
+import org.saturnclient.common.ref.render.QuaternionfRef;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.saturnclient.ui.ElementContext;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.components.SkinPreview.EntityDrawer;
+
+import net.minecraft.client.render.DiffuseLighting;
+import net.minecraft.client.render.entity.EntityRenderDispatcher;
+import net.minecraft.entity.LivingEntity;
+
+public class EntityDrawerImpl implements EntityDrawer {
+ public static void drawEntity(RenderScope renderScope, int x1, int y1, int x2, int y2, int size, float f,
+ float mouseX,
+ float mouseY, LivingEntity entity, float angle, boolean negativeAngle) {
+ float g = (float) (x1 + x2) / 2.0F;
+ float h = (float) (y1 + y2) / 2.0F;
+ renderScope.enableScissor(0, 6, x2, y2);
+ float i = (float) Math.atan((double) ((g - mouseX) / 40.0F));
+ float j = (float) Math.atan((double) ((h - mouseY) / 40.0F));
+ Quaternionf quaternionf = (new Quaternionf()).rotateZ(3.1415927F);
+ float k = entity.bodyYaw;
+ float l = entity.getYaw();
+ float m = entity.getPitch();
+ float n = entity.prevHeadYaw;
+ float o = entity.headYaw;
+ entity.setYaw(angle);
+ entity.bodyYaw = angle;
+ entity.setPitch(-(j * 20.0F));
+ entity.headYaw = negativeAngle ? (i * 40.0F) + 180f : -(i * 40.0F);
+ entity.prevHeadYaw = entity.getHeadYaw();
+ entity.setCustomNameVisible(false);
+
+ float p = entity.getScale();
+ Vector3f vector3f = new Vector3f(0.0F, entity.getHeight() / 2.0F + f * p, 0.0F);
+ float q = (float) size / p;
+ drawEntity(renderScope, g, h, q, vector3f, quaternionf, null, entity);
+
+ entity.setCustomNameVisible(true);
+ entity.bodyYaw = k;
+ entity.setYaw(l);
+ entity.setPitch(m);
+ entity.prevHeadYaw = n;
+ entity.headYaw = o;
+ renderScope.disableScissor();
+ }
+
+ public static void drawEntity(RenderScope renderScope, float x, float y, float size, Vector3f vector3f,
+ Quaternionf quaternionf, @Nullable Quaternionf quaternionf2, LivingEntity entity) {
+ renderScope.getMatrixStack().push();
+ renderScope.getMatrixStack().translate((double) x, (double) y, 50.0);
+ renderScope.getMatrixStack().scale(size, size, -size);
+ renderScope.getMatrixStack().translate(vector3f.x, vector3f.y, vector3f.z);
+ renderScope.getMatrixStack().multiply((QuaternionfRef) quaternionf);
+ renderScope.draw();
+ DiffuseLighting.method_34742();
+ EntityRenderDispatcher entityRenderDispatcher = SaturnClient.client.getEntityRenderDispatcher();
+
+ entityRenderDispatcher.setRenderShadows(false);
+ if (renderScope instanceof RenderScopeImpl r) {
+ r.draw((vertexConsumers) -> {
+ entityRenderDispatcher.render(entity, 0.0, 0.0, 0.0, 1.0F, 1.0f,
+ r.matrices, vertexConsumers,
+ 15728880);
+ });
+ }
+ renderScope.draw();
+ entityRenderDispatcher.setRenderShadows(true);
+ renderScope.getMatrixStack().pop();
+ DiffuseLighting.enableGuiDepthLighting();
+ }
+
+ @Override
+ public void render(RenderScope renderScope, boolean negativeAngle, float angle, float scale, ElementContext ctx) {
+ LivingEntity entity = SaturnClient.client.player;
+ if (entity != null) {
+ drawEntity(renderScope, 0, 0, 75, 78, 30, 0.0625F, ctx.mouseX / scale, ctx.mouseY / scale, entity, angle,
+ negativeAngle);
+ }
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/ui/RenderScopeImpl.java b/versions/1.21/src/main/java/org/saturnclient/impl/ui/RenderScopeImpl.java
new file mode 100644
index 0000000..1acc300
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/ui/RenderScopeImpl.java
@@ -0,0 +1,501 @@
+package org.saturnclient.impl.ui;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import org.jetbrains.annotations.Nullable;
+import org.joml.Matrix4f;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+
+import org.saturnclient.common.provider.Providers;
+import org.saturnclient.common.ref.asset.IdentifierRef;
+import org.saturnclient.common.ref.asset.SpriteRef;
+import org.saturnclient.common.ref.game.ItemStackRef;
+import org.saturnclient.common.ref.render.MatrixStackRef;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.saturnclient.saturnclient.mixin.DrawContextAccessor;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.resources.Fonts;
+import org.saturnclient.ui.resources.SvgTexture;
+
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.font.TextRenderer.TextLayerType;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.ScreenRect;
+import net.minecraft.client.render.BufferBuilder;
+import net.minecraft.client.render.BufferRenderer;
+import net.minecraft.client.render.DiffuseLighting;
+import net.minecraft.client.render.GameRenderer;
+import net.minecraft.client.render.OverlayTexture;
+import net.minecraft.client.render.RenderLayer;
+import net.minecraft.client.render.Tessellator;
+import net.minecraft.client.render.VertexConsumer;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.VertexFormat.DrawMode;
+import net.minecraft.client.render.VertexFormats;
+import net.minecraft.client.render.model.BakedModel;
+import net.minecraft.client.render.model.json.ModelTransformationMode;
+import net.minecraft.client.texture.Sprite;
+import net.minecraft.client.util.Window;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.entity.LivingEntity;
+import net.minecraft.item.ItemStack;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.crash.CrashException;
+import net.minecraft.util.crash.CrashReport;
+import net.minecraft.util.crash.CrashReportSection;
+import net.minecraft.util.math.RotationAxis;
+import net.minecraft.world.World;
+
+public class RenderScopeImpl implements RenderScope {
+ public MatrixStack matrices;
+ public VertexConsumerProvider.Immediate vertexConsumers;
+ private ScissorStack scissorStack = new ScissorStack();
+ private int opacity = 255 << 24;
+
+ public RenderScopeImpl(DrawContext context) {
+ this(context.getMatrices(), ((DrawContextAccessor) context).getVertexConsumers());
+ }
+
+ public RenderScopeImpl(MatrixStack matrices, VertexConsumerProvider.Immediate vertexConsumers) {
+ this.matrices = matrices;
+ this.vertexConsumers = vertexConsumers;
+ this.scissorStack = new ScissorStack();
+ }
+
+ @Override
+ public MatrixStackRef getMatrixStack() {
+ return (MatrixStackRef) matrices;
+ }
+
+ @Override
+ public void setOpacity(float alpha) {
+ this.opacity = (int) (alpha * 255) << 24;
+ }
+
+ @Override
+ public int getColor(int color) {
+ int originalAlpha = (color >>> 24) & 0xFF;
+ int newAlpha = (opacity >>> 24) & 0xFF;
+ int mixedAlpha = (originalAlpha * newAlpha) / 255;
+ return (mixedAlpha << 24) | (color & 0x00FFFFFF);
+ }
+
+ @Override
+ public void drawRect(int x, int y, int width, int height, int color) {
+ if (color == 0)
+ return;
+ color = getColor(color);
+ Matrix4f matrix4f = this.matrices.peek().getPositionMatrix();
+
+ int x1 = x;
+ int x2 = x + width;
+ int y1 = y;
+ int y2 = y + height;
+
+ int i;
+ if (x1 < x2) {
+ i = x1;
+ x1 = x2;
+ x2 = i;
+ }
+
+ if (y1 < y2) {
+ i = y1;
+ y1 = y2;
+ y2 = i;
+ }
+
+ VertexConsumer vertexConsumer = this.vertexConsumers.getBuffer(RenderLayer.getGui());
+ vertexConsumer.vertex(matrix4f, (float) x1, (float) y1, 0).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x1, (float) y2, 0).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x2, (float) y2, 0).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x2, (float) y1, 0).color(color);
+ }
+
+ @Override
+ public void drawText(String text, int x, int y, int font, int color) {
+ drawText(1.0f, text, x, y, font, color);
+ }
+
+ @Override
+ public void drawText(float scale, String text, int x, int y, int font, int color) {
+ if (color == 0)
+ return;
+ if (font == 0) {
+ scale *= 2;
+ }
+ int i = 0;
+ for (String line : text.split("\n")) {
+ color = getColor(color);
+ matrices.push();
+ matrices.translate(x, y + (i * Fonts.getHeight()), 0);
+ matrices.scale(scale, scale, 1.0f);
+ TextRenderer textRenderer = SaturnClient.client.textRenderer;
+ textRenderer.draw((Text) Fonts.setFont(line, font), 0,
+ font == 0 ? 1 : 7, color, false,
+ this.matrices.peek().getPositionMatrix(),
+ this.vertexConsumers, TextLayerType.NORMAL, 0, 15728880);
+ matrices.pop();
+ i++;
+ }
+ }
+
+ private void drawRoundedCorner(int width, int height, int radius, int color) {
+ int w = width * 20;
+ int h = height * 20;
+
+ for (int y = 0; y < h; y++) {
+ int startX = 0;
+
+ if (y < radius) {
+ double dy = radius - y - 0.5;
+ double dx = Math.sqrt(Math.max(0, radius * radius - dy * dy));
+ startX = radius - (int) dx;
+ }
+
+ this.drawRect(startX, y, w - startX, 1, color);
+ }
+ }
+
+ private void drawRoundedSide(int cornerWidth, int cornerHeight, int radius, int color) {
+ // Top
+ this.matrices.push();
+
+ this.matrices.scale(0.05f, 0.05f, 1.0f);
+
+ this.drawRoundedCorner(cornerWidth, cornerHeight, radius * 10, color);
+
+ this.matrices.pop();
+
+ // Bottom
+ this.matrices.push();
+
+ this.matrices.translate(cornerWidth, cornerHeight * 2, 0);
+
+ this.matrices.multiply(RotationAxis.NEGATIVE_Z.rotationDegrees(90));
+
+ this.matrices.translate(0, -cornerWidth, 0);
+
+ this.matrices.scale(0.05f, 0.05f, 1.0f);
+
+ this.drawRoundedCorner(cornerHeight, cornerWidth, radius * 10, color);
+
+ this.matrices.pop();
+ }
+
+ @Override
+ public void drawRoundedRectangle(int x, int y, int width, int height, int radius, int color) {
+ if (color == 0)
+ return;
+ radius = Math.min(radius, Math.min(width, height));
+ int cornerWidth = width / 2;
+ int cornerHeight = height / 2;
+
+ this.matrices.push();
+
+ this.matrices.translate(x, y, 0);
+
+ this.drawRoundedSide(cornerWidth, cornerHeight, radius, color);
+
+ this.matrices.translate(cornerWidth * 2, cornerHeight * 2, 0);
+
+ this.matrices.multiply(RotationAxis.NEGATIVE_Z.rotationDegrees(180));
+
+ this.matrices.translate(0, 0, 0);
+
+ this.drawRoundedSide(cornerWidth, cornerHeight, radius, color);
+
+ this.matrices.pop();
+ }
+
+ @Override
+ public int getScaledWindowWidth() {
+ return SaturnClient.client.getWindow().getScaledWidth();
+ }
+
+ @Override
+ public int getScaledWindowHeight() {
+ return SaturnClient.client.getWindow().getScaledHeight();
+ }
+
+ @Override
+ public void drawTexture(IdentifierRef sprite, int x, int y, float u, float v, int width, int height, int color) {
+ this.drawTexture(sprite, x, y, u, v, width, height, width, height, width, height, color);
+ }
+
+ @Override
+ public void drawTexture(IdentifierRef sprite, int x, int y, float u, float v, int width, int height) {
+ this.drawTexture(sprite, x, y, u, v, width, height, width, height, width, height);
+ }
+
+ @Override
+ public void drawTexture(IdentifierRef sprite, int x, int y, float u, float v, int width, int height,
+ int regionWith,
+ int regionHeight, int textureWidth, int textureHeight) {
+ this.drawTexture(sprite, x, y, u, v, width, height, regionWith, regionHeight, textureWidth, textureHeight, -1);
+ }
+
+ @Override
+ public void drawTexture(IdentifierRef sprite, int x, int y, float u, float v, int width, int height,
+ int regionWidth,
+ int regionHeight, int textureWidth, int textureHeight, int color) {
+ this.drawTexturedQuad((Identifier) (Object) sprite, x, x + width, y, y + height,
+ (u + 0.0F) / (float) textureWidth,
+ (u + (float) regionWidth) / (float) textureWidth, (v + 0.0F) / (float) textureHeight,
+ (v + (float) regionHeight) / (float) textureHeight, color);
+ }
+
+ /* Minecraft sprites */
+
+ public void drawTexture(Identifier sprite, int x, int y, float u, float v, int width, int height, int color) {
+ this.drawTexture(sprite, x, y, u, v, width, height, width, height, width, height, color);
+ }
+
+ public void drawTexture(Identifier sprite, int x, int y, float u, float v, int width, int height) {
+ this.drawTexture(sprite, x, y, u, v, width, height, width, height, width, height);
+ }
+
+ public void drawTexture(Identifier sprite, int x, int y, float u, float v, int width, int height, int regionWith,
+ int regionHeight, int textureWidth, int textureHeight) {
+ this.drawTexture(sprite, x, y, u, v, width, height, regionWith, regionHeight, textureWidth, textureHeight, -1);
+ }
+
+ public void drawTexture(Identifier sprite, int x, int y, float u, float v, int width, int height, int regionWidth,
+ int regionHeight, int textureWidth, int textureHeight, int color) {
+ this.drawTexturedQuad(sprite, x, x + width, y, y + height, (u + 0.0F) / (float) textureWidth,
+ (u + (float) regionWidth) / (float) textureWidth, (v + 0.0F) / (float) textureHeight,
+ (v + (float) regionHeight) / (float) textureHeight, color);
+ }
+
+ private void drawTexturedQuad(Identifier sprite, int x1, int x2, int y1, int y2, float u1, float u2, float v1,
+ float v2, int color) {
+
+ if (sprite.toString().endsWith(".svg")) {
+ sprite = (Identifier) (Object) SvgTexture.getSvg(Providers.saturn.getClient(),
+ (IdentifierRef) (Object) sprite,
+ (x2 - x1) * 2, (y2 - y1) * 2);
+ }
+
+ if (color == 0)
+ return;
+ x1 *= 4;
+ x2 *= 4;
+ y1 *= 4;
+ y2 *= 4;
+ color = getColor(color);
+
+ matrices.push();
+ matrices.scale(0.25f, 0.25f, 1.0f);
+
+ RenderSystem.setShaderTexture(0, sprite);
+ RenderSystem.setShader(GameRenderer::getPositionTexProgram);
+ Matrix4f matrix4f = this.matrices.peek().getPositionMatrix();
+ BufferBuilder bufferBuilder = Tessellator.getInstance().begin(DrawMode.QUADS, VertexFormats.POSITION_TEXTURE);
+ bufferBuilder.vertex(matrix4f, (float) x1, (float) y1, 0f).texture(u1, v1);
+ bufferBuilder.vertex(matrix4f, (float) x1, (float) y2, 0f).texture(u1, v2);
+ bufferBuilder.vertex(matrix4f, (float) x2, (float) y2, 0f).texture(u2, v2);
+ bufferBuilder.vertex(matrix4f, (float) x2, (float) y1, 0f).texture(u2, v1);
+ BufferRenderer.drawWithGlobalProgram(bufferBuilder.end());
+
+ matrices.pop();
+ }
+
+ @Override
+ public void enableScissor(int x1, int y1, int x2, int y2) {
+ this.setScissor(this.scissorStack.push(new ScreenRect(x1, y1, x2 - x1, y2 - y1)));
+ }
+
+ public void disableScissor() {
+ this.setScissor(this.scissorStack.pop());
+ }
+
+ public boolean scissorContains(int x, int y) {
+ return this.scissorStack.contains(x, y);
+ }
+
+ private void setScissor(@Nullable ScreenRect rect) {
+ if (rect != null) {
+ Window window = SaturnClient.client.getWindow();
+ int i = window.getFramebufferHeight();
+ double d = window.getScaleFactor();
+ double e = (double) rect.getLeft() * d;
+ double f = (double) i - (double) rect.getBottom() * d;
+ double g = (double) rect.width() * d;
+ double h = (double) rect.height() * d;
+ RenderSystem.enableScissor((int) e, (int) f, Math.max(0, (int) g), Math.max(0, (int) h));
+ } else {
+ RenderSystem.disableScissor();
+ }
+
+ }
+
+ @Override
+ public void draw() {
+ this.vertexConsumers.draw();
+ }
+
+ public void draw(Consumer drawer) {
+ drawer.accept(this.vertexConsumers);
+ this.vertexConsumers.draw();
+ }
+
+ static class ScissorStack {
+ private final Deque stack = new ArrayDeque<>();
+
+ ScissorStack() {
+ }
+
+ public ScreenRect push(ScreenRect rect) {
+ ScreenRect screenRect = (ScreenRect) this.stack.peekLast();
+ if (screenRect != null) {
+ ScreenRect screenRect2 = (ScreenRect) Objects.requireNonNullElse(rect.intersection(screenRect),
+ ScreenRect.empty());
+ this.stack.addLast(screenRect2);
+ return screenRect2;
+ } else {
+ this.stack.addLast(rect);
+ return rect;
+ }
+ }
+
+ @Nullable
+ public ScreenRect pop() {
+ if (this.stack.isEmpty()) {
+ throw new IllegalStateException("Scissor stack underflow");
+ } else {
+ this.stack.removeLast();
+ return (ScreenRect) this.stack.peekLast();
+ }
+ }
+
+ public boolean contains(int x, int y) {
+ return this.stack.isEmpty() ? true : ((ScreenRect) this.stack.peek()).contains(x, y);
+ }
+ }
+
+ @Override
+ public void drawItem(ItemStackRef item, int x, int y) {
+ this.drawItem(SaturnClient.client.player, SaturnClient.client.world, item, x, y, 0);
+ }
+
+ @Override
+ public void drawItem(ItemStackRef stack, int x, int y, int seed) {
+ this.drawItem(SaturnClient.client.player, SaturnClient.client.world, stack, x, y, seed);
+ }
+
+ @Override
+ public void drawItem(ItemStackRef stack, int x, int y, int seed, int z) {
+ this.drawItem(SaturnClient.client.player, SaturnClient.client.world, (ItemStack) (Object) stack, x, y, seed, z);
+ }
+
+ @Override
+ public void drawItemWithoutEntity(ItemStackRef stack, int x, int y) {
+ this.drawItemWithoutEntity(stack, x, y, 0);
+ }
+
+ @Override
+ public void drawItemWithoutEntity(ItemStackRef stack, int x, int y, int seed) {
+ this.drawItem((LivingEntity) null, SaturnClient.client.world, stack, x, y, seed);
+ }
+
+ private void drawItem(@Nullable LivingEntity entity, @Nullable World world, ItemStackRef stack, int x, int y,
+ int seed) {
+ this.drawItem(entity, world, (ItemStack) (Object) stack, x, y, seed, 0);
+ }
+
+ private void drawItem(@Nullable LivingEntity entity, @Nullable World world, ItemStack stack, int x, int y, int seed,
+ int z) {
+ if (!stack.isEmpty()) {
+ BakedModel bakedModel = SaturnClient.client.getItemRenderer().getModel(stack, world, entity, seed);
+ this.matrices.push();
+ this.matrices.translate((float) (x + 8), (float) (y + 8), (float) (150 + (bakedModel.hasDepth() ? z : 0)));
+
+ try {
+ this.matrices.scale(16.0F, -16.0F, 16.0F);
+ boolean bl = !bakedModel.isSideLit();
+ if (bl) {
+ DiffuseLighting.disableGuiDepthLighting();
+ }
+
+ SaturnClient.client.getItemRenderer().renderItem(stack, ModelTransformationMode.GUI, false,
+ this.matrices,
+ this.vertexConsumers, 15728880, OverlayTexture.DEFAULT_UV, bakedModel);
+ this.draw();
+ if (bl) {
+ DiffuseLighting.enableGuiDepthLighting();
+ }
+ } catch (Throwable throwable) {
+ CrashReport crashReport = CrashReport.create(throwable, "Rendering item");
+ CrashReportSection crashReportSection = crashReport.addElement("Item being rendered");
+ crashReportSection.add("Item Type", () -> String.valueOf(stack.getItem()));
+ crashReportSection.add("Item Components", () -> String.valueOf(stack.getComponents()));
+ crashReportSection.add("Item Foil", () -> String.valueOf(stack.hasGlint()));
+ throw new CrashException(crashReport);
+ }
+
+ this.matrices.pop();
+ }
+ }
+
+ @Override
+ public void drawSpriteStretched(SpriteRef sprite, int x, int y, int width, int height) {
+ this.drawSpriteStretched(sprite, x, y, width, height, -1);
+ }
+
+ @Override
+ public void drawSpriteStretched(SpriteRef saturnSprite, int x, int y, int width, int height, int color) {
+ Sprite sprite = (Sprite) saturnSprite;
+ if (color == 0)
+ return;
+ if (width != 0 && height != 0) {
+ this.drawTexturedQuad(sprite.getAtlasId(), x, x + width, y, y + height, sprite.getMinU(), sprite.getMaxU(),
+ sprite.getMinV(), sprite.getMaxV(), color);
+ }
+ }
+
+ public void fill(int x1, int y1, int x2, int y2, int color) {
+ this.fill(x1, y1, x2, y2, 0, color);
+ }
+
+ public void fill(int x1, int y1, int x2, int y2, int z, int color) {
+ this.fill(RenderLayer.getGui(), x1, y1, x2, y2, z, color);
+ }
+
+ public void fill(RenderLayer layer, int x1, int y1, int x2, int y2, int color) {
+ this.fill(layer, x1, y1, x2, y2, 0, color);
+ }
+
+ public void fill(RenderLayer layer, int x1, int y1, int x2, int y2, int z, int color) {
+ Matrix4f matrix4f = this.matrices.peek().getPositionMatrix();
+ int i;
+ if (x1 < x2) {
+ i = x1;
+ x1 = x2;
+ x2 = i;
+ }
+
+ if (y1 < y2) {
+ i = y1;
+ y1 = y2;
+ y2 = i;
+ }
+
+ VertexConsumer vertexConsumer = this.vertexConsumers.getBuffer(layer);
+ vertexConsumer.vertex(matrix4f, (float) x1, (float) y1, (float) z).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x1, (float) y2, (float) z).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x2, (float) y2, (float) z).color(color);
+ vertexConsumer.vertex(matrix4f, (float) x2, (float) y1, (float) z).color(color);
+ }
+
+ public void drawBorder(int x, int y, int width, int height, int color) {
+ this.fill(x, y, x + width, y + 1, color);
+ this.fill(x, y + height - 1, x + width, y + height, color);
+ this.fill(x, y + 1, x + 1, y + height - 1, color);
+ this.fill(x + width - 1, y + 1, x + width, y + height - 1, color);
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/impl/ui/SaturnScreenFabric.java b/versions/1.21/src/main/java/org/saturnclient/impl/ui/SaturnScreenFabric.java
new file mode 100644
index 0000000..face3af
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/impl/ui/SaturnScreenFabric.java
@@ -0,0 +1,161 @@
+package org.saturnclient.impl.ui;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import org.saturnclient.saturnclient.mixin.DrawContextAccessor;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.SaturnScreen;
+import org.saturnclient.ui.SaturnScreen.ScreenProvider;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gl.PostEffectProcessor;
+import net.minecraft.client.gui.CubeMapRenderer;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.RotatingCubeMapRenderer;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.texture.ResourceTexture;
+import net.minecraft.client.texture.TextureManager;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+
+public class SaturnScreenFabric extends Screen implements ScreenProvider {
+ protected static final Identifier PANORAMA = Identifier.of("saturnclient",
+ "textures/gui/title/background/panorama");
+ protected static final CubeMapRenderer PANORAMA_RENDERER = new CubeMapRenderer(PANORAMA);
+
+ public static final RotatingCubeMapRenderer ROTATING_PANORAMA_RENDERER;
+
+ static {
+ ROTATING_PANORAMA_RENDERER = new RotatingCubeMapRenderer(PANORAMA_RENDERER);
+ }
+
+ // private final Pool pool = new Pool(3);
+ public final SaturnScreen screen;
+
+ public static void preload(MinecraftClient client) {
+ TextureManager textureManager = client.getTextureManager();
+
+ // Force upload each texture to GPU
+ for (int i = 0; i < 6; i++) {
+ String var10001 = PANORAMA.getPath();
+ Identifier tex = PANORAMA.withPath(var10001 + "_" + i + ".png");
+ ResourceTexture resourceTexture = new ResourceTexture(tex);
+ textureManager.registerTexture(tex, resourceTexture);
+ }
+ }
+
+ public SaturnScreenFabric(SaturnScreen screen) {
+ super(Text.literal(screen.title));
+ screen.provider = this;
+ this.screen = screen;
+ }
+
+ @Override
+ protected void init() {
+ screen.init();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public void render(DrawContext context, int mouseX, int mouseY, float delta) {
+ if (screen.start == null) {
+ screen.start = Instant.now();
+ }
+ long elapsed = Duration.between(screen.start, Instant.now()).toMillis();
+
+ mouseX *= 2;
+ mouseY *= 2;
+
+ if (client.world == null && client.getCurrentServerEntry() == null) {
+ ROTATING_PANORAMA_RENDERER.render(context, this.width, this.height, screen.backgroundOpacity, delta);
+ }
+
+ // PostEffectProcessor postEffectProcessor = this.client.getShaderLoader().loadPostEffect(
+ // Identifier.ofVanilla("blur"),
+ // DefaultFramebufferSet.MAIN_ONLY);
+
+ // if (postEffectProcessor != null) {
+ // postEffectProcessor.setUniforms("Radius", screen.backgroundBlur * Math.min((float) elapsed / 700, 1.0f));
+ // postEffectProcessor.render(this.client.getFramebuffer(), this.pool);
+ // }
+
+ // We are using a Abstracted RenderScope because older minecraft versions don't
+ // use DrawContext
+ RenderScope renderScope = new RenderScopeImpl(context.getMatrices(),
+ ((DrawContextAccessor) context).getVertexConsumers());
+
+ screen.render(renderScope, mouseX, mouseY, delta, elapsed);
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (screen.mouseClicked(mouseX, mouseY, button)) {
+ return true;
+ }
+
+ return super.mouseClicked(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
+ if (screen.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) {
+ return true;
+ }
+
+ return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
+ }
+
+ @Override
+ public boolean mouseReleased(double mouseX, double mouseY, int button) {
+ if (screen.mouseReleased(mouseX, mouseY, button)) {
+ return true;
+ }
+
+ return super.mouseReleased(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
+ if (screen.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)) {
+ return true;
+ }
+
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (screen.keyPressed(keyCode, scanCode, modifiers)) {
+ return true;
+ }
+
+ return super.keyPressed(keyCode, scanCode, modifiers);
+ }
+
+ @Override
+ public void resize(MinecraftClient client, int width, int height) {
+ screen.resize(width, height);
+ super.resize(client, width, height);
+ }
+
+ // ------------------------------------------
+ // ScreenProvider implementations
+ // ------------------------------------------
+
+ @Override
+ public void close() {
+ this.screen.onClose();
+ super.close();
+ }
+
+ @Override
+ public int getWidth() {
+ return width * 2;
+ }
+
+ @Override
+ public int getHeight() {
+ return height * 2;
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/PlayerTracker.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/PlayerTracker.java
new file mode 100644
index 0000000..8e37f09
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/PlayerTracker.java
@@ -0,0 +1,52 @@
+package org.saturnclient.saturnclient;
+
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+
+import org.saturnclient.client.ServiceClient;
+
+import net.minecraft.client.network.PlayerListEntry;
+import net.minecraft.client.MinecraftClient;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+import com.mojang.authlib.GameProfile;
+
+public class PlayerTracker {
+ private static Set trackedPlayers = new HashSet<>();
+
+ public static void initialize() {
+ // When client joins a server, reset tracked players
+ ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
+ trackedPlayers.clear();
+ client.execute(() -> {
+ checkForNewPlayers(SaturnClient.client);
+ });
+ });
+
+ // Check for new players every tick
+ ClientTickEvents.END_CLIENT_TICK.register(client -> checkForNewPlayers(client));
+ }
+
+ private static void checkForNewPlayers(MinecraftClient client) {
+ if (client.getNetworkHandler() == null)
+ return;
+
+ for (PlayerListEntry player : client.getNetworkHandler().getPlayerList()) {
+ UUID playerUUID = player.getProfile().getId();
+
+ if (!trackedPlayers.contains(playerUUID)) {
+ // A new player has joined
+ onPlayerJoin(client, player);
+ trackedPlayers.add(playerUUID);
+ }
+ }
+ }
+
+ private static void onPlayerJoin(MinecraftClient client, PlayerListEntry player) {
+ GameProfile profile = player.getProfile();
+ ServiceClient.player(profile.getId(), profile.getName());
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/SaturnClient.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/SaturnClient.java
new file mode 100644
index 0000000..370b12c
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/SaturnClient.java
@@ -0,0 +1,72 @@
+package org.saturnclient.saturnclient;
+
+import net.fabricmc.api.ModInitializer;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+import net.minecraft.client.MinecraftClient;
+
+import org.saturnclient.client.ServiceClient;
+import org.saturnclient.cosmetics.Emotes;
+import org.saturnclient.cosmetics.Hats;
+import org.saturnclient.feature.FeatureManager;
+import org.saturnclient.cosmetics.Cloaks;
+import org.saturnclient.common.provider.Providers;
+import org.saturnclient.impl.provider.GLFWProviderImpl;
+import org.saturnclient.impl.provider.ModuleProviderFabric;
+import org.saturnclient.impl.provider.RefConstructorImpl;
+import org.saturnclient.impl.provider.SaturnProviderImpl;
+import org.saturnclient.impl.ui.EntityDrawerImpl;
+import org.saturnclient.impl.ui.SaturnScreenFabric;
+import org.saturnclient.saturnclient.event.KeyInputHandler;
+import org.saturnclient.config.Config;
+import org.saturnclient.config.manager.ConfigManager;
+import org.saturnclient.ui.components.SkinPreview;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Main class for the Saturn Client mod.
+ */
+public class SaturnClient implements ModInitializer {
+
+ public static final String MOD_ID = "saturnclient";
+ public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
+ public static MinecraftClient client;
+
+ @Override
+ public void onInitialize() {
+ LOGGER.info("Initializing " + MOD_ID);
+ client = MinecraftClient.getInstance();
+
+ Providers.saturn = new SaturnProviderImpl();
+ Providers.refConstructor = new RefConstructorImpl();
+ Providers.GLFW = new GLFWProviderImpl();
+ Providers.module = new ModuleProviderFabric(client);
+ SkinPreview.DRAWER = new EntityDrawerImpl();
+
+ Config.init();
+ FeatureManager.init();
+
+ client.execute(() -> {
+ SaturnScreenFabric.preload(client);
+ });
+
+ ClientLifecycleEvents.CLIENT_STOPPING.register(_o -> ConfigManager.save());
+
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ for (org.saturnclient.feature.Feature m : FeatureManager.ENABLED_MODS) {
+ m.tick();
+ }
+ });
+
+ KeyInputHandler.register();
+ Emotes.initialize();
+ if (ServiceClient.authenticate()) {
+ Cloaks.initialize();
+ Hats.initialize();
+ LOGGER.info(MOD_ID + " initialization complete");
+ } else {
+ LOGGER.error("Failed to authenticate with the server");
+ }
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/event/KeyInputHandler.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/event/KeyInputHandler.java
new file mode 100644
index 0000000..090aec8
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/event/KeyInputHandler.java
@@ -0,0 +1,57 @@
+package org.saturnclient.saturnclient.event;
+
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
+import net.minecraft.client.option.KeyBinding;
+import net.minecraft.client.util.InputUtil;
+
+import org.saturnclient.config.Config;
+import org.saturnclient.saturnclient.SaturnClient;
+import org.saturnclient.client.ServiceClient;
+import org.saturnclient.common.provider.Providers;
+import org.saturnclient.ui.screens.EmoteWheel;
+import org.saturnclient.ui.screens.ShiftMenu;
+
+import dev.kosmx.playerAnim.api.layered.AnimationStack;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
+
+import org.lwjgl.glfw.GLFW;
+
+/**
+ * Handles keyboard input events for the Saturn Client.
+ * Manages keybindings and their associated actions.
+ */
+public class KeyInputHandler {
+ private static final String KEY_CATEGORY = "key.category.saturnclient";
+ private static final String MAIN_MENU_KEY_ID = "key.saturnclient.main_menu";
+ private static final int DEFAULT_MENU_KEY = GLFW.GLFW_KEY_RIGHT_SHIFT;
+ private static KeyBinding mainMenuKeyBinding;
+
+ public static void register() {
+ mainMenuKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
+ MAIN_MENU_KEY_ID,
+ InputUtil.Type.KEYSYM,
+ DEFAULT_MENU_KEY,
+ KEY_CATEGORY));
+
+ // Register the event handler for the main menu key
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ if (mainMenuKeyBinding.wasPressed()) {
+ Providers.saturn.getClient().setScreen(new ShiftMenu());
+ }
+
+ if (Config.openEmoteWheel.wasKeyPressed() && client.currentScreen == null
+ && !InputUtil.isKeyPressed(client.getWindow().getHandle(), GLFW.GLFW_KEY_F3)) {
+ Providers.saturn.getClient().setScreen(new EmoteWheel());
+ }
+
+ if (SaturnClient.client.player != null && SaturnClient.client.player.isSneaking()) {
+ AnimationStack animationStack = PlayerAnimationAccess.getPlayerAnimLayer(SaturnClient.client.player);
+ if (animationStack.isActive()) {
+ animationStack.removeLayer(1000);
+ ServiceClient.emote("");
+ }
+ }
+ });
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/DrawContextAccessor.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/DrawContextAccessor.java
new file mode 100644
index 0000000..f049e40
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/DrawContextAccessor.java
@@ -0,0 +1,12 @@
+package org.saturnclient.saturnclient.mixin;
+
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.render.VertexConsumerProvider;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@Mixin(DrawContext.class)
+public interface DrawContextAccessor {
+ @Accessor("vertexConsumers")
+ VertexConsumerProvider.Immediate getVertexConsumers();
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/NameTagMixin.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/NameTagMixin.java
new file mode 100644
index 0000000..f23190c
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/NameTagMixin.java
@@ -0,0 +1,101 @@
+package org.saturnclient.saturnclient.mixin;
+
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.font.TextRenderer.TextLayerType;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.render.entity.EntityRenderDispatcher;
+import net.minecraft.client.render.entity.EntityRenderer;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.EntityAttachmentType;
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import net.minecraft.util.math.Vec3d;
+
+import org.saturnclient.client.player.Roles;
+import org.saturnclient.client.player.SaturnPlayer;
+import org.saturnclient.saturnclient.SaturnClient;
+
+import java.util.UUID;
+
+import org.joml.Matrix4f;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.Shadow;
+
+@Mixin(value = EntityRenderer.class, priority = 1800)
+public abstract class NameTagMixin {
+ @Shadow
+ public EntityRenderDispatcher dispatcher;
+
+ @Shadow
+ private TextRenderer textRenderer;
+
+ /**
+ * @author HexLeo
+ * @reason Adds the Saturn Client icon to the player's name if they are online
+ * with Saturn Client
+ */
+ @Inject(method = "renderLabelIfPresent(Lnet/minecraft/client/render/entity/state/EntityRenderState;Lnet/minecraft/text/Text;Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/render/VertexConsumerProvider;I)V", at = @At("HEAD"), cancellable = true)
+ protected void renderLabelIfPresent(T entity, Text text, MatrixStack matrices,
+ VertexConsumerProvider vertexConsumers, int light, float tickDelta, CallbackInfo ci) {
+
+ UUID uuid = isSaturn(entity);
+
+ if (uuid != null) {
+ MutableText iconText = Text.literal(Roles.getSaturnIndicator())
+ .styled(style -> style.withColor(Roles.getIconColor(uuid)));
+
+ Text nameText = text.copy().styled(style -> style.withColor(Formatting.WHITE));
+
+ renderLabel(entity, iconText.append(nameText), matrices, vertexConsumers, light, tickDelta);
+
+ ci.cancel();
+ }
+ }
+
+ private UUID isSaturn(T entity) {
+ if (entity instanceof PlayerEntity playerEntity) {
+ SaturnPlayer player = SaturnPlayer.get(playerEntity.getUuid());
+ if (player != null) {
+ return player.uuid;
+ }
+ }
+
+ return null;
+ }
+
+ private void renderLabel(T entity, Text text, MatrixStack matrices,
+ VertexConsumerProvider vertexConsumers, int light, float tickDelta) {
+ double d = this.dispatcher.getSquaredDistanceToCamera(entity);
+ if (!(d > (double) 4096.0F)) {
+ Vec3d vec3d = entity.getAttachments().getPointNullable(EntityAttachmentType.NAME_TAG, 0,
+ entity.getYaw(tickDelta));
+ if (vec3d != null) {
+ boolean bl = !entity.isSneaky();
+ int i = "deadmau5".equals(text.getString()) ? -10 : 0;
+ matrices.push();
+ matrices.translate(vec3d.x, vec3d.y + (double) 0.5F, vec3d.z);
+ matrices.multiply(this.dispatcher.getRotation());
+ matrices.scale(0.025F, -0.025F, 0.025F);
+ Matrix4f matrix4f = matrices.peek().getPositionMatrix();
+ float f = SaturnClient.client.options.getTextBackgroundOpacity(0.25F);
+ int j = (int) (f * 255.0F) << 24;
+ TextRenderer textRenderer = this.textRenderer;
+ float g = (float) (-textRenderer.getWidth(text) / 2);
+ textRenderer.draw(text, g, (float) i, 553648127, false, matrix4f, vertexConsumers,
+ bl ? TextLayerType.SEE_THROUGH : TextLayerType.NORMAL, j, light);
+ if (bl) {
+ textRenderer.draw(text, g, (float) i, -1, false, matrix4f, vertexConsumers, TextLayerType.NORMAL, 0,
+ light);
+ }
+
+ matrices.pop();
+ }
+ }
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/PlayerEntityRendererMixin.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/PlayerEntityRendererMixin.java
new file mode 100644
index 0000000..f0e169d
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/PlayerEntityRendererMixin.java
@@ -0,0 +1,29 @@
+package org.saturnclient.saturnclient.mixin;
+
+import net.minecraft.client.network.AbstractClientPlayerEntity;
+import net.minecraft.client.render.entity.EntityRendererFactory;
+import net.minecraft.client.render.entity.LivingEntityRenderer;
+import net.minecraft.client.render.entity.PlayerEntityRenderer;
+import net.minecraft.client.render.entity.model.PlayerEntityModel;
+
+import org.saturnclient.impl.cosmetics.CloakFeatureRenderer;
+import org.saturnclient.impl.cosmetics.HatFeatureRenderer;
+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;
+
+@Mixin(PlayerEntityRenderer.class)
+public abstract class PlayerEntityRendererMixin
+ extends LivingEntityRenderer> {
+
+ protected PlayerEntityRendererMixin() {
+ super(null, null, 0.0f);
+ }
+
+ @Inject(method = "", at = @At("TAIL"))
+ private void addCustomFeatureRenderer(EntityRendererFactory.Context ctx, boolean slim, CallbackInfo ci) {
+ this.addFeature(new HatFeatureRenderer(this));
+ this.addFeature(new CloakFeatureRenderer(this));
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/SetScreenMixin.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/SetScreenMixin.java
new file mode 100644
index 0000000..061d641
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/SetScreenMixin.java
@@ -0,0 +1,27 @@
+package org.saturnclient.saturnclient.mixin;
+
+import org.saturnclient.config.Config;
+import org.saturnclient.impl.ui.SaturnScreenFabric;
+import org.saturnclient.ui.screens.TitleMenu;
+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.gui.screen.Screen;
+import net.minecraft.client.gui.screen.TitleScreen;
+
+@Mixin(MinecraftClient.class)
+public class SetScreenMixin {
+ @ModifyVariable(method = "setScreen", at = @At("HEAD"), argsOnly = true)
+ private Screen replaceTitleScreen(Screen screen) {
+ if (Config.saturnTitleScreen.value) {
+ if (screen instanceof TitleScreen)
+ return new SaturnScreenFabric(new TitleMenu());
+ if (screen == null && ((MinecraftClient) (Object) this).world == null)
+ return new SaturnScreenFabric(new TitleMenu());
+ }
+
+ return screen;
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/SplashOverlayMixin.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/SplashOverlayMixin.java
new file mode 100644
index 0000000..ecc4823
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/SplashOverlayMixin.java
@@ -0,0 +1,161 @@
+package org.saturnclient.saturnclient.mixin;
+
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import org.saturnclient.config.Theme;
+import org.saturnclient.impl.ui.RenderScopeImpl;
+import org.saturnclient.ui.RenderScope;
+import org.saturnclient.ui.resources.Textures;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Overwrite;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Final;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.screen.SplashOverlay;
+import net.minecraft.resource.ResourceReload;
+import net.minecraft.util.Util;
+import net.minecraft.util.math.MathHelper;
+
+@Mixin(SplashOverlay.class)
+public abstract class SplashOverlayMixin {
+
+ @Shadow
+ private MinecraftClient client;
+ @Shadow
+ private ResourceReload reload;
+
+ @Shadow
+ private long reloadCompleteTime;
+
+ @Shadow
+ private long reloadStartTime;
+
+ @Shadow
+ @Final
+ private boolean reloading;
+
+ @Shadow
+ @Final
+ private Consumer> exceptionHandler;
+
+ @Shadow
+ private float progress;
+
+ /**
+ * @author SaturnClient
+ * @reason Replace vanilla splash rendering completely but keep exit logic
+ */
+ @Overwrite
+ public void render(DrawContext context, int mouseX, int mouseY, float delta) {
+ int screenWidth = context.getScaledWindowWidth();
+ int screenHeight = context.getScaledWindowHeight();
+ long currentTime = Util.getMeasuringTimeMs();
+
+ // Initialize reload start time if not set
+ if (reloadStartTime == -1L) {
+ reloadStartTime = currentTime;
+ }
+
+ // Compute fade-out progress after reload completes
+ float fadeOutProgress = reloadCompleteTime > -1L
+ ? (float) (currentTime - reloadCompleteTime) / 1000f
+ : -1f;
+
+ int alpha = fadeOutProgress >= 0f
+ ? MathHelper.ceil((1.0f - MathHelper.clamp(fadeOutProgress - 1f, 0f, 1f)) * 255f)
+ : 255;
+
+ // Compute fade-in progress while reloading
+ float reloadProgress = reloadStartTime > -1L
+ ? (float) (currentTime - reloadStartTime) / 500f
+ : -1f;
+
+ // Get render scope for better UI
+ RenderScope scope = new RenderScopeImpl(context);
+
+ // Draw background overlay
+ if (fadeOutProgress >= 1.0F) {
+ renderCurrentScreen(context, mouseX, mouseY, delta);
+ int overlayAlpha = computeOverlayAlpha(fadeOutProgress - 1f);
+ scope.drawTexture(Textures.SPLASH, 0, 0, 0, 0, screenWidth, screenHeight,
+ Theme.withAlpha(overlayAlpha, 0xFFFFFFFF));
+ } else if (reloading) {
+ if (reloadProgress < 1.0F) {
+ renderCurrentScreen(context, mouseX, mouseY, delta);
+ }
+ scope.drawTexture(Textures.SPLASH, 0, 0, 0, 0, screenWidth, screenHeight,
+ Theme.withAlpha(alpha, 0xFFFFFFFF));
+ } else {
+ scope.drawTexture(Textures.SPLASH, 0, 0, 0, 0, screenWidth, screenHeight,
+ Theme.withAlpha(alpha, 0xFFFFFFFF));
+ }
+
+ float u = this.reload.getProgress();
+ this.progress = MathHelper.clamp(this.progress * 0.95F + u * 0.050000012F, 0.0F, 1.0F);
+
+ // Draw central logo
+ drawLogo(scope, screenWidth, screenHeight, alpha);
+ renderProgressBar(scope, context, screenWidth, screenHeight, alpha);
+
+ // Remove overlay after fade-out completes
+ if (fadeOutProgress >= 2.0F) {
+ client.setOverlay(null);
+ }
+
+ // Handle reload completion and exceptions
+ handleReloadCompletion(context);
+ }
+
+ private void renderCurrentScreen(DrawContext context, int mouseX, int mouseY, float delta) {
+ if (client.currentScreen != null) {
+ client.currentScreen.render(context, mouseX, mouseY, delta);
+ }
+ }
+
+ /** Draw the main logo centered */
+ private void drawLogo(RenderScope scope, int screenWidth, int screenHeight, int alpha) {
+ scope.drawTexture(Textures.LOGO, (screenWidth - 60) / 2, ((screenHeight - 60) / 2) - 20, 0, 0, 60, 60,
+ Theme.withAlpha(alpha, 0xFFFFFFFF));
+ }
+
+ private void renderProgressBar(RenderScope scope, DrawContext context, int screenWidth, int screenHeight,
+ int alpha) {
+ int progressY = ((screenHeight - 5) / 2) + 25;
+ context.drawBorder((screenWidth - 120) / 2, progressY, 120, 5, Theme.withAlpha(alpha, 0xFFFFFFFF));
+
+ int progressWidth = Math.min((int) (progress * 120), 120);
+ int progressX = (screenWidth - 120) / 2;
+
+ context.fill(progressX, progressY, progressX + progressWidth, progressY + 5,
+ Theme.withAlpha(alpha, 0xFFFFFFFF));
+ }
+
+ /** Compute overlay alpha from fade progress */
+ private int computeOverlayAlpha(float progress) {
+ return MathHelper.ceil((1.0F - MathHelper.clamp(progress, 0.0F, 1.0F)) * 255.0F);
+ }
+
+ /** Handle reload completion logic */
+ private void handleReloadCompletion(DrawContext context) {
+ float reloadProgress = reloadStartTime > -1L ? (float) (Util.getMeasuringTimeMs() - reloadStartTime) / 500f
+ : -1f;
+
+ if (reloadCompleteTime == -1L && reload.isComplete() && (!reloading || reloadProgress >= 2.0F)) {
+ try {
+ reload.throwException();
+ exceptionHandler.accept(Optional.empty());
+ } catch (Throwable ex) {
+ exceptionHandler.accept(Optional.of(ex));
+ }
+
+ reloadCompleteTime = Util.getMeasuringTimeMs();
+
+ if (client.currentScreen != null) {
+ client.currentScreen.init(client, context.getScaledWindowWidth(), context.getScaledWindowHeight());
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/TabListMixin.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/TabListMixin.java
new file mode 100644
index 0000000..5dd00f6
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/TabListMixin.java
@@ -0,0 +1,43 @@
+package org.saturnclient.saturnclient.mixin;
+
+import net.minecraft.client.network.PlayerListEntry;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+
+import java.util.UUID;
+
+import org.jetbrains.annotations.Nullable;
+import org.saturnclient.client.player.Roles;
+import org.saturnclient.client.player.SaturnPlayer;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Overwrite;
+import org.spongepowered.asm.mixin.Shadow;
+
+import com.mojang.authlib.GameProfile;
+
+@Mixin(PlayerListEntry.class)
+public abstract class TabListMixin {
+
+ @Shadow
+ private GameProfile profile;
+
+ /**
+ * @author HexLeo
+ * @reason Adds the Saturn Client icon to the player's name if they are online
+ * with Saturn Client
+ */
+ @Overwrite
+ @Nullable
+ public Text getDisplayName() {
+ GameProfile profile = ((PlayerListEntry) (Object) this).getProfile();
+ String name = profile.getName();
+ UUID uuid = profile.getId();
+
+ if (SaturnPlayer.get(uuid) != null) {
+ return Text.literal(Roles.getSaturnIndicator())
+ .styled(style -> style.withColor(Roles.getIconColor(uuid)))
+ .append(Text.literal(name).styled(style -> style.withColor(Formatting.WHITE)));
+ }
+ return Text.literal(name);
+ }
+}
diff --git a/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/WhoAmI.java b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/WhoAmI.java
new file mode 100644
index 0000000..041e0cf
--- /dev/null
+++ b/versions/1.21/src/main/java/org/saturnclient/saturnclient/mixin/WhoAmI.java
@@ -0,0 +1,32 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+/*
+ * Original code from FokshaWasTaken (GitHub),
+ * Repository: https://github.com/FokshaWasTaken/who-am-i/
+*/
+
+package org.saturnclient.saturnclient.mixin;
+
+import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
+import net.minecraft.client.render.entity.LivingEntityRenderer;
+import net.minecraft.entity.Entity;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+
+@Mixin(LivingEntityRenderer.class)
+public class WhoAmI {
+ @ModifyExpressionValue(method = "hasLabel(Lnet/minecraft/entity/LivingEntity;D)Z", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;getCameraEntity()Lnet/minecraft/entity/Entity;"))
+ public Entity obfuscateCameraEntity(Entity original) {
+ // hasLabel checks if the rendered entity is equal to the camera entity (aka our
+ // player),
+ // so by setting the camera entity to null in this instance makes the check
+ // always succeed
+ // this also allows to keep the other checks in mojang's code, such as checking
+ // if the hud is enabled etc.
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/versions/1.21/src/main/resources/fabric.mod.json b/versions/1.21/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..f447ac6
--- /dev/null
+++ b/versions/1.21/src/main/resources/fabric.mod.json
@@ -0,0 +1,28 @@
+{
+ "schemaVersion": 1,
+ "id": "saturnclient",
+ "version": "${version}",
+ "name": "saturnclient",
+ "description": "The ultimate open source client",
+ "authors": ["Saturn", "ORUS"],
+ "contact": {
+ "website": "https://selimaj.dev",
+ "sources": "https://github.com/saturnclientmc/saturnclient"
+ },
+ "license": "Apache-2.0",
+ "icon": "assets/saturnclient/icon.png",
+ "environment": "client",
+ "entrypoints": {
+ "main": ["org.saturnclient.saturnclient.SaturnClient"]
+ },
+ "mixins": [
+ "saturnclient.mixins.json",
+ "mods.mixins.json",
+ "impl.mixins.json"
+ ],
+ "depends": {
+ "fabricloader": ">=${loader_version}",
+ "fabric": "*",
+ "minecraft": "${minecraft_version}"
+ }
+}
diff --git a/versions/1.21/src/main/resources/impl.mixins.json b/versions/1.21/src/main/resources/impl.mixins.json
new file mode 100644
index 0000000..15e322f
--- /dev/null
+++ b/versions/1.21/src/main/resources/impl.mixins.json
@@ -0,0 +1,19 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "org.saturnclient.impl.mixins",
+ "compatibilityLevel": "JAVA_21",
+ "client": [
+ "IdentifierMixin",
+ "ItemStackMixin",
+ "MatrixStackMixin",
+ "MinecraftClientMixin",
+ "QuaternionfMixin",
+ "WindowMixin",
+ "TextMixin",
+ "SpriteMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
diff --git a/versions/1.21/src/main/resources/mods.mixins.json b/versions/1.21/src/main/resources/mods.mixins.json
new file mode 100644
index 0000000..c402565
--- /dev/null
+++ b/versions/1.21/src/main/resources/mods.mixins.json
@@ -0,0 +1,17 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "org.saturnclient.impl.modules.mixins",
+ "compatibilityLevel": "JAVA_21",
+ "client": [
+ "GameRendererMixin",
+ "LightmapTextureManagerMixin",
+ "LivingEntityRendererMixin",
+ "NametagsMixin",
+ "RenderMixin",
+ "TpsMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
diff --git a/versions/1.21/src/main/resources/saturnclient.mixins.json b/versions/1.21/src/main/resources/saturnclient.mixins.json
new file mode 100644
index 0000000..3bc9ccc
--- /dev/null
+++ b/versions/1.21/src/main/resources/saturnclient.mixins.json
@@ -0,0 +1,18 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "org.saturnclient.saturnclient.mixin",
+ "compatibilityLevel": "JAVA_21",
+ "client": [
+ "TabListMixin",
+ "NameTagMixin",
+ "PlayerEntityRendererMixin",
+ "WhoAmI",
+ "DrawContextAccessor",
+ "SplashOverlayMixin",
+ "SetScreenMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}