Shinoyuki-BetterAutoSaveСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачатьСкачать
ВЕРСИИ (22)
release
BetterAutoSave v0.20.2 (NeoForge 1.21.1)
0.20.2-neoforge21 сент. 2026 г.1,8 тыс.Minecraft: 1.21.1neoforge
Что нового
## v0.20.2 — Fixes two race windows in async chunk saving
In two very narrow timing windows, async chunk saving exposed a false "this chunk has no write in flight" signal to the main thread. When hit, the same chunk ends up with two concurrent writes, or a save request is wrongly treated as "already being handled" and skipped. The worst outcomes are the chunk rolling back to an older state after a restart, or the last edits before an unload never reaching disk
Each window is only a few instructions wide and requires the main thread to process a save of that same chunk at exactly that moment, so the real-world probability is low. They are data-safety issues nonetheless, and all 0.20.x users are advised to upgrade. This release only touches the chunk saving path
### 1. Problem
In `ChunkMap.save` the main thread picks one of two branches purely from the chunk's save state: if it is "in flight" (a write is already under way), the latest content is registered as a relay snapshot to be written once the in-flight write finishes; if it is "dirty", a new save cycle is started. Switching between these states has to happen in a single step, with no other value visible to the main thread in between
Two paths split that switch into two steps, briefly exposing "dirty" in the middle:
1. **In-place retry after a failed write**. When a write fails and `maxRetries` is not yet exhausted, the callback resubmits the already-serialized old data in place. It first set the state to "dirty" and only in the next step back to "in flight". A main thread caught in between concludes nothing is in flight: if it wins the race it opens a second save cycle that writes the same chunk concurrently with the retry; if it loses, it treats this save as "already being handled" and lets it through without registering a relay. If the latter happens during a chunk unload, the last edits before the unload have no copy left anywhere
2. **Relay write**. When the in-flight write finishes and a relay snapshot is waiting, the callback first set the state to "dirty" and took the snapshot, and only in the next step entered the relay cycle. Registering a relay does not mark the chunk as saved, so a main thread processing the same chunk during this gap likewise opens a second cycle. If the chunk was edited again after the relay snapshot was taken, both cycles consider their data stale when they land and neither marks the chunk clean; if the older data happens to finish writing last, the region file keeps the old content while the chunk is already marked as saved and will not be written again
The second window does not require a disk error — it only needs the same chunk to be edited and saved again while its previous write is in flight (typically on unload, or during autosave under load)
Under some interleavings both windows also leave the internal pending-drain counter permanently one too high: `/betterautosave drain-unload` never sees it reach zero, and the Prometheus `mustDrain` metric keeps reporting a pending chunk
### 2. Fix
The rule: only a callback that is truly finishing, with no further write to follow, may set the state to "dirty". A callback that is going to write again stays "in flight" throughout
1. When a failed write still has retries left, the state stays "in flight" and the data is resubmitted in place. Only when the serialization thread itself fails and no resubmission is possible does the fallback set the state to "dirty" on its own, so the next save cycle recaptures the chunk. The entity saving path has worked this way since 0.11.0; this release brings the chunk path in line
2. Taking the relay snapshot and entering the relay cycle are now one atomic operation, with no "dirty" in between
Neither change touches the save format, config keys or the public API, and the state machine behaves as before on every other path
### 3. Verification
- All 538 unit tests pass, 9 of them new: they drive the state machine step by step in the interleaved order on a single thread to reproduce both windows deterministically, plus a randomized case covering retry limits from 0 to 10 including the exact-exhaustion boundary
- Mutation testing: reverting each key part of the fix individually (6 variants) makes tests fail every time
- The fix was independently reviewed adversarially by two separate reviewers
### 4. Upgrading
Drop in the new jar. The save format and config keys are unchanged
release
BetterAutoSave v0.20.2 (Forge 1.20.1)
0.20.2-forge21 сент. 2026 г.4,5 тыс.Minecraft: 1.20.1forge
Что нового
## v0.20.2 — Fixes two race windows in async chunk saving
In two very narrow timing windows, async chunk saving exposed a false "this chunk has no write in flight" signal to the main thread. When hit, the same chunk ends up with two concurrent writes, or a save request is wrongly treated as "already being handled" and skipped. The worst outcomes are the chunk rolling back to an older state after a restart, or the last edits before an unload never reaching disk
Each window is only a few instructions wide and requires the main thread to process a save of that same chunk at exactly that moment, so the real-world probability is low. They are data-safety issues nonetheless, and all 0.20.x users are advised to upgrade. This release only touches the chunk saving path
### 1. Problem
In `ChunkMap.save` the main thread picks one of two branches purely from the chunk's save state: if it is "in flight" (a write is already under way), the latest content is registered as a relay snapshot to be written once the in-flight write finishes; if it is "dirty", a new save cycle is started. Switching between these states has to happen in a single step, with no other value visible to the main thread in between
Two paths split that switch into two steps, briefly exposing "dirty" in the middle:
1. **In-place retry after a failed write**. When a write fails and `maxRetries` is not yet exhausted, the callback resubmits the already-serialized old data in place. It first set the state to "dirty" and only in the next step back to "in flight". A main thread caught in between concludes nothing is in flight: if it wins the race it opens a second save cycle that writes the same chunk concurrently with the retry; if it loses, it treats this save as "already being handled" and lets it through without registering a relay. If the latter happens during a chunk unload, the last edits before the unload have no copy left anywhere
2. **Relay write**. When the in-flight write finishes and a relay snapshot is waiting, the callback first set the state to "dirty" and took the snapshot, and only in the next step entered the relay cycle. Registering a relay does not mark the chunk as saved, so a main thread processing the same chunk during this gap likewise opens a second cycle. If the chunk was edited again after the relay snapshot was taken, both cycles consider their data stale when they land and neither marks the chunk clean; if the older data happens to finish writing last, the region file keeps the old content while the chunk is already marked as saved and will not be written again
The second window does not require a disk error — it only needs the same chunk to be edited and saved again while its previous write is in flight (typically on unload, or during autosave under load)
Under some interleavings both windows also leave the internal pending-drain counter permanently one too high: `/betterautosave drain-unload` never sees it reach zero, and the Prometheus `mustDrain` metric keeps reporting a pending chunk
### 2. Fix
The rule: only a callback that is truly finishing, with no further write to follow, may set the state to "dirty". A callback that is going to write again stays "in flight" throughout
1. When a failed write still has retries left, the state stays "in flight" and the data is resubmitted in place. Only when the serialization thread itself fails and no resubmission is possible does the fallback set the state to "dirty" on its own, so the next save cycle recaptures the chunk. The entity saving path has worked this way since 0.11.0; this release brings the chunk path in line
2. Taking the relay snapshot and entering the relay cycle are now one atomic operation, with no "dirty" in between
Neither change touches the save format, config keys or the public API, and the state machine behaves as before on every other path
### 3. Verification
- All 538 unit tests pass, 9 of them new: they drive the state machine step by step in the interleaved order on a single thread to reproduce both windows deterministically, plus a randomized case covering retry limits from 0 to 10 including the exact-exhaustion boundary
- Mutation testing: reverting each key part of the fix individually (6 variants) makes tests fail every time
- The fix was independently reviewed adversarially by two separate reviewers
### 4. Upgrading
Drop in the new jar. The save format and config keys are unchanged
release
BetterAutoSave v0.20.1 (NeoForge 1.21.1)
0.20.1-neoforge22 авг. 2026 г.4,9 тыс.Minecraft: 1.21.1neoforge
Что нового
## v0.20.1 — Fixes a startup crash when installed alongside Lithium-family optimisation mods
0.20.0 crashes the server at startup when installed together with Harium, Radium, Radium Re-Reforged, Canary or another Lithium port. If you run any of those, upgrade to 0.20.1 or roll back to 0.19.0. Only 0.20.0 is affected
### 1. Symptom
Mixin application fails at startup and the entire config is rejected:
```
Mixin apply failed shinoyuki_betterautosave.mixins.json:ServerChunkCacheSyncLoadMixin
-> net.minecraft.server.level.ServerChunkCache
InvalidInjectionException: @At("INVOKE") on ...betterautosave$measureSyncChunkLoad with priority 1000
cannot inject into ServerChunkCache::m_7587_ merged by
me.jellysquid.mods.lithium.mixin.world.chunk_access.ServerChunkManagerMixin with priority 1000
```
What fails is the sync-load detector added in 0.20.0, but the whole of `shinoyuki_betterautosave.mixins.json` goes down with it — async saving and async loading stop working too, and the server does not start
### 2. Cause
The 0.20.0 detector wraps the "wait until the chunk is ready" call inside `ServerChunkCache.getChunk` with `@WrapOperation` and declares `require = 0`, intending "if the injection point is gone, skip silently rather than let a pure observation feature take the server down"
That declaration does not cover the failure mode that actually occurred. Lithium ports `@Overwrite` `getChunk` outright, and Mixin has an earlier check in `Injector.findTargetNodes`: if the injector's priority is not higher than that of the mixin which already merged the method, it throws `InvalidInjectionException` immediately. Both sides sit at the default priority of 1000, so the condition fails — and because this check runs *before* the require hit-count check, `require = 0` is never consulted
The 0.20.0 release notes said "the detector silently does nothing rather than failing server startup". The intent was right; the implementation did not deliver it. This release does
### 3. Fix
The detector's injection now passes a gate at class-load time. If either condition holds, the mixin is not applied at all:
1. `diagnostics.syncLoadDetection` is `false` in the on-disk `common.toml` (a missing file, section or key defaults to `true`)
2. A mod known to `@Overwrite getChunk` is present: `harium`, `radium`, `canary`, `lithium`
When the gate closes, a WARN line names the mod responsible:
```
[BetterAutoSave] sync chunk load detector disabled: mod 'harium' overwrites
ServerChunkCache.getChunk, which Mixin will not let us wrap. Async saving and loading are unaffected
```
That line is necessary: without it, "I installed 0.20 but never see any sync-load reports" has no explanation. **Saving, async loading and every other feature are unaffected by this gate**
Raising the mixin's priority was considered and rejected. BAS would then apply first, and the other mod's `@Overwrite` would replace the whole method body along with the injection — moving the crash risk onto them while this feature stops working anyway
### 4. Similar mods not on the list
Condition 2 is a whitelist covering the four known cases. If a mod that also `@Overwrite`s `getChunk` appears and is not listed, set `diagnostics.syncLoadDetection` to `false` and restart (condition 1); no version update is required
The gating logic is identical on both loaders. The NeoForge side had no mixin config plugin before this release; one has been added
### 5. Verification
- 529 unit tests, 11 of them covering the gate itself: whitelist matching, case handling, a reverse assertion against false positives, TOML section awareness and default-value semantics
- Both directions verified on a 1.20.1 test server: BAS 0.20.0 + Harium 2.0 reproduces the startup crash reliably; with 0.20.1 the server starts normally and the log names `harium`; with Harium removed the server starts normally, the WARN is absent and the detector is injected as usual
- The crash exception is quoted verbatim in the source comments alongside the fix, so that `require = 0` is not reached for again against this class of problem
### 6. Upgrading
Drop in the new jar. Save format and config keys are unchanged. Versions 0.19.0 and earlier do not contain this mixin and are not affected
release
BetterAutoSave v0.20.1 (Forge 1.20.1)
0.20.1-forge22 авг. 2026 г.13 тыс.Minecraft: 1.20.1forge
Что нового
## v0.20.1 — Fixes a startup crash when installed alongside Lithium-family optimisation mods
0.20.0 crashes the server at startup when installed together with Harium, Radium, Radium Re-Reforged, Canary or another Lithium port. If you run any of those, upgrade to 0.20.1 or roll back to 0.19.0. Only 0.20.0 is affected
### 1. Symptom
Mixin application fails at startup and the entire config is rejected:
```
Mixin apply failed shinoyuki_betterautosave.mixins.json:ServerChunkCacheSyncLoadMixin
-> net.minecraft.server.level.ServerChunkCache
InvalidInjectionException: @At("INVOKE") on ...betterautosave$measureSyncChunkLoad with priority 1000
cannot inject into ServerChunkCache::m_7587_ merged by
me.jellysquid.mods.lithium.mixin.world.chunk_access.ServerChunkManagerMixin with priority 1000
```
What fails is the sync-load detector added in 0.20.0, but the whole of `shinoyuki_betterautosave.mixins.json` goes down with it — async saving and async loading stop working too, and the server does not start
### 2. Cause
The 0.20.0 detector wraps the "wait until the chunk is ready" call inside `ServerChunkCache.getChunk` with `@WrapOperation` and declares `require = 0`, intending "if the injection point is gone, skip silently rather than let a pure observation feature take the server down"
That declaration does not cover the failure mode that actually occurred. Lithium ports `@Overwrite` `getChunk` outright, and Mixin has an earlier check in `Injector.findTargetNodes`: if the injector's priority is not higher than that of the mixin which already merged the method, it throws `InvalidInjectionException` immediately. Both sides sit at the default priority of 1000, so the condition fails — and because this check runs *before* the require hit-count check, `require = 0` is never consulted
The 0.20.0 release notes said "the detector silently does nothing rather than failing server startup". The intent was right; the implementation did not deliver it. This release does
### 3. Fix
The detector's injection now passes a gate at class-load time. If either condition holds, the mixin is not applied at all:
1. `diagnostics.syncLoadDetection` is `false` in the on-disk `common.toml` (a missing file, section or key defaults to `true`)
2. A mod known to `@Overwrite getChunk` is present: `harium`, `radium`, `canary`, `lithium`
When the gate closes, a WARN line names the mod responsible:
```
[BetterAutoSave] sync chunk load detector disabled: mod 'harium' overwrites
ServerChunkCache.getChunk, which Mixin will not let us wrap. Async saving and loading are unaffected
```
That line is necessary: without it, "I installed 0.20 but never see any sync-load reports" has no explanation. **Saving, async loading and every other feature are unaffected by this gate**
Raising the mixin's priority was considered and rejected. BAS would then apply first, and the other mod's `@Overwrite` would replace the whole method body along with the injection — moving the crash risk onto them while this feature stops working anyway
### 4. Similar mods not on the list
Condition 2 is a whitelist covering the four known cases. If a mod that also `@Overwrite`s `getChunk` appears and is not listed, set `diagnostics.syncLoadDetection` to `false` and restart (condition 1); no version update is required
The gating logic is identical on both loaders. The NeoForge side had no mixin config plugin before this release; one has been added
### 5. Verification
- 529 unit tests, 11 of them covering the gate itself: whitelist matching, case handling, a reverse assertion against false positives, TOML section awareness and default-value semantics
- Both directions verified on a 1.20.1 test server: BAS 0.20.0 + Harium 2.0 reproduces the startup crash reliably; with 0.20.1 the server starts normally and the log names `harium`; with Harium removed the server starts normally, the WARN is absent and the detector is injected as usual
- The crash exception is quoted verbatim in the source comments alongside the fix, so that `require = 0` is not reached for again against this class of problem
### 6. Upgrading
Drop in the new jar. Save format and config keys are unchanged. Versions 0.19.0 and earlier do not contain this mixin and are not affected
release
BetterAutoSave v0.20.0 (NeoForge 1.21.1)
0.20.0-neoforge21 авг. 2026 г.102Minecraft: 1.21.1neoforge
Что нового
## v0.20.0 — Making main-thread sync chunk loads and inter-tick pauses observable
0.20.0 adds two diagnostic features that observe and never intervene: detection of main-thread synchronous chunk loads, and monitoring of long pauses between ticks. Both are on by default, and both builds have them
### 1. Why these two stalls were invisible
A production server with 74 players online froze. In the spark profile, BetterAutoSave's own save path on the main thread (`NbtIo` writes) accounted for 0.03% — saving was not the cause. The two actual sources were:
- One main-thread synchronous chunk load, triggered by third-party code, blocking for 5.2 seconds
- Two pauses that happened between ticks, 17.1 seconds and 14.8 seconds
The second kind is the harder one. MSPT only measures time spent inside a tick; the wait between two ticks is not counted, so those 17 seconds are invisible on the TPS graph and on every ordinary monitoring dashboard — the dashboard looks healthy while players are timing out. Digging both out of spark's inclusive call tree took hours of manual work. 0.20.0 makes the server report them by itself
### 2. Main-thread synchronous chunk loads
When a chunk is not in memory and something on the main thread asks for it directly, the main thread waits in place until the disk read — and if needed, terrain generation — has finished. The whole server is frozen for that duration. Vanilla rarely takes this path during normal play; what usually triggers it is third-party logic fetching a chunk by coordinates inside an event callback, a scheduled task or a command
BAS times the "wait until the chunk is ready" call inside vanilla's `ServerChunkCache.getChunk`. A wait of at least `diagnostics.syncLoadThresholdMs` (default 50 ms, one game tick) is recorded with its duration, chunk coordinates, dimension, and the first non-vanilla class on the call stack:
```
[BetterAutoSave] main-thread sync chunk load: 5188ms at (120,-340) in minecraft:overworld, called from com.example.protection.RegionScanner
```
Each distinct source prints one line the first time it appears; after that it only accumulates into the table, so a player exploring new terrain cannot flood the log
The instrumented call sits on the branch taken after vanilla's four-slot chunk cache misses: when the requested chunk is already cached, execution never reaches it. On a miss it costs two extra `System.nanoTime()` calls (tens of nanoseconds), and a stack is captured only once the wait actually exceeds the threshold — during normal operation none is ever captured
### 3. Inter-tick pauses
Between the end of one tick and the start of the next, the server waits for the next tick while draining its task queue. That time is not part of MSPT. If something in the queue stalls — a task submitted by another mod, a chunk system callback, a command — MSPT can stay perfectly healthy while players have been frozen for ten seconds or more
The default mode takes one timestamp at the start and one at the end of each tick, and records a gap of at least `diagnostics.tickGapThresholdMs` (default 1000 ms):
```
[BetterAutoSave] inter-tick gap: 17100ms after tick 148213 (this time is not counted in MSPT)
```
The default mode reports how long the gap was and which tick it followed, not who caused it. To get down to individual tasks, turn on `diagnostics.tickGapDeepAttribution` (off by default): it times every task in the server task queue and files those taking more than a tenth of the threshold (100 ms by default) under their actual `Runnable` type. That mode adds two nanosecond reads per task and the server runs hundreds of tasks per tick, so turn it on only while narrowing down a gap that has already been reported, then turn it back off
### 4. On how attribution is worded
The detection reports which call chain the block happened on, not who has a bug. Fetching a chunk synchronously is a perfectly reasonable thing to write in many situations; its cost simply scales with server size, view distance and disk speed. Within a 5-second wait, the disk, terrain generation and other load on the same host can each be the dominant factor. Stalls reported by this feature usually reflect another mod's call pattern and are not by themselves evidence of a defect in that mod
Treat attribution as the starting point of an investigation, not its conclusion. The command output carries the same sentence every time:
```
note: stalls listed above are attributed to the call site, not to a defect in the owning mod.
```
### 5. Three ways to read the data
`/betterautosave diagnose [count]` (default 10, accepts 1-50) prints the running totals and the top N attributions; `/betterautosave diagnose reset` clears both tables and re-arms the once-per-source log de-duplication. A full sample of the output is in section 8 of `docs/CONFIGURATION.en.md`
Reset deliberately leaves the cumulative counters (`bas_sync_load_stalls_total` and friends) alone: Prometheus counters must stay monotonic, and zeroing them breaks `rate()`. The command reply says so
The periodic diagnostic summary (`diagnostics.diagnosticLogging`, on by default) gains two lines:
```
[BetterAutoSave] |- syncLoad: stalls=37 totalBlocked=48210ms tracked=2 top=examplemod=31400ms x12, com.example.map.RegionCache=14600ms x19
[BetterAutoSave] `- tickGap: exceeded=2 max=17100ms last=14800ms after tick 148213 deepTasks=0
```
The Prometheus exporter gains four metrics:
| Metric | Type | Meaning |
|---|---|---|
| `bas_sync_load_stalls_total` | counter | Main-thread synchronous chunk loads over the threshold |
| `bas_sync_load_stall_seconds_total` | counter | Cumulative seconds blocked in those loads |
| `bas_tick_gap_exceeded_total` | counter | Inter-tick gaps over the threshold |
| `bas_tick_gap_max_seconds` | gauge | Longest inter-tick gap since server start |
### 6. New settings
| Key | Default | Effect |
|---|---|---|
| `diagnostics.syncLoadDetection` | `true` | Master switch for main-thread sync chunk load detection |
| `diagnostics.syncLoadThresholdMs` | `50` | Minimum single-load block time to record, in milliseconds (1 - 60000) |
| `diagnostics.syncLoadTrackLimit` | `64` | Max tracked (attribution, stack) pairs, LRU beyond that. Takes effect at startup |
| `diagnostics.syncLoadStackDepth` | `24` | Non-vanilla stack frames retained per record (4 - 128) |
| `diagnostics.tickGapDetection` | `true` | Master switch for inter-tick gap detection |
| `diagnostics.tickGapThresholdMs` | `1000` | Minimum gap to record, in milliseconds (50 - 600000) |
| `diagnostics.tickGapDeepAttribution` | `false` | Deep mode: time individual tasks to attribute a gap |
| `diagnostics.tickGapDeepTrackLimit` | `64` | LRU limit for the deep-mode table; ignored while deep mode is off. Takes effect at startup |
Everything except the two `TrackLimit` keys is hot-reloadable and takes effect immediately
### 7. Known limits
- If a mod that rewrites the chunk-fetch path (something in the C2ME family) is installed alongside, the instrumented call may no longer exist and the probe cannot attach. Sync load detection then silently does nothing instead of failing the server's startup — a purely observational feature is not worth a boot crash. Whether the injection point still exists is guarded by build-time tests rather than a runtime hard check
- Attribution uses the first non-vanilla class on the stack, replaced with a mod id when it can be matched against the loader's mod file scan data and left as the class name when it cannot. When the call chain crosses an event bus, that class may be the subscriber rather than the original caller; the deeper frames are printed under each row in the command output
- The deep-mode "one tenth of the threshold" factor has no measured basis. It is an engineering estimate that a single task contributing to a one-second gap is usually in the 100 ms range. If real servers show too many mid-sized tasks slipping through, it will be promoted to its own setting in a later version
- Both `TrackLimit` values are frozen when the server starts; changing them requires a restart
### 8. Behavior change
The periodic diagnostic summary now keeps printing during degraded pipeline sessions, where it used to stop along with the pipeline. Sync loads and inter-tick gaps come from external call patterns and have nothing to do with whether BAS itself is degraded — and a degraded session is often exactly when the data is needed. The side effect is that the save-metrics summary also keeps printing in that state
### 9. Upgrading
- Drop in the new jar; the on-disk format and every existing setting are unchanged
- Order matters: replace the jar and restart first so the new build writes the 8 new keys into `common.toml`, then edit them. Editing the config before swapping the jar lets the running old build delete keys it does not recognize
- To opt out, set `diagnostics.syncLoadDetection` and `diagnostics.tickGapDetection` to `false`; both take effect immediately
### 10. Verification
- The sliding window, p99, LRU eviction and concurrent writes of the tables, the stack filter rules, and the accumulate / keep-maximum semantics of the four new metrics are covered by unit tests, each checked by removing the logic under test and confirming the test fails
- Each build carries a build-time ASM gate asserting that the probe really wraps that wait call and that both the timing and the stack capture happen after it returns — so a later change cannot quietly undo the "no stack captured during normal operation" property
- One honest gap in coverage: the table that turns a class name into a mod id depends on the loader's runtime mod file scan and has no unit test. It falls back to the fully qualified class name when no match is found
### 11. Why this release adds diagnostics instead of more performance work
BAS has been evaluated to the end of what it can do here: under the extreme compatibility constraints it holds itself to, there is no meaningful async chunk optimization left to take. Where room does remain, taking it would break that compatibility — producing data-safety problems and conflicts between mods
The same 74-player stress test, sampled over 550 seconds: `NbtIo` writes took 0.03% of main-thread time, `ChunkSerializer` serialization 0.67%, and everything BAS itself does 1.42% in total. Some room genuinely remains — `copySections` unconditionally makes two `PalettedContainer.copy` calls even for empty sections, about 0.1 pp, and batching the POI replay, about 0.1 to 0.2 pp — but all of it is down in the noise, and what can be taken without changing the compatibility premise adds up to less than half a percentage point
Nor can it be said that chunk loading has stopped being the bottleneck. The opposite is true: the bottleneck sits in the half of the chunk system BAS cannot reach. In that same sample, `DistanceManager` distance-field propagation consumed 82% of the chunk system's main-thread budget, and the tasks actually driving loads forward only 18%
The cost of each further step is concrete. Moving `ForgeCaps` onto a worker thread belongs to the same family as the data loss in issue #8: mods that attach a chunk capability lose data silently. Moving `ChunkDataEvent.Load` onto a worker thread calls every listener off the main thread; it throws nothing and simply rots over time. Moving POI / `SectionStorage` onto a worker thread corrupts villager AI data silently, because `SectionStorage` is not thread-safe. Taking over `DistanceManager` means a state machine that is not thread-safe, and a head-on conflict with C2ME. Requiring installation on both sides, or forcing everything async, means giving up single-side installation, opt-in and instant rollback
Performance has reached the limit compatibility allows, so this release changes direction: instead of chasing those last fractions of a percent, the server now says for itself where the stalls come from. The full reasoning is under "明确不做及其理由" in `docs/ROADMAP.md`
release
BetterAutoSave v0.20.0 (Forge 1.20.1)
0.20.0-forge21 авг. 2026 г.72Minecraft: 1.20.1forge
Что нового
## v0.20.0 — Making main-thread sync chunk loads and inter-tick pauses observable
0.20.0 adds two diagnostic features that observe and never intervene: detection of main-thread synchronous chunk loads, and monitoring of long pauses between ticks. Both are on by default, and both builds have them
### 1. Why these two stalls were invisible
A production server with 74 players online froze. In the spark profile, BetterAutoSave's own save path on the main thread (`NbtIo` writes) accounted for 0.03% — saving was not the cause. The two actual sources were:
- One main-thread synchronous chunk load, triggered by third-party code, blocking for 5.2 seconds
- Two pauses that happened between ticks, 17.1 seconds and 14.8 seconds
The second kind is the harder one. MSPT only measures time spent inside a tick; the wait between two ticks is not counted, so those 17 seconds are invisible on the TPS graph and on every ordinary monitoring dashboard — the dashboard looks healthy while players are timing out. Digging both out of spark's inclusive call tree took hours of manual work. 0.20.0 makes the server report them by itself
### 2. Main-thread synchronous chunk loads
When a chunk is not in memory and something on the main thread asks for it directly, the main thread waits in place until the disk read — and if needed, terrain generation — has finished. The whole server is frozen for that duration. Vanilla rarely takes this path during normal play; what usually triggers it is third-party logic fetching a chunk by coordinates inside an event callback, a scheduled task or a command
BAS times the "wait until the chunk is ready" call inside vanilla's `ServerChunkCache.getChunk`. A wait of at least `diagnostics.syncLoadThresholdMs` (default 50 ms, one game tick) is recorded with its duration, chunk coordinates, dimension, and the first non-vanilla class on the call stack:
```
[BetterAutoSave] main-thread sync chunk load: 5188ms at (120,-340) in minecraft:overworld, called from com.example.protection.RegionScanner
```
Each distinct source prints one line the first time it appears; after that it only accumulates into the table, so a player exploring new terrain cannot flood the log
The instrumented call sits on the branch taken after vanilla's four-slot chunk cache misses: when the requested chunk is already cached, execution never reaches it. On a miss it costs two extra `System.nanoTime()` calls (tens of nanoseconds), and a stack is captured only once the wait actually exceeds the threshold — during normal operation none is ever captured
### 3. Inter-tick pauses
Between the end of one tick and the start of the next, the server waits for the next tick while draining its task queue. That time is not part of MSPT. If something in the queue stalls — a task submitted by another mod, a chunk system callback, a command — MSPT can stay perfectly healthy while players have been frozen for ten seconds or more
The default mode takes one timestamp at the start and one at the end of each tick, and records a gap of at least `diagnostics.tickGapThresholdMs` (default 1000 ms):
```
[BetterAutoSave] inter-tick gap: 17100ms after tick 148213 (this time is not counted in MSPT)
```
The default mode reports how long the gap was and which tick it followed, not who caused it. To get down to individual tasks, turn on `diagnostics.tickGapDeepAttribution` (off by default): it times every task in the server task queue and files those taking more than a tenth of the threshold (100 ms by default) under their actual `Runnable` type. That mode adds two nanosecond reads per task and the server runs hundreds of tasks per tick, so turn it on only while narrowing down a gap that has already been reported, then turn it back off
### 4. On how attribution is worded
The detection reports which call chain the block happened on, not who has a bug. Fetching a chunk synchronously is a perfectly reasonable thing to write in many situations; its cost simply scales with server size, view distance and disk speed. Within a 5-second wait, the disk, terrain generation and other load on the same host can each be the dominant factor. Stalls reported by this feature usually reflect another mod's call pattern and are not by themselves evidence of a defect in that mod
Treat attribution as the starting point of an investigation, not its conclusion. The command output carries the same sentence every time:
```
note: stalls listed above are attributed to the call site, not to a defect in the owning mod.
```
### 5. Three ways to read the data
`/betterautosave diagnose [count]` (default 10, accepts 1-50) prints the running totals and the top N attributions; `/betterautosave diagnose reset` clears both tables and re-arms the once-per-source log de-duplication. A full sample of the output is in section 8 of `docs/CONFIGURATION.en.md`
Reset deliberately leaves the cumulative counters (`bas_sync_load_stalls_total` and friends) alone: Prometheus counters must stay monotonic, and zeroing them breaks `rate()`. The command reply says so
The periodic diagnostic summary (`diagnostics.diagnosticLogging`, on by default) gains two lines:
```
[BetterAutoSave] |- syncLoad: stalls=37 totalBlocked=48210ms tracked=2 top=examplemod=31400ms x12, com.example.map.RegionCache=14600ms x19
[BetterAutoSave] `- tickGap: exceeded=2 max=17100ms last=14800ms after tick 148213 deepTasks=0
```
The Prometheus exporter gains four metrics:
| Metric | Type | Meaning |
|---|---|---|
| `bas_sync_load_stalls_total` | counter | Main-thread synchronous chunk loads over the threshold |
| `bas_sync_load_stall_seconds_total` | counter | Cumulative seconds blocked in those loads |
| `bas_tick_gap_exceeded_total` | counter | Inter-tick gaps over the threshold |
| `bas_tick_gap_max_seconds` | gauge | Longest inter-tick gap since server start |
### 6. New settings
| Key | Default | Effect |
|---|---|---|
| `diagnostics.syncLoadDetection` | `true` | Master switch for main-thread sync chunk load detection |
| `diagnostics.syncLoadThresholdMs` | `50` | Minimum single-load block time to record, in milliseconds (1 - 60000) |
| `diagnostics.syncLoadTrackLimit` | `64` | Max tracked (attribution, stack) pairs, LRU beyond that. Takes effect at startup |
| `diagnostics.syncLoadStackDepth` | `24` | Non-vanilla stack frames retained per record (4 - 128) |
| `diagnostics.tickGapDetection` | `true` | Master switch for inter-tick gap detection |
| `diagnostics.tickGapThresholdMs` | `1000` | Minimum gap to record, in milliseconds (50 - 600000) |
| `diagnostics.tickGapDeepAttribution` | `false` | Deep mode: time individual tasks to attribute a gap |
| `diagnostics.tickGapDeepTrackLimit` | `64` | LRU limit for the deep-mode table; ignored while deep mode is off. Takes effect at startup |
Everything except the two `TrackLimit` keys is hot-reloadable and takes effect immediately
### 7. Known limits
- If a mod that rewrites the chunk-fetch path (something in the C2ME family) is installed alongside, the instrumented call may no longer exist and the probe cannot attach. Sync load detection then silently does nothing instead of failing the server's startup — a purely observational feature is not worth a boot crash. Whether the injection point still exists is guarded by build-time tests rather than a runtime hard check
- Attribution uses the first non-vanilla class on the stack, replaced with a mod id when it can be matched against the loader's mod file scan data and left as the class name when it cannot. When the call chain crosses an event bus, that class may be the subscriber rather than the original caller; the deeper frames are printed under each row in the command output
- The deep-mode "one tenth of the threshold" factor has no measured basis. It is an engineering estimate that a single task contributing to a one-second gap is usually in the 100 ms range. If real servers show too many mid-sized tasks slipping through, it will be promoted to its own setting in a later version
- Both `TrackLimit` values are frozen when the server starts; changing them requires a restart
### 8. Behavior change
The periodic diagnostic summary now keeps printing during degraded pipeline sessions, where it used to stop along with the pipeline. Sync loads and inter-tick gaps come from external call patterns and have nothing to do with whether BAS itself is degraded — and a degraded session is often exactly when the data is needed. The side effect is that the save-metrics summary also keeps printing in that state
### 9. Upgrading
- Drop in the new jar; the on-disk format and every existing setting are unchanged
- Order matters: replace the jar and restart first so the new build writes the 8 new keys into `common.toml`, then edit them. Editing the config before swapping the jar lets the running old build delete keys it does not recognize
- To opt out, set `diagnostics.syncLoadDetection` and `diagnostics.tickGapDetection` to `false`; both take effect immediately
### 10. Verification
- The sliding window, p99, LRU eviction and concurrent writes of the tables, the stack filter rules, and the accumulate / keep-maximum semantics of the four new metrics are covered by unit tests, each checked by removing the logic under test and confirming the test fails
- Each build carries a build-time ASM gate asserting that the probe really wraps that wait call and that both the timing and the stack capture happen after it returns — so a later change cannot quietly undo the "no stack captured during normal operation" property
- One honest gap in coverage: the table that turns a class name into a mod id depends on the loader's runtime mod file scan and has no unit test. It falls back to the fully qualified class name when no match is found
### 11. Why this release adds diagnostics instead of more performance work
BAS has been evaluated to the end of what it can do here: under the extreme compatibility constraints it holds itself to, there is no meaningful async chunk optimization left to take. Where room does remain, taking it would break that compatibility — producing data-safety problems and conflicts between mods
The same 74-player stress test, sampled over 550 seconds: `NbtIo` writes took 0.03% of main-thread time, `ChunkSerializer` serialization 0.67%, and everything BAS itself does 1.42% in total. Some room genuinely remains — `copySections` unconditionally makes two `PalettedContainer.copy` calls even for empty sections, about 0.1 pp, and batching the POI replay, about 0.1 to 0.2 pp — but all of it is down in the noise, and what can be taken without changing the compatibility premise adds up to less than half a percentage point
Nor can it be said that chunk loading has stopped being the bottleneck. The opposite is true: the bottleneck sits in the half of the chunk system BAS cannot reach. In that same sample, `DistanceManager` distance-field propagation consumed 82% of the chunk system's main-thread budget, and the tasks actually driving loads forward only 18%
The cost of each further step is concrete. Moving `ForgeCaps` onto a worker thread belongs to the same family as the data loss in issue #8: mods that attach a chunk capability lose data silently. Moving `ChunkDataEvent.Load` onto a worker thread calls every listener off the main thread; it throws nothing and simply rots over time. Moving POI / `SectionStorage` onto a worker thread corrupts villager AI data silently, because `SectionStorage` is not thread-safe. Taking over `DistanceManager` means a state machine that is not thread-safe, and a head-on conflict with C2ME. Requiring installation on both sides, or forcing everything async, means giving up single-side installation, opt-in and instant rollback
Performance has reached the limit compatibility allows, so this release changes direction: instead of chasing those last fractions of a percent, the server now says for itself where the stalls come from. The full reasoning is under "明确不做及其理由" in `docs/ROADMAP.md`
release
BetterAutoSave v0.19.0 (NeoForge 1.21.1)
0.19.0-neoforge3 авг. 2026 г.774Minecraft: 1.21.1neoforge
Что нового
## v0.19.0 — Hardening player data and level.dat, plus the autosave main-thread spike
0.19.0 closes three vanilla paths that lose data silently, builds a full read/write verification loop around `level.dat`, and adds two optional performance optimizations. The data-safety fixes are on by default; the performance optimizations are off by default
(0.18.0 was never published; its content is folded into this release)
### 1. Three paths that lose data silently
All three are vanilla behavior. None of them raises an error, interrupts anything, or is necessarily noticed by the player right away
**A failed player-data read is treated as a brand-new player.** When reading `playerdata/<uuid>.dat` throws, vanilla logs one line and proceeds as if the player had never joined — inventory, position and experience all reset — while `<uuid>.dat_old` sits untouched in the same directory. Power loss mid-write, a bad sector, or an external tool truncating the file all land here
`playerData.loadFallback` (on by default) changes this to: quarantine the damaged primary as `<uuid>_corrupted_<timestamp>.dat` to preserve the evidence, then try `.dat_old`, and on a successful read run it through the data fixer and return it. Only when neither file is readable does it fall back to vanilla behavior. A missing primary file — an actual new player — behaves exactly as before
**Advancement and stats files are truncating writes.** Vanilla writes `advancements/<uuid>.json` and `stats/<uuid>.json` straight over the live file, with no temp file and no backup. Lose power halfway through and what remains is a length-truncated JSON — the next read fails to parse, and that player's advancements and statistics are gone
`playerData.atomicSidecarWrite` (on by default) switches to temp file + atomic rename, keeping the previous copy as `.bak` immediately before the rename. It falls back automatically on filesystems that do not support atomic rename
Closing the truncation window is the job of the temp file and the atomic replace themselves. The additional `fsync` sits behind its own switch, `playerData.sidecarFsync`, which is **off by default**: `PlayerList.save` runs on the server thread and writes both files for every online player on every autosave, so turning it on pins two synchronous device flushes per player onto a single tick — 120 of them at 60 players. Vanilla performs no `fsync` anywhere on this path, not even for `playerdata/<uuid>.dat`, and ext4 in its default `data=ordered` mode already flushes the new data before committing a rename over an existing file. Turn it on only if the host has no battery-backed write cache and unclean power loss is a real concern, and pair it with the stagger setting below
**A crash shutdown skips the save teardown.** BetterAutoSave's four shutdown guards were all hung off `ServerStoppingEvent`. That event is never fired when the server exits abnormally, and there is a second case where another mod throws inside it and breaks the whole event chain — in both cases every guard is bypassed and in-flight save tasks are discarded
This release moves the shutdown flag up to the entry of `stopServer`, bypassing the event mechanism entirely. It also lifts the degraded-teardown contract onto the `SaveTask` interface: the previous type-dispatch code silently dropped any task type it did not have a branch for, while the caller still counted it as "N tasks handled"
### 2. A verification loop around level.dat
`level.dat` holds the world seed, spawn point, game rules, dimension configuration and Forge's registry ID table. Corrupting it does not cost you a patch of terrain — it costs you the world, or worse: the server starts from blank metadata that passed as "readable" and the world seed becomes 0
Vanilla has exactly one layer of protection: rotating the previous copy to `level.dat_old` on write. But the read side only consults it when the file **exists and cannot be read**, and its test is simply whether the bytes parse as NBT
This release adds three more layers, all on by default:
- `levelData.verifyOnStartup` — a four-level check at startup (missing / undecompressable or unparseable / structurally incomplete / OK), with the first three repaired from `level.dat_old`. "Structurally incomplete" means it parses as NBT but is missing `Data`, `DataVersion` or `LevelName` — precisely the class vanilla's test lets through, and the one with the worst consequences
- `levelData.startupBackup` — right after verification passes, a raw byte copy is kept under `<world>/betterautosave/leveldat/`, three generations deep. Vanilla will never read that directory, so it takes no part in automatic repair; when both the primary and `level.dat_old` are found damaged, the log prints copy-pasteable restore commands and leaves the decision to the operator
- `levelData.postWriteVerify` — after a write, the file is read back and checked on a worker thread (default `CHECKSUM`, which streams the full decompression to trigger gzip's CRC and length checks; `FULL` also applies the structural test; `OFF` disables it). This layer is read-only and never repairs. Its job is to surface a bad write before the next autosave rotates the good copy away
### 3. The autosave main-thread spike (issue #25)
On a server with a lot of mods, a spark MSPT graph usually shows an evenly spaced spike every 5 minutes, even with nobody online. It does not come from chunk saving — it comes from vanilla unconditionally rewriting `level.dat` on every autosave
One classification detail is worth correcting up front: the file involved is `level.dat` in the world root (world metadata), not the `*.dat` files under `world/data/` (SavedData). BetterAutoSave's existing async saving only covered the latter; the `level.dat` path had never been in scope
Once a lot of mods are installed, almost all of `level.dat` is Forge's registry ID table. Measured on a real production server (Forge 1.20.1, 137 mods):
| Item | Measured |
|---|---|
| `level.dat`, uncompressed | 1,234,370 bytes |
| of which the registry ID table `fml/Registries` | 1,215,091 bytes (98.44%, 17 registries / 26,648 ids) |
| world data `/Data` | 12,018 bytes (0.97%) |
| main-thread cost of rebuilding that table | roughly 25ms per autosave |
Diffing two `level.dat` files written 5 minutes apart, byte by byte after decompression, shows 5 differing bytes out of 1,234,370 — all of them inside `/Data`. The 1,222,341-byte registry block is identical. In other words, the thing being recomputed for 25ms every 5 minutes comes out exactly the same as last time
Adds `levelData.cacheRegistrySnapshot`. When enabled, the table is cached and reused on subsequent saves. Three independent layers invalidate the cache, any one of which forces a rebuild:
- Forge's `IdMappingEvent`, covering all three official ID-change paths
- A fingerprint of every persisted registry taken before each write (entry count and frozen state), covering `ForgeRegistry.unfreeze()` — a public entry point that emits no event
- `levelData.registryCacheRevalidateCycles`, which periodically forces a full rebuild and compares it against the cache tag by tag; a mismatch logs an ERROR and the freshly computed value is used
This optimization only removes main-thread rebuild work. It does not change when writes happen, does not introduce background threads, and does not touch the on-disk protocol for `level.dat`
Note that on a cache hit the whole of `ForgeHooks.writeAdditionalLevelSaveData` is skipped, so any other mod injecting into that method is skipped as well. No such mod is known (the method is marked internal API and has a single caller), and the cached content is itself taken from a pass on which all such injections did run. If a mod writes time-varying data there, the periodic comparison reports it as a `MISMATCH`
Measured in production: 15 hours 18 minutes of continuous uptime, 93 periodic forced rebuilds all matching with zero `MISMATCH`, the registry section byte-identical across four `save-all` runs, and the main-thread cost of `writeAdditionalLevelSaveData` down from 76ms to 16ms
### 4. Main-thread cost of saving players
Sampled on a production server with 4 players online, `PlayerList.saveAll` accounts for roughly 80ms of each autosave — about 6.7ms per player, of which advancements are 55%, player data 30% and statistics 15%. This term grows linearly with player count, extrapolating to roughly 400ms per autosave at 60 players
This release provides two switches, both off by default:
`playerData.advancementsSkipMode` — vanilla rewrites every online player's advancement file on every autosave, whether or not the progress changed. Enabling this skips the write based on a dirty flag. Three settings: `OFF` (vanilla behavior), `AUDIT` (still writes, but compares against a digest of the last write and logs only when the decision would have been wrong — used to confirm the dirty flag misses nothing with your particular mod set), and `ON` (actually skips)
One implementation choice is deliberate: vanilla's `progressChanged` set is not reused. It is cleared by `flushDirty` every tick and is almost always empty at autosave time, so using it as the write-side dirty flag would skip saves that genuinely did change. This release uses an independent flag, set only when granting or revoking progress actually succeeds
The one thing genuinely lost by skipping is the self-healing property that came for free with vanilla's unconditional rewrite — recovery from external modification (a restored backup, an operator editing the file by hand). `playerData.advancementsForceFullWriteCycles` (default 12) brings it back with a periodic forced full write, which also covers third-party mods that alter progress without going through the standard interface
`playerData.staggerMaxPerTick` — spreads an autosave's player writes across the following ticks. The default `0` is vanilla behavior (everyone written in the same tick). It applies only inside the autosave window; `/save-all`, shutdown and player disconnect still write immediately
The recommended rollout is the same as for the registry cache: run `AUDIT` for a few days, confirm no mismatch appears in the log, then switch to `ON`
### 5. New settings
| Setting | Default | Purpose |
|---|---|---|
| `playerData.loadFallback` | `true` | Quarantine and fall back to `.dat_old` on a failed player-data read |
| `playerData.atomicSidecarWrite` | `true` | Atomic writes plus one backup for advancement and stats files |
| `playerData.sidecarFsync` | `false` | Also fsync those writes (main-thread cost, scales with player count) |
| `levelData.verifyOnStartup` | `true` | Verify `level.dat` at startup and repair from `level.dat_old` |
| `levelData.startupBackup` | `true` | Keep three generations of `level.dat` copies |
| `levelData.postWriteVerify` | `CHECKSUM` | Read `level.dat` back on a worker thread after writing |
| `levelData.cacheRegistrySnapshot` | `false` | Cache the registry ID table, removing the autosave spike |
| `levelData.registryCacheRevalidateCycles` | `12` | Periodically force a rebuild and compare against the cache |
| `playerData.advancementsSkipMode` | `OFF` | Skip advancement writes based on a dirty flag |
| `playerData.advancementsForceFullWriteCycles` | `12` | Force one full write after this many consecutive skips |
| `playerData.staggerMaxPerTick` | `0` | Spread player writes across ticks |
### 6. Build differences
Of what this release adds, the `levelData` and `playerData` groups are currently available on the Forge build only
The registry cache has no counterpart problem on NeoForge: upstream removed the registry ID table from `level.dat` entirely, so there is nothing to cache and no such spike. The remaining items ship on Forge first, with the symmetric NeoForge port to follow in a later release. The dual-build feature matrix in the README has been filled in and marks the current coverage of each item
### 7. Verification
- All 436 unit tests pass (shared module 69 + Forge 232 + NeoForge 135), 46 of them new in this release
- Every new piece of logic was mutation-checked: removing the backup-restore logic, the backup rotation, the dirty-flag test, the `DataVersion` criterion, the read-back retry, the shutdown-path window reset, the master-switch check, or the stats write-failure fallback each makes the corresponding cases fail as expected
- A dedicated adversarial code review was run before release; it found and closed 6 issues, 2 of which would have caused a main-thread regression or rolled back player saves under real load. The `fsync` split, the forced autosave-window reset on the shutdown path, the retry on read-back verification, and honoring the master switch in every new setting all came out of that pass
- The registry cache ran for 15 hours 18 minutes in production; see section 3 above
### 8. Upgrading
- Replace the jar; the save format is unchanged
- Data-safety fixes are on by default, performance optimizations are off; new keys are filled in with their defaults on first start and existing settings are left alone
- The on-disk formats for `level.dat` and player data are identical to vanilla, so you can roll back to an earlier version at any time
release
BetterAutoSave v0.19.0 (Forge 1.20.1)
0.19.0-forge3 авг. 2026 г.5,1 тыс.Minecraft: 1.20.1forge
Что нового
## v0.19.0 — Hardening player data and level.dat, plus the autosave main-thread spike
0.19.0 closes three vanilla paths that lose data silently, builds a full read/write verification loop around `level.dat`, and adds two optional performance optimizations. The data-safety fixes are on by default; the performance optimizations are off by default
(0.18.0 was never published; its content is folded into this release)
### 1. Three paths that lose data silently
All three are vanilla behavior. None of them raises an error, interrupts anything, or is necessarily noticed by the player right away
**A failed player-data read is treated as a brand-new player.** When reading `playerdata/<uuid>.dat` throws, vanilla logs one line and proceeds as if the player had never joined — inventory, position and experience all reset — while `<uuid>.dat_old` sits untouched in the same directory. Power loss mid-write, a bad sector, or an external tool truncating the file all land here
`playerData.loadFallback` (on by default) changes this to: quarantine the damaged primary as `<uuid>_corrupted_<timestamp>.dat` to preserve the evidence, then try `.dat_old`, and on a successful read run it through the data fixer and return it. Only when neither file is readable does it fall back to vanilla behavior. A missing primary file — an actual new player — behaves exactly as before
**Advancement and stats files are truncating writes.** Vanilla writes `advancements/<uuid>.json` and `stats/<uuid>.json` straight over the live file, with no temp file and no backup. Lose power halfway through and what remains is a length-truncated JSON — the next read fails to parse, and that player's advancements and statistics are gone
`playerData.atomicSidecarWrite` (on by default) switches to temp file + atomic rename, keeping the previous copy as `.bak` immediately before the rename. It falls back automatically on filesystems that do not support atomic rename
Closing the truncation window is the job of the temp file and the atomic replace themselves. The additional `fsync` sits behind its own switch, `playerData.sidecarFsync`, which is **off by default**: `PlayerList.save` runs on the server thread and writes both files for every online player on every autosave, so turning it on pins two synchronous device flushes per player onto a single tick — 120 of them at 60 players. Vanilla performs no `fsync` anywhere on this path, not even for `playerdata/<uuid>.dat`, and ext4 in its default `data=ordered` mode already flushes the new data before committing a rename over an existing file. Turn it on only if the host has no battery-backed write cache and unclean power loss is a real concern, and pair it with the stagger setting below
**A crash shutdown skips the save teardown.** BetterAutoSave's four shutdown guards were all hung off `ServerStoppingEvent`. That event is never fired when the server exits abnormally, and there is a second case where another mod throws inside it and breaks the whole event chain — in both cases every guard is bypassed and in-flight save tasks are discarded
This release moves the shutdown flag up to the entry of `stopServer`, bypassing the event mechanism entirely. It also lifts the degraded-teardown contract onto the `SaveTask` interface: the previous type-dispatch code silently dropped any task type it did not have a branch for, while the caller still counted it as "N tasks handled"
### 2. A verification loop around level.dat
`level.dat` holds the world seed, spawn point, game rules, dimension configuration and Forge's registry ID table. Corrupting it does not cost you a patch of terrain — it costs you the world, or worse: the server starts from blank metadata that passed as "readable" and the world seed becomes 0
Vanilla has exactly one layer of protection: rotating the previous copy to `level.dat_old` on write. But the read side only consults it when the file **exists and cannot be read**, and its test is simply whether the bytes parse as NBT
This release adds three more layers, all on by default:
- `levelData.verifyOnStartup` — a four-level check at startup (missing / undecompressable or unparseable / structurally incomplete / OK), with the first three repaired from `level.dat_old`. "Structurally incomplete" means it parses as NBT but is missing `Data`, `DataVersion` or `LevelName` — precisely the class vanilla's test lets through, and the one with the worst consequences
- `levelData.startupBackup` — right after verification passes, a raw byte copy is kept under `<world>/betterautosave/leveldat/`, three generations deep. Vanilla will never read that directory, so it takes no part in automatic repair; when both the primary and `level.dat_old` are found damaged, the log prints copy-pasteable restore commands and leaves the decision to the operator
- `levelData.postWriteVerify` — after a write, the file is read back and checked on a worker thread (default `CHECKSUM`, which streams the full decompression to trigger gzip's CRC and length checks; `FULL` also applies the structural test; `OFF` disables it). This layer is read-only and never repairs. Its job is to surface a bad write before the next autosave rotates the good copy away
### 3. The autosave main-thread spike (issue #25)
On a server with a lot of mods, a spark MSPT graph usually shows an evenly spaced spike every 5 minutes, even with nobody online. It does not come from chunk saving — it comes from vanilla unconditionally rewriting `level.dat` on every autosave
One classification detail is worth correcting up front: the file involved is `level.dat` in the world root (world metadata), not the `*.dat` files under `world/data/` (SavedData). BetterAutoSave's existing async saving only covered the latter; the `level.dat` path had never been in scope
Once a lot of mods are installed, almost all of `level.dat` is Forge's registry ID table. Measured on a real production server (Forge 1.20.1, 137 mods):
| Item | Measured |
|---|---|
| `level.dat`, uncompressed | 1,234,370 bytes |
| of which the registry ID table `fml/Registries` | 1,215,091 bytes (98.44%, 17 registries / 26,648 ids) |
| world data `/Data` | 12,018 bytes (0.97%) |
| main-thread cost of rebuilding that table | roughly 25ms per autosave |
Diffing two `level.dat` files written 5 minutes apart, byte by byte after decompression, shows 5 differing bytes out of 1,234,370 — all of them inside `/Data`. The 1,222,341-byte registry block is identical. In other words, the thing being recomputed for 25ms every 5 minutes comes out exactly the same as last time
Adds `levelData.cacheRegistrySnapshot`. When enabled, the table is cached and reused on subsequent saves. Three independent layers invalidate the cache, any one of which forces a rebuild:
- Forge's `IdMappingEvent`, covering all three official ID-change paths
- A fingerprint of every persisted registry taken before each write (entry count and frozen state), covering `ForgeRegistry.unfreeze()` — a public entry point that emits no event
- `levelData.registryCacheRevalidateCycles`, which periodically forces a full rebuild and compares it against the cache tag by tag; a mismatch logs an ERROR and the freshly computed value is used
This optimization only removes main-thread rebuild work. It does not change when writes happen, does not introduce background threads, and does not touch the on-disk protocol for `level.dat`
Note that on a cache hit the whole of `ForgeHooks.writeAdditionalLevelSaveData` is skipped, so any other mod injecting into that method is skipped as well. No such mod is known (the method is marked internal API and has a single caller), and the cached content is itself taken from a pass on which all such injections did run. If a mod writes time-varying data there, the periodic comparison reports it as a `MISMATCH`
Measured in production: 15 hours 18 minutes of continuous uptime, 93 periodic forced rebuilds all matching with zero `MISMATCH`, the registry section byte-identical across four `save-all` runs, and the main-thread cost of `writeAdditionalLevelSaveData` down from 76ms to 16ms
### 4. Main-thread cost of saving players
Sampled on a production server with 4 players online, `PlayerList.saveAll` accounts for roughly 80ms of each autosave — about 6.7ms per player, of which advancements are 55%, player data 30% and statistics 15%. This term grows linearly with player count, extrapolating to roughly 400ms per autosave at 60 players
This release provides two switches, both off by default:
`playerData.advancementsSkipMode` — vanilla rewrites every online player's advancement file on every autosave, whether or not the progress changed. Enabling this skips the write based on a dirty flag. Three settings: `OFF` (vanilla behavior), `AUDIT` (still writes, but compares against a digest of the last write and logs only when the decision would have been wrong — used to confirm the dirty flag misses nothing with your particular mod set), and `ON` (actually skips)
One implementation choice is deliberate: vanilla's `progressChanged` set is not reused. It is cleared by `flushDirty` every tick and is almost always empty at autosave time, so using it as the write-side dirty flag would skip saves that genuinely did change. This release uses an independent flag, set only when granting or revoking progress actually succeeds
The one thing genuinely lost by skipping is the self-healing property that came for free with vanilla's unconditional rewrite — recovery from external modification (a restored backup, an operator editing the file by hand). `playerData.advancementsForceFullWriteCycles` (default 12) brings it back with a periodic forced full write, which also covers third-party mods that alter progress without going through the standard interface
`playerData.staggerMaxPerTick` — spreads an autosave's player writes across the following ticks. The default `0` is vanilla behavior (everyone written in the same tick). It applies only inside the autosave window; `/save-all`, shutdown and player disconnect still write immediately
The recommended rollout is the same as for the registry cache: run `AUDIT` for a few days, confirm no mismatch appears in the log, then switch to `ON`
### 5. New settings
| Setting | Default | Purpose |
|---|---|---|
| `playerData.loadFallback` | `true` | Quarantine and fall back to `.dat_old` on a failed player-data read |
| `playerData.atomicSidecarWrite` | `true` | Atomic writes plus one backup for advancement and stats files |
| `playerData.sidecarFsync` | `false` | Also fsync those writes (main-thread cost, scales with player count) |
| `levelData.verifyOnStartup` | `true` | Verify `level.dat` at startup and repair from `level.dat_old` |
| `levelData.startupBackup` | `true` | Keep three generations of `level.dat` copies |
| `levelData.postWriteVerify` | `CHECKSUM` | Read `level.dat` back on a worker thread after writing |
| `levelData.cacheRegistrySnapshot` | `false` | Cache the registry ID table, removing the autosave spike |
| `levelData.registryCacheRevalidateCycles` | `12` | Periodically force a rebuild and compare against the cache |
| `playerData.advancementsSkipMode` | `OFF` | Skip advancement writes based on a dirty flag |
| `playerData.advancementsForceFullWriteCycles` | `12` | Force one full write after this many consecutive skips |
| `playerData.staggerMaxPerTick` | `0` | Spread player writes across ticks |
### 6. Build differences
Of what this release adds, the `levelData` and `playerData` groups are currently available on the Forge build only
The registry cache has no counterpart problem on NeoForge: upstream removed the registry ID table from `level.dat` entirely, so there is nothing to cache and no such spike. The remaining items ship on Forge first, with the symmetric NeoForge port to follow in a later release. The dual-build feature matrix in the README has been filled in and marks the current coverage of each item
### 7. Verification
- All 436 unit tests pass (shared module 69 + Forge 232 + NeoForge 135), 46 of them new in this release
- Every new piece of logic was mutation-checked: removing the backup-restore logic, the backup rotation, the dirty-flag test, the `DataVersion` criterion, the read-back retry, the shutdown-path window reset, the master-switch check, or the stats write-failure fallback each makes the corresponding cases fail as expected
- A dedicated adversarial code review was run before release; it found and closed 6 issues, 2 of which would have caused a main-thread regression or rolled back player saves under real load. The `fsync` split, the forced autosave-window reset on the shutdown path, the retry on read-back verification, and honoring the master switch in every new setting all came out of that pass
- The registry cache ran for 15 hours 18 minutes in production; see section 3 above
### 8. Upgrading
- Replace the jar; the save format is unchanged
- Data-safety fixes are on by default, performance optimizations are off; new keys are filled in with their defaults on first start and existing settings are left alone
- The on-disk formats for `level.dat` and player data are identical to vanilla, so you can roll back to an earlier version at any time
release
BetterAutoSave v0.17.0 (NeoForge 1.21.1)
0.17.0-neoforge31 июл. 2026 г.307Minecraft: 1.21.1neoforge
Что нового
# v0.17.0 — Main-thread save capture cost roughly halved
0.17.0 is a performance release. The main-thread cost of capturing a chunk for saving drops by roughly half, by removing a full-chunk scan that ran on every snapshot but whose result was never used. This build also closes a data-safety gap where a third-party biome container could be referenced directly into an async snapshot
## Removing the dead block recount during capture (issue #24)
Main-thread capture previously wrapped each section's two paletted containers in a `LevelChunkSection`. That constructor unconditionally calls `recalcBlockCounts()`, which walks all 4096 cells of every section whose palette holds more than one entry, feeding each into a hash map — about 98,000 hash writes for a 24-section chunk
The three counts it produces (non-empty blocks, randomly ticking blocks, randomly ticking fluids) only serve live-chunk tick scheduling and network sync. Vanilla reads none of them when serializing a section — a copy with completely wrong counts serializes byte-for-byte identically to a correct one. In other words, the result of that scan was never used anywhere on the save path
This build carries only the two containers the worker actually needs for encoding. The on-disk NBT is unchanged, and no mixin or vanilla behavior is involved
Before/after on a real production server (Forge 1.20.1, 137 mods, AMD Ryzen 9 9950X3D2, ZGC, 60-second sample, `eventCompatMode = PARTIAL`):
| Main-thread frame | Before | After |
|---|---|---|
| Capture entry `captureWithGeneration` | 6692ms (19.1% of busy time) | 2948ms (8.2%) |
| Section capture `copySections` within it | 4096ms | 1024ms |
| Block recount `recalcBlockCounts` | 3152ms | 0 (call no longer exists) |
| BetterAutoSave total main-thread footprint | 25.8% of busy time | 15.2% |
The number of chunks saved in each sample was cross-checked against frames unaffected by this change; the "after" run actually processed about 15% more, so the reductions above are not inflated by a lighter workload
A side effect: with the per-section temporary hash map gone, ZGC relocation read barriers (`forwarding_find`) fell 32%. The paletted container deep copy itself became 20% faster as a result, even though that code was not touched
To be clear, this does not raise TPS on an already healthy server — both samples ran at TPS 20. What it buys is main-thread headroom: the same save throughput now occupies less main-thread time, making it less likely to crowd the tick budget under heavy load or with large worlds
## No more duplicate capture in FULL compatibility mode
Under `eventCompatMode = FULL`, snapshot capture (section copies, light layer clones, heightmap clones, block entity NBT, structure data copies) previously ran unconditionally before the mode branch — even though that mode actually uses the complete NBT produced by vanilla `ChunkSerializer.write`, which gathers the same data itself. That gather-then-discard work is now skipped
Users who switched to FULL to work around issue #8 no longer pay double the main-thread capture cost
## Data safety
If a live section's biome container is not vanilla's `PalettedContainer` implementation — which vanilla never produces, and only occurs when a third-party mod supplies a read-only container — it was previously referenced directly into the async snapshot. The read-only interface has no copy method, so it could not be detached, meaning the main thread could still mutate the same object while a worker encoded it. This build encodes it to NBT on the main thread instead, fully separating it from the live container
## Upgrading
- Drop-in jar replacement, no config changes, no save format changes
- The on-disk NBT is byte-for-byte identical to before, so rolling back to 0.16.3 is safe at any time
release
BetterAutoSave v0.17.0 (Forge 1.20.1)
0.17.0-forge31 июл. 2026 г.161Minecraft: 1.20.1forge
Что нового
# v0.17.0 — Main-thread save capture cost roughly halved
0.17.0 is a performance release. The main-thread cost of capturing a chunk for saving drops by roughly half, by removing a full-chunk scan that ran on every snapshot but whose result was never used. This build also closes a data-safety gap where a third-party biome container could be referenced directly into an async snapshot
## Removing the dead block recount during capture (issue #24)
Main-thread capture previously wrapped each section's two paletted containers in a `LevelChunkSection`. That constructor unconditionally calls `recalcBlockCounts()`, which walks all 4096 cells of every section whose palette holds more than one entry, feeding each into a hash map — about 98,000 hash writes for a 24-section chunk
The three counts it produces (non-empty blocks, randomly ticking blocks, randomly ticking fluids) only serve live-chunk tick scheduling and network sync. Vanilla reads none of them when serializing a section — a copy with completely wrong counts serializes byte-for-byte identically to a correct one. In other words, the result of that scan was never used anywhere on the save path
This build carries only the two containers the worker actually needs for encoding. The on-disk NBT is unchanged, and no mixin or vanilla behavior is involved
Before/after on a real production server (Forge 1.20.1, 137 mods, AMD Ryzen 9 9950X3D2, ZGC, 60-second sample, `eventCompatMode = PARTIAL`):
| Main-thread frame | Before | After |
|---|---|---|
| Capture entry `captureWithGeneration` | 6692ms (19.1% of busy time) | 2948ms (8.2%) |
| Section capture `copySections` within it | 4096ms | 1024ms |
| Block recount `recalcBlockCounts` | 3152ms | 0 (call no longer exists) |
| BetterAutoSave total main-thread footprint | 25.8% of busy time | 15.2% |
The number of chunks saved in each sample was cross-checked against frames unaffected by this change; the "after" run actually processed about 15% more, so the reductions above are not inflated by a lighter workload
A side effect: with the per-section temporary hash map gone, ZGC relocation read barriers (`forwarding_find`) fell 32%. The paletted container deep copy itself became 20% faster as a result, even though that code was not touched
To be clear, this does not raise TPS on an already healthy server — both samples ran at TPS 20. What it buys is main-thread headroom: the same save throughput now occupies less main-thread time, making it less likely to crowd the tick budget under heavy load or with large worlds
## No more duplicate capture in FULL compatibility mode
Under `eventCompatMode = FULL`, snapshot capture (section copies, light layer clones, heightmap clones, block entity NBT, structure data copies) previously ran unconditionally before the mode branch — even though that mode actually uses the complete NBT produced by vanilla `ChunkSerializer.write`, which gathers the same data itself. That gather-then-discard work is now skipped
Users who switched to FULL to work around issue #8 no longer pay double the main-thread capture cost
## Data safety
If a live section's biome container is not vanilla's `PalettedContainer` implementation — which vanilla never produces, and only occurs when a third-party mod supplies a read-only container — it was previously referenced directly into the async snapshot. The read-only interface has no copy method, so it could not be detached, meaning the main thread could still mutate the same object while a worker encoded it. This build encodes it to NBT on the main thread instead, fully separating it from the live container
## Upgrading
- Drop-in jar replacement, no config changes, no save format changes
- The on-disk NBT is byte-for-byte identical to before, so rolling back to 0.16.3 is safe at any time
release
BetterAutoSave v0.16.3 (NeoForge 1.21.1)
0.16.3+neoforge8 июл. 2026 г.518Minecraft: 1.21.1neoforge
Что нового
# v0.16.3 — Fix NeoForge users getting the Forge jar on Modrinth + rollup of fixes since 0.16.2
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 jar
This 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 `exceptionallyAsync` so 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 `entityChunksPerTickBase` knob (it had no effect)
- Raised the `deadlineGuardSeconds` lower 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.copy` deep-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 set `deadlineGuardSeconds` below 5, it is corrected automatically on startup
release
BetterAutoSave v0.16.3 (Forge 1.20.1)
0.16.3+forge8 июл. 2026 г.3,9 тыс.Minecraft: 1.20.1forge
Что нового
# v0.16.3 — Fix NeoForge users getting the Forge jar on Modrinth + rollup of fixes since 0.16.2
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 jar
This 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 `exceptionallyAsync` so 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 `entityChunksPerTickBase` knob (it had no effect)
- Raised the `deadlineGuardSeconds` lower 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.copy` deep-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 set `deadlineGuardSeconds` below 5, it is corrected automatically on startup
release
BetterAutoSave v0.16.0
0.16.029 июн. 2026 г.23Minecraft: 1.20.1forge
Что нового
# v0.16.0 — Async chunk load (stable)
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 POI `getOrLoad` dropped from 15.0% to 0.1%, load-side main-thread cost down ~13.7%
- **Capability subsystem thread-safety symmetry fix**: during chunk load, `LevelChunk` construction dispatches the global `AttachCapabilitiesEvent` and `readCapsFromNBT` triggers third-party capability deserialization — both are the same kind of main-thread-assuming work as the already-deferred `ChunkDataEvent.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-in
- `load.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
beta
BetterAutoSave v0.16.0-beta.1
0.16.0-beta.127 июн. 2026 г.21Minecraft: 1.20.1forge
Что нового
# v0.16.0-beta.1 — Async POI prefetch (Tier A, load-side performance)
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 on `tryRead().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 `getOrLoad` dropped 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; set `asyncPoiPrefetch=false` to disable it
beta
BetterAutoSave v0.15.0-beta.1
0.15.0-beta.127 июн. 2026 г.18Minecraft: 1.20.1forge
Что нового
# v0.15.0-beta.1 — Async chunk load (working build, off by default)
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 threw `ClassNotFoundException` when 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.Load` replay
- 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` + `join` block-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; `maxInFlight` default 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 `.dat` saves 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-side `general.enabled`
- `load.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 reappears
- `load.loadMaxRetries` (default 1): worker-thread re-attempts after a parse throws, before falling back to a vanilla main-thread read
- `workers.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=FULL` or `load.enabled=false` returns 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.enabled` to true — and back up your world first
release
Shinoyuki-BetterAutoSave 0.12.1
0.12.125 июн. 2026 г.17Minecraft: 1.20.1forge
Что нового
## NeoForge 1.21.1 build — same source, two loaders
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-all` saves 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 build
beta
Shinoyuki-BetterAutoSave 0.13.0-beta.1
0.13.0-beta.123 июн. 2026 г.29Minecraft: 1.20.1forge
Что нового
# v0.13.0-beta.1 — Async chunk load (experimental, off by default)
This 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.Load` replay
- 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-side `general.enabled`
- `load.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 reappears
- `load.loadMaxRetries` (default 1): worker-thread re-attempts after a parse throws, before falling back to a vanilla main-thread read
- `workers.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=FULL` or `load.enabled=false` returns 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 first
release
Shinoyuki-BetterAutoSave 0.12.0
0.12.022 июн. 2026 г.17Minecraft: 1.20.1forge
Что нового
# v0.12.0 — NeoForge 1.21.1 support + data-integrity hardening
BetterAutoSave 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-all` run 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 `ForgeCaps` block Forge injects at the end of `ChunkSerializer.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 throw `ConcurrentModificationException` or write a torn NBT. The outer tag is now deep-copied before hand-off, matching NeoForge vanilla `SavedData.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
release
Shinoyuki-BetterAutoSave 0.11.1
0.11.121 июн. 2026 г.16Minecraft: 1.20.1forge
Что нового
# v0.11.1 — Fix `/save-all flush` hang during operation
Critical 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
release
Shinoyuki-BetterAutoSave 0.11.0
0.11.012 июн. 2026 г.46Minecraft: 1.20.1forge
Что нового
## v0.11.0 — Relay Protocol Redesign
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
alpha
Shinoyuki-BetterAutoSave 0.10.1
0.10.111 июн. 2026 г.17Minecraft: 1.20.1forge
Что нового
## v0.10.1 — 存档安全补丁
这是一个纯修复版本,补上了三个边界问题:两个低概率但一旦触发就会丢存档的情况,以及一个运营中执行 `/save-all flush` 会卡服的毛病。另外修了几处文档里写过时的版本号。功能没有任何变化
### 修了两个会丢存档的边界
这两个问题都不常见——要在后台保存恰好出错的那一刻撞上才会发生,平时跑图基本碰不到。但一旦撞上,对应的区块这一轮的改动就再也存不下来了,连关服时的兜底保存也救不回,所以必须堵死。
- **后台派发出错后区块卡死**:BAS 把区块交给后台保存时,如果那一步抛了异常,区块会停在一个"既不再尝试后台保存、也不交还给原版同步保存"的死状态,玩家不再编辑它就永久丢失。现在出错后会把区块状态复位,下一轮自动重新保存,原版兜底也能接手
- **写盘失败后区块被静默丢弃**:区块数据写硬盘失败时,BAS 原本会安排重试,但漏了一步——没把区块重新标记成"待保存",导致重试和关服兜底都看不到它,于是被悄悄丢掉。现在写盘失败的区块会在主线程被重新标记,下一轮重试或交还原版同步保存,不再丢
### `/save-all flush` 不再卡服
以前在服务器运行中手动执行 `/save-all flush`,主线程会停下来死等所有后台保存任务写完,最长能卡几十秒,期间全服掉 TPS、玩家卡顿。现在区分了两种情况:关服时仍然老老实实等写完(必须的,不能丢数据);运行中执行则不再阻塞,后台保存继续在背景跑,命令立即返回,想看进度用 `/betterautosave status`
### 顺手加固了 .dat 文件写入
存档里的 `.dat` 文件(袭击数据、强制加载区块、部分 mod 的存档等)现在改成原子写入——先写临时文件、刷盘、再整体替换。这样即使写到一半掉电或进程被杀,也不会留下一个损坏的半截文件导致下次读不出来。多线程写同名文件的并发也做了去重,不会再交错写坏
### 文档勘误
README 安装说明里的 jar 文件名、路线图里的当前版本标注,之前还写着 0.9,更新成了 0.10
### 升级
替换 jar 即可,配置文件不变,无需任何调整
alpha
Shinoyuki-BetterAutoSave 0.10.0
0.10.011 июн. 2026 г.33Minecraft: 1.20.1forge
Что нового
## v0.10.0 — BetterBackup 配套接口
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 接口,比看日志舒服得多。