From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Samsuik <40902469+Samsuik@users.noreply.github.com> Date: Sat, 9 Sep 2023 18:39:15 +0100 Subject: [PATCH] Merge Cannon Entities diff --git a/src/main/java/me/samsuik/sakura/entity/merge/MergeHistory.java b/src/main/java/me/samsuik/sakura/entity/merge/MergeHistory.java new file mode 100644 index 0000000000000000000000000000000000000000..794547b36f0780b4dd300fc162cd9b7018c38edb --- /dev/null +++ b/src/main/java/me/samsuik/sakura/entity/merge/MergeHistory.java @@ -0,0 +1,155 @@ +package me.samsuik.sakura.entity.merge; + +import it.unimi.dsi.fastutil.HashCommon; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.longs.LongSet; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.entity.Entity; + +public class MergeHistory { + + // packed position -> known merging information + private final Long2ObjectMap mergeDataMap = new Long2ObjectOpenHashMap<>(); + private MergeData mergeData = null; + + public MergeData retrievePositions(Entity entity) { + var origin = entity.getPackedOrigin(); + + if (mergeData != null && mergeData.knownPositions().contains(origin)) { + return mergeData; + } + + return mergeData = mergeDataMap.get(origin); + } + + public void markPositions(Entity entity) { + var mergeList = entity.getMergeList(); + var origin = entity.getPackedOrigin(); + + // I apologise for the lambda parameter name in advance + var data = mergeDataMap.computeIfAbsent(origin, (OwO) -> new MergeData( + // Known entity positions that have been able to merge + // This is used for non-strict merging. + new LongOpenHashSet(), + // First copy of the previous positions that is retained. + // This is used for on spawn (aot) merging. + // Retaining means if the collection you're comparing doesn't the same elements the rest gets yeeted. + // We also make use of a _reasonable_ threshold before on spawn merging to reduce abuse and breakage. + new LongOpenHashSet(), + new EntityTable(Math.min(mergeList.size() * 2, 512)), + // todo: allow configuring expiry and threshold + new Expiry(MinecraftServer.currentTickLong, 200), + new Threshold(MinecraftServer.currentTickLong, 12, 200) + )); + + // Refresh expiry + data.expiry().refresh(MinecraftServer.currentTickLong); + + var insert = data.knownPositions().isEmpty(); + var positions = new LongOpenHashSet((mergeList.size() + 1) / 2); + + positions.add(entity.getPackedOrigin()); + + for (var mergedEntity : mergeList) { + positions.add(mergedEntity.getPackedOrigin()); + } + + // todo: if tnt spread is enabled double the threshold above then make the first half of the threshold inserting known positions. + // ^ This can allow better merging of randomised tnt for the compromise of it taking longer to merge on spawn. + // ^ There is an uncommon design that uses a single booster at the back and pushes all the tnt forward. + // ^ Using a chest as an offset means tnt alignment doesn't matter so people get away with spread but can make merging difficult. + if (insert) { + data.retainedPositions().addAll(positions); + } else { + data.retainedPositions().retainAll(positions); + } + + data.knownPositions().addAll(positions); + } + + public void expire(long tick) { + // clear this every tick + mergeData = null; + + // only expire every 20 ticks + if (tick % 20 != 0) return; + + // using a linked hashmap isn't applicable here as an optimisation + // because we allow the spawn positions to "refresh" this would create a memory leak + mergeDataMap.values().removeIf((data) -> data.expiry().isExpired(tick)); + } + + public record MergeData(LongSet knownPositions, LongSet retainedPositions, EntityTable table, Expiry expiry, Threshold threshold) { + public boolean hasPassed() { + return threshold.hasPassed(MinecraftServer.currentTickLong); + } + + public Entity findFirstAtPosition(Entity entity) { + var found = table.locate(entity); + + if (found != null && found.getId() < entity.getId() && knownPositions.contains(found.getPackedOrigin()) && !found.isRemoved() && entity.compareState(found)) { + return found; + } + + return null; + } + } + + private static class EntityTable { + private final Entity[] entities; + private final int mask; + + EntityTable(int size) { + var n = HashCommon.nextPowerOfTwo(size - 1); + entities = new Entity[n]; + mask = n - 1; + } + + Entity locate(Entity entity) { + var pos = entity.blockPosition().hashCode(); + var key = pos & mask; + var found = entities[key]; + entities[key] = entity; + return found; + } + } + + private static class Threshold { + private final long existence; // tick when this was created + private final int thresholdAttempts; + private final long thresholdAge; + private int attempts; + + Threshold(long tick, int attempts, long age) { + existence = tick; + thresholdAttempts = attempts; + thresholdAge = age; + } + + boolean hasPassed(long tick) { + return ++attempts >= thresholdAttempts + || tick - existence >= thresholdAge; + } + } + + private static class Expiry { + private long expireAt; + private final int inc; + + Expiry(long tick, int inc) { + expireAt = tick + inc; + this.inc = inc; + } + + void refresh(long tick) { + expireAt = tick + inc; + } + + boolean isExpired(long tick) { + return tick >= expireAt; + } + } + +} diff --git a/src/main/java/net/minecraft/server/MinecraftServer.java b/src/main/java/net/minecraft/server/MinecraftServer.java index eef411f838ecdeff0d4052fac22900e4ad87ceb5..4bc68b3145f42f5a432e1e897b3f41606735afd1 100644 --- a/src/main/java/net/minecraft/server/MinecraftServer.java +++ b/src/main/java/net/minecraft/server/MinecraftServer.java @@ -1579,6 +1579,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop { if (!entity.isRemoved()) { if (false && this.shouldDiscardEntity(entity)) { // CraftBukkit - We prevent spawning in general, so this butchering is not needed @@ -896,6 +897,15 @@ public class ServerLevel extends Level implements WorldGenLevel { entity.stopRiding(); } + // Sakura start + var previous = previousEntity[0]; + if (entity.isMergeableType(previous) && entity.tryMergeInto(previous)) { + return; + } else { + previousEntity[0] = entity; + } + // Sakura end + gameprofilerfiller.push("tick"); this.guardEntityTick(this::tickNonPassenger, entity); gameprofilerfiller.pop(); diff --git a/src/main/java/net/minecraft/world/entity/Entity.java b/src/main/java/net/minecraft/world/entity/Entity.java index 3757e3e77a567187e0f9cb60cb10a95bec330693..31e9e180ecddefc99d0984e793682f40258af3fb 100644 --- a/src/main/java/net/minecraft/world/entity/Entity.java +++ b/src/main/java/net/minecraft/world/entity/Entity.java @@ -559,6 +559,105 @@ public abstract class Entity implements Nameable, EntityAccess, CommandSource { return BlockPos.asLong(v.getBlockX(), v.getBlockY(), v.getBlockZ()); } // Sakura end + // Sakura start - cannon entity merging + // List of merged entities, should be naturally sorted (oldest -> youngest) + private final List mergeList = new java.util.ArrayList<>(1); + private @Nullable me.samsuik.sakura.entity.merge.MergeHistory.MergeData originData = null; + private me.samsuik.sakura.entity.merge.MergeLevel mergeLevel; + protected int stacked = 1; // default + + public final me.samsuik.sakura.entity.merge.MergeLevel getMergeLevel() { + return mergeLevel; + } + + public final void setMergeLevel(me.samsuik.sakura.entity.merge.MergeLevel level) { + mergeLevel = level; + } + + public final int getStacked() { + return stacked; + } + + public final void setStacked(int stack) { + stacked = stack; + } + + public List getMergeList() { + return mergeList; + } + + private boolean isSafeToSpawnMerge(Entity entity) { + return tickCount == 1 && originData != null + && originData.hasPassed() // on spawn safety delay has passed + && originData == entity.originData // make sure it's the same group + && originData.retainedPositions().contains(entity.getPackedOrigin()); + } + + public boolean isMergeableType(@Nullable Entity previous) { + return false; + } + + public final boolean tryMergeInto(@Nullable Entity entity) { + if (mergeLevel.atLeast(me.samsuik.sakura.entity.merge.MergeLevel.NON_STRICT) && tickCount == 0) { + originData = level.mergeHistory.retrievePositions(this); + } + + Entity mergeEntity = null; + + if (entity == null || entity.getType() != getType()) { + // first entity in the tick loop, we have to let it into this method so that we can retrieve the originData + return false; + } else if (mergeLevel.atLeast(me.samsuik.sakura.entity.merge.MergeLevel.SPAWN) && entity.isSafeToSpawnMerge(this)) { + // On spawn merging, this merges entities immediately upon spawning after + // it is considered "safe". We try to make sure it is safe by only retaining + // positions that do not change when we're collecting information. + mergeEntity = entity; + } else { + // Strict, simple merging + // This merges entities that are in the exact same state and sequential. + // Sane for most use cases but as it is merging entities plugins may misbehave. + if (mergeLevel.atLeast(me.samsuik.sakura.entity.merge.MergeLevel.STRICT) && compareState(entity)) { + mergeEntity = entity; + } + + // Non strict merging algorithm uses information collected after entities die + // to be able to perform more aggressive merging by already knowing the OOE. + if (mergeLevel.atLeast(me.samsuik.sakura.entity.merge.MergeLevel.NON_STRICT) && mergeEntity == null && originData != null) { + mergeEntity = originData.findFirstAtPosition(this); + } + } + + if (mergeEntity != null && isSafeToMergeInto(mergeEntity)) { + mergeInto(mergeEntity); + return true; + } + + return false; + } + + protected void respawn() {} + + protected boolean isSafeToMergeInto(Entity entity) { + return false; + } + + private void mergeInto(Entity entity) { + entity.mergeList.add(this); + entity.mergeList.addAll(mergeList); + entity.stacked += stacked; + + mergeList.clear(); // clear the list to stop our tracking when merging + stacked = 0; // prevent any possible duplication + + // update api handle, this is so cannondebug can function + //noinspection ConstantValue + if (bukkitEntity != null) { + bukkitEntity.setHandle(entity); + } + + discard(); + } + // Sakura end public Entity(EntityType type, Level world) { this.id = Entity.ENTITY_COUNTER.incrementAndGet(); @@ -607,6 +706,7 @@ public abstract class Entity implements Nameable, EntityAccess, CommandSource { this.getEntityData().registrationLocked = true; // Spigot this.setPos(0.0D, 0.0D, 0.0D); this.eyeHeight = this.getEyeHeight(net.minecraft.world.entity.Pose.STANDING, this.dimensions); + this.mergeLevel = level.sakuraConfig().cannons.mergeLevel; // Sakura } public boolean isColliding(BlockPos pos, BlockState state) { @@ -2454,6 +2554,11 @@ public abstract class Entity implements Nameable, EntityAccess, CommandSource { nbt.putBoolean("Paper.FreezeLock", true); } // Paper end + // Sakura start + if (stacked > 0) { + nbt.putInt("Sakura.Stacked", stacked); + } + // Sakura end return nbt; } catch (Throwable throwable) { CrashReport crashreport = CrashReport.forThrowable(throwable, "Saving entity NBT"); @@ -2622,6 +2727,11 @@ public abstract class Entity implements Nameable, EntityAccess, CommandSource { freezeLocked = nbt.getBoolean("Paper.FreezeLock"); } // Paper end + // Sakura start + if (nbt.contains("Sakura.Stacked")) { + stacked = nbt.getInt("Sakura.Stacked"); + } + // Sakura end } catch (Throwable throwable) { CrashReport crashreport = CrashReport.forThrowable(throwable, "Loading entity NBT"); @@ -4784,6 +4894,11 @@ public abstract class Entity implements Nameable, EntityAccess, CommandSource { return; } // Paper end - rewrite chunk system + // Sakura start + if (reason == RemovalReason.DISCARDED && !mergeList.isEmpty()) { + level.mergeHistory.markPositions(this); + } + // Sakura end final boolean alreadyRemoved = this.removalReason != null; if (this.removalReason == null) { this.removalReason = reason; diff --git a/src/main/java/net/minecraft/world/entity/item/FallingBlockEntity.java b/src/main/java/net/minecraft/world/entity/item/FallingBlockEntity.java index 2e0f38ffad09b6afbea74205b89beb774e779545..2afcb3ebdfba545d7c1d73fd0aed486c1f8bf6ae 100644 --- a/src/main/java/net/minecraft/world/entity/item/FallingBlockEntity.java +++ b/src/main/java/net/minecraft/world/entity/item/FallingBlockEntity.java @@ -132,6 +132,58 @@ public class FallingBlockEntity extends Entity { return !this.isRemoved(); } + // Sakura start - cannon entity merging + @Override + public boolean isMergeableType(@Nullable Entity previous) { + return previous == null || !isRemoved() && !previous.isRemoved(); + } + + @Override + protected boolean isSafeToMergeInto(Entity entity) { + return entity instanceof FallingBlockEntity fbe + && fbe.blockState.equals(blockState) + && fbe.time - 1 == time; // todo: special case in case on spawn isn't used + } + + @Override + protected void respawn() { + while (stacked-- > 1) { + // create a temporary falling block entity + var fallingBlock = new FallingBlockEntity(EntityType.FALLING_BLOCK, level()); + + // use our the previous state + entityState().apply(fallingBlock); + fallingBlock.time = time - 1; + + // and tick + fallingBlock.tick(); + + // Well, this can actually happen. + // If you horizontal or rectangle stack sand into a b36 this condition will be met. + // This could break some suspicious render queuing setups relying on horizontal stacking + // and keeping sand in b36 using pistons pushing back and forth. + if (!fallingBlock.isRemoved()) { + fallingBlock.stacked = stacked; + level().addFreshEntity(fallingBlock); + break; + } + } + } + + @Nullable + public ItemEntity spawnAtLocation(ItemLike item) { + // This is to prevent sand continuing to respawn incase it broke. + ItemEntity itemEntity = null; + + for (int i = 0; i < stacked; ++i) { + itemEntity = super.spawnAtLocation(item); + } + + stacked = 1; + return itemEntity; + } + // Sakura end + @Override public void tick() { // Paper start - fix sand duping @@ -214,6 +266,7 @@ public class FallingBlockEntity extends Entity { if (this.level().setBlock(blockposition, this.blockState, 3)) { ((ServerLevel) this.level()).getChunkSource().chunkMap.broadcast(this, new ClientboundBlockUpdatePacket(blockposition, this.level().getBlockState(blockposition))); this.discard(); + this.respawn(); // Sakura if (block instanceof Fallable) { ((Fallable) block).onLand(this.level(), blockposition, this.blockState, iblockdata, this); } diff --git a/src/main/java/net/minecraft/world/entity/item/PrimedTnt.java b/src/main/java/net/minecraft/world/entity/item/PrimedTnt.java index fbeb52a49b791f992af19c7d69ba44b820541b09..02ef6ca32f3de52e921fdcf3f0f572ce7afef318 100644 --- a/src/main/java/net/minecraft/world/entity/item/PrimedTnt.java +++ b/src/main/java/net/minecraft/world/entity/item/PrimedTnt.java @@ -63,6 +63,60 @@ public class PrimedTnt extends Entity implements TraceableEntity { return !this.isRemoved(); } + // Sakura start - cannon entity merging + @Override + public boolean isMergeableType(@Nullable Entity previous) { + return previous == null || !isRemoved() && !previous.isRemoved(); + } + + @Override + protected boolean isSafeToMergeInto(Entity entity) { + return entity instanceof PrimedTnt tnt + && tnt.getFuse() + 1 == getFuse() + // required to prevent issues with powdered snow + && (tnt.entityState().fallDistance() == 0.0f && fallDistance == 0.0f + || tnt.entityState().fallDistance() > 2.5f && fallDistance > 2.5f); + } + + @Override + protected void respawn() { + if (stacked <= 1) return; + + // we create a temporary entity that will be affected by each explosion + // this allows us to only keep one entity in the world in an attempt to + // minimise complexity of stacked tnt explosions. + var tnt = new PrimedTnt(level(), 0, 0, 0, owner); + + // Copy our pre-tick state to the temporary entity + entityState().apply(tnt); + + // add the entity to the world and chunk + level().addFreshEntity(tnt); + + // Some bad plugins may change tnt momentum while we are respawning + // ex: a plugin that sets tnt momentum to 0 upon spawning + tnt.setDeltaMovement(entityState().momentum()); + + for (int i = stacked - 1; i >= 1; --i) { + // make sure this entity cannot explode unexpectedly + setFuse(100); + stacked = 0; + + // explode! + explode(); + + // clone state from temporary entity + tnt.storeEntityState(); + tnt.entityState().apply(this); + + // tick, this is only to move the entity and apply physics. + tick(); + } + + tnt.discard(); + } + // Sakura end + @Override public void tick() { if (this.level().spigotConfig.maxTntTicksPerTick > 0 && ++this.level().spigotConfig.currentPrimedTnt > this.level().spigotConfig.maxTntTicksPerTick) { return; } // Spigot @@ -88,6 +142,7 @@ public class PrimedTnt extends Entity implements TraceableEntity { if (i <= 0) { // CraftBukkit start - Need to reverse the order of the explosion and the entity death so we have a location for the event // this.discard(); + this.respawn(); // Sakura if (!this.level().isClientSide) { this.explode(); } diff --git a/src/main/java/net/minecraft/world/level/Level.java b/src/main/java/net/minecraft/world/level/Level.java index 6d9a15a1c1faff57103757b6ff71f38e4713ef71..2f72a059b051bb3d35e0844c6b7ae3b6e2655e36 100644 --- a/src/main/java/net/minecraft/world/level/Level.java +++ b/src/main/java/net/minecraft/world/level/Level.java @@ -227,6 +227,7 @@ public abstract class Level implements LevelAccessor, AutoCloseable { return slices.getSectionEntities(chunkY); } // Sakura end + public final me.samsuik.sakura.entity.merge.MergeHistory mergeHistory = new me.samsuik.sakura.entity.merge.MergeHistory(); // Sakura protected Level(WritableLevelData worlddatamutable, ResourceKey resourcekey, RegistryAccess iregistrycustom, Holder holder, Supplier supplier, boolean flag, boolean flag1, long i, int j, org.bukkit.generator.ChunkGenerator gen, org.bukkit.generator.BiomeProvider biomeProvider, org.bukkit.World.Environment env, java.util.function.Function paperWorldConfigCreator, Supplier sakuraWorldConfigCreator, java.util.concurrent.Executor executor) { // Sakura // Paper - Async-Anti-Xray - Pass executor this.spigotConfig = new org.spigotmc.SpigotWorldConfig(((net.minecraft.world.level.storage.PrimaryLevelData) worlddatamutable).getLevelName()); // Spigot diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftFallingBlock.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftFallingBlock.java index a39694a27e362312eb42a29fd7c833f9c7437d46..55bfb0afc0e4e9f1ce2dd15f729bee61822c5afc 100644 --- a/src/main/java/org/bukkit/craftbukkit/entity/CraftFallingBlock.java +++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftFallingBlock.java @@ -14,6 +14,28 @@ public class CraftFallingBlock extends CraftEntity implements FallingBlock { super(server, entity); } + // Sakura start + @Override + public @org.jetbrains.annotations.NotNull me.samsuik.sakura.entity.merge.MergeLevel getMergeLevel() { + return getHandle().getMergeLevel(); + } + + @Override + public void setMergeLevel(@org.jetbrains.annotations.NotNull me.samsuik.sakura.entity.merge.MergeLevel level) { + getHandle().setMergeLevel(level); + } + + @Override + public int getStacked() { + return getHandle().getStacked(); + } + + @Override + public void setStacked(int stacked) { + getHandle().setStacked(stacked); + } + // Sakura end + @Override public FallingBlockEntity getHandle() { return (FallingBlockEntity) entity; diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftTNTPrimed.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftTNTPrimed.java index dc13deb1cea14f0650b292ddb6437fadefc0b8be..e9f8abb514654b87ec4f35b90fff04818a05780d 100644 --- a/src/main/java/org/bukkit/craftbukkit/entity/CraftTNTPrimed.java +++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftTNTPrimed.java @@ -12,6 +12,28 @@ public class CraftTNTPrimed extends CraftEntity implements TNTPrimed { super(server, entity); } + // Sakura start + @Override + public @org.jetbrains.annotations.NotNull me.samsuik.sakura.entity.merge.MergeLevel getMergeLevel() { + return getHandle().getMergeLevel(); + } + + @Override + public void setMergeLevel(@org.jetbrains.annotations.NotNull me.samsuik.sakura.entity.merge.MergeLevel level) { + getHandle().setMergeLevel(level); + } + + @Override + public int getStacked() { + return getHandle().getStacked(); + } + + @Override + public void setStacked(int stacked) { + getHandle().setStacked(stacked); + } + // Sakura end + @Override public float getYield() { return this.getHandle().yield;