Accessories & CosmeticArmor duplication fix:
- snapshotAccessories() and snapshotCosmeticArmor() returned null when
slots were empty, causing writeModSnapshot to SKIP the write. The DB
kept stale data from when slots had items, restoring them on next join.
- Now return "{}" (like snapshotCuriosData already does), so empty state
is properly written to DB. On restore, apply*FromData clears slots
when it sees "{}" (length <= 2).
Guard remaining online=0 writes:
- deadPlayerWhileLogging and syncNotCompletedPlayer logout paths now
use AND last_server=? to prevent setting online=0 for a player that
already moved to another server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CRITICAL FIX - Stale server overwrite prevention:
- writeSnapshotToDB now guards ALL writes with AND last_server=? so a
crashing/slow server cannot overwrite fresher data saved by another server
- Logout and shutdown saves atomically set online=0 in the SAME UPDATE as
the data write (no more gap between data write and flag set)
- ModCompatSync.writeModSnapshot guarded variant uses subquery on last_server
CRITICAL FIX - Poll loop actually waits now:
- onPlayerLoggedInKickCheck no longer sets last_server (only online=1)
- last_server is claimed AFTER the poll in doPlayerJoin completes
- This allows the poll to correctly detect and wait for the old server's
async save to finish before reading data
- Poll increased from 30 to 60 attempts (30s window)
Memory leak fix:
- Added removePlayerLock() in doPlayerJoin's outer catch block to prevent
unbounded growth of playerLocks ConcurrentHashMap on exceptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Advancements: default to null instead of "" in snapshotPlayerData, use
COALESCE(?, advancements) in SQL so failed file reads preserve DB value
instead of silently wiping advancements every 5min periodic save
- Effects: skip saving effects when player isDeadOrDying() — Minecraft
clears effects on respawn not death, so pre-death effects were persisted
in DB and restored as phantom effects on next login
- Legacy store() also uses COALESCE(NULLIF(?, ''), advancements)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Migrate connection pool from manual LinkedBlockingQueue to HikariCP
(eliminates isValid() ping on every query visible in Spark profiler)
- Move ALL DB writes off server thread: logout uses snapshot+async+latch,
shutdown uses snapshot+CompletableFuture.allOf for parallel saves
- Pre-read curios/accessories/cosmeticarmor/attachments on background
thread during login (4-7 fewer DB queries on main thread per login)
- Auto-save interval increased to 5 minutes
- Fix pool shutdown ordering: shutdownPool() now runs AFTER all shutdown
saves complete (previously could fire before, silently losing all data)
- Fix connection leak in executeQuery/executePreparedQuery when
prepareStatement throws (leaked connections exhaust HikariCP pool)
- Fix duplication bug: saveStorageContents guard used nbt.size()<=1 which
blocked legitimately emptied backpacks from saving to DB
- Fix stale SaveToFile overwriting logout: check playerLocks.containsKey
before writing to prevent stale background task from regressing data
- Remove LIMIT 1000 on startup online=0 reset (could leave players stuck)
- Add executorService.shutdown() on server stop to prevent JVM hang
- Add apply methods (applyCuriosFromData, applyAccessoriesFromData, etc.)
to separate entity writes from DB reads for thread-safe restore
- Add UUID collectors (collectBackpackUuids, collectSSUuids) and
background save methods for snapshot+async logout/shutdown pattern
Root cause of lag (TPS 9-16, MSPT spikes to 4846ms):
PlayerEvent.SaveToFile triggered synchronous JDBC writes on the
server main thread every Minecraft autosave cycle. With 35 players
this caused hundreds of network round-trips to MySQL blocking the
tick loop for up to 4846ms (97x the 50ms limit).
Fixes applied:
- onPlayerSaveToFile: now fully async. Entity state is snapshotted
on the main thread (pure memory ops, <1ms), then ALL DB writes are
submitted to the background executor. Main thread never blocks on
MySQL again.
- snapshotPlayerData: now captures ALL entity-dependent mod data
(Curios, Accessories, CosmeticArmor, NeoForge attachments) on the
main thread. Previously these were read from a background thread
which is not thread-safe and could cause data corruption.
- writeSnapshotToDB: single method that writes all player data in one
background pass: player_data + curios + mod_player_data.
- Auto-save background task: removed ModCompatSync.storeAll(player),
storeSophisticatedBackpacks, storeSophisticatedStorageItems,
storeRefinedStorageDisks from background thread. These all accessed
entity state off-thread. Mod compat data is now in the main-thread
snapshot; backpack/SS/RS2 contents are saved on logout/shutdown.
- Added ModCompatSync snapshot API: snapshotAccessories(),
snapshotCosmeticArmor(), snapshotAttachments(), writeModSnapshot()
for clean separation of entity reads vs DB writes.
Spark showed 5.66% server thread from auto-save. Breakdown:
- store() DB write: 1.39% (already moved to background)
- StoreCurios DB write: 0.56% (was on main thread)
- storeAccessories DB write: 0.55% (was on main thread)
- storeCosmeticArmor DB write: 0.56% (was on main thread)
- storeNeoForgeAttachments DB write: 0.58% (was on main thread)
- storeSophisticatedStorage: 0.69% (was on main thread)
- storeSophisticatedBackpacks: 0.59% (was on main thread)
Changes:
1. Curios snapshot: new snapshotCuriosData() reads entity state on
main thread (fast), returns serialized string. DB write in background.
2. ALL mod saves moved to background thread lambda:
- ModCompatSync.storeAll (Accessories, CosmeticArmor, Attachments)
- Sophisticated Backpacks/Storage/RS2
3. Auto-save interval doubled: 1200 -> 2400 ticks (1min -> 2min)
4. Main thread now only does: entity snapshot (~0.3ms) + curios snapshot
Expected: ~80% reduction in main thread usage (5.66% -> ~1%)
Vyrriox
ROOT CAUSE: Sophisticated Backpacks/Storage wrappers cache inventory
in memory. When store() reads from BackpackStorage/ItemContentsStorage,
the SavedData may not have the latest wrapper state (unflushed changes).
This returns empty/default NBT which overwrites the real data in our DB.
Going back to the original server showed data because that server's
local SavedData still had the correct data (never overwritten).
FIX: saveStorageContents() now checks if the NBT is empty/minimal
before writing. If the DB already has substantial data (>50 bytes)
and the new NBT is empty, the save is SKIPPED to preserve the real
data. This prevents the empty-overwrite scenario while still allowing
legitimate saves of actual content.
Vyrriox
CRITICAL-1: online=0 moved to finally block in logout handler.
If store() threw an exception, online=0 was never written and the
player was permanently locked out of all servers.
CRITICAL-2: Same fix for shutdown handler. Any save failure during
shutdown left the player permanently stuck as online=1.
IMPORTANT: Auto-save background DB write now acquires tryLock()
before writing. If logout already saved newer data and holds/held
the lock, the stale auto-save snapshot is skipped. Prevents
overwriting correct logout data with an older snapshot.
Vyrriox
Spark showed 5.66% server thread from auto-save DB writes blocking
the tick loop (~1-2ms per player per query, ~8 queries per save).
New approach:
- snapshotPlayerData() captures ALL entity data into an immutable
PlayerDataSnapshot record on the main thread (fast, no DB I/O)
- writeSnapshotToDB() writes the snapshot to DB on the background
thread via executorService (slow DB I/O off main thread)
- Mod data (Curios, Accessories, CosmeticArmor, NeoForge attachments)
still read entity state on main thread but their DB writes happen
inline (they manage their own connections)
- Sophisticated Backpacks/Storage/RS2 saves happen during snapshot
phase on main thread (they need entity access for inventory scan)
Expected: ~60-70% reduction in main thread blocking from auto-save.
Vyrriox
Spark showed PlayerSync consuming 10.16% of the server thread, almost
entirely from DriverManager.getConnection() (TCP handshake + MySQL auth
+ USE db) called for EVERY single query. With auto-save every 60s,
each player generated ~6 new connections per save cycle on main thread.
FIX: Simple connection pool (LinkedBlockingQueue, 5 connections).
- Connections are reused instead of opened/closed per query
- isValid(2) check before reuse to detect dead connections
- returnConnection() puts connections back in pool instead of closing
- QueryResult.close() also returns to pool
- autoReconnect=true in JDBC URL for resilience
- shutdownPool() for clean server stop
- Non-database connections (startup DDL) bypass the pool
Expected improvement: ~90% reduction in MySQL overhead on server thread.
Vyrriox
ROOT CAUSE from logs:
"Invalid UUID capacity: Invalid UUID string: capacity"
"Invalid UUID resources: Invalid UUID string: resources"
We saved the INNER storage data ({type, capacity, resources}) but the
map codec expects {uuid-string: {type, capacity, resources}}.
The codec tried to parse "capacity", "resources", "type" as UUIDs.
FIX: Wrap the stored NBT back in a UUID-keyed CompoundTag before
decoding: wrapped.put(uuid.toString(), storedNbt)
Also increased sync timeout from 15s to 60s - the server was 34s
behind (691 ticks) causing timeout errors for player sync.
Vyrriox
repo.set(uuid, storage) throws IllegalArgumentException if the UUID
already exists in the StorageRepository. This happens when a player
revisits a server where the disk was previously used.
Items appeared briefly (data was decoded correctly) but then the
exception prevented the set() and the storage fell back to empty.
Fix:
- Call repo.remove(uuid) before repo.set(uuid, storage)
- If set() still fails, inject directly into the entries map via
reflection + mark SavedData dirty
- setDirty() ensures the injected data persists to disk
Vyrriox
ROOT CAUSE: getMapCodec(Runnable) returns MapCodec (not Codec).
createCodec(Runnable) returns Codec<Map<UUID, SerializableStorage>>.
Reflection on getMapCodec silently failed because the returned
MapCodec.decode() has a different signature than Codec.decode().
Both save fallback and restore codec paths now use createCodec().
RS2 uses ErrorHandlingMapCodec with UUIDUtil.STRING_CODEC for keys,
so the encoded format IS a CompoundTag with UUID strings as keys.
Vyrriox
Save side:
- save() returns data in a NEW CompoundTag (fixed in previous commit)
- Now logs full NBT structure for debugging (describeNbtStructure)
- If UUID not found in save() NBT, falls back to reflection on
internal entries map + codec.encodeStart() to serialize directly
Restore side:
- Rewritten to use raw Codec types to avoid generic compilation issues
- Decodes stored NBT via the same map codec, then repo.set() to inject
Both sides now have comprehensive logging to diagnose any remaining
format issues in production.
Vyrriox
save() returns the serialized data in a NEW CompoundTag - it does NOT
fill the input parameter. We were passing an empty tag and reading it
back, getting nothing. The actual data was in the return value.
Log showed: "RS2 disk UUID xxx exists in repo but NOT found in save()
NBT. Keys at top: []" - empty because we ignored the return value.
Vyrriox
BUG 1 - syncNotCompletedPlayer race condition:
syncNotCompletedPlayer.add() was inside the background thread body.
A player disconnecting instantly before the thread starts bypasses
the "sync not completed" guard in onPlayerLogout, causing store()
to read invalid entity state.
FIX: add() moved to onPlayerJoin BEFORE executorService.submit().
BUG 2 - doPlayerSaveToFile off main thread:
onPlayerSaveToFile wrapped doPlayerSaveToFile in executorService,
but SaveToFile already fires on the main thread. store() reads
player inventory/armor/effects from a background thread = corruption.
FIX: Call doPlayerSaveToFile directly (no executor). Same fix as
auto-save and logout paths.
Vyrriox
ROOT CAUSE for both:
- RS2: We removed dataStorage.save() to avoid fastasyncworldsave crash,
but then read the .dat file which had stale data. Disks appeared
empty because the file didn't contain the latest in-memory state.
- SS: getOrCreateStorageContents() could create empty content if the
data wasn't loaded yet for that UUID.
FIX RS2:
- Save: Use SavedData.save(CompoundTag, Provider) which serializes
from MEMORY, not disk. No file I/O, no fastasyncworldsave conflict.
- Restore: Decode entries via RS2's codec (reflection on getMapCodec)
and inject via repo.set(). Falls back to direct NBT injection if
codec fails.
- Removed dead code: getRS2DataFile, injectRS2EntryIntoNbt
FIX SS:
- Already using StackStorageWrapper.fromStack() API for UUID extraction
(DataComponent-based, not CustomData). This was fixed in previous
commit. If data still missing, the save() logging will show which
UUIDs fail to find in ItemContentsStorage.
Vyrriox
storeRefinedStorageDisks() called DimensionDataStorage.save() directly
to flush RS2 data before reading the .dat file. This triggers all
SavedData saves simultaneously and conflicts with fastasyncworldsave's
async save mixin, causing ConcurrentModificationException crash.
Fix: Only mark RS2 SavedData as dirty (setDirty()) and let the normal
world save cycle handle the flush. The .dat file read may get slightly
stale data but avoids crashing the server.
Vyrriox
CRITICAL-1/2: Remove duplicate online=1 writes from doPlayerJoin.
The synchronous onPlayerLoggedInKickCheck already sets online=1.
The background thread writes raced with logout's online=0, permanently
locking players as "online" after crash-disconnect during join.
HIGH-1: Startup SQL uses PreparedStatement for server_id (was string concat).
HIGH-2: update() method now uses try-with-resources for PreparedStatement.
HIGH-3: NPE guard in RS2 data file logging when getRS2DataFile returns null.
Vyrriox
ROOT CAUSE: When Server B kicks a player for being already online on
Server A, the onPlayerLogout handler on Server B fires and sets
online=0 in the DB. The player then immediately reconnects to Server B,
the DB says online=0, and the kick check passes - player is now on
BOTH servers simultaneously.
FIX: New kickedForDuplicateLogin set tracks players being kicked for
duplicate login. onPlayerLogout checks this set FIRST and skips the
online=0 update entirely. The player's DB record correctly stays
online=1 with last_server=A, preventing reconnect bypass.
Flow:
1. Player on Server A (online=1, last_server=A)
2. Player tries Server B → kick check → online=1, A alive → KICK
3. kickedForDuplicateLogin.add(uuid) BEFORE disconnect
4. onPlayerLogout fires → sees kickedForDuplicateLogin → skips online=0
5. Player retries Server B → online=1 still → KICKED AGAIN
Vyrriox
CRITICAL fixes:
- C-1/C-2/C-4: Auto-save and logout now run on MAIN THREAD. All entity
reads (inventory, curios, effects) were happening off-thread, causing
duplication exploits (player interacts during save → items duplicated).
Auto-save uses tryLock() to skip players already being saved.
- C-5: NPE fix for non-RS2 items (null check on registry key lookup)
- C-6: RS2 .dat file written atomically (temp file + rename) to prevent
corruption of entire RS2 storage on crash mid-write
HIGH fixes:
- H-3: Deadlock prevention: lock released BEFORE latch.await() in
doPlayerJoin. Prevents shutdown deadlock where background thread
holds lock while waiting for main thread, and shutdown holds main
thread while waiting for lock.
- H-5: Curios cache now works WITHOUT keepInventory. Players who die
then disconnect before respawning no longer lose curios data.
- H-8: server_id SQL uses PreparedStatements instead of string concat
MEDIUM fixes:
- M-1: NumberFormatException in LocalJsonUtil caught per-entry instead
of crashing entire map parse (prevents losing all cosmetic armor)
Vyrriox
1. Sophisticated Storage shulkers/barrels/chests:
- ROOT CAUSE: UUID stored as DataComponent (not in CustomData).
extractStorageUuid() only checked CustomData, missing the UUID.
- FIX: Use StackStorageWrapper.fromStack(provider, item).getContentsUuid()
which reads the DataComponent via the proper API.
- Also scan ender chest for packed storage items.
2. Refined Storage 2 disks:
- ROOT CAUSE: save() on StorageRepositoryImpl returned data in an
unknown codec format that our extraction couldn't parse.
- FIX: Read/write the .dat file directly from disk after forcing
a save flush. This uses the exact NBT format RS2 writes.
- Search multiple NBT structures (direct keys, nested compounds,
list-of-pairs) to handle any codec format.
- On restore: write entries into .dat file, clear DimensionDataStorage
cache via reflection to force RS2 to reload.
3. Kick system:
- ROOT CAUSE: PlayerNegotiationEvent.getConnection().disconnect()
does NOT work in NeoForge 1.21.1 (too early in connection).
- FIX: Full duplicate check moved to PlayerLoggedInEvent with
HIGHEST priority. Uses player.connection.disconnect() which
is reliable on the server thread.
- Marks online=1 synchronously to close race condition.
Vyrriox
1. CRITICAL - Anti-dupe: Player inventory mutations now run on the main
server thread via server.execute(). DB reads stay async, but all
setItem/setHealth/addEffect calls happen on the tick thread.
CountDownLatch ensures the lock is held until apply completes.
2. CRITICAL - Resource leaks: 3 QueryResults in PlayerSync.java startup
now use try-with-resources + PreparedStatements instead of raw
String.format SQL.
3. HIGH - Curios save: UPDATE changed to REPLACE INTO to prevent silent
no-ops when the curios row doesn't exist yet (new player who died
before first init save).
4. HIGH - RS2 restore: Removed skip-if-exists check. DB is always the
source of truth - stale local data was persisting permanently.
5. HIGH - Race conditions: Shutdown save now acquires per-player lock.
All logout saves (curios, mod-compat, inventory) moved inside
doPlayerLogout under a single lock acquisition.
6. HIGH - SQL injection: DATABASE_NAME validated against [A-Za-z0-9_]+
regex on startup to prevent injection via config.
Vyrriox
Minecraft only flushes PlayerAdvancements to disk during auto-save
(~every 5 min). If a player earns an advancement and switches servers
before the next auto-save, store() reads the stale file and the
advancement is lost in the DB.
Fix: call sp.getAdvancements().save() to force flush to disk before
reading the advancement file in store().
Vyrriox
1. NeoForge attachments (SOL Onion, Ars Nouveau, etc.):
- deserializeAttachments signature is (Provider, CompoundTag) not
(CompoundTag) - reflection was failing silently, nothing restored
- Use serializeAttachments(Provider) directly for saving instead of
saveWithoutId() for cleaner approach
- This fixes SOL Onion food diversity, Ars Nouveau mana/glyphs,
Iron's Spellbooks, Pehkui scale, and all other NeoForge attachments
2. Multi-server kick:
- Add secondary kick check in PlayerLoggedInEvent as fallback
- Mark online=1 SYNCHRONOUSLY on login to close race condition
where async doPlayerJoin hasn't set online=1 yet
3. Backpack upgrades:
- Call refreshInventoryForInputOutput() before reading from
BackpackStorage to flush pending wrapper changes
Vyrriox
Root cause: Sophisticated Storage uses its own ItemContentsStorage
(SavedData) for packed items, NOT BackpackStorage from Sophisticated
Backpacks. The code was calling BackpackStorage which returned empty
data for storage items.
Fixes:
- Use ItemContentsStorage.get().getOrCreateStorageContents() for save
- Use ItemContentsStorage.get().setStorageContents() for restore
- Add extractStorageUuid() for "storageUuid" key (SS uses this, not
"contentsUuid" which is for backpacks only)
- Try both UUID keys when scanning inventory items
- Add sophisticatedstorage as compileOnly dependency
Vyrriox
Effects from the local server .dat file persisted when the player had
no effects saved in the DB. removeAllEffects() was only called inside
the if-block that checks for saved effect data, so it was skipped when
effectData was null/empty. Now effects are ALWAYS cleared before
restoring from DB.
SOL Onion food diversity is already synced via the generic NeoForge
attachment system (FoodPlayerData is a NeoForge attachment).
Vyrriox
- Sync RS2 disk storage contents between servers (storageReference UUID)
- Support both refinedstorage and extradisks namespaces
- Save: extract individual entries from StorageRepository SavedData
- Restore: decode via RS2 codec and inject into target server repository
- Skip restore if storage already exists on target server (no overwrite)
- Scan inventory + ender chest for disks
Vyrriox
Replace unbounded CachedThreadPool with bounded ThreadPoolExecutor.
Problem: CachedThreadPool creates unlimited threads. With many players
online and slow DB queries, thread count explodes (25000+ threads
observed in issue #169), causing memory leaks and server crashes.
Fix: ThreadPoolExecutor with 2 core / 8 max threads, 30s keepalive,
256-task bounded queue, and CallerRunsPolicy for backpressure.
When the queue is full, tasks execute on the calling thread instead
of creating more threads, providing natural flow control.
Closesmlus-asuka/PlayerSync#169
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a generic system that syncs ALL NeoForge player attachments,
covering per-player data from every mod in the modpack:
- Ars Nouveau: mana, glyph/spell knowledge
- Iron's Spellbooks: mana, learned spells, cooldowns
- Pehkui: player scale
- Spice of Life: Onion: food diversity history
- And ANY other mod using NeoForge's attachment system
Implementation:
- Save: extracts neoforge:attachments tag from player.saveWithoutId()
- Restore: uses reflection to call NeoForge's deserializeAttachments()
which ensures exact same deserialization path as normal player load
- Stored as BNBT in mod_player_data table (mod_id=neoforge_attachments)
Also verified CosmeticArmours (mod id: cosmeticarmoursmod) and
CosmeticWeapons (mod id: cosmeticweaponsmod) are content-only mods
that add craftable items - no custom player storage, fully handled
by existing inventory sync.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Per-player ReentrantLock prevents concurrent save/restore operations,
eliminating race conditions that could cause item duplication
- Save ALL online players on ServerStoppingEvent (before disconnect) to
prevent data loss from server shutdowns/restarts
- Lock acquired before restore on join, released in finally block
- Lock acquired before save on logout, cleaned up after completion
- Verified compatibility with 430-mod Arcadia V2 modpack:
- All item DataComponents from all mods preserved via BNBT serialization
- Curios items (Artifacts, Elytra Slot, Charm of Undying, etc.) synced
- Accessories items (Aether, Deep Aether) synced
- Server-specific data (FTB Quests/Chunks, Waystones, Lootr) correctly
NOT synced as intended
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add Accessories API sync for Aether mod accessory slots (pendant, cape,
gloves, rings, shield, misc). Uses same pattern as Curios: validate data
before clearing slots, PreparedStatements for DB operations
- Add Cosmetic Armor Reworked sync for 4 cosmetic armor slots via
InventoryManager/CosArmorAPI
- Add Apotheosis + Placebo as compileOnly deps. Apotheosis item data
(affixes, gems, sockets, rarity) travels with items via DataComponents
and is already synced by the inventory sync
- New generic mod_player_data DB table with composite key (uuid, mod_id)
for extensible mod-specific data storage
- Integrated save/restore in join, logout, and auto-save pipelines
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix advancements disappearing: use PreparedStatements for all SQL with
user data (advancement JSON contains chars that broke string-concat SQL),
add null safety for advancement file
- Fix multi-server kick: run doPlayerConnect synchronously instead of async
(players could join before the duplicate check completed)
- Fix Curios disappearing: clear slots AFTER validating data exists (not
before), use CuriosCache for dead players on logout instead of empty API
- Fix Sophisticated Storage items: add storeSophisticatedStorageItems() and
restoreSophisticatedStorageItems() to sync packed barrels/shulkers/chests
- Anti-duplication: clear all inventories before restoring from DB on join
- Fix tick counter: remove LevelTickEvent (fired per dimension = 3x too
fast), merge heartbeat into ServerTickEvent
- Fix connection leaks: use try-with-resources for all QueryResult
- Fix logout order: save data BEFORE marking player offline
- Skip auto-save for dead/unsynced players to prevent saving empty data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>