first commit

This commit is contained in:
iceBear67 committed 2026-07-31 10:46:40 +00:00
commit ae8c4f5232
65 files changed
+4124

No files matched your search

+1
View File
@@ -0,0 +1 @@
**/build/**
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Whitespace-only changes.
Binary file not shown.
@@ -0,0 +1,2 @@
#Fri Jul 31 09:53:09 UTC 2026
gradle.version=8.10.2
Binary file not shown.
View File
Whitespace-only changes.
+131
View File
@@ -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 <task>
```
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-<version>.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/<mode>/` 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 `<levelDir>/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-<stamp>-<trigger>.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.
+127
View File
@@ -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 `<levelDir>/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 [<chunkX> <chunkZ>]` | 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.
+137
View File
@@ -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 }
}
+156
View File
@@ -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/<mode> 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 <mode> <extra jvm properties...>
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"
+18
View File
@@ -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 <level>/chunk-summaries.
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+252
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+13
View File
@@ -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'
@@ -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<ServerRun> 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<TicketEntry> 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<Hotspot> 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());
}
}
@@ -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}.
*
* <p>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<Path> snapshotFiles() {
try (Stream<Path> files = Files.list(summaries())) {
return files.filter(p -> p.getFileName().toString().startsWith("summary-")).sorted().toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
List<InspectionReport> 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;
}
}
@@ -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.
*
* <p>Two tiers of instrumentation:
* <ul>
* <li><b>Always on.</b> 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.</li>
* <li><b>{@code -Dchunkinspector.deep=true}.</b> Adds the stack trace behind every ticket, for the
* cases where the key is opaque. Only then are the instrumenting Mixins applied at all.</li>
* </ul>
*/
@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());
}
}
@@ -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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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);
}
}
@@ -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.
*
* <p>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));
}
}
@@ -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.
*
* <p>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<Path> 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<Path> snapshot(MinecraftServer server, String trigger, boolean attribution) {
if (writer == null) {
return CompletableFuture.failedFuture(new IllegalStateException("no level directory yet"));
}
List<LevelCapture> captures = capture(server);
Collection<Origin> 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<LevelCapture> capture(MinecraftServer server) {
var captures = new ArrayList<LevelCapture>();
for (ServerLevel level : server.getAllLevels()) {
captures.add(LevelCapture.of(level));
}
return List.copyOf(captures);
}
static InspectionReport assemble(
List<LevelCapture> captures, Collection<Origin> 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());
}
}
@@ -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 <em>this</em> chunk loaded?".
*
* <p>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<Contribution> contributors) {
public boolean loaded(int maxLevel) {
return level <= maxLevel;
}
}
public Explanation explain(List<TicketRecord> tickets, long chunk, int maxLevel) {
List<Contribution> 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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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.
*
* <p>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<String> 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<TicketRecord> tickets;
private final List<ChunkRecord> chunks;
private final int maxLevel;
private final Collection<Origin> origins;
private LevelAnalysis(
String dimension, long gameTime, List<TicketRecord> tickets, List<ChunkRecord> chunks, int maxLevel, Collection<Origin> 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<TicketRecord> tickets,
List<ChunkRecord> chunks,
int maxLevel,
boolean attribution,
Collection<Origin> 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<String> 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<LevelPropagation.Source> 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<String>();
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<TypeBreakdown> breakdowns(Attribution attribution) {
SequencedMap<String, TypeAccumulator> 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<String, Boolean> 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<TicketEntry> 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<Hotspot> hotspots(Attribution attribution) {
var loaded = new LongOpenHashSet();
for (ChunkRecord chunk : chunks) {
if (!"INACCESSIBLE".equals(chunk.fullStatus())) {
loaded.add(chunk.chunk());
}
}
var clusters = new ArrayList<Hotspot>();
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<String> 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<String> topCauses = causes.object2IntEntrySet().stream()
.sorted(Comparator.comparingInt(Object2IntMap.Entry<String>::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<OriginEntry> originEntries() {
if (origins.isEmpty()) {
return List.of();
}
Map<String, Long> 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();
}
}
@@ -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 <em>which</em> 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.
*
* <p>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}.
*
* <p>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<Source> 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);
}
}
@@ -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);
}
}
@@ -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.
*
* <p>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<TicketRecord> tickets,
List<ChunkRecord> 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<TicketRecord>();
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<ChunkRecord>();
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);
}
}
@@ -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.
*
* <p>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);
};
}
}
@@ -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.
*
* <p>Stamping the <em>incoming</em> 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.
*
* <p>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())));
}
}
}
@@ -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.
*
* <p>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<String> 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<String> myTargets, Set<String> otherTargets) {}
@Override
public List<String> 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) {}
}
@@ -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;
}
}
@@ -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.
*
* <p>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();
}
@@ -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<String, String> 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;
}
}
}
@@ -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<String> types, List<String> stack) {}
@@ -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.
*
* <p>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<String> 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<String, Site> 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<Origin> 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<String> stack) {}
/** One walk yields both the rendered trace and the owning mod of its topmost frame. */
private Captured capture() {
return WALKER.walk(frames -> {
List<StackFrame> 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<String> 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<String> stack;
private final LongAdder adds = new LongAdder();
private final Set<String> types = ConcurrentHashMap.newKeySet();
Site(String mod, List<String> stack) {
this.mod = mod;
this.stack = stack;
}
}
}
@@ -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;
}
}
@@ -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<LevelReport> 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<TypeBreakdown> byType,
List<TicketEntry> suspects,
List<Hotspot> hotspots,
List<OriginEntry> origins,
List<String> 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<String> 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<String> 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<String> types,
List<String> stack) {}
}
@@ -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 <levelDir>/chunk-summaries}.
*
* <p>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<Path> files = Files.list(directory)) {
List<Path> 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;
}
}
@@ -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<String> 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";
}
}
@@ -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;
@@ -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"
]
}
@@ -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"
@@ -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<TicketRecord> tickets() {
return List.of(playerTicket(), leakTicket(), forcedTicket(), portalTicket());
}
public static List<ChunkRecord> chunks() {
var chunks = new ArrayList<ChunkRecord>();
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<ChunkRecord> square(int x, int z, int radius, int level, String fullStatus) {
var chunks = new ArrayList<ChunkRecord>();
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<Origin> 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)));
}
}
@@ -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<String> 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);
}
}
@@ -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));
}
}
}
}
@@ -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");
}
}
@@ -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<Hotspot> 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<Hotspot> 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();
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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<String> 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();
}
}
@@ -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<String> names(Path directory) throws IOException {
try (Stream<Path> 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));
}
}
}
@@ -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");
}
}
@@ -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<String> forceLoad(String ticketType, int times) {
var ids = new ArrayList<String>();
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);
}
}