From dcc4c0d6e8b93d6e05fa8e3d1780da8ac4c10cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:01:32 +0000 Subject: [PATCH 1/2] 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") From 9e58cac64a51ccf90d12279bd53d48e42a33924a Mon Sep 17 00:00:00 2001 From: TonyJamesStark Date: Sat, 3 Oct 2026 12:08:40 -0700 Subject: [PATCH 2/2] Restored left-out attributes from a frozen baseline Restoring sparse kill data reset every attribute of the respawned entity to the restoring server's defaults. An attribute the logging server did not have yet was reset as well: a horse logged before 1.20.5 and restored on 26.1 got the global step height, safe fall distance and fall damage multiplier instead of the horse values. AttributeUtils now holds a frozen baseline of 40 attribute values, taken from the 26.2 registry defaults. Kill data leaves out an attribute only when it is in the baseline, has the baseline value and has no modifiers, and sets that attribute's bit in a long stored after the format marker at index 10. Restore sets exactly the attributes with a bit to their baseline values and leaves every other attribute as spawned. Entries are only ever appended, so stored bits keep their meaning, and the restoring server's defaults are never read. Rows without the marker restore as before. Co-Authored-By: Claude Opus 5.5 --- .../listener/entity/EntityDeathListener.java | 14 +- .../coreprotect/utility/AttributeUtils.java | 124 ++++++++++++++++++ .../utility/entity/EntityUtil.java | 64 +++------ 3 files changed, 152 insertions(+), 50 deletions(-) diff --git a/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java b/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java index d31fd4c31..a7b2ecaf9 100644 --- a/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java +++ b/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java @@ -79,6 +79,7 @@ import net.coreprotect.spigot.SpigotAdapter; import net.coreprotect.thread.CacheHandler; import net.coreprotect.thread.Scheduler; +import net.coreprotect.utility.AttributeUtils; import net.coreprotect.utility.EntitySpawnTracking; import net.coreprotect.utility.entity.EntityUtil; import net.coreprotect.utility.entity.LivingEntityDetails; @@ -307,11 +308,19 @@ else if (entity instanceof Player) { } } + long baselineBits = 0; if (entity instanceof Attributable) { Attributable attributable = entity; for (Attribute attribute : Lists.newArrayList(Registry.ATTRIBUTE)) { AttributeInstance attributeInstance = attributable.getAttribute(attribute); - if (attributeInstance != null && EntityUtil.isAttributeModified(attributeInstance)) { + if (attributeInstance == null) { + continue; + } + int baselineBit = AttributeUtils.baselineBit(attributeInstance); + if (baselineBit >= 0) { + baselineBits |= 1L << baselineBit; + } + else { List attributeData = new ArrayList<>(); List attributeModifiers = new ArrayList<>(); attributeData.add(BukkitAdapter.ADAPTER.getRegistryKey(attributeInstance.getAttribute())); @@ -585,7 +594,8 @@ else if (entity instanceof Zoglin) { data.add(details); data.add(EntitySpawnTracking.isTracked(entity) ? entity.getUniqueId().toString() : null); data.add(null); // kill location, only stored for placed entities - data.add(EntityUtil.SPARSE_ATTRIBUTES); + data.add(EntityUtil.BASELINE_ATTRIBUTES); + data.add(baselineBits); if (!(entity instanceof Player)) { Queue.queueEntityKill(e, entity.getLocation(), data, type); diff --git a/src/main/java/net/coreprotect/utility/AttributeUtils.java b/src/main/java/net/coreprotect/utility/AttributeUtils.java index 4e45ba730..7b188e3c4 100644 --- a/src/main/java/net/coreprotect/utility/AttributeUtils.java +++ b/src/main/java/net/coreprotect/utility/AttributeUtils.java @@ -1,14 +1,68 @@ package net.coreprotect.utility; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import org.bukkit.Keyed; import org.bukkit.NamespacedKey; import org.bukkit.Registry; +import org.bukkit.attribute.Attributable; import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.attribute.AttributeModifier; public final class AttributeUtils { + /** + * Attribute base values that entity kill data can leave out, frozen from the 26.2 attribute registry defaults. + * Kill data marks each left-out attribute with the bit of its position here, and a restore sets exactly this + * value, whatever the defaults of the restoring server are. Entries may only be appended: changing or reordering + * one would change what the bits of stored kill data mean. The bits are a long, so at most 64 entries fit. + */ + private static final List BASELINE = List.of( + new BaselineAttribute("air_drag_modifier", 1.0), + new BaselineAttribute("armor", 0.0), + new BaselineAttribute("armor_toughness", 0.0), + new BaselineAttribute("attack_damage", 2.0), + new BaselineAttribute("attack_knockback", 0.0), + new BaselineAttribute("attack_speed", 4.0), + new BaselineAttribute("below_name_distance", 10.0), + new BaselineAttribute("block_break_speed", 1.0), + new BaselineAttribute("block_interaction_range", 4.5), + new BaselineAttribute("bounciness", 0.0), + new BaselineAttribute("burning_time", 1.0), + new BaselineAttribute("camera_distance", 4.0), + new BaselineAttribute("entity_interaction_range", 3.0), + new BaselineAttribute("explosion_knockback_resistance", 0.0), + new BaselineAttribute("fall_damage_multiplier", 1.0), + new BaselineAttribute("flying_speed", 0.4), + new BaselineAttribute("follow_range", 32.0), + new BaselineAttribute("friction_modifier", 1.0), + new BaselineAttribute("gravity", 0.08), + new BaselineAttribute("jump_strength", 0.41999998688697815), + new BaselineAttribute("knockback_resistance", 0.0), + new BaselineAttribute("luck", 0.0), + new BaselineAttribute("max_absorption", 0.0), + new BaselineAttribute("max_health", 20.0), + new BaselineAttribute("mining_efficiency", 0.0), + new BaselineAttribute("movement_efficiency", 0.0), + new BaselineAttribute("movement_speed", 0.7), + new BaselineAttribute("name_tag_distance", 64.0), + new BaselineAttribute("oxygen_bonus", 0.0), + new BaselineAttribute("safe_fall_distance", 3.0), + new BaselineAttribute("scale", 1.0), + new BaselineAttribute("sneaking_speed", 0.3), + new BaselineAttribute("spawn_reinforcements", 0.0), + new BaselineAttribute("step_height", 0.6), + new BaselineAttribute("submerged_mining_speed", 0.2), + new BaselineAttribute("sweeping_damage_ratio", 0.0), + new BaselineAttribute("tempt_range", 10.0), + new BaselineAttribute("water_movement_efficiency", 0.0), + new BaselineAttribute("waypoint_receive_range", 0.0), + new BaselineAttribute("waypoint_transmit_range", 0.0) + ); + private AttributeUtils() { throw new IllegalStateException("Utility class"); } @@ -81,4 +135,74 @@ private static boolean isLegacyCategory(String category) { return category.equals("generic") || category.equals("player") || category.equals("horse") || category.equals("zombie"); } + + /** + * Returns the baseline bit of an attribute that kill data can leave out: it has no modifiers and its base value is + * the baseline value. + * + * @param attributeInstance + * the attribute of the entity that is being logged + * @return the bit, or -1 when the attribute has to be stored + */ + public static int baselineBit(AttributeInstance attributeInstance) { + if (!attributeInstance.getModifiers().isEmpty()) { + return -1; + } + NamespacedKey key = ((Keyed) attributeInstance.getAttribute()).getKey(); + if (!key.getNamespace().equals(NamespacedKey.MINECRAFT)) { + return -1; + } + String name = normalize(key.getKey()); + for (int bit = 0; bit < BASELINE.size(); bit++) { + BaselineAttribute baseline = BASELINE.get(bit); + if (baseline.key.equals(name)) { + return Double.compare(attributeInstance.getBaseValue(), baseline.value) == 0 ? bit : -1; + } + } + return -1; + } + + /** + * Sets every attribute whose bit is set to its baseline value without modifiers. Attributes without a bit keep the + * value they spawned with, including attributes that did not exist when the kill was logged. + * + * @param attributable + * the restored entity + * @param baselineBits + * the baseline bits stored with the kill data + */ + public static void restoreBaseline(Attributable attributable, long baselineBits) { + for (int bit = 0; bit < BASELINE.size(); bit++) { + if ((baselineBits & (1L << bit)) == 0) { + continue; + } + BaselineAttribute baseline = BASELINE.get(bit); + Attribute attribute; + try { + attribute = resolve(NamespacedKey.MINECRAFT + ":" + baseline.key); + } + catch (IllegalArgumentException exception) { + continue; + } + + AttributeInstance attributeInstance = attributable.getAttribute(attribute); + if (attributeInstance == null) { + continue; + } + attributeInstance.setBaseValue(baseline.value); + for (AttributeModifier modifier : new ArrayList<>(attributeInstance.getModifiers())) { + attributeInstance.removeModifier(modifier); + } + } + } + + private static final class BaselineAttribute { + private final String key; + private final double value; + + private BaselineAttribute(String key, double value) { + this.key = key; + this.value = value; + } + } } diff --git a/src/main/java/net/coreprotect/utility/entity/EntityUtil.java b/src/main/java/net/coreprotect/utility/entity/EntityUtil.java index baf3bf84f..cc2ca7032 100644 --- a/src/main/java/net/coreprotect/utility/entity/EntityUtil.java +++ b/src/main/java/net/coreprotect/utility/entity/EntityUtil.java @@ -85,16 +85,17 @@ 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. + * Position in the entity kill data list of the attribute format marker, followed by the baseline bits. 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. + * Attribute format marker: the attribute list leaves out the attributes whose bits are set in the long that + * follows the marker (see {@link AttributeUtils#baselineBit}). Rows without the marker list every attribute the + * entity had. */ - public static final int SPARSE_ATTRIBUTES = 1; + public static final int BASELINE_ATTRIBUTES = 1; private EntityUtil() { throw new IllegalStateException("Utility class"); @@ -227,8 +228,9 @@ else if (count == 1) { Attributable attributable = (Attributable) entity; @SuppressWarnings("unchecked") List attributes = (List) list.get(5); - if (hasSparseAttributes(list)) { - resetAttributes(attributeInstances(attributable)); + Long baselineBits = baselineBits(list); + if (baselineBits != null) { + AttributeUtils.restoreBaseline(attributable, baselineBits); } restoreAttributes(attributable, attributes); } @@ -732,50 +734,16 @@ 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; + static Long baselineBits(List list) { + if (list.size() <= ATTRIBUTE_FORMAT_INDEX + 1) { + return null; } 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); - } + Object bits = list.get(ATTRIBUTE_FORMAT_INDEX + 1); + if (format instanceof Number && ((Number) format).intValue() == BASELINE_ATTRIBUTES && bits instanceof Long) { + return (Long) bits; } + return null; } static void restoreAttributes(Attributable attributable, List attributes) {