From ae8c4f5232dcbf127e20d9d03172bc32fbac81f2 Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Fri, 31 Jul 2026 10:45:51 +0000 Subject: [PATCH] first commit --- .gitignore | 1 + .gradle/8.10.2/checksums/checksums.lock | Bin 0 -> 17 bytes .gradle/8.10.2/checksums/md5-checksums.bin | Bin 0 -> 31897 bytes .gradle/8.10.2/checksums/sha1-checksums.bin | Bin 0 -> 80213 bytes .../dependencies-accessors/gc.properties | 0 .../executionHistory/executionHistory.bin | Bin 0 -> 240774 bytes .../executionHistory/executionHistory.lock | Bin 0 -> 17 bytes .gradle/8.10.2/fileChanges/last-build.bin | Bin 0 -> 1 bytes .gradle/8.10.2/fileHashes/fileHashes.bin | Bin 0 -> 25897 bytes .gradle/8.10.2/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .../8.10.2/fileHashes/resourceHashesCache.bin | Bin 0 -> 21659 bytes .gradle/8.10.2/gc.properties | 0 .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .gradle/buildOutputCleanup/cache.properties | 2 + .gradle/buildOutputCleanup/outputFiles.bin | Bin 0 -> 19487 bytes .gradle/vcs-1/gc.properties | 0 CLAUDE.md | 131 ++++++ README.md | 127 ++++++ build.gradle | 137 +++++++ e2e/run-servers.sh | 156 ++++++++ gradle.properties | 18 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++ gradlew.bat | 94 +++++ settings.gradle | 13 + .../e2e/ChunkInspectorE2ETest.java | 241 +++++++++++ .../dev/chunkinspector/e2e/ServerRun.java | 87 ++++ .../dev/chunkinspector/ChunkInspector.java | 68 ++++ .../dev/chunkinspector/InspectorCommand.java | 135 +++++++ .../dev/chunkinspector/InspectorConfig.java | 89 +++++ .../dev/chunkinspector/InspectorService.java | 147 +++++++ .../analysis/ChunkExplainer.java | 51 +++ .../dev/chunkinspector/analysis/ChunkKey.java | 38 ++ .../chunkinspector/analysis/ChunkRecord.java | 23 ++ .../analysis/LevelAnalysis.java | 377 ++++++++++++++++++ .../analysis/LevelPropagation.java | 124 ++++++ .../chunkinspector/analysis/TicketRecord.java | 33 ++ .../dev/chunkinspector/game/LevelCapture.java | 64 +++ .../dev/chunkinspector/game/TicketKeys.java | 31 ++ .../mixin/DistanceManagerMixin.java | 35 ++ .../mixin/InspectorMixinPlugin.java | 60 +++ .../dev/chunkinspector/mixin/TicketMixin.java | 28 ++ .../mixin/TicketOwnerAccessor.java | 22 + .../chunkinspector/origin/ModAttribution.java | 54 +++ .../dev/chunkinspector/origin/Origin.java | 14 + .../chunkinspector/origin/OriginRegistry.java | 125 ++++++ .../chunkinspector/origin/TicketOrigin.java | 18 + .../report/InspectionReport.java | 136 +++++++ .../chunkinspector/report/ReportWriter.java | 105 +++++ .../dev/chunkinspector/report/TextDigest.java | 102 +++++ .../resources/META-INF/accesstransformer.cfg | 7 + src/main/resources/chunkinspector.mixins.json | 15 + .../templates/META-INF/neoforge.mods.toml | 27 ++ .../java/dev/chunkinspector/Fixtures.java | 97 +++++ .../chunkinspector/InspectorConfigTest.java | 91 +++++ .../analysis/ChunkExplainerTest.java | 81 ++++ .../chunkinspector/analysis/ChunkKeyTest.java | 48 +++ .../analysis/LevelAnalysisTest.java | 178 +++++++++ .../analysis/LevelPropagationTest.java | 100 +++++ .../origin/ModAttributionTest.java | 35 ++ .../origin/OriginRegistryTest.java | 87 ++++ .../report/ReportWriterTest.java | 120 ++++++ .../chunkinspector/report/TextDigestTest.java | 60 +++ .../java/example/mod/LeakyChunkLoader.java | 33 ++ 65 files changed, 4124 insertions(+) create mode 100644 .gitignore create mode 100644 .gradle/8.10.2/checksums/checksums.lock create mode 100644 .gradle/8.10.2/checksums/md5-checksums.bin create mode 100644 .gradle/8.10.2/checksums/sha1-checksums.bin create mode 100644 .gradle/8.10.2/dependencies-accessors/gc.properties create mode 100644 .gradle/8.10.2/executionHistory/executionHistory.bin create mode 100644 .gradle/8.10.2/executionHistory/executionHistory.lock create mode 100644 .gradle/8.10.2/fileChanges/last-build.bin create mode 100644 .gradle/8.10.2/fileHashes/fileHashes.bin create mode 100644 .gradle/8.10.2/fileHashes/fileHashes.lock create mode 100644 .gradle/8.10.2/fileHashes/resourceHashesCache.bin create mode 100644 .gradle/8.10.2/gc.properties create mode 100644 .gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 .gradle/buildOutputCleanup/cache.properties create mode 100644 .gradle/buildOutputCleanup/outputFiles.bin create mode 100644 .gradle/vcs-1/gc.properties create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 build.gradle create mode 100755 e2e/run-servers.sh create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/e2e/java/dev/chunkinspector/e2e/ChunkInspectorE2ETest.java create mode 100644 src/e2e/java/dev/chunkinspector/e2e/ServerRun.java create mode 100644 src/main/java/dev/chunkinspector/ChunkInspector.java create mode 100644 src/main/java/dev/chunkinspector/InspectorCommand.java create mode 100644 src/main/java/dev/chunkinspector/InspectorConfig.java create mode 100644 src/main/java/dev/chunkinspector/InspectorService.java create mode 100644 src/main/java/dev/chunkinspector/analysis/ChunkExplainer.java create mode 100644 src/main/java/dev/chunkinspector/analysis/ChunkKey.java create mode 100644 src/main/java/dev/chunkinspector/analysis/ChunkRecord.java create mode 100644 src/main/java/dev/chunkinspector/analysis/LevelAnalysis.java create mode 100644 src/main/java/dev/chunkinspector/analysis/LevelPropagation.java create mode 100644 src/main/java/dev/chunkinspector/analysis/TicketRecord.java create mode 100644 src/main/java/dev/chunkinspector/game/LevelCapture.java create mode 100644 src/main/java/dev/chunkinspector/game/TicketKeys.java create mode 100644 src/main/java/dev/chunkinspector/mixin/DistanceManagerMixin.java create mode 100644 src/main/java/dev/chunkinspector/mixin/InspectorMixinPlugin.java create mode 100644 src/main/java/dev/chunkinspector/mixin/TicketMixin.java create mode 100644 src/main/java/dev/chunkinspector/mixin/TicketOwnerAccessor.java create mode 100644 src/main/java/dev/chunkinspector/origin/ModAttribution.java create mode 100644 src/main/java/dev/chunkinspector/origin/Origin.java create mode 100644 src/main/java/dev/chunkinspector/origin/OriginRegistry.java create mode 100644 src/main/java/dev/chunkinspector/origin/TicketOrigin.java create mode 100644 src/main/java/dev/chunkinspector/report/InspectionReport.java create mode 100644 src/main/java/dev/chunkinspector/report/ReportWriter.java create mode 100644 src/main/java/dev/chunkinspector/report/TextDigest.java create mode 100644 src/main/resources/META-INF/accesstransformer.cfg create mode 100644 src/main/resources/chunkinspector.mixins.json create mode 100644 src/main/templates/META-INF/neoforge.mods.toml create mode 100644 src/test/java/dev/chunkinspector/Fixtures.java create mode 100644 src/test/java/dev/chunkinspector/InspectorConfigTest.java create mode 100644 src/test/java/dev/chunkinspector/analysis/ChunkExplainerTest.java create mode 100644 src/test/java/dev/chunkinspector/analysis/ChunkKeyTest.java create mode 100644 src/test/java/dev/chunkinspector/analysis/LevelAnalysisTest.java create mode 100644 src/test/java/dev/chunkinspector/analysis/LevelPropagationTest.java create mode 100644 src/test/java/dev/chunkinspector/origin/ModAttributionTest.java create mode 100644 src/test/java/dev/chunkinspector/origin/OriginRegistryTest.java create mode 100644 src/test/java/dev/chunkinspector/report/ReportWriterTest.java create mode 100644 src/test/java/dev/chunkinspector/report/TextDigestTest.java create mode 100644 src/test/java/example/mod/LeakyChunkLoader.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..094e431 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/build/** diff --git a/.gradle/8.10.2/checksums/checksums.lock b/.gradle/8.10.2/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..cd73c90070642be091d8cafff8e11ee8f698a504 GIT binary patch literal 17 VcmZQxCVPGByM$Su3}C=~8UQ)?1yTS2 literal 0 HcmV?d00001 diff --git a/.gradle/8.10.2/checksums/md5-checksums.bin b/.gradle/8.10.2/checksums/md5-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..1c707b9bed06c162cdfc45a05e8b50712ac23d45 GIT binary patch literal 31897 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 literal 0 HcmV?d00001 diff --git a/.gradle/8.10.2/checksums/sha1-checksums.bin b/.gradle/8.10.2/checksums/sha1-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..913a2217145b156a63f4e24dafdf2c7cdc84fbfd GIT binary patch literal 80213 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 z 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

p~opc1?LU#al5u=mTm%}inLE1rK*&5KOW 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 literal 0 HcmV?d00001 diff --git a/.gradle/8.10.2/dependencies-accessors/gc.properties b/.gradle/8.10.2/dependencies-accessors/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/8.10.2/executionHistory/executionHistory.bin b/.gradle/8.10.2/executionHistory/executionHistory.bin new file mode 100644 index 0000000000000000000000000000000000000000..7750255704239aaded94986cd9a6dec5016184ff GIT binary patch literal 240774 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 literal 0 HcmV?d00001 diff --git a/.gradle/8.10.2/executionHistory/executionHistory.lock b/.gradle/8.10.2/executionHistory/executionHistory.lock new file mode 100644 index 0000000000000000000000000000000000000000..82345bc7b7d21e060d4c688b7ce74a1a2ffd3631 GIT binary patch literal 17 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 +``` + +That sets `JAVA_HOME` (Temurin 21.0.12+8), puts Gradle 8.10.2 on `PATH`, and sets `GRADLE_USER_HOME`. + +## Reading Minecraft's source + +Decompiled, mojmapped sources are already extracted for grepping — **use these, not `javap`**: + +- `/home/user/mc-sources/minecraft/` — 5364 Minecraft `.java` files +- `/home/user/mc-sources/neoforge/` — 953 NeoForge `.java` files + +Verify vanilla behaviour there before asserting it in code or tests. Nearly every subtle bug in this +project came from guessing at a constant instead of grepping for it. + +## Commands + +```fish +./gradlew build # compile + unit tests + jar +./gradlew test # unit tests only (fast, no game runtime) +./gradlew test --tests 'dev.chunkinspector.analysis.LevelPropagationTest' +./gradlew test --tests '*.aChunkQueuedThenSupersededKeepsTheStrongerAnswer' +./gradlew jar # the mod jar, build/libs/chunkinspector-.jar +./gradlew e2eTest # full end-to-end: installs and drives two real dedicated servers +./gradlew runServer # MDG dev server, for manual poking +``` + +`e2eTest` is deliberately excluded from `check` — it downloads NeoForge (~300 MB, cached in +`~/.cache/chunkinspector-e2e`) and boots two servers, ~90 s once the cache is warm. It `dependsOn jar`, +so it always tests the shipped artifact. + +The server half can be run without Gradle: `./e2e/run-servers.sh` (honours `E2E_OUT`, `MOD_JAR`, +`NEO_VERSION`, `E2E_BOOT_TIMEOUT`). Servers are left in `build/e2e//` afterwards — read +`build/e2e/light/console.log` and `build/e2e/*/world/chunk-summaries/latest.txt` when a test fails, +they are far more informative than the assertion message. + +## Architecture + +Minecraft 1.21.1, NeoForge 21.1.247, Mojang mappings, ModDevGradle. Java 21 + Lombok. + +### The layering rule that everything else follows + +`analysis/`, `report/` and `origin/` contain **no Minecraft types**. `game/` is the only package that +touches them, and its job is to reduce live game state to plain records (`TicketRecord`, +`ChunkRecord`) via `LevelCapture.of(ServerLevel)`. + +This is not stylistic. It is what makes 62 unit tests runnable without a game runtime, and it is why +`build.gradle` adds `fastutil` and `gson` as *test* dependencies at the versions Minecraft itself +ships. Keep new logic on the Minecraft-free side of that line; if you need a game value, capture it +into a record rather than importing `net.minecraft` into `analysis/`. + +### Two cost tiers, decided before Mixin application + +The always-on tier injects **nothing**. `META-INF/accesstransformer.cfg` widens five +`DistanceManager`/`Ticket`/`ChunkMap` members for read-only access, and a snapshot walks them on +demand. + +Deep mode (`-Dchunkinspector.deep=true`) adds a `StackWalker` capture at +`DistanceManager.addTicket(long, Ticket)` — the single choke point every ticket passes through. +`InspectorMixinPlugin.shouldApplyMixin` *declines to apply* `DistanceManagerMixin` and `TicketMixin` +when deep mode is off, so Minecraft's bytecode is untouched rather than carrying a disabled branch. + +Because that decision happens during Mixin config load, all configuration is system properties +(`InspectorConfig`) — a config file would be read too late. Adding a new tuning knob means adding it +there, not to a NeoForge config spec. + +### Snapshot lifecycle + +`InspectorService` splits the work: `capture()` runs on the server thread and only copies two +collections; analysis, propagation and JSON serialisation happen on the single-threaded +`chunkinspector-report` executor against the immutable snapshot. That single thread is also what +makes `ReportWriter`'s check-then-create filename logic race-free. + +Triggers are `periodic` (every `interval` ticks), `command`, and `shutdown` (always full analysis, +blocks up to 30 s). Output goes to `/chunk-summaries/`. + +### The analysis itself + +`LevelPropagation` replays vanilla's `ChunkTracker` rules — a ticket at chunk C level L gives every +chunk at Chebyshev distance d a level of L+d, lowest wins, stop above `ChunkLevel.MAX_LEVEL` — using +Dial's algorithm (bucket queue) so the pass is O(loaded chunks). Crucially it also records *which* +ticket won each chunk; the game throws that away, and it is the entire point of the mod. + +`LevelAnalysis` turns that into totals, per-type breakdowns, suspects, hotspots and unattributed +chunks. "Influence" is a partition, not a sum: each loaded chunk is credited to exactly one winning +ticket, so per-ticket numbers add up rather than overlap. + +Attribution is opt-in per snapshot (`attribution` flag / `snapshot full`) because it is the only +part that is more than O(tickets). + +### Ticket identity without instrumentation + +`TicketKeys.render` is what lets the free tier often be sufficient — a NeoForge forced-chunk key +already carries the requesting mod's id. Deep mode is only needed when the key is opaque. + +## Gotchas + +- `net.minecraft.server.level.Ticket` is `final`, so `ticket instanceof TicketOrigin` will not + compile even though `TicketMixin` makes it true at runtime. Cast through `Object`. +- `OriginRegistry` filters `dev.chunkinspector.*` frames out of every capture, so a test that calls + `record()` from within that package captures nothing. `src/test/java/example/mod/LeakyChunkLoader` + exists to be an outside caller. +- `/forceload add` takes **block** coordinates, not chunk coordinates. The e2e script force-loads + `1600 1600 1663 1663` to cover chunks (100,100)..(103,103). +- Snapshot filenames resolve to the second, so they carry the trigger (`summary--.json`) + and uniquify on collision — a periodic and a command snapshot really do land in the same second. +- The mixin config's `defaultRequire: 1` means a silently non-applying injector fails the build at + load time. Check `console.log` for `Mixin apply failed` when a deep-mode e2e test goes quiet. + +## Tests + +`src/test/java/dev/chunkinspector/Fixtures.java` holds the shared scenario (a player ticket, a leaky +permanent ticket, a forceload, a portal ticket, and a chunk set) used across the analysis, report and +digest tests. Extend it rather than hand-rolling another fixture. + +`src/e2e/` is a separate source set whose `compileClasspath` includes `main`'s output — the e2e tests +parse the servers' JSON back through the production `InspectionReport` records, which is deliberate: +it proves the file a server writes is a file this mod can read. + +See `README.md` for the user-facing documentation: all nine system properties, the commands, and how +to read a report. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e72d4e --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# Chunk Inspector + +Finds out **why chunks stay loaded** on a heavily modded Minecraft **1.21.1 / NeoForge** server. + +Every loaded chunk on a vanilla-or-modded server is loaded because some *chunk ticket* holds it, or +because it is within propagation range of one. Chunk Inspector reads the live ticket table, replays +the game's own level propagation, and writes a report naming the tickets, the chunks they keep alive, +and — if you ask for it — the exact line of code that created each one. + +Reports land in `/chunk-summaries` (i.e. next to `level.dat` in the world folder). + +## Two cost tiers + +| | Always on | `-Dchunkinspector.deep=true` | +|---|---|---| +| Bytecode injected into Minecraft | **none** | `DistanceManager.addTicket`, `Ticket` | +| Per-ticket cost | zero | one stack walk | +| Names the ticket type, level, age, owner key | yes | yes | +| Names the *code* that created the ticket | no | yes | + +The default tier is genuinely free: an access transformer widens a few ticket fields, and a snapshot +walks them on demand. Nothing runs between snapshots. The instrumenting Mixins are not merely +disabled without `deep` — a Mixin config plugin declines to apply them, so Minecraft's classes are +left untouched. + +Reach for `deep` when the always-on tier shows you *which* tickets leak but the owner key is opaque. +Many tickets already identify themselves (a NeoForge forced chunk carries the requesting mod's id), +so it is often unnecessary. + +## Commands + +All require permission level 2. + +| Command | What it does | +|---|---| +| `/chunkinspector status` | Cheap in-chat summary of the current dimension | +| `/chunkinspector why [ ]` | Every ticket contributing to one chunk's level, strongest first. Defaults to the chunk you are standing in | +| `/chunkinspector snapshot` | Writes a report now | +| `/chunkinspector snapshot full` | ... including the expensive chunk-to-ticket attribution pass | + +A report is also written every `interval` ticks and once on shutdown. The shutdown one always runs +the full analysis, because it is the one people come back to. + +## System properties + +Pass these on the server's JVM command line, before `-jar` / `@libraries/...`. + +| Property | Default | Meaning | +|---|---|---| +| `chunkinspector.enabled` | `true` | Master switch. When off, no hooks and no reports | +| `chunkinspector.deep` | `false` | Record the stack trace behind every ticket | +| `chunkinspector.interval` | `600` | Ticks between automatic snapshots; `0` disables them | +| `chunkinspector.attribution` | `false` | Run the chunk-to-ticket pass on *periodic* snapshots too | +| `chunkinspector.retention` | `48` | Snapshot files to keep; `0` keeps everything | +| `chunkinspector.textReport` | `true` | Also write the human-readable `latest.txt` | +| `chunkinspector.stackDepth` | `24` | Frames kept per call site (deep mode) | +| `chunkinspector.maxOrigins` | `8192` | Distinct call sites remembered (deep mode) | +| `chunkinspector.sampleRate` | `1` | Capture one stack per *N* ticket adds (deep mode) | + +Example — free monitoring, one snapshot per minute, a day's history: + +``` +-Dchunkinspector.interval=1200 -Dchunkinspector.retention=1440 +``` + +Example — a full investigation: + +``` +-Dchunkinspector.deep=true -Dchunkinspector.attribution=true +``` + +## Output + +``` +chunk-summaries/ + latest.json the newest report + latest.txt the same thing, readable + summary-20260731-103326-periodic.json timestamped history, pruned to `retention` + summary-20260731-103326-command.json + summary-20260731-103340-shutdown.json +``` + +`latest.txt` of a server with a 4x4 `/forceload`: + +``` +══ minecraft:overworld ══ game time 528 + chunks: 113 loaded, 61 block-ticking, 25 entity-ticking (1741 tracked) + tickets: 17 on 17 chunks, 17 never expire, 16 from /forceload + + by ticket type + forced 16 tickets 16 chunks level>=31 keeps 64 loaded oldest 26s + start 1 tickets 1 chunks level>=30 keeps 49 loaded oldest 26s + + most suspicious tickets + chunk 103, 102 forced level 31 chunk 103, 102 + -> permanent ticket, alive 26s, keeping 9 chunks loaded; entity-ticking, so mobs and machines run here + + loaded chunk clusters + 64 chunks centre 101, 101 bounds 98, 98 .. 105, 105 forced x64 + 49 chunks centre 0, 0 bounds -3, -3 .. 3, 3 start x49 +``` + +The JSON carries the same information plus everything that did not fit — see +`InspectionReport` for the field-by-field documentation. + +### How to read it + +- **suspects** are tickets with *no timeout* that the game does not manage itself. Only these can + leak forever. `keeping N chunks loaded` is a partition, not a sum: each loaded chunk is credited to + the single strongest ticket holding it, so the numbers across tickets add up rather than overlap. +- **loaded chunk clusters** group contiguous loaded chunks. A leak usually shows up as one cluster + far from spawn with a single cause. +- **level** is the vanilla chunk level: `<=31` entity-ticking, `<=32` block-ticking, `<=33` full. + An entity-ticking leak is far more expensive than a merely-loaded one. +- **unattributed** lists loaded chunks no ticket explains. Normally empty; entries here mean + something is holding chunks outside the ticket system. + +## Building + +``` +./gradlew build # jar + unit tests +./gradlew e2eTest # installs a real NeoForge dedicated server and asserts on its reports +``` + +`e2eTest` is deliberately not part of `check`: it downloads NeoForge and runs two servers end to end, +taking a few minutes. It boots a dedicated server in each tier, force-loads a 4x4 square of chunks +over the console, and verifies the reports name it. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..192952b --- /dev/null +++ b/build.gradle @@ -0,0 +1,137 @@ +plugins { + id 'java-library' + id 'net.neoforged.moddev' version '2.0.143' +} + +group = mod_group_id +version = mod_version +base { archivesName = mod_id } + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +repositories { + mavenCentral() +} + +neoForge { + version = project.neo_version + + // Expose the mojmapped, decompiled Minecraft sources so they can be grepped. + // `./gradlew createMinecraftArtifacts` produces them under build/ + the MDG cache. + accessTransformers.from('src/main/resources/META-INF/accesstransformer.cfg') + + runs { + configureEach { + systemProperty 'forge.logging.markers', 'REGISTRIES' + logLevel = org.slf4j.event.Level.DEBUG + } + client { + client() + } + server { + server() + programArgument '--nogui' + } + } + + mods { + "$mod_id" { + sourceSet sourceSets.main + } + } +} + +// The end-to-end suite asserts on reports produced by a real dedicated server, so it is kept out of +// `check`: it downloads and installs NeoForge and takes minutes. Run it with `./gradlew e2eTest`. +sourceSets { + e2e { + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + +configurations { + e2eImplementation.extendsFrom testImplementation + e2eRuntimeOnly.extendsFrom testRuntimeOnly +} + +dependencies { + compileOnly 'org.projectlombok:lombok:1.18.34' + annotationProcessor 'org.projectlombok:lombok:1.18.34' + + testCompileOnly 'org.projectlombok:lombok:1.18.34' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.34' + + testImplementation platform('org.junit:junit-bom:5.11.3') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.assertj:assertj-core:3.26.3' + + // The analysis, report and origin packages are deliberately free of Minecraft types so they can be + // unit-tested without a game runtime. These two are the only libraries they touch, at the versions + // Minecraft 1.21.1 itself ships. + testImplementation 'it.unimi.dsi:fastutil:8.5.12' + testImplementation 'com.google.code.gson:gson:2.10.1' +} + +// Interpolate mod metadata into neoforge.mods.toml +var generateModMetadata = tasks.register('generateModMetadata', ProcessResources) { + var replaceProperties = [ + minecraft_version : minecraft_version, + neo_version : neo_version, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : mod_version, + mod_authors : mod_authors, + mod_description : mod_description, + ] + inputs.properties replaceProperties + expand replaceProperties + from 'src/main/templates' + into "$buildDir/generated/sources/modMetadata" +} +sourceSets.main.resources.srcDir generateModMetadata +neoForge.ideSyncTask generateModMetadata + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release = 21 + options.compilerArgs << '-Xlint:all,-processing,-serial' +} + +tasks.named('test', Test) { + useJUnitPlatform() + testLogging { + events 'passed', 'skipped', 'failed' + exceptionFormat 'full' + showStandardStreams = false + } +} + +var e2eRoot = layout.buildDirectory.dir('e2e') + +var e2eServers = tasks.register('e2eServers', Exec) { + group = 'verification' + description = 'Installs a NeoForge dedicated server, runs the mod on it twice and keeps its reports.' + dependsOn tasks.named('jar') + workingDir projectDir + commandLine 'bash', 'e2e/run-servers.sh' + environment 'E2E_OUT', e2eRoot.get().asFile.absolutePath + outputs.upToDateWhen { false } +} + +tasks.register('e2eTest', Test) { + group = 'verification' + description = 'Asserts on the reports the dedicated servers produced. Not part of `check`: it is slow.' + dependsOn e2eServers + testClassesDirs = sourceSets.e2e.output.classesDirs + classpath = sourceSets.e2e.runtimeClasspath + systemProperty 'e2e.root', e2eRoot.get().asFile.absolutePath + useJUnitPlatform() + testLogging { + events 'passed', 'skipped', 'failed' + exceptionFormat 'full' + } + outputs.upToDateWhen { false } +} diff --git a/e2e/run-servers.sh b/e2e/run-servers.sh new file mode 100755 index 0000000..5319ce0 --- /dev/null +++ b/e2e/run-servers.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# Provisions a real NeoForge dedicated server, drops the built mod into it and drives it through a +# scripted session — once with the default (zero-instrumentation) configuration and once with deep +# mode on. The resulting worlds are left in build/e2e/ for ChunkInspectorE2ETest to assert on. +# +# Runnable on its own: ./e2e/run-servers.sh +# +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +neo_version="${NEO_VERSION:-$(sed -n 's/^neo_version=//p' "$project_dir/gradle.properties")}" +mod_jar="${MOD_JAR:-$(ls "$project_dir"/build/libs/chunkinspector-*.jar 2>/dev/null | head -1)}" +out_dir="${E2E_OUT:-$project_dir/build/e2e}" +cache_dir="${E2E_CACHE:-$HOME/.cache/chunkinspector-e2e}" +boot_timeout="${E2E_BOOT_TIMEOUT:-420}" + +# The forceloaded square the assertions look for: chunks (100, 100)..(103, 103) — 4x4, far enough +# from spawn to form its own cluster and small enough to stay under the report's suspect cap. +# /forceload takes *block* coordinates, so these are the corners of those chunks: 100 * 16 = 1600 +# and 103 * 16 + 15 = 1663. +force_from="1600 1600" +force_to="1663 1663" + +if [[ -z "$mod_jar" || ! -f "$mod_jar" ]]; then + echo "no mod jar found; run ./gradlew jar first" >&2 + exit 1 +fi + +log() { printf '[e2e] %s\n' "$*"; } + +# --------------------------------------------------------------------- provision + +installer="$cache_dir/neoforge-$neo_version-installer.jar" +template="$cache_dir/server-$neo_version" + +mkdir -p "$cache_dir" +if [[ ! -f "$installer" ]]; then + log "downloading NeoForge $neo_version installer" + curl -fsSL -o "$installer.part" \ + "https://maven.neoforged.net/releases/net/neoforged/neoforge/$neo_version/neoforge-$neo_version-installer.jar" + mv "$installer.part" "$installer" +fi + +# The installation is cached and copied per run so the two modes can never share a world. +if [[ ! -f "$template/.installed" ]]; then + log "installing dedicated server into $template" + rm -rf "$template" + mkdir -p "$template" + # From the cache directory: the installer drops its own .log file next to the working directory. + (cd "$cache_dir" && java -jar "$installer" --installServer "$template") > "$cache_dir/install.log" 2>&1 \ + || { tail -40 "$cache_dir/install.log" >&2; exit 1; } + touch "$template/.installed" +fi + +args_file="libraries/net/neoforged/neoforge/$neo_version/unix_args.txt" +[[ -f "$template/$args_file" ]] || { echo "missing $args_file in the installed server" >&2; exit 1; } + +# ------------------------------------------------------------------------- run + +# run +run() { + local mode="$1" + shift + local dir="$out_dir/$mode" + + log "preparing $mode server in $dir" + rm -rf "$dir" + mkdir -p "$dir" + cp -a "$template/." "$dir/" + rm -f "$dir/.installed" + mkdir -p "$dir/mods" + cp "$mod_jar" "$dir/mods/" + + echo "eula=true" > "$dir/eula.txt" + cat > "$dir/server.properties" <<'PROPERTIES' +level-name=world +level-type=minecraft:flat +level-seed=e2e +online-mode=false +max-players=1 +view-distance=10 +simulation-distance=10 +spawn-protection=0 +sync-chunk-writes=false +enable-jmx-monitoring=false +enable-status=false +max-tick-time=-1 +PROPERTIES + + local fifo="$dir/console-in" + mkfifo "$fifo" + ( + cd "$dir" + exec java -Xmx2G "$@" "@$args_file" --nogui < console-in > console.log 2>&1 + ) & + local server=$! + # Opening the write end unblocks the server's read end; keeping it open keeps stdin alive. + exec 9> "$fifo" + + log "waiting for $mode server to finish loading" + local waited=0 + until grep -q 'Done (' "$dir/console.log" 2>/dev/null; do + if ! kill -0 "$server" 2>/dev/null; then + exec 9>&- + tail -40 "$dir/console.log" >&2 + echo "$mode server exited before it finished loading" >&2 + exit 1 + fi + sleep 1 + waited=$((waited + 1)) + if [[ $waited -ge $boot_timeout ]]; then + exec 9>&- + kill "$server" 2>/dev/null || true + echo "$mode server did not start within ${boot_timeout}s" >&2 + exit 1 + fi + done + + say() { printf '%s\n' "$1" >&9; log " > $1"; sleep "${2:-2}"; } + + # A leaking chunk loader, simulated with the one permanent ticket every vanilla server can make. + # The pause covers generating the 4x4 square, which has never been visited before. + say "forceload add $force_from $force_to" 5 + say "chunkinspector status" + say "chunkinspector why 102 102" + say "chunkinspector snapshot full" 5 + # Long enough for at least one periodic snapshot (interval is 200 ticks = 10s) to also land. + say "chunkinspector status" 12 + say "stop" 0 + + log "waiting for $mode server to shut down" + wait "$server" || true + exec 9>&- + rm -f "$fifo" + + local summaries="$dir/world/chunk-summaries" + [[ -d "$summaries" ]] || { tail -60 "$dir/console.log" >&2; echo "no $summaries produced" >&2; exit 1; } + log "$mode done: $(ls "$summaries" | wc -l) files in chunk-summaries" +} + +mkdir -p "$out_dir" + +# Tier one: nothing injected into the game at all. +run light \ + -Dchunkinspector.interval=200 \ + -Dchunkinspector.retention=5 + +# Tier two: the opt-in expensive analysis. +run deep \ + -Dchunkinspector.deep=true \ + -Dchunkinspector.attribution=true \ + -Dchunkinspector.interval=200 \ + -Dchunkinspector.retention=5 + +log "all servers completed" diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..826a16b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true + +# Environment +minecraft_version=1.21.1 +neo_version=21.1.247 +parchment_minecraft_version=1.21.1 + +# Mod +mod_id=chunkinspector +mod_name=Chunk Inspector +mod_license=MIT +mod_version=1.0.0 +mod_group_id=dev.chunkinspector +mod_authors=ChunkInspector Contributors +mod_description=Diagnoses why chunks stay loaded on heavily-modded servers by tracking chunk tickets and writing reports to /chunk-summaries. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..c33c8c7 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + maven { name = 'NeoForged'; url = 'https://maven.neoforged.net/releases' } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} + +rootProject.name = 'chunkinspector' diff --git a/src/e2e/java/dev/chunkinspector/e2e/ChunkInspectorE2ETest.java b/src/e2e/java/dev/chunkinspector/e2e/ChunkInspectorE2ETest.java new file mode 100644 index 0000000..d522f4c --- /dev/null +++ b/src/e2e/java/dev/chunkinspector/e2e/ChunkInspectorE2ETest.java @@ -0,0 +1,241 @@ +package dev.chunkinspector.e2e; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.chunkinspector.report.InspectionReport; +import dev.chunkinspector.report.InspectionReport.Hotspot; +import dev.chunkinspector.report.InspectionReport.LevelReport; +import dev.chunkinspector.report.InspectionReport.OriginEntry; +import dev.chunkinspector.report.InspectionReport.TicketEntry; +import dev.chunkinspector.report.InspectionReport.TypeBreakdown; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Asserts on two real NeoForge dedicated servers that {@code e2e/run-servers.sh} has already run to + * completion: one with the default configuration, one with {@code -Dchunkinspector.deep=true}. Both + * were driven through the same session — force-load a 4x4 square of chunks, ask the mod about it, + * then shut down. + */ +class ChunkInspectorE2ETest { + + /** Matches the forceload issued by the script: chunks (100, 100) through (103, 103). */ + private static final int FORCED_FROM = 100; + private static final int FORCED_TO = 103; + private static final int FORCED_CHUNKS = 16; + + private static final Pattern BOUNDS = Pattern.compile("(-?\\d+), (-?\\d+) \\.\\. (-?\\d+), (-?\\d+)"); + + private static final ServerRun LIGHT = ServerRun.of("light"); + private static final ServerRun DEEP = ServerRun.of("deep"); + + static List bothModes() { + return List.of(LIGHT, DEEP); + } + + private static TypeBreakdown byType(LevelReport level, String type) { + return level.byType().stream() + .filter(t -> t.type().equals(type)) + .findFirst() + .orElseThrow(() -> new AssertionError("no " + type + " tickets in " + level.byType())); + } + + // ------------------------------------------------------- the reports exist and are well formed + + @ParameterizedTest + @MethodSource("bothModes") + void reportsLandInTheLevelDirectoryUnderChunkSummaries(ServerRun run) { + assertThat(run.summaries()).isDirectory(); + assertThat(run.summaries().getParent().getFileName()).hasToString("world"); + + InspectionReport latest = run.latest(); + assertThat(latest.schema()).isEqualTo(InspectionReport.SCHEMA); + assertThat(latest.generatedAt()).isNotBlank(); + assertThat(latest.levels()).extracting(LevelReport::dimension) + .contains("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"); + assertThat(run.digest()).contains("Chunk Inspector", "minecraft:overworld"); + } + + @ParameterizedTest + @MethodSource("bothModes") + void everyTriggerProducesItsOwnSnapshotAndTheLastOneIsTheShutdown(ServerRun run) { + assertThat(run.snapshots()).extracting(InspectionReport::trigger) + .contains("periodic", "command", "shutdown"); + // latest.json must mirror the newest snapshot, which is always the one taken on shutdown. + assertThat(run.latest().trigger()).isEqualTo("shutdown"); + } + + @ParameterizedTest + @MethodSource("bothModes") + void oldSnapshotsArePrunedToTheConfiguredRetention(ServerRun run) { + assertThat(run.snapshotFiles()).hasSizeLessThanOrEqualTo(5); // -Dchunkinspector.retention=5 + assertThat(run.snapshotFiles()).isNotEmpty(); + } + + @ParameterizedTest + @MethodSource("bothModes") + void theServerStartedCleanlyWithNoMixinOrModErrors(ServerRun run) { + String console = run.console(); + assertThat(console).contains("Chunk Inspector active"); + assertThat(console).doesNotContain( + "Mixin apply failed", + "Mixin prepare failed", + "org.spongepowered.asm.mixin.throwables", + "Failed to create mod instance", + "Chunk summary failed"); + } + + // -------------------------------------------------------------- the leak is actually diagnosed + + @ParameterizedTest + @MethodSource("bothModes") + void theForceloadedChunksAreCountedAsPermanentTickets(ServerRun run) { + LevelReport overworld = ServerRun.level(run.latest(), "minecraft:overworld"); + + assertThat(overworld.totals().forcedChunks()).isEqualTo(FORCED_CHUNKS); + assertThat(overworld.totals().permanentTickets()).isGreaterThanOrEqualTo(FORCED_CHUNKS); + assertThat(overworld.totals().loadedChunks()).isGreaterThanOrEqualTo(FORCED_CHUNKS); + + TypeBreakdown forced = byType(overworld, "forced"); + assertThat(forced.tickets()).isEqualTo(FORCED_CHUNKS); + assertThat(forced.chunks()).isEqualTo(FORCED_CHUNKS); + assertThat(forced.expiring()).as("a forceload never expires on its own").isFalse(); + } + + @ParameterizedTest + @MethodSource("bothModes") + void everyForcedTicketIsFlaggedAsASuspectAndNamesItsChunk(ServerRun run) { + List forced = ServerRun.level(run.latest(), "minecraft:overworld").suspects().stream() + .filter(s -> s.type().equals("forced")) + .toList(); + + assertThat(forced).hasSize(FORCED_CHUNKS); + assertThat(forced).allSatisfy(entry -> { + assertThat(entry.reason()).contains("permanent ticket"); + assertThat(entry.owner()).matches("chunk 10[0-3], 10[0-3]"); + assertThat(entry.chunk()).matches("10[0-3], 10[0-3]"); + assertThat(entry.blockPos()).isNotBlank(); + }); + assertThat(run.digest()).contains("most suspicious tickets", "forced"); + } + + @ParameterizedTest + @MethodSource("bothModes") + void theSpawnChunksAreRecognisedAsNormalAndNeverFlagged(ServerRun run) { + LevelReport overworld = ServerRun.level(run.latest(), "minecraft:overworld"); + + assertThat(byType(overworld, "start").tickets()).isPositive(); + assertThat(overworld.suspects()).extracting(TicketEntry::type).doesNotContain("start", "player"); + } + + @ParameterizedTest + @MethodSource("bothModes") + void theForceloadedSquareShowsUpAsItsOwnClusterOfLoadedChunks(ServerRun run) { + List hotspots = ServerRun.level(run.latest(), "minecraft:overworld").hotspots(); + + assertThat(hotspots).isNotEmpty(); + assertThat(hotspots).anySatisfy(hotspot -> { + Matcher bounds = BOUNDS.matcher(hotspot.bounds()); + assertThat(bounds.matches()).as("parsable bounds in %s", hotspot).isTrue(); + assertThat(Integer.parseInt(bounds.group(1))).isLessThanOrEqualTo(FORCED_FROM); + assertThat(Integer.parseInt(bounds.group(2))).isLessThanOrEqualTo(FORCED_FROM); + assertThat(Integer.parseInt(bounds.group(3))).isGreaterThanOrEqualTo(FORCED_TO); + assertThat(Integer.parseInt(bounds.group(4))).isGreaterThanOrEqualTo(FORCED_TO); + assertThat(hotspot.chunks()).isGreaterThanOrEqualTo(FORCED_CHUNKS); + assertThat(hotspot.causes()).anyMatch(cause -> cause.startsWith("forced")); + }); + } + + @ParameterizedTest + @MethodSource("bothModes") + void theShutdownSnapshotAttributesEveryLoadedChunkToATicket(ServerRun run) { + LevelReport overworld = ServerRun.level(run.latest(), "minecraft:overworld"); + + assertThat(overworld.attributionRan()).isTrue(); + assertThat(overworld.unattributed()) + .as("every loaded chunk should be explained by some ticket") + .isEmpty(); + assertThat(byType(overworld, "forced").influence()) + .as("the forceload keeps at least its own chunks loaded") + .isGreaterThanOrEqualTo(FORCED_CHUNKS); + } + + // ------------------------------------------------------------------------------ the commands + + @ParameterizedTest + @MethodSource("bothModes") + void theCommandsAnswerOnTheConsole(ServerRun run) { + String console = run.console(); + + assertThat(console).contains("minecraft:overworld: "); // /chunkinspector status + assertThat(console).contains("Chunk 102, 102 is at level"); // /chunkinspector why + assertThat(console).contains("forced from chunk 10"); // ... naming the forceload + assertThat(console).containsPattern("Wrote .*chunk-summaries.summary-.*\\.json"); // ... snapshot + } + + // ------------------------------------------------------------------------- the two cost tiers + + @Test + void withoutDeepModeMinecraftIsNeverInstrumentedAndNoCallSiteIsNamed() { + assertThat(LIGHT.console()).contains("deep mode off, leaving untouched Minecraft's classes"); + assertThat(LIGHT.latest().settings().deep()).isFalse(); + + assertThat(LIGHT.latest().levels()).allSatisfy(level -> { + assertThat(level.origins()).isEmpty(); + assertThat(level.suspects()).allSatisfy(suspect -> assertThat(suspect.origin()).isNull()); + }); + // The overworld genuinely has suspects, so the check above is not vacuous everywhere. + assertThat(ServerRun.level(LIGHT.latest(), "minecraft:overworld").suspects()).isNotEmpty(); + assertThat(LIGHT.digest()).contains("-Dchunkinspector.deep=true"); + } + + @Test + void deepModeNamesTheCodeThatCreatedEachTicket() { + assertThat(DEEP.console()).contains("deep mode on, instrumenting Minecraft's classes"); + assertThat(DEEP.latest().settings().deep()).isTrue(); + + LevelReport overworld = ServerRun.level(DEEP.latest(), "minecraft:overworld"); + assertThat(overworld.origins()).isNotEmpty(); + assertThat(overworld.origins()).allSatisfy(origin -> { + assertThat(origin.id()).matches("[0-9a-f]{12}"); + assertThat(origin.adds()).isPositive(); + assertThat(origin.live()).isPositive(); + assertThat(origin.types()).isNotEmpty(); + assertThat(origin.stack()).isNotEmpty(); + assertThat(origin.stack()).noneMatch(frame -> frame.startsWith("dev.chunkinspector.")); + }); + + // The forceload was issued by the command, so its origin must lead back to ServerLevel. + OriginEntry forcedOrigin = overworld.origins().stream() + .filter(o -> o.types().contains("forced")) + .findFirst() + .orElseThrow(() -> new AssertionError("no call site recorded for forced tickets")); + assertThat(forcedOrigin.stack().getFirst()).startsWith("net.minecraft.server.level.ServerLevel.setChunkForced"); + assertThat(forcedOrigin.live()).isEqualTo(FORCED_CHUNKS); + assertThat(forcedOrigin.mod()).isNotBlank(); + + // ... and every flagged forced ticket must cross-reference it. + assertThat(overworld.suspects()).filteredOn(s -> s.type().equals("forced")) + .extracting(TicketEntry::origin) + .containsOnly(forcedOrigin.id()); + + assertThat(DEEP.digest()).contains("ticket call sites", "net.minecraft.server.level.ServerLevel.setChunkForced"); + } + + @Test + void deepModeDoesNotChangeWhatTheLightModeAlreadyFoundCorrectly() { + LevelReport light = ServerRun.level(LIGHT.latest(), "minecraft:overworld"); + LevelReport deep = ServerRun.level(DEEP.latest(), "minecraft:overworld"); + + // Two independent servers ran the same script, so the ticket picture must match. That is the + // evidence that the cheap tier loses nothing but the call stacks. + assertThat(deep.totals().forcedChunks()).isEqualTo(light.totals().forcedChunks()); + assertThat(byType(deep, "forced").chunks()).isEqualTo(byType(light, "forced").chunks()); + assertThat(byType(deep, "start").tickets()).isEqualTo(byType(light, "start").tickets()); + assertThat(deep.suspects()).hasSameSizeAs(light.suspects()); + } +} diff --git a/src/e2e/java/dev/chunkinspector/e2e/ServerRun.java b/src/e2e/java/dev/chunkinspector/e2e/ServerRun.java new file mode 100644 index 0000000..55746a1 --- /dev/null +++ b/src/e2e/java/dev/chunkinspector/e2e/ServerRun.java @@ -0,0 +1,87 @@ +package dev.chunkinspector.e2e; + +import com.google.gson.Gson; +import dev.chunkinspector.report.InspectionReport; +import dev.chunkinspector.report.InspectionReport.LevelReport; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +/** + * One finished server session, as left on disk by {@code e2e/run-servers.sh}. + * + *

Reading the reports back through the production {@link InspectionReport} records rather than + * through raw JSON means the test also proves the file a server writes is a file this mod can read. + */ +record ServerRun(String mode, Path directory) { + + private static final Gson GSON = new Gson(); + + static ServerRun of(String mode) { + Path root = Path.of(System.getProperty("e2e.root", "build/e2e")); + return new ServerRun(mode, root.resolve(mode)); + } + + Path summaries() { + return directory.resolve("world").resolve("chunk-summaries"); + } + + InspectionReport latest() { + return read(summaries().resolve("latest.json")); + } + + String digest() { + return text(summaries().resolve("latest.txt")); + } + + String console() { + return text(directory.resolve("console.log")); + } + + List snapshotFiles() { + try (Stream files = Files.list(summaries())) { + return files.filter(p -> p.getFileName().toString().startsWith("summary-")).sorted().toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + List snapshots() { + return snapshotFiles().stream().map(ServerRun::read).toList(); + } + + /** The overworld report, which is where the scripted session does its damage. */ + LevelReport overworld() { + return level(latest(), "minecraft:overworld"); + } + + static LevelReport level(InspectionReport report, String dimension) { + return report.levels().stream() + .filter(l -> l.dimension().equals(dimension)) + .findFirst() + .orElseThrow(() -> new AssertionError("no report for " + dimension + " in " + report.levels().stream() + .map(LevelReport::dimension) + .toList())); + } + + private static InspectionReport read(Path file) { + return GSON.fromJson(text(file), InspectionReport.class); + } + + private static String text(Path file) { + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("expected " + file.toAbsolutePath() + " to exist", e); + } + } + + @Override + public String toString() { + return mode; + } +} diff --git a/src/main/java/dev/chunkinspector/ChunkInspector.java b/src/main/java/dev/chunkinspector/ChunkInspector.java new file mode 100644 index 0000000..8374217 --- /dev/null +++ b/src/main/java/dev/chunkinspector/ChunkInspector.java @@ -0,0 +1,68 @@ +package dev.chunkinspector; + +import dev.chunkinspector.origin.OriginRegistry; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.event.server.ServerStartedEvent; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; + +/** + * Entry point. + * + *

Two tiers of instrumentation: + *

    + *
  • Always on. Nothing is injected into the game. An access transformer widens a handful + * of ticket fields, and a snapshot walks them on demand. Many tickets already name their + * owner — a NeoForge forced chunk carries the requesting mod's id in its key — so this alone + * often identifies the culprit.
  • + *
  • {@code -Dchunkinspector.deep=true}. Adds the stack trace behind every ticket, for the + * cases where the key is opaque. Only then are the instrumenting Mixins applied at all.
  • + *
+ */ +@Mod(ChunkInspector.MOD_ID) +public final class ChunkInspector { + + public static final String MOD_ID = "chunkinspector"; + + private static final OriginRegistry ORIGINS = new OriginRegistry(); + private static final InspectorService SERVICE = new InspectorService(ORIGINS); + + public ChunkInspector(IEventBus modBus) { + if (InspectorConfig.enabled()) { + NeoForge.EVENT_BUS.register(this); + } + } + + /** Used by the deep-mode Mixin; static because a Mixin has no other way to reach the mod. */ + public static OriginRegistry origins() { + return ORIGINS; + } + + public static InspectorService service() { + return SERVICE; + } + + @SubscribeEvent + void onServerStarted(ServerStartedEvent event) { + SERVICE.serverStarted(event.getServer()); + } + + @SubscribeEvent + void onServerTick(ServerTickEvent.Post event) { + SERVICE.serverTick(event.getServer()); + } + + @SubscribeEvent + void onServerStopping(ServerStoppingEvent event) { + SERVICE.serverStopping(event.getServer()); + } + + @SubscribeEvent + void onRegisterCommands(RegisterCommandsEvent event) { + InspectorCommand.register(event.getDispatcher()); + } +} diff --git a/src/main/java/dev/chunkinspector/InspectorCommand.java b/src/main/java/dev/chunkinspector/InspectorCommand.java new file mode 100644 index 0000000..6e71968 --- /dev/null +++ b/src/main/java/dev/chunkinspector/InspectorCommand.java @@ -0,0 +1,135 @@ +package dev.chunkinspector; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.context.CommandContext; +import dev.chunkinspector.analysis.ChunkExplainer; +import dev.chunkinspector.analysis.ChunkKey; +import dev.chunkinspector.analysis.LevelAnalysis; +import dev.chunkinspector.game.LevelCapture; +import dev.chunkinspector.report.InspectionReport.LevelReport; +import java.util.List; +import lombok.experimental.UtilityClass; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.SectionPos; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; + +/** + * {@code /chunkinspector} — operator tooling for the two questions that come up in practice: + * "what is loading everything?" and "why is this particular chunk loaded?". + */ +@UtilityClass +public class InspectorCommand { + + private final int PERMISSION_LEVEL = 2; + private final int SHOWN_TYPES = 6; + private final int SHOWN_SUSPECTS = 5; + + public void register(CommandDispatcher dispatcher) { + dispatcher.register(Commands.literal(ChunkInspector.MOD_ID) + .requires(source -> source.hasPermission(PERMISSION_LEVEL)) + .then(Commands.literal("snapshot") + .executes(context -> snapshot(context, InspectorConfig.attributionInSnapshots())) + .then(Commands.literal("full").executes(context -> snapshot(context, true)))) + .then(Commands.literal("status").executes(InspectorCommand::status)) + .then(Commands.literal("why") + .executes(context -> why(context, currentChunk(context))) + .then(Commands.argument("chunkX", IntegerArgumentType.integer()) + .then(Commands.argument("chunkZ", IntegerArgumentType.integer()) + .executes(context -> why(context, ChunkKey.of( + IntegerArgumentType.getInteger(context, "chunkX"), + IntegerArgumentType.getInteger(context, "chunkZ")))))))); + } + + private int snapshot(CommandContext context, boolean attribution) { + CommandSourceStack source = context.getSource(); + source.sendSuccess(() -> literal("Capturing chunk summary" + (attribution ? " with full attribution" : "") + "…"), true); + ChunkInspector.service() + .snapshot(source.getServer(), "command", attribution) + .whenComplete((path, error) -> source.getServer().execute(() -> { + if (error != null) { + source.sendFailure(literal("Chunk summary failed: " + error.getMessage())); + } else { + source.sendSuccess(() -> literal("Wrote " + path), true); + } + })); + return 1; + } + + private int status(CommandContext context) { + CommandSourceStack source = context.getSource(); + LevelCapture capture = LevelCapture.of(source.getLevel()); + LevelReport report = LevelAnalysis.analyse( + capture.dimension(), + capture.gameTime(), + capture.tickets(), + capture.chunks(), + capture.maxLevel(), + false, + ChunkInspector.origins().snapshot()); + + var totals = report.totals(); + send(source, ChatFormatting.AQUA, capture.dimension() + ": " + totals.loadedChunks() + " loaded, " + + totals.entityTicking() + " entity-ticking, " + totals.tickets() + " tickets (" + + totals.permanentTickets() + " permanent)"); + report.byType().stream().limit(SHOWN_TYPES).forEach(type -> + send(source, ChatFormatting.GRAY, " " + type.type() + ": " + type.tickets() + " tickets on " + + type.chunks() + " chunks, level>=" + type.minLevel() + + (type.expiring() ? " (expires)" : ""))); + List suspects = report.suspects(); + if (suspects.isEmpty()) { + send(source, ChatFormatting.GREEN, " no permanent non-vanilla tickets"); + } else { + report.suspects().stream().limit(SHOWN_SUSPECTS).forEach(suspect -> + send(source, ChatFormatting.YELLOW, " " + suspect.type() + " @ " + suspect.chunk() + + " — " + suspect.owner() + " — " + suspect.reason())); + } + send(source, ChatFormatting.DARK_GRAY, "Run /" + ChunkInspector.MOD_ID + " snapshot full for the complete report."); + return 1; + } + + private int why(CommandContext context, long chunk) { + CommandSourceStack source = context.getSource(); + LevelCapture capture = LevelCapture.of(source.getLevel()); + ChunkExplainer.Explanation explanation = ChunkExplainer.explain(capture.tickets(), chunk, capture.maxLevel()); + + if (explanation.contributors().isEmpty()) { + send(source, ChatFormatting.GREEN, "Chunk " + ChunkKey.format(chunk) + " is not held by any ticket."); + return 0; + } + send(source, ChatFormatting.AQUA, "Chunk " + ChunkKey.format(chunk) + " is at level " + explanation.level() + ":"); + explanation.contributors().forEach(contribution -> { + var ticket = contribution.ticket(); + send(source, ChatFormatting.GRAY, " %s from %s at chunk %s (distance %d, level %d) — %s%s".formatted( + contribution.contributed() == explanation.level() ? "▶ " + ticket.type() : " " + ticket.type(), + ticket.owner(), + ChunkKey.format(ticket.chunk()), + contribution.distance(), + contribution.contributed(), + ticket.expiring() ? "expires" : "permanent", + ticket.originId() == null ? "" : ", origin " + ticket.originId())); + }); + if (!InspectorConfig.deep()) { + send(source, ChatFormatting.DARK_GRAY, "Restart with -Dchunkinspector.deep=true to see which code added these."); + } + return explanation.contributors().size(); + } + + private long currentChunk(CommandContext context) { + var position = context.getSource().getPosition(); + return ChunkKey.of( + SectionPos.blockToSectionCoord(position.x), + SectionPos.blockToSectionCoord(position.z)); + } + + private void send(CommandSourceStack source, ChatFormatting colour, String text) { + source.sendSuccess(() -> literal(text).withStyle(colour), false); + } + + private MutableComponent literal(String text) { + return Component.literal(text); + } +} diff --git a/src/main/java/dev/chunkinspector/InspectorConfig.java b/src/main/java/dev/chunkinspector/InspectorConfig.java new file mode 100644 index 0000000..d624ba7 --- /dev/null +++ b/src/main/java/dev/chunkinspector/InspectorConfig.java @@ -0,0 +1,89 @@ +package dev.chunkinspector; + +import lombok.experimental.UtilityClass; + +/** + * All tuning is done through system properties so that a server administrator can change behaviour + * without a config file round-trip and, more importantly, so that the values are known before the + * Mixin subsystem decides which transformations to apply. + * + *

The expensive instrumentation ({@link #deep()}) is opt-in: when it is off the corresponding + * Mixins are never applied to Minecraft's classes at all, so the cost is exactly zero. + */ +@UtilityClass +public class InspectorConfig { + + public static final String PREFIX = "chunkinspector."; + + /** Master switch. When false the mod loads but installs no hooks and writes no reports. */ + public boolean enabled() { + return flag("enabled", true); + } + + /** + * Enables the expensive instrumentation: every ticket addition is attributed to the code that + * requested it by walking the call stack. Costs a stack walk per ticket add. + */ + public boolean deep() { + return enabled() && flag("deep", false); + } + + /** Ticks between automatic snapshots. {@code 0} disables periodic snapshots. */ + public int intervalTicks() { + return positiveOrZero("interval", 600); + } + + /** + * Whether periodic snapshots also run the chunk-to-ticket attribution pass, which is + * {@code O(sum of ticket influence areas)}. Always available on demand via the command. + */ + public boolean attributionInSnapshots() { + return flag("attribution", false); + } + + /** How many timestamped snapshot files to keep per level before the oldest are deleted. */ + public int retention() { + return positiveOrZero("retention", 48); + } + + /** Maximum number of stack frames retained per captured origin. */ + public int stackDepth() { + return Math.clamp(intProperty("stackDepth", 24), 4, 256); + } + + /** Upper bound on distinct call sites remembered, so a pathological server cannot OOM. */ + public int maxOrigins() { + return Math.clamp(intProperty("maxOrigins", 8192), 64, 1 << 20); + } + + /** + * Only capture a stack for one in {@code N} ticket additions. {@code 1} (the default) captures + * everything; raise it on servers where ticket churn is itself the bottleneck. + */ + public int sampleRate() { + return Math.max(1, intProperty("sampleRate", 1)); + } + + /** Emit a plain-text digest next to the JSON report. */ + public boolean textReport() { + return flag("textReport", true); + } + + public boolean flag(String key, boolean fallback) { + String raw = System.getProperty(PREFIX + key); + return raw == null || raw.isBlank() ? fallback : Boolean.parseBoolean(raw); + } + + private int intProperty(String key, int fallback) { + try { + String raw = System.getProperty(PREFIX + key); + return raw == null || raw.isBlank() ? fallback : Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private int positiveOrZero(String key, int fallback) { + return Math.max(0, intProperty(key, fallback)); + } +} diff --git a/src/main/java/dev/chunkinspector/InspectorService.java b/src/main/java/dev/chunkinspector/InspectorService.java new file mode 100644 index 0000000..43cb639 --- /dev/null +++ b/src/main/java/dev/chunkinspector/InspectorService.java @@ -0,0 +1,147 @@ +package dev.chunkinspector; + +import dev.chunkinspector.analysis.LevelAnalysis; +import dev.chunkinspector.game.LevelCapture; +import dev.chunkinspector.origin.Origin; +import dev.chunkinspector.origin.OriginRegistry; +import dev.chunkinspector.report.InspectionReport; +import dev.chunkinspector.report.ReportWriter; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.storage.LevelResource; + +/** + * Owns the snapshot lifecycle: capture on the server thread, analyse and write off it. + * + *

Splitting the work this way is what lets the periodic snapshot run on a busy server without + * being felt — the main thread only copies two collections, and the propagation pass and the JSON + * serialisation happen on a background thread against immutable data. + */ +@Slf4j +public final class InspectorService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").withZone(ZoneOffset.UTC); + + private final OriginRegistry origins; + private final int intervalTicks = InspectorConfig.intervalTicks(); + + private ReportWriter writer; + private ExecutorService reporter; + private int ticksSinceSnapshot; + + public InspectorService(OriginRegistry origins) { + this.origins = origins; + } + + public void serverStarted(MinecraftServer server) { + Path levelDirectory = server.getWorldPath(LevelResource.ROOT).normalize(); + writer = new ReportWriter(levelDirectory, InspectorConfig.retention()); + reporter = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "chunkinspector-report"); + thread.setDaemon(true); + return thread; + }); + log.info("Chunk Inspector active: reports in {}, deep={}, interval={} ticks", + writer.directory(), InspectorConfig.deep(), intervalTicks); + } + + public void serverTick(MinecraftServer server) { + if (intervalTicks <= 0 || writer == null) { + return; + } + if (++ticksSinceSnapshot >= intervalTicks) { + ticksSinceSnapshot = 0; + snapshot(server, "periodic", InspectorConfig.attributionInSnapshots()); + } + } + + public void serverStopping(MinecraftServer server) { + if (writer == null) { + return; + } + // A shutdown snapshot is the one people come back to, so it is worth the full analysis and + // worth blocking briefly for. + CompletableFuture pending = snapshot(server, "shutdown", true); + reporter.shutdown(); + try { + pending.get(30, TimeUnit.SECONDS); + reporter.awaitTermination(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.warn("Shutdown chunk summary did not complete", e); + } + writer = null; + } + + /** Captures on the calling (server) thread and completes once the report has been written. */ + public CompletableFuture snapshot(MinecraftServer server, String trigger, boolean attribution) { + if (writer == null) { + return CompletableFuture.failedFuture(new IllegalStateException("no level directory yet")); + } + List captures = capture(server); + Collection originSnapshot = origins.snapshot(); + Instant now = Instant.now(); + ReportWriter target = writer; + + return CompletableFuture.supplyAsync(() -> { + try { + InspectionReport report = assemble(captures, originSnapshot, trigger, attribution, now); + return target.write(report, STAMP.format(now), InspectorConfig.textReport()); + } catch (Exception e) { + throw new IllegalStateException("failed to write chunk summary", e); + } + }, reporter).whenComplete((path, error) -> { + if (error != null) { + log.error("Chunk summary failed", error); + } else { + log.info("Wrote chunk summary {}", path); + } + }); + } + + /** Must run on the server thread. */ + public List capture(MinecraftServer server) { + var captures = new ArrayList(); + for (ServerLevel level : server.getAllLevels()) { + captures.add(LevelCapture.of(level)); + } + return List.copyOf(captures); + } + + static InspectionReport assemble( + List captures, Collection origins, String trigger, boolean attribution, Instant now) { + return new InspectionReport( + InspectionReport.SCHEMA, + now.toString(), + trigger, + new InspectionReport.Settings( + InspectorConfig.deep(), + attribution, + InspectorConfig.intervalTicks(), + InspectorConfig.sampleRate()), + captures.stream() + .map(capture -> LevelAnalysis.analyse( + capture.dimension(), + capture.gameTime(), + capture.tickets(), + capture.chunks(), + capture.maxLevel(), + attribution, + origins)) + .toList()); + } +} diff --git a/src/main/java/dev/chunkinspector/analysis/ChunkExplainer.java b/src/main/java/dev/chunkinspector/analysis/ChunkExplainer.java new file mode 100644 index 0000000..c0ba5a1 --- /dev/null +++ b/src/main/java/dev/chunkinspector/analysis/ChunkExplainer.java @@ -0,0 +1,51 @@ +package dev.chunkinspector.analysis; + +import java.util.Comparator; +import java.util.List; +import lombok.experimental.UtilityClass; + +/** + * Answers the one question an administrator actually asks: "why is this chunk loaded?". + * + *

A single chunk needs no propagation pass — a ticket at distance {@code d} contributes level + * {@code ticketLevel + d} — so this is a linear scan over the tickets and cheap enough to run from a + * command at any time, even on a server with deep mode off. + */ +@UtilityClass +public class ChunkExplainer { + + private final int MAX_CONTRIBUTORS = 10; + + /** + * @param ticket the contributing ticket + * @param distance Chebyshev distance from the ticket to the chunk in question + * @param contributed the level this ticket imposes on that chunk + */ + public record Contribution(TicketRecord ticket, int distance, int contributed) {} + + /** + * @param chunk the chunk that was asked about + * @param level its resulting level, or {@code Integer.MAX_VALUE} if nothing reaches it + * @param contributors every ticket that reaches it, strongest first + */ + public record Explanation(long chunk, int level, List contributors) { + + public boolean loaded(int maxLevel) { + return level <= maxLevel; + } + } + + public Explanation explain(List tickets, long chunk, int maxLevel) { + List contributors = tickets.stream() + .map(ticket -> { + int distance = ChunkKey.distance(ticket.chunk(), chunk); + return new Contribution(ticket, distance, Math.max(0, ticket.level()) + distance); + }) + .filter(c -> c.contributed() <= maxLevel) + .sorted(Comparator.comparingInt(Contribution::contributed)) + .limit(MAX_CONTRIBUTORS) + .toList(); + int level = contributors.isEmpty() ? Integer.MAX_VALUE : contributors.getFirst().contributed(); + return new Explanation(chunk, level, contributors); + } +} diff --git a/src/main/java/dev/chunkinspector/analysis/ChunkKey.java b/src/main/java/dev/chunkinspector/analysis/ChunkKey.java new file mode 100644 index 0000000..13a3814 --- /dev/null +++ b/src/main/java/dev/chunkinspector/analysis/ChunkKey.java @@ -0,0 +1,38 @@ +package dev.chunkinspector.analysis; + +import lombok.experimental.UtilityClass; + +/** + * Packing identical to {@code net.minecraft.world.level.ChunkPos}, restated here so that the + * analysis layer stays free of Minecraft types and can be unit tested on its own. + */ +@UtilityClass +public class ChunkKey { + + public long of(int x, int z) { + return (long) x & 0xFFFF_FFFFL | ((long) z & 0xFFFF_FFFFL) << 32; + } + + public int x(long key) { + return (int) (key & 0xFFFF_FFFFL); + } + + public int z(long key) { + return (int) (key >>> 32 & 0xFFFF_FFFFL); + } + + /** Chebyshev distance, which is how chunk ticket levels propagate. */ + public int distance(long a, long b) { + return Math.max(Math.abs(x(a) - x(b)), Math.abs(z(a) - z(b))); + } + + /** {@code "12, -34"} — the form players type into {@code /tp}-adjacent commands. */ + public String format(long key) { + return x(key) + ", " + z(key); + } + + /** Block coordinates of the chunk centre, at a height that is safe to teleport to. */ + public String formatBlock(long key) { + return (x(key) << 4) + 8 + " ~ " + ((z(key) << 4) + 8); + } +} diff --git a/src/main/java/dev/chunkinspector/analysis/ChunkRecord.java b/src/main/java/dev/chunkinspector/analysis/ChunkRecord.java new file mode 100644 index 0000000..5b9dcfb --- /dev/null +++ b/src/main/java/dev/chunkinspector/analysis/ChunkRecord.java @@ -0,0 +1,23 @@ +package dev.chunkinspector.analysis; + +/** + * One chunk the server is currently holding. + * + * @param chunk packed chunk position + * @param level the chunk's effective ticket level + * @param fullStatus {@code INACCESSIBLE}, {@code FULL}, {@code BLOCK_TICKING} or {@code ENTITY_TICKING} + */ +public record ChunkRecord(long chunk, int level, String fullStatus) { + + public boolean loaded(int maxLevel) { + return level <= maxLevel; + } + + public boolean entityTicking() { + return "ENTITY_TICKING".equals(fullStatus); + } + + public boolean blockTicking() { + return entityTicking() || "BLOCK_TICKING".equals(fullStatus); + } +} diff --git a/src/main/java/dev/chunkinspector/analysis/LevelAnalysis.java b/src/main/java/dev/chunkinspector/analysis/LevelAnalysis.java new file mode 100644 index 0000000..a92dd96 --- /dev/null +++ b/src/main/java/dev/chunkinspector/analysis/LevelAnalysis.java @@ -0,0 +1,377 @@ +package dev.chunkinspector.analysis; + +import dev.chunkinspector.origin.Origin; +import dev.chunkinspector.report.InspectionReport.Hotspot; +import dev.chunkinspector.report.InspectionReport.LevelReport; +import dev.chunkinspector.report.InspectionReport.OriginEntry; +import dev.chunkinspector.report.InspectionReport.TicketEntry; +import dev.chunkinspector.report.InspectionReport.Totals; +import dev.chunkinspector.report.InspectionReport.TypeBreakdown; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.longs.LongIterator; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.SequencedMap; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * Turns a flat capture of one dimension's tickets and chunks into the report a human reads. + * + *

Deliberately free of Minecraft types: everything it needs arrives as {@link TicketRecord} and + * {@link ChunkRecord}, which keeps the interesting logic — attribution, clustering, suspicion + * ranking — directly unit testable. + */ +public final class LevelAnalysis { + + /** Ticket types that are part of normal operation and never worth flagging. */ + private static final List BENIGN_TYPES = List.of("player", "start", "unknown", "post_teleport", "portal"); + + private static final int MAX_SUSPECTS = 25; + private static final int MAX_HOTSPOTS = 15; + private static final int MAX_OWNERS_PER_TYPE = 8; + private static final int MAX_CAUSES_PER_HOTSPOT = 5; + private static final int MAX_UNATTRIBUTED = 64; + private static final int MIN_HOTSPOT_CHUNKS = 4; + private static final int NO_INFLUENCE = -1; + + private final String dimension; + private final long gameTime; + private final List tickets; + private final List chunks; + private final int maxLevel; + private final Collection origins; + + private LevelAnalysis( + String dimension, long gameTime, List tickets, List chunks, int maxLevel, Collection origins) { + this.dimension = dimension; + this.gameTime = gameTime; + this.tickets = tickets; + this.chunks = chunks; + this.maxLevel = maxLevel; + this.origins = origins; + } + + /** + * @param maxLevel {@code ChunkLevel.MAX_LEVEL}; chunks above this are not loaded + * @param attribution run the propagation pass that maps every loaded chunk to a ticket + * @param origins deep-mode call sites, empty when deep mode is off + */ + public static LevelReport analyse( + String dimension, + long gameTime, + List tickets, + List chunks, + int maxLevel, + boolean attribution, + Collection origins) { + return new LevelAnalysis(dimension, gameTime, tickets, chunks, maxLevel, origins).build(attribution); + } + + private LevelReport build(boolean attribution) { + Attribution result = attribution ? attribute() : Attribution.none(tickets.size()); + return new LevelReport( + dimension, + gameTime, + totals(), + breakdowns(result), + suspects(result), + hotspots(result), + originEntries(), + result.unattributed(), + attribution); + } + + // ------------------------------------------------------------------ totals + + private Totals totals() { + var ticketedChunks = new LongOpenHashSet(); + int permanent = 0; + int forced = 0; + for (TicketRecord ticket : tickets) { + ticketedChunks.add(ticket.chunk()); + if (!ticket.expiring()) { + permanent++; + } + if ("forced".equals(ticket.type())) { + forced++; + } + } + int loaded = 0; + int blockTicking = 0; + int entityTicking = 0; + for (ChunkRecord chunk : chunks) { + if (!"INACCESSIBLE".equals(chunk.fullStatus())) { + loaded++; + } + if (chunk.blockTicking()) { + blockTicking++; + } + if (chunk.entityTicking()) { + entityTicking++; + } + } + return new Totals(chunks.size(), loaded, blockTicking, entityTicking, tickets.size(), ticketedChunks.size(), permanent, forced); + } + + // ------------------------------------------------------------- attribution + + /** + * @param influencePerTicket loaded chunks won by each ticket, indexed like {@link #tickets} + * @param winnerPerChunk loaded chunk -> ticket index, only populated when attribution ran + * @param unattributed loaded chunks no ticket explains, formatted for the report + * @param ran whether the propagation pass was performed at all + */ + private record Attribution(int[] influencePerTicket, LevelPropagation.Result winnerPerChunk, List unattributed, boolean ran) { + + static Attribution none(int ticketCount) { + return new Attribution(new int[ticketCount], null, List.of(), false); + } + + int influenceOf(int ticketIndex) { + return ran ? influencePerTicket[ticketIndex] : NO_INFLUENCE; + } + } + + private Attribution attribute() { + List sources = tickets.stream() + .map(t -> new LevelPropagation.Source(t.chunk(), t.level())) + .toList(); + LevelPropagation.Result propagated = LevelPropagation.solve(sources, maxLevel); + + int[] influence = new int[tickets.size()]; + var unattributed = new ArrayList(); + for (ChunkRecord chunk : chunks) { + if ("INACCESSIBLE".equals(chunk.fullStatus())) { + continue; + } + int winner = propagated.winnerOf(chunk.chunk()); + if (winner == LevelPropagation.Result.NONE) { + if (unattributed.size() < MAX_UNATTRIBUTED) { + unattributed.add(ChunkKey.format(chunk.chunk())); + } + continue; + } + influence[winner]++; + } + return new Attribution(influence, propagated, List.copyOf(unattributed), true); + } + + // ------------------------------------------------------------- by type + + private List breakdowns(Attribution attribution) { + SequencedMap byType = new LinkedHashMap<>(); + for (int i = 0; i < tickets.size(); i++) { + TicketRecord ticket = tickets.get(i); + byType.computeIfAbsent(ticket.type(), TypeAccumulator::new).accept(ticket, attribution.influenceOf(i)); + } + return byType.values().stream() + .map(TypeAccumulator::toBreakdown) + .sorted(Comparator.comparingInt(TypeBreakdown::influence).reversed() + .thenComparing(Comparator.comparingInt(TypeBreakdown::chunks).reversed())) + .toList(); + } + + /** Mutable fold state for one ticket type; kept private so the report stays immutable. */ + private static final class TypeAccumulator { + private final String type; + private final LongOpenHashSet chunks = new LongOpenHashSet(); + private final SequencedMap owners = new LinkedHashMap<>(); + private int tickets; + private int minLevel = Integer.MAX_VALUE; + private int influence = NO_INFLUENCE; + private long oldest; + /** Only claim a type expires on its own when every one of its tickets does. */ + private boolean expiring = true; + + TypeAccumulator(String type) { + this.type = type; + } + + void accept(TicketRecord ticket, int ticketInfluence) { + tickets++; + chunks.add(ticket.chunk()); + minLevel = Math.min(minLevel, ticket.level()); + oldest = Math.max(oldest, ticket.ageTicks()); + expiring &= ticket.expiring(); + if (owners.size() < MAX_OWNERS_PER_TYPE) { + owners.putIfAbsent(ticket.owner(), Boolean.TRUE); + } + if (ticketInfluence != NO_INFLUENCE) { + influence = Math.max(influence, 0) + ticketInfluence; + } + } + + TypeBreakdown toBreakdown() { + return new TypeBreakdown(type, tickets, chunks.size(), minLevel, influence, oldest, expiring, List.copyOf(owners.keySet())); + } + } + + // ------------------------------------------------------------- suspects + + private List suspects(Attribution attribution) { + record Scored(TicketRecord ticket, int influence) {} + return IntStream.range(0, tickets.size()) + .mapToObj(i -> new Scored(tickets.get(i), attribution.influenceOf(i))) + .filter(s -> suspicious(s.ticket())) + .sorted(Comparator.comparingInt(Scored::influence).reversed() + .thenComparing(Comparator.comparingLong((Scored s) -> s.ticket().ageTicks()).reversed())) + .limit(MAX_SUSPECTS) + .map(s -> new TicketEntry( + ChunkKey.format(s.ticket().chunk()), + ChunkKey.formatBlock(s.ticket().chunk()), + s.ticket().type(), + s.ticket().level(), + s.ticket().ageTicks(), + s.ticket().owner(), + s.ticket().originId(), + describe(s.ticket(), s.influence()))) + .toList(); + } + + /** + * A ticket is worth reporting when nothing will ever remove it for you: it has no timeout and + * it is not one of the types the game manages itself. + */ + private static boolean suspicious(TicketRecord ticket) { + return !ticket.expiring() && !BENIGN_TYPES.contains(ticket.type()); + } + + private static String describe(TicketRecord ticket, int influence) { + var reason = new StringBuilder("permanent ticket, alive ").append(formatTicks(ticket.ageTicks())); + if (influence != NO_INFLUENCE) { + reason.append(", keeping ").append(influence).append(influence == 1 ? " chunk" : " chunks").append(" loaded"); + } + if (ticket.level() <= 31) { + reason.append("; entity-ticking, so mobs and machines run here"); + } + return reason.toString(); + } + + static String formatTicks(long ticks) { + long seconds = ticks / 20; + if (seconds < 60) { + return seconds + "s"; + } + if (seconds < 3600) { + return seconds / 60 + "m" + seconds % 60 + "s"; + } + return seconds / 3600 + "h" + seconds % 3600 / 60 + "m"; + } + + // ------------------------------------------------------------- hotspots + + /** + * Finds 8-connected clusters of loaded chunks. A leaking chunk loader shows up as one blob, + * which tells an administrator where to fly to far more directly than a list of coordinates. + */ + private List hotspots(Attribution attribution) { + var loaded = new LongOpenHashSet(); + for (ChunkRecord chunk : chunks) { + if (!"INACCESSIBLE".equals(chunk.fullStatus())) { + loaded.add(chunk.chunk()); + } + } + + var clusters = new ArrayList(); + var visited = new LongOpenHashSet(loaded.size()); + var stack = new LongArrayList(); + for (LongIterator seeds = loaded.iterator(); seeds.hasNext(); ) { + long seed = seeds.nextLong(); + if (!visited.add(seed)) { + continue; + } + stack.add(seed); + var members = new LongArrayList(); + while (!stack.isEmpty()) { + long chunk = stack.removeLong(stack.size() - 1); + members.add(chunk); + int x = ChunkKey.x(chunk); + int z = ChunkKey.z(chunk); + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + long neighbour = ChunkKey.of(x + dx, z + dz); + if ((dx != 0 || dz != 0) && loaded.contains(neighbour) && visited.add(neighbour)) { + stack.add(neighbour); + } + } + } + } + if (members.size() >= MIN_HOTSPOT_CHUNKS) { + clusters.add(toHotspot(members, attribution)); + } + } + return clusters.stream() + .sorted(Comparator.comparingInt(Hotspot::chunks).reversed()) + .limit(MAX_HOTSPOTS) + .toList(); + } + + private Hotspot toHotspot(LongArrayList members, Attribution attribution) { + int minX = Integer.MAX_VALUE; + int minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int maxZ = Integer.MIN_VALUE; + Object2IntMap causes = new Object2IntOpenHashMap<>(); + var memberSet = attribution.ran() ? null : new LongOpenHashSet(members); + + for (int i = 0; i < members.size(); i++) { + long chunk = members.getLong(i); + minX = Math.min(minX, ChunkKey.x(chunk)); + maxX = Math.max(maxX, ChunkKey.x(chunk)); + minZ = Math.min(minZ, ChunkKey.z(chunk)); + maxZ = Math.max(maxZ, ChunkKey.z(chunk)); + if (attribution.ran()) { + int winner = attribution.winnerPerChunk().winnerOf(chunk); + if (winner != LevelPropagation.Result.NONE) { + causes.mergeInt(tickets.get(winner).type(), 1, Integer::sum); + } + } + } + if (memberSet != null) { + // Without the propagation pass, fall back to whichever tickets sit inside the cluster. + for (TicketRecord ticket : tickets) { + if (memberSet.contains(ticket.chunk())) { + causes.mergeInt(ticket.type(), 1, Integer::sum); + } + } + } + + long centre = ChunkKey.of((minX + maxX) / 2, (minZ + maxZ) / 2); + List topCauses = causes.object2IntEntrySet().stream() + .sorted(Comparator.comparingInt(Object2IntMap.Entry::getIntValue).reversed()) + .limit(MAX_CAUSES_PER_HOTSPOT) + .map(e -> e.getKey() + " x" + e.getIntValue()) + .toList(); + return new Hotspot( + members.size(), + minX + ", " + minZ + " .. " + maxX + ", " + maxZ, + ChunkKey.format(centre), + ChunkKey.formatBlock(centre), + topCauses); + } + + // ------------------------------------------------------------- origins + + private List originEntries() { + if (origins.isEmpty()) { + return List.of(); + } + Map live = tickets.stream() + .filter(t -> t.originId() != null) + .collect(Collectors.groupingBy(TicketRecord::originId, Collectors.counting())); + return origins.stream() + .filter(o -> live.containsKey(o.id())) + .map(o -> new OriginEntry(o.id(), o.mod(), o.adds(), live.get(o.id()).intValue(), o.types(), o.stack())) + .sorted(Comparator.comparingInt(OriginEntry::live).reversed() + .thenComparing(Comparator.comparingLong(OriginEntry::adds).reversed())) + .toList(); + } +} diff --git a/src/main/java/dev/chunkinspector/analysis/LevelPropagation.java b/src/main/java/dev/chunkinspector/analysis/LevelPropagation.java new file mode 100644 index 0000000..70effe4 --- /dev/null +++ b/src/main/java/dev/chunkinspector/analysis/LevelPropagation.java @@ -0,0 +1,124 @@ +package dev.chunkinspector.analysis; + +import it.unimi.dsi.fastutil.longs.Long2IntMap; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import java.util.List; + +/** + * Replays vanilla's chunk-ticket level propagation while remembering which ticket won each + * chunk. That is the whole point of the mod: the game knows a chunk is loaded at level 33, but it + * throws away the reason, and the reason is what an administrator needs. + * + *

The rules are taken verbatim from {@code ChunkTracker}: a ticket at chunk {@code C} with level + * {@code L} gives every chunk at Chebyshev distance {@code d} a level of {@code L + d}, the lowest + * level wins, and propagation stops once the level exceeds {@code ChunkLevel.MAX_LEVEL}. + * + *

Implemented as Dial's algorithm — a bucket queue indexed by level — so the whole pass is + * {@code O(loaded chunks)} rather than {@code O(sum of ticket areas)}. That is what makes the + * "expensive" analysis affordable enough to also offer it on a timer. + */ +public final class LevelPropagation { + + private LevelPropagation() {} + + /** + * A ticket reduced to what propagation cares about. + * + * @param chunk packed chunk position, see {@link ChunkKey} + * @param level the ticket level; lower loads more + */ + public record Source(long chunk, int level) {} + + /** + * @param level chunk -> resulting ticket level, absent when the chunk is not reachable + * @param winner chunk -> index into the source list that produced that level + */ + public record Result(Long2IntMap level, Long2IntMap winner) { + + public static final int NONE = -1; + + /** The propagated level, or {@code Integer.MAX_VALUE} when nothing reaches this chunk. */ + public int levelOf(long chunk) { + return level.containsKey(chunk) ? level.get(chunk) : Integer.MAX_VALUE; + } + + /** Index of the source responsible for {@code chunk}, or {@link #NONE}. */ + public int winnerOf(long chunk) { + return winner.containsKey(chunk) ? winner.get(chunk) : NONE; + } + + public int reachedChunks() { + return level.size(); + } + } + + /** + * @param sources every live ticket, in a stable order; indices are reported back in the result + * @param maxLevel the highest level still considered loaded, i.e. {@code ChunkLevel.MAX_LEVEL} + */ + public static Result solve(List sources, int maxLevel) { + var level = new Long2IntOpenHashMap(); + var winner = new Long2IntOpenHashMap(); + level.defaultReturnValue(Integer.MAX_VALUE); + winner.defaultReturnValue(Result.NONE); + + // One bucket per level. Chunks only ever move to a strictly higher bucket, so a single + // forward sweep suffices and no priority queue is needed. + var buckets = new LongArrayList[maxLevel + 1]; + + for (int i = 0; i < sources.size(); i++) { + Source source = sources.get(i); + relax(level, winner, buckets, maxLevel, source.chunk(), source.level(), i); + } + + for (int current = 0; current <= maxLevel; current++) { + LongArrayList bucket = buckets[current]; + if (bucket == null) { + continue; + } + for (int i = 0; i < bucket.size(); i++) { + long chunk = bucket.getLong(i); + if (level.get(chunk) != current) { + continue; // superseded by a stronger ticket after being queued + } + int owner = winner.get(chunk); + int x = ChunkKey.x(chunk); + int z = ChunkKey.z(chunk); + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dz == 0) { + continue; + } + relax(level, winner, buckets, maxLevel, ChunkKey.of(x + dx, z + dz), current + 1, owner); + } + } + } + buckets[current] = null; // release as we go; a big world holds a lot of chunks + } + + return new Result(level, winner); + } + + private static void relax( + Long2IntOpenHashMap level, + Long2IntOpenHashMap winner, + LongArrayList[] buckets, + int maxLevel, + long chunk, + int newLevel, + int owner) { + // Vanilla clamps levels into [0, maxLevel]; a region ticket with a radius above 33 would + // otherwise produce a negative level and a negative bucket index. + int clamped = Math.max(0, newLevel); + if (clamped > maxLevel || clamped >= level.get(chunk)) { + return; + } + level.put(chunk, clamped); + winner.put(chunk, owner); + if (buckets[clamped] == null) { + buckets[clamped] = new LongArrayList(); + } + buckets[clamped].add(chunk); + } +} diff --git a/src/main/java/dev/chunkinspector/analysis/TicketRecord.java b/src/main/java/dev/chunkinspector/analysis/TicketRecord.java new file mode 100644 index 0000000..c7e3ec1 --- /dev/null +++ b/src/main/java/dev/chunkinspector/analysis/TicketRecord.java @@ -0,0 +1,33 @@ +package dev.chunkinspector.analysis; + +/** + * One live chunk ticket, flattened out of Minecraft's data structures so the analysis can run + * without touching the server. + * + * @param chunk packed chunk position + * @param type the {@code TicketType} name, e.g. {@code player} or {@code neoforge:block} + * @param level ticket level; lower loads more, 33 is a fully loaded chunk + * @param timeout ticks after which the game drops the ticket on its own, {@code 0} means never + * @param ageTicks how long the ticket has been alive + * @param owner rendered ticket key — for many types this identifies the culprit outright + * @param originId deep-mode call site id; {@code null} outside deep mode + */ +public record TicketRecord( + long chunk, + String type, + int level, + long timeout, + long ageTicks, + String owner, + String originId) { + + /** Tickets that expire on their own cannot be the cause of a permanently loaded chunk. */ + public boolean expiring() { + return timeout > 0; + } + + /** Player view/simulation tickets are expected and are never reported as suspects. */ + public boolean fromPlayer() { + return "player".equals(type); + } +} diff --git a/src/main/java/dev/chunkinspector/game/LevelCapture.java b/src/main/java/dev/chunkinspector/game/LevelCapture.java new file mode 100644 index 0000000..0372553 --- /dev/null +++ b/src/main/java/dev/chunkinspector/game/LevelCapture.java @@ -0,0 +1,64 @@ +package dev.chunkinspector.game; + +import dev.chunkinspector.analysis.ChunkRecord; +import dev.chunkinspector.analysis.TicketRecord; +import dev.chunkinspector.origin.TicketOrigin; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.server.level.ChunkHolder; +import net.minecraft.server.level.ChunkLevel; +import net.minecraft.server.level.ChunkMap; +import net.minecraft.server.level.DistanceManager; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.Ticket; + +/** + * A plain copy of one dimension's ticket and chunk state. + * + *

Taking a copy is the whole trick behind "low cost": the main thread only walks two collections + * and allocates records — no analysis, no I/O, no locks — and everything expensive then happens on + * a background thread against this immutable snapshot. + * + * @param dimension the dimension id, e.g. {@code minecraft:overworld} + * @param maxLevel {@code ChunkLevel.MAX_LEVEL}, captured so the analysis needs no game classes + */ +public record LevelCapture( + String dimension, + long gameTime, + List tickets, + List chunks, + int maxLevel) { + + /** Must run on the server thread. */ + public static LevelCapture of(ServerLevel level) { + ChunkMap chunkMap = level.getChunkSource().chunkMap; + DistanceManager distances = chunkMap.getDistanceManager(); + long now = distances.ticketTickCounter; + + var tickets = new ArrayList(); + distances.tickets.long2ObjectEntrySet().forEach(entry -> { + for (Ticket ticket : entry.getValue()) { + tickets.add(new TicketRecord( + entry.getLongKey(), + String.valueOf(ticket.getType()), + ticket.getTicketLevel(), + ticket.getType().timeout(), + Math.max(0, now - ticket.createdTick), + TicketKeys.render(ticket.key), + TicketOrigin.of(ticket))); + } + }); + + var chunks = new ArrayList(); + for (ChunkHolder holder : chunkMap.getChunks()) { + chunks.add(new ChunkRecord(holder.getPos().toLong(), holder.getTicketLevel(), holder.getFullStatus().name())); + } + + return new LevelCapture( + level.dimension().location().toString(), + level.getGameTime(), + List.copyOf(tickets), + List.copyOf(chunks), + ChunkLevel.MAX_LEVEL); + } +} diff --git a/src/main/java/dev/chunkinspector/game/TicketKeys.java b/src/main/java/dev/chunkinspector/game/TicketKeys.java new file mode 100644 index 0000000..6c28605 --- /dev/null +++ b/src/main/java/dev/chunkinspector/game/TicketKeys.java @@ -0,0 +1,31 @@ +package dev.chunkinspector.game; + +import dev.chunkinspector.mixin.TicketOwnerAccessor; +import lombok.experimental.UtilityClass; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Unit; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ChunkPos; + +/** + * Renders a ticket's key into something a human can act on. + * + *

This matters more than it looks: for a NeoForge forced chunk the key already contains the id of + * the mod that requested it, so a leaking chunk loader is identified without any instrumentation at + * all. Only when the key is opaque does deep mode become necessary. + */ +@UtilityClass +public class TicketKeys { + + public String render(Object key) { + return switch (key) { + case null -> "-"; + case ChunkPos pos -> "chunk " + pos.x + ", " + pos.z; + case BlockPos pos -> "block " + pos.toShortString(); + case Unit ignored -> "-"; + case TicketOwnerAccessor owner -> owner.chunkinspector$controller() + " -> " + render(owner.chunkinspector$owner()); + case Entity entity -> entity.getType().toShortString() + " " + entity.getStringUUID(); + default -> String.valueOf(key); + }; + } +} diff --git a/src/main/java/dev/chunkinspector/mixin/DistanceManagerMixin.java b/src/main/java/dev/chunkinspector/mixin/DistanceManagerMixin.java new file mode 100644 index 0000000..5f5031f --- /dev/null +++ b/src/main/java/dev/chunkinspector/mixin/DistanceManagerMixin.java @@ -0,0 +1,35 @@ +package dev.chunkinspector.mixin; + +import dev.chunkinspector.ChunkInspector; +import dev.chunkinspector.origin.TicketOrigin; +import net.minecraft.server.level.DistanceManager; +import net.minecraft.server.level.Ticket; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * The single choke point for chunk tickets: {@code addRegionTicket}, {@code addTicket}, + * {@code updateChunkForced} and the player ticket tracker all funnel through this method. + * + *

Stamping the incoming ticket at HEAD is deliberate. If an equal ticket already exists, + * {@code SortedArraySet.addOrGet} keeps the old one and discards ours, so the stamp that survives is + * always the one from the call that genuinely created the ticket — which is the call an + * administrator is looking for. + * + *

Only applied when deep mode is enabled — see {@link InspectorMixinPlugin}. + */ +@Mixin(DistanceManager.class) +public abstract class DistanceManagerMixin { + + @Inject(method = "addTicket(JLnet/minecraft/server/level/Ticket;)V", at = @At("HEAD")) + private void chunkinspector$recordOrigin(long chunk, Ticket ticket, CallbackInfo ci) { + // Ticket is final, so javac rejects `instanceof TicketOrigin`; TicketMixin has already made + // every instance implement it by the time this runs. + TicketOrigin holder = (TicketOrigin) (Object) ticket; + if (holder.chunkinspector$origin() == null) { + holder.chunkinspector$origin(ChunkInspector.origins().record(String.valueOf(ticket.getType()))); + } + } +} diff --git a/src/main/java/dev/chunkinspector/mixin/InspectorMixinPlugin.java b/src/main/java/dev/chunkinspector/mixin/InspectorMixinPlugin.java new file mode 100644 index 0000000..4902fe4 --- /dev/null +++ b/src/main/java/dev/chunkinspector/mixin/InspectorMixinPlugin.java @@ -0,0 +1,60 @@ +package dev.chunkinspector.mixin; + +import dev.chunkinspector.InspectorConfig; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +/** + * Keeps the "cheap by default" promise honest. + * + *

The census that runs on every server only reads fields that an access transformer has already + * widened, so it costs nothing until a snapshot is taken. The attribution hooks are a different + * matter — they run inside {@code addTicket}. Rather than leave a disabled branch in a hot method, + * this plugin declines to apply those Mixins at all unless {@code -Dchunkinspector.deep=true} was + * passed, so with deep mode off Minecraft's bytecode is untouched. + */ +@Slf4j +public class InspectorMixinPlugin implements IMixinConfigPlugin { + + /** Mixins that instrument the game rather than merely read it. */ + private static final Set DEEP_ONLY = Set.of( + "dev.chunkinspector.mixin.DistanceManagerMixin", + "dev.chunkinspector.mixin.TicketMixin"); + + private boolean deep; + + @Override + public void onLoad(String mixinPackage) { + this.deep = InspectorConfig.deep(); + log.info("Chunk Inspector: deep mode {}, {} Minecraft's classes", + deep ? "on" : "off", deep ? "instrumenting" : "leaving untouched"); + } + + @Override + public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + return deep || !DEEP_ONLY.contains(mixinClassName); + } + + @Override + public String getRefMapperConfig() { + return null; + } + + @Override + public void acceptTargets(Set myTargets, Set otherTargets) {} + + @Override + public List getMixins() { + return null; + } + + @Override + public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {} + + @Override + public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {} +} diff --git a/src/main/java/dev/chunkinspector/mixin/TicketMixin.java b/src/main/java/dev/chunkinspector/mixin/TicketMixin.java new file mode 100644 index 0000000..c117514 --- /dev/null +++ b/src/main/java/dev/chunkinspector/mixin/TicketMixin.java @@ -0,0 +1,28 @@ +package dev.chunkinspector.mixin; + +import dev.chunkinspector.origin.TicketOrigin; +import net.minecraft.server.level.Ticket; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +/** + * Adds a single reference field to every ticket so that deep mode can attribute it back to a call + * site without an external map. Only applied when deep mode is enabled — see + * {@link InspectorMixinPlugin}. + */ +@Mixin(Ticket.class) +public abstract class TicketMixin implements TicketOrigin { + + @Unique + private String chunkinspector$originId; + + @Override + public String chunkinspector$origin() { + return this.chunkinspector$originId; + } + + @Override + public void chunkinspector$origin(String id) { + this.chunkinspector$originId = id; + } +} diff --git a/src/main/java/dev/chunkinspector/mixin/TicketOwnerAccessor.java b/src/main/java/dev/chunkinspector/mixin/TicketOwnerAccessor.java new file mode 100644 index 0000000..8d6733e --- /dev/null +++ b/src/main/java/dev/chunkinspector/mixin/TicketOwnerAccessor.java @@ -0,0 +1,22 @@ +package dev.chunkinspector.mixin; + +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +/** + * NeoForge's forced-chunk tickets carry a {@code TicketOwner} key that already names the mod that + * asked for the chunk — the single most useful piece of attribution available, and free. The class + * is package private with no getters, so this accessor exposes it. + * + *

Purely an accessor: no game code is modified, so this one is applied even outside deep mode. + */ +@Mixin(targets = "net.neoforged.neoforge.common.world.chunk.ForcedChunkManager$TicketOwner", remap = false) +public interface TicketOwnerAccessor { + + @Accessor("id") + ResourceLocation chunkinspector$controller(); + + @Accessor("owner") + Comparable chunkinspector$owner(); +} diff --git a/src/main/java/dev/chunkinspector/origin/ModAttribution.java b/src/main/java/dev/chunkinspector/origin/ModAttribution.java new file mode 100644 index 0000000..2f585de --- /dev/null +++ b/src/main/java/dev/chunkinspector/origin/ModAttribution.java @@ -0,0 +1,54 @@ +package dev.chunkinspector.origin; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.experimental.UtilityClass; + +/** + * Best-effort "which mod owns this class". NeoForge loads every mod as a named JPMS module, so the + * module name is both the cheapest and the most accurate answer available; the remaining branches + * only exist for classes that arrive on the classpath some other way. + */ +@UtilityClass +public class ModAttribution { + + private final Map CACHE = new ConcurrentHashMap<>(); + + private final String UNKNOWN = "unknown"; + + public String of(Class type) { + if (type == null) { + return UNKNOWN; + } + return CACHE.computeIfAbsent(type.getName(), ignored -> resolve(type)); + } + + private String resolve(Class type) { + String module = type.getModule() == null ? null : type.getModule().getName(); + if (module != null && !module.isBlank() && !module.startsWith("java.") && !module.startsWith("jdk.")) { + return module; + } + return fromCodeSource(type); + } + + /** Falls back to the jar file name, which is still enough for a human to identify the mod. */ + private String fromCodeSource(Class type) { + try { + var domain = type.getProtectionDomain(); + var source = domain == null ? null : domain.getCodeSource(); + if (source == null || source.getLocation() == null) { + return UNKNOWN; + } + // Trailing slash included: mods can be loaded from an exploded directory, where the last + // path segment is still the most identifiable thing available. + String path = source.getLocation().getPath(); + while (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + String file = path.substring(path.lastIndexOf('/') + 1); + return file.isBlank() ? UNKNOWN : file; + } catch (RuntimeException e) { + return UNKNOWN; + } + } +} diff --git a/src/main/java/dev/chunkinspector/origin/Origin.java b/src/main/java/dev/chunkinspector/origin/Origin.java new file mode 100644 index 0000000..d6f25d2 --- /dev/null +++ b/src/main/java/dev/chunkinspector/origin/Origin.java @@ -0,0 +1,14 @@ +package dev.chunkinspector.origin; + +import java.util.List; + +/** + * An immutable snapshot of one call site that creates chunk tickets. + * + * @param id short stable hash of {@link #stack()}, stamped onto every ticket created here + * @param mod best-effort owning mod id + * @param adds how many tickets this site has created since the server started + * @param types the ticket types seen from this site + * @param stack the trimmed stack trace, outermost caller last + */ +public record Origin(String id, String mod, long adds, List types, List stack) {} diff --git a/src/main/java/dev/chunkinspector/origin/OriginRegistry.java b/src/main/java/dev/chunkinspector/origin/OriginRegistry.java new file mode 100644 index 0000000..c96f2c7 --- /dev/null +++ b/src/main/java/dev/chunkinspector/origin/OriginRegistry.java @@ -0,0 +1,125 @@ +package dev.chunkinspector.origin; + +import dev.chunkinspector.InspectorConfig; +import java.lang.StackWalker.StackFrame; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +/** + * Deep-mode only: remembers the distinct call sites that create chunk tickets. + * + *

Each site gets a short id which is stamped straight onto the {@code Ticket} object, so there is + * no side table keyed by ticket and therefore nothing to leak when the game drops a ticket without + * telling us — which it does, in {@code purgeStaleTickets} and {@code removeTicketsOnClosing}. + */ +public final class OriginRegistry { + + /** + * Frames belonging to these prefixes are the plumbing between the caller and the ticket system; + * dropping them puts the responsible mod's own frame at the top of every trace. + */ + private static final List PLUMBING = List.of( + "dev.chunkinspector.", + "net.minecraft.server.level.DistanceManager", + "net.minecraft.server.level.ChunkMap", + "net.minecraft.server.level.ServerChunkCache", + "net.minecraft.server.level.TicketType", + "java.", + "jdk."); + + private static final StackWalker WALKER = + StackWalker.getInstance(Set.of(StackWalker.Option.RETAIN_CLASS_REFERENCE), 64); + + private final Map sites = new ConcurrentHashMap<>(); + private final AtomicLong sampleCounter = new AtomicLong(); + private final int stackDepth = InspectorConfig.stackDepth(); + private final int maxOrigins = InspectorConfig.maxOrigins(); + private final int sampleRate = InspectorConfig.sampleRate(); + + /** + * Captures the current call stack and returns the id to stamp on the ticket, or {@code null} + * when this add was not sampled, produced no usable frames, or the registry is already full. + */ + public String record(String ticketType) { + if (sampleRate > 1 && sampleCounter.incrementAndGet() % sampleRate != 0) { + return null; + } + Captured captured = capture(); + if (captured == null) { + return null; + } + String id = idOf(captured.stack()); + Site site = sites.get(id); + if (site == null) { + if (sites.size() >= maxOrigins) { + return null; + } + site = sites.computeIfAbsent(id, ignored -> new Site(captured.mod(), captured.stack())); + } + site.adds.increment(); + site.types.add(ticketType); + return id; + } + + public List snapshot() { + return sites.entrySet().stream() + .map(e -> new Origin( + e.getKey(), + e.getValue().mod, + e.getValue().adds.sum(), + List.copyOf(e.getValue().types), + e.getValue().stack)) + .toList(); + } + + public boolean isEmpty() { + return sites.isEmpty(); + } + + private record Captured(String mod, List stack) {} + + /** One walk yields both the rendered trace and the owning mod of its topmost frame. */ + private Captured capture() { + return WALKER.walk(frames -> { + List kept = frames + .filter(f -> PLUMBING.stream().noneMatch(prefix -> f.getClassName().startsWith(prefix))) + .limit(stackDepth) + .toList(); + if (kept.isEmpty()) { + return null; + } + return new Captured( + ModAttribution.of(kept.getFirst().getDeclaringClass()), + kept.stream().map(f -> f.getClassName() + "." + f.getMethodName() + ":" + f.getLineNumber()).toList()); + }); + } + + /** A 48-bit FNV-1a fold: short enough to read in a report, wide enough not to collide. */ + static String idOf(List stack) { + long hash = 0xcbf29ce484222325L; + for (String frame : stack) { + for (int i = 0; i < frame.length(); i++) { + hash = (hash ^ frame.charAt(i)) * 0x100000001b3L; + } + hash = (hash ^ '\n') * 0x100000001b3L; + } + return "%012x".formatted(hash & 0xFFFF_FFFF_FFFFL); + } + + /** Mutable per-site counters; only ever read through {@link #snapshot()}. */ + private static final class Site { + private final String mod; + private final List stack; + private final LongAdder adds = new LongAdder(); + private final Set types = ConcurrentHashMap.newKeySet(); + + Site(String mod, List stack) { + this.mod = mod; + this.stack = stack; + } + } +} diff --git a/src/main/java/dev/chunkinspector/origin/TicketOrigin.java b/src/main/java/dev/chunkinspector/origin/TicketOrigin.java new file mode 100644 index 0000000..3825f31 --- /dev/null +++ b/src/main/java/dev/chunkinspector/origin/TicketOrigin.java @@ -0,0 +1,18 @@ +package dev.chunkinspector.origin; + +/** + * Implemented on {@code net.minecraft.server.level.Ticket} by the deep-mode Mixin so a ticket can + * carry the id of the call site that created it. Outside deep mode the Mixin is never applied and + * the cast simply fails, which the caller treats as "no attribution available". + */ +public interface TicketOrigin { + + String chunkinspector$origin(); + + void chunkinspector$origin(String id); + + /** Null-safe read for a ticket that may or may not have been instrumented. */ + static String of(Object ticket) { + return ticket instanceof TicketOrigin holder ? holder.chunkinspector$origin() : null; + } +} diff --git a/src/main/java/dev/chunkinspector/report/InspectionReport.java b/src/main/java/dev/chunkinspector/report/InspectionReport.java new file mode 100644 index 0000000..ea0b3cd --- /dev/null +++ b/src/main/java/dev/chunkinspector/report/InspectionReport.java @@ -0,0 +1,136 @@ +package dev.chunkinspector.report; + +import java.util.List; + +/** + * The serialised form of a single inspection. Everything below is a plain record tree so that Gson + * can write it without any custom adapters, and so that the unit tests can build one by hand. + * + * @param schema bumped whenever the shape changes incompatibly + * @param generatedAt ISO-8601 wall-clock time the snapshot was taken + * @param trigger what caused the snapshot ({@code periodic}, {@code command}, {@code shutdown}) + * @param settings the effective configuration, so a report is self-describing + * @param levels one entry per loaded dimension + */ +public record InspectionReport( + int schema, + String generatedAt, + String trigger, + Settings settings, + List levels) { + + public static final int SCHEMA = 1; + + /** Effective configuration at the time of the snapshot. */ + public record Settings(boolean deep, boolean attribution, int intervalTicks, int sampleRate) {} + + /** + * @param dimension e.g. {@code minecraft:overworld} + * @param gameTime the level's game time + * @param totals headline counters + * @param byType per-{@code TicketType} aggregation, descending by chunk count + * @param suspects individual tickets that look like leaks, most suspicious first + * @param hotspots contiguous clusters of loaded chunks, largest first + * @param origins deep-mode call sites that created tickets, most tickets first + * @param unattributed loaded chunks no ticket can explain (only when attribution ran) + * @param attributionRan whether the expensive chunk-to-ticket pass was performed + */ + public record LevelReport( + String dimension, + long gameTime, + Totals totals, + List byType, + List suspects, + List hotspots, + List origins, + List unattributed, + boolean attributionRan) {} + + /** + * @param trackedChunks every chunk the chunk map holds, including partially generated ones + * @param loadedChunks chunks at {@code FULL} status or better, i.e. really in memory + * @param blockTicking chunks receiving block ticks + * @param entityTicking chunks receiving entity ticks — the expensive ones + * @param tickets total live tickets + * @param ticketedChunks chunks that carry at least one ticket + * @param permanentTickets tickets with no timeout; only these can leak forever + * @param forcedChunks vanilla {@code /forceload} tickets + */ + public record Totals( + int trackedChunks, + int loadedChunks, + int blockTicking, + int entityTicking, + int tickets, + int ticketedChunks, + int permanentTickets, + int forcedChunks) {} + + /** + * @param type the {@code TicketType} name, e.g. {@code minecraft:player} + * @param tickets how many live tickets of this type exist + * @param chunks how many distinct chunks carry one + * @param minLevel the strongest (numerically lowest) level seen — lower loads more + * @param influence chunks kept loaded by this type, or {@code -1} if attribution was skipped + * @param oldestAgeTicks age of the longest-lived ticket of this type + * @param expiring whether this type has a timeout, i.e. it cannot leak forever + * @param owners distinct ticket keys, truncated; useful for spotting a runaway mod + */ + public record TypeBreakdown( + String type, + int tickets, + int chunks, + int minLevel, + int influence, + long oldestAgeTicks, + boolean expiring, + List owners) {} + + /** + * A single ticket that is worth a human look. + * + * @param chunk {@code "x, z"} chunk coordinates + * @param blockPos the block coordinates of that chunk's corner, for {@code /tp} + * @param owner rendered ticket key + * @param origin deep-mode call site id, or {@code null} + * @param reason why the ticket was flagged + */ + public record TicketEntry( + String chunk, + String blockPos, + String type, + int level, + long ageTicks, + String owner, + String origin, + String reason) {} + + /** + * A rectangular cluster of loaded chunks, which usually corresponds to one culprit. + * + * @param chunks number of loaded chunks in the cluster + * @param bounds {@code "minX, minZ .. maxX, maxZ"} in chunk coordinates + * @param center representative chunk, {@code "x, z"} + * @param blockPos block coordinates of the center, for {@code /tp} + * @param causes ticket types responsible, descending by contribution + */ + public record Hotspot(int chunks, String bounds, String center, String blockPos, List causes) {} + + /** + * A deep-mode call site. + * + * @param id short stable hash, cross-referenced from {@link TicketEntry#origin()} + * @param mod best-effort mod id that owns the top non-Minecraft frame + * @param adds how many tickets this site has added since the server started + * @param live how many of those tickets are still alive + * @param types ticket types created here + * @param stack the trimmed stack trace + */ + public record OriginEntry( + String id, + String mod, + long adds, + int live, + List types, + List stack) {} +} diff --git a/src/main/java/dev/chunkinspector/report/ReportWriter.java b/src/main/java/dev/chunkinspector/report/ReportWriter.java new file mode 100644 index 0000000..aa81213 --- /dev/null +++ b/src/main/java/dev/chunkinspector/report/ReportWriter.java @@ -0,0 +1,105 @@ +package dev.chunkinspector.report; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.IOException; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +/** + * Persists reports under {@code /chunk-summaries}. + * + *

Writes go through a temporary file and an atomic move so that a half-written {@code latest.json} + * can never be observed by whatever tooling an administrator points at the directory. + */ +public final class ReportWriter { + + public static final String DIRECTORY = "chunk-summaries"; + + private static final String PREFIX = "summary-"; + private static final String JSON = ".json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private final Path directory; + private final int retention; + + public ReportWriter(Path levelDirectory, int retention) { + this.directory = levelDirectory.resolve(DIRECTORY); + this.retention = retention; + } + + public Path directory() { + return directory; + } + + /** + * Writes the timestamped snapshot plus the {@code latest} aliases and prunes old snapshots. + * + * @return the timestamped file that was written + */ + public Path write(InspectionReport report, String stamp, boolean alsoText) throws IOException { + Files.createDirectories(directory); + + Path snapshot = free(PREFIX + stamp + "-" + report.trigger()); + writeAtomic(snapshot, writer -> GSON.toJson(report, writer)); + writeAtomic(directory.resolve("latest" + JSON), writer -> GSON.toJson(report, writer)); + if (alsoText) { + writeAtomic(directory.resolve("latest.txt"), writer -> writer.write(TextDigest.render(report))); + } + prune(); + return snapshot; + } + + /** + * The name a snapshot should take, made unique if one is already there. The stamp only resolves + * to the second, so a periodic snapshot and one asked for by hand really can land together; all + * writes go through a single thread, which is what makes this check-then-create safe. + */ + private Path free(String name) { + Path candidate = directory.resolve(name + JSON); + for (int attempt = 2; Files.exists(candidate); attempt++) { + candidate = directory.resolve(name + "-" + attempt + JSON); + } + return candidate; + } + + /** Keeps the {@code retention} newest snapshots; {@code 0} keeps everything. */ + private void prune() throws IOException { + if (retention <= 0) { + return; + } + try (Stream files = Files.list(directory)) { + List snapshots = files + .filter(p -> p.getFileName().toString().startsWith(PREFIX)) + .filter(p -> p.getFileName().toString().endsWith(JSON)) + .sorted(Comparator.comparing(p -> p.getFileName().toString())) + .toList(); + for (int i = 0; i < snapshots.size() - retention; i++) { + Files.deleteIfExists(snapshots.get(i)); + } + } + } + + private void writeAtomic(Path target, ContentWriter content) throws IOException { + Path temporary = target.resolveSibling(target.getFileName() + ".tmp"); + try (Writer writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8)) { + content.writeTo(writer); + } + try { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + @FunctionalInterface + private interface ContentWriter { + void writeTo(Writer writer) throws IOException; + } +} diff --git a/src/main/java/dev/chunkinspector/report/TextDigest.java b/src/main/java/dev/chunkinspector/report/TextDigest.java new file mode 100644 index 0000000..b59c5c6 --- /dev/null +++ b/src/main/java/dev/chunkinspector/report/TextDigest.java @@ -0,0 +1,102 @@ +package dev.chunkinspector.report; + +import dev.chunkinspector.report.InspectionReport.LevelReport; +import java.util.List; +import lombok.experimental.UtilityClass; + +/** + * Renders a report as the summary an administrator actually reads first. The JSON alongside it stays + * the machine-readable source of truth; this is the version you can page through over SSH. + */ +@UtilityClass +public class TextDigest { + + private final int TOP_TYPES = 12; + private final int TOP_SUSPECTS = 10; + private final int TOP_HOTSPOTS = 8; + private final int TOP_ORIGINS = 8; + + public String render(InspectionReport report) { + var out = new StringBuilder(4096); + out.append("Chunk Inspector — ").append(report.generatedAt()).append(" (").append(report.trigger()).append(")\n"); + out.append("deep=").append(report.settings().deep()) + .append(" attribution=").append(report.settings().attribution()) + .append(" interval=").append(report.settings().intervalTicks()).append(" ticks\n"); + + for (LevelReport level : report.levels()) { + renderLevel(out, level); + } + if (!report.settings().deep()) { + out.append("\nNo call site could be named for the tickets above. Restart with ") + .append("-Dchunkinspector.deep=true to record the stack trace behind every ticket.\n"); + } + return out.toString(); + } + + private void renderLevel(StringBuilder out, LevelReport level) { + var totals = level.totals(); + out.append("\n══ ").append(level.dimension()).append(" ══ game time ").append(level.gameTime()).append('\n'); + out.append(" chunks: ").append(totals.loadedChunks()).append(" loaded") + .append(", ").append(totals.blockTicking()).append(" block-ticking") + .append(", ").append(totals.entityTicking()).append(" entity-ticking") + .append(" (").append(totals.trackedChunks()).append(" tracked)\n"); + out.append(" tickets: ").append(totals.tickets()).append(" on ").append(totals.ticketedChunks()).append(" chunks") + .append(", ").append(totals.permanentTickets()).append(" never expire") + .append(", ").append(totals.forcedChunks()).append(" from /forceload\n"); + + section(out, "by ticket type", level.byType().stream().limit(TOP_TYPES).map(t -> + "%-28s %5d tickets %5d chunks level>=%d %s oldest %s%s".formatted( + t.type(), + t.tickets(), + t.chunks(), + t.minLevel(), + t.influence() < 0 ? "influence n/a" : "keeps %d loaded".formatted(t.influence()), + formatTicks(t.oldestAgeTicks()), + t.expiring() ? " (expires)" : "")).toList()); + + section(out, "most suspicious tickets", level.suspects().stream().limit(TOP_SUSPECTS).map(s -> + "chunk %-14s %-24s level %2d %s%s\n -> %s".formatted( + s.chunk(), + s.type(), + s.level(), + s.owner(), + s.origin() == null ? "" : " origin " + s.origin(), + s.reason())).toList()); + + section(out, "loaded chunk clusters", level.hotspots().stream().limit(TOP_HOTSPOTS).map(h -> + "%5d chunks centre %-14s bounds %-28s %s".formatted( + h.chunks(), h.center(), h.bounds(), String.join(", ", h.causes()))).toList()); + + section(out, "ticket call sites", level.origins().stream().limit(TOP_ORIGINS).map(o -> + "%s mod=%-24s live=%-5d adds=%-7d %s\n %s".formatted( + o.id(), + o.mod(), + o.live(), + o.adds(), + String.join(",", o.types()), + String.join("\n ", o.stack()))).toList()); + + if (level.attributionRan() && !level.unattributed().isEmpty()) { + section(out, "loaded but explained by no ticket", level.unattributed()); + } + } + + private void section(StringBuilder out, String title, List lines) { + if (lines.isEmpty()) { + return; + } + out.append("\n ").append(title).append('\n'); + lines.forEach(line -> out.append(" ").append(line).append('\n')); + } + + private String formatTicks(long ticks) { + long seconds = ticks / 20; + if (seconds < 60) { + return seconds + "s"; + } + if (seconds < 3600) { + return seconds / 60 + "m"; + } + return seconds / 3600 + "h" + seconds % 3600 / 60 + "m"; + } +} diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..7516f03 --- /dev/null +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,7 @@ +# Read-only widening. The census reads these fields directly instead of injecting into the game, +# which is what keeps the always-on mode free: nothing is transformed on any hot path. +public net.minecraft.server.level.DistanceManager tickets +public net.minecraft.server.level.DistanceManager ticketTickCounter +public net.minecraft.server.level.Ticket key +public net.minecraft.server.level.Ticket createdTick +public net.minecraft.server.level.ChunkMap getChunks()Ljava/lang/Iterable; diff --git a/src/main/resources/chunkinspector.mixins.json b/src/main/resources/chunkinspector.mixins.json new file mode 100644 index 0000000..ef04599 --- /dev/null +++ b/src/main/resources/chunkinspector.mixins.json @@ -0,0 +1,15 @@ +{ + "required": true, + "minVersion": "0.8.5", + "package": "dev.chunkinspector.mixin", + "compatibilityLevel": "JAVA_21", + "plugin": "dev.chunkinspector.mixin.InspectorMixinPlugin", + "injectors": { + "defaultRequire": 1 + }, + "mixins": [ + "DistanceManagerMixin", + "TicketMixin", + "TicketOwnerAccessor" + ] +} diff --git a/src/main/templates/META-INF/neoforge.mods.toml b/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..6552051 --- /dev/null +++ b/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,27 @@ +modLoader = "javafml" +loaderVersion = "[4,)" +license = "${mod_license}" + +[[mods]] +modId = "${mod_id}" +version = "${mod_version}" +displayName = "${mod_name}" +authors = "${mod_authors}" +description = '''${mod_description}''' + +[[mixins]] +config = "${mod_id}.mixins.json" + +[[dependencies.${mod_id}]] +modId = "neoforge" +type = "required" +versionRange = "[21.1.0,)" +ordering = "NONE" +side = "BOTH" + +[[dependencies.${mod_id}]] +modId = "minecraft" +type = "required" +versionRange = "[1.21.1,1.22)" +ordering = "NONE" +side = "BOTH" diff --git a/src/test/java/dev/chunkinspector/Fixtures.java b/src/test/java/dev/chunkinspector/Fixtures.java new file mode 100644 index 0000000..5b8edac --- /dev/null +++ b/src/test/java/dev/chunkinspector/Fixtures.java @@ -0,0 +1,97 @@ +package dev.chunkinspector; + +import dev.chunkinspector.analysis.ChunkKey; +import dev.chunkinspector.analysis.ChunkRecord; +import dev.chunkinspector.analysis.LevelAnalysis; +import dev.chunkinspector.analysis.TicketRecord; +import dev.chunkinspector.origin.Origin; +import dev.chunkinspector.report.InspectionReport; +import java.util.ArrayList; +import java.util.List; + +/** + * A small, hand-built world used by most tests: a player standing at the origin, and a mod that has + * quietly forced a 5x5 block of chunks a hundred chunks away and never let go. That is exactly the + * situation the mod exists to diagnose, so the same fixture serves the analysis, report and digest + * tests. + */ +public final class Fixtures { + + /** {@code ChunkLevel.MAX_LEVEL} for 1.21.1: 33 plus the generation radius around a full chunk. */ + public static final int MAX_LEVEL = 41; + + public static final String LEAK_ORIGIN = "a1b2c3d4e5f6"; + + private Fixtures() {} + + public static TicketRecord ticket( + int x, int z, String type, int level, long timeout, long age, String owner, String origin) { + return new TicketRecord(ChunkKey.of(x, z), type, level, timeout, age, owner, origin); + } + + /** The player's own view ticket — always present, never a suspect. */ + public static TicketRecord playerTicket() { + return ticket(0, 0, "player", 31, 0, 1_000, "chunk 0, 0", null); + } + + /** A NeoForge forced-chunk ticket carrying the requesting mod's id, as the real key does. */ + public static TicketRecord leakTicket() { + return ticket(100, 100, "neoforge:block", 31, 0, 72_000, "leakymod:loader -> block 1600, 64, 1600", LEAK_ORIGIN); + } + + /** A vanilla {@code /forceload} on the same chunk, so ties have to be broken. */ + public static TicketRecord forcedTicket() { + return ticket(100, 100, "forced", 31, 0, 5, "chunk 100, 100", null); + } + + /** Expires by itself, so it can never be the cause of a permanently loaded chunk. */ + public static TicketRecord portalTicket() { + return ticket(50, 50, "portal", 30, 300, 10, "block 800, 64, 800", null); + } + + public static List tickets() { + return List.of(playerTicket(), leakTicket(), forcedTicket(), portalTicket()); + } + + public static List chunks() { + var chunks = new ArrayList(); + chunks.addAll(square(0, 0, 1, 33, "FULL")); + chunks.addAll(square(100, 100, 2, 31, "ENTITY_TICKING")); + // A chunk the server still tracks but no longer keeps in memory: it must not count as loaded. + chunks.add(new ChunkRecord(ChunkKey.of(500, 500), MAX_LEVEL, "INACCESSIBLE")); + return List.copyOf(chunks); + } + + /** A {@code (2 * radius + 1)}-square of chunks centred on {@code (x, z)}. */ + public static List square(int x, int z, int radius, int level, String fullStatus) { + var chunks = new ArrayList(); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + chunks.add(new ChunkRecord(ChunkKey.of(x + dx, z + dz), level, fullStatus)); + } + } + return chunks; + } + + public static List origins() { + return List.of(new Origin( + LEAK_ORIGIN, + "leakymod", + 42, + List.of("neoforge:block"), + List.of("leakymod.machine.QuarryBlockEntity.tick:88", "leakymod.machine.QuarryBlockEntity.serverTick:41"))); + } + + public static InspectionReport.LevelReport levelReport(boolean attribution) { + return LevelAnalysis.analyse("minecraft:overworld", 123_456, tickets(), chunks(), MAX_LEVEL, attribution, origins()); + } + + public static InspectionReport report(boolean deep, boolean attribution) { + return new InspectionReport( + InspectionReport.SCHEMA, + "2026-07-31T10:15:30Z", + "command", + new InspectionReport.Settings(deep, attribution, 600, 1), + List.of(levelReport(attribution))); + } +} diff --git a/src/test/java/dev/chunkinspector/InspectorConfigTest.java b/src/test/java/dev/chunkinspector/InspectorConfigTest.java new file mode 100644 index 0000000..854579c --- /dev/null +++ b/src/test/java/dev/chunkinspector/InspectorConfigTest.java @@ -0,0 +1,91 @@ +package dev.chunkinspector; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class InspectorConfigTest { + + private static final List KEYS = List.of( + "enabled", "deep", "interval", "attribution", "retention", "stackDepth", "maxOrigins", "sampleRate", "textReport"); + + @AfterEach + void clearProperties() { + KEYS.forEach(key -> System.clearProperty(InspectorConfig.PREFIX + key)); + } + + private static void set(String key, String value) { + System.setProperty(InspectorConfig.PREFIX + key, value); + } + + @Test + void defaultsAreTheCheapConfiguration() { + assertThat(InspectorConfig.enabled()).isTrue(); + assertThat(InspectorConfig.deep()).isFalse(); + assertThat(InspectorConfig.attributionInSnapshots()).isFalse(); + assertThat(InspectorConfig.intervalTicks()).isEqualTo(600); + assertThat(InspectorConfig.retention()).isEqualTo(48); + assertThat(InspectorConfig.sampleRate()).isEqualTo(1); + assertThat(InspectorConfig.textReport()).isTrue(); + } + + @Test + void deepModeRequiresTheModToBeEnabledAtAll() { + set("deep", "true"); + assertThat(InspectorConfig.deep()).isTrue(); + + set("enabled", "false"); + assertThat(InspectorConfig.deep()).isFalse(); + } + + @Test + void blankAndMissingValuesFallBackToTheDefault() { + set("interval", ""); + assertThat(InspectorConfig.intervalTicks()).isEqualTo(600); + + set("retention", " "); + assertThat(InspectorConfig.retention()).isEqualTo(48); + } + + @Test + void unparseableNumbersDoNotBreakAServerStartup() { + set("interval", "every-so-often"); + set("maxOrigins", "lots"); + + assertThat(InspectorConfig.intervalTicks()).isEqualTo(600); + assertThat(InspectorConfig.maxOrigins()).isEqualTo(8192); + } + + @Test + void intervalsAndRetentionMayBeZeroButNeverNegative() { + set("interval", "0"); + set("retention", "-5"); + + assertThat(InspectorConfig.intervalTicks()).isZero(); + assertThat(InspectorConfig.retention()).isZero(); + } + + @Test + void boundedValuesAreClampedIntoAWorkableRange() { + set("stackDepth", "1"); + assertThat(InspectorConfig.stackDepth()).isEqualTo(4); + set("stackDepth", "10000"); + assertThat(InspectorConfig.stackDepth()).isEqualTo(256); + + set("maxOrigins", "1"); + assertThat(InspectorConfig.maxOrigins()).isEqualTo(64); + + set("sampleRate", "0"); + assertThat(InspectorConfig.sampleRate()).isEqualTo(1); + set("sampleRate", "64"); + assertThat(InspectorConfig.sampleRate()).isEqualTo(64); + } + + @Test + void valuesAreTrimmedBecauseServerScriptsAreWrittenByHand() { + set("interval", " 40 "); + assertThat(InspectorConfig.intervalTicks()).isEqualTo(40); + } +} diff --git a/src/test/java/dev/chunkinspector/analysis/ChunkExplainerTest.java b/src/test/java/dev/chunkinspector/analysis/ChunkExplainerTest.java new file mode 100644 index 0000000..1b7de80 --- /dev/null +++ b/src/test/java/dev/chunkinspector/analysis/ChunkExplainerTest.java @@ -0,0 +1,81 @@ +package dev.chunkinspector.analysis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.chunkinspector.Fixtures; +import dev.chunkinspector.analysis.ChunkExplainer.Explanation; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ChunkExplainerTest { + + private static final int MAX = 33; + + @Test + void aTicketExplainsEveryChunkWithinItsReach() { + var tickets = List.of(Fixtures.ticket(0, 0, "forced", 31, 0, 100, "chunk 0, 0", null)); + + Explanation here = ChunkExplainer.explain(tickets, ChunkKey.of(0, 0), MAX); + assertThat(here.level()).isEqualTo(31); + assertThat(here.loaded(MAX)).isTrue(); + assertThat(here.contributors()).singleElement().satisfies(c -> { + assertThat(c.distance()).isZero(); + assertThat(c.contributed()).isEqualTo(31); + assertThat(c.ticket().type()).isEqualTo("forced"); + }); + + Explanation edge = ChunkExplainer.explain(tickets, ChunkKey.of(2, -2), MAX); + assertThat(edge.level()).isEqualTo(33); + assertThat(edge.contributors()).singleElement().satisfies(c -> assertThat(c.distance()).isEqualTo(2)); + } + + @Test + void aChunkOutOfEveryTicketsReachIsExplainedByNothing() { + var tickets = List.of(Fixtures.ticket(0, 0, "forced", 31, 0, 100, "chunk 0, 0", null)); + + Explanation explanation = ChunkExplainer.explain(tickets, ChunkKey.of(3, 0), MAX); + + assertThat(explanation.contributors()).isEmpty(); + assertThat(explanation.level()).isEqualTo(Integer.MAX_VALUE); + assertThat(explanation.loaded(MAX)).isFalse(); + } + + @Test + void contributorsAreOrderedByHowMuchTheyActuallyContribute() { + var tickets = List.of( + Fixtures.ticket(3, 0, "far", 31, 0, 1, "far", null), // 31 + 3 = 34, out of reach + Fixtures.ticket(2, 0, "weak", 31, 0, 1, "weak", null), // 31 + 2 = 33 + Fixtures.ticket(0, 0, "strong", 30, 0, 1, "strong", null)); // 30 + 0 = 30 + + Explanation explanation = ChunkExplainer.explain(tickets, ChunkKey.of(0, 0), MAX); + + assertThat(explanation.contributors()).extracting(c -> c.ticket().type()).containsExactly("strong", "weak"); + assertThat(explanation.level()).isEqualTo(30); + } + + @Test + void negativeTicketLevelsAreClampedTheSameWayPropagationClampsThem() { + var tickets = List.of(Fixtures.ticket(0, 0, "huge_region", -5, 0, 1, "-", null)); + + assertThat(ChunkExplainer.explain(tickets, ChunkKey.of(0, 0), MAX).level()).isZero(); + assertThat(ChunkExplainer.explain(tickets, ChunkKey.of(10, 0), MAX).level()).isEqualTo(10); + } + + @Test + void agreesWithTheFullPropagationPass() { + // The explainer is a shortcut for one chunk; if it ever disagreed with the propagation the + // command and the report would tell an administrator two different stories. + var tickets = Fixtures.tickets(); + var sources = tickets.stream().map(t -> new LevelPropagation.Source(t.chunk(), t.level())).toList(); + var propagated = LevelPropagation.solve(sources, Fixtures.MAX_LEVEL); + + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + long chunk = ChunkKey.of(x, z); + assertThat(ChunkExplainer.explain(tickets, chunk, Fixtures.MAX_LEVEL).level()) + .as("chunk %s", ChunkKey.format(chunk)) + .isEqualTo(propagated.levelOf(chunk)); + } + } + } +} diff --git a/src/test/java/dev/chunkinspector/analysis/ChunkKeyTest.java b/src/test/java/dev/chunkinspector/analysis/ChunkKeyTest.java new file mode 100644 index 0000000..9e7805d --- /dev/null +++ b/src/test/java/dev/chunkinspector/analysis/ChunkKeyTest.java @@ -0,0 +1,48 @@ +package dev.chunkinspector.analysis; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ChunkKeyTest { + + @ParameterizedTest + @CsvSource({ + "0, 0", + "1, 2", + "-1, -1", + "1875000, -1875000", + "2147483647, -2147483648", + }) + void roundTripsIncludingNegativeCoordinates(int x, int z) { + long key = ChunkKey.of(x, z); + assertThat(ChunkKey.x(key)).isEqualTo(x); + assertThat(ChunkKey.z(key)).isEqualTo(z); + } + + @Test + void packsExactlyLikeChunkPos() { + // ChunkPos.asLong puts x in the low word and z in the high word; a mismatch here would make + // every key we read out of the game meaningless. + assertThat(ChunkKey.of(1, 2)).isEqualTo(0x0000_0002_0000_0001L); + assertThat(ChunkKey.of(-1, 0)).isEqualTo(0x0000_0000_FFFF_FFFFL); + } + + @Test + void distanceIsChebyshevBecauseThatIsHowLevelsPropagate() { + long origin = ChunkKey.of(0, 0); + assertThat(ChunkKey.distance(origin, ChunkKey.of(3, 1))).isEqualTo(3); + assertThat(ChunkKey.distance(origin, ChunkKey.of(-4, 4))).isEqualTo(4); + assertThat(ChunkKey.distance(origin, origin)).isZero(); + assertThat(ChunkKey.distance(ChunkKey.of(10, 10), ChunkKey.of(7, 12))).isEqualTo(3); + } + + @Test + void formatsCoordinatesAPlayerCanType() { + assertThat(ChunkKey.format(ChunkKey.of(12, -34))).isEqualTo("12, -34"); + assertThat(ChunkKey.formatBlock(ChunkKey.of(0, 0))).isEqualTo("8 ~ 8"); + assertThat(ChunkKey.formatBlock(ChunkKey.of(100, -3))).isEqualTo("1608 ~ -40"); + } +} diff --git a/src/test/java/dev/chunkinspector/analysis/LevelAnalysisTest.java b/src/test/java/dev/chunkinspector/analysis/LevelAnalysisTest.java new file mode 100644 index 0000000..2596902 --- /dev/null +++ b/src/test/java/dev/chunkinspector/analysis/LevelAnalysisTest.java @@ -0,0 +1,178 @@ +package dev.chunkinspector.analysis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.chunkinspector.Fixtures; +import dev.chunkinspector.report.InspectionReport.Hotspot; +import dev.chunkinspector.report.InspectionReport.LevelReport; +import dev.chunkinspector.report.InspectionReport.TypeBreakdown; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class LevelAnalysisTest { + + private static TypeBreakdown byType(LevelReport report, String type) { + return report.byType().stream() + .filter(t -> t.type().equals(type)) + .findFirst() + .orElseThrow(() -> new AssertionError("no breakdown for " + type + " in " + report.byType())); + } + + @Test + void totalsSeparateTrackedFromActuallyLoadedChunks() { + var totals = Fixtures.levelReport(false).totals(); + + assertThat(totals.trackedChunks()).isEqualTo(35); // 3x3 + 5x5 + one inaccessible + assertThat(totals.loadedChunks()).isEqualTo(34); + assertThat(totals.entityTicking()).isEqualTo(25); + assertThat(totals.blockTicking()).isEqualTo(25); // entity-ticking implies block-ticking + assertThat(totals.tickets()).isEqualTo(4); + assertThat(totals.ticketedChunks()).isEqualTo(3); // two tickets share chunk 100, 100 + assertThat(totals.permanentTickets()).isEqualTo(3); // the portal ticket expires + assertThat(totals.forcedChunks()).isEqualTo(1); + } + + @Test + void typeBreakdownRecordsOwnersAndWhetherTheTypeExpires() { + LevelReport report = Fixtures.levelReport(false); + + assertThat(report.byType()).extracting(TypeBreakdown::type) + .containsExactlyInAnyOrder("player", "neoforge:block", "forced", "portal"); + + TypeBreakdown leak = byType(report, "neoforge:block"); + assertThat(leak.tickets()).isEqualTo(1); + assertThat(leak.chunks()).isEqualTo(1); + assertThat(leak.minLevel()).isEqualTo(31); + assertThat(leak.oldestAgeTicks()).isEqualTo(72_000); + assertThat(leak.expiring()).isFalse(); + // The NeoForge ticket key names the mod outright — no instrumentation needed. + assertThat(leak.owners()).containsExactly("leakymod:loader -> block 1600, 64, 1600"); + + assertThat(byType(report, "portal").expiring()).isTrue(); + } + + @Test + void aTypeOnlyCountsAsExpiringWhenEveryTicketOfItDoes() { + var tickets = List.of( + Fixtures.ticket(0, 0, "mixed", 31, 300, 10, "a", null), + Fixtures.ticket(1, 0, "mixed", 31, 0, 10, "b", null)); + + LevelReport report = LevelAnalysis.analyse("d", 0, tickets, List.of(), Fixtures.MAX_LEVEL, false, List.of()); + + assertThat(byType(report, "mixed").expiring()).isFalse(); + } + + @Test + void suspectsAreThePermanentNonVanillaTicketsOldestFirst() { + LevelReport report = Fixtures.levelReport(false); + + assertThat(report.suspects()).extracting("type").containsExactly("neoforge:block", "forced"); + var leak = report.suspects().getFirst(); + assertThat(leak.chunk()).isEqualTo("100, 100"); + assertThat(leak.blockPos()).isEqualTo("1608 ~ 1608"); + assertThat(leak.origin()).isEqualTo(Fixtures.LEAK_ORIGIN); + assertThat(leak.reason()).contains("permanent ticket", "1h0m", "entity-ticking"); + } + + @Test + void attributionRanksTypesByTheChunksTheyActuallyKeepLoaded() { + LevelReport report = Fixtures.levelReport(true); + + assertThat(report.attributionRan()).isTrue(); + assertThat(byType(report, "neoforge:block").influence()).isEqualTo(25); + assertThat(byType(report, "player").influence()).isEqualTo(9); + // The forced ticket sits on the same chunk but was added second, so it wins nothing; the + // portal ticket is nowhere near a loaded chunk. + assertThat(byType(report, "forced").influence()).isZero(); + assertThat(byType(report, "portal").influence()).isZero(); + + assertThat(report.byType()).extracting(TypeBreakdown::type).startsWith("neoforge:block", "player"); + assertThat(report.suspects().getFirst().reason()).contains("keeping 25 chunks loaded"); + } + + @Test + void withoutAttributionInfluenceIsReportedAsUnknownRatherThanZero() { + LevelReport report = Fixtures.levelReport(false); + + assertThat(report.attributionRan()).isFalse(); + assertThat(report.byType()).allSatisfy(type -> assertThat(type.influence()).isEqualTo(-1)); + assertThat(report.suspects().getFirst().reason()).doesNotContain("keeping"); + assertThat(report.unattributed()).isEmpty(); + } + + @Test + void hotspotsGroupLoadedChunksIntoClustersLargestFirst() { + List hotspots = Fixtures.levelReport(true).hotspots(); + + assertThat(hotspots).hasSize(2); + Hotspot leak = hotspots.getFirst(); + assertThat(leak.chunks()).isEqualTo(25); + assertThat(leak.bounds()).isEqualTo("98, 98 .. 102, 102"); + assertThat(leak.center()).isEqualTo("100, 100"); + assertThat(leak.blockPos()).isEqualTo("1608 ~ 1608"); + assertThat(leak.causes()).containsExactly("neoforge:block x25"); + + assertThat(hotspots.get(1).chunks()).isEqualTo(9); + assertThat(hotspots.get(1).causes()).containsExactly("player x9"); + } + + @Test + void withoutAttributionHotspotCausesFallBackToTheTicketsInsideTheCluster() { + List hotspots = Fixtures.levelReport(false).hotspots(); + + assertThat(hotspots.getFirst().causes()) + .containsExactlyInAnyOrder("neoforge:block x1", "forced x1"); + } + + @Test + void clustersSmallerThanFourChunksAreNotWorthReporting() { + var chunks = new ArrayList<>(Fixtures.square(0, 0, 1, 33, "FULL")); // 9 chunks + chunks.addAll(Fixtures.square(900, 900, 0, 33, "FULL")); // a single lonely chunk + + LevelReport report = + LevelAnalysis.analyse("d", 0, List.of(), chunks, Fixtures.MAX_LEVEL, false, List.of()); + + assertThat(report.hotspots()).extracting(Hotspot::chunks).containsExactly(9); + } + + @Test + void chunksNoTicketExplainsAreListedSeparately() { + var chunks = new ArrayList<>(Fixtures.chunks()); + chunks.add(new ChunkRecord(ChunkKey.of(9999, 9999), 33, "FULL")); + + LevelReport report = LevelAnalysis.analyse( + "d", 0, Fixtures.tickets(), chunks, Fixtures.MAX_LEVEL, true, List.of()); + + // Everything else is explained; only the orphan is left over. + assertThat(report.unattributed()).containsExactly("9999, 9999"); + } + + @Test + void originsAreReportedOnlyWhileTheirTicketsAreStillAlive() { + LevelReport withLiveTicket = Fixtures.levelReport(false); + assertThat(withLiveTicket.origins()).hasSize(1); + var origin = withLiveTicket.origins().getFirst(); + assertThat(origin.id()).isEqualTo(Fixtures.LEAK_ORIGIN); + assertThat(origin.mod()).isEqualTo("leakymod"); + assertThat(origin.live()).isEqualTo(1); + assertThat(origin.adds()).isEqualTo(42); + + LevelReport ticketGone = LevelAnalysis.analyse( + "d", 0, List.of(Fixtures.playerTicket()), Fixtures.chunks(), Fixtures.MAX_LEVEL, false, Fixtures.origins()); + assertThat(ticketGone.origins()).isEmpty(); + } + + @Test + void anEmptyLevelProducesAnEmptyReportRatherThanFailing() { + LevelReport report = + LevelAnalysis.analyse("minecraft:the_end", 0, List.of(), List.of(), Fixtures.MAX_LEVEL, true, List.of()); + + assertThat(report.totals().trackedChunks()).isZero(); + assertThat(report.byType()).isEmpty(); + assertThat(report.suspects()).isEmpty(); + assertThat(report.hotspots()).isEmpty(); + assertThat(report.unattributed()).isEmpty(); + assertThat(report.attributionRan()).isTrue(); + } +} diff --git a/src/test/java/dev/chunkinspector/analysis/LevelPropagationTest.java b/src/test/java/dev/chunkinspector/analysis/LevelPropagationTest.java new file mode 100644 index 0000000..b6c802a --- /dev/null +++ b/src/test/java/dev/chunkinspector/analysis/LevelPropagationTest.java @@ -0,0 +1,100 @@ +package dev.chunkinspector.analysis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.chunkinspector.analysis.LevelPropagation.Result; +import dev.chunkinspector.analysis.LevelPropagation.Source; +import java.util.List; +import org.junit.jupiter.api.Test; + +class LevelPropagationTest { + + private static final int MAX = 33; + + private static Result solve(Source... sources) { + return LevelPropagation.solve(List.of(sources), MAX); + } + + @Test + void noSourcesReachNothing() { + Result result = LevelPropagation.solve(List.of(), MAX); + assertThat(result.reachedChunks()).isZero(); + assertThat(result.levelOf(ChunkKey.of(0, 0))).isEqualTo(Integer.MAX_VALUE); + assertThat(result.winnerOf(ChunkKey.of(0, 0))).isEqualTo(Result.NONE); + } + + @Test + void levelGrowsWithChebyshevDistance() { + Result result = solve(new Source(ChunkKey.of(0, 0), 31)); + + assertThat(result.levelOf(ChunkKey.of(0, 0))).isEqualTo(31); + assertThat(result.levelOf(ChunkKey.of(1, 1))).isEqualTo(32); // diagonal is distance 1 + assertThat(result.levelOf(ChunkKey.of(2, 0))).isEqualTo(33); + assertThat(result.levelOf(ChunkKey.of(-2, 2))).isEqualTo(33); + assertThat(result.levelOf(ChunkKey.of(3, 0))).isEqualTo(Integer.MAX_VALUE); // 34 > MAX + // A ticket at level 31 with MAX 33 covers a 5x5 square and nothing more. + assertThat(result.reachedChunks()).isEqualTo(25); + } + + @Test + void everyReachedChunkNamesItsSource() { + Result result = solve(new Source(ChunkKey.of(7, -7), 32)); + + assertThat(result.winnerOf(ChunkKey.of(7, -7))).isZero(); + assertThat(result.winnerOf(ChunkKey.of(8, -6))).isZero(); + assertThat(result.winnerOf(ChunkKey.of(9, -7))).isEqualTo(Result.NONE); + } + + @Test + void theStrongerTicketWinsEachChunkIndependently() { + // A weak ticket at the origin and a strong one four chunks east. Chunks in between belong to + // whichever actually explains them, which is the whole point of tracking a winner at all. + Result result = solve(new Source(ChunkKey.of(0, 0), 32), new Source(ChunkKey.of(4, 0), 30)); + + assertThat(result.levelOf(ChunkKey.of(0, 0))).isEqualTo(32); + assertThat(result.winnerOf(ChunkKey.of(0, 0))).isZero(); + + assertThat(result.levelOf(ChunkKey.of(2, 0))).isEqualTo(32); // 30 + 2 beats 32 + 2 + assertThat(result.winnerOf(ChunkKey.of(2, 0))).isEqualTo(1); + + assertThat(result.levelOf(ChunkKey.of(4, 0))).isEqualTo(30); + assertThat(result.winnerOf(ChunkKey.of(4, 0))).isEqualTo(1); + } + + @Test + void tiesGoToTheFirstSourceSoResultsAreDeterministic() { + Result result = solve(new Source(ChunkKey.of(0, 0), 31), new Source(ChunkKey.of(0, 0), 31)); + + assertThat(result.winnerOf(ChunkKey.of(0, 0))).isZero(); + assertThat(result.winnerOf(ChunkKey.of(1, 0))).isZero(); + } + + @Test + void ticketsWeakerThanMaxLevelReachNothing() { + Result result = solve(new Source(ChunkKey.of(0, 0), MAX + 1)); + assertThat(result.reachedChunks()).isZero(); + } + + @Test + void negativeLevelsAreClampedLikeVanilla() { + // A region ticket with a radius above 33 produces a negative level; before clamping this + // indexed a bucket array out of bounds. + Result result = solve(new Source(ChunkKey.of(0, 0), -5)); + + assertThat(result.levelOf(ChunkKey.of(0, 0))).isZero(); + assertThat(result.levelOf(ChunkKey.of(1, 0))).isEqualTo(1); + assertThat(result.levelOf(ChunkKey.of(MAX, 0))).isEqualTo(MAX); + assertThat(result.levelOf(ChunkKey.of(MAX + 1, 0))).isEqualTo(Integer.MAX_VALUE); + } + + @Test + void aChunkQueuedThenSupersededKeepsTheStrongerAnswer() { + // Source 0 is discovered first and queues (5, 0) at level 33; source 1 later claims the same + // chunk at level 31. The stale queue entry must not overwrite it. + Result result = solve(new Source(ChunkKey.of(0, 0), 28), new Source(ChunkKey.of(5, 0), 31)); + + assertThat(result.levelOf(ChunkKey.of(5, 0))).isEqualTo(31); + assertThat(result.winnerOf(ChunkKey.of(5, 0))).isEqualTo(1); + assertThat(result.levelOf(ChunkKey.of(4, 0))).isEqualTo(32); + } +} diff --git a/src/test/java/dev/chunkinspector/origin/ModAttributionTest.java b/src/test/java/dev/chunkinspector/origin/ModAttributionTest.java new file mode 100644 index 0000000..9cc5aa7 --- /dev/null +++ b/src/test/java/dev/chunkinspector/origin/ModAttributionTest.java @@ -0,0 +1,35 @@ +package dev.chunkinspector.origin; + +import static org.assertj.core.api.Assertions.assertThat; + +import example.mod.LeakyChunkLoader; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ModAttributionTest { + + @Test + void platformModulesAreNeverReportedAsMods() { + // "java.base" would be a technically correct module name and a useless answer, so the JDK's + // own modules are rejected and the lookup falls through instead. + assertThat(List.class.getModule().getName()).isEqualTo("java.base"); + assertThat(ModAttribution.of(List.class)).isEqualTo("unknown"); + } + + @Test + void classpathClassesFallBackToTheirCodeSource() { + // Loaded from an exploded directory, so this exercises the trailing-slash path that a mod + // loaded from a folder rather than a jar would also take. + assertThat(ModAttribution.of(LeakyChunkLoader.class)).isNotBlank().isNotEqualTo("unknown"); + } + + @Test + void aMissingClassIsNotAnError() { + assertThat(ModAttribution.of(null)).isEqualTo("unknown"); + } + + @Test + void repeatedLookupsAreCachedAndStable() { + assertThat(ModAttribution.of(LeakyChunkLoader.class)).isEqualTo(ModAttribution.of(LeakyChunkLoader.class)); + } +} diff --git a/src/test/java/dev/chunkinspector/origin/OriginRegistryTest.java b/src/test/java/dev/chunkinspector/origin/OriginRegistryTest.java new file mode 100644 index 0000000..0cb596b --- /dev/null +++ b/src/test/java/dev/chunkinspector/origin/OriginRegistryTest.java @@ -0,0 +1,87 @@ +package dev.chunkinspector.origin; + +import static org.assertj.core.api.Assertions.assertThat; + +import example.mod.LeakyChunkLoader; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Lives in {@code dev.chunkinspector.origin} on purpose: the registry drops frames from its own + * package, so the test's own frames disappear from the capture and the recorded stack is exactly + * what a real mod's would be. + */ +class OriginRegistryTest { + + @Test + void idsAreTwelveHexCharactersAndDependOnTheWholeStack() { + String a = OriginRegistry.idOf(List.of("com.example.Foo.bar:12")); + String b = OriginRegistry.idOf(List.of("com.example.Foo.bar:13")); + String c = OriginRegistry.idOf(List.of("com.example.Foo.bar:12", "com.example.Foo.baz:99")); + + assertThat(a).matches("[0-9a-f]{12}"); + assertThat(a).isEqualTo(OriginRegistry.idOf(List.of("com.example.Foo.bar:12"))); + assertThat(a).isNotEqualTo(b).isNotEqualTo(c); + assertThat(OriginRegistry.idOf(List.of())).matches("[0-9a-f]{12}"); + } + + @Test + void frameOrderMattersSoTwoPathsIntoTheSameMethodStaySeparate() { + assertThat(OriginRegistry.idOf(List.of("a:1", "b:2"))) + .isNotEqualTo(OriginRegistry.idOf(List.of("b:2", "a:1"))); + } + + @Test + void repeatedAddsFromOneCallSiteFoldIntoOneOrigin() { + var registry = new OriginRegistry(); + assertThat(registry.isEmpty()).isTrue(); + + List ids = new LeakyChunkLoader(registry).forceLoad("neoforge:block", 3); + + assertThat(ids).hasSize(3).containsOnly(ids.getFirst()); + assertThat(registry.snapshot()).singleElement().satisfies(origin -> { + assertThat(origin.id()).isEqualTo(ids.getFirst()); + assertThat(origin.adds()).isEqualTo(3); + assertThat(origin.types()).containsExactly("neoforge:block"); + assertThat(origin.stack()).isNotEmpty(); + // Our own frames and the ticket plumbing are gone; the mod's frame is on top. + assertThat(origin.stack().getFirst()).startsWith("example.mod.LeakyChunkLoader.forceLoad:"); + assertThat(origin.stack()).noneMatch(frame -> frame.startsWith("dev.chunkinspector.")); + assertThat(origin.mod()).isNotBlank(); + }); + } + + @Test + void distinctCallSitesGetDistinctOrigins() { + var registry = new OriginRegistry(); + var loader = new LeakyChunkLoader(registry); + + String fromLoop = loader.forceLoad("forced", 1).getFirst(); + String fromOnce = loader.loadOnce("forced"); + + assertThat(fromLoop).isNotEqualTo(fromOnce); + assertThat(registry.snapshot()).hasSize(2).extracting(Origin::id).containsExactlyInAnyOrder(fromLoop, fromOnce); + } + + @Test + void oneCallSiteCreatingSeveralTicketTypesRecordsThemAll() { + var registry = new OriginRegistry(); + var loader = new LeakyChunkLoader(registry); + + loader.loadOnce("neoforge:block"); + loader.loadOnce("neoforge:entity"); + + assertThat(registry.snapshot()).singleElement().satisfies(origin -> { + assertThat(origin.adds()).isEqualTo(2); + assertThat(origin.types()).containsExactlyInAnyOrder("neoforge:block", "neoforge:entity"); + }); + } + + @Test + void aFreshRegistryContributesNothingToAReport() { + var registry = new OriginRegistry(); + + assertThat(registry.isEmpty()).isTrue(); + assertThat(registry.snapshot()).isEmpty(); + } +} diff --git a/src/test/java/dev/chunkinspector/report/ReportWriterTest.java b/src/test/java/dev/chunkinspector/report/ReportWriterTest.java new file mode 100644 index 0000000..59c8eb4 --- /dev/null +++ b/src/test/java/dev/chunkinspector/report/ReportWriterTest.java @@ -0,0 +1,120 @@ +package dev.chunkinspector.report; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.chunkinspector.Fixtures; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ReportWriterTest { + + private static List names(Path directory) throws IOException { + try (Stream files = Files.list(directory)) { + return files.map(p -> p.getFileName().toString()).sorted().toList(); + } + } + + @Test + void writesUnderChunkSummariesInsideTheLevelDirectory(@TempDir Path levelDirectory) throws IOException { + var writer = new ReportWriter(levelDirectory, 8); + + Path written = writer.write(Fixtures.report(false, true), "20260731-101530", true); + + assertThat(writer.directory()).isEqualTo(levelDirectory.resolve("chunk-summaries")); + assertThat(written).isEqualTo(writer.directory().resolve("summary-20260731-101530-command.json")); + assertThat(names(writer.directory())) + .containsExactly("latest.json", "latest.txt", "summary-20260731-101530-command.json"); + } + + @Test + void theJsonRoundTripsBackIntoTheSameReport(@TempDir Path levelDirectory) throws IOException { + var writer = new ReportWriter(levelDirectory, 8); + InspectionReport original = Fixtures.report(true, true); + + Path written = writer.write(original, "20260731-101530", false); + + InspectionReport parsed = + new Gson().fromJson(Files.readString(written, StandardCharsets.UTF_8), InspectionReport.class); + assertThat(parsed).isEqualTo(original); + } + + @Test + void latestAlwaysMirrorsTheNewestSnapshot(@TempDir Path levelDirectory) throws IOException { + var writer = new ReportWriter(levelDirectory, 8); + + writer.write(Fixtures.report(false, false), "20260731-101530", true); + writer.write(Fixtures.report(true, true), "20260731-101630", true); + + JsonObject latest = JsonParser + .parseString(Files.readString(writer.directory().resolve("latest.json"), StandardCharsets.UTF_8)) + .getAsJsonObject(); + assertThat(latest.getAsJsonObject("settings").get("deep").getAsBoolean()).isTrue(); + assertThat(latest.get("schema").getAsInt()).isEqualTo(InspectionReport.SCHEMA); + assertThat(Files.readString(writer.directory().resolve("latest.txt"))).contains("minecraft:overworld"); + } + + @Test + void onlyTheNewestSnapshotsAreKept(@TempDir Path levelDirectory) throws IOException { + var writer = new ReportWriter(levelDirectory, 2); + + for (int minute = 0; minute < 5; minute++) { + writer.write(Fixtures.report(false, false), "20260731-1015%02d".formatted(minute), false); + } + + assertThat(names(writer.directory())).containsExactly( + "latest.json", "summary-20260731-101503-command.json", "summary-20260731-101504-command.json"); + } + + @Test + void twoSnapshotsInTheSameSecondBothSurvive(@TempDir Path levelDirectory) throws IOException { + var writer = new ReportWriter(levelDirectory, 8); + + Path first = writer.write(Fixtures.report(false, false), "20260731-101530", false); + Path second = writer.write(Fixtures.report(true, true), "20260731-101530", false); + + assertThat(second).isNotEqualTo(first); + assertThat(names(writer.directory())).containsExactly( + "latest.json", "summary-20260731-101530-command-2.json", "summary-20260731-101530-command.json"); + } + + @Test + void retentionOfZeroKeepsEverything(@TempDir Path levelDirectory) throws IOException { + var writer = new ReportWriter(levelDirectory, 0); + + for (int minute = 0; minute < 3; minute++) { + writer.write(Fixtures.report(false, false), "20260731-1015%02d".formatted(minute), false); + } + + assertThat(names(writer.directory())).hasSize(4); // three snapshots plus latest.json + } + + @Test + void noTemporaryFilesSurviveAWrite(@TempDir Path levelDirectory) throws IOException { + var writer = new ReportWriter(levelDirectory, 8); + + writer.write(Fixtures.report(true, true), "20260731-101530", true); + + assertThat(names(writer.directory())).noneMatch(name -> name.endsWith(".tmp")); + } + + @Test + void overwritingLatestRepeatedlyNeverLeavesItTruncated(@TempDir Path levelDirectory) throws IOException { + var writer = new ReportWriter(levelDirectory, 4); + Path latest = levelDirectory.resolve("chunk-summaries").resolve("latest.json"); + + for (int i = 0; i < 4; i++) { + writer.write(Fixtures.report(false, true), "20260731-10150%d".formatted(i), true); + assertThat(new Gson().fromJson(Files.readString(latest), InspectionReport.class)) + .isEqualTo(Fixtures.report(false, true)); + } + } +} diff --git a/src/test/java/dev/chunkinspector/report/TextDigestTest.java b/src/test/java/dev/chunkinspector/report/TextDigestTest.java new file mode 100644 index 0000000..08b4fc7 --- /dev/null +++ b/src/test/java/dev/chunkinspector/report/TextDigestTest.java @@ -0,0 +1,60 @@ +package dev.chunkinspector.report; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.chunkinspector.Fixtures; +import dev.chunkinspector.analysis.LevelAnalysis; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TextDigestTest { + + @Test + void showsTheLeakWithoutTheAdministratorHavingToOpenTheJson() { + String digest = TextDigest.render(Fixtures.report(true, true)); + + assertThat(digest).contains( + "minecraft:overworld", + "34 loaded", + "25 entity-ticking", + "by ticket type", + "most suspicious tickets", + "loaded chunk clusters", + "ticket call sites"); + // The three things an administrator needs: what, where, and who. + assertThat(digest).contains("neoforge:block"); + assertThat(digest).contains("98, 98 .. 102, 102"); + assertThat(digest).contains("leakymod"); + } + + @Test + void influenceIsShownAsUnavailableRatherThanZeroWhenAttributionWasSkipped() { + String digest = TextDigest.render(Fixtures.report(true, false)); + + assertThat(digest).contains("influence n/a").doesNotContain("keeps 0 loaded"); + } + + @Test + void withoutDeepModeItSaysHowToGetCallSites() { + assertThat(TextDigest.render(Fixtures.report(false, true))) + .contains("-Dchunkinspector.deep=true"); + assertThat(TextDigest.render(Fixtures.report(true, true))) + .doesNotContain("-Dchunkinspector.deep=true"); + } + + @Test + void sectionsWithNothingInThemAreOmittedEntirely() { + var empty = new InspectionReport( + InspectionReport.SCHEMA, + "2026-07-31T10:15:30Z", + "periodic", + new InspectionReport.Settings(true, true, 600, 1), + List.of(LevelAnalysis.analyse( + "minecraft:the_nether", 0, List.of(), List.of(), Fixtures.MAX_LEVEL, true, List.of()))); + + String digest = TextDigest.render(empty); + + assertThat(digest).contains("minecraft:the_nether", "0 loaded"); + assertThat(digest).doesNotContain("by ticket type", "loaded chunk clusters", "ticket call sites"); + } +} diff --git a/src/test/java/example/mod/LeakyChunkLoader.java b/src/test/java/example/mod/LeakyChunkLoader.java new file mode 100644 index 0000000..dc66b5c --- /dev/null +++ b/src/test/java/example/mod/LeakyChunkLoader.java @@ -0,0 +1,33 @@ +package example.mod; + +import dev.chunkinspector.origin.OriginRegistry; +import java.util.ArrayList; +import java.util.List; + +/** + * Stands in for a third-party mod that adds chunk tickets. It deliberately lives outside the + * {@code dev.chunkinspector} package, because the registry filters our own frames out of every + * capture — a test calling the registry directly would leave nothing to attribute. + */ +public final class LeakyChunkLoader { + + private final OriginRegistry registry; + + public LeakyChunkLoader(OriginRegistry registry) { + this.registry = registry; + } + + /** Every add comes from the same line, so all of them must fold into one call site. */ + public List forceLoad(String ticketType, int times) { + var ids = new ArrayList(); + for (int i = 0; i < times; i++) { + ids.add(registry.record(ticketType)); + } + return ids; + } + + /** A second, distinct call site in the same class. */ + public String loadOnce(String ticketType) { + return registry.record(ticketType); + } +}