128 lines
5.6 KiB
Markdown
128 lines
5.6 KiB
Markdown
# 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.
|