ADR-067 — Automatic PRESCAN, Adaptive Scan Rate Control, and .mca-Header Generation Check¶
Status: Proposed Date: 2026-06-14
Context¶
Fleet bStats data shows the aggregate generation success rate is below 50%, with only ~5% of servers reaching 99%+, and the dominant failure cause is vert (no safe vertical position found). This matches the case where an operator never runs /rtp scan: with no pre-computed bad-location bitmap, the vertical safety check brute-forces every candidate at teleport time. The scan is effectively mandatory for a healthy success rate but is shipped as an opt-in command, so the default first-run experience is a >50% failure rate.
The intended fix is to make scanning run automatically (auto-scan), which raises the question of how fast an automatic background scan may run without harming the live server. A fixed positions-per-tick rate is a guess made at config time; it either starves the server on slow hardware or under-utilises a pregenerated world (where a full anvil-only scan reaches 500–2000 cps and completes in seconds). We now have the runtime signals to do better:
ChunkLoadProfile(ADR-related metrics work) tracks the running-minimum "floor" load time separately for already-generated vs ungenerated chunks, plus cumulative totals — a per-class cost estimate.- Whole-server MSPT and the existing
queue_depth_pressure/chunk_load_backlog_pressuresignals expose tick headroom and pipeline saturation.
A key correction emerged during design: we cannot predict, before issuing a batch, which positions are generated, and .mca file presence is region-granular (a 32×32 chunk region whose columns may be heterogeneously generated). The generation status of an individual chunk column must be read either from the server API (World.isChunkGenerated) or from the region file's 4 KB sector-offset table. ScanTask already bins candidate positions by Minecraft region file (the PR-14 "region-file bin-and-batch" pass keyed on (x>>5, z>>5)) and already holds the raw .mca bytes for the current bin hot in AnvilRegionByteCache (16-entry LRU, ~1.0 hit rate within a bin). The per-chunk generation check is therefore a near-free in-memory lookup into bytes the scan already has, rather than a per-position API call (Option B below).
Two further constraints shape what kind of automatic scan is acceptable. First, full-load scanning (PHASE_FULLSCAN) force-loads and, on ungenerated terrain, generates chunks; running it automatically across a large RTP radius would force a full pregeneration of that radius, which is impossible to do without a major disk-space cost. An automatic scan therefore must be PRESCAN-only (anvil probe of terrain that already exists on disk) and must never promote to FULLSCAN. Second, beyond a scheduled background sweep, safety verdicts can be harvested opportunistically: whenever the server loads a chunk for its own reasons, that chunk's column data is already resident in memory, so verifying it (and a small bounded neighbourhood of already-resident chunks, clipped to the region's boundary) costs no disk I/O and no extra chunk load. Every part of this behaviour must be operator-configurable.
Decision¶
Populate the bad-location safety map through three disk- and generation-neutral tiers, none of which force-generates terrain, governed by an adaptive per-tick budget and the cached .mca-header generation check. Every tier and every threshold below is exposed as a performance.yml key (point 5) so an operator can tune or fully disable each independently.
Tier 1 — Scheduled automatic PRESCAN (autoScan). When a region has no .scan coverage or coverage below a configurable threshold, a background scan starts automatically. It is PRESCAN-only: it sweeps the configured radius reading only r.X.Z.mca files that already exist on disk, records safe/unsafe verdicts for generated columns, and leaves ungenerated columns unmarked (so live verification or a later manual scan can still resolve them). It never promotes to FULLSCAN. The existing PRESCAN→FULLSCAN promotion paths in ScanTask (the up-front GENSCAN skip when ungen > 0, and the mid-sweep compareAndSet(PHASE_PRESCAN, PHASE_FULLSCAN)) are suppressed for an auto-scan unless the operator opts in via autoScanFullLoad. Manual /rtp scan retains its authoritative full-load behaviour unchanged.
Tier 2 — Opportunistic on-load PRESCAN (autoScanOnLoad). When the server loads a chunk for its own reasons, the already-resident column data is verified for free (S-005-safe: the chunk is already loaded, nothing is force-loaded or generated). The search optionally expands to a bounded neighbourhood of chunks that are also already resident (autoScanOnLoadRadius), never force-loading a neighbour, and is capped per load event (autoScanOnLoadMaxColumns). Verdicts are recorded only for columns inside the region's shape range / world border — out-of-region columns are clipped by a cheap range test before any safety check. Columns with a fresh existing verdict are skipped.
Tier 3 — Live verification at teleport. The existing fallback for columns still unknown after Tiers 1 and 2; unchanged.
The adaptive rate machinery below governs Tier 1 (and is the path a manual FULLSCAN is throttled by). Tier 2's per-event work cap is the analogous bound for the on-load path, but its budget is trivially small because it performs no I/O.
- Per-tick budget (two orthogonal throttle axes, combined by minimum).
- MSPT headroom (reactive):
headroom = max(0, msptBudget - currentMspt); the scan may consumescanBudgetMs = headroom * scanMsptShare. At or above the budget the scan pauses for that tick. - Queue / chunk-load pressure (reactive): when queue depth exceeds a high watermark the scan trickles (one position); between watermarks it scales down proportionally.
-
The effective per-tick allowance is the minimum of the two axes, never below one position (progress guaranteed) and never above an operator-configured ceiling.
-
Check-then-allocate per batch (predictive pre-commit bound). Inside the existing per-bin dispatch loop, for each candidate position about to be issued: read its generation status, pick the matching
ChunkLoadProfilefloor (generated vs ungenerated, with a conservative bootstrap default until samples exist), and stop allocating once the summed predicted cost would exceed the tick budget. Because the floor is a lower bound on real cost, the estimate errs toward doing less, structurally preventing scan-induced MSPT spikes rather than merely dampening them. -
Generation check via cached
.mcaheader (Option B). The per-chunk generated/ungenerated decision reads the region file's sector-offset table: the 4-byte entry at offset4 * ((cx & 31) + (cz & 31) * 32)is zero for an unwritten (ungenerated) column and non-zero for a generated one. The raw bytes come fromAnvilRegionByteCache, which is already hot for the current bin. The decode lives behind ananvil-apihelper (e.g.isChunkPresent(byte[] regionBytes, int cx, int cz)) sortp-corecarries no raw.mcalayout constants and reaches it the same way it already reaches the anvil subsystem (ADR-026). -
PRESCAN vs FULLSCAN. PRESCAN (anvil prefilter, no chunk loads, S-005-safe) does not contribute to chunk-load pressure and may run at near-full rate on any server; its budget is governed by measured anvil I/O cost per position. FULLSCAN loads chunks and competes with live teleports, so it is the path the chunk-load-cost budget throttles.
-
Everything configurable; operator policy, not per-server tuning. All behaviour is exposed through
performance.ymlkeys, each with a default that reproduces current behaviour when the feature is off, so the operator sets a policy once and the controller measures the rate at runtime: - Auto-scan triggering:
autoScan(enable scheduled background PRESCAN),autoScanCoverageThreshold(coverage fraction below which a region auto-scans),autoScanFullLoad(allow an auto-scan to promote PRESCAN→FULLSCAN, i.e. force-generate terrain; off by default). - Opportunistic on-load:
autoScanOnLoad(enable on-load harvesting),autoScanOnLoadRadius(already-resident neighbourhood radius in chunks),autoScanOnLoadMaxColumns(hard per-load-event work cap). - Adaptive rate (Tier 1 / manual FULLSCAN):
scanAdaptive,scanMsptBudget,scanMsptShare,scanQueueHighWatermark,scanQueueLowWatermark,scanMaxPositionsPerTick.
Scope: rtp-core (ScanTask rate logic + the PRESCAN-only auto-scan trigger and the on-load harvesting hook, reading the existing profile singletons) plus an anvil-api isChunkPresent helper and the performance.yml keys. The on-load tier consumes a chunk-load notification the platform adapters already surface; no rtp-api interface change and no new force-load path.
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
| Fixed positions-per-tick rate (status quo) | A config-time guess: starves the server on weak hardware and wastes a pregenerated world's headroom; the fleet data shows the resulting opt-in scan is rarely run, leaving a >50% failure rate. |
| Automatic FULLSCAN (force-generate the whole radius) | Generating a large RTP radius up front imposes a major, often prohibitive disk-space cost; auto-scan must stay PRESCAN-only (existing terrain on disk) and leave force-generation to explicit operator action. |
| Scheduled PRESCAN only, no on-load harvesting | The scheduled sweep covers the whole radius but the far reaches (exactly where RTP sends players) may rarely be loaded; the opportunistic on-load tier fills hot areas and self-heals stale verdicts at zero marginal cost. Kept as a complement, not a replacement. |
| On-load harvesting only, no scheduled sweep | Only covers terrain that happens to load naturally; the cold far radius would never be verified, so the vert failure rate would stay high there. |
| Predict generated/ungenerated for a whole batch before issuing | Impossible: generation status is per chunk column and .mca presence is region-granular and heterogeneous; the status of a specific column is not knowable until checked. |
World.isChunkGenerated(cx,cz) per position (Option A) |
Correct but one native call per candidate; redundant given the scan already holds the region-file bytes hot in AnvilRegionByteCache, where the same answer is an O(1) in-memory read. |
| Throttle on whole-server MSPT alone | MSPT is whole-server tick utilisation, not pipeline saturation; a low-MSPT server with a saturated chunk-load queue would still extend player wait times. The queue-pressure axis covers this independently. |
| Throttle on a single combined cost figure (no gen/ungen split) | Hides the dominant cost driver (terrain generation), so the predictive bound cannot account for a batch that happens to hit ungenerated terrain; the per-class floor is what makes the pre-commit bound accurate. |
Consequences¶
- Positive: Auto-scan runs safely on a live server without operator rate-tuning and without force-generating terrain, so it costs no extra disk space; on a pregenerated world it finishes in seconds (invisible), on an ungenerated world it backs off automatically and verifies only what already exists. The on-load tier harvests verdicts for free from chunks the server loads anyway and self-heals staleness. The predictive bound prevents scan-induced MSPT spikes structurally rather than after the fact, and the generation check is essentially free, reusing the existing region-file binning and cache. The three tiers degrade gracefully: unknown columns simply fall back to live verification (current behaviour), never worse than today.
- Negative / Trade-offs: Adds a small per-position in-memory lookup and a per-tick budget computation to the scan hot path, and a bounded per-load-event check to the chunk-load path. Introduces a larger operator-facing
performance.ymlsurface (auto-scan trigger, on-load, and adaptive-rate key groups; mitigated by safe defaults and per-group off switches). The floor estimate is inaccurate untilChunkLoadProfileaccumulates samples, so early scan ticks rely on conservative bootstrap defaults. PRESCAN-only auto-scan cannot verify ungenerated terrain, so a deliberately-ungenerated world keeps relying on live verification for the unloaded far radius.isChunkPresentreflects state at allocation time; a column generated by another player between check and load makes the actual cost lower than predicted (the safe direction).
References¶
rtp-core/src/main/java/io/github/dailystruggle/rtp/common/tasks/ScanTask.java— PR-14 region-file bin-and-batch (key = (x>>5)<<32 | (z>>5)), per-bin dispatch loop.api/anvil-api/src/main/java/io/github/dailystruggle/rtp/anvil/AnvilRegionByteCache.java— cached raw.mcabytes (source of the sector-offset table).rtp-core/src/main/java/io/github/dailystruggle/rtp/common/metrics/ChunkLoadProfile.java— per-class (generated/ungenerated) floor + totals.- ADR-016 — anvil read-only subsystem (PRESCAN prefilter, S-005 safety).
- ADR-026 — how
rtp-corereaches the anvil subsystem. - Prohibition S-005 (no chunk loading on the main thread):
docs/dev/REQUIREMENTS.md §3.