# 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:`) - **Legacy format:** Custom character replacement (`|`, `^`, `<`, `>`, `~` for `,`, `"`, `{`, `}`, `'`) - The mod **reads both formats** but writes using the configured format (`use_legacy_serialization` config 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_nbt` tag 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, bypassing `giveExperience()` side effects (events, packets, score) #### Dead/Dying Player Edge Case When a player dies during login (`isDeadOrDying()`), the mod: 1. Clears the `player_synced` tag 2. Teleports to respawn point 3. Sets health to 1 4. Updates online status 5. 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 clients - `ChatSyncClient` — connects to the chat server, relays `ServerChatEvent` messages, 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` or `Map`. 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 in `curios` table - **Restore:** Clears all Curios slots, reads DB data, deserializes with placeholder support - **Death handling:** `CuriosCache` snapshots 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()`, extracts `contentsUuid` via `NBTHelper`, stores NBT in `backpack_data` table using `REPLACE 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 (`/data/mekanism_personal_storage/`), 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.personalStorageId` tag. Uses reflection to call `PersonalStorageManager.getInventoryIfPresent(stack)`, then serializes all 54 `IInventorySlot` entries via `slot.serializeNBT()` into a single NBT `CompoundTag`, which is Base64-encoded and stored in the `mekanism_personal_chest` table keyed by `storage_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 call `PersonalStorageManager.getInventoryFor(stack)` to create/retrieve the `SavedData` entry on the target server, then calls `slot.deserializeNBT()` on each of the 54 slots. - **Reflection:** `PersonalStorageManager` and `PersonalStorageData` are in Mekanism's implementation JAR (not the API JAR), so access is via `Class.forName()` + `Method.invoke()`. The `IInventorySlot` interface IS in the API JAR and is used directly at compile time. - **Config:** Controlled by `sync_mekanism_personal_chest` (default `false`). | 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 1. **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. 2. **Base64 NBT serialization** — Replaced legacy character-replacement format. Backward-compatible read of both formats; config flag to force legacy writes. 3. **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. 4. **Dead/dying player handling** — Special case for players who die during the login process or disconnect via "Title Screen" while dead. Uses `CuriosCache` to snapshot Curios data on death so it's not lost on logout. 5. **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. 6. **Schema auto-migration** — The mod checks column existence and data types on startup and alters tables as needed, supporting upgrades without manual SQL. 7. **Server heartbeat system** — Each server writes `last_update` every ~90s. Other servers check this to determine if a player is genuinely online elsewhere (5-minute window) before kicking. 8. **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 via `Class.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`).