RTP Code Tour — Behavioral Flow for Repair Work¶
Audience. Minecraft plugin developers at roughly CS-student level (and AI agents) whose job is to locate where RTP might need repair given a reported behavior — a laggy teleport, a stuck queue, a leaked chunk, a rejected safe spot, a cancelled
/rtpwith no feedback.This doc is not a reference. It is a guided walk through the diagrams in
docs/architecture/and the module graph inARCHITECTURE.md, narrating each arrow with what can go wrong there and which rule or ADR governs the answer. Detailed explanations live in the linked docs; read them only when the tour points you at them.Before you change code, run the pre-flight checklist in
.junie/AGENTS.md(S-00x rules, Folia threading,.bakpolicy).
How to use this tour¶
Pick the symptom closest to the reported bug, jump to that section, and follow the diagram arrows. Each step answers three questions:
- Where am I in the code? (module → class → method)
- What invariant must hold here? (S-00x rule, REQ-*, ADR)
- How does this step typically break? (the realistic failure mode)
If a section sends you to a sibling doc, treat that doc as optional deep-reading — return here when done.
Symptom → start here:
| Symptom | Section |
|---|---|
"/rtp takes seconds / blocks the server" |
§2 Teleport pipeline |
| "Queue never refills / always empty" | §3 Budgeted cache generator |
| "Chunks stay loaded forever / RAM grows" | §4 Chunk ticket lifecycle and §5 Active GC sweep |
"/rtp scan stalls, crashes, or never finishes" |
§6 Scan task crawler |
| "I don't know which module to edit" | §1 Module map |
| "Teleport silently does nothing / no error message" | §7 Failure attribution and user feedback |
"Folia throws ThreadAccessException / wrong region" |
§8 Folia threading gotchas |
| "Plugin doesn't start / missed WorldLoadEvent / integrations didn't hook" | §10 Plugin setup lifecycle |
"Why did /rtp pick that world/region? / override loop error" |
§11 How behaviors are decided |
"Why was this spot rejected? / too many vert / biome / safety misses" |
§12 Location selection per attempt |
"Changed a config value and it didn't take effect / /rtp reload leaks / messages stuck in English" |
§13 Configuration load and reload |
"Server stop hangs / cached locations lost after restart / leaked chunks on /stop" |
§14 Shutdown and flush lifecycle |
1. Module map — where does my change go?¶
The dependency graph (source: ARCHITECTURE.md) tells you the only legal direction code may flow:
rtp-api ──► rtp-core ──► rtp-plugin ◄── rtp-bukkit / rtp-paper / rtp-folia
│ (and rtp-core ──► rtp-fabric)
└──► addons/
Repair rule of thumb. Ask "is the bug behavior different on Folia vs Paper vs Spigot?"
- Same on every platform → the defect lives in
rtp-coreorrtp-api. Never putorg.bukkit.*imports there —RTPServerAccessoris the only bridge (see.junie/AGENTS.mdLogging & Feedback). - One platform only → the defect lives in that platform adapter. Do not "fix" it in core by branching on platform; push the difference behind an abstraction.
- Only in addons → the addon calls
rtp-apiincorrectly, orrtp-apineeds a new capability (propose via ADR first — seedocs/adr/README.md).
Deep read (optional):
ARCHITECTURE.mdfor the enforced boundaries,DESIGN.mdfor why they exist.
2. Teleport pipeline (end-to-end)¶
Canonical diagram: docs/architecture/01-teleport-execution-pipeline.md. Open it in a side window; each node below matches a state in that state-diagram.
The pipeline is four phases — Setup → Load → Teleport → Cleanup — embodied by TeleportPipelineTask in rtp-core/.../common/tasks/teleport/. Static action lists (setupPreActions, setupPostActions, loadPreActions, …) are the only supported extension points; addons register into them.
Walk the diagram with repair eyes:
-
CmdTrigger→QueryCache. The player runs/rtp. The command lives incommands-apiand the Bukkit dispatch inrtp-plugin/.../bukkit/commands/. Break: if the user sees nothing at all, the command probably failed parameter validation — see REQ-RTP-S-007 ("busy" / "invalid command" messages must be configurable viamessages.yml) and §7 below. -
QueryCachechoice node.RegionQueueManagerchecks whether a pre-validated location is already waiting. Break: if teleport is instant when the server is idle but slow under load, the region's queue is starving — jump to §3. -
cache_check → ReqTicket(hot path) /perm_check(cold path). Theunqueuedpermission lets a player skip the wait and trigger an ad-hoc search. Break: a player withunqueuedcausing server stutter is running an unbounded loop — ensure the shape is an Archimedean 1D mapping, not a reroll loop (ADR-001). -
GenRandom— SETUP stage. The shape (Circle,Square, …) emits(x,z)from its 1D index. This runs on an async worker. Break: non-uniform distribution usually means a customShapein an addon violates the 1D contract;MemoryShapecaching of bad indices can also mask it. -
ReqTicket— LOAD stage. Here the platform adapter is called to asynchronously acquire a chunk ticket. This is the single highest-risk step in the whole plugin: - S-005 — no synchronous chunk I/O on the main thread.
- S-002 — no permanently force-loaded chunks (use plugin chunk tickets, not
Chunk.setForceLoaded(true)). -
On pure Spigot,
BukkitRTPWorld.loadChunkFuturemay bounce to tick; seeDESIGN.md §rtp-bukkit Implementation Notesand ADR-016 for the Anvil pre-filter that avoids a load. -
EvalBlocks— TELEPORT-prep safety. Runs on the region-owning thread (Folia) or main thread (Paper/Spigot). Break: if a "safe" spot turns out to be in lava or a claim, inspectRegion.isSafeplus the verticalAdjustor and thebadLocationpredicates — see ADR-017. S-001 forbids unsafe-block destinations; S-003 forbids claim-protected land. Never add a "second" block check in an adapter — keep all checks inrtp-core. -
EvalBlocks → GenRandomretry /Teardownmax-retries. Bounded by configuration (maxAttempts). Break: silent exhaustion → see §7, and the regression guardReqRtpS004NullChunkAttributionTestmentioned in.junie/AGENTS.md. -
MovePlayer— Entity Scheduler. On Folia, player mutations go through the Entity Scheduler, not the Region Scheduler. Break: teleport "succeeds" but the player ends up at spawn → wrong scheduler or a pre/post-teleport event handler threw and the pipeline swallowed it (S-004 violation). -
Teardown— Cleanup phase.TeleportPipelineTask.runCleanup()releases the chunk reservation, untracks fromMemoryTracker, and decrementsinFlightCalculations. This must run on every exit path — normal, exception, disconnect, cancel. Jump to §4.
Deep read (optional):
DESIGN.md §Pipeline Phases,REQUIREMENTS.md §3for all S-00x.
3. Budgeted cache generator (queue refill)¶
Canonical diagram: docs/architecture/02-budgeted-cache-generator.md.
The queue is refilled by a separate pulse — selectionAPI.compute() — that wakes on a timer, walks every region in round-robin order, and spends at most one budget per tick/pulse. The budget is either time-bound (Spigot/Paper) or count-bound (Folia). Which one you get is not negotiable: Folia per-region ticks make wall-clock slicing non-deterministic.
Walk the diagram:
PulseTrigger → InitBudget → CheckBudget. Break: if a region never refills, the round-robinperiodgate (CheckPeriod) is probably too large, or an earlier region is eating the whole budget. Log the per-region spend inMemoryTracker.runDiagnostics().ExecuteRegion → SpawnWorker → PushQueue → WakePlayer. Players parked in the public queue (nodeQueueWaitin diagram 01) are unblocked here, not in/rtpitself. Break: "player waits forever even though queue just got a location" usually means the wake-up signal was missed — check the ordering ofPushQueueand the notification toQueueWait.YieldTask. When the budget is exhausted the pulse yields. Break: a runawaywhile-loop in a custom shape will never yield — S-005-adjacent and the reason bounded algorithms are mandated (see ADR-001).
Deep read (optional):
DESIGN.md §Pulse-Driven Maintenance.
4. Chunk ticket lifecycle¶
Canonical diagram: docs/architecture/03-chunk-ticket-lifecycle.md.
Every chunk the plugin touches must be framed by addPluginChunkTicket / removePluginChunkTicket plus a MemoryTracker.track / untrack pair. The diagram has three exit paths and all three converge on DropTicket → UntrackRes → RAM Freed:
- Happy path:
EvalBlocks → CloseResviatry-finally. - Stalled pipeline:
SweepTask → ForceClosefrom the background GC (see §5). - Player disconnect: the quit listener calls
reservation.close()for every in-flight task tied to the player.
Break patterns:
- Ticket acquired, exception thrown before the
finally— chunk leaks. S-002 violation risk. - Ticket closed twice (once in the happy path, once in a cleanup action) — adapter may log a warning or crash on some versions. The fix is idempotent
close(), never a null-guarded skip. Chunk.setForceLoaded(true)used instead of plugin tickets — permanent leak, not reclaimed on disable. Hard prohibition.
Deep read (optional):
DESIGN.md §Chunk Allocation Management,.junie/AGENTS.mdCode & Testing Conventions.
5. Active GC sweep¶
Canonical diagram: docs/architecture/04-active-gc-sweep.md.
RTP does not trust the happy path alone. A periodic async timer does two sweeps:
- Internal sweep — iterate
MemoryTracker's tracked reservations; ifage > timeout, force-close them, decrementinFlightCalculations, untrack. - Native sweep — query the server for all chunk tickets owned by the plugin and drop any that the internal tracker doesn't know about. This catches orphans from code paths that forgot to
track().
Break patterns:
- Timeout too aggressive → healthy but slow pipelines are killed mid-teleport (manifests as S-004 silent discards unless the force-close routes through the proper failure attribution).
- Timeout too lax → real leaks accumulate for minutes before GC reclaims them.
- Native sweep disabled or broken → orphans accumulate silently; only the server RAM graph reveals it.
When debugging a "slow leak" complaint, enable MemoryTracker.runDiagnostics() output first, not Java heap dumps.
6. Scan task crawler¶
Canonical diagram: docs/architecture/05-scan-task-crawler.md.
/rtp scan is a separate long-lived async worker. It shares the safety checks with the teleport pipeline but has its own throttle (inFlightGate) and its own wrap-up (checkpointing to disk via .scan).
Walk it when:
- Scan stalls at N% forever.
DrainGateis waiting on chunk futures that never complete. Inspect the adapter'sgetOrLoadChunk— on pure Spigot, uncovered by the Anvil pre-filter, it bounces to the main thread and can starve under heavy TPS drop (ADR-016). - Scan disagrees with live teleport. The scan uses
AnvilBiome(off-tick pre-filter) while the teleport pipeline may use live generation — seeisSelfContained()branch in the diagram. For the deeper rationale of the pre-filter and what it can/cannot decide without a load, read ADR-016. - Scan looks like it leaks. It shouldn't — it goes through the same ticket lifecycle from §4. If it does, look at the
DrainGatepath: releases happen in the callback, so an exception inVertAdjust/PhysBiomebeforeReleaseGateleaks a permit and a ticket.
7. Failure attribution and user feedback¶
Two absolute rules converge here:
- S-004 — no silently discarded teleport failures. Every pipeline exit that is not a successful teleport must attribute the failure (there is a
FailTypesenum and aFailTypes.nullChunkpath guarded byReqRtpS004NullChunkAttributionTest). - S-007 + REQ-RTP-F-013 — all user-facing messages (including "busy" and "invalid command") are configurable via
messages.yml. Never hardcode strings in a command or adapter.
Where to look when a user reports "nothing happens":
- Command layer (
commands-api, BukkitBukkitBaseRTPCmdand friends). Platform overrides ofmsgInvalidCommand/msgBadParametermust callRTP.log(Level.WARNING, msg)— this is required for auditing and forrtp test fullto observe the failure (see.junie/AGENTS.mdLogging & Feedback). - Pipeline cleanup (
runCleanup()) — did the failure reach an action list that reports to the player? Silentreturninside a phase is the classic S-004 violation. - Config loader — a missing or malformed
messages.ymlkey reads as empty string. Check the fallback.
Deep read (optional):
TRACEABILITY.mdrowREQ-RTP-F-013,REQUIREMENTS.md §3for S-004/S-007.
8. Folia threading gotchas¶
Folia splits the world into regions, each owned by a dedicated thread. Calling the wrong API on the wrong thread throws ThreadAccessException. The short checklist:
- Before scheduling:
Bukkit.isOwnedByCurrentRegion(entity|location). If yes, run inline; if no,RegionScheduler.run(plugin, location, task). - Player mutations (teleport, inventory) — Entity Scheduler, not Region.
- Vault / economy (
withdraw,deposit,getBalance) — Global Region Scheduler or Async Scheduler. Region threads throw. - Task pipelines — Count-Bound only on Folia (
CountBoundTaskPipe). Time-Bound is permitted on Spigot/Paper only. - Database — enabled on all platforms; on Folia it runs via
RTP.scheduler.runTaskTimerAsynchronously.
When a bug reproduces only on Folia, the answer is almost always one of the above.
Deep read (optional):
DESIGN.md §rtp-folia Implementation Notes,.junie/AGENTS.mdFolia Threading.
9. Your first repair — checklist¶
- Reproduce locally (or write a failing test — see
COVERAGE_PLAN.md). - Identify the section above that matches the symptom.
- From the diagram, name the arrow where behavior diverges from the spec.
- Find the enclosing S-00x rule / REQ-* via
TRACEABILITY.md. - Edit the correct module per §1. Before editing any uncommitted code file, make a
.bakcopy (.junie/AGENTS.mdBackup Policy). - Add or extend a REQ-traceable test (class name or
@DisplayNamereferencingREQ-*/S-00x) and updateTRACEABILITY.mdif new. - Run the targeted test via
.\gradlew :<module>:test --tests "<pattern>"(PowerShell,;not&&). - If the change crosses a module boundary or touches more than one class, stop and propose first per
.junie/AGENTS.mdPropose Before Refactoring.
10. Plugin setup lifecycle¶
Canonical diagram: docs/architecture/06-plugin-setup-lifecycle.md. Entry class: RTPBukkitPlugin (rtp-plugin/.../bukkit/RTPBukkitPlugin.java).
Startup is the one path that is not repeated at runtime, so bugs here look like "plugin silently half-enabled" rather than a pipeline failure. Read the diagram, then use the following repair lenses:
onLoadfail-fast. Only SQLite JDBC is probed; missing JDBC throwsIllegalStateExceptionbefore Bukkit callsonEnable. If you see no RTP log lines at all, check the server log for that exception first.- Reflective accessor wiring (
BukkitServerProvider.resolveServerModel→Class.forName(serverModel.accessorClassName)). AClassNotFoundException/NoSuchMethodExceptionhere bails out viaonDisable()from insideonEnable. Symptom: "plugin shows as enabled in/plbut every command says unknown". Fix: confirm the platform detection (isPaper()/isFolia()) resolved the expected model, and that the adapter JAR for that platform is on the classpath. - Synchronous event registration.
setupBukkitEvents()is deliberately called in-line, not viarunTaskLater(..., 1). The git history onOnWorldLoadUnloadrecords why: Multiverse-style generators fireWorldLoadEventon tick 1, and a deferred listener missed them, leaving dormant regions for late-loaded worlds unbound. If you ever feel tempted to "clean this up" by deferring, don't — read the Javadoc onOnWorldLoadUnload.rebindFallbackRegionsForAllLoadedWorldsfirst. - Startup tasks drained three times.
RTP.startupTasks.execute(MAX)is invoked eagerly, then on tick 1 (viarunTaskLater), then again after integrations/effects register — because integrations can push new startup tasks. If a feature "works after/rtp reloadbut not on a fresh boot", it's probably a startup task registered too late to be drained. - Deferred integrations (
setupIntegrations,setupEffects). These run on tick 1 so that other plugins have completed their ownonEnable. If a claim-plugin integration is missing, the usual cause is that the claim plugin enabled after RTP's tick-1 hook; check load-order inplugin.yml(softdepend). ChunkUnloadProcessoris non-Folia only. On Folia, per-region tick scheduling handles chunk lifetime. If a new chunk-unload bug reproduces only on Spigot/Paper, this timer is where to look.- Shutdown path.
onDisablecancels all RTP-owned Bukkit tasks, kills each subsystem processor (AsyncTeleportProcessing,SyncTeleportProcessing,ScanTaskProcessing,DatabaseProcessing), then callsRTP.stop(). Every allocator that ran duringonEnablemust have a matching release here — otherwise a/rtp reloador a server stop leaks state. SeeLESSONS_LEARNED.mdfor prior shutdown-flush pitfalls.
Deep read (optional):
DESIGN.mdfor the platform-adapter split,LESSONS_LEARNED.mdfor database / shutdown-flush / command-pipeline pitfalls.
11. How behaviors are decided¶
Canonical diagram: docs/architecture/07-rtp-command-region-selection.md. Scope: the /rtp + /wild command path through SelectionAPI.getRegion(player) — other behavior paths (onEvent auto-teleport, tempRegion, /rtp scan) have their own entry points and are not covered by this diagram. Entry class: SelectionAPI.getRegion(RTPPlayer) in rtp-core/.../common/selection/SelectionAPI.java.
The key mental model: RTP's behavior is data, not code. From the moment the player issues /rtp, every subsequent choice (which world, which region, which shape, which vertical adjustor, cache size, price, whether to queue or search ad-hoc) is read from configuration and permission nodes. rtp-core never branches on world or region name.
Walk the diagram as a repair tool:
- Command layer. Parameter validation failures route to
msgBadParameter; a server already saturated with in-flight calculations routes tomsgBusy. Both strings are configurable (S-007, REQ-RTP-F-013). If a user says "nothing happens", this is the first lens — see also §7. - World resolution loop.
WorldKeys.requirePermission+WorldKeys.overrideform a chain: if the player lacksrtp.worlds.<name>, the world falls back to the configured override. ASet<String> worldsAttemptedguard throwsIllegalStateException("infinite override loop detected at world - ...")on cycle — this is not an S-004 violation; it is a configuration bug surfaced loudly on purpose. - Region resolution loop. Identical structure with
RegionKeysandrtp.regions.<name>. Same cycle guard, same exception. The region key chosen at the end of the world loop is the starting region for this loop. - Queue vs ad-hoc search.
rtp.unqueuedis the pivot. Without it, the player waits on the public queue (diagram 02); with it, an ad-hoc async search is spawned immediately. Avoid grantingrtp.unqueuedbroadly — on large servers every holder can trigger a search, and only the 1D spiral guarantee (ADR-001) keeps that bounded. RegionSettingsis the leaf. Shape, vertical adjustor, cache cap, active chunk cap, price, spatial resolution, world-border override — all sourced fromregion.yml. If two regions "behave differently" for no apparent reason, diff theirRegionSettings, not their code paths.
Break patterns:
- "Player gets teleported to the wrong world." Almost always a
WorldKeys.overridechain that quietly redirects. Dump theworldsAttemptedset by enabling verbose logging, or traceSelectionAPI.getRegion(player)by hand. - "
IllegalStateException: infinite override loop." A config author wrote a cycle (e.g.,world_nether.override: worldandworld.override: world_netherboth withrequirePermission: true). Fix the config; do not catch the exception. - "Shape feels clustered." Not a decision-tree bug — see §2 step 4 and ADR-001.
- "Effects don't fire." Effects are a separate decision tree driven by
rtp.effect.<stage>.*permissions; seteffectParsing: trueinperformance.yml. Seedocs/admin/EVENTS_AND_EFFECTS.md. - "I want one command to use a custom shape." Use
SelectionAPI.tempRegion(params, baseRegionName)— it clones a base region and overrides specificRegionKeys. Never subclassRegionin an addon.
Deep read (optional):
REQUIREMENTS.md §3(S-007 configurable messages),docs/admin/EVENTS_AND_EFFECTS.md,GLOSSARY.mdfor the canonical meaning of region vs world vs shape.
12. Location selection (per attempt)¶
Canonical diagram: docs/architecture/08-location-selection-per-attempt.md. Entry class: PregenTask.runAttempt in rtp-core/.../common/selection/region/PregenTask.java, called via LocationGenerator.getLocationFuture.
This is the decision core of the plugin. Every caller you met earlier — the /rtp command (§11), the cache generator (§3), /rtp scan (§6) — ends up asking ILocationGenerator for a coordinate, and that coordinate comes out of this loop. If it emits a bad (x, y, z) or emits one too slowly, everything downstream looks broken. That is why it gets its own diagram separate from the outer attempt-loop plumbing.
Mental model: one attempt = pick → probe → resolve → evaluate → accept or recycle. The loop is orchestrated by PregenTask as a non-blocking state machine (ADR-015 Option B) so the async worker is never parked on .get().
Walk the diagram as a repair tool:
- Cap check.
i > maxAttemptsorbiomeChecks >= maxBiomeChecks→completeExhausted. Break: emptyGenerationResultwith no logged reason almost always meansmaxBiomeCheckswas hit silently. Dumpstate.failMap(verbose) to see which bucket drained the budget. - Shape pick.
MemoryShape.rand()is the bounded Archimedean spiral (ADR-001). IfbiomeRecall: true, a prefix-sum weighted pick is used over biomes already seen;biomeRecallForced: falsesilently falls back to uniform when memory is empty. Break: "clustered spawns" → recall is on and has converged on a few tiles; toggle it off or lowercacheCapso the memory churns. - WorldBorder probe. If the candidate is outside,
worldBorderFails++andmaxAttempts++— cheap misses. Cap is 1000. Break: a region configured beyond the vanilla border will hit the cap and emit an empty result; checkworldBorderOverrideinregion.yml. - Probe-first chunk resolution.
world.getOrLoadChunk(cx, cz)walks cached → Anvil → live (ADR-016 §13.1). A null return is attributed toFailTypes.nullChunkwith a sub-reason (ticketFailed,chunkLoadTimeout,asyncLoadNull). Do not refactor this attribution — the regression guard isReqRtpS004NullChunkAttributionTest(S-004). - Self-contained vs live branch. If
chunk.isSelfContained()(Anvil), stay on the async thread. Otherwise hop to the region-owning thread viadispatchLiveEvaluation, which allocates theChunkReservation(§4) and arms the ADR-015 stale guard. This branch is where most S-005 violations hide when porting — if a new platform runs the live path on the wrong thread, you will seeThreadAccessExceptionon Folia or silent corruption on Paper. The Fabric blocker mentioned inMULTI_PLATFORM_PLAN.mdis exactly this. vert.adjust(chunk). Returns the(x, y, z)for a given chunk under the region's vertical adjustor (linear, nether-ceiling, etc.). Null = no valid y. Break: "teleports into lava at y=-64" or "always picks y=0" → wrong adjustor for the dimension; verifyvert:inregion.yml.- Biome filter.
biomeNames+biomeWhitelist. Misses incrementbiomeChecks(soft capmaxBiomeChecks) but alsomaxAttempts++, so biome filtering doesn't prematurely exhaust hard attempts. Break: "infinite misses" when targeting a rare biome → turn onbiomeRecallso the shape remembers hits; without it every attempt is uniform across the whole region. - Neighbour grid load. For
safetyRadius r, loads the(2r+1)²neighbour chunks viagetChunkAtwith a 5-secondorTimeout. Timeout or any null neighbour →FailTypes.nullChunk / neighborNull. Break: scans or caches that stall withneighborNullin verbose output point at a slow chunk backend — check the Anvil pre-filter wiring (ADR-016 §11). - Safety y-scan. Walks
±safetyRadiusaround(x, y, z), rejecting if any block is inunsafeBlocks. This is the S-001 enforcement point. Never add a second check in an adapter or command; all block safety lives here (and in thebadLocationpredicates, ADR-017). GlobalRegionVerifiers. Async chain that runs claim-plugin checks (S-003) and any custom verifiers addons have registered. Failure →FailTypes.safetyExternal. Break: "claim overlap still teleports me in" → a verifier is missing or throwing and being swallowed; the pipeline attributes the exception, but addons must register a verifier to enforce their claim system. Inline claim calls in the pipeline or commands are an S-003 violation.completeSuccess. Records the biome hit back intoMemoryShape, preloads aChunkSetof radiusmax(safetyRadius, performance.viewDistanceSelect), and transfers ownership viaGenerationResultso diagram 01'sStart LOADstage can hand it off without a second round-trip. Break: "player arrives and chunks pop in for a second" →viewDistanceSelectis too low, or theChunkSetwas dropped (checkMemoryTrackerdiagnostics, §4).
Recurring repair lens: almost every rejection calls MemoryShape.addBadLocation(finalL) so the spiral won't re-propose that 1D index. This is what makes vert / safety / safetyExternal failures self-limiting on a correctly-shaped region. If you see the same coordinate rejected twice within one cache fill, either the shape is not a MemoryShape (so it has no memory) or addBadLocation wasn't called on that path — audit the new branch.
Deep read (optional):
DESIGN.mdfor the pipeline-vs-generator split, ADR-015 for the non-blocking state machine, ADR-016 for the probe-first chain, ADR-001 for why the pick itself is bounded.
13. Configuration load and reload¶
Canonical diagram: docs/architecture/09-configuration-load-and-reload.md. Entry class: Configs in rtp-core/.../common/configuration/Configs.java; called from RTPBukkitPlugin.onEnable (first load) and from the /rtp reload command (subsequent reloads) via Configs.reload -> reloadAction -> reloadConfigs + reloadRegions.
This section is the one to open first for any bug where "the config says X but RTP acts as if Y". Most such reports resolve here before you ever touch the pipeline.
Mental model: RTP never mutates a ConfigParser in place. A reload builds a fresh set of parsers off to the side, then swaps the configParserMap / multiConfigParserMap fields atomically. Any task already holding a reference to an old parser keeps reading the old values until it finishes. This is by design — it makes mid-teleport reloads safe — but it is also why key changes sometimes appear to "not apply" for one cycle.
Walk the diagram as a repair tool:
FlushDB→CancelTasks.fileDatabase.processQueries(MAX)drains the write queue before reconnecting; then in-flight teleports are cancelled viaRTPTeleportCancelandprocessingPlayersis cleared. Break: if a reload crashes mid-teleport, someone bypassedConfigs.reloadand skipped the cancel step — always reload through the command orConfigs.reload.- New parsers (blue subgraph). Seven single-file parsers (
logging.yml,config.yml,messages.yml,economy.yml,performance.yml, plus thesafety/directory) and twoMultiConfigParsers (regions/,worlds/). Adding a new config category means adding an enum underconfiguration/enums/and a newConfigParser<...>line here — there is no registry, the list is deliberately explicit. - Locale bootstrap (ADR-020 / REQ-RTP-F-013). Before any
ConfigParseris constructed,LanguageBootstrapreadsplugins/RTP/language.yml(created withlanguage: enon first run) and returns a sanitized locale string. Each fresh parser is then built locale-aware: when the locale is non-en, the parser loadslang/<locale>/<name>.yml(and the optionallang/<locale>/<name>.lang.ymlkey-name remap) directly from the jar with English fallback. Break: "messages still in English despitelanguage: deinlanguage.yml" → the correspondinglang/de/messages.ymljar resource is missing, orlanguage.ymlitself was deleted (it will be recreated asenon next reload). Note: editinglanguageinconfig.ymlhas no effect — that key was removed. - Atomic swap (green).
this.configParserMap = newConfigParserMap;is where new readers start seeing new values. In-flight tasks that captured the old map earlier in their lifetime continue on the old values — this is not a bug, it is the reason reloads are non-disruptive. ShutRegions. EveryRegioninpermRegionLookupandtempRegionshasshutDown()called before the maps are cleared. Break: "reload duplicated my region" → a third party inserted intopermRegionLookupafter the clear, orshutDownthrew on one entry and bailed out of the loop.BuildRegions→ Dormant decision. For eachregions/*.yml,RegionConfigLoader.loadproduces aRegionSettings, thendetectFallbackConfiguredWorldchecks whether the configured world is already loaded. Yes → live region. No → dormant region (world isnull, rebinds onWorldLoadEventviaOnWorldLoadUnload.rebindWorld). This is the Multiverse-compatibility path — see also §10 step 3. Break: "region never activates when my world loads" → confirm the world name matches, and thatOnWorldLoadUnloadis registered (diagram 07).ShapePick— deferred async. Each region's shape is selected onmiscAsyncTaskswith a 60-tick delay, and skipped entirely when the region is dormant or has no shape. Break: "shape is null on first attempt" after a reload → attempt fired before the 60-tick shape pick; this is expected, the next pulse will succeed.onReloadcallbacks. Integrations, effects, and anything else registered viaConfigs.onReload(Runnable)fires here. Break: "integration re-hooks on first load but not on reload" → the integration didn't register anonReloadcallback; it only hooked duringsetupIntegrationsinonEnable(diagram 07).
Common misreads:
- "Per-world setting ignored." Readers must go through
Configs.getWorldParserValue(worldName, key), which walks world-override chain → falls back to the globalConfigKeysparser. ReadingConfigKeysdirectly skips every per-world override. - "Per-region setting ignored." Same pattern with
getParser(RegionKeys.class)vs. the specific region'sConfigParser<RegionKeys>obtained through the region's ownRegionSettings.RegionSettingsis the materialized snapshot; mutate the YAML + reload, not the snapshot. - "
/rtp reloadordering." First-enable and reload take exactly the same path (reloadAction). If a bug reproduces on reload but not on fresh boot, the difference is in what was created since the boot — usually a region, a player cache, or an integration that didn't implementonReload. - "Override loop error on reload." That exception comes from the
SelectionAPI.getRegioncycle guard (diagram 08), not from this path. The config load is content-agnostic; cycles are only detected when something actually traverses the override chain.
Deep read (optional):
DESIGN.mdfor the per-world / per-region override resolution, ADR-020 for the locale bootstrap,LESSONS_LEARNED.mdfor prior reload / database-flush pitfalls.
14. Shutdown and flush lifecycle¶
Canonical diagram: docs/architecture/10-shutdown-and-flush-lifecycle.md. Entry classes: RTPBukkitPlugin.onDisable (rtp-plugin/.../bukkit/RTPBukkitPlugin.java) and RTP.stop() (rtp-core/.../common/RTP.java).
Shutdown is the symmetric partner of §10 Plugin setup lifecycle. Unlike a reload (§13), which reuses allocations, a shutdown must release every allocation made during onEnable. Two classes of bug dominate this path: data loss (cached locations not flushed before the DB stop flag is set) and resource leaks (chunk tickets not released — an S-002 violation).
Mental model: four phases — (1) stop accepting new work, (2) drain in-flight work, (3) persist state to disk, (4) release platform resources. The ordering between phases 3 and 4 is load-bearing.
Walk the diagram as a repair tool:
- Cancel command timers.
commandTimer.cancelandcommandProcessing.cancelstop new/rtpdispatches. Break: "a new/rtpran mid-disable and NPE'd" → a command listener was registered outsidesetupBukkitEventsand isn't cancelled here. - Kill the four task processors.
AsyncTeleportProcessing,SyncTeleportProcessing,ScanTaskProcessing,DatabaseProcessingeach have a statickill(). Each is wrapped incatch (NoClassDefFoundError ignored)because Bukkit can callonDisabletwice on init failure (see the bail-outs at lines 108/119 ofRTPBukkitPlugin). Do not remove those guards. RTP.stopenters. Completes every outstandingCompletableFuturewithnull, cancels in-flightTeleportDataviaRTPTeleportCancel. Break: "server hang on stop" → something is.get()-ing a future that was never added toRTP.futures; audit new async code for the registration step.- Database flush sequence — load-bearing ordering.
SQL flush → rebuildCachedLocationsFromMemory → flushDirtyCache → processQueries(MAX), all beforestop.set(true). Regression test:MemoryShapeShutdownTest. Break: "cached locations gone after restart" is almost always a reorder here.processQueriesbails immediately ifstopis already set, so setting the flag early silently drops the drain. SeeLESSONS_LEARNED.md§"Shutdown ordering". - Stop the task pipes and cancel tracked tasks.
miscAsyncTasks.stop+miscSyncTasks.stop+ schedulercancelTaskfor every entry intrackedTasks. Break: "a scheduled task kept running after disable" → it wasn't registered viaRTP.scheduler(which adds totrackedTasks); fix the registration, not this loop. - Region shutdown.
permRegionLookup.values().forEach(shutDown)then clear; same fortempRegions. A region'sshutDowncloses its own chunk reservations (see §4) and unregisters fromMemoryTracker. Break: "Folia warning about region threads" during shutdown → a region'sshutDowntouched entity state without a scheduler hop; route throughRTPScheduler. - Set the DB stop flag and close. Only now does
databaseAccessor.stop.set(true)+close()run. The flag gates new enqueues;closereleases the JDBC connection. Break: "DB file locked on next startup" → eitherclosethrew (check logs) or an asynchronous write was still in flight (means step 4 didn't fully drain). - Re-cancel any late
TeleportData. A second pass overlatestTeleportDatacatches any entries that were added during region shutdown. Both cancel passes are required. ScanTask.kill,networkManager.shutdown,serverAccessor.stop. Static registry clears and platform hooks. The network bus is now typed as theRTPNetworkManagerinterface (wasRedisManager); concreteRedisManageris constructed reflectively inRTP.createRedisNetworkManagersortp-corecarries no symbolic ref to the Jedis driver class (ADR-024). On Folia,serverAccessor.stopmust run on the global region scheduler — the accessor handles that internally; don't relocate the call.- Post-
RTP.stop— Bukkit-side cleanup. Cancel all RTP-owned asyncBukkitTasks that were still pending (belt-and-suspenders for tasks not intrackedTasks), write thereferenceDatasentinel row (zero-UUID + timestamp) so the next boot can tell a clean shutdown from a crash, and finallyreleaseAllChunkTickets.releaseAllChunkTicketsis the last durable action and is the S-002 enforcement point — if region shutdown throws and unwinds past it, tickets leak across the/reload.
Break patterns:
- "
/stophangs for a minute, then kills the JVM." A future thatCompleteFuturescan't reach, or aprocessQueries(MAX)that is waiting on a JDBC connection that died. Look at thread dumps taken during the hang. - "Chunks stay force-loaded after
/reload." S-002 regression.releaseAllChunkTicketseither didn't run (exception unwound past it) or a new allocator bypassed the central ticket registry. Every chunk-ticket allocation path must register withMemoryTrackerso this single call can release them. - "
referenceDatarow missing / startup thinks every boot is a crash." Someone moved the sentinel write ahead ofRTP.stop(). It must be after, because it uses the still-openDatabaseAccessorand callsprocessQueries(MAX)one more time to force the write. - "
NoClassDefFoundErrorin shutdown logs." Expected and ignored — a platform class (e.g., Folia's scheduler) wasn't on the classpath because the adapter JAR was never installed, andonDisableran as part of theonEnablebail-out. Real defects in shutdown will surface as other exceptions, notNoClassDefFoundError.
Deep read (optional):
LESSONS_LEARNED.md§"Shutdown ordering",TRACEABILITY.mdrowREQ-CORE-NF-001(deterministic shutdown persistence),REQUIREMENTS.md §3S-002 (no permanently force-loaded chunks).
Welcome to RTP. The diagrams are the map; the S-00x rules are the law; the ADRs are the precedent.