Перейти к содержимому
Mineforgian

Shinoyuki-BetterAutoSave

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

Загрузки
9K
Подписчики
3
Обновлён
21 августа 2026 г.
Лицензия
AGPL-3.0-or-later

Опубликован 11 июня 2026 г.

BetterAutoSave

简体中文 | English

BetterAutoSave

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

Download: Modrinth · GitHub Releases

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

What problem does this mod solve

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

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

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

Requirements and installation

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

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

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

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

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

Will it lose world data?

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

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

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

Common configuration

Key Default Description
general.enabled true Master switch; off means vanilla behavior, as if not installed
throttle.chunksPerTickBase 4 Max chunks snapshotted by the main thread per game tick
throttle.adaptiveEnabled true Slow down automatically when the server struggles; keep it on
workers.chunkWorkerThreads 2 Background threads for chunks
workers.entityWorkerThreads 2 Background threads for entities
workers.savedDataWorkerThreads 1 Background threads for saved data; raise to 2 with mods that write a lot of vanilla SavedData
compat.eventCompatMode PARTIAL Event compatibility level; leave it alone unless you know you need it

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

Feature matrix across the two builds

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

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

In-game commands

Requires OP (permission level 2).

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

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

The stalls ordinary monitoring cannot see

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

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

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

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

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

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

Design boundary

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

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

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

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

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

Mod conflicts

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

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

Quick recovery if something goes wrong

All three options keep world data intact:

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

Building / development

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

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

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

License

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

Ченджлог

0.20.0-neoforgeРелиз1.21.1 · 21 августа 2026 г.

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

0.20.0-forgeРелиз1.20.1 · 21 августа 2026 г.

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

0.19.0-neoforgeРелиз1.21.1 · 3 августа 2026 г.

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
0.19.0-forgeРелиз1.20.1 · 3 августа 2026 г.

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
0.17.0-neoforgeРелиз1.21.1 · 31 июля 2026 г.

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
0.17.0-forgeРелиз1.20.1 · 31 июля 2026 г.

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
0.16.3+neoforgeРелиз1.21.1 · 8 июля 2026 г.

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
0.16.3+forgeРелиз1.20.1 · 8 июля 2026 г.

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

Комментарии

Загружаем…