first commit
This commit is contained in:
commit
ae8c4f5232
65 files changed
+4124
No files matched your search
@@ -0,0 +1 @@
|
||||
**/build/**
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Fri Jul 31 09:53:09 UTC 2026
|
||||
gradle.version=8.10.2
|
||||
Binary file not shown.
Whitespace-only changes.
@@ -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.
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
Executable
+156
@@ -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"
|
||||
@@ -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.
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user