diff --git a/src/main/java/com/modularmc/synceddata/SyncedData.java b/src/main/java/com/modularmc/synceddata/SyncedData.java index fee5cec..a74b4c2 100644 --- a/src/main/java/com/modularmc/synceddata/SyncedData.java +++ b/src/main/java/com/modularmc/synceddata/SyncedData.java @@ -1,6 +1,8 @@ package com.modularmc.synceddata; import com.modularmc.synceddata.api.sync_system.SyncedComponents; +import com.modularmc.synceddata.api.sync_system.network.ClientBlockEntitySyncPayload; +import com.modularmc.synceddata.api.sync_system.network.ServerBlockEntitySyncPayload; import com.modularmc.synceddata.utils.FormattingUtil; import net.minecraft.client.Minecraft; @@ -12,6 +14,8 @@ import net.neoforged.fml.loading.FMLEnvironment; import net.neoforged.fml.loading.FMLPaths; import net.neoforged.neoforge.data.loading.DatagenModLoader; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; import net.neoforged.neoforge.server.ServerLifecycleHooks; import com.mojang.serialization.Codec; @@ -34,6 +38,15 @@ public class SyncedData { public SyncedData(IEventBus bus) { SyncedComponents.COMPONENTS.register(bus); + bus.addListener(SyncedData::registerPayloadHandlers); + } + + private static void registerPayloadHandlers(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar(MOD_ID); + registrar.playToClient(ServerBlockEntitySyncPayload.TYPE, ServerBlockEntitySyncPayload.CODEC, + ServerBlockEntitySyncPayload::execute); + registrar.playToServer(ClientBlockEntitySyncPayload.TYPE, ClientBlockEntitySyncPayload.CODEC, + ClientBlockEntitySyncPayload::execute); } public static Identifier id(String path) { diff --git a/src/main/java/com/modularmc/synceddata/api/sync_system/ManagedSyncBlockEntity.java b/src/main/java/com/modularmc/synceddata/api/sync_system/ManagedSyncBlockEntity.java index cc27fd2..f258732 100644 --- a/src/main/java/com/modularmc/synceddata/api/sync_system/ManagedSyncBlockEntity.java +++ b/src/main/java/com/modularmc/synceddata/api/sync_system/ManagedSyncBlockEntity.java @@ -2,6 +2,8 @@ import com.modularmc.synceddata.api.blockentity.BlockEntityCreationInfo; import com.modularmc.synceddata.api.sync_system.holder.SyncDataHolder; +import com.modularmc.synceddata.api.sync_system.network.ClientBlockEntitySyncPayload; +import com.modularmc.synceddata.api.sync_system.network.ServerBlockEntitySyncPayload; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.core.BlockPos; @@ -13,12 +15,14 @@ import net.minecraft.network.protocol.game.ClientGamePacketListener; import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.storage.ValueInput; import net.minecraft.world.level.storage.ValueOutput; +import net.neoforged.neoforge.client.network.ClientPacketDistributor; +import net.neoforged.neoforge.network.PacketDistributor; import com.mojang.serialization.MapCodec; import lombok.Getter; @@ -105,13 +109,26 @@ public final void updateTick() { setChanged(); if (getLevel() instanceof ServerLevel serverLevel) { if (syncDataHolder.scanAndMarkChanges(serverLevel.registryAccess())) { - serverLevel.sendBlockUpdated(getBlockPos(), getBlockState(), getBlockState(), - Block.UPDATE_CLIENTS); + byte[] data = syncDataHolder.collectClientNetworkChanges(serverLevel.registryAccess(), false); + if (data.length > 0) { + PacketDistributor.sendToPlayersTrackingChunk(serverLevel, + new ChunkPos(getBlockPos().getX() >> 4, getBlockPos().getZ() >> 4), + ServerBlockEntitySyncPayload.of(this, data)); + } } } } - public final void handleClientUpdate(HolderLookup.Provider registries, CompoundTag tag) { - syncDataHolder.applyServerUpdate(registries, tag); + public final void handleClientUpdate(net.minecraft.core.RegistryAccess registries, byte[] data) { + syncDataHolder.applyServerNetworkUpdate(registries, data); + } + + public final void pushClientChangesToServer() { + if (getLevel() instanceof ClientLevel clientLevel) { + byte[] changes = syncDataHolder.collectServerNetworkChanges(clientLevel.registryAccess()); + if (changes.length > 0) { + ClientPacketDistributor.sendToServer(new ClientBlockEntitySyncPayload(getBlockPos().asLong(), changes)); + } + } } } diff --git a/src/main/java/com/modularmc/synceddata/api/sync_system/holder/SyncDataHolder.java b/src/main/java/com/modularmc/synceddata/api/sync_system/holder/SyncDataHolder.java index f369996..1c75d31 100644 --- a/src/main/java/com/modularmc/synceddata/api/sync_system/holder/SyncDataHolder.java +++ b/src/main/java/com/modularmc/synceddata/api/sync_system/holder/SyncDataHolder.java @@ -7,13 +7,18 @@ import com.modularmc.synceddata.api.sync_system.meta.FieldSyncData; import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; import net.minecraft.nbt.NbtOps; import net.minecraft.nbt.Tag; +import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.world.item.ItemStack; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; +import io.netty.buffer.Unpooled; +import it.unimi.dsi.fastutil.objects.Reference2ReferenceOpenHashMap; import java.util.*; @@ -22,7 +27,7 @@ public class SyncDataHolder { private final ClassSyncData syncData; private final ISyncManaged holder; - private final Map cachedValues = new HashMap<>(); + private final Map cachedValues = new Reference2ReferenceOpenHashMap<>(); private CompoundTag pendingClientChanges = null; private boolean fullSyncPending = true; @@ -30,7 +35,7 @@ public SyncDataHolder(ISyncManaged o) { holder = o; syncData = ClassSyncData.getClassData(o.getClass()); for (FieldSyncData field : syncData.getClientSyncFields()) { - cachedValues.put(field.fieldName, field.handle.get(holder)); + cachedValues.put(field, field.handle.get(holder)); } } @@ -44,12 +49,12 @@ public boolean scanAndMarkChanges(HolderLookup.Provider registries) { for (FieldSyncData field : syncData.getClientSyncFields()) { Object currentValue = field.handle.get(holder); - Object previousValue = cachedValues.get(field.fieldName); + Object previousValue = cachedValues.get(field); boolean changed = fullSyncPending || !Objects.equals(currentValue, previousValue); if (changed) { Tag serialized = encodeField(field, currentValue, registries); changes.put(field.nbtSaveKey, serialized); - cachedValues.put(field.fieldName, currentValue); + cachedValues.put(field, currentValue); hasChanges = true; } } @@ -67,6 +72,84 @@ public CompoundTag getPendingChanges() { return changes != null ? changes : new CompoundTag(); } + public byte[] collectClientNetworkChanges(RegistryAccess registries, boolean force) { + if (force) { + CompoundTag forcedChanges = new CompoundTag(); + for (FieldSyncData field : syncData.getClientSyncFields()) { + Object currentValue = field.handle.get(holder); + forcedChanges.put(field.nbtSaveKey, encodeField(field, currentValue, registries)); + cachedValues.put(field, currentValue); + } + pendingClientChanges = forcedChanges; + fullSyncPending = false; + } + + CompoundTag pendingChanges = getPendingChanges(); + if (pendingChanges.isEmpty()) { + return new byte[0]; + } + + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), registries); + try { + FieldSyncData[] fields = syncData.getOrderedClientSyncFields(); + for (int i = 0; i < fields.length; i++) { + FieldSyncData field = fields[i]; + Tag value = pendingChanges.get(field.nbtSaveKey); + if (value == null) { + continue; + } + buf.writeVarInt(i); + buf.writeNbt(value); + } + byte[] data = new byte[buf.readableBytes()]; + buf.getBytes(0, data); + return data; + } finally { + buf.release(); + } + } + + public CompoundTag collectServerChanges(HolderLookup.Provider registries) { + CompoundTag changes = new CompoundTag(); + for (FieldSyncData field : syncData.getServerUpdateFields()) { + Object currentValue = field.handle.get(holder); + Object previousValue = cachedValues.get(field); + if (!Objects.equals(currentValue, previousValue)) { + changes.put(field.fieldName, encodeField(field, currentValue, registries)); + cachedValues.put(field, currentValue); + } + } + return changes; + } + + public byte[] collectServerNetworkChanges(RegistryAccess registries) { + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), registries); + try { + boolean wroteAny = false; + FieldSyncData[] fields = syncData.getOrderedServerUpdateFields(); + for (int i = 0; i < fields.length; i++) { + FieldSyncData field = fields[i]; + Object currentValue = field.handle.get(holder); + Object previousValue = cachedValues.get(field); + if (Objects.equals(currentValue, previousValue)) { + continue; + } + buf.writeVarInt(i); + buf.writeNbt(encodeField(field, currentValue, registries)); + cachedValues.put(field, currentValue); + wroteAny = true; + } + if (!wroteAny) { + return new byte[0]; + } + byte[] data = new byte[buf.readableBytes()]; + buf.getBytes(0, data); + return data; + } finally { + buf.release(); + } + } + public CompoundTag serializeToSaveNBT(HolderLookup.Provider registries) { CompoundTag tag = new CompoundTag(); for (var field : syncData.getWorldSaveFields()) { @@ -93,7 +176,7 @@ public CompoundTag serializeFullClientSyncNBT(HolderLookup.Provider registries) Object value = field.handle.get(holder); Tag serialized = encodeField(field, value, registries); tag.put(field.nbtSaveKey, serialized); - cachedValues.put(field.fieldName, value); + cachedValues.put(field, value); } fullSyncPending = false; return tag; @@ -113,7 +196,7 @@ public void deserializeNBT(HolderLookup.Provider registries, CompoundTag tag, bo } if (readingClientFields) { - cachedValues.put(field.fieldName, field.handle.get(holder)); + cachedValues.put(field, field.handle.get(holder)); for (var listener : field.changeListenerHandles) { try { listener.invoke(holder); @@ -140,11 +223,7 @@ public void deserializeItemNBT(HolderLookup.Provider registries, CompoundTag tag } public void applyServerUpdate(HolderLookup.Provider registries, CompoundTag tag) { - Set targetFields = new HashSet<>(); - targetFields.addAll(syncData.getServerSyncFields()); - targetFields.addAll(syncData.getBothSyncFields()); - - for (var field : targetFields) { + for (var field : syncData.getServerUpdateFields()) { Tag value = tag.get(field.fieldName); if (value != null) { Object decoded = decodeField(field, value, field.handle.get(holder), registries); @@ -155,6 +234,70 @@ public void applyServerUpdate(HolderLookup.Provider registries, CompoundTag tag) } } + public void applyServerNetworkUpdate(RegistryAccess registries, byte[] data) { + if (data.length == 0) { + return; + } + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(Unpooled.wrappedBuffer(data), registries); + try { + FieldSyncData[] fields = syncData.getOrderedServerUpdateFields(); + while (buf.isReadable()) { + int index = buf.readVarInt(); + if (index < 0 || index >= fields.length) { + throw new IllegalArgumentException("Invalid server sync field index: " + index); + } + FieldSyncData field = fields[index]; + Tag value = buf.readNbt(NbtAccounter.unlimitedHeap()); + if (value != null) { + Object decoded = decodeField(field, value, field.handle.get(holder), registries); + if (decoded != null) { + field.handle.set(holder, decoded); + } + } + } + } finally { + buf.release(); + } + } + + public void applyClientNetworkUpdate(RegistryAccess registries, byte[] data) { + if (data.length == 0) { + return; + } + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(Unpooled.wrappedBuffer(data), registries); + try { + FieldSyncData[] fields = syncData.getOrderedClientSyncFields(); + while (buf.isReadable()) { + int index = buf.readVarInt(); + if (index < 0 || index >= fields.length) { + throw new IllegalArgumentException("Invalid client sync field index: " + index); + } + FieldSyncData field = fields[index]; + Tag value = buf.readNbt(NbtAccounter.unlimitedHeap()); + if (value != null) { + Object decoded = decodeField(field, value, field.handle.get(holder), registries); + if (decoded != null) { + field.handle.set(holder, decoded); + } + cachedValues.put(field, field.handle.get(holder)); + for (var listener : field.changeListenerHandles) { + try { + listener.invoke(holder); + } catch (Throwable e) { + SyncedData.LOGGER.error("Sync: Error invoking change listener for field {}", field.fieldName); + SyncedData.LOGGER.error(e); + } + } + if (field.triggerClientRerender) { + holder.scheduleRenderUpdate(); + } + } + } + } finally { + buf.release(); + } + } + public void applyToItemStack(ItemStack stack, HolderLookup.Provider registries) { CompoundTag data = serializeToItemNBT(registries); if (!data.isEmpty()) { @@ -176,7 +319,6 @@ private Tag encodeField(FieldSyncData field, Object value, HolderLookup.Provider return nullTag; } if (field.codec != null) { - @SuppressWarnings("unchecked") Codec codec = field.codec; DataResult result = codec.encodeStart(registries.createSerializationContext(NbtOps.INSTANCE), value); return result.getOrThrow(); @@ -196,7 +338,6 @@ private Object decodeField(FieldSyncData field, Tag tag, Object currentValue, Ho return currentValue; } if (field.codec != null) { - @SuppressWarnings("unchecked") Codec codec = field.codec; DataResult result = codec.parse(registries.createSerializationContext(NbtOps.INSTANCE), tag); return result.getOrThrow(); diff --git a/src/main/java/com/modularmc/synceddata/api/sync_system/network/ClientBlockEntitySyncPayload.java b/src/main/java/com/modularmc/synceddata/api/sync_system/network/ClientBlockEntitySyncPayload.java new file mode 100644 index 0000000..28f85d8 --- /dev/null +++ b/src/main/java/com/modularmc/synceddata/api/sync_system/network/ClientBlockEntitySyncPayload.java @@ -0,0 +1,48 @@ +package com.modularmc.synceddata.api.sync_system.network; + +import com.modularmc.synceddata.SyncedData; +import com.modularmc.synceddata.api.sync_system.ManagedSyncBlockEntity; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.network.handling.IPayloadContext; + +import org.jspecify.annotations.NonNull; + +public record ClientBlockEntitySyncPayload(long pos, byte[] data) implements CustomPacketPayload { + + public static final Identifier ID = SyncedData.id("client_be_sync"); + public static final Type TYPE = new Type<>(ID); + public static final StreamCodec CODEC = StreamCodec.ofMember( + ClientBlockEntitySyncPayload::write, + ClientBlockEntitySyncPayload::decode); + + @Override + public @NonNull Type type() { + return TYPE; + } + + private void write(RegistryFriendlyByteBuf buf) { + buf.writeLong(pos); + buf.writeByteArray(data); + } + + private static ClientBlockEntitySyncPayload decode(RegistryFriendlyByteBuf buf) { + return new ClientBlockEntitySyncPayload(buf.readLong(), buf.readByteArray()); + } + + public static void execute(ClientBlockEntitySyncPayload packet, IPayloadContext context) { + if (!(context.player() instanceof ServerPlayer serverPlayer)) { + return; + } + if (!(serverPlayer.level().getBlockEntity(BlockPos.of(packet.pos)) instanceof ManagedSyncBlockEntity blockEntity)) { + return; + } + blockEntity.handleClientUpdate(serverPlayer.level().registryAccess(), packet.data); + blockEntity.markAsChanged(); + } +} diff --git a/src/main/java/com/modularmc/synceddata/api/sync_system/network/ServerBlockEntitySyncPayload.java b/src/main/java/com/modularmc/synceddata/api/sync_system/network/ServerBlockEntitySyncPayload.java new file mode 100644 index 0000000..4357ae2 --- /dev/null +++ b/src/main/java/com/modularmc/synceddata/api/sync_system/network/ServerBlockEntitySyncPayload.java @@ -0,0 +1,53 @@ +package com.modularmc.synceddata.api.sync_system.network; + +import com.modularmc.synceddata.SyncedData; +import com.modularmc.synceddata.api.sync_system.ManagedSyncBlockEntity; + +import net.minecraft.client.Minecraft; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.neoforged.neoforge.network.handling.IPayloadContext; + +import org.jspecify.annotations.NonNull; + +public record ServerBlockEntitySyncPayload(long pos, byte[] data) implements CustomPacketPayload { + + public static final Identifier ID = SyncedData.id("server_be_sync"); + public static final Type TYPE = new Type<>(ID); + public static final StreamCodec CODEC = StreamCodec.ofMember( + ServerBlockEntitySyncPayload::write, + ServerBlockEntitySyncPayload::decode); + + @Override + public @NonNull Type type() { + return TYPE; + } + + private void write(RegistryFriendlyByteBuf buf) { + buf.writeLong(pos); + buf.writeByteArray(data); + } + + private static ServerBlockEntitySyncPayload decode(RegistryFriendlyByteBuf buf) { + return new ServerBlockEntitySyncPayload(buf.readLong(), buf.readByteArray()); + } + + public static void execute(ServerBlockEntitySyncPayload packet, IPayloadContext context) { + var level = Minecraft.getInstance().level; + if (level == null) { + return; + } + if (!(level.getBlockEntity(BlockPos.of(packet.pos)) instanceof ManagedSyncBlockEntity blockEntity)) { + return; + } + blockEntity.getSyncDataHolder().applyClientNetworkUpdate(level.registryAccess(), packet.data); + } + + public static ServerBlockEntitySyncPayload of(BlockEntity blockEntity, byte[] data) { + return new ServerBlockEntitySyncPayload(blockEntity.getBlockPos().asLong(), data); + } +} diff --git a/src/test/java/com/modularmc/synceddata/test/SyncAnnotationTest.java b/src/test/java/com/modularmc/synceddata/test/SyncAnnotationTest.java index a4063ee..ea8e318 100644 --- a/src/test/java/com/modularmc/synceddata/test/SyncAnnotationTest.java +++ b/src/test/java/com/modularmc/synceddata/test/SyncAnnotationTest.java @@ -77,6 +77,51 @@ public static void noChange(GameTestHelper helper) { helper.succeed(); } + @TestHolder(value = "sync_server_update") + @EmptyTemplate("3") + @GameTest + public static void serverUpdate(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + var blockEntity = new SyncTestFixtures.TestBlockEntity(); + blockEntity.target = 77; + blockEntity.block = net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", "stone"); + blockEntity.active = true; + + byte[] changes = blockEntity.getSyncDataHolder().collectServerNetworkChanges(registries); + check(helper, changes.length > 0, "server network change missing"); + + var restored = new SyncTestFixtures.TestBlockEntity(); + restored.getSyncDataHolder().applyServerNetworkUpdate(registries, changes); + check(helper, restored.target == 77, "target not applied"); + check(helper, net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", "stone").equals(restored.block), + "block not applied"); + check(helper, !restored.active, "client field should stay untouched"); + helper.succeed(); + } + + @TestHolder(value = "sync_client_network_update") + @EmptyTemplate("3") + @GameTest + public static void clientNetworkUpdate(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + var blockEntity = new SyncTestFixtures.TestBlockEntity(); + blockEntity.active = true; + blockEntity.lastTime = 99L; + blockEntity.target = 12; + + check(helper, blockEntity.getSyncDataHolder().scanAndMarkChanges(registries), "scan should detect client changes"); + byte[] changes = blockEntity.getSyncDataHolder().collectClientNetworkChanges(registries, false); + check(helper, changes.length > 0, "client network change missing"); + + var restored = new SyncTestFixtures.TestBlockEntity(); + restored.getSyncDataHolder().applyClientNetworkUpdate(registries, changes); + check(helper, restored.active, "active not applied"); + check(helper, restored.lastTime == 99L, "lastTime not applied"); + check(helper, restored.target == 12, "target not applied"); + check(helper, restored.callbacks.size() == 3, "listeners not invoked"); + helper.succeed(); + } + @TestHolder(value = "sync_invalid_listener") @EmptyTemplate("3") @GameTest