PlayerSync/docs/code-analysis.md
mlus ac20ad327c
Some checks failed
Build / build (push) Has been cancelled
Feat: 添加了Mek支持
Merge pull request #172 from mlus-asuka/fix/169-bounded-thread-pool

Fix/169 bounded thread pool
2026-06-09 10:22:39 +08:00

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_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<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 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 (<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.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).