diff --git a/.github/workflows/build-snapshot.yml b/.github/workflows/build-commit.yml similarity index 86% rename from .github/workflows/build-snapshot.yml rename to .github/workflows/build-commit.yml index 1d9e69322b..eafa512a7f 100644 --- a/.github/workflows/build-snapshot.yml +++ b/.github/workflows/build-commit.yml @@ -1,6 +1,6 @@ # Used when a commit is pushed to the repository # This makes use of caching for faster builds and uploads the resulting artifacts -name: build-snapshot +name: build-commit on: [ push ] @@ -28,9 +28,9 @@ jobs: ~/.gradle/caches ~/.gradle/loom-cache ~/.gradle/wrapper - key: ${{ runner.os }}-build-snapshot-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + key: ${{ runner.os }}-build-commit-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} restore-keys: | - ${{ runner.os }}-build-snapshot- + ${{ runner.os }}-build-commit- - name: Build artifacts run: ./gradlew build - name: Upload artifacts diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml new file mode 100644 index 0000000000..bab048e460 --- /dev/null +++ b/.github/workflows/build-tag.yml @@ -0,0 +1,43 @@ +# Used when a commit is tagged and pushed to the repository +# This makes use of caching for faster builds and uploads the resulting artifacts +name: build-tag + +on: + push: + tags: + - '*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Extract current branch name + shell: bash + # bash pattern expansion to grab branch name without slashes + run: ref="${GITHUB_REF#refs/heads/}" && echo "branch=${ref////-}" >> $GITHUB_OUTPUT + id: ref + - name: Checkout sources + uses: actions/checkout@v3 + - name: Set up JDK + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: 17 + - name: Initialize caches + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/loom-cache + ~/.gradle/wrapper + key: ${{ runner.os }}-build-tag-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-build-tag- + - name: Build artifacts + run: ./gradlew build -Pbuild.release=true + - name: Upload artifacts + uses: actions/upload-artifact@v2 + with: + name: sodium-artifacts-${{ steps.ref.outputs.branch }} + path: build/libs/*.jar diff --git a/build.gradle b/build.gradle index 3ff3e386e5..cbe9463745 100644 --- a/build.gradle +++ b/build.gradle @@ -6,17 +6,13 @@ plugins { // is not reachable for some reason, and it makes builds much more reproducible. Observation also shows that it // really helps to improve startup times on slow connections. id 'fabric-loom' version '1.4.1' - - // This dependency is only used to determine the state of the Git working tree so that build artifacts can be - // more easily identified. TODO: Lazily load GrGit via a service only when builds are performed. - id 'org.ajoberstar.grgit' version '5.2.0' } apply from: "${rootProject.projectDir}/gradle/fabric.gradle" apply from: "${rootProject.projectDir}/gradle/java.gradle" -archivesBaseName = "${project.archives_base_name}-mc${project.minecraft_version}" -version = "${project.mod_version}${getVersionMetadata()}" +archivesBaseName = project.archives_base_name +version = createVersionString() group = project.maven_group loom { @@ -93,30 +89,41 @@ dependencies { modIncludeImplementation(fabricApi.module("fabric-api-base", project.fabric_version)) modIncludeImplementation(fabricApi.module("fabric-block-view-api-v2", project.fabric_version)) modIncludeImplementation(fabricApi.module("fabric-rendering-fluids-v1", project.fabric_version)) - // `fabricApi.module` does not work with deprecated modules, so add the module dependency manually. - // This is planned to be fixed in Loom 1.4. modIncludeImplementation(fabricApi.module("fabric-rendering-data-attachment-v1", project.fabric_version)) modIncludeImplementation(fabricApi.module("fabric-resource-loader-v0", project.fabric_version)) } -def getVersionMetadata() { - // CI builds only - if (project.hasProperty("build.release")) { - return "" // no tag whatsoever - } +def createVersionString() { + var builder = new StringBuilder() - if (grgit != null) { - def head = grgit.head() - def id = head.abbreviatedId + boolean release = project.hasProperty("build.release") - // Flag the build if the build tree is not clean - if (!grgit.status().clean) { - id += "-dirty" + String build_id = System.getenv("GITHUB_RUN_NUMBER") + String mod_version = project.mod_version as String + + if (release) { + builder.append(mod_version) + } else { + // Strip the existing pre-release version + if (mod_version.contains('-')) { + builder.append(mod_version.substring(0, mod_version.indexOf('-'))) + } else { + builder.append(mod_version) } - return "+git.${id}" + builder.append("-snapshot") } - // No tracking information could be found about the build - return "+unknown" -} + var minecraft_version = (project.minecraft_version as String) + builder.append("+mc").append(minecraft_version) + + if (!release) { + if (build_id != null) { + builder.append("-build.${build_id}") + } else { + builder.append("-local") + } + } + + return builder.toString() +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 4c52ced447..c99cf2cfa3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ loader_version=0.15.6 fabric_version=0.91.0+1.20.1 # Mod Properties -mod_version=0.5.6 +mod_version=0.5.6-rc.3 maven_group=me.jellysquid.mods archives_base_name=sodium-fabric main_class=net.caffeinemc.mods.sodium.desktop.LaunchWarn diff --git a/src/desktop/java/net/caffeinemc/mods/sodium/desktop/utils/browse/GNOMEImpl.java b/src/desktop/java/net/caffeinemc/mods/sodium/desktop/utils/browse/GNOMEImpl.java index 6bf377a7cf..b453efe2cf 100644 --- a/src/desktop/java/net/caffeinemc/mods/sodium/desktop/utils/browse/GNOMEImpl.java +++ b/src/desktop/java/net/caffeinemc/mods/sodium/desktop/utils/browse/GNOMEImpl.java @@ -1,10 +1,11 @@ package net.caffeinemc.mods.sodium.desktop.utils.browse; import java.io.IOException; +import java.util.Objects; class GNOMEImpl implements BrowseUrlHandler { public static boolean isSupported() { - return XDGImpl.isSupported() && System.getenv("XDG_CURRENT_DESKTOP").equals("GNOME"); + return XDGImpl.isSupported() && Objects.equals(System.getenv("XDG_CURRENT_DESKTOP"), "GNOME"); } @Override diff --git a/src/desktop/java/net/caffeinemc/mods/sodium/desktop/utils/browse/KDEImpl.java b/src/desktop/java/net/caffeinemc/mods/sodium/desktop/utils/browse/KDEImpl.java index d62412fdd1..61fb07d037 100644 --- a/src/desktop/java/net/caffeinemc/mods/sodium/desktop/utils/browse/KDEImpl.java +++ b/src/desktop/java/net/caffeinemc/mods/sodium/desktop/utils/browse/KDEImpl.java @@ -1,10 +1,11 @@ package net.caffeinemc.mods.sodium.desktop.utils.browse; import java.io.IOException; +import java.util.Objects; class KDEImpl implements BrowseUrlHandler { public static boolean isSupported() { - return XDGImpl.isSupported() && System.getenv("XDG_CURRENT_DESKTOP").equals("KDE"); + return XDGImpl.isSupported() && Objects.equals(System.getenv("XDG_CURRENT_DESKTOP"), "KDE"); } @Override diff --git a/src/main/java/me/jellysquid/mods/sodium/client/SodiumClientMod.java b/src/main/java/me/jellysquid/mods/sodium/client/SodiumClientMod.java index e1ebe98f06..3de3a87329 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/SodiumClientMod.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/SodiumClientMod.java @@ -1,6 +1,8 @@ package me.jellysquid.mods.sodium.client; import me.jellysquid.mods.sodium.client.gui.SodiumGameOptions; +import me.jellysquid.mods.sodium.client.data.fingerprint.FingerprintMeasure; +import me.jellysquid.mods.sodium.client.data.fingerprint.HashedFingerprint; import me.jellysquid.mods.sodium.client.gui.console.Console; import me.jellysquid.mods.sodium.client.gui.console.message.MessageLevel; import me.jellysquid.mods.sodium.client.util.FlawlessFrames; @@ -33,6 +35,12 @@ public void onInitializeClient() { CONFIG = loadConfig(); FlawlessFrames.onClientInitialization(); + + try { + updateFingerprint(); + } catch (Throwable t) { + LOGGER.error("Failed to update fingerprint", t); + } } public static SodiumGameOptions options() { @@ -84,4 +92,33 @@ public static String getVersion() { return MOD_VERSION; } + + private static void updateFingerprint() { + var current = FingerprintMeasure.create(); + + if (current == null) { + return; + } + + HashedFingerprint saved = null; + + try { + saved = HashedFingerprint.loadFromDisk(); + } catch (Throwable t) { + LOGGER.error("Failed to load existing fingerprint", t); + } + + if (saved == null || !current.looselyMatches(saved)) { + HashedFingerprint.writeToDisk(current.hashed()); + + CONFIG.notifications.hasSeenDonationPrompt = false; + CONFIG.notifications.hasClearedDonationButton = false; + + try { + SodiumGameOptions.writeToDisk(CONFIG); + } catch (IOException e) { + LOGGER.error("Failed to update config file", e); + } + } + } } diff --git a/src/main/java/me/jellysquid/mods/sodium/client/compatibility/checks/ModuleScanner.java b/src/main/java/me/jellysquid/mods/sodium/client/compatibility/checks/ModuleScanner.java index 5819cf131a..af141f56e0 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/compatibility/checks/ModuleScanner.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/compatibility/checks/ModuleScanner.java @@ -75,7 +75,10 @@ private static void checkRTSSModules() { } } - private static final Pattern RTSS_VERSION_PATTERN = Pattern.compile("^(?\\d*), (?\\d*), (?\\d*), (?\\d*)$"); + // BUG: For some reason, the version string can either be in the format of "X.Y.Z.W" or "X, Y, Z, W"... + // This does not make sense, and probably points to our handling of code pages being wrong. But for the time being, + // the regex has been made to handle both formats, because looking at Win32 code any longer is going to break me. + private static final Pattern RTSS_VERSION_PATTERN = Pattern.compile("^(?\\d*)(, |\\.)(?\\d*)(, |\\.)(?\\d*)(, |\\.)(?\\d*)$"); private static boolean isRTSSCompatible(String version) { var matcher = RTSS_VERSION_PATTERN.matcher(version); diff --git a/src/main/java/me/jellysquid/mods/sodium/client/compatibility/checks/ResourcePackScanner.java b/src/main/java/me/jellysquid/mods/sodium/client/compatibility/checks/ResourcePackScanner.java index cc74b4c052..fe11d392d2 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/compatibility/checks/ResourcePackScanner.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/compatibility/checks/ResourcePackScanner.java @@ -106,6 +106,10 @@ private static void printCompatibilityReport(Map scanResult var path = entry.getKey(); var result = entry.getValue(); + if (result.shaderPrograms.isEmpty() && result.shaderIncludes.isEmpty()) { + continue; + } + builder.append("- Resource pack: ").append(getResourcePackName(path)).append("\n"); if (!result.shaderPrograms.isEmpty()) { diff --git a/src/main/java/me/jellysquid/mods/sodium/mixin/MixinConfig.java b/src/main/java/me/jellysquid/mods/sodium/client/data/config/MixinConfig.java similarity index 98% rename from src/main/java/me/jellysquid/mods/sodium/mixin/MixinConfig.java rename to src/main/java/me/jellysquid/mods/sodium/client/data/config/MixinConfig.java index ebfbccd477..c7bb953892 100644 --- a/src/main/java/me/jellysquid/mods/sodium/mixin/MixinConfig.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/data/config/MixinConfig.java @@ -1,5 +1,6 @@ -package me.jellysquid.mods.sodium.mixin; +package me.jellysquid.mods.sodium.client.data.config; +import me.jellysquid.mods.sodium.mixin.MixinOption; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import net.fabricmc.loader.api.metadata.CustomValue; diff --git a/src/main/java/me/jellysquid/mods/sodium/client/data/fingerprint/FingerprintMeasure.java b/src/main/java/me/jellysquid/mods/sodium/client/data/fingerprint/FingerprintMeasure.java new file mode 100644 index 0000000000..4908c491c3 --- /dev/null +++ b/src/main/java/me/jellysquid/mods/sodium/client/data/fingerprint/FingerprintMeasure.java @@ -0,0 +1,68 @@ +package me.jellysquid.mods.sodium.client.data.fingerprint; + +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import org.apache.commons.codec.binary.Hex; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Objects; + +public record FingerprintMeasure(@NotNull String uuid, @NotNull String path) { + private static final int SALT_LENGTH = 64; + + public static @Nullable FingerprintMeasure create() { + var uuid = MinecraftClient.getInstance().getSession().getUuidOrNull(); + var path = FabricLoader.getInstance().getGameDir(); + + if (uuid == null || path == null) { + return null; + } + + return new FingerprintMeasure(uuid.toString(), path.toAbsolutePath().toString()); + } + + public HashedFingerprint hashed() { + var date = Instant.now(); + var salt = createSalt(); + + var uuidHashHex = sha512(salt, this.uuid()); + var pathHashHex = sha512(salt, this.path()); + + return new HashedFingerprint(HashedFingerprint.CURRENT_VERSION, salt, uuidHashHex, pathHashHex, date.getEpochSecond()); + } + + public boolean looselyMatches(HashedFingerprint hashed) { + var uuidHashHex = sha512(hashed.saltHex(), this.uuid()); + var pathHashHex = sha512(hashed.saltHex(), this.path()); + + return Objects.equals(uuidHashHex, hashed.uuidHashHex()) || Objects.equals(pathHashHex, hashed.pathHashHex()); + } + + private static String sha512(@NotNull String salt, @NotNull String message) { + MessageDigest md; + + try { + md = MessageDigest.getInstance("SHA-512"); + md.update(Hex.decodeHex(salt)); + md.update(message.getBytes(StandardCharsets.UTF_8)); + } catch (Throwable t) { + throw new RuntimeException("Failed to hash value", t); + } + + return Hex.encodeHexString(md.digest()); + } + + private static String createSalt() { + var rng = new SecureRandom(); + + var salt = new byte[SALT_LENGTH]; + rng.nextBytes(salt); + + return Hex.encodeHexString(salt); + } +} diff --git a/src/main/java/me/jellysquid/mods/sodium/client/data/fingerprint/HashedFingerprint.java b/src/main/java/me/jellysquid/mods/sodium/client/data/fingerprint/HashedFingerprint.java new file mode 100644 index 0000000000..e1d4326571 --- /dev/null +++ b/src/main/java/me/jellysquid/mods/sodium/client/data/fingerprint/HashedFingerprint.java @@ -0,0 +1,75 @@ +package me.jellysquid.mods.sodium.client.data.fingerprint; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import me.jellysquid.mods.sodium.client.util.FileUtil; +import net.fabricmc.loader.api.FabricLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public record HashedFingerprint( + @SerializedName("v") + int version, + + @NotNull + @SerializedName("s") + String saltHex, + + @NotNull + @SerializedName("u") + String uuidHashHex, + + @NotNull + @SerializedName("p") + String pathHashHex, + + @SerializedName("t") + long timestamp) +{ + public static final int CURRENT_VERSION = 1; + + public static @Nullable HashedFingerprint loadFromDisk() { + Path path = getFilePath(); + + if (!Files.exists(path)) { + return null; + } + + HashedFingerprint data; + + try { + data = new Gson() + .fromJson(Files.readString(path), HashedFingerprint.class); + } catch (IOException e) { + throw new RuntimeException("Failed to load data file", e); + } + + if (data.version() != CURRENT_VERSION) { + return null; + } + + return data; + } + + public static void writeToDisk(@NotNull HashedFingerprint data) { + Objects.requireNonNull(data); + + try { + FileUtil.writeTextRobustly(new Gson() + .toJson(data), getFilePath()); + } catch (IOException e) { + throw new RuntimeException("Failed to save data file", e); + } + } + + private static Path getFilePath() { + return FabricLoader.getInstance() + .getConfigDir() + .resolve("sodium-fingerprint.json"); + } +} diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptionPages.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptionPages.java index 1965b38d3e..8791f32a62 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptionPages.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptionPages.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.List; +// TODO: Rename in Sodium 0.6 public class SodiumGameOptionPages { private static final SodiumOptionsStorage sodiumOpts = new SodiumOptionsStorage(); private static final MinecraftOptionsStorage vanillaOpts = new MinecraftOptionsStorage(); diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptions.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptions.java index 12a19f2405..785b9d5dbd 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptions.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptions.java @@ -5,6 +5,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.annotations.SerializedName; import me.jellysquid.mods.sodium.client.gui.options.TextProvider; +import me.jellysquid.mods.sodium.client.util.FileUtil; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.client.option.GraphicsMode; import net.minecraft.text.Text; @@ -14,8 +15,8 @@ import java.lang.reflect.Modifier; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; +// TODO: Rename in Sodium 0.6 public class SodiumGameOptions { private static final String DEFAULT_FILE_NAME = "sodium-options.json"; @@ -61,7 +62,10 @@ public static class QualitySettings { } public static class NotificationSettings { - public boolean hideDonationButton = false; + public boolean forceDisableDonationPrompts = false; + + public boolean hasClearedDonationButton = false; + public boolean hasSeenDonationPrompt = false; } public enum GraphicsQuality implements TextProvider { @@ -134,14 +138,7 @@ public static void writeToDisk(SodiumGameOptions config) throws IOException { throw new IOException("Not a directory: " + dir); } - // Use a temporary location next to the config's final destination - Path tempPath = path.resolveSibling(path.getFileName() + ".tmp"); - - // Write the file to our temporary location - Files.writeString(tempPath, GSON.toJson(config)); - - // Atomically replace the old config file (if it exists) with the temporary file - Files.move(tempPath, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + FileUtil.writeTextRobustly(GSON.toJson(config), path); } public boolean isReadOnly() { diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumOptionsGUI.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumOptionsGUI.java index acce4e14a9..96da66b6b1 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumOptionsGUI.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumOptionsGUI.java @@ -1,34 +1,44 @@ package me.jellysquid.mods.sodium.client.gui; import me.jellysquid.mods.sodium.client.SodiumClientMod; +import me.jellysquid.mods.sodium.client.data.fingerprint.HashedFingerprint; import me.jellysquid.mods.sodium.client.gui.console.Console; import me.jellysquid.mods.sodium.client.gui.console.message.MessageLevel; import me.jellysquid.mods.sodium.client.gui.options.*; import me.jellysquid.mods.sodium.client.gui.options.control.Control; import me.jellysquid.mods.sodium.client.gui.options.control.ControlElement; import me.jellysquid.mods.sodium.client.gui.options.storage.OptionStorage; +import me.jellysquid.mods.sodium.client.gui.prompt.ScreenPrompt; +import me.jellysquid.mods.sodium.client.gui.prompt.ScreenPromptable; import me.jellysquid.mods.sodium.client.gui.screen.ConfigCorruptedScreen; import me.jellysquid.mods.sodium.client.gui.widgets.FlatButtonWidget; import me.jellysquid.mods.sodium.client.util.Dim2i; +import net.fabricmc.loader.api.FabricLoader; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.screen.option.VideoOptionsScreen; import net.minecraft.text.OrderedText; +import net.minecraft.text.StringVisitable; +import net.minecraft.text.Style; import net.minecraft.text.Text; import net.minecraft.util.Formatting; import net.minecraft.util.Language; import net.minecraft.util.Util; +import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.stream.Stream; -public class SodiumOptionsGUI extends Screen { +// TODO: Rename in Sodium 0.6 +public class SodiumOptionsGUI extends Screen implements ScreenPromptable { private final List pages = new ArrayList<>(); private final List> controls = new ArrayList<>(); @@ -43,6 +53,8 @@ public class SodiumOptionsGUI extends Screen { private boolean hasPendingChanges; private ControlElement hoveredElement; + private @Nullable ScreenPrompt prompt; + private SodiumOptionsGUI(Screen prevScreen) { super(Text.literal("Sodium Renderer Settings")); @@ -52,6 +64,61 @@ private SodiumOptionsGUI(Screen prevScreen) { this.pages.add(SodiumGameOptionPages.quality()); this.pages.add(SodiumGameOptionPages.performance()); this.pages.add(SodiumGameOptionPages.advanced()); + + this.checkPromptTimers(); + } + + private void checkPromptTimers() { + // Never show the prompt in developer workspaces. + if (FabricLoader.getInstance().isDevelopmentEnvironment()) { + return; + } + + var options = SodiumClientMod.options(); + + // If the user has disabled the nags forcefully (by config), or has already seen the prompt, don't show it again. + if (options.notifications.forceDisableDonationPrompts || options.notifications.hasSeenDonationPrompt) { + return; + } + + HashedFingerprint fingerprint = null; + + try { + fingerprint = HashedFingerprint.loadFromDisk(); + } catch (Throwable t) { + SodiumClientMod.logger() + .error("Failed to read the fingerprint from disk", t); + } + + // If the fingerprint doesn't exist, or failed to be loaded, abort. + if (fingerprint == null) { + return; + } + + // The fingerprint records the installation time. If it's been a while since installation, show the user + // a prompt asking for them to consider donating. + var now = Instant.now(); + var threshold = Instant.ofEpochSecond(fingerprint.timestamp()) + .plus(3, ChronoUnit.DAYS); + + if (now.isAfter(threshold)) { + this.openDonationPrompt(options); + } + } + + private void openDonationPrompt(SodiumGameOptions options) { + var prompt = new ScreenPrompt(this, DONATION_PROMPT_MESSAGE, 320, 190, + new ScreenPrompt.Action(Text.literal("Buy us a coffee"), this::openDonationPage)); + prompt.setFocused(true); + + options.notifications.hasSeenDonationPrompt = true; + + try { + SodiumGameOptions.writeToDisk(options); + } catch (IOException e) { + SodiumClientMod.logger() + .error("Failed to update config file", e); + } } public static Screen createScreen(Screen currentScreen) { @@ -98,7 +165,7 @@ private void rebuildGUI() { this.donateButton = new FlatButtonWidget(new Dim2i(this.width - 128, 6, 100, 20), Text.translatable("sodium.options.buttons.donate"), this::openDonationPage); this.hideDonateButton = new FlatButtonWidget(new Dim2i(this.width - 26, 6, 20, 20), Text.literal("x"), this::hideDonationButton); - if (SodiumClientMod.options().notifications.hideDonationButton) { + if (SodiumClientMod.options().notifications.hasClearedDonationButton || SodiumClientMod.options().notifications.forceDisableDonationPrompts) { this.setDonationButtonVisibility(false); } @@ -116,7 +183,7 @@ private void setDonationButtonVisibility(boolean value) { private void hideDonationButton() { SodiumGameOptions options = SodiumClientMod.options(); - options.notifications.hideDonationButton = true; + options.notifications.hasClearedDonationButton = true; try { SodiumGameOptions.writeToDisk(options); @@ -169,12 +236,17 @@ private void rebuildGUIOptions() { @Override public void render(DrawContext drawContext, int mouseX, int mouseY, float delta) { this.updateControls(); + this.renderBackground(drawContext); - super.render(drawContext, mouseX, mouseY, delta); + super.render(drawContext, this.prompt != null ? -1 : mouseX, this.prompt != null ? -1 : mouseY, delta); if (this.hoveredElement != null) { this.renderOptionTooltip(drawContext, this.hoveredElement); } + + if (this.prompt != null) { + this.prompt.render(drawContext, mouseX, mouseY, delta); + } } private void updateControls() { @@ -302,6 +374,10 @@ private void openDonationPage() { @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (this.prompt != null) { + return this.prompt.keyPressed(keyCode, scanCode, modifiers); + } + if (keyCode == GLFW.GLFW_KEY_P && (modifiers & GLFW.GLFW_MOD_SHIFT) != 0) { MinecraftClient.getInstance().setScreen(new VideoOptionsScreen(this.prevScreen, MinecraftClient.getInstance().options)); @@ -313,6 +389,10 @@ public boolean keyPressed(int keyCode, int scanCode, int modifiers) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (this.prompt != null) { + return this.prompt.mouseClicked(mouseX, mouseY, button); + } + boolean clicked = super.mouseClicked(mouseX, mouseY, button); if (!clicked) { @@ -332,4 +412,32 @@ public boolean shouldCloseOnEsc() { public void close() { this.client.setScreen(this.prevScreen); } + + @Override + public void setPrompt(@Nullable ScreenPrompt prompt) { + this.prompt = prompt; + } + + @Nullable + @Override + public ScreenPrompt getPrompt() { + return this.prompt; + } + + @Override + public Dim2i getDimensions() { + return new Dim2i(0, 0, this.width, this.height); + } + + private static final List DONATION_PROMPT_MESSAGE; + + static { + DONATION_PROMPT_MESSAGE = List.of( + StringVisitable.concat(Text.literal("Hello!")), + StringVisitable.concat(Text.literal("It seems that you've been enjoying "), Text.literal("Sodium").setStyle(Style.EMPTY.withColor(0x27eb92)), Text.literal(", the free and open-source optimization mod for Minecraft.")), + StringVisitable.concat(Text.literal("Mods like these are complex. They require "), Text.literal("thousands of hours").setStyle(Style.EMPTY.withColor(0xff6e00)), Text.literal(" of development, debugging, and tuning to create the experience that players have come to expect.")), + StringVisitable.concat(Text.literal("If you'd like to show your token of appreciation, and support the development of our mod in the process, then consider "), Text.literal("buying us a coffee").setStyle(Style.EMPTY.withColor(0xed49ce)), Text.literal(".")), + StringVisitable.concat(Text.literal("And thanks again for using our mod! We hope it helps you (and your computer.)")) + ); + } } diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/prompt/ScreenPrompt.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/prompt/ScreenPrompt.java new file mode 100644 index 0000000000..e00e8a073b --- /dev/null +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/prompt/ScreenPrompt.java @@ -0,0 +1,153 @@ +package me.jellysquid.mods.sodium.client.gui.prompt; + +import me.jellysquid.mods.sodium.client.gui.widgets.AbstractWidget; +import me.jellysquid.mods.sodium.client.gui.widgets.FlatButtonWidget; +import me.jellysquid.mods.sodium.client.util.Dim2i; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Drawable; +import net.minecraft.client.gui.Element; +import net.minecraft.text.StringVisitable; +import net.minecraft.text.Text; +import org.jetbrains.annotations.NotNull; +import org.lwjgl.glfw.GLFW; + +import java.util.List; + +public class ScreenPrompt implements Element, Drawable { + private final ScreenPromptable parent; + private final List text; + + private final Action action; + + private FlatButtonWidget closeButton, actionButton; + + private final int width, height; + + public ScreenPrompt(ScreenPromptable parent, List text, int width, int height, Action action) { + this.parent = parent; + this.text = text; + + this.width = width; + this.height = height; + + this.action = action; + } + + public void render(DrawContext drawContext, int mouseX, int mouseY, float delta) { + var matrices = drawContext.getMatrices(); + matrices.push(); + matrices.translate(0.0f, 0.0f, 1000.0f); + + var parentDimensions = this.parent.getDimensions(); + + drawContext.fill(0, 0, parentDimensions.width(), parentDimensions.height(), 0x70090909); + + matrices.translate(0.0f, 0.0f, 50.0f); + + int boxX = (parentDimensions.width() / 2) - (width / 2); + int boxY = (parentDimensions.height() / 2) - (height / 2); + + drawContext.fill(boxX, boxY, boxX + width, boxY + height, 0xFF171717); + drawContext.drawBorder(boxX, boxY, width, height, 0xFF121212); + + matrices.translate(0.0f, 0.0f, 50.0f); + + int padding = 5; + + int textX = boxX + padding; + int textY = boxY + padding; + + int textMaxWidth = width - (padding * 2); + int textMaxHeight = height - (padding * 2); + + var textRenderer = MinecraftClient.getInstance().textRenderer; + + for (var paragraph : this.text) { + var formatted = textRenderer.wrapLines(paragraph, textMaxWidth); + + for (var line : formatted) { + drawContext.drawText(textRenderer, line, textX, textY, 0xFFFFFFFF, true); + textY += textRenderer.fontHeight + 2; + } + + textY += 8; + } + + this.closeButton = new FlatButtonWidget(new Dim2i((boxX + width) - 84, (boxY + height) - 24, 80, 20), Text.literal("Close"), this::close); + this.closeButton.setStyle(createButtonStyle()); + + this.actionButton = new FlatButtonWidget(new Dim2i((boxX + width) - 198, (boxY + height) - 24, 110, 20), this.action.label, this::runAction); + this.actionButton.setStyle(createButtonStyle()); + + for (var button : getWidgets()) { + button.render(drawContext, mouseX, mouseY, delta); + } + + matrices.pop(); + } + + private static FlatButtonWidget.Style createButtonStyle() { + var style = new FlatButtonWidget.Style(); + style.bgDefault = 0xff2b2b2b; + style.bgHovered = 0xff393939; + style.bgDisabled = style.bgDefault; + + style.textDefault = 0xFFFFFFFF; + style.textDisabled = style.textDefault; + + return style; + } + + @NotNull + private List getWidgets() { + return List.of(this.closeButton, this.actionButton); + } + + @Override + public void setFocused(boolean focused) { + if (focused) { + this.parent.setPrompt(this); + } else { + this.parent.setPrompt(null); + } + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + for (var widget : this.getWidgets()) { + if (widget.mouseClicked(mouseX, mouseY, button)) { + return true; + } + } + + return false; + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + this.close(); + } + + return Element.super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean isFocused() { + return this.parent.getPrompt() == this; + } + + private void close() { + this.parent.setPrompt(null); + } + + private void runAction() { + this.action.runnable.run(); + this.close(); + } + + public record Action(Text label, Runnable runnable) { + + } +} diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/prompt/ScreenPromptable.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/prompt/ScreenPromptable.java new file mode 100644 index 0000000000..b791fd8379 --- /dev/null +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/prompt/ScreenPromptable.java @@ -0,0 +1,12 @@ +package me.jellysquid.mods.sodium.client.gui.prompt; + +import me.jellysquid.mods.sodium.client.util.Dim2i; +import org.jetbrains.annotations.Nullable; + +public interface ScreenPromptable { + void setPrompt(@Nullable ScreenPrompt prompt); + + @Nullable ScreenPrompt getPrompt(); + + Dim2i getDimensions(); +} diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/widgets/FlatButtonWidget.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/widgets/FlatButtonWidget.java index 54d8ac968c..4d6a67e32c 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/widgets/FlatButtonWidget.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/widgets/FlatButtonWidget.java @@ -8,12 +8,17 @@ import net.minecraft.client.gui.navigation.GuiNavigationPath; import net.minecraft.client.util.InputUtil; import net.minecraft.text.Text; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Objects; + public class FlatButtonWidget extends AbstractWidget implements Drawable { private final Dim2i dim; private final Runnable action; + private @NotNull Style style = Style.defaults(); + private boolean selected; private boolean enabled = true; private boolean visible = true; @@ -34,8 +39,8 @@ public void render(DrawContext drawContext, int mouseX, int mouseY, float delta) this.hovered = this.dim.containsCursor(mouseX, mouseY); - int backgroundColor = this.enabled ? (this.hovered ? 0xE0000000 : 0x90000000) : 0x60000000; - int textColor = this.enabled ? 0xFFFFFFFF : 0x90FFFFFF; + int backgroundColor = this.enabled ? (this.hovered ? this.style.bgHovered : this.style.bgDefault) : this.style.bgDisabled; + int textColor = this.enabled ? this.style.textDefault : this.style.textDisabled; int strWidth = this.font.getWidth(this.label); @@ -50,6 +55,12 @@ public void render(DrawContext drawContext, int mouseX, int mouseY, float delta) } } + public void setStyle(@NotNull Style style) { + Objects.requireNonNull(style); + + this.style = style; + } + public void setSelected(boolean selected) { this.selected = selected; } @@ -114,4 +125,20 @@ public Text getLabel() { public ScreenRect getNavigationFocus() { return new ScreenRect(this.dim.x(), this.dim.y(), this.dim.width(), this.dim.height()); } + + public static class Style { + public int bgHovered, bgDefault, bgDisabled; + public int textDefault, textDisabled; + + public static Style defaults() { + var style = new Style(); + style.bgHovered = 0xE0000000; + style.bgDefault = 0x90000000; + style.bgDisabled = 0x60000000; + style.textDefault = 0xFFFFFFFF; + style.textDisabled = 0x90FFFFFF; + + return style; + } + } } diff --git a/src/main/java/me/jellysquid/mods/sodium/client/render/chunk/compile/pipeline/BlockOcclusionCache.java b/src/main/java/me/jellysquid/mods/sodium/client/render/chunk/compile/pipeline/BlockOcclusionCache.java index fbd9c1fe94..5f39c91fe3 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/render/chunk/compile/pipeline/BlockOcclusionCache.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/render/chunk/compile/pipeline/BlockOcclusionCache.java @@ -1,8 +1,9 @@ package me.jellysquid.mods.sodium.client.render.chunk.compile.pipeline; -import it.unimi.dsi.fastutil.objects.Object2ByteLinkedOpenHashMap; +import it.unimi.dsi.fastutil.Hash; +import it.unimi.dsi.fastutil.objects.Object2IntLinkedOpenCustomHashMap; +import me.jellysquid.mods.sodium.client.util.DirectionUtil; import net.minecraft.block.BlockState; -import net.minecraft.block.SideShapeType; import net.minecraft.util.function.BooleanBiFunction; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Direction; @@ -11,116 +12,136 @@ import net.minecraft.world.BlockView; public class BlockOcclusionCache { - private static final byte UNCACHED_VALUE = (byte) 127; + private static final int CACHE_SIZE = 512; - private final Object2ByteLinkedOpenHashMap map; - private final CachedOcclusionShapeTest cachedTest = new CachedOcclusionShapeTest(); - private final BlockPos.Mutable cpos = new BlockPos.Mutable(); + private static final int ENTRY_ABSENT = -1; + private static final int ENTRY_FALSE = 0; + private static final int ENTRY_TRUE = 1; + + + private final Object2IntLinkedOpenCustomHashMap comparisonLookupTable; + private final ShapeComparison cachedComparisonObject = new ShapeComparison(); + private final BlockPos.Mutable cachedPositionObject = new BlockPos.Mutable(); public BlockOcclusionCache() { - this.map = new Object2ByteLinkedOpenHashMap<>(2048, 0.5F); - this.map.defaultReturnValue(UNCACHED_VALUE); + this.comparisonLookupTable = new Object2IntLinkedOpenCustomHashMap<>(CACHE_SIZE, 0.5F, new ShapeComparison.ShapeComparisonStrategy()); + this.comparisonLookupTable.defaultReturnValue(ENTRY_ABSENT); } /** * @param selfState The state of the block in the world * @param view The world view for this render context - * @param pos The position of the block + * @param selfPos The position of the block * @param facing The facing direction of the side to check * @return True if the block side facing {@param dir} is not occluded, otherwise false */ - public boolean shouldDrawSide(BlockState selfState, BlockView view, BlockPos pos, Direction facing) { - BlockPos.Mutable adjPos = this.cpos; - adjPos.set(pos.getX() + facing.getOffsetX(), pos.getY() + facing.getOffsetY(), pos.getZ() + facing.getOffsetZ()); + public boolean shouldDrawSide(BlockState selfState, BlockView view, BlockPos selfPos, Direction facing) { + BlockPos.Mutable otherPos = this.cachedPositionObject; + otherPos.set(selfPos.getX() + facing.getOffsetX(), selfPos.getY() + facing.getOffsetY(), selfPos.getZ() + facing.getOffsetZ()); - BlockState adjState = view.getBlockState(adjPos); + BlockState otherState = view.getBlockState(otherPos); - if (selfState.isSideInvisible(adjState, facing)) { + // Blocks can define special behavior to control whether faces are rendered. + // This is mostly used by transparent blocks (Leaves, Glass, etc.) to not render interior faces between blocks + // of the same type. + if (selfState.isSideInvisible(otherState, facing)) { return false; - } else if (adjState.isOpaque()) { - VoxelShape selfShape = selfState.getCullingFace(view, pos, facing); - VoxelShape adjShape = adjState.getCullingFace(view, adjPos, facing.getOpposite()); + } - if (selfShape == VoxelShapes.fullCube() && adjShape == VoxelShapes.fullCube()) { - return false; - } + // If the other block is transparent, then it is unable to hide any geometry. + if (!otherState.isOpaque()) { + return true; + } - if (selfShape.isEmpty()) { - if (adjShape.isEmpty()){ - return true; //example: top face of potted plants if top slab is placed above - } - else if (!adjState.isSideSolid(view,pos,facing.getOpposite(), SideShapeType.FULL)){ - return true; //example: face of potted plants rendered if top stair placed above - } - } + // The cull shape of the block being rendered + VoxelShape selfShape = selfState.getCullingFace(view, selfPos, facing); - return this.calculate(selfShape, adjShape); - } else { + // If the block being rendered has an empty cull shape, intersection tests will always fail + if (selfShape.isEmpty()) { return true; } - } - private boolean calculate(VoxelShape selfShape, VoxelShape adjShape) { - CachedOcclusionShapeTest cache = this.cachedTest; - cache.a = selfShape; - cache.b = adjShape; - cache.updateHash(); + // The cull shape of the block neighboring the one being rendered + VoxelShape otherShape = otherState.getCullingFace(view, otherPos, DirectionUtil.getOpposite(facing)); - byte cached = this.map.getByte(cache); + // If the other block has an empty cull shape, then it cannot hide any geometry + if (otherShape.isEmpty()) { + return true; + } - if (cached != UNCACHED_VALUE) { - return cached == 1; + // If both blocks use a full-cube cull shape, then they will always hide the faces between each other + if (selfShape == VoxelShapes.fullCube() && otherShape == VoxelShapes.fullCube()) { + return false; } - boolean ret = VoxelShapes.matchesAnywhere(selfShape, adjShape, BooleanBiFunction.ONLY_FIRST); + // No other simplifications apply, so we need to perform a full shape comparison, which is very slow + return this.lookup(selfShape, otherShape); + } + + private boolean lookup(VoxelShape self, VoxelShape other) { + ShapeComparison comparison = this.cachedComparisonObject; + comparison.self = self; + comparison.other = other; + + // Entries at the cache are promoted to the top of the table when accessed + // The entries at the bottom of the table are removed when it gets too large + return switch (this.comparisonLookupTable.getAndMoveToFirst(comparison)) { + case ENTRY_FALSE -> false; + case ENTRY_TRUE -> true; + default -> this.calculate(comparison); + }; + } - this.map.put(cache.copy(), (byte) (ret ? 1 : 0)); + private boolean calculate(ShapeComparison comparison) { + boolean result = VoxelShapes.matchesAnywhere(comparison.self, comparison.other, BooleanBiFunction.ONLY_FIRST); - if (this.map.size() > 2048) { - this.map.removeLastByte(); + // Remove entries while the table is too large + while (this.comparisonLookupTable.size() >= CACHE_SIZE) { + this.comparisonLookupTable.removeLastInt(); } - return ret; + this.comparisonLookupTable.putAndMoveToFirst(comparison.copy(), (result ? ENTRY_TRUE : ENTRY_FALSE)); + + return result; } - private static final class CachedOcclusionShapeTest { - private VoxelShape a, b; - private int hashCode; + private static final class ShapeComparison { + private VoxelShape self, other; - private CachedOcclusionShapeTest() { + private ShapeComparison() { } - private CachedOcclusionShapeTest(VoxelShape a, VoxelShape b, int hashCode) { - this.a = a; - this.b = b; - this.hashCode = hashCode; + private ShapeComparison(VoxelShape self, VoxelShape other) { + this.self = self; + this.other = other; } - public void updateHash() { - int result = System.identityHashCode(this.a); - result = 31 * result + System.identityHashCode(this.b); + public static class ShapeComparisonStrategy implements Hash.Strategy { + @Override + public int hashCode(ShapeComparison value) { + int result = System.identityHashCode(value.self); + result = 31 * result + System.identityHashCode(value.other); - this.hashCode = result; - } + return result; + } - public CachedOcclusionShapeTest copy() { - return new CachedOcclusionShapeTest(this.a, this.b, this.hashCode); - } + @Override + public boolean equals(ShapeComparison a, ShapeComparison b) { + if (a == b) { + return true; + } - @Override - public boolean equals(Object o) { - if (o instanceof CachedOcclusionShapeTest that) { - return this.a == that.a && - this.b == that.b; - } + if (a == null || b == null) { + return false; + } - return false; + return a.self == b.self && a.other == b.other; + } } - @Override - public int hashCode() { - return this.hashCode; + public ShapeComparison copy() { + return new ShapeComparison(this.self, this.other); } } } diff --git a/src/main/java/me/jellysquid/mods/sodium/client/util/FileUtil.java b/src/main/java/me/jellysquid/mods/sodium/client/util/FileUtil.java new file mode 100644 index 0000000000..6ec2278e58 --- /dev/null +++ b/src/main/java/me/jellysquid/mods/sodium/client/util/FileUtil.java @@ -0,0 +1,19 @@ +package me.jellysquid.mods.sodium.client.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +public class FileUtil { + public static void writeTextRobustly(String text, Path path) throws IOException { + // Use a temporary location next to the config's final destination + Path tempPath = path.resolveSibling(path.getFileName() + ".tmp"); + + // Write the file to our temporary location + Files.writeString(tempPath, text); + + // Atomically replace the old config file (if it exists) with the temporary file + Files.move(tempPath, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } +} diff --git a/src/main/java/me/jellysquid/mods/sodium/mixin/SodiumMixinPlugin.java b/src/main/java/me/jellysquid/mods/sodium/mixin/SodiumMixinPlugin.java index ea5de68e9b..cdd27181c5 100644 --- a/src/main/java/me/jellysquid/mods/sodium/mixin/SodiumMixinPlugin.java +++ b/src/main/java/me/jellysquid/mods/sodium/mixin/SodiumMixinPlugin.java @@ -1,5 +1,6 @@ package me.jellysquid.mods.sodium.mixin; +import me.jellysquid.mods.sodium.client.data.config.MixinConfig; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.objectweb.asm.tree.ClassNode; diff --git a/src/main/java/me/jellysquid/mods/sodium/mixin/features/gui/hooks/debug/DebugHudMixin.java b/src/main/java/me/jellysquid/mods/sodium/mixin/features/gui/hooks/debug/DebugHudMixin.java index fcf8de7ed3..82d36706b3 100644 --- a/src/main/java/me/jellysquid/mods/sodium/mixin/features/gui/hooks/debug/DebugHudMixin.java +++ b/src/main/java/me/jellysquid/mods/sodium/mixin/features/gui/hooks/debug/DebugHudMixin.java @@ -47,8 +47,10 @@ private static Formatting getVersionColor() { String version = SodiumClientMod.getVersion(); Formatting color; - if (version.contains("+git.")) { + if (version.contains("-local")) { color = Formatting.RED; + } else if (version.contains("-snapshot")) { + color = Formatting.LIGHT_PURPLE; } else { color = Formatting.GREEN; }