From dcc4c0d6e8b93d6e05fa8e3d1780da8ac4c10cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:01:32 +0000 Subject: [PATCH] Reduced entity kill data by skipping default attributes EntityDeathListener stored every registered attribute of a killed mob, about 23 entries on 1.21+, although almost all hold default values. The attribute list made up about 88% of each kill blob. Store an attribute only when its base value differs from the default or it has modifiers. Kill data now always carries index 7 (UUID or null), index 8 (kill location slot, null for mobs) and a format marker at index 9. When restore sees the marker, it first resets every attribute of the respawned entity to its default base value and removes spawn-time modifiers, then applies the stored attributes, so rollback reproduces the logged entity exactly. Rows without the marker restore as before. Capture and restore both use AttributeInstance#getDefaultValue. It is deprecated since 26.1 for returning the server default rather than the entity type default, but it exists on every supported version (1.16.5+) and the same value is used on both sides. Zombie kill blob: 2036 to 1027 bytes in the legacy format, and 250 to 141 bytes with EntityDataCodec (DuckDB and ClickHouse today, SQLite and MySQL after the v26 migration). On SQLite that is 4096 to 1365 bytes per kill on disk now, or 158 bytes after v26. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- .../listener/entity/EntityDeathListener.java | 9 +-- .../utility/entity/EntityUtil.java | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java b/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java index 04364dc87..d31fd4c31 100644 --- a/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java +++ b/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java @@ -80,6 +80,7 @@ import net.coreprotect.thread.CacheHandler; import net.coreprotect.thread.Scheduler; import net.coreprotect.utility.EntitySpawnTracking; +import net.coreprotect.utility.entity.EntityUtil; import net.coreprotect.utility.entity.LivingEntityDetails; import net.coreprotect.utility.serialize.ItemMetaHandler; @@ -310,7 +311,7 @@ else if (entity instanceof Player) { Attributable attributable = entity; for (Attribute attribute : Lists.newArrayList(Registry.ATTRIBUTE)) { AttributeInstance attributeInstance = attributable.getAttribute(attribute); - if (attributeInstance != null) { + if (attributeInstance != null && EntityUtil.isAttributeModified(attributeInstance)) { List attributeData = new ArrayList<>(); List attributeModifiers = new ArrayList<>(); attributeData.add(BukkitAdapter.ADAPTER.getRegistryKey(attributeInstance.getAttribute())); @@ -582,9 +583,9 @@ else if (entity instanceof Zoglin) { data.add(entity.getCustomName()); data.add(attributes); data.add(details); - if (EntitySpawnTracking.isTracked(entity)) { - data.add(entity.getUniqueId().toString()); - } + data.add(EntitySpawnTracking.isTracked(entity) ? entity.getUniqueId().toString() : null); + data.add(null); // kill location, only stored for placed entities + data.add(EntityUtil.SPARSE_ATTRIBUTES); if (!(entity instanceof Player)) { Queue.queueEntityKill(e, entity.getLocation(), data, type); diff --git a/src/main/java/net/coreprotect/utility/entity/EntityUtil.java b/src/main/java/net/coreprotect/utility/entity/EntityUtil.java index 78e2a3c67..baf3bf84f 100644 --- a/src/main/java/net/coreprotect/utility/entity/EntityUtil.java +++ b/src/main/java/net/coreprotect/utility/entity/EntityUtil.java @@ -84,6 +84,18 @@ public class EntityUtil { private static final long ENTITY_RESTORE_TIMEOUT_SECONDS = 30L; + /** + * Position in the entity kill data list of the attribute format marker. Index 7 holds the entity UUID or null, + * and index 8 the kill location that EntitySpawnTracking stores for placed entities. + */ + public static final int ATTRIBUTE_FORMAT_INDEX = 9; + + /** + * Attribute format marker: the attribute list omits every attribute that had the default base value and no + * modifiers. Rows without the marker list every attribute the entity had. + */ + public static final int SPARSE_ATTRIBUTES = 1; + private EntityUtil() { throw new IllegalStateException("Utility class"); } @@ -215,6 +227,9 @@ else if (count == 1) { Attributable attributable = (Attributable) entity; @SuppressWarnings("unchecked") List attributes = (List) list.get(5); + if (hasSparseAttributes(list)) { + resetAttributes(attributeInstances(attributable)); + } restoreAttributes(attributable, attributes); } @@ -717,6 +732,52 @@ else if (entity instanceof Zoglin) { return completion; } + /** + * Returns whether an attribute has to be stored to restore the entity: its base value differs from the default, + * or it has modifiers. {@link #resetAttributes(Iterable)} rebuilds every attribute this skips. + * + * @param attributeInstance + * the attribute of the entity that is being logged + * @return true when the attribute must be stored + */ + @SuppressWarnings("deprecation") // The server default exists on every supported version; restore uses the same value. + public static boolean isAttributeModified(AttributeInstance attributeInstance) { + return !attributeInstance.getModifiers().isEmpty() || Double.compare(attributeInstance.getBaseValue(), attributeInstance.getDefaultValue()) != 0; + } + + static boolean hasSparseAttributes(List list) { + if (list.size() <= ATTRIBUTE_FORMAT_INDEX) { + return false; + } + Object format = list.get(ATTRIBUTE_FORMAT_INDEX); + return format instanceof Number && ((Number) format).intValue() == SPARSE_ATTRIBUTES; + } + + private static List attributeInstances(Attributable attributable) { + List attributeInstances = new ArrayList<>(); + for (Attribute attribute : Registry.ATTRIBUTE) { + AttributeInstance attributeInstance = attributable.getAttribute(attribute); + if (attributeInstance != null) { + attributeInstances.add(attributeInstance); + } + } + return attributeInstances; + } + + /** + * Sets attributes of a restored entity to their default base value without modifiers, so that attributes a + * sparse attribute list omits match the logged entity. Spawning can randomize base values and add modifiers. + */ + @SuppressWarnings("deprecation") // Same default value that isAttributeModified compares against. + static void resetAttributes(Iterable attributeInstances) { + for (AttributeInstance attributeInstance : attributeInstances) { + attributeInstance.setBaseValue(attributeInstance.getDefaultValue()); + for (AttributeModifier modifier : new ArrayList<>(attributeInstance.getModifiers())) { + attributeInstance.removeModifier(modifier); + } + } + } + static void restoreAttributes(Attributable attributable, List attributes) { for (Object value : attributes) { @SuppressWarnings("unchecked")