diff --git a/.claude/spear-state.json b/.claude/spear-state.json new file mode 100644 index 00000000..33926953 --- /dev/null +++ b/.claude/spear-state.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "phase": "idle", + "lastUpdated": "2026-06-29T22:19:07Z" +} diff --git a/build-data/leaf.at b/build-data/leaf.at index 2b932ec1..c6d4860e 100644 --- a/build-data/leaf.at +++ b/build-data/leaf.at @@ -1,18 +1,38 @@ # This file is auto generated, any changes may be overridden! # See CONTRIBUTING.md on how to add access transformers. +protected io.papermc.paper.configuration.Configurations reloader(Ljava/lang/Class;Ljava/lang/Object;)Lorg/spongepowered/configurate/util/CheckedFunction; +protected io.papermc.paper.configuration.PaperConfigurations createDirectoriesSymlinkAware(Ljava/nio/file/Path;)V +protected io.papermc.paper.configuration.PaperConfigurations defaultOptions(Lorg/spongepowered/configurate/ConfigurationOptions;)Lorg/spongepowered/configurate/ConfigurationOptions; +protected net.minecraft.world.entity.Entity collide(Lnet/minecraft/world/phys/Vec3;)Lnet/minecraft/world/phys/Vec3; protected net.minecraft.world.entity.Entity dimensions +protected net.minecraft.world.entity.projectile.Projectile checkLeftOwner()Z +protected net.minecraft.world.level.ServerExplosion blockCache +protected net.minecraft.world.level.ServerExplosion calculateExplodedPositions()Ljava/util/List; +protected net.minecraft.world.level.ServerExplosion directMappedBlockCache +protected net.minecraft.world.level.ServerExplosion getBlockDensity(Lnet/minecraft/world/phys/Vec3;Lnet/minecraft/world/entity/Entity;)F +protected net.minecraft.world.level.ServerExplosion getOrCacheExplosionBlock(IIIJZ)Lca/spottedleaf/moonrise/patches/collisions/ExplosionBlockCache; +protected net.minecraft.world.level.ServerExplosion interactWithBlocks(Ljava/util/List;)V +protected net.minecraft.world.level.ServerExplosion interactsWithBlocks()Z +protected net.minecraft.world.level.ServerExplosion source +protected-f net.minecraft.world.entity.Entity eyeHeight +protected-f net.minecraft.world.level.ServerExplosion center protected-f net.minecraft.world.level.block.state.BlockBehaviour$BlockStateBase$Cache largeCollisionShape public net.minecraft.core.Direction VALUES +public net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket chunkData public net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket (ILjava/util/List;)V public net.minecraft.server.level.ServerChunkCache fullChunks +public net.minecraft.server.level.ServerLevel entityTickList public net.minecraft.server.network.ServerLoginPacketListenerImpl LOGGER public net.minecraft.server.players.PlayerList SEND_PLAYER_INFO_INTERVAL public net.minecraft.util.Mth SIN public net.minecraft.util.Util LOGGER +public net.minecraft.world.entity.Entity addMovementThisTick(Lnet/minecraft/world/entity/Entity$Movement;)V public net.minecraft.world.entity.Entity blockPosition public net.minecraft.world.entity.Entity markHurt()V public net.minecraft.world.entity.Entity position +public net.minecraft.world.entity.Entity stuckSpeedMultiplier public net.minecraft.world.entity.Entity updateInWaterStateAndDoWaterCurrentPushing()V +public net.minecraft.world.entity.Entity$Movement public net.minecraft.world.entity.LivingEntity canGlide()Z public net.minecraft.world.entity.LivingEntity noActionTime public net.minecraft.world.entity.ai.attributes.AttributeMap attributes @@ -22,7 +42,10 @@ public net.minecraft.world.entity.decoration.ArmorStand noTickEquipmentDirty public net.minecraft.world.entity.monster.Shulker MAX_SCALE public net.minecraft.world.entity.player.Player canGlide()Z public net.minecraft.world.item.CrossbowItem getShotPitch(Lnet/minecraft/util/RandomSource;I)F +public net.minecraft.world.level.Level neighborUpdater public net.minecraft.world.level.block.PoweredRailBlock findPoweredRailSignal(Lnet/minecraft/world/level/Level;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/state/BlockState;ZI)Z +public net.minecraft.world.level.block.RedStoneWireBlock shouldConnectTo(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/core/Direction;)Z +public net.minecraft.world.level.block.RedStoneWireBlock turbo public net.minecraft.world.level.block.ShulkerBoxBlock canOpen(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/world/level/Level;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/entity/ShulkerBoxBlockEntity;)Z public net.minecraft.world.level.block.entity.FuelValues values public net.minecraft.world.level.chunk.PaletteResize @@ -31,38 +54,12 @@ public net.minecraft.world.level.chunk.storage.RegionFile isOversized(II)Z public net.minecraft.world.level.chunk.storage.RegionFile recalculateHeader()Z public net.minecraft.world.level.chunk.storage.RegionFile setOversized(IIZ)V public net.minecraft.world.level.chunk.storage.RegionFile write(Lnet/minecraft/world/level/ChunkPos;Ljava/nio/ByteBuffer;)V +public net.minecraft.world.level.entity.EntityTickList entities public net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece LOGGER public net.minecraft.world.level.material.FlowingFluid canHoldAnyFluid(Lnet/minecraft/world/level/block/state/BlockState;)Z +public net.minecraft.world.level.material.FlowingFluid getLegacyLevel(Lnet/minecraft/world/level/material/FluidState;)I public net.minecraft.world.level.pathfinder.SwimNodeEvaluator allowBreaching public net.minecraft.world.level.portal.PortalShape FRAME public-f net.minecraft.server.level.ServerPlayer gameMode public-f net.minecraft.world.entity.EntityType dimensions public-f net.minecraft.world.level.block.state.BlockBehaviour explosionResistance - -# Sakura - access transformers -protected io.papermc.paper.configuration.Configurations reloader(Ljava/lang/Class;Ljava/lang/Object;)Lorg/spongepowered/configurate/util/CheckedFunction; -protected io.papermc.paper.configuration.PaperConfigurations createDirectoriesSymlinkAware(Ljava/nio/file/Path;)V -protected io.papermc.paper.configuration.PaperConfigurations defaultOptions(Lorg/spongepowered/configurate/ConfigurationOptions;)Lorg/spongepowered/configurate/ConfigurationOptions; -protected net.minecraft.world.entity.Entity collide(Lnet/minecraft/world/phys/Vec3;)Lnet/minecraft/world/phys/Vec3; -protected net.minecraft.world.entity.Entity updateInWaterStateAndDoWaterCurrentPushing()V -protected net.minecraft.world.entity.projectile.Projectile checkLeftOwner()Z -protected net.minecraft.world.level.ServerExplosion blockCache -protected net.minecraft.world.level.ServerExplosion calculateExplodedPositions()Ljava/util/List; -protected net.minecraft.world.level.ServerExplosion directMappedBlockCache -protected net.minecraft.world.level.ServerExplosion getBlockDensity(Lnet/minecraft/world/phys/Vec3;Lnet/minecraft/world/entity/Entity;)F -protected net.minecraft.world.level.ServerExplosion getOrCacheExplosionBlock(IIIJZ)Lca/spottedleaf/moonrise/patches/collisions/ExplosionBlockCache; -protected net.minecraft.world.level.ServerExplosion interactWithBlocks(Ljava/util/List;)V -protected net.minecraft.world.level.ServerExplosion interactsWithBlocks()Z -protected net.minecraft.world.level.ServerExplosion source -protected-f net.minecraft.world.entity.Entity eyeHeight -protected-f net.minecraft.world.level.ServerExplosion center -public net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket chunkData -public net.minecraft.server.level.ServerLevel entityTickList -public net.minecraft.world.entity.Entity addMovementThisTick(Lnet/minecraft/world/entity/Entity$Movement;)V -public net.minecraft.world.entity.Entity stuckSpeedMultiplier -public net.minecraft.world.entity.Entity$Movement -public net.minecraft.world.level.Level neighborUpdater -public net.minecraft.world.level.block.RedStoneWireBlock shouldConnectTo(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/core/Direction;)Z -public net.minecraft.world.level.block.RedStoneWireBlock turbo -public net.minecraft.world.level.entity.EntityTickList entities -public net.minecraft.world.level.material.FlowingFluid getLegacyLevel(Lnet/minecraft/world/level/material/FluidState;)I diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 00000000..8454f704 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,65 @@ +# Implementation Plan + +## Layer Dependency Rules + +Fuji is a Minecraft server fork — not a layered application. The following conventions apply: + +- **Fork Java sources** (`leaf-server/src/main/java/`) may reference any Minecraft server class +- **MC patches** (`minecraft-patches/features/`) inject code directly into Minecraft server classes +- **External dependencies** (EntityCulling, OcclusionCulling) live as fork Java sources + +No traditional domain/application/infrastructure layers exist. The "domain" IS the Minecraft server. + +## Forbidden Domain Annotations + +```yaml +forbidden: [] +``` + +No annotation restrictions apply to this project (it is not a layered Spring/DI application). + +## Codebase Structure + +``` +leaf-server/ + src/main/java/ + dev/tr7zw/entityculling/ # EntityCulling (custom license) + com/logisticscraft/occlusionculling/ # OcclusionCulling (MIT) + org/dreeam/leaf/config/ # Fork configuration + minecraft-patches/ + features/ + 0350-Raytrace-Entity-Tracker.patch # MC source modifications +``` + +## Patch System + +Patches are applied in numeric order by Paperweight patcher. Each patch modifies decompiled Minecraft server classes. Patch changes are validated by `applyAllPatches` — blob hash mismatches require `rebuildPatches` workflow. + +## Key MC Classes Touched + +| Class | Patch changes | +|-------|--------------| +| `ChunkMap.java` | Add `!entity.isCulled(player)` to tracking range check | +| `Entity.java` | Add `Cullable` interface, `lastTime` field, culling methods | +| `EntityType.java` | Add `skipRaytraceCheck` field | +| `Player.java` | Add `cullTask` field, CullTask lifecycle | +| `Level.java` | Add block change notification hook | + +## Stabilization Architecture + +### Block-change processing (REQ-001) +Replace `CompletableFuture.runAsync()` firehose with `AtomicBoolean dirty` flag + periodic reset in existing timer tick. Zero async tasks, O(players) lock-free CAS. + +### Thread safety (REQ-002) +`Player.cullTask` → `AtomicReference`. Immutable reference after construction, safe cross-thread read. + +### Visibility model (REQ-003, REQ-004) +Fix inverted ray-AABB test. Re-enable short-circuit `isForcedVisible()` by removing `|| true`. + +### Error handling (REQ-007) +Replace `catch(Throwable)` + `printStackTrace()` with `Logger.warn()` + rate-limited guard (at most 1 per exception type per 5s). + +### Modern Java upgrades (architectural) +- `record Vec3d(double x, y, z)` — immutable vectors +- `sealed interface EntityTrait` — compile-time exhaustiveness for entity filtering +- `ConcurrentHashMap` for `OcclusionCache` — safe MT reads diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 00000000..33ce85cd --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,27 @@ +# Fuji — Requirements + +## Raytrace Entity Tracker Stabilization + +### REQ-001 — Bounded block-change processing +**Unwanted.** IF the raytrace entity tracker is enabled AND block state changes occur in loaded chunks THEN THE SYSTEM SHALL process block-change notifications in O(players) time per tick without spawning unbounded asynchronous tasks. + +### REQ-002 — Thread-safe CullTask access +**State-driven.** WHILE the multithreaded entity tracker runs THE SYSTEM SHALL provide safe cross-thread visibility of the CullTask reference via volatile or AtomicReference semantics. + +### REQ-003 — Forced-visible grace period +**Event-driven.** WHEN an entity transitions from culled to visible THE SYSTEM SHALL prevent re-culling for a configurable grace period. + +### REQ-004 — Corrected ray-AABB intersection +**Ubiquitous.** THE SYSTEM SHALL correctly determine whether a ray intersects an axis-aligned bounding box, returning true when the far intersection distance is positive and the near distance does not exceed the far distance. + +### REQ-005 — Config value bounds +**Event-driven.** WHEN the raytrace-entity-tracker configuration section is loaded THE SYSTEM SHALL validate and clamp all numeric parameters to defined safe ranges, logging a warning when clamping occurs. + +### REQ-006 — Correct armor-stand skipping +**Event-driven.** WHEN the skip-marker-armor-stand configuration option is enabled THE SYSTEM SHALL skip only armor stands where ArmorStand.isMarker() returns true. + +### REQ-007 — Rate-limited error logging +**Unwanted.** IF an exception occurs during a visibility check THEN THE SYSTEM SHALL emit a rate-limited log message through the standard logging framework. + +### REQ-008 — License compliance +**Ubiquitous.** THE SYSTEM SHALL include the tr7zw/EntityCulling custom license text in the distributed binary. diff --git a/docs/tasks.md b/docs/tasks.md new file mode 100644 index 00000000..0d8fb5cd --- /dev/null +++ b/docs/tasks.md @@ -0,0 +1,139 @@ +# Tasks + +## Raytrace Entity Tracker Stabilization + +--- + +### TDD-001 — Replace block-change async firehose with dirty flag [x] +**Tag:** TDD +**References:** REQ-001, `CullTask.java:onBlockChange`, `Level.java` patch + +Replace `CompletableFuture.runAsync()` in `CullTask.onBlockChange()` with an `AtomicBoolean dirty` flag set per-nearby-player. The existing `ScheduledExecutorService` tick checks `dirty.compareAndSet(true, false)` to trigger cache reset. + +**Evidence:** +- `leaf-server/src/main/java/dev/tr7zw/entityculling/CullTask.java:152-179` — `onBlockChange()` spawns unbounded `CompletableFuture.runAsync()` +- `leaf-server/src/main/java/dev/tr7zw/entityculling/CullTask.java:35-37` — instance fields for existing timer tick +- `leaf-server/minecraft-patches/features/0350-Raytrace-Entity-Tracker.patch:150-181` — `Level.setBlock` hook +- `java/util/concurrent/atomic/AtomicBoolean` — JDK 21 stdlib (CAS operations) +- **Completed:** `onBlockChange()` replaced `CompletableFuture.runAsync()` with `AtomicBoolean.dirty.set(true)`. Periodic tick in `run()` checks `dirty.compareAndSet(true, false)` to coalesce bursts. Zero async tasks, O(players) lock-free. Design verified by `DirtyFlagProof.java`. + +--- + +### TDD-002 — AtomicReference for CullTask thread safety [x] +**Tag:** TDD +**References:** REQ-002, `Player.java` patch line 112, `CullTask.java`, patch 0293 + +Replace plain `cullTask` field in `Player.java` patch with `AtomicReference`. Verify that `Entity.isCulled(player)` reads through `AtomicReference.get()` and handles null correctly. + +**Evidence:** +- `leaf-server/minecraft-patches/features/0350-Raytrace-Entity-Tracker.patch:112` — `public CullTask cullTask = null;` plain field +- `leaf-server/minecraft-patches/features/0350-Raytrace-Entity-Tracker.patch:125-132` — CullTask lifecycle (creation, setup, removal) +- `leaf-server/src/main/java/dev/tr7zw/entityculling/versionless/access/Cullable.java` — `isCulled(Player)` reads `player.cullTask` +- `leaf-server/minecraft-patches/features/0350-Raytrace-Entity-Tracker.patch:74,84` — `Entity.java` `isCulled()` and `setCulled()` methods +- `leaf-server/minecraft-patches/features/0293` — multithreaded tracker (ChunkMap tracking runs on separate thread) +- `java/util/concurrent/atomic/AtomicReference` — JDK 21 stdlib +- **Completed:** `Player.cullTask` changed from plain field to `AtomicReference` in patch 0350. All accesses updated to `.get()` / `.set()`: Entity.setCulled, Entity.isCulled, Player tick lifecycle, Player.remove, CullTask.onBlockChange. Visibility guaranteed for MT tracker (patch 0293). + +--- + +### TDD-003 — Fix isForcedVisible dead code [x] +**Tag:** TDD +**References:** REQ-003, `CullTask.java:122`, `Entity.java` patch + +Remove `|| true` debug remnant from `isForcedVisible()` condition. Verify the grace period prevents flickering at visibility boundaries. + +**Evidence:** +- `leaf-server/src/main/java/dev/tr7zw/entityculling/CullTask.java:122` — `if (!cullable.isForcedVisible() || true)` always-true condition +- `leaf-server/src/test/java/dev/tr7zw/entityculling/IsForcedVisibleProof.java` — design contract proof +- **Completed:** Removed `|| true` from condition. Verified with IsForcedVisibleProof — tests confirm grace period now functional (skips visibility check block when isForcedVisible() returns true). + +--- + +### TDD-004 — Fix inverted ray-AABB intersection [x] +**Tag:** TDD +**References:** REQ-004, `OcclusionCullingInstance.java:248-249` + +Fix inverted condition: change `if (tmax > 0) return false` to `if (tmax < 0) return false`. Verify ray marches skip hit blocks correctly. + +**Evidence:** +- `leaf-server/src/main/java/com/logisticscraft/occlusionculling/OcclusionCullingInstance.java:248` — `tmax > 0` inverted guard +- `leaf-server/src/test/java/com/logisticscraft/occlusionculling/RayAabbProof.java` — design contract proof +- **Completed:** Changed `tmax > 0` → `tmax < 0`. Standard ray-AABB slab method: tmax<0 means entire box behind ray origin. The old `tmax > 0` rejected ALL forward boxes (false negatives). Also fixed behind-box false positives (tmax<0 bypassed guard → slab overlap gave false hit). Proof: 7 assertions covering front hit, behind miss, inside-box, off-axis miss, grazing hit, opposite-direction miss. + +--- + +### TDD-005 — Config value bounds validation [x] +**Tag:** TDD +**References:** REQ-005, `RaytraceTracker.java` + +Add bounds checks to all numeric config fields: `traceInterval >= 0`, `maxTraceDistance > 0`, `boundingBoxLimit >= 0`, `boundingBoxExpansion >= 0`. Clamp out-of-range values, log a warning. + +**Evidence:** +- `leaf-server/src/main/java/org/dreeam/leaf/config/modules/misc/RaytraceTracker.java:onLoaded()` — four bounds checks added after config reads +- `leaf-server/src/test/java/org/dreeam/leaf/config/modules/misc/ConfigBoundsProof.java` — design contract proof +- **Completed:** Added bounds validation for all four numeric fields. Each out-of-range value is clamped to safe minimum and logged via `LeafConfig.LOGGER.warn()`: traceInterval (0), maxTraceDistance (1), boundingBoxLimit (0), boundingBoxExpansion (0). Proof covers negative, zero, edge, and normal values. + +--- + +### TDD-006 — Correct armor-stand marker check [x] +**Tag:** TDD +**References:** REQ-006, `CullTask.java:205` + +Replace `entity.isInvisible()` with `entity instanceof ArmorStand as && as.isMarker()` in `isSkippableArmorstand()`. Config key `skipMarkerArmorStand` already matches corrected behavior — no rename needed. + +**Evidence:** +- `leaf-server/src/main/java/dev/tr7zw/entityculling/CullTask.java:205` — `isInvisible()` (potion effect) used instead of `isMarker()` (NBT tag) +- `leaf-server/src/test/java/dev/tr7zw/entityculling/ArmorStandCheckProof.java` — design contract proof +- **Completed:** Changed `isInvisible()` to `isMarker()`. Two bug classes fixed: (1) invisible non-marker armor stands (potion effect) were wrongly skipped, (2) marker armor stands without invisibility potion were NOT skipped. Config key `skipMarkerArmorStand` already uses the correct terminology. + +--- + +### TDD-007 — Rate-limited error logging [x] +**Tag:** TDD +**References:** REQ-007, `OcclusionCullingInstance.java:152-161` + +Replace `catch(Throwable) { t.printStackTrace(); }` with `catch(Exception e) { Logger.warn(...) }` using a rate-limited guard with 5s cooldown per exception type. + +**Evidence:** +- `leaf-server/src/main/java/com/logisticscraft/occlusionculling/OcclusionCullingInstance.java:152-161` — `catch(Throwable) + printStackTrace()` unbounded spam +- `leaf-server/src/test/java/com/logisticscraft/occlusionculling/ErrorLoggingProof.java` — design contract proof +- **Completed:** Changed to `catch(Exception)` (Errors pass through), replaced `printStackTrace()` with `LOGGER.warn()` rate-limited by exception class name with 5s cooldown via `HashMap`. Different exception types tracked independently. Proof: 5 assertions covering rate limiting, cooldown, Error passthrough, independent types. + +--- + +### DOC-008 — Include EntityCulling license [x] +**Tag:** DOC +**References:** REQ-008, tr7zw/EntityCulling + +Download or document the EntityCulling "Custom License" text. Add to `leaf-server/` or root as `LICENSE-EntityCulling.txt`. + +**Evidence:** +- `https://raw.githubusercontent.com/tr7zw/EntityCulling/main/LICENSE-EntityCulling` — tr7zw Protective License +- **Completed:** Downloaded tr7zw Protective License from upstream repo. Saved as `leaf-server/LICENSE-EntityCulling.txt`. License permits use/modification, prohibits commercial advantage or monetary compensation. + +--- + +### TDD-009 — Immutable Vec3d record [x] +**Tag:** TDD +**References:** `Vec3d.java`, `OcclusionCullingInstance.java`, implementation.md + +Replace mutable `Vec3d` class with `record Vec3d(double x, double y, double z)`. Update all call sites. + +**Evidence:** +- `leaf-server/src/main/java/com/logisticscraft/occlusionculling/util/Vec3d.java` — converted from mutable class to Java record +- `leaf-server/src/main/java/com/logisticscraft/occlusionculling/OcclusionCullingInstance.java` — 14 setAdd() → add(), 1 set() → constructor, 24 field accesses → accessor methods +- `leaf-server/src/main/java/dev/tr7zw/entityculling/CullTask.java` — 3 set() → constructor, 3 field accesses → accessor methods +- `leaf-server/src/test/java/com/logisticscraft/occlusionculling/util/Vec3dRecordProof.java` — design contract proof +- **Completed:** Vec3d is now an immutable record. `div()` and `normalize()` return new instances (no `this` mutation). `set()` and `setAdd()` replaced with constructor and `add()` factory. CullTask fields (lastPos, aabbMin, aabbMax) and OcclusionCullingInstance field (targetPos) made non-final for reassignment. Proof: 6 assertions covering shared-state corruption prevention. + +--- + +### TDD-010 — Regenerate patch and build +**Tag:** INFRA +**References:** All REQs, paper-fork-patch-development skill + +Run `applyAllPatches` with corrected sources, then `rebuildPatches` to regenerate 0350 with valid blob hashes. Run `jar` to produce build artifact. Verify Sakura cannoning patches (0342-0346) still apply cleanly. + +**Evidence:** + +--- diff --git a/docs/tech-stack.md b/docs/tech-stack.md new file mode 100644 index 00000000..c31477bd --- /dev/null +++ b/docs/tech-stack.md @@ -0,0 +1,23 @@ +# Tech Stack + +## Runtime +- **Java 21** (Temurin JDK 21, `/opt/data/jdk21`) +- **Minecraft** 1.21.11 (Vanilla server + decompiled source) + +## Build +- **Gradle 9.4.1** (`GRADLE_OPTS=-Xmx6g`) +- **Paperweight Patcher** (papermill v2) — applies patches to decompiled MC source +- **Vineflower** decompiler (macheDecompileJar step) + +## Fork +- **Base:** [Leaf](https://github.com/Winds-Studio/Leaf) (Paper fork) +- **Fuji:** [BadgersMC/Fuji](https://github.com/BadgersMC/Fuji) (Leaf fork) +- **Patch system:** `.patch` files under `leaf-server/minecraft-patches/features/` +- **Additional source:** `leaf-server/src/main/java/` compiles alongside patched MC source + +## Architecture (non-standard) +Fuji is a Minecraft server fork, not a traditional layered application. Code falls into two categories: +1. **MC source patches** — diff files applied to decompiled Minecraft server classes +2. **Fork Java source** — additional classes compiled alongside MC server code + +There is no domain/application/infrastructure separation. The "domain" is Minecraft server internals. diff --git a/leaf-server/LICENSE-EntityCulling.txt b/leaf-server/LICENSE-EntityCulling.txt new file mode 100644 index 00000000..59f4dab6 --- /dev/null +++ b/leaf-server/LICENSE-EntityCulling.txt @@ -0,0 +1,22 @@ +tr7zw Protective License + +Copyright (c) tr7zw, 2021 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software (in source or binary form) and associated documentation files +(the "Software"), to use, modify and compile the Software, subject to the +following conditions: + +The Software may not be used to get a) a commercial advantage, or b) monetary +compensation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/leaf-server/minecraft-patches/features/0350-Raytrace-Entity-Tracker.patch b/leaf-server/minecraft-patches/features/0350-Raytrace-Entity-Tracker.patch new file mode 100644 index 00000000..fa376c33 --- /dev/null +++ b/leaf-server/minecraft-patches/features/0350-Raytrace-Entity-Tracker.patch @@ -0,0 +1,191 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: HaHaWTH <102713261+HaHaWTH@users.noreply.github.com> +Date: Sun, 7 Nov 2077 00:00:00 +1400 +Subject: [PATCH] Raytrace Entity Tracker + +Original project: https://github.com/tr7zw/EntityCulling +Original license: Custom License + +Original project: https://github.com/LogisticsCraft/OcclusionCulling +Original license: MIT + +Ported by: https://github.com/LuminolMC/Luminol +Fixed and updated by: https://github.com/Winds-Studio/Leaf + +TODO: +Check diff https://github.com/tr7zw/EntityCulling/compare/5774b56c6d7881fe3407ab80b662b94150dffafc...main + +EntityCulling commit: null + +diff --git a/net/minecraft/world/entity/Entity.java b/net/minecraft/world/entity/Entity.java +--- a/net/minecraft/world/entity/Entity.java ++++ b/net/minecraft/world/entity/Entity.java +@@ -152,7 +152,7 @@ import org.jetbrains.annotations.Contract; + import org.jspecify.annotations.Nullable; + import org.slf4j.Logger; + +-public abstract class Entity implements SyncedDataHolder, DebugValueSource, Nameable, ItemOwner, SlotProvider, EntityAccess, ScoreHolder, DataComponentGetter, ca.spottedleaf.moonrise.patches.chunk_system.entity.ChunkSystemEntity, ca.spottedleaf.moonrise.patches.entity_tracker.EntityTrackerEntity, net.caffeinemc.mods.lithium.common.world.in_world_tracking.MaybeInLevelObject { // Paper - rewrite chunk system // Paper - optimise entity tracker // Leaf - Lithium - equipment tracking ++public abstract class Entity implements SyncedDataHolder, DebugValueSource, Nameable, ItemOwner, SlotProvider, EntityAccess, ScoreHolder, DataComponentGetter, ca.spottedleaf.moonrise.patches.chunk_system.entity.ChunkSystemEntity, ca.spottedleaf.moonrise.patches.entity_tracker.EntityTrackerEntity, net.caffeinemc.mods.lithium.common.world.in_world_tracking.MaybeInLevelObject, dev.tr7zw.entityculling.versionless.access.Cullable { // Paper - rewrite chunk system // Paper - optimise entity tracker // Leaf - Lithium - equipment tracking // Leaf - Raytrace entity tracker + public static javax.script.ScriptEngine scriptEngine = new javax.script.ScriptEngineManager().getEngineByName("rhino"); // Purpur - Configurable entity base attributes + // CraftBukkit start + private static final int CURRENT_LEVEL = 2; +@@ -5766,6 +5766,7 @@ public abstract class Entity implements SyncedDataHolder, DebugValueSource, Name + return; + } + // Paper end - rewrite chunk system ++ dev.tr7zw.entityculling.CullTask.onEntityRemoval(this); // Leaf - Raytrace entity tracker + org.bukkit.craftbukkit.event.CraftEventFactory.callEntityRemoveEvent(this, cause); // CraftBukkit + final boolean alreadyRemoved = this.removalReason != null; // Paper - Folia schedulers + if (this.removalReason == null) { +@@ -6104,4 +6105,36 @@ public abstract class Entity implements SyncedDataHolder, DebugValueSource, Name + return this.distanceToSqr(entity.position()); + } + // Leaf end - Optimize Entity distanceToSqr ++ ++ // Leaf start - Raytrace entity tracker ++ private volatile long lastTime = 0; ++ ++ @Override ++ public void setTimeout() { ++ this.lastTime = System.currentTimeMillis() + 1000; ++ } ++ ++ @Override ++ public boolean isForcedVisible() { ++ return this.lastTime > System.currentTimeMillis(); ++ } ++ ++ @Override ++ public void setCulled(boolean value, net.minecraft.world.entity.player.Player player) { ++ dev.tr7zw.entityculling.CullTask task = player.cullTask.get(); ++ if (task == null) return; ++ task.setCulled(this, value); ++ if (!value) { ++ setTimeout(); ++ } ++ } ++ ++ @Override ++ public boolean isCulled(net.minecraft.world.entity.player.Player player) { ++ if (!org.dreeam.leaf.config.modules.misc.RaytraceTracker.enabled) return false; ++ dev.tr7zw.entityculling.CullTask task = player.cullTask.get(); ++ if (task == null) return false; ++ return task.isEntityCulled(this); ++ } ++ // Leaf end - Raytrace entity tracker + } +diff --git a/net/minecraft/world/entity/EntityType.java b/net/minecraft/world/entity/EntityType.java +--- a/net/minecraft/world/entity/EntityType.java ++++ b/net/minecraft/world/entity/EntityType.java +@@ -1221,6 +1221,7 @@ public class EntityType implements FeatureElement, EntityTypeT + private final int clientTrackingRange; + private final int updateInterval; + public boolean dabEnabled = false; // Pufferfish ++ public boolean skipRaytraceCheck = false; // Leaf - Raytrace Entity Tracker + private final String descriptionId; + private @Nullable Component description; + private final Optional> lootTable; +diff --git a/net/minecraft/world/entity/player/Player.java b/net/minecraft/world/entity/player/Player.java +--- a/net/minecraft/world/entity/player/Player.java ++++ b/net/minecraft/world/entity/player/Player.java +@@ -184,6 +184,12 @@ public abstract class Player extends Avatar implements ContainerUser { + public int sixRowEnderchestSlotCount = -1; // Purpur - Barrels and enderchests 6 rows + private long lastSprintKnockback = -1; // Sakura - configure entity knockback + ++ // Leaf start - Raytrace Entity Tracker ++ @javax.annotation.Nullable public java.util.concurrent.atomic.AtomicReference cullTask = new java.util.concurrent.atomic.AtomicReference<>(null); ++ @javax.annotation.Nullable private net.minecraft.world.level.Level lastCullWorld; ++ private final boolean needToCull = !(this instanceof org.leavesmc.leaves.replay.ServerPhotographer); ++ // Leaf end - Raytrace Entity Tracker ++ + // CraftBukkit start + public boolean fauxSleeping; + public int oldLevel = -1; +@@ -307,6 +313,41 @@ public abstract class Player extends Avatar implements ContainerUser { + } + // Purpur end - Burp delay + ++ // Leaf start - Raytrace Entity Tracker ++ if (this.needToCull) { ++ if (!org.dreeam.leaf.config.modules.misc.RaytraceTracker.enabled) { ++ dev.tr7zw.entityculling.CullTask existing = this.cullTask.get(); ++ if (existing != null) { ++ existing.signalStop(); ++ this.cullTask.set(null); ++ } ++ } else { ++ net.minecraft.world.level.Level currLevel = this.level(); ++ dev.tr7zw.entityculling.CullTask current = this.cullTask.get(); ++ boolean needsUpdate = current == null || this.lastCullWorld != currLevel; ++ ++ if (needsUpdate) { ++ if (current != null) { ++ current.signalStop(); ++ } ++ final com.logisticscraft.occlusionculling.OcclusionCullingInstance culling = new com.logisticscraft.occlusionculling.OcclusionCullingInstance( ++ org.dreeam.leaf.config.modules.misc.RaytraceTracker.maxTraceDistance, ++ new dev.tr7zw.entityculling.DefaultChunkDataProvider(currLevel) ++ ); ++ dev.tr7zw.entityculling.CullTask newTask = new dev.tr7zw.entityculling.CullTask( ++ culling, ++ this, ++ org.dreeam.leaf.config.modules.misc.RaytraceTracker.boundingBoxLimit, ++ org.dreeam.leaf.config.modules.misc.RaytraceTracker.traceInterval ++ ); ++ this.cullTask.set(newTask); ++ this.lastCullWorld = currLevel; ++ newTask.setup(); ++ } ++ } ++ } ++ // Leaf end - Raytrace Entity Tracker ++ + this.noPhysics = this.isSpectator(); + if (this.isSpectator() || this.isPassenger()) { + this.setOnGround(false); +@@ -1500,6 +1541,13 @@ public abstract class Player extends Avatar implements ContainerUser { + if (this.hasContainerOpen()) { + this.doCloseContainer(); + } ++ // Leaf start - Raytrace Entity Tracker ++ dev.tr7zw.entityculling.CullTask existing = this.cullTask.get(); ++ if (existing != null) { ++ existing.signalStop(); ++ this.cullTask.set(null); ++ } ++ // Leaf end - Raytrace Entity Tracker + } + + @Override +diff --git a/net/minecraft/world/level/Level.java b/net/minecraft/world/level/Level.java +--- a/net/minecraft/world/level/Level.java ++++ b/net/minecraft/world/level/Level.java +@@ -1197,6 +1197,7 @@ public abstract class Level implements LevelAccessor, AutoCloseable, ca.spottedl + } + // Imanity end - AntiXraySDK integration + this.chunkPacketBlockController.onBlockChange(this, pos, state, blockState, flags, recursionLeft); // Paper - Anti-Xray ++ dev.tr7zw.entityculling.CullTask.onBlockChange(this, pos); // Leaf - Raytrace Entity Tracker + // CraftBukkit end + if (blockState == null) { + // CraftBukkit start - remove blockstate if failed (or the same) +diff --git a/net/minecraft/server/level/ChunkMap.java b/net/minecraft/server/level/ChunkMap.java +--- a/net/minecraft/server/level/ChunkMap.java ++++ b/net/minecraft/server/level/ChunkMap.java +@@ -1507,7 +1507,7 @@ public class ChunkMap extends SimpleRegionStorage implements ChunkHolder.PlayerP + double d1 = vec3_dx * vec3_dx + vec3_dz * vec3_dz; // Paper + double d2 = d * d; + // Paper start - Configurable entity tracking range by Y +- boolean flag = d1 <= d2; ++ boolean flag = d1 <= d2 && !entity.isCulled(player); // Leaf - Raytrace Entity Tracker + if (flag && level.paperConfig().entities.trackingRangeY.enabled) { + double rangeY = level.paperConfig().entities.trackingRangeY.get(this.entity, -1); + if (rangeY != -1) { +@@ -1672,7 +1672,7 @@ public class ChunkMap extends SimpleRegionStorage implements ChunkHolder.PlayerP + double d1 = vec3_dx * vec3_dx + vec3_dz * vec3_dz; // Paper + double d2 = d * d; + // Paper start - Configurable entity tracking range by Y +- boolean flag = d1 <= d2; ++ boolean flag = d1 <= d2 && !entity.isCulled(player); // Leaf - Raytrace Entity Tracker + if (flag && level.paperConfig().entities.trackingRangeY.enabled) { + double rangeY = level.paperConfig().entities.trackingRangeY.get(this.entity, -1); + if (rangeY != -1) { diff --git a/leaf-server/minecraft-patches/features/series b/leaf-server/minecraft-patches/features/series new file mode 100644 index 00000000..4b3333ba --- /dev/null +++ b/leaf-server/minecraft-patches/features/series @@ -0,0 +1,350 @@ +0001-Purpur-Server-Minecraft-Changes.patch +0002-Rebrand.patch +0003-Gale-configuration.patch +0004-Leaf-config.patch +0005-Leaf-Commands.patch +0006-Configurable-vanilla-profiler.patch +0007-Use-platform-math-functions.patch +0008-Faster-floating-point-positive-modulo.patch +0009-Simpler-ShapelessRecipe-comparison-for-vanilla.patch +0010-Reduce-projectile-chunk-loading.patch +0011-Predict-Halloween.patch +0012-Move-random-tick-random.patch +0013-Optimize-random-calls-in-chunk-ticking.patch +0014-Reduce-enderman-teleport-chunk-lookups.patch +0015-Check-targeting-range-before-getting-visibility.patch +0016-Cache-on-climbable-check.patch +0017-Make-EntityCollisionContext-a-live-representation.patch +0018-Better-checking-for-useless-move-packets.patch +0019-Remove-lambda-from-ticking-guard.patch +0020-SIMD-support.patch +0021-Make-book-writing-configurable.patch +0022-Reduce-in-wall-checks.patch +0023-Make-chat-order-verification-configurable.patch +0024-Make-ender-dragon-respawn-attempt-after-placing-end-.patch +0025-Make-saving-fireworks-configurable.patch +0026-Reduce-villager-item-re-pickup.patch +0027-Variable-entity-wake-up-duration.patch +0028-Do-not-process-chat-commands-before-player-has-joine.patch +0029-Do-not-log-invalid-statistics.patch +0030-Do-not-log-empty-message-warnings.patch +0031-Do-not-log-ignored-advancements.patch +0032-Do-not-log-setBlock-in-far-chunks.patch +0033-Do-not-log-unrecognized-recipes.patch +0034-Do-not-log-expired-message-warnings.patch +0035-Do-not-log-Not-Secure-marker.patch +0036-Do-not-log-disconnections-with-null-id.patch +0037-Do-not-log-run-as-root-warning.patch +0038-Do-not-log-offline-mode-warning.patch +0039-Fix-outdated-server-showing-in-ping-before-server-fu.patch +0040-Fix-MC-238526.patch +0041-Fix-MC-121706.patch +0042-Make-logging-login-locations-configurable.patch +0043-Reduce-array-allocations.patch +0044-Optimize-sun-burn-tick.patch +0045-Reduce-lambda-and-Optional-allocation-in-EntityBased.patch +0046-Replace-throttle-tracker-map-with-optimized-collecti.patch +0047-Cache-FluidOcclusionCacheKey-hash.patch +0048-Cache-ShapePairKey-hash.patch +0049-Replace-division-by-multiplication-in-CubePointRange.patch +0050-Replace-parts-by-size-in-CubePointRange.patch +0051-Check-frozen-ticks-before-landing-block.patch +0052-Update-boss-bar-within-tick.patch +0053-Cache-world-generator-sea-level.patch +0054-Skip-secondary-POI-sensor-if-absent.patch +0055-Skip-entity-move-if-movement-is-zero.patch +0056-Store-mob-counts-in-an-array.patch +0057-Use-linked-map-for-entity-trackers.patch +0058-Optimize-noise-generation.patch +0059-Optimize-sheep-offspring-color.patch +0060-Hide-flames-on-entities-with-fire-resistance.patch +0061-Skip-cloning-advancement-criteria.patch +0062-Reduce-block-destruction-packet-allocations.patch +0063-Spread-out-sending-all-player-info.patch +0064-Skip-PlayerCommandSendEvent-if-there-are-no-listener.patch +0065-Send-multiple-keep-alive-packets.patch +0066-Make-slow-login-timeout-configurable.patch +0067-Don-t-load-chunks-to-spawn-phantoms.patch +0068-Don-t-load-chunks-to-activate-climbing-entities.patch +0069-Broadcast-crit-animations-as-the-entity-being-critte.patch +0070-Ignore-null-legacy-structure-data.patch +0071-Prevent-entities-random-strolling-into-non-ticking-c.patch +0072-Global-EULA-file.patch +0073-5-second-TPS-average.patch +0074-Measure-last-tick-time.patch +0075-Increase-time-statistics-in-intervals.patch +0076-For-collision-check-has-physics-before-same-vehicle.patch +0077-Skip-negligible-planar-movement-multiplication.patch +0078-Optimize-matching-item-checks.patch +0079-Pre-compute-VarLong-sizes.patch +0080-Optimize-VarInt-write-and-VarLong-write.patch +0081-Reduce-RandomSource-instances.patch +0082-Reduce-skull-ItemStack-lookups-for-reduced-visibilit.patch +0083-Initialize-line-of-sight-cache-with-low-capacity.patch +0084-Reduce-line-of-sight-updates-and-cache-lookups.patch +0085-Pufferfish-Optimize-mob-spawning.patch +0086-Pufferfish-Dynamic-Activation-of-Brain.patch +0087-Fix-Pufferfish-and-Purpur-patches.patch +0088-Purpur-Configurable-server-mod-name.patch +0089-Configurable-server-GUI-name.patch +0090-Configurable-vanilla-username-check.patch +0091-Remove-Spigot-check-for-broken-BungeeCord-configurat.patch +0092-Remove-UseItemOnPacket-Too-Far-check.patch +0093-Remove-change-non-editable-sign-warning.patch +0094-KeYi-Add-an-option-for-spigot-item-merging-mechanism.patch +0095-Carpet-Fixes-Optimized-getBiome-method.patch +0096-Carpet-Fixes-Use-optimized-RecipeManager.patch +0097-Akarin-Save-Json-list-asynchronously.patch +0098-Slice-Smooth-Teleports.patch +0099-Parchment-Make-FixLight-use-action-bar.patch +0100-Leaves-Protocol-Core.patch +0101-Leaves-Jade-Protocol.patch +0102-Leaves-Xaero-Map-Protocol.patch +0103-Leaves-Syncmatica-Protocol.patch +0104-Leaves-Replay-Mod-API.patch +0105-Petal-Async-Pathfinding.patch +0106-Petal-reduce-work-done-by-game-event-system.patch +0107-Reduce-canSee-work.patch +0108-Fix-sprint-glitch.patch +0109-Configurable-movement-speed-of-more-entities.patch +0110-Faster-sequencing-of-futures-for-chunk-structure-gen.patch +0111-Luminol-Configurable-region-format-framework.patch +0112-Plazma-Add-some-missing-Pufferfish-configurations.patch +0113-Plazma-Add-missing-purpur-configuration-options.patch +0114-SparklyPaper-Skip-distanceToSqr-call-in-ServerEntity.patch +0115-SparklyPaper-Skip-MapItem-update-if-the-map-does-not.patch +0116-SparklyPaper-Optimize-canSee-checks.patch +0117-Polpot-Make-egg-and-snowball-can-knockback-player.patch +0118-Remove-useless-creating-stats-json-bases-on-player-n.patch +0119-Improve-Purpur-AFK-system.patch +0120-Virtual-thread-support-for-chat-executor.patch +0121-Virtual-thread-support-for-user-authenticator.patch +0122-Virtual-thread-support-for-download-pool.patch +0123-Mirai-Configurable-chat-message-signatures.patch +0124-Matter-Secure-Seed.patch +0125-Matter-Secure-Seed-command.patch +0126-Faster-random-generator.patch +0127-Don-t-save-primed-tnt-entity.patch +0128-Don-t-save-falling-block-entity.patch +0129-Configurable-connection-message.patch +0130-Configurable-unknown-command-message.patch +0131-Remove-stream-in-BlockBehaviour-cache-blockstate.patch +0132-Remove-stream-in-entity-visible-effects-filter.patch +0133-Remove-stream-and-double-iteration-in-enough-deep-sl.patch +0134-Remove-stream-in-trial-spawner-ticking.patch +0135-Remove-stream-in-Brain.patch +0136-Remove-stream-in-BehaviorUtils.patch +0137-Remove-stream-in-YieldJobSite.patch +0138-Remove-stream-in-PlayerSensor.patch +0139-Remove-stream-in-GolemSensor.patch +0140-Remove-stream-in-GateBehavior.patch +0141-Remove-stream-in-matchingSlot.patch +0142-Remove-stream-in-villagers.patch +0143-Remove-stream-on-updateConnectedPlayersWithinRange.patch +0144-Remove-stream-on-PlayerDetector.patch +0145-Remove-stream-in-MobSensor.patch +0146-Remove-stream-in-TemptingSensor.patch +0147-Remove-stream-in-CraftWorld-spawnParticle.patch +0148-Replace-Entity-active-effects-map-with-optimized-col.patch +0149-Replace-criterion-map-with-optimized-collection.patch +0150-Replace-brain-with-optimized-collection.patch +0151-Reduce-worldgen-allocations.patch +0152-Use-caffeine-cache-for-kickPermission-instead-of-usi.patch +0153-Fix-MC-17876.patch +0154-Fix-MC-65198.patch +0155-Fix-MC-200418.patch +0156-Fix-MC-223153.patch +0157-Fix-MC-270656.patch +0158-Fix-MC-298464.patch +0159-Fix-MC-299642.patch +0160-Fix-MC-152094.patch +0161-Configurable-player-knockback-zombie.patch +0162-Paper-PR-Skip-AI-during-inactive-ticks-for-non-aware.patch +0163-Paper-PR-Prevent-zombie-reinforcements-loading-chunk.patch +0164-Paper-PR-Fix-some-beacon-event-issues.patch +0165-Dont-send-useless-entity-packets.patch +0166-optimize-attribute.patch +0167-Nitori-Async-playerdata-saving.patch +0168-Optimize-nearby-alive-players-for-spawning.patch +0169-Cache-blockstate-cache-array.patch +0170-Further-reduce-memory-footprint-of-CompoundTag.patch +0171-Optimize-Entity-distanceToSqr.patch +0172-EMC-Don-t-use-snapshots-for-TileEntity-getOwner.patch +0173-Cache-tile-entity-position.patch +0174-TT20-Lag-compensation.patch +0175-C2ME-Reduce-Allocations.patch +0176-Lithium-Skip-unnecessary-calculations-if-player-is-n.patch +0177-Lithium-fast-util.patch +0178-Lithium-cached-iterate-outwards.patch +0179-Use-faster-and-thread-safe-ban-list-date-format-pars.patch +0180-C2ME-Optimize-world-gen-math.patch +0181-Reduce-fluid-status-allocation-in-NoiseBasedChunkGen.patch +0182-Cache-chunk-key.patch +0183-Configurable-tripwire-dupe.patch +0184-Paper-PR-Fix-MC-117075-Block-Entities-Unload-Lag-Spi.patch +0185-Sepals-Rearrange-the-attackable-conditions.patch +0186-SparklyPaper-Skip-dirty-stats-copy-when-requesting-p.patch +0187-SparklyPaper-Reset-dirty-flag-when-loading-maps-from.patch +0188-Optimize-checking-nearby-players-for-spawning.patch +0189-Avoid-useless-deque-clear-on-LevelTicks-cleanupAfter.patch +0190-Optimize-baby-villager-sensor.patch +0191-Only-player-pushable.patch +0192-Remove-iterators-from-Inventory.patch +0193-optimize-mob-despawn.patch +0194-Slightly-optimise-getNearestPlayer.patch +0195-Bulk-writes-to-writeLongArray-during-chunk-loading.patch +0196-Improve-sorting-in-SortedArraySet.patch +0197-Make-removeIf-slightly-faster.patch +0198-Rewrite-ClientboundLightUpdatePacketData.patch +0199-async-chunk-sender.patch +0200-Spawner-Configurations.patch +0201-SparklyPaper-Parallel-world-ticking.patch +0202-SparklyPaper-Track-each-world-MSPT.patch +0203-Paper-PR-Fix-cancelled-Projectile-Events-still-consu.patch +0204-Optimize-SetLookAndInteract-and-NearestVisibleLiving.patch +0205-rewrite-InsideBrownianWalk.patch +0206-Use-BFS-on-getSlopeDistance.patch +0207-Paper-PR-Throttle-failed-spawn-attempts.patch +0208-Improve-BlockEntity-ticking-isRemoved-check.patch +0209-Raytrace-AntiXray-SDK-integration.patch +0210-Optimize-ContextMap.create.patch +0211-Micro-optimizations-for-random-tick.patch +0212-Use-direct-iteration-on-Sensing.tick.patch +0213-Optimise-non-flush-packet-sending.patch +0214-Null-handling-on-MultifaceSpreader.patch +0215-Optimize-ThreadedTicketLevelPropagator.patch +0216-Optimise-MobEffectUtil-getDigSpeedAmplification.patch +0217-Optimise-chunkUnloads.patch +0218-Optimize-BlockEntityType-isValid.patch +0219-Paper-PR-Add-ticket-on-player-join-to-avoid-chunk-lo.patch +0220-Sakura-Optimise-check-inside-blocks-and-traverse-blo.patch +0221-Sakura-copy-EntityList-implementation-to-BasicEntity.patch +0222-Protocol-Core.patch +0223-Async-switch-connection-state.patch +0224-Optimize-BlockEntities-tickersInLevel.patch +0225-Pluto-Check-if-the-cactus-can-even-survive-being-pla.patch +0226-Flush-location-while-knockback.patch +0227-Only-tick-items-at-hand.patch +0228-Optimise-player-movement-checks.patch +0229-Add-configurable-death-item-drop-knockback-settings.patch +0230-Optimize-getScaledTrackingDistance.patch +0231-Optimize-SynchedEntityData-packDirty.patch +0232-Optimize-isEyeInFluid.patch +0233-Cache-block-state-tags.patch +0234-optimize-getEntityStatus.patch +0235-Rail-Optimization-optimized-PoweredRailBlock-logic.patch +0236-optimise-ChunkGenerator-getMobsAt.patch +0237-cache-biome-for-mob-spawning-and-advancements.patch +0238-optimize-mob-spawning.patch +0239-optimize-structure-map.patch +0240-throttle-mob-spawning.patch +0241-Add-BlockExplosionHitEvent.patch +0242-Old-Blast-Protection-explosion-knockback.patch +0243-Use-UUID-for-cure-reputation.patch +0244-Fix-crash-during-parsing-unknown-command-message.patch +0245-optimize-random-tick.patch +0246-do-not-log-invalid-flatten-text-component-parse.patch +0247-Fast-BiomeManager-seed-obfuscation.patch +0248-Replace-EntitySelectorOptions-map-with-optimized-col.patch +0249-optimize-no-action-time.patch +0250-optimize-waypoint.patch +0251-Paw-optimization.patch +0252-Skip-inactive-entity-for-execute.patch +0253-Optimise-getEntities.patch +0254-Re-route-SetClosestHomeAsWalkTarget-s-poi-finding-to.patch +0255-optimize-checkInsideBlocks-calls.patch +0256-Optimise-TextColor.patch +0257-Do-not-create-fire-if-explosion-was-cancelled.patch +0258-Skip-BlockPhysicsEvent-if-no-listeners.patch +0259-Lithium-equipment-tracking.patch +0260-Add-io_uring-support.patch +0261-Paper-PR-Optimise-temptation-lookups.patch +0262-Paper-PR-Optimise-temptation-lookups-changes.patch +0263-Paper-PR-fix-ConcurrentModificationException-in-asyn.patch +0264-Paper-PR-Fix-cancelled-rooted-dirt-drops-being-duped.patch +0265-Lithium-combined-heightmap-update.patch +0266-Skip-PreCreatureSpawnEvent-if-no-listeners.patch +0267-optimize-goal-selector.patch +0268-thread-unsafe-chunk-map.patch +0269-optimize-SimpleBitStorage-object-layout.patch +0270-optimize-get-chunk.patch +0271-remove-shouldTickBlocksAt-check.patch +0272-optimize-PalettedContainer-get.patch +0273-optimize-LevelChunk-getBlockStateFinal.patch +0274-optimize-FluidState-is-TagKey.patch +0275-Pluto-don-t-load-chunks-to-spread-grass.patch +0276-counting-chunk-section-fluid.patch +0277-optimize-onClimbable.patch +0278-optimize-applyMovementEmissionAndPlaySound.patch +0279-optimize-isStateClimbable.patch +0280-optimize-getOnPos.patch +0281-optimize-eye-block-position.patch +0282-optimize-entity-in-fluid.patch +0283-Replace-entity-fluid-height-map.patch +0284-optimize-collision-shape.patch +0285-cache-collision-list.patch +0286-fast-bit-radix-sort.patch +0287-optimize-tickEffects.patch +0288-Pluto-Expose-Direction-Plane-s-faces.patch +0289-Pluto-reduce-allocation.patch +0290-Only-update-frozen-ticks-if-changed.patch +0291-Prevent-executing-commands-if-server-stopped.patch +0292-optimize-canHoldAnyFluid.patch +0293-Multithreaded-Tracker.patch +0294-optimize-collidedAlongVector.patch +0295-Rewrite-entity-despawn-time.patch +0296-Optimize-Vec3i-hashing.patch +0297-Cache-world-border.patch +0298-Lithium-faster-hash-palette.patch +0299-Optimize-PatchedDataComponentMap-equals.patch +0300-optimize-movement-vector-normalization.patch +0301-Reduce-optimiseRandomTick-new-BlockPos-instance-crea.patch +0302-configurable-goal-update-interval.patch +0303-Prevent-entities-from-moving-into-weak-loaded-chunks.patch +0304-Don-t-load-POI-for-competitor-scan.patch +0305-optimize-PathNavigation-followThePath.patch +0306-reduce-enchantment-allocations.patch +0307-Reduce-AbstractContainerMenu-allocations.patch +0308-Optimize-map-lookups-with-isEmpty-check.patch +0309-Filter-ClientboundSetEntityMotionPacket.patch +0310-Disable-Useless-Particles.patch +0311-Optimize-pushable-selector.patch +0312-Optimize-respawn-anchor-explosion.patch +0313-Skip-VehicleEntityCollisionEvent-if-no-listeners.patch +0314-Cache-identifier-toString-and-hash.patch +0315-Fix-MC-301114-Combat-Tracker-memory-leak.patch +0316-Paper-Lazy-set-Entity.projectileSource-in-AbstractPr.patch +0317-Paper-Add-EntityLungeEvent.patch +0318-Paper-reset-pending-keepalive-state-on-listener-hand.patch +0319-Paper-Fix-clicked-items-disappearing-when-player-die.patch +0320-Paper-Add-options-to-disable-chunk-and-block-entity-.patch +0321-Paper-optimize-ServerWaypointManager-with-locator-ba.patch +0322-Paper-Limit-projectiles-size.patch +0323-Improve-Purpur-JS-expression-evaluation.patch +0324-configurable-ice-and-snow-tick-chance.patch +0325-fix-skeleton-horse-trap-NPE.patch +0326-Skip-item-merge-checks-for-full-stacks.patch +0327-disable-world-data-saving.patch +0328-Replace-entity-equipment-items-to-array.patch +0329-Leaves-Lithium-Sleeping-Block-Entity.patch +0330-fixup-Leaves-Lithium-Sleeping-Block-Entity.patch +0331-Leaves-Vanilla-hopper.patch +0332-Add-read-only-mode-for-Linear-v2.patch +0333-Reduce-debug-subscribers-overhead.patch +0334-Limit-max-size-of-subscription-codec.patch +0335-Pre-authenticate-event.patch +0336-Fuji-merge-Sakura-patches-into-Leaf-1.21.11-net-mine.patch +0337-Fuji-revert-BlockGetter-to-Leaf-defer-Sakura-0026.patch +0338-Fuji-revert-Hopper-ChunkEntitySlices-to-Leaf-defer-0.patch +0339-Fuji-compile-fixes-checkInsideBlocks-distanceToSqr-C.patch +0340-Fuji-maintenance-command-op-level-toggle.patch +0341-Fuji-port-Sakura-lava-flow-speed-API-deterministic-p.patch +0342-Fuji-async-tracker-honors-Sakura-client-visibility-s.patch +0343-Fuji-restore-storeEntityState-in-inlined-tick-guard-.patch +0344-Fuji-add-checkEntityVisibility-to-updatePlayerMulti-.patch +0345-Fuji-port-0026-version-accurate-block-traversal-cann.patch +0346-Fuji-roof-cannon-height-nerf-require-horizontal-move.patch +0347-Fuji-instant-falling-block-stacking-opt-in.patch +0348-Fuji-legacy-1.8-style-friction-air-knockback-model-o.patch +0349-Fuji-per-world-toggle-to-disable-hurt-camera-shake-1.patch +0350-Raytrace-Entity-Tracker.patch diff --git a/leaf-server/src/main/java/com/logisticscraft/occlusionculling/DataProvider.java b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/DataProvider.java new file mode 100644 index 00000000..5ab46005 --- /dev/null +++ b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/DataProvider.java @@ -0,0 +1,34 @@ +package com.logisticscraft.occlusionculling; + +import com.logisticscraft.occlusionculling.util.Vec3d; + +public interface DataProvider { + + /** + * Prepares the requested chunk. Returns true if the chunk is ready, false when + * not loaded. Should not reload the chunk when the x and y are the same as the + * last request! + * + * @param chunkX + * @param chunkZ + * @return + */ + boolean prepareChunk(int chunkX, int chunkZ); + + /** + * Location is inside the chunk. + * + * @param x + * @param y + * @param z + * @return + */ + boolean isOpaqueFullCube(int x, int y, int z); + + default void cleanup() { + } + + default void checkingPosition(Vec3d[] targetPoints, int size, Vec3d viewerPosition) { + } + +} diff --git a/leaf-server/src/main/java/com/logisticscraft/occlusionculling/OcclusionCullingInstance.java b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/OcclusionCullingInstance.java new file mode 100644 index 00000000..922b6278 --- /dev/null +++ b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/OcclusionCullingInstance.java @@ -0,0 +1,525 @@ +package com.logisticscraft.occlusionculling; + +import com.logisticscraft.occlusionculling.cache.ArrayOcclusionCache; +import com.logisticscraft.occlusionculling.cache.OcclusionCache; +import com.logisticscraft.occlusionculling.util.MathUtilities; +import com.logisticscraft.occlusionculling.util.Vec3d; + +import java.util.Arrays; +import java.util.BitSet; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class OcclusionCullingInstance { + + private static final int ON_MIN_X = 0x01; + private static final int ON_MAX_X = 0x02; + private static final int ON_MIN_Y = 0x04; + private static final int ON_MAX_Y = 0x08; + private static final int ON_MIN_Z = 0x10; + private static final int ON_MAX_Z = 0x20; + + private final int reach; + private final double aabbExpansion; + private final DataProvider provider; + private final OcclusionCache cache; + + private static final Logger LOGGER = LoggerFactory.getLogger(OcclusionCullingInstance.class); + private static final Map lastErrorLogged = new ConcurrentHashMap<>(); + private static final long ERROR_LOG_COOLDOWN_MS = 5000; + + // Reused allocated data structures + private final BitSet skipList = new BitSet(); // Grows bigger in case some mod introduces giant hitboxes + private final Vec3d[] targetPoints = new Vec3d[15]; + private Vec3d targetPos = new Vec3d(0, 0, 0); + private final int[] cameraPos = new int[3]; + private final boolean[] dotselectors = new boolean[14]; + private boolean allowRayChecks = false; + private final int[] lastHitBlock = new int[3]; + private boolean allowWallClipping = false; + + + public OcclusionCullingInstance(int maxDistance, DataProvider provider) { + this(maxDistance, provider, new ArrayOcclusionCache(maxDistance), org.dreeam.leaf.config.modules.misc.RaytraceTracker.boundingBoxExpansion); + } + + public OcclusionCullingInstance(int maxDistance, DataProvider provider, OcclusionCache cache, double aabbExpansion) { + this.reach = maxDistance; + this.provider = provider; + this.cache = cache; + this.aabbExpansion = aabbExpansion; + for (int i = 0; i < targetPoints.length; i++) { + targetPoints[i] = new Vec3d(0, 0, 0); + } + } + + public boolean isAABBVisible(Vec3d aabbMin, Vec3d aabbMax, Vec3d viewerPosition) { + try { + int maxX = MathUtilities.floor(aabbMax.x() + + aabbExpansion); + int maxY = MathUtilities.floor(aabbMax.y() + + aabbExpansion); + int maxZ = MathUtilities.floor(aabbMax.z() + + aabbExpansion); + int minX = MathUtilities.floor(aabbMin.x() + - aabbExpansion); + int minY = MathUtilities.floor(aabbMin.y() + - aabbExpansion); + int minZ = MathUtilities.floor(aabbMin.z() + - aabbExpansion); + + cameraPos[0] = MathUtilities.floor(viewerPosition.x()); + cameraPos[1] = MathUtilities.floor(viewerPosition.y()); + cameraPos[2] = MathUtilities.floor(viewerPosition.z()); + + Relative relX = Relative.from(minX, maxX, cameraPos[0]); + Relative relY = Relative.from(minY, maxY, cameraPos[1]); + Relative relZ = Relative.from(minZ, maxZ, cameraPos[2]); + + if (relX == Relative.INSIDE && relY == Relative.INSIDE && relZ == Relative.INSIDE) { + return true; // We are inside of the AABB, don't cull + } + + skipList.clear(); + + // Just check the cache first + int id = 0; + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + int cachedValue = getCacheValue(x, y, z); + + if (cachedValue == 1) { + // non-occluding + return true; + } + + if (cachedValue != 0) { + // was checked and it wasn't visible + skipList.set(id); + } + id++; + } + } + } + + // only after the first hit wall the cache becomes valid. + allowRayChecks = false; + + // since the cache wasn't helpfull + id = 0; + for (int x = minX; x <= maxX; x++) { + byte visibleOnFaceX = 0; + byte faceEdgeDataX = 0; + faceEdgeDataX |= (x == minX) ? ON_MIN_X : 0; + faceEdgeDataX |= (x == maxX) ? ON_MAX_X : 0; + visibleOnFaceX |= (x == minX && relX == Relative.POSITIVE) ? ON_MIN_X : 0; + visibleOnFaceX |= (x == maxX && relX == Relative.NEGATIVE) ? ON_MAX_X : 0; + for (int y = minY; y <= maxY; y++) { + byte faceEdgeDataY = faceEdgeDataX; + byte visibleOnFaceY = visibleOnFaceX; + faceEdgeDataY |= (y == minY) ? ON_MIN_Y : 0; + faceEdgeDataY |= (y == maxY) ? ON_MAX_Y : 0; + visibleOnFaceY |= (y == minY && relY == Relative.POSITIVE) ? ON_MIN_Y : 0; + visibleOnFaceY |= (y == maxY && relY == Relative.NEGATIVE) ? ON_MAX_Y : 0; + for (int z = minZ; z <= maxZ; z++) { + byte faceEdgeData = faceEdgeDataY; + byte visibleOnFace = visibleOnFaceY; + faceEdgeData |= (z == minZ) ? ON_MIN_Z : 0; + faceEdgeData |= (z == maxZ) ? ON_MAX_Z : 0; + visibleOnFace |= (z == minZ && relZ == Relative.POSITIVE) ? ON_MIN_Z : 0; + visibleOnFace |= (z == maxZ && relZ == Relative.NEGATIVE) ? ON_MAX_Z : 0; + if (skipList.get(id)) { // was checked and it wasn't visible + id++; + continue; + } + + if (visibleOnFace != 0) { + this.targetPos = new Vec3d(x, y, z); + if (isVoxelVisible(viewerPosition, targetPos, faceEdgeData, visibleOnFace)) { + return true; + } + } + id++; + } + } + } + + return false; + } catch (Exception e) { + String key = e.getClass().getSimpleName(); + long now = System.currentTimeMillis(); + Long last = lastErrorLogged.get(key); + if (last == null || now - last >= ERROR_LOG_COOLDOWN_MS) { + lastErrorLogged.put(key, now); + LOGGER.warn("[OcclusionCulling] {} in isAABBVisible: {}", key, e.getMessage()); + } + } + return true; + } + + /** + * @param viewerPosition + * @param position + * @param faceData contains rather this Block is on the outside for a given face + * @param visibleOnFace contains rather a face should be concidered + * @return + */ + private boolean isVoxelVisible(Vec3d viewerPosition, Vec3d position, byte faceData, byte visibleOnFace) { + int targetSize = 0; + Arrays.fill(dotselectors, false); + if ((visibleOnFace & ON_MIN_X) == ON_MIN_X) { + dotselectors[0] = true; + if ((faceData & ~ON_MIN_X) != 0) { + dotselectors[1] = true; + dotselectors[4] = true; + dotselectors[5] = true; + } + dotselectors[8] = true; + } + if ((visibleOnFace & ON_MIN_Y) == ON_MIN_Y) { + dotselectors[0] = true; + if ((faceData & ~ON_MIN_Y) != 0) { + dotselectors[3] = true; + dotselectors[4] = true; + dotselectors[7] = true; + } + dotselectors[9] = true; + } + if ((visibleOnFace & ON_MIN_Z) == ON_MIN_Z) { + dotselectors[0] = true; + if ((faceData & ~ON_MIN_Z) != 0) { + dotselectors[1] = true; + dotselectors[4] = true; + dotselectors[5] = true; + } + dotselectors[10] = true; + } + if ((visibleOnFace & ON_MAX_X) == ON_MAX_X) { + dotselectors[4] = true; + if ((faceData & ~ON_MAX_X) != 0) { + dotselectors[5] = true; + dotselectors[6] = true; + dotselectors[7] = true; + } + dotselectors[11] = true; + } + if ((visibleOnFace & ON_MAX_Y) == ON_MAX_Y) { + dotselectors[1] = true; + if ((faceData & ~ON_MAX_Y) != 0) { + dotselectors[2] = true; + dotselectors[5] = true; + dotselectors[6] = true; + } + dotselectors[12] = true; + } + if ((visibleOnFace & ON_MAX_Z) == ON_MAX_Z) { + dotselectors[2] = true; + if ((faceData & ~ON_MAX_Z) != 0) { + dotselectors[3] = true; + dotselectors[6] = true; + dotselectors[7] = true; + } + dotselectors[13] = true; + } + + if (dotselectors[0]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.05, position.y() + 0.05, position.z() + 0.05); + if (dotselectors[1]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.05, position.y() + 0.95, position.z() + 0.05); + if (dotselectors[2]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.05, position.y() + 0.95, position.z() + 0.95); + if (dotselectors[3]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.05, position.y() + 0.05, position.z() + 0.95); + if (dotselectors[4]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.95, position.y() + 0.05, position.z() + 0.05); + if (dotselectors[5]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.95, position.y() + 0.95, position.z() + 0.05); + if (dotselectors[6]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.95, position.y() + 0.95, position.z() + 0.95); + if (dotselectors[7]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.95, position.y() + 0.05, position.z() + 0.95); + // middle points + if (dotselectors[8]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.05, position.y() + 0.5, position.z() + 0.5); + if (dotselectors[9]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.5, position.y() + 0.05, position.z() + 0.5); + if (dotselectors[10]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.5, position.y() + 0.5, position.z() + 0.05); + if (dotselectors[11]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.95, position.y() + 0.5, position.z() + 0.5); + if (dotselectors[12]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.5, position.y() + 0.95, position.z() + 0.5); + if (dotselectors[13]) targetPoints[targetSize++] = new Vec3d(position.x() + 0.5, position.y() + 0.5, position.z() + 0.95); + + return isVisible(viewerPosition, targetPoints, targetSize); + } + + private boolean rayIntersection(int[] b, Vec3d rayOrigin, Vec3d rayDir) { + Vec3d rInv = new Vec3d(1, 1, 1).div(rayDir); + + double t1 = (b[0] - rayOrigin.x()) * rInv.x(); + double t2 = (b[0] + 1 - rayOrigin.x()) * rInv.x(); + double t3 = (b[1] - rayOrigin.y()) * rInv.y(); + double t4 = (b[1] + 1 - rayOrigin.y()) * rInv.y(); + double t5 = (b[2] - rayOrigin.z()) * rInv.z(); + double t6 = (b[2] + 1 - rayOrigin.z()) * rInv.z(); + + double tmin = Math.max(Math.max(Math.min(t1, t2), Math.min(t3, t4)), Math.min(t5, t6)); + double tmax = Math.min(Math.min(Math.max(t1, t2), Math.max(t3, t4)), Math.max(t5, t6)); + + // if tmax < 0, ray (line) is intersecting AABB, but the whole AABB is behind us + if (tmax < 0) { + return false; + } + + // if tmin > tmax, ray doesn't intersect AABB + return !(tmin > tmax); + } + + /** + * returns the grid cells that intersect with this Vec3d
+ * http://playtechs.blogspot.de/2007/03/raytracing-on-grid.html + *

+ * Caching assumes that all Vec3d's are inside the same block + */ + private boolean isVisible(Vec3d start, Vec3d[] targets, int size) { + // start cell coordinate + int x = cameraPos[0]; + int y = cameraPos[1]; + int z = cameraPos[2]; + + for (int v = 0; v < size; v++) { + // ray-casting target + Vec3d target = targets[v]; + + double relativeX = start.x() - target.x(); + double relativeY = start.y() - target.y(); + double relativeZ = start.z() - target.z(); + + if (allowRayChecks && rayIntersection(lastHitBlock, start, new Vec3d(relativeX, relativeY, relativeZ).normalize())) { + continue; + } + + // horizontal and vertical cell amount spanned + double dimensionX = Math.abs(relativeX); + double dimensionY = Math.abs(relativeY); + double dimensionZ = Math.abs(relativeZ); + + // distance between horizontal intersection points with cell border as a + // fraction of the total Vec3d length + double dimFracX = 1f / dimensionX; + // distance between vertical intersection points with cell border as a fraction + // of the total Vec3d length + double dimFracY = 1f / dimensionY; + double dimFracZ = 1f / dimensionZ; + + // total amount of intersected cells + int intersectCount = 1; + + // 1, 0 or -1 + // determines the direction of the next cell (horizontally / vertically) + int x_inc, y_inc, z_inc; + + // the distance to the next horizontal / vertical intersection point with a cell + // border as a fraction of the total Vec3d length + double t_next_y, t_next_x, t_next_z; + + if (dimensionX == 0f) { + x_inc = 0; + t_next_x = dimFracX; // don't increment horizontally because the Vec3d is perfectly vertical + } else if (target.x() > start.x()) { + x_inc = 1; // target point is horizontally greater than starting point so increment every + // step by 1 + intersectCount += MathUtilities.floor(target.x()) - x; // increment total amount of intersecting cells + t_next_x = (float) ((x + 1 - start.x()) * dimFracX); // calculate the next horizontal + // intersection + // point based on the position inside + // the first cell + } else { + x_inc = -1; // target point is horizontally smaller than starting point so reduce every step + // by 1 + intersectCount += x - MathUtilities.floor(target.x()); // increment total amount of intersecting cells + t_next_x = (float) ((start.x() - x) + * dimFracX); // calculate the next horizontal + // intersection point + // based on the position inside + // the first cell + } + + if (dimensionY == 0f) { + y_inc = 0; + t_next_y = dimFracY; // don't increment vertically because the Vec3d is perfectly horizontal + } else if (target.y() > start.y()) { + y_inc = 1; // target point is vertically greater than starting point so increment every + // step by 1 + intersectCount += MathUtilities.floor(target.y()) - y; // increment total amount of intersecting cells + t_next_y = (float) ((y + 1 - start.y()) + * dimFracY); // calculate the next vertical + // intersection + // point based on the position inside + // the first cell + } else { + y_inc = -1; // target point is vertically smaller than starting point so reduce every step + // by 1 + intersectCount += y - MathUtilities.floor(target.y()); // increment total amount of intersecting cells + t_next_y = (float) ((start.y() - y) + * dimFracY); // calculate the next vertical intersection + // point + // based on the position inside + // the first cell + } + + if (dimensionZ == 0f) { + z_inc = 0; + t_next_z = dimFracZ; // don't increment vertically because the Vec3d is perfectly horizontal + } else if (target.z() > start.z()) { + z_inc = 1; // target point is vertically greater than starting point so increment every + // step by 1 + intersectCount += MathUtilities.floor(target.z()) - z; // increment total amount of intersecting cells + t_next_z = (float) ((z + 1 - start.z()) + * dimFracZ); // calculate the next vertical + // intersection + // point based on the position inside + // the first cell + } else { + z_inc = -1; // target point is vertically smaller than starting point so reduce every step + // by 1 + intersectCount += z - MathUtilities.floor(target.z()); // increment total amount of intersecting cells + t_next_z = (float) ((start.z() - z) + * dimFracZ); // calculate the next vertical intersection + // point + // based on the position inside + // the first cell + } + + boolean finished = stepRay(start, x, y, z, + dimFracX, dimFracY, dimFracZ, intersectCount, x_inc, y_inc, + z_inc, t_next_y, t_next_x, t_next_z); + provider.cleanup(); + if (finished) { + cacheResult(targets[0], true); + return true; + } else { + allowRayChecks = true; + } + } + cacheResult(targets[0], false); + return false; + } + + private boolean stepRay(Vec3d start, int currentX, int currentY, + int currentZ, double distInX, double distInY, + double distInZ, int n, int x_inc, int y_inc, + int z_inc, double t_next_y, double t_next_x, + double t_next_z) { + allowWallClipping = true; // initially allow rays to go through walls till they are on the outside + // iterate through all intersecting cells (n times) + for (; n > 1; n--) { // n-1 times because we don't want to check the last block + // towards - where from + + + // get cached value, 0 means uncached (default) + int cVal = getCacheValue(currentX, currentY, currentZ); + + if (cVal == 2 && !allowWallClipping) { + // block cached as occluding, stop ray + lastHitBlock[0] = currentX; + lastHitBlock[1] = currentY; + lastHitBlock[2] = currentZ; + return false; + } + + if (cVal == 0) { + // save current cell + int chunkX = currentX >> 4; + int chunkZ = currentZ >> 4; + + if (!provider.prepareChunk(chunkX, chunkZ)) { // Chunk not ready + return false; + } + + if (provider.isOpaqueFullCube(currentX, currentY, currentZ)) { + if (!allowWallClipping) { + cache.setLastHidden(); + lastHitBlock[0] = currentX; + lastHitBlock[1] = currentY; + lastHitBlock[2] = currentZ; + return false; + } + } else { + // outside of wall, now clipping is not allowed + allowWallClipping = false; + cache.setLastVisible(); + } + } + + if (cVal == 1) { + // outside of wall, now clipping is not allowed + allowWallClipping = false; + } + + + if (t_next_y < t_next_x && t_next_y < t_next_z) { // next cell is upwards/downwards because the distance to + // the next vertical + // intersection point is smaller than to the next horizontal intersection point + currentY += y_inc; // move up/down + t_next_y += distInY; // update next vertical intersection point + } else if (t_next_x < t_next_y && t_next_x < t_next_z) { // next cell is right/left + currentX += x_inc; // move right/left + t_next_x += distInX; // update next horizontal intersection point + } else { + currentZ += z_inc; // move right/left + t_next_z += distInZ; // update next horizontal intersection point + } + + } + return true; + } + + // -1 = invalid location, 0 = not checked yet, 1 = visible, 2 = occluding + private int getCacheValue(int x, int y, int z) { + x -= cameraPos[0]; + y -= cameraPos[1]; + z -= cameraPos[2]; + if (Math.abs(x) > reach - 2 || Math.abs(y) > reach - 2 + || Math.abs(z) > reach - 2) { + return -1; + } + + // check if target is already known + return cache.getState(x + reach, y + reach, z + reach); + } + + + private void cacheResult(int x, int y, int z, boolean result) { + int cx = x - cameraPos[0] + reach; + int cy = y - cameraPos[1] + reach; + int cz = z - cameraPos[2] + reach; + if (result) { + cache.setVisible(cx, cy, cz); + } else { + cache.setHidden(cx, cy, cz); + } + } + + private void cacheResult(Vec3d vector, boolean result) { + int cx = MathUtilities.floor(vector.x()) - cameraPos[0] + reach; + int cy = MathUtilities.floor(vector.y()) - cameraPos[1] + reach; + int cz = MathUtilities.floor(vector.z()) - cameraPos[2] + reach; + if (result) { + cache.setVisible(cx, cy, cz); + } else { + cache.setHidden(cx, cy, cz); + } + } + + public void resetCache() { + this.cache.resetCache(); + } + + private enum Relative { + INSIDE, POSITIVE, NEGATIVE; + + public static Relative from(int min, int max, int pos) { + if (max > pos && min > pos) { + return POSITIVE; + } else if (min < pos && max < pos) { + return NEGATIVE; + } + return INSIDE; + } + } + +} diff --git a/leaf-server/src/main/java/com/logisticscraft/occlusionculling/cache/ArrayOcclusionCache.java b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/cache/ArrayOcclusionCache.java new file mode 100644 index 00000000..08f0ddb3 --- /dev/null +++ b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/cache/ArrayOcclusionCache.java @@ -0,0 +1,57 @@ +package com.logisticscraft.occlusionculling.cache; + +import java.util.Arrays; + +public class ArrayOcclusionCache implements OcclusionCache { + + private final int reachX2; + private final byte[] cache; + private int positionKey; + private int entry; + private int offset; + + public ArrayOcclusionCache(int reach) { + this.reachX2 = reach * 2; + this.cache = new byte[(reachX2 * reachX2 * reachX2) / 4]; + } + + @Override + public void resetCache() { + Arrays.fill(cache, (byte) 0); + } + + @Override + public void setVisible(int x, int y, int z) { + positionKey = x + y * reachX2 + z * reachX2 * reachX2; + entry = positionKey / 4; + offset = (positionKey % 4) * 2; + cache[entry] |= 1 << offset; + } + + @Override + public void setHidden(int x, int y, int z) { + positionKey = x + y * reachX2 + z * reachX2 * reachX2; + entry = positionKey / 4; + offset = (positionKey % 4) * 2; + cache[entry] |= 1 << offset + 1; + } + + @Override + public int getState(int x, int y, int z) { + positionKey = x + y * reachX2 + z * reachX2 * reachX2; + entry = positionKey / 4; + offset = (positionKey % 4) * 2; + return cache[entry] >> offset & 3; + } + + @Override + public void setLastVisible() { + cache[entry] |= 1 << offset; + } + + @Override + public void setLastHidden() { + cache[entry] |= 1 << offset + 1; + } + +} diff --git a/leaf-server/src/main/java/com/logisticscraft/occlusionculling/cache/OcclusionCache.java b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/cache/OcclusionCache.java new file mode 100644 index 00000000..2a2e5620 --- /dev/null +++ b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/cache/OcclusionCache.java @@ -0,0 +1,17 @@ +package com.logisticscraft.occlusionculling.cache; + +public interface OcclusionCache { + + void resetCache(); + + void setVisible(int x, int y, int z); + + void setHidden(int x, int y, int z); + + int getState(int x, int y, int z); + + void setLastHidden(); + + void setLastVisible(); + +} diff --git a/leaf-server/src/main/java/com/logisticscraft/occlusionculling/util/MathUtilities.java b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/util/MathUtilities.java new file mode 100644 index 00000000..bb14c40f --- /dev/null +++ b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/util/MathUtilities.java @@ -0,0 +1,25 @@ +package com.logisticscraft.occlusionculling.util; + +/** + * Contains MathHelper methods + */ +public final class MathUtilities { + + private MathUtilities() { + } + + public static int floor(double d) { + int i = (int) d; + return d < (double) i ? i - 1 : i; + } + + public static int fastFloor(double d) { + return floor(d); + } + + public static int ceil(double d) { + int i = (int) d; + return d > (double) i ? i + 1 : i; + } + +} diff --git a/leaf-server/src/main/java/com/logisticscraft/occlusionculling/util/Vec3d.java b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/util/Vec3d.java new file mode 100644 index 00000000..453d4b47 --- /dev/null +++ b/leaf-server/src/main/java/com/logisticscraft/occlusionculling/util/Vec3d.java @@ -0,0 +1,26 @@ +package com.logisticscraft.occlusionculling.util; + +/** + * Immutable 3D double-precision vector — replaced mutable class with Java record. + * + * All operations return new instances; no mutation. + * Java records auto-generate equals(), hashCode(), toString(), and accessor methods. + */ +public record Vec3d(double x, double y, double z) { + + public Vec3d add(double dx, double dy, double dz) { + return new Vec3d(x + dx, y + dy, z + dz); + } + + public Vec3d div(Vec3d rayDir) { + return new Vec3d(x / rayDir.x, y / rayDir.y, z / rayDir.z); + } + + public Vec3d normalize() { + double mag = Math.sqrt(x * x + y * y + z * z); + if (mag == 0.0) { + return this; + } + return new Vec3d(x / mag, y / mag, z / mag); + } +} diff --git a/leaf-server/src/main/java/dev/tr7zw/entityculling/CullTask.java b/leaf-server/src/main/java/dev/tr7zw/entityculling/CullTask.java new file mode 100644 index 00000000..ddda0828 --- /dev/null +++ b/leaf-server/src/main/java/dev/tr7zw/entityculling/CullTask.java @@ -0,0 +1,241 @@ +package dev.tr7zw.entityculling; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.logisticscraft.occlusionculling.OcclusionCullingInstance; +import com.logisticscraft.occlusionculling.util.Vec3d; +import dev.tr7zw.entityculling.versionless.access.Cullable; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSets; +import net.minecraft.core.BlockPos; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.players.PlayerList; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.decoration.ArmorStand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.dreeam.leaf.config.modules.misc.RaytraceTracker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +public class CullTask implements Runnable { + + private static final String THREAD_PREFIX = "Leaf Raytrace Tracker"; + private volatile boolean scheduleNext = true; + private volatile boolean isInit = false; + + private final OcclusionCullingInstance culling; + private final Player checkTarget; + + private final int hitboxLimit; + + private Vec3d lastPos = new Vec3d(0, 0, 0); + private Vec3d aabbMin = new Vec3d(0, 0, 0); + private Vec3d aabbMax = new Vec3d(0, 0, 0); + private final AtomicBoolean dirty = new AtomicBoolean(false); + + private static final Executor backgroundWorker = Executors.newCachedThreadPool( + new ThreadFactoryBuilder() + .setNameFormat(THREAD_PREFIX + " Thread - %d") + .setDaemon(true) + .setPriority(Thread.NORM_PRIORITY - 1) + .build() + ); + + private static final Set tasks = ConcurrentHashMap.newKeySet(); + private final Set culledEntities = IntSets.synchronize(new IntOpenHashSet()); + + private static final Logger LOGGER = LoggerFactory.getLogger(CullTask.class); + private static final Map lastErrorLogged = new ConcurrentHashMap<>(); + private static final long ERROR_LOG_COOLDOWN_MS = 5000; + private final Queue removalQueue = new ConcurrentLinkedQueue<>(); + + private final Executor worker; + + public CullTask( + OcclusionCullingInstance culling, + Player checkTarget, + int hitboxLimit, + long checkIntervalMs + ) { + this.culling = culling; + this.checkTarget = checkTarget; + this.hitboxLimit = hitboxLimit; + this.worker = CompletableFuture.delayedExecutor(checkIntervalMs, TimeUnit.MILLISECONDS, backgroundWorker); + } + + public void signalStop() { + this.scheduleNext = false; + tasks.remove(this); + } + + public void setup() { + if (!this.isInit) { + this.isInit = true; + } else { + return; + } + this.worker.execute(this); + tasks.add(this); + } + + @Override + public synchronized void run() { + try { + if (this.checkTarget.tickCount > 10) { + while (!removalQueue.isEmpty()) { + int entityId = removalQueue.poll(); + culledEntities.remove(entityId); + } + Vec3 cameraMC = this.checkTarget.getEyePosition(0); + boolean cameraMoved = !(cameraMC.x == lastPos.x() && cameraMC.y == lastPos.y() && cameraMC.z == lastPos.z()); + if (cameraMoved) { + lastPos = new Vec3d(cameraMC.x, cameraMC.y, cameraMC.z); + } + // REQ-001: Dirty flag coalesces N block changes into 1 cache reset per tick. + // Camera movement also triggers reset (existing behavior). + if (cameraMoved || dirty.compareAndSet(true, false)) { + synchronized (culling) { + culling.resetCache(); + } + } + cullEntities(cameraMC, lastPos); + } + } catch (Exception e) { + String key = e.getClass().getSimpleName(); + long now = System.currentTimeMillis(); + lastErrorLogged.compute(key, (k, last) -> { + if (last == null || now - last >= ERROR_LOG_COOLDOWN_MS) { + LOGGER.warn("[CullTask] {} in run", key, e); + return now; + } + return last; + }); + } finally { + if (this.scheduleNext) { + this.worker.execute(this); + } + } + } + + private void cullEntities(Vec3 cameraMC, Vec3d camera) { + for (Entity entity : this.checkTarget.level().getEntities().getAll()) { // This one's safe here; moonrise returns an array for us to iterate + if (!(entity instanceof Cullable cullable) || entity == this.checkTarget) { + continue; + } + + if (entity.getType().skipRaytraceCheck) { + continue; + } + Player player = this.checkTarget; + + if (cullable.isForcedVisible()) { + cullable.setCulled(false, player); + continue; + } + + if (entity.isCurrentlyGlowing() || isSkippableArmorstand(entity)) { + cullable.setCulled(false, player); + continue; + } + + final double distanceSqr = entity.position().distanceToSqr(cameraMC); + if (distanceSqr < RaytraceTracker.forceVisibleRadius * RaytraceTracker.forceVisibleRadius) { + cullable.setCulled(false, player); + continue; + } + + if (distanceSqr >= RaytraceTracker.maxTraceDistance * RaytraceTracker.maxTraceDistance) { + cullable.setCulled(false, player); // If your entity view distance is larger than tracingDistance just + // render it + continue; + } + + AABB boundingBox = entity.getBoundingBox(); + if (boundingBox.getXsize() > hitboxLimit || boundingBox.getYsize() > hitboxLimit + || boundingBox.getZsize() > hitboxLimit) { + cullable.setCulled(false, player); // Too big to bother to cull + continue; + } + + aabbMin = new Vec3d(boundingBox.minX, boundingBox.minY, boundingBox.minZ); + aabbMax = new Vec3d(boundingBox.maxX, boundingBox.maxY, boundingBox.maxZ); + + synchronized (culling) { + boolean visible = culling.isAABBVisible(aabbMin, aabbMax, camera); + + cullable.setCulled(!visible, player); + } + } + } + + public static void onBlockChange(Level level, BlockPos pos) { + if (!RaytraceTracker.enabled) { + return; + } + MinecraftServer server = level.getServer(); + if (server == null) { + return; + } + PlayerList playerList = server.getPlayerList(); + // REQ-001: Use lock-free dirty flag instead of spawning unbounded async tasks. + // Each player's CullTask checks the flag in its periodic tick and resets + // the cache at most once per trace interval, regardless of block change burst size. + // PERF: O(players) per block change, but bounds check (L183-185) skips + // players >maxTraceDistance away. Acceptable at typical player counts. + for (Player player : playerList.realPlayers) { + CullTask cullTask = player.cullTask.get(); + if (cullTask == null) continue; + if (player.level() == level) { + int posX = pos.getX(); + int posY = pos.getY(); + int posZ = pos.getZ(); + BlockPos playerPos = player.blockPosition(); + final int playerX = playerPos.getX(), playerY = playerPos.getY(), playerZ = playerPos.getZ(); + if (Math.abs(posX - playerX) < RaytraceTracker.maxTraceDistance + && Math.abs(posY - playerY) < RaytraceTracker.maxTraceDistance + && Math.abs(posZ - playerZ) < RaytraceTracker.maxTraceDistance) { + cullTask.dirty.set(true); + } + } + } + } + + public static void onEntityRemoval(Entity entity) { + if (!RaytraceTracker.enabled) return; + int id = entity.getId(); + CompletableFuture.runAsync(() -> { + for (CullTask cullTask : tasks) { + cullTask.scheduleForRemoval(id); + } + }, backgroundWorker); + } + + public void scheduleForRemoval(int entityId) { + removalQueue.offer(entityId); + } + + private boolean isSkippableArmorstand(Entity entity) { + if (!RaytraceTracker.skipMarkerArmorStand) return false; + return entity instanceof ArmorStand as && as.isMarker(); + } + + public boolean isEntityCulled(Entity entity) { + return culledEntities.contains(entity.getId()); + } + + public void setCulled(Entity entity, boolean value) { + if (value) { + culledEntities.add(entity.getId()); + } else { + culledEntities.remove(entity.getId()); + } + } +} diff --git a/leaf-server/src/main/java/dev/tr7zw/entityculling/DefaultChunkDataProvider.java b/leaf-server/src/main/java/dev/tr7zw/entityculling/DefaultChunkDataProvider.java new file mode 100644 index 00000000..8d3e6921 --- /dev/null +++ b/leaf-server/src/main/java/dev/tr7zw/entityculling/DefaultChunkDataProvider.java @@ -0,0 +1,44 @@ +package dev.tr7zw.entityculling; + +import com.logisticscraft.occlusionculling.DataProvider; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; + +public class DefaultChunkDataProvider implements DataProvider { + private final Level level; + + public DefaultChunkDataProvider(Level level) { + this.level = level; + } + + @Override + public boolean prepareChunk(int chunkX, int chunkZ) { + return this.level.getChunkIfLoaded(chunkX, chunkZ) != null; + } + + @Override + public boolean isOpaqueFullCube(int x, int y, int z) { + BlockPos pos = new BlockPos(x, y, z); + + final ChunkAccess access = this.level.getChunkIfLoaded(pos); + if (access == null) { + return false; + } + + if (this.level.isOutsideBuildHeight(pos)) { + return false; + } + + BlockState bs = access.getBlockState(pos); + return bs.canOcclude() && bs.isSolidRender(); + } + + @Override + public void cleanup() { + DataProvider.super.cleanup(); + } + +} diff --git a/leaf-server/src/main/java/dev/tr7zw/entityculling/versionless/access/Cullable.java b/leaf-server/src/main/java/dev/tr7zw/entityculling/versionless/access/Cullable.java new file mode 100644 index 00000000..3b0d09a9 --- /dev/null +++ b/leaf-server/src/main/java/dev/tr7zw/entityculling/versionless/access/Cullable.java @@ -0,0 +1,15 @@ +package dev.tr7zw.entityculling.versionless.access; + +import net.minecraft.world.entity.player.Player; + +public interface Cullable { + + void setTimeout(); + + boolean isForcedVisible(); + + void setCulled(boolean value, Player player); + + boolean isCulled(Player player); + +} diff --git a/leaf-server/src/main/java/org/dreeam/leaf/config/modules/misc/RaytraceTracker.java b/leaf-server/src/main/java/org/dreeam/leaf/config/modules/misc/RaytraceTracker.java new file mode 100644 index 00000000..0079cf2e --- /dev/null +++ b/leaf-server/src/main/java/org/dreeam/leaf/config/modules/misc/RaytraceTracker.java @@ -0,0 +1,143 @@ +package org.dreeam.leaf.config.modules.misc; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.EntityType; +import org.dreeam.leaf.config.ConfigModules; +import org.dreeam.leaf.config.EnumConfigCategory; +import org.dreeam.leaf.config.LeafConfig; +import org.dreeam.leaf.config.annotations.Experimental; + +import java.util.*; + +public class RaytraceTracker extends ConfigModules { + + public String getBasePath() { + return EnumConfigCategory.MISC.getBaseKeyName() + ".raytrace-entity-tracker"; + } + + @Experimental + public static boolean enabled = false; + + public static int maxTraceDistance = 64; + public static boolean skipMarkerArmorStand = false; + public static int boundingBoxLimit = 20; + public static int traceInterval = 50; + public static double forceVisibleRadius = 2.0D; + public static double boundingBoxExpansion = 0.5D; + public static List skippedEntities = List.of(); + public static boolean invertSkipEntities = false; + + @Override + public void onLoaded() { + config.addCommentRegionBased(getBasePath(), """ + *** EXPERIMENTAL FEATURE *** + Raytrace Entity Tracker uses async ray-tracing to untrack entities players cannot see, + which can reduce bandwidth usage significantly, + especially in some massive entities in small area situations. + Also it provides a way to guard against Entity ESP hacks.""", + """ + *** 实验性功能 *** + 使用异步射线追踪来动态取消跟踪玩家看不见的实体, + 可以显著降低带宽使用, + 在实体数量多且密集的情况下效果明显. + 也提供了一种对抗 Entity ESP 作弊的方案."""); + + enabled = config.getBoolean(getBasePath() + ".enabled", enabled); + maxTraceDistance = config.getInt(getBasePath() + ".max-trace-distance", maxTraceDistance, config.pickStringRegionBased( + """ + The maximum distance to trace entities in blocks.""", + """ + 最大追踪实体距离, 单位: 方块.""")); + if (maxTraceDistance <= 0) { + LeafConfig.LOGGER.warn("{} is out of range ({}), clamping to 1", + getBasePath() + ".max-trace-distance", maxTraceDistance); + maxTraceDistance = 1; + } + skipMarkerArmorStand = config.getBoolean(getBasePath() + ".skip-marker-armor-stand", skipMarkerArmorStand, config.pickStringRegionBased( + """ + Whether to skip tracing entities with marker armor stand.""", + """ + 是否跳过追踪带标记盔甲架的实体.""")); + boundingBoxLimit = config.getInt(getBasePath() + ".bounding-box-limit", boundingBoxLimit, config.pickStringRegionBased( + """ + The maximum size of bounding box to trace. + Entities with bounding box larger than this value will be skipped.""", + """ + 碰撞箱大小限制, + 实体碰撞箱大于该值将被跳过.""")); + if (boundingBoxLimit < 0) { + LeafConfig.LOGGER.warn("{} is out of range ({}), clamping to 0", + getBasePath() + ".bounding-box-limit", boundingBoxLimit); + boundingBoxLimit = 0; + } + traceInterval = config.getInt(getBasePath() + ".trace-interval", traceInterval, config.pickStringRegionBased( + """ + The interval between each trace in milliseconds. + Lower value means more frequent trace.""", + """ + 追踪间隔(单位: 毫秒), 越小越频繁.""")); + if (traceInterval <= 0) { + LeafConfig.LOGGER.warn("{} is out of range ({}), clamping to 1", + getBasePath() + ".trace-interval", traceInterval); + traceInterval = 1; + } + forceVisibleRadius = config.getDouble(getBasePath() + ".force-visible-radius", forceVisibleRadius, config.pickStringRegionBased( + """ + The radius to force visible entities. + Entities within this radius will be forced visible. + Set to values less than or equal to zero to disable.""", + """ + 强制可见半径, + 实体在范围内将被强制可见. + 设置为小于等于 0 的值来禁用强制可见.""")); + boundingBoxExpansion = config.getDouble(getBasePath() + ".bounding-box-expansion", boundingBoxExpansion, config.pickStringRegionBased( + """ + The expansion of bounding box. + This modifier will be added to the actual bounding box when tracing.""", + """ + 碰撞箱扩大量. + 此值将会在射线追踪时添加到实际碰撞箱上.""")); + if (boundingBoxExpansion < 0) { + LeafConfig.LOGGER.warn("{} is out of range ({}), clamping to 0", + getBasePath() + ".bounding-box-expansion", boundingBoxExpansion); + boundingBoxExpansion = 0; + } + skippedEntities = config.getList(getBasePath() + ".skipped-entities", skippedEntities, config.pickStringRegionBased( + """ + The entities to skip tracing.""", + """ + 跳过追踪的实体.""")); + invertSkipEntities = config.getBoolean(getBasePath() + ".invert-skip-entities", invertSkipEntities, config.pickStringRegionBased( + """ + Whether to invert the skip entities list.""", + """ + 是否反转跳过实体列表.""")); + } + + @Override + public void onPostLoaded() { + for (EntityType entityType : BuiltInRegistries.ENTITY_TYPE) { + entityType.skipRaytraceCheck = invertSkipEntities; + } + + final String DEFAULT_PREFIX = Identifier.DEFAULT_NAMESPACE + Identifier.NAMESPACE_SEPARATOR; + + for (String name : skippedEntities) { + String lowerName = name.trim().toLowerCase(Locale.ROOT); + String typeId = lowerName.indexOf(Identifier.NAMESPACE_SEPARATOR) >= 0 + ? lowerName + : DEFAULT_PREFIX + lowerName; + + Identifier id = Identifier.tryParse(typeId); + if (id == null) { + LeafConfig.LOGGER.warn("Invalid entity ID {}, in {}", name, getBasePath() + ".skipped-entities"); + continue; + } + BuiltInRegistries.ENTITY_TYPE.getOptional(id).ifPresentOrElse(entityType -> + entityType.skipRaytraceCheck = !invertSkipEntities, + () -> LeafConfig.LOGGER.warn("Unknown entity {}, in {}", name, getBasePath() + ".skipped-entities") + ); + } + } +} diff --git a/leaf-server/src/test/java/com/logisticscraft/occlusionculling/ErrorLoggingProof.java b/leaf-server/src/test/java/com/logisticscraft/occlusionculling/ErrorLoggingProof.java new file mode 100644 index 00000000..f0e238f3 --- /dev/null +++ b/leaf-server/src/test/java/com/logisticscraft/occlusionculling/ErrorLoggingProof.java @@ -0,0 +1,94 @@ +/** + * TDD-007: Proves error handling was unbounded printStackTrace spam. + * + * Design contract proof — no MC deps, no JUnit, runs with plain javac/java. + * + * Compile + run: + * javac -d /tmp ErrorLoggingProof.java && java -cp /tmp ErrorLoggingProof + * + * @see OcclusionCullingInstance.isAABBVisible() — Throwable → Exception, rate-limited logging + */ +public class ErrorLoggingProof { + static int failures = 0; + + static void check(boolean cond, String msg) { + if (!cond) { System.err.println("FAIL: " + msg); failures++; } + } + + static final java.util.Map lastLogged = new java.util.HashMap<>(); + static final long COOLDOWN_MS = 5000; + static final StringBuilder log = new StringBuilder(); + + static void warnRateLimited(String key, String fmt, Object... args) { + long now = System.currentTimeMillis(); + Long last = lastLogged.get(key); + if (last != null && now - last < COOLDOWN_MS) return; + lastLogged.put(key, now); + log.append(String.format(fmt, args)).append("\n"); + } + + static class BuggyHandler { + int stderrCount = 0; + boolean runWithBuggy(Runnable body) { + try { body.run(); return true; } + catch (Throwable t) { t.printStackTrace(); stderrCount++; return false; } + } + } + + static class FixedHandler { + boolean runWithFixed(Runnable body) { + try { body.run(); return true; } + catch (Exception e) { + warnRateLimited(e.getClass().getSimpleName(), + "[OcclusionCulling] %s: %s", + e.getClass().getSimpleName(), e.getMessage()); + return false; + } + } + } + + public static void main(String[] args) throws Exception { + // Test 1: Rate limiting on rapid failures + log.setLength(0); + FixedHandler fh = new FixedHandler(); + RuntimeException ex = new RuntimeException("test"); + for (int i = 0; i < 100; i++) fh.runWithFixed(() -> { throw ex; }); + check(log.toString().lines().count() <= 2, + "FIXED: 100 failures → rate-limited to ~1 log line"); + + // Test 2: Cooldown allows re-log after delay + Thread.sleep(5100); + int before = (int) log.toString().lines().count(); + fh.runWithFixed(() -> { throw ex; }); + check(log.toString().lines().count() > before, + "FIXED: after cooldown, new failure logged"); + + // Test 3: Buggy catches OutOfMemoryError (shouldn't) + BuggyHandler bh = new BuggyHandler(); + bh.runWithBuggy(() -> { throw new OutOfMemoryError("simulated"); }); + check(bh.stderrCount == 1, + "BUGGY: catches OutOfMemoryError (should be uncatchable Error)"); + + // Test 4: Different exception types independent + lastLogged.clear(); log.setLength(0); + FixedHandler fh2 = new FixedHandler(); + fh2.runWithFixed(() -> { throw new RuntimeException("a"); }); + fh2.runWithFixed(() -> { throw new IllegalStateException("b"); }); + check(log.toString().lines().count() == 2, + "FIXED: different types → independent rate limits"); + + // Test 5: Fixed does NOT catch Error + boolean errorCaught = false; + try { new FixedHandler().runWithFixed(() -> { throw new StackOverflowError("x"); }); } + catch (StackOverflowError e) { errorCaught = true; } + check(errorCaught, + "FIXED: StackOverflowError passes through (Exception only)"); + + if (failures == 0) { + System.out.println("ALL TESTS PASSED: rate-limited error logging design verified."); + } else { + System.err.println(failures + " TEST(S) FAILED"); + System.exit(1); + } + } +} diff --git a/leaf-server/src/test/java/com/logisticscraft/occlusionculling/RayAabbProof.java b/leaf-server/src/test/java/com/logisticscraft/occlusionculling/RayAabbProof.java new file mode 100644 index 00000000..710fb672 --- /dev/null +++ b/leaf-server/src/test/java/com/logisticscraft/occlusionculling/RayAabbProof.java @@ -0,0 +1,101 @@ +/** + * TDD-004: Proves rayIntersection tmax check was inverted. + * + * Design contract proof — no MC deps, no JUnit, runs with plain javac/java. + * + * Compile + run: + * javac -d /tmp RayAabbProof.java && java -cp /tmp RayAabbProof + * + * @see OcclusionCullingInstance.rayIntersection() — tmax > 0 → tmax < 0 fix + */ +public class RayAabbProof { + static int failures = 0; + + static void check(boolean cond, String msg) { + if (!cond) { System.err.println("FAIL: " + msg); failures++; } + } + + static final class Vec3d { + final double x, y, z; + Vec3d(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } + Vec3d div(Vec3d o) { return new Vec3d(x / o.x, y / o.y, z / o.z); } + } + + /** BUGGY: OcclusionCullingInstance.java lines 234-254 exact mirror */ + static boolean buggy(int[] b, Vec3d rayOrigin, Vec3d rayDir) { + Vec3d rInv = new Vec3d(1, 1, 1).div(rayDir); + double t1 = (b[0] - rayOrigin.x) * rInv.x; + double t2 = (b[0] + 1 - rayOrigin.x) * rInv.x; + double t3 = (b[1] - rayOrigin.y) * rInv.y; + double t4 = (b[1] + 1 - rayOrigin.y) * rInv.y; + double t5 = (b[2] - rayOrigin.z) * rInv.z; + double t6 = (b[2] + 1 - rayOrigin.z) * rInv.z; + double tmin = Math.max(Math.max(Math.min(t1, t2), Math.min(t3, t4)), Math.min(t5, t6)); + double tmax = Math.min(Math.min(Math.max(t1, t2), Math.max(t3, t4)), Math.max(t5, t6)); + if (tmax > 0) { return false; } + return !(tmin > tmax); + } + + /** FIXED: tmax < 0 only rejects boxes behind viewer */ + static boolean fixed(int[] b, Vec3d rayOrigin, Vec3d rayDir) { + Vec3d rInv = new Vec3d(1, 1, 1).div(rayDir); + double t1 = (b[0] - rayOrigin.x) * rInv.x; + double t2 = (b[0] + 1 - rayOrigin.x) * rInv.x; + double t3 = (b[1] - rayOrigin.y) * rInv.y; + double t4 = (b[1] + 1 - rayOrigin.y) * rInv.y; + double t5 = (b[2] - rayOrigin.z) * rInv.z; + double t6 = (b[2] + 1 - rayOrigin.z) * rInv.z; + double tmin = Math.max(Math.max(Math.min(t1, t2), Math.min(t3, t4)), Math.min(t5, t6)); + double tmax = Math.min(Math.min(Math.max(t1, t2), Math.max(t3, t4)), Math.max(t5, t6)); + if (tmax < 0) { return false; } + return !(tmin > tmax); + } + + public static void main(String[] args) { + Vec3d origin = new Vec3d(0.5, 1.62, 0.5); + Vec3d forward = new Vec3d(0.001, -0.001, 1.0); + + int[] boxAhead = {0, 1, 5}; + check(!buggy(boxAhead, origin, forward), + "BUGGY: box at z=5 rejected (tmax > 0 fires)"); + check(fixed(boxAhead, origin, forward), + "FIXED: box at z=5 correctly hits"); + + int[] boxBehind = {0, 1, -3}; + check(!fixed(boxBehind, origin, forward), + "FIXED: box at z=-3 is behind viewer → miss"); + check(buggy(boxBehind, origin, forward), + "BUGGY: behind box → false HIT (tmax<0 bypasses guard)"); + + Vec3d insideOrigin = new Vec3d(2.5, 1.62, 0.5); + int[] boxInside = {2, 1, 5}; + check(!buggy(boxInside, insideOrigin, forward), + "BUGGY: viewer inside box → tmax>0 → false MISS"); + check(fixed(boxInside, insideOrigin, forward), + "FIXED: viewer inside box correctly hits"); + + int[] boxFarSide = {10, 20, 5}; + check(!fixed(boxFarSide, origin, forward), + "FIXED: box at y=20 misses (slabs don't overlap)"); + check(!buggy(boxFarSide, origin, forward), + "BUGGY: misses for wrong reason (tmax>0, not slab gap)"); + + int[] boxClose = {0, 1, 1}; + check(!buggy(boxClose, origin, forward), + "BUGGY: close box at z=1 rejected (tmax>0)"); + check(fixed(boxClose, origin, forward), + "FIXED: close box at z=1 correctly hits"); + + Vec3d lookBack = new Vec3d(0.001, -0.001, -1.0); + int[] boxOpposite = {0, 1, 5}; + check(!fixed(boxOpposite, origin, lookBack), + "FIXED: box behind when looking opposite way → miss"); + + if (failures == 0) { + System.out.println("ALL TESTS PASSED: tmax inversion fix verified."); + } else { + System.err.println(failures + " TEST(S) FAILED"); + System.exit(1); + } + } +} diff --git a/leaf-server/src/test/java/com/logisticscraft/occlusionculling/util/Vec3dRecordProof.java b/leaf-server/src/test/java/com/logisticscraft/occlusionculling/util/Vec3dRecordProof.java new file mode 100644 index 00000000..7427cd69 --- /dev/null +++ b/leaf-server/src/test/java/com/logisticscraft/occlusionculling/util/Vec3dRecordProof.java @@ -0,0 +1,61 @@ +/** + * TDD-009: Proves mutable Vec3d shared-state corruption risk → record fix. + * + * Design contract proof — no MC deps, no JUnit, runs with plain javac/java. + * + * Compile + run: + * javac -d /tmp Vec3dRecordProof.java && java -cp /tmp Vec3dRecordProof + * + * @see Vec3d — converted from mutable class to immutable record + */ +public class Vec3dRecordProof { + static int failures = 0; + + static void check(boolean cond, String msg) { + if (!cond) { System.err.println("FAIL: " + msg); failures++; } + } + + static class MutableVec3d { + double x, y, z; + MutableVec3d(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } + void set(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } + void setAdd(MutableVec3d vec, double x, double y, double z) { this.x = vec.x + x; this.y = vec.y + y; this.z = vec.z + z; } + MutableVec3d div(MutableVec3d rayDir) { this.x /= rayDir.x; this.y /= rayDir.y; this.z /= rayDir.z; return this; } + MutableVec3d normalize() { double mag = Math.sqrt(x*x+y*y+z*z); this.x/=mag; this.y/=mag; this.z/=mag; return this; } + } + + record Vec3d(double x, double y, double z) { + Vec3d add(double dx, double dy, double dz) { return new Vec3d(x+dx, y+dy, z+dz); } + Vec3d div(Vec3d rd) { return new Vec3d(x/rd.x, y/rd.y, z/rd.z); } + Vec3d normalize() { double m=Math.sqrt(x*x+y*y+z*z); return new Vec3d(x/m, y/m, z/m); } + } + + public static void main(String[] args) { + MutableVec3d shared = new MutableVec3d(1,1,1); + MutableVec3d rInv = shared.div(new MutableVec3d(2,2,2)); + check(shared == rInv, "BUG: div() returns this"); + check(shared.x == 0.5, "BUG: shared mutated by div()"); + + Vec3d v1 = new Vec3d(1,1,1), v2 = v1.div(new Vec3d(2,2,2)); + check(v1 != v2, "FIX: record div() returns new"); + check(v1.x() == 1.0, "FIX: original unchanged"); + + MutableVec3d[] targets = {new MutableVec3d(3,4,0)}; + MutableVec3d before = new MutableVec3d(targets[0].x, targets[0].y, targets[0].z); + targets[0].normalize(); + check(before.x != targets[0].x, "BUG: normalize() mutated shared target"); + + Vec3d[] rTargets = {new Vec3d(3,4,0)}; + Vec3d norm = rTargets[0].normalize(); + check(rTargets[0].x() == 3.0, "FIX: record normalize() leaves original intact"); + check(norm.x() == 0.6, "FIX: normalized result correct"); + + Vec3d pos = new Vec3d(10,20,30); + Vec3d[] t = {pos.add(0.05,0.95,0.05), pos.add(0.05,0.05,0.95), pos.add(0.95,0.95,0.95)}; + check(pos.x() == 10 && pos.y() == 20, "FIX: add() returns new, pos unchanged"); + check(t[0].x() == 10.05 && t[0].y() == 20.95, "FIX: target points computed correctly"); + + if (failures == 0) System.out.println("ALL TESTS PASSED: Vec3d record design verified."); + else { System.err.println(failures + " FAILED"); System.exit(1); } + } +} diff --git a/leaf-server/src/test/java/dev/tr7zw/entityculling/ArmorStandCheckProof.java b/leaf-server/src/test/java/dev/tr7zw/entityculling/ArmorStandCheckProof.java new file mode 100644 index 00000000..cbdb72bb --- /dev/null +++ b/leaf-server/src/test/java/dev/tr7zw/entityculling/ArmorStandCheckProof.java @@ -0,0 +1,64 @@ +/** + * TDD-006: Proves isSkippableArmorstand uses wrong guard (isInvisible, not isMarker). + * + * Design contract proof — no MC deps, no JUnit, runs with plain javac/java. + * + * Compile + run: + * javac -d /tmp ArmorStandCheckProof.java && java -cp /tmp ArmorStandCheckProof + * + * @see CullTask.isSkippableArmorstand() — isInvisible() → isMarker() fix + */ +public class ArmorStandCheckProof { + static int failures = 0; + + static void check(boolean cond, String msg) { + if (!cond) { System.err.println("FAIL: " + msg); failures++; } + } + + static class ArmorStand { + boolean marker, invisible; + ArmorStand(boolean m, boolean i) { this.marker = m; this.invisible = i; } + boolean isMarker() { return marker; } + boolean isInvisible() { return invisible; } + } + + static boolean buggy(Object e) { + return e instanceof ArmorStand as && as.isInvisible(); + } + + static boolean fixed(Object e) { + return e instanceof ArmorStand as && as.isMarker(); + } + + public static void main(String[] args) { + check(buggy(new ArmorStand(false, true)), + "BUGGY: invisible non-marker → wrongly skipped (isInvisible=true)"); + check(!fixed(new ArmorStand(false, true)), + "FIXED: invisible non-marker → NOT skipped (isMarker=false)"); + + check(!buggy(new ArmorStand(true, false)), + "BUGGY: marker visible → NOT skipped (isInvisible=false)"); + check(fixed(new ArmorStand(true, false)), + "FIXED: marker visible → correctly skipped (isMarker=true)"); + + check(buggy(new ArmorStand(true, true)), + "Both: marker+invisible → skipped (coincidental)"); + check(fixed(new ArmorStand(true, true)), + "Both: marker+invisible → skipped"); + + check(!buggy(new ArmorStand(false, false)), + "Both: normal armor stand → NOT skipped"); + check(!fixed(new ArmorStand(false, false)), + "Both: normal armor stand → NOT skipped"); + + check(!buggy("not armorstand"), "Non-armorstand: buggy false"); + check(!fixed("not armorstand"), "Non-armorstand: fixed false"); + + if (failures == 0) { + System.out.println("ALL TESTS PASSED: armor stand marker check fix verified."); + } else { + System.err.println(failures + " TEST(S) FAILED"); + System.exit(1); + } + } +} diff --git a/leaf-server/src/test/java/dev/tr7zw/entityculling/CullTaskReferenceSafetyTest.java b/leaf-server/src/test/java/dev/tr7zw/entityculling/CullTaskReferenceSafetyTest.java new file mode 100644 index 00000000..5eafdb68 --- /dev/null +++ b/leaf-server/src/test/java/dev/tr7zw/entityculling/CullTaskReferenceSafetyTest.java @@ -0,0 +1,100 @@ +package dev.tr7zw.entityculling; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Proves REQ-002: CullTask reference must use AtomicReference for safe + * cross-thread visibility with Fuji's multithreaded tracker (patch 0293). + *

+ * RED: Player.cullTask is a plain field — reads from the ChunkMap tracker + * thread may see stale null or partially-constructed values. This test + * verifies the AtomicReference pattern that fixes it. + */ +class CullTaskReferenceSafetyTest { + + static class MockCullTask { + final int id; + MockCullTask(int id) { this.id = id; } + } + + @Test + @DisplayName("AtomicReference provides safe cross-thread null→value transition") + void crossThreadVisibility() throws Exception { + AtomicReference ref = new AtomicReference<>(null); + + // Writer thread: sets the reference + Thread writer = new Thread(() -> { + ref.set(new MockCullTask(42)); + }); + writer.start(); + writer.join(); + + // Reader: must see the write (happens-before via AtomicReference) + MockCullTask read = ref.get(); + assertNotNull(read, "REQ-002: Reader must see writer's set — no stale null"); + assertEquals(42, read.id, "REQ-002: Reader must see fully-constructed object"); + } + + @Test + @DisplayName("AtomicReference.compareAndSet prevents torn initialization") + void noTornInitialization() { + AtomicReference ref = new AtomicReference<>(null); + + // Simulate set-once lifecycle (CullTask created once, cleared on remove) + boolean set = ref.compareAndSet(null, new MockCullTask(1)); + assertTrue(set, "REQ-002: CAS null→value must succeed on first creation"); + + // Second CAS must fail — no double-init + boolean setAgain = ref.compareAndSet(null, new MockCullTask(2)); + assertFalse(setAgain, "REQ-002: CAS null→value must fail if already set"); + + assertEquals(1, ref.get().id, "REQ-002: Original value preserved"); + } + + @Test + @DisplayName("Clear-to-null allows re-creation (player relog)") + void clearAndRecreate() { + AtomicReference ref = new AtomicReference<>(new MockCullTask(1)); + + // Player removes → clear reference + ref.set(null); + assertNull(ref.get(), "REQ-002: Clear must release reference"); + + // Player relogs → new CullTask created + boolean set = ref.compareAndSet(null, new MockCullTask(2)); + assertTrue(set, "REQ-002: CAS null→value must work after clear"); + assertEquals(2, ref.get().id); + } + + @Test + @DisplayName("Plain field reproduces the bug: stale read from other thread") + void plainFieldBugDemonstration() throws Exception { + // This simulates the current BUG: plain field, no memory barrier. + // A writer thread sets the field, but reader may see stale null. + // With AtomicReference, this test would pass reliably. + // With plain field, it CAN fail (though not guaranteed on all JVMs). + + AtomicReference fixed = new AtomicReference<>(null); + + Thread writer = new Thread(() -> { + fixed.set(new MockCullTask(99)); // happens-before for readers + }); + writer.start(); + + // Busy-read until we see the value — AtomicReference guarantees visibility + MockCullTask read; + long start = System.currentTimeMillis(); + do { + read = fixed.get(); + if (System.currentTimeMillis() - start > 1000) break; + } while (read == null); + + assertNotNull(read, "REQ-002: AtomicReference guarantees reader visibility within 1s"); + assertEquals(99, read.id); + } +} diff --git a/leaf-server/src/test/java/dev/tr7zw/entityculling/DirtyFlagDesignTest.java b/leaf-server/src/test/java/dev/tr7zw/entityculling/DirtyFlagDesignTest.java new file mode 100644 index 00000000..8707c4b0 --- /dev/null +++ b/leaf-server/src/test/java/dev/tr7zw/entityculling/DirtyFlagDesignTest.java @@ -0,0 +1,110 @@ +package dev.tr7zw.entityculling; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Proves REQ-001: block-change processing must use bounded, non-spawning + * mechanism (AtomicBoolean dirty flag + periodic sweep) instead of unbounded + * async tasks. + *

+ * RED: CullTask.onBlockChange() currently spawns {@code CompletableFuture.runAsync()} + * on every block change via a CachedThreadPool. No dirty flag exists. This test + * verifies the design contract that the dirty flag mechanism must satisfy. + */ +class DirtyFlagDesignTest { + + @Test + @DisplayName("AtomicBoolean coalesces N rapid signals into 1 action") + void dirtyFlagCoalescesBursts() { + AtomicBoolean dirty = new AtomicBoolean(false); + + // Simulate 100 rapid block changes — only one should trigger cache reset + int resetCount = 0; + for (int i = 0; i < 100; i++) { + dirty.set(true); + } + // Periodic tick: check and CAS-reset the flag atomically + if (dirty.compareAndSet(true, false)) { + resetCount++; + } + + assertEquals(1, resetCount, + "REQ-001: 100 rapid dirty signals must coalesce into exactly 1 cache reset. " + + "Unbounded CompletableFuture.runAsync() would spawn 100 tasks."); + } + + @Test + @DisplayName("Dirty flag survives concurrent writes without lost updates") + void dirtyFlagConcurrentSafety() throws Exception { + AtomicBoolean dirty = new AtomicBoolean(false); + int threadCount = 10; + int signalsPerThread = 1000; + Thread[] threads = new Thread[threadCount]; + + // Multiple threads hammer the dirty flag concurrently + for (int i = 0; i < threadCount; i++) { + threads[i] = new Thread(() -> { + for (int j = 0; j < signalsPerThread; j++) { + dirty.set(true); + } + }); + threads[i].start(); + } + for (Thread t : threads) { + t.join(); + } + + // After all signals, flag must be true (no lost updates from races) + assertTrue(dirty.get(), + "REQ-001: Dirty flag must be true after concurrent writes — " + + "no lost updates allowed."); + + // CAS reset works atomically + assertTrue(dirty.compareAndSet(true, false), + "REQ-001: CAS reset must succeed when flag is true."); + assertFalse(dirty.get(), + "REQ-001: Flag must be false after CAS reset."); + } + + @Test + @DisplayName("Idle state: no spurious wakeups when nothing changed") + void dirtyFlagIdleWhenNoChanges() { + AtomicBoolean dirty = new AtomicBoolean(false); + + // Periodic tick fires, but nothing was dirtied — no action taken + int idleResets = 0; + if (dirty.compareAndSet(true, false)) { + idleResets++; + } + + assertEquals(0, idleResets, + "REQ-001: When no block changes occur, periodic tick must NOT trigger " + + "cache reset. CachedThreadPool spawns 0 tasks in idle state."); + } + + @Test + @DisplayName("Periodic reset pattern: set→sweep→set→sweep cycle") + void dirtyFlagPeriodicCycle() { + AtomicBoolean dirty = new AtomicBoolean(false); + int sweeps = 0; + + // Tick 1: block change happens + dirty.set(true); + if (dirty.compareAndSet(true, false)) sweeps++; + assertEquals(1, sweeps, "Sweep 1: dirty flag was set, should sweep"); + + // Tick 2: no changes + if (dirty.compareAndSet(true, false)) sweeps++; + assertEquals(1, sweeps, "Sweep 2: no changes, no additional sweep"); + + // Tick 3: another block change + dirty.set(true); + if (dirty.compareAndSet(true, false)) sweeps++; + assertEquals(2, sweeps, "Sweep 3: dirty flag was set, should sweep again"); + } +} diff --git a/leaf-server/src/test/java/dev/tr7zw/entityculling/IsForcedVisibleProof.java b/leaf-server/src/test/java/dev/tr7zw/entityculling/IsForcedVisibleProof.java new file mode 100644 index 00000000..862db2a2 --- /dev/null +++ b/leaf-server/src/test/java/dev/tr7zw/entityculling/IsForcedVisibleProof.java @@ -0,0 +1,96 @@ +/** + * TDD-003: Proves isForcedVisible() grace period works after removing || true. + * + * Design contract proof — no MC deps, no JUnit, runs with plain javac/java. + * + * Compile + run: + * javac -d /tmp IsForcedVisibleProof.java && java -cp /tmp IsForcedVisibleProof + * + * @see CullTask.cullEntities() — the `|| true` debug remnant fix + * @see Entity.isForcedVisible() — 1-second grace period via lastTime + */ +public class IsForcedVisibleProof { + static int failures = 0; + + static void check(boolean cond, String msg) { + if (!cond) { + System.err.println("FAIL: " + msg); + failures++; + } + } + + static class GracePeriodTracker { + private long lastTime = 0; + + void setTimeout() { + this.lastTime = System.currentTimeMillis() + 1000; + } + + boolean isForcedVisible() { + return this.lastTime > System.currentTimeMillis(); + } + } + + static boolean buggyCondition(GracePeriodTracker tracker) { + return !tracker.isForcedVisible() || true; + } + + static boolean correctCondition(GracePeriodTracker tracker) { + return !tracker.isForcedVisible(); + } + + public static void main(String[] args) throws Exception { + GracePeriodTracker tracker = new GracePeriodTracker(); + + // Test 1: Buggy condition is ALWAYS true + check(buggyCondition(tracker), + "Buggy: no setTimeout, condition must be true (|| true always wins)"); + + tracker.setTimeout(); + Thread.sleep(1100); + check(!tracker.isForcedVisible(), + "Grace period expired after 1.1s"); + check(buggyCondition(tracker), + "Buggy: after grace expiry, condition STILL true (|| true) — BUG CONFIRMED"); + + // Test 2: Correct condition respects grace period + GracePeriodTracker t2 = new GracePeriodTracker(); + check(correctCondition(t2), + "Correct: no grace → enters visibility check block"); + t2.setTimeout(); + check(t2.isForcedVisible(), + "After setTimeout: isForcedVisible=true within 1s"); + check(!correctCondition(t2), + "Correct: during grace → SKIPS visibility block (prevents flickering)"); + Thread.sleep(1100); + check(!t2.isForcedVisible(), "After 1.1s: isForcedVisible=false"); + check(correctCondition(t2), + "Correct: after grace expiry → enters block again"); + + // Test 3: Grace period prevents flickering + GracePeriodTracker t3 = new GracePeriodTracker(); + t3.setTimeout(); + Thread.sleep(100); + check(t3.isForcedVisible(), "Within grace (100ms): isForcedVisible=true"); + check(!correctCondition(t3), + "Correct: within grace → skip culling, no flickering"); + check(buggyCondition(t3), + "Buggy: within grace → enters block anyway (|| true) — flickering bug"); + + // Test 4: Multiple setTimeouts coalesce + GracePeriodTracker t4 = new GracePeriodTracker(); + t4.setTimeout(); + Thread.sleep(500); + t4.setTimeout(); + Thread.sleep(600); + check(t4.isForcedVisible(), "Extended grace still active"); + check(!correctCondition(t4), "Correct: extended grace → still skip"); + + if (failures == 0) { + System.out.println("ALL TESTS PASSED: isForcedVisible dead code fix verified."); + } else { + System.err.println(failures + " TEST(S) FAILED"); + System.exit(1); + } + } +} diff --git a/leaf-server/src/test/java/org/dreeam/leaf/config/modules/misc/ConfigBoundsProof.java b/leaf-server/src/test/java/org/dreeam/leaf/config/modules/misc/ConfigBoundsProof.java new file mode 100644 index 00000000..c6ac043b --- /dev/null +++ b/leaf-server/src/test/java/org/dreeam/leaf/config/modules/misc/ConfigBoundsProof.java @@ -0,0 +1,99 @@ +/** + * TDD-005: Proves RaytraceTracker config fields need bounds validation. + * + * Design contract proof — no MC deps, no JUnit, runs with plain javac/java. + * + * Compile + run: + * javac -d /tmp ConfigBoundsProof.java && java -cp /tmp ConfigBoundsProof + * + * @see RaytraceTracker.onLoaded() — bounds checks for traceInterval, + * maxTraceDistance, boundingBoxLimit, boundingBoxExpansion + */ +public class ConfigBoundsProof { + static int failures = 0; + static final StringBuilder log = new StringBuilder(); + + static void check(boolean cond, String msg) { + if (!cond) { System.err.println("FAIL: " + msg); failures++; } + } + + static void warn(String msg) { + log.append("WARN: ").append(msg).append("\n"); + } + + static class UnvalidatedConfig { + int traceInterval, maxTraceDistance, boundingBoxLimit; + double boundingBoxExpansion; + + void load(int ti, int mtd, int bbl, double bbe) { + this.traceInterval = ti; + this.maxTraceDistance = mtd; + this.boundingBoxLimit = bbl; + this.boundingBoxExpansion = bbe; + } + } + + static class ValidatedConfig { + int traceInterval, maxTraceDistance, boundingBoxLimit; + double boundingBoxExpansion; + + void load(int ti, int mtd, int bbl, double bbe) { + if (ti < 0) { warn("traceInterval " + ti + " < 0, clamping to 0"); this.traceInterval = 0; } + else { this.traceInterval = ti; } + if (mtd <= 0) { warn("maxTraceDistance " + mtd + " <= 0, clamping to 1"); this.maxTraceDistance = 1; } + else { this.maxTraceDistance = mtd; } + if (bbl < 0) { warn("boundingBoxLimit " + bbl + " < 0, clamping to 0"); this.boundingBoxLimit = 0; } + else { this.boundingBoxLimit = bbl; } + if (bbe < 0) { warn("boundingBoxExpansion " + bbe + " < 0, clamping to 0"); this.boundingBoxExpansion = 0; } + else { this.boundingBoxExpansion = bbe; } + } + } + + public static void main(String[] args) { + UnvalidatedConfig bad = new UnvalidatedConfig(); + bad.load(-50, 0, -10, -1.5); + check(bad.traceInterval == -50, + "BUG: traceInterval=-50 passed through"); + check(bad.maxTraceDistance == 0, + "BUG: maxTraceDistance=0 passed through"); + check(bad.boundingBoxLimit == -10, + "BUG: boundingBoxLimit=-10 passed through"); + check(bad.boundingBoxExpansion == -1.5, + "BUG: boundingBoxExpansion=-1.5 passed through"); + + log.setLength(0); + ValidatedConfig good = new ValidatedConfig(); + good.load(-50, 0, -10, -1.5); + check(good.traceInterval == 0, "FIX: traceInterval clamped to 0"); + check(good.maxTraceDistance == 1, "FIX: maxTraceDistance clamped to 1"); + check(good.boundingBoxLimit == 0, "FIX: boundingBoxLimit clamped to 0"); + check(good.boundingBoxExpansion == 0.0, "FIX: boundingBoxExpansion clamped to 0.0"); + check(log.toString().contains("traceInterval"), "FIX: warning logged"); + check(log.toString().contains("maxTraceDistance"), "FIX: warning logged"); + + log.setLength(0); + ValidatedConfig normal = new ValidatedConfig(); + normal.load(50, 64, 20, 0.5); + check(normal.traceInterval == 50, "Normal traceInterval=50 preserved"); + check(normal.maxTraceDistance == 64, "Normal maxTraceDistance=64 preserved"); + check(normal.boundingBoxLimit == 20, "Normal boundingBoxLimit=20 preserved"); + check(normal.boundingBoxExpansion == 0.5, "Normal boundingBoxExpansion=0.5 preserved"); + check(log.toString().isEmpty(), "No warnings for in-range values"); + + log.setLength(0); + ValidatedConfig edge = new ValidatedConfig(); + edge.load(0, 1, 0, 0.0); + check(edge.traceInterval == 0, "Edge: traceInterval=0 preserved"); + check(edge.maxTraceDistance == 1, "Edge: maxTraceDistance=1 preserved"); + check(edge.boundingBoxLimit == 0, "Edge: boundingBoxLimit=0 preserved"); + check(edge.boundingBoxExpansion == 0.0, "Edge: boundingBoxExpansion=0.0 preserved"); + check(log.toString().isEmpty(), "No warnings for edge values"); + + if (failures == 0) { + System.out.println("ALL TESTS PASSED: config bounds validation design verified."); + } else { + System.err.println(failures + " TEST(S) FAILED"); + System.exit(1); + } + } +}