COWMODSКаталог модов

Shinoyuki-BetterAutoSave

Async world saving for Forge 1.20.1 servers — chunk, entity and saved-data serialization moved off the main thread. Kills autosave lag spikes.

Автор: Shinoyuki_Miyako35,8 тыс.6обновлён 21 сент. 2026 г.
Оптимизация

Описание

BetterAutoSave

简体中文 | English

BetterAutoSave

Make server autosaves stutter-free ~ Parts of this mod's code were generated by Claude Opus 4.8 / Claude Fable 5. If you run into any issue, please open an issue

Download: Modrinth · GitHub Releases

Project status: actively developed and in a fast pre-1.0 iteration phase, with frequent updates (including releases coordinated with BetterBackup). The core async save has been validated in production for a long time and is safe by default; async chunk loading is a newer, opt-in feature and is off by default. Watch Releases / Modrinth for updates

What problem does this mod solve

A vanilla Minecraft server autosaves every 5 minutes. During that save, the main thread has to serialize every modified chunk and write it to disk — and the whole server is frozen while it happens. On an empty server you will not notice, but on a server with many mods and players this pause is routinely 200 ms to several seconds, and every player lags at once.

Besides the periodic autosave, several other moments stutter the same way: players teleporting or large numbers of chunks being unloaded (a chunk must be saved before it leaves memory), entity-dense areas during a save (large farms / mob grinders), and global data such as villages and raids (vanilla SavedData) where a single large file hits the disk.

BAS makes the main thread do only the one thing that must happen in place — taking an independent snapshot of the data to be saved. Serialization and disk IO are handed to background threads. Because the background works on copies, it never interferes with the main thread, which lets go immediately after the snapshot. Chunks, entities and saved data all go through this pipeline. When the server is struggling BAS automatically slows down, but forces full speed as the next autosave cycle approaches so a backlog can never build up.

Requirements and installation

Both loaders are maintained from the same source. Server-side only on both; clients do not need to install it.

  • Forge 1.20.1: Forge 47.3.22 or newer (47.3 / 47.4 lines both work), Java 17 or newer
  • NeoForge 1.21.1: NeoForge 21.1 line, Java 21 or newer

Download the jar matching your loader from Modrinth or Releases and drop it into the server's mods/ folder:

  • For Forge, use the -all jar (named like shinoyuki_betterautosave-<version>-all.jar; it bundles MixinExtras and other dependencies). The plain thin jar crashes on load for missing dependencies.
  • For NeoForge, use shinoyuki_betterautosave-neoforge-<version>.jar.

After the first launch the config file is generated at config/Shinoyuki-Optimize/shinoyuki_betterautosave/common.toml. The defaults work out of the box; most servers do not need to change anything.

Will it lose world data?

No. BAS is designed on one premise: it must never be less safe than vanilla.

  • On shutdown it waits for every pending save to hit the disk before letting the server exit, and the final save goes through the vanilla synchronous path.
  • BAS never "holds saves for later" — a chunk enters background processing the moment it should be saved. There is no "nothing saved for minutes, crash loses it all" window (some similar mods have this problem).
  • If a background write fails it retries automatically and never pretends it succeeded: chunks and saved data fall back to the vanilla synchronous write once retries are exhausted; entities have no coordinate recovery queue and are already evicted from memory by vanilla, so an exhausted retry logs an ERROR and drops that chunk's latest entity increment — the same outcome as vanilla here (vanilla entity saving likewise has no retry and no synchronous fallback; BAS actually retries a few more times first).

Beyond that, the Forge build also fixes three vanilla paths that silently lose data (player data read failure, truncating writes for advancements and stats, and level.dat having only a single backup). Those fixes are on by default — see the configuration reference.

Common configuration

KeyDefaultDescription
general.enabledtrueMaster switch; off means vanilla behavior, as if not installed
throttle.chunksPerTickBase4Max chunks snapshotted by the main thread per game tick
throttle.adaptiveEnabledtrueSlow down automatically when the server struggles; keep it on
workers.chunkWorkerThreads2Background threads for chunks
workers.entityWorkerThreads2Background threads for entities
workers.savedDataWorkerThreads1Background threads for saved data; raise to 2 with mods that write a lot of vanilla SavedData
compat.eventCompatModePARTIALEvent compatibility level; leave it alone unless you know you need it

The Forge build has 43 settings, the NeoForge build 26. Every setting, why each default is what it is, and the recommended rollout path are documented in CONFIGURATION.en.md, covering player data protection, level.dat integrity, async chunk loading, Prometheus monitoring and working with backup tools.

Feature matrix across the two builds

The two builds are not identical. Some gaps exist because NeoForge fixed the problem upstream, so the corresponding option is unnecessary there; others are Forge-first and not yet ported symmetrically.

FeatureForge 1.20.1NeoForge 1.21.1
Async saving (chunks / entities / SavedData)yesyes
Async chunk loading ([load] section)yesno (the section does not exist; the NeoForge build has no load-side mixins at all)
level.dat registry cacheyesnot needed (upstream moved the table out of level.dat)
level.dat startup checkyesnot needed (1.21 already falls back and quarantines on a read failure)
level.dat startup backup / post-write verifyyesno (1.21 has no equivalent either; port pending)
playerdata read fallbackyesnot needed (fixed upstream in 1.21)
advancements / stats atomic writeyesno (1.21 still truncates on write; port pending)
advancements dirty skip, staggered player savingyesno (performance only; port pending)
Sync chunk load / inter-tick gap diagnosticsyesyes

In-game commands

Requires OP (permission level 2).

CommandEffect
/betterautosave statusOne-line current status
/betterautosave metricsOne-line metrics summary
/betterautosave debugFull diagnostics: queue depths, per-stage timings, counters
/betterautosave flushDrain every pending save to disk. The command returns immediately and polls in the background until it completes or times out (safety.shutdownTimeoutSeconds)
/betterautosave drain-unloadWait for all pending chunks to land; likewise polls in the background and returns immediately
/betterautosave hottest-chunks [count]List the slowest-saving chunks (default 10, accepts 1-50) to locate hotspots
/betterautosave force-asyncForce one background save pass over all chunks in the current dimension (diagnostic)
/betterautosave diagnose [count]List the sources of main-thread synchronous chunk loads and the inter-tick gap statistics (default 10, accepts 1-50)
/betterautosave diagnose resetClear both tables above (cumulative counters are kept; see below)

High-cost chunks usually sit where block entities are dense — large automated farms, mod shop panels, complex redstone.

The stalls ordinary monitoring cannot see

Two kinds of stall never show up on a normal dashboard. Since 0.20.0 BAS records both itself — observing only, never intervening, on by default.

Main-thread synchronous chunk loads. When a chunk is not in memory and something on the main thread asks for it directly, the main thread waits in place until the disk read — and if needed, terrain generation — has finished, and the whole server is frozen meanwhile. A production stress test with 74 players measured a single wait of 5.2 seconds. BAS records every wait over 50 ms (threshold configurable): how long it blocked, the chunk coordinates, the dimension, and the first non-vanilla class on the call stack.

Long pauses between ticks. MSPT only measures time spent inside a game tick; the wait between two ticks is not counted. The same stress test contained pauses of 17.1 and 14.8 seconds that were invisible on the TPS graph and on the dashboard — everything looked healthy while players were timing out. BAS takes one timestamp at the start and one at the end of every tick and records any gap over 1 second (configurable); an optional deep mode, off by default, attributes a gap to the individual task that caused it.

Cost: the sync-load probe sits on the branch taken after vanilla's four-slot chunk cache misses, so it is not reached at all on a cache hit; on a miss it only adds two nanosecond reads, and a stack is captured solely once the wait exceeds the threshold — never during normal operation. The tick gap check is two nanosecond reads per tick.

On attribution: the detection reports which call chain the block happened on, not who has a bug. Stalls reported by this feature usually reflect another mod's call pattern — fetching a chunk synchronously is perfectly reasonable in many situations, its cost simply scales with server size, view distance and disk speed — and are not by themselves evidence of a defect in that mod. Treat it as the starting point of an investigation, not its conclusion.

Read it with /betterautosave diagnose, or scrape the four new Prometheus metrics (bas_sync_load_stalls_total, bas_tick_gap_max_seconds and two more). The eight settings and a full sample of the command output are in section 8 of CONFIGURATION.en.md.

Design boundary

BAS has been evaluated to the end of what it can do here: under the extreme compatibility constraints it holds itself to, there is no meaningful async chunk optimization left to take. Where room does remain, taking it would break that compatibility — producing data-safety problems and conflicts between mods.

In a production stress test with 74 players online, sampled over 550 seconds, NbtIo writes accounted for 0.03% of main-thread time, ChunkSerializer serialization for 0.67%, and everything BAS itself does for 1.42% in total. That is not the same as "there is no optimization left" — the same sample still shows roughly 0.3 percentage points on the table (copySections unconditionally makes two PalettedContainer.copy calls even for empty sections, about 0.1 pp; batching the POI replay, about 0.1 to 0.2 pp). But that is already down in the noise, and what can be taken without changing the compatibility premise adds up to less than half a percentage point.

It is also not the same as "BAS makes chunk loading stop being the bottleneck". The opposite is true: the bottleneck sits in the half of the chunk system BAS cannot reach. In that same sample, DistanceManager distance-field propagation consumed 82% of the chunk system's main-thread budget, and the tasks actually driving loads forward only 18%.

What each further step would break is concrete. Moving ForgeCaps onto a worker thread belongs to the same family as the data loss in issue #8: mods that attach a chunk capability lose data silently. Moving ChunkDataEvent.Load onto a worker thread calls every listener off the main thread; it throws nothing and simply rots over time. Moving POI / SectionStorage onto a worker thread runs into SectionStorage not being thread-safe, and the outcome is silently corrupted villager AI data. Taking over DistanceManager means a state machine that is not thread-safe, and a head-on conflict with C2ME. Requiring installation on both sides, or forcing everything async, gives up single-side installation, opt-in and instant rollback — the largest differentiator BAS has.

Performance has reached the limit compatibility allows, so this release changes direction: instead of chasing those last fractions of a percent, BAS now tells server owners where the stalls actually come from. The full reasoning is in ROADMAP.md (Chinese).

Mod conflicts

  • Cannot be installed together (all take over the same save path): Fast Async World Save (fastasyncworldsave, BAS logs a WARN when it detects this one), Smooth Chunk Save, and other async / per-tick save mods.
  • C2ME / C2ME-Forge: split it by feature. The save side is pick-one; parallel loading is complementary under BAS's default config but becomes pick-one once BAS async loading is enabled; worldgen is always complementary.
  • Compatible: Starlight, Radium / Canary, Modernfix, FerriteCore and similar.

The reasoning, the full list of injection points and the data-integrity contract of each compatibility level are in COMPATIBILITY.en.md.

Quick recovery if something goes wrong

All three options keep world data intact:

  1. Disable temporarily: set general.enabled to false, restart or /reload. The mod stays installed but all logic is skipped — pure vanilla.
  2. Uninstall completely: move the jar out of mods/ and restart. World data remains protected by vanilla saving; uninstalling loses nothing.
  3. Tune instead of removing: if you suspect a performance setting, adjust chunksPerTickBase (1-64) or switch eventCompatMode to FULL first — no need to uninstall.

Building / development

./gradlew build                 # compile + run all tests (common / forge / neoforge)
./gradlew :forge:runServer      # start a 1.20.1 Forge dev server
./gradlew :neoforge:runServer   # start a 1.21.1 NeoForge dev server

Module layout: common/ (zero-Minecraft pure-algorithm core, source-merged into both loaders — the crown-jewel save state machine lives here once, never forked) + forge/ (1.20.1) + neoforge/ (1.21.1).

The version roadmap and capability overview are in ROADMAP.md (Chinese); dual-version porting details are in archive/MULTIVERSION_PLAN.md (Chinese).

License

AGPL-3.0-or-later, with two section 7 additional permissions (LICENSE-EXCEPTION.md): a modpack distribution exception — unmodified official release jars may be included verbatim in modpacks and server packs with no obligation beyond keeping the project name and a repository link — and a Minecraft linking exception explicitly permitting combination with Minecraft itself and LGPL-licensed mod loaders. Modified versions of this mod remain under the full AGPL, including its section 13 network terms.

Последние версии

Все версии →
release

BetterAutoSave v0.20.2 (NeoForge 1.21.1)

0.20.2-neoforge
21 сент. 2026 г.1,8 тыс.Minecraft: 1.21.1neoforge
Скачать
Что нового
## v0.20.2 — Fixes two race windows in async chunk saving In two very narrow timing windows, async chunk saving exposed a false "this chunk has no write in flight" signal to the main thread. When hit, the same chunk ends up with two concurrent writes, or a save request is wrongly treated as "already being handled" and skipped. The worst outcomes are the chunk rolling back to an older state after a restart, or the last edits before an unload never reaching disk Each window is only a few instructions wide and requires the main thread to process a save of that same chunk at exactly that moment, so the real-world probability is low. They are data-safety issues nonetheless, and all 0.20.x users are advised to upgrade. This release only touches the chunk saving path ### 1. Problem In `ChunkMap.save` the main thread picks one of two branches purely from the chunk's save state: if it is "in flight" (a write is already under way), the latest content is registered as a relay snapshot to be written once the in-flight write finishes; if it is "dirty", a new save cycle is started. Switching between these states has to happen in a single step, with no other value visible to the main thread in between Two paths split that switch into two steps, briefly exposing "dirty" in the middle: 1. **In-place retry after a failed write**. When a write fails and `maxRetries` is not yet exhausted, the callback resubmits the already-serialized old data in place. It first set the state to "dirty" and only in the next step back to "in flight". A main thread caught in between concludes nothing is in flight: if it wins the race it opens a second save cycle that writes the same chunk concurrently with the retry; if it loses, it treats this save as "already being handled" and lets it through without registering a relay. If the latter happens during a chunk unload, the last edits before the unload have no copy left anywhere 2. **Relay write**. When the in-flight write finishes and a relay snapshot is waiting, the callback first set the state to "dirty" and took the snapshot, and only in the next step entered the relay cycle. Registering a relay does not mark the chunk as saved, so a main thread processing the same chunk during this gap likewise opens a second cycle. If the chunk was edited again after the relay snapshot was taken, both cycles consider their data stale when they land and neither marks the chunk clean; if the older data happens to finish writing last, the region file keeps the old content while the chunk is already marked as saved and will not be written again The second window does not require a disk error — it only needs the same chunk to be edited and saved again while its previous write is in flight (typically on unload, or during autosave under load) Under some interleavings both windows also leave the internal pending-drain counter permanently one too high: `/betterautosave drain-unload` never sees it reach zero, and the Prometheus `mustDrain` metric keeps reporting a pending chunk ### 2. Fix The rule: only a callback that is truly finishing, with no further write to follow, may set the state to "dirty". A callback that is going to write again stays "in flight" throughout 1. When a failed write still has retries left, the state stays "in flight" and the data is resubmitted in place. Only when the serialization thread itself fails and no resubmission is possible does the fallback set the state to "dirty" on its own, so the next save cycle recaptures the chunk. The entity saving path has worked this way since 0.11.0; this release brings the chunk path in line 2. Taking the relay snapshot and entering the relay cycle are now one atomic operation, with no "dirty" in between Neither change touches the save format, config keys or the public API, and the state machine behaves as before on every other path ### 3. Verification - All 538 unit tests pass, 9 of them new: they drive the state machine step by step in the interleaved order on a single thread to reproduce both windows deterministically, plus a randomized case covering retry limits from 0 to 10 including the exact-exhaustion boundary - Mutation testing: reverting each key part of the fix individually (6 variants) makes tests fail every time - The fix was independently reviewed adversarially by two separate reviewers ### 4. Upgrading Drop in the new jar. The save format and config keys are unchanged
release

BetterAutoSave v0.20.2 (Forge 1.20.1)

0.20.2-forge
21 сент. 2026 г.4,5 тыс.Minecraft: 1.20.1forge
Скачать
Что нового
## v0.20.2 — Fixes two race windows in async chunk saving In two very narrow timing windows, async chunk saving exposed a false "this chunk has no write in flight" signal to the main thread. When hit, the same chunk ends up with two concurrent writes, or a save request is wrongly treated as "already being handled" and skipped. The worst outcomes are the chunk rolling back to an older state after a restart, or the last edits before an unload never reaching disk Each window is only a few instructions wide and requires the main thread to process a save of that same chunk at exactly that moment, so the real-world probability is low. They are data-safety issues nonetheless, and all 0.20.x users are advised to upgrade. This release only touches the chunk saving path ### 1. Problem In `ChunkMap.save` the main thread picks one of two branches purely from the chunk's save state: if it is "in flight" (a write is already under way), the latest content is registered as a relay snapshot to be written once the in-flight write finishes; if it is "dirty", a new save cycle is started. Switching between these states has to happen in a single step, with no other value visible to the main thread in between Two paths split that switch into two steps, briefly exposing "dirty" in the middle: 1. **In-place retry after a failed write**. When a write fails and `maxRetries` is not yet exhausted, the callback resubmits the already-serialized old data in place. It first set the state to "dirty" and only in the next step back to "in flight". A main thread caught in between concludes nothing is in flight: if it wins the race it opens a second save cycle that writes the same chunk concurrently with the retry; if it loses, it treats this save as "already being handled" and lets it through without registering a relay. If the latter happens during a chunk unload, the last edits before the unload have no copy left anywhere 2. **Relay write**. When the in-flight write finishes and a relay snapshot is waiting, the callback first set the state to "dirty" and took the snapshot, and only in the next step entered the relay cycle. Registering a relay does not mark the chunk as saved, so a main thread processing the same chunk during this gap likewise opens a second cycle. If the chunk was edited again after the relay snapshot was taken, both cycles consider their data stale when they land and neither marks the chunk clean; if the older data happens to finish writing last, the region file keeps the old content while the chunk is already marked as saved and will not be written again The second window does not require a disk error — it only needs the same chunk to be edited and saved again while its previous write is in flight (typically on unload, or during autosave under load) Under some interleavings both windows also leave the internal pending-drain counter permanently one too high: `/betterautosave drain-unload` never sees it reach zero, and the Prometheus `mustDrain` metric keeps reporting a pending chunk ### 2. Fix The rule: only a callback that is truly finishing, with no further write to follow, may set the state to "dirty". A callback that is going to write again stays "in flight" throughout 1. When a failed write still has retries left, the state stays "in flight" and the data is resubmitted in place. Only when the serialization thread itself fails and no resubmission is possible does the fallback set the state to "dirty" on its own, so the next save cycle recaptures the chunk. The entity saving path has worked this way since 0.11.0; this release brings the chunk path in line 2. Taking the relay snapshot and entering the relay cycle are now one atomic operation, with no "dirty" in between Neither change touches the save format, config keys or the public API, and the state machine behaves as before on every other path ### 3. Verification - All 538 unit tests pass, 9 of them new: they drive the state machine step by step in the interleaved order on a single thread to reproduce both windows deterministically, plus a randomized case covering retry limits from 0 to 10 including the exact-exhaustion boundary - Mutation testing: reverting each key part of the fix individually (6 variants) makes tests fail every time - The fix was independently reviewed adversarially by two separate reviewers ### 4. Upgrading Drop in the new jar. The save format and config keys are unchanged
release

BetterAutoSave v0.20.1 (NeoForge 1.21.1)

0.20.1-neoforge
22 авг. 2026 г.4,9 тыс.Minecraft: 1.21.1neoforge
Скачать
Что нового
## v0.20.1 — Fixes a startup crash when installed alongside Lithium-family optimisation mods 0.20.0 crashes the server at startup when installed together with Harium, Radium, Radium Re-Reforged, Canary or another Lithium port. If you run any of those, upgrade to 0.20.1 or roll back to 0.19.0. Only 0.20.0 is affected ### 1. Symptom Mixin application fails at startup and the entire config is rejected: ``` Mixin apply failed shinoyuki_betterautosave.mixins.json:ServerChunkCacheSyncLoadMixin -> net.minecraft.server.level.ServerChunkCache InvalidInjectionException: @At("INVOKE") on ...betterautosave$measureSyncChunkLoad with priority 1000 cannot inject into ServerChunkCache::m_7587_ merged by me.jellysquid.mods.lithium.mixin.world.chunk_access.ServerChunkManagerMixin with priority 1000 ``` What fails is the sync-load detector added in 0.20.0, but the whole of `shinoyuki_betterautosave.mixins.json` goes down with it — async saving and async loading stop working too, and the server does not start ### 2. Cause The 0.20.0 detector wraps the "wait until the chunk is ready" call inside `ServerChunkCache.getChunk` with `@WrapOperation` and declares `require = 0`, intending "if the injection point is gone, skip silently rather than let a pure observation feature take the server down" That declaration does not cover the failure mode that actually occurred. Lithium ports `@Overwrite` `getChunk` outright, and Mixin has an earlier check in `Injector.findTargetNodes`: if the injector's priority is not higher than that of the mixin which already merged the method, it throws `InvalidInjectionException` immediately. Both sides sit at the default priority of 1000, so the condition fails — and because this check runs *before* the require hit-count check, `require = 0` is never consulted The 0.20.0 release notes said "the detector silently does nothing rather than failing server startup". The intent was right; the implementation did not deliver it. This release does ### 3. Fix The detector's injection now passes a gate at class-load time. If either condition holds, the mixin is not applied at all: 1. `diagnostics.syncLoadDetection` is `false` in the on-disk `common.toml` (a missing file, section or key defaults to `true`) 2. A mod known to `@Overwrite getChunk` is present: `harium`, `radium`, `canary`, `lithium` When the gate closes, a WARN line names the mod responsible: ``` [BetterAutoSave] sync chunk load detector disabled: mod 'harium' overwrites ServerChunkCache.getChunk, which Mixin will not let us wrap. Async saving and loading are unaffected ``` That line is necessary: without it, "I installed 0.20 but never see any sync-load reports" has no explanation. **Saving, async loading and every other feature are unaffected by this gate** Raising the mixin's priority was considered and rejected. BAS would then apply first, and the other mod's `@Overwrite` would replace the whole method body along with the injection — moving the crash risk onto them while this feature stops working anyway ### 4. Similar mods not on the list Condition 2 is a whitelist covering the four known cases. If a mod that also `@Overwrite`s `getChunk` appears and is not listed, set `diagnostics.syncLoadDetection` to `false` and restart (condition 1); no version update is required The gating logic is identical on both loaders. The NeoForge side had no mixin config plugin before this release; one has been added ### 5. Verification - 529 unit tests, 11 of them covering the gate itself: whitelist matching, case handling, a reverse assertion against false positives, TOML section awareness and default-value semantics - Both directions verified on a 1.20.1 test server: BAS 0.20.0 + Harium 2.0 reproduces the startup crash reliably; with 0.20.1 the server starts normally and the log names `harium`; with Harium removed the server starts normally, the WARN is absent and the detector is injected as usual - The crash exception is quoted verbatim in the source comments alongside the fix, so that `require = 0` is not reached for again against this class of problem ### 6. Upgrading Drop in the new jar. Save format and config keys are unchanged. Versions 0.19.0 and earlier do not contain this mixin and are not affected
release

BetterAutoSave v0.20.1 (Forge 1.20.1)

0.20.1-forge
22 авг. 2026 г.13 тыс.Minecraft: 1.20.1forge
Скачать
Что нового
## v0.20.1 — Fixes a startup crash when installed alongside Lithium-family optimisation mods 0.20.0 crashes the server at startup when installed together with Harium, Radium, Radium Re-Reforged, Canary or another Lithium port. If you run any of those, upgrade to 0.20.1 or roll back to 0.19.0. Only 0.20.0 is affected ### 1. Symptom Mixin application fails at startup and the entire config is rejected: ``` Mixin apply failed shinoyuki_betterautosave.mixins.json:ServerChunkCacheSyncLoadMixin -> net.minecraft.server.level.ServerChunkCache InvalidInjectionException: @At("INVOKE") on ...betterautosave$measureSyncChunkLoad with priority 1000 cannot inject into ServerChunkCache::m_7587_ merged by me.jellysquid.mods.lithium.mixin.world.chunk_access.ServerChunkManagerMixin with priority 1000 ``` What fails is the sync-load detector added in 0.20.0, but the whole of `shinoyuki_betterautosave.mixins.json` goes down with it — async saving and async loading stop working too, and the server does not start ### 2. Cause The 0.20.0 detector wraps the "wait until the chunk is ready" call inside `ServerChunkCache.getChunk` with `@WrapOperation` and declares `require = 0`, intending "if the injection point is gone, skip silently rather than let a pure observation feature take the server down" That declaration does not cover the failure mode that actually occurred. Lithium ports `@Overwrite` `getChunk` outright, and Mixin has an earlier check in `Injector.findTargetNodes`: if the injector's priority is not higher than that of the mixin which already merged the method, it throws `InvalidInjectionException` immediately. Both sides sit at the default priority of 1000, so the condition fails — and because this check runs *before* the require hit-count check, `require = 0` is never consulted The 0.20.0 release notes said "the detector silently does nothing rather than failing server startup". The intent was right; the implementation did not deliver it. This release does ### 3. Fix The detector's injection now passes a gate at class-load time. If either condition holds, the mixin is not applied at all: 1. `diagnostics.syncLoadDetection` is `false` in the on-disk `common.toml` (a missing file, section or key defaults to `true`) 2. A mod known to `@Overwrite getChunk` is present: `harium`, `radium`, `canary`, `lithium` When the gate closes, a WARN line names the mod responsible: ``` [BetterAutoSave] sync chunk load detector disabled: mod 'harium' overwrites ServerChunkCache.getChunk, which Mixin will not let us wrap. Async saving and loading are unaffected ``` That line is necessary: without it, "I installed 0.20 but never see any sync-load reports" has no explanation. **Saving, async loading and every other feature are unaffected by this gate** Raising the mixin's priority was considered and rejected. BAS would then apply first, and the other mod's `@Overwrite` would replace the whole method body along with the injection — moving the crash risk onto them while this feature stops working anyway ### 4. Similar mods not on the list Condition 2 is a whitelist covering the four known cases. If a mod that also `@Overwrite`s `getChunk` appears and is not listed, set `diagnostics.syncLoadDetection` to `false` and restart (condition 1); no version update is required The gating logic is identical on both loaders. The NeoForge side had no mixin config plugin before this release; one has been added ### 5. Verification - 529 unit tests, 11 of them covering the gate itself: whitelist matching, case handling, a reverse assertion against false positives, TOML section awareness and default-value semantics - Both directions verified on a 1.20.1 test server: BAS 0.20.0 + Harium 2.0 reproduces the startup crash reliably; with 0.20.1 the server starts normally and the log names `harium`; with Harium removed the server starts normally, the WARN is absent and the detector is injected as usual - The crash exception is quoted verbatim in the source comments alongside the fix, so that `require = 0` is not reached for again against this class of problem ### 6. Upgrading Drop in the new jar. Save format and config keys are unchanged. Versions 0.19.0 and earlier do not contain this mixin and are not affected
release

BetterAutoSave v0.20.0 (NeoForge 1.21.1)

0.20.0-neoforge
21 авг. 2026 г.102Minecraft: 1.21.1neoforge
Скачать
Что нового
## v0.20.0 — Making main-thread sync chunk loads and inter-tick pauses observable 0.20.0 adds two diagnostic features that observe and never intervene: detection of main-thread synchronous chunk loads, and monitoring of long pauses between ticks. Both are on by default, and both builds have them ### 1. Why these two stalls were invisible A production server with 74 players online froze. In the spark profile, BetterAutoSave's own save path on the main thread (`NbtIo` writes) accounted for 0.03% — saving was not the cause. The two actual sources were: - One main-thread synchronous chunk load, triggered by third-party code, blocking for 5.2 seconds - Two pauses that happened between ticks, 17.1 seconds and 14.8 seconds The second kind is the harder one. MSPT only measures time spent inside a tick; the wait between two ticks is not counted, so those 17 seconds are invisible on the TPS graph and on every ordinary monitoring dashboard — the dashboard looks healthy while players are timing out. Digging both out of spark's inclusive call tree took hours of manual work. 0.20.0 makes the server report them by itself ### 2. Main-thread synchronous chunk loads When a chunk is not in memory and something on the main thread asks for it directly, the main thread waits in place until the disk read — and if needed, terrain generation — has finished. The whole server is frozen for that duration. Vanilla rarely takes this path during normal play; what usually triggers it is third-party logic fetching a chunk by coordinates inside an event callback, a scheduled task or a command BAS times the "wait until the chunk is ready" call inside vanilla's `ServerChunkCache.getChunk`. A wait of at least `diagnostics.syncLoadThresholdMs` (default 50 ms, one game tick) is recorded with its duration, chunk coordinates, dimension, and the first non-vanilla class on the call stack: ``` [BetterAutoSave] main-thread sync chunk load: 5188ms at (120,-340) in minecraft:overworld, called from com.example.protection.RegionScanner ``` Each distinct source prints one line the first time it appears; after that it only accumulates into the table, so a player exploring new terrain cannot flood the log The instrumented call sits on the branch taken after vanilla's four-slot chunk cache misses: when the requested chunk is already cached, execution never reaches it. On a miss it costs two extra `System.nanoTime()` calls (tens of nanoseconds), and a stack is captured only once the wait actually exceeds the threshold — during normal operation none is ever captured ### 3. Inter-tick pauses Between the end of one tick and the start of the next, the server waits for the next tick while draining its task queue. That time is not part of MSPT. If something in the queue stalls — a task submitted by another mod, a chunk system callback, a command — MSPT can stay perfectly healthy while players have been frozen for ten seconds or more The default mode takes one timestamp at the start and one at the end of each tick, and records a gap of at least `diagnostics.tickGapThresholdMs` (default 1000 ms): ``` [BetterAutoSave] inter-tick gap: 17100ms after tick 148213 (this time is not counted in MSPT) ``` The default mode reports how long the gap was and which tick it followed, not who caused it. To get down to individual tasks, turn on `diagnostics.tickGapDeepAttribution` (off by default): it times every task in the server task queue and files those taking more than a tenth of the threshold (100 ms by default) under their actual `Runnable` type. That mode adds two nanosecond reads per task and the server runs hundreds of tasks per tick, so turn it on only while narrowing down a gap that has already been reported, then turn it back off ### 4. On how attribution is worded The detection reports which call chain the block happened on, not who has a bug. Fetching a chunk synchronously is a perfectly reasonable thing to write in many situations; its cost simply scales with server size, view distance and disk speed. Within a 5-second wait, the disk, terrain generation and other load on the same host can each be the dominant factor. Stalls reported by this feature usually reflect another mod's call pattern and are not by themselves evidence of a defect in that mod Treat attribution as the starting point of an investigation, not its conclusion. The command output carries the same sentence every time: ``` note: stalls listed above are attributed to the call site, not to a defect in the owning mod. ``` ### 5. Three ways to read the data `/betterautosave diagnose [count]` (default 10, accepts 1-50) prints the running totals and the top N attributions; `/betterautosave diagnose reset` clears both tables and re-arms the once-per-source log de-duplication. A full sample of the output is in section 8 of `docs/CONFIGURATION.en.md` Reset deliberately leaves the cumulative counters (`bas_sync_load_stalls_total` and friends) alone: Prometheus counters must stay monotonic, and zeroing them breaks `rate()`. The command reply says so The periodic diagnostic summary (`diagnostics.diagnosticLogging`, on by default) gains two lines: ``` [BetterAutoSave] |- syncLoad: stalls=37 totalBlocked=48210ms tracked=2 top=examplemod=31400ms x12, com.example.map.RegionCache=14600ms x19 [BetterAutoSave] `- tickGap: exceeded=2 max=17100ms last=14800ms after tick 148213 deepTasks=0 ``` The Prometheus exporter gains four metrics: | Metric | Type | Meaning | |---|---|---| | `bas_sync_load_stalls_total` | counter | Main-thread synchronous chunk loads over the threshold | | `bas_sync_load_stall_seconds_total` | counter | Cumulative seconds blocked in those loads | | `bas_tick_gap_exceeded_total` | counter | Inter-tick gaps over the threshold | | `bas_tick_gap_max_seconds` | gauge | Longest inter-tick gap since server start | ### 6. New settings | Key | Default | Effect | |---|---|---| | `diagnostics.syncLoadDetection` | `true` | Master switch for main-thread sync chunk load detection | | `diagnostics.syncLoadThresholdMs` | `50` | Minimum single-load block time to record, in milliseconds (1 - 60000) | | `diagnostics.syncLoadTrackLimit` | `64` | Max tracked (attribution, stack) pairs, LRU beyond that. Takes effect at startup | | `diagnostics.syncLoadStackDepth` | `24` | Non-vanilla stack frames retained per record (4 - 128) | | `diagnostics.tickGapDetection` | `true` | Master switch for inter-tick gap detection | | `diagnostics.tickGapThresholdMs` | `1000` | Minimum gap to record, in milliseconds (50 - 600000) | | `diagnostics.tickGapDeepAttribution` | `false` | Deep mode: time individual tasks to attribute a gap | | `diagnostics.tickGapDeepTrackLimit` | `64` | LRU limit for the deep-mode table; ignored while deep mode is off. Takes effect at startup | Everything except the two `TrackLimit` keys is hot-reloadable and takes effect immediately ### 7. Known limits - If a mod that rewrites the chunk-fetch path (something in the C2ME family) is installed alongside, the instrumented call may no longer exist and the probe cannot attach. Sync load detection then silently does nothing instead of failing the server's startup — a purely observational feature is not worth a boot crash. Whether the injection point still exists is guarded by build-time tests rather than a runtime hard check - Attribution uses the first non-vanilla class on the stack, replaced with a mod id when it can be matched against the loader's mod file scan data and left as the class name when it cannot. When the call chain crosses an event bus, that class may be the subscriber rather than the original caller; the deeper frames are printed under each row in the command output - The deep-mode "one tenth of the threshold" factor has no measured basis. It is an engineering estimate that a single task contributing to a one-second gap is usually in the 100 ms range. If real servers show too many mid-sized tasks slipping through, it will be promoted to its own setting in a later version - Both `TrackLimit` values are frozen when the server starts; changing them requires a restart ### 8. Behavior change The periodic diagnostic summary now keeps printing during degraded pipeline sessions, where it used to stop along with the pipeline. Sync loads and inter-tick gaps come from external call patterns and have nothing to do with whether BAS itself is degraded — and a degraded session is often exactly when the data is needed. The side effect is that the save-metrics summary also keeps printing in that state ### 9. Upgrading - Drop in the new jar; the on-disk format and every existing setting are unchanged - Order matters: replace the jar and restart first so the new build writes the 8 new keys into `common.toml`, then edit them. Editing the config before swapping the jar lets the running old build delete keys it does not recognize - To opt out, set `diagnostics.syncLoadDetection` and `diagnostics.tickGapDetection` to `false`; both take effect immediately ### 10. Verification - The sliding window, p99, LRU eviction and concurrent writes of the tables, the stack filter rules, and the accumulate / keep-maximum semantics of the four new metrics are covered by unit tests, each checked by removing the logic under test and confirming the test fails - Each build carries a build-time ASM gate asserting that the probe really wraps that wait call and that both the timing and the stack capture happen after it returns — so a later change cannot quietly undo the "no stack captured during normal operation" property - One honest gap in coverage: the table that turns a class name into a mod id depends on the loader's runtime mod file scan and has no unit test. It falls back to the fully qualified class name when no match is found ### 11. Why this release adds diagnostics instead of more performance work BAS has been evaluated to the end of what it can do here: under the extreme compatibility constraints it holds itself to, there is no meaningful async chunk optimization left to take. Where room does remain, taking it would break that compatibility — producing data-safety problems and conflicts between mods The same 74-player stress test, sampled over 550 seconds: `NbtIo` writes took 0.03% of main-thread time, `ChunkSerializer` serialization 0.67%, and everything BAS itself does 1.42% in total. Some room genuinely remains — `copySections` unconditionally makes two `PalettedContainer.copy` calls even for empty sections, about 0.1 pp, and batching the POI replay, about 0.1 to 0.2 pp — but all of it is down in the noise, and what can be taken without changing the compatibility premise adds up to less than half a percentage point Nor can it be said that chunk loading has stopped being the bottleneck. The opposite is true: the bottleneck sits in the half of the chunk system BAS cannot reach. In that same sample, `DistanceManager` distance-field propagation consumed 82% of the chunk system's main-thread budget, and the tasks actually driving loads forward only 18% The cost of each further step is concrete. Moving `ForgeCaps` onto a worker thread belongs to the same family as the data loss in issue #8: mods that attach a chunk capability lose data silently. Moving `ChunkDataEvent.Load` onto a worker thread calls every listener off the main thread; it throws nothing and simply rots over time. Moving POI / `SectionStorage` onto a worker thread corrupts villager AI data silently, because `SectionStorage` is not thread-safe. Taking over `DistanceManager` means a state machine that is not thread-safe, and a head-on conflict with C2ME. Requiring installation on both sides, or forcing everything async, means giving up single-side installation, opt-in and instant rollback Performance has reached the limit compatibility allows, so this release changes direction: instead of chasing those last fractions of a percent, the server now says for itself where the stalls come from. The full reasoning is under "明确不做及其理由" in `docs/ROADMAP.md`
release

BetterAutoSave v0.20.0 (Forge 1.20.1)

0.20.0-forge
21 авг. 2026 г.72Minecraft: 1.20.1forge
Скачать
Что нового
## v0.20.0 — Making main-thread sync chunk loads and inter-tick pauses observable 0.20.0 adds two diagnostic features that observe and never intervene: detection of main-thread synchronous chunk loads, and monitoring of long pauses between ticks. Both are on by default, and both builds have them ### 1. Why these two stalls were invisible A production server with 74 players online froze. In the spark profile, BetterAutoSave's own save path on the main thread (`NbtIo` writes) accounted for 0.03% — saving was not the cause. The two actual sources were: - One main-thread synchronous chunk load, triggered by third-party code, blocking for 5.2 seconds - Two pauses that happened between ticks, 17.1 seconds and 14.8 seconds The second kind is the harder one. MSPT only measures time spent inside a tick; the wait between two ticks is not counted, so those 17 seconds are invisible on the TPS graph and on every ordinary monitoring dashboard — the dashboard looks healthy while players are timing out. Digging both out of spark's inclusive call tree took hours of manual work. 0.20.0 makes the server report them by itself ### 2. Main-thread synchronous chunk loads When a chunk is not in memory and something on the main thread asks for it directly, the main thread waits in place until the disk read — and if needed, terrain generation — has finished. The whole server is frozen for that duration. Vanilla rarely takes this path during normal play; what usually triggers it is third-party logic fetching a chunk by coordinates inside an event callback, a scheduled task or a command BAS times the "wait until the chunk is ready" call inside vanilla's `ServerChunkCache.getChunk`. A wait of at least `diagnostics.syncLoadThresholdMs` (default 50 ms, one game tick) is recorded with its duration, chunk coordinates, dimension, and the first non-vanilla class on the call stack: ``` [BetterAutoSave] main-thread sync chunk load: 5188ms at (120,-340) in minecraft:overworld, called from com.example.protection.RegionScanner ``` Each distinct source prints one line the first time it appears; after that it only accumulates into the table, so a player exploring new terrain cannot flood the log The instrumented call sits on the branch taken after vanilla's four-slot chunk cache misses: when the requested chunk is already cached, execution never reaches it. On a miss it costs two extra `System.nanoTime()` calls (tens of nanoseconds), and a stack is captured only once the wait actually exceeds the threshold — during normal operation none is ever captured ### 3. Inter-tick pauses Between the end of one tick and the start of the next, the server waits for the next tick while draining its task queue. That time is not part of MSPT. If something in the queue stalls — a task submitted by another mod, a chunk system callback, a command — MSPT can stay perfectly healthy while players have been frozen for ten seconds or more The default mode takes one timestamp at the start and one at the end of each tick, and records a gap of at least `diagnostics.tickGapThresholdMs` (default 1000 ms): ``` [BetterAutoSave] inter-tick gap: 17100ms after tick 148213 (this time is not counted in MSPT) ``` The default mode reports how long the gap was and which tick it followed, not who caused it. To get down to individual tasks, turn on `diagnostics.tickGapDeepAttribution` (off by default): it times every task in the server task queue and files those taking more than a tenth of the threshold (100 ms by default) under their actual `Runnable` type. That mode adds two nanosecond reads per task and the server runs hundreds of tasks per tick, so turn it on only while narrowing down a gap that has already been reported, then turn it back off ### 4. On how attribution is worded The detection reports which call chain the block happened on, not who has a bug. Fetching a chunk synchronously is a perfectly reasonable thing to write in many situations; its cost simply scales with server size, view distance and disk speed. Within a 5-second wait, the disk, terrain generation and other load on the same host can each be the dominant factor. Stalls reported by this feature usually reflect another mod's call pattern and are not by themselves evidence of a defect in that mod Treat attribution as the starting point of an investigation, not its conclusion. The command output carries the same sentence every time: ``` note: stalls listed above are attributed to the call site, not to a defect in the owning mod. ``` ### 5. Three ways to read the data `/betterautosave diagnose [count]` (default 10, accepts 1-50) prints the running totals and the top N attributions; `/betterautosave diagnose reset` clears both tables and re-arms the once-per-source log de-duplication. A full sample of the output is in section 8 of `docs/CONFIGURATION.en.md` Reset deliberately leaves the cumulative counters (`bas_sync_load_stalls_total` and friends) alone: Prometheus counters must stay monotonic, and zeroing them breaks `rate()`. The command reply says so The periodic diagnostic summary (`diagnostics.diagnosticLogging`, on by default) gains two lines: ``` [BetterAutoSave] |- syncLoad: stalls=37 totalBlocked=48210ms tracked=2 top=examplemod=31400ms x12, com.example.map.RegionCache=14600ms x19 [BetterAutoSave] `- tickGap: exceeded=2 max=17100ms last=14800ms after tick 148213 deepTasks=0 ``` The Prometheus exporter gains four metrics: | Metric | Type | Meaning | |---|---|---| | `bas_sync_load_stalls_total` | counter | Main-thread synchronous chunk loads over the threshold | | `bas_sync_load_stall_seconds_total` | counter | Cumulative seconds blocked in those loads | | `bas_tick_gap_exceeded_total` | counter | Inter-tick gaps over the threshold | | `bas_tick_gap_max_seconds` | gauge | Longest inter-tick gap since server start | ### 6. New settings | Key | Default | Effect | |---|---|---| | `diagnostics.syncLoadDetection` | `true` | Master switch for main-thread sync chunk load detection | | `diagnostics.syncLoadThresholdMs` | `50` | Minimum single-load block time to record, in milliseconds (1 - 60000) | | `diagnostics.syncLoadTrackLimit` | `64` | Max tracked (attribution, stack) pairs, LRU beyond that. Takes effect at startup | | `diagnostics.syncLoadStackDepth` | `24` | Non-vanilla stack frames retained per record (4 - 128) | | `diagnostics.tickGapDetection` | `true` | Master switch for inter-tick gap detection | | `diagnostics.tickGapThresholdMs` | `1000` | Minimum gap to record, in milliseconds (50 - 600000) | | `diagnostics.tickGapDeepAttribution` | `false` | Deep mode: time individual tasks to attribute a gap | | `diagnostics.tickGapDeepTrackLimit` | `64` | LRU limit for the deep-mode table; ignored while deep mode is off. Takes effect at startup | Everything except the two `TrackLimit` keys is hot-reloadable and takes effect immediately ### 7. Known limits - If a mod that rewrites the chunk-fetch path (something in the C2ME family) is installed alongside, the instrumented call may no longer exist and the probe cannot attach. Sync load detection then silently does nothing instead of failing the server's startup — a purely observational feature is not worth a boot crash. Whether the injection point still exists is guarded by build-time tests rather than a runtime hard check - Attribution uses the first non-vanilla class on the stack, replaced with a mod id when it can be matched against the loader's mod file scan data and left as the class name when it cannot. When the call chain crosses an event bus, that class may be the subscriber rather than the original caller; the deeper frames are printed under each row in the command output - The deep-mode "one tenth of the threshold" factor has no measured basis. It is an engineering estimate that a single task contributing to a one-second gap is usually in the 100 ms range. If real servers show too many mid-sized tasks slipping through, it will be promoted to its own setting in a later version - Both `TrackLimit` values are frozen when the server starts; changing them requires a restart ### 8. Behavior change The periodic diagnostic summary now keeps printing during degraded pipeline sessions, where it used to stop along with the pipeline. Sync loads and inter-tick gaps come from external call patterns and have nothing to do with whether BAS itself is degraded — and a degraded session is often exactly when the data is needed. The side effect is that the save-metrics summary also keeps printing in that state ### 9. Upgrading - Drop in the new jar; the on-disk format and every existing setting are unchanged - Order matters: replace the jar and restart first so the new build writes the 8 new keys into `common.toml`, then edit them. Editing the config before swapping the jar lets the running old build delete keys it does not recognize - To opt out, set `diagnostics.syncLoadDetection` and `diagnostics.tickGapDetection` to `false`; both take effect immediately ### 10. Verification - The sliding window, p99, LRU eviction and concurrent writes of the tables, the stack filter rules, and the accumulate / keep-maximum semantics of the four new metrics are covered by unit tests, each checked by removing the logic under test and confirming the test fails - Each build carries a build-time ASM gate asserting that the probe really wraps that wait call and that both the timing and the stack capture happen after it returns — so a later change cannot quietly undo the "no stack captured during normal operation" property - One honest gap in coverage: the table that turns a class name into a mod id depends on the loader's runtime mod file scan and has no unit test. It falls back to the fully qualified class name when no match is found ### 11. Why this release adds diagnostics instead of more performance work BAS has been evaluated to the end of what it can do here: under the extreme compatibility constraints it holds itself to, there is no meaningful async chunk optimization left to take. Where room does remain, taking it would break that compatibility — producing data-safety problems and conflicts between mods The same 74-player stress test, sampled over 550 seconds: `NbtIo` writes took 0.03% of main-thread time, `ChunkSerializer` serialization 0.67%, and everything BAS itself does 1.42% in total. Some room genuinely remains — `copySections` unconditionally makes two `PalettedContainer.copy` calls even for empty sections, about 0.1 pp, and batching the POI replay, about 0.1 to 0.2 pp — but all of it is down in the noise, and what can be taken without changing the compatibility premise adds up to less than half a percentage point Nor can it be said that chunk loading has stopped being the bottleneck. The opposite is true: the bottleneck sits in the half of the chunk system BAS cannot reach. In that same sample, `DistanceManager` distance-field propagation consumed 82% of the chunk system's main-thread budget, and the tasks actually driving loads forward only 18% The cost of each further step is concrete. Moving `ForgeCaps` onto a worker thread belongs to the same family as the data loss in issue #8: mods that attach a chunk capability lose data silently. Moving `ChunkDataEvent.Load` onto a worker thread calls every listener off the main thread; it throws nothing and simply rots over time. Moving POI / `SectionStorage` onto a worker thread corrupts villager AI data silently, because `SectionStorage` is not thread-safe. Taking over `DistanceManager` means a state machine that is not thread-safe, and a head-on conflict with C2ME. Requiring installation on both sides, or forcing everything async, means giving up single-side installation, opt-in and instant rollback Performance has reached the limit compatibility allows, so this release changes direction: instead of chasing those last fractions of a percent, the server now says for itself where the stalls come from. The full reasoning is under "明确不做及其理由" in `docs/ROADMAP.md`
release

BetterAutoSave v0.19.0 (NeoForge 1.21.1)

0.19.0-neoforge
3 авг. 2026 г.774Minecraft: 1.21.1neoforge
Скачать
Что нового
## v0.19.0 — Hardening player data and level.dat, plus the autosave main-thread spike 0.19.0 closes three vanilla paths that lose data silently, builds a full read/write verification loop around `level.dat`, and adds two optional performance optimizations. The data-safety fixes are on by default; the performance optimizations are off by default (0.18.0 was never published; its content is folded into this release) ### 1. Three paths that lose data silently All three are vanilla behavior. None of them raises an error, interrupts anything, or is necessarily noticed by the player right away **A failed player-data read is treated as a brand-new player.** When reading `playerdata/<uuid>.dat` throws, vanilla logs one line and proceeds as if the player had never joined — inventory, position and experience all reset — while `<uuid>.dat_old` sits untouched in the same directory. Power loss mid-write, a bad sector, or an external tool truncating the file all land here `playerData.loadFallback` (on by default) changes this to: quarantine the damaged primary as `<uuid>_corrupted_<timestamp>.dat` to preserve the evidence, then try `.dat_old`, and on a successful read run it through the data fixer and return it. Only when neither file is readable does it fall back to vanilla behavior. A missing primary file — an actual new player — behaves exactly as before **Advancement and stats files are truncating writes.** Vanilla writes `advancements/<uuid>.json` and `stats/<uuid>.json` straight over the live file, with no temp file and no backup. Lose power halfway through and what remains is a length-truncated JSON — the next read fails to parse, and that player's advancements and statistics are gone `playerData.atomicSidecarWrite` (on by default) switches to temp file + atomic rename, keeping the previous copy as `.bak` immediately before the rename. It falls back automatically on filesystems that do not support atomic rename Closing the truncation window is the job of the temp file and the atomic replace themselves. The additional `fsync` sits behind its own switch, `playerData.sidecarFsync`, which is **off by default**: `PlayerList.save` runs on the server thread and writes both files for every online player on every autosave, so turning it on pins two synchronous device flushes per player onto a single tick — 120 of them at 60 players. Vanilla performs no `fsync` anywhere on this path, not even for `playerdata/<uuid>.dat`, and ext4 in its default `data=ordered` mode already flushes the new data before committing a rename over an existing file. Turn it on only if the host has no battery-backed write cache and unclean power loss is a real concern, and pair it with the stagger setting below **A crash shutdown skips the save teardown.** BetterAutoSave's four shutdown guards were all hung off `ServerStoppingEvent`. That event is never fired when the server exits abnormally, and there is a second case where another mod throws inside it and breaks the whole event chain — in both cases every guard is bypassed and in-flight save tasks are discarded This release moves the shutdown flag up to the entry of `stopServer`, bypassing the event mechanism entirely. It also lifts the degraded-teardown contract onto the `SaveTask` interface: the previous type-dispatch code silently dropped any task type it did not have a branch for, while the caller still counted it as "N tasks handled" ### 2. A verification loop around level.dat `level.dat` holds the world seed, spawn point, game rules, dimension configuration and Forge's registry ID table. Corrupting it does not cost you a patch of terrain — it costs you the world, or worse: the server starts from blank metadata that passed as "readable" and the world seed becomes 0 Vanilla has exactly one layer of protection: rotating the previous copy to `level.dat_old` on write. But the read side only consults it when the file **exists and cannot be read**, and its test is simply whether the bytes parse as NBT This release adds three more layers, all on by default: - `levelData.verifyOnStartup` — a four-level check at startup (missing / undecompressable or unparseable / structurally incomplete / OK), with the first three repaired from `level.dat_old`. "Structurally incomplete" means it parses as NBT but is missing `Data`, `DataVersion` or `LevelName` — precisely the class vanilla's test lets through, and the one with the worst consequences - `levelData.startupBackup` — right after verification passes, a raw byte copy is kept under `<world>/betterautosave/leveldat/`, three generations deep. Vanilla will never read that directory, so it takes no part in automatic repair; when both the primary and `level.dat_old` are found damaged, the log prints copy-pasteable restore commands and leaves the decision to the operator - `levelData.postWriteVerify` — after a write, the file is read back and checked on a worker thread (default `CHECKSUM`, which streams the full decompression to trigger gzip's CRC and length checks; `FULL` also applies the structural test; `OFF` disables it). This layer is read-only and never repairs. Its job is to surface a bad write before the next autosave rotates the good copy away ### 3. The autosave main-thread spike (issue #25) On a server with a lot of mods, a spark MSPT graph usually shows an evenly spaced spike every 5 minutes, even with nobody online. It does not come from chunk saving — it comes from vanilla unconditionally rewriting `level.dat` on every autosave One classification detail is worth correcting up front: the file involved is `level.dat` in the world root (world metadata), not the `*.dat` files under `world/data/` (SavedData). BetterAutoSave's existing async saving only covered the latter; the `level.dat` path had never been in scope Once a lot of mods are installed, almost all of `level.dat` is Forge's registry ID table. Measured on a real production server (Forge 1.20.1, 137 mods): | Item | Measured | |---|---| | `level.dat`, uncompressed | 1,234,370 bytes | | of which the registry ID table `fml/Registries` | 1,215,091 bytes (98.44%, 17 registries / 26,648 ids) | | world data `/Data` | 12,018 bytes (0.97%) | | main-thread cost of rebuilding that table | roughly 25ms per autosave | Diffing two `level.dat` files written 5 minutes apart, byte by byte after decompression, shows 5 differing bytes out of 1,234,370 — all of them inside `/Data`. The 1,222,341-byte registry block is identical. In other words, the thing being recomputed for 25ms every 5 minutes comes out exactly the same as last time Adds `levelData.cacheRegistrySnapshot`. When enabled, the table is cached and reused on subsequent saves. Three independent layers invalidate the cache, any one of which forces a rebuild: - Forge's `IdMappingEvent`, covering all three official ID-change paths - A fingerprint of every persisted registry taken before each write (entry count and frozen state), covering `ForgeRegistry.unfreeze()` — a public entry point that emits no event - `levelData.registryCacheRevalidateCycles`, which periodically forces a full rebuild and compares it against the cache tag by tag; a mismatch logs an ERROR and the freshly computed value is used This optimization only removes main-thread rebuild work. It does not change when writes happen, does not introduce background threads, and does not touch the on-disk protocol for `level.dat` Note that on a cache hit the whole of `ForgeHooks.writeAdditionalLevelSaveData` is skipped, so any other mod injecting into that method is skipped as well. No such mod is known (the method is marked internal API and has a single caller), and the cached content is itself taken from a pass on which all such injections did run. If a mod writes time-varying data there, the periodic comparison reports it as a `MISMATCH` Measured in production: 15 hours 18 minutes of continuous uptime, 93 periodic forced rebuilds all matching with zero `MISMATCH`, the registry section byte-identical across four `save-all` runs, and the main-thread cost of `writeAdditionalLevelSaveData` down from 76ms to 16ms ### 4. Main-thread cost of saving players Sampled on a production server with 4 players online, `PlayerList.saveAll` accounts for roughly 80ms of each autosave — about 6.7ms per player, of which advancements are 55%, player data 30% and statistics 15%. This term grows linearly with player count, extrapolating to roughly 400ms per autosave at 60 players This release provides two switches, both off by default: `playerData.advancementsSkipMode` — vanilla rewrites every online player's advancement file on every autosave, whether or not the progress changed. Enabling this skips the write based on a dirty flag. Three settings: `OFF` (vanilla behavior), `AUDIT` (still writes, but compares against a digest of the last write and logs only when the decision would have been wrong — used to confirm the dirty flag misses nothing with your particular mod set), and `ON` (actually skips) One implementation choice is deliberate: vanilla's `progressChanged` set is not reused. It is cleared by `flushDirty` every tick and is almost always empty at autosave time, so using it as the write-side dirty flag would skip saves that genuinely did change. This release uses an independent flag, set only when granting or revoking progress actually succeeds The one thing genuinely lost by skipping is the self-healing property that came for free with vanilla's unconditional rewrite — recovery from external modification (a restored backup, an operator editing the file by hand). `playerData.advancementsForceFullWriteCycles` (default 12) brings it back with a periodic forced full write, which also covers third-party mods that alter progress without going through the standard interface `playerData.staggerMaxPerTick` — spreads an autosave's player writes across the following ticks. The default `0` is vanilla behavior (everyone written in the same tick). It applies only inside the autosave window; `/save-all`, shutdown and player disconnect still write immediately The recommended rollout is the same as for the registry cache: run `AUDIT` for a few days, confirm no mismatch appears in the log, then switch to `ON` ### 5. New settings | Setting | Default | Purpose | |---|---|---| | `playerData.loadFallback` | `true` | Quarantine and fall back to `.dat_old` on a failed player-data read | | `playerData.atomicSidecarWrite` | `true` | Atomic writes plus one backup for advancement and stats files | | `playerData.sidecarFsync` | `false` | Also fsync those writes (main-thread cost, scales with player count) | | `levelData.verifyOnStartup` | `true` | Verify `level.dat` at startup and repair from `level.dat_old` | | `levelData.startupBackup` | `true` | Keep three generations of `level.dat` copies | | `levelData.postWriteVerify` | `CHECKSUM` | Read `level.dat` back on a worker thread after writing | | `levelData.cacheRegistrySnapshot` | `false` | Cache the registry ID table, removing the autosave spike | | `levelData.registryCacheRevalidateCycles` | `12` | Periodically force a rebuild and compare against the cache | | `playerData.advancementsSkipMode` | `OFF` | Skip advancement writes based on a dirty flag | | `playerData.advancementsForceFullWriteCycles` | `12` | Force one full write after this many consecutive skips | | `playerData.staggerMaxPerTick` | `0` | Spread player writes across ticks | ### 6. Build differences Of what this release adds, the `levelData` and `playerData` groups are currently available on the Forge build only The registry cache has no counterpart problem on NeoForge: upstream removed the registry ID table from `level.dat` entirely, so there is nothing to cache and no such spike. The remaining items ship on Forge first, with the symmetric NeoForge port to follow in a later release. The dual-build feature matrix in the README has been filled in and marks the current coverage of each item ### 7. Verification - All 436 unit tests pass (shared module 69 + Forge 232 + NeoForge 135), 46 of them new in this release - Every new piece of logic was mutation-checked: removing the backup-restore logic, the backup rotation, the dirty-flag test, the `DataVersion` criterion, the read-back retry, the shutdown-path window reset, the master-switch check, or the stats write-failure fallback each makes the corresponding cases fail as expected - A dedicated adversarial code review was run before release; it found and closed 6 issues, 2 of which would have caused a main-thread regression or rolled back player saves under real load. The `fsync` split, the forced autosave-window reset on the shutdown path, the retry on read-back verification, and honoring the master switch in every new setting all came out of that pass - The registry cache ran for 15 hours 18 minutes in production; see section 3 above ### 8. Upgrading - Replace the jar; the save format is unchanged - Data-safety fixes are on by default, performance optimizations are off; new keys are filled in with their defaults on first start and existing settings are left alone - The on-disk formats for `level.dat` and player data are identical to vanilla, so you can roll back to an earlier version at any time
release

BetterAutoSave v0.19.0 (Forge 1.20.1)

0.19.0-forge
3 авг. 2026 г.5,1 тыс.Minecraft: 1.20.1forge
Скачать
Что нового
## v0.19.0 — Hardening player data and level.dat, plus the autosave main-thread spike 0.19.0 closes three vanilla paths that lose data silently, builds a full read/write verification loop around `level.dat`, and adds two optional performance optimizations. The data-safety fixes are on by default; the performance optimizations are off by default (0.18.0 was never published; its content is folded into this release) ### 1. Three paths that lose data silently All three are vanilla behavior. None of them raises an error, interrupts anything, or is necessarily noticed by the player right away **A failed player-data read is treated as a brand-new player.** When reading `playerdata/<uuid>.dat` throws, vanilla logs one line and proceeds as if the player had never joined — inventory, position and experience all reset — while `<uuid>.dat_old` sits untouched in the same directory. Power loss mid-write, a bad sector, or an external tool truncating the file all land here `playerData.loadFallback` (on by default) changes this to: quarantine the damaged primary as `<uuid>_corrupted_<timestamp>.dat` to preserve the evidence, then try `.dat_old`, and on a successful read run it through the data fixer and return it. Only when neither file is readable does it fall back to vanilla behavior. A missing primary file — an actual new player — behaves exactly as before **Advancement and stats files are truncating writes.** Vanilla writes `advancements/<uuid>.json` and `stats/<uuid>.json` straight over the live file, with no temp file and no backup. Lose power halfway through and what remains is a length-truncated JSON — the next read fails to parse, and that player's advancements and statistics are gone `playerData.atomicSidecarWrite` (on by default) switches to temp file + atomic rename, keeping the previous copy as `.bak` immediately before the rename. It falls back automatically on filesystems that do not support atomic rename Closing the truncation window is the job of the temp file and the atomic replace themselves. The additional `fsync` sits behind its own switch, `playerData.sidecarFsync`, which is **off by default**: `PlayerList.save` runs on the server thread and writes both files for every online player on every autosave, so turning it on pins two synchronous device flushes per player onto a single tick — 120 of them at 60 players. Vanilla performs no `fsync` anywhere on this path, not even for `playerdata/<uuid>.dat`, and ext4 in its default `data=ordered` mode already flushes the new data before committing a rename over an existing file. Turn it on only if the host has no battery-backed write cache and unclean power loss is a real concern, and pair it with the stagger setting below **A crash shutdown skips the save teardown.** BetterAutoSave's four shutdown guards were all hung off `ServerStoppingEvent`. That event is never fired when the server exits abnormally, and there is a second case where another mod throws inside it and breaks the whole event chain — in both cases every guard is bypassed and in-flight save tasks are discarded This release moves the shutdown flag up to the entry of `stopServer`, bypassing the event mechanism entirely. It also lifts the degraded-teardown contract onto the `SaveTask` interface: the previous type-dispatch code silently dropped any task type it did not have a branch for, while the caller still counted it as "N tasks handled" ### 2. A verification loop around level.dat `level.dat` holds the world seed, spawn point, game rules, dimension configuration and Forge's registry ID table. Corrupting it does not cost you a patch of terrain — it costs you the world, or worse: the server starts from blank metadata that passed as "readable" and the world seed becomes 0 Vanilla has exactly one layer of protection: rotating the previous copy to `level.dat_old` on write. But the read side only consults it when the file **exists and cannot be read**, and its test is simply whether the bytes parse as NBT This release adds three more layers, all on by default: - `levelData.verifyOnStartup` — a four-level check at startup (missing / undecompressable or unparseable / structurally incomplete / OK), with the first three repaired from `level.dat_old`. "Structurally incomplete" means it parses as NBT but is missing `Data`, `DataVersion` or `LevelName` — precisely the class vanilla's test lets through, and the one with the worst consequences - `levelData.startupBackup` — right after verification passes, a raw byte copy is kept under `<world>/betterautosave/leveldat/`, three generations deep. Vanilla will never read that directory, so it takes no part in automatic repair; when both the primary and `level.dat_old` are found damaged, the log prints copy-pasteable restore commands and leaves the decision to the operator - `levelData.postWriteVerify` — after a write, the file is read back and checked on a worker thread (default `CHECKSUM`, which streams the full decompression to trigger gzip's CRC and length checks; `FULL` also applies the structural test; `OFF` disables it). This layer is read-only and never repairs. Its job is to surface a bad write before the next autosave rotates the good copy away ### 3. The autosave main-thread spike (issue #25) On a server with a lot of mods, a spark MSPT graph usually shows an evenly spaced spike every 5 minutes, even with nobody online. It does not come from chunk saving — it comes from vanilla unconditionally rewriting `level.dat` on every autosave One classification detail is worth correcting up front: the file involved is `level.dat` in the world root (world metadata), not the `*.dat` files under `world/data/` (SavedData). BetterAutoSave's existing async saving only covered the latter; the `level.dat` path had never been in scope Once a lot of mods are installed, almost all of `level.dat` is Forge's registry ID table. Measured on a real production server (Forge 1.20.1, 137 mods): | Item | Measured | |---|---| | `level.dat`, uncompressed | 1,234,370 bytes | | of which the registry ID table `fml/Registries` | 1,215,091 bytes (98.44%, 17 registries / 26,648 ids) | | world data `/Data` | 12,018 bytes (0.97%) | | main-thread cost of rebuilding that table | roughly 25ms per autosave | Diffing two `level.dat` files written 5 minutes apart, byte by byte after decompression, shows 5 differing bytes out of 1,234,370 — all of them inside `/Data`. The 1,222,341-byte registry block is identical. In other words, the thing being recomputed for 25ms every 5 minutes comes out exactly the same as last time Adds `levelData.cacheRegistrySnapshot`. When enabled, the table is cached and reused on subsequent saves. Three independent layers invalidate the cache, any one of which forces a rebuild: - Forge's `IdMappingEvent`, covering all three official ID-change paths - A fingerprint of every persisted registry taken before each write (entry count and frozen state), covering `ForgeRegistry.unfreeze()` — a public entry point that emits no event - `levelData.registryCacheRevalidateCycles`, which periodically forces a full rebuild and compares it against the cache tag by tag; a mismatch logs an ERROR and the freshly computed value is used This optimization only removes main-thread rebuild work. It does not change when writes happen, does not introduce background threads, and does not touch the on-disk protocol for `level.dat` Note that on a cache hit the whole of `ForgeHooks.writeAdditionalLevelSaveData` is skipped, so any other mod injecting into that method is skipped as well. No such mod is known (the method is marked internal API and has a single caller), and the cached content is itself taken from a pass on which all such injections did run. If a mod writes time-varying data there, the periodic comparison reports it as a `MISMATCH` Measured in production: 15 hours 18 minutes of continuous uptime, 93 periodic forced rebuilds all matching with zero `MISMATCH`, the registry section byte-identical across four `save-all` runs, and the main-thread cost of `writeAdditionalLevelSaveData` down from 76ms to 16ms ### 4. Main-thread cost of saving players Sampled on a production server with 4 players online, `PlayerList.saveAll` accounts for roughly 80ms of each autosave — about 6.7ms per player, of which advancements are 55%, player data 30% and statistics 15%. This term grows linearly with player count, extrapolating to roughly 400ms per autosave at 60 players This release provides two switches, both off by default: `playerData.advancementsSkipMode` — vanilla rewrites every online player's advancement file on every autosave, whether or not the progress changed. Enabling this skips the write based on a dirty flag. Three settings: `OFF` (vanilla behavior), `AUDIT` (still writes, but compares against a digest of the last write and logs only when the decision would have been wrong — used to confirm the dirty flag misses nothing with your particular mod set), and `ON` (actually skips) One implementation choice is deliberate: vanilla's `progressChanged` set is not reused. It is cleared by `flushDirty` every tick and is almost always empty at autosave time, so using it as the write-side dirty flag would skip saves that genuinely did change. This release uses an independent flag, set only when granting or revoking progress actually succeeds The one thing genuinely lost by skipping is the self-healing property that came for free with vanilla's unconditional rewrite — recovery from external modification (a restored backup, an operator editing the file by hand). `playerData.advancementsForceFullWriteCycles` (default 12) brings it back with a periodic forced full write, which also covers third-party mods that alter progress without going through the standard interface `playerData.staggerMaxPerTick` — spreads an autosave's player writes across the following ticks. The default `0` is vanilla behavior (everyone written in the same tick). It applies only inside the autosave window; `/save-all`, shutdown and player disconnect still write immediately The recommended rollout is the same as for the registry cache: run `AUDIT` for a few days, confirm no mismatch appears in the log, then switch to `ON` ### 5. New settings | Setting | Default | Purpose | |---|---|---| | `playerData.loadFallback` | `true` | Quarantine and fall back to `.dat_old` on a failed player-data read | | `playerData.atomicSidecarWrite` | `true` | Atomic writes plus one backup for advancement and stats files | | `playerData.sidecarFsync` | `false` | Also fsync those writes (main-thread cost, scales with player count) | | `levelData.verifyOnStartup` | `true` | Verify `level.dat` at startup and repair from `level.dat_old` | | `levelData.startupBackup` | `true` | Keep three generations of `level.dat` copies | | `levelData.postWriteVerify` | `CHECKSUM` | Read `level.dat` back on a worker thread after writing | | `levelData.cacheRegistrySnapshot` | `false` | Cache the registry ID table, removing the autosave spike | | `levelData.registryCacheRevalidateCycles` | `12` | Periodically force a rebuild and compare against the cache | | `playerData.advancementsSkipMode` | `OFF` | Skip advancement writes based on a dirty flag | | `playerData.advancementsForceFullWriteCycles` | `12` | Force one full write after this many consecutive skips | | `playerData.staggerMaxPerTick` | `0` | Spread player writes across ticks | ### 6. Build differences Of what this release adds, the `levelData` and `playerData` groups are currently available on the Forge build only The registry cache has no counterpart problem on NeoForge: upstream removed the registry ID table from `level.dat` entirely, so there is nothing to cache and no such spike. The remaining items ship on Forge first, with the symmetric NeoForge port to follow in a later release. The dual-build feature matrix in the README has been filled in and marks the current coverage of each item ### 7. Verification - All 436 unit tests pass (shared module 69 + Forge 232 + NeoForge 135), 46 of them new in this release - Every new piece of logic was mutation-checked: removing the backup-restore logic, the backup rotation, the dirty-flag test, the `DataVersion` criterion, the read-back retry, the shutdown-path window reset, the master-switch check, or the stats write-failure fallback each makes the corresponding cases fail as expected - A dedicated adversarial code review was run before release; it found and closed 6 issues, 2 of which would have caused a main-thread regression or rolled back player saves under real load. The `fsync` split, the forced autosave-window reset on the shutdown path, the retry on read-back verification, and honoring the master switch in every new setting all came out of that pass - The registry cache ran for 15 hours 18 minutes in production; see section 3 above ### 8. Upgrading - Replace the jar; the save format is unchanged - Data-safety fixes are on by default, performance optimizations are off; new keys are filled in with their defaults on first start and existing settings are left alone - The on-disk formats for `level.dat` and player data are identical to vanilla, so you can roll back to an earlier version at any time
release

BetterAutoSave v0.17.0 (NeoForge 1.21.1)

0.17.0-neoforge
31 июл. 2026 г.307Minecraft: 1.21.1neoforge
Скачать
Что нового
# v0.17.0 — Main-thread save capture cost roughly halved 0.17.0 is a performance release. The main-thread cost of capturing a chunk for saving drops by roughly half, by removing a full-chunk scan that ran on every snapshot but whose result was never used. This build also closes a data-safety gap where a third-party biome container could be referenced directly into an async snapshot ## Removing the dead block recount during capture (issue #24) Main-thread capture previously wrapped each section's two paletted containers in a `LevelChunkSection`. That constructor unconditionally calls `recalcBlockCounts()`, which walks all 4096 cells of every section whose palette holds more than one entry, feeding each into a hash map — about 98,000 hash writes for a 24-section chunk The three counts it produces (non-empty blocks, randomly ticking blocks, randomly ticking fluids) only serve live-chunk tick scheduling and network sync. Vanilla reads none of them when serializing a section — a copy with completely wrong counts serializes byte-for-byte identically to a correct one. In other words, the result of that scan was never used anywhere on the save path This build carries only the two containers the worker actually needs for encoding. The on-disk NBT is unchanged, and no mixin or vanilla behavior is involved Before/after on a real production server (Forge 1.20.1, 137 mods, AMD Ryzen 9 9950X3D2, ZGC, 60-second sample, `eventCompatMode = PARTIAL`): | Main-thread frame | Before | After | |---|---|---| | Capture entry `captureWithGeneration` | 6692ms (19.1% of busy time) | 2948ms (8.2%) | | Section capture `copySections` within it | 4096ms | 1024ms | | Block recount `recalcBlockCounts` | 3152ms | 0 (call no longer exists) | | BetterAutoSave total main-thread footprint | 25.8% of busy time | 15.2% | The number of chunks saved in each sample was cross-checked against frames unaffected by this change; the "after" run actually processed about 15% more, so the reductions above are not inflated by a lighter workload A side effect: with the per-section temporary hash map gone, ZGC relocation read barriers (`forwarding_find`) fell 32%. The paletted container deep copy itself became 20% faster as a result, even though that code was not touched To be clear, this does not raise TPS on an already healthy server — both samples ran at TPS 20. What it buys is main-thread headroom: the same save throughput now occupies less main-thread time, making it less likely to crowd the tick budget under heavy load or with large worlds ## No more duplicate capture in FULL compatibility mode Under `eventCompatMode = FULL`, snapshot capture (section copies, light layer clones, heightmap clones, block entity NBT, structure data copies) previously ran unconditionally before the mode branch — even though that mode actually uses the complete NBT produced by vanilla `ChunkSerializer.write`, which gathers the same data itself. That gather-then-discard work is now skipped Users who switched to FULL to work around issue #8 no longer pay double the main-thread capture cost ## Data safety If a live section's biome container is not vanilla's `PalettedContainer` implementation — which vanilla never produces, and only occurs when a third-party mod supplies a read-only container — it was previously referenced directly into the async snapshot. The read-only interface has no copy method, so it could not be detached, meaning the main thread could still mutate the same object while a worker encoded it. This build encodes it to NBT on the main thread instead, fully separating it from the live container ## Upgrading - Drop-in jar replacement, no config changes, no save format changes - The on-disk NBT is byte-for-byte identical to before, so rolling back to 0.16.3 is safe at any time
release

BetterAutoSave v0.17.0 (Forge 1.20.1)

0.17.0-forge
31 июл. 2026 г.161Minecraft: 1.20.1forge
Скачать
Что нового
# v0.17.0 — Main-thread save capture cost roughly halved 0.17.0 is a performance release. The main-thread cost of capturing a chunk for saving drops by roughly half, by removing a full-chunk scan that ran on every snapshot but whose result was never used. This build also closes a data-safety gap where a third-party biome container could be referenced directly into an async snapshot ## Removing the dead block recount during capture (issue #24) Main-thread capture previously wrapped each section's two paletted containers in a `LevelChunkSection`. That constructor unconditionally calls `recalcBlockCounts()`, which walks all 4096 cells of every section whose palette holds more than one entry, feeding each into a hash map — about 98,000 hash writes for a 24-section chunk The three counts it produces (non-empty blocks, randomly ticking blocks, randomly ticking fluids) only serve live-chunk tick scheduling and network sync. Vanilla reads none of them when serializing a section — a copy with completely wrong counts serializes byte-for-byte identically to a correct one. In other words, the result of that scan was never used anywhere on the save path This build carries only the two containers the worker actually needs for encoding. The on-disk NBT is unchanged, and no mixin or vanilla behavior is involved Before/after on a real production server (Forge 1.20.1, 137 mods, AMD Ryzen 9 9950X3D2, ZGC, 60-second sample, `eventCompatMode = PARTIAL`): | Main-thread frame | Before | After | |---|---|---| | Capture entry `captureWithGeneration` | 6692ms (19.1% of busy time) | 2948ms (8.2%) | | Section capture `copySections` within it | 4096ms | 1024ms | | Block recount `recalcBlockCounts` | 3152ms | 0 (call no longer exists) | | BetterAutoSave total main-thread footprint | 25.8% of busy time | 15.2% | The number of chunks saved in each sample was cross-checked against frames unaffected by this change; the "after" run actually processed about 15% more, so the reductions above are not inflated by a lighter workload A side effect: with the per-section temporary hash map gone, ZGC relocation read barriers (`forwarding_find`) fell 32%. The paletted container deep copy itself became 20% faster as a result, even though that code was not touched To be clear, this does not raise TPS on an already healthy server — both samples ran at TPS 20. What it buys is main-thread headroom: the same save throughput now occupies less main-thread time, making it less likely to crowd the tick budget under heavy load or with large worlds ## No more duplicate capture in FULL compatibility mode Under `eventCompatMode = FULL`, snapshot capture (section copies, light layer clones, heightmap clones, block entity NBT, structure data copies) previously ran unconditionally before the mode branch — even though that mode actually uses the complete NBT produced by vanilla `ChunkSerializer.write`, which gathers the same data itself. That gather-then-discard work is now skipped Users who switched to FULL to work around issue #8 no longer pay double the main-thread capture cost ## Data safety If a live section's biome container is not vanilla's `PalettedContainer` implementation — which vanilla never produces, and only occurs when a third-party mod supplies a read-only container — it was previously referenced directly into the async snapshot. The read-only interface has no copy method, so it could not be detached, meaning the main thread could still mutate the same object while a worker encoded it. This build encodes it to NBT on the main thread instead, fully separating it from the live container ## Upgrading - Drop-in jar replacement, no config changes, no save format changes - The on-disk NBT is byte-for-byte identical to before, so rolling back to 0.16.3 is safe at any time