Merge pull request #172 from mlus-asuka/fix/169-bounded-thread-pool Fix/169 bounded thread pool
21 KiB
PlayerSync — Code Analysis
1. Project Overview
PlayerSync is a Minecraft Forge 1.20.1 server-side mod that synchronizes player data across multiple Forge servers sharing a centralized MySQL database. It is designed for Forge 群组服 (Forge group server) architectures where players can move between different physical sub-servers and their inventory, equipment, advancements, effects, and other data must follow them seamlessly.
| Property | Value |
|---|---|
| Mod ID | playersync |
| Minecraft | 1.20.1 |
| Forge | 47.4.0+ |
| Java | 17 |
| License | GPL-3.0 |
| Author | mlus |
| DB | MySQL (via JDBC mysql-connector-j) |
2. Project File Structure
src/main/java/vip/fubuki/playersync/
├── PlayerSync.java # Main mod class, DB initialization
├── CommandInit.java # `/playersync` command registration
├── config/
│ └── JdbcConfig.java # All mod configuration (ForgeConfigSpec)
├── sync/
│ ├── VanillaSync.java # Core player data sync (inventory, effects, advancements, etc.)
│ ├── ChatSync.java # Cross-server chat sync orchestrator
│ ├── chat/
│ │ ├── ChatSyncServer.java # TCP chat server (one designated server)
│ │ └── ChatSyncClient.java # TCP chat client (all servers)
│ └── addons/
│ ├── ModsSupport.java # Curios, Sophisticated Backpacks & Mekanism integration
│ ├── CuriosCache.java # Death-safe Curios data caching
│ └── MekanismSupport.java # Mekanism personal chest inventory sync
└── util/
├── JDBCsetUp.java # MySQL JDBC connection & query helper
├── LocalJsonUtil.java # Lightweight map⇔string parser
└── PSThreadPoolFactory.java # Named thread factory for async DB operations
src/main/resources/
└── assets/playersync/lang/
├── en_us.json # English translations
└── zh_cn.json # Chinese translations
src/main/templates/
├── META-INF/mods.toml # Mod metadata template
└── pack.mcmeta # Resource pack metadata
3. Architecture
3.1 Deployment Topology
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Server A │ │ Server B │ │ Server C │
│ (forge-1) │ │ (forge-2) │ │ (forge-3) │
│ PlayerSync │ │ PlayerSync │ │ PlayerSync │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└──────────────────┼──────────────────┘
│
┌──────▼──────┐ ┌──────────────┐
│ MySQL DB │◄──────│ Chat Server │
│ playersync │ │ (TCP:7900) │
└─────────────┘ └──────────────┘
- All sub-servers read/write player data to the same MySQL database.
- One designated server runs the chat sync server (TCP). All servers connect to it as clients to broadcast chat messages across servers.
- Each server gets a unique
server_id(randomly generated on first config load).
3.2 Data Flow Summary
Player Login:
1. PlayerNegotiationEvent → doPlayerConnect() [check if already online on another server]
2. OnDatapackSyncEvent → restore advancements [write JSON to disk]
3. PlayerLoggedInEvent → doPlayerJoin() [restore all data from DB]
└─ ModsSupport.doCuriosRestore()
└─ ModsSupport.doBackPackRestore()
└─ MekanismSupport.restorePersonalChestData()
Player Playing:
4. PlayerEvent.SaveToFile → doPlayerSaveToFile() [periodic save]
5. TickEvent.ServerTick → auto-save every 1200t [~1 min]
6. ServerChatEvent → ChatSyncClient → TCP [chat broadcast]
Player Death (with keepInventory):
6. LivingDeathEvent → CuriosCache.tryStoreCuriosToCache() [snapshot curios]
Player Logout:
7. PlayerLoggedOutEvent → onPlayerLeave() → store() + curios save
└─ if dead: use CuriosCache, else normal save
Server Shutdown:
8. ServerStoppedEvent → mark server disabled in DB
4. Core Module Analysis
4.1 PlayerSync.java — Main Mod Class
Path: src/main/java/vip/fubuki/playersync/PlayerSync.java
The entry point and lifecycle controller.
| Method | Trigger | Responsibility |
|---|---|---|
PlayerSync() |
Mod construction | Register config, event bus |
commonSetup() |
FMLCommonSetupEvent |
Register JDBC driver, init VanillaSync, conditionally init ChatSync |
onServerStarting() |
ServerStartingEvent |
Create DB, create/alter tables, register server heartbeat, mark players offline |
onServerStopping() |
ServerStoppingEvent |
Shutdown ChatSync |
Database tables created on server start:
| Table | Purpose |
|---|---|
player_data |
Core player state (inv, armor, enderchest, advancements, effects, XP, health, food, score, left_hand, cursors, online flag, last_server) |
server_info |
Server heartbeat (id, enable, data_version, last_update) |
curios |
Curios inventory (only if Curios mod loaded) |
backpack_data |
Sophisticated Backpacks NBT data (only if mod loaded) |
mekanism_personal_chest |
Mekanism personal chest inventory data (only if Mekanism loaded) |
The mod runs automatic schema migration — it checks column counts and data types on startup and alters tables as needed.
4.2 VanillaSync.java — Core Sync Engine
Path: src/main/java/vip/fubuki/playersync/sync/VanillaSync.java
The largest and most critical class. All methods are static and registered via @Mod.EventBusSubscriber.
Thread Pool
CORE: 2 threads, MAX: 8 threads, QUEUE: 256 (LinkedBlockingQueue)
Rejection policy: CallerRunsPolicy (backpressure)
All DB operations run via executorService.submit(...) to avoid blocking the server thread. The bounded thread pool with CallerRunsPolicy was a fix for #169 — previously an unbounded CachedThreadPool could spawn 25000+ threads under load.
Event Handlers
| Event | Method | Behavior |
|---|---|---|
OnDatapackSyncEvent |
onDataPackSyncEvent() |
Restore advancements JSON from DB to disk, then reload |
PlayerNegotiationEvent |
onPlayerConnect() |
Pre-login check: kick if player is already online on another active server (5-minute heartbeat window) |
PlayerLoggedInEvent |
onPlayerJoin() |
Full data restore: health, food, XP, score, inventory, armor, ender chest, effects, left hand, cursor. Returns generic placeholders for unknown modded items. |
PlayerEvent.SaveToFile |
onPlayerSaveToFile() |
Save current state to DB (skipped if player not yet synced) |
PlayerLoggedOutEvent |
onPlayerLogout() |
Mark offline in DB, save data, handle dead/dying edge cases |
LivingDeathEvent |
onPlayerDeath() |
Cache Curios data (for keepInventory worlds) |
TickEvent.LevelTick |
onUpdate() |
Server heartbeat: update last_update every 1800 ticks (~90s) |
TickEvent.ServerTick |
onServerTick() |
Auto-save all online players every 1200 ticks (~1 min); clean expired Curios cache every 36000 ticks (~30 min) |
ServerStoppedEvent |
onServerShutdown() |
Mark server as disabled in DB |
Serialization
- New format (default): Base64 with
B64:prefix (B64:<base64-nbt-string>) - Legacy format: Custom character replacement (
|,^,<,>,~for,,",{,},') - The mod reads both formats but writes using the configured format (
use_legacy_serializationconfig flag) - NBT is always tagged with the current data version for forward compatibility
Placeholder Items
When an item from one server's mod set doesn't exist on another, it becomes a paper item with:
- Red italic name: "Item Voucher" (customizable)
- Lore showing the original item ID and stack count
- Stored
playersync:original_item_nbttag for later restoration - Unique UUID to prevent stacking
XP Calculation
getTotalExperience()— converts level+progress to absolute XP using Minecraft's level curve formulas (different for levels 0-15, 16-30, 31+)setXpForPlayer()— inverse: distributes absolute XP into level+progress, bypassinggiveExperience()side effects (events, packets, score)
Dead/Dying Player Edge Case
When a player dies during login (isDeadOrDying()), the mod:
- Clears the
player_syncedtag - Teleports to respawn point
- Sets health to 1
- Updates online status
- Disconnects player with a reconnect message
This avoids saving corrupted "dead" state as the player's synced data.
4.3 ChatSync.java — Chat Sync Orchestrator
Path: src/main/java/vip/fubuki/playersync/sync/ChatSync.java
Starts both the TCP server (if configured as chat server) and TCP client on separate threads.
ChatSyncServer(port 7900 default) — accepts client connections, broadcasts received messages to all other connected clientsChatSyncClient— connects to the chat server, relaysServerChatEventmessages, receives and broadcasts messages from the server to local players- Uses exponential backoff for reconnect (5s base, up to 60s, max 10 attempts)
4.4 JdbcConfig.java — Configuration
Path: src/main/java/vip/fubuki/playersync/config/JdbcConfig.java
All configuration is done via Forge's ForgeConfigSpec system (TOML file on server). Key entries:
| Config Key | Default | Description |
|---|---|---|
host |
localhost |
MySQL host |
db_port |
3306 |
MySQL port |
use_ssl |
false |
SSL for DB connection |
user_name |
playersync |
DB username |
password |
pleaseChangeThisPassword |
DB password |
db_name |
playersync |
Database name |
Server_id |
random int | Unique server identifier |
sync_world |
[] |
World names for advancements (empty = auto-detect) |
sync_advancements |
true |
Enable advancement sync |
sync_chat |
false |
Enable cross-server chat |
IsChatServer |
false |
This server acts as chat relay host |
ChatServerIP |
127.0.0.1 |
Chat server address |
ChatServerPort |
7900 |
Chat server port |
kick_when_already_online |
true |
Prevent multi-server login |
use_legacy_serialization |
false |
Use old serialization format |
item_placeholder_title_override |
"" |
Custom placeholder title |
item_placeholder_description_override |
"" |
Custom placeholder description |
sync_mekanism_personal_chest |
false |
Sync Mekanism personal chest inventories |
4.5 JDBCsetUp.java — Database Utility
Path: src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java
Encapsulates all MySQL operations:
| Method | Purpose |
|---|---|
getConnection(selectDatabase) |
Create JDBC connection; selectDatabase=false for DDL statements |
executeQuery() |
Run SELECT, returns QueryResult record |
executeUpdate() |
Run INSERT/UPDATE/DELETE/DDL with database |
executeUpdateWithoutDatabase() |
Run DDL without default database |
update() |
Parameterized update (anti-SQL-injection) |
The QueryResult record (Connection, PreparedStatement, ResultSet) implements AutoCloseable for try-with-resources support.
Note: SQL queries in VanillaSync use string concatenation for UUID values rather than parameterized queries — this is a potential SQL injection risk if UUIDs could be attacker-controlled (though Minecraft UUIDs are generally safe). The JDBCsetUp.update() method does provide parameterized queries but is not used for the main sync queries.
4.6 LocalJsonUtil.java — Lightweight Map Parser
Path: src/main/java/vip/fubuki/playersync/util/LocalJsonUtil.java
A minimal parser that converts Java Map.toString() output back into Map<String, String> or Map<Integer, String>. Used because the item/effect data is stored as {0=..., 1=..., 2=...} format (Java map string representation), not standard JSON.
Uses = as key-value separator (not :), matching Java's Map.toString() format.
4.7 PSThreadPoolFactory.java — Named Thread Factory
Path: src/main/java/vip/fubuki/playersync/util/PSThreadPoolFactory.java
Simple ThreadFactory implementation producing threads named PlayerSync-thread-N for easier debugging.
4.8 CommandInit.java — Command Registration
Path: src/main/java/vip/fubuki/playersync/CommandInit.java
Registers the /playersync command (permission level 2 required). Currently has a reconnect subcommand that is a stub (returns 0, implementation commented out).
4.9 Addon Support (ModsSupport.java & CuriosCache.java)
Curios Integration
- Save: Serializes all Curios slot items as
{slotType:index=serializedNbt, ...}map, stored incuriostable - Restore: Clears all Curios slots, reads DB data, deserializes with placeholder support
- Death handling:
CuriosCachesnapshots Curios inventory on death (keepInventory worlds only), used on logout to prevent empty-save bug from the "Title Screen" disconnect case - Cache entries expire after 1 hour
Sophisticated Backpacks Integration
- Save: Iterates backpack items via
PlayerInventoryProvider.runOnBackpacks(), extractscontentsUuidviaNBTHelper, stores NBT inbackpack_datatable usingREPLACE INTO - Restore: On player join, reads backpack NBT from DB and calls
BackpackStorage.get().setBackpackContents()to inject it
Mekanism Integration
Jetpack Mode (already synced): The jetpack mode (NORMAL/HOVER/DISABLED) is stored in the ItemStack NBT at path mekData.mode. PlayerSync's existing serializeNBT() → Base64 pipeline preserves all ItemStack NBT including mekData.*, so jetpack state is automatically synced without any additional code. The PlayerState.activeJetpacks set is purely runtime memory (used for sound/particle effects) and is cleared by Mekanism on player logout — it does not need persistence.
Personal Chest (new sync): Personal chest inventories are stored in Mekanism's world-level SavedData files (<world>/data/mekanism_personal_storage/<uuid>), NOT on the ItemStack. The ItemStack only carries a UUID reference (mekData.personalStorageId). When a player moves between servers, the ItemStack is restored but the target server's SavedData has no inventory for that UUID — the chest appears empty.
MekanismSupport.java solves this by:
-
Save: Iterates the player's inventory + ender chest, finds every item with a
mekData.personalStorageIdtag. Uses reflection to callPersonalStorageManager.getInventoryIfPresent(stack), then serializes all 54IInventorySlotentries viaslot.serializeNBT()into a single NBTCompoundTag, which is Base64-encoded and stored in themekanism_personal_chesttable keyed bystorage_id. -
Restore: After inventory is restored (in
doPlayerJoin()), iterates the player's items again to find personal chests. For each, queries the DB for saved inventory data. Uses reflection to callPersonalStorageManager.getInventoryFor(stack)to create/retrieve theSavedDataentry on the target server, then callsslot.deserializeNBT()on each of the 54 slots. -
Reflection:
PersonalStorageManagerandPersonalStorageDataare in Mekanism's implementation JAR (not the API JAR), so access is viaClass.forName()+Method.invoke(). TheIInventorySlotinterface IS in the API JAR and is used directly at compile time. -
Config: Controlled by
sync_mekanism_personal_chest(defaultfalse).
| Method | Purpose |
|---|---|
collectPersonalChestItems(player) |
Scan inventory + ender chest for personal chest items, return map of storageId → ItemStack |
savePersonalChestData(player) |
Serialize each chest's 54 slots, write to DB via REPLACE INTO |
restorePersonalChestData(player) |
Read from DB, call getInventoryFor(), deserialize slots into target world's SavedData |
getInventoryIfPresent(stack) |
Reflection wrapper for PersonalStorageManager.getInventoryIfPresent() |
getInventoryFor(stack) |
Reflection wrapper for PersonalStorageManager.getInventoryFor() |
getInventorySlots(inventory) |
Reflection wrapper for AbstractPersonalStorageItemInventory.getInventorySlots(null) |
getPersonalStorageId(stack) |
Extract personalStorageId UUID from ItemStack NBT mekData |
5. Synchronized Data
| Data | Storage Column | Type | Format |
|---|---|---|---|
| Inventory (36 slots) | inventory |
MEDIUMBLOB | {0=B64:..., 1=B64:..., ...} |
| Armor (4 slots) | armor |
BLOB | {0=B64:..., ...} |
| Off-hand | left_hand |
BLOB | Base64 NBT string |
| Cursor (carried) | cursors |
BLOB | Base64 NBT string |
| Ender Chest (27 slots) | enderchest |
MEDIUMBLOB | {0=B64:..., ...} |
| Advancements | advancements |
MEDIUMBLOB | Raw JSON bytes |
| Active Effects | effects |
BLOB | {effectId=B64:nbt, ...} |
| XP | xp |
INT | Absolute XP value |
| Health | health |
INT | HP as integer |
| Food Level | food_level |
INT | 0-20 |
| Score | score |
INT | Display score |
| Online Status | online |
TINYINT | 0 or 1 |
| Last Server | last_server |
INT | Server ID |
| Curios | curios_item (separate table) |
BLOB | {slotType:index=B64:..., ...} |
| Backpacks | backpack_nbt (separate table) |
MEDIUMBLOB | Base64 NBT string |
| Mekanism Personal Chest | inventory_data (separate table) |
MEDIUMBLOB | Base64 NBT of 54 slots (via IInventorySlot.serializeNBT()) |
6. Lifecycle Events Summary
Server Starting
├── commonSetup: register JDBC driver, register VanillaSync, (optionally) ChatSync
└── onServerStarting:
├── CREATE DATABASE IF NOT EXISTS
├── CREATE/ALTER player_data table
├── CREATE/ALTER server_info table
├── CREATE curios table (if Curios loaded)
├── CREATE backpack_data table (if SophisticatedBackpacks loaded)
├── CREATE mekanism_personal_chest table (if Mekanism loaded)
├── INSERT/UPDATE server_info (heartbeat)
└── Reset stale online flags
Player Connecting
├── onPlayerConnect: check already-online, kick if needed
├── onDataPackSyncEvent: write advancements JSON to disk
└── onPlayerJoin:
├── Restore: health, food, XP, score
├── Restore: left_hand, cursors, armor, inventory, enderchest, effects
├── Restore: Curios items (ModsSupport)
├── Restore: Backpack data (ModsSupport)
└── Restore: Mekanism personal chest data (MekanismSupport)
Player Playing
├── onPlayerSaveToFile: save() to DB (per save event)
├── onServerTick: auto-save all players every ~1 min
├── onUpdate: heartbeat every ~90s
└── ServerChatEvent → ChatSyncClient → TCP
Player Logging Out
├── onPlayerDeath: cache Curios (if keepInventory)
└── onPlayerLogout:
├── Dead/dying: use CuriosCache, don't save new data
├── Sync incomplete: skip save (safety)
└── Normal: store() + ModsSupport.onPlayerLeave()
Server Shutting Down
├── onServerShutdown: mark server disabled
└── onServerStopping: shutdown ChatSync
7. Key Design Decisions
-
Thread pool bounded with CallerRunsPolicy — Fix for #169. Prevents thread explosion under concurrent player load by limiting to 8 threads + 256 queue, with natural backpressure.
-
Base64 NBT serialization — Replaced legacy character-replacement format. Backward-compatible read of both formats; config flag to force legacy writes.
-
Placeholder items for missing mods — When a modded item's registry entry doesn't exist on the current server (e.g., player moved from Mekanism server to vanilla), a paper voucher preserves the original NBT so the item can be restored when the player returns.
-
Dead/dying player handling — Special case for players who die during the login process or disconnect via "Title Screen" while dead. Uses
CuriosCacheto snapshot Curios data on death so it's not lost on logout. -
Advancement sync via filesystem — Rather than direct NBT manipulation, advancements are synced by writing the JSON file to the world's advancements directory and calling
reload(). This leverages Minecraft's built-in advancement loading. -
Schema auto-migration — The mod checks column existence and data types on startup and alters tables as needed, supporting upgrades without manual SQL.
-
Server heartbeat system — Each server writes
last_updateevery ~90s. Other servers check this to determine if a player is genuinely online elsewhere (5-minute window) before kicking. -
Reflection-based mod addon support — Third-party mod data whose storage classes are not in the public API JAR (like Mekanism's
PersonalStorageManager) are accessed viaClass.forName()+Method.invoke(). This avoids hard-compile-dependencies on implementation JARs while still enabling deep data sync at runtime. The compile-time dependency stays on the API JAR only (IInventorySlot,NBTConstants).