Files
chunkinspector/CLAUDE.md
T
2026-07-31 10:46:40 +00:00

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 .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

./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.