diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a70ab7cbc..8003db66d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,10 @@ jobs: run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()" - name: Compile the private terrain tick membership fixture run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()" + - name: Compile the private culling whitelist fixture + run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()" + - name: Compile the private terrain read shutdown fixture + run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')" - name: Install headless graphics libraries for applied-hook checks if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0 @@ -72,6 +76,12 @@ jobs: - name: Verify applied tick membership transitions and allocation if: runner.os == 'Linux' run: python tools/test_tick_dedup.py + - name: Verify live culling whitelist registration and empty iteration + if: runner.os == 'Linux' + run: python tools/test_culling_empty.py + - name: Verify interrupted DH reads and preserved shared futures + if: runner.os == 'Linux' + run: python tools/test_terrain_read.py - name: Upload original mod and pack manifests only uses: actions/upload-artifact@v4 with: @@ -85,3 +95,5 @@ jobs: dist/object-header-benchmark.txt dist/dispatch-integration.json dist/tick-dedup-integration.json + dist/culling-empty-integration.json + dist/terrain-read-integration.json diff --git a/README.md b/README.md index d2ff5de59..8e545f0df 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Client-first performance research for Minecraft Java **26.3**, preserving vanill ## Play on your Mac -Download **horizon-mac-starter-0.13.0.zip** from [Releases](https://github.com/ludovic111/minecraft-optimization-lab/releases), extract it and open `Mac-Start.command`. It verifies and installs official universal Prism Launcher in `~/Applications` if needed, then opens the balanced pack import. Sign into Microsoft **inside Prism yourself**. This project needs no credentials. +Download **horizon-mac-starter-0.14.0.zip** from [Releases](https://github.com/ludovic111/minecraft-optimization-lab/releases), extract it and open `Mac-Start.command`. It verifies and installs official universal Prism Launcher in `~/Applications` if needed, then opens the balanced pack import. Sign into Microsoft **inside Prism yourself**. This project needs no credentials. Use native **Java 25 ARM64**, maximum heap **6144 MiB**, minimum **512 MiB** on your 16 GB M2 Pro. Prism can download Java automatically. Start at 1280×800 or 1920×1200; full Retina resolution is a larger GPU workload. [START-HERE.txt](packs/START-HERE.txt) has details. The same `.mrpack` files import into Prism on Windows and Linux. @@ -21,6 +21,7 @@ Use native **Java 25 ARM64**, maximum heap **6144 MiB**, minimum **512 MiB** on | adaptive-queue (experimental) | 12 | 128 | 6144 MiB | | dispatch-retry (experimental) | 12 | 128 | 6144 MiB | | tick-dedup (experimental) | 12 | 128 | 6144 MiB | +| culling-empty (experimental) | 12 | 128 | 6144 MiB | | foreground-first (combined experiment) | 12 | 128 | 6144 MiB | | foreground-vulkan (combined experiment) | 12 | 128 | 6144 MiB | | low-memory | 8 | none | 3072 MiB | @@ -34,6 +35,13 @@ Distant LOD is simplified terrain. Nearby blocks retain full detail. Initial gen The optional `queue-wait` pack yields during pending distant-terrain debounce retries; `adaptive-queue` combines it with the existing controllers. [Queue experiment and measurement limits](docs/debounce-queue.md). +Version 0.14 adds an isolated [empty culling whitelist experiment](docs/culling-empty.md) +that avoids allocating iterators while there are no dynamic handlers. Live +registration and nonempty callbacks retain upstream behavior. Balanced keeps it +off; whole-game gains still need hardware comparisons. DH profiles also request +a scoped [interruptible blending-read wait](docs/terrain-read-wait.md) to let +background workers leave a pending metadata read during shutdown. + Version 0.13 adds an isolated [tick membership experiment](docs/tick-membership.md) that reuses compact storage for DH's private per-tick membership checks. The combined foreground profiles include it too; balanced keeps it off. The applied diff --git a/benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json b/benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json new file mode 100644 index 000000000..5a3a31a0d --- /dev/null +++ b/benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json @@ -0,0 +1,47 @@ +{ + "schema": 1, + "platform": "Linux-6.17.0-1022-azure-x86_64-with-glibc2.39", + "architecture": "x86_64", + "minecraft": "26.3", + "entity_culling": "1.11.2", + "results": [ + { + "enabled": false, + "empty_iterator_calls": 1, + "live_registration_checked": true, + "block_callback_order_checked": true, + "type_guard_checked": true, + "allocated_bytes": 128001272, + "cpu_ns": 29632156, + "wall_ns": 49712014, + "measured_calls": 4000000, + "result": false + }, + { + "enabled": true, + "empty_iterator_calls": 0, + "live_registration_checked": true, + "block_callback_order_checked": true, + "type_guard_checked": true, + "allocated_bytes": 208, + "cpu_ns": 809881, + "wall_ns": 801517, + "measured_calls": 4000000, + "result": false + } + ], + "notes": [ + "Actual publisher whitelist methods inside Fabric; live registration and block-entity callbacks checked.", + "Nonempty iteration, callback order, short circuit, null/wrong-type inputs and clearing retain upstream behavior.", + "Empty-state iterator counts use a private instrumented set; allocation loop uses normal empty HashSets.", + "Entity callbacks with a valid game entity are not exercised by this menu-only fixture.", + "JIT escape analysis can remove allocations in an isolated loop; real culling-thread profiles are needed.", + "Not a game FPS, whole-game RAM, visual equivalence or Mac GPU benchmark.", + "Much of an empty warmed loop may be folded by the JIT; CPU timings are not per-check costs or game speed estimates." + ], + "provenance": { + "source_commit": "35dd69e9a6279fa89d762a0d24e49b0dbdb11b51", + "ci_run": "https://github.com/ludovic111/minecraft-optimization-lab/actions/runs/37164002916", + "artifact": "horizon-ubuntu-latest" + } +} diff --git a/benchmarks/terrain-read-2026-10-04/linux-applied-hooks.json b/benchmarks/terrain-read-2026-10-04/linux-applied-hooks.json new file mode 100644 index 000000000..1c3137e29 --- /dev/null +++ b/benchmarks/terrain-read-2026-10-04/linux-applied-hooks.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "platform": "Linux-6.17.0-1022-azure-x86_64-with-glibc2.39", + "architecture": "x86_64", + "minecraft": "26.3", + "distant_horizons": "3.3.4", + "original_mod_sha256": "c506982631880604513de8ef85e3b4461fbb898702e60b4a6f74ca3b3d4a687f", + "results": [ + { + "enabled": false, + "applied_io_worker_checked": true, + "ordinary_join_checked": true, + "shared_future_preserved": true, + "exception_identity_checked": true, + "completed_interrupt_checked": true, + "cancelled_completion_checked": true, + "preinterrupted_pending_checked": true + }, + { + "enabled": true, + "applied_io_worker_checked": true, + "ordinary_join_checked": true, + "shared_future_preserved": true, + "exception_identity_checked": true, + "completed_interrupt_checked": true, + "cancelled_completion_checked": true, + "preinterrupted_pending_checked": true + } + ], + "notes": [ + "Actual game IOWorker under Fabric, with controlled cached blending-read futures.", + "DH worker interruption releases only its waiter; the shared future remains consumable.", + "Ordinary callers retain join behavior; completed values, cancellation and exception identity are checked.", + "This is a shutdown correctness fixture, not an FPS or world-generation throughput benchmark." + ], + "provenance": { + "source_commit": "9a1b22aa56f58a3ee39a3564be859a662d2beedc", + "workflow_run": "https://github.com/ludovic111/minecraft-optimization-lab/actions/runs/37166150119", + "job": "portable-mod (ubuntu-latest)", + "artifact": "horizon-ubuntu-latest", + "command": "python tools/test_terrain_read.py" + } +} diff --git a/benchmarks/terrain-read-2026-10-04/local-applied-hooks.json b/benchmarks/terrain-read-2026-10-04/local-applied-hooks.json new file mode 100644 index 000000000..51b186dd7 --- /dev/null +++ b/benchmarks/terrain-read-2026-10-04/local-applied-hooks.json @@ -0,0 +1,41 @@ +{ + "schema": 1, + "platform": "Linux-7.0.0-31-generic-x86_64-with-glibc2.43", + "architecture": "x86_64", + "minecraft": "26.3", + "distant_horizons": "3.3.4", + "original_mod_sha256": "c506982631880604513de8ef85e3b4461fbb898702e60b4a6f74ca3b3d4a687f", + "results": [ + { + "enabled": false, + "applied_io_worker_checked": true, + "ordinary_join_checked": true, + "shared_future_preserved": true, + "exception_identity_checked": true, + "completed_interrupt_checked": true, + "cancelled_completion_checked": true, + "preinterrupted_pending_checked": true + }, + { + "enabled": true, + "applied_io_worker_checked": true, + "ordinary_join_checked": true, + "shared_future_preserved": true, + "exception_identity_checked": true, + "completed_interrupt_checked": true, + "cancelled_completion_checked": true, + "preinterrupted_pending_checked": true + } + ], + "notes": [ + "Actual game IOWorker under Fabric, with controlled cached blending-read futures.", + "DH worker interruption releases only its waiter; the shared future remains consumable.", + "Ordinary callers retain join behavior; completed values, cancellation and exception identity are checked.", + "This is a shutdown correctness fixture, not an FPS or world-generation throughput benchmark." + ], + "provenance": { + "source_commit": "9a1b22aa56f58a3ee39a3564be859a662d2beedc", + "environment": "local Linux x64 software graphics; actual Fabric fixture", + "command": "python tools/test_terrain_read.py" + } +} diff --git a/docs/culling-empty.md b/docs/culling-empty.md new file mode 100644 index 000000000..c1a45c3af --- /dev/null +++ b/docs/culling-empty.md @@ -0,0 +1,58 @@ +# Avoid allocating iterators for empty culling whitelists + +A local game JFR found `HashMap.KeySet` iterator allocation in Entity Culling's +dynamic entity/block-entity whitelist checks on `CullThread`. The pinned 1.11.2 +methods create a set iterator before discovering that there are no handlers. + +The optional hook returns the JDK's shared empty iterator while the live set is +empty. It checks that set every call; it does not cache a whitelist, remember an +empty state, skip a registered handler, or alter culling decisions. Nonempty +sets use their original iterator, callback order, type guard and short circuit. +No per-call callback object is introduced by the hook. + +Use the `culling-empty` profile for an isolated comparison with `m2-balanced`. +Both keep the same graphics, LOD radius, worker budget and other controller +settings. The hook is opt-in, restricted to Entity Culling 1.11.2, and can be +disabled with `enabled=false` in `config/horizon-culling-empty.properties` and a +restart. Frame reports record `empty_culling_whitelist`; the CLI and offline +viewer disclose a difference as an experiment variable. + +`python tools/test_culling_empty.py` runs the actual publisher methods inside +Fabric. It checks empty iterator dispatch, registration after initialization, +duplicate registration, real block-entity callback order/short circuit, null and +wrong-type inputs, and returning to an empty state. The menu-only fixture does +not exercise callbacks using a valid world entity; nonempty entity iteration +uses the original implementation. Private instrumentation and fixture JARs +never enter release packs. Full game logs remain private. + +A local warmed loop of four million empty checks allocated 128,001,248 bytes +upstream versus 1,248 with the hook. CPU was 41 ms versus 33 ms in that single +observation. These counts isolate this allocation path; JIT escape analysis can +produce different results in other calling contexts. They are not game FPS, +whole-game RAM, statistical speed estimates or Mac GPU results. Repeated matching workloads and real hardware tests remain needed. A fixed-order +Linux software-rendered pair observed about 97,445 versus 35,128 allocated bytes +per second on `CullThread`, over separate 64-second counter spans. The captured +graphics matched and both recordings excluded zero frames, but this one pair is +not a repeatable FPS or retained-RAM result. The candidate also encountered an +intermittent shutdown wait after its world had saved; the control exited normally. +See the [terrain read shutdown check](terrain-read-wait.md). + +For local world smoke checks, the baseline and candidate use separate copies of +the same peaceful demo-world snapshot. An earlier capture was interrupted by a +zombie killing the player and is excluded from performance comparisons. Release +packs do not change world difficulty. Frame reports now capture actual world +difficulty at their start/end boundaries so comparison tools can flag a mismatch. + +The [sanitized Linux CI result](../benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json) +includes its source/workflow provenance and independently checks live registration +and the applied shortcut. CPU timings can include JIT folding of this empty loop; +they are not per-check costs or estimates of game speed. + +The final candidate JAR (`c506982631880604513de8ef85e3b4461fbb898702e60b4a6f74ca3b3d4a687f`) +then recorded 579 frame intervals and 2,408 integrated-server tick durations over +120 seconds, with zero excluded frames and matching CSV counts/means. Its actual +frame report retained custom graphics, render/simulation distances 12/12, DH radius +128 at MEDIUM/MEDIUM quality, two workers at runtime ratio 0.5, compact headers, +and both the culling shortcut and scoped terrain-read fix enabled. No captured +visual setting changed. This verifies integration; it is not a repeated hardware +performance comparison. diff --git a/docs/terrain-read-wait.md b/docs/terrain-read-wait.md new file mode 100644 index 000000000..399270684 --- /dev/null +++ b/docs/terrain-read-wait.md @@ -0,0 +1,50 @@ +# Release a blocked terrain read during shutdown + +A local world test saved its chunks and closed the DH databases, then hit the +client shutdown watchdog. Its remaining non-daemon DH world-generation worker +was waiting in `IOWorker.isOldChunkAround` on a cached blending-metadata future. +The unchanged control exited normally, so the failure is intermittent; this +observation does not attribute it to the culling optimization. + +The scoped redirect uses an interruptible wait only for pending blending reads +on threads named `DH-World Gen Thread[...]`. The pinned DH 3.3.4 pool uses this +name and interrupts remaining workers during shutdown. Ordinary Minecraft +callers keep `CompletableFuture.join` behavior. Completed results use the original +join path. Failed reads retain join's exception identity and wrapping behavior. + +An interrupted DH waiter restores its interrupt flag and throws a completion +exception whose interrupted cause is recognized by DH's existing shutdown +handling. It never cancels or completes the cached future, which belongs to other +callers too. Chunk stores, saves, storage closure and world difficulty are untouched. +This changes cancellation of an interrupted background task, rather than terrain +results from an uninterrupted task. + +Profiles with distant terrain request the fix; baseline and low-memory profiles +without DH do not. It is restricted to Minecraft 26.3 and DH 3.3.4. To opt out, +set `enabled=false` in `config/horizon-terrain-read-wait.properties` and restart. +Frame reports record `interruptible_terrain_read`, and comparison tools disclose +changes in that state. + +`python tools/test_terrain_read.py` checks the actual transformed game IOWorker +inside Fabric, with controlled cached metadata futures. It forces an interrupted +pending DH read, verifies the shared future remains available to a later caller, +and checks that ordinary callers remain blocked until completion. It also +checks an interrupt already set before a pending read, successful completed +reads with an interrupt already set, cancelled +futures, exceptional completion identity, and recognition by DH's shutdown +exception utility. The private fixture does not enter release packs. This is a +correctness check, not an FPS, throughput or Mac shutdown benchmark. + +The [sanitized Linux CI result](../benchmarks/terrain-read-2026-10-04/linux-applied-hooks.json) +includes source/workflow provenance and the original mod checksum. The CI JAR +matches the local candidate byte-for-byte. Windows and hosted Apple Silicon +macOS 26 compile the fixture and pass the portable build/metrics checks; applied +read interruption is exercised on Linux, not a live macOS game. + +The exact candidate also completed a 120-second local world recording, saved, +and reloaded that world in the same client. After the first save, thread inspection +found no remaining DH generation workers and no background workers blocked on a +future. After reload, two generation workers existed and neither was waiting in +blending metadata; no background metadata-scan wait was present at that snapshot. +This is one software-rendered lifecycle observation, not proof that every +shutdown race, world or platform is covered. diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/budget/TerrainReadWait.java b/mods/horizon-lab/src/main/java/dev/horizonlab/budget/TerrainReadWait.java new file mode 100644 index 000000000..e2210a1a9 --- /dev/null +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/budget/TerrainReadWait.java @@ -0,0 +1,27 @@ +package dev.horizonlab.budget; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; + +/** Allow the pinned DH generation pool's shutdown interrupt to release a read-only wait. */ +public final class TerrainReadWait { + private TerrainReadWait() {} + + public static T join(CompletableFuture future) { + // Ordinary game callers retain join's interrupt behavior. Completed reads use the original path. + if (future.isDone() || !Thread.currentThread().getName().startsWith("DH-World Gen Thread[")) + return future.join(); + try { + return future.get(); + } catch (InterruptedException shutdown) { + Thread.currentThread().interrupt(); + // DH recognizes this cause as shutdown. The cached future belongs to other callers too. + throw new CompletionException(shutdown); + } catch (ExecutionException | CancellationException failure) { + // Now completed: retain join's original exception identity and wrapping semantics. + return future.join(); + } + } +} diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java b/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java index a3f80c882..e9e2cf095 100644 --- a/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java @@ -97,6 +97,8 @@ public final class HorizonClient implements ClientModInitializer { context.put("wakeable_idle_wait", dev.horizonlab.mixin.HorizonMixinPlugin.debounceWaitEnabled()); context.put("dispatch_retry_backoff", dev.horizonlab.mixin.HorizonMixinPlugin.dispatchRetryEnabled()); context.put("tick_membership_dedup", dev.horizonlab.mixin.HorizonMixinPlugin.tickDedupEnabled()); + context.put("empty_culling_whitelist", dev.horizonlab.mixin.HorizonMixinPlugin.cullingEmptyEnabled()); + context.put("interruptible_terrain_read", dev.horizonlab.mixin.HorizonMixinPlugin.terrainReadWaitEnabled()); context.put("adaptive_target_fps", targetFps); context.putAll(visualSettings(client)); var device = RenderSystem.getDevice().getDeviceInfo(); @@ -115,6 +117,7 @@ public final class HorizonClient implements ClientModInitializer { private static Map visualSettings(Minecraft client) { Map context = new LinkedHashMap<>(); + if (client.level != null) context.put("world_difficulty", client.level.getDifficulty().name()); context.put("render_distance", client.options.renderDistance().get()); context.put("simulation_distance", client.options.simulationDistance().get()); context.put("vsync", client.options.enableVsync().get()); diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/CullingEmptyWhitelistMixin.java b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/CullingEmptyWhitelistMixin.java new file mode 100644 index 000000000..8b16c0449 --- /dev/null +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/CullingEmptyWhitelistMixin.java @@ -0,0 +1,19 @@ +package dev.horizonlab.mixin; + +import java.util.Collections; +import java.util.Iterator; +import java.util.Set; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +/** Empty dynamic whitelists have no handlers to visit; nonempty sets retain their own iterator. */ +@Mixin(targets = "dev.tr7zw.entityculling.EntityCullingModBase", remap = false) +public abstract class CullingEmptyWhitelistMixin { + @Redirect(method = {"isEntityDynamicWhitelisted", "isBlockEntityDynamicWhitelisted"}, + at = @At(value = "INVOKE", target = "Ljava/util/Set;iterator()Ljava/util/Iterator;")) + private Iterator horizon$iterateDynamicHandlers(Set handlers) { + // Check the live set on every call. Registration, order, callbacks and nonempty iteration stay upstream. + return handlers.isEmpty() ? Collections.emptyIterator() : handlers.iterator(); + } +} diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/DhTerrainReadWaitMixin.java b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/DhTerrainReadWaitMixin.java new file mode 100644 index 000000000..ce66c1b06 --- /dev/null +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/DhTerrainReadWaitMixin.java @@ -0,0 +1,18 @@ +package dev.horizonlab.mixin; + +import dev.horizonlab.budget.TerrainReadWait; +import java.util.concurrent.CompletableFuture; +import net.minecraft.world.level.chunk.storage.IOWorker; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +/** Only blending metadata reads on pinned DH workers become interruptible; saves stay upstream. */ +@Mixin(IOWorker.class) +public abstract class DhTerrainReadWaitMixin { + @Redirect(method = "isOldChunkAround", at = @At(value = "INVOKE", + target = "Ljava/util/concurrent/CompletableFuture;join()Ljava/lang/Object;")) + private Object horizon$waitForBlendingMetadata(CompletableFuture future) { + return TerrainReadWait.join(future); + } +} diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/HorizonMixinPlugin.java b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/HorizonMixinPlugin.java index e52e8f253..1a30f4a6e 100644 --- a/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/HorizonMixinPlugin.java +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/HorizonMixinPlugin.java @@ -16,6 +16,8 @@ public final class HorizonMixinPlugin implements IMixinConfigPlugin { private static boolean debounceWait; private static boolean dispatchRetry; private static boolean tickDedup; + private static boolean cullingEmpty; + private static boolean terrainReadWait; @Override public void onLoad(String mixinPackage) { var loader = FabricLoader.getInstance(); @@ -79,17 +81,54 @@ public final class HorizonMixinPlugin implements IMixinConfigPlugin { } catch (Exception error) { LoggerFactory.getLogger("horizonlab").warn("Tick membership deduplication disabled: invalid configuration", error); } + try { + Properties properties = new Properties(); + var cullingFile = loader.getConfigDir().resolve("horizon-culling-empty.properties"); + if (Files.exists(cullingFile)) { + try (var reader = Files.newBufferedReader(cullingFile)) { properties.load(reader); } + } + boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false")); + boolean supported = loader.getModContainer("entityculling") + .map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("1.11.2")).orElse(false); + cullingEmpty = requested && supported; + if (requested) LoggerFactory.getLogger("horizonlab").info( + "Empty culling whitelist iteration: {} (requires pinned Entity Culling 1.11.2)", cullingEmpty ? "enabled" : "unavailable"); + } catch (Exception error) { + LoggerFactory.getLogger("horizonlab").warn("Empty culling whitelist iteration disabled: invalid configuration", error); + } + try { + Properties properties = new Properties(); + var readFile = loader.getConfigDir().resolve("horizon-terrain-read-wait.properties"); + if (Files.exists(readFile)) { + try (var reader = Files.newBufferedReader(readFile)) { properties.load(reader); } + } + boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false")); + boolean supported = loader.getModContainer("distanthorizons") + .map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false) + && loader.getModContainer("minecraft") + .map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("26.3")).orElse(false); + terrainReadWait = requested && supported; + if (requested) LoggerFactory.getLogger("horizonlab").info( + "Interruptible DH blending metadata reads: {} (requires Minecraft 26.3 and DH 3.3.4)", + terrainReadWait ? "enabled" : "unavailable"); + } catch (Exception error) { + LoggerFactory.getLogger("horizonlab").warn("Interruptible terrain read disabled: invalid configuration", error); + } } public static boolean workerPacingEnabled() { return workerPacing; } public static boolean debounceWaitEnabled() { return debounceWait; } public static boolean dispatchRetryEnabled() { return dispatchRetry; } public static boolean tickDedupEnabled() { return tickDedup; } + public static boolean cullingEmptyEnabled() { return cullingEmpty; } + public static boolean terrainReadWaitEnabled() { return terrainReadWait; } @Override public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { if (mixinClassName.endsWith("DhWorkerPacingMixin")) return workerPacing; if (mixinClassName.endsWith("DhDebounceWaitMixin")) return debounceWait; if (mixinClassName.endsWith("DhPriorityDispatchMixin")) return dispatchRetry; if (mixinClassName.endsWith("DhTickDedupMixin")) return tickDedup; + if (mixinClassName.endsWith("CullingEmptyWhitelistMixin")) return cullingEmpty; + if (mixinClassName.endsWith("DhTerrainReadWaitMixin")) return terrainReadWait; return true; } @Override public String getRefMapperConfig() { return null; } diff --git a/mods/horizon-lab/src/main/resources/fabric.mod.json b/mods/horizon-lab/src/main/resources/fabric.mod.json index 21271d5ed..175a5526c 100644 --- a/mods/horizon-lab/src/main/resources/fabric.mod.json +++ b/mods/horizon-lab/src/main/resources/fabric.mod.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "horizonlab", - "version": "0.13.0", + "version": "0.14.0", "name": "Horizon Lab", "description": "Client frame pacing and server tick measurements for reproducible optimization experiments.", "authors": [ diff --git a/mods/horizon-lab/src/main/resources/horizonlab.mixins.json b/mods/horizon-lab/src/main/resources/horizonlab.mixins.json index 308a9aee7..bc3639af1 100644 --- a/mods/horizon-lab/src/main/resources/horizonlab.mixins.json +++ b/mods/horizon-lab/src/main/resources/horizonlab.mixins.json @@ -4,6 +4,6 @@ "plugin": "dev.horizonlab.mixin.HorizonMixinPlugin", "compatibilityLevel": "JAVA_25", "mixins": ["ServerTickMixin"], - "client": ["FrameMixin", "DhWorkerPacingMixin", "DhDebounceWaitMixin", "DhPriorityDispatchMixin", "DhTickDedupMixin"], + "client": ["FrameMixin", "DhWorkerPacingMixin", "DhDebounceWaitMixin", "DhPriorityDispatchMixin", "DhTickDedupMixin", "CullingEmptyWhitelistMixin", "DhTerrainReadWaitMixin"], "injectors": {"defaultRequire": 1} } diff --git a/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/CullingEmptyIntegration.java b/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/CullingEmptyIntegration.java new file mode 100644 index 000000000..fa4c0e53f --- /dev/null +++ b/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/CullingEmptyIntegration.java @@ -0,0 +1,100 @@ +package dev.horizonlab.dispatch; + +import dev.horizonlab.mixin.HorizonMixinPlugin; +import dev.tr7zw.entityculling.EntityCullingModBase; +import dev.tr7zw.entityculling.access.Cullable; +import java.lang.management.ManagementFactory; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.function.Function; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; + +/** Private fixture validates applied redirects and live handler registration on the publisher's instance. */ +public final class CullingEmptyIntegration implements ClientModInitializer { + private static volatile boolean result; + private static final class CountingSet extends HashSet { + int iterators; + @Override public Iterator iterator() { iterators++; return super.iterator(); } + } + @Override public void onInitializeClient() { + ClientLifecycleEvents.CLIENT_STARTED.register(client -> { + try { exercise(); } + catch (ReflectiveOperationException failure) { throw new AssertionError("Culling integration failed", failure); } + if (Boolean.getBoolean("horizon.fixture.exit")) System.exit(0); + }); + } + + private static void exercise() throws ReflectiveOperationException { + var owner = EntityCullingModBase.instance; + if (owner == null) throw new AssertionError("Publisher culling mod was not initialized"); + Field entity = EntityCullingModBase.class.getDeclaredField("dynamicEntityWhitelist"); + Field block = EntityCullingModBase.class.getDeclaredField("dynamicBlockEntityWhitelist"); + entity.setAccessible(true); block.setAccessible(true); + Object oldEntity = entity.get(owner), oldBlock = block.get(owner); + boolean enabled = HorizonMixinPlugin.cullingEmptyEnabled(); + var entities = new CountingSet>(); + var blocks = new CountingSet>(); + var chest = new ChestBlockEntity(BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); + var subject = (Cullable) (Object) chest; + try { + entity.set(owner, entities); block.set(owner, blocks); + if (owner.isEntityDynamicWhitelisted(null) || owner.isBlockEntityDynamicWhitelisted(subject)) + throw new AssertionError("Empty whitelist unexpectedly matched"); + int expectedEmpty = enabled ? 0 : 1; + if (entities.iterators != expectedEmpty || blocks.iterators != expectedEmpty) + throw new AssertionError("Empty whitelist iterator hook did not apply"); + var called = new ArrayList(); + Function first = value -> { called.add(1); return false; }; + Function second = value -> { called.add(2); return value == chest; }; + owner.addDynamicBlockEntityWhitelist(first); + owner.addDynamicBlockEntityWhitelist(second); + owner.addDynamicBlockEntityWhitelist(first); // Duplicate registration remains a set operation. + var expected = new ArrayList(); + for (var handler : blocks) { expected.add(handler == first ? 1 : 2); if (handler == second) break; } + int before = blocks.iterators; + if (!owner.isBlockEntityDynamicWhitelisted(subject) || !called.equals(expected) || blocks.iterators != before + 1) + throw new AssertionError("Live registration, callback order or short circuit changed"); + int callbackCount = called.size(); + if (owner.isBlockEntityDynamicWhitelisted(null) || called.size() != callbackCount) + throw new AssertionError("Null unexpectedly matched or invoked a callback"); + owner.addDynamicEntityWhitelist(value -> { throw new AssertionError("Wrong entity type invoked handler"); }); + before = entities.iterators; + if (owner.isEntityDynamicWhitelisted(subject) || entities.iterators != before + 1) + throw new AssertionError("Nonempty entity iterator or type guard changed"); + blocks.clear(); entities.clear(); before = blocks.iterators; + if (owner.isBlockEntityDynamicWhitelisted(subject) || blocks.iterators != before + expectedEmpty) + throw new AssertionError("Empty state after clear was cached incorrectly"); + // Normal HashSets for the allocation observation; instrumentation is not in this loop. + entity.set(owner, new HashSet<>()); block.set(owner, new HashSet<>()); + loop(owner, 200000); + var bean = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + bean.setThreadAllocatedMemoryEnabled(true); bean.setThreadCpuTimeEnabled(true); + long id = Thread.currentThread().threadId(), bytes = bean.getThreadAllocatedBytes(id); + long cpu = bean.getCurrentThreadCpuTime(), wall = System.nanoTime(); + loop(owner, 2000000); + long wallDelta = System.nanoTime() - wall, cpuDelta = bean.getCurrentThreadCpuTime() - cpu; + long byteDelta = bean.getThreadAllocatedBytes(id) - bytes; + System.out.println("HORIZON_CULLING_EMPTY_RESULT {\"enabled\":" + enabled + + ",\"empty_iterator_calls\":" + expectedEmpty + ",\"live_registration_checked\":true" + + ",\"block_callback_order_checked\":true,\"type_guard_checked\":true" + + ",\"allocated_bytes\":" + byteDelta + ",\"cpu_ns\":" + cpuDelta + ",\"wall_ns\":" + wallDelta + + ",\"measured_calls\":4000000,\"result\":" + result + "}"); + } finally { entity.set(owner, oldEntity); block.set(owner, oldBlock); } + } + + private static void loop(EntityCullingModBase owner, int iterations) { + boolean found = false; + for (int index = 0; index < iterations; index++) { + found |= owner.isEntityDynamicWhitelisted(null); + found |= owner.isBlockEntityDynamicWhitelisted(null); + } + result = found; + } +} diff --git a/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/TerrainReadIntegration.java b/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/TerrainReadIntegration.java new file mode 100644 index 000000000..0a9ccefd4 --- /dev/null +++ b/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/TerrainReadIntegration.java @@ -0,0 +1,155 @@ +package dev.horizonlab.dispatch; + +import com.seibel.distanthorizons.core.util.ExceptionUtil; +import dev.horizonlab.mixin.HorizonMixinPlugin; +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.BitSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.storage.IOWorker; +import net.minecraft.world.level.chunk.storage.RegionStorageInfo; + +/** Applies the redirect to a real game IOWorker, using controlled cached read futures. */ +public final class TerrainReadIntegration implements ClientModInitializer { + private static final class Worker extends IOWorker { + Worker(Path path) { super(new RegionStorageInfo("fixture", Level.OVERWORLD, "horizon-read-fixture"), path, false); } + } + private record Outcome(Boolean result, Throwable error, boolean interrupted) {} + private record Call(Thread thread, CountDownLatch finished, AtomicReference outcome) {} + + @Override public void onInitializeClient() { + ClientLifecycleEvents.CLIENT_STARTED.register(client -> { + try { exercise(); } + catch (Exception failure) { throw new AssertionError("Terrain read integration failed", failure); } + if (Boolean.getBoolean("horizon.fixture.exit")) System.exit(0); + }); + } + + @SuppressWarnings("unchecked") + private static void cache(Worker worker, CompletableFuture future) throws Exception { + var field = IOWorker.class.getDeclaredField("regionCacheForBlender"); + field.setAccessible(true); + var map = (Long2ObjectLinkedOpenHashMap>) field.get(worker); + synchronized (map) { map.clear(); map.put(ChunkPos.pack(0, 0), future); } + } + + private static Call start(Worker worker, String name, boolean initiallyInterrupted) { + var result = new AtomicReference(); + var finished = new CountDownLatch(1); + Thread thread = new Thread(() -> { + if (initiallyInterrupted) Thread.currentThread().interrupt(); + Boolean value = null; Throwable error = null; + try { value = worker.isOldChunkAround(new ChunkPos(0, 0), 0); } + catch (Throwable failure) { error = failure; } + result.set(new Outcome(value, error, Thread.currentThread().isInterrupted())); + finished.countDown(); + }, name); + thread.setDaemon(true); // Fixture failures cannot strand the test JVM. + thread.start(); + return new Call(thread, finished, result); + } + + private static Outcome finish(Call call) throws Exception { + if (!call.finished.await(4, TimeUnit.SECONDS)) throw new AssertionError("Worker did not finish"); + call.thread.join(1000); + return call.outcome.get(); + } + + private static void awaitPending(Call call) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(4); + while (call.thread.getState() != Thread.State.WAITING) { + if (!call.thread.isAlive() || System.nanoTime() > deadline) throw new AssertionError("Read never waited"); + Thread.sleep(1); + } + } + + private static void pending(Worker worker, String name, boolean cancellable) throws Exception { + var future = new CompletableFuture(); + cache(worker, future); + var call = start(worker, name, false); + try { + awaitPending(call); + call.thread.interrupt(); + if (cancellable) { + var outcome = finish(call); + if (!(outcome.error instanceof CompletionException) || !ExceptionUtil.isShutdownException(outcome.error) + || !outcome.interrupted || future.isDone()) throw new AssertionError("Shutdown changed shared future or interrupt"); + } else { + if (call.finished.await(100, TimeUnit.MILLISECONDS)) throw new AssertionError("Ordinary join became interruptible"); + } + future.complete(new BitSet()); + var outcome = finish(call); + if (!cancellable && (!Boolean.FALSE.equals(outcome.result) || outcome.error != null || !outcome.interrupted)) + throw new AssertionError("Original join result or interrupt changed"); + // A later caller can still consume the shared read after cancellation of only one waiter. + if (worker.isOldChunkAround(new ChunkPos(0, 0), 0)) throw new AssertionError("Shared read was damaged"); + } finally { future.complete(new BitSet()); call.thread.join(4000); } + } + + private static void exercise() throws Exception { + boolean enabled = HorizonMixinPlugin.terrainReadWaitEnabled(); + Path path = Files.createTempDirectory("horizon-read-fixture-"); + try (var worker = new Worker(path)) { + pending(worker, "DH-World Gen Thread[fixture]", enabled); + pending(worker, "Server thread fixture", false); + pending(worker, "DH-LOD Builder Thread[fixture]", false); + var waiting = new CompletableFuture(); + cache(worker, waiting); + var initialCall = start(worker, "DH-World Gen Thread[fixture]", true); + try { + if (enabled) { + var preInterrupted = finish(initialCall); + if (!(preInterrupted.error instanceof CompletionException) || !preInterrupted.interrupted + || waiting.isDone() || !ExceptionUtil.isShutdownException(preInterrupted.error)) + throw new AssertionError("Pending read ignored an existing shutdown interrupt"); + } else { + awaitPending(initialCall); + if (initialCall.finished.await(100, TimeUnit.MILLISECONDS)) + throw new AssertionError("Original pending join honored an existing interrupt"); + waiting.complete(new BitSet()); + var preInterrupted = finish(initialCall); + if (!Boolean.FALSE.equals(preInterrupted.result) || preInterrupted.error != null || !preInterrupted.interrupted) + throw new AssertionError("Original pre-interrupted join changed"); + } + } finally { waiting.complete(new BitSet()); initialCall.thread.join(4000); } + BitSet bits = new BitSet(); bits.set(0); + cache(worker, CompletableFuture.completedFuture(bits)); + var complete = finish(start(worker, "DH-World Gen Thread[fixture]", true)); + if (!Boolean.TRUE.equals(complete.result) || complete.error != null || !complete.interrupted) + throw new AssertionError("Completed read changed result or interrupt"); + var failed = new CompletableFuture(); + var original = new CompletionException(new IllegalStateException("fixture failure")); + cache(worker, failed); + var call = start(worker, "DH-World Gen Thread[fixture]", false); + awaitPending(call); failed.completeExceptionally(original); + if (finish(call).error != original) throw new AssertionError("Exceptional completion identity changed"); + var pendingCancelled = new CompletableFuture(); + cache(worker, pendingCancelled); + var cancelCall = start(worker, "DH-World Gen Thread[fixture]", false); + awaitPending(cancelCall); pendingCancelled.cancel(false); + Throwable expectedCancellation = null; + try { pendingCancelled.join(); } catch (java.util.concurrent.CancellationException expected) { expectedCancellation = expected; } + var actualCancellation = finish(cancelCall).error; + if (!(actualCancellation instanceof java.util.concurrent.CancellationException) + || !java.util.Objects.equals(actualCancellation.getMessage(), expectedCancellation.getMessage()) + || actualCancellation.getCause() != expectedCancellation.getCause()) + throw new AssertionError("Pending cancellation changed join's message or cause"); + var cancelled = new CompletableFuture(); cancelled.cancel(false); + cache(worker, cancelled); + if (!(finish(start(worker, "DH-World Gen Thread[fixture]", false)).error instanceof java.util.concurrent.CancellationException)) + throw new AssertionError("Cancelled completion changed"); + System.out.println("HORIZON_TERRAIN_READ_RESULT {\"enabled\":" + enabled + + ",\"applied_io_worker_checked\":true,\"ordinary_join_checked\":true,\"shared_future_preserved\":true" + + ",\"exception_identity_checked\":true,\"completed_interrupt_checked\":true,\"cancelled_completion_checked\":true,\"preinterrupted_pending_checked\":true}"); + } finally { Files.deleteIfExists(path); } + } +} diff --git a/packs/START-HERE.txt b/packs/START-HERE.txt index afe0ce763..eb4e1cd68 100644 --- a/packs/START-HERE.txt +++ b/packs/START-HERE.txt @@ -62,6 +62,10 @@ PROFILES terrain tick membership checks. Action order and visual settings stay the same. Set enabled=false in config/horizon-tick-dedup.properties and restart to disable. Compare against m2-balanced for an isolated test; no Mac FPS gain is measured yet. +- culling-empty: same balanced settings, with a shared empty iterator for empty + dynamic entity/block-entity whitelists. Registered callbacks stay upstream. + Disable in config/horizon-culling-empty.properties (enabled=false), then restart. + Compare against m2-balanced; no whole-game or Mac FPS gain is measured yet. - foreground-first: combines adaptive terrain, precise pacing, wakeable queue waits, bounded rejected dispatch retries and (since 0.13) tick membership reuse. Uses OpenGL and a 120 FPS @@ -154,3 +158,9 @@ and field of view at capture boundaries. If visual settings changed, the viewer flags the run. Older reports without these fields cannot establish quality equivalence. Snapshots cannot detect a change that was restored before the end, and do not prove that all requested distant terrain finished rendering. + +Since 0.14, profiles with Distant Horizons request an interruptible blending-read +wait for its generation workers during shutdown. Ordinary Minecraft callers and +chunk saves keep their original behavior. To disable, set enabled=false in +config/horizon-terrain-read-wait.properties and restart. This is a scoped shutdown +correctness fix, not a claim of greater FPS or faster terrain generation. diff --git a/packs/mods.lock.json b/packs/mods.lock.json index c89996a73..5b78d3184 100644 --- a/packs/mods.lock.json +++ b/packs/mods.lock.json @@ -3,7 +3,7 @@ "minecraft": "26.3", "java_major": 25, "fabric_loader": "0.19.5", - "pack_version": "0.13.0", + "pack_version": "0.14.0", "options_data_version": 5023, "projects": { "sodium": { diff --git a/tests/test_packs.py b/tests/test_packs.py index 7aaff9586..9a441294d 100644 --- a/tests/test_packs.py +++ b/tests/test_packs.py @@ -87,6 +87,24 @@ class PackTests(unittest.TestCase): "config/horizon-dispatch-retry.properties"]: self.assertEqual(original[filename], candidate[filename]) + def test_empty_culling_experiment_preserves_balanced_settings(self): + original = build_pack.overrides("m2-balanced", b"fixture") + for name in build_pack.PROFILES: + files = build_pack.overrides(name, b"fixture") + self.assertEqual(files["config/horizon-culling-empty.properties"], + b"enabled=true\n" if name == "culling-empty" else b"enabled=false\n") + candidate = build_pack.overrides("culling-empty", b"fixture") + for filename in ["options.txt", "config/DistantHorizons.toml", "config/horizon-adaptive.properties", + "config/horizon-worker-pacing.properties", "config/horizon-queue-wait.properties", + "config/horizon-dispatch-retry.properties", "config/horizon-tick-dedup.properties"]: + self.assertEqual(original[filename], candidate[filename]) + + def test_terrain_read_shutdown_fix_only_requested_with_distant_terrain(self): + for name, profile in build_pack.PROFILES.items(): + files = build_pack.overrides(name, b"fixture") + self.assertEqual(files["config/horizon-terrain-read-wait.properties"], + b"enabled=true\n" if profile["lod"] else b"enabled=false\n") + def test_wrong_game_or_download_path_is_rejected(self): for field, value in [("game_versions", ["1.21"]), ("filename", "../bad.jar"), ("url", "http://unsafe.invalid")]: lock = copy.deepcopy(build_pack.read_lock()) @@ -173,13 +191,14 @@ class ComparisonTests(unittest.TestCase): def test_terrain_quality_and_shader_changes_cannot_look_like_equal_workloads(self): baseline = self.report() baseline.update(dh_capture_status="available", dh_lod_radius_chunks=128, dh_horizontal_quality="MEDIUM", - iris_capture_status="available", iris_shaders_enabled=False, dh_thread_count=2) + iris_capture_status="available", iris_shaders_enabled=False, dh_thread_count=2, world_difficulty="NORMAL") candidate = copy.deepcopy(baseline) candidate.update(dh_lod_radius_chunks=256, iris_shaders_enabled=True, iris_shader_pack="Example pack", - dh_thread_count=1, visual_settings_changed=["render_distance"]) + dh_thread_count=1, world_difficulty="PEACEFUL", visual_settings_changed=["render_distance"]) result = compare_benchmarks.compare(baseline, candidate) self.assertTrue(any("dh_lod_radius_chunks" in field for field in result["mismatches"])) self.assertTrue(any("iris_shaders_enabled" in field for field in result["mismatches"])) + self.assertTrue(any("world_difficulty" in field for field in result["mismatches"])) self.assertTrue(any("dh_thread_count differs" in warning for warning in result["warnings"])) self.assertTrue(any("changed between capture boundaries" in warning for warning in result["warnings"])) legacy = self.report() diff --git a/tools/Compare-Benchmarks.html b/tools/Compare-Benchmarks.html index acf36697e..532973a93 100644 --- a/tools/Compare-Benchmarks.html +++ b/tools/Compare-Benchmarks.html @@ -30,7 +30,7 @@