diff --git a/NOTICE.md b/NOTICE.md index aa7faaf..2718cfb 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -18,6 +18,11 @@ Fabric Loader and Fabric API are external dependencies, not bundled in the Lumen JAR. Their authors retain their rights; Fabric's projects are available at https://github.com/FabricMC under their respective licenses. +Windows Stream mode uses JNA and JNA-platform 5.17.0, supplied by Minecraft's +runtime, to call Windows APIs for its separate overlay. Lumen does not package +JNA or JNA-platform binaries. JNA's [license](https://github.com/java-native-access/jna/blob/5.17.0/LICENSE) +allows LGPL-2.1-or-later or Apache-2.0, at the user's option. + The Gradle wrapper is generated by Gradle and carries its Apache-2.0 notices. JUnit is used only for development tests. Minecraft is a separate proprietary product of Mojang/Microsoft. Lumen is not an official Minecraft product and is diff --git a/README.md b/README.md index 3ea37be..6327dcc 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ A client-only Fabric edition of Lumen by Auron Network. English interface, dark translucent panels and yellow accents. Current Java test build: -**1.0.0-beta.3**; Java and Bedrock use separate binaries and version numbers. +**1.0.0-beta.4**; Java and Bedrock use separate binaries and version numbers. -[Download the Java beta](https://github.com/AuronNetwork/Lumen-Java/releases/tag/v1.0.0-beta.3) +[Download the Java beta](https://github.com/AuronNetwork/Lumen-Java/releases/tag/v1.0.0-beta.4) · [Report an issue](https://github.com/AuronNetwork/Lumen-Java/issues) · [Test coverage and limits](TESTING.md) · [Bedrock edition](https://github.com/AuronNetwork/Lumen) @@ -15,7 +15,7 @@ translucent panels and yellow accents. Current Java test build: 1. Install [Fabric Loader](https://fabricmc.net/use/installer/) **0.19.5 or newer** for **Minecraft Java 26.3** and select that profile in your launcher. -2. Place `lumen-java-1.0.0-beta.3.jar` and +2. Place `lumen-java-1.0.0-beta.4.jar` and [Fabric API for 26.3](https://modrinth.com/mod/fabric-api/versions?g=26.3) in that profile's `mods` folder. This build uses Fabric API **0.161.0+26.3**. For the standard launcher the default folder is `%APPDATA%\.minecraft\mods`. @@ -24,20 +24,25 @@ translucent panels and yellow accents. Current Java test build: Minecraft 26.3 requires Java 25; the official launcher supplies its game runtime. Install this mod on the client only. No Lumen EXE, DLL, Latite or server mod is required. Remove older Lumen Java JARs from the same profile before updating. +Stream mode needs no additional mod: it uses JNA and JNA-platform **5.17.0** +already provided by Minecraft, without bundling those libraries in the Lumen JAR. In Prism Launcher, right-click the instance, choose **Folder**, and use its `minecraft/mods` folder. Close that instance before replacing the Lumen JAR. ### Iris shader compatibility -Beta 2 fixes through-wall markers disappearing with Iris shader packs. Ore and -chest outlines are drawn after shader composition, with the current world camera -projection, and remain below menus and the HUD. No extra compatibility mod or -shader setting is required. Turning **Through walls** off retains normal depth -occlusion. F1 hides the through-wall marker overlay together with the HUD. - -Verified with Iris **1.11.6**, Sodium **0.9.3-alpha.1**, and Complementary Reimagined -**r5.9.3** on Minecraft 26.3, including toggling shaders off/on during play. +In normal mode, the rendering introduced in beta 2 fixes through-wall markers +disappearing with Iris shader packs. Ore and chest outlines are drawn after shader +composition, with the current world camera projection, and remain below menus +and the HUD. No extra compatibility mod or shader setting is required. Turning +**Through walls** off retains normal depth occlusion. F1 hides the through-wall +marker overlay together with the HUD. + +Beta 4 passed the full client and OBS Game Capture tests with Iris **1.11.6**, +Sodium **0.9.2**, and Complementary Reimagined **r5.9.3** on Minecraft 26.3, +including shaders off/on and Stream mode off/on. See [TESTING.md](TESTING.md) +for the verified scope. Other shader packs and rendering mods need separate testing. Shader packs can provide their own lighting, so Fullbright's lightmap setting may not brighten their rendered scene. @@ -54,7 +59,7 @@ their rendered scene. Keys are rebindable in Minecraft's Controls / Key Binds / Lumen category. The menu releases the cursor, consumes its own clicks and does not pause the world. -The **Features**, **Ore Filters** and **Chest Filters** tabs provide: +The **Features**, **Ore Filters**, **Chest Filters** and **Streaming** tabs provide: - Xray with a 4-24 block radius and 32-512 nearest markers. - Ten ore categories, including deepslate variants, nether gold, quartz and ancient debris. @@ -62,9 +67,11 @@ The **Features**, **Ore Filters** and **Chest Filters** tabs provide: - Optional visibility through walls. Double chests have two marked block positions. - 2x-20x hold-to-zoom and local Fullbright. - **No Attack Cooldown**: optional full melee attack strength in singleplayer. +- Optional Windows Stream mode for Xray markers with OBS Game Capture. -Fresh settings start with Xray, Fullbright and No Attack Cooldown off, Zoom enabled, a 16-block radius, -192 markers, 4x zoom, selected valuable ores and all chest filters off. +Fresh settings start with Xray, Fullbright, No Attack Cooldown and Stream mode off, +Zoom enabled, a 16-block radius, 192 markers, 4x zoom, selected valuable ores and +all chest filters off. Settings are saved in the profile's `config/lumen.properties`. Bedrock's INI settings are not automatically imported. @@ -75,6 +82,39 @@ Zoom and Fullbright modify the rendered view, without overwriting Minecraft's FOV/brightness options or adding potion effects. Modpack compatibility and additional rendering backends require separate testing. +### Stream mode for OBS Game Capture + +1. Use **Windows 10 version 2004 or newer, or Windows 11**, with Minecraft in + **windowed or borderless fullscreen** mode. Exclusive fullscreen hides the overlay. +2. Open **Insert → Streaming → Stream mode** and set it to **ON**. Enable Xray + and select the desired ore/chest filters as usual, then close the menu. +3. In OBS, add a **Game Capture** source and select **only the Minecraft game window**. + Check an OBS preview or test recording before streaming. + +Stream mode defaults to **OFF** and is saved in `config/lumen.properties`, +including across Minecraft restarts. When enabled, Xray marker pixels are drawn +in a separate transparent, click-through Windows overlay. They are never drawn +into Minecraft's captured frame. Only the Xray markers move to this overlay: +the Lumen menu, Zoom and Fullbright remain visible in Game Capture. + +The overlay uses Windows `SetWindowDisplayAffinity` with +`WDA_EXCLUDEFROMCAPTURE` (`0x11`) and verifies that setting before showing markers. +If the overlay or capture exclusion fails, markers stay hidden; Lumen does not +fall back to drawing them in the Minecraft frame. Capture exclusion applies to +specific public Windows capture APIs. It is not a guarantee for every recording +method, screen-sharing tool or capture card. The setup described here is OBS +**Game Capture** selecting the Minecraft window. See [TESTING.md](TESTING.md) for +which runtime and capture checks have actually been completed for this beta. + +External markers hide while any menu is open, with F1, when Minecraft is +minimized or loses focus (including Alt+Tab), and on disconnect. With +**Through walls OFF**, the external overlay checks block line of sight from the +camera; it cannot use Minecraft's world depth buffer, so its occlusion can differ +from normal marker rendering. + +Stream mode is unavailable on non-Windows systems. Leave it **OFF** there to use +the existing normal marker rendering. + ### No Attack Cooldown Open **Insert → Features → No Attack Cooldown** to turn the feature on or off. diff --git a/TESTING.md b/TESTING.md index 72330fb..294da45 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,12 +1,73 @@ # Java 26.3 validation -Tested September 29, 2026: Lumen Java 1.0.0-beta.3, Minecraft 26.3, +Tested October 15, 2026: Lumen Java 1.0.0-beta.4, Minecraft 26.3, Fabric Loader 0.19.5, Fabric API 0.161.0+26.3, Java 25, Windows x64. Rendering backend used by the automated game test: OpenGL on NVIDIA hardware. +## Stream mode and OBS Game Capture + +Lumen 1.0.0-beta.4 was tested in the actual Minecraft 26.3 client with a +separate portable **OBS Studio 32.2.2** instance. That OBS instance used a fresh +profile and scene collection; it never streamed, recorded or started a virtual +camera. The user's running OBS profile and personal Minecraft instance were not +modified. + +The same full test passed twice: first without Iris, then with **Iris 1.11.6**, +**Sodium 0.9.2** and **Complementary Reimagined r5.9.3**. Fixture downloads were +SHA-512 verified against official Modrinth metadata. Capture checks used real +OBS `game_capture` output returned by `GetSourceScreenshot`, rather than a mock +capture or a Minecraft screenshot alone. + +| OBS checkpoint | Diamond | Chest | Trapped chest | Ender chest | +| --- | ---: | ---: | ---: | ---: | +| Stream mode OFF, normal rendering | present | present | present | present | +| Stream mode ON, external overlay visible | 0 pixels | 0 pixels | 0 pixels | 0 pixels | +| Stream mode OFF again | present | present | present | present | + +All four colors must have at least 100 matching pixels in each normal-rendering +capture, and exactly zero in the stream-mode capture. The image must still +contain the Minecraft scene/HUD, so a blank source cannot pass. Independent +sidecar color counts agree with the in-game assertions. Screenshot matching +accounts for HUD vignette brightness and integer color rounding. + +Additional checks completed: + +- The actual Streaming menu toggle defaults OFF, saves ON and restores normal + rendering when switched OFF. Settings tests also cover older/malformed files. +- The native window is visible while the game remains foreground, with Windows + reporting capture affinity `0x11`. Its input styles are click-through and + non-activating. The Minecraft game framebuffer contains no marker pixels. +- A test-only copy of the completed native GDI surface contains all four marker + colors. Capture exclusion remains `0x11` during inspection; no screenshot code + is included in the production mod. +- Resizing to 1280x720 keeps the external window aligned to the Minecraft client + dimensions. Zoom retains external markers and a clean game framebuffer. +- Xray OFF, F1, opening the menu, looking behind the camera, and occluded blocks + with Through walls OFF hide the external markers. Normal rendering returns + after Stream mode is disabled, and closing destroys the native window. +- The existing filter, Fullbright, mouse/slider, combat damage, configuration, + block removal and world exit/re-entry checks still pass. + +A preliminary test correctly hid the overlay when Windows kept another window +in the foreground. The test fixture now explicitly focuses only its own game +window. Production overlay code never focuses Minecraft or steals activation. + +The Windows overlay is limited to 60 updates per second and batches marker +edges by color. If the native window or verified capture exclusion is unavailable, +Stream mode suppresses both ordinary game-rendering paths. It never falls back to +putting markers in the recorded framebuffer. Frames older than 250 ms are hidden. +With Through walls OFF, the external renderer uses block line of sight, which +can differ from normal depth-buffer occlusion. + +This validates **OBS Game Capture of the Minecraft window** on Windows/OpenGL. +It does not validate every OBS source method, capture card, third-party recorder, +Windows version, multi-monitor/DPI combination, or a live Twitch broadcast. +Exclusive fullscreen is not supported. The Lumen menu, zoom and Fullbright remain +part of the normal game image and remain visible to the recording. + ## Completed -- `gradlew build`: successful; eleven JUnit tests pass with no failures. +- `gradlew build`: successful; twelve JUnit tests pass with no failures. - `gradlew runClientGameTest`: successful in a separate Fabric development instance. Its own test world was created, closed and reopened. - All production mixins loaded in the actual game without missing-target errors. @@ -73,7 +134,7 @@ Normal depth-tested outlines still use vanilla gizmos. There is no Iris dependen or shader modification in the production mod. The full client game test also passed with Iris **1.11.6+mc26.3**, Sodium -**0.9.3-alpha.1+mc26.3**, and **Complementary Reimagined r5.9.3** at default shader +**0.9.2+mc26.3**, and **Complementary Reimagined r5.9.3** at default shader settings. Fixtures were read from local files and copied only into the disposable test instance; no personal Prism profile, mod or world was changed. @@ -87,7 +148,7 @@ test instance; no personal Prism profile, mod or world was changed. that the Lumen menu draws above the outlines. - The existing filter, zoom release, lightmap restoration, mouse/menu, slider, persistence, block removal and world re-entry checks also pass with Iris. -- The beta 3 integrated-server combat damage and restoration checks also pass +- The beta 4 integrated-server combat damage and restoration checks also pass with this shader configuration. The three projection tests cover viewport clipping, near-plane crossings, @@ -103,7 +164,7 @@ ordering, read/marker/radius limits, removal/replacement, teleport and reset. These are automated development-client tests plus visual inspection of the captured game frames. User acceptance in a normal installed Fabric profile is -still pending for beta 3. Other shader packs, the rest of the user's Prism modpack +still pending for beta 4. Other shader packs, the rest of the user's Prism modpack (including Distant Horizons and Freecam), Vulkan, non-Windows platforms and multiplayer compatibility were not tested. The Java release remains a beta. Fullbright's lightmap state restores correctly with Iris, but a shader pack's @@ -124,3 +185,7 @@ rapid attacks on separate targets, then turn it off to restore normal charging. Repeated hits on one target still respect its damage immunity. Report the Lumen version and that profile's `logs/latest.log` if a problem occurs. Do not install the development test mod or source JAR. + +For Stream mode, use **Insert → Streaming → Stream mode ON**, close the menu, +and compare the game with the OBS Game Capture preview or a short local recording. +Use windowed/borderless mode and verify the full OBS scene before going live. diff --git a/build.gradle b/build.gradle index 19ea75c..4d6ad95 100644 --- a/build.gradle +++ b/build.gradle @@ -48,4 +48,7 @@ tasks.named('runClientGameTest') { if (providers.gradleProperty('shaderTestPack').isPresent()) { systemProperty 'lumen.test.shaderPack', providers.gradleProperty('shaderTestPack').get() } + if (providers.gradleProperty('obsTestDir').isPresent()) { + systemProperty 'lumen.test.obsDir', providers.gradleProperty('obsTestDir').get() + } } diff --git a/gradle.properties b/gradle.properties index 116069e..7c8d92f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,4 @@ org.gradle.parallel=true minecraft_version=26.3 loader_version=0.19.5 fabric_version=0.161.0+26.3 -mod_version=1.0.0-beta.3 +mod_version=1.0.0-beta.4 diff --git a/src/gametest/java/net/auronnetwork/lumen/LumenGameTest.java b/src/gametest/java/net/auronnetwork/lumen/LumenGameTest.java index ddf19f7..9cedd79 100644 --- a/src/gametest/java/net/auronnetwork/lumen/LumenGameTest.java +++ b/src/gametest/java/net/auronnetwork/lumen/LumenGameTest.java @@ -15,7 +15,7 @@ private static void check(boolean value, String description) { if (!value) throw new AssertionError(description); } private static final BlockKind[] TEST_MARKERS = {BlockKind.DIAMOND, BlockKind.CHEST, BlockKind.TRAPPED_CHEST, BlockKind.ENDER_CHEST}; - private static void checkMarkerImage(java.nio.file.Path path, boolean visible) { + static void checkMarkerImage(java.nio.file.Path path, boolean visible) { try { var image = javax.imageio.ImageIO.read(path.toFile()); for (var kind : TEST_MARKERS) { @@ -55,6 +55,7 @@ private static void setShaders(ClientGameTestContext context, boolean enabled) { }); } @Override public void runTest(ClientGameTestContext context) { + LumenStreamTest.prepareWindow(context); if (FabricLoader.getInstance().isModLoaded("iris")) { context.runOnClient(mc -> { try { @@ -150,6 +151,7 @@ private static void setShaders(ClientGameTestContext context, boolean enabled) { check(Math.abs(mc.gameRenderer.mainCamera().getFov() - normal) < 0.01f, "C release restores FOV"); check(mc.options.gamma().get() == gamma && mc.options.fov().get() == fov, "Original game settings unchanged"); }); + LumenStreamTest.run(context); context.getInput().pressKey(LumenClient.menuKey); context.waitForScreen(LumenScreen.class); context.runOnClient(mc -> { diff --git a/src/gametest/java/net/auronnetwork/lumen/LumenStreamTest.java b/src/gametest/java/net/auronnetwork/lumen/LumenStreamTest.java new file mode 100644 index 0000000..2807d56 --- /dev/null +++ b/src/gametest/java/net/auronnetwork/lumen/LumenStreamTest.java @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: GPL-3.0-only +package net.auronnetwork.lumen; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.jna.Platform; +import com.sun.jna.Pointer; +import com.sun.jna.Memory; +import com.sun.jna.platform.win32.GDI32; +import com.sun.jna.platform.win32.Kernel32; +import com.sun.jna.platform.win32.WinDef.DWORD; +import com.sun.jna.platform.win32.WinDef.HDC; +import com.sun.jna.platform.win32.WinGDI.BITMAPINFO; +import com.sun.jna.platform.win32.User32; +import com.sun.jna.platform.win32.WinDef.HWND; +import com.sun.jna.platform.win32.WinDef.RECT; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.BlockPos; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** Windows/OBS integration checks in the disposable development instance only. */ +final class LumenStreamTest { + private static void check(boolean value, String message) { + if (!value) throw new AssertionError(message + " (" + StreamOverlay.status() + ")"); + } + static void prepareWindow(ClientGameTestContext context) { + if (!Platform.isWindows()) return; + context.runOnClient(mc -> { + String version = FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString(); + mc.getWindow().setTitle("Lumen Capture Test " + version); + var window = new HWND(Pointer.createConstant(StreamMode.windowHandle(mc))); + var user = User32.INSTANCE; + int foregroundThread = user.GetWindowThreadProcessId(user.GetForegroundWindow(), null); + int thisThread = Kernel32.INSTANCE.GetCurrentThreadId(); + boolean attached = foregroundThread != thisThread && foregroundThread != 0 + && user.AttachThreadInput(new DWORD(thisThread), new DWORD(foregroundThread), true); + try { + user.ShowWindow(window, 9); // Restore only this disposable test window. + user.BringWindowToTop(window); + user.SetForegroundWindow(window); + user.SetFocus(window); + } finally { + if (attached) user.AttachThreadInput(new DWORD(thisThread), new DWORD(foregroundThread), false); + } + }); + } + static void run(ClientGameTestContext context) { + if (!Platform.isWindows()) return; + checkpoint(context, "normal-baseline", true); + context.getInput().pressKey(LumenClient.menuKey); + context.waitForScreen(LumenScreen.class); + context.clickScreenButton("Streaming"); + context.waitTicks(2); + context.clickScreenButton("Stream mode OFF"); + context.runOnClient(mc -> { + check(LumenClient.settings.streamMode, "Actual Streaming toggle enables stream mode"); + try { + var config = FabricLoader.getInstance().getConfigDir().resolve("lumen.properties"); + check(LumenSettings.load(config).streamMode, "Stream mode persists"); + } catch (java.io.IOException e) { throw new AssertionError(e); } + }); + context.waitTicks(3); + context.takeScreenshot("lumen-streaming-menu"); + context.getInput().pressKey(LumenClient.menuKey); + context.waitFor(mc -> mc.gui.screen() == null); + prepareWindow(context); + try { + context.waitFor(mc -> StreamOverlay.ready() && StreamOverlay.visible() && StreamOverlay.lineCount() >= 48, 300); + } catch (AssertionError error) { + context.runOnClient(mc -> LumenClient.LOG.error("Stream test state: draw={}, target={}, foreground={}, overlay={}, ready={}, visible={}, lines={}, status={}", + StreamMode.canDraw(mc), StreamMode.windowHandle(mc), User32.INSTANCE.GetForegroundWindow(), + StreamOverlay.windowHandle(), StreamOverlay.ready(), StreamOverlay.visible(), StreamOverlay.lineCount(), StreamMode.status(mc))); + throw error; + } + check(StreamOverlay.captureAffinity() == 0x11, "Windows confirms capture exclusion"); + int flags = User32.INSTANCE.GetWindowLong(new HWND(Pointer.createConstant(StreamOverlay.windowHandle())), -20); + check((flags & 0x08000020) == 0x08000020, "Overlay never activates and passes mouse input through"); + LumenGameTest.checkMarkerImage(context.takeScreenshot("lumen-stream-mode-clean-game"), false); + checkpoint(context, "stream-on", false); + + context.getInput().resizeWindow(1280, 720); + context.waitTicks(5); + awaitVisible(context); + RECT gameClient = new RECT(), overlay = new RECT(); + long game = context.computeOnClient(StreamMode::windowHandle); + check(User32.INSTANCE.GetClientRect(new HWND(Pointer.createConstant(game)), gameClient) + && User32.INSTANCE.GetWindowRect(new HWND(Pointer.createConstant(StreamOverlay.windowHandle())), overlay), "Window dimensions available"); + check(gameClient.right - gameClient.left == overlay.right - overlay.left + && gameClient.bottom - gameClient.top == overlay.bottom - overlay.top, "Overlay follows resized game client"); + + context.getInput().holdKey(LumenClient.zoomKey); + context.waitTicks(4); + check(StreamOverlay.visible() && StreamOverlay.lineCount() > 0, "Zoom keeps desktop markers visible"); + LumenGameTest.checkMarkerImage(context.takeScreenshot("lumen-stream-mode-clean-zoom"), false); + context.getInput().releaseKey(LumenClient.zoomKey); + context.waitTicks(3); + context.getInput().pressKey(LumenClient.xrayKey); + context.waitFor(mc -> !StreamOverlay.visible() && StreamOverlay.lineCount() == 0, 100); + LumenGameTest.checkMarkerImage(captureLastNativeFrame(), true); + LumenGameTest.checkMarkerImage(context.takeScreenshot("lumen-stream-mode-xray-off"), false); + context.getInput().pressKey(LumenClient.xrayKey); + awaitVisible(context); + context.getInput().pressKey(options -> options.keyToggleGui); + context.waitFor(mc -> !StreamOverlay.visible(), 100); + context.getInput().pressKey(options -> options.keyToggleGui); + awaitVisible(context); + + context.getInput().lookAt(new BlockPos(0, 102, -6)); + context.waitFor(mc -> !StreamOverlay.visible(), 100); + context.getInput().lookAt(new BlockPos(0, 102, 6)); + awaitVisible(context); + context.runOnClient(mc -> LumenClient.settings.throughWalls = false); + context.waitFor(mc -> !StreamOverlay.visible(), 100); + LumenGameTest.checkMarkerImage(context.takeScreenshot("lumen-stream-mode-occluded"), false); + context.runOnClient(mc -> LumenClient.settings.throughWalls = true); + awaitVisible(context); + + context.getInput().pressKey(LumenClient.menuKey); + context.waitForScreen(LumenScreen.class); + context.waitFor(mc -> !StreamOverlay.visible(), 100); + context.runOnClient(mc -> check(!mc.mouseHandler.isMouseGrabbed(), "Stream-mode menu releases cursor")); + context.clickScreenButton("Streaming"); + context.waitTicks(2); + context.clickScreenButton("Stream mode ON"); + context.getInput().pressKey(LumenClient.menuKey); + context.waitFor(mc -> mc.gui.screen() == null); + context.waitTicks(4); + check(!StreamOverlay.visible(), "OFF hides the external overlay"); + LumenGameTest.checkMarkerImage(context.takeScreenshot("lumen-stream-mode-off-normal-restored"), true); + checkpoint(context, "normal-restored", true); + StreamOverlay.close(); + context.waitFor(mc -> StreamOverlay.windowHandle() == 0 && !StreamOverlay.ready(), 100); + LumenClient.LOG.info("LUMEN_STREAM_TEST_OK: native overlay/0x11, clean game framebuffer, UI/persistence, mouse flags, resize, zoom, Xray/F1/menu/depth/behind-camera hide, normal rendering restored and native shutdown"); + } + + private static void awaitVisible(ClientGameTestContext context) { + prepareWindow(context); + try { + context.waitFor(mc -> StreamOverlay.visible(), 200); + } catch (AssertionError failure) { + context.runOnClient(mc -> LumenClient.LOG.error("Stream recovery state: draw={}, target={}, foreground={}, overlay={}, ready={}, lines={}, markers={}, status={}, yaw={}, pitch={}", + StreamMode.canDraw(mc), StreamMode.windowHandle(mc), User32.INSTANCE.GetForegroundWindow(), + StreamOverlay.windowHandle(), StreamOverlay.ready(), StreamOverlay.lineCount(), LumenClient.markerCount(), + StreamMode.status(mc), mc.player.getYRot(), mc.player.getXRot())); + throw failure; + } + } + + /** Optional file bridge to a separate portable OBS; no personal OBS state is touched. */ + private static void checkpoint(ClientGameTestContext context, String phase, boolean expectMarkers) { + String directory = System.getProperty("lumen.test.obsDir"); + if (directory == null) return; + prepareWindow(context); + Path folder = Path.of(directory); + String version = FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString(); + String id = version.replace('.', '-') + "-" + phase; + Path response = folder.resolve("response-" + id + ".json"); + try { + Files.createDirectories(folder); + Files.deleteIfExists(response); + JsonObject data = new JsonObject(); + data.addProperty("id", id); data.addProperty("phase", phase); + data.addProperty("title", "Lumen Capture Test " + version); + data.addProperty("pid", ProcessHandle.current().pid()); + data.addProperty("hwnd", context.computeOnClient(StreamMode::windowHandle)); + data.addProperty("overlayHwnd", StreamOverlay.windowHandle()); + data.addProperty("expectMarkers", expectMarkers); + Path temporary = folder.resolve("request.tmp"); + Files.writeString(temporary, data.toString()); + Files.move(temporary, folder.resolve("request.json"), StandardCopyOption.REPLACE_EXISTING); + } catch (java.io.IOException e) { throw new AssertionError("Cannot request isolated OBS capture", e); } + context.waitFor(mc -> Files.exists(response), 1200); + try { + JsonObject result = JsonParser.parseString(Files.readString(response)).getAsJsonObject(); + check(result.get("ok").getAsBoolean(), "OBS capture checkpoint " + id + ": " + result); + LumenGameTest.checkMarkerImage(Path.of(result.get("screenshot").getAsString()), expectMarkers); + LumenClient.LOG.info("LUMEN_OBS_CAPTURE_OK: {}", id); + } catch (java.io.IOException e) { throw new AssertionError("Cannot read isolated OBS capture", e); } + } + + /** Copies the actual completed native GDI surface after hiding, without changing capture affinity. */ + private static Path captureLastNativeFrame() { + check(!StreamOverlay.visible() && StreamOverlay.captureAffinity() == 0x11, "Native frame is hidden and still excluded"); + try { + Object worker = field(StreamOverlay.class, null, "worker"); + HDC source = (HDC)field(worker.getClass(), worker, "bufferDc"); + int width = (int)field(worker.getClass(), worker, "width"); + int height = (int)field(worker.getClass(), worker, "height"); + var gdi = GDI32.INSTANCE; + var copy = gdi.CreateCompatibleDC(source); + var bitmap = gdi.CreateCompatibleBitmap(source, width, height); + var original = gdi.SelectObject(copy, bitmap); + try { + check(gdi.BitBlt(copy, 0, 0, width, height, source, 0, 0, 0x00cc0020), "Native buffer copied"); + gdi.SelectObject(copy, original); + var info = new BITMAPINFO(); + info.bmiHeader.biWidth = width; info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32; info.bmiHeader.biCompression = 0; + try (Memory pixels = new Memory((long)width * height * 4)) { + check(gdi.GetDIBits(copy, bitmap, 0, height, pixels, info, 0) == height, "Native frame pixels read"); + var image = new java.awt.image.BufferedImage(width, height, java.awt.image.BufferedImage.TYPE_INT_RGB); + image.setRGB(0, 0, width, height, pixels.getIntArray(0, width * height), 0, width); + var path = FabricLoader.getInstance().getGameDir().resolve("screenshots/lumen-stream-overlay-local-buffer.png"); + javax.imageio.ImageIO.write(image, "png", path.toFile()); + return path; + } + } finally { + gdi.SelectObject(copy, original); gdi.DeleteObject(bitmap); gdi.DeleteDC(copy); + } + } catch (ReflectiveOperationException | java.io.IOException e) { throw new AssertionError("Cannot inspect native marker surface", e); } + } + + private static Object field(Class type, Object instance, String name) throws ReflectiveOperationException { + var field = type.getDeclaredField(name); + field.setAccessible(true); + return field.get(instance); + } +} diff --git a/src/main/java/net/auronnetwork/lumen/LumenClient.java b/src/main/java/net/auronnetwork/lumen/LumenClient.java index 99380b7..c5e110f 100644 --- a/src/main/java/net/auronnetwork/lumen/LumenClient.java +++ b/src/main/java/net/auronnetwork/lumen/LumenClient.java @@ -4,6 +4,7 @@ import com.mojang.blaze3d.platform.InputConstants; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; @@ -46,10 +47,12 @@ public final class LumenClient implements ClientModInitializer { zoomKey = register("zoom", InputConstants.KEY_C, category); brightnessKey = register("fullbright", InputConstants.KEY_B, category); ClientTickEvents.END_CLIENT_TICK.register(LumenClient::tick); - ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> AttackCooldown.clear()); + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> { AttackCooldown.clear(); StreamOverlay.hide(); }); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> StreamOverlay.close()); HudElementRegistry.addFirst(Identifier.fromNamespaceAndPath("lumen", "markers"), (graphics, delta) -> { Minecraft mc = Minecraft.getInstance(); - if (settings.throughWalls && canDrawMarkers(mc) && !graphics.guiRenderState.isHudHidden) + if ((settings.throughWalls || settings.streamMode) && canDrawMarkers(mc) && !graphics.guiRenderState.isHudHidden + && (!settings.streamMode || StreamMode.canDraw(mc))) MarkerOverlay.extract(graphics, mc, hits, settings.filters); }); LOG.info("Lumen Java ready: Minecraft 26.3; {} supported blocks; local-world combat option available", kinds.size()); @@ -59,6 +62,7 @@ private static KeyMapping register(String name, int key, KeyMapping.Category cat } public static void save() { AttackCooldown.refresh(Minecraft.getInstance()); + StreamMode.refresh(Minecraft.getInstance()); try { settings.save(CONFIG); saveError = ""; } catch (Exception e) { saveError = "Could not save settings. See latest.log."; LOG.error("Cannot save Lumen settings", e); } } @@ -73,6 +77,7 @@ public static boolean fullbrightActive() { } private static void tick(Minecraft mc) { AttackCooldown.refresh(mc); + StreamMode.refresh(mc); while (menuKey.consumeClick()) { if (mc.player != null && mc.gui.screen() == null) mc.gui.setScreen(new LumenScreen()); } @@ -102,7 +107,7 @@ public static void extractMarkers() { Minecraft mc = Minecraft.getInstance(); // Iris retains world depth for its composites, so vanilla always-on-top gizmos // remain occluded. Through-wall markers use the separate GUI overlay below. - if (settings.throughWalls || !canDrawMarkers(mc)) return; + if (settings.streamMode || settings.throughWalls || !canDrawMarkers(mc)) return; try (var ignored = mc.levelExtractor.collectPerFrameMainThreadGizmos()) { for (var hit : hits) { if (!settings.selected(hit.kind())) continue; diff --git a/src/main/java/net/auronnetwork/lumen/LumenScreen.java b/src/main/java/net/auronnetwork/lumen/LumenScreen.java index de8560a..3570421 100644 --- a/src/main/java/net/auronnetwork/lumen/LumenScreen.java +++ b/src/main/java/net/auronnetwork/lumen/LumenScreen.java @@ -27,10 +27,10 @@ public final class LumenScreen extends Screen { panelWidth = Math.min(600, width - 16); panelHeight = Math.min(340, height - 16); left = (width - panelWidth) / 2; top = (height - panelHeight) / 2; int contentWidth = panelWidth - 24; - String[] tabs = {"Features", "Ore Filters", "Chest Filters"}; + String[] tabs = {"Features", "Ore Filters", "Chest Filters", "Streaming"}; for (int i = 0; i < tabs.length; i++) { final int index = i; - button(left + 12 + i * (contentWidth / 3), top + 43, contentWidth / 3 - 4, 22, + button(left + 12 + i * (contentWidth / tabs.length), top + 43, contentWidth / tabs.length - 4, 22, () -> tabs[index], () -> tab == index, () -> { tab = index; rebuildWidgets(); }, ""); } int y = top + 76; @@ -56,6 +56,9 @@ public final class LumenScreen extends Screen { addRenderableWidget(new LumenSlider(right, y + 2 * gap, columnWidth, rowHeight, "Zoom", "x", 2, 20, 1, () -> s.zoomFactor, v -> s.zoomFactor = v)); button(right, y + 4 * gap, columnWidth, rowHeight, () -> "Done", () -> false, this::onClose, "Close Lumen and return to your game."); + } else if (tab == 3) { + toggle(left + 12, y, contentWidth - 4, rowHeight, "Stream mode", () -> s.streamMode, () -> s.streamMode = !s.streamMode, + "Draw Xray in a separate Windows overlay for OBS Game Capture. The menu, Zoom and Fullbright remain in your recording. Use windowed or borderless mode."); } else { int start = tab == 1 ? 0 : 10, end = tab == 1 ? 10 : 13; int columns = panelWidth >= 390 ? 3 : 2; @@ -95,6 +98,17 @@ private void button(int x, int y, int w, int h, Supplier label, BooleanS String scope = AttackCooldown.available(minecraft) ? "Combat: singleplayer" : "Combat inactive: local only"; g.centeredText(font, font.plainSubstrByWidth(scope, columnWidth - 8), left + 24 + columnWidth + columnWidth / 2, top + 76 + 3 * gap + 4, MUTED); + } else if (tab == 3) { + String[] lines = {"Keep Xray markers out of OBS Game Capture.", + "Windows 10 (2004+) / Windows 11.", + "Use windowed or borderless fullscreen.", + "In OBS, capture only the Minecraft game window.", + "The Lumen menu, Zoom and Fullbright stay visible.", + StreamMode.status(minecraft)}; + int start = top + 76 + Math.clamp((panelHeight - 104) / 5, 20, 43); + for (int i = 0; i < lines.length; i++) + g.text(font, font.plainSubstrByWidth(lines[i], panelWidth - 24), left + 12, start + i * 15, + i == lines.length - 1 ? SIGNAL : MUTED, false); } String status = LumenClient.saveError.isEmpty() ? (LumenClient.settings.xray ? LumenClient.markerCount() + " blocks marked" : "Xray is off") diff --git a/src/main/java/net/auronnetwork/lumen/LumenSettings.java b/src/main/java/net/auronnetwork/lumen/LumenSettings.java index ed0b8b4..5542b5f 100644 --- a/src/main/java/net/auronnetwork/lumen/LumenSettings.java +++ b/src/main/java/net/auronnetwork/lumen/LumenSettings.java @@ -8,6 +8,7 @@ public final class LumenSettings { public boolean xray = false, throughWalls = true, zoom = true, fullbright = false; public boolean noAttackCooldown = false; + public boolean streamMode = false; public int radius = 16, markers = 192, zoomFactor = 4, filters = BlockKind.DEFAULT_MASK; public boolean selected(BlockKind kind) { return kind != null && (filters & kind.bit()) != 0; } @@ -27,6 +28,7 @@ public static LumenSettings load(Path path) throws IOException { s.zoom = bool(p, "zoom", s.zoom); s.fullbright = bool(p, "fullbright", s.fullbright); s.noAttackCooldown = bool(p, "noAttackCooldown", s.noAttackCooldown); + s.streamMode = bool(p, "streamMode", s.streamMode); s.radius = integer(p, "radius", s.radius); s.markers = integer(p, "markers", s.markers); s.zoomFactor = integer(p, "zoomFactor", s.zoomFactor); @@ -42,6 +44,7 @@ public void save(Path path) throws IOException { p.setProperty("zoom", Boolean.toString(zoom)); p.setProperty("fullbright", Boolean.toString(fullbright)); p.setProperty("noAttackCooldown", Boolean.toString(noAttackCooldown)); + p.setProperty("streamMode", Boolean.toString(streamMode)); p.setProperty("radius", Integer.toString(radius)); p.setProperty("markers", Integer.toString(markers)); p.setProperty("zoomFactor", Integer.toString(zoomFactor)); diff --git a/src/main/java/net/auronnetwork/lumen/MarkerOverlay.java b/src/main/java/net/auronnetwork/lumen/MarkerOverlay.java index 33689ec..67721e3 100644 --- a/src/main/java/net/auronnetwork/lumen/MarkerOverlay.java +++ b/src/main/java/net/auronnetwork/lumen/MarkerOverlay.java @@ -14,7 +14,13 @@ import org.joml.Matrix3x2f; import org.joml.Matrix4f; import org.joml.Vector4f; +import java.util.ArrayList; import java.util.List; +import java.util.function.BiConsumer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; /** One frame-owned GUI batch, composited after world shaders and below menus/HUD. */ public final class MarkerOverlay implements GuiElementRenderState { @@ -26,13 +32,18 @@ public final class MarkerOverlay implements GuiElementRenderState { private final float halfWidth; private final ScreenRectangle viewport; private final boolean zeroToOne; + private final boolean external; + private final long targetWindow; private boolean projectionReady; private MarkerOverlay(GuiGraphicsExtractor graphics, Minecraft mc, List hits) { - this.hits = hits; + external = LumenClient.settings.streamMode; + targetWindow = external ? StreamMode.windowHandle(mc) : 0; var camera = mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState; view = new Matrix4f(camera.viewRotationMatrix); cameraX = camera.pos.x; cameraY = camera.pos.y; cameraZ = camera.pos.z; + this.hits = external && !LumenClient.settings.throughWalls + ? hits.stream().filter(hit -> visibleFromCamera(mc, hit)).toList() : hits; pose = new Matrix3x2f(graphics.pose()); viewport = new ScreenRectangle(0, 0, graphics.guiWidth(), graphics.guiHeight()); halfWidth = (float) (1.0 / mc.getWindow().getGuiScale()); // two physical pixels @@ -41,10 +52,11 @@ private MarkerOverlay(GuiGraphicsExtractor graphics, Minecraft mc, List hits, int filters) { var selected = hits.stream().filter(h -> (filters & h.kind().bit()) != 0).toList(); - if (selected.isEmpty()) return; + if (selected.isEmpty()) { if (LumenClient.settings.streamMode) StreamOverlay.hide(); return; } var frame = new MarkerOverlay(graphics, mc, selected); graphics.guiRenderState.setData(KEY, frame); - graphics.guiRenderState.addGuiElement(frame); + // Stream-mode markers never enter the game framebuffer, including on native failure. + if (!frame.external) graphics.guiRenderState.addGuiElement(frame); } /** Called on this frame during world rendering, before the GUI vertices are built. @@ -53,10 +65,20 @@ static void extract(GuiGraphicsExtractor graphics, Minecraft mc, List(hits.size() * 12); + projectLines((line, color) -> lines.add(new StreamOverlay.Line(line.x0() / viewport.width(), + line.y0() / viewport.height(), line.x1() / viewport.width(), line.y1() / viewport.height(), color))); + StreamOverlay.submit(targetWindow, List.copyOf(lines)); + } } @Override public void buildVertices(VertexConsumer vertices) { - if (!projectionReady) return; + if (!projectionReady || LumenClient.settings.streamMode) return; + projectLines((line, color) -> draw(vertices, line, color)); + } + + private void projectLines(BiConsumer output) { var corners = new Vector4f[8]; for (int i = 0; i < 8; i++) corners[i] = new Vector4f(); for (var hit : hits) { @@ -71,11 +93,20 @@ public void setWorldProjection(Matrix4f worldProjection) { for (int i = 0; i < 8; i++) for (int axis = 1; axis <= 4; axis <<= 1) { if ((i & axis) != 0) continue; var line = MarkerProjection.clip(corners[i], corners[i | axis], viewport.width(), viewport.height(), zeroToOne); - if (line != null) draw(vertices, line, hit.kind().color); + if (line != null) output.accept(line, hit.kind().color); } } } + /** A desktop overlay has no world depth buffer; depth mode uses block line of sight. */ + private boolean visibleFromCamera(Minecraft mc, BlockScanner.Hit hit) { + var p = hit.position(); + var position = new BlockPos(p.x(), p.y(), p.z()); + var result = mc.level.clip(new ClipContext(new Vec3(cameraX, cameraY, cameraZ), + Vec3.atCenterOf(position), ClipContext.Block.VISUAL, ClipContext.Fluid.NONE, mc.player)); + return result.getType() == HitResult.Type.MISS || result.getBlockPos().equals(position); + } + private void draw(VertexConsumer vertices, MarkerProjection.Segment line, int color) { float dx = line.x1() - line.x0(), dy = line.y1() - line.y0(); float length = (float)Math.sqrt(dx * dx + dy * dy); diff --git a/src/main/java/net/auronnetwork/lumen/StreamMode.java b/src/main/java/net/auronnetwork/lumen/StreamMode.java new file mode 100644 index 0000000..7db33ef --- /dev/null +++ b/src/main/java/net/auronnetwork/lumen/StreamMode.java @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-only +package net.auronnetwork.lumen; + +import com.sun.jna.Platform; +import net.minecraft.client.Minecraft; +import org.lwjgl.sdl.SDLProperties; +import org.lwjgl.sdl.SDLVideo; + +/** Connects the frame-owned marker projection to the optional Windows desktop overlay. */ +public final class StreamMode { + private StreamMode() { } + + public static boolean canDraw(Minecraft mc) { + return LumenClient.settings.streamMode && LumenClient.settings.xray && Platform.isWindows() + && mc.level != null && mc.player != null && mc.gui.screen() == null + && !mc.gui.hud.isHidden() && mc.isWindowActive() && !mc.getWindow().isIconified() + && !mc.getWindow().isExclusiveFullscreen(); + } + + public static void refresh(Minecraft mc) { + if (!canDraw(mc)) StreamOverlay.hide(); + } + + static long windowHandle(Minecraft mc) { + return SDLProperties.SDL_GetPointerProperty(SDLVideo.SDL_GetWindowProperties(mc.getWindow().handle()), + SDLVideo.SDL_PROP_WINDOW_WIN32_HWND_POINTER, 0); + } + + public static String status(Minecraft mc) { + if (!LumenClient.settings.streamMode) return "Stream mode is off"; + if (!Platform.isWindows()) return "Windows 10 (2004+) or Windows 11 required"; + if (mc.getWindow().isExclusiveFullscreen()) return "Use windowed or borderless fullscreen"; + if (!LumenClient.settings.xray) return "Stream mode ready; Xray is off"; + return StreamOverlay.status(); + } +} diff --git a/src/main/java/net/auronnetwork/lumen/StreamOverlay.java b/src/main/java/net/auronnetwork/lumen/StreamOverlay.java new file mode 100644 index 0000000..64e2aa3 --- /dev/null +++ b/src/main/java/net/auronnetwork/lumen/StreamOverlay.java @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: GPL-3.0-only +package net.auronnetwork.lumen; + +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Platform; +import com.sun.jna.Pointer; +import com.sun.jna.platform.win32.GDI32; +import com.sun.jna.platform.win32.Kernel32; +import com.sun.jna.platform.win32.User32; +import com.sun.jna.platform.win32.WinDef.HBITMAP; +import com.sun.jna.platform.win32.WinDef.HDC; +import com.sun.jna.platform.win32.WinDef.HINSTANCE; +import com.sun.jna.platform.win32.WinDef.HWND; +import com.sun.jna.platform.win32.WinDef.LPARAM; +import com.sun.jna.platform.win32.WinDef.LRESULT; +import com.sun.jna.platform.win32.WinDef.POINT; +import com.sun.jna.platform.win32.WinDef.RECT; +import com.sun.jna.platform.win32.WinDef.WPARAM; +import com.sun.jna.platform.win32.WinNT.HANDLE; +import com.sun.jna.platform.win32.WinUser.MSG; +import com.sun.jna.platform.win32.WinUser.SIZE; +import com.sun.jna.platform.win32.WinUser.WNDCLASSEX; +import com.sun.jna.platform.win32.WinUser.WindowProc; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.win32.StdCallLibrary; +import com.sun.jna.win32.W32APIOptions; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +/** + * A separate, click-through Windows window. No marker is drawn into Minecraft's + * framebuffer. Windows capture exclusion is verified before this window is shown. + * Capture programs must support the Windows display-affinity contract. + */ +public final class StreamOverlay { + /** Coordinates are relative to the physical Minecraft client area; color is ARGB. */ + public record Line(float x0, float y0, float x1, float y1, int color) { } + + private record Frame(long owner, List lines, long submitted, long generation) { } + + private static final long FRAME_PERIOD = 1_000_000_000L / 60; + private static final long STALE_AFTER = 250_000_000L; + private static final int EXCLUDE_FROM_CAPTURE = 0x11; + private static final AtomicReference LATEST = new AtomicReference<>(); + private static final AtomicLong GENERATION = new AtomicLong(); + private static final Object LIFECYCLE = new Object(); + private static volatile Worker worker; + private static volatile boolean unavailable; + private static volatile boolean ready; + private static volatile String status = "Overlay starts when markers are available."; + private static volatile long nativeWindow; + private static volatile boolean visible; + private static volatile int lineCount; + private static volatile int affinity; + + private StreamOverlay() { } + + /** Publishes only the latest immutable frame. Native work happens on a daemon thread. */ + public static void submit(long minecraftHwnd, List immutableLines) { + if (minecraftHwnd == 0 || immutableLines == null || immutableLines.isEmpty()) { + hide(); + return; + } + if (unavailable) return; + LATEST.set(new Frame(minecraftHwnd, immutableLines, System.nanoTime(), GENERATION.get())); + startIfNeeded(); + Worker current = worker; + if (current != null) LockSupport.unpark(current); + } + + /** Invalidates any frame already being prepared; the worker hides within one tick. */ + public static void hide() { + GENERATION.incrementAndGet(); + LATEST.set(null); + Worker current = worker; + if (current != null) LockSupport.unpark(current); + } + + /** Asynchronously destroys the native resources. A later submission can restart it. */ + public static void close() { + hide(); + synchronized (LIFECYCLE) { + Worker current = worker; + if (current != null) { + current.stopping = true; + ready = false; + LockSupport.unpark(current); + } + } + } + + public static String status() { return status; } + public static boolean ready() { return ready; } + + // Read-only diagnostics for the in-client smoke test. These never call Win32. + public static long windowHandle() { return nativeWindow; } + public static boolean visible() { return visible; } + public static int lineCount() { return lineCount; } + public static int captureAffinity() { return affinity; } + + private static void startIfNeeded() { + if (worker != null || unavailable) return; + synchronized (LIFECYCLE) { + if (worker == null && !unavailable && LATEST.get() != null) { + Worker next = new Worker(); + worker = next; + status = "Starting Windows overlay..."; + next.start(); + } + } + } + + private interface OverlayUser32 extends User32 { + boolean ClientToScreen(HWND hwnd, POINT point); + boolean IsIconic(HWND hwnd); + boolean SetWindowDisplayAffinity(HWND hwnd, int affinity); + boolean GetWindowDisplayAffinity(HWND hwnd, IntByReference affinity); + Pointer SetThreadDpiAwarenessContext(Pointer context); + } + + private interface OverlayGdi32 extends GDI32 { + HANDLE CreatePen(int style, int width, int color); + boolean PolyPolyline(HDC dc, Pointer points, int[] pointCounts, int polylineCount); + boolean PatBlt(HDC dc, int x, int y, int width, int height, int operation); + boolean GdiFlush(); + } + + private interface Dwm extends StdCallLibrary { + int DwmIsCompositionEnabled(IntByReference enabled); + } + + private interface Nt extends StdCallLibrary { + int RtlGetVersion(Pointer version); + } + + private static final class Worker extends Thread { + private static final int EX_STYLE = 0x00080000 | 0x00000020 | 0x00000080 + | 0x08000000 | 0x00000008; // LAYERED, TRANSPARENT, TOOLWINDOW, NOACTIVATE, TOPMOST + private static final int BLACKNESS = 0x00000042; + private static final HWND TOPMOST = new HWND(Pointer.createConstant(-1L)); + private final String className = "LumenStreamOverlay_" + Long.toUnsignedString(System.nanoTime()); + private final WindowProc windowProc = this::windowProcedure; + private volatile boolean stopping; + private OverlayUser32 user; + private OverlayGdi32 gdi; + private Dwm dwm; + private HINSTANCE instance; + private HWND window; + private HDC screenDc; + private HDC bufferDc; + private HBITMAP bitmap; + private HANDLE previousBitmap; + private Pointer previousDpi; + private boolean registered; + private int width; + private int height; + + private Worker() { + super("Lumen Windows stream overlay"); + setDaemon(true); + } + + @Override + public void run() { + try { + initialize(); + if (!stopping) ready = true; + long nextFrame = 0; + MSG message = new MSG(); + while (!stopping) { + while (user.PeekMessage(message, null, 0, 0, 1)) { + if (message.message == 0x0012) { // WM_QUIT + stopping = true; + break; + } + user.TranslateMessage(message); + user.DispatchMessage(message); + } + if (stopping) break; + if (LATEST.get() == null) hideNative("Ready; no markers to display."); + long now = System.nanoTime(); + if (now >= nextFrame) { + update(now); + nextFrame = now + FRAME_PERIOD; + } + LockSupport.parkNanos(this, Math.max(1, nextFrame - System.nanoTime())); + } + } catch (Throwable failure) { + unavailable = true; + String reason = failure.getMessage(); + status = "Overlay unavailable: " + (reason == null + ? failure.getClass().getSimpleName() : reason); + } finally { + ready = false; + cleanup(); + synchronized (LIFECYCLE) { + if (worker == this) worker = null; + if (!unavailable) { + status = "Overlay closed."; + startIfNeeded(); + } + } + } + } + + private void initialize() { + require(Platform.isWindows(), "Windows 10 version 2004 or newer is required."); + Nt nt = Native.load("ntdll", Nt.class, W32APIOptions.DEFAULT_OPTIONS); + try (Memory version = new Memory(284)) { // RTL_OSVERSIONINFOEXW, independent of app manifest + version.clear(); + version.setInt(0, 284); + require(nt.RtlGetVersion(version) == 0, "Cannot verify the Windows version."); + int major = version.getInt(4); + int build = version.getInt(12); + require(major > 10 || (major == 10 && build >= 19041), + "Windows 10 version 2004 or newer is required."); + } + user = Native.load("user32", OverlayUser32.class, W32APIOptions.DEFAULT_OPTIONS); + gdi = Native.load("gdi32", OverlayGdi32.class, W32APIOptions.DEFAULT_OPTIONS); + dwm = Native.load("dwmapi", Dwm.class, W32APIOptions.DEFAULT_OPTIONS); + require(compositionEnabled(), "Windows desktop composition is required."); + // Only the overlay thread changes awareness; Minecraft's process state is untouched. + previousDpi = user.SetThreadDpiAwarenessContext(Pointer.createConstant(-4L)); + require(previousDpi != null, "Cannot enable physical-pixel overlay coordinates."); + instance = Kernel32.INSTANCE.GetModuleHandle(null); + WNDCLASSEX definition = new WNDCLASSEX(); + definition.hInstance = instance; + definition.lpszClassName = className; + definition.lpfnWndProc = windowProc; + require(user.RegisterClassEx(definition).intValue() != 0, nativeError("RegisterClassEx")); + registered = true; + // No WS_VISIBLE, no owner and no parent: this is our own hidden top-level window. + window = user.CreateWindowEx(EX_STYLE, className, "Lumen stream overlay", 0x80000000, + 0, 0, 1, 1, null, null, instance, null); + require(valid(window), nativeError("CreateWindowEx")); + nativeWindow = address(window); + require(user.SetWindowDisplayAffinity(window, EXCLUDE_FROM_CAPTURE), + nativeError("Windows capture exclusion")); + verifyAffinity(); + screenDc = user.GetDC(null); + require(valid(screenDc), nativeError("GetDC")); + bufferDc = gdi.CreateCompatibleDC(screenDc); + require(valid(bufferDc), nativeError("CreateCompatibleDC")); + status = "Ready; Windows capture exclusion verified."; + } + + private void update(long now) { + Frame frame = LATEST.get(); + if (frame == null || frame.generation != GENERATION.get() + || now - frame.submitted > STALE_AFTER || frame.lines.isEmpty()) { + hideNative("Ready; waiting for current markers."); + return; + } + HWND owner = new HWND(Pointer.createConstant(frame.owner)); + if (!user.IsWindow(owner) || !user.IsWindowVisible(owner) || user.IsIconic(owner) + || address(user.GetForegroundWindow()) != frame.owner) { + hideNative("Ready; Minecraft must be in the foreground."); + return; + } + require(compositionEnabled(), "Windows desktop composition stopped."); + verifyAffinity(); + RECT client = new RECT(); + if (!user.GetClientRect(owner, client)) { + hideNative("Ready; waiting for the Minecraft window."); + return; + } + POINT origin = new POINT(client.left, client.top); + POINT corner = new POINT(client.right, client.bottom); + if (!user.ClientToScreen(owner, origin) || !user.ClientToScreen(owner, corner)) { + hideNative("Ready; waiting for the Minecraft window."); + return; + } + int newWidth = corner.x - origin.x; + int newHeight = corner.y - origin.y; + if (newWidth <= 0 || newHeight <= 0) { + hideNative("Ready; Minecraft is minimized."); + return; + } + require(newWidth <= 32768 && newHeight <= 32768 + && (long) newWidth * newHeight <= 67_108_864, + "The Minecraft window exceeds the supported overlay size."); + resizeBuffer(newWidth, newHeight); + int drawn = draw(frame.lines); + // hide()/close() can invalidate an expensive frame while GDI prepares it. + if (stopping || frame.generation != GENERATION.get() || LATEST.get() == null + || System.nanoTime() - frame.submitted > STALE_AFTER || drawn == 0 + || address(user.GetForegroundWindow()) != frame.owner || user.IsIconic(owner)) { + hideNative("Ready; waiting for current markers."); + return; + } + require(user.UpdateLayeredWindow(window, screenDc, origin, new SIZE(width, height), + bufferDc, new POINT(0, 0), 0, null, 1), nativeError("UpdateLayeredWindow")); + // Check once more immediately before making the prepared layer visible. + verifyAffinity(); + if (stopping || frame.generation != GENERATION.get() || LATEST.get() == null) { + hideNative("Ready; waiting for current markers."); + return; + } + require(user.SetWindowPos(window, TOPMOST, 0, 0, 0, 0, + 0x0001 | 0x0002 | 0x0010 | 0x0040), nativeError("SetWindowPos")); + visible = true; + lineCount = drawn; + status = "Active; Windows capture exclusion verified."; + } + + private void verifyAffinity() { + IntByReference actual = new IntByReference(); + require(user.GetWindowDisplayAffinity(window, actual) + && actual.getValue() == EXCLUDE_FROM_CAPTURE, + "Windows capture exclusion could not be verified."); + affinity = actual.getValue(); + } + + private boolean compositionEnabled() { + IntByReference enabled = new IntByReference(); + return dwm.DwmIsCompositionEnabled(enabled) >= 0 && enabled.getValue() != 0; + } + + private void resizeBuffer(int newWidth, int newHeight) { + if (newWidth == width && newHeight == height) return; + releaseBitmap(); + bitmap = gdi.CreateCompatibleBitmap(screenDc, newWidth, newHeight); + require(valid(bitmap), nativeError("CreateCompatibleBitmap")); + previousBitmap = gdi.SelectObject(bufferDc, bitmap); + require(valid(previousBitmap), nativeError("SelectObject")); + width = newWidth; + height = newHeight; + } + + private int draw(List lines) { + require(gdi.PatBlt(bufferDc, 0, 0, width, height, BLACKNESS), nativeError("PatBlt")); + Map> batches = new LinkedHashMap<>(); + int count = 0; + for (Line line : lines) { + if (line == null || !Float.isFinite(line.x0) || !Float.isFinite(line.y0) + || !Float.isFinite(line.x1) || !Float.isFinite(line.y1)) continue; + int rgb = line.color & 0x00ffffff; + if (rgb == 0) rgb = 0x010101; // Black is reserved for the transparent background. + int colorRef = ((rgb & 0xff) << 16) | (rgb & 0xff00) | ((rgb >>> 16) & 0xff); + batches.computeIfAbsent(colorRef, ignored -> new ArrayList<>()).add(line); + } + for (var entry : batches.entrySet()) { + var batch = entry.getValue(); + // POINT is exactly two 32-bit LONGs on both 32- and 64-bit Windows. + // Pack Java arrays first, then copy once instead of making a native call per point. + int[] coordinates = new int[Math.multiplyExact(batch.size(), 4)]; + int[] pointCounts = new int[batch.size()]; + for (int i = 0; i < batch.size(); i++) { + Line line = batch.get(i); + coordinates[i * 4] = coordinate(line.x0, width); + coordinates[i * 4 + 1] = coordinate(line.y0, height); + coordinates[i * 4 + 2] = coordinate(line.x1, width); + coordinates[i * 4 + 3] = coordinate(line.y1, height); + pointCounts[i] = 2; // Each disconnected polyline is one marker edge. + } + HANDLE pen = gdi.CreatePen(0, 2, entry.getKey()); // Two physical pixels. + require(valid(pen), nativeError("CreatePen")); + HANDLE originalPen = null; + try (Memory points = new Memory((long) coordinates.length * Integer.BYTES)) { + points.write(0, coordinates, 0, coordinates.length); + originalPen = gdi.SelectObject(bufferDc, pen); + require(valid(originalPen), nativeError("SelectObject")); + require(gdi.PolyPolyline(bufferDc, points, pointCounts, batch.size()), + nativeError("Draw markers")); + count += batch.size(); + } finally { + if (valid(originalPen)) gdi.SelectObject(bufferDc, originalPen); + gdi.DeleteObject(pen); + } + } + require(gdi.GdiFlush(), nativeError("GdiFlush")); + return count; + } + + private LRESULT windowProcedure(HWND hwnd, int message, WPARAM wParam, LPARAM lParam) { + return switch (message) { + case 0x0084 -> new LRESULT(-1); // WM_NCHITTEST: HTTRANSPARENT + case 0x0021 -> new LRESULT(3); // WM_MOUSEACTIVATE: MA_NOACTIVATE + case 0x0014 -> new LRESULT(1); // WM_ERASEBKGND: the complete layer is supplied atomically + case 0x0010 -> { // WM_CLOSE, always destroy on the owning thread + stopping = true; + user.ShowWindow(hwnd, 0); + yield new LRESULT(0); + } + case 0x0002 -> { stopping = true; yield new LRESULT(0); } // WM_DESTROY + default -> user.DefWindowProc(hwnd, message, wParam, lParam); + }; + } + + private void hideNative(String reason) { + if (visible) user.ShowWindow(window, 0); + visible = false; + lineCount = 0; + status = reason; + } + + private void releaseBitmap() { + if (previousBitmap != null) { + gdi.SelectObject(bufferDc, previousBitmap); + previousBitmap = null; + } + if (bitmap != null) { + gdi.DeleteObject(bitmap); + bitmap = null; + } + width = height = 0; + } + + private void cleanup() { + // Every native object belongs to this thread. Independent cleanup steps prevent a + // secondary native error from leaving a visible layer or a registered callback. + try { if (user != null && valid(window)) user.ShowWindow(window, 0); } catch (Throwable ignored) { } + visible = false; + lineCount = 0; + affinity = 0; + nativeWindow = 0; + try { if (user != null && valid(window)) user.DestroyWindow(window); } catch (Throwable ignored) { } + try { if (gdi != null) releaseBitmap(); } catch (Throwable ignored) { } + try { if (gdi != null && valid(bufferDc)) gdi.DeleteDC(bufferDc); } catch (Throwable ignored) { } + try { if (user != null && valid(screenDc)) user.ReleaseDC(null, screenDc); } catch (Throwable ignored) { } + try { if (registered) user.UnregisterClass(className, instance); } catch (Throwable ignored) { } + try { if (previousDpi != null) user.SetThreadDpiAwarenessContext(previousDpi); } catch (Throwable ignored) { } + } + } + + private static int coordinate(float normalized, int extent) { + // A malformed/offscreen projection must not overflow GDI's coordinate range. + return (int) Math.max(-65536, Math.min(65536, (double) normalized * extent)); + } + + private static long address(HANDLE handle) { + return handle == null ? 0 : Pointer.nativeValue(handle.getPointer()); + } + + private static boolean valid(HANDLE handle) { + long value = address(handle); + return value != 0 && value != -1; + } + + private static String nativeError(String action) { + return action + " failed (Windows error " + Native.getLastError() + ")."; + } + + private static void require(boolean condition, String reason) { + if (!condition) throw new IllegalStateException(reason); + } +} diff --git a/src/main/java/net/auronnetwork/lumen/mixin/GameRendererMixin.java b/src/main/java/net/auronnetwork/lumen/mixin/GameRendererMixin.java index 9940fee..2df4125 100644 --- a/src/main/java/net/auronnetwork/lumen/mixin/GameRendererMixin.java +++ b/src/main/java/net/auronnetwork/lumen/mixin/GameRendererMixin.java @@ -2,6 +2,8 @@ package net.auronnetwork.lumen.mixin; import net.auronnetwork.lumen.MarkerOverlay; +import net.auronnetwork.lumen.StreamMode; +import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.GameRenderer; import org.joml.Matrix4f; import org.spongepowered.asm.mixin.Mixin; @@ -14,6 +16,7 @@ abstract class GameRendererMixin { @Inject(method = "extract", at = @At("HEAD")) private void lumen$clearPreviousFrame(CallbackInfo ci) { + StreamMode.refresh(Minecraft.getInstance()); ((GameRenderer)(Object)this).gameRenderState().guiRenderState.setData(MarkerOverlay.KEY, null); } diff --git a/src/test/java/net/auronnetwork/lumen/LumenCoreTest.java b/src/test/java/net/auronnetwork/lumen/LumenCoreTest.java index 79d3a86..7d3764a 100644 --- a/src/test/java/net/auronnetwork/lumen/LumenCoreTest.java +++ b/src/test/java/net/auronnetwork/lumen/LumenCoreTest.java @@ -50,6 +50,21 @@ class LumenCoreTest { Files.writeString(file, "noAttackCooldown=invalid\n"); assertFalse(LumenSettings.load(file).noAttackCooldown); } + @Test void streamModeDefaultsOffAndPersistsWithoutChangingOtherOptions() throws Exception { + var file = directory.resolve("lumen.properties"); + assertFalse(LumenSettings.load(file).streamMode); + Files.writeString(file, "xray=true\nthroughWalls=false\nfilters=8191\n"); + var s = LumenSettings.load(file); + assertFalse(s.streamMode); + s.streamMode = true; s.save(file); + var restored = LumenSettings.load(file); + assertTrue(restored.streamMode); assertTrue(restored.xray); + assertFalse(restored.throughWalls); assertEquals(s.filters, restored.filters); + restored.streamMode = false; restored.save(file); + assertFalse(LumenSettings.load(file).streamMode); + Files.writeString(file, "streamMode=invalid\n"); + assertFalse(LumenSettings.load(file).streamMode); + } @Test void markerOrderingLimitsAndRefreshTrackWorldChanges() { var scanner = new BlockScanner(); var world = new HashMap(); var near = new BlockScanner.Position(1, 0, 0); var far = new BlockScanner.Position(3, 0, 0);