commit ae8c4f5232dcbf127e20d9d03172bc32fbac81f2 Author: iceBear67 Date: Fri Jul 31 10:45:51 2026 +0000 first commit 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 0000000..cd73c90 Binary files /dev/null and b/.gradle/8.10.2/checksums/checksums.lock differ 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 0000000..1c707b9 Binary files /dev/null and b/.gradle/8.10.2/checksums/md5-checksums.bin differ 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 0000000..913a221 Binary files /dev/null and b/.gradle/8.10.2/checksums/sha1-checksums.bin differ 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 0000000..7750255 Binary files /dev/null and b/.gradle/8.10.2/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.10.2/executionHistory/executionHistory.lock b/.gradle/8.10.2/executionHistory/executionHistory.lock new file mode 100644 index 0000000..82345bc Binary files /dev/null and b/.gradle/8.10.2/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.10.2/fileChanges/last-build.bin b/.gradle/8.10.2/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/8.10.2/fileChanges/last-build.bin differ diff --git a/.gradle/8.10.2/fileHashes/fileHashes.bin b/.gradle/8.10.2/fileHashes/fileHashes.bin new file mode 100644 index 0000000..009e5e1 Binary files /dev/null and b/.gradle/8.10.2/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.10.2/fileHashes/fileHashes.lock b/.gradle/8.10.2/fileHashes/fileHashes.lock new file mode 100644 index 0000000..5a0defd Binary files /dev/null and b/.gradle/8.10.2/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.10.2/fileHashes/resourceHashesCache.bin b/.gradle/8.10.2/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000..120d494 Binary files /dev/null and b/.gradle/8.10.2/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/8.10.2/gc.properties b/.gradle/8.10.2/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..c1c1e11 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..9c9da0f --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Fri Jul 31 09:53:09 UTC 2026 +gradle.version=8.10.2 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000..cde6619 Binary files /dev/null and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1db3866 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,131 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Toolchain + +Shell state does **not** persist between Bash calls. Source the toolchain in every command that runs +Gradle: + +```fish +source /home/user/tools/env.sh && ./gradlew +``` + +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 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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); + } +}