Some checks failed
Build / build (push) Has been cancelled
Merge pull request #172 from mlus-asuka/fix/169-bounded-thread-pool Fix/169 bounded thread pool
407 lines
21 KiB
Markdown
407 lines
21 KiB
Markdown
# 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`).
|