diff --git a/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java b/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java
index 0bceae2..b43730c 100644
--- a/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java
+++ b/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java
@@ -1146,21 +1146,104 @@ public class ModsSupport {
* Saves RS2 disk storage contents by UUID using a pre-captured ServerLevel reference.
* Can be called from a background thread (SavedData read + DB write, no entity access).
*/
+ /**
+ * PHASE 16: cached RS2 codec. Resolution via reflection is expensive enough to be
+ * visible in Spark profiles when repeated per-save; we only need the codec instance
+ * once per JVM life. Volatile + double-checked idiom.
+ */
+ @SuppressWarnings("rawtypes")
+ private static volatile com.mojang.serialization.Codec RS2_MAP_CODEC_CACHE;
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static com.mojang.serialization.Codec getOrCreateRS2MapCodec(Object repo) {
+ com.mojang.serialization.Codec c = RS2_MAP_CODEC_CACHE;
+ if (c != null) return c;
+ synchronized (ModsSupport.class) {
+ c = RS2_MAP_CODEC_CACHE;
+ if (c != null) return c;
+ try {
+ java.lang.reflect.Method m = repo.getClass().getDeclaredMethod("createCodec", Runnable.class);
+ m.setAccessible(true);
+ c = (com.mojang.serialization.Codec) m.invoke(null, (Runnable) () -> {});
+ RS2_MAP_CODEC_CACHE = c;
+ } catch (Throwable t) {
+ PlayerSync.LOGGER.error("[rs2] cannot resolve map codec — save/restore will fallback", t);
+ }
+ return c;
+ }
+ }
+
+ /**
+ * PHASE 16: save ONLY the disks the player actually carries in their inventory,
+ * never the full RS2 SavedData.
+ *
+ *
Previous implementation called {@code sd.save(new CompoundTag, registry)}
+ * which serializes every disk registered on the server into a single NBT blob
+ * then searched it for the player's UUIDs. On a populated server (hundreds of
+ * disks in storage networks) this single call dominated logout latency
+ * (rs2=1064ms observed in production).
+ *
+ *
New implementation uses the RS2 {@code createCodec} (same one
+ * {@link #restoreRefinedStorageDisks} uses for decode) to ENCODE one disk at a
+ * time — only the UUIDs the player has in their inventory. Cost is O(player
+ * disk count) instead of O(world disk count).
+ *
+ *
If codec resolution fails (older RS2 version, refactor), falls back to
+ * the old full-save path so a player with a disk still gets their data synced.
+ */
public static void saveRS2DisksByLevel(List diskUuids, net.minecraft.server.level.ServerLevel level,
net.minecraft.core.HolderLookup.Provider registryAccess) {
if (diskUuids.isEmpty()) return;
try {
com.refinedmods.refinedstorage.common.api.storage.StorageRepository repo =
com.refinedmods.refinedstorage.common.api.RefinedStorageApi.INSTANCE.getStorageRepository(level);
- if (!(repo instanceof net.minecraft.world.level.saveddata.SavedData sd)) return;
+ if (repo == null) return;
- net.minecraft.nbt.CompoundTag fullNbt = sd.save(new net.minecraft.nbt.CompoundTag(), registryAccess);
-
- // PHASE 13 PERF: collect all disk NBTs into a single Map and delegate to the
- // batched writer. Previous behavior made N sequential REPLACE INTO calls
- // (observed as rs2=500ms+ in [perf-logout] breakdowns). One batched transaction
- // now dominates by ~10× for players with multiple disks.
Map toSave = new HashMap<>();
+ @SuppressWarnings("rawtypes")
+ com.mojang.serialization.Codec mapCodec = getOrCreateRS2MapCodec(repo);
+
+ if (mapCodec != null) {
+ var ops = registryAccess.createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE);
+ for (UUID uuid : diskUuids) {
+ try {
+ Optional diskOpt = repo.get(uuid);
+ if (diskOpt.isEmpty()) continue; // disk exists in inventory but empty in repo
+ // Build a single-entry map {uuid -> disk} and encode via the same map codec
+ // RS2 uses for its full save. The output CompoundTag is
+ // {"uuid-string": {type, capacity, resources}}
+ // We store ONLY the inner {type, capacity, resources}, matching what
+ // restoreRefinedStorageDisks expects via restoreStorageContents.
+ Map singleMap =
+ java.util.Collections.singletonMap(uuid, diskOpt.get());
+ @SuppressWarnings("unchecked")
+ com.mojang.serialization.DataResult enc =
+ mapCodec.encodeStart(ops, singleMap);
+ Optional tagOpt = enc.result();
+ if (tagOpt.isEmpty()) {
+ PlayerSync.LOGGER.warn("[rs2-save] codec encode returned empty for disk {}", uuid);
+ continue;
+ }
+ if (!(tagOpt.get() instanceof CompoundTag wrapped)) continue;
+ CompoundTag inner = wrapped.getCompound(uuid.toString());
+ if (inner != null && !inner.isEmpty()) {
+ toSave.put(uuid, inner);
+ }
+ } catch (Throwable t) {
+ PlayerSync.LOGGER.warn("[rs2-save] encode failed for disk {} ({}) — skipping", uuid, t.getMessage());
+ }
+ }
+ if (!toSave.isEmpty()) {
+ saveBackpackSnapshots(toSave);
+ PlayerSync.LOGGER.info("Saved {} RS2 disk(s) via direct codec (player-scoped)", toSave.size());
+ return;
+ }
+ }
+
+ // Fallback: legacy sd.save() if codec path fails or produced nothing.
+ if (!(repo instanceof net.minecraft.world.level.saveddata.SavedData sd)) return;
+ PlayerSync.LOGGER.debug("[rs2-save] codec path empty, falling back to sd.save() for {} disk(s)", diskUuids.size());
+ net.minecraft.nbt.CompoundTag fullNbt = sd.save(new net.minecraft.nbt.CompoundTag(), registryAccess);
for (UUID uuid : diskUuids) {
net.minecraft.nbt.CompoundTag entryNbt = findRS2EntryInNbt(fullNbt, uuid.toString());
if (entryNbt != null && !entryNbt.isEmpty()) {
@@ -1168,8 +1251,8 @@ public class ModsSupport {
}
}
if (!toSave.isEmpty()) {
- saveBackpackSnapshots(toSave); // shared batched writer (backpack_data table)
- PlayerSync.LOGGER.info("Saved {} RS2 disk(s) in one batch", toSave.size());
+ saveBackpackSnapshots(toSave);
+ PlayerSync.LOGGER.info("Saved {} RS2 disk(s) via legacy full-save fallback", toSave.size());
}
} catch (Exception e) {
PlayerSync.LOGGER.error("Error saving RS2 disks by level", e);
@@ -1210,16 +1293,12 @@ public class ModsSupport {
com.refinedmods.refinedstorage.common.api.storage.StorageRepository repo =
com.refinedmods.refinedstorage.common.api.RefinedStorageApi.INSTANCE.getStorageRepository(sp.serverLevel());
- // Get the map codec via reflection (same codec used for save)
+ // PHASE 16: use the shared codec cache (same one saveRS2DisksByLevel uses).
+ // Saves reflection cost on every player join.
@SuppressWarnings("rawtypes")
- com.mojang.serialization.Codec mapCodec;
- try {
- java.lang.reflect.Method getMapCodecMethod =
- repo.getClass().getDeclaredMethod("createCodec", Runnable.class);
- getMapCodecMethod.setAccessible(true);
- mapCodec = (com.mojang.serialization.Codec) getMapCodecMethod.invoke(null, (Runnable) () -> {});
- } catch (Exception e) {
- PlayerSync.LOGGER.error("Cannot get RS2 map codec, disk restore will fail", e);
+ com.mojang.serialization.Codec mapCodec = getOrCreateRS2MapCodec(repo);
+ if (mapCodec == null) {
+ PlayerSync.LOGGER.error("Cannot get RS2 map codec, disk restore will fail");
return;
}