Links
Tags
Creators
Details
0.19.0-neoforge
Compatibility
Changes
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 fromlevel.dat_old. "Structurally incomplete" means it parses as NBT but is missingData,DataVersionorLevelName— precisely the class vanilla's test lets through, and the one with the worst consequenceslevelData.startupBackup— right after verification passes, a raw byte copy is kept under<world>/betterautosave/leveldat/, three generations deep. Vanilla will never read that directory, so it takes no part in automatic repair; when both the primary andlevel.dat_oldare found damaged, the log prints copy-pasteable restore commands and leaves the decision to the operatorlevelData.postWriteVerify— after a write, the file is read back and checked on a worker thread (defaultCHECKSUM, which streams the full decompression to trigger gzip's CRC and length checks;FULLalso applies the structural test;OFFdisables it). This layer is read-only and never repairs. Its job is to surface a bad write before the next autosave rotates the good copy away
3. The autosave main-thread spike (issue #25)
On a server with a lot of mods, a spark MSPT graph usually shows an evenly spaced spike every 5 minutes, even with nobody online. It does not come from chunk saving — it comes from vanilla unconditionally rewriting level.dat on every 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
DataVersioncriterion, the read-back retry, the shutdown-path window reset, the master-switch check, or the stats write-failure fallback each makes the corresponding cases fail as expected - A dedicated adversarial code review was run before release; it found and closed 6 issues, 2 of which would have caused a main-thread regression or rolled back player saves under real load. The
fsyncsplit, the forced autosave-window reset on the shutdown path, the retry on read-back verification, and honoring the master switch in every new setting all came out of that pass - The registry cache ran for 15 hours 18 minutes in production; see section 3 above
8. Upgrading
- Replace the jar; the save format is unchanged
- Data-safety fixes are on by default, performance optimizations are off; new keys are filled in with their defaults on first start and existing settings are left alone
- The on-disk formats for
level.datand player data are identical to vanilla, so you can roll back to an earlier version at any time
Projects on Modrinth are automatically available through a Maven repository for use with JVM build tools such as Gradle. To learn more about the Modrinth Maven API, click here.
Note: When available, you should use the creator's maven repo instead as it will have transitive dependency information that the Modrinth Maven API does not. You may also end up with duplicate dependencies if you use a mix of Modrinth and non-Modrinth Maven repositories for your dependencies, because the group identifier will be different when served through the Modrinth Maven API.
Maven coordinates:
Version ID:
build.gradle:
repositories {
exclusiveContent {
forRepository {
maven {
name = "Modrinth"
url = "https://api.modrinth.com/maven"
}
}
// forRepositories(fg.repository) // Uncomment when using ForgeGradle
filter {
includeGroup "maven.modrinth"
}
}
}
// Standard Gradle dependency
dependencies {
implementation "maven.modrinth:WAXR3mM5:S8d9hMxH"
}
// Legacy Loom dependency
dependencies {
modImplementation "maven.modrinth:WAXR3mM5:S8d9hMxH"
}

