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..7cb3b0d5a284199cdc117038227d33681b356aa3 --- /dev/null +++ b/src/main/java/me/samsuik/sakura/entity/merge/MergeHistory.java @@ -0,0 +1,138 @@ +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 me.samsuik.sakura.utils.objects.Expiry; +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; + } + } + +} diff --git a/src/main/java/net/minecraft/server/MinecraftServer.java b/src/main/java/net/minecraft/server/MinecraftServer.java index e1da429e78ee7445d2243661efbf08e152c8c732..457ccc9934fc6563a1b260ec9d12f0c875a4bd37 100644 --- a/src/main/java/net/minecraft/server/MinecraftServer.java +++ b/src/main/java/net/minecraft/server/MinecraftServer.java @@ -1580,6 +1580,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 + Entity 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 9118ebf8c0f2fc4cb8fb438426ef49cbdf9acc52..639712fe3e81703791b87622ae598c11b274802f 100644 --- a/src/main/java/net/minecraft/world/entity/Entity.java +++ b/src/main/java/net/minecraft/world/entity/Entity.java @@ -562,6 +562,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 boolean isLegacyTrackingEntity = false; @@ -640,6 +739,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) { @@ -2487,6 +2587,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"); @@ -2634,6 +2739,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"); @@ -4796,6 +4906,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 24586ae4833b5bf3596b0921273f712c14f1be9c..45e042dc8b875b08f5f09955258913a256371b54 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 12c6705d652cf873134aca556e205aa5ec0248fb..c7defb4555edd792c83ec001c8dfbf604376b190 100644 --- a/src/main/java/net/minecraft/world/level/Level.java +++ b/src/main/java/net/minecraft/world/level/Level.java @@ -234,6 +234,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 73911b81f4e927026657953a0c68ddda9a8f93c1..c64ab06b62334e5ab1ab5ad78fa400de45c15723 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) this.entity; diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftTNTPrimed.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftTNTPrimed.java index 3f32c683ddc6999b89f2e4051eb6ae784b296b8f..1ccf2153d82403a9b4fb3c972d1b7809c8efde2d 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;