diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java b/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java index 2a2ed7c60..094ff1383 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java @@ -23,6 +23,11 @@ public class CompatibilityPlatformImpl implements CompatibilityPlatform { @Override public void displayChatMessage(Component text) { + Minecraft minecraft = Minecraft.getInstance(); + if (!minecraft.isSameThread()) { + minecraft.execute(() -> displayChatMessage(text)); + return; + } //? if >=1.21.4 { MinecraftClientAudiences.of().audience().sendMessage(text); //?} else { diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java index 81c544735..728fe2552 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java @@ -29,7 +29,10 @@ import kotlin.Unit; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.MethodNode; //todo import org.polyfrost.oneconfig.internal.generated.RelocatedMixins; //? moul_compat { import org.polyfrost.oneconfig.internal.generated.RelocatedMixins; @@ -37,6 +40,7 @@ import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; import org.spongepowered.asm.mixin.extensibility.IMixinInfo; +import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -65,7 +69,6 @@ public void acceptTargets(Set myTargets, Set otherTargets) { public List getMixins() { List mixins = new ArrayList<>(); - //? moul_compat { RelocatedMixins.INSTANCE.register(e -> { mixins.add(e); @@ -126,7 +129,12 @@ public List getMixins() { *///? } //? skyblocker_compat { - mixins.add("compat.skyblocker.Mixin_SkyblockerFancyStatusBars"); + Boolean skyblockerSingleton = declaresStaticMethod("de.hysky.skyblocker.skyblock.fancybars.FancyStatusBars", "initStatic"); + if (skyblockerSingleton != null) { + mixins.add(skyblockerSingleton + ? "compat.skyblocker.Mixin_SkyblockerFancyStatusBarsInstance" + : "compat.skyblocker.Mixin_SkyblockerFancyStatusBarsStatic"); + } mixins.add("compat.skyblocker.Mixin_SkyblockerWidgetManager"); //? } @@ -227,6 +235,23 @@ private static boolean isClassPresent(String className) { } } + private static Boolean declaresStaticMethod(String className, String methodName) { + try (InputStream in = OneConfigMixinInit.class.getClassLoader() + .getResourceAsStream(className.replace('.', '/') + ".class")) { + if (in == null) return null; + ClassNode node = new ClassNode(); + new ClassReader(in).accept(node, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + for (MethodNode method : node.methods) { + if (method.name.equals(methodName) && (method.access & Opcodes.ACC_STATIC) != 0) return Boolean.TRUE; + } + return Boolean.FALSE; + } catch (Throwable t) { + LogManager.getLogger(OneConfigMixinInit.class) + .warn("could not read {} to pick a mixin shape, skipping the mixins that depend on it", className, t); + return null; + } + } + @Override public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { } diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBars.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBarsInstance.java similarity index 63% rename from minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBars.java rename to minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBarsInstance.java index 6f12c919a..7781ad947 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBars.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBarsInstance.java @@ -13,33 +13,19 @@ @Pseudo @Mixin(FancyStatusBars.class) -public class Mixin_SkyblockerFancyStatusBars { +public class Mixin_SkyblockerFancyStatusBarsInstance { - //? if skyblocker_hud_v2 { - @Inject(method = "initStatic", at = @At("TAIL"), require = 0) - private static void oneconfig$registerHudCompat(CallbackInfo ci) { - SkyblockerCompat.initialize(); - } - - @Inject(method = "extractRenderState", at = @At("HEAD"), cancellable = true, require = 0) - private void oneconfig$suppressWhileEditing(CallbackInfoReturnable cir) { - if (CompatOverlayRenderer.oneConfigScreenOpen() && !SkyblockerCompat.isRedrawing()) { - cir.setReturnValue(false); - } - } - //?} else { - /*@Inject(method = "init", at = @At("TAIL"), require = 0) + @Inject(method = "initStatic", at = @At("TAIL"), require = 0, expect = 0) private static void oneconfig$registerHudCompat(CallbackInfo ci) { SkyblockerCompat.initialize(); } //~ if >= 26.1 'render' -> 'extractRenderState' - @Inject(method = "extractRenderState", at = @At("HEAD"), cancellable = true, require = 0) - private static void oneconfig$suppressWhileEditing(CallbackInfoReturnable cir) { - if (CompatOverlayRenderer.oneConfigScreenOpen() && !SkyblockerCompat.isRedrawing()) { + @Inject(method = "extractRenderState", at = @At("HEAD"), cancellable = true, require = 0, expect = 0) + private void oneconfig$suppressWhileEditing(CallbackInfoReturnable cir) { + if (CompatOverlayRenderer.oneConfigScreenOpen() && !SkyblockerCompat.isRedrawing() && SkyblockerCompat.isActive()) { cir.setReturnValue(false); } } - *///?} } //? } diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBarsStatic.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBarsStatic.java new file mode 100644 index 000000000..cf47f3183 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/skyblocker/Mixin_SkyblockerFancyStatusBarsStatic.java @@ -0,0 +1,31 @@ +package org.polyfrost.oneconfig.internal.mixin.compat.skyblocker; + +//? skyblocker_compat { +import de.hysky.skyblocker.skyblock.fancybars.FancyStatusBars; +import org.polyfrost.oneconfig.internal.compat.SkyblockerCompat; +import org.polyfrost.oneconfig.internal.ui.hud.CompatOverlayRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Pseudo +@Mixin(FancyStatusBars.class) +public class Mixin_SkyblockerFancyStatusBarsStatic { + + @Inject(method = "init", at = @At("TAIL"), require = 0, expect = 0) + private static void oneconfig$registerHudCompat(CallbackInfo ci) { + SkyblockerCompat.initialize(); + } + + //~ if >= 26.1 'render' -> 'extractRenderState' + @Inject(method = "extractRenderState", at = @At("HEAD"), cancellable = true, require = 0, expect = 0) + private static void oneconfig$suppressWhileEditing(CallbackInfoReturnable cir) { + if (CompatOverlayRenderer.oneConfigScreenOpen() && !SkyblockerCompat.isRedrawing() && SkyblockerCompat.isActive()) { + cir.setReturnValue(false); + } + } +} +//? } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/SkyblockerCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/SkyblockerCompat.kt index c2c6bfe32..310751804 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/SkyblockerCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/SkyblockerCompat.kt @@ -19,6 +19,8 @@ import org.polyfrost.oneconfig.api.hud.v1.OneConfigHudWrapper import org.polyfrost.oneconfig.api.hud.v1.events.HudEditorToggleEvent import org.polyfrost.oneconfig.internal.ui.hud.CompatOverlayRenderer import java.awt.Color +import java.lang.reflect.Field +import java.lang.reflect.Method import java.util.function.Consumer import kotlin.math.abs @@ -33,6 +35,7 @@ object SkyblockerCompat { private var initialized = false private var dirty = false + private var barsUnavailable = false @Volatile private var redrawing = false @@ -40,40 +43,93 @@ object SkyblockerCompat { @Volatile private var dragged: StatusBar? = null - //? if skyblocker_hud_v2 { - private fun statusBars(): Map = FancyStatusBars.INSTANCE.statusBars + private val cls = FancyStatusBars::class.java - private fun positioner(): BarPositioner = FancyStatusBars.INSTANCE.barPositioner + private val NO_INSTANCE = Any() - private fun saveBars() = FancyStatusBars.INSTANCE.saveBarConfig() + @Volatile + private var selfCache: Any? = null + + private fun self(): Any? { + selfCache?.let { return if (it === NO_INSTANCE) null else it } + val found = runCatching { cls.getField("INSTANCE").get(null) }.getOrNull() + ?: runCatching { cls.getMethod("getInstance").invoke(null) }.getOrNull() + if (found == null && !barsReady) return null + selfCache = found ?: NO_INSTANCE + return found + } - private fun placeBars() = FancyStatusBars.INSTANCE.placeBarsInPositioner() + private fun field(name: String): Field? = runCatching { + cls.getDeclaredField(name).apply { isAccessible = true } + }.onFailure { LOGGER.warn("Skyblocker FancyStatusBars.{} is unavailable", name, it) }.getOrNull() + + private fun method(name: String, vararg params: Class<*>): Method? = runCatching { + cls.getDeclaredMethod(name, *params).apply { isAccessible = true } + }.onFailure { LOGGER.warn("Skyblocker FancyStatusBars.{}() is unavailable", name, it) }.getOrNull() + + private val statusBarsField by lazy { field("statusBars") } + private val barPositionerField by lazy { field("barPositioner") } + private val saveBarConfigMethod by lazy { method("saveBarConfig") } + private val placeBarsMethod by lazy { method("placeBarsInPositioner") } + private val updatePositionsMethod by lazy { method("updatePositions", java.lang.Boolean.TYPE) } + private val healthFancyBarMethod by lazy { method("isHealthFancyBarEnabled") } + private val renderBarsMethod by lazy { + //~ if >= 26.1 'render' -> 'extractRenderState' + method("extractRenderState", GuiGraphicsExtractor::class.java, Minecraft::class.java) + } - private fun updatePositions(ignoreVisibility: Boolean) = FancyStatusBars.INSTANCE.updatePositions(ignoreVisibility) + private val handlesResolved: Boolean by lazy { + val ok = listOf(renderBarsMethod, saveBarConfigMethod, updatePositionsMethod, barPositionerField, statusBarsField) + .all { it != null } + if (!ok) LOGGER.warn("Skyblocker status bar compat is off: FancyStatusBars has an unrecognised shape") + ok + } - private fun healthFancyBarEnabled(): Boolean = FancyStatusBars.INSTANCE.isHealthFancyBarEnabled() + @Volatile + private var barsReady = false - private fun renderStatusBars(ctx: GuiGraphicsExtractor, mc: Minecraft) { - FancyStatusBars.INSTANCE.extractRenderState(ctx, mc) + @JvmStatic + fun isActive(): Boolean { + if (barsReady) return true + if (!handlesResolved) return false + if (runCatching { statusBarsField?.get(self()) }.getOrNull() == null) return false + barsReady = true + return true } - //?} else { - /*private fun statusBars(): Map = FancyStatusBars.statusBars - private fun positioner(): BarPositioner = FancyStatusBars.barPositioner + private fun noStatusBars(error: Throwable?): Map { + if (!barsUnavailable) { + barsUnavailable = true + LOGGER.warn("Skyblocker's status bar API is unavailable, its bars will be left out of the OneConfig HUD editor", error) + } + return emptyMap() + } + + @Suppress("UNCHECKED_CAST") + private fun statusBars(): Map = + runCatching { statusBarsField?.get(self()) as? Map } + .getOrElse { noStatusBars(it) } ?: noStatusBars(null) + + private fun positioner(): BarPositioner = + checkNotNull(barPositionerField?.get(self()) as? BarPositioner) { "Skyblocker barPositioner is unavailable" } - private fun saveBars() = FancyStatusBars.saveBarConfig() + private fun saveBars() { + saveBarConfigMethod?.invoke(self()) + } - private fun placeBars() = FancyStatusBars.placeBarsInPositioner() + private fun placeBars() { + placeBarsMethod?.invoke(self()) + } - private fun updatePositions(ignoreVisibility: Boolean) = FancyStatusBars.updatePositions(ignoreVisibility) + private fun updatePositions(ignoreVisibility: Boolean) { + updatePositionsMethod?.invoke(self(), ignoreVisibility) + } - private fun healthFancyBarEnabled(): Boolean = FancyStatusBars.isHealthFancyBarEnabled() + private fun healthFancyBarEnabled(): Boolean = healthFancyBarMethod?.invoke(self()) as? Boolean == true private fun renderStatusBars(ctx: GuiGraphicsExtractor, mc: Minecraft) { - //~ if >= 26.1 'render' -> 'extractRenderState' - FancyStatusBars.extractRenderState(ctx, mc) + renderBarsMethod?.invoke(self(), ctx, mc) } - *///?} @JvmStatic fun isRedrawing(): Boolean = redrawing @@ -86,6 +142,7 @@ object SkyblockerCompat { } private fun register() { + if (!handlesResolved) return var count = 0 for (type in StatusBarType.values()) { runCatching { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/StellaCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/StellaCompat.kt index 355ec0f19..d771b8bcd 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/StellaCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/StellaCompat.kt @@ -269,21 +269,23 @@ object StellaCompat { if (!element.isEnabled()) return@forEach ctx.pose().pushMatrix() - ctx.pose().translate(element.x, element.y) - ctx.pose().scale(element.scale, element.scale) - - val custom = customRenderers[element.id] - if (custom != null) custom(ctx) - else { - if (element.width == 0 && element.height == 0) { - element.width = element.text.width() + 4 - element.height = element.text.height() + 6 + try { + ctx.pose().translate(element.x, element.y) + ctx.pose().scale(element.scale, element.scale) + + val custom = customRenderers[element.id] + if (custom != null) custom(ctx) + else { + if (element.width == 0 && element.height == 0) { + element.width = element.text.width() + 4 + element.height = element.text.height() + 6 + } + + Render2D.drawString(ctx, element.text, 2, 3, shadow = false) } - - Render2D.drawString(ctx, element.text, 2, 3, shadow = false) + } finally { + ctx.pose().popMatrix() } - - ctx.pose().popMatrix() } } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/MinecraftItemCatalogService.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/MinecraftItemCatalogService.kt index b5fc8ae36..d78719e84 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/MinecraftItemCatalogService.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/MinecraftItemCatalogService.kt @@ -116,6 +116,10 @@ class MinecraftItemCatalogService : ItemCatalogService { } private fun renderPendingBatch() { + if (Minecraft.getInstance().player == null) { + retryRenderLater() + return + } val guiWidth = Platform.screen().guiWidth() val guiHeight = Platform.screen().guiHeight() val windowWidth = Platform.screen().windowWidth() @@ -585,6 +589,18 @@ class MinecraftItemCatalogService : ItemCatalogService { } } + private fun retryRenderLater() { + val retry = synchronized(requestLock) { + if (waiting.isEmpty()) { + renderScheduled = false + false + } else { + true + } + } + if (retry) scheduleRender() + } + private fun markRenderFinished() { var scheduleNext = false synchronized(requestLock) { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeSceneContextImpl.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeSceneContextImpl.kt index 9d9b257d2..a79aaab7c 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeSceneContextImpl.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeSceneContextImpl.kt @@ -99,7 +99,11 @@ private class PlatformImpl : PlatformContext { } fun resetPointerIcon() { - applyPointerIcon(PointerIcon.Default) + //? if >= 26.3 { + /*SDL_SetCursor(SDL_GetDefaultCursor()) + *///?} else { + glfwSetCursor(handle, 0L) + //?} } private fun applyPointerIcon(pointerIcon: PointerIcon) { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt index f453bc7c0..33fd0fd83 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt @@ -691,7 +691,7 @@ abstract class ComposeScreen( //? >= 1.21.10 { override fun charTyped(event: CharacterEvent): Boolean { - val char = Char(event.codepoint) + val char = if (Character.isBmpCodePoint(event.codepoint)) Char(event.codepoint) else Char(0) val codepoint = event.codepoint //? >= 26.1 { val modifiers = 0 //dropped from the event in 26.1 because glfw no longer passes them @@ -824,6 +824,18 @@ abstract class ComposeScreen( } private fun sendCharacterEvent(char: Char, codePoint: Int, modifiers: Int): Boolean { + if (!Character.isBmpCodePoint(codePoint)) { + var handled = false + for (part in Character.toChars(codePoint)) { + handled = sendCharKeyEvent(part, codePoint, modifiers) || handled + } + return handled + } + if (char == KeyEvent.CHAR_UNDEFINED) return false + return sendCharKeyEvent(char, codePoint, modifiers) + } + + private fun sendCharKeyEvent(char: Char, codePoint: Int, modifiers: Int): Boolean { return sendKeyEventSafely { androidx.compose.ui.input.key.KeyEvent( key = Key(KeyEvent.VK_UNDEFINED), diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeSupport.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeSupport.kt index d91ba4322..886c43e4f 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeSupport.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeSupport.kt @@ -63,6 +63,7 @@ object ComposeSupport { private fun awtInitFailure(): String? = try { Class.forName("java.awt.event.KeyEvent", true, ComposeSupport::class.java.classLoader) + Class.forName("androidx.compose.ui.input.pointer.PointerIcon", true, ComposeSupport::class.java.classLoader) null } catch (t: Throwable) { LOG.error("AWT failed to initialize on this runtime; the OneConfig UI has been disabled.", t) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaCtx.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaCtx.kt index 659032142..66a573145 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaCtx.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaCtx.kt @@ -1,6 +1,7 @@ package org.polyfrost.oneconfig.internal.ui.compose import com.mojang.blaze3d.pipeline.TextureTarget +import com.mojang.blaze3d.systems.RenderSystem //? if >= 1.21.5 && < 1.21.8 { /*import com.mojang.blaze3d.pipeline.BlendFunction import com.mojang.blaze3d.pipeline.RenderPipeline @@ -9,7 +10,6 @@ import com.mojang.blaze3d.platform.SourceFactor *///? } //? if < 1.21.5 { /*import com.mojang.blaze3d.platform.GlStateManager -import com.mojang.blaze3d.systems.RenderSystem *///? } //? if >= 1.21.4 && < 1.21.5 { /*import com.mojang.blaze3d.vertex.DefaultVertexFormat @@ -488,14 +488,20 @@ object SkiaCtx { hudRealIsGeneral = true } guiGraphics.pose().pushMatrix() - guiGraphics.pose().scale(1f / guiScale, 1f / guiScale) - guiGraphics.blit(RenderPipelines.GUI_TEXTURED_PREMULTIPLIED_ALPHA, HUD_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) - guiGraphics.pose().popMatrix() + try { + guiGraphics.pose().scale(1f / guiScale, 1f / guiScale) + guiGraphics.blit(RenderPipelines.GUI_TEXTURED_PREMULTIPLIED_ALPHA, HUD_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) + } finally { + guiGraphics.pose().popMatrix() + } //? } else { /*guiGraphics.pose().pushPose() - guiGraphics.pose().scale(1f / guiScale, 1f / guiScale, 1f) - guiGraphics.blit(::premulGuiTextured, HUD_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) - guiGraphics.pose().popPose() + try { + guiGraphics.pose().scale(1f / guiScale, 1f / guiScale, 1f) + guiGraphics.blit(::premulGuiTextured, HUD_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) + } finally { + guiGraphics.pose().popPose() + } *///? } //? } else { /*var wrapper = hudTextureWrapper @@ -506,6 +512,7 @@ object SkiaCtx { } wrapper.setGlTexId(rt.colorTextureId) guiGraphics.pose().pushPose() + try { guiGraphics.pose().scale(1f / guiScale, 1f / guiScale, 1f) //? >= 1.21.4 { guiGraphics.blit(::premulGuiTextured, HUD_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) @@ -521,7 +528,9 @@ object SkiaCtx { RenderSystem.disableBlend() RenderSystem.defaultBlendFunc() *///?} - guiGraphics.pose().popPose() + } finally { + guiGraphics.pose().popPose() + } *///? } } @@ -548,14 +557,20 @@ object SkiaCtx { composeRealIsGeneral = true } guiGraphics.pose().pushMatrix() - guiGraphics.pose().scale(1f / guiScale, 1f / guiScale) - guiGraphics.blit(RenderPipelines.GUI_TEXTURED_PREMULTIPLIED_ALPHA, COMPOSE_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) - guiGraphics.pose().popMatrix() + try { + guiGraphics.pose().scale(1f / guiScale, 1f / guiScale) + guiGraphics.blit(RenderPipelines.GUI_TEXTURED_PREMULTIPLIED_ALPHA, COMPOSE_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) + } finally { + guiGraphics.pose().popMatrix() + } //? } else { /*guiGraphics.pose().pushPose() - guiGraphics.pose().scale(1f / guiScale, 1f / guiScale, 1f) - guiGraphics.blit(::premulGuiTextured, COMPOSE_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) - guiGraphics.pose().popPose() + try { + guiGraphics.pose().scale(1f / guiScale, 1f / guiScale, 1f) + guiGraphics.blit(::premulGuiTextured, COMPOSE_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) + } finally { + guiGraphics.pose().popPose() + } *///? } //? } else { /*var wrapper = composeTextureWrapper @@ -566,6 +581,7 @@ object SkiaCtx { } wrapper.setGlTexId(rt.colorTextureId) guiGraphics.pose().pushPose() + try { guiGraphics.pose().scale(1f / guiScale, 1f / guiScale, 1f) //? >= 1.21.4 { guiGraphics.blit(::premulGuiTextured, COMPOSE_TEXTURE_LOC, 0, 0, 0f, 0f, w, h, w, h) @@ -581,7 +597,9 @@ object SkiaCtx { RenderSystem.disableBlend() RenderSystem.defaultBlendFunc() *///?} - guiGraphics.pose().popPose() + } finally { + guiGraphics.pose().popPose() + } *///? } } @@ -722,24 +740,68 @@ object SkiaCtx { } } + private var oversizeReported = false + + private fun maxTextureSize(): Int = + //? if >= 26.2 { + RenderSystem.getDevice().deviceInfo.limits().maxTextureSize() + //? } else if >= 1.21.5 { + /*RenderSystem.getDevice().maxTextureSize + *///? } else { + /*RenderSystem.maxSupportedTextureSize() + *///? } + + private var maxTextureSizeCache = 0 + + private fun cachedMaxTextureSize(): Int { + if (maxTextureSizeCache == 0) { + maxTextureSizeCache = runCatching { maxTextureSize() }.getOrNull() + ?.takeIf { it > 0 } + ?: Int.MAX_VALUE + } + return maxTextureSizeCache + } + + private fun viewportFitsTexture(w: Int, h: Int): Boolean { + val max = cachedMaxTextureSize() + if (w <= max && h <= max) { + oversizeReported = false + return true + } + if (!oversizeReported) { + oversizeReported = true + LOG.warn("SkiaCtx: viewport {}x{} is past the max texture size ({}); skipping offscreen surfaces", w, h, max) + destroyHudTarget() + destroyComposeTarget() + } + return false + } + private fun resolveHudSurface(): Surface? { val w = Platform.screen().viewportWidth() val h = Platform.screen().viewportHeight() if (w <= 0 || h <= 0) return null + if (!viewportFitsTexture(w, h)) return null var rt = hudTarget val needNewTarget = rt == null || rt.width != w || rt.height != h if (needNewTarget) { + if (System.currentTimeMillis() - allocFailedAt < ALLOC_RETRY_COOLDOWN_MS) return null destroyHudTarget() - //? if >= 26.2 { - rt = TextureTarget(null, w, h, true, com.mojang.blaze3d.GpuFormat.RGBA8_UNORM) - //? } else if >= 1.21.5 { - /*rt = TextureTarget(null, w, h, true) - *///? } else if >= 1.21.4 { - // rt = TextureTarget(w, h, true) - //? } else { - /*rt = TextureTarget(w, h, true, Minecraft.ON_OSX) - *///? } + rt = try { + //? if >= 26.2 { + TextureTarget(null, w, h, true, com.mojang.blaze3d.GpuFormat.RGBA8_UNORM) + //? } else if >= 1.21.5 { + /*TextureTarget(null, w, h, true) + *///? } else if >= 1.21.4 { + // TextureTarget(w, h, true) + //? } else { + /*TextureTarget(w, h, true, Minecraft.ON_OSX) + *///? } + } catch (e: Throwable) { + onAllocFailure(HUD_TARGET, w, h, e) + return null + } hudTarget = rt //? >= 1.21.5 { @@ -760,7 +822,13 @@ object SkiaCtx { hudBrt?.close(); hudBrt = null hudRealIsGeneral = false val svc = vulkanService ?: return null - val (brt, colorFmt) = svc.makeOffscreenBRT(rt, w, h) + val (brt, colorFmt) = try { + svc.makeOffscreenBRT(rt, w, h) + } catch (e: Throwable) { + destroyHudTarget() + onAllocFailure(HUD_TARGET, w, h, e) + return null + } hudBrt = brt hudSurface = Surface.makeFromBackendRenderTarget( directContext, brt, @@ -784,11 +852,14 @@ object SkiaCtx { hudTarget = null } - private var composeAllocFailedAt = 0L - private var composeAllocReported = false + private var allocFailedAt = 0L + private var allocReported = false private const val ALLOC_RETRY_COOLDOWN_MS = 2000L + private const val HUD_TARGET = "hud" + private const val COMPOSE_TARGET = "compose" + // bottom left lets OpenGL do a plain copy which is faster // compensated in drawComposeBlit because GuiGraphics always samples top left private val composeOrigin get() = if (isVulkanMode) SurfaceOrigin.TOP_LEFT else SurfaceOrigin.BOTTOM_LEFT @@ -797,11 +868,12 @@ object SkiaCtx { val w = Platform.screen().viewportWidth() val h = Platform.screen().viewportHeight() if (w <= 0 || h <= 0) return null + if (!viewportFitsTexture(w, h)) return null var rt = composeTarget val needNewTarget = rt == null || rt.width != w || rt.height != h if (needNewTarget) { - if (System.currentTimeMillis() - composeAllocFailedAt < ALLOC_RETRY_COOLDOWN_MS) return null + if (System.currentTimeMillis() - allocFailedAt < ALLOC_RETRY_COOLDOWN_MS) return null destroyComposeTarget() rt = try { //? if >= 26.2 { @@ -814,7 +886,7 @@ object SkiaCtx { /*TextureTarget(w, h, true, Minecraft.ON_OSX) *///? } } catch (e: Throwable) { - onComposeAllocFailure(w, h, e) + onAllocFailure(COMPOSE_TARGET, w, h, e) return null } composeTarget = rt @@ -838,10 +910,10 @@ object SkiaCtx { composeRealIsGeneral = false val svc = vulkanService ?: return null val brt = try { - svc.makeOffscreenBRT(rt!!, w, h) + svc.makeOffscreenBRT(rt, w, h) } catch (e: Throwable) { destroyComposeTarget() - onComposeAllocFailure(w, h, e) + onAllocFailure(COMPOSE_TARGET, w, h, e) return null } composeBrt = brt.first @@ -860,16 +932,15 @@ object SkiaCtx { return composeSurface } - private fun onComposeAllocFailure(w: Int, h: Int, error: Throwable) { - composeAllocFailedAt = System.currentTimeMillis() - destroyComposeTarget() - destroyHudTarget() + private fun onAllocFailure(what: String, w: Int, h: Int, error: Throwable) { + allocFailedAt = System.currentTimeMillis() + if (what == COMPOSE_TARGET) destroyComposeTarget() else destroyHudTarget() org.polyfrost.oneconfig.internal.ui.SkiaOffscreenTarget.destroyAll() if (isVulkanMode) invalidateVkSurfaces() runCatching { directContext.flush() } - LOG.error("SkiaCtx: failed to allocate the {}x{} compose target; skipping compose frames", w, h, error) - if (!composeAllocReported) { - composeAllocReported = true + LOG.error("SkiaCtx: failed to allocate the {}x{} {} target; skipping offscreen frames", w, h, what, error) + if (!allocReported) { + allocReported = true runCatching { Platform.screen().showMessage( "OneConfig couldn't allocate GPU memory for its UI (${w}x$h). " + diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/sound/McUiSoundService.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/sound/McUiSoundService.kt index 62dbdf21b..9755db982 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/sound/McUiSoundService.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/sound/McUiSoundService.kt @@ -14,7 +14,6 @@ import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong class McUiSoundService : UiSoundService { - private val random = RandomSource.create() private val sliderTick = AtomicInteger(0) @Volatile @@ -237,7 +236,7 @@ class McUiSoundService : UiSoundService { @Volatile var targetVolume: Float, val theme: UiSoundTheme, private val nativeLoop: Boolean, - ) : AbstractTickableSoundInstance(event, source, random) { + ) : AbstractTickableSoundInstance(event, source, RandomSource.create()) { @Volatile private var fadingOut = false diff --git a/modules/config-impl/api/config-impl.api b/modules/config-impl/api/config-impl.api index 2e4fed921..68f4d084d 100644 --- a/modules/config-impl/api/config-impl.api +++ b/modules/config-impl/api/config-impl.api @@ -28,6 +28,7 @@ public final class org/polyfrost/oneconfig/api/config/v1/CompatSnapshots : org/p public static fun register (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; public static fun setDispatcher (Ljava/util/function/Consumer;)V public static fun track (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static fun untrack (Ljava/lang/String;)V } public abstract class org/polyfrost/oneconfig/api/config/v1/Config { diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshots.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshots.java index 608965921..747274d1b 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshots.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshots.java @@ -33,6 +33,7 @@ import java.util.Collections; import java.util.IdentityHashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -79,6 +80,12 @@ public static Tree track(Tree registered) { return INSTANCE.track0(registered); } + public static void untrack(String treeId) { + if (treeId == null) return; + INSTANCE.known.remove(treeId); + INSTANCE.defaults.remove(treeId); + } + private Tree register0(Tree tree) { tree.addMetadata(Backend.UI_ONLY_METADATA, Boolean.TRUE); dropStaleRegistration(tree.getID()); @@ -144,7 +151,11 @@ public void onProfileSaving(String profile) { } dispatchAndWait(() -> { for (Tree tree : known.values()) { - captureAll(tree, profile); + try { + captureAll(tree, profile); + } catch (Throwable t) { + ConfigManager.LOGGER.error("Failed to capture compat snapshot for '{}'", tree.getID(), t); + } } }); flushSnapshotThenBaseline(store, profile, baselineStore, BASELINE_BUCKET); @@ -184,7 +195,11 @@ public void onProfileDeleted(String profile) { currentProfile = ""; dispatchAndWait(() -> { for (Tree tree : known.values()) { - applyProfile(tree, ""); + try { + applyProfile(tree, ""); + } catch (Throwable t) { + ConfigManager.LOGGER.error("Failed to clear compat snapshot for '{}'", tree.getID(), t); + } } }); } @@ -224,6 +239,7 @@ private void applyProfile(Tree tree, String profile) { String treeId = tree.getID(); Map snap = store.load(profile).get(treeId); boolean[] changed = {false}; + Map pending = new LinkedHashMap<>(); forEachProp(tree, p -> { if (!isValueProp(p)) return; String key = keyOf(p); @@ -231,17 +247,13 @@ private void applyProfile(Tree tree, String profile) { Object baseline = getBaseline(treeId, key); if (baseline != null && liveSer != null && !valuesEqual(liveSer, baseline)) { - store.putValue(profile, treeId, key, liveSer); - setBaseline(treeId, key, liveSer); + pending.put(key, liveSer); return; } Object stored = snap == null ? null : snap.get(key); if (stored == null) { - if (liveSer != null) { - store.putValue(profile, treeId, key, liveSer); - setBaseline(treeId, key, liveSer); - } + if (liveSer != null) pending.put(key, liveSer); return; } @@ -250,10 +262,7 @@ private void applyProfile(Tree tree, String profile) { value = deserialize(stored); } catch (Throwable t) { ConfigManager.LOGGER.warn("Failed to deserialize compat value for '{}', re-snapshotting from live value", key, t); - if (liveSer != null) { - store.putValue(profile, treeId, key, liveSer); - setBaseline(treeId, key, liveSer); - } + if (liveSer != null) pending.put(key, liveSer); return; } if (value instanceof OneConfigKeybind && ((OneConfigKeybind) value).getHasUnresolvedInputs()) { @@ -279,12 +288,19 @@ private void applyProfile(Tree tree, String profile) { applying.remove(p); } }); + boolean ownerStillThere = !gateClosed(tree); + if (ownerStillThere) { + pending.forEach((key, serialized) -> { + store.putValue(profile, treeId, key, serialized); + setBaseline(treeId, key, serialized); + }); + } // Persist the profile snapshot before its baseline. If the first write fails, keeping an // older baseline is safe: the next load treats the live value as an external change and // repairs the snapshot. The opposite order could make a stale snapshot look current and // roll a setting back after a restart. flushSnapshotThenBaseline(store, profile, baselineStore, BASELINE_BUCKET); - if (changed[0]) runSave(tree); + if (changed[0] && ownerStillThere) runSave(tree); } public static void capture(Tree tree) { @@ -297,14 +313,16 @@ private void captureAll(Tree tree, String profile) { captureDefaults(tree); ensureKeys(tree); String treeId = tree.getID(); + Map pending = new LinkedHashMap<>(); forEachProp(tree, p -> { if (!isValueProp(p)) return; - String key = keyOf(p); Object serialized = trySerialize(p.get()); - if (serialized != null) { - store.putValue(profile, treeId, key, serialized); - setBaseline(treeId, key, serialized); - } + if (serialized != null) pending.put(keyOf(p), serialized); + }); + if (gateClosed(tree)) return; + pending.forEach((key, serialized) -> { + store.putValue(profile, treeId, key, serialized); + setBaseline(treeId, key, serialized); }); } diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java index 56fc0c2f1..1f2d2e491 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java @@ -88,6 +88,10 @@ private void whenInitialized(Runnable action) { deferredSetup.add(action); return; } + if (tree == null && ConfigManager.didInitializationFail(this)) { + ConfigManager.LOGGER.warn("Skipping deferred setup for config {}: its initialization failed", id); + return; + } action.run(); } @@ -122,7 +126,7 @@ protected void initialize(boolean byConfigManager) { tree.addMetadata("category", category); if (!ConfigManager.isRebindingProfiles()) { - ConfigManager.backup().backend.save0(tree); + saveDefaultsBackup(tree); } // capture code defaults before register() loads stored values over them so the UI can offer a reset action if (defaultSnapshot == null) { @@ -159,6 +163,15 @@ private void runDeferredSetup() { } } + private void saveDefaultsBackup(Tree tree) { + try { + ConfigManager.backup().backend.save0(tree); + } catch (Throwable t) { + ConfigManager.LOGGER.error("failed to write the defaults backup for config {}, restore-to-default may be unavailable", id, t); + ConfigManager.notifyWriteFailed(this, t); + } + } + /** * Recursively record the current value of every property in [tree] as transient {@code "default"} metadata *
@@ -335,13 +348,32 @@ protected void addDependency(String option, String name, Supplier getProperty(Tree tree, String option) { public void save() { if (tree == null) return; - ConfigManager.active().save(tree); + ConfigManager manager = ConfigManager.active(); + if (!manager.save(tree)) ConfigManager.notifyWriteFailed(this, manager.backend.lastSaveFailure()); } /** diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java index 3b2cad3f8..f2783ce98 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java @@ -41,6 +41,7 @@ import org.polyfrost.oneconfig.api.config.v1.serialize.adapter.impl.OneConfigKeybindAdapter; import org.polyfrost.oneconfig.api.config.v1.serialize.impl.FileSerializer; import org.polyfrost.oneconfig.api.config.v1.serialize.impl.NightConfigSerializer; +import org.polyfrost.oneconfig.api.notifications.v1.Notifications; import java.io.IOException; import java.io.InputStream; @@ -54,9 +55,11 @@ import java.nio.file.StandardCopyOption; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import java.util.stream.Stream; @@ -142,7 +145,6 @@ public static Path profileDir(String profile) { final FileBackend backend; private volatile boolean shutdown = false; - @SuppressWarnings("unchecked") private ConfigManager(Path onto, FileSerializer... serializers) { backend = new FileBackend(onto, (FileSerializer[]) serializers); @@ -191,11 +193,32 @@ public static void initialize() { LOGGER.info("Initializing {} configs...", pendingInitialization.size()); while (!pendingInitialization.isEmpty()) { Config config = pendingInitialization.poll(); - if (config != null) config.initialize(true); + if (config == null) continue; + try { + config.initialize(true); + } catch (Throwable t) { + failedInitialization.add(config.id); + Tree half = config.tree; + config.tree = null; + if (half != null && half.getID() != null) { + try { + active().unregister(half.getID()); + } catch (Throwable u) { + LOGGER.warn("failed to unregister the half-built tree for config {}", config.id, u); + } + } + LOGGER.error("failed to initialize config {}, skipping it", config.id, t); + } } LOGGER.info("Initialized configs in {}ms", (System.nanoTime() - t1) / 1_000_000.0); } + private static final Set failedInitialization = ConcurrentHashMap.newKeySet(); + + static boolean didInitializationFail(Config config) { + return failedInitialization.contains(config.id); + } + @ApiStatus.Internal public static void submitForInitialization(Config config) { // never initialize synchronously here because this runs from the Config base constructor @@ -248,13 +271,36 @@ private static void notifyResetOptions(Config config, List options) { String message = options.size() == 1 ? "The option '" + options.get(0) + "' could not be loaded and was reset to its default. A backup was saved as " + config.getTree().getID() + ".corrupted." : options.size() + " options could not be loaded and were reset to their defaults (" + String.join(", ", options) + "). A backup was saved as " + config.getTree().getID() + ".corrupted."; - org.polyfrost.oneconfig.api.notifications.v1.Notifications.error(name + ": options reset", message); + Notifications.error(name + ": options reset", message); } catch (Throwable t) { - // notifications are best-effort and must never break config loading LOGGER.error("failed to notify about reset options for config {}", config.id, t); } } + private static final AtomicBoolean writeFailureNotified = new AtomicBoolean(); + + static void notifyWriteFailed(Config config, @Nullable Throwable cause) { + if (cause == null) { + LOGGER.error("config {} reported an unsuccessful save with no underlying error", config.id); + return; + } + if (!writeFailureNotified.compareAndSet(false, true)) return; + try { + String name = config.title != null ? config.title : config.id; + Throwable root = cause.getCause() != null ? cause.getCause() : cause; + String reason = root.getMessage(); + String tail = reason != null + ? " (" + reason + ")." + : ". This is usually a full disk or a config folder OneConfig cannot write to."; + Notifications.error(name + ": could not save config", + "OneConfig could not save this config" + tail + + " Your settings still work this session but will not be kept;" + + " the log has the exact cause."); + } catch (Throwable t) { + LOGGER.error("failed to notify about the write failure for config {}", config.id, t); + } + } + private static void initProfiles() { addProfileChangeListener(CompatSnapshots.INSTANCE); Property ownedProfileSubdirs = Properties.simple( diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/impl/FileBackend.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/impl/FileBackend.java index 7d99b8027..56b859d47 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/impl/FileBackend.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/impl/FileBackend.java @@ -75,9 +75,9 @@ protected static String read(Path p) { } protected static void write(Path p, String s) { + Path tmp = p.resolveSibling(p.getFileName() + ".tmp"); try { Files.createDirectories(p.getParent()); - Path tmp = p.resolveSibling(p.getFileName() + ".tmp"); Files.write(tmp, s.getBytes(CHARSET)); try { Files.move(tmp, p, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); @@ -85,6 +85,10 @@ protected static void write(Path p, String s) { Files.move(tmp, p, StandardCopyOption.REPLACE_EXISTING); } } catch (Exception e) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) { + } throw new SerializationException("Failed to write file", e); } } diff --git a/modules/config/api/config.api b/modules/config/api/config.api index fad55b773..14b6b151c 100644 --- a/modules/config/api/config.api +++ b/modules/config/api/config.api @@ -146,6 +146,7 @@ public abstract class org/polyfrost/oneconfig/api/config/v1/backend/Backend { public fun exists (Ljava/lang/String;)Z public final fun get (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; public final fun getTrees ()Ljava/util/Collection; + public final fun lastSaveFailure ()Ljava/lang/Exception; public final fun load (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; public final fun load (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Z protected abstract fun load0 (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; diff --git a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/Backend.java b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/Backend.java index 17b438c9c..285fa043f 100644 --- a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/Backend.java +++ b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/Backend.java @@ -249,6 +249,7 @@ public final void saveAll() { public final boolean save(Tree tree) { if (tree.getID() == null) throw new IllegalArgumentException("tree must be master (have a valid ID)"); putSafe(tree); + LAST_SAVE_FAILURE.remove(); try { Object customSave = tree.getMetadata("custom_save"); if (customSave != null) { @@ -261,10 +262,17 @@ public final boolean save(Tree tree) { return save0(tree); } catch (Exception e) { LOGGER.error("error saving tree with ID {}!", tree.getID(), e); + LAST_SAVE_FAILURE.set(e); return false; } } + private static final ThreadLocal LAST_SAVE_FAILURE = new ThreadLocal<>(); + + public final @Nullable Exception lastSaveFailure() { + return LAST_SAVE_FAILURE.get(); + } + protected abstract boolean delete0(@NotNull Tree tree) throws Exception; /** @@ -354,7 +362,6 @@ protected void putSafe(Tree in) { } } - public static final class RegistrationResult { public final Tree tree; public final byte state; diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt index 76d23e509..bb08a2b65 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt @@ -1178,11 +1178,8 @@ abstract class Hud(id: String, title: String, val category: Category) : Cloneabl /** * Whether this exact HUD can be deleted right now - * - * It must be a real instance (providers have nothing to delete) of a type the user is allowed - * to delete ([deletable]) */ - fun canDelete(): Boolean = isReal && deletable() + fun canDelete(): Boolean = deletable() && (isReal || this in HudManager.activeInstances) internal open val profileLocalTree: Boolean get() = true diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt index 013a8caaa..dbd9c9d2c 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt @@ -36,6 +36,7 @@ import org.jetbrains.annotations.ApiStatus import org.polyfrost.compose.node.RootNode import org.polyfrost.compose.render.RenderContext import org.polyfrost.compose.runtime.PolyComposeHost +import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots import org.polyfrost.oneconfig.api.config.v1.ConfigManager import org.polyfrost.oneconfig.api.config.v1.Properties import org.polyfrost.oneconfig.api.config.v1.Tree @@ -380,7 +381,7 @@ object HudManager { } fun removeHud(hud: Hud, delete: Boolean = false) { - require(hud.isReal) { "Tried to remove a non-real HUD - use unregister() instead." } + if (!hud.isReal) LOGGER.warn("Removing HUD ${hud.title}, which has no config tree") activeInstances.remove(hud) disposeHudLogging(hud, delete) } @@ -431,6 +432,7 @@ object HudManager { LOGGER.warn("refusing to delete the config of ${hud.title}, which is marked as not user-deletable") } else if (delete && treeId != null) { cleanup { ConfigManager.active().delete(treeId) } + cleanup { CompatSnapshots.untrack(treeId) } } // back to being a plain provider so a single-instance HUD can be made again later cleanup { hud.detachTree() } diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/OneConfigHudCompat.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/OneConfigHudCompat.kt index 177d5dca1..251d8b4c3 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/OneConfigHudCompat.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/OneConfigHudCompat.kt @@ -6,6 +6,7 @@ import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots import org.polyfrost.oneconfig.api.config.v1.Properties import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Tree +import java.util.concurrent.ConcurrentHashMap private class OneConfigHudCompat(val wrapper: OneConfigHudWrapper) : Hud(wrapper.id, wrapper.name, Category.COMPAT), LegacyHudMarker { @@ -16,15 +17,50 @@ private class OneConfigHudCompat(val wrapper: OneConfigHudWrapper) : private val hiddenRevision = mutableStateOf(0) + @Volatile + private var faulted = false + private val loggedFailures = ConcurrentHashMap.newKeySet() + + private fun fault(member: String, error: Throwable) { + if (faulted) return + faulted = true + HudManager.LOGGER.error( + "Disabling compat HUD '${wrapper.id}' from '${wrapper.modId ?: "unknown"}': $member failed, so " + + "that mod is probably a different version than OneConfig was built against", + error, + ) + } + + private fun reportTransient(member: String, error: Throwable) { + if (!loggedFailures.add(member)) return + HudManager.LOGGER.error("Compat HUD '${wrapper.id}': $member threw, using a fallback for it", error) + } + + private inline fun guard(member: String, fallback: T, block: () -> T): T { + if (faulted) return fallback + return try { + block() + } catch (e: LinkageError) { + fault(member, e) + fallback + } catch (e: Exception) { + reportTransient(member, e) + fallback + } + } + override var hidden: Boolean get() { hiddenRevision.value - return wrapper.hidden + return guard("hidden", true) { wrapper.hidden } } set(value) { - if (wrapper.hidden == value) return - wrapper.hidden = value - hiddenRevision.value++ + guard("hidden", Unit) { + if (wrapper.hidden != value) { + wrapper.hidden = value + hiddenRevision.value++ + } + } } override val persistOwnState: Boolean get() = false @@ -34,28 +70,42 @@ private class OneConfigHudCompat(val wrapper: OneConfigHudWrapper) : override fun update(): Boolean = false override fun multipleInstancesAllowed(): Boolean = false - override fun deletable(): Boolean = false + override fun deletable(): Boolean = faulted + + override val supportsScale: Boolean get() = guard("supportsScale", false) { wrapper.supportsScale } - override val supportsScale: Boolean get() = wrapper.supportsScale + private var lastX = 0f + private var lastY = 0f + private var lastScale = 1f - override var x: Float by wrapper::x - override var y: Float by wrapper::y - override var relativeX: Float by wrapper::x - override var relativeY: Float by wrapper::y + override var x: Float + get() = guard("x", null) { wrapper.x }?.also { lastX = it } ?: lastX + set(value) { guard("x", Unit) { wrapper.x = value; lastX = value } } + override var y: Float + get() = guard("y", null) { wrapper.y }?.also { lastY = it } ?: lastY + set(value) { guard("y", Unit) { wrapper.y = value; lastY = value } } + override var relativeX: Float + get() = x + set(value) { x = value } + override var relativeY: Float + get() = y + set(value) { y = value } - override var customScale: Float by wrapper::scale + override var customScale: Float + get() = guard("scale", null) { wrapper.scale }?.also { lastScale = it } ?: lastScale + set(value) { guard("scale", Unit) { wrapper.scale = value; lastScale = value } } private var lastW = 0f private var lastH = 0f private fun sizeW(): Float { - val live = runCatching { wrapper.scaledWidth }.getOrDefault(0f) + val live = guard("scaledWidth", 0f) { wrapper.scaledWidth } if (live > 0f) lastW = live return if (live > 0f) live else lastW } private fun sizeH(): Float { - val live = runCatching { wrapper.scaledHeight }.getOrDefault(0f) + val live = guard("scaledHeight", 0f) { wrapper.scaledHeight } if (live > 0f) lastH = live return if (live > 0f) live else lastH } @@ -77,21 +127,74 @@ private class OneConfigHudCompat(val wrapper: OneConfigHudWrapper) : get() = sizeH() set(_) {} - override val resizeAxes: HudResize get() = wrapper.resizeAxes + override val resizeAxes: HudResize get() = guard("resizeAxes", HudResize.None) { wrapper.resizeAxes } override fun applyEditorWidth(width: Float) { - wrapper.scaledWidth = width + guard("scaledWidth", Unit) { wrapper.scaledWidth = width } } override fun updateRelativeX(absX: Float) { x = absX } override fun updateRelativeY(absY: Float) { y = absY } - override fun onEditorDragStart() = wrapper.onDragStart() + override fun onEditorDragStart() { + guard("onDragStart", Unit) { wrapper.onDragStart() } + } override fun onEditorDragEnd() { - wrapper.onDragEnd() + guard("onDragEnd", Unit) { wrapper.onDragEnd() } CompatSnapshots.capture(tree) } + + private val placementReady: Boolean get() = guard("placementReady", false) { wrapper.placementReady } + + private val ownsPlacement: Boolean get() = guard("ownsPlacement", true) { wrapper.ownsPlacement } + + fun linkedPropertiesGuarded(): List> = + guard("linkedProperties", emptyList()) { wrapper.linkedProperties() } + + private fun saveWrapper() { + guard("save", Unit) { wrapper.save() } + } + + fun trackPlacementPerProfile(tree: Tree) { + excludeFromSnapshots(tree) + tree.addMetadata(CompatSnapshots.GATE_METADATA, java.util.function.BooleanSupplier { placementReady }) + if (!ownsPlacement) { + tree["oc_compat_x"] = placementProperty("x", "X Position", { x }, { x = it }) + tree["oc_compat_y"] = placementProperty("y", "Y Position", { y }, { y = it }) + if (supportsScale) { + tree["oc_compat_scale"] = placementProperty("scale", "Scale", { customScale }, { customScale = it }) + } + } + tree.addMetadata("custom_save", Runnable { saveWrapper() }) + CompatSnapshots.track(tree) + } + + private fun placementProperty( + key: String, + name: String, + getter: () -> Float, + setter: (Float) -> Unit, + ): Property = Properties.functional( + { getter() }, + { value -> setter(value) }, + "oc_compat_$key", + name, + null, + Float::class.java, + ).apply { + addMetadata(CompatSnapshots.KEY_METADATA, "oc_compat_$key") + addDisplayCondition { Property.Display.HIDDEN } + } + + private fun excludeFromSnapshots(tree: Tree) { + for (node in tree.map.values) { + when (node) { + is Property<*> -> node.addMetadata(CompatSnapshots.NO_SNAPSHOT_META, true) + is Tree -> excludeFromSnapshots(node) + } + } + } } interface OneConfigHudWrapper { @@ -137,48 +240,10 @@ interface OneConfigHudWrapper { hud.setup() val tree = hud.tree if (tree != null) { - for (prop in linkedProperties()) tree.put(prop) - trackPlacementPerProfile(tree) + for (prop in hud.linkedPropertiesGuarded()) tree.put(prop) + hud.trackPlacementPerProfile(tree) } hud.captureStaticSizeDefaults() hud.capturePositionDefaults() } - - private fun trackPlacementPerProfile(tree: Tree) { - excludeFromSnapshots(tree) - tree.addMetadata(CompatSnapshots.GATE_METADATA, java.util.function.BooleanSupplier { placementReady }) - if (!ownsPlacement) { - tree["oc_compat_x"] = placementProperty("x", "X Position", { x }, { x = it }) - tree["oc_compat_y"] = placementProperty("y", "Y Position", { y }, { y = it }) - if (supportsScale) tree["oc_compat_scale"] = placementProperty("scale", "Scale", { scale }, { scale = it }) - } - tree.addMetadata("custom_save", Runnable { save() }) - CompatSnapshots.track(tree) - } - - private fun placementProperty( - key: String, - name: String, - getter: () -> Float, - setter: (Float) -> Unit, - ): Property = Properties.functional( - { getter() }, - { value -> setter(value) }, - "oc_compat_$key", - name, - null, - Float::class.java, - ).apply { - addMetadata(CompatSnapshots.KEY_METADATA, "oc_compat_$key") - addDisplayCondition { Property.Display.HIDDEN } - } - - private fun excludeFromSnapshots(tree: Tree) { - for (node in tree.map.values) { - when (node) { - is Property<*> -> node.addMetadata(CompatSnapshots.NO_SNAPSHOT_META, true) - is Tree -> excludeFromSnapshots(node) - } - } - } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ModOrder.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ModOrder.kt index 3c2de4988..8949dbbcf 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ModOrder.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ModOrder.kt @@ -13,7 +13,7 @@ import java.nio.file.StandardOpenOption /** * User-defined mod card order persisted one id per line * - * Mods that were never dragged are unknown here and sort alphabetically behind the ones that were + * Mods that were never dragged are unknown here and sort alphabetically ahead of the ones that were */ object ModOrder { private val LOGGER = LoggerFactory.getLogger("OneConfig/ModOrder") @@ -46,7 +46,7 @@ object ModOrder { fun indexOf(id: String): Int { ensureLoaded() val index = order.indexOf(id) - return if (index >= 0) index else Int.MAX_VALUE + return if (index >= 0) index else Int.MIN_VALUE } /** @@ -57,7 +57,7 @@ object ModOrder { */ fun reorder(visible: List, all: List) { ensureLoaded() - all.forEach { if (it !in order) order.add(it) } + order.addAll(0, all.filter { it !in order }) val slots = order.indices.filter { order[it] in visible } if (slots.size != visible.size) { LOGGER.warn("Mod order slots ({}) did not match visible mods ({})", slots.size, visible.size) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt index 4ba85a629..35352cabc 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt @@ -765,7 +765,6 @@ private fun DrawScope.drawHudSizeBadge(label: String, centerX: Float, topY: Floa } } - @OptIn(ExperimentalComposeUiApi::class) @Composable private fun HudActionButton( @@ -967,7 +966,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { val panelHud: Hud? = if (panelOpen) primaryHud() else null val deleteHuds: (Collection) -> Unit = { huds -> - val removed = huds.filter { it.deletable() } + val removed = huds.filter { it.canDelete() } if (removed.isNotEmpty()) { Snapshot.withMutableSnapshot { val removedSet = removed.toSet() @@ -1043,67 +1042,72 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { LaunchedEffect(HudManager.editorOpenRevision.intValue) { HudDesignSession.clearCommands() for (command in HudDesignSession.commands) { - when (command) { - is StudioCommand.Select -> { - val huds = command.huds - if (huds.isNotEmpty() && huds.all { it in HudManager.activeInstances }) { - Snapshot.withMutableSnapshot { selectedHuds = huds.toSet() } + try { + when (command) { + is StudioCommand.Select -> { + val huds = command.huds + if (huds.isNotEmpty() && huds.all { it in HudManager.activeInstances }) { + Snapshot.withMutableSnapshot { selectedHuds = huds.toSet() } + } } - } - StudioCommand.OpenSettings -> { - val primary = primaryHud() - if (primary != null) { - Snapshot.withMutableSnapshot { - panelOpen = true - activeCategory = StudioCategory.Settings + StudioCommand.OpenSettings -> { + if (primaryHud() != null) { + Snapshot.withMutableSnapshot { + panelOpen = true + activeCategory = StudioCategory.Settings + } } } - } - StudioCommand.Copy -> { - Snapshot.withMutableSnapshot { hudClipboard = selectedHuds.toList() } - UiSounds.play(UiSoundEvent.CLICK) - } + StudioCommand.Copy -> { + Snapshot.withMutableSnapshot { hudClipboard = selectedHuds.toList() } + UiSounds.play(UiSoundEvent.CLICK) + } - StudioCommand.Cut -> { - Snapshot.withMutableSnapshot { hudClipboard = selectedHuds.toList() } - deleteHuds(selectedHuds) - } + StudioCommand.Cut -> { + Snapshot.withMutableSnapshot { hudClipboard = selectedHuds.toList() } + deleteHuds(selectedHuds) + } - StudioCommand.Paste -> { - if (hudClipboard.isNotEmpty()) { - val s = Platform.screen().screenToMcScale() - val pasted = duplicateHudGroup( - hudClipboard, - Offset(lastPointerPos[0] * s, lastPointerPos[1] * s), - ) - if (pasted.isNotEmpty()) { - Snapshot.withMutableSnapshot { - selectedHuds = pasted.toSet() - pasteMenuOffset = null + StudioCommand.Paste -> { + if (hudClipboard.isNotEmpty()) { + val s = Platform.screen().screenToMcScale() + val pasted = duplicateHudGroup( + hudClipboard, + Offset(lastPointerPos[0] * s, lastPointerPos[1] * s), + ) + if (pasted.isNotEmpty()) { + Snapshot.withMutableSnapshot { + selectedHuds = pasted.toSet() + pasteMenuOffset = null + } } + UiSounds.play(UiSoundEvent.CLICK) } - UiSounds.play(UiSoundEvent.CLICK) } - } - StudioCommand.Delete -> deleteHuds(selectedHuds) + StudioCommand.Delete -> deleteHuds(selectedHuds) - StudioCommand.SelectAll -> Snapshot.withMutableSnapshot { - selectedHuds = HudManager.activeInstances.filter { !it.locked }.toSet() - } + StudioCommand.SelectAll -> Snapshot.withMutableSnapshot { + selectedHuds = HudManager.activeInstances.filter { !it.locked }.toSet() + } - StudioCommand.Lock -> { - val targets = selectedHuds.toList() - if (targets.isNotEmpty()) { - val lock = targets.any { !it.locked } - Snapshot.withMutableSnapshot { - targets.forEach { it.locked = lock } + StudioCommand.Lock -> { + val targets = selectedHuds.toList() + if (targets.isNotEmpty()) { + val lock = targets.any { !it.locked } + Snapshot.withMutableSnapshot { + targets.forEach { it.locked = lock } + } + OneConfigConfig.INSTANCE.save() } - OneConfigConfig.INSTANCE.save() } } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + LOGGER.error("Failed to handle $command in the HUD Design Studio", e) } } } @@ -2555,7 +2559,7 @@ fun HudDragLayer(modifier: Modifier = Modifier) { actionBarGapPx = actionBarGapPx, chromeAlpha = 1f, onDelete = { - if (actionBarTarget.deletable()) { + if (actionBarTarget.canDelete()) { Snapshot.withMutableSnapshot { hoveredHud = null if (draggedHud === actionBarTarget) { @@ -2577,7 +2581,6 @@ fun HudDragLayer(modifier: Modifier = Modifier) { } } - @Composable private fun DesignStudioPanel( modifier: Modifier = Modifier, diff --git a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsManager.kt b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsManager.kt index 5709ed31d..daf2c9481 100644 --- a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsManager.kt +++ b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsManager.kt @@ -45,7 +45,8 @@ object NotificationsManager { } private fun mutate(block: () -> Unit) { - if (Snapshot.current.readOnly) block() else Snapshot.withMutableSnapshot(block) + if (Snapshot.current.readOnly) Snapshot.global { Snapshot.withMutableSnapshot(block) } + else Snapshot.withMutableSnapshot(block) } /** diff --git a/modules/poly-compose/src/main/kotlin/org/polyfrost/compose/runtime/PolyComposeHost.kt b/modules/poly-compose/src/main/kotlin/org/polyfrost/compose/runtime/PolyComposeHost.kt index b29537acb..b48967e27 100644 --- a/modules/poly-compose/src/main/kotlin/org/polyfrost/compose/runtime/PolyComposeHost.kt +++ b/modules/poly-compose/src/main/kotlin/org/polyfrost/compose/runtime/PolyComposeHost.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean class PolyComposeClock { private val clock = BroadcastFrameClock() @@ -21,12 +22,19 @@ class PolyComposeClock { internal val recomposer: CompositionContext get() = recomposerImpl + private val inFrame = AtomicBoolean(false) + fun frame(nanos: Long = System.nanoTime(), notify: Boolean = true): Boolean { - if (notify) Snapshot.sendApplyNotifications() - val appliedBefore = recomposerImpl.changeCount - clock.sendFrame(nanos) - appliedChange = recomposerImpl.changeCount != appliedBefore - return appliedChange || recomposerImpl.hasPendingWork + if (!inFrame.compareAndSet(false, true)) return recomposerImpl.hasPendingWork + try { + if (notify) Snapshot.sendApplyNotifications() + val appliedBefore = recomposerImpl.changeCount + clock.sendFrame(nanos) + appliedChange = recomposerImpl.changeCount != appliedBefore + return appliedChange || recomposerImpl.hasPendingWork + } finally { + inFrame.set(false) + } } var appliedChange = false