6.7 KiB
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:
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.javafiles/home/user/mc-sources/neoforge/— 953 NeoForge.javafiles
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
./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.Ticketisfinal, soticket instanceof TicketOriginwill not compile even thoughTicketMixinmakes it true at runtime. Cast throughObject.OriginRegistryfiltersdev.chunkinspector.*frames out of every capture, so a test that callsrecord()from within that package captures nothing.src/test/java/example/mod/LeakyChunkLoaderexists to be an outside caller./forceload addtakes block coordinates, not chunk coordinates. The e2e script force-loads1600 1600 1663 1663to 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: 1means a silently non-applying injector fails the build at load time. Checkconsole.logforMixin apply failedwhen 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.