first commit

This commit is contained in:
iceBear67
2026-07-31 10:46:40 +00:00
commit ae8c4f5232
65 changed files with 4124 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
**/build/**
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
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
+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);
}
}