diff --git a/adapters/mc-neoforge-1-21-1/adapter-build-logic/build.gradle.kts b/adapters/mc-neoforge-1-21-1/adapter-build-logic/build.gradle.kts new file mode 100644 index 0000000..63ec97c --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/adapter-build-logic/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() + mavenCentral() +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21") + implementation("net.neoforged.moddev:net.neoforged.moddev.gradle.plugin:2.0.141") +} diff --git a/adapters/mc-neoforge-1-21-1/adapter-build-logic/settings.gradle.kts b/adapters/mc-neoforge-1-21-1/adapter-build-logic/settings.gradle.kts new file mode 100644 index 0000000..7f753ac --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/adapter-build-logic/settings.gradle.kts @@ -0,0 +1,7 @@ +pluginManagement { + includeBuild("../../../build-logic") + repositories { + gradlePluginPortal() + mavenCentral() + } +} diff --git a/adapters/mc-neoforge-1-21-1/adapter-build-logic/src/main/kotlin/dsgl-mc-neoforge-1-21-1.conventions.gradle.kts b/adapters/mc-neoforge-1-21-1/adapter-build-logic/src/main/kotlin/dsgl-mc-neoforge-1-21-1.conventions.gradle.kts new file mode 100644 index 0000000..7cc971e --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/adapter-build-logic/src/main/kotlin/dsgl-mc-neoforge-1-21-1.conventions.gradle.kts @@ -0,0 +1,92 @@ +plugins { + kotlin("jvm") + id("net.neoforged.moddev") +} + +val modId: String by properties +val modVersion: String by properties +val neoVersion: String by properties +val parchmentVersion: String by properties +val mcVersion: String by properties + +neoForge { + version = neoVersion + + parchment { + mappingsVersion = parchmentVersion + minecraftVersion = mcVersion + } + + runs { + create("client") { + client() + gameDirectory = project.file("runs/client") + programArguments.addAll("--username", "Developer") + systemProperty("neoforge.enabledGameTestNamespaces", modId) + } + create("client2") { + client() + gameDirectory = project.file("runs/client2") + programArguments.addAll("--username", "Developer2") + systemProperty("neoforge.enabledGameTestNamespaces", modId) + } + + create("server") { + server() + gameDirectory = project.file("runs/server") + programArgument("--nogui") + systemProperty("neoforge.enabledGameTestNamespaces", modId) + } + + // This run config launches GameTestServer and runs all registered gametests, then exits. + // By default, the server will crash when no gametests are provided. + // The gametest system is also enabled by default for other run configs under the /test command. + create("gameTestServer") { + type = "gameTestServer" + gameDirectory = project.file("runs/gameTestServer") + systemProperty("neoforge.enabledGameTestNamespaces", modId) + } + + create("data") { + data() + gameDirectory = project.file("runs/data") + + programArguments.addAll("--mod", modId, "--all", "--output", file("src/generated/resources/").absolutePath, "--existing", file("src/main/resources/").absolutePath) + } + + configureEach { + // The markers can be added/remove as needed separated by commas. + // "SCAN": For mods scan. + // "REGISTRIES": For firing of registry events. + // "REGISTRYDUMP": For getting the contents of all registries. + systemProperty("forge.logging.markers", "REGISTRIES") + + //logLevel = org.slf4j.event.Level.DEBUG + + // Colorful logs + jvmArgument("-XX:+AllowEnhancedClassRedefinition") + systemProperty("terminal.jline", "true") + loggingConfigFile.set(project.file("log4j2_config.xml")) + } + } + + mods { + create(modId) { + sourceSet(sourceSets.main.get()) + } + } +} + +repositories { + gradlePluginPortal() + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +kotlin { + jvmToolchain(21) +} diff --git a/adapters/mc-neoforge-1-21-1/build.gradle.kts b/adapters/mc-neoforge-1-21-1/build.gradle.kts new file mode 100644 index 0000000..975d66a --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("dsgl-mc-adapter.conventions") + id("dsgl-mc-neoforge-1-21-1.conventions") + id("dsgl-releaseable-module.conventions") +} + +dsglRelease { + syncKeys.add("modVersion") +} + +dependencies { + val coreProject = findProject(":core") + ?: findProject(":dsgl:core") + ?: error("DSGL core project not found (expected :core or :dsgl:core).") + implementation(coreProject) + testImplementation(kotlin("test-junit")) + testImplementation(kotlin("test")) +} diff --git a/adapters/mc-neoforge-1-21-1/gradle.properties b/adapters/mc-neoforge-1-21-1/gradle.properties new file mode 100644 index 0000000..ec1a7c1 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/gradle.properties @@ -0,0 +1,38 @@ +publishEnabled=false + +# Minecraft and Forge params +neoVersion=21.1.221 +neoVersionRange=[21.1.0,) +parchmentVersion=2024.11.17 +mcVersion=1.21.1 +mcVersionRange=[1.21.1, 1.22) + +# Mod params +modGroup=org.dreamfinity +modId=dsgl-demo +modName=dsgl-demo +modArchivesName=dsgl-demo +modAuthor=Veritaris +modIcon= +modDescription=Dreamfinity Simple GUI Library Demo +modCredits=Veritaris +buildVersion=1 +modVersion=0.0.1 + +# Mod dev params +isClientBuild=false +clientRunArgs="--username" "Dreamfinity" +serverRunArgs="--no-gui" +hotReload=true +msdfDebug=false +msdfDebugDecorations=false +msdfDebugPerformance=false +rebuildTrace=false +perfDebug=false +dsglOverlayDebug=true +dsglOverlayControls=true + +startParameter.offline=true + +# Publishing params +publishProjectDepsOnly=true diff --git a/adapters/mc-neoforge-1-21-1/log4j2_config.xml b/adapters/mc-neoforge-1-21-1/log4j2_config.xml new file mode 100644 index 0000000..6b64dc1 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/log4j2_config.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/DsglFonts.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/DsglFonts.kt new file mode 100644 index 0000000..e7299bd --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/DsglFonts.kt @@ -0,0 +1,53 @@ +package org.dreamfinity.dsgl.mcNeoforge1211 + +import org.dreamfinity.dsgl.core.font.FontPreloadSummary +import org.dreamfinity.dsgl.core.font.FontRegistry +import java.io.File + +object DsglFonts { + private val lock = Any() + + @Volatile + private var initialized: Boolean = false + + @Volatile + private var lastSummary: FontPreloadSummary? = null + + private val warmFontIds: Set = linkedSetOf( + FontRegistry.DEFAULT_FONT_ID, + FontRegistry.FONT_UBUNTU, + FontRegistry.FONT_JB_MONO, + FontRegistry.TELEGRAFICO, + FontRegistry.FALLBACK_FONT_ID + ) + + fun ensureInitialized(gameDir: File, classLoader: ClassLoader = javaClass.classLoader): FontPreloadSummary { + if (initialized) { + return lastSummary ?: FontRegistry.discoverAndPreloadFonts( + externalFontsDir = File(gameDir, "dsgl/fonts"), + classLoader = classLoader + ).also { lastSummary = it } + } + synchronized(lock) { + if (initialized) { + return lastSummary ?: FontRegistry.discoverAndPreloadFonts( + externalFontsDir = File(gameDir, "dsgl/fonts"), + classLoader = classLoader + ).also { lastSummary = it } + } + val summary = FontRegistry.discoverAndPreloadFonts( + externalFontsDir = File(gameDir, "dsgl/fonts"), + classLoader = classLoader + ) + val warmed = FontRegistry.predecodeAtlases(warmFontIds) + if (warmed > 0) { + println("[DSGL-MSDF] predecoded atlases for $warmed warm fonts") + } + lastSummary = summary + initialized = true + return summary + } + } + + fun summaryOrNull(): FontPreloadSummary? = lastSummary +} diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/DsglScreenHost.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/DsglScreenHost.kt new file mode 100644 index 0000000..9402844 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/DsglScreenHost.kt @@ -0,0 +1,1420 @@ +package org.dreamfinity.dsgl.mcNeoforge1211 + +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.Component +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.HotReloadBridge +import org.dreamfinity.dsgl.core.animation.StyleAnimationEngine +import org.dreamfinity.dsgl.core.colorpicker.* +import org.dreamfinity.dsgl.core.contextmenu.ContextMenuRuntime +import org.dreamfinity.dsgl.core.debug.OverlayDebugControlHost +import org.dreamfinity.dsgl.core.debug.OverlayLayerDebugState +import org.dreamfinity.dsgl.core.dnd.DndRuntime +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.elements.ColorPickerInlineNode +import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode +import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode +import org.dreamfinity.dsgl.core.dom.elements.TextAreaNode +import org.dreamfinity.dsgl.core.event.* +import org.dreamfinity.dsgl.core.hooks.HookHotReloadRemountException +import org.dreamfinity.dsgl.core.hooks.HookRenderSessionMode +import org.dreamfinity.dsgl.core.host.DsglWindowHost +import org.dreamfinity.dsgl.core.host.Viewport +import org.dreamfinity.dsgl.core.host.rawMouseToDsglX +import org.dreamfinity.dsgl.core.host.rawMouseToDsglY +import org.dreamfinity.dsgl.core.input.ClipboardAccess +import org.dreamfinity.dsgl.core.input.ClipboardBridge +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.inspector.InspectorMode +import org.dreamfinity.dsgl.core.overlay.ApplicationOverlayHost +import org.dreamfinity.dsgl.core.overlay.OverlayLayerContracts +import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope +import org.dreamfinity.dsgl.core.overlay.UiLayerId +import org.dreamfinity.dsgl.core.overlay.system.SystemOverlayHost +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.select.SelectRuntime +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.StyleEngine +import org.lwjgl.input.Keyboard +import org.lwjgl.input.Mouse +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.util.* + +/** + * Minecraft 1.21.1 host that owns UI lifecycle and boilerplate. + * + * Subclass or instantiate with a [DsglWindow] and open it via + * `Minecraft.getMinecraft().displayGuiScreen(...)`. + */ +abstract class DsglScreenHost( + private val windowFactory: () -> DsglWindow, + var rendersCount: Long = 0, + title: Component = Component.empty() +) : Screen(title), DsglWindowHost { + companion object { + @Volatile + private var stylesPreloadedOnce: Boolean = false + } + + constructor(window: DsglWindow) : this({ window }) + + override lateinit var window: DsglWindow + private lateinit var adapter: Mc1211UiAdapter + private var domTree: DomTree? = null + private var lastWidth: Int = 0 + private var lastHeight: Int = 0 + private var lastViewport: Viewport = Viewport(width = 0, height = 0) + private var needsRender: Boolean = true + private var needsLayout: Boolean = true + private var lastMouseEvent: Long = 0 + private var eventButton: Int = -1 + private var lastMouseX: Int = 0 + private var lastMouseY: Int = 0 + private var lastMoveX: Int = Int.MIN_VALUE + private var lastMoveY: Int = Int.MIN_VALUE + private val pressedKeys: MutableSet = HashSet() + private val hoverChain: MutableList = mutableListOf() + private var hoverTarget: DOMNode? = null + private var dragCaptureTarget: DOMNode? = null + private var dragCaptureKey: Any? = null + private var dragCaptureClass: Class? = null + private var dragCaptureFocusKey: Any? = null + private var inspectorPointerCaptured: Boolean = false + private var layoutRevision: Long = 0L + private val pendingCleanupRoots: MutableSet = + Collections.newSetFromMap(IdentityHashMap()) + private val composedCommandsBuffer: MutableList = ArrayList(512) + private val stagingCommandsBuffer: MutableList = ArrayList(512) + private val applicationOverlayCommandsBuffer: MutableList = ArrayList(256) + private var activeTarget: DOMNode? = null + private var lastFrameNanos: Long = 0L + private val inspector: InspectorController = InspectorController() + private val applicationOverlayHost: ApplicationOverlayHost = ApplicationOverlayHost() + private val systemOverlayHost: SystemOverlayHost = SystemOverlayHost(inspector) + private val debugOverlayHost: OverlayDebugControlHost = OverlayDebugControlHost() + private val colorSamplerOwnershipRouter: ActiveColorSamplerOwnershipRouter = ActiveColorSamplerOwnershipRouter() + private var activeColorSamplerOwner: ActiveColorSamplerOwner = ActiveColorSamplerOwner.None + private var activeInlineColorSamplerNode: ColorPickerInlineNode? = null + private val inspectorInputDebug: Boolean = false + private val perfDebug: Boolean = java.lang.Boolean.getBoolean("dsgl.perf.debug") + private val phaseTraceDebug: Boolean = java.lang.Boolean.getBoolean("dsgl.rebuild.trace") + private var lastPerfLogMs: Long = 0L + private var frameIndex: Long = 0L + private var blankFrameGuardSkips: Long = 0L + private val pipelineErrorLogTimes: MutableMap = linkedMapOf() + private val clipboardAccess: ClipboardAccess = object : ClipboardAccess { + override fun readText(): String { + return try { + clipboardAccess.readText() + } catch (_: Exception) { + "" + } + } + + override fun writeText(value: String) { + try { + clipboardAccess.writeText(value) + } catch (_: Exception) { + } + } + } + val mc: Minecraft + get() = Minecraft.getInstance() + + override fun init() { + DsglFonts.ensureInitialized(mc.gameDirectory, javaClass.classLoader) + adapter = Mc1211UiAdapter(mc) + ClipboardBridge.install(clipboardAccess) + ScreenColorSamplerBridge.install( + object : ScreenColorSampler { + override fun sampleColorAt(x: Int, y: Int): Int? = adapter.sampleScreenColor(x, y) + + override fun sampleArea(x: Int, y: Int, width: Int, height: Int, outArgb: IntArray): Boolean { + return adapter.sampleScreenArea(x, y, width, height, outArgb) + } + } + ) + inspector.deactivate() + inspectorPointerCaptured = false + colorSamplerOwnershipRouter.reset() + activeColorSamplerOwner = ActiveColorSamplerOwner.None + activeInlineColorSamplerNode = null + layoutRevision = 0L + StyleEngine.clearAllInspectorOverrides() + StyleAnimationEngine.clear() + StyleEngine.setStylesDirectory(File(mc.gameDirectory, "dsgl/styles")) + if (!stylesPreloadedOnce) { + StyleEngine.forceReloadStylesheets() + stylesPreloadedOnce = true + } + window = windowFactory() + window.attachHost(this) + window.markOpened(Instant.now(), ZoneId.systemDefault()) + needsRender = true + needsLayout = true + window.onOpen() + updateSize(force = true) + } + + override fun render(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float){ + if (!::adapter.isInitialized) return + frameIndex += 1 + tracePhase("draw.start") + updateSize(force = false) + val dsglMouseX = lastViewport.rawMouseToDsglX(mouseX) + val dsglMouseY = lastViewport.rawMouseToDsglY(mouseY) + window.onFrame(System.currentTimeMillis()) + val rebuiltThisFrame = rebuildIfNeeded() + val tree = domTree ?: return + val nowNanos = System.nanoTime() + val dtSeconds = if (lastFrameNanos == 0L) { + 1.0 / 60.0 + } else { + ((nowNanos - lastFrameNanos).toDouble() / 1_000_000_000.0).coerceIn(0.0, 0.25) + } + lastFrameNanos = nowNanos + OverlayLayerDebugState.updateFrameTiming(dtSeconds) + window.tick(dtSeconds.toFloat(), partialTick) + val animationVisualsChanged = StyleAnimationEngine.tickAndApply(tree.root, dtSeconds, partialTick) + if (animationVisualsChanged) { + tree.markVisualDirty() + } + var stylesAlreadyApplied = false + var layoutCommittedThisFrame = false + if (needsLayout) { + tracePhase("layout.start") + if (tryCommitLayout(tree, "drawScreen")) { + needsLayout = false + stylesAlreadyApplied = true + layoutCommittedThisFrame = true + tracePhase("layout.end") + } else { + tracePhase("layout.fail") + adapter.paint(composedCommandsBuffer) + flushPendingCleanup() + super.render(guiGraphics, mouseX, mouseY, partialTick) + captureColorPickerEyedropperSamples() + return + } + } + inspector.onLayoutCommitted(tree.root, layoutRevision) + inspector.onCursorMoved(dsglMouseX, dsglMouseY) + inspectorPointerCaptured = inspector.isPointerCaptured + if (inspectorPointerCaptured) { + inspector.onCapturedPointerMove(dsglMouseX, dsglMouseY, lastWidth, lastHeight) + } + val appOverlayRenderEnabled = OverlayLayerDebugState.isRenderEnabled(UiLayerId.ApplicationOverlay) + val systemOverlayRenderEnabled = OverlayLayerDebugState.isRenderEnabled(UiLayerId.SystemOverlay) + val appOverlayInputEnabled = OverlayLayerDebugState.isInputEnabled(UiLayerId.ApplicationOverlay) + val systemOverlayInputEnabled = OverlayLayerDebugState.isInputEnabled(UiLayerId.SystemOverlay) + val inspectorBlocks = systemOverlayInputEnabled && ( + inspectorPointerCaptured || inspector.shouldConsumePointer(dsglMouseX, dsglMouseY) + ) + tracePhase("commands.start") + if (!stylesAlreadyApplied) { + tracePhase("style.start") + } + val commands = try { + tree.paint(adapter, applyStyles = !stylesAlreadyApplied) + } catch (error: Throwable) { + logPipelineError( + key = "draw.paint", + message = "[DSGL] Paint pipeline failed; rendering previous committed frame: ${error.message}" + ) + adapter.paint(composedCommandsBuffer) + flushPendingCleanup() + super.render(guiGraphics, mouseX, mouseY, partialTick) + captureColorPickerEyedropperSamples() + return + } + if (!stylesAlreadyApplied) { + tracePhase("style.end") + } + ContextMenuRuntime.engine.onFrame(adapter, lastWidth, lastHeight, 1f) + SelectRuntime.engine.onFrame(adapter, lastWidth, lastHeight, 1f) + ColorPickerRuntime.engine.onFrame(lastWidth, lastHeight) + ColorPickerRuntime.engine.onCursorPosition(dsglMouseX, dsglMouseY) + refreshActiveColorSamplerOwner(tree.root) + val applicationOverlayCommands = if (!appOverlayRenderEnabled) { + emptyList() + } else { + try { + applicationOverlayHost.render(adapter, lastWidth, lastHeight) + applicationOverlayHost.paint(adapter) + } catch (error: Throwable) { + logPipelineError( + key = "draw.applicationOverlay", + message = "[DSGL] Application overlay paint failed; skipping app overlay frame: ${error.message}" + ) + emptyList() + } + } + systemOverlayHost.syncFrame( + inspectedRoot = tree.root, + inspectedLayoutRevision = layoutRevision, + cursorX = dsglMouseX, + cursorY = dsglMouseY, + inspectorPointerCaptured = inspectorPointerCaptured + ) + val systemOverlayCommands = if (!systemOverlayRenderEnabled) { + emptyList() + } else { + try { + systemOverlayHost.render(adapter, lastWidth, lastHeight) + systemOverlayHost.paint(adapter) + } catch (error: Throwable) { + logPipelineError( + key = "draw.systemOverlay", + message = "[DSGL] System overlay paint failed; skipping system overlay frame: ${error.message}" + ) + emptyList() + } + } + val debugOverlayCommands = runCatching { + debugOverlayHost.render(lastWidth, lastHeight) + debugOverlayHost.paint(adapter) + }.getOrElse { + emptyList() + } + val contextMenuBlocks = appOverlayInputEnabled && !inspectorBlocks && ContextMenuRuntime.engine.isOpen() + val selectBlocks = appOverlayInputEnabled && !inspectorBlocks && SelectRuntime.engine.isOpen() + val inlineSamplerOwnsSession = activeColorSamplerOwner is ActiveColorSamplerOwner.Inline + val colorPickerBlocks = !inspectorBlocks && ( + (systemOverlayInputEnabled && systemOverlayHost.isSystemColorPickerOpen()) || + (appOverlayInputEnabled && ColorPickerRuntime.engine.isOpen() && !inlineSamplerOwnsSession) + ) + if (!inspectorBlocks && !contextMenuBlocks && !selectBlocks && !colorPickerBlocks) { + DndRuntime.engine.onMouseMove(tree.root, dsglMouseX, dsglMouseY) + } + DndRuntime.engine.onFrame(tree.root, dtSeconds) + val prevX = if (lastMoveX == Int.MIN_VALUE) dsglMouseX else lastMoveX + val prevY = if (lastMoveY == Int.MIN_VALUE) dsglMouseY else lastMoveY + val dx = dsglMouseX - prevX + val dy = dsglMouseY - prevY + if (inspectorBlocks || contextMenuBlocks || selectBlocks || colorPickerBlocks) { + clearHoverChainStates() + hoverTarget = null + } else { + updateHover(tree.root, hoverChain, dsglMouseX, dsglMouseY, dx, dy) + hoverTarget = hoverChain.lastOrNull() + if (dragCaptureTarget != null && hasFocusChangedSinceCapture()) { + releaseDragCapture() + } + if (dx != 0 || dy != 0) { + val moveEvent = MouseMoveEvent(dsglMouseX, dsglMouseY, prevX, prevY) + moveEvent.target = resolveForcedPointerTarget() ?: dragCaptureTarget ?: hoverTarget + EventBus.post(moveEvent) + } + } + lastMoveX = dsglMouseX + lastMoveY = dsglMouseY + applicationOverlayCommandsBuffer.clear() + if (appOverlayRenderEnabled) { + applicationOverlayCommandsBuffer.addAll(applicationOverlayCommands) + DndRuntime.engine.appendPlaceholderCommands(applicationOverlayCommandsBuffer) + DndRuntime.engine.appendOverlayCommands( + tree.root, + adapter, + lastWidth, + lastHeight, + applicationOverlayCommandsBuffer + ) + SelectRuntime.engine.appendOverlayCommands(adapter, lastWidth, lastHeight, applicationOverlayCommandsBuffer) + ContextMenuRuntime.engine.appendOverlayCommands( + adapter, + lastWidth, + lastHeight, + applicationOverlayCommandsBuffer + ) + ColorPickerRuntime.engine.appendOverlayCommands(applicationOverlayCommandsBuffer) + appendInlineColorPickerOverlayCommands(applicationOverlayCommandsBuffer) + } + OverlayLayerContracts.composePaintCommands( + applicationRoot = commands, + applicationOverlay = applicationOverlayCommandsBuffer, + systemOverlay = systemOverlayCommands, + debug = debugOverlayCommands, + out = stagingCommandsBuffer, + shouldRenderLayer = OverlayLayerDebugState::isRenderEnabled + ) + val keepPrevious = shouldKeepPreviousFrameCommands( + tree = tree, + rebuiltThisFrame = rebuiltThisFrame, + layoutCommittedThisFrame = layoutCommittedThisFrame, + candidate = stagingCommandsBuffer + ) + if (!keepPrevious) { + composedCommandsBuffer.clear() + composedCommandsBuffer.addAll(stagingCommandsBuffer) + } else { + blankFrameGuardSkips += 1 + tracePhase("commands.guard-preserved") + } + tracePhase("commands.end") + adapter.paint(composedCommandsBuffer) + tracePhase("draw.end") + maybeLogPerf(tree) + flushPendingCleanup() + super.render(guiGraphics, mouseX, mouseY, partialTick) + captureColorPickerEyedropperSamples() + } + + // TODO Neoforge-1.21 Probably should be split in core? + // This method doesn't exist with both typedChar and keyCode as args. 2 separate methods exist instead (below) + override fun keyTyped(typedChar: Char, keyCode: Int) { + window.onKeyTyped(typedChar, keyCode) + } + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + return super.keyPressed(keyCode, scanCode, modifiers) + } + + override fun charTyped(codePoint: Char, modifiers: Int): Boolean { + return super.charTyped(codePoint, modifiers) + } + + override fun onClose() { + ClipboardBridge.install(null) + ScreenColorSamplerBridge.install(null) + FocusManager.clearFocus() + DndRuntime.engine.cancelActiveDrag() + ColorPickerRuntime.engine.closeAll() + SelectRuntime.engine.closeAll() + ContextMenuRuntime.engine.closeAll() + clearActiveTarget() + flushPendingCleanup() + clearHoverChainStates() + inspector.deactivate() + inspectorPointerCaptured = false + colorSamplerOwnershipRouter.reset() + activeColorSamplerOwner = ActiveColorSamplerOwner.None + activeInlineColorSamplerNode = null + layoutRevision = 0L + StyleEngine.clearAllInspectorOverrides() + StyleAnimationEngine.clear() + domTree?.clearRefs() + applicationOverlayHost.clearRefs() + systemOverlayHost.clearRefs() + debugOverlayHost.clearRefs() + domTree?.root?.let { root -> + EventBus.run { root.clearListenersDeep() } + } + hoverChain.clear() + hoverTarget = null + releaseDragCapture() + lastFrameNanos = 0L + window.disposeHookRuntime() + window.onClose() + super.onClose() + } + + override fun isPauseScreen(): Boolean = false + + override fun requestRebuild(reason: String?) { + needsRender = true + } + + override fun requestRedraw() { + } + + override fun getViewport(): Viewport { + return lastViewport + } + + private fun updateSize(force: Boolean) { + val viewport = adapter.viewport() + val width = viewport.width + val height = viewport.height + lastViewport = viewport + if (force || width != lastWidth || height != lastHeight) { + ContextMenuRuntime.engine.closeAll() + lastWidth = width + lastHeight = height + needsLayout = true + needsRender = true + window.onResize(width, height) + } + } + + private fun rebuildIfNeeded(): Boolean { + val hotSwapped = HotReloadBridge.consumeHotSwap() + if (!hotSwapped && !needsRender && domTree != null) { + return false + } + + if (hotSwapped) { + println("Hot swapped - re-building the DOM") + } + + return try { + tracePhase("rebuild.start") + rendersCount++ + val nextTree = renderWithHookSession(hotSwapped) + val currentTree = domTree + if (currentTree == null) { + domTree = nextTree + } else { + val reconcile = currentTree.reconcileWith(nextTree) + if (reconcile.detachedRoots.isNotEmpty()) { + pendingCleanupRoots.addAll(reconcile.detachedRoots) + flushPendingCleanup() + } + domTree = currentTree + } + // Reconcile may involve selector-state mutations on template nodes. + // Force a full style pass on the active retained tree to avoid one-frame unstyled flashes. + StyleEngine.markSelectorStateChanged() + needsRender = false + needsLayout = true + domTree?.root?.let { root -> + FocusManager.retainFocus(root) + restoreDragCapture(root) + DndRuntime.engine.rebindAfterReconcile(root) + } + window.commitRenderBuild() + tracePhase("rebuild.end") + true + } catch (error: Throwable) { + window.discardRenderBuild() + logPipelineError( + key = "rebuild", + message = "[DSGL] Rebuild failed; keeping previous committed frame/tree: ${error.message}" + ) + false + } + } + + private fun renderWithHookSession(hotSwapped: Boolean): DomTree { + val mode = if (hotSwapped) HookRenderSessionMode.HotReload else HookRenderSessionMode.Normal + val maxAttempts = if (hotSwapped) 8 else 1 + var attempt = 0 + var lastRemountRequest: HookHotReloadRemountException? = null + + while (attempt < maxAttempts) { + attempt += 1 + window.beginRenderBuild(mode) + var remountRequested = false + try { + return window.render() + } catch (remount: HookHotReloadRemountException) { + if (!hotSwapped) { + throw remount + } + remountRequested = true + lastRemountRequest = remount + println(remount.message) + } finally { + window.endRenderBuild() + if (remountRequested) { + window.discardRenderBuild() + } + } + } + + throw IllegalStateException( + "Hot-reload hook remount recovery exceeded $maxAttempts attempts: ${lastRemountRequest?.message}" + ) + } + + // TODO Neoforge-1.21 Keyboard handling is done in KeyboardHandler class instead of GUI + // Maybe replace with onKeyPressed, onKeyReleased + override fun handleKeyboardInput() { + updateSize(force = false) + KeyModifiers.sync( + shift = Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) || Keyboard.isKeyDown(Keyboard.KEY_RSHIFT), + control = Keyboard.isKeyDown(Keyboard.KEY_LCONTROL) || Keyboard.isKeyDown(Keyboard.KEY_RCONTROL), + meta = Keyboard.isKeyDown(Keyboard.KEY_LMETA) || Keyboard.isKeyDown(Keyboard.KEY_RMETA) + ) + systemOverlayHost.onInputFrame(lastWidth, lastHeight) + ColorPickerRuntime.engine.onFrame(lastWidth, lastHeight) + val keyCode = Keyboard.getEventKey() + val keyChar = Keyboard.getEventCharacter() + val inspectorMouseX = if (lastMoveX == Int.MIN_VALUE) lastMouseX else lastMoveX + val inspectorMouseY = if (lastMoveY == Int.MIN_VALUE) lastMouseY else lastMoveY + if (Keyboard.getEventKeyState()) { + if (!Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) && keyCode == Keyboard.KEY_F12) { + inspector.toggle() + inspectorPointerCaptured = false + if (inspector.active) { + DndRuntime.engine.cancelActiveDrag() + releaseDragCapture() + clearActiveTarget() + clearHoverChainStates() + } + mc.dispatchKeypresses() + return + } + if (Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) && keyCode == Keyboard.KEY_F12 && inspector.active) { + inspector.toggleMode() + mc.dispatchKeypresses() + return + } + if (keyCode == Keyboard.KEY_F10) { + val demoAnchorX = if (lastMoveX == Int.MIN_VALUE) inspectorMouseX else lastMoveX + val demoAnchorY = if (lastMoveY == Int.MIN_VALUE) inspectorMouseY else lastMoveY + systemOverlayHost.togglePanelDemo(demoAnchorX, demoAnchorY) + mc.dispatchKeypresses() + return + } + if (keyCode == Keyboard.KEY_ESCAPE && inspector.cancelPickMode()) { + logInspectorInput("escape cancelled inspector pick mode") + mc.dispatchKeypresses() + return + } + if (consumeOverlayKeyDown( + keyCode = keyCode, + keyChar = keyChar, + inspectorMouseX = inspectorMouseX, + inspectorMouseY = inspectorMouseY + ) + ) { + mc.dispatchKeypresses() + return + } + if (keyCode == Keyboard.KEY_F6) { + StyleEngine.forceReloadStylesheets() + requestRebuild("style reload") + } + if (pressedKeys.add(keyCode)) { + val downEvent = KeyboardKeyDownEvent(keyChar, keyCode) + EventBus.post(downEvent) + if (downEvent.cancelled) { + pressedKeys.remove(keyCode) + } else { + window.onKeyTyped(keyChar, keyCode) + if (keyCode == Keyboard.KEY_ESCAPE) { + mc.displayGuiScreen(null) + } + } + } + } else { + val keyboardBlocked = inspector.active && ( + inspector.shouldConsumeKeyboard(inspectorMouseX, inspectorMouseY) || + inspector.mode == InspectorMode.Locked + ) + if (keyboardBlocked) { + pressedKeys.remove(keyCode) + logInspectorInput("keyboard up consumed keyCode=$keyCode") + mc.dispatchKeypresses() + return + } + if (pressedKeys.remove(keyCode)) { + EventBus.post(KeyboardKeyUpEvent(keyChar, keyCode)) + } + } + + mc.dispatchKeypresses() + } + + // TODO NeoForge-1.21 Mouse input is handled in MouseHandler instead + // Maybe should be replaced by appropriate handlers? + override fun handleMouseInput() { + updateSize(force = false) + rebuildIfNeeded() + val tree = domTree ?: return + if (needsLayout) { + if (tryCommitLayout(tree, "handleMouseInput")) { + needsLayout = false + } else { + return + } + } + + val mouseX = lastViewport.rawMouseToDsglX(Mouse.getEventX()) + val mouseY = lastViewport.rawMouseToDsglY(Mouse.getEventY()) + val dWheel = Mouse.getDWheel() + val mouseButton = Mouse.getEventButton() + inspector.onCursorMoved(mouseX, mouseY) + ContextMenuRuntime.engine.onFrame( + measureContext = adapter, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + viewportScale = 1f + ) + SelectRuntime.engine.onFrame( + measureContext = adapter, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + viewportScale = 1f + ) + systemOverlayHost.onInputFrame(lastWidth, lastHeight) + inspectorPointerCaptured = inspector.isPointerCaptured + systemOverlayHost.syncFrame( + inspectedRoot = tree.root, + inspectedLayoutRevision = layoutRevision, + cursorX = mouseX, + cursorY = mouseY, + inspectorPointerCaptured = inspectorPointerCaptured + ) + ColorPickerRuntime.engine.onFrame(lastWidth, lastHeight) + refreshActiveColorSamplerOwner(tree.root) + val appPressMove = mouseButton == -1 && eventButton != -1 + if (!appPressMove && consumeOverlayPointerEvent(mouseX, mouseY, dWheel, mouseButton)) { + consumeOverlayPointerState(mouseX, mouseY) + return + } + + + refreshHoverTarget(mouseX, mouseY) + + if (mouseButton > 2) return + + if (Mouse.getEventButtonState()) { + eventButton = mouseButton + lastMouseEvent = Minecraft.getSystemTime() + mapButton(mouseButton)?.let { mappedButton -> + val event = MouseDownEvent(mouseX, mouseY, mappedButton) + event.target = resolvePointerDownTarget() + EventBus.post(event) + DndRuntime.engine.onMouseDown(tree.root, event.target ?: hoverTarget, event) + if (mappedButton == MouseButton.LEFT) { + setActiveTarget(event.target ?: hoverTarget) + val captureTarget = resolveDragCaptureTarget(event.target ?: hoverTarget, mouseX, mouseY) + if (captureTarget != null) { + setDragCapture(captureTarget) + captureTarget.beginPointerCapture(mouseX, mouseY, mappedButton) + } else if (dragCaptureTarget != null) { + releaseDragCapture() + } + } + } + } else if (mouseButton != -1 && eventButton == mouseButton) { + val releaseTarget = resolvePointerUpTarget() + val hadDragCapture = dragCaptureTarget != null + eventButton = -1 + mapButton(mouseButton)?.let { mappedButton -> + val upEvent = MouseUpEvent(mouseX, mouseY, mappedButton) + upEvent.target = releaseTarget + EventBus.post(upEvent) + dragCaptureTarget?.endPointerCapture(mouseX, mouseY, mappedButton) + val dndConsumed = DndRuntime.engine.onMouseUp(tree.root, upEvent) + if (!hadDragCapture && !dndConsumed) { + val clickEvent = MouseClickEvent(mouseX, mouseY, mappedButton) + clickEvent.target = resolveClickTarget() + EventBus.post(clickEvent) + } + } + clearActiveTarget() + releaseDragCapture() + } else if (eventButton != -1 && lastMouseEvent > 0L) { + mapButton(eventButton)?.let { mappedButton -> + val dx = mouseX - lastMouseX + val dy = mouseY - lastMouseY + if (dx != 0 || dy != 0) { + DndRuntime.engine.onMouseMove(tree.root, mouseX, mouseY) + val dragEvent = MouseDragEvent( + lastMouseX, + lastMouseY, + dx, + dy, + mappedButton + ) + if (!DndRuntime.engine.isDragging) { + dragEvent.target = dragCaptureTarget ?: hoverTarget + EventBus.post(dragEvent) + } + dragCaptureTarget?.continuePointerCapture( + mouseX = mouseX, + mouseY = mouseY, + mouseDX = dx, + mouseDY = dy, + button = mappedButton + ) + } + } + } + + if (dWheel != 0) { + val wheelTarget = resolveWheelTarget() + if (wheelTarget != null) { + val wheelEvent = MouseWheelEvent(mouseX, mouseY, dWheel) + wheelEvent.target = wheelTarget + EventBus.post(wheelEvent) + if (!wheelEvent.cancelled) { + bubbleGenericWheel(wheelTarget, mouseX, mouseY, dWheel) + } + } + } + + lastMouseX = mouseX + lastMouseY = mouseY + } + + private fun consumeOverlayKeyDown( + keyCode: Int, + keyChar: Char, + inspectorMouseX: Int, + inspectorMouseY: Int + ): Boolean { + val consumedBy = OverlayLayerContracts.firstInputConsumer( + canConsume = { layer -> + when (layer) { + UiLayerId.Debug -> debugOverlayHost.handleKeyDown(keyCode, keyChar) + UiLayerId.SystemOverlay -> consumeSystemOverlayKeyDown( + keyCode = keyCode, + keyChar = keyChar, + inspectorMouseX = inspectorMouseX, + inspectorMouseY = inspectorMouseY + ) + + UiLayerId.ApplicationOverlay -> consumeApplicationOverlayKeyDown(keyCode, keyChar) + UiLayerId.ApplicationRoot -> false + } + }, + isLayerInputEnabled = OverlayLayerDebugState::isInputEnabled + ) + return consumedBy != null + } + + private fun consumeSystemOverlayKeyDown( + keyCode: Int, + keyChar: Char, + inspectorMouseX: Int, + inspectorMouseY: Int + ): Boolean { + if (systemOverlayHost.handleKeyDown(keyCode, keyChar)) { + return true + } + val keyboardBlocked = inspector.active && ( + inspector.shouldConsumeKeyboard(inspectorMouseX, inspectorMouseY) || + inspector.mode == InspectorMode.Locked + ) + if (keyboardBlocked) { + logInspectorInput("keyboard down consumed keyCode=$keyCode") + return true + } + return false + } + + private fun consumeApplicationOverlayKeyDown(keyCode: Int, keyChar: Char): Boolean { + if (ColorPickerRuntime.engine.handleKeyDown(keyCode, keyChar)) { + return true + } + if (applicationOverlayHost.handleKeyDown(keyCode, keyChar)) { + return true + } + if (SelectRuntime.engine.handleKeyDown(keyCode, keyChar)) { + return true + } + if (ContextMenuRuntime.engine.handleKeyDown(keyCode)) { + return true + } + return false + } + + private fun consumeOverlayPointerEvent( + mouseX: Int, + mouseY: Int, + dWheel: Int, + mouseButton: Int + ): Boolean { + val mappedButton = mapButton(mouseButton) + val buttonPressed = Mouse.getEventButtonState() + val consumedBy = OverlayLayerContracts.firstInputConsumer( + canConsume = { layer -> + when (layer) { + UiLayerId.Debug -> consumeDebugPointerEvent( + mouseX = mouseX, + mouseY = mouseY, + dWheel = dWheel, + mappedButton = mappedButton, + mouseButton = mouseButton, + buttonPressed = buttonPressed + ) + + UiLayerId.SystemOverlay -> consumeSystemOverlayPointerEvent( + mouseX = mouseX, + mouseY = mouseY, + dWheel = dWheel, + mouseButton = mouseButton, + mappedButton = mappedButton, + buttonPressed = buttonPressed + ) + + UiLayerId.ApplicationOverlay -> consumeApplicationOverlayPointerEvent( + mouseX = mouseX, + mouseY = mouseY, + dWheel = dWheel, + mouseButton = mouseButton, + mappedButton = mappedButton, + buttonPressed = buttonPressed + ) + + UiLayerId.ApplicationRoot -> false + } + }, + isLayerInputEnabled = OverlayLayerDebugState::isInputEnabled + ) + return consumedBy != null + } + + private fun consumeDebugPointerEvent( + mouseX: Int, + mouseY: Int, + dWheel: Int, + mappedButton: MouseButton?, + mouseButton: Int, + buttonPressed: Boolean + ): Boolean { + if (dWheel != 0 && debugOverlayHost.handleMouseWheel(mouseX, mouseY, dWheel)) { + return true + } + if (mouseButton != -1 && mappedButton != null) { + return if (buttonPressed) { + debugOverlayHost.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + debugOverlayHost.handleMouseUp(mouseX, mouseY, mappedButton) + } + } + if (mouseButton == -1 && debugOverlayHost.handleMouseMove(mouseX, mouseY)) { + return true + } + return false + } + + private fun consumeSystemOverlayPointerEvent( + mouseX: Int, + mouseY: Int, + dWheel: Int, + mouseButton: Int, + mappedButton: MouseButton?, + buttonPressed: Boolean + ): Boolean { + if (dWheel != 0 && systemOverlayHost.handleMouseWheel(mouseX, mouseY, dWheel)) { + return true + } + if (mouseButton != -1 && mappedButton != null) { + val consumedBySystemOverlay = if (buttonPressed) { + systemOverlayHost.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + systemOverlayHost.handleMouseUp(mouseX, mouseY, mappedButton) + } + if (consumedBySystemOverlay) { + return true + } + } else if (mouseButton == -1 && systemOverlayHost.handleMouseMove(mouseX, mouseY)) { + return true + } + + val inspectorConsumesPointer = inspector.shouldConsumePointer(mouseX, mouseY) + if (!inspectorConsumesPointer) return false + if (!buttonPressed && mouseButton != -1) { + inspectorPointerCaptured = false + } + logInspectorInput("pointer event consumed by inspector bounds button=$mouseButton wheel=$dWheel") + return true + } + + private fun consumeApplicationOverlayPointerEvent( + mouseX: Int, + mouseY: Int, + dWheel: Int, + mouseButton: Int, + mappedButton: MouseButton?, + buttonPressed: Boolean + ): Boolean { + val inlineSamplerOwnsSession = activeColorSamplerOwner is ActiveColorSamplerOwner.Inline + if (!inlineSamplerOwnsSession) { + if (dWheel != 0 && ColorPickerRuntime.engine.handleMouseWheel(mouseX, mouseY, dWheel)) { + return true + } + if (mouseButton != -1 && mappedButton != null) { + val consumedByColorPicker = if (buttonPressed) { + ColorPickerRuntime.engine.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + ColorPickerRuntime.engine.handleMouseUp(mouseX, mouseY, mappedButton) + } + if (consumedByColorPicker) { + return true + } + } else if (mouseButton == -1 && ColorPickerRuntime.engine.handleMouseMove(mouseX, mouseY)) { + return true + } + } + + if (dWheel != 0 && applicationOverlayHost.handleMouseWheel(mouseX, mouseY, dWheel)) { + return true + } + if (mouseButton != -1 && mappedButton != null) { + val consumedByAppOverlay = if (buttonPressed) { + applicationOverlayHost.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + applicationOverlayHost.handleMouseUp(mouseX, mouseY, mappedButton) + } + if (consumedByAppOverlay) { + return true + } + } else if (mouseButton == -1 && applicationOverlayHost.handleMouseMove(mouseX, mouseY)) { + return true + } + + if (dWheel != 0 && ContextMenuRuntime.engine.handleMouseWheel(mouseX, mouseY, dWheel)) { + return true + } + if (dWheel != 0 && SelectRuntime.engine.handleMouseWheel(mouseX, mouseY, dWheel)) { + return true + } + if (mouseButton != -1 && mappedButton != null) { + val consumedByContextMenu = if (buttonPressed) { + ContextMenuRuntime.engine.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + ContextMenuRuntime.engine.handleMouseUp(mouseX, mouseY, mappedButton) + } + if (consumedByContextMenu) { + return true + } + val consumedBySelect = if (buttonPressed) { + SelectRuntime.engine.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + SelectRuntime.engine.handleMouseUp(mouseX, mouseY, mappedButton) + } + if (consumedBySelect) { + return true + } + return false + } + if (mouseButton == -1 && ContextMenuRuntime.engine.handleMouseMove(mouseX, mouseY)) { + return true + } + if (mouseButton == -1 && SelectRuntime.engine.handleMouseMove(mouseX, mouseY)) { + return true + } + return false + } + + private fun consumeOverlayPointerState(mouseX: Int, mouseY: Int) { + eventButton = -1 + clearActiveTarget() + releaseDragCapture() + lastMouseX = mouseX + lastMouseY = mouseY + } + + private fun mapButton(button: Int): MouseButton? { + return when (button) { + 0 -> MouseButton.LEFT + 1 -> MouseButton.RIGHT + 2 -> MouseButton.MIDDLE + else -> null + } + } + + init { + inspector.installColorPickerHost(systemOverlayHost.systemInspectorColorPickerPopupHost()) + } + + private fun refreshActiveColorSamplerOwner(root: DOMNode?) { + val inlineByToken = LinkedHashMap() + if (root != null) { + collectActiveInlineColorSamplers(root, inlineByToken) + } + val focusedInline = FocusManager.focusedNode() as? ColorPickerInlineNode + if (focusedInline != null && focusedInline.wantsGlobalPointerInput()) { + inlineByToken.putIfAbsent(colorSamplerToken(focusedInline), focusedInline) + } + activeColorSamplerOwner = colorSamplerOwnershipRouter.update( + popupEyedropperActive = ColorPickerRuntime.engine.hasActiveEyedropper(), + inlineActiveTokens = inlineByToken.keys.toSet() + ) + activeInlineColorSamplerNode = when (val owner = activeColorSamplerOwner) { + is ActiveColorSamplerOwner.Inline -> inlineByToken[owner.token] + else -> null + } + } + + private fun collectActiveInlineColorSamplers( + node: DOMNode, + out: MutableMap + ) { + if (node is ColorPickerInlineNode && node.wantsGlobalPointerInput()) { + out.putIfAbsent(colorSamplerToken(node), node) + } + for (child in node.children) { + collectActiveInlineColorSamplers(child, out) + } + } + + private fun colorSamplerToken(node: ColorPickerInlineNode): Any { + return node.key ?: node + } + + private fun resolveForcedPointerTarget(): DOMNode? { + if (activeColorSamplerOwner is ActiveColorSamplerOwner.Inline) { + val inline = activeInlineColorSamplerNode + if (inline != null && inline.wantsGlobalPointerInput()) { + return inline + } + } + return null + } + + private fun appendInlineColorPickerOverlayCommands(out: MutableList) { + val layer = OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.Application) + if (layer != UiLayerId.ApplicationOverlay) return + if (activeColorSamplerOwner is ActiveColorSamplerOwner.Inline) { + val inline = activeInlineColorSamplerNode ?: return + if (!inline.wantsGlobalPointerInput()) return + inline.appendEyedropperOverlayCommands( + viewportWidth = lastWidth.coerceAtLeast(1), + viewportHeight = lastHeight.coerceAtLeast(1), + out = out + ) + } + } + + private fun captureColorPickerEyedropperSamples() { + refreshActiveColorSamplerOwner(domTree?.root) + if (OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.System) == UiLayerId.SystemOverlay) { + systemOverlayHost.captureSystemColorPickerEyedropperSample() + } + if (OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.Application) != UiLayerId.ApplicationOverlay) { + return + } + when (activeColorSamplerOwner) { + ActiveColorSamplerOwner.Popup -> ColorPickerRuntime.engine.captureEyedropperSample() + is ActiveColorSamplerOwner.Inline -> { + val inline = activeInlineColorSamplerNode + if (inline != null && inline.wantsGlobalPointerInput()) { + inline.captureEyedropperSample() + } + } + + ActiveColorSamplerOwner.None -> { + if (ColorPickerRuntime.engine.hasActiveEyedropper()) { + ColorPickerRuntime.engine.captureEyedropperSample() + } + } + } + } + + private fun flushPendingCleanup() { + if (pendingCleanupRoots.isEmpty()) return + val detachedRoots = pendingCleanupRoots.toList() + pendingCleanupRoots.clear() + detachedRoots.forEach { root -> + EventBus.run { root.clearListenersDeep() } + } + } + + internal fun debugPendingCleanupCount(): Int = pendingCleanupRoots.size + + internal fun debugBindTreeForTests(tree: DomTree, needsLayout: Boolean = false) { + domTree = tree + this.needsLayout = needsLayout + } + + internal fun debugRefreshHoverTargetForTests(mouseX: Int, mouseY: Int) { + refreshHoverTarget(mouseX, mouseY) + } + + internal fun debugHoverTargetForTests(): DOMNode? = hoverTarget + + internal fun debugResolvePointerDownTargetForTests(): DOMNode? = resolvePointerDownTarget() + + internal fun debugResolveClickTargetForTests(): DOMNode? = resolveClickTarget() + + internal fun debugSetNeedsRenderForTests(value: Boolean) { + needsRender = value + } + + internal fun debugRebuildIfNeededForTests(): Boolean = rebuildIfNeeded() + + private fun setDragCapture(target: DOMNode) { + dragCaptureTarget = target + dragCaptureKey = target.key + dragCaptureClass = target.javaClass + dragCaptureFocusKey = FocusManager.focusedNode()?.key + } + + private fun releaseDragCapture() { + dragCaptureTarget?.cancelPointerCapture() + RangeInputNode.clearActiveDrag() + SingleLineInputNode.clearActiveDrag() + TextAreaNode.clearActiveDrag() + dragCaptureTarget = null + dragCaptureKey = null + dragCaptureClass = null + dragCaptureFocusKey = null + } + + private fun setActiveTarget(target: DOMNode?) { + if (target?.styleDisabled == true) return + if (activeTarget === target) return + activeTarget?.setActiveState(false) + activeTarget = target + activeTarget?.setActiveState(true) + } + + private fun clearActiveTarget() { + activeTarget?.setActiveState(false) + activeTarget = null + } + + private fun resolveDragCaptureTarget(start: DOMNode?, mouseX: Int, mouseY: Int): DOMNode? { + var current = start + while (current != null) { + when (current) { + is RangeInputNode -> return current + is SingleLineInputNode -> if (current.shouldCaptureTextSelectionDrag(mouseX, mouseY)) return current + is TextAreaNode -> if (current.shouldCaptureAnyDrag(mouseX, mouseY)) return current + } + if (current.shouldCapturePointerDrag(mouseX, mouseY)) { + return current + } + current = current.parent + } + return null + } + + private fun restoreDragCapture(root: DOMNode) { + if (dragCaptureTarget == null) return + val key = dragCaptureKey + val cls = dragCaptureClass + if (cls == null) { + releaseDragCapture() + return + } + if (key == null) { + val captured = dragCaptureTarget + if (captured != null && captured.javaClass == cls) { + if (eventButton != -1) { + return + } + if (isSameOrAncestor(root, captured)) { + return + } + } + releaseDragCapture() + return + } + + val restored = findByKeyAndClass(root, key, cls) + if (restored != null) { + dragCaptureTarget = restored + } else { + releaseDragCapture() + } + } + + private fun findByKeyAndClass( + node: DOMNode, + key: Any, + cls: Class + ): DOMNode? { + if (node.key == key && node.javaClass == cls) return node + for (child in node.children) { + val found = findByKeyAndClass(child, key, cls) + if (found != null) { + return found + } + } + return null + } + + private fun hasFocusChangedSinceCapture(): Boolean { + if (dragCaptureFocusKey == null) return false + val currentFocusKey = FocusManager.focusedNode()?.key + return currentFocusKey != dragCaptureFocusKey + } + + private fun refreshHoverTarget(mouseX: Int, mouseY: Int) { + val tree = domTree ?: return + if (needsLayout) { + if (tryCommitLayout(tree, "refreshHoverTarget")) { + needsLayout = false + } else { + return + } + } + val chain = collectHoverChain(tree.root, mouseX, mouseY) + hoverTarget = chain.lastOrNull() + } + + private fun resolvePointerDownTarget(): DOMNode? { + return resolveForcedPointerTarget() ?: hoverTarget + } + + private fun resolvePointerUpTarget(): DOMNode? { + return dragCaptureTarget ?: resolveForcedPointerTarget() ?: hoverTarget + } + + private fun resolveClickTarget(): DOMNode? { + return hoverTarget + } + + private fun resolveWheelTarget(): DOMNode? { + val focused = FocusManager.focusedNode() + if (focused is TextAreaNode) { + val hovered = hoverTarget + if (!isSameOrAncestor(focused, hovered)) { + return focused + } + } + return hoverTarget + } + + private fun bubbleGenericWheel(target: DOMNode, mouseX: Int, mouseY: Int, delta: Int): Boolean { + var current: DOMNode? = target + while (current != null) { + if (current.handleGenericWheel(mouseX, mouseY, delta)) { + return true + } + current = current.parent + } + return false + } + + private fun isSameOrAncestor(candidate: DOMNode, node: DOMNode?): Boolean { + var current = node + while (current != null) { + if (current === candidate) return true + current = current.parent + } + return false + } + + private fun clearHoverChainStates() { + hoverChain.forEach { node -> + node.setHoveredState(false) + } + hoverChain.clear() + } + + private fun logInspectorInput(message: String) { + if (!inspectorInputDebug) return + println("[DSGL-InspectorInput] $message") + } + + private fun tryCommitLayout(tree: DomTree, phase: String): Boolean { + return try { + tree.render(adapter, lastWidth, lastHeight) + val rootBounds = tree.root.bounds + if (lastWidth > 0 && lastHeight > 0 && (rootBounds.width <= 0 || rootBounds.height <= 0)) { + logPipelineError( + key = "layout.$phase.invalidBounds", + message = "[DSGL] Layout commit produced invalid root bounds ${rootBounds.width}x${rootBounds.height} in $phase." + ) + return false + } + layoutRevision++ + inspector.onLayoutCommitted(tree.root, layoutRevision) + true + } catch (error: Throwable) { + logPipelineError( + key = "layout.$phase", + message = "[DSGL] Layout commit failed in $phase; keeping previous frame: ${error.message}" + ) + false + } + } + + private fun shouldKeepPreviousFrameCommands( + tree: DomTree, + rebuiltThisFrame: Boolean, + layoutCommittedThisFrame: Boolean, + candidate: List + ): Boolean { + val shape = validateCommandShape(candidate) + if (!shape.valid) { + logPipelineError( + key = "shape.guard", + message = "[DSGL] Guarded invalid command shape (clip=${shape.clipDepth}, transform=${shape.transformDepth}, opacity=${shape.opacityDepth}); keeping previous frame." + ) + return composedCommandsBuffer.isNotEmpty() + } + if (candidate.isNotEmpty()) return false + if (composedCommandsBuffer.isEmpty()) return false + if (!rebuiltThisFrame && !layoutCommittedThisFrame) return false + if (!hasRenderableNodes(tree.root)) return false + logPipelineError( + key = "blank.guard", + message = "[DSGL] Guarded against blank rebuild frame; keeping previous commands." + ) + return true + } + + private data class CommandShape( + val valid: Boolean, + val clipDepth: Int, + val transformDepth: Int, + val opacityDepth: Int + ) + + private fun validateCommandShape(commands: List): CommandShape { + var clipDepth = 0 + var transformDepth = 0 + var opacityDepth = 0 + for (command in commands) { + when (command) { + is RenderCommand.PushClip -> clipDepth += 1 + is RenderCommand.PopClip -> { + clipDepth -= 1 + if (clipDepth < 0) return CommandShape(false, clipDepth, transformDepth, opacityDepth) + } + + is RenderCommand.PushTransform -> transformDepth += 1 + is RenderCommand.PopTransform -> { + transformDepth -= 1 + if (transformDepth < 0) return CommandShape(false, clipDepth, transformDepth, opacityDepth) + } + + is RenderCommand.PushOpacity -> opacityDepth += 1 + is RenderCommand.PopOpacity -> { + opacityDepth -= 1 + if (opacityDepth < 0) return CommandShape(false, clipDepth, transformDepth, opacityDepth) + } + + else -> Unit + } + } + return CommandShape( + valid = clipDepth == 0 && transformDepth == 0 && opacityDepth == 0, + clipDepth = clipDepth, + transformDepth = transformDepth, + opacityDepth = opacityDepth + ) + } + + private fun hasRenderableNodes(node: DOMNode): Boolean { + if (node.display != Display.None && node.children.isNotEmpty()) { + return true + } + node.children.forEach { child -> + if (hasRenderableNodes(child)) return true + } + return false + } + + private fun tracePhase(phase: String) { + if (!phaseTraceDebug) return + println("[DSGL-RebuildTrace] frame=$frameIndex phase=$phase needsRender=$needsRender needsLayout=$needsLayout") + } + + private fun logPipelineError(key: String, message: String) { + val now = System.currentTimeMillis() + val previous = pipelineErrorLogTimes[key] ?: 0L + if (now - previous < 2_000L) return + pipelineErrorLogTimes[key] = now + println(message) + } + + private fun maybeLogPerf(tree: DomTree) { + if (!perfDebug) return + val now = System.currentTimeMillis() + if (now - lastPerfLogMs < 2_000L) return + lastPerfLogMs = now + val paintStats = tree.paintStats() + val styleStats = StyleEngine.lastStyleApplyReport() + println( + "[DSGL-PERF] frames=${paintStats.frames} commandRebuilds=${paintStats.commandRebuilds} " + + "chunkVisited=${paintStats.chunkNodesVisitedLastFrame} chunkRebuilt=${paintStats.chunkNodesRebuiltLastFrame} " + + "styled=${styleStats.visitedNodes} styleCacheHit=${styleStats.cacheHits} " + + "styleRecomputed=${styleStats.recomputedNodes} blankGuardSkips=$blankFrameGuardSkips" + ) + } +} diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/GlUtils.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/GlUtils.kt new file mode 100644 index 0000000..6c0c1d0 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/GlUtils.kt @@ -0,0 +1,92 @@ +package org.dreamfinity.dsgl.mcNeoforge1211 + +import net.minecraft.client.renderer.RenderHelper +import org.lwjgl.opengl.GL11 + +/** + * Executes a block with an OpenGL matrix/attribute stack push/pop. + */ +inline fun withStack( + attributes: List, + block: () -> Unit +) { + val attributesBitMask = attributes.reduce { a, b -> a or b } + withStack(attributesBitMask) { block() } +} + +/** + * Executes a block with an OpenGL matrix/attribute stack push/pop. + */ +inline fun withStack( + attributesBitMask: Int = 0, + block: () -> Unit +) { + if (attributesBitMask > 0) { + GL11.glPushAttrib(attributesBitMask) + } + GL11.glPushMatrix() + try { + block() + } finally { + GL11.glPopMatrix() + if (attributesBitMask > 0) { + GL11.glPopAttrib() + } + } +} + +/** + * Enables and disables GL capabilities for the duration of [block]. + */ +inline fun withAttributes( + enable: List = emptyList(), + disable: List = emptyList(), + block: () -> Unit +) { + for (capability in enable) { + GL11.glEnable(capability) + } + for (capability in disable) { + GL11.glDisable(capability) + } + try { + block() + } finally { + for (capability in enable) { + GL11.glDisable(capability) + } + for (capability in disable) { + GL11.glEnable(capability) + } + } +} + +/** + * Executes a block with an OpenGL matrix/attribute stack push/pop. + */ +inline fun withAttributes( + enableBitMask: Int? = null, + disableBitMask: Int? = null, + block: () -> Unit +) { + enableBitMask?.let { GL11.glEnable(it) } + disableBitMask?.let { GL11.glDisable(it) } + try { + block() + } finally { + enableBitMask?.let { GL11.glDisable(it) } + disableBitMask?.let { GL11.glEnable(it) } + } +} + +/** + * Runs [block] with standard item lighting enabled. + */ +inline fun withItemGuiLightning(block: () -> Unit) { + RenderHelper.enableGUIStandardItemLighting() + try { + block() + } finally { + RenderHelper.disableStandardItemLighting() + } +} diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/Mc1211UiAdapter.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/Mc1211UiAdapter.kt new file mode 100644 index 0000000..39f2171 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/Mc1211UiAdapter.kt @@ -0,0 +1,1587 @@ +package org.dreamfinity.dsgl.mcNeoforge1211 + +import com.mojang.blaze3d.platform.NativeImage +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.Gui +import net.minecraft.client.renderer.entity.ItemRenderer +import net.minecraft.client.renderer.texture.DynamicTexture +import net.minecraft.resources.ResourceLocation +import net.minecraft.world.item.BlockItem +import net.minecraft.world.item.ItemStack +import org.dreamfinity.dsgl.core.dom.layout.FontLineMetrics +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.font.FontRegistry +import org.dreamfinity.dsgl.core.host.Viewport +import org.dreamfinity.dsgl.core.host.dsglRectToGlScissor +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.mcNeoforge1211.scissorsHelper.ScissorContext +import org.dreamfinity.dsgl.mcNeoforge1211.text.MsdfTextRenderer +import org.lwjgl.BufferUtils +import org.lwjgl.opengl.* +import java.io.File +import java.net.URL + +/** + * Minecraft 1.21.1 adapter that turns DSGL render commands into Minecraft calls. + */ +class Mc1211UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : UiMeasureContext { + private enum class ReadbackApi { + OpenGl30, + ArbFramebufferObject, + ExtFramebufferObject, + Legacy + } + + private data class ReadbackBindingState( + val readFramebufferBinding: Int, + val drawFramebufferBinding: Int, + val framebufferBinding: Int, + val currentReadBuffer: Int + ) { + val usingFramebufferObject: Boolean + get() = readFramebufferBinding != 0 + } + + private data class ReadbackSetup( + val previousReadBuffer: Int, + val appliedReadBuffer: Int, + val shouldRestore: Boolean + ) + + private data class FramebufferBindingSnapshot( + val readFramebufferBinding: Int, + val drawFramebufferBinding: Int, + val framebufferBinding: Int + ) + + private data class SceneTextureSource( + val textureId: Int, + val textureWidth: Int, + val textureHeight: Int + ) + + private data class MagnifierCaptureShader( + val programId: Int, + val sourceTextureUniform: Int, + val sourceOriginUniform: Int, + val sourceSizeUniform: Int, + val viewportSizeUniform: Int, + val sourceTextureSizeUniform: Int, + val fallbackColorUniform: Int + ) + + companion object { + private val imageCache: MutableMap = HashMap() + private val dynamicTexturesCache: MutableMap = HashMap() + private val MAGNIFIER_CAPTURE_VERTEX_SHADER: String = """ + #version 120 + varying vec2 vUv; + void main() { + gl_Position = gl_Vertex; + vUv = gl_MultiTexCoord0.xy; + } + """.trimIndent() + private val MAGNIFIER_CAPTURE_FRAGMENT_SHADER: String = """ + #version 120 + uniform sampler2D uSourceTexture; + uniform vec2 uSourceOriginTopLeft; + uniform vec2 uSourceSize; + uniform vec2 uViewportSize; + uniform vec2 uSourceTextureSize; + uniform vec4 uFallbackColor; + varying vec2 vUv; + void main() { + vec2 dstPixel = floor(vUv * uSourceSize); + float sourceX = uSourceOriginTopLeft.x + dstPixel.x; + float sourceYTop = uSourceOriginTopLeft.y + (uSourceSize.y - 1.0 - dstPixel.y); + bool inside = + sourceX >= 0.0 && + sourceYTop >= 0.0 && + sourceX < uViewportSize.x && + sourceYTop < uViewportSize.y; + if (!inside) { + gl_FragColor = uFallbackColor; + return; + } + float sourceYBottom = (uViewportSize.y - 1.0) - sourceYTop; + vec2 sourceUv = vec2( + (sourceX + 0.5) / uSourceTextureSize.x, + (sourceYBottom + 0.5) / uSourceTextureSize.y + ); + vec4 sampled = texture2D(uSourceTexture, sourceUv); + gl_FragColor = vec4(sampled.rgb, 1.0); + } + """.trimIndent() + } + + private val itemRenderer: ItemRenderer + get() = Minecraft.getInstance().itemRenderer + private val textRenderer: MsdfTextRenderer = MsdfTextRenderer() + private val opacityStack: MutableList = ArrayList(8) + private var opacityMultiplier: Float = 1f + private val errorLogTimes: MutableMap = linkedMapOf() + private val readbackDiagnosticsVerbose: Boolean = java.lang.Boolean.getBoolean("dsgl.readback.diagnostics.verbose") + private val readbackApi: ReadbackApi by lazy(LazyThreadSafetyMode.NONE) { resolveReadbackApi() } + + private val samplePixelBuffer = BufferUtils.createByteBuffer(4) + private var sampleAreaBuffer = BufferUtils.createByteBuffer(4 * 256) + private val glIntStateQueryBuffer = BufferUtils.createIntBuffer(16) + private val glFloatStateQueryBuffer = BufferUtils.createFloatBuffer(16) + + private var capturedRegionTextureId: Int = 0 + private var capturedRegionFramebufferId: Int = 0 + private var capturedRegionWidth: Int = 0 + private var capturedRegionHeight: Int = 0 + private var capturedRegionValid: Boolean = false + private var capturedRegionFallbackColor: Int = 0xFF000000.toInt() + private var magnifierCaptureShader: MagnifierCaptureShader? = null + private var magnifierCaptureShaderInitFailed: Boolean = false + + private val checkerTextureCache: LinkedHashMap = LinkedHashMap(16, 0.75f, true) + private val checkerTextureUploadBuffer = BufferUtils.createByteBuffer(16) + private val maxCheckerTextures: Int = 32 + + private var cachedViewport: Viewport = Viewport(width = 1, height = 1, scale = 1f, x = 0, y = 0) + private var cachedDisplayWidth: Int = -1 + private var cachedDisplayHeight: Int = -1 + + override fun measureText(text: String): Int = textRenderer.measureText(text, null, null) + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int { + return textRenderer.measureText(text, fontId, fontSize) + } + override fun measureTextRange( + text: String, + startIndex: Int, + endIndexExclusive: Int, + fontId: String?, + fontSize: Int? + ): Int { + return textRenderer.measureTextRange(text, startIndex, endIndexExclusive, fontId, fontSize) + } + + override val fontHeight: Int + get() = textRenderer.lineHeight(FontRegistry.DEFAULT_FONT_ID, null) + + override fun fontHeight(fontId: String?, fontSize: Int?): Int { + return textRenderer.lineHeight(fontId, fontSize) + } + + override fun fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics? { + return textRenderer.fontLineMetrics(fontId, fontSize) + } + + fun viewport(): Viewport { + val displayWidth = mc.window.width.coerceAtLeast(1) + val displayHeight = mc.window.height.coerceAtLeast(1) + if (displayWidth != cachedDisplayWidth || displayHeight != cachedDisplayHeight) { + cachedDisplayWidth = displayWidth + cachedDisplayHeight = displayHeight + cachedViewport = Viewport( + width = displayWidth, + height = displayHeight, + scale = 1f, + x = 0, + y = 0 + ) + } + return cachedViewport + } + + fun sampleScreenColor(x: Int, y: Int): Int? { + val viewport = viewport() + if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) return null + val readY = viewport.height - 1 - y + samplePixelBuffer.clear() + return try { + val setup = beginReadback() + if (readbackDiagnosticsVerbose) { + diagnoseReadbackSource( + path = "sampleScreenColor", + sourceX = x, + sourceY = y, + sourceWidth = 1, + sourceHeight = 1, + setup = setup + ) + } + try { + GL11.glReadPixels(x, readY, 1, 1, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, samplePixelBuffer) + } finally { + endReadback(setup) + } + val r = samplePixelBuffer.get(0).toInt() and 0xFF + val g = samplePixelBuffer.get(1).toInt() and 0xFF + val b = samplePixelBuffer.get(2).toInt() and 0xFF + val a = samplePixelBuffer.get(3).toInt() and 0xFF + (a shl 24) or (r shl 16) or (g shl 8) or b + } catch (_: Throwable) { + null + } + } + + fun sampleScreenArea(x: Int, y: Int, width: Int, height: Int, outArgb: IntArray): Boolean { + if (width <= 0 || height <= 0) return false + val required = width * height + if (outArgb.size < required) return false + val viewport = viewport() + var i = 0 + while (i < required) { + outArgb[i] = 0 + i++ + } + + val srcX = x.coerceIn(0, viewport.width) + val srcY = y.coerceIn(0, viewport.height) + val maxW = viewport.width - srcX + val maxH = viewport.height - srcY + val srcW = minOf(width, maxW).coerceAtLeast(0) + val srcH = minOf(height, maxH).coerceAtLeast(0) + if (srcW <= 0 || srcH <= 0) return false + + val byteCount = srcW * srcH * 4 + if (sampleAreaBuffer.capacity() < byteCount) { + sampleAreaBuffer = BufferUtils.createByteBuffer(byteCount) + } + sampleAreaBuffer.clear() + sampleAreaBuffer.limit(byteCount) + return try { + val readY = viewport.height - (srcY + srcH) + val setup = beginReadback() + if (readbackDiagnosticsVerbose) { + diagnoseReadbackSource( + path = "sampleScreenArea", + sourceX = srcX, + sourceY = srcY, + sourceWidth = srcW, + sourceHeight = srcH, + setup = setup + ) + } + try { + GL11.glReadPixels(srcX, readY, srcW, srcH, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, sampleAreaBuffer) + } finally { + endReadback(setup) + } + val dstOffsetX = (srcX - x).coerceAtLeast(0) + val dstOffsetY = (srcY - y).coerceAtLeast(0) + var row = 0 + while (row < srcH) { + val glRow = srcH - 1 - row + var col = 0 + while (col < srcW) { + val srcIndex = (glRow * srcW + col) * 4 + val r = sampleAreaBuffer.get(srcIndex).toInt() and 0xFF + val g = sampleAreaBuffer.get(srcIndex + 1).toInt() and 0xFF + val b = sampleAreaBuffer.get(srcIndex + 2).toInt() and 0xFF + val a = sampleAreaBuffer.get(srcIndex + 3).toInt() and 0xFF + val dstX = dstOffsetX + col + val dstY = dstOffsetY + row + outArgb[dstY * width + dstX] = (a shl 24) or (r shl 16) or (g shl 8) or b + col++ + } + row++ + } + true + } catch (_: Throwable) { + false + } + } + + private fun captureScreenRegion(command: RenderCommand.CaptureScreenRegion, viewport: Viewport) { + val sourceWidth = command.sourceWidth.coerceAtLeast(1) + val sourceHeight = command.sourceHeight.coerceAtLeast(1) + capturedRegionFallbackColor = command.fallbackColor + ensureCapturedRegionTexture(sourceWidth, sourceHeight) + if (capturedRegionTextureId == 0) { + capturedRegionValid = false + return + } + val sceneTextureSource = resolveActiveSceneTextureSource() + val shader = ensureMagnifierCaptureShader() + if (sceneTextureSource == null || shader == null || sceneTextureSource.textureId == capturedRegionTextureId) { + capturedRegionValid = fillCapturedRegionFallbackTexture(command.fallbackColor, sourceWidth, sourceHeight) + if (readbackDiagnosticsVerbose) { + logRateLimited( + key = "magnifier:capture:fallback", + message = "[DSGL-Magnifier] Falling back to solid fill preview. sourceTexture=${sceneTextureSource?.textureId ?: 0} shaderReady=${shader != null}" + ) + } + return + } + val framebufferSnapshot = snapshotFramebufferBindings() + val previousReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + val previousDrawBuffer = GL11.glGetInteger(GL11.GL_DRAW_BUFFER) + val previousTextureBinding = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D) + snapshotViewportState() + val previousViewportX = viewportXFromSnapshot() + val previousViewportY = viewportYFromSnapshot() + val previousViewportWidth = viewportWidthFromSnapshot() + val previousViewportHeight = viewportHeightFromSnapshot() + var renderingSucceeded = false + try { + if (!ensureCapturedRegionFramebuffer()) { + return + } + bindDrawFramebuffer(capturedRegionFramebufferId) + attachCapturedRegionTextureToFramebuffer() + if (!isCurrentFramebufferComplete()) { + return + } + val attachment = defaultColorAttachmentReadBuffer() + GL11.glDrawBuffer(attachment) + GL11.glReadBuffer(attachment) + GL11.glViewport(0, 0, sourceWidth, sourceHeight) + GL11.glDisable(GL11.GL_SCISSOR_TEST) + GL11.glDisable(GL11.GL_BLEND) + GL11.glDisable(GL11.GL_CULL_FACE) + GL11.glDisable(GL11.GL_DEPTH_TEST) + GL11.glEnable(GL11.GL_TEXTURE_2D) + ARBShaderObjects.glUseProgramObjectARB(shader.programId) + GL13.glActiveTexture(GL13.GL_TEXTURE0) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, sceneTextureSource.textureId) + ARBShaderObjects.glUniform1iARB(shader.sourceTextureUniform, 0) + ARBShaderObjects.glUniform2fARB( + shader.sourceOriginUniform, + command.sourceX.toFloat(), + command.sourceY.toFloat() + ) + ARBShaderObjects.glUniform2fARB(shader.sourceSizeUniform, sourceWidth.toFloat(), sourceHeight.toFloat()) + ARBShaderObjects.glUniform2fARB( + shader.viewportSizeUniform, + viewport.width.toFloat(), + viewport.height.toFloat() + ) + ARBShaderObjects.glUniform2fARB( + shader.sourceTextureSizeUniform, + sceneTextureSource.textureWidth.toFloat(), + sceneTextureSource.textureHeight.toFloat() + ) + val fallbackAlpha = ((command.fallbackColor ushr 24) and 0xFF) / 255f + val fallbackRed = ((command.fallbackColor ushr 16) and 0xFF) / 255f + val fallbackGreen = ((command.fallbackColor ushr 8) and 0xFF) / 255f + val fallbackBlue = (command.fallbackColor and 0xFF) / 255f + ARBShaderObjects.glUniform4fARB( + shader.fallbackColorUniform, + fallbackRed, + fallbackGreen, + fallbackBlue, + fallbackAlpha + ) + GL11.glColor4f(1f, 1f, 1f, 1f) + GL11.glBegin(GL11.GL_QUADS) + GL11.glTexCoord2f(0f, 0f) + GL11.glVertex2f(-1f, -1f) + GL11.glTexCoord2f(1f, 0f) + GL11.glVertex2f(1f, -1f) + GL11.glTexCoord2f(1f, 1f) + GL11.glVertex2f(1f, 1f) + GL11.glTexCoord2f(0f, 1f) + GL11.glVertex2f(-1f, 1f) + GL11.glEnd() + renderingSucceeded = true + } catch (error: Throwable) { + if (readbackDiagnosticsVerbose) { + logRateLimited( + key = "magnifier:capture:error", + message = "[DSGL-Magnifier] GPU capture failed: ${error.message ?: error::class.java.simpleName}" + ) + } + } finally { + ARBShaderObjects.glUseProgramObjectARB(0) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, previousTextureBinding) + GL11.glReadBuffer(previousReadBuffer) + GL11.glDrawBuffer(previousDrawBuffer) + restoreFramebufferBindings(framebufferSnapshot) + GL11.glViewport( + previousViewportX, + previousViewportY, + previousViewportWidth, + previousViewportHeight + ) + } + capturedRegionValid = + renderingSucceeded || fillCapturedRegionFallbackTexture(command.fallbackColor, sourceWidth, sourceHeight) + } + + private fun drawCapturedScreenRegion(command: RenderCommand.DrawCapturedScreenRegion) { + if (command.width <= 0 || command.height <= 0) return + if (!capturedRegionValid || capturedRegionTextureId == 0) { + Gui.drawRect( + command.x, + command.y, + command.x + command.width, + command.y + command.height, + applyOpacity(capturedRegionFallbackColor) + ) + return + } + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glDisable(GL11.GL_CULL_FACE) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, capturedRegionTextureId) + GL11.glColor4f(1f, 1f, 1f, opacityMultiplier.coerceIn(0f, 1f)) + GL11.glBegin(GL11.GL_QUADS) + GL11.glTexCoord2f(0f, 1f) + GL11.glVertex2f(command.x.toFloat(), command.y.toFloat()) + GL11.glTexCoord2f(1f, 1f) + GL11.glVertex2f((command.x + command.width).toFloat(), command.y.toFloat()) + GL11.glTexCoord2f(1f, 0f) + GL11.glVertex2f((command.x + command.width).toFloat(), (command.y + command.height).toFloat()) + GL11.glTexCoord2f(0f, 0f) + GL11.glVertex2f(command.x.toFloat(), (command.y + command.height).toFloat()) + GL11.glEnd() + } + + private fun drawCheckerboard(command: RenderCommand.DrawCheckerboard) { + if (command.width <= 0 || command.height <= 0) return + val cellSize = command.cellSize.coerceAtLeast(1) + val textureId = resolveCheckerTextureId(command.lightColor, command.darkColor) + if (textureId == 0) { + Gui.drawRect( + command.x, + command.y, + command.x + command.width, + command.y + command.height, + applyOpacity(command.lightColor) + ) + return + } + val patternSize = (cellSize * 2f).coerceAtLeast(1f) + val u0 = (command.x + command.offsetX) / patternSize + val v0 = (command.y + command.offsetY) / patternSize + val u1 = u0 + (command.width / patternSize) + val v1 = v0 + (command.height / patternSize) + + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glDisable(GL11.GL_CULL_FACE) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId) + GL11.glColor4f(1f, 1f, 1f, opacityMultiplier.coerceIn(0f, 1f)) + GL11.glBegin(GL11.GL_QUADS) + GL11.glTexCoord2f(u0, v0) + GL11.glVertex2f(command.x.toFloat(), command.y.toFloat()) + GL11.glTexCoord2f(u1, v0) + GL11.glVertex2f((command.x + command.width).toFloat(), command.y.toFloat()) + GL11.glTexCoord2f(u1, v1) + GL11.glVertex2f((command.x + command.width).toFloat(), (command.y + command.height).toFloat()) + GL11.glTexCoord2f(u0, v1) + GL11.glVertex2f(command.x.toFloat(), (command.y + command.height).toFloat()) + GL11.glEnd() + } + + private fun resolveCheckerTextureId(lightColor: Int, darkColor: Int): Int { + val key = checkerTextureKey(lightColor, darkColor) + checkerTextureCache[key]?.let { return it } + + val textureId = GL11.glGenTextures() + if (textureId == 0) return 0 + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_REPEAT) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_REPEAT) + + val light = argbToRgbaBytes(lightColor) + val dark = argbToRgbaBytes(darkColor) + checkerTextureUploadBuffer.clear() + checkerTextureUploadBuffer.put(light[0]).put(light[1]).put(light[2]).put(light[3]) + checkerTextureUploadBuffer.put(dark[0]).put(dark[1]).put(dark[2]).put(dark[3]) + checkerTextureUploadBuffer.put(dark[0]).put(dark[1]).put(dark[2]).put(dark[3]) + checkerTextureUploadBuffer.put(light[0]).put(light[1]).put(light[2]).put(light[3]) + checkerTextureUploadBuffer.flip() + + GL11.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 1) + GL11.glTexImage2D( + GL11.GL_TEXTURE_2D, + 0, + GL11.GL_RGBA, + 2, + 2, + 0, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + checkerTextureUploadBuffer + ) + + checkerTextureCache[key] = textureId + while (checkerTextureCache.size > maxCheckerTextures) { + val eldest = checkerTextureCache.entries.iterator().next() + GL11.glDeleteTextures(eldest.value) + checkerTextureCache.remove(eldest.key) + } + return textureId + } + + private fun checkerTextureKey(lightColor: Int, darkColor: Int): Long { + return (lightColor.toLong() shl 32) xor (darkColor.toLong() and 0xFFFF_FFFFL) + } + + private fun ensureCapturedRegionTexture(width: Int, height: Int) { + if (capturedRegionTextureId == 0) { + capturedRegionTextureId = GL11.glGenTextures() + if (capturedRegionTextureId == 0) return + GL11.glBindTexture(GL11.GL_TEXTURE_2D, capturedRegionTextureId) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_CLAMP) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_CLAMP) + capturedRegionWidth = 0 + capturedRegionHeight = 0 + } + if (capturedRegionWidth == width && capturedRegionHeight == height) return + capturedRegionWidth = width + capturedRegionHeight = height + GL11.glBindTexture(GL11.GL_TEXTURE_2D, capturedRegionTextureId) + GL11.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 1) + GL11.glTexImage2D( + GL11.GL_TEXTURE_2D, + 0, + GL11.GL_RGBA, + capturedRegionWidth, + capturedRegionHeight, + 0, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + null as java.nio.ByteBuffer? + ) + } + + private fun ensureCapturedRegionFramebuffer(): Boolean { + if (capturedRegionFramebufferId != 0) return true + capturedRegionFramebufferId = generateFramebufferObject() + return capturedRegionFramebufferId != 0 + } + + private fun resolveActiveSceneTextureSource(): SceneTextureSource? { + val state = detectReadbackBindingState() + if (!state.usingFramebufferObject) return null + val colorAttachment = if (isColorAttachmentReadBuffer(state.currentReadBuffer)) { + state.currentReadBuffer + } else { + defaultColorAttachmentReadBuffer() + } + val objectType = getFramebufferAttachmentObjectType(colorAttachment) + if (objectType != GL11.GL_TEXTURE) return null + val textureId = getFramebufferAttachmentObjectName(colorAttachment) + if (textureId <= 0) return null + val previousTextureBinding = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D) + return try { + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId) + val textureWidth = GL11.glGetTexLevelParameteri(GL11.GL_TEXTURE_2D, 0, GL11.GL_TEXTURE_WIDTH) + val textureHeight = GL11.glGetTexLevelParameteri(GL11.GL_TEXTURE_2D, 0, GL11.GL_TEXTURE_HEIGHT) + if (textureWidth <= 0 || textureHeight <= 0) return null + SceneTextureSource(textureId = textureId, textureWidth = textureWidth, textureHeight = textureHeight) + } finally { + GL11.glBindTexture(GL11.GL_TEXTURE_2D, previousTextureBinding) + } + } + + private fun getFramebufferAttachmentObjectType(colorAttachment: Int): Int { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL30.glGetFramebufferAttachmentParameteri( + GL30.GL_READ_FRAMEBUFFER, + colorAttachment, + GL30.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE + ) + + ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glGetFramebufferAttachmentParameteri( + ARBFramebufferObject.GL_READ_FRAMEBUFFER, + colorAttachment, + ARBFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE + ) + + ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glGetFramebufferAttachmentParameteriEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + colorAttachment, + EXTFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE_EXT + ) + + ReadbackApi.Legacy -> GL11.GL_NONE + } + } + + private fun getFramebufferAttachmentObjectName(colorAttachment: Int): Int { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL30.glGetFramebufferAttachmentParameteri( + GL30.GL_READ_FRAMEBUFFER, + colorAttachment, + GL30.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME + ) + + ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glGetFramebufferAttachmentParameteri( + ARBFramebufferObject.GL_READ_FRAMEBUFFER, + colorAttachment, + ARBFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME + ) + + ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glGetFramebufferAttachmentParameteriEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + colorAttachment, + EXTFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME_EXT + ) + + ReadbackApi.Legacy -> 0 + } + } + + private fun ensureMagnifierCaptureShader(): MagnifierCaptureShader? { + magnifierCaptureShader?.let { return it } + if (magnifierCaptureShaderInitFailed) return null + return try { + val vertexShader = compileShaderObject( + type = ARBVertexShader.GL_VERTEX_SHADER_ARB, + source = MAGNIFIER_CAPTURE_VERTEX_SHADER + ) + val fragmentShader = compileShaderObject( + type = ARBFragmentShader.GL_FRAGMENT_SHADER_ARB, + source = MAGNIFIER_CAPTURE_FRAGMENT_SHADER + ) + val program = ARBShaderObjects.glCreateProgramObjectARB() + ARBShaderObjects.glAttachObjectARB(program, vertexShader) + ARBShaderObjects.glAttachObjectARB(program, fragmentShader) + ARBShaderObjects.glLinkProgramARB(program) + val linkStatus = ARBShaderObjects.glGetObjectParameteriARB( + program, + ARBShaderObjects.GL_OBJECT_LINK_STATUS_ARB + ) + if (linkStatus == GL11.GL_FALSE) { + val info = ARBShaderObjects.glGetInfoLogARB(program, 4096) + throw IllegalStateException("Magnifier shader link failed: $info") + } + val shader = MagnifierCaptureShader( + programId = program, + sourceTextureUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceTexture"), + sourceOriginUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceOriginTopLeft"), + sourceSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceSize"), + viewportSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uViewportSize"), + sourceTextureSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceTextureSize"), + fallbackColorUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uFallbackColor") + ) + magnifierCaptureShader = shader + shader + } catch (error: Throwable) { + magnifierCaptureShaderInitFailed = true + if (readbackDiagnosticsVerbose) { + logRateLimited( + key = "magnifier:shader:init", + message = "[DSGL-Magnifier] Failed to initialize capture shader: ${error.message ?: error::class.java.simpleName}" + ) + } + null + } + } + + private fun compileShaderObject(type: Int, source: String): Int { + val shader = ARBShaderObjects.glCreateShaderObjectARB(type) + ARBShaderObjects.glShaderSourceARB(shader, source) + ARBShaderObjects.glCompileShaderARB(shader) + val compileStatus = ARBShaderObjects.glGetObjectParameteriARB( + shader, + ARBShaderObjects.GL_OBJECT_COMPILE_STATUS_ARB + ) + if (compileStatus == GL11.GL_FALSE) { + val info = ARBShaderObjects.glGetInfoLogARB(shader, 4096) + throw IllegalStateException("Magnifier shader compile failed: $info") + } + return shader + } + + private fun generateFramebufferObject(): Int { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL30.glGenFramebuffers() + ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glGenFramebuffers() + ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glGenFramebuffersEXT() + ReadbackApi.Legacy -> 0 + } + } + + private fun snapshotFramebufferBindings(): FramebufferBindingSnapshot { + return FramebufferBindingSnapshot( + readFramebufferBinding = currentReadFramebufferBinding(), + drawFramebufferBinding = currentDrawFramebufferBinding(), + framebufferBinding = currentFramebufferBinding() + ) + } + + private fun restoreFramebufferBindings(snapshot: FramebufferBindingSnapshot) { + when (readbackApi) { + ReadbackApi.OpenGl30 -> { + GL30.glBindFramebuffer(GL30.GL_READ_FRAMEBUFFER, snapshot.readFramebufferBinding) + GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, snapshot.drawFramebufferBinding) + } + + ReadbackApi.ArbFramebufferObject -> { + ARBFramebufferObject.glBindFramebuffer( + ARBFramebufferObject.GL_READ_FRAMEBUFFER, + snapshot.readFramebufferBinding + ) + ARBFramebufferObject.glBindFramebuffer( + ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, + snapshot.drawFramebufferBinding + ) + } + + ReadbackApi.ExtFramebufferObject -> { + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + snapshot.framebufferBinding + ) + } + + ReadbackApi.Legacy -> Unit + } + } + + private fun bindDrawFramebuffer(framebufferId: Int) { + when (readbackApi) { + ReadbackApi.OpenGl30 -> GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, framebufferId) + ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glBindFramebuffer( + ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, + framebufferId + ) + + ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + framebufferId + ) + + ReadbackApi.Legacy -> Unit + } + } + + private fun attachCapturedRegionTextureToFramebuffer() { + when (readbackApi) { + ReadbackApi.OpenGl30 -> GL30.glFramebufferTexture2D( + GL30.GL_DRAW_FRAMEBUFFER, + GL30.GL_COLOR_ATTACHMENT0, + GL11.GL_TEXTURE_2D, + capturedRegionTextureId, + 0 + ) + + ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glFramebufferTexture2D( + ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, + ARBFramebufferObject.GL_COLOR_ATTACHMENT0, + GL11.GL_TEXTURE_2D, + capturedRegionTextureId, + 0 + ) + + ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glFramebufferTexture2DEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT, + GL11.GL_TEXTURE_2D, + capturedRegionTextureId, + 0 + ) + + ReadbackApi.Legacy -> Unit + } + } + + private fun isCurrentFramebufferComplete(): Boolean { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL30.glCheckFramebufferStatus(GL30.GL_DRAW_FRAMEBUFFER) == GL30.GL_FRAMEBUFFER_COMPLETE + ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glCheckFramebufferStatus( + ARBFramebufferObject.GL_DRAW_FRAMEBUFFER + ) == ARBFramebufferObject.GL_FRAMEBUFFER_COMPLETE + + ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glCheckFramebufferStatusEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT + ) == EXTFramebufferObject.GL_FRAMEBUFFER_COMPLETE_EXT + + ReadbackApi.Legacy -> false + } + } + + private fun fillCapturedRegionFallbackTexture( + fallbackColor: Int, + width: Int, + height: Int + ): Boolean { + val snapshot = snapshotFramebufferBindings() + val previousReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + val previousDrawBuffer = GL11.glGetInteger(GL11.GL_DRAW_BUFFER) + snapshotViewportState() + val previousViewportX = viewportXFromSnapshot() + val previousViewportY = viewportYFromSnapshot() + val previousViewportWidth = viewportWidthFromSnapshot() + val previousViewportHeight = viewportHeightFromSnapshot() + snapshotClearColorState() + val previousClearRed = clearRedFromSnapshot() + val previousClearGreen = clearGreenFromSnapshot() + val previousClearBlue = clearBlueFromSnapshot() + val previousClearAlpha = clearAlphaFromSnapshot() + return try { + if (!ensureCapturedRegionFramebuffer()) return false + bindDrawFramebuffer(capturedRegionFramebufferId) + attachCapturedRegionTextureToFramebuffer() + if (!isCurrentFramebufferComplete()) return false + val attachment = defaultColorAttachmentReadBuffer() + GL11.glDrawBuffer(attachment) + GL11.glViewport(0, 0, width, height) + val alpha = ((fallbackColor ushr 24) and 0xFF) / 255f + val red = ((fallbackColor ushr 16) and 0xFF) / 255f + val green = ((fallbackColor ushr 8) and 0xFF) / 255f + val blue = (fallbackColor and 0xFF) / 255f + GL11.glClearColor(red, green, blue, alpha) + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT) + true + } catch (_: Throwable) { + false + } finally { + GL11.glClearColor( + previousClearRed, + previousClearGreen, + previousClearBlue, + previousClearAlpha + ) + GL11.glReadBuffer(previousReadBuffer) + GL11.glDrawBuffer(previousDrawBuffer) + restoreFramebufferBindings(snapshot) + GL11.glViewport( + previousViewportX, + previousViewportY, + previousViewportWidth, + previousViewportHeight + ) + } + } + + private fun snapshotViewportState() { + glIntStateQueryBuffer.clear() + GL11.glGetIntegerv(GL11.GL_VIEWPORT, glIntStateQueryBuffer) + } + + private fun viewportXFromSnapshot(): Int = glIntStateQueryBuffer.get(0) + private fun viewportYFromSnapshot(): Int = glIntStateQueryBuffer.get(1) + private fun viewportWidthFromSnapshot(): Int = glIntStateQueryBuffer.get(2) + private fun viewportHeightFromSnapshot(): Int = glIntStateQueryBuffer.get(3) + + private fun snapshotClearColorState() { + glFloatStateQueryBuffer.clear() + GL11.glGetFloatv(GL11.GL_COLOR_CLEAR_VALUE, glFloatStateQueryBuffer) + } + + private fun clearRedFromSnapshot(): Float = glFloatStateQueryBuffer.get(0) + private fun clearGreenFromSnapshot(): Float = glFloatStateQueryBuffer.get(1) + private fun clearBlueFromSnapshot(): Float = glFloatStateQueryBuffer.get(2) + private fun clearAlphaFromSnapshot(): Float = glFloatStateQueryBuffer.get(3) + + private fun argbToRgbaBytes(argb: Int, forceOpaqueAlpha: Boolean = false): ByteArray { + val r = ((argb ushr 16) and 0xFF).toByte() + val g = ((argb ushr 8) and 0xFF).toByte() + val b = (argb and 0xFF).toByte() + val a = if (forceOpaqueAlpha) 0xFF.toByte() else ((argb ushr 24) and 0xFF).toByte() + return byteArrayOf(r, g, b, a) + } + + // TODO NeoForge-1.21 blit (methods like drawRect) require GuiGraphics argument to be passed from host render method + // ItemStack rendering methods require PoseStack and BufferSource + /** Executes DSGL render commands using Minecraft rendering APIs. */ + override fun paint(commands: List) { + paintsCount++ + opacityStack.clear() + opacityMultiplier = 1f + val transformStack = RenderCommandTransformStack() + transformStack.reset() + val viewport = viewport() + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS) + try { + ScissorContext.clear() + GL11.glDisable(GL11.GL_SCISSOR_TEST) + GL11.glViewport(viewport.x, viewport.y, viewport.width, viewport.height) + GL11.glMatrixMode(GL11.GL_PROJECTION) + GL11.glPushMatrix() + GL11.glLoadIdentity() + GL11.glOrtho(0.0, viewport.width.toDouble(), viewport.height.toDouble(), 0.0, -1000.0, 1000.0) + GL11.glMatrixMode(GL11.GL_MODELVIEW) + GL11.glPushMatrix() + GL11.glLoadIdentity() + GL11.glAlphaFunc(GL11.GL_GREATER, 0.0f) + try { + for (command in commands) { + when (command) { + is RenderCommand.DrawRect -> { + Gui.drawRect( + command.x, + command.y, + command.x + command.width, + command.y + command.height, + applyOpacity(command.color) + ) + } + + is RenderCommand.DrawColorField -> { + drawColorField( + x = command.x, + y = command.y, + width = command.width, + height = command.height, + hueDeg = command.hueDeg + ) + } + + is RenderCommand.DrawHueBar -> { + drawHueBar( + x = command.x, + y = command.y, + width = command.width, + height = command.height + ) + } + + is RenderCommand.DrawAlphaBar -> { + drawAlphaBar( + x = command.x, + y = command.y, + width = command.width, + height = command.height, + rgbColor = command.rgbColor + ) + } + + is RenderCommand.DrawCheckerboard -> { + drawCheckerboard(command) + } + + is RenderCommand.DrawText -> { + try { + textRenderer.draw( + command = command, + opacityMultiplier = opacityMultiplier + ) + } catch (error: LinkageError) { + logRateLimited( + key = "drawText:linkage", + message = "[DSGL] Skipping DrawText due linkage error in text renderer: ${error.message}" + ) + } catch (error: Throwable) { + logRateLimited( + key = "drawText:runtime", + message = "[DSGL] Skipping DrawText due renderer error: ${error.message}" + ) + } + } + + is RenderCommand.DrawImage -> { + val location = resolveImage(command.resource) ?: continue + mc.textureManager.getTexture(location).bind() + GL11.glColor4f(1f, 1f, 1f, opacityMultiplier.coerceIn(0f, 1f)) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + Gui.drawModalRectWithCustomSizedTexture( + command.x, + command.y, + 0f, + 0f, + command.width, + command.height, + command.width.toFloat(), + command.height.toFloat() + ) + } + + is RenderCommand.CaptureScreenRegion -> { + captureScreenRegion(command, viewport) + } + + is RenderCommand.DrawCapturedScreenRegion -> { + drawCapturedScreenRegion(command) + } + + is RenderCommand.DrawItemStack -> { + val stack = (command.stack as? McItemStackRef)?.stack ?: continue + drawItemStack( + stack = stack, + x = command.x, + y = command.y, + size = command.size, + width = command.width, + rotY = command.rotYDeg, + rotX = command.rotXDeg + ) + } + + is RenderCommand.PushClip -> { + val transformedClip = transformStack.resolveClipRect( + x = command.x, + y = command.y, + width = command.width, + height = command.height + ) + pushClip( + viewport = viewport, + guiX = transformedClip.x, + guiY = transformedClip.y, + guiWidth = transformedClip.width, + guiHeight = transformedClip.height + ) + } + + is RenderCommand.PopClip -> { + ScissorContext.pop() + } + + is RenderCommand.PushTransform -> { + transformStack.push(command) + GL11.glPushMatrix() + GL11.glTranslatef(command.originX, command.originY, 0f) + GL11.glTranslatef(command.translateX, command.translateY, 0f) + GL11.glRotatef(command.rotateDeg, 0f, 0f, 1f) + GL11.glScalef(command.scaleX, command.scaleY, 1f) + GL11.glTranslatef(-command.originX, -command.originY, 0f) + } + + is RenderCommand.PopTransform -> { + transformStack.pop() + GL11.glPopMatrix() + } + + is RenderCommand.PushOpacity -> { + opacityStack.add(opacityMultiplier) + opacityMultiplier = (opacityMultiplier * command.opacity).coerceIn(0f, 1f) + } + + is RenderCommand.PopOpacity -> { + opacityMultiplier = + if (opacityStack.isEmpty()) 1f else opacityStack.removeAt(opacityStack.lastIndex) + } + } + } + } finally { + GL11.glAlphaFunc(GL11.GL_GREATER, 0.1f) + GL11.glMatrixMode(GL11.GL_MODELVIEW) + GL11.glPopMatrix() + GL11.glMatrixMode(GL11.GL_PROJECTION) + GL11.glPopMatrix() + GL11.glMatrixMode(GL11.GL_MODELVIEW) + } + } finally { + ScissorContext.clear() + transformStack.reset() + opacityStack.clear() + opacityMultiplier = 1f + GL11.glPopAttrib() + } + } + + private fun applyOpacity(color: Int): Int { + if (opacityMultiplier >= 0.999f) return color + val alpha = ((color ushr 24) and 0xFF) + val scaled = (alpha * opacityMultiplier).toInt().coerceIn(0, 255) + return (color and 0x00FF_FFFF) or (scaled shl 24) + } + + private fun drawColorField(x: Int, y: Int, width: Int, height: Int, hueDeg: Float) { + if (width <= 0 || height <= 0) return + + val normalizedHue = ((hueDeg % 360f) + 360f) % 360f + val hueColor = (hsvToArgbInt(normalizedHue, 1f, 1f) and 0x00FF_FFFF) or (0xFF shl 24) + + drawGradientBlock { + drawHorizontalGradientRectRaw( + x, y, width, height, + applyOpacity(0xFFFFFFFF.toInt()), + applyOpacity(hueColor) + ) + drawVerticalGradientRectRaw( + x, y, width, height, + applyOpacity(0x00000000), + applyOpacity(0xFF000000.toInt()) + ) + } + } + + private fun drawHueBar(x: Int, y: Int, width: Int, height: Int) { + if (width <= 0 || height <= 0) return + val segments = 6 + val hueStops = floatArrayOf(0f, 60f, 120f, 180f, 240f, 300f, 360f) + var index = 0 + while (index < segments) { + val startX = x + (width * index) / segments + val endX = if (index == segments - 1) x + width else x + (width * (index + 1)) / segments + val segmentWidth = (endX - startX).coerceAtLeast(1) + val startColor = applyOpacity(hsvToArgbInt(hueStops[index], 1f, 1f)) + val endColor = applyOpacity(hsvToArgbInt(hueStops[index + 1], 1f, 1f)) + drawHorizontalGradientRect(startX, y, segmentWidth, height, startColor, endColor) + index += 1 + } + } + + private fun drawAlphaBar(x: Int, y: Int, width: Int, height: Int, rgbColor: Int) { + if (width <= 0 || height <= 0) return + val rgbOnly = rgbColor and 0x00FF_FFFF + val leftColor = applyOpacity(rgbOnly) + val rightColor = applyOpacity(rgbOnly or (0xFF shl 24)) + drawHorizontalGradientRect(x, y, width, height, leftColor, rightColor) + } + + private fun drawHorizontalGradientRect(x: Int, y: Int, width: Int, height: Int, leftColor: Int, rightColor: Int) { + if (width <= 0 || height <= 0) return + GL11.glDisable(GL11.GL_TEXTURE_2D) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glShadeModel(GL11.GL_SMOOTH) + drawHorizontalGradientRectRaw(x, y, width, height, leftColor, rightColor) + GL11.glShadeModel(GL11.GL_FLAT) + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glColor4f(1f, 1f, 1f, 1f) + } + + private fun drawVerticalGradientRect(x: Int, y: Int, width: Int, height: Int, topColor: Int, bottomColor: Int) { + if (width <= 0 || height <= 0) return + GL11.glDisable(GL11.GL_TEXTURE_2D) + GL11.glDisable(GL11.GL_ALPHA) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glShadeModel(GL11.GL_SMOOTH) + drawVerticalGradientRectRaw(x, y, width, height, topColor, bottomColor) + GL11.glEnable(GL11.GL_ALPHA) + GL11.glShadeModel(GL11.GL_FLAT) + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glColor4f(1f, 1f, 1f, 1f) + } + + private inline fun drawGradientBlock(block: () -> Unit) { + GL11.glDisable(GL11.GL_TEXTURE_2D) + GL11.glDisable(GL11.GL_DEPTH_TEST) + GL11.glDepthMask(false) + GL11.glDisable(GL11.GL_ALPHA_TEST) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glShadeModel(GL11.GL_SMOOTH) + + block() + + GL11.glShadeModel(GL11.GL_FLAT) + GL11.glDisable(GL11.GL_BLEND) + GL11.glEnable(GL11.GL_ALPHA_TEST) + GL11.glDepthMask(true) + GL11.glEnable(GL11.GL_DEPTH_TEST) + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glColor4f(1f, 1f, 1f, 1f) + } + + private fun drawHorizontalGradientRectRaw( + x: Int, + y: Int, + width: Int, + height: Int, + leftColor: Int, + rightColor: Int + ) { + GL11.glBegin(GL11.GL_QUADS) + glColor(leftColor) + GL11.glVertex2f(x.toFloat(), y.toFloat()) + GL11.glVertex2f(x.toFloat(), (y + height).toFloat()) + glColor(rightColor) + GL11.glVertex2f((x + width).toFloat(), (y + height).toFloat()) + GL11.glVertex2f((x + width).toFloat(), y.toFloat()) + GL11.glEnd() + } + + private fun drawVerticalGradientRectRaw( + x: Int, + y: Int, + width: Int, + height: Int, + topColor: Int, + bottomColor: Int + ) { + GL11.glBegin(GL11.GL_QUADS) + glColor(topColor) + GL11.glVertex2f((x + width).toFloat(), y.toFloat()) + GL11.glVertex2f(x.toFloat(), y.toFloat()) + glColor(bottomColor) + GL11.glVertex2f(x.toFloat(), (y + height).toFloat()) + GL11.glVertex2f((x + width).toFloat(), (y + height).toFloat()) + GL11.glEnd() + } + + private fun drawBilinearGradientRect( + x: Int, + y: Int, + width: Int, + height: Int, + topLeftColor: Int, + topRightColor: Int, + bottomRightColor: Int, + bottomLeftColor: Int + ) { + if (width <= 0 || height <= 0) return + GL11.glDisable(GL11.GL_TEXTURE_2D) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glShadeModel(GL11.GL_SMOOTH) + GL11.glBegin(GL11.GL_QUADS) + glColor(topLeftColor) + GL11.glVertex2f(x.toFloat(), y.toFloat()) + glColor(bottomLeftColor) + GL11.glVertex2f(x.toFloat(), (y + height).toFloat()) + glColor(bottomRightColor) + GL11.glVertex2f((x + width).toFloat(), (y + height).toFloat()) + glColor(topRightColor) + GL11.glVertex2f((x + width).toFloat(), y.toFloat()) + GL11.glEnd() + GL11.glShadeModel(GL11.GL_FLAT) + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glColor4f(1f, 1f, 1f, 1f) + } + + private fun glColor(argb: Int) { + val a = ((argb ushr 24) and 0xFF) / 255f + val r = ((argb ushr 16) and 0xFF) / 255f + val g = ((argb ushr 8) and 0xFF) / 255f + val b = (argb and 0xFF) / 255f + GL11.glColor4f(r, g, b, a) + } + + private fun hsvToArgbInt(hueDeg: Float, saturation: Float, value: Float): Int { + val h = ((hueDeg % 360f) + 360f) % 360f + val s = saturation.coerceIn(0f, 1f) + val v = value.coerceIn(0f, 1f) + val c = v * s + val x = c * (1f - kotlin.math.abs((h / 60f) % 2f - 1f)) + val m = v - c + val (r1, g1, b1) = when { + h < 60f -> Triple(c, x, 0f) + h < 120f -> Triple(x, c, 0f) + h < 180f -> Triple(0f, c, x) + h < 240f -> Triple(0f, x, c) + h < 300f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + val r = ((r1 + m) * 255f).toInt().coerceIn(0, 255) + val g = ((g1 + m) * 255f).toInt().coerceIn(0, 255) + val b = ((b1 + m) * 255f).toInt().coerceIn(0, 255) + return (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + private fun beginReadback(): ReadbackSetup { + val previousReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + val desiredReadBuffer = selectReadBufferForActiveTarget(previousReadBuffer) + if (desiredReadBuffer == previousReadBuffer) { + return ReadbackSetup( + previousReadBuffer = previousReadBuffer, + appliedReadBuffer = desiredReadBuffer, + shouldRestore = false + ) + } + GL11.glReadBuffer(desiredReadBuffer) + return ReadbackSetup( + previousReadBuffer = previousReadBuffer, + appliedReadBuffer = desiredReadBuffer, + shouldRestore = true + ) + } + + private fun endReadback(setup: ReadbackSetup) { + if (!setup.shouldRestore) return + if (setup.previousReadBuffer == setup.appliedReadBuffer) return + GL11.glReadBuffer(setup.previousReadBuffer) + } + + private fun selectReadBufferForActiveTarget(currentReadBuffer: Int): Int { + val readFramebufferBinding = currentReadFramebufferBinding() + if (readFramebufferBinding == 0) { + return GL11.GL_BACK + } + if (isColorAttachmentReadBuffer(currentReadBuffer)) { + return currentReadBuffer + } + return defaultColorAttachmentReadBuffer() + } + + private fun resolveReadbackApi(): ReadbackApi { + val caps = GLContext.getCapabilities() + return when { + caps.OpenGL30 -> ReadbackApi.OpenGl30 + caps.GL_ARB_framebuffer_object -> ReadbackApi.ArbFramebufferObject + caps.GL_EXT_framebuffer_object -> ReadbackApi.ExtFramebufferObject + else -> ReadbackApi.Legacy + } + } + + private fun currentReadFramebufferBinding(): Int { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL11.glGetInteger(GL30.GL_READ_FRAMEBUFFER_BINDING) + ReadbackApi.ArbFramebufferObject -> GL11.glGetInteger(ARBFramebufferObject.GL_READ_FRAMEBUFFER_BINDING) + ReadbackApi.ExtFramebufferObject -> GL11.glGetInteger(EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT) + ReadbackApi.Legacy -> 0 + } + } + + private fun currentDrawFramebufferBinding(): Int { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL11.glGetInteger(GL30.GL_DRAW_FRAMEBUFFER_BINDING) + ReadbackApi.ArbFramebufferObject -> GL11.glGetInteger(ARBFramebufferObject.GL_DRAW_FRAMEBUFFER_BINDING) + ReadbackApi.ExtFramebufferObject -> GL11.glGetInteger(EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT) + ReadbackApi.Legacy -> 0 + } + } + + private fun currentFramebufferBinding(): Int { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL11.glGetInteger(GL30.GL_FRAMEBUFFER_BINDING) + ReadbackApi.ArbFramebufferObject -> GL11.glGetInteger(ARBFramebufferObject.GL_FRAMEBUFFER_BINDING) + ReadbackApi.ExtFramebufferObject -> GL11.glGetInteger(EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT) + ReadbackApi.Legacy -> 0 + } + } + + private fun defaultColorAttachmentReadBuffer(): Int { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL30.GL_COLOR_ATTACHMENT0 + ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.GL_COLOR_ATTACHMENT0 + ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT + ReadbackApi.Legacy -> GL11.GL_BACK + } + } + + private fun detectReadbackBindingState(): ReadbackBindingState { + val readFramebufferBinding = currentReadFramebufferBinding() + return ReadbackBindingState( + readFramebufferBinding = readFramebufferBinding, + drawFramebufferBinding = currentDrawFramebufferBinding(), + framebufferBinding = currentFramebufferBinding(), + currentReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + ) + } + + private fun diagnoseReadbackSource( + path: String, + sourceX: Int, + sourceY: Int, + sourceWidth: Int, + sourceHeight: Int, + setup: ReadbackSetup + ) { + val state = detectReadbackBindingState() + val recommended = selectReadBufferForActiveTarget(state.currentReadBuffer) + val appliedCompatible = isReadBufferCompatibleWithActiveTarget(setup.appliedReadBuffer, state) + val previousCompatible = isReadBufferCompatibleWithActiveTarget(setup.previousReadBuffer, state) + val message = buildString { + append("[DSGL-Readback] path=").append(path) + append(" src=(").append(sourceX).append(',').append(sourceY).append(' ') + append(sourceWidth).append('x').append(sourceHeight).append(')') + append(" readFbo=").append(state.readFramebufferBinding) + append(" drawFbo=").append(state.drawFramebufferBinding) + append(" fbo=").append(state.framebufferBinding) + append(" previousReadBuffer=").append(glEnumName(setup.previousReadBuffer)) + append(" appliedReadBuffer=").append(glEnumName(setup.appliedReadBuffer)) + append(" currentReadBuffer=").append(glEnumName(state.currentReadBuffer)) + append(" changed=").append(setup.shouldRestore) + append(" previousCompatible=").append(previousCompatible) + append(" appliedCompatible=").append(appliedCompatible) + append(" recommendedReadBuffer=").append(glEnumName(recommended)) + append(" api=").append(readbackApi.name) + } + logRateLimited( + key = "readback:$path:${state.readFramebufferBinding}:${setup.appliedReadBuffer}", + message = message + ) + } + + private fun isReadBufferCompatibleWithActiveTarget(readBuffer: Int, state: ReadbackBindingState): Boolean { + return if (state.usingFramebufferObject) { + readBuffer == GL11.GL_NONE || isColorAttachmentReadBuffer(readBuffer) + } else { + when (readBuffer) { + GL11.GL_BACK, + GL11.GL_FRONT, + GL11.GL_LEFT, + GL11.GL_RIGHT, + GL11.GL_FRONT_LEFT, + GL11.GL_FRONT_RIGHT, + GL11.GL_BACK_LEFT, + GL11.GL_BACK_RIGHT -> true + + else -> false + } + } + } + + private fun isColorAttachmentReadBuffer(readBuffer: Int): Boolean { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> readBuffer in GL30.GL_COLOR_ATTACHMENT0..(GL30.GL_COLOR_ATTACHMENT0 + 31) + ReadbackApi.ArbFramebufferObject -> readBuffer in ARBFramebufferObject.GL_COLOR_ATTACHMENT0..(ARBFramebufferObject.GL_COLOR_ATTACHMENT0 + 15) + ReadbackApi.ExtFramebufferObject -> readBuffer in EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT..(EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT + 15) + ReadbackApi.Legacy -> false + } + } + + private fun glEnumName(value: Int): String { + return when (value) { + GL11.GL_NONE -> "GL_NONE" + GL11.GL_FRONT -> "GL_FRONT" + GL11.GL_BACK -> "GL_BACK" + GL11.GL_LEFT -> "GL_LEFT" + GL11.GL_RIGHT -> "GL_RIGHT" + GL11.GL_FRONT_LEFT -> "GL_FRONT_LEFT" + GL11.GL_FRONT_RIGHT -> "GL_FRONT_RIGHT" + GL11.GL_BACK_LEFT -> "GL_BACK_LEFT" + GL11.GL_BACK_RIGHT -> "GL_BACK_RIGHT" + GL30.GL_COLOR_ATTACHMENT0, + ARBFramebufferObject.GL_COLOR_ATTACHMENT0, + EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT -> "GL_COLOR_ATTACHMENT0" + else -> { + val hex = Integer.toHexString(value).uppercase() + "0x$hex" + } + } + } + + private fun logRateLimited(key: String, message: String) { + val now = System.currentTimeMillis() + val previous = errorLogTimes[key] ?: 0L + if (now - previous < 3_000L) return + errorLogTimes[key] = now + println(message) + } + + private fun pushClip(viewport: Viewport, guiX: Int, guiY: Int, guiWidth: Int, guiHeight: Int) { + val scissor = viewport.dsglRectToGlScissor(guiX, guiY, guiWidth, guiHeight) + ScissorContext.push(scissor.x, scissor.y, scissor.width, scissor.height) + } + + private fun isBlockStack(stack: ItemStack): Boolean { + return stack.item is BlockItem + } + + private fun draw2DItem(stack: ItemStack, x: Int, y: Int, size: Int, maxWidth: Int) { + val drawX = x + ((maxWidth - size) / 2).coerceAtLeast(0) + withStack { + withAttributes(enable = listOf(GL11.GL_DEPTH_TEST)) { + val scale = size / 16.0f + GL11.glTranslatef(drawX.toFloat(), y.toFloat(), 0.0f) + GL11.glScalef(scale, scale, 1.0f) + itemRenderer.renderItemAndEffectIntoGUI(mc.font, mc.textureManager, stack, 0, 0) + } + } + } + + private fun draw3DItem(stack: ItemStack, x: Int, y: Int, size: Int, width: Int, rotY: Double, rotX: Double) { + val scale = size / 16.0f + val drawX = x + ((width - size) / 2).coerceAtLeast(0) + + withStack { + withAttributes(enable = listOf(GL11.GL_BLEND, GL11.GL_DEPTH_TEST, GL12.GL_RESCALE_NORMAL)) { + withItemGuiLightning { + GL11.glTranslated(drawX.toDouble(), y.toDouble(), 100.0) + GL11.glScaled(scale.toDouble(), scale.toDouble(), scale.toDouble()) + GL11.glTranslated(8.0, 8.0, 0.0) + GL11.glRotated(rotX, 1.0, 0.0, 0.0) + GL11.glRotated(rotY, 0.0, 1.0, 0.0) + GL11.glTranslated(-8.0, -8.0, 0.0) + itemRenderer.renderItemAndEffectIntoGUI(mc.font, mc.textureManager, stack, 0, 0) + } + } + } + } + + // TODO Neoforge-1.21 requires PoseStack and BufferSource from render method + private fun drawItemStack( + stack: ItemStack, + x: Int, + y: Int, + size: Int, + width: Int, + rotY: Double, + rotX: Double + ) { + withStack(attributesBitMask = GL11.GL_ALL_ATTRIB_BITS) { + val previousZ = itemRenderer.zLevel + try { + itemRenderer.zLevel = 0f + GL11.glColor4f(1f, 1f, 1f, opacityMultiplier.coerceIn(0f, 1f)) + if (isBlockStack(stack)) { + draw3DItem(stack, x, y, size, width, rotY, rotX) + } else { + draw2DItem(stack, x, y, size, width) + } + } finally { + itemRenderer.zLevel = previousZ + } + } + } + + private fun resolveImage(source: String): ResourceLocation? { + imageCache[source]?.let { return it } + + return when { + source.startsWith("http://") || source.startsWith("https://") -> { + val url = runCatching { URL(source) }.getOrNull() ?: return null + val file = remoteFileFor(url) + if (!file.exists()) { + if (!downloadToFile(url, file)) return null + } + loadDynamicTexture(file, source) + } + + source.startsWith("file://") -> { + var relative = source.removePrefix("file://") + while (relative.startsWith("/") || relative.startsWith("\\")) { + relative = relative.substring(1) + } + val baseDir = File(mc.gameDirectory, "dsgl") + val file = File(baseDir, relative) + loadDynamicTexture(file, source) + } + + else -> { + val location = ResourceLocation.parse(source) + imageCache[source] = location + location + } + } + } + + private fun remoteFileFor(url: URL): File { + val host = if (url.host.isNullOrBlank()) "unknown" else url.host + var path = url.path + if (path.isBlank() || path == "/") { + path = "/index" + } + if (path.startsWith("/")) { + path = path.substring(1) + } + path = path.replace("..", "_") + val baseDir = File(mc.gameDirectory, "dsgl/cache/downloads") + return File(baseDir, host + File.separator + path) + } + + private fun downloadToFile(url: URL, file: File): Boolean { + return try { + file.parentFile?.mkdirs() + url.openStream().use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + true + } catch (ex: Exception) { + false + } + } + + private fun loadDynamicTexture(file: File, cacheKey: String): ResourceLocation? { + if (!file.exists()) return null + val cached = imageCache[cacheKey] + if (cached != null) return cached + return try { + file.inputStream().use { inS -> + val image = NativeImage.read(inS) ?: return null + val texture = DynamicTexture(image) + dynamicTexturesCache[cacheKey] = texture + val name = "dsgl_${cacheKey.hashCode().toString(16)}" + val location = mc.textureManager.register(name, texture) + imageCache[cacheKey] = location + location + } + } catch (ex: Exception) { + null + } + } +} diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/McItemStackRef.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/McItemStackRef.kt new file mode 100644 index 0000000..7d98934 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/McItemStackRef.kt @@ -0,0 +1,9 @@ +package org.dreamfinity.dsgl.mcNeoforge1211 + +import net.minecraft.world.item.ItemStack +import org.dreamfinity.dsgl.core.ItemStackRef + +/** + * Wrapper for Minecraft 1.21.1 [ItemStack] to satisfy [ItemStackRef]. + */ +class McItemStackRef(val stack: ItemStack) : ItemStackRef diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/RenderCommandTransformStack.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/RenderCommandTransformStack.kt new file mode 100644 index 0000000..39d0931 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/RenderCommandTransformStack.kt @@ -0,0 +1,79 @@ +package org.dreamfinity.dsgl.mcNeoforge1211 + +import kotlin.math.ceil +import kotlin.math.floor +import org.dreamfinity.dsgl.core.dom.layout.AffineTransform2D +import org.dreamfinity.dsgl.core.render.RenderCommand + +internal data class GuiClipRect( + val x: Int, + val y: Int, + val width: Int, + val height: Int +) + +internal class RenderCommandTransformStack { + private val stack: ArrayDeque = ArrayDeque() + private var current: AffineTransform2D = AffineTransform2D.IDENTITY + + fun reset() { + stack.clear() + current = AffineTransform2D.IDENTITY + } + + fun push(command: RenderCommand.PushTransform) { + stack.addLast(current) + current = current.times(command.toAffineTransform()) + } + + fun pop() { + current = if (stack.isNotEmpty()) stack.removeLast() else AffineTransform2D.IDENTITY + } + + fun currentTransform(): AffineTransform2D = current + + fun transformPoint(x: Float, y: Float): Pair { + return current.transform(x, y) + } + + fun resolveClipRect(x: Int, y: Int, width: Int, height: Int): GuiClipRect { + val safeWidth = width.coerceAtLeast(0) + val safeHeight = height.coerceAtLeast(0) + if (safeWidth == 0 || safeHeight == 0) { + return GuiClipRect(x, y, 0, 0) + } + if (current == AffineTransform2D.IDENTITY) { + return GuiClipRect(x, y, safeWidth, safeHeight) + } + + val topLeft = current.transform(x.toFloat(), y.toFloat()) + val topRight = current.transform((x + safeWidth).toFloat(), y.toFloat()) + val bottomLeft = current.transform(x.toFloat(), (y + safeHeight).toFloat()) + val bottomRight = current.transform((x + safeWidth).toFloat(), (y + safeHeight).toFloat()) + + val minX = minOf(topLeft.first, topRight.first, bottomLeft.first, bottomRight.first) + val maxX = maxOf(topLeft.first, topRight.first, bottomLeft.first, bottomRight.first) + val minY = minOf(topLeft.second, topRight.second, bottomLeft.second, bottomRight.second) + val maxY = maxOf(topLeft.second, topRight.second, bottomLeft.second, bottomRight.second) + + val resolvedX = floor(minX.toDouble()).toInt() + val resolvedY = floor(minY.toDouble()).toInt() + val resolvedWidth = ceil((maxX - minX).toDouble()).toInt().coerceAtLeast(0) + val resolvedHeight = ceil((maxY - minY).toDouble()).toInt().coerceAtLeast(0) + return GuiClipRect(resolvedX, resolvedY, resolvedWidth, resolvedHeight) + } + + private fun RenderCommand.PushTransform.toAffineTransform(): AffineTransform2D { + val toOrigin = AffineTransform2D.translation(originX, originY) + val translate = AffineTransform2D.translation(translateX, translateY) + val rotate = AffineTransform2D.rotation(rotateDeg) + val scale = AffineTransform2D.scale(scaleX, scaleY) + val fromOrigin = AffineTransform2D.translation(-originX, -originY) + return toOrigin + .times(translate) + .times(rotate) + .times(scale) + .times(fromOrigin) + } +} + diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/scissorsHelper/ScissorContext.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/scissorsHelper/ScissorContext.kt new file mode 100644 index 0000000..ea5a647 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/scissorsHelper/ScissorContext.kt @@ -0,0 +1,42 @@ +package org.dreamfinity.dsgl.mcNeoforge1211.scissorsHelper + +import org.lwjgl.opengl.GL11 +import java.util.* + +object ScissorContext { + val instance = ScissorContext + val stack: Deque = ArrayDeque() + var scissorsEnabledByContext = false + + fun push(x: Number, y: Number, width: Number, height: Number): ScissorsArea { + if (stack.isEmpty()) { + scissorsEnabledByContext = !GL11.glIsEnabled(GL11.GL_SCISSOR_TEST) + if (scissorsEnabledByContext) GL11.glEnable(GL11.GL_SCISSOR_TEST) + } + val scissorsArea = + ScissorsArea(x.toInt(), y.toInt(), width.toInt(), height.toInt()) intersectionWith stack.peekFirst() + stack.push(scissorsArea) + GL11.glScissor(scissorsArea.x, scissorsArea.y, scissorsArea.width, scissorsArea.height) + return scissorsArea + } + + fun pop(): ScissorsArea? { + if (stack.isEmpty()) return null + + val removed = stack.pop() + val current = stack.peekFirst() + if (current != null) { + GL11.glScissor(current.x, current.y, current.width, current.height) + } else { + if (scissorsEnabledByContext) GL11.glDisable(GL11.GL_SCISSOR_TEST) + scissorsEnabledByContext = false + } + return removed + } + + fun clear() { + while (stack.isNotEmpty()) { + pop() + } + } +} diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/scissorsHelper/ScissorsArea.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/scissorsHelper/ScissorsArea.kt new file mode 100644 index 0000000..9a93210 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/scissorsHelper/ScissorsArea.kt @@ -0,0 +1,21 @@ +package org.dreamfinity.dsgl.mcNeoforge1211.scissorsHelper + +import kotlin.math.max +import kotlin.math.min + +data class ScissorsArea(val x: Int, val y: Int, val width: Int, val height: Int) + +infix fun ScissorsArea.intersectionWith(another: ScissorsArea?): ScissorsArea { + return another?.let { + val x1 = max(this.x, another.x) + val x2 = min(this.x + this.width, another.x + another.width) + val y1 = max(this.y, another.y) + val y2 = min(this.y + this.height, another.y + another.height) + ScissorsArea( + x1, + y1, + max(0, x2 - x1), + max(0, y2 - y1) + ) + } ?: this +} diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/text/MsdfRuntimeDebugSettings.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/text/MsdfRuntimeDebugSettings.kt new file mode 100644 index 0000000..b0c6a49 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/text/MsdfRuntimeDebugSettings.kt @@ -0,0 +1,6 @@ +package org.dreamfinity.dsgl.mcNeoforge1211.text + +object MsdfRuntimeDebugSettings { + @Volatile + var decorationGuidesEnabled: Boolean = java.lang.Boolean.getBoolean("dsgl.msdf.debug.decorations") +} diff --git a/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/text/MsdfTextRenderer.kt b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/text/MsdfTextRenderer.kt new file mode 100644 index 0000000..5fda72d --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/kotlin/org/dreamfinity/dsgl/mcNeoforge1211/text/MsdfTextRenderer.kt @@ -0,0 +1,1188 @@ +package org.dreamfinity.dsgl.mcNeoforge1211.text + +import org.dreamfinity.dsgl.core.font.* +import org.dreamfinity.dsgl.core.dom.layout.FontLineMetrics +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.TextFormatting +import org.dreamfinity.dsgl.core.text.* +import org.lwjgl.BufferUtils +import org.lwjgl.opengl.* +import java.nio.ByteBuffer +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +internal class MsdfTextRenderer { + private data class PreparedText( + val text: String, + val styleSpans: List + ) + + private data class LayoutCacheKey( + val text: String, + val primaryFontId: String, + val fontSize: Int, + val textFormatting: TextFormatting, + val baseFlagsMask: Int, + val styleSpansHash: Int + ) + + private data class CachedLineLayout( + val start: Int, + val shaped: ShapedText + ) + + private data class LayoutCacheEntry(val lines: List) + + private class SegmentBuffer(initialCapacity: Int = 64) { + private var startX = FloatArray(initialCapacity) + private var endX = FloatArray(initialCapacity) + private var y = FloatArray(initialCapacity) + private var thickness = FloatArray(initialCapacity) + private var color = IntArray(initialCapacity) + private var kind = IntArray(initialCapacity) + + var size: Int = 0 + private set + + fun clear() { + size = 0 + } + + fun appendMerged( + segmentKind: Int, + segmentStartX: Float, + segmentEndX: Float, + segmentY: Float, + segmentThickness: Float, + segmentColor: Int + ) { + if (segmentEndX <= segmentStartX) return + val last = size - 1 + if (last >= 0 && + kind[last] == segmentKind && + color[last] == segmentColor && + kotlin.math.abs(endX[last] - segmentStartX) <= 0.51f && + kotlin.math.abs(y[last] - segmentY) <= 0.51f && + kotlin.math.abs(thickness[last] - segmentThickness) <= 0.1f + ) { + endX[last] = segmentEndX + return + } + ensureCapacity(size + 1) + kind[size] = segmentKind + startX[size] = segmentStartX + endX[size] = segmentEndX + y[size] = segmentY + thickness[size] = segmentThickness + color[size] = segmentColor + size += 1 + } + + fun kindAt(index: Int): Int = kind[index] + fun startXAt(index: Int): Float = startX[index] + fun endXAt(index: Int): Float = endX[index] + fun yAt(index: Int): Float = y[index] + fun thicknessAt(index: Int): Float = thickness[index] + fun colorAt(index: Int): Int = color[index] + + private fun ensureCapacity(required: Int) { + if (required <= startX.size) return + var next = startX.size + while (next < required) { + next = (next * 2).coerceAtLeast(required) + } + startX = startX.copyOf(next) + endX = endX.copyOf(next) + y = y.copyOf(next) + thickness = thickness.copyOf(next) + color = color.copyOf(next) + kind = kind.copyOf(next) + } + } + + private data class RendererDebugCounters( + var drawCalls: Long = 0L, + var layoutCacheHits: Long = 0L, + var layoutCacheMisses: Long = 0L, + var glyphVectorRequests: Long = 0L, + var glyphResolutionRequests: Long = 0L, + var textureUploads: Long = 0L, + var textureUploadBytes: Long = 0L + ) + + private data class DecorationSegment( + var startX: Float, + var endX: Float, + val y: Float, + val thickness: Float, + val color: Int + ) + + private data class ObfuscationBuckets( + val byAdvanceBucket: Map>, + val expandedByAdvanceBucket: Map>, + val sortedKeys: List, + val allGlyphs: List + ) + + private data class LineSlice( + val start: Int, + val endExclusive: Int + ) + + private val textures: MutableMap = linkedMapOf() + private val layoutCache: MutableMap = + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MAX_LAYOUT_CACHE_ENTRIES + } + } + private var programId: Int = 0 + private var uniformAtlas: Int = -1 + private var uniformPxRange: Int = -1 + private val errorLogTimes: MutableMap = linkedMapOf() + private val debugLogKeys: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) + private val debugGlyphResolutionEnabled: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + java.lang.Boolean.getBoolean("dsgl.msdf.debug") + } + private val obfuscationBuckets: MutableMap = linkedMapOf() + private var obfuscationLastNano: Long = System.nanoTime() + private var obfuscationAccumSec: Double = 0.0 + private var obfuscationTimeSlice: Long = 0 + private val maxTextureSize: Int by lazy { GL11.glGetInteger(GL11.GL_MAX_TEXTURE_SIZE).coerceAtLeast(1) } + private val segmentBuffer = SegmentBuffer(96) + private val debugCounters = RendererDebugCounters() + private val debugPerformanceEnabled: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + java.lang.Boolean.getBoolean("dsgl.msdf.debug.performance") + } + private var debugLastLogMs: Long = 0L + fun measureText(text: String, fontId: String?, fontSize: Int?): Int { + return FontRegistry.measureText(text, fontId, fontSize) + } + + fun measureTextRange( + text: String, + startIndex: Int, + endIndexExclusive: Int, + fontId: String?, + fontSize: Int? + ): Int { + val shaped = FontRegistry.shapeTextRange( + text = text, + startIndex = startIndex, + endIndexExclusive = endIndexExclusive, + fontId = fontId, + fontSize = fontSize, + formattingMode = "plain" + ) + return shaped.width.toInt().coerceAtLeast(0) + } + + fun lineHeight(fontId: String?, fontSize: Int?): Int { + return FontRegistry.lineHeight(fontId, fontSize) + } + + fun fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics? { + val font = FontRegistry.get(fontId) ?: return null + val metrics = font.meta.metrics + if (metrics.emSize <= 0f || metrics.lineHeight <= 0f) return null + return FontLineMetrics( + emSize = metrics.emSize, + lineHeightEm = metrics.lineHeight, + ascenderEm = metrics.ascender, + descenderEm = metrics.descender + ) + } + + fun draw(command: RenderCommand.DrawText, opacityMultiplier: Float) { + debugCounters.drawCalls += 1 + val primaryFont = FontRegistry.get(command.fontId) ?: return + val runtimeFallbackFont = FontRegistry.get(FontRegistry.FALLBACK_FONT_ID) + ?.takeIf { it.descriptor.fontId != primaryFont.descriptor.fontId } + val missingGlyphFont = FontRegistry.get(FontRegistry.FALLBACK_FONT_ID) + ?: FontRegistry.get(FontRegistry.DEFAULT_FONT_ID) + val fontSize = FontRegistry.resolveFontSize(command.fontSize) + val prepared = prepareText(command) + val layoutEntry = getOrBuildLayoutCacheEntry( + command = command, + prepared = prepared, + primaryFont = primaryFont, + fontSize = fontSize + ) + if (prepared.text.isEmpty() || layoutEntry.lines.isEmpty()) return + + val debugDecorationGuidesEnabled = MsdfRuntimeDebugSettings.decorationGuidesEnabled + updateObfuscationClock() + segmentBuffer.clear() + + val depthWasEnabled = GL11.glIsEnabled(GL11.GL_DEPTH_TEST) + if (depthWasEnabled) { + GL11.glDisable(GL11.GL_DEPTH_TEST) + } + + try { + val primaryScalePx = TextDecorationLayout.scalePx(fontSize, primaryFont.meta.metrics.emSize) + val lineHeight = primaryFont.meta.lineHeightPx(fontSize).toFloat().coerceAtLeast(1f) + val fontDecorationMetrics = DecorationFontMetrics( + emSize = primaryFont.meta.metrics.emSize, + lineHeightEm = primaryFont.meta.metrics.lineHeight, + ascenderEm = primaryFont.meta.metrics.ascender, + descenderEm = primaryFont.meta.metrics.descender, + underlineYEm = primaryFont.meta.metrics.underlineY, + underlineThicknessEm = primaryFont.meta.metrics.underlineThickness + ) + debugGlyphResolution(prepared.text, primaryFont) + + if (!useProgram()) return + + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + + var lineTop = command.y.toFloat() + var lineIndex = 0 + var spanIndex = 0 + var globalGlyphIndex = 0 + var activeFontId: String? = null + var activeTexture: FontTextureHandle? = null + var glBegun = false + var currentDrawColor: Int = Int.MIN_VALUE + var activeResolvedSpanIndex = Int.MIN_VALUE + var activeStyleColor = withOpacity(command.color, opacityMultiplier) + var activeStyleFlags = baseFlagsMask(command) + + fun beginForFont(font: LoadedMsdfFont): FontTextureHandle? { + if (activeFontId == font.descriptor.fontId && activeTexture != null && glBegun) { + return activeTexture + } + if (glBegun) { + GL11.glEnd() + glBegun = false + } + val texture = textureFor(font) ?: return null + GL13.glActiveTexture(GL13.GL_TEXTURE0) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, texture.textureId) + ARBShaderObjects.glUniform1iARB(uniformAtlas, 0) + ARBShaderObjects.glUniform1fARB(uniformPxRange, font.meta.atlas.distanceRange) + GL11.glBegin(GL11.GL_QUADS) + glBegun = true + activeFontId = font.descriptor.fontId + activeTexture = texture + currentDrawColor = Int.MIN_VALUE + return texture + } + + try { + layoutEntry.lines.forEach { line -> + val baselineY = TextDecorationLayout.baselineY( + lineTopY = lineTop, + ascenderEm = primaryFont.meta.metrics.ascender, + scalePx = primaryScalePx + ) + val shaped = line.shaped + val lineRecord = TextVisualLine( + lineIndex = lineIndex, + lineTopY = lineTop, + baselineY = baselineY, + lineHeightPx = lineHeight, + glyphStartIndex = globalGlyphIndex, + glyphEndIndexExclusive = globalGlyphIndex + shaped.glyphs.size + ) + val lineMetrics = TextDecorationLayout.resolveLineMetrics( + line = lineRecord, + fontMetrics = fontDecorationMetrics, + fontPx = fontSize + ) + var lineStartX = command.x.toFloat() + var lineEndX = lineStartX + var glyphIndexInLine = 0 + var lastObfuscatedGlyphIndex: Int? = null + var cachedShapedFontId: String? = null + var cachedShapedFont: LoadedMsdfFont = primaryFont + + shaped.glyphs.forEach { shapedGlyph -> + val globalCharStart = line.start + shapedGlyph.charStart + while (spanIndex < prepared.styleSpans.size && globalCharStart >= prepared.styleSpans[spanIndex].end) { + spanIndex += 1 + } + val resolvedSpanIndex = if ( + spanIndex < prepared.styleSpans.size && + globalCharStart >= prepared.styleSpans[spanIndex].start && + globalCharStart < prepared.styleSpans[spanIndex].end + ) { + spanIndex + } else { + -1 + } + if (resolvedSpanIndex != activeResolvedSpanIndex) { + activeResolvedSpanIndex = resolvedSpanIndex + if (resolvedSpanIndex >= 0) { + val span = prepared.styleSpans[resolvedSpanIndex] + activeStyleColor = withOpacity(span.color, opacityMultiplier) + activeStyleFlags = flagsMask( + bold = span.bold, + italic = span.italic, + underline = span.underline, + strikethrough = span.strikethrough, + obfuscated = span.obfuscated + ) + } else { + activeStyleColor = withOpacity(command.color, opacityMultiplier) + activeStyleFlags = baseFlagsMask(command) + } + } + + val shapedFont = if (shapedGlyph.fontId == cachedShapedFontId) { + cachedShapedFont + } else { + (FontRegistry.get(shapedGlyph.fontId) ?: primaryFont).also { resolved -> + cachedShapedFontId = shapedGlyph.fontId + cachedShapedFont = resolved + } + } + val forceMissingGlyphFont = if ( + shapedGlyph.sourceCodepoint == REPLACEMENT_CODEPOINT || + isShapedGlyphMissingInFont(shapedFont, shapedGlyph) + ) { + missingGlyphFont ?: runtimeFallbackFont ?: shapedFont + } else { + null + } + + var glyphFont = forceMissingGlyphFont ?: shapedFont + var glyph = if (forceMissingGlyphFont != null) { + preferredMissingGlyph(glyphFont) + } else { + resolveGlyphForShapedInput(shapedFont, shapedGlyph) + } + if (glyph == null && runtimeFallbackFont != null) { + val fallbackByCodepoint = resolveGlyphForShapedInput(runtimeFallbackFont, shapedGlyph) + if (fallbackByCodepoint != null) { + glyphFont = runtimeFallbackFont + glyph = fallbackByCodepoint + } else { + preferredMissingGlyph(runtimeFallbackFont)?.let { fallbackDefault -> + glyphFont = runtimeFallbackFont + glyph = fallbackDefault + } + } + } + debugCounters.glyphResolutionRequests += 1 + + val styleBold = (activeStyleFlags and STYLE_FLAG_BOLD) != 0 + val styleItalic = (activeStyleFlags and STYLE_FLAG_ITALIC) != 0 + val styleUnderline = (activeStyleFlags and STYLE_FLAG_UNDERLINE) != 0 + val styleStrikethrough = (activeStyleFlags and STYLE_FLAG_STRIKETHROUGH) != 0 + val styleObfuscated = (activeStyleFlags and STYLE_FLAG_OBFUSCATED) != 0 + + val boldAdvance = + if (styleBold && !TextStyleMetrics.isWhitespaceCodepoint(shapedGlyph.sourceCodepoint)) { + BOLD_ADVANCE_EXTRA_PX.toFloat() + } else { + 0f + } + val glyphAdvance = shapedGlyph.advance + boldAdvance + val glyphStartX = command.x + shapedGlyph.x + val glyphEndX = glyphStartX + glyphAdvance + if (glyphStartX < lineStartX) lineStartX = glyphStartX + if (glyphEndX > lineEndX) lineEndX = glyphEndX + + val resolvedGlyph = glyph + if (resolvedGlyph != null && resolvedGlyph.drawable) { + val texture = beginForFont(glyphFont) + if (texture == null) { + lastObfuscatedGlyphIndex = null + glyphIndexInLine += 1 + globalGlyphIndex += 1 + return@forEach + } + if (activeStyleColor != currentDrawColor) { + val r = ((activeStyleColor ushr 16) and 0xFF) / 255f + val g = ((activeStyleColor ushr 8) and 0xFF) / 255f + val b = (activeStyleColor and 0xFF) / 255f + val a = ((activeStyleColor ushr 24) and 0xFF) / 255f + GL11.glColor4f(r, g, b, a) + currentDrawColor = activeStyleColor + } + + val drawGlyph = + if (styleObfuscated && ObfuscationTextSelector.shouldObfuscateCodepoint(shapedGlyph.sourceCodepoint)) { + resolveObfuscatedGlyph( + font = glyphFont, + sourceKey = command.sourceKey ?: command.text, + original = resolvedGlyph, + lineIndex = lineIndex, + glyphIndexInLine = glyphIndexInLine, + avoidGlyphIndex = lastObfuscatedGlyphIndex + ) + } else { + resolvedGlyph + } + + val effectiveGlyph = drawGlyph ?: resolvedGlyph + val glyphScale = TextDecorationLayout.scalePx(fontSize, glyphFont.meta.metrics.emSize) + emitGlyphQuad( + glyph = effectiveGlyph, + baselineY = baselineY + shapedGlyph.y, + cursorX = glyphStartX, + atlasWidth = texture.width, + atlasHeight = texture.height, + fontScalePx = glyphScale, + italic = styleItalic, + italicSkewPx = glyphScale * 0.2f + ) + if (styleBold) { + emitGlyphQuad( + glyph = effectiveGlyph, + baselineY = baselineY + shapedGlyph.y, + cursorX = glyphStartX + 0.75f, + atlasWidth = texture.width, + atlasHeight = texture.height, + fontScalePx = glyphScale, + italic = styleItalic, + italicSkewPx = glyphScale * 0.2f + ) + } + lastObfuscatedGlyphIndex = if (styleObfuscated) effectiveGlyph.glyphIndex else null + } else { + lastObfuscatedGlyphIndex = null + } + + if (glyphEndX > glyphStartX) { + if (styleUnderline) { + segmentBuffer.appendMerged( + segmentKind = SEGMENT_UNDERLINE, + segmentStartX = glyphStartX, + segmentEndX = glyphEndX, + segmentY = lineMetrics.underlineY, + segmentThickness = lineMetrics.underlineThickness, + segmentColor = activeStyleColor + ) + } + if (styleStrikethrough) { + segmentBuffer.appendMerged( + segmentKind = SEGMENT_STRIKETHROUGH, + segmentStartX = glyphStartX, + segmentEndX = glyphEndX, + segmentY = lineMetrics.strikethroughY, + segmentThickness = lineMetrics.strikethroughThickness, + segmentColor = activeStyleColor + ) + } + } + + globalGlyphIndex += 1 + glyphIndexInLine += 1 + } + + if (debugDecorationGuidesEnabled && lineEndX > lineStartX) { + segmentBuffer.appendMerged( + segmentKind = SEGMENT_DEBUG_BASELINE, + segmentStartX = lineStartX, + segmentEndX = lineEndX, + segmentY = lineRecord.baselineY.coerceIn( + lineRecord.lineTopY, + lineRecord.lineTopY + lineRecord.lineHeightPx + ), + segmentThickness = 1f, + segmentColor = 0x66FFAA00 + ) + segmentBuffer.appendMerged( + segmentKind = SEGMENT_DEBUG_UNDERLINE, + segmentStartX = lineStartX, + segmentEndX = lineEndX, + segmentY = lineMetrics.underlineY, + segmentThickness = lineMetrics.underlineThickness, + segmentColor = 0x6600FF00 + ) + segmentBuffer.appendMerged( + segmentKind = SEGMENT_DEBUG_STRIKE, + segmentStartX = lineStartX, + segmentEndX = lineEndX, + segmentY = lineMetrics.strikethroughY, + segmentThickness = lineMetrics.strikethroughThickness, + segmentColor = 0x66FF00FF + ) + } + + lineTop += lineHeight + lineIndex += 1 + } + } finally { + if (glBegun) { + GL11.glEnd() + } + ARBShaderObjects.glUseProgramObjectARB(0) + } + + drawDecorationSegments(segmentBuffer, debugDecorationGuidesEnabled) + maybeLogPerformance() + } finally { + if (depthWasEnabled) { + GL11.glEnable(GL11.GL_DEPTH_TEST) + } + } + } + + private fun prepareText(command: RenderCommand.DrawText): PreparedText { + if (command.textFormatting != TextFormatting.Minecraft) { + return PreparedText( + text = command.text, + styleSpans = command.textStyleSpans + ) + } + + if (command.textStyleSpans.isNotEmpty()) { + return PreparedText( + text = command.text, + styleSpans = command.textStyleSpans + ) + } + + val parsed = MinecraftFormattingParser.parse(command.text, TextFormatting.Minecraft) + val spans = MinecraftFormattingParser.resolveStyleSpans( + parsed = parsed, + baseColor = command.color, + baseFlags = TextStyleFlags( + bold = command.bold, + italic = command.italic, + underline = command.underline, + strikethrough = command.strikethrough, + obfuscated = command.obfuscated + ) + ).map { span -> + RenderCommand.TextStyleSpan( + start = span.start, + end = span.end, + color = span.color, + bold = span.flags.bold, + italic = span.flags.italic, + underline = span.flags.underline, + strikethrough = span.flags.strikethrough, + obfuscated = span.flags.obfuscated + ) + } + return PreparedText( + text = parsed.plainText, + styleSpans = spans + ) + } + + private fun splitLines(text: String): List { + if (text.isEmpty()) return listOf(LineSlice(0, 0)) + val lines = ArrayList(4) + var start = 0 + var index = 0 + while (index < text.length) { + if (text[index] == '\n') { + lines += LineSlice(start = start, endExclusive = index) + start = index + 1 + } + index += 1 + } + lines += LineSlice(start = start, endExclusive = text.length) + return lines + } + + private fun resolveGlyphForShapedInput(font: LoadedMsdfFont, shapedGlyph: ShapedGlyph): MsdfGlyph? { + val sourceCodepoint = shapedGlyph.sourceCodepoint + val canUseGlyphIndex = shapedGlyph.fontId == font.descriptor.fontId + val fromIndex = if (canUseGlyphIndex) { + font.meta.glyphByIndex(shapedGlyph.glyphIndex) + } else { + null + } + val indexLooksMissing = isMissingGlyphIndex(font, shapedGlyph.glyphIndex, fromIndex) + val indexMatchesSource = if (!indexLooksMissing) { + val fromIndexCodepoint = fromIndex?.codepoint + fromIndex != null && ( + fromIndexCodepoint == null || + fromIndexCodepoint == sourceCodepoint + ) + } else { + false + } + + if (sourceCodepoint == REPLACEMENT_CODEPOINT) { + return preferredMissingGlyph(font) + } + + if (indexMatchesSource) return fromIndex + + val fromCodepoint = font.meta.glyph(sourceCodepoint) + if (fromCodepoint != null) return fromCodepoint + + return preferredMissingGlyph(font) + } + + private fun preferredMissingGlyph(font: LoadedMsdfFont): MsdfGlyph? { + val meta = font.meta + val replacementByCodepoint = meta.glyph(REPLACEMENT_CODEPOINT) + if (replacementByCodepoint != null) return replacementByCodepoint + val questionByCodepoint = meta.glyph('?'.code) + if (questionByCodepoint != null) return questionByCodepoint + + val questionByIndex = font.preferredQuestionGlyphIndex?.let(meta::glyphByIndex) + if (questionByIndex != null) return questionByIndex + val replacementByIndex = font.preferredMissingGlyphIndex + ?.takeIf { it != 0 } + ?.let(meta::glyphByIndex) + if (replacementByIndex != null) return replacementByIndex + val notDef = meta.glyphByIndex(0) + if (notDef != null) return notDef + return meta.fallbackGlyph() + } + + private fun isShapedGlyphMissingInFont(font: LoadedMsdfFont, shapedGlyph: ShapedGlyph): Boolean { + if (TextStyleMetrics.isWhitespaceCodepoint(shapedGlyph.sourceCodepoint)) return false + val fromIndex = font.meta.glyphByIndex(shapedGlyph.glyphIndex) + return isMissingGlyphIndex(font, shapedGlyph.glyphIndex, fromIndex) + } + + private fun isMissingGlyphIndex(font: LoadedMsdfFont, glyphIndex: Int, glyph: MsdfGlyph?): Boolean { + if (glyph == null) return true + val preferredMissingIndex = font.preferredMissingGlyphIndex + if (preferredMissingIndex != null && glyphIndex == preferredMissingIndex) return true + if (glyphIndex == 0 && (glyph.codepoint == null || glyph.codepoint == REPLACEMENT_CODEPOINT)) return true + return false + } + + private fun emitGlyphQuad( + glyph: MsdfGlyph, + baselineY: Float, + cursorX: Float, + atlasWidth: Int, + atlasHeight: Int, + fontScalePx: Float, + italic: Boolean, + italicSkewPx: Float + ) { + val plane = glyph.planeBounds ?: return + val atlas = glyph.atlasBounds ?: return + + val x0 = cursorX + plane.left * fontScalePx + val x1 = cursorX + plane.right * fontScalePx + val y0 = baselineY - plane.top * fontScalePx + val y1 = baselineY - plane.bottom * fontScalePx + val skew = if (italic) italicSkewPx else 0f + + val u0 = atlas.left / atlasWidth.toFloat() + val u1 = atlas.right / atlasWidth.toFloat() + val v0 = atlas.bottom / atlasHeight.toFloat() + val v1 = atlas.top / atlasHeight.toFloat() + + GL11.glTexCoord2f(u0, v0) + GL11.glVertex2f(x0, y1) + GL11.glTexCoord2f(u1, v0) + GL11.glVertex2f(x1, y1) + GL11.glTexCoord2f(u1, v1) + GL11.glVertex2f(x1 + skew, y0) + GL11.glTexCoord2f(u0, v1) + GL11.glVertex2f(x0 + skew, y0) + } + + private fun getOrBuildLayoutCacheEntry( + command: RenderCommand.DrawText, + prepared: PreparedText, + primaryFont: LoadedMsdfFont, + fontSize: Int + ): LayoutCacheEntry { + val key = LayoutCacheKey( + text = prepared.text, + primaryFontId = primaryFont.descriptor.fontId, + fontSize = fontSize, + textFormatting = command.textFormatting, + baseFlagsMask = baseFlagsMask(command), + styleSpansHash = styleSpansFingerprint(prepared.styleSpans) + ) + synchronized(layoutCache) { + val cached = layoutCache[key] + if (cached != null) { + debugCounters.layoutCacheHits += 1 + return cached + } + } + + debugCounters.layoutCacheMisses += 1 + val slices = splitLines(prepared.text) + val lines = ArrayList(slices.size) + slices.forEach { slice -> + debugCounters.glyphVectorRequests += 1 + val shaped = FontRegistry.shapeTextRange( + text = prepared.text, + startIndex = slice.start, + endIndexExclusive = slice.endExclusive, + fontId = command.fontId, + fontSize = fontSize, + formattingMode = command.textFormatting.name + ) + lines += CachedLineLayout( + start = slice.start, + shaped = shaped + ) + } + + val built = LayoutCacheEntry(lines = lines) + synchronized(layoutCache) { + layoutCache[key] = built + } + return built + } + + private fun drawDecorationSegments(segments: SegmentBuffer, includeDebug: Boolean) { + if (segments.size == 0) return + val texture2dWasEnabled = GL11.glIsEnabled(GL11.GL_TEXTURE_2D) + val blendWasEnabled = GL11.glIsEnabled(GL11.GL_BLEND) + val alphaTestWasEnabled = GL11.glIsEnabled(GL11.GL_ALPHA_TEST) + val lightingWasEnabled = GL11.glIsEnabled(GL11.GL_LIGHTING) + val cullWasEnabled = GL11.glIsEnabled(GL11.GL_CULL_FACE) + ARBShaderObjects.glUseProgramObjectARB(0) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, 0) + if (lightingWasEnabled) GL11.glDisable(GL11.GL_LIGHTING) + if (alphaTestWasEnabled) GL11.glDisable(GL11.GL_ALPHA_TEST) + if (cullWasEnabled) GL11.glDisable(GL11.GL_CULL_FACE) + if (!blendWasEnabled) GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + if (texture2dWasEnabled) GL11.glDisable(GL11.GL_TEXTURE_2D) + GL11.glBegin(GL11.GL_QUADS) + try { + var index = 0 + while (index < segments.size) { + val kind = segments.kindAt(index) + val isDebug = kind == SEGMENT_DEBUG_BASELINE || + kind == SEGMENT_DEBUG_UNDERLINE || + kind == SEGMENT_DEBUG_STRIKE + if (isDebug && !includeDebug) { + index += 1 + continue + } + val color = segments.colorAt(index) + val r = ((color ushr 16) and 0xFF) / 255f + val g = ((color ushr 8) and 0xFF) / 255f + val b = (color and 0xFF) / 255f + val a = ((color ushr 24) and 0xFF) / 255f + GL11.glColor4f(r, g, b, a) + val y0 = segments.yAt(index) + val y1 = maxOf( + y0 + 0.5f, + y0 + segments.thicknessAt(index) + ) + GL11.glVertex2f(segments.startXAt(index), y0) + GL11.glVertex2f(segments.endXAt(index), y0) + GL11.glVertex2f(segments.endXAt(index), y1) + GL11.glVertex2f(segments.startXAt(index), y1) + index += 1 + } + } finally { + GL11.glEnd() + if (texture2dWasEnabled) GL11.glEnable(GL11.GL_TEXTURE_2D) + if (!blendWasEnabled) GL11.glDisable(GL11.GL_BLEND) + if (alphaTestWasEnabled) GL11.glEnable(GL11.GL_ALPHA_TEST) + if (lightingWasEnabled) GL11.glEnable(GL11.GL_LIGHTING) + if (cullWasEnabled) GL11.glEnable(GL11.GL_CULL_FACE) + } + } + + private fun baseFlagsMask(command: RenderCommand.DrawText): Int { + return flagsMask( + bold = command.bold, + italic = command.italic, + underline = command.underline, + strikethrough = command.strikethrough, + obfuscated = command.obfuscated + ) + } + + private fun flagsMask( + bold: Boolean, + italic: Boolean, + underline: Boolean, + strikethrough: Boolean, + obfuscated: Boolean + ): Int { + var mask = 0 + if (bold) mask = mask or STYLE_FLAG_BOLD + if (italic) mask = mask or STYLE_FLAG_ITALIC + if (underline) mask = mask or STYLE_FLAG_UNDERLINE + if (strikethrough) mask = mask or STYLE_FLAG_STRIKETHROUGH + if (obfuscated) mask = mask or STYLE_FLAG_OBFUSCATED + return mask + } + + private fun styleSpansFingerprint(spans: List): Int { + if (spans.isEmpty()) return 0 + var hash = 1 + spans.forEach { span -> + hash = 31 * hash + span.start + hash = 31 * hash + span.end + hash = 31 * hash + if (span.bold) 1 else 0 + hash = 31 * hash + if (span.italic) 1 else 0 + hash = 31 * hash + if (span.underline) 1 else 0 + hash = 31 * hash + if (span.strikethrough) 1 else 0 + hash = 31 * hash + if (span.obfuscated) 1 else 0 + } + return hash + } + + private fun resolveObfuscatedGlyph( + font: LoadedMsdfFont, + sourceKey: String, + original: MsdfGlyph, + lineIndex: Int, + glyphIndexInLine: Int, + avoidGlyphIndex: Int? + ): MsdfGlyph? { + val buckets = obfuscationBuckets.getOrPut(font.descriptor.fontId) { + buildObfuscationBuckets(font) + } + if (buckets.allGlyphs.isEmpty()) return original + val baseBucket = advanceBucketKey(original.advance) + val candidates = buckets.expandedByAdvanceBucket[baseBucket] + ?: nearestExpandedCandidates(buckets, baseBucket) + ?: buckets.allGlyphs + if (candidates.isEmpty()) return original + + val originalKey = original.codepoint ?: original.glyphIndex + val primaryIndex = ObfuscationTextSelector.selectCandidateIndex( + sourceKey = sourceKey, + lineIndex = lineIndex, + glyphIndexInLine = glyphIndexInLine, + timeSlice = obfuscationTimeSlice, + originalCodepoint = originalKey, + candidateCount = candidates.size + ) + val primary = candidates[primaryIndex] + if (avoidGlyphIndex == null || candidates.size <= 1 || primary.glyphIndex != avoidGlyphIndex) { + return primary + } + val secondaryIndex = (primaryIndex + 1 + (obfuscationTimeSlice.toInt() and 3)) % candidates.size + val secondary = candidates[secondaryIndex] + if (secondary.glyphIndex != avoidGlyphIndex) return secondary + return candidates.firstOrNull { it.glyphIndex != avoidGlyphIndex } ?: primary + } + + private fun buildObfuscationBuckets(font: LoadedMsdfFont): ObfuscationBuckets { + val glyphs = font.meta.glyphsByIndex.values + .filter { glyph -> + val codepoint = glyph.codepoint + glyph.drawable && (codepoint == null || !TextStyleMetrics.isWhitespaceCodepoint(codepoint)) + } + if (glyphs.isEmpty()) { + return ObfuscationBuckets( + byAdvanceBucket = emptyMap(), + expandedByAdvanceBucket = emptyMap(), + sortedKeys = emptyList(), + allGlyphs = emptyList() + ) + } + val grouped = linkedMapOf>() + glyphs.forEach { glyph -> + grouped.getOrPut(advanceBucketKey(glyph.advance)) { ArrayList() }.add(glyph) + } + val sorted = grouped.keys.sorted() + val frozenGrouped = grouped.mapValues { (_, value) -> value.toList() } + val expanded = linkedMapOf>() + sorted.forEach { key -> + expanded[key] = expandCandidatesForBucket( + grouped = frozenGrouped, + sortedKeys = sorted, + baseKey = key + ) + } + return ObfuscationBuckets( + byAdvanceBucket = frozenGrouped, + expandedByAdvanceBucket = expanded, + sortedKeys = sorted, + allGlyphs = glyphs + ) + } + + private fun advanceBucketKey(advance: Float): Int { + return (advance * 100f).toInt() + } + + private fun nearestExpandedCandidates(buckets: ObfuscationBuckets, key: Int): List? { + val keys = buckets.sortedKeys + if (keys.isEmpty()) return null + var nearest = keys.first() + var distance = kotlin.math.abs(nearest - key) + keys.forEach { candidate -> + val nextDistance = kotlin.math.abs(candidate - key) + if (nextDistance < distance) { + distance = nextDistance + nearest = candidate + } + } + return buckets.expandedByAdvanceBucket[nearest] + } + + private fun expandCandidatesForBucket( + grouped: Map>, + sortedKeys: List, + baseKey: Int + ): List { + val byDistance = sortedKeys.sortedBy { key -> kotlin.math.abs(key - baseKey) } + val out = ArrayList(MIN_OBFUSCATION_CANDIDATES) + byDistance.forEach { key -> + val candidates = grouped[key].orEmpty() + if (candidates.isNotEmpty()) { + out.addAll(candidates) + } + if (out.size >= MIN_OBFUSCATION_CANDIDATES) { + return@forEach + } + } + return if (out.isEmpty()) grouped.values.flatten() else out + } + + private fun updateObfuscationClock() { + val now = System.nanoTime() + val dt = (now - obfuscationLastNano).coerceAtLeast(0L) / 1_000_000_000.0 + obfuscationLastNano = now + obfuscationAccumSec += dt + val step = OBFUSCATION_TIME_STEP_SEC + if (obfuscationAccumSec >= step) { + val ticks = (obfuscationAccumSec / step).toLong() + obfuscationAccumSec -= ticks * step + obfuscationTimeSlice += ticks + } + } + + private fun textureFor(font: LoadedMsdfFont): FontTextureHandle? { + val fontId = font.descriptor.fontId + textures[fontId]?.let { return it } + font.handle?.let { return it } + + return runCatching { + val handle = uploadTexture(font) + textures[fontId] = handle + handle + }.onSuccess { + font.handle = it + font.atlasPayload.markLoadedToGPUTexture() + }.onFailure { error -> + logRateLimited("texture:$fontId", "[DSGL-MSDF] Failed to load atlas '$fontId': ${error.message}") + }.getOrNull() + } + + private fun uploadTexture(font: LoadedMsdfFont): FontTextureHandle { + val bitmap = font.atlasPayload.ensureDecoded() + val width = bitmap.width.coerceAtLeast(1) + val height = bitmap.height.coerceAtLeast(1) + if (width > maxTextureSize || height > maxTextureSize) { + throw IllegalStateException( + "Atlas '${font.descriptor.fontId}' is ${width}x${height}, exceeds GL_MAX_TEXTURE_SIZE=$maxTextureSize" + ) + } + val buffer = BufferUtils.createByteBuffer(width * height * 4) + buffer.put(ByteBuffer.wrap(bitmap.rgbaBytes)) + buffer.flip() + + val textureId = GL11.glGenTextures() + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_CLAMP) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_CLAMP) + val glError = scopedGlError { + GL11.glTexImage2D( + GL11.GL_TEXTURE_2D, + 0, + GL11.GL_RGBA8, + width, + height, + 0, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + buffer + ) + } + if (glError != GL11.GL_NO_ERROR) { + GL11.glDeleteTextures(textureId) + throw IllegalStateException( + "glTexImage2D failed for '${font.descriptor.fontId}' (${width}x${height}), glError=0x${ + glError.toString(16) + }" + ) + } + debugCounters.textureUploads += 1 + debugCounters.textureUploadBytes += (width.toLong() * height.toLong() * 4L) + return FontTextureHandle(textureId = textureId, width = width, height = height) + } + + private fun drainGlErrors(): Int { + var firstError = GL11.GL_NO_ERROR + while (true) { + val error = GL11.glGetError() + if (error == GL11.GL_NO_ERROR) break + if (firstError == GL11.GL_NO_ERROR) { + firstError = error + } + } + return firstError + } + + private inline fun scopedGlError(block: () -> Unit): Int { + drainGlErrors() + block() + return drainGlErrors() + } + + private fun useProgram(): Boolean { + if (programId == 0) { + val loaded = runCatching { createProgram() } + .onFailure { error -> + logRateLimited("shader:init", "[DSGL-MSDF] Failed to initialize shader: ${error.message}") + } + .getOrNull() ?: return false + programId = loaded + uniformAtlas = ARBShaderObjects.glGetUniformLocationARB(programId, "uAtlas") + uniformPxRange = ARBShaderObjects.glGetUniformLocationARB(programId, "uPxRange") + } + ARBShaderObjects.glUseProgramObjectARB(programId) + return true + } + + private fun createProgram(): Int { + val vertexShader = compileShader( + type = ARBVertexShader.GL_VERTEX_SHADER_ARB, + source = VERTEX_SHADER_SOURCE + ) + val fragmentShader = compileShader( + type = ARBFragmentShader.GL_FRAGMENT_SHADER_ARB, + source = FRAGMENT_SHADER_SOURCE + ) + + val program = ARBShaderObjects.glCreateProgramObjectARB() + ARBShaderObjects.glAttachObjectARB(program, vertexShader) + ARBShaderObjects.glAttachObjectARB(program, fragmentShader) + ARBShaderObjects.glLinkProgramARB(program) + val linkStatus = ARBShaderObjects.glGetObjectParameteriARB( + program, + ARBShaderObjects.GL_OBJECT_LINK_STATUS_ARB + ) + if (linkStatus == GL11.GL_FALSE) { + val info = ARBShaderObjects.glGetInfoLogARB(program, 4096) + throw IllegalStateException("Program link failed: $info") + } + return program + } + + private fun compileShader(type: Int, source: String): Int { + val shader = ARBShaderObjects.glCreateShaderObjectARB(type) + ARBShaderObjects.glShaderSourceARB(shader, source) + ARBShaderObjects.glCompileShaderARB(shader) + val compileStatus = ARBShaderObjects.glGetObjectParameteriARB( + shader, + ARBShaderObjects.GL_OBJECT_COMPILE_STATUS_ARB + ) + if (compileStatus == GL11.GL_FALSE) { + val info = ARBShaderObjects.glGetInfoLogARB(shader, 4096) + throw IllegalStateException("Shader compile failed: $info") + } + return shader + } + + private fun withOpacity(color: Int, opacityMultiplier: Float): Int { + if (opacityMultiplier >= 0.999f) return color + val alpha = ((color ushr 24) and 0xFF) + val scaled = (alpha * opacityMultiplier).toInt().coerceIn(0, 255) + return (color and 0x00FF_FFFF) or (scaled shl 24) + } + + private fun logRateLimited(key: String, message: String) { + val now = System.currentTimeMillis() + val previous = errorLogTimes[key] ?: 0L + if (now - previous < 3_000L) return + errorLogTimes[key] = now + println(message) + } + + private fun maybeLogPerformance() { + if (!debugPerformanceEnabled) return + val now = System.currentTimeMillis() + if (now - debugLastLogMs < 1_000L) return + debugLastLogMs = now + val layoutSize = synchronized(layoutCache) { layoutCache.size } + println( + "[DSGL-MSDF] drawCalls=${debugCounters.drawCalls} " + + "layoutCache hit=${debugCounters.layoutCacheHits} miss=${debugCounters.layoutCacheMisses} size=$layoutSize " + + "glyphVectors=${debugCounters.glyphVectorRequests} glyphResolves=${debugCounters.glyphResolutionRequests} " + + "textureUploads=${debugCounters.textureUploads} " + + "textureUploadBytes=${debugCounters.textureUploadBytes}" + ) + } + + private fun debugGlyphResolution(text: String, font: LoadedMsdfFont) { + if (!debugGlyphResolutionEnabled) return + val sample = text.take(64) + val key = "${font.descriptor.fontId}|$sample" + if (!debugLogKeys.add(key)) return + + val shaped = FontRegistry.shapeText( + text = sample, + fontId = font.descriptor.fontId, + fontSize = FontRegistry.DEFAULT_FONT_SIZE, + formattingMode = "debug" + ) + println("[DSGL-MSDF] text='$sample' glyphs=${shaped.glyphs.size} runs=${shaped.runs.size}") + shaped.glyphs.take(32).forEach { glyph -> + val loaded = FontRegistry.get(glyph.fontId) + val atlasGlyph = loaded?.meta?.glyphByIndex(glyph.glyphIndex) + println( + "[DSGL-MSDF] font=${glyph.fontId} glyphIndex=${glyph.glyphIndex} sourceCp=U+%04X found=%s".format( + glyph.sourceCodepoint, + atlasGlyph != null + ) + ) + } + } + + companion object { + private const val MAX_LAYOUT_CACHE_ENTRIES: Int = 512 + private const val MIN_OBFUSCATION_CANDIDATES: Int = 24 + private const val OBFUSCATION_TIME_STEP_SEC: Double = 0.05 + private const val STYLE_FLAG_BOLD: Int = 1 shl 0 + private const val STYLE_FLAG_ITALIC: Int = 1 shl 1 + private const val STYLE_FLAG_UNDERLINE: Int = 1 shl 2 + private const val STYLE_FLAG_STRIKETHROUGH: Int = 1 shl 3 + private const val STYLE_FLAG_OBFUSCATED: Int = 1 shl 4 + private const val SEGMENT_UNDERLINE: Int = 1 + private const val SEGMENT_STRIKETHROUGH: Int = 2 + private const val SEGMENT_DEBUG_BASELINE: Int = 3 + private const val SEGMENT_DEBUG_UNDERLINE: Int = 4 + private const val SEGMENT_DEBUG_STRIKE: Int = 5 + private const val REPLACEMENT_CODEPOINT: Int = 0xFFFD + + private const val VERTEX_SHADER_SOURCE: String = """ + #version 120 + varying vec2 vTexCoord; + varying vec4 vColor; + + void main() { + gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; + vTexCoord = gl_MultiTexCoord0.st; + vColor = gl_Color; + } + """ + + private const val FRAGMENT_SHADER_SOURCE: String = """ + #version 120 + uniform sampler2D uAtlas; + uniform float uPxRange; + varying vec2 vTexCoord; + varying vec4 vColor; + + float median(float a, float b, float c) { + return max(min(a, b), min(max(a, b), c)); + } + + void main() { + vec4 sample = texture2D(uAtlas, vTexCoord); + float dist = max(median(sample.r, sample.g, sample.b), sample.a) - 0.5; + float w = 0.5 / max(uPxRange, 0.0001); + float alpha = smoothstep(-w, w, dist); + gl_FragColor = vec4(vColor.rgb, vColor.a * alpha); + } + """ + } +} diff --git a/adapters/mc-neoforge-1-21-1/src/main/resources/META-INF/MANIFEST.MF b/adapters/mc-neoforge-1-21-1/src/main/resources/META-INF/MANIFEST.MF new file mode 100644 index 0000000..9db1830 --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/resources/META-INF/MANIFEST.MF @@ -0,0 +1,9 @@ +Manifest-Version: 1.0 +Specification-Title: ${modName} +Specification-Version: ${modVersion} +Specification-Vendor: ${modAuthor} +Implementation-Title: ${modId} +Implementation-Version: ${modVersion} +Implementation-Vendor: ${modAuthor} +Implementation-Vendor-Id: ${modGroup} +Built-For-MC: ${gameVersion} diff --git a/adapters/mc-neoforge-1-21-1/src/main/resources/mcmod.info b/adapters/mc-neoforge-1-21-1/src/main/resources/mcmod.info new file mode 100644 index 0000000..8ed582e --- /dev/null +++ b/adapters/mc-neoforge-1-21-1/src/main/resources/mcmod.info @@ -0,0 +1,18 @@ +[ + { + "modid": "${modId}", + "name": "${modName}", + "description": "${modDescription}", + "version": "${modVersion}", + "mcversion": "${gameVersion}", + "url": "", + "updateUrl": "", + "authorList": [ + "${modAuthor}" + ], + "credits": "${modCredits}", + "logoFile": "${modIcon}", + "screenshots": [], + "dependencies": [] + } +] diff --git a/gradle.properties b/gradle.properties index 50da364..ad270bc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,5 @@ version=0.0.1 startParameter.offline=true org.gradle.jvmargs=-Xmx4g # adapters turn on / off for development -enableMinecraftForge1710=true +enableMinecraftForge1710=false +enableMinecraftNeoforge1211=true diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b9f101..5b49397 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,6 +14,9 @@ pluginManagement { if (isAdapterEnabled("MinecraftForge1710")) { includeBuild("adapters/mc-forge-1-7-10/adapter-build-logic") } + if (isAdapterEnabled("MinecraftNeoforge1211")) { + includeBuild("adapters/mc-neoforge-1-21-1/adapter-build-logic") + } } rootProject.name = "dsgl" @@ -24,3 +27,7 @@ if (isAdapterEnabled("MinecraftForge1710")) { include(":adapters:mc-forge-1-7-10") include(":adapters:mc-forge-1-7-10:demo") } +if (isAdapterEnabled("MinecraftNeoforge1211")) { + include(":adapters:mc-neoforge-1-21-1") + include(":adapters:mc-neoforge-1-21-1:demo") +}