BetterAutoSave
简体中文 | English

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
-alljar (named likeshinoyuki_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
| Key | Default | Description |
|---|---|---|
general.enabled |
true | Master switch; off means vanilla behavior, as if not installed |
throttle.chunksPerTickBase |
4 | Max chunks snapshotted by the main thread per game tick |
throttle.adaptiveEnabled |
true | Slow down automatically when the server struggles; keep it on |
workers.chunkWorkerThreads |
2 | Background threads for chunks |
workers.entityWorkerThreads |
2 | Background threads for entities |
workers.savedDataWorkerThreads |
1 | Background threads for saved data; raise to 2 with mods that write a lot of vanilla SavedData |
compat.eventCompatMode |
PARTIAL | Event 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.
| Feature | Forge 1.20.1 | NeoForge 1.21.1 |
|---|---|---|
| Async saving (chunks / entities / SavedData) | yes | yes |
Async chunk loading ([load] section) |
yes | no (the section does not exist; the NeoForge build has no load-side mixins at all) |
| level.dat registry cache | yes | not needed (upstream moved the table out of level.dat) |
| level.dat startup check | yes | not needed (1.21 already falls back and quarantines on a read failure) |
| level.dat startup backup / post-write verify | yes | no (1.21 has no equivalent either; port pending) |
| playerdata read fallback | yes | not needed (fixed upstream in 1.21) |
| advancements / stats atomic write | yes | no (1.21 still truncates on write; port pending) |
| advancements dirty skip, staggered player saving | yes | no (performance only; port pending) |
| Sync chunk load / inter-tick gap diagnostics | yes | yes |
In-game commands
Requires OP (permission level 2).
| Command | Effect |
|---|---|
/betterautosave status |
One-line current status |
/betterautosave metrics |
One-line metrics summary |
/betterautosave debug |
Full diagnostics: queue depths, per-stage timings, counters |
/betterautosave flush |
Drain every pending save to disk. The command returns immediately and polls in the background until it completes or times out (safety.shutdownTimeoutSeconds) |
/betterautosave drain-unload |
Wait 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-async |
Force 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 reset |
Clear 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:
- Disable temporarily: set
general.enabledtofalse, restart or/reload. The mod stays installed but all logic is skipped — pure vanilla. - Uninstall completely: move the jar out of
mods/and restart. World data remains protected by vanilla saving; uninstalling loses nothing. - Tune instead of removing: if you suspect a performance setting, adjust
chunksPerTickBase(1-64) or switcheventCompatModetoFULLfirst — 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.
All Versions
+ Add VersionIn 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 betweenTwo 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 anywhere2. **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
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 betweenTwo 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 anywhere2. **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
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 @Overwrites 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 usualThe 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
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 @Overwrites 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 usualThe 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
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 recognizeTo 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`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 recognizeTo 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`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>.datthrows, vanilla logs one line and proceeds as if the player had never joined — inventory, position and experience all reset — while<uuid>.dat_oldsits 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>.jsonandstats/<uuid>.jsonstraight 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 renameClosing 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 0Vanilla 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 NBTThis 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 fromlevel.dat_old. "Structurally incomplete" means it parses as NBT but is missingData,DataVersionorLevelName— precisely the class vanilla's test lets through, and the one with the worst consequenceslevelData.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 andlevel.dat_oldare found damaged, the log prints copy-pasteable restore commands and leaves the decision to the operatorlevelData.postWriteVerify— after a write, the file is read back and checked on a worker thread (defaultCHECKSUM, which streams the full decompression to trigger gzip's CRC and length checks;FULLalso applies the structural test;OFFdisables 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 autosaveOne 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 scopeOnce 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 timeAdds
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.datNote 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 MISMATCHMeasured 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 playersThis 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 succeedsThe 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 interfaceplayerData.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 immediatelyThe 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 onlyThe 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
DataVersioncriterion, 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
fsyncsplit, 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.datand player data are identical to vanilla, so you can roll back to an earlier version at any time
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>.datthrows, vanilla logs one line and proceeds as if the player had never joined — inventory, position and experience all reset — while<uuid>.dat_oldsits 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>.jsonandstats/<uuid>.jsonstraight 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 renameClosing 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 0Vanilla 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 NBTThis 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 fromlevel.dat_old. "Structurally incomplete" means it parses as NBT but is missingData,DataVersionorLevelName— precisely the class vanilla's test lets through, and the one with the worst consequenceslevelData.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 andlevel.dat_oldare found damaged, the log prints copy-pasteable restore commands and leaves the decision to the operatorlevelData.postWriteVerify— after a write, the file is read back and checked on a worker thread (defaultCHECKSUM, which streams the full decompression to trigger gzip's CRC and length checks;FULLalso applies the structural test;OFFdisables 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 autosaveOne 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 scopeOnce 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 timeAdds
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.datNote 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 MISMATCHMeasured 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 playersThis 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 succeedsThe 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 interfaceplayerData.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 immediatelyThe 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 onlyThe 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
DataVersioncriterion, 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
fsyncsplit, 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.datand player data are identical to vanilla, so you can roll back to an earlier version at any time
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 chunkThe 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 touchedTo 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 skippedUsers 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
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 chunkThe 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 touchedTo 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 skippedUsers 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
0.16.3 primarily fixes a distribution problem: on Modrinth, NeoForge 1.21.1 users who hit the default download got the Forge 1.20.1 jar, which FML then rejects with "requires forge / 1.20.1" — surfacing to users as "incompatible with neoforge". The mod itself was never at fault — the 0.16.2 NeoForge jar loads and runs correctly on real NeoForge 21.1.233 and 21.1.235 servers. This build also rolls up a batch of data-safety and config fixes accumulated since 0.16.2
## Distribution fix (main reason for this release)
Every release previously bundled both the Forge and NeoForge jars into a single Modrinth version, tagged with the union of both loaders (forge, neoforge) and both game versions (1.20.1, 1.21.1). Modrinth applies those tags to the whole version, and the default download is the version's first file (Forge's self-contained
-all.jar) — so this one version showed up under "NeoForge 1.21.1", "Forge 1.20.1", and even the non-existent "Forge 1.21.1" / "NeoForge 1.20.1" combinations, and a NeoForge user hitting the default download always got the Forge jarThis build changes the release pipeline to publish one Modrinth version per loader (
+forge / +neoforge), each carrying only its own jar as the default download, each appearing only under the correct loader and game version. The GitHub Release side already ships the two jars under distinct names and is unaffected## Data safety and correctness
- The in-flight dedup key for same-named SavedData across dimensions now uses the file path, fixing a case where identically named data in different dimensions could be treated as one
- Online single-chunk restore now performs a real transactional rollback on install; the async lighting/resend tail of a restore gained
exceptionallyAsyncso failures are no longer swallowed silently - Fixed a case where a task already past the gate could be silently dropped in the window around a save-degradation flip
- Async loading (opt-in, Forge only) now handles replay failure and worker parse failure separately, and its misleading log lines are corrected
- Async-loading mixins now apply gated on
load.enabled, with zero bytecode involvement when the feature is off - The SavedData large-file guard now keys off the uncompressed in-memory footprint, matching real spikes more closely
## Config changes
- Removed the ineffective
entityChunksPerTickBaseknob (it had no effect) - Raised the
deadlineGuardSecondslower bound to 5, so setting it to 0 can no longer disable the save deadline backstop
## Performance and compatibility
- The NeoForge SavedData path adopts serialize-once, removing the main-thread
tag.copydeep-copy spike - On startup, detecting another async-save mod that also takes over
ChunkMap.save(such as fastasyncworldsave) now logs a WARN advising you to run only one
## Upgrading
- Drop-in jar replacement
- NeoForge 1.21.1 users: Modrinth now distributes correctly per loader — just grab the NeoForge build (the old mis-tagged combined versions have been taken down)
- If your config manually set the now-removed
entityChunksPerTickBase, or setdeadlineGuardSecondsbelow 5, it is corrected automatically on startup
0.16.3 primarily fixes a distribution problem: on Modrinth, NeoForge 1.21.1 users who hit the default download got the Forge 1.20.1 jar, which FML then rejects with "requires forge / 1.20.1" — surfacing to users as "incompatible with neoforge". The mod itself was never at fault — the 0.16.2 NeoForge jar loads and runs correctly on real NeoForge 21.1.233 and 21.1.235 servers. This build also rolls up a batch of data-safety and config fixes accumulated since 0.16.2
## Distribution fix (main reason for this release)
Every release previously bundled both the Forge and NeoForge jars into a single Modrinth version, tagged with the union of both loaders (forge, neoforge) and both game versions (1.20.1, 1.21.1). Modrinth applies those tags to the whole version, and the default download is the version's first file (Forge's self-contained
-all.jar) — so this one version showed up under "NeoForge 1.21.1", "Forge 1.20.1", and even the non-existent "Forge 1.21.1" / "NeoForge 1.20.1" combinations, and a NeoForge user hitting the default download always got the Forge jarThis build changes the release pipeline to publish one Modrinth version per loader (
+forge / +neoforge), each carrying only its own jar as the default download, each appearing only under the correct loader and game version. The GitHub Release side already ships the two jars under distinct names and is unaffected## Data safety and correctness
- The in-flight dedup key for same-named SavedData across dimensions now uses the file path, fixing a case where identically named data in different dimensions could be treated as one
- Online single-chunk restore now performs a real transactional rollback on install; the async lighting/resend tail of a restore gained
exceptionallyAsyncso failures are no longer swallowed silently - Fixed a case where a task already past the gate could be silently dropped in the window around a save-degradation flip
- Async loading (opt-in, Forge only) now handles replay failure and worker parse failure separately, and its misleading log lines are corrected
- Async-loading mixins now apply gated on
load.enabled, with zero bytecode involvement when the feature is off - The SavedData large-file guard now keys off the uncompressed in-memory footprint, matching real spikes more closely
## Config changes
- Removed the ineffective
entityChunksPerTickBaseknob (it had no effect) - Raised the
deadlineGuardSecondslower bound to 5, so setting it to 0 can no longer disable the save deadline backstop
## Performance and compatibility
- The NeoForge SavedData path adopts serialize-once, removing the main-thread
tag.copydeep-copy spike - On startup, detecting another async-save mod that also takes over
ChunkMap.save(such as fastasyncworldsave) now logs a WARN advising you to run only one
## Upgrading
- Drop-in jar replacement
- NeoForge 1.21.1 users: Modrinth now distributes correctly per loader — just grab the NeoForge build (the old mis-tagged combined versions have been taken down)
- If your config manually set the now-removed
entityChunksPerTickBase, or setdeadlineGuardSecondsbelow 5, it is corrected automatically on startup
0.16.0 brings the async chunk load introduced in v0.15 to a stable release: load-side POI reads moved off-thread, a thread-safety symmetry fix for the capability subsystem, and hardened shutdown paths. Async load is still off by default and opt-in (
load.enabled), aimed at server owners chasing load performance who are willing to opt in. Forge 1.20.1 build only## What this version brings
Async load (PARTIAL mode) moves chunk deserialization from the main thread to a background load worker. The 0.16 cycle did three things around this path:
- Tier A async POI prefetch**: the load worker reads that column's POI region bytes off-thread during deserialization, the main thread populates the cache before replay, eliminating the synchronous
tryRead().join()disk wait that used to sit on the main thread. Real production server (137 mods, 8 load workers, fast flight): main-thread POIgetOrLoaddropped from 15.0% to 0.1%, load-side main-thread cost down ~13.7% - Capability subsystem thread-safety symmetry fix**: during chunk load,
LevelChunkconstruction dispatches the globalAttachCapabilitiesEventandreadCapsFromNBTtriggers third-party capability deserialization — both are the same kind of main-thread-assuming work as the already-deferredChunkDataEvent.Load. This build defers them to the main-thread replay as well, fixing a potential thread-safety hazard for mods that attach persistent capabilities to chunks under async load. The replay executes faithfully, capability data is read back as usual, no data loss - Hardened shutdown**: load / save workers are now daemon threads with a JVM shutdown fallback hook, preventing another mod that throws on the shutdown event chain from hanging the whole server while BAS finishes its teardown; in-flight load tasks are also included in the shutdown drain barrier, symmetric with the save side
## Verification
- Pre-release multi-angle adversarial review of the capability off-thread path + 155 unit tests (including byte-faithful replay assertions for the capability defer)
- Test-server reobf jar end-to-end: the async load worker loads spawn chunks through the capability defer + main-thread replay, zero exceptions, zero data delta
## Safety and compatibility
- Lossless: capability gather and deserialization are replayed faithfully on the main thread in original order, byte-for-byte equivalent to vanilla synchronous load
- Failure fallback: a worker parse failure falls back to a main-thread vanilla read, reads disk bytes only, zero data loss
- Users without async load enabled (the default): no behavior change in the load path
## Configuration (
[load] section)load.enabled(default false): master switch for async chunk load, opt-inload.asyncPoiPrefetch(default true): with async load on and PARTIAL mode, prefetch the POI region on the load worker; set false to fall back to a main-thread synchronous read if a POI-storage mod misbehaves
## Upgrading
- Drop-in jar replacement. Without async load enabled (
load.enabled=false, default): no behavior change - Modpacks that attach persistent capabilities to chunks: this build fixes a thread-safety hazard for those capabilities under async load — upgrading is recommended
This build extends v0.15's async load by moving the POI region disk read off the main thread onto the background load worker. With async load on, this POI read wait was the single largest remaining main-thread cost; this build effectively eliminates it. The feature kicks in automatically with async load, Forge 1.20.1 build only. Async load itself is still off by default and opt-in
## What it does
- Async load (PARTIAL mode) moves chunk deserialization to a background worker, but
PoiManager.checkConsistencyWithBlocks(the POI consistency check) still replays on the main thread, and the POI region read it triggers blocks the main thread synchronously ontryRead().join()waiting for disk - spark profiling: under fast movement / high view-distance, this POI read wait is the top remaining main-thread cost once async load is on
- This build has the load worker read that column's POI region bytes off-thread during deserialization; the main thread populates the POI cache from those prefetched bytes before replay, so the consistency check hits the cache instead of reading disk
## Measured impact
- Real production server (137-mod heavyweight pack, 8 load workers, fast flight): main-thread POI
getOrLoaddropped from **15.0% to 0.1%**, the synchronous read wait gone, main-thread chunk-load cost down ~**13.7%** - TPS at cap, zero parse fallbacks, zero POI data delta
## Configuration (
[load] section)load.asyncPoiPrefetch(default true, hot-reloadable): whether to prefetch the POI region on the load worker. Only takes effect when async load is on (load.enabled=true) and mode is PARTIAL. Set false to fall back to a main-thread synchronous POI read (vanilla behavior) if a POI-storage mod misbehaves
## Safety and compatibility
- Thread safety: the worker only touches the underlying IOWorker (thread-safe), never the non-concurrent POI cache; the POI cache parse and writes all stay on the main-thread replay, in line with every other POI access
- No data loss: the prefetched bytes are that column's already-saved POI data, and the main-thread parse-and-populate is byte-for-byte equivalent to vanilla
readColumn; if the column was already loaded before replay, the prefetched bytes are skipped in favor of the live data - Failure fallback: a failed POI prefetch does not affect chunk loading — it falls back to a main-thread vanilla POI read
- The online chunk-restore path does not prefetch (it restores an already-loaded chunk whose POI is usually in memory, so a prefetch would be skipped by the guard and wasted)
## Why it is still a beta
- Tier A is a load-side enhancement to async load, which itself is still in public testing and off by default; this build is marked pre-release alongside it
- Main-thread cost elimination and zero-data-loss signals are real-server verified, but sustained concurrent multiplayer steady state is still under observation
## Upgrading
- Drop-in jar replacement. If you have not enabled async load (
load.enabled=false, the default): this build changes nothing - If you have enabled async load: Tier A is on automatically (
asyncPoiPrefetch=true), no extra configuration needed; setasyncPoiPrefetch=falseto disable it
0.15.0-beta.1 is the first async-chunk-load prerelease that runs on a real production server. The previous public prerelease v0.13.0-beta.1 shipped a jar that omitted the MixinExtras dependency, so enabling async loading on a real server crashed it on a missing class; this build fixes the packaging, ships a self-contained jar, and rewrites the async-load implementation to be non-blocking. The feature is still off by default and opt-in, Forge 1.20.1 build only. Back up your entire world folder before enabling it
### Key fix: MixinExtras packaging
- Async load's instruction injection is built on MixinExtras
@WrapOperation. The v0.13.0-beta.1 jar did not bundle that dependency, so a real server threwClassNotFoundExceptionwhen loading the async-load mixins (the dev environment ships the dependency, so this was only ever validated in dev and the problem stayed hidden) - This build ships a self-contained jar (MixinExtras embedded) — install and go, no manual dependency to add. This is the first async-load build that can actually be validated in production
### What it does
- Vanilla deserializes a loading chunk's on-disk bytes into game objects (
ChunkSerializer.read) on the main thread; with a high view-distance and players moving or teleporting fast, this becomes a main-thread burden - When enabled, the pure deserialization runs on a background load worker; the main thread keeps only what must run there: POI consistency, light sections, and
ChunkDataEvent.Loadreplay - The real payoff is on production servers (view-distance 10-12 + many players): the freed main-thread time becomes TPS headroom, smoother loading, room for more players
- It is not a flight-speed silver bullet: single-player flying at max view-distance is bound by vanilla's single-threaded chunk pipeline, which off-thread deserialization cannot help — that part cannot be parallelized without breaking mod compatibility
### What it does not touch
- This optimizes the disk serialization boundary of **existing** chunks (read from disk, written to disk), not **worldgen**. Generating never-before-seen terrain is untouched: brand-new chunks never pass through
ChunkSerializer.read, so async load only kicks in for chunks that were previously generated and saved
### Changes since v0.13.0-beta.1
- Non-blocking future chain: async load's internals were rewritten from "
@WrapOperation+joinblock-and-wait" into a non-blocking future chain, so the main thread no longer stalls waiting on a background parse - In-flight throttle (
LoadInFlightLimiter): caps deserializations in flight so a batch finishing at once cannot land all its main-thread replay in a single tick;maxInFlightdefault 32 → 128, and hot-adjustable - Fine-grained Codec lock: the parse path's concurrency guard is narrowed to the Codec dispatch cache alone, cutting contention between workers
- Now includes the v0.12.1 SavedData deep-copy spike fix (issue #12): oversized
.datsaves no longer deep-copy the whole tag on the main thread - Adds the online chunk-restore coordination layer (
SaveCoordination): lets the companion BetterBackup restore a single chunk or an area in place without stopping the server; on a standalone BAS install this is a dormant low-level capability — the matching command lives in BetterBackup
### Configuration (
[load] section, off by default)load.enabled(default false): master switch for async loading, independent of the save-sidegeneral.enabledload.loadEventCompatMode(default PARTIAL): PARTIAL = deserialize on a worker, POI/light/event back on the main thread; FULL = the entire read stays on the main thread (equivalent to the feature being off, zero behavior delta — a per-config fallback if a mod misbehaves)load.maxInFlight(default 128, hot-adjustable): cap on deserializations submitted to workers concurrently, so a batch finishing at once cannot land all its main-thread replay in a single tick; raise per server until a burst reappearsload.loadMaxRetries(default 1): worker-thread re-attempts after a parse throws, before falling back to a vanilla main-thread readworkers.loadWorkerThreads(default 2): load worker count; deserialization is largely single-thread bound, 2 covers typical loads; raise on high-core machines with high view-distance or fast movement
### Safety and compatibility
- Data safety: if an off-thread parse fails, the same bytes are re-read on the main thread as a fallback — no data is lost in any case
- One-flag kill switch:
loadEventCompatMode=FULLorload.enabled=falsereturns to vanilla load behavior instantly; config hot-reloads, no restart needed - Mixin compatibility: every chunk-read injection point uses
@WrapOperation, coexisting with mods (e.g. architectury) that touch the same instructions rather than claiming them exclusively - Pick one against other mods that also load chunks off-thread (C2ME-Forge); all other mods are orthogonal and do not conflict
### Why it is a beta
- Verified on a real 137-mod heavyweight pack (Create / immersive vehicles and other block-entity-dense content): self-contained jar, async loading on, TPS locked at 19.99 while flying at 450 m/s, heavy chunk deserialization confirmed off the main thread, zero worker errors, zero degrade, zero data-loss signals
- But "repeated save/reload in dense areas" and "sustained concurrent multiplayer" are not yet exhaustively verified, hence off by default and marked pre-release — back up first, roll out to a small group, and report issues
### Upgrading
- Drop-in jar replacement; this build's jar embeds MixinExtras and installs as-is — do not keep using the old v0.13.0-beta.1 jar (missing dependency will crash)
- Async loading stays off and does not change existing save behavior; to try the new feature, set
load.enabledto true — and back up your world first
BetterAutoSave now ships a NeoForge 1.21.1 build from the same 0.11.0 source. Features, configuration, commands and compatibility levels are identical to the Forge 1.20.1 build: the async-save algorithm core (scheduler, relay state machine, workers, diagnostics) is zero-Minecraft pure Java, source-merged and shared by both loaders, so the crown-jewel save relay protocol lives once and never forks per loader
### Requirements
- NeoForge 21.1 line, Java 21 or newer
- Server-side only, clients do not need to install it
- jar:
shinoyuki_betterautosave-neoforge-0.11.0.jar
### Porting highlights
- Serialization and disk-IO glue verified point by point against the decompiled 1.21.1 sources: chunk-status getter rename, the registries argument on block-entity and SavedData saves, the Path overload for compressed NBT writes, the DataResult getter shape, the chunk-holder latest-chunk rename
- All 7 mixin injection points survive on 1.21.1; entity storage's region IO is pushed down into SimpleRegionStorage on 1.21.1, and the async entity pipeline is reused unchanged behind two accessor hops (EntityStorage -> SimpleRegionStorage -> worker)
- Async chunk saving now also writes NeoForge data attachments and the LevelChunk custom-light field, matching the vanilla on-disk layout — mods that use data attachments do not lose them during async saves
### Verification
- Real NeoForge 1.21.1 server: all mixins applied, periodic and operational
/save-allsaves with no main-thread stall, chunks and entities written asynchronously, workers drained cleanly on shutdown, zero load errors on restart - 114 unit tests green (including the cases that need a Minecraft runtime bootstrap)
### Installation
Drop
shinoyuki_betterautosave-neoforge-0.11.0.jar into the server's mods/; config path, commands and compatibility levels match the Forge buildThis beta adds async chunk **loading** — moving the deserialization of a chunk from its on-disk bytes back into game objects off the main thread. It is the load-side counterpart to the existing async **save**. The feature is off by default and opt-in, and is still in public testing; this beta ships a Forge 1.20.1 build only (the NeoForge 1.21.1 port comes later). Back up your entire world folder before enabling it
## What it does
- Vanilla deserializes a loading chunk's on-disk bytes into game objects (
ChunkSerializer.read) on the main thread; with a high view-distance and players moving or teleporting fast, this becomes a main-thread burden - When enabled, the pure deserialization runs on a background load worker; the main thread keeps only what must run there: POI consistency, light sections, and
ChunkDataEvent.Loadreplay - The real payoff is on production servers (view-distance 10-12 + many players): the freed main-thread time becomes TPS headroom, smoother loading, room for more players
- It is not a flight-speed silver bullet: single-player flying at max view-distance is bound by vanilla's single-threaded chunk pipeline, which off-thread deserialization cannot help — that part cannot be parallelized without breaking mod compatibility
## What it does not touch
- This optimizes the disk serialization boundary of **existing** chunks (read from disk, written to disk), not **worldgen**. Generating never-before-seen terrain is untouched: brand-new chunks never pass through
ChunkSerializer.read, so async load only kicks in for chunks that were previously generated and saved
## Configuration (
[load] section, off by default)load.enabled(default false): master switch for async loading, independent of the save-sidegeneral.enabledload.loadEventCompatMode(default PARTIAL): PARTIAL = deserialize on a worker, POI/light/event back on the main thread; FULL = the entire read stays on the main thread (equivalent to the feature being off, zero behavior delta — a per-config fallback if a mod misbehaves)load.maxInFlight(default 128): cap on deserializations submitted to workers concurrently, so a batch finishing at once cannot land all its main-thread replay in a single tick; raise per server until a burst reappearsload.loadMaxRetries(default 1): worker-thread re-attempts after a parse throws, before falling back to a vanilla main-thread readworkers.loadWorkerThreads(default 2): load worker count; deserialization is largely single-thread bound, 2 covers typical loads
## Safety and compatibility
- Data safety: if an off-thread parse fails, the same bytes are re-read on the main thread as a fallback — no data is lost in any case
- One-flag kill switch:
loadEventCompatMode=FULLorload.enabled=falsereturns to vanilla load behavior instantly; config hot-reloads, no restart needed - Mixin compatibility: every chunk-read injection point uses
@WrapOperation, coexisting with mods (e.g. architectury) that touch the same instructions rather than claiming them exclusively - Pick one against other mods that also load chunks off-thread (C2ME-Forge); all other mods are orthogonal and do not conflict
## Why it is a beta
- Verified on a real 138-mod heavyweight pack (Create / MTR / immersive vehicles and other block-entity-dense content): with async loading on, zero worker errors, zero degrade, zero data-loss signals
- But "repeated save/reload in dense areas" and "concurrent multiplayer" are not yet exhaustively verified, hence off by default and marked pre-release — back up first, roll out to a small group, and report issues
## Upgrading
Drop-in jar replacement; async loading stays off and does not change existing save behavior. To try the new feature, set
load.enabled to true — and back up your world firstBetterAutoSave now ships from a single source tree as both a Forge 1.20.1 and a NeoForge 1.21.1 build. The async-save core (scheduling, the relay state machine, workers, diagnostics) is zero-Minecraft-dependency pure Java, source-merged and shared by both loaders. This release also fixes the silent loss of chunk capability data (#8) and the data-integrity issues found in a pre-release code review.
## NeoForge 1.21.1
- For NeoForge 21.1.x, Java 21+, server-side only; jar:
shinoyuki_betterautosave-neoforge-0.12.0.jar - Features, config, commands, and compatibility modes are identical to the Forge 1.20.1 build
- Serialization and disk-IO glue verified point-by-point against decompiled 1.21.1 sources; all 7 mixin injection points survive; entity region IO moved into SimpleRegionStorage in 1.21.1, adapted through a two-hop accessor so the async entity pipeline is reused as-is
- Async chunk save now includes NeoForge data attachments and the LevelChunk auxiliary light field, matching the vanilla on-disk format — mods using data attachments will not lose data during async saves
- Verified on a real NeoForge 1.21.1 server: all mixins load, periodic and operational
/save-allrun without main-thread stalls, workers drain cleanly on shutdown, restart loads with zero corruption
## Data-integrity fixes
- Silent chunk-capability data loss (#8)**: in the default PARTIAL mode, the main-thread hand-built chunk NBT omitted the
ForgeCapsblock Forge injects at the end ofChunkSerializer.write, so any mod storing data in a chunk capability (e.g. illusion) lost it on every async save and lost it world-wide after a normal restart. PARTIAL/DISABLED now serialize ForgeCaps on the main thread, matching the vanilla format (the NeoForge data-attachment path was already correct) - SavedData async-write race**: a mod's
save()may return a live internal tag; while a worker serializes it asynchronously, the mod mutating it can throwConcurrentModificationExceptionor write a torn NBT. The outer tag is now deep-copied before hand-off, matching NeoForge vanillaSavedData.save's own copy - Data loss on degraded fallback**: when workers all die from a systemic failure (typically OOM) and the pipeline degrades, queued tasks that had already declared their data "saved" but were never written used to be skipped by the shutdown flush and lost. The degrade transition now drains the residual tasks — chunks restore their unsaved flag for the vanilla synchronous fallback, SavedData is re-marked dirty, and entity losses (no coordinate recovery) are reported — all thread-safe
## Other hardening
- The in-flight serializing counter is compensated under extreme queue OOM, so shutdown no longer burns its full timeout and falsely reports unsaved data
- SavedData atomic writes clean up the temp file if any step fails, leaving no orphans
- Operational
/save-all flush's intentional deviations from a few vanilla side-effects are now documented; behavior unchanged
## Upgrading
Drop-in jar replacement, no config changes. Forge users on 0.11.x are strongly encouraged to upgrade — 0.11.x still carries the #8 chunk-capability loss risk
/save-all flush hang during operationCritical hotfix for a regression: running
/save-all flush while the server is live could spin the main thread in an endless loop, tripping the 60-second ServerWatchdog kill and freezing the server. This affects any path that issues /save-all flush during operation — an admin running it by hand, backup mods snapshotting before a save, or a scheduled save task.## The problem
Vanilla
saveAllChunks(flush=true) keeps re-scanning every chunk "as long as any chunk got written this round," stopping only once a full pass writes nothing. To advance vanilla's save cooldown, BAS reports already-clean chunks as "saved"; during operation that report keeps the loop's condition permanently true, so the main thread never leaves the save loop and the watchdog declares a hang after 60 seconds.Only the operational
/save-all flush reaches this path. Shutdown flush is unaffected (it runs a different synchronous drain), and periodic autosave is unaffected.## The fix
Operational
/save-all flush no longer hands control to vanilla's synchronous loop. It now uses the same async enqueue path as periodic autosave — dirty chunks go to the background serialization workers and the command returns in well under a second. Shutdown flush keeps its existing synchronous drain semantics unchanged.## Upgrading
Drop-in jar replacement, no config changes. Every 0.11.0 user should upgrade: if anything on the server can issue
/save-all flush, 0.11.0 can be watchdog-killed after a few hours of uptime.Fixes #5, #6
The pendingSnapshot relay protocol (the mechanism that relays the latest-generation snapshot to disk when in-flight IO collides with new edits) used to spread its state across four independent atomic units with no single linearization point; multiple rounds of interleaving attacks proved that patching could not converge. This release consolidates it into a single-word SlotWord CAS state machine and systematically seals the collision, terminal and shutdown paths — 10 Critical / 3 Major / 1 Minor fixes, unit tests 139 -> 179. Features and config unchanged. Each entry reads "call site: 0.10 consequence; current behavior"
### Relay protocol (chunk)
- ChunkMapSaveMixin collision branch, old order capture -> dispatch -> register: in 0.10, an in-flight IO landing during dispatch found an empty slot, and the relay snapshot registered afterwards became an orphan with no consumer — the latest generation was silently lost; registration now precedes dispatch, so the callback can see the relay slot at any moment
- Same branch with register moved first, while a third-party ChunkDataEvent.Save listener is still mutating the tag on the main thread during dispatch: in 0.10 the in-flight callback could take the not-yet-ready tag and resubmit it, three threads sharing one NBT without barriers, potentially corrupting what hit the disk; the relay slot is now a three-state EMPTY/PREPARING/READY machine — consumption rights never leave the main thread before dispatch returns, callbacks only mark missed and the main-thread publish self-kicks
- ChunkSaveTask terminal-death exits (onUnhandledError / retries exhausted) used drainPendingSnapshot to seize the slot unconditionally: in 0.10 they could equally seize a PREPARING tag still being mutated by the main thread; replaced by takeReadyForTerminalConsumer with atomic three-way consumption — READY is relayed directly, PREPARING is handed back to the main thread, only EMPTY falls through to the vanilla fallback
- Stale missed marker surviving across save cycles at enterSerializing: in 0.10 a new cycle could inherit it and the main thread would resubmit concurrently before the current generation's IO had landed — with multiple workers, an older generation could overwrite the newest one, silently losing data; replaced by a generational fence (globally monotonic cycleSeq), missed is only valid within its own cycle
- Landing and slot-take split into two steps (ioCompletedSuccessfully + takeReadyPendingSnapshot): in 0.10, getting descheduled between the two steps stamped missed into the wrong cycle, causing premature relays or a false CLEAN that settled the books early; landAndTake merges both into a single CAS, missed is always stamped with the cycle at landing time
- Protocol fields (phase / mustDrain / relay slot / in-flight generation) consolidated into the single-word SlotWord: in 0.10, the interleaving of a terminal consumer clearing mustDrain with the main thread's begin occupying the slot could drop the gauge to 0, so the shutdown join stopped waiting for an in-flight relay and lost its increment; with the single CAS, a non-empty slot now guarantees gauge >= 1
- begin landing after the callback has already exited via CLEAN_LANDED (phase==CLEAN): in 0.10 neither self-kick trigger fired and publish released a READY orphan with no consumer ever coming — increments were silently lost on the unload path and mustDrainPending leaked +1 permanently; begin now sets a no-in-flight-consumer flag in the same CAS, and publish uses it to hand the pending back to the main thread for an immediate resubmit
- markMustDrain in the terminal-failure HANDED_TO_MAIN branch overwrote TERMINAL_HANDED: in 0.10 this deactivated one of publish's self-kick triggers (the redundant trigger covered it, no data loss); the overwrite is removed, both triggers now work independently
### Entity path
- EntitySaveTask CLEAN_LANDED branch evicted without taking the slot: in 0.10, when the callback misjudged CLEAN from a stale generation read, the freshest entity list just registered by the main thread was garbage-collected together with the state object — the entities had already been evicted by vanilla and there is no recovery channel, a permanent silent loss; CLEAN_LANDED now always takes the slot before evicting, and relays if non-empty
- EntityStorageMixin registration and callback slot-take invisible to each other: in 0.10, a REQUEUE callback taking the slot before registration got null and skipped the resubmit, and the pending registered afterwards fell into a dead state — stuck forever with mustDrainPending leaking +1; a write-then-read duality is now established (callback writes the terminal phase then takes the slot, main thread writes the slot then re-reads phase) so at least one side always sees the other, getAndSet arbitrates the sole consumer, and the main thread self-kicks a resubmit when it detects no in-flight consumer
- EntitySaveState.ioFailed published a transient DIRTY during in-place REQUEUE resubmits: in 0.10 a main-thread re-read hitting that window misjudged "no in-flight consumer" and stole the relay slot, running a second in-flight cycle against the retrying callback and overwriting inFlightGeneration; the resubmit now stays IO_PENDING throughout, and DIRTY is narrowed to the true terminal "no consumer, wait for next cycle"
- Entity collision branch not clearing mustDrain after capturePending throws (typically OOM): in 0.10 the gauge leaked +1 permanently for that position; the catch now performs the CAS cleanup, symmetric with the chunk-side cancellation path
### IO failure and shutdown
- A synchronous throw from submit inside submitIo was swallowed by a dependent future nobody joins: in 0.10 the task was stuck in IO_PENDING forever, inFlightIoPending leaked +1, and the shutdown drainPending never saw zero; submit is now wrapped in its own try — a synchronous throw rebalances the gauge and goes through the existing IO-failure terminal compensation
- A synchronous throw (OOM) at the REQUEUE relay-resubmit sink: in 0.10 the already-taken relay generation was lost outright and the serializing gauge leak poisoned the shutdown verdict; resubmits are wrapped in safeReoffer, the sink balances its own inc/dec, and an exceptionally handler at the end of the callback chain guarantees no path stays silent
- The workersStopping residual window (a relay arriving after shutdown began) only logged without settling: in 0.10 mustDrain became an ownerless orphan — the diagnostics idle gate never closed, drain-unload reported false timeouts, and the Prometheus gauge drifted permanently; the residual-window branch now performs the real CAS cleanup, idempotent against double decrements
### Upgrading
Drop-in jar replacement, config unchanged
这是一个纯修复版本,补上了三个边界问题:两个低概率但一旦触发就会丢存档的情况,以及一个运营中执行
/save-all flush 会卡服的毛病。另外修了几处文档里写过时的版本号。功能没有任何变化### 修了两个会丢存档的边界
这两个问题都不常见——要在后台保存恰好出错的那一刻撞上才会发生,平时跑图基本碰不到。但一旦撞上,对应的区块这一轮的改动就再也存不下来了,连关服时的兜底保存也救不回,所以必须堵死。
- 后台派发出错后区块卡死**:BAS 把区块交给后台保存时,如果那一步抛了异常,区块会停在一个"既不再尝试后台保存、也不交还给原版同步保存"的死状态,玩家不再编辑它就永久丢失。现在出错后会把区块状态复位,下一轮自动重新保存,原版兜底也能接手
- 写盘失败后区块被静默丢弃**:区块数据写硬盘失败时,BAS 原本会安排重试,但漏了一步——没把区块重新标记成"待保存",导致重试和关服兜底都看不到它,于是被悄悄丢掉。现在写盘失败的区块会在主线程被重新标记,下一轮重试或交还原版同步保存,不再丢
###
/save-all flush 不再卡服以前在服务器运行中手动执行
/save-all flush,主线程会停下来死等所有后台保存任务写完,最长能卡几十秒,期间全服掉 TPS、玩家卡顿。现在区分了两种情况:关服时仍然老老实实等写完(必须的,不能丢数据);运行中执行则不再阻塞,后台保存继续在背景跑,命令立即返回,想看进度用 /betterautosave status### 顺手加固了 .dat 文件写入
存档里的
.dat 文件(袭击数据、强制加载区块、部分 mod 的存档等)现在改成原子写入——先写临时文件、刷盘、再整体替换。这样即使写到一半掉电或进程被杀,也不会留下一个损坏的半截文件导致下次读不出来。多线程写同名文件的并发也做了去重,不会再交错写坏### 文档勘误
README 安装说明里的 jar 文件名、路线图里的当前版本标注,之前还写着 0.9,更新成了 0.10
### 升级
替换 jar 即可,配置文件不变,无需任何调整
v0.10 是一个小版本:为配套的备份 mod [Shinoyuki-BetterBackup](https://github.com/xiaoxiao-cvs/Shinoyuki-BetterBackup) 提供集成接口,顺便把诊断日志的默认频率调得更安静。
### 管线状态通知接口
新增
PipelineStateListener API。BAS 的后台工作线程如果遇到异常会进入降级模式(自动回退原版保存路径,存档安全不受影响)——从这个版本起,其他 mod 可以订阅这个信号。配套的 BetterBackup 用它在 BAS 降级时自动暂停备份并把快照标记为不完整,避免在不知情的状态下产出"看起来正常"的备份。装 BetterBackup 需要 BAS v0.10.0 起步。
不装 BetterBackup 的话这个接口什么都不做,普通玩家无感知。
### 诊断日志不再刷屏
后台诊断摘要的默认间隔从每 10 秒一组改为每 5 分钟一组,配置上限放宽到 1 小时。
注意:已经在跑的服务器,配置文件里存的还是旧值。想安静的话把
common.toml 里的这一项改大,保存即生效,不用重启:``
toml
[diagnostics]
diagnosticLogIntervalTicks = 6000 # 5 分钟一组; 最大 72000 (1 小时)
``需要持续监控的服主建议直接用 v0.9 的 Prometheus 接口,比看日志舒服得多。