* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: TonyJamesStark <tonyjamesstark1@gmail.com>
* Fixed world purges leaving orphaned entity data
co_entity has no world column, so /co purge r:#world deleted the kill
rows in co_block but kept every co_entity row. Those blobs became
orphans that no later purge removed.
- SQLite: copy only the co_entity rows that a retained kill row still
references. This also drops orphans left by earlier purges.
- MySQL/DuckDB: delete the co_entity rows of the kill rows a world purge
removes, before co_block is purged. MySQL uses a join so MariaDB and
MySQL 5.7 do not run a dependent subquery. Global purges keep the
cheaper time-based delete, which is equivalent. If MySQL stops between
the two deletes, running the same purge again removes the remaining
kill rows.
Player kills use the kill action with type 0 and a user id in data, so
they are never treated as co_entity references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
* Fixed SQLite purges with world and block filters purging other tables
On SQLite, /co purge r:#world i:<block> built the retain condition for
world-scoped tables without checking the block restriction, so it purged
co_container, co_chat and the other world-scoped tables in that world
although a block restriction should leave them untouched. The
entity_container/entity_interaction branch and the MySQL path already
check it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
* Fixed console purges with a radius purging all worlds
/co purge rejects a numeric radius, but the check only saw the radius
when the sender had a location. From the console, /co purge t:30d r:50
parsed no radius and no world, so it ran as a server-wide purge. The
radius is now also parsed against a placeholder location, so it is
rejected for every sender. r:#world is unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
* Added orphaned entity data cleanup to MySQL #optimize purges
MySQL deletes in place, so co_entity rows orphaned by earlier world
purges stay until something removes them. With #optimize, delete every
co_entity row that no kill row references, through a temporary table of
referenced ids (avoids an anti-join on the unindexed co_block.data), then
let OPTIMIZE reclaim the space. Failures are reported like the table
loop, so the entity_spawn link cleanup still runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
* Moved purge conditions into PurgeFilter
PurgeFilter now builds the purge condition for every table, and the
SQLite copy, the SQLite recovery path and the MySQL/DuckDB delete all use
it instead of three hand-built copies. Behavior is unchanged; the block
restriction check that the previous commit added to the SQLite copy is
part of the shared condition.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
* Added entity kill filters to purge command
/co purge rejected entity types and actions, so mob farm kill logs could
only be removed together with all other data. New arguments on SQLite,
MySQL and DuckDB:
- a:kill purges only entity kill rows.
- i:<entity> purges kills of those entity types; it can be combined with
block types.
- e:<entity> keeps kills of those entity types while purging the rest.
Each purged kill also removes its co_entity row. a:kill with block types,
entity types in both i: and e:, e: with only block types in i:,
non-entity exclusions and entity filters on ClickHouse are rejected. Any
a: value other than a kill alias is rejected instead of falling through
to an unrestricted purge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
* Added radius support to purge command
/co purge rejected any numeric radius, so kills of one entity type could
only be purged across a whole world. A radius is now accepted when the
purge is restricted to co_block rows with i: or a:kill, for example
/co purge t:30d r:50 i:zombie. r:50x10 also limits the height.
The radius becomes an x/y/z range in the co_block condition. co_entity
rows follow the purged kill rows, so no entity data is orphaned, and the
(wid,x,z,time) index already covers the range. Other tables are left
untouched, as with any restricted purge.
Rejected: a radius without i: or a:kill, WorldEdit selections, radius
purges on ClickHouse, and a numeric radius from the console (it has no
location, as in the console radius fix merged into this branch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
* Made MySQL entity data purges safe against concurrent writers
The #optimize orphan sweep snapshots the co_entity ids that kill rows
reference, then deletes every row missing from the snapshot. Another
installation sharing the database could commit a kill between the two
statements and lose its entity data. The sweep now only deletes rows
inside the purge time range. A kill always writes its co_entity row with
the current time, and a purge range ends at least 24 hours ago, so a
concurrent kill never matches.
A world purge deletes the entity data of the purged kills and
then the kill rows. On MySQL a failure or cancellation between the two
left kept kill rows without their rollback data. Both deletes now run in
one transaction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Limited SQLite entity data cleanup to the purge time range
A SQLite purge dropped every co_entity row no kill referenced, including
rows newer than the purge range, and counted them as deleted. It now only
removes unreferenced rows inside the range, like the MySQL #optimize sweep.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rejected purge include and exclude lists without a value
/co purge t:30d e: passed validation, parsed to no exclusions, and ran
as an unrestricted time purge. e:, and e:,,, did the same, as did an
e: at the end of the command and an empty i:. The argument check now
follows each include or exclude list across its continuation tokens and
rejects a list that ends without a value. Space-separated lists such as
e:zombie, villager still work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Matched purge entity filters to kills logged under renamed types
Bukkit resolves a legacy name such as zombie_pigman to the renamed type,
so the filter only matched the id of the new name. e:zombie_pigman then
purged the old kills it was meant to keep, and i:zombie_pigman missed
them. The filter now covers every entity map id that resolves to the type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rejected purges with more than one radius argument
/co purge t:30d r:50 r:#world_nether i:zombie ignored the explicit world
and purged around the player in their current world. Two r: arguments
always conflict, so the second one is now rejected before the purge
starts, whether it is a world, #global or another radius.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Applied max-radius to radius purges
A radius purge accepted any value. A radius past the int range saturated,
the area overflowed, and the purge reported success with nothing deleted.
It now stops at max-radius like lookups and rollbacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Checked radius arguments inside purge include and exclude lists
/co purge t:30d i:zombie, r:50 r:#world_nether passed the second-radius
check: the validator took r:50 as a value of the include list, then saw
r:#world_nether as the only radius. The radius and world parsers read
both. A list now continues only with plain or minecraft: namespaced
values, so a radius after a trailing comma is counted and the second one
is rejected. This is the same change as 39deec3 on entity-kill-purge-filters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LFQuzYmYun57wJi2a5mqkM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: TonyJamesStark <tonyjamesstark1@gmail.com>
/co purge rejects a numeric radius, but the check only saw the radius
when the sender had a location. From the console, /co purge t:30d r:50
parsed no radius and no world, so it ran as a server-wide purge. The
radius is now also parsed against a placeholder location, so it is
rejected for every sender. r:#world is unaffected.
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
Co-authored-by: Claude <noreply@anthropic.com>
On SQLite, /co purge r:#world i:<block> built the retain condition for
world-scoped tables without checking the block restriction, so it purged
co_container, co_chat and the other world-scoped tables in that world
although a block restriction should leave them untouched. The
entity_container/entity_interaction branch and the MySQL path already
check it.
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f
Co-authored-by: Claude <noreply@anthropic.com>
* Stop threads and subscribers that outlive a plugin disable
The WorldEdit subscriber, the cache sweeper thread, per-click inspector threads and the bStats scheduler all kept running after CoreProtect was disabled, and the Folia shutdown checkpoint could wait 30 seconds on tasks that never run. Shutdown now unregisters and stops each of them, inspector lookups use a small named pool, and the checkpoint waits 5 seconds.
* Recreate inspector executor on enable and account for shutdown leftovers
The inspector executor was a static final that stayed shut down after a disable, so a same-instance enable could never run another lookup. It is now created on each enable. At shutdown, lookups still queued after the drain window release their throttle slot, since shutdownNow() returns them without running their finally block, and running lookups that survive the interrupt are logged by thread and player after a bounded wait. The Folia checkpoint timeout change is reverted to keep this change to thread cleanup.
* Apply Folia inventory rollbacks on each player's scheduler
Inventory rollbacks run in the region that owns chunk 0,0 and changed every player's inventory from there, which Folia does not allow. On Folia the rows are now grouped per player and applied on that player's entity scheduler, the batch waits for those tasks, and the rollback counters are updated atomically. Paper and Spigot are unchanged.
* Run Folia inventory tasks as mutations of the rollback context
The per-player inventory tasks now go through the rollback's entity context. A task checks for cancellation before it changes anything, runs as an active mutation so cleanup waits for it to finish, and adds its item count to that context instead of the shared counter for the sender, so a late task cannot change a rollback that already ended or a newer one from the same sender. The counter helpers go back to their upstream form since player tasks no longer write to them.
* Read bisected halves through the WorldEdit edit, not the live world
When an edit removes a door or tall plant, the other half was read from the live world on the edit thread, which does not own the chunk and can see blocks the edit already changed. The half is now read through the edit extent, or the FAWE processor's chunk arrays, and carried on WorldEditBlockState. The type list Queue checked inline moves to BlockGroup.LOGGED_BY_LOWER_HALF so both sides use the same one.
* Capture the other bisected half before the WorldEdit change applies
CoreProtect's extent sits at BEFORE_CHANGE, below WorldEdit's batching, so its reads reach the live world, and both the lower half and the removed partner were read after setBlock(). Read the other half before setBlock() instead, and only use it when it is the same material with the opposite half, so an unrelated block next to a removed half is no longer logged from block data alone. A door or tall flower is already logged by its lower half, so its partner no longer gets a second, duplicate row. The FAWE lower half is validated the same way.
* Make /co reload swap world configs and leave block groups alone
The per-world config map was a plain HashMap that getConfig wrote into on a miss, and reload cleared it before refilling, so a listener hitting that window could leave a world on the global config until the next reload. The map is now concurrent and read-only for listeners, and reload swaps new configs in without a gap. Reload also stops rebuilding the version adapters and block groups, which only depend on the server version, while listeners read them.
* Rebuild adapters and block groups on /co reload again
Several block groups are filled from datapack tags (buttons, pressure
plates, doors, saplings, flowers, carpets, logs and more), so they are
not fixed by the server version. Running the adapters and
BlockGroup.initialize() only at startup would leave those groups stale
after a datapack reload until the server restarts. Restore the previous
behaviour and keep this change to the world config map.
* Fix races on the pending item transaction lists
Item listeners appended to the shared per-player lists with get, add and put while the consumer read and then cleared them, so items could be logged twice or not at all, and the anvil path replaced pending items outright. Listeners now append inside ConcurrentHashMap.compute and the consumer takes each list with remove. The prepared copy is kept for every database type so a retried relational batch still writes its rows.
* Close discard and clone-failure gaps in pending item transactions
Listeners now append their item and register its generation in one
Queue.addPendingItems call that holds the Queue lock, the same lock
ItemTransactionProcess.discard takes, so discard can no longer clear an
item whose transaction has not been queued yet. ItemLogger.prepare now
only detaches the pending lists; cloning and merging move into
PreparedTransaction.log, which runs after the prepared transaction is
stored for the batch, so a cloning failure leaves the detached items
available to the retried transaction.
* Restore paintings and item frames on Folia without teleporting
Rollback spawns a painting or item frame and then teleports it into place, but Folia always throws from Entity#teleport, so paintings are lost and frames never get their recorded facing or item. On Folia the entity is now spawned directly at its recorded block with its facing, art or item set in the pre-spawn consumer. Paper and Spigot are unchanged.
* Spawn Folia hangings through the adapter on 1.19.4 and 1.20.1
Compiling against the current API binds World#spawn(Location, Class,
Consumer) to the java.util.function.Consumer overload, which only exists
from Bukkit 1.20.2. Folia 1.19.4 and 1.20.1 only have the
org.bukkit.util.Consumer overload, so restoring a painting or item frame
there threw NoSuchMethodError. The spawn now goes through
BukkitAdapter.spawn, which calls the java.util.function.Consumer overload
when World has it and otherwise invokes the org.bukkit.util.Consumer
overload by reflection.
* Log armor stand breaks from the retired callback on Folia
When an armor stand dies from non-player damage, the break and its equipment are logged from a task on the stand's scheduler. On Folia the stand is removed in the same tick, so the task is retired instead of run and nothing is logged. The same runnable is now passed as the retired callback, so exactly one of them logs the break.
* Log armor stand breaks from EntityDeathEvent instead of isDead
The damage listeners now only capture the attacker and the stand's
contents, and EntityDeathEvent logs the break when the stand actually
dies. The scheduled task and Folia's retired callback just discard a
capture that was never used, so a stand that survived the hit and was
unloaded or otherwise removed before the callback ran no longer
produces break and container rows, and the retired callback never
touches the world.
* Materialize the MySQL sign text filter as a derived table
MySQL cannot semi-join the rowid IN (UNION ...) subquery the sign text filter builds, so it runs it as a dependent subquery, re-probing all eight branches for every candidate row and never using the line_N prefix indexes. Wrapping the union in a derived table lets MySQL materialize it once through those indexes. Other databases build the same query as before.
* Inline the MySQL sign text filter so restricted lookups stay indexed
The derived table materialized every sign row matching the text across the whole table before the location, time and user restrictions were applied, so a common prefix at one sign location scanned the full table. MySQL now gets the same inline line predicates DuckDB already uses: restricted lookups filter the rows found through the wid, time or user index, and unrestricted lookups still reach the line prefix indexes through an index merge.
* Split the sign filter comment into two sentences
The explosion scan cloned a Location for every block and built five more per block to find attached neighbours, hashing each one on the region thread. It now keys the map by a packed long position and walks the neighbours from an offset table. The same blocks are logged.
Lookup.countLookupRows and LookupRaw.performLookupRaw closed their ResultSet as the last statement of the try, so any error while reading rows left it, and the statement behind it, open until the connection was returned. Both now close it on every path.
populatedChunks had no world in its key, so a chunk generated in one world suppressed water, lava and vine rows at the same chunk coordinates in every other world, and entries were only removed when a later fluid row happened to land there. It is now kept per world and swept after 240 seconds, the longest window BlockPlaceLogger checks.
A tamed animal whose owner has no known name is stored with a null owner, and restoring it throws a NullPointerException at the owner step, which drops the rest of the restore. A null owner name is now skipped like an empty one.
The consumer ran Material.matchMaterial's two regex passes on names they cannot change, and re-encoded the same blockdata strings into ids on every row. Plain enum names now go straight to getMaterial, and stringToByteData caches its results per string, bounded, reset when the id maps reload and skipped on ClickHouse.
The id allocators read the counter, add one and store it without a lock, so two threads that see new names at the same time (region threads on Folia, lookup and API threads everywhere) can give the same id to two different names. Allocation now holds the ConfigHandler monitor reloadAndGetId already uses, with a re-check, and queues the insert after releasing it.
Every dispense copied the dispenser's block entity twice on the region thread (block.getState() in the dispense listeners and inventory.getHolder() in onInventoryInteract), and both copies were only used for their type, location and live inventory. Paper can hand out non-snapshot states for this, so the dispense path now asks for those through a new PaperInterface.getBlockState(Block, boolean), falling back to a normal snapshot on Spigot.