From 5879de2862b22880f892d73625d008af455a6d95 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 01:01:24 +0800 Subject: [PATCH 01/59] Cull distant dropped item labels --- .../entities/DroppedItemDisplay.java | 94 +++++++++--- .../entities/DroppedItemSpatialIndex.java | 136 ++++++++++++++++++ common/src/main/resources/config.yml | 3 + .../entities/DroppedItemSpatialIndexTest.java | 55 +++++++ 4 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java index 584bc3b..ee3c5c3 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -56,8 +56,11 @@ import org.joml.Quaternionf; import org.joml.Vector3f; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -71,6 +74,8 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implements Listener { public static final EntryKey KEY = new EntryKey("item"); + private static final int DEFAULT_VIEW_DISTANCE = 64; + private static final int VIEW_DISTANCE_HYSTERESIS = 16; private final Map trackedItems = new HashMap<>(); private final Map labels = new HashMap<>(); @@ -87,6 +92,7 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private int updateRate = 20; private int ticksUntilUpdate; private int despawnTicks = 6000; + private int viewDistance = DEFAULT_VIEW_DISTANCE; private boolean stripColorBlacklist; private DroppedItemBlacklist blacklist = DroppedItemBlacklist.compile(List.of(), DroppedItemDisplay::warn); @@ -107,6 +113,11 @@ public void onReload(InteractionVisualizerReloadEvent event) { .getDouble("Entities.Item.Options.LabelYOffset"); labelYOffset = Double.isFinite(configuredLabelYOffset) ? configuredLabelYOffset : 0.8D; updateRate = Math.max(1, InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.UpdateRate")); + int configuredViewDistance = InteractionVisualizer.plugin.getConfiguration() + .getInt("Entities.Item.Options.ViewDistance"); + viewDistance = configuredViewDistance > 0 + ? Math.max(8, Math.min(512, configuredViewDistance)) + : DEFAULT_VIEW_DISTANCE; int configuredDespawnTicks = InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.DespawnTicks"); despawnTicks = configuredDespawnTicks > 0 ? configuredDespawnTicks : 6000; stripColorBlacklist = InteractionVisualizer.plugin.getConfiguration() @@ -200,18 +211,61 @@ private void track(Item item) { } private void tickAll() { - reconcileEligibleViewers(); - for (Map.Entry entry : new HashMap<>(trackedItems).entrySet()) { + Collection viewers = reconcileEligibleViewers(); + DroppedItemSpatialIndex.ViewerIndex viewerIndex = new DroppedItemSpatialIndex.ViewerIndex(); + for (Player viewer : viewers) { + Location location = viewer.getLocation(); + viewerIndex.addViewer(viewer.getWorld().getUID(), location.getX(), location.getY(), location.getZ()); + } + + List validItems = new ArrayList<>(trackedItems.size()); + Iterator> iterator = trackedItems.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); Item item = entry.getValue(); if (!item.isValid() || item.isDead()) { - remove(entry.getKey()); + iterator.remove(); + removeLabel(entry.getKey()); continue; } - update(item); + validItems.add(new TrackedItem(entry.getKey(), item, item.getLocation())); + } + + if (viewerIndex.isEmpty()) { + for (UUID itemId : new HashSet<>(labels.keySet())) { + removeLabel(itemId); + } + return; + } + + DroppedItemSpatialIndex itemIndex = new DroppedItemSpatialIndex(); + for (TrackedItem tracked : validItems) { + Location location = tracked.location(); + itemIndex.addItem(tracked.item().getWorld().getUID(), + location.getX(), location.getY(), location.getZ()); + } + for (TrackedItem tracked : validItems) { + update(tracked, itemIndex, viewerIndex); } } - private void update(Item item) { + private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, + DroppedItemSpatialIndex.ViewerIndex viewerIndex) { + Item item = tracked.item(); + Location itemLocation = tracked.location(); + TextDisplay label = labels.get(tracked.itemId()); + int trackingDistance = InteractionVisualizer.playerTrackingRange + .getOrDefault(item.getWorld(), DEFAULT_VIEW_DISTANCE); + int effectiveViewDistance = Math.min(viewDistance, trackingDistance); + int cullingDistance = label == null + ? effectiveViewDistance + : effectiveViewDistance + VIEW_DISTANCE_HYSTERESIS; + if (!viewerIndex.hasViewerWithin(item.getWorld().getUID(), + itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), cullingDistance)) { + removeLabel(tracked.itemId()); + return; + } + ItemStack stack = item.getItemStack(); String matchingName = matchingName(stack); NamespacedKey customItemId = blacklist.requiresCustomItemId() @@ -219,13 +273,14 @@ private void update(Item item) { : null; int ticksLeft = despawnTicks - item.getTicksLived(); if (stack.isEmpty() || blacklist.matches(matchingName, stack.getType(), customItemId) - || item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 || isCramping(item)) { + || item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 + || (cramp > 0 && itemIndex.exceedsItemLimit(item.getWorld().getUID(), + itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), cramp))) { removeLabel(item.getUniqueId()); return; } Component text = format(stack, ticksLeft); - TextDisplay label = labels.get(item.getUniqueId()); boolean created = false; if (label == null || !label.isValid() || !label.getWorld().equals(item.getWorld())) { removeLabel(item.getUniqueId()); @@ -236,6 +291,10 @@ private void update(Item item) { if (!text.equals(label.text())) { label.text(text); } + float targetViewRange = labelViewRange(); + if (Math.abs(label.getViewRange() - targetViewRange) > 1.0E-4F) { + label.setViewRange(targetViewRange); + } boolean mounted = item.equals(label.getVehicle()) || item.addPassenger(label); if (mounted) { // A mounted display follows the item on every client render frame. @@ -274,7 +333,7 @@ private TextDisplay spawnLabel(Item item) { display.setSilent(true); display.setNoPhysics(true); display.setBillboard(Display.Billboard.CENTER); - display.setViewRange(1.0F); + display.setViewRange(labelViewRange()); display.setInterpolationDuration(0); display.setTeleportDuration(0); display.setShadowed(true); @@ -287,6 +346,10 @@ private TextDisplay spawnLabel(Item item) { }); } + private float labelViewRange() { + return (float) Math.max(0.125D, Math.min(8.0D, viewDistance / 64.0D)); + } + private Location labelLocation(Item item) { return item.getLocation().add(0.0, labelYOffset, 0.0); } @@ -308,7 +371,7 @@ private static void setLabelVerticalTranslation(TextDisplay label, float targetY new Quaternionf(current.getRightRotation()))); } - private void reconcileEligibleViewers() { + private Collection reconcileEligibleViewers() { Map desired = new HashMap<>(); for (Player player : InteractionVisualizerAPI.getPlayerModuleList(Modules.HOLOGRAM, KEY)) { if (player.isOnline()) { @@ -338,6 +401,7 @@ private void reconcileEligibleViewers() { } } } + return desired.values(); } private void showToEligibleViewers(TextDisplay label) { @@ -390,15 +454,6 @@ private String matchingName(ItemStack stack) { return stripColorBlacklist ? ChatColorUtils.stripColor(plain) : plain; } - private boolean isCramping(Item item) { - return cramp > 0 && item.getWorld() - .getNearbyEntitiesByType(Item.class, item.getLocation(), 0.5, 0.5, 0.5) - .stream() - .filter(nearby -> !isOwned(nearby)) - .limit(cramp + 1L) - .count() > cramp; - } - private void remove(UUID itemId) { trackedItems.remove(itemId); removeLabel(itemId); @@ -427,4 +482,7 @@ private static boolean isOwned(Entity entity) { private static NamespacedKey ownerKey() { return new NamespacedKey(InteractionVisualizer.plugin, "visual_entity"); } + + private record TrackedItem(UUID itemId, Item item, Location location) { + } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java new file mode 100644 index 0000000..d647a37 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java @@ -0,0 +1,136 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Per-update spatial indexes for dropped items and eligible viewers. */ +final class DroppedItemSpatialIndex { + + private static final double ITEM_QUERY_RADIUS = 0.5D; + private static final double ITEM_CELL_SIZE = ITEM_QUERY_RADIUS; + private static final double VIEWER_CELL_SIZE = 16.0D; + + private final Map> itemCells = new HashMap<>(); + + void addItem(UUID worldId, double x, double y, double z) { + itemCells.computeIfAbsent(itemCell(worldId, x, y, z), ignored -> new ArrayList<>()) + .add(new Point(x, y, z)); + } + + boolean exceedsItemLimit(UUID worldId, double x, double y, double z, int maximum) { + if (maximum < 1) { + return false; + } + + int minimumX = itemCoordinate(x - ITEM_QUERY_RADIUS); + int maximumX = itemCoordinate(x + ITEM_QUERY_RADIUS); + int minimumY = itemCoordinate(y - ITEM_QUERY_RADIUS); + int maximumY = itemCoordinate(y + ITEM_QUERY_RADIUS); + int minimumZ = itemCoordinate(z - ITEM_QUERY_RADIUS); + int maximumZ = itemCoordinate(z + ITEM_QUERY_RADIUS); + int matches = 0; + + for (int cellX = minimumX; cellX <= maximumX; cellX++) { + for (int cellY = minimumY; cellY <= maximumY; cellY++) { + for (int cellZ = minimumZ; cellZ <= maximumZ; cellZ++) { + List points = itemCells.get(new Cell(worldId, cellX, cellY, cellZ)); + if (points == null) { + continue; + } + for (Point point : points) { + if (Math.abs(point.x() - x) <= ITEM_QUERY_RADIUS + && Math.abs(point.y() - y) <= ITEM_QUERY_RADIUS + && Math.abs(point.z() - z) <= ITEM_QUERY_RADIUS + && ++matches > maximum) { + return true; + } + } + } + } + } + return false; + } + + private static Cell itemCell(UUID worldId, double x, double y, double z) { + return new Cell(worldId, itemCoordinate(x), itemCoordinate(y), itemCoordinate(z)); + } + + private static int itemCoordinate(double coordinate) { + return (int) Math.floor(coordinate / ITEM_CELL_SIZE); + } + + static final class ViewerIndex { + + private final Map> viewerCells = new HashMap<>(); + + void addViewer(UUID worldId, double x, double y, double z) { + viewerCells.computeIfAbsent(viewerCell(worldId, x, z), ignored -> new ArrayList<>()) + .add(new Point(x, y, z)); + } + + boolean isEmpty() { + return viewerCells.isEmpty(); + } + + boolean hasViewerWithin(UUID worldId, double x, double y, double z, double range) { + if (range < 0.0D) { + return false; + } + + int minimumX = viewerCoordinate(x - range); + int maximumX = viewerCoordinate(x + range); + int minimumZ = viewerCoordinate(z - range); + int maximumZ = viewerCoordinate(z + range); + double rangeSquared = range * range; + + for (int cellX = minimumX; cellX <= maximumX; cellX++) { + for (int cellZ = minimumZ; cellZ <= maximumZ; cellZ++) { + List points = viewerCells.get(new ViewerCell(worldId, cellX, cellZ)); + if (points == null) { + continue; + } + for (Point point : points) { + double deltaX = point.x() - x; + double deltaY = point.y() - y; + double deltaZ = point.z() - z; + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + return true; + } + } + } + } + return false; + } + + private static ViewerCell viewerCell(UUID worldId, double x, double z) { + return new ViewerCell(worldId, viewerCoordinate(x), viewerCoordinate(z)); + } + + private static int viewerCoordinate(double coordinate) { + return (int) Math.floor(coordinate / VIEWER_CELL_SIZE); + } + } + + private record Cell(UUID worldId, int x, int y, int z) { + } + + private record ViewerCell(UUID worldId, int x, int z) { + } + + private record Point(double x, double y, double z) { + } +} diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index 5e7bcf7..f456787 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -281,6 +281,9 @@ Entities: #This is in ticks (20 ticks = 1 second) #Setting this too low might impact performance UpdateRate: 20 + #Maximum distance in blocks at which a dropped-item label is kept active + #Labels outside every eligible player's range are not spawned server-side + ViewDistance: 64 #Paper does not expose its internal per-item despawn rate. This value #drives the public-API timer (6000 ticks = 5 minutes). DespawnTicks: 6000 diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java new file mode 100644 index 0000000..a523869 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java @@ -0,0 +1,55 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DroppedItemSpatialIndexTest { + + @Test + void preservesTheOriginalHalfBlockCrampingBoxAcrossCellBoundaries() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex index = new DroppedItemSpatialIndex(); + index.addItem(world, 0.49D, 64.0D, 0.49D); + index.addItem(world, 0.99D, 64.5D, -0.01D); + + assertTrue(index.exceedsItemLimit(world, 0.49D, 64.0D, 0.49D, 1)); + assertFalse(index.exceedsItemLimit(world, 0.49D, 64.0D, 0.49D, 2)); + } + + @Test + void excludesItemsOutsideTheBoxAndItemsInOtherWorlds() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex index = new DroppedItemSpatialIndex(); + index.addItem(world, 0.0D, 64.0D, 0.0D); + index.addItem(world, 0.5001D, 64.0D, 0.0D); + index.addItem(UUID.randomUUID(), 0.0D, 64.0D, 0.0D); + + assertFalse(index.exceedsItemLimit(world, 0.0D, 64.0D, 0.0D, 1)); + } + + @Test + void findsViewersAcrossChunkBoundariesUsingExactDistance() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(); + viewers.addViewer(world, 16.1D, 64.0D, 0.0D); + + assertTrue(viewers.hasViewerWithin(world, 15.9D, 64.0D, 0.0D, 0.3D)); + assertFalse(viewers.hasViewerWithin(world, 15.9D, 64.0D, 0.0D, 0.1D)); + assertFalse(viewers.hasViewerWithin(UUID.randomUUID(), 15.9D, 64.0D, 0.0D, 1.0D)); + } +} From b81b6a189326a6404fecd7cb707dd340c4b9b3d0 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 01:11:42 +0800 Subject: [PATCH 02/59] Rate limit dropped label visibility --- .../entities/DroppedItemDisplay.java | 160 ++++++++++++++---- .../entities/VisibilityTokenBucket.java | 65 +++++++ common/src/main/resources/config.yml | 4 + .../entities/VisibilityTokenBucketTest.java | 46 +++++ 4 files changed, 244 insertions(+), 31 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java index ee3c5c3..1ae536f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -76,10 +76,13 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen public static final EntryKey KEY = new EntryKey("item"); private static final int DEFAULT_VIEW_DISTANCE = 64; private static final int VIEW_DISTANCE_HYSTERESIS = 16; + private static final int DEFAULT_VISIBILITY_BUCKET_SIZE = 128; + private static final int DEFAULT_VISIBILITY_REFILL = 32; private final Map trackedItems = new HashMap<>(); private final Map labels = new HashMap<>(); private final Set eligibleViewers = new HashSet<>(); + private final Map visibilityStates = new HashMap<>(); private String regularFormatting; private String singularFormatting; @@ -93,6 +96,8 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private int ticksUntilUpdate; private int despawnTicks = 6000; private int viewDistance = DEFAULT_VIEW_DISTANCE; + private int visibilityBucketSize = DEFAULT_VISIBILITY_BUCKET_SIZE; + private int visibilityRefill = DEFAULT_VISIBILITY_REFILL; private boolean stripColorBlacklist; private DroppedItemBlacklist blacklist = DroppedItemBlacklist.compile(List.of(), DroppedItemDisplay::warn); @@ -118,6 +123,16 @@ public void onReload(InteractionVisualizerReloadEvent event) { viewDistance = configuredViewDistance > 0 ? Math.max(8, Math.min(512, configuredViewDistance)) : DEFAULT_VIEW_DISTANCE; + int configuredBucketSize = InteractionVisualizer.plugin.getConfiguration() + .getInt("Entities.Item.Options.VisibilityRateLimit.BucketSize"); + visibilityBucketSize = configuredBucketSize > 0 + ? configuredBucketSize + : DEFAULT_VISIBILITY_BUCKET_SIZE; + int configuredRefill = InteractionVisualizer.plugin.getConfiguration() + .getInt("Entities.Item.Options.VisibilityRateLimit.RestorePerTick"); + visibilityRefill = configuredRefill > 0 + ? configuredRefill + : DEFAULT_VISIBILITY_REFILL; int configuredDespawnTicks = InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.DespawnTicks"); despawnTicks = configuredDespawnTicks > 0 ? configuredDespawnTicks : 6000; stripColorBlacklist = InteractionVisualizer.plugin.getConfiguration() @@ -158,6 +173,7 @@ public ScheduledTask run() { return new ScheduledRunnable() { @Override public void run() { + drainVisibilityQueues(); if (--ticksUntilUpdate <= 0) { ticksUntilUpdate = updateRate; tickAll(); @@ -201,7 +217,10 @@ public void onEntityRemove(EntityRemoveEvent event) { if (label != null) { // EntityRemoveEvent is monitoring-only. Defer entity mutation // until Paper has finished removing the item's passengers. - Scheduler.runTask(InteractionVisualizer.plugin, () -> removeLabel(label)); + Scheduler.runTask(InteractionVisualizer.plugin, () -> { + forgetLabelVisibility(itemId, label); + removeLabel(label); + }); } } } @@ -247,6 +266,7 @@ private void tickAll() { for (TrackedItem tracked : validItems) { update(tracked, itemIndex, viewerIndex); } + reconcileLabelVisibility(viewers, validItems); } private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, @@ -281,12 +301,10 @@ private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, } Component text = format(stack, ticksLeft); - boolean created = false; if (label == null || !label.isValid() || !label.getWorld().equals(item.getWorld())) { removeLabel(item.getUniqueId()); label = spawnLabel(item); labels.put(item.getUniqueId(), label); - created = true; } if (!text.equals(label.text())) { label.text(text); @@ -316,11 +334,6 @@ private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, } label.teleport(labelLocation(item)); } - if (created) { - // Mount and configure the final render height before revealing the - // label so the first tracking bundle cannot flash at item height. - showToEligibleViewers(label); - } } private TextDisplay spawnLabel(Item item) { @@ -381,36 +394,100 @@ private Collection reconcileEligibleViewers() { for (UUID uuid : new HashSet<>(eligibleViewers)) { if (!desired.containsKey(uuid)) { Player player = Bukkit.getPlayer(uuid); - if (player != null) { - for (TextDisplay label : labels.values()) { - if (label.isValid()) { - player.hideEntity(InteractionVisualizer.plugin, label); - } - } - } + removeVisibilityState(uuid, player); eligibleViewers.remove(uuid); } } for (Map.Entry entry : desired.entrySet()) { - if (eligibleViewers.add(entry.getKey())) { - Player player = entry.getValue(); - for (TextDisplay label : labels.values()) { - if (label.isValid()) { - player.showEntity(InteractionVisualizer.plugin, label); + eligibleViewers.add(entry.getKey()); + } + return desired.values(); + } + + private void reconcileLabelVisibility(Collection viewers, List validItems) { + for (Player player : viewers) { + UUID playerId = player.getUniqueId(); + VisibilityState state = visibilityStates.computeIfAbsent(playerId, + ignored -> new VisibilityState(visibilityBucketSize)); + Set desired = new HashSet<>(); + Location playerLocation = player.getLocation(); + int trackingDistance = InteractionVisualizer.playerTrackingRange + .getOrDefault(player.getWorld(), DEFAULT_VIEW_DISTANCE); + double range = Math.min(viewDistance, trackingDistance); + double rangeSquared = range * range; + + for (TrackedItem tracked : validItems) { + TextDisplay label = labels.get(tracked.itemId()); + if (label == null || !label.isValid() || !tracked.item().getWorld().equals(player.getWorld())) { + continue; + } + Location location = tracked.location(); + double deltaX = location.getX() - playerLocation.getX(); + double deltaY = location.getY() - playerLocation.getY(); + double deltaZ = location.getZ() - playerLocation.getZ(); + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + desired.add(tracked.itemId()); + } + } + + for (UUID itemId : new HashSet<>(state.shown)) { + if (!desired.contains(itemId)) { + TextDisplay label = labels.get(itemId); + if (label != null && label.isValid()) { + player.hideEntity(InteractionVisualizer.plugin, label); } + state.shown.remove(itemId); + } + } + for (UUID itemId : state.desired) { + if (!desired.contains(itemId)) { + state.pending.cancel(itemId); + } + } + state.desired = desired; + for (UUID itemId : desired) { + if (!state.shown.contains(itemId)) { + state.pending.request(itemId); + } + } + } + } + + private void drainVisibilityQueues() { + for (Map.Entry entry : visibilityStates.entrySet()) { + Player player = Bukkit.getPlayer(entry.getKey()); + VisibilityState state = entry.getValue(); + if (player == null || !player.isOnline() || !eligibleViewers.contains(entry.getKey())) { + continue; + } + for (UUID itemId : state.pending.drain(visibilityBucketSize, visibilityRefill, id -> { + TextDisplay label = labels.get(id); + return state.desired.contains(id) && !state.shown.contains(id) + && label != null && label.isValid(); + })) { + TextDisplay label = labels.get(itemId); + if (label != null && label.isValid()) { + player.showEntity(InteractionVisualizer.plugin, label); + state.shown.add(itemId); } } } - return desired.values(); } - private void showToEligibleViewers(TextDisplay label) { - for (UUID uuid : eligibleViewers) { - Player player = Bukkit.getPlayer(uuid); - if (player != null) { - player.showEntity(InteractionVisualizer.plugin, label); + private void removeVisibilityState(UUID playerId, Player player) { + VisibilityState state = visibilityStates.remove(playerId); + if (state == null) { + return; + } + if (player != null) { + for (UUID itemId : state.shown) { + TextDisplay label = labels.get(itemId); + if (label != null && label.isValid()) { + player.hideEntity(InteractionVisualizer.plugin, label); + } } } + state.pending.clear(); } private Component format(ItemStack stack, int ticksLeft) { @@ -460,17 +537,27 @@ private void remove(UUID itemId) { } private void removeLabel(UUID itemId) { - removeLabel(labels.remove(itemId)); + TextDisplay label = labels.remove(itemId); + forgetLabelVisibility(itemId, label); + removeLabel(label); } - private void removeLabel(TextDisplay label) { - if (label != null && label.isValid()) { - for (UUID uuid : eligibleViewers) { - Player player = Bukkit.getPlayer(uuid); + private void forgetLabelVisibility(UUID itemId, TextDisplay label) { + for (Map.Entry entry : visibilityStates.entrySet()) { + VisibilityState state = entry.getValue(); + state.desired.remove(itemId); + state.pending.cancel(itemId); + if (state.shown.remove(itemId) && label != null && label.isValid()) { + Player player = Bukkit.getPlayer(entry.getKey()); if (player != null) { player.hideEntity(InteractionVisualizer.plugin, label); } } + } + } + + private void removeLabel(TextDisplay label) { + if (label != null && label.isValid()) { label.remove(); } } @@ -485,4 +572,15 @@ private static NamespacedKey ownerKey() { private record TrackedItem(UUID itemId, Item item, Location location) { } + + private static final class VisibilityState { + + private Set desired = new HashSet<>(); + private final Set shown = new HashSet<>(); + private final VisibilityTokenBucket pending; + + private VisibilityState(int initialTokens) { + this.pending = new VisibilityTokenBucket<>(initialTokens); + } + } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java new file mode 100644 index 0000000..d790199 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java @@ -0,0 +1,65 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +/** Deduplicated token bucket used to smooth client visibility bursts. */ +final class VisibilityTokenBucket { + + private final Deque pending = new ArrayDeque<>(); + private final Set queued = new HashSet<>(); + private int tokens; + + VisibilityTokenBucket(int initialTokens) { + this.tokens = Math.max(0, initialTokens); + } + + void request(T value) { + if (queued.add(value)) { + pending.addLast(value); + } + } + + void cancel(T value) { + queued.remove(value); + } + + List drain(int capacity, int refill, Predicate stillWanted) { + tokens = Math.min(Math.max(1, capacity), tokens + Math.max(0, refill)); + if (tokens == 0 || pending.isEmpty()) { + return List.of(); + } + + List ready = new ArrayList<>(Math.min(tokens, pending.size())); + while (tokens > 0 && !pending.isEmpty()) { + T value = pending.removeFirst(); + if (!queued.remove(value) || !stillWanted.test(value)) { + continue; + } + ready.add(value); + tokens--; + } + return ready; + } + + void clear() { + pending.clear(); + queued.clear(); + } +} diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index f456787..a13dceb 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -284,6 +284,10 @@ Entities: #Maximum distance in blocks at which a dropped-item label is kept active #Labels outside every eligible player's range are not spawned server-side ViewDistance: 64 + #Smooth label visibility bursts when entering areas with many dropped items + VisibilityRateLimit: + BucketSize: 128 + RestorePerTick: 32 #Paper does not expose its internal per-item despawn rate. This value #drives the public-API timer (6000 ticks = 5 minutes). DespawnTicks: 6000 diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java new file mode 100644 index 0000000..f3aecb5 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java @@ -0,0 +1,46 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class VisibilityTokenBucketTest { + + @Test + void rateLimitsAndRefillsVisibilityChanges() { + VisibilityTokenBucket bucket = new VisibilityTokenBucket<>(2); + bucket.request(1); + bucket.request(2); + bucket.request(3); + bucket.request(4); + + assertEquals(List.of(1, 2), bucket.drain(2, 0, ignored -> true)); + assertEquals(List.of(3), bucket.drain(2, 1, ignored -> true)); + assertEquals(List.of(4), bucket.drain(2, 1, ignored -> true)); + } + + @Test + void deduplicatesAndDropsCancelledOrStaleRequests() { + VisibilityTokenBucket bucket = new VisibilityTokenBucket<>(4); + bucket.request(1); + bucket.request(1); + bucket.request(2); + bucket.request(3); + bucket.cancel(2); + + assertEquals(List.of(1), bucket.drain(4, 0, value -> value != 3)); + } +} From 3004f878aeaf31abcf5bd059a6fc87dfb5534d28 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 01:22:46 +0800 Subject: [PATCH 03/59] Add Paper A-B performance benchmark --- .github/workflows/benchmark.yml | 77 ++++ .../entities/DroppedItemBenchmarkPlugin.java | 356 ++++++++++++++++++ benchmark/src/main/resources/plugin.yml | 5 + build.gradle.kts | 18 + 4 files changed, 456 insertions(+) create mode 100644 .github/workflows/benchmark.yml create mode 100644 benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java create mode 100644 benchmark/src/main/resources/plugin.yml diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..a0a2bef --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,77 @@ +name: A-B Performance Benchmark + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + benchmark: + name: Paper 26.1.2 A-B benchmark + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build disposable benchmark plugin + run: ./gradlew benchmarkJar --no-daemon --no-build-cache --rerun-tasks + + - name: Download stable Paper 26.1.2 + env: + PAPER_USER_AGENT: InteractionVisualizer-Benchmark/1.0 (https://github.com/EllanServer/InteractionVisualizer) + run: | + mkdir -p benchmark-server/plugins + BUILDS=$(curl --fail --silent --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + https://fill.papermc.io/v3/projects/paper/versions/26.1.2/builds) + PAPER_URL=$(echo "$BUILDS" | jq -r 'first(.[] | select(.channel == "STABLE") | .downloads."server:default".url) // empty') + test -n "$PAPER_URL" + curl --fail --location --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + --output benchmark-server/server.jar "$PAPER_URL" + cp build/libs/InteractionVisualizer-*-benchmark.jar benchmark-server/plugins/ + + - name: Configure isolated benchmark server + working-directory: benchmark-server + run: | + echo 'eula=true' > eula.txt + cat > server.properties <<'EOF' + online-mode=false + level-name=benchmark-world + level-type=minecraft:flat + generate-structures=false + spawn-protection=0 + view-distance=3 + simulation-distance=3 + max-players=1 + sync-chunk-writes=false + EOF + + - name: Run alternating A-B measurements + working-directory: benchmark-server + run: | + java -Xms2G -Xmx2G -XX:+UseG1GC -jar server.jar --nogui | tee benchmark-server.log + test -f plugins/InteractionVisualizerBenchmark/benchmark-results.jsonl + + - name: Publish benchmark results + if: always() + uses: actions/upload-artifact@v4 + with: + name: interactionvisualizer-ab-${{ github.sha }} + path: | + benchmark-server/plugins/InteractionVisualizerBenchmark/benchmark-results.jsonl + benchmark-server/benchmark-server.log + if-no-files-found: warn + retention-days: 30 diff --git a/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java b/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java new file mode 100644 index 0000000..bc0dd35 --- /dev/null +++ b/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java @@ -0,0 +1,356 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Item; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.util.Vector; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.function.LongSupplier; + +/** + * Disposable benchmark plugin run only by the GitHub Actions performance job. + * It deliberately alternates A/B order and reports medians and p95 values. + */ +public final class DroppedItemBenchmarkPlugin extends JavaPlugin { + + private static final int WARMUP_ROUNDS = 6; + private static final int MEASUREMENT_ROUNDS = 18; + private static final double VIEW_DISTANCE = 64.0D; + private static volatile long blackhole; + + private final List results = new ArrayList<>(); + + @Override + public void onEnable() { + Bukkit.getScheduler().runTask(this, () -> { + try { + runBenchmarks(); + } catch (Throwable throwable) { + getLogger().severe("A/B benchmark failed: " + throwable.getMessage()); + throwable.printStackTrace(); + } finally { + Bukkit.shutdown(); + } + }); + } + + private void runBenchmarks() throws IOException { + World world = Bukkit.getWorlds().getFirst(); + world.setAutoSave(false); + world.getEntitiesByClass(Item.class).forEach(Item::remove); + + for (int itemCount : List.of(250, 1000, 2500)) { + benchmarkCramping(world, itemCount, false); + benchmarkCramping(world, itemCount, true); + } + for (int itemCount : List.of(500, 2000, 8000)) { + for (int viewerCount : List.of(1, 10, 50)) { + benchmarkVisibility(itemCount, viewerCount); + } + } + for (int pending : List.of(100, 500, 2000, 8000)) { + reportTokenBucket(pending, 128, 32); + } + + Files.createDirectories(getDataFolder().toPath()); + Path output = getDataFolder().toPath().resolve("benchmark-results.jsonl"); + Files.write(output, results); + getLogger().info("Wrote " + results.size() + " A/B results to " + output.toAbsolutePath()); + } + + private void benchmarkCramping(World world, int itemCount, boolean clustered) { + List items = spawnItems(world, itemCount, clustered); + UUID worldId = world.getUID(); + LongSupplier baseline = () -> paperNearbyQueries(items); + LongSupplier candidate = () -> indexedNearbyQueries(items, worldId); + Comparison comparison = compare(baseline, candidate); + String result = String.format(Locale.ROOT, + "{\"benchmark\":\"cramping\",\"distribution\":\"%s\",\"items\":%d," + + "\"baselineMedianNs\":%d,\"baselineP95Ns\":%d," + + "\"candidateMedianNs\":%d,\"candidateP95Ns\":%d,\"speedup\":%.3f}", + clustered ? "clustered" : "sparse", itemCount, + comparison.baseline().median(), comparison.baseline().p95(), + comparison.candidate().median(), comparison.candidate().p95(), comparison.speedup()); + report(result); + items.forEach(Item::remove); + } + + private List spawnItems(World world, int itemCount, boolean clustered) { + int logicalCount = clustered ? (itemCount + 7) / 8 : itemCount; + int columns = (int) Math.ceil(Math.sqrt(logicalCount)); + double spacing = clustered ? 2.0D : 1.25D; + List items = new ArrayList<>(itemCount); + for (int index = 0; index < itemCount; index++) { + int logicalIndex = clustered ? index / 8 : index; + double baseX = 8.0D + (logicalIndex % columns) * spacing; + double baseZ = 8.0D + (logicalIndex / columns) * spacing; + int clusterIndex = clustered ? index % 8 : 0; + double x = baseX + (clusterIndex & 1) * 0.12D; + double y = 80.0D + ((clusterIndex >>> 1) & 1) * 0.12D; + double z = baseZ + ((clusterIndex >>> 2) & 1) * 0.12D; + world.getChunkAt((int) Math.floor(x) >> 4, (int) Math.floor(z) >> 4).load(); + ItemStack stack = new ItemStack(Material.STONE); + stack.editMeta(meta -> meta.customName(Component.text("iv-benchmark-" + index))); + Item item = world.dropItem(new Location(world, x, y, z), stack, entity -> { + entity.setGravity(false); + entity.setVelocity(new Vector()); + entity.setPickupDelay(Integer.MAX_VALUE); + entity.setWillAge(false); + }); + items.add(item); + } + return items; + } + + private static long paperNearbyQueries(List items) { + long cramped = 0; + for (Item item : items) { + if (item.getWorld().getNearbyEntitiesByType(Item.class, item.getLocation(), 0.5D, 0.5D, 0.5D) + .stream().limit(7L).count() > 6L) { + cramped++; + } + } + blackhole = cramped; + return cramped; + } + + private static long indexedNearbyQueries(List items, UUID worldId) { + DroppedItemSpatialIndex index = new DroppedItemSpatialIndex(); + List locations = new ArrayList<>(items.size()); + for (Item item : items) { + Location location = item.getLocation(); + locations.add(location); + index.addItem(worldId, location.getX(), location.getY(), location.getZ()); + } + long cramped = 0; + for (Location location : locations) { + if (index.exceedsItemLimit(worldId, location.getX(), location.getY(), location.getZ(), 6)) { + cramped++; + } + } + blackhole = cramped; + return cramped; + } + + private void benchmarkVisibility(int itemCount, int viewerCount) { + Random random = new Random(0x51A71A1L + itemCount * 31L + viewerCount); + List items = randomPoints(random, itemCount, 1024.0D); + List viewers = randomPoints(random, viewerCount, 1024.0D); + LongSupplier baseline = () -> scanVisibility(items, viewers); + LongSupplier candidate = () -> indexedVisibility(items, viewers); + long expectedPairs = baseline.getAsLong(); + long candidatePairs = candidate.getAsLong(); + if (expectedPairs != candidatePairs) { + throw new IllegalStateException("Visibility A/B mismatch: " + expectedPairs + " != " + candidatePairs); + } + Comparison comparison = compare(baseline, candidate); + int activeLabels = activeLabels(items, viewers); + double reduction = itemCount == 0 ? 0.0D : 100.0D * (itemCount - activeLabels) / itemCount; + String result = String.format(Locale.ROOT, + "{\"benchmark\":\"visibility\",\"items\":%d,\"viewers\":%d," + + "\"baselineMedianNs\":%d,\"baselineP95Ns\":%d," + + "\"candidateMedianNs\":%d,\"candidateP95Ns\":%d,\"speedup\":%.3f," + + "\"visiblePairs\":%d,\"allLabels\":%d,\"activeLabels\":%d," + + "\"labelReductionPct\":%.3f}", + itemCount, viewerCount, + comparison.baseline().median(), comparison.baseline().p95(), + comparison.candidate().median(), comparison.candidate().p95(), comparison.speedup(), + expectedPairs, itemCount, activeLabels, reduction); + report(result); + } + + private static List randomPoints(Random random, int count, double width) { + List points = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + points.add(new Point(index, random.nextDouble(width), 64.0D + random.nextDouble(32.0D), + random.nextDouble(width))); + } + return points; + } + + private static long scanVisibility(List items, List viewers) { + long pairs = 0; + double rangeSquared = VIEW_DISTANCE * VIEW_DISTANCE; + for (Point viewer : viewers) { + Set desired = new HashSet<>(); + for (Point item : items) { + if (item.distanceSquared(viewer) <= rangeSquared) { + desired.add(item.id()); + } + } + pairs += desired.size(); + } + blackhole = pairs; + return pairs; + } + + private static long indexedVisibility(List items, List viewers) { + VisibilityIndex index = new VisibilityIndex(); + for (Point item : items) { + index.add(item); + } + long pairs = 0; + for (Point viewer : viewers) { + pairs += index.nearby(viewer, VIEW_DISTANCE).size(); + } + blackhole = pairs; + return pairs; + } + + private static int activeLabels(List items, List viewers) { + int active = 0; + double rangeSquared = VIEW_DISTANCE * VIEW_DISTANCE; + for (Point item : items) { + for (Point viewer : viewers) { + if (item.distanceSquared(viewer) <= rangeSquared) { + active++; + break; + } + } + } + return active; + } + + private void reportTokenBucket(int pending, int capacity, int refill) { + int firstTick = Math.min(pending, capacity); + int remaining = Math.max(0, pending - firstTick); + int convergenceTicks = pending == 0 ? 0 : 1 + (remaining + refill - 1) / refill; + double peakReduction = pending == 0 ? 0.0D : 100.0D * (pending - firstTick) / pending; + report(String.format(Locale.ROOT, + "{\"benchmark\":\"visibility-burst\",\"pending\":%d," + + "\"baselinePeakOpsPerTick\":%d,\"candidatePeakOpsPerTick\":%d," + + "\"peakReductionPct\":%.3f,\"convergenceTicks\":%d}", + pending, pending, firstTick, peakReduction, convergenceTicks)); + } + + private static Comparison compare(LongSupplier baseline, LongSupplier candidate) { + for (int round = 0; round < WARMUP_ROUNDS; round++) { + if ((round & 1) == 0) { + baseline.getAsLong(); + candidate.getAsLong(); + } else { + candidate.getAsLong(); + baseline.getAsLong(); + } + } + long[] baselineTimes = new long[MEASUREMENT_ROUNDS]; + long[] candidateTimes = new long[MEASUREMENT_ROUNDS]; + for (int round = 0; round < MEASUREMENT_ROUNDS; round++) { + if ((round & 1) == 0) { + baselineTimes[round] = time(baseline); + candidateTimes[round] = time(candidate); + } else { + candidateTimes[round] = time(candidate); + baselineTimes[round] = time(baseline); + } + } + Samples baselineSamples = Samples.of(baselineTimes); + Samples candidateSamples = Samples.of(candidateTimes); + return new Comparison(baselineSamples, candidateSamples, + (double) baselineSamples.median() / Math.max(1L, candidateSamples.median())); + } + + private static long time(LongSupplier operation) { + long started = System.nanoTime(); + operation.getAsLong(); + return System.nanoTime() - started; + } + + private void report(String result) { + results.add(result); + getLogger().info("IV_BENCH_RESULT " + result); + } + + private record Samples(long median, long p95) { + + private static Samples of(long[] values) { + long[] sorted = Arrays.copyOf(values, values.length); + Arrays.sort(sorted); + return new Samples(sorted[sorted.length / 2], sorted[(int) Math.ceil(sorted.length * 0.95D) - 1]); + } + } + + private record Comparison(Samples baseline, Samples candidate, double speedup) { + } + + private record Point(int id, double x, double y, double z) { + + private double distanceSquared(Point other) { + double deltaX = x - other.x; + double deltaY = y - other.y; + double deltaZ = z - other.z; + return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ; + } + } + + private static final class VisibilityIndex { + + private static final double CELL_SIZE = 16.0D; + private final Map> cells = new HashMap<>(); + + private void add(Point point) { + cells.computeIfAbsent(key(coordinate(point.x()), coordinate(point.z())), ignored -> new ArrayList<>()) + .add(point); + } + + private Set nearby(Point viewer, double range) { + int minimumX = coordinate(viewer.x() - range); + int maximumX = coordinate(viewer.x() + range); + int minimumZ = coordinate(viewer.z() - range); + int maximumZ = coordinate(viewer.z() + range); + double rangeSquared = range * range; + Set nearby = new HashSet<>(); + for (int cellX = minimumX; cellX <= maximumX; cellX++) { + for (int cellZ = minimumZ; cellZ <= maximumZ; cellZ++) { + List points = cells.get(key(cellX, cellZ)); + if (points == null) { + continue; + } + for (Point point : points) { + if (point.distanceSquared(viewer) <= rangeSquared) { + nearby.add(point.id()); + } + } + } + } + return nearby; + } + + private static int coordinate(double value) { + return (int) Math.floor(value / CELL_SIZE); + } + + private static long key(int x, int z) { + return ((long) x << 32) ^ (z & 0xFFFFFFFFL); + } + } +} diff --git a/benchmark/src/main/resources/plugin.yml b/benchmark/src/main/resources/plugin.yml new file mode 100644 index 0000000..d061923 --- /dev/null +++ b/benchmark/src/main/resources/plugin.yml @@ -0,0 +1,5 @@ +name: InteractionVisualizerBenchmark +version: '1.0' +main: com.loohp.interactionvisualizer.entities.DroppedItemBenchmarkPlugin +api-version: '26.1' +description: Disposable A/B performance harness for InteractionVisualizer diff --git a/build.gradle.kts b/build.gradle.kts index f0af2b3..28b4c74 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -70,6 +70,24 @@ sourceSets { } } +val benchmarkSourceSet = sourceSets.create("benchmark") { + java.setSrcDirs(listOf("benchmark/src/main/java")) + resources.setSrcDirs(listOf("benchmark/src/main/resources")) + compileClasspath += sourceSets.main.get().output + configurations.compileClasspath.get() + runtimeClasspath += output + compileClasspath +} + +val benchmarkJar = tasks.register("benchmarkJar") { + description = "Builds the standalone Paper A/B benchmark plugin (never shipped in production)." + group = LifecycleBasePlugin.VERIFICATION_GROUP + dependsOn(benchmarkSourceSet.classesTaskName) + archiveClassifier = "benchmark" + from(benchmarkSourceSet.output) + from(sourceSets.main.get().output) { + include("com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex*.class") + } +} + tasks.withType().configureEach { options.release = 25 options.encoding = "UTF-8" From 599453766efdd5834727be479f3febd61431e7bf Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 01:23:17 +0800 Subject: [PATCH 04/59] Run performance benchmark for relevant PR changes --- .github/workflows/benchmark.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index a0a2bef..b4666ac 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -2,6 +2,14 @@ name: A-B Performance Benchmark on: workflow_dispatch: + pull_request: + paths: + - '.github/workflows/benchmark.yml' + - 'benchmark/**' + - 'build.gradle.kts' + - 'common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java' + - 'common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java' + - 'common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java' permissions: contents: read From bacf810914ab8ff73f97b0dbfdc0666e8a1b2be0 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 01:25:22 +0800 Subject: [PATCH 05/59] Fix benchmark fixture compilation --- .../entities/DroppedItemBenchmarkPlugin.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java b/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java index bc0dd35..a6020c7 100644 --- a/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java +++ b/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java @@ -119,7 +119,8 @@ private List spawnItems(World world, int itemCount, boolean clustered) { double z = baseZ + ((clusterIndex >>> 2) & 1) * 0.12D; world.getChunkAt((int) Math.floor(x) >> 4, (int) Math.floor(z) >> 4).load(); ItemStack stack = new ItemStack(Material.STONE); - stack.editMeta(meta -> meta.customName(Component.text("iv-benchmark-" + index))); + int itemId = index; + stack.editMeta(meta -> meta.customName(Component.text("iv-benchmark-" + itemId))); Item item = world.dropItem(new Location(world, x, y, z), stack, entity -> { entity.setGravity(false); entity.setVelocity(new Vector()); From 9966c66cada02a62ad961dcafd768a250ee4a44f Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 01:32:24 +0800 Subject: [PATCH 06/59] Skip unused cramping index --- .../entities/DroppedItemDisplay.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java index 1ae536f..847ce56 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -257,11 +257,13 @@ private void tickAll() { return; } - DroppedItemSpatialIndex itemIndex = new DroppedItemSpatialIndex(); - for (TrackedItem tracked : validItems) { - Location location = tracked.location(); - itemIndex.addItem(tracked.item().getWorld().getUID(), - location.getX(), location.getY(), location.getZ()); + DroppedItemSpatialIndex itemIndex = cramp > 0 ? new DroppedItemSpatialIndex() : null; + if (itemIndex != null) { + for (TrackedItem tracked : validItems) { + Location location = tracked.location(); + itemIndex.addItem(tracked.item().getWorld().getUID(), + location.getX(), location.getY(), location.getZ()); + } } for (TrackedItem tracked : validItems) { update(tracked, itemIndex, viewerIndex); @@ -294,7 +296,7 @@ private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, int ticksLeft = despawnTicks - item.getTicksLived(); if (stack.isEmpty() || blacklist.matches(matchingName, stack.getType(), customItemId) || item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 - || (cramp > 0 && itemIndex.exceedsItemLimit(item.getWorld().getUID(), + || (itemIndex != null && itemIndex.exceedsItemLimit(item.getWorld().getUID(), itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), cramp))) { removeLabel(item.getUniqueId()); return; From 0b6eb446a5fb926cca571379ee33d968b10dad70 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 01:56:01 +0800 Subject: [PATCH 07/59] Add live performance measurement harness --- .../loohp/interactionvisualizer/Commands.java | 109 ++++++ .../InteractionVisualizer.java | 2 + .../blocks/BarrelDisplay.java | 6 +- .../blocks/ChestDisplay.java | 6 +- .../blocks/DispenserDisplay.java | 6 +- .../blocks/DoubleChestDisplay.java | 6 +- .../blocks/DropperDisplay.java | 6 +- .../blocks/EnderchestDisplay.java | 6 +- .../blocks/HopperDisplay.java | 6 +- .../blocks/ShulkerBoxDisplay.java | 6 +- .../debug/PerformanceScene.java | 92 +++++ .../entities/DroppedItemDisplay.java | 18 + .../managers/DisplayManager.java | 94 +++++- .../managers/PerformanceMetrics.java | 319 ++++++++++++++++++ .../EnchantmentTableAnimation.java | 9 +- common/src/main/resources/plugin.yml | 3 + 16 files changed, 645 insertions(+), 49 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/Commands.java b/common/src/main/java/com/loohp/interactionvisualizer/Commands.java index 8cbdecf..1f5a7a4 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/Commands.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/Commands.java @@ -24,6 +24,8 @@ import com.loohp.interactionvisualizer.managers.MaterialManager; import com.loohp.interactionvisualizer.managers.MusicManager; import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; +import com.loohp.interactionvisualizer.debug.PerformanceScene; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.updater.Updater; import com.loohp.interactionvisualizer.updater.Updater.UpdaterResponse; @@ -60,6 +62,10 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String return true; } + if (args[0].equalsIgnoreCase("perf")) { + return handlePerformanceCommand(sender, args); + } + if (args[0].equalsIgnoreCase("reload")) { if (sender.hasPermission("interactionvisualizer.reload")) { plugin.loadConfig(); @@ -261,6 +267,89 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String return true; } + private boolean handlePerformanceCommand(CommandSender sender, String[] args) { + if (!sender.hasPermission("interactionvisualizer.performance")) { + sender.sendMessage(ChatColorUtils.translateAlternateColorCodes('&', plugin.getConfiguration().getString("Messages.NoPermission"))); + return true; + } + if (args.length < 2) { + sender.sendMessage(Component.text("Usage: /iv perf ")); + return true; + } + switch (args[1].toLowerCase()) { + case "start" -> { + String label = args.length >= 3 ? args[2] : "unnamed"; + if (PerformanceMetrics.start(label)) { + sender.sendMessage(Component.text("[InteractionVisualizer] Performance sampling started: " + label)); + } else { + sender.sendMessage(Component.text("[InteractionVisualizer] Performance sampling is already active.")); + } + } + case "stop" -> { + PerformanceMetrics.Snapshot snapshot = PerformanceMetrics.stop(); + if (snapshot == null) { + sender.sendMessage(Component.text("[InteractionVisualizer] Performance sampling is not active.")); + } else { + sender.sendMessage(Component.text("[InteractionVisualizer] " + snapshot.summary())); + } + } + case "status" -> { + PerformanceMetrics.Snapshot snapshot = PerformanceMetrics.snapshot(); + if (snapshot == null) { + sender.sendMessage(Component.text("[InteractionVisualizer] Performance sampling is not active.")); + } else { + sender.sendMessage(Component.text("[InteractionVisualizer] " + snapshot.summary())); + } + } + case "scene" -> { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("[InteractionVisualizer] The scene command must be run by a player.")); + return true; + } + if (args.length < 4) { + sender.sendMessage(Component.text("Usage: /iv perf scene [lifetimeTicks]")); + return true; + } + boolean moving = args[2].equalsIgnoreCase("motion"); + if (!moving && !args[2].equalsIgnoreCase("static")) { + sender.sendMessage(Component.text("[InteractionVisualizer] Scene type must be static or motion.")); + return true; + } + int count = parseInteger(args[3], 1); + long lifetime = args.length >= 5 ? parseLong(args[4], moving ? 80L : 200L) : moving ? 80L : 200L; + int spawned = PerformanceScene.spawn(player, moving, count, lifetime); + sender.sendMessage(Component.text("[InteractionVisualizer] Spawned " + spawned + " " + + (moving ? "moving" : "static") + " benchmark items for " + lifetime + " ticks.")); + } + case "clear" -> { + if (sender instanceof Player player) { + PerformanceScene.clear(player); + sender.sendMessage(Component.text("[InteractionVisualizer] Cleared your benchmark scene.")); + } else { + sender.sendMessage(Component.text("[InteractionVisualizer] The clear command must be run by a player.")); + } + } + default -> sender.sendMessage(Component.text("Usage: /iv perf ")); + } + return true; + } + + private static int parseInteger(String value, int defaultValue) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + + private static long parseLong(String value, long defaultValue) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + @Override public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { List tab = new LinkedList<>(); @@ -304,8 +393,19 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe tab.add("refresh"); } } + if (sender.hasPermission("interactionvisualizer.performance") && "perf".startsWith(args[0].toLowerCase())) { + tab.add("perf"); + } return tab; case 2: + if (args[0].equalsIgnoreCase("perf") && sender.hasPermission("interactionvisualizer.performance")) { + for (String option : List.of("start", "stop", "status", "scene", "clear")) { + if (option.startsWith(args[1].toLowerCase())) { + tab.add(option); + } + } + return tab; + } if (args[0].equalsIgnoreCase("toggle")) { if (sender.hasPermission("interactionvisualizer.toggle")) { if ("itemstand".startsWith(args[1].toLowerCase())) { @@ -324,6 +424,15 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } return tab; case 3: + if (args[0].equalsIgnoreCase("perf") && args[1].equalsIgnoreCase("scene") + && sender.hasPermission("interactionvisualizer.performance")) { + for (String option : List.of("static", "motion")) { + if (option.startsWith(args[2].toLowerCase())) { + tab.add(option); + } + } + return tab; + } if (args[0].equalsIgnoreCase("toggle")) { if (sender.hasPermission("interactionvisualizer.toggle")) { if (args[1].equalsIgnoreCase("itemstand") || args[1].equalsIgnoreCase("itemdrop") || args[1].equalsIgnoreCase("hologram") || args[1].equalsIgnoreCase("all")) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index 624f61e..506f1ff 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -31,6 +31,7 @@ import com.loohp.interactionvisualizer.managers.MusicManager; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PreferenceManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TaskManager; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.metrics.Charts; @@ -188,6 +189,7 @@ public void onEnable() { MaterialManager.setup(); getCommand("interactionvisualizer").setExecutor(new Commands()); + PerformanceMetrics.register(this); TaskManager.run(); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java index 97225d3..906d8ed 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java @@ -207,11 +207,10 @@ public void onUseBarrel(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -308,12 +307,11 @@ public void onDragBarrel(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ChestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ChestDisplay.java index ccdf426..b744901 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ChestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ChestDisplay.java @@ -216,11 +216,10 @@ public void onUseChest(InventoryClickEvent event) { vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -323,12 +322,11 @@ public void onDragChest(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DispenserDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DispenserDisplay.java index 28efcf9..3aaf810 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DispenserDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DispenserDisplay.java @@ -204,11 +204,10 @@ public void onUseDispenser(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -302,12 +301,11 @@ public void onDragDispenser(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DoubleChestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DoubleChestDisplay.java index e6aeb9a..e9d5182 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DoubleChestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DoubleChestDisplay.java @@ -231,11 +231,10 @@ public void onUseDoubleChest(InventoryClickEvent event) { vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -355,12 +354,11 @@ public void onDragDoubleChest(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DropperDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DropperDisplay.java index 8d54db5..1fe8abd 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DropperDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DropperDisplay.java @@ -207,11 +207,10 @@ public void onUseDropper(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -308,12 +307,11 @@ public void onDragDropper(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java index baa7c26..a9160c4 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java @@ -212,11 +212,10 @@ public void onUseEnderChest(InventoryClickEvent event) { vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -294,12 +293,11 @@ public void onDragEnderChest(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/HopperDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/HopperDisplay.java index 764d215..e192e8a 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/HopperDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/HopperDisplay.java @@ -207,11 +207,10 @@ public void onUseHopper(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.65, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -308,12 +307,11 @@ public void onDragHopper(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.65, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ShulkerBoxDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ShulkerBoxDisplay.java index 3cea4a3..f2db276 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ShulkerBoxDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ShulkerBoxDisplay.java @@ -208,11 +208,10 @@ public void onUseShulkerbox(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -304,12 +303,11 @@ public void onDragShulkerbox(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java new file mode 100644 index 0000000..8e62795 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java @@ -0,0 +1,92 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.debug; + +import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.scheduler.Scheduler; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Vector; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** Permission-gated, disposable visual-item workload for live A/B profiling. */ +public final class PerformanceScene { + + private static final int MAX_ITEMS = 8_192; + private static final long MAX_LIFETIME_TICKS = 12_000L; + private static final Map> scenes = new HashMap<>(); + + private PerformanceScene() { + } + + public static int spawn(Player owner, boolean moving, int requestedCount, long requestedLifetimeTicks) { + int count = Math.max(1, Math.min(MAX_ITEMS, requestedCount)); + long lifetimeTicks = Math.max(1L, Math.min(MAX_LIFETIME_TICKS, requestedLifetimeTicks)); + clear(owner); + + Collection viewers = new ArrayList<>(Bukkit.getOnlinePlayers()); + Location center = owner.getLocation().add(0.0D, 1.5D, 0.0D); + int width = (int) Math.ceil(Math.sqrt(count)); + Set items = new HashSet<>(count); + + for (int index = 0; index < count; index++) { + int xIndex = index % width; + int zIndex = index / width; + Location location = center.clone().add( + (xIndex - (width - 1) / 2.0D) * 0.36D, + (index % 5) * 0.05D, + (zIndex - (width - 1) / 2.0D) * 0.36D); + Item item = new Item(location); + item.setItemStack(new ItemStack(Material.STONE)); + if (moving) { + double angle = index * 2.399963229728653D; + double speed = 0.04D + (index % 7) * 0.005D; + item.setVelocity(new Vector(Math.cos(angle) * speed, 0.11D + (index % 3) * 0.015D, + Math.sin(angle) * speed)); + item.setGravity(true); + } + items.add(item); + DisplayManager.sendItemSpawn(viewers, item); + } + + scenes.put(owner.getUniqueId(), items); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> remove(items), lifetimeTicks, owner.getLocation()); + return count; + } + + public static void clear(Player owner) { + Set previous = scenes.remove(owner.getUniqueId()); + if (previous != null) { + remove(previous); + } + } + + private static void remove(Set items) { + for (Item item : items) { + DisplayManager.removeItem(null, item, true, false); + } + scenes.values().removeIf(current -> current == items); + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java index 847ce56..44ad9ef 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -18,6 +18,7 @@ import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; import com.loohp.interactionvisualizer.integration.CustomContentManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.utils.ComponentFont; @@ -230,6 +231,17 @@ private void track(Item item) { } private void tickAll() { + long started = PerformanceMetrics.isCollecting() ? System.nanoTime() : 0L; + try { + tickAllInternal(); + } finally { + if (started != 0L) { + PerformanceMetrics.droppedItemNanos(System.nanoTime() - started); + } + } + } + + private void tickAllInternal() { Collection viewers = reconcileEligibleViewers(); DroppedItemSpatialIndex.ViewerIndex viewerIndex = new DroppedItemSpatialIndex.ViewerIndex(); for (Player viewer : viewers) { @@ -339,6 +351,7 @@ private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, } private TextDisplay spawnLabel(Item item) { + PerformanceMetrics.bukkitEntitySpawn(); return item.getWorld().spawn(labelLocation(item), TextDisplay.class, display -> { display.setPersistent(false); @@ -436,6 +449,7 @@ private void reconcileLabelVisibility(Collection viewers, List> active = new ConcurrentHashMap<>(); public static final Map> playerStatus = new ConcurrentHashMap<>(); @@ -278,14 +281,22 @@ public static void reset(Player player) { public static void removeAll(Player player) { runSync(() -> { + List virtualItemIdsToRemove = new ArrayList<>(); for (Map.Entry> entry : shownViewers.entrySet()) { if (entry.getValue().remove(player.getUniqueId())) { if (entry.getKey() instanceof Item item) { - removeVirtualItem(item, player.getUniqueId(), player); + Integer id = forgetVirtualItem(item, player.getUniqueId()); + if (id != null) { + virtualItemIdsToRemove.add(id); + } } - entry.getKey().getBukkitEntity().ifPresent(actual -> player.hideEntity(plugin(), actual)); + entry.getKey().getBukkitEntity().ifPresent(actual -> { + PerformanceMetrics.bukkitHide(); + player.hideEntity(plugin(), actual); + }); } } + removeVirtualItems(player, virtualItemIdsToRemove); playerStatus.put(player, ConcurrentHashMap.newKeySet()); }); } @@ -342,6 +353,7 @@ private static void sync(VisualizerEntity logical, int revision) { } private static void syncDisplay(DisplayEntity logical) { + PerformanceMetrics.displaySync(); boolean text = logical.isTextDisplay(); org.bukkit.entity.Entity current = logical.getBukkitEntity().orElse(null); if (current != null && (!current.getWorld().equals(logical.getWorld()) @@ -365,6 +377,7 @@ private static void syncDisplay(DisplayEntity logical) { actual = text ? logical.getWorld().spawn(logical.getLocation(), TextDisplay.class, DisplayManager::initializeDisplay) : logical.getWorld().spawn(logical.getLocation(), org.bukkit.entity.ItemDisplay.class, DisplayManager::initializeDisplay); + PerformanceMetrics.bukkitEntitySpawn(); logical.bind(actual); trackActual(logical, actual); } else { @@ -400,6 +413,7 @@ private static void applyBase(Display actual, DisplayEntity logical) { Location target = logical.getLocation(); if (!actual.getWorld().equals(target.getWorld()) || actual.getLocation().distanceSquared(target) > 1.0E-8 || actual.getYaw() != target.getYaw() || actual.getPitch() != target.getPitch()) { + PerformanceMetrics.bukkitEntityTeleport(); actual.teleport(target); } actual.setSilent(logical.isSilent()); @@ -411,6 +425,7 @@ private static void applyBase(Display actual, DisplayEntity logical) { } private static void syncItem(Item logical) { + PerformanceMetrics.itemSync(); org.bukkit.entity.Entity current = logical.getBukkitEntity().orElse(null); if (current != null && (!current.getWorld().equals(logical.getWorld()) || !(current instanceof ItemDisplay))) { @@ -425,6 +440,7 @@ private static void syncItem(Item logical) { clearViewerTracking(logical); actual = logical.getWorld().spawn(logical.getLocation(), ItemDisplay.class, DisplayManager::initializeDisplay); + PerformanceMetrics.bukkitEntitySpawn(); logical.bind(actual); trackActual(logical, actual); } @@ -468,6 +484,7 @@ private static void applyItemBase(org.bukkit.entity.Entity actual, Item logical) actual.setCustomNameVisible(logical.isCustomNameVisible()); if (!actual.getWorld().equals(logical.getWorld()) || actual.getLocation().distanceSquared(logical.getLocation()) > 1.0E-8) { + PerformanceMetrics.bukkitEntityTeleport(); actual.teleport(logical.getLocation()); } } @@ -565,20 +582,27 @@ private static void scheduleItemAnimationTick() { } private static void tickItemAnimations() { - for (Map.Entry entry : itemAnimations.entrySet()) { - Item logical = entry.getKey(); - ItemAnimationState animation = entry.getValue(); - try { - tickItemAnimation(logical, animation); - } catch (RuntimeException exception) { - itemAnimations.remove(logical, animation); - remove(null, logical, true); - Plugin plugin = plugin(); - if (plugin != null) { - plugin.getLogger().log(Level.WARNING, - "Stopped an invalid visual item animation at " + logical.getLocation(), exception); + long started = PerformanceMetrics.isCollecting() ? System.nanoTime() : 0L; + try { + for (Map.Entry entry : itemAnimations.entrySet()) { + Item logical = entry.getKey(); + ItemAnimationState animation = entry.getValue(); + try { + tickItemAnimation(logical, animation); + } catch (RuntimeException exception) { + itemAnimations.remove(logical, animation); + remove(null, logical, true); + Plugin plugin = plugin(); + if (plugin != null) { + plugin.getLogger().log(Level.WARNING, + "Stopped an invalid visual item animation at " + logical.getLocation(), exception); + } } } + } finally { + if (started != 0L) { + PerformanceMetrics.itemAnimationNanos(System.nanoTime() - started); + } } } @@ -600,6 +624,7 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation } animation.velocity = itemVelocityAfterMovement(movement); + PerformanceMetrics.bukkitEntityTeleport(); if (!actual.teleport(destination)) { remove(null, logical, true); return; @@ -651,16 +676,19 @@ private static void spawnVirtualItem(Item logical, Player player) { try { spawnedId = SparrowHeart.getInstance().dropFakeItem( player, logical.getItemStack(), anchor.getLocation()); + PerformanceMetrics.virtualSpawnBundle(); ids.put(viewer, spawnedId); Vector motion = nextVirtualItemMotion(logical); if (motion.lengthSquared() > ITEM_ANIMATION_EPSILON) { SparrowHeart.getInstance().sendClientSideEntityMotion(player, motion, spawnedId); + PerformanceMetrics.virtualMotionBundle(); } } catch (RuntimeException exception) { if (spawnedId != null) { ids.remove(viewer, spawnedId); try { SparrowHeart.getInstance().removeClientSideEntity(player, spawnedId); + PerformanceMetrics.virtualRemovePacket(); } catch (RuntimeException cleanupException) { exception.addSuppressed(cleanupException); } @@ -696,6 +724,7 @@ private static void synchronizeVirtualItemMotion(Item logical, Location location // packet both corrects the absolute position and primes the next tick. SparrowHeart.getInstance().sendClientSideTeleportEntity( player, location, motion, false, entry.getValue()); + PerformanceMetrics.virtualTeleportBundle(); } catch (RuntimeException exception) { removeVirtualItem(logical, entry.getKey(), player); logVirtualItemFailure("move", logical, exception); @@ -742,6 +771,7 @@ private static void collectVirtualItem(Item logical, Player collector) { // lets the client resolve the collector (including its local // player fallback), so do not add a target-tracking filter. ClientPickupAnimationBridge.send(viewer, itemEntityId, collector, amount); + PerformanceMetrics.virtualPickupPacket(); } catch (RuntimeException | LinkageError exception) { removeClaimedVirtualItem(logical, viewer, itemEntityId); logVirtualItemFailure("collect", logical, exception); @@ -776,12 +806,36 @@ private static void removeClaimedVirtualItem(Item logical, Player player, int id if (player != null && player.isOnline()) { try { SparrowHeart.getInstance().removeClientSideEntity(player, id); + PerformanceMetrics.virtualRemovePacket(); } catch (RuntimeException exception) { logVirtualItemFailure("remove", logical, exception); } } } + private static void removeVirtualItems(Player player, List ids) { + if (ids.isEmpty() || player == null || !player.isOnline()) { + return; + } + for (int start = 0; start < ids.size(); start += VIRTUAL_REMOVE_BATCH_SIZE) { + int end = Math.min(ids.size(), start + VIRTUAL_REMOVE_BATCH_SIZE); + int[] batch = new int[end - start]; + for (int index = start; index < end; index++) { + batch[index - start] = ids.get(index); + } + try { + SparrowHeart.getInstance().removeClientSideEntity(player, batch); + PerformanceMetrics.virtualRemovePacket(); + } catch (RuntimeException exception) { + Plugin plugin = plugin(); + if (plugin != null) { + plugin.getLogger().log(Level.WARNING, + "Failed to batch-remove " + batch.length + " Sparrow virtual items", exception); + } + } + } + } + private static void destroyVirtualItems(Item logical) { Map ids = virtualItemIds.remove(logical); if (ids == null) { @@ -792,6 +846,7 @@ private static void destroyVirtualItems(Item logical) { if (player != null && player.isOnline()) { try { SparrowHeart.getInstance().removeClientSideEntity(player, entry.getValue()); + PerformanceMetrics.virtualRemovePacket(); } catch (RuntimeException exception) { logVirtualItemFailure("remove", logical, exception); } @@ -815,6 +870,7 @@ private static void discardActual(VisualizerEntity logical, org.bukkit.entity.En } untrackActual(logical, actual); clearViewerTracking(logical, actual); + PerformanceMetrics.bukkitEntityRemove(); actual.remove(); logical.unbind(); } @@ -888,6 +944,7 @@ private static void clearViewerTracking(VisualizerEntity logical, org.bukkit.ent Player player = Bukkit.getPlayer(uuid); if (player != null) { if (actual != null) { + PerformanceMetrics.bukkitHide(); player.hideEntity(plugin(), actual); } Set status = playerStatus.get(player); @@ -905,9 +962,11 @@ private static void removeOwnedEntities(boolean clearVisibilityOverrides) { if (entity.getPersistentDataContainer().has(key, PersistentDataType.STRING)) { if (clearVisibilityOverrides) { for (Player player : Bukkit.getOnlinePlayers()) { + PerformanceMetrics.bukkitHide(); player.hideEntity(plugin(), entity); } } + PerformanceMetrics.bukkitEntityRemove(); entity.remove(); } } @@ -936,6 +995,7 @@ private static void reconcileViewers(VisualizerEntity logical) { if (logical instanceof Item item) { removeVirtualItem(item, uuid, player); } + PerformanceMetrics.bukkitHide(); player.hideEntity(plugin(), actual); Set status = playerStatus.get(player); if (status != null) { @@ -949,6 +1009,7 @@ private static void reconcileViewers(VisualizerEntity logical) { if (shown.add(uuid)) { Player player = Bukkit.getPlayer(uuid); if (player != null) { + PerformanceMetrics.bukkitShow(); player.showEntity(plugin(), actual); playerStatus.computeIfAbsent(player, ignored -> ConcurrentHashMap.newKeySet()).add(logical); } @@ -986,6 +1047,7 @@ private static void remove(Collection players, VisualizerEntity logical, if (logical instanceof Item item) { removeVirtualItem(item, player.getUniqueId(), player); } + PerformanceMetrics.bukkitHide(); player.hideEntity(plugin(), actual); Set status = playerStatus.get(player); if (status != null) { @@ -1043,7 +1105,9 @@ public void onLeave(PlayerQuitEvent event) { for (Map.Entry> entry : shownViewers.entrySet()) { entry.getValue().remove(uuid); if (entry.getKey() instanceof Item item) { - removeVirtualItem(item, uuid, event.getPlayer()); + // The connection is closing; discard bookkeeping without + // sending a remove packet that cannot be observed. + forgetVirtualItem(item, uuid); } } playerStatus.remove(event.getPlayer()); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java new file mode 100644 index 0000000..49ff7dd --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -0,0 +1,319 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.destroystokyo.paper.event.server.ServerTickEndEvent; +import com.loohp.interactionvisualizer.InteractionVisualizer; +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; + +import java.util.Arrays; +import java.util.Locale; + +/** + * Opt-in, allocation-free counters for reproducible live A/B measurements. + * + *

The hot-path methods deliberately compile down to one predictable branch + * while sampling is disabled. They count only packet operations owned by this + * plugin; external on-wire byte accounting remains the responsibility of a + * packet capture tool.

+ */ +public final class PerformanceMetrics implements Listener { + + private static final int MAX_TICK_SAMPLES = 72_000; + private static final PerformanceMetrics INSTANCE = new PerformanceMetrics(); + + private final double[] tickDurations = new double[MAX_TICK_SAMPLES]; + + private volatile boolean collecting; + private String label = ""; + private long startedNanos; + private int tickCount; + private long tickSamplesDropped; + private long virtualSpawnBundles; + private long virtualMotionBundles; + private long virtualTeleportBundles; + private long virtualRemovePackets; + private long virtualPickupPackets; + private long bukkitEntitySpawns; + private long bukkitEntityRemoves; + private long bukkitEntityTeleports; + private long bukkitShowCalls; + private long bukkitHideCalls; + private long displaySyncs; + private long itemSyncs; + private long virtualViewerChecks; + private long itemAnimationNanos; + private long droppedItemNanos; + + private PerformanceMetrics() { + } + + public static void register(Plugin plugin) { + Bukkit.getPluginManager().registerEvents(INSTANCE, plugin); + } + + public static boolean isCollecting() { + return INSTANCE.collecting; + } + + public static boolean start(String requestedLabel) { + if (INSTANCE.collecting) { + return false; + } + INSTANCE.label = sanitizeLabel(requestedLabel); + INSTANCE.startedNanos = System.nanoTime(); + INSTANCE.tickCount = 0; + INSTANCE.tickSamplesDropped = 0; + INSTANCE.virtualSpawnBundles = 0; + INSTANCE.virtualMotionBundles = 0; + INSTANCE.virtualTeleportBundles = 0; + INSTANCE.virtualRemovePackets = 0; + INSTANCE.virtualPickupPackets = 0; + INSTANCE.bukkitEntitySpawns = 0; + INSTANCE.bukkitEntityRemoves = 0; + INSTANCE.bukkitEntityTeleports = 0; + INSTANCE.bukkitShowCalls = 0; + INSTANCE.bukkitHideCalls = 0; + INSTANCE.displaySyncs = 0; + INSTANCE.itemSyncs = 0; + INSTANCE.virtualViewerChecks = 0; + INSTANCE.itemAnimationNanos = 0; + INSTANCE.droppedItemNanos = 0; + INSTANCE.collecting = true; + return true; + } + + public static Snapshot stop() { + if (!INSTANCE.collecting) { + return null; + } + INSTANCE.collecting = false; + Snapshot snapshot = INSTANCE.snapshot(); + InteractionVisualizer.plugin.getLogger().info("IV_PERF " + snapshot.json()); + return snapshot; + } + + public static Snapshot snapshot() { + return INSTANCE.collecting ? INSTANCE.snapshot() : null; + } + + public static void virtualSpawnBundle() { + if (INSTANCE.collecting) { + INSTANCE.virtualSpawnBundles++; + } + } + + public static void virtualMotionBundle() { + if (INSTANCE.collecting) { + INSTANCE.virtualMotionBundles++; + } + } + + public static void virtualTeleportBundle() { + if (INSTANCE.collecting) { + INSTANCE.virtualTeleportBundles++; + } + } + + public static void virtualRemovePacket() { + if (INSTANCE.collecting) { + INSTANCE.virtualRemovePackets++; + } + } + + public static void virtualPickupPacket() { + if (INSTANCE.collecting) { + INSTANCE.virtualPickupPackets++; + } + } + + public static void bukkitEntitySpawn() { + if (INSTANCE.collecting) { + INSTANCE.bukkitEntitySpawns++; + } + } + + public static void bukkitEntityRemove() { + if (INSTANCE.collecting) { + INSTANCE.bukkitEntityRemoves++; + } + } + + public static void bukkitEntityTeleport() { + if (INSTANCE.collecting) { + INSTANCE.bukkitEntityTeleports++; + } + } + + public static void bukkitShow() { + if (INSTANCE.collecting) { + INSTANCE.bukkitShowCalls++; + } + } + + public static void bukkitHide() { + if (INSTANCE.collecting) { + INSTANCE.bukkitHideCalls++; + } + } + + public static void displaySync() { + if (INSTANCE.collecting) { + INSTANCE.displaySyncs++; + } + } + + public static void itemSync() { + if (INSTANCE.collecting) { + INSTANCE.itemSyncs++; + } + } + + public static void virtualViewerChecks(int checks) { + if (INSTANCE.collecting) { + INSTANCE.virtualViewerChecks += checks; + } + } + + public static void itemAnimationNanos(long nanos) { + if (INSTANCE.collecting) { + INSTANCE.itemAnimationNanos += nanos; + } + } + + public static void droppedItemNanos(long nanos) { + if (INSTANCE.collecting) { + INSTANCE.droppedItemNanos += nanos; + } + } + + @EventHandler + public void onServerTickEnd(ServerTickEndEvent event) { + if (!collecting) { + return; + } + if (tickCount < tickDurations.length) { + // Paper exposes this duration in milliseconds. + tickDurations[tickCount++] = event.getTickDuration(); + } else { + tickSamplesDropped++; + } + } + + private Snapshot snapshot() { + int samples = tickCount; + double[] sorted = Arrays.copyOf(tickDurations, samples); + Arrays.sort(sorted); + double mean = 0.0D; + long over50 = 0; + for (double duration : sorted) { + mean += duration; + if (duration > 50.0D) { + over50++; + } + } + mean = samples == 0 ? 0.0D : mean / samples; + long elapsedNanos = Math.max(1L, System.nanoTime() - startedNanos); + return new Snapshot(label, elapsedNanos, samples, tickSamplesDropped, + percentile(sorted, 0.50D), percentile(sorted, 0.95D), percentile(sorted, 0.99D), + percentile(sorted, 0.999D), samples == 0 ? 0.0D : sorted[samples - 1], mean, over50, + virtualSpawnBundles, virtualMotionBundles, virtualTeleportBundles, virtualRemovePackets, + virtualPickupPackets, bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, + bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, virtualViewerChecks, + itemAnimationNanos, droppedItemNanos); + } + + private static double percentile(double[] sorted, double percentile) { + if (sorted.length == 0) { + return 0.0D; + } + int index = (int) Math.ceil(percentile * sorted.length) - 1; + return sorted[Math.max(0, Math.min(sorted.length - 1, index))]; + } + + private static String sanitizeLabel(String value) { + if (value == null || value.isBlank()) { + return "unnamed"; + } + return value.replaceAll("[^A-Za-z0-9_.-]", "_").substring(0, Math.min(48, value.length())); + } + + public record Snapshot( + String label, + long elapsedNanos, + int tickSamples, + long droppedTickSamples, + double msptP50, + double msptP95, + double msptP99, + double msptP999, + double msptMax, + double msptMean, + long ticksOver50ms, + long virtualSpawnBundles, + long virtualMotionBundles, + long virtualTeleportBundles, + long virtualRemovePackets, + long virtualPickupPackets, + long bukkitEntitySpawns, + long bukkitEntityRemoves, + long bukkitEntityTeleports, + long bukkitShowCalls, + long bukkitHideCalls, + long displaySyncs, + long itemSyncs, + long virtualViewerChecks, + long itemAnimationNanos, + long droppedItemNanos) { + + public double seconds() { + return elapsedNanos / 1_000_000_000.0D; + } + + /** Sparrow outer packets are exact; Bukkit operations remain separately reported. */ + public long knownVirtualPackets() { + return virtualSpawnBundles + virtualMotionBundles + virtualTeleportBundles + + virtualRemovePackets + virtualPickupPackets; + } + + public String summary() { + return String.format(Locale.ROOT, + "label=%s samples=%d p50/p95/p99=%.3f/%.3f/%.3fms virtualPackets=%d anchors=%d/%d/%d", + label, tickSamples, msptP50, msptP95, msptP99, knownVirtualPackets(), + bukkitEntitySpawns, bukkitEntityTeleports, bukkitEntityRemoves); + } + + public String json() { + return String.format(Locale.ROOT, + "{\"label\":\"%s\",\"seconds\":%.3f,\"tickSamples\":%d," + + "\"droppedTickSamples\":%d,\"msptP50\":%.6f,\"msptP95\":%.6f," + + "\"msptP99\":%.6f,\"msptP999\":%.6f,\"msptMax\":%.6f," + + "\"msptMean\":%.6f,\"ticksOver50ms\":%d," + + "\"virtualSpawnBundles\":%d,\"virtualMotionBundles\":%d," + + "\"virtualTeleportBundles\":%d,\"virtualRemovePackets\":%d," + + "\"virtualPickupPackets\":%d,\"knownVirtualPackets\":%d," + + "\"bukkitEntitySpawns\":%d,\"bukkitEntityRemoves\":%d," + + "\"bukkitEntityTeleports\":%d,\"bukkitShowCalls\":%d," + + "\"bukkitHideCalls\":%d,\"displaySyncs\":%d,\"itemSyncs\":%d," + + "\"virtualViewerChecks\":%d,\"itemAnimationMs\":%.6f,\"droppedItemMs\":%.6f}", + label, seconds(), tickSamples, droppedTickSamples, msptP50, msptP95, msptP99, + msptP999, msptMax, msptMean, ticksOver50ms, virtualSpawnBundles, + virtualMotionBundles, virtualTeleportBundles, virtualRemovePackets, virtualPickupPackets, + knownVirtualPackets(), bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, + bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, virtualViewerChecks, + itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D); + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java index a2e164b..043208e 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java @@ -148,9 +148,10 @@ private CompletableFuture playEnchantAnimation(Map playPickUpAnimation(ItemStack itemstack) { return future; } - DisplayManager.updateItem(item); + if (spawned) { + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + } else { + DisplayManager.updateItem(item); + } DisplayManager.collectItem(item, enchanter); Scheduler.runTaskLater(plugin, () -> { diff --git a/common/src/main/resources/plugin.yml b/common/src/main/resources/plugin.yml index 1593f28..1730aea 100644 --- a/common/src/main/resources/plugin.yml +++ b/common/src/main/resources/plugin.yml @@ -37,3 +37,6 @@ permissions: interactionvisualizer.update: description: Allows you receive update messages default: op + interactionvisualizer.performance: + description: Allows you to run InteractionVisualizer performance measurements + default: op From a77f5d4aaa856efa3300f062c39146aa3bf800a2 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 01:57:50 +0800 Subject: [PATCH 08/59] Fix live performance harness compilation --- .../managers/PerformanceMetrics.java | 6 +++--- .../objectholders/EnchantmentTableAnimation.java | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java index 49ff7dd..f5528e7 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -100,13 +100,13 @@ public static Snapshot stop() { return null; } INSTANCE.collecting = false; - Snapshot snapshot = INSTANCE.snapshot(); + Snapshot snapshot = INSTANCE.createSnapshot(); InteractionVisualizer.plugin.getLogger().info("IV_PERF " + snapshot.json()); return snapshot; } public static Snapshot snapshot() { - return INSTANCE.collecting ? INSTANCE.snapshot() : null; + return INSTANCE.collecting ? INSTANCE.createSnapshot() : null; } public static void virtualSpawnBundle() { @@ -212,7 +212,7 @@ public void onServerTickEnd(ServerTickEndEvent event) { } } - private Snapshot snapshot() { + private Snapshot createSnapshot() { int samples = tickCount; double[] sorted = Arrays.copyOf(tickDurations, samples); Arrays.sort(sorted); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java index 043208e..dad27e2 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java @@ -160,7 +160,11 @@ private CompletableFuture playEnchantAnimation(Map playPickUpAnimation(ItemStack itemstack) { return future; } - if (spawned) { - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); - } else { - DisplayManager.updateItem(item); - } + DisplayManager.updateItem(item); DisplayManager.collectItem(item, enchanter); Scheduler.runTaskLater(plugin, () -> { From d3dd6f9ac48d531a39cf17e0e04b60c4662f34e9 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 02:03:39 +0800 Subject: [PATCH 09/59] Add static item anchor A-B mode --- .../InteractionVisualizer.java | 4 +++ .../managers/DisplayManager.java | 36 +++++++++++++++---- common/src/main/resources/config.yml | 5 +++ .../DisplayManagerItemAnimationTest.java | 8 +++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index 506f1ff..564a85f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -103,6 +103,8 @@ public class InteractionVisualizer extends JavaPlugin { public static boolean hideIfObstructed = false; public static boolean defaultDisabledAll = false; + /** A/B switch: virtual item remains authoritative while an invisible tracker stays stationary. */ + public static boolean staticVirtualItemAnchorsDuringAnimation = false; public static ILightManager lightManager; public static PreferenceManager preferenceManager; @@ -287,6 +289,8 @@ public void loadConfig() { } defaultDisabledAll = getConfiguration().getBoolean("Settings.DefaultDisableAll"); + staticVirtualItemAnchorsDuringAnimation = getConfiguration().getBoolean( + "Settings.Performance.VirtualItems.StaticAnchorDuringAnimation"); getServer().getPluginManager().callEvent(new InteractionVisualizerReloadEvent()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java index 849b7a5..3fe7b23 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java @@ -465,7 +465,11 @@ private static void syncItem(Item logical) { Vector velocity = logical.getVelocity(); boolean animated = requiresItemAnimation(logical.hasGravity(), velocity); if (animated) { - itemAnimations.put(logical, new ItemAnimationState(velocity, logical.hasGravity())); + ItemAnimationState previous = itemAnimations.get(logical); + Location position = previous == null ? actual.getLocation() : previous.position.clone(); + itemAnimations.put(logical, new ItemAnimationState(velocity, logical.hasGravity(), position, + useStaticAnchorForAnimation(InteractionVisualizer.staticVirtualItemAnchorsDuringAnimation, + logical.isCustomNameVisible()))); scheduleItemAnimationTick(); } else { itemAnimations.remove(logical); @@ -565,6 +569,10 @@ static boolean requiresItemAnimation(boolean gravity, Vector velocity) { return gravity || velocity.lengthSquared() > ITEM_ANIMATION_EPSILON; } + static boolean useStaticAnchorForAnimation(boolean configured, boolean customNameVisible) { + return configured && !customNameVisible; + } + private static void scheduleItemAnimationTick() { if (itemAnimationTickScheduled || itemAnimations.isEmpty() || plugin() == null || !plugin().isEnabled()) { @@ -614,7 +622,7 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation } Vector movement = itemMovementForTick(animation.gravity, animation.velocity); - Location destination = actual.getLocation().add(movement); + Location destination = animation.position.clone().add(movement); destination.checkFinite(); World world = destination.getWorld(); if (destination.getY() < world.getMinHeight() - ITEM_VOID_MARGIN @@ -624,10 +632,13 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation } animation.velocity = itemVelocityAfterMovement(movement); - PerformanceMetrics.bukkitEntityTeleport(); - if (!actual.teleport(destination)) { - remove(null, logical, true); - return; + animation.position = destination.clone(); + if (!animation.staticAnchor) { + PerformanceMetrics.bukkitEntityTeleport(); + if (!actual.teleport(destination)) { + remove(null, logical, true); + return; + } } if (animation.gravity) { // Heart's fake item is no-gravity. Absolute correction preserves the @@ -636,6 +647,13 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation } if (!animation.gravity && animation.velocity.lengthSquared() <= ITEM_ANIMATION_EPSILON) { itemAnimations.remove(logical, animation); + if (animation.staticAnchor) { + PerformanceMetrics.bukkitEntityTeleport(); + if (!actual.teleport(destination)) { + remove(null, logical, true); + return; + } + } synchronizeVirtualItemMotion(logical, destination); } } @@ -1159,10 +1177,14 @@ private static final class ItemAnimationState { private Vector velocity; private final boolean gravity; + private Location position; + private final boolean staticAnchor; - private ItemAnimationState(Vector velocity, boolean gravity) { + private ItemAnimationState(Vector velocity, boolean gravity, Location position, boolean staticAnchor) { this.velocity = velocity.clone(); this.gravity = gravity; + this.position = position.clone(); + this.staticAnchor = staticAnchor; } } diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index a13dceb..b6074ca 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -328,6 +328,11 @@ Settings: #MIGHT BE RESOURCE INTENSIVE #MIGHT BE RESOURCE INTENSIVE HideIfViewObstructed: false + Performance: + VirtualItems: + #A/B switch: keeps invisible tracker anchors still during short item animations. + #The client-side virtual item remains corrected every tick; turn this on only after profiling your server. + StaticAnchorDuringAnimation: false Options: Updater: true diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java index a34d459..3a2657a 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java @@ -39,6 +39,14 @@ void gravityAndVelocityActivateTheAnimationLoop() { assertTrue(DisplayManager.requiresItemAnimation(false, new Vector(0.0, 0.05, 0.0))); } + @Test + void staticAnchorExperimentNeverDetachesVisibleNames() { + assertFalse(DisplayManager.useStaticAnchorForAnimation(false, false)); + assertFalse(DisplayManager.useStaticAnchorForAnimation(false, true)); + assertTrue(DisplayManager.useStaticAnchorForAnimation(true, false)); + assertFalse(DisplayManager.useStaticAnchorForAnimation(true, true)); + } + @Test void copiesTheNmsAirMovementOrderAndPrecision() { Vector movement = DisplayManager.itemMovementForTick(true, new Vector(0.18, 0.15, 0.05)); From c79829582c09624f68eb7c1f741dc5d41dc7a07b Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 03:30:15 +0800 Subject: [PATCH 10/59] Add phase-two rendering experiments --- .../objectholders/ChunkPosition.java | 7 +- .../InteractionVisualizer.java | 30 + .../api/events/TileEntityActivatedEvent.java | 49 ++ .../api/events/TileEntityAddedEvent.java | 49 ++ .../events/TileEntityDeactivatedEvent.java | 52 ++ .../blocks/BeeHiveDisplay.java | 242 ++++++- .../blocks/BeeNestDisplay.java | 242 ++++++- .../blocks/BlastFurnaceDisplay.java | 170 ++++- .../blocks/BlockUpdateScheduler.java | 291 +++++++++ .../blocks/FurnaceDisplay.java | 165 ++++- .../blocks/FurnaceDisplayUpdater.java | 144 +++++ .../blocks/SmokerDisplay.java | 179 ++++- .../managers/DisplayManager.java | 609 ++++++++++++++++-- .../managers/PerformanceMetrics.java | 102 ++- .../managers/TileEntityManager.java | 226 ++++++- common/src/main/resources/config.yml | 14 + .../blocks/BlockDisplayFlagIsolationTest.java | 58 ++ .../blocks/BlockUpdateSchedulerTest.java | 143 ++++ .../blocks/FurnaceDisplayUpdaterTest.java | 29 + .../DisplayManagerItemAnimationTest.java | 19 + ...DisplayManagerVisibilityShowQueueTest.java | 128 ++++ .../objectholders/ChunkPositionTest.java | 55 ++ 22 files changed, 2860 insertions(+), 143 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityActivatedEvent.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityAddedEvent.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityDeactivatedEvent.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateScheduler.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdater.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockDisplayFlagIsolationTest.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockUpdateSchedulerTest.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdaterTest.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerVisibilityShowQueueTest.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/objectholders/ChunkPositionTest.java diff --git a/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/ChunkPosition.java b/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/ChunkPosition.java index dacee40..0a56472 100644 --- a/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/ChunkPosition.java +++ b/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/ChunkPosition.java @@ -61,10 +61,13 @@ public int getChunkZ() { @Override public boolean equals(Object object) { - if (!(object instanceof ChunkPosition)) { + if (this == object) { + return true; + } + if (!(object instanceof ChunkPosition other)) { return false; } - return hashCode() == object.hashCode(); + return x == other.x && z == other.z && world.equals(other.world); } @Override diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index 564a85f..00fa6d2 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -105,6 +105,17 @@ public class InteractionVisualizer extends JavaPlugin { public static boolean defaultDisabledAll = false; /** A/B switch: virtual item remains authoritative while an invisible tracker stays stationary. */ public static boolean staticVirtualItemAnchorsDuringAnimation = false; + /** A/B switch: eligible stationary virtual items are tracked and rendered entirely by packets. */ + public static boolean packetOnlyStaticVirtualItems = false; + /** A/B switch: smooths visibility recovery bursts; hides are always immediate. */ + public static boolean visibilityRateLimiting = false; + public static int visibilityRateLimitBucketSize = 128; + public static int visibilityRateLimitRestorePerTick = 32; + /** A/B switch: coalesces block changes and updates tracked blocks from fixed-budget loops. */ + public static boolean eventDrivenBlockUpdates = false; + public static int blockUpdateMaxDirtyPerTick = 64; + + private boolean blockUpdateModeInitialized; public static ILightManager lightManager; public static PreferenceManager preferenceManager; @@ -291,6 +302,25 @@ public void loadConfig() { defaultDisabledAll = getConfiguration().getBoolean("Settings.DefaultDisableAll"); staticVirtualItemAnchorsDuringAnimation = getConfiguration().getBoolean( "Settings.Performance.VirtualItems.StaticAnchorDuringAnimation"); + packetOnlyStaticVirtualItems = getConfiguration().getBoolean( + "Settings.Performance.VirtualItems.PacketOnlyStatic"); + visibilityRateLimiting = getConfiguration().getBoolean( + "Settings.Performance.VisibilityRateLimit.Enabled"); + visibilityRateLimitBucketSize = Math.max(1, getConfiguration().getInt( + "Settings.Performance.VisibilityRateLimit.BucketSize")); + visibilityRateLimitRestorePerTick = Math.max(1, getConfiguration().getInt( + "Settings.Performance.VisibilityRateLimit.RestorePerTick")); + boolean configuredEventDrivenBlockUpdates = getConfiguration().getBoolean( + "Settings.Performance.BlockUpdates.EventDriven"); + if (!blockUpdateModeInitialized) { + eventDrivenBlockUpdates = configuredEventDrivenBlockUpdates; + } else if (eventDrivenBlockUpdates != configuredEventDrivenBlockUpdates) { + getLogger().warning("Settings.Performance.BlockUpdates.EventDriven is applied only at startup; " + + "restart the server to change the active block update mode."); + } + blockUpdateMaxDirtyPerTick = Math.max(1, getConfiguration().getInt( + "Settings.Performance.BlockUpdates.MaxDirtyPerTick")); + blockUpdateModeInitialized = true; getServer().getPluginManager().callEvent(new InteractionVisualizerReloadEvent()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityActivatedEvent.java b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityActivatedEvent.java new file mode 100644 index 0000000..4e0b16f --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityActivatedEvent.java @@ -0,0 +1,49 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.api.events; + +import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; +import org.bukkit.block.Block; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +/** Called when a previously indexed tile entity of the same type becomes active again. */ +public class TileEntityActivatedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Block block; + private final TileEntityType type; + + public TileEntityActivatedEvent(Block block, TileEntityType type) { + this.block = block; + this.type = type; + } + + public Block getBlock() { + return block; + } + + public TileEntityType getTileEntityType() { + return type; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityAddedEvent.java b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityAddedEvent.java new file mode 100644 index 0000000..7ae5575 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityAddedEvent.java @@ -0,0 +1,49 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.api.events; + +import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; +import org.bukkit.block.Block; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +/** Called when a new tile entity or a different type is added to the active index. */ +public class TileEntityAddedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Block block; + private final TileEntityType type; + + public TileEntityAddedEvent(Block block, TileEntityType type) { + this.block = block; + this.type = type; + } + + public Block getBlock() { + return block; + } + + public TileEntityType getTileEntityType() { + return type; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityDeactivatedEvent.java b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityDeactivatedEvent.java new file mode 100644 index 0000000..b365fd0 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityDeactivatedEvent.java @@ -0,0 +1,52 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.api.events; + +import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; +import org.bukkit.block.Block; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +/** + * Called when a tile entity leaves the nearby active index without implying + * that the underlying block was broken. + */ +public class TileEntityDeactivatedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Block block; + private final TileEntityType type; + + public TileEntityDeactivatedEvent(Block block, TileEntityType type) { + this.block = block; + this.type = type; + } + + public Block getBlock() { + return block; + } + + public TileEntityType getTileEntityType() { + return type; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java index cdb3dd1..7d7d42c 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java @@ -25,10 +25,14 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -36,17 +40,28 @@ import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; import org.bukkit.block.data.Directional; +import org.bukkit.entity.Bee; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityChangeBlockEvent; import org.bukkit.event.entity.EntityEnterBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; @@ -70,9 +85,12 @@ public class BeeHiveDisplay extends VisualizerRunnableDisplay implements Listene private String filledColor = "&e"; private String noCampfireColor = "&c"; private String beeCountText = "&e{Current}&6/{Max}"; + private final BlockUpdateScheduler blockUpdates; public BeeHiveDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBeehive, + this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -93,6 +111,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = beehiveMap.entrySet().iterator(); int count = 0; @@ -140,6 +161,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBeehive(); for (Block block : list) { @@ -168,19 +204,186 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - updateBlock(block); + measureLegacyUpdate(block); }, delay, block.getLocation()); } }, 0, checkingPeriod); } + private void measureLegacyUpdate(Block block) { + if (!PerformanceMetrics.isCollecting()) { + updateBlock(block); + return; + } + long start = System.nanoTime(); + try { + updateBlock(block); + } finally { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); + } + } + + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.BEEHIVE).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (block.getType() != Material.BEEHIVE) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + if (!beehiveMap.containsKey(block)) { + beehiveMap.put(block, new HashMap<>(spawnDisplayEntitys(block))); + } + updateBlock(block); + return false; + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null + && block.getType() == Material.BEEHIVE && beehiveMap.containsKey(block)) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + private void markAffectedColumnDirty(Block changedBlock) { + if (!InteractionVisualizer.eventDrivenBlockUpdates || changedBlock == null) { + return; + } + for (int distance = 1; distance <= 5; distance++) { + markDirty(changedBlock.getRelative(BlockFace.UP, distance)); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockPlace(BlockPlaceEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlockPlaced()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockBreak(BlockBreakEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockBurn(BlockBurnEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockFade(BlockFadeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockIgnite(BlockIgniteEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedFluidFlow(BlockFromToEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getToBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockExplode(BlockExplodeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + for (Block block : event.blockList()) { + markAffectedColumnDirty(block); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedEntityExplode(EntityExplodeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + for (Block block : event.blockList()) { + markAffectedColumnDirty(block); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDispenserHarvest(BlockDispenseEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Material dispensed = event.getItem().getType(); + if (dispensed != Material.GLASS_BOTTLE && dispensed != Material.SHEARS) { + return; + } + BlockData data = event.getBlock().getBlockData(); + if (data instanceof Directional directional) { + markDirty(event.getBlock().getRelative(directional.getFacing())); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBeehiveAdded(TileEntityAddedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEEHIVE) { + blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBeehiveActivated(TileEntityActivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEEHIVE) { + blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBeehiveDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEEHIVE) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBeeEnterBeehive(EntityEnterBlockEvent event) { if (event.isCancelled()) { return; } Block block = event.getBlock(); - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + if (!(event.getEntity() instanceof Bee) || block.getType() != Material.BEEHIVE + || !beehiveMap.containsKey(block)) { + return; + } + markDirty(block); + } else { + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); + } } @EventHandler(priority = EventPriority.MONITOR) @@ -189,7 +392,16 @@ public void onBeeLeaveBeehive(EntityChangeBlockEvent event) { return; } Block block = event.getBlock(); - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + markAffectedColumnDirty(block); + if (!(event.getEntity() instanceof Bee) || block.getType() != Material.BEEHIVE + || !beehiveMap.containsKey(block)) { + return; + } + markDirty(block); + } else { + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); + } } @SuppressWarnings("deprecation") @@ -199,19 +411,30 @@ public void onInteractBeehive(PlayerInteractEvent event) { return; } Block block = event.getClickedBlock(); - if (block != null) { - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + if (block == null) { + return; + } + // Covers both harvesting the hive itself and toggling an + // obstruction (for example a trapdoor) in the smoke column. + markAffectedColumnDirty(block); + markDirty(block); + } else if (block != null) { + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); } } @EventHandler(priority = EventPriority.MONITOR) public void onBreakBeehive(TileEntityRemovedEvent event) { - Block block = event.getBlock(); - if (!beehiveMap.containsKey(block)) { + removeTrackedDisplay(event.getBlock()); + } + + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = beehiveMap.remove(block); + if (map == null) { return; } - - Map map = beehiveMap.get(block); if (map.get("0") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("0"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -220,7 +443,6 @@ public void onBreakBeehive(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - beehiveMap.remove(block); } public void updateBlock(Block block) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java index 02b848a..504727f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java @@ -25,10 +25,14 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -36,17 +40,28 @@ import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; import org.bukkit.block.data.Directional; +import org.bukkit.entity.Bee; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityChangeBlockEvent; import org.bukkit.event.entity.EntityEnterBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; @@ -70,9 +85,12 @@ public class BeeNestDisplay extends VisualizerRunnableDisplay implements Listene private String filledColor = "&e"; private String noCampfireColor = "&c"; private String beeCountText = "&e{Current}&6/{Max}"; + private final BlockUpdateScheduler blockUpdates; public BeeNestDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBeenest, + this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -93,6 +111,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = beenestMap.entrySet().iterator(); int count = 0; @@ -140,6 +161,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBeenest(); for (Block block : list) { @@ -168,19 +204,186 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - updateBlock(block); + measureLegacyUpdate(block); }, delay, block.getLocation()); } }, 0, checkingPeriod); } + private void measureLegacyUpdate(Block block) { + if (!PerformanceMetrics.isCollecting()) { + updateBlock(block); + return; + } + long start = System.nanoTime(); + try { + updateBlock(block); + } finally { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); + } + } + + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.BEE_NEST).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (block.getType() != Material.BEE_NEST) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + if (!beenestMap.containsKey(block)) { + beenestMap.put(block, new HashMap<>(spawnDisplayEntitys(block))); + } + updateBlock(block); + return false; + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null + && block.getType() == Material.BEE_NEST && beenestMap.containsKey(block)) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + private void markAffectedColumnDirty(Block changedBlock) { + if (!InteractionVisualizer.eventDrivenBlockUpdates || changedBlock == null) { + return; + } + for (int distance = 1; distance <= 5; distance++) { + markDirty(changedBlock.getRelative(BlockFace.UP, distance)); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockPlace(BlockPlaceEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlockPlaced()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockBreak(BlockBreakEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockBurn(BlockBurnEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockFade(BlockFadeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockIgnite(BlockIgniteEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedFluidFlow(BlockFromToEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getToBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockExplode(BlockExplodeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + for (Block block : event.blockList()) { + markAffectedColumnDirty(block); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedEntityExplode(EntityExplodeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + for (Block block : event.blockList()) { + markAffectedColumnDirty(block); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDispenserHarvest(BlockDispenseEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Material dispensed = event.getItem().getType(); + if (dispensed != Material.GLASS_BOTTLE && dispensed != Material.SHEARS) { + return; + } + BlockData data = event.getBlock().getBlockData(); + if (data instanceof Directional directional) { + markDirty(event.getBlock().getRelative(directional.getFacing())); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBeenestAdded(TileEntityAddedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEE_NEST) { + blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBeenestActivated(TileEntityActivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEE_NEST) { + blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBeenestDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEE_NEST) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBeeEnterBeenest(EntityEnterBlockEvent event) { if (event.isCancelled()) { return; } Block block = event.getBlock(); - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + if (!(event.getEntity() instanceof Bee) || block.getType() != Material.BEE_NEST + || !beenestMap.containsKey(block)) { + return; + } + markDirty(block); + } else { + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); + } } @EventHandler(priority = EventPriority.MONITOR) @@ -189,7 +392,16 @@ public void onBeeLeaveBeenest(EntityChangeBlockEvent event) { return; } Block block = event.getBlock(); - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + markAffectedColumnDirty(block); + if (!(event.getEntity() instanceof Bee) || block.getType() != Material.BEE_NEST + || !beenestMap.containsKey(block)) { + return; + } + markDirty(block); + } else { + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); + } } @SuppressWarnings("deprecation") @@ -199,19 +411,30 @@ public void onInteractBeenest(PlayerInteractEvent event) { return; } Block block = event.getClickedBlock(); - if (block != null) { - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + if (block == null) { + return; + } + // Covers both harvesting the nest itself and toggling an + // obstruction (for example a trapdoor) in the smoke column. + markAffectedColumnDirty(block); + markDirty(block); + } else if (block != null) { + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); } } @EventHandler(priority = EventPriority.MONITOR) public void onBreakBeenest(TileEntityRemovedEvent event) { - Block block = event.getBlock(); - if (!beenestMap.containsKey(block)) { + removeTrackedDisplay(event.getBlock()); + } + + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = beenestMap.remove(block); + if (map == null) { return; } - - Map map = beenestMap.get(block); if (map.get("0") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("0"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -220,7 +443,6 @@ public void onBreakBeenest(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - beenestMap.remove(block); } public void updateBlock(Block block) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java index 596d462..5feddd3 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java @@ -25,11 +25,15 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -39,6 +43,7 @@ import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -50,9 +55,14 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceExtractEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.EulerAngle; @@ -78,9 +88,12 @@ public class BlastFurnaceDisplay extends VisualizerRunnableDisplay implements Li private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; + private final BlockUpdateScheduler blockUpdates; public BlastFurnaceDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBlastFurnace, + this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -102,6 +115,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = blastfurnaceMap.entrySet().iterator(); int count = 0; @@ -149,6 +165,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBlastFurnace(); for (Block block : list) { @@ -178,15 +209,18 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - if (!isActive(block.getLocation())) { - return; - } - if (!block.getType().equals(Material.BLAST_FURNACE)) { - return; - } - org.bukkit.block.BlastFurnace blastfurnace = (org.bukkit.block.BlastFurnace) block.getState(); + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + try { + if (!isActive(block.getLocation())) { + return; + } + if (!block.getType().equals(Material.BLAST_FURNACE)) { + return; + } + org.bukkit.block.BlastFurnace blastfurnace = (org.bukkit.block.BlastFurnace) block.getState(); - { + { Inventory inv = blastfurnace.getInventory(); ItemStack itemstack = inv.getItem(0); if (itemstack != null) { @@ -281,12 +315,114 @@ public ScheduledTask run() { DisplayManager.updateDisplay(stand); } } + } + } finally { + if (collecting) { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); + } } }, delay, block.getLocation()); } }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.BLAST_FURNACE).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (!isBlastFurnace(block.getType())) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = blastfurnaceMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + blastfurnaceMap.put(block, values); + } + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, + filledColor, noFuelColor, progressBarLength, amountPending); + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isBlastFurnace(block.getType())) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + private void markDirty(Inventory inventory) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Location location; + try { + location = inventory.getLocation(); + } catch (Exception | AbstractMethodError ignored) { + return; + } + if (location != null) { + markDirty(location.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlastFurnaceBurn(FurnaceBurnEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlastFurnaceStartSmelt(FurnaceStartSmeltEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlastFurnaceSmelt(FurnaceSmeltEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBlastFurnaceExtract(FurnaceExtractEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlastFurnaceMoveItem(InventoryMoveItemEvent event) { + markDirty(event.getSource()); + markDirty(event.getDestination()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBlastFurnaceAdded(TileEntityAddedEvent event) { + if (event.getTileEntityType() == TileEntityType.BLAST_FURNACE) { + markDirty(event.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBlastFurnaceActivated(TileEntityActivatedEvent event) { + if (event.getTileEntityType() == TileEntityType.BLAST_FURNACE) { + markDirty(event.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBlastFurnaceDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BLAST_FURNACE) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBlastFurnace(InventoryClickEvent event) { if (VanishUtils.isVanished((Player) event.getWhoClicked())) { @@ -348,6 +484,7 @@ public void onBlastFurnace(InventoryClickEvent event) { if (!event.getView().getTopInventory().getLocation().getBlock().getType().equals(Material.BLAST_FURNACE)) { return; } + markDirty(event.getView().getTopInventory().getLocation().getBlock()); Block block = event.getView().getTopInventory().getLocation().getBlock(); @@ -438,6 +575,7 @@ public void onDragBlastFurnace(InventoryDragEvent event) { for (int slot : event.getRawSlots()) { if (slot >= 0 && slot <= 2) { + markDirty(event.getView().getTopInventory().getLocation().getBlock()); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); break; } @@ -446,12 +584,15 @@ public void onDragBlastFurnace(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakBlastFurnace(TileEntityRemovedEvent event) { - Block block = event.getBlock(); - if (!blastfurnaceMap.containsKey(block)) { + removeTrackedDisplay(event.getBlock()); + } + + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = blastfurnaceMap.remove(block); + if (map == null) { return; } - - Map map = blastfurnaceMap.get(block); if (map.get("Item") instanceof Item) { Item item = (Item) map.get("Item"); DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); @@ -460,7 +601,6 @@ public void onBreakBlastFurnace(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("Stand"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - blastfurnaceMap.remove(block); } public boolean hasItemToCook(org.bukkit.block.BlastFurnace blastfurnace) { @@ -490,6 +630,10 @@ public boolean isActive(Location loc) { return PlayerLocationManager.hasPlayerNearby(loc); } + private boolean isBlastFurnace(Material material) { + return material == Material.BLAST_FURNACE; + } + public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateScheduler.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateScheduler.java new file mode 100644 index 0000000..6d4725f --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateScheduler.java @@ -0,0 +1,291 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; +import java.util.function.Supplier; + +/** + * Main-thread scheduler for event-driven block displays. + * + *

Dirty entries are coalesced by key and become eligible on an explicit + * tick. Active entries are rotated through a reusable queue so each entry is + * checked approximately once per configured update period. A weakly + * consistent iterator performs a bounded safety audit without allocating a + * snapshot of every tracked block. The dirty cap supplied to {@link #tick} + * applies to this scheduler instance (one display type), not globally across + * every display.

+ */ +final class BlockUpdateScheduler { + + @FunctionalInterface + interface Updater { + /** + * @return {@code true} while the value needs periodic active updates + */ + boolean update(T value); + } + + private final Supplier> auditSource; + private final int activePeriod; + private final int auditPeriod; + private final NavigableMap> dirtyByTick; + private final Map dirtyDueTick; + private final NavigableMap> activeByTick; + private final Map activeDueTick; + private final HashSet processedThisTick; + private final HashSet removedValues; + + private Iterator auditIterator; + private long nextAuditTick; + private int auditBudget; + private int activeBudget; + private boolean initialAudit; + + BlockUpdateScheduler(Supplier> auditSource, int activePeriod, int auditPeriod) { + this.auditSource = Objects.requireNonNull(auditSource, "auditSource"); + this.activePeriod = Math.max(1, activePeriod); + this.auditPeriod = Math.max(1, auditPeriod); + this.dirtyByTick = new TreeMap<>(); + this.dirtyDueTick = new HashMap<>(); + this.activeByTick = new TreeMap<>(); + this.activeDueTick = new HashMap<>(); + this.processedThisTick = new HashSet<>(); + this.removedValues = new HashSet<>(); + this.nextAuditTick = Long.MIN_VALUE; + this.activeBudget = 0; + this.initialAudit = true; + } + + void markDirty(T value, long readyTick) { + if (value == null) { + return; + } + this.removedValues.remove(value); + Long previousTick = this.dirtyDueTick.get(value); + if (previousTick != null && previousTick <= readyTick) { + return; + } + if (previousTick != null) { + LinkedHashSet previous = this.dirtyByTick.get(previousTick); + if (previous != null) { + previous.remove(value); + if (previous.isEmpty()) { + this.dirtyByTick.remove(previousTick); + } + } + } + this.dirtyDueTick.put(value, readyTick); + this.dirtyByTick.computeIfAbsent(readyTick, ignored -> new LinkedHashSet<>()).add(value); + } + + void markActive(T value, long readyTick) { + this.removedValues.remove(value); + this.schedule(value, readyTick, this.activeDueTick, this.activeByTick); + } + + void remove(T value) { + if (value == null) { + return; + } + if (this.auditIterator != null) { + this.removedValues.add(value); + } + this.unschedule(value, this.activeDueTick, this.activeByTick); + this.unschedule(value, this.dirtyDueTick, this.dirtyByTick); + } + + int tick(long tick, int maxDirtyPerTick, Updater updater) { + Objects.requireNonNull(updater, "updater"); + int dirtyBudget = Math.max(1, maxDirtyPerTick); + this.processedThisTick.clear(); + + int checks = this.drainDirty(tick, dirtyBudget, updater); + checks += this.drainActive(tick, updater); + checks += this.drainAudit(tick, dirtyBudget, updater); + return checks; + } + + private int drainDirty(long tick, int budget, Updater updater) { + int checks = 0; + while (checks < budget) { + Map.Entry> entry = this.dirtyByTick.firstEntry(); + if (entry == null || entry.getKey() > tick) { + break; + } + Iterator iterator = entry.getValue().iterator(); + if (!iterator.hasNext()) { + this.dirtyByTick.pollFirstEntry(); + continue; + } + T value = iterator.next(); + iterator.remove(); + this.dirtyDueTick.remove(value, entry.getKey()); + if (entry.getValue().isEmpty()) { + this.dirtyByTick.pollFirstEntry(); + } + if (this.updateOnce(value, tick, updater)) { + checks++; + } + } + return checks; + } + + private int drainActive(long tick, Updater updater) { + if (this.activeDueTick.isEmpty()) { + this.activeBudget = 0; + return 0; + } + this.activeBudget = Math.max(this.activeBudget, + Math.max(1, (this.activeDueTick.size() + this.activePeriod - 1) / this.activePeriod)); + int checks = 0; + while (checks < this.activeBudget) { + Map.Entry> entry = this.activeByTick.firstEntry(); + if (entry == null || entry.getKey() > tick) { + break; + } + Iterator iterator = entry.getValue().iterator(); + if (!iterator.hasNext()) { + this.activeByTick.pollFirstEntry(); + continue; + } + T value = iterator.next(); + iterator.remove(); + this.activeDueTick.remove(value, entry.getKey()); + if (entry.getValue().isEmpty()) { + this.activeByTick.pollFirstEntry(); + } + if (this.removedValues.contains(value)) { + continue; + } + if (this.processedThisTick.contains(value)) { + this.markActive(value, tick + this.activePeriod); + continue; + } + boolean keepActive = this.updateAndRemember(value, updater); + checks++; + if (keepActive) { + this.markActive(value, tick + this.activePeriod); + } + } + return checks; + } + + private int drainAudit(long tick, int initialBudget, Updater updater) { + if (this.auditIterator == null && tick >= this.nextAuditTick) { + Collection source = this.auditSource.get(); + this.auditIterator = source.iterator(); + this.auditBudget = this.initialAudit + ? initialBudget + : Math.max(1, (source.size() + this.auditPeriod - 1) / this.auditPeriod); + this.initialAudit = false; + this.nextAuditTick = tick + this.auditPeriod; + } + if (this.auditIterator == null) { + return 0; + } + + int checks = 0; + int attempts = this.auditBudget; + while (attempts-- > 0 && this.auditIterator.hasNext()) { + T value = this.auditIterator.next(); + if (this.updateOnce(value, tick, updater)) { + checks++; + } + } + if (!this.auditIterator.hasNext()) { + this.auditIterator = null; + this.removedValues.clear(); + } + return checks; + } + + private boolean updateOnce(T value, long tick, Updater updater) { + if (this.removedValues.contains(value)) { + return false; + } + if (!this.processedThisTick.add(value)) { + return false; + } + boolean keepActive = updater.update(value); + if (keepActive) { + this.unschedule(value, this.activeDueTick, this.activeByTick); + this.markActive(value, tick + this.activePeriod); + } else { + this.unschedule(value, this.activeDueTick, this.activeByTick); + } + return true; + } + + private boolean updateAndRemember(T value, Updater updater) { + this.processedThisTick.add(value); + return updater.update(value); + } + + private void schedule(T value, long readyTick, Map dueTicks, + NavigableMap> byTick) { + if (value == null) { + return; + } + Long previousTick = dueTicks.get(value); + if (previousTick != null && previousTick <= readyTick) { + return; + } + if (previousTick != null) { + LinkedHashSet previous = byTick.get(previousTick); + if (previous != null) { + previous.remove(value); + if (previous.isEmpty()) { + byTick.remove(previousTick); + } + } + } + dueTicks.put(value, readyTick); + byTick.computeIfAbsent(readyTick, ignored -> new LinkedHashSet<>()).add(value); + } + + private void unschedule(T value, Map dueTicks, + NavigableMap> byTick) { + Long dueTick = dueTicks.remove(value); + if (dueTick == null) { + return; + } + LinkedHashSet values = byTick.get(dueTick); + if (values != null) { + values.remove(value); + if (values.isEmpty()) { + byTick.remove(dueTick); + } + } + } + + int pendingDirtyCount() { + return this.dirtyDueTick.size(); + } + + int activeCount() { + return this.activeDueTick.size(); + } + + int invalidatedAuditValueCount() { + return this.removedValues.size(); + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java index 01fa547..b4be33d 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java @@ -25,11 +25,15 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -39,6 +43,7 @@ import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -50,9 +55,14 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceExtractEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.EulerAngle; @@ -78,9 +88,11 @@ public class FurnaceDisplay extends VisualizerRunnableDisplay implements Listene private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; + private final BlockUpdateScheduler blockUpdates; public FurnaceDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyFurnace, this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -102,6 +114,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = furnaceMap.entrySet().iterator(); int count = 0; @@ -149,6 +164,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyFurnace(); for (Block block : list) { @@ -178,16 +208,18 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + try { + if (!isActive(block.getLocation())) { + return; + } + if (!isFurnace(block.getType())) { + return; + } + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); - if (!isActive(block.getLocation())) { - return; - } - if (!isFurnace(block.getType())) { - return; - } - org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); - - { + { Inventory inv = furnace.getInventory(); ItemStack itemstack = inv.getItem(0); if (itemstack != null) { @@ -282,12 +314,114 @@ public ScheduledTask run() { DisplayManager.updateDisplay(stand); } } + } + } finally { + if (collecting) { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); + } } }, delay, block.getLocation()); } }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.FURNACE).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (!isFurnace(block.getType())) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = furnaceMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + furnaceMap.put(block, values); + } + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, + filledColor, noFuelColor, progressBarLength, amountPending); + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isFurnace(block.getType())) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + private void markDirty(Inventory inventory) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Location location; + try { + location = inventory.getLocation(); + } catch (Exception | AbstractMethodError ignored) { + return; + } + if (location != null) { + markDirty(location.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFurnaceBurn(FurnaceBurnEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFurnaceSmelt(FurnaceSmeltEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onFurnaceExtract(FurnaceExtractEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFurnaceMoveItem(InventoryMoveItemEvent event) { + markDirty(event.getSource()); + markDirty(event.getDestination()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onFurnaceAdded(TileEntityAddedEvent event) { + if (event.getTileEntityType() == TileEntityType.FURNACE) { + markDirty(event.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onFurnaceActivated(TileEntityActivatedEvent event) { + if (event.getTileEntityType() == TileEntityType.FURNACE) { + markDirty(event.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onFurnaceDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.FURNACE) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onFurnace(InventoryClickEvent event) { if (VanishUtils.isVanished((Player) event.getWhoClicked())) { @@ -407,6 +541,7 @@ public void onUseFurnace(InventoryClickEvent event) { if (!isFurnace(event.getView().getTopInventory().getLocation().getBlock().getType())) { return; } + markDirty(event.getView().getTopInventory().getLocation().getBlock()); if (event.getRawSlot() >= 0 && event.getRawSlot() <= 2) { DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); @@ -440,6 +575,7 @@ public void onDragFurnace(InventoryDragEvent event) { for (int slot : event.getRawSlots()) { if (slot >= 0 && slot <= 2) { + markDirty(event.getView().getTopInventory().getLocation().getBlock()); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); break; } @@ -448,11 +584,15 @@ public void onDragFurnace(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakFurnace(TileEntityRemovedEvent event) { - Block block = event.getBlock(); - if (!furnaceMap.containsKey(block)) { + removeTrackedDisplay(event.getBlock()); + } + + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = furnaceMap.remove(block); + if (map == null) { return; } - Map map = furnaceMap.get(block); if (map.get("Item") instanceof Item) { Item item = (Item) map.get("Item"); DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); @@ -461,7 +601,6 @@ public void onBreakFurnace(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("Stand"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - furnaceMap.remove(block); } public boolean hasItemToCook(org.bukkit.block.Furnace furnace) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdater.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdater.java new file mode 100644 index 0000000..c29b1cf --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdater.java @@ -0,0 +1,144 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI; +import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.objectholders.EntryKey; +import com.loohp.interactionvisualizer.utils.ChatColorUtils; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Material; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Vector; + +import java.util.Map; + +/** Shared hybrid update path for the three Bukkit furnace variants. */ +final class FurnaceDisplayUpdater { + + private FurnaceDisplayUpdater() { + } + + static boolean update(org.bukkit.block.Furnace furnace, Map values, EntryKey key, + String progressBarCharacter, String emptyColor, String filledColor, + String noFuelColor, int progressBarLength, String amountPending) { + Inventory inventory = furnace.getInventory(); + ItemStack itemStack = normalize(inventory.getItem(0)); + if (itemStack == null) { + itemStack = normalize(inventory.getItem(2)); + } + + if (values.get("Item") instanceof String) { + if (itemStack != null) { + Item item = new Item(furnace.getLocation().clone().add(0.5, 1.0, 0.5)); + item.setItemStack(itemStack); + item.setVelocity(new Vector()); + item.setPickupDelay(32767); + item.setGravity(false); + values.put("Item", item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, key), item); + } else { + values.put("Item", "N/A"); + } + } else { + Item item = (Item) values.get("Item"); + if (itemStack != null) { + if (!item.getItemStack().equals(itemStack)) { + item.setItemStack(itemStack); + DisplayManager.updateItem(item); + } + } else { + values.put("Item", "N/A"); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); + } + } + + DisplayEntity stand = (DisplayEntity) values.get("Stand"); + ItemStack input = normalize(inventory.getItem(0)); + if (input == null) { + hideProgress(stand); + return false; + } + + int time = furnace.getCookTime(); + int maximum = furnace.getCookTimeTotal(); + double scaled = scaledProgress(time, maximum, progressBarLength); + StringBuilder symbol = new StringBuilder(Math.max(16, progressBarLength * 3)); + double i = 1; + for (; i < scaled; i++) { + symbol.append(filledColor).append(progressBarCharacter); + } + i--; + if (scaled - i > 0 && scaled - i < 0.67) { + symbol.append(emptyColor).append(progressBarCharacter); + } else if (scaled - i > 0) { + symbol.append(filledColor).append(progressBarCharacter); + } + for (i = progressBarLength - 1; i >= scaled; i--) { + symbol.append(emptyColor).append(progressBarCharacter); + } + + int left = input.getAmount() - 1; + if (left > 0) { + symbol.append(amountPending.replace("{Amount}", Integer.toString(left))); + } + String text = symbol.toString(); + if (text.contains("{CompletedAmount}")) { + ItemStack output = inventory.getItem(2); + text = text.replace("{CompletedAmount}", Integer.toString(output == null ? 0 : output.getAmount())); + } + if (!hasFuel(furnace)) { + text = noFuelColor + ChatColorUtils.stripColor(text); + } + if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(text) + || !stand.isCustomNameVisible()) { + stand.setCustomNameVisible(true); + stand.setCustomName(text); + DisplayManager.updateDisplay(stand); + } + return furnace.getBurnTime() > 0 || furnace.getCookTime() > 0; + } + + static double scaledProgress(int time, int maximum, int progressBarLength) { + int boundedLength = Math.max(0, progressBarLength); + if (maximum <= 0 || boundedLength == 0) { + return 0.0D; + } + double scaled = (double) time / (double) maximum * (double) boundedLength; + return Math.max(0.0D, Math.min((double) boundedLength, scaled)); + } + + private static ItemStack normalize(ItemStack itemStack) { + return itemStack == null || itemStack.getType() == Material.AIR ? null : itemStack; + } + + private static boolean hasFuel(org.bukkit.block.Furnace furnace) { + if (furnace.getBurnTime() > 0) { + return true; + } + return normalize(furnace.getInventory().getItem(1)) != null; + } + + private static void hideProgress(DisplayEntity stand) { + if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).isEmpty() + || stand.isCustomNameVisible()) { + stand.setCustomNameVisible(false); + stand.setCustomName(""); + DisplayManager.updateDisplay(stand); + } + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java index 0842d9e..6992a28 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java @@ -25,10 +25,15 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -38,6 +43,7 @@ import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -50,9 +56,14 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceExtractEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.EulerAngle; @@ -78,9 +89,12 @@ public class SmokerDisplay extends VisualizerRunnableDisplay implements Listener private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; + private final BlockUpdateScheduler blockUpdates; public SmokerDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbySmoker, + this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -102,6 +116,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = smokerMap.entrySet().iterator(); int count = 0; @@ -149,6 +166,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbySmoker(); for (Block block : list) { @@ -178,15 +210,18 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - if (!isActive(block.getLocation())) { - return; - } - if (!block.getType().equals(Material.SMOKER)) { - return; - } - org.bukkit.block.Smoker smoker = (org.bukkit.block.Smoker) block.getState(); + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + try { + if (!isActive(block.getLocation())) { + return; + } + if (!block.getType().equals(Material.SMOKER)) { + return; + } + org.bukkit.block.Smoker smoker = (org.bukkit.block.Smoker) block.getState(); - { + { Inventory inv = smoker.getInventory(); ItemStack itemstack = inv.getItem(0); if (itemstack != null) { @@ -281,12 +316,114 @@ public ScheduledTask run() { DisplayManager.updateDisplay(stand); } } + } + } finally { + if (collecting) { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); + } } }, delay, block.getLocation()); } }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.SMOKER).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (!isSmoker(block.getType())) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = smokerMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + smokerMap.put(block, values); + } + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, + filledColor, noFuelColor, progressBarLength, amountPending); + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isSmoker(block.getType())) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + private void markDirty(Inventory inventory) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Location location; + try { + location = inventory.getLocation(); + } catch (Exception | AbstractMethodError ignored) { + return; + } + if (location != null) { + markDirty(location.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSmokerBurn(FurnaceBurnEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSmokerStartSmelt(FurnaceStartSmeltEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSmokerSmelt(FurnaceSmeltEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onSmokerExtract(FurnaceExtractEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSmokerMoveItem(InventoryMoveItemEvent event) { + markDirty(event.getSource()); + markDirty(event.getDestination()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onSmokerAdded(TileEntityAddedEvent event) { + if (event.getTileEntityType() == TileEntityType.SMOKER) { + markDirty(event.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onSmokerActivated(TileEntityActivatedEvent event) { + if (event.getTileEntityType() == TileEntityType.SMOKER) { + markDirty(event.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onSmokerDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.SMOKER) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onSmoker(InventoryClickEvent event) { if (VanishUtils.isVanished((Player) event.getWhoClicked())) { @@ -348,6 +485,7 @@ public void onSmoker(InventoryClickEvent event) { if (!event.getView().getTopInventory().getLocation().getBlock().getType().equals(Material.SMOKER)) { return; } + markDirty(event.getView().getTopInventory().getLocation().getBlock()); Block block = event.getView().getTopInventory().getLocation().getBlock(); @@ -438,6 +576,7 @@ public void onDragSmoker(InventoryDragEvent event) { for (int slot : event.getRawSlots()) { if (slot >= 0 && slot <= 2) { + markDirty(event.getView().getTopInventory().getLocation().getBlock()); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); break; } @@ -446,12 +585,23 @@ public void onDragSmoker(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakSmoker(BlockBreakEvent event) { - Block block = event.getBlock(); - if (!smokerMap.containsKey(block)) { - return; + removeTrackedDisplay(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onRemoveSmoker(TileEntityRemovedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.SMOKER) { + removeTrackedDisplay(event.getBlock()); } + } - Map map = smokerMap.get(block); + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = smokerMap.remove(block); + if (map == null) { + return; + } if (map.get("Item") instanceof Item) { Item item = (Item) map.get("Item"); DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); @@ -460,7 +610,6 @@ public void onBreakSmoker(BlockBreakEvent event) { DisplayEntity stand = (DisplayEntity) map.get("Stand"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - smokerMap.remove(block); } public boolean hasItemToCook(org.bukkit.block.Smoker smoker) { @@ -490,6 +639,10 @@ public boolean isActive(Location loc) { return PlayerLocationManager.hasPlayerNearby(loc); } + private boolean isSmoker(Material material) { + return material == Material.SMOKER; + } + public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java index 3fe7b23..cfeda53 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java @@ -19,11 +19,14 @@ import com.loohp.interactionvisualizer.entityholders.VisualizerEntity; import com.loohp.interactionvisualizer.integration.packet.ClientPickupAnimationBridge; import com.loohp.interactionvisualizer.utils.DisplayTransformFactory; +import io.papermc.paper.event.packet.PlayerChunkLoadEvent; +import io.papermc.paper.event.packet.PlayerChunkUnloadEvent; import io.papermc.paper.event.player.PlayerTrackEntityEvent; import io.papermc.paper.event.player.PlayerUntrackEntityEvent; import net.kyori.adventure.text.Component; import net.momirealms.sparrow.heart.SparrowHeart; import org.bukkit.Bukkit; +import org.bukkit.Chunk; import org.bukkit.Color; import org.bukkit.Location; import org.bukkit.NamespacedKey; @@ -39,28 +42,34 @@ import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataType; import org.bukkit.plugin.Plugin; import org.bukkit.util.Vector; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; +import java.util.Deque; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; import java.util.logging.Level; /** * Main-thread Paper entity renderer. * - *

Paper's native tracker owns range and chunk lifecycle. Logical items use - * empty display anchors plus Sparrow's client-side {@code ITEM} packets, so - * the client keeps vanilla bob/spin rendering without server item physics.

+ *

Paper's native tracker owns range and chunk lifecycle for displays and + * animated/name-bearing items. Eligible static items can instead follow the + * player's sent-chunk lifecycle and use only Sparrow client-side {@code ITEM} + * packets, retaining vanilla bob/spin rendering without server item physics.

*/ public final class DisplayManager implements Listener { @@ -77,6 +86,7 @@ public final class DisplayManager implements Listener { private static final Map renderedRevision = new ConcurrentHashMap<>(); private static final Map> shownViewers = new ConcurrentHashMap<>(); private static final Set scheduled = ConcurrentHashMap.newKeySet(); + private static final Set forceScheduled = ConcurrentHashMap.newKeySet(); private static final Map logicalByActualUuid = new ConcurrentHashMap<>(); private static final Map actualUuidByLogical = new ConcurrentHashMap<>(); private static final Map> logicalsByChunk = new ConcurrentHashMap<>(); @@ -84,7 +94,11 @@ public final class DisplayManager implements Listener { private static final Map itemAnimations = new ConcurrentHashMap<>(); private static final Map> virtualItemIds = new ConcurrentHashMap<>(); private static final Map renderedItemStacks = new ConcurrentHashMap<>(); + private static final Map renderedItemLocations = new ConcurrentHashMap<>(); + private static final Set packetOnlyItems = ConcurrentHashMap.newKeySet(); + private static final Map> visibilityShowQueues = new ConcurrentHashMap<>(); private static boolean itemAnimationTickScheduled; + private static boolean visibilityTickScheduled; public DisplayManager() { } @@ -113,9 +127,38 @@ public static void run() { /** One-shot liveness and viewer reconciliation, used after configuration reloads. */ public static void update() { runSync(() -> { + // Active viewer collections are live filtered views. A reload can + // therefore invalidate requests that have not reached shownViewers + // yet; clear them once and rebuild from the new desired sets below. + for (VisibilityShowQueue queue : visibilityShowQueues.values()) { + queue.clear(); + } for (VisualizerEntity logical : new HashSet<>(active.keySet())) { index(logical); - if (logical.getBukkitEntity().isEmpty() && requiresActualEntity(logical)) { + if (logical instanceof Item item) { + // These experiment flags are not part of the logical entity's + // cache code. Force a sync only when a reload actually changes + // the representation; otherwise avoid a full item teleport burst. + ItemAnimationState animation = itemAnimations.get(item); + boolean desiredStaticAnchor = useStaticAnchorForAnimation( + InteractionVisualizer.staticVirtualItemAnchorsDuringAnimation, + item.isCustomNameVisible()); + boolean packetOnly = qualifiesForPacketOnlyStatic(item); + boolean representationChanged = packetOnlyItems.contains(item) != packetOnly + || animation != null && animation.staticAnchor != desiredStaticAnchor; + boolean missingRequiredAnchor = !packetOnly && item.getBukkitEntity().isEmpty(); + Location location = item.getLocation(); + boolean chunkLoaded = location.getWorld().isChunkLoaded( + location.getBlockX() >> 4, location.getBlockZ() >> 4); + if ((representationChanged || missingRequiredAnchor) && (packetOnly || chunkLoaded)) { + schedule(logical, true); + } else { + // If packet-only was disabled while the chunk is absent, + // hide its old fake viewers now; ChunkLoadEvent will create + // the real anchor without synchronously loading the chunk. + reconcileViewers(logical); + } + } else if (logical.getBukkitEntity().isEmpty() && requiresActualEntity(logical)) { Location location = logical.getLocation(); if (location.getWorld().isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { schedule(logical, true); @@ -124,6 +167,9 @@ public static void update() { reconcileViewers(logical); } } + if (!InteractionVisualizer.visibilityRateLimiting) { + drainVisibilityShowQueues(); + } }); } @@ -146,6 +192,7 @@ public static void shutdown() { renderedRevision.clear(); shownViewers.clear(); scheduled.clear(); + forceScheduled.clear(); logicalByActualUuid.clear(); actualUuidByLogical.clear(); logicalsByChunk.clear(); @@ -153,7 +200,14 @@ public static void shutdown() { itemAnimations.clear(); virtualItemIds.clear(); renderedItemStacks.clear(); + renderedItemLocations.clear(); + packetOnlyItems.clear(); + for (VisibilityShowQueue queue : visibilityShowQueues.values()) { + queue.clear(); + } + visibilityShowQueues.clear(); itemAnimationTickScheduled = false; + visibilityTickScheduled = false; playerStatus.clear(); removeOwnedEntities(true); }; @@ -281,6 +335,7 @@ public static void reset(Player player) { public static void removeAll(Player player) { runSync(() -> { + clearVisibilityShowQueue(player.getUniqueId()); List virtualItemIdsToRemove = new ArrayList<>(); for (Map.Entry> entry : shownViewers.entrySet()) { if (entry.getValue().remove(player.getUniqueId())) { @@ -305,8 +360,8 @@ public static void sendPlayerPackets(Player player) { runSync(() -> { playerStatus.putIfAbsent(player, ConcurrentHashMap.newKeySet()); for (VisualizerEntity logical : active.keySet()) { - if (logical.getBukkitEntity().isPresent()) { - reconcileViewers(logical); + if (logical.getBukkitEntity().isPresent() || packetOnlyItems.contains(logical)) { + reconcileViewer(logical, player); } else if (requiresActualEntity(logical)) { schedule(logical, true); } @@ -318,13 +373,19 @@ private static void schedule(VisualizerEntity logical, boolean force) { if (logical == null || plugin() == null || !plugin().isEnabled()) { return; } + if (force) { + // A force request may arrive while a normal sync is already queued. + // Preserve the upgrade until whichever task observes it. + forceScheduled.add(logical); + } if (scheduled.add(logical)) { runSync(() -> { try { + boolean forceSync = forceScheduled.remove(logical); if (active.containsKey(logical)) { index(logical); int revision = logical.cacheCode(); - if (force || (requiresActualEntity(logical) && logical.getBukkitEntity().isEmpty()) + if (forceSync || (requiresActualEntity(logical) && logical.getBukkitEntity().isEmpty()) || renderedRevision.getOrDefault(logical, Integer.MIN_VALUE) != revision) { sync(logical, revision); } @@ -332,8 +393,8 @@ private static void schedule(VisualizerEntity logical, boolean force) { } finally { scheduled.remove(logical); } - if (active.containsKey(logical) - && renderedRevision.getOrDefault(logical, Integer.MIN_VALUE) != logical.cacheCode()) { + if (active.containsKey(logical) && (forceScheduled.contains(logical) + || renderedRevision.getOrDefault(logical, Integer.MIN_VALUE) != logical.cacheCode())) { schedule(logical, false); } }); @@ -426,7 +487,44 @@ private static void applyBase(Display actual, DisplayEntity logical) { private static void syncItem(Item logical) { PerformanceMetrics.itemSync(); + boolean packetOnly = qualifiesForPacketOnlyStatic(logical); + boolean wasPacketOnly = packetOnlyItems.contains(logical); org.bukkit.entity.Entity current = logical.getBukkitEntity().orElse(null); + + if (packetOnly) { + PerformanceMetrics.packetOnlyItemSync(); + ItemStack itemStack = logical.getItemStack(); + Location location = logical.getLocation(); + + if (current != null) { + discardActual(logical, current); + } else if (!wasPacketOnly) { + clearViewerTracking(logical); + logical.unbind(); + untrackActual(logical); + } + + packetOnlyItems.add(logical); + itemAnimations.remove(logical); + ItemStack previousItemStack = renderedItemStacks.put(logical, itemStack.clone()); + Location previousLocation = renderedItemLocations.put(logical, location.clone()); + boolean itemChanged = previousItemStack == null || !previousItemStack.equals(itemStack); + boolean locationChanged = previousLocation == null || !previousLocation.equals(location); + if (wasPacketOnly && (itemChanged || locationChanged)) { + respawnVirtualItems(logical); + } + return; + } + + if (wasPacketOnly) { + packetOnlyItems.remove(logical); + renderedItemLocations.remove(logical); + itemAnimations.remove(logical); + clearViewerTracking(logical); + logical.unbind(); + untrackActual(logical); + } + if (current != null && (!current.getWorld().equals(logical.getWorld()) || !(current instanceof ItemDisplay))) { discardActual(logical, current); @@ -447,6 +545,7 @@ private static void syncItem(Item logical) { ItemStack itemStack = logical.getItemStack(); ItemStack previousItemStack = renderedItemStacks.put(logical, itemStack.clone()); + renderedItemLocations.remove(logical); boolean itemChanged = previousItemStack == null || !previousItemStack.equals(itemStack); // The Paper entity is only an invisible tracker/name anchor. Rendering @@ -554,7 +653,7 @@ private static boolean requiresActualEntity(VisualizerEntity logical) { if (logical instanceof ItemFrame frame) { return shouldRender(frame); } - return logical instanceof Item; + return logical instanceof Item item && !qualifiesForPacketOnlyStatic(item); } private static boolean shouldRender(DisplayEntity logical) { @@ -573,6 +672,19 @@ static boolean useStaticAnchorForAnimation(boolean configured, boolean customNam return configured && !customNameVisible; } + static boolean qualifiesForPacketOnlyStatic(boolean configured, boolean gravity, Vector velocity, + boolean customNameVisible, boolean glowing) { + return configured && !gravity && velocity != null + && velocity.getX() == 0.0D && velocity.getY() == 0.0D && velocity.getZ() == 0.0D + && !customNameVisible && !glowing; + } + + private static boolean qualifiesForPacketOnlyStatic(Item logical) { + return qualifiesForPacketOnlyStatic( + InteractionVisualizer.packetOnlyStaticVirtualItems, + logical.hasGravity(), logical.getVelocity(), logical.isCustomNameVisible(), logical.isGlowing()); + } + private static void scheduleItemAnimationTick() { if (itemAnimationTickScheduled || itemAnimations.isEmpty() || plugin() == null || !plugin().isEnabled()) { @@ -675,25 +787,38 @@ static Vector itemVelocityAfterMovement(Vector movement) { movement.getZ() * ITEM_HORIZONTAL_DRAG_PER_TICK); } - private static void spawnVirtualItem(Item logical, Player player) { + private static boolean spawnVirtualItem(Item logical, Player player) { org.bukkit.entity.Entity anchor = logical.getBukkitEntity().orElse(null); Set shown = shownViewers.get(logical); - if (!(anchor instanceof ItemDisplay) || !active.containsKey(logical) - || shown == null || !shown.contains(player.getUniqueId()) - || !player.isOnline() || !player.getWorld().equals(anchor.getWorld())) { - return; + boolean packetOnly = packetOnlyItems.contains(logical); + Location source; + if (!active.containsKey(logical) || shown == null || !shown.contains(player.getUniqueId()) + || !player.isOnline()) { + return false; + } + if (packetOnly) { + source = logical.getLocation(); + if (!qualifiesForPacketOnlyStatic(logical) || !player.getWorld().equals(source.getWorld()) + || !player.isChunkSent(Chunk.getChunkKey(source))) { + return false; + } + } else { + if (!(anchor instanceof ItemDisplay) || !player.getWorld().equals(anchor.getWorld())) { + return false; + } + source = anchor.getLocation(); } Map ids = virtualItemIds.computeIfAbsent(logical, ignored -> new ConcurrentHashMap<>()); UUID viewer = player.getUniqueId(); if (ids.containsKey(viewer)) { - return; + return true; } Integer spawnedId = null; try { spawnedId = SparrowHeart.getInstance().dropFakeItem( - player, logical.getItemStack(), anchor.getLocation()); + player, logical.getItemStack(), source); PerformanceMetrics.virtualSpawnBundle(); ids.put(viewer, spawnedId); Vector motion = nextVirtualItemMotion(logical); @@ -701,6 +826,7 @@ private static void spawnVirtualItem(Item logical, Player player) { SparrowHeart.getInstance().sendClientSideEntityMotion(player, motion, spawnedId); PerformanceMetrics.virtualMotionBundle(); } + return true; } catch (RuntimeException exception) { if (spawnedId != null) { ids.remove(viewer, spawnedId); @@ -715,6 +841,7 @@ private static void spawnVirtualItem(Item logical, Player player) { virtualItemIds.remove(logical, ids); } logVirtualItemFailure("spawn", logical, exception); + return false; } } @@ -756,11 +883,13 @@ private static void respawnVirtualItems(Item logical) { return; } Set viewers = new HashSet<>(ids.keySet()); + boolean packetOnly = packetOnlyItems.contains(logical); for (UUID viewer : viewers) { Player player = Bukkit.getPlayer(viewer); removeVirtualItem(logical, viewer, player); - if (player != null) { - spawnVirtualItem(logical, player); + boolean spawned = player != null && spawnVirtualItem(logical, player); + if (packetOnly && !spawned) { + forgetShownViewer(logical, viewer, player); } } } @@ -951,6 +1080,7 @@ private static void clearViewerTracking(VisualizerEntity logical) { } private static void clearViewerTracking(VisualizerEntity logical, org.bukkit.entity.Entity actual) { + cancelVisibilityShows(logical); if (logical instanceof Item item) { destroyVirtualItems(item); } @@ -973,6 +1103,227 @@ private static void clearViewerTracking(VisualizerEntity logical, org.bukkit.ent } } + private static boolean isViewerEnabledFor(VisualizerEntity logical, Player player) { + Collection desiredPlayers = active.get(logical); + if (desiredPlayers == null || player == null) { + return false; + } + UUID viewer = player.getUniqueId(); + for (Player candidate : desiredPlayers) { + if (candidate != null && viewer.equals(candidate.getUniqueId())) { + return true; + } + } + return false; + } + + private static boolean isViewerDesired(VisualizerEntity logical, Player player) { + return isViewerRenderable(logical, player) && isViewerEnabledFor(logical, player); + } + + private static boolean isQueuedViewerStillRenderable(VisualizerEntity logical, Player player) { + // Eligibility was checked before enqueueing. Every internal eligibility + // mutation (preference reset, reload, world/chunk lifecycle, removal) + // explicitly cancels or reconciles the queue, so the hot drain path only + // needs O(1) liveness/renderability validation. + return active.containsKey(logical) && isViewerRenderable(logical, player); + } + + private static boolean isViewerRenderable(VisualizerEntity logical, Player player) { + if (player == null || !player.isOnline()) { + return false; + } + if (logical instanceof Item item && packetOnlyItems.contains(item)) { + Location location = item.getLocation(); + if (!player.getWorld().equals(location.getWorld())) { + return false; + } + PerformanceMetrics.virtualViewerChecks(1); + return qualifiesForPacketOnlyStatic(item) && player.isChunkSent(Chunk.getChunkKey(location)); + } + org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); + return actual != null && player.getWorld().equals(actual.getWorld()); + } + + private static void requestViewerShow(VisualizerEntity logical, Player player) { + Set shown = shownViewers.get(logical); + if (shown != null && shown.contains(player.getUniqueId())) { + cancelVisibilityShow(logical, player.getUniqueId()); + return; + } + if (!InteractionVisualizer.visibilityRateLimiting) { + cancelVisibilityShow(logical, player.getUniqueId()); + showViewerNow(logical, player); + return; + } + + VisibilityShowQueue queue = visibilityShowQueues.computeIfAbsent( + player.getUniqueId(), ignored -> new VisibilityShowQueue<>( + InteractionVisualizer.visibilityRateLimitBucketSize, currentServerTick())); + if (queue.request(logical)) { + PerformanceMetrics.visibilityShowQueued(); + } + scheduleVisibilityTick(); + } + + private static void showViewerNow(VisualizerEntity logical, Player player) { + cancelVisibilityShow(logical, player.getUniqueId()); + Set shown = shownViewers.computeIfAbsent(logical, ignored -> ConcurrentHashMap.newKeySet()); + UUID viewer = player.getUniqueId(); + if (!shown.add(viewer)) { + return; + } + + boolean shownSuccessfully; + if (logical instanceof Item item && packetOnlyItems.contains(item)) { + shownSuccessfully = spawnVirtualItem(item, player); + } else { + org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); + shownSuccessfully = actual != null; + if (shownSuccessfully) { + PerformanceMetrics.bukkitShow(); + player.showEntity(plugin(), actual); + } + } + + if (shownSuccessfully) { + playerStatus.computeIfAbsent(player, ignored -> ConcurrentHashMap.newKeySet()).add(logical); + } else { + forgetShownViewer(logical, viewer, player); + } + } + + private static void hideViewer(VisualizerEntity logical, UUID viewer, Player player) { + cancelVisibilityShow(logical, viewer); + Set shown = shownViewers.get(logical); + boolean wasShown = shown != null && shown.remove(viewer); + if (shown != null && shown.isEmpty()) { + shownViewers.remove(logical, shown); + } + if (logical instanceof Item item) { + removeVirtualItem(item, viewer, player); + } + org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); + if (wasShown && actual != null && player != null) { + PerformanceMetrics.bukkitHide(); + player.hideEntity(plugin(), actual); + } + if (player != null) { + Set status = playerStatus.get(player); + if (status != null) { + status.remove(logical); + } + } + } + + private static Integer forgetShownViewer(VisualizerEntity logical, UUID viewer, Player player) { + cancelVisibilityShow(logical, viewer); + Set shown = shownViewers.get(logical); + if (shown != null) { + shown.remove(viewer); + if (shown.isEmpty()) { + shownViewers.remove(logical, shown); + } + } + Integer forgottenVirtualId = logical instanceof Item item ? forgetVirtualItem(item, viewer) : null; + if (player != null) { + Set status = playerStatus.get(player); + if (status != null) { + status.remove(logical); + } + } + return forgottenVirtualId; + } + + private static void cancelVisibilityShow(VisualizerEntity logical, UUID viewer) { + VisibilityShowQueue queue = visibilityShowQueues.get(viewer); + if (queue != null) { + queue.cancel(logical); + } + } + + private static void cancelVisibilityShows(VisualizerEntity logical) { + for (VisibilityShowQueue queue : visibilityShowQueues.values()) { + queue.cancel(logical); + } + } + + private static void clearVisibilityShowQueue(UUID viewer) { + VisibilityShowQueue queue = visibilityShowQueues.remove(viewer); + if (queue != null) { + queue.clear(); + } + } + + private static void scheduleVisibilityTick() { + if (visibilityTickScheduled || !hasPendingVisibilityShows() + || plugin() == null || !plugin().isEnabled()) { + return; + } + visibilityTickScheduled = true; + Bukkit.getScheduler().runTaskLater(plugin(), () -> { + try { + drainVisibilityShowQueues(); + } finally { + visibilityTickScheduled = false; + scheduleVisibilityTick(); + } + }, 1L); + } + + private static void drainVisibilityShowQueues() { + boolean limited = InteractionVisualizer.visibilityRateLimiting; + int capacity = InteractionVisualizer.visibilityRateLimitBucketSize; + int refill = InteractionVisualizer.visibilityRateLimitRestorePerTick; + long currentTick = limited ? currentServerTick() : 0L; + for (Map.Entry> entry : visibilityShowQueues.entrySet()) { + UUID viewer = entry.getKey(); + VisibilityShowQueue queue = entry.getValue(); + Player player = Bukkit.getPlayer(viewer); + if (player == null || !player.isOnline()) { + queue.clear(); + visibilityShowQueues.remove(viewer, queue); + continue; + } + + Predicate desired = logical -> isQueuedViewerStillRenderable(logical, player); + List ready = limited + ? queue.drain(capacity, refill, currentTick, desired) + : queue.drainAll(desired); + for (VisualizerEntity logical : ready) { + PerformanceMetrics.visibilityShowDrained(); + showViewerNow(logical, player); + } + } + } + + private static boolean hasPendingVisibilityShows() { + for (VisibilityShowQueue queue : visibilityShowQueues.values()) { + if (queue.hasPending()) { + return true; + } + } + return false; + } + + private static long currentServerTick() { + return Integer.toUnsignedLong(Bukkit.getCurrentTick()); + } + + private static void forgetPacketOnlyViewer(Player player) { + UUID viewer = player.getUniqueId(); + clearVisibilityShowQueue(viewer); + Set status = playerStatus.get(player); + if (status == null || status.isEmpty()) { + return; + } + for (VisualizerEntity logical : new HashSet<>(status)) { + if (logical instanceof Item item && packetOnlyItems.contains(item)) { + forgetShownViewer(item, viewer, player); + } + } + } + private static void removeOwnedEntities(boolean clearVisibilityOverrides) { NamespacedKey key = ownerKey(); for (World world : Bukkit.getWorlds()) { @@ -992,57 +1343,57 @@ private static void removeOwnedEntities(boolean clearVisibilityOverrides) { } private static void reconcileViewers(VisualizerEntity logical) { - org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); Collection desiredPlayers = active.get(logical); - if (actual == null || desiredPlayers == null) { - return; - } - Set desired = new HashSet<>(); - for (Player player : desiredPlayers) { - if (player != null && player.isOnline() && player.getWorld().equals(actual.getWorld())) { - desired.add(player.getUniqueId()); + if (desiredPlayers != null) { + for (Player player : desiredPlayers) { + if (isViewerRenderable(logical, player)) { + desired.add(player.getUniqueId()); + } } } - - Set shown = shownViewers.computeIfAbsent(logical, ignored -> ConcurrentHashMap.newKeySet()); - for (UUID uuid : new HashSet<>(shown)) { - if (!desired.contains(uuid)) { - Player player = Bukkit.getPlayer(uuid); - if (player != null) { - if (logical instanceof Item item) { - removeVirtualItem(item, uuid, player); - } - PerformanceMetrics.bukkitHide(); - player.hideEntity(plugin(), actual); - Set status = playerStatus.get(player); - if (status != null) { - status.remove(logical); - } + Set shown = shownViewers.get(logical); + if (shown != null) { + for (UUID uuid : new HashSet<>(shown)) { + if (!desired.contains(uuid)) { + hideViewer(logical, uuid, Bukkit.getPlayer(uuid)); } - shown.remove(uuid); } } for (UUID uuid : desired) { - if (shown.add(uuid)) { + shown = shownViewers.get(logical); + if (shown == null || !shown.contains(uuid)) { Player player = Bukkit.getPlayer(uuid); if (player != null) { - PerformanceMetrics.bukkitShow(); - player.showEntity(plugin(), actual); - playerStatus.computeIfAbsent(player, ignored -> ConcurrentHashMap.newKeySet()).add(logical); + requestViewerShow(logical, player); } } } } + private static void reconcileViewer(VisualizerEntity logical, Player player) { + UUID viewer = player.getUniqueId(); + if (!isViewerDesired(logical, player)) { + hideViewer(logical, viewer, player); + return; + } + Set shown = shownViewers.get(logical); + if (shown == null || !shown.contains(viewer)) { + requestViewerShow(logical, player); + } + } + private static void remove(Collection players, VisualizerEntity logical, boolean removeFromActive) { if (removeFromActive) { active.remove(logical); renderedRevision.remove(logical); + forceScheduled.remove(logical); unindex(logical); if (logical instanceof Item item) { itemAnimations.remove(item); renderedItemStacks.remove(item); + renderedItemLocations.remove(item); + packetOnlyItems.remove(item); } } runSync(() -> { @@ -1055,22 +1406,10 @@ private static void remove(Collection players, VisualizerEntity logical, logical.unbind(); clearViewerTracking(logical); } - } else if (actual != null && players != null) { - Set shown = shownViewers.get(logical); - if (shown == null) { - return; - } + } else if (players != null) { for (Player player : players) { - if (player != null && shown.remove(player.getUniqueId())) { - if (logical instanceof Item item) { - removeVirtualItem(item, player.getUniqueId(), player); - } - PerformanceMetrics.bukkitHide(); - player.hideEntity(plugin(), actual); - Set status = playerStatus.get(player); - if (status != null) { - status.remove(logical); - } + if (player != null) { + hideViewer(logical, player.getUniqueId(), player); } } } @@ -1109,17 +1448,26 @@ public void onUntrackEntity(PlayerUntrackEntityEvent event) { @EventHandler public void onJoin(PlayerJoinEvent event) { + forgetPacketOnlyViewer(event.getPlayer()); Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); } @EventHandler public void onWorldChange(PlayerChangedWorldEvent event) { + forgetPacketOnlyViewer(event.getPlayer()); + Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); + } + + @EventHandler + public void onRespawn(PlayerRespawnEvent event) { + forgetPacketOnlyViewer(event.getPlayer()); Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); } @EventHandler public void onLeave(PlayerQuitEvent event) { UUID uuid = event.getPlayer().getUniqueId(); + clearVisibilityShowQueue(uuid); for (Map.Entry> entry : shownViewers.entrySet()) { entry.getValue().remove(uuid); if (entry.getKey() instanceof Item item) { @@ -1131,6 +1479,48 @@ public void onLeave(PlayerQuitEvent event) { playerStatus.remove(event.getPlayer()); } + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerChunkLoad(PlayerChunkLoadEvent event) { + if (!InteractionVisualizer.packetOnlyStaticVirtualItems) { + return; + } + Chunk chunk = event.getChunk(); + ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + Set logicals = logicalsByChunk.get(key); + if (logicals == null) { + return; + } + for (VisualizerEntity logical : new HashSet<>(logicals)) { + if (logical instanceof Item item && packetOnlyItems.contains(item)) { + reconcileViewer(item, event.getPlayer()); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerChunkUnload(PlayerChunkUnloadEvent event) { + if (!InteractionVisualizer.packetOnlyStaticVirtualItems || packetOnlyItems.isEmpty()) { + return; + } + Chunk chunk = event.getChunk(); + ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + Set logicals = logicalsByChunk.get(key); + if (logicals == null) { + return; + } + Player player = event.getPlayer(); + List virtualItemIdsToRemove = new ArrayList<>(); + for (VisualizerEntity logical : new HashSet<>(logicals)) { + if (logical instanceof Item item && packetOnlyItems.contains(item)) { + Integer id = forgetShownViewer(item, player.getUniqueId(), player); + if (id != null) { + virtualItemIdsToRemove.add(id); + } + } + } + removeVirtualItems(player, virtualItemIdsToRemove); + } + @EventHandler public void onChunkLoad(ChunkLoadEvent event) { ChunkKey key = new ChunkKey(event.getWorld().getUID(), event.getChunk().getX(), event.getChunk().getZ()); @@ -1160,6 +1550,8 @@ public void onEntityRemove(EntityRemoveEvent event) { renderedRevision.remove(logical); unindex(logical); renderedItemStacks.remove((Item) logical); + renderedItemLocations.remove((Item) logical); + packetOnlyItems.remove((Item) logical); return; } if (event.getCause() != EntityRemoveEvent.Cause.UNLOAD && active.containsKey(logical)) { @@ -1173,6 +1565,99 @@ public void onEntityRemove(EntityRemoveEvent event) { } } + static final class VisibilityShowQueue { + + private final Deque> pending; + private final Map queued; + private int tokens; + private long lastRefillTick; + private long nextGeneration; + + VisibilityShowQueue(int initialTokens) { + this(initialTokens, 0L); + } + + VisibilityShowQueue(int initialTokens, long initialTick) { + this.pending = new ArrayDeque<>(); + this.queued = new HashMap<>(); + this.tokens = Math.max(0, initialTokens); + this.lastRefillTick = initialTick; + } + + boolean request(T value) { + if (queued.containsKey(value)) { + return false; + } + long generation = ++nextGeneration; + queued.put(value, generation); + pending.addLast(new QueuedShow<>(value, generation)); + return true; + } + + void cancel(T value) { + queued.remove(value); + if (queued.isEmpty()) { + pending.clear(); + } + } + + List drain(int capacity, int refill, Predicate desired) { + return drain(capacity, refill, lastRefillTick + 1L, desired); + } + + List drain(int capacity, int refill, long currentTick, Predicate desired) { + int boundedCapacity = Math.max(1, capacity); + long elapsedTicks = Math.max(0L, currentTick - lastRefillTick); + long restored = elapsedTicks * (long) Math.max(0, refill); + tokens = (int) Math.min(boundedCapacity, Math.max(0L, tokens + restored)); + lastRefillTick = Math.max(lastRefillTick, currentTick); + List ready = new ArrayList<>(Math.min(tokens, queued.size())); + int inspected = 0; + while (tokens > 0 && inspected < boundedCapacity && !pending.isEmpty()) { + QueuedShow request = pending.removeFirst(); + inspected++; + T value = request.value(); + if (!queued.remove(value, request.generation()) || !desired.test(value)) { + continue; + } + ready.add(value); + tokens--; + } + if (queued.isEmpty()) { + pending.clear(); + } + return ready; + } + + List drainAll(Predicate desired) { + List ready = new ArrayList<>(queued.size()); + while (!pending.isEmpty()) { + QueuedShow request = pending.removeFirst(); + T value = request.value(); + if (queued.remove(value, request.generation()) && desired.test(value)) { + ready.add(value); + } + } + return ready; + } + + boolean isEmpty() { + return queued.isEmpty(); + } + + boolean hasPending() { + return !queued.isEmpty(); + } + + void clear() { + pending.clear(); + queued.clear(); + } + + private record QueuedShow(T value, long generation) { + } + } + private static final class ItemAnimationState { private Vector velocity; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java index f5528e7..bbac7c4 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -38,6 +38,13 @@ public final class PerformanceMetrics implements Listener { private volatile boolean collecting; private String label = ""; + private boolean configStaticAnchor; + private boolean configPacketOnlyStatic; + private boolean configVisibilityRateLimit; + private int configVisibilityBucketSize; + private int configVisibilityRestorePerTick; + private boolean configEventDrivenBlockUpdates; + private int configBlockUpdateMaxDirtyPerTick; private long startedNanos; private int tickCount; private long tickSamplesDropped; @@ -53,9 +60,14 @@ public final class PerformanceMetrics implements Listener { private long bukkitHideCalls; private long displaySyncs; private long itemSyncs; + private long packetOnlyItemSyncs; private long virtualViewerChecks; + private long visibilityShowsQueued; + private long visibilityShowsDrained; private long itemAnimationNanos; private long droppedItemNanos; + private long blockUpdateChecks; + private long blockUpdateNanos; private PerformanceMetrics() { } @@ -73,6 +85,13 @@ public static boolean start(String requestedLabel) { return false; } INSTANCE.label = sanitizeLabel(requestedLabel); + INSTANCE.configStaticAnchor = InteractionVisualizer.staticVirtualItemAnchorsDuringAnimation; + INSTANCE.configPacketOnlyStatic = InteractionVisualizer.packetOnlyStaticVirtualItems; + INSTANCE.configVisibilityRateLimit = InteractionVisualizer.visibilityRateLimiting; + INSTANCE.configVisibilityBucketSize = InteractionVisualizer.visibilityRateLimitBucketSize; + INSTANCE.configVisibilityRestorePerTick = InteractionVisualizer.visibilityRateLimitRestorePerTick; + INSTANCE.configEventDrivenBlockUpdates = InteractionVisualizer.eventDrivenBlockUpdates; + INSTANCE.configBlockUpdateMaxDirtyPerTick = InteractionVisualizer.blockUpdateMaxDirtyPerTick; INSTANCE.startedNanos = System.nanoTime(); INSTANCE.tickCount = 0; INSTANCE.tickSamplesDropped = 0; @@ -88,9 +107,14 @@ public static boolean start(String requestedLabel) { INSTANCE.bukkitHideCalls = 0; INSTANCE.displaySyncs = 0; INSTANCE.itemSyncs = 0; + INSTANCE.packetOnlyItemSyncs = 0; INSTANCE.virtualViewerChecks = 0; + INSTANCE.visibilityShowsQueued = 0; + INSTANCE.visibilityShowsDrained = 0; INSTANCE.itemAnimationNanos = 0; INSTANCE.droppedItemNanos = 0; + INSTANCE.blockUpdateChecks = 0; + INSTANCE.blockUpdateNanos = 0; INSTANCE.collecting = true; return true; } @@ -181,12 +205,30 @@ public static void itemSync() { } } + public static void packetOnlyItemSync() { + if (INSTANCE.collecting) { + INSTANCE.packetOnlyItemSyncs++; + } + } + public static void virtualViewerChecks(int checks) { if (INSTANCE.collecting) { INSTANCE.virtualViewerChecks += checks; } } + public static void visibilityShowQueued() { + if (INSTANCE.collecting) { + INSTANCE.visibilityShowsQueued++; + } + } + + public static void visibilityShowDrained() { + if (INSTANCE.collecting) { + INSTANCE.visibilityShowsDrained++; + } + } + public static void itemAnimationNanos(long nanos) { if (INSTANCE.collecting) { INSTANCE.itemAnimationNanos += nanos; @@ -199,6 +241,13 @@ public static void droppedItemNanos(long nanos) { } } + public static void blockUpdateChecks(int checks, long nanos) { + if (INSTANCE.collecting) { + INSTANCE.blockUpdateChecks += checks; + INSTANCE.blockUpdateNanos += nanos; + } + } + @EventHandler public void onServerTickEnd(ServerTickEndEvent event) { if (!collecting) { @@ -226,13 +275,16 @@ private Snapshot createSnapshot() { } mean = samples == 0 ? 0.0D : mean / samples; long elapsedNanos = Math.max(1L, System.nanoTime() - startedNanos); - return new Snapshot(label, elapsedNanos, samples, tickSamplesDropped, + return new Snapshot(label, configStaticAnchor, configPacketOnlyStatic, configVisibilityRateLimit, + configVisibilityBucketSize, configVisibilityRestorePerTick, configEventDrivenBlockUpdates, + configBlockUpdateMaxDirtyPerTick, elapsedNanos, samples, tickSamplesDropped, percentile(sorted, 0.50D), percentile(sorted, 0.95D), percentile(sorted, 0.99D), percentile(sorted, 0.999D), samples == 0 ? 0.0D : sorted[samples - 1], mean, over50, virtualSpawnBundles, virtualMotionBundles, virtualTeleportBundles, virtualRemovePackets, virtualPickupPackets, bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, - bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, virtualViewerChecks, - itemAnimationNanos, droppedItemNanos); + bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, packetOnlyItemSyncs, + virtualViewerChecks, visibilityShowsQueued, visibilityShowsDrained, + itemAnimationNanos, droppedItemNanos, blockUpdateChecks, blockUpdateNanos); } private static double percentile(double[] sorted, double percentile) { @@ -247,11 +299,19 @@ private static String sanitizeLabel(String value) { if (value == null || value.isBlank()) { return "unnamed"; } - return value.replaceAll("[^A-Za-z0-9_.-]", "_").substring(0, Math.min(48, value.length())); + String sanitized = value.replaceAll("[^A-Za-z0-9_.-]", "_"); + return sanitized.substring(0, Math.min(48, sanitized.length())); } public record Snapshot( String label, + boolean staticAnchorDuringAnimation, + boolean packetOnlyStatic, + boolean visibilityRateLimit, + int visibilityBucketSize, + int visibilityRestorePerTick, + boolean eventDrivenBlockUpdates, + int blockUpdateMaxDirtyPerTick, long elapsedNanos, int tickSamples, long droppedTickSamples, @@ -274,9 +334,14 @@ public record Snapshot( long bukkitHideCalls, long displaySyncs, long itemSyncs, + long packetOnlyItemSyncs, long virtualViewerChecks, + long visibilityShowsQueued, + long visibilityShowsDrained, long itemAnimationNanos, - long droppedItemNanos) { + long droppedItemNanos, + long blockUpdateChecks, + long blockUpdateNanos) { public double seconds() { return elapsedNanos / 1_000_000_000.0D; @@ -290,14 +355,20 @@ public long knownVirtualPackets() { public String summary() { return String.format(Locale.ROOT, - "label=%s samples=%d p50/p95/p99=%.3f/%.3f/%.3fms virtualPackets=%d anchors=%d/%d/%d", - label, tickSamples, msptP50, msptP95, msptP99, knownVirtualPackets(), + "label=%s modes=%s/%s/%s/%s samples=%d p50/p95/p99=%.3f/%.3f/%.3fms virtualPackets=%d anchors=%d/%d/%d", + label, staticAnchorDuringAnimation, packetOnlyStatic, visibilityRateLimit, + eventDrivenBlockUpdates, + tickSamples, msptP50, msptP95, msptP99, knownVirtualPackets(), bukkitEntitySpawns, bukkitEntityTeleports, bukkitEntityRemoves); } public String json() { return String.format(Locale.ROOT, - "{\"label\":\"%s\",\"seconds\":%.3f,\"tickSamples\":%d," + + "{\"label\":\"%s\",\"staticAnchorDuringAnimation\":%b," + + "\"packetOnlyStatic\":%b,\"visibilityRateLimit\":%b," + + "\"visibilityBucketSize\":%d,\"visibilityRestorePerTick\":%d," + + "\"eventDrivenBlockUpdates\":%b,\"blockUpdateMaxDirtyPerTick\":%d," + + "\"seconds\":%.3f,\"tickSamples\":%d," + "\"droppedTickSamples\":%d,\"msptP50\":%.6f,\"msptP95\":%.6f," + "\"msptP99\":%.6f,\"msptP999\":%.6f,\"msptMax\":%.6f," + "\"msptMean\":%.6f,\"ticksOver50ms\":%d," + @@ -307,13 +378,20 @@ public String json() { "\"bukkitEntitySpawns\":%d,\"bukkitEntityRemoves\":%d," + "\"bukkitEntityTeleports\":%d,\"bukkitShowCalls\":%d," + "\"bukkitHideCalls\":%d,\"displaySyncs\":%d,\"itemSyncs\":%d," + - "\"virtualViewerChecks\":%d,\"itemAnimationMs\":%.6f,\"droppedItemMs\":%.6f}", - label, seconds(), tickSamples, droppedTickSamples, msptP50, msptP95, msptP99, + "\"packetOnlyItemSyncs\":%d,\"virtualViewerChecks\":%d," + + "\"visibilityShowsQueued\":%d,\"visibilityShowsDrained\":%d," + + "\"itemAnimationMs\":%.6f,\"droppedItemMs\":%.6f," + + "\"blockUpdateChecks\":%d,\"blockUpdateMs\":%.6f}", + label, staticAnchorDuringAnimation, packetOnlyStatic, visibilityRateLimit, + visibilityBucketSize, visibilityRestorePerTick, eventDrivenBlockUpdates, + blockUpdateMaxDirtyPerTick, seconds(), tickSamples, droppedTickSamples, msptP50, msptP95, msptP99, msptP999, msptMax, msptMean, ticksOver50ms, virtualSpawnBundles, virtualMotionBundles, virtualTeleportBundles, virtualRemovePackets, virtualPickupPackets, knownVirtualPackets(), bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, - bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, virtualViewerChecks, - itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D); + bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, packetOnlyItemSyncs, + virtualViewerChecks, visibilityShowsQueued, visibilityShowsDrained, + itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D, + blockUpdateChecks, blockUpdateNanos / 1_000_000.0D); } } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java index 1705ca3..3b18e63 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java @@ -22,6 +22,9 @@ import com.loohp.interactionvisualizer.InteractionVisualizer; import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.objectholders.ChunkPosition; import com.loohp.interactionvisualizer.objectholders.TileEntity; @@ -41,11 +44,16 @@ import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityChangeBlockEvent; import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.vehicle.VehicleMoveEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; import org.bukkit.plugin.Plugin; import java.util.Collection; @@ -56,6 +64,7 @@ import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; public class TileEntityManager implements Listener { @@ -64,6 +73,9 @@ public class TileEntityManager implements Listener { private static final TileEntityType[] tileEntityTypes = TileEntityType.values(); private static final Map> active = new EnumMap<>(TileEntityType.class); private static final Map> byChunk = new HashMap<>(); + private static final Map lastActiveTypes = new HashMap<>(); + private static final Map> watchedChunksByPlayer = new HashMap<>(); + private static final Map watcherCounts = new HashMap<>(); public static void _init_() { for (TileEntityType type : tileEntityTypes) { @@ -72,13 +84,30 @@ public static void _init_() { TileEntityManager instance = new TileEntityManager(); Bukkit.getPluginManager().registerEvents(instance, plugin); Scheduler.runTaskTimer(plugin, () -> { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Set online = new LinkedHashSet<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + online.add(player.getUniqueId()); + updateWatchedChunks(player.getUniqueId(), getAllChunks(player.getLocation())); + } + for (UUID playerId : new LinkedHashSet<>(watchedChunksByPlayer.keySet())) { + if (!online.contains(playerId)) { + clearWatchedChunks(playerId); + } + } + return; + } for (TileEntityType type : tileEntityTypes) { Set blocks = active.get(type); blocks.removeIf(block -> !PlayerLocationManager.hasPlayerNearby(block.getLocation())); } }, 0, InteractionVisualizerAPI.getGCPeriod()); for (Player player : Bukkit.getOnlinePlayers()) { - addTileEntities(getAllChunks(player.getLocation())); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(player.getUniqueId(), getAllChunks(player.getLocation())); + } else { + addTileEntities(getAllChunks(player.getLocation())); + } } } @@ -125,12 +154,22 @@ private synchronized static void addTileEntities(ChunkPosition chunk) { blocks = new LinkedHashSet<>(); byChunk.put(chunk, blocks); } + boolean activate = !InteractionVisualizer.eventDrivenBlockUpdates || watcherCounts.getOrDefault(chunk, 0) > 0; Map newBlocks = new LinkedHashMap<>(); for (BlockState state : list) { Block block = state.getBlock(); TileEntityType type = TileEntity.getTileEntityType(state.getType()); if (type != null) { - active.get(type).add(block); + if (activate && active.get(type).add(block)) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + TileEntityType lastActiveType = lastActiveTypes.put(block, type); + if (type.equals(lastActiveType)) { + callActivatedEvent(block, type); + } else { + callAddedEvent(block, type); + } + } + } newBlocks.put(block, type); blocks.add(block); } @@ -141,6 +180,9 @@ private synchronized static void addTileEntities(ChunkPosition chunk) { TileEntityType type = newBlocks.get(block); if (type == null) { itr.remove(); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + lastActiveTypes.remove(block); + } for (TileEntityType t : tileEntityTypes) { if (active.get(t).remove(block)) { Bukkit.getPluginManager().callEvent(new TileEntityRemovedEvent(block, t)); @@ -158,9 +200,123 @@ private synchronized static void addTileEntities(ChunkPosition chunk) { } } + private synchronized static void deactivateTileEntities(ChunkPosition chunk) { + Set blocks = byChunk.get(chunk); + if (blocks == null || blocks.isEmpty()) { + return; + } + for (Block block : blocks) { + for (TileEntityType type : tileEntityTypes) { + if (active.get(type).remove(block)) { + callDeactivatedEvent(block, type); + } + } + } + } + + private synchronized static void unloadTileEntities(ChunkPosition chunk) { + deactivateTileEntities(chunk); + Set blocks = byChunk.remove(chunk); + if (blocks != null) { + for (Block block : blocks) { + lastActiveTypes.remove(block); + } + } + } + + private synchronized static void updateWatchedChunks(UUID playerId, Set nextChunks) { + Set previousChunks = watchedChunksByPlayer.put(playerId, nextChunks); + if (previousChunks == null) { + previousChunks = Set.of(); + } + + for (ChunkPosition chunk : previousChunks) { + if (nextChunks.contains(chunk)) { + continue; + } + Integer count = watcherCounts.get(chunk); + if (count == null || count <= 1) { + watcherCounts.remove(chunk); + deactivateTileEntities(chunk); + } else { + watcherCounts.put(chunk, count - 1); + } + } + + for (ChunkPosition chunk : nextChunks) { + if (previousChunks.contains(chunk)) { + continue; + } + int count = watcherCounts.getOrDefault(chunk, 0); + watcherCounts.put(chunk, count + 1); + if (count == 0) { + addTileEntities(chunk); + } + } + } + + private synchronized static void clearWatchedChunks(UUID playerId) { + Set chunks = watchedChunksByPlayer.remove(playerId); + if (chunks == null) { + return; + } + for (ChunkPosition chunk : chunks) { + Integer count = watcherCounts.get(chunk); + if (count == null || count <= 1) { + watcherCounts.remove(chunk); + deactivateTileEntities(chunk); + } else { + watcherCounts.put(chunk, count - 1); + } + } + } + + private static void callAddedEvent(Block block, TileEntityType type) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Bukkit.getPluginManager().callEvent(new TileEntityAddedEvent(block, type)); + } + } + + private static void callActivatedEvent(Block block, TileEntityType type) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Bukkit.getPluginManager().callEvent(new TileEntityActivatedEvent(block, type)); + } + } + + private static void callDeactivatedEvent(Block block, TileEntityType type) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Bukkit.getPluginManager().callEvent(new TileEntityDeactivatedEvent(block, type)); + } + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onJoin(PlayerJoinEvent event) { - addTileEntities(getAllChunks(event.getPlayer().getLocation())); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(event.getPlayer().getLocation())); + } else { + addTileEntities(getAllChunks(event.getPlayer().getLocation())); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + clearWatchedChunks(event.getPlayer().getUniqueId()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onRespawn(PlayerRespawnEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(event.getRespawnLocation())); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChangedWorld(PlayerChangedWorldEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(event.getPlayer().getLocation())); + } } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -169,6 +325,11 @@ public void onInteract(PlayerInteractEvent event) { if (block != null) { TileEntityType type = TileEntity.getTileEntityType(block.getType()); if (type != null) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Set chunks = getAllChunks(event.getPlayer().getLocation()); + chunks.add(getChunk(block.getLocation())); + updateWatchedChunks(event.getPlayer().getUniqueId(), chunks); + } if (!active.get(type).contains(block)) { addTileEntities(getChunk(block.getLocation())); } @@ -181,7 +342,11 @@ public void onTeleport(PlayerTeleportEvent event) { Location from = event.getFrom(); Location to = event.getTo(); if (!from.getWorld().equals(to.getWorld()) || from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4) { - addTileEntities(getAllChunks(to)); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(to)); + } else { + addTileEntities(getAllChunks(to)); + } } } @@ -189,8 +354,20 @@ public void onTeleport(PlayerTeleportEvent event) { public void onPlayerMove(PlayerMoveEvent event) { Location from = event.getFrom(); Location to = event.getTo(); - if (!from.getWorld().equals(to.getWorld()) || ((from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4) && !isMovingTooFast(event.getPlayer(), from, to))) { - addTileEntities(getAllChunks(to)); + if (!from.getWorld().equals(to.getWorld())) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(to)); + } else { + addTileEntities(getAllChunks(to)); + } + } else if (from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4) { + if (!isMovingTooFast(event.getPlayer(), from, to)) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(to)); + } else { + addTileEntities(getAllChunks(to)); + } + } } } @@ -199,12 +376,45 @@ public void onVehicleMove(VehicleMoveEvent event) { if (event.getVehicle().getPassengers().stream().anyMatch(each -> each instanceof Player)) { Location from = event.getFrom(); Location to = event.getTo(); - if (!from.getWorld().equals(to.getWorld()) || ((from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4) && !isMovingTooFast(null, from, to))) { - addTileEntities(getAllChunks(to)); + boolean changedWorld = !from.getWorld().equals(to.getWorld()); + boolean changedChunk = from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4; + if (changedWorld || changedChunk) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + boolean movingTooFast = !changedWorld && isMovingTooFast(null, from, to); + if (!movingTooFast) { + Set chunks = getAllChunks(to); + for (org.bukkit.entity.Entity passenger : event.getVehicle().getPassengers()) { + if (passenger instanceof Player player) { + updateWatchedChunks(player.getUniqueId(), chunks); + } + } + } + } else if (changedWorld || !isMovingTooFast(null, from, to)) { + addTileEntities(getAllChunks(to)); + } } } } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkLoad(ChunkLoadEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + ChunkPosition chunk = new ChunkPosition(event.getWorld(), event.getChunk().getX(), event.getChunk().getZ()); + if (watcherCounts.getOrDefault(chunk, 0) > 0) { + addTileEntities(chunk); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkUnload(ChunkUnloadEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + unloadTileEntities(new ChunkPosition(event.getWorld(), event.getChunk().getX(), event.getChunk().getZ())); + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onBreakBlock(BlockBreakEvent event) { if (TileEntity.isTileEntityType(event.getBlock().getType())) { diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index b6074ca..0d4c9ae 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -333,6 +333,20 @@ Settings: #A/B switch: keeps invisible tracker anchors still during short item animations. #The client-side virtual item remains corrected every tick; turn this on only after profiling your server. StaticAnchorDuringAnimation: false + #A/B switch: eligible stationary virtual items without a visible custom name use no Bukkit tracker entity. + #Requires separate packet-capture and client compatibility validation before enabling in production. + PacketOnlyStatic: false + VisibilityRateLimit: + #A/B switch: hides remain immediate; newly visible entities are restored through a per-player token bucket. + Enabled: false + BucketSize: 128 + RestorePerTick: 32 + BlockUpdates: + #A/B switch: coalesces event-driven changes and replaces per-block child tasks with fixed-budget loops. + #Restart the server after changing this option so runnable display tasks are recreated. + EventDriven: false + #Dirty-update budget per block display type; active rotation and fallback audit have separate bounded work. + MaxDirtyPerTick: 64 Options: Updater: true diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockDisplayFlagIsolationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockDisplayFlagIsolationTest.java new file mode 100644 index 0000000..593059d --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockDisplayFlagIsolationTest.java @@ -0,0 +1,58 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.InteractionVisualizer; +import org.bukkit.inventory.Inventory; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BlockDisplayFlagIsolationTest { + + @Test + void disabledEventDrivenModeDoesNotInspectMovedInventoryLocations() throws ReflectiveOperationException { + boolean previous = InteractionVisualizer.eventDrivenBlockUpdates; + AtomicInteger inventoryCalls = new AtomicInteger(); + Inventory inventory = (Inventory) Proxy.newProxyInstance( + Inventory.class.getClassLoader(), new Class[]{Inventory.class}, (proxy, method, arguments) -> { + inventoryCalls.incrementAndGet(); + throw new AssertionError("Disabled event-driven mode inspected inventory via " + method.getName()); + }); + + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Object unsafe = unsafeField.get(null); + Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class); + + try { + InteractionVisualizer.eventDrivenBlockUpdates = false; + for (Class displayType : List.of( + FurnaceDisplay.class, BlastFurnaceDisplay.class, SmokerDisplay.class)) { + Object display = allocateInstance.invoke(unsafe, displayType); + Method markDirty = displayType.getDeclaredMethod("markDirty", Inventory.class); + markDirty.setAccessible(true); + markDirty.invoke(display, inventory); + } + assertEquals(0, inventoryCalls.get()); + } finally { + InteractionVisualizer.eventDrivenBlockUpdates = previous; + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockUpdateSchedulerTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockUpdateSchedulerTest.java new file mode 100644 index 0000000..8e20d16 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockUpdateSchedulerTest.java @@ -0,0 +1,143 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BlockUpdateSchedulerTest { + + @Test + void coalescesDirtyValuesUntilTheirReadyTick() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 20, 600); + List updates = new ArrayList<>(); + + scheduler.markDirty("furnace", 11); + scheduler.markDirty("furnace", 11); + scheduler.markDirty("furnace", 12); + + assertEquals(0, scheduler.tick(10, 64, value -> { + updates.add(value); + return false; + })); + assertEquals(1, scheduler.pendingDirtyCount()); + assertEquals(1, scheduler.tick(11, 64, value -> { + updates.add(value); + return false; + })); + assertEquals(List.of("furnace"), updates); + assertEquals(0, scheduler.pendingDirtyCount()); + } + + @Test + void prioritizesDirtyAndRotatesActiveWithinTheConfiguredPeriod() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 2, 600); + List updates = new ArrayList<>(); + scheduler.markActive("a", 1); + scheduler.markActive("b", 1); + scheduler.markActive("c", 1); + scheduler.markDirty("c", 1); + + assertEquals(3, scheduler.tick(1, 64, value -> { + updates.add(value); + return true; + })); + assertEquals("c", updates.getFirst()); + assertEquals(3, scheduler.activeCount()); + + updates.clear(); + assertEquals(0, scheduler.tick(2, 64, value -> { + updates.add(value); + return true; + })); + assertEquals(3, scheduler.activeCount()); + + // Values are not due again until two ticks after their prior update. + assertEquals(2, scheduler.tick(3, 64, value -> { + updates.add(value); + return !value.equals("a"); + })); + assertEquals(2, scheduler.activeCount()); + } + + @Test + void initialAndPeriodicAuditsAreBoundedWithoutSnapshots() { + Set source = new LinkedHashSet<>(); + for (int i = 0; i < 10; i++) { + source.add(i); + } + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(() -> source, 20, 5); + + assertEquals(3, scheduler.tick(0, 3, value -> false)); + assertEquals(3, scheduler.tick(1, 3, value -> false)); + assertEquals(3, scheduler.tick(2, 3, value -> false)); + assertEquals(1, scheduler.tick(3, 3, value -> false)); + assertEquals(0, scheduler.tick(4, 3, value -> false)); + + // The next audit begins at tick 5 and uses ceil(10 / 5) = 2 checks. + assertEquals(2, scheduler.tick(5, 3, value -> false)); + } + + @Test + void removalCancelsDirtyAndActiveWork() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 1, 600); + scheduler.markDirty("gone", 1); + scheduler.markActive("gone", 1); + scheduler.remove("gone"); + + assertEquals(0, scheduler.tick(1, 64, value -> true)); + assertEquals(0, scheduler.pendingDirtyCount()); + assertEquals(0, scheduler.activeCount()); + assertEquals(0, scheduler.invalidatedAuditValueCount()); + } + + @Test + void removalInvalidatesValuesAlreadyCapturedByAnAuditIterator() { + Set source = new LinkedHashSet<>(List.of("kept", "gone")); + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>( + () -> new ArrayList<>(source), 20, 600); + List updates = new ArrayList<>(); + + assertEquals(1, scheduler.tick(0, 1, value -> { + updates.add(value); + return false; + })); + source.remove("gone"); + scheduler.remove("gone"); + + assertEquals(0, scheduler.tick(1, 1, value -> { + updates.add(value); + return false; + })); + assertEquals(List.of("kept"), updates); + } + + @Test + void shrinkingActiveSetDoesNotReduceTheCurrentRotationBudget() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 3, 600); + for (int value = 0; value < 6; value++) { + scheduler.markActive(value, 1); + } + + assertEquals(2, scheduler.tick(1, 64, value -> false)); + assertEquals(2, scheduler.tick(2, 64, value -> false)); + assertEquals(2, scheduler.tick(3, 64, value -> false)); + assertEquals(0, scheduler.activeCount()); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdaterTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdaterTest.java new file mode 100644 index 0000000..3263a9e --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdaterTest.java @@ -0,0 +1,29 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FurnaceDisplayUpdaterTest { + + @Test + void invalidOrExtremeCookTimesStayWithinTheProgressBar() { + assertEquals(0.0D, FurnaceDisplayUpdater.scaledProgress(0, 0, 10)); + assertEquals(0.0D, FurnaceDisplayUpdater.scaledProgress(10, -1, 10)); + assertEquals(0.0D, FurnaceDisplayUpdater.scaledProgress(-20, 200, 10)); + assertEquals(5.0D, FurnaceDisplayUpdater.scaledProgress(100, 200, 10)); + assertEquals(10.0D, FurnaceDisplayUpdater.scaledProgress(Short.MAX_VALUE, 1, 10)); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java index 3a2657a..375200b 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java @@ -47,6 +47,25 @@ void staticAnchorExperimentNeverDetachesVisibleNames() { assertFalse(DisplayManager.useStaticAnchorForAnimation(true, true)); } + @Test + void packetOnlyStaticItemsRequireEverySafeProperty() { + assertTrue(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(), false, false)); + + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + false, false, new Vector(), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, true, new Vector(), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(0.0, 0.01, 0.0), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(Double.MIN_VALUE, 0.0, 0.0), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(), true, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(), false, true)); + } + @Test void copiesTheNmsAirMovementOrderAndPrecision() { Vector movement = DisplayManager.itemMovementForTick(true, new Vector(0.18, 0.15, 0.05)); diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerVisibilityShowQueueTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerVisibilityShowQueueTest.java new file mode 100644 index 0000000..8a07b92 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerVisibilityShowQueueTest.java @@ -0,0 +1,128 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DisplayManagerVisibilityShowQueueTest { + + @Test + void deduplicatesAndCancelsPendingShows() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(2); + + assertTrue(queue.request("first")); + assertFalse(queue.request("first")); + assertTrue(queue.request("second")); + queue.cancel("first"); + + assertEquals(List.of("second"), queue.drain(2, 0, ignored -> true)); + assertTrue(queue.isEmpty()); + } + + @Test + void cancelThenRequestUsesTheNewQueuePositionExactlyOnce() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(3); + queue.request("first"); + queue.request("second"); + queue.cancel("first"); + assertTrue(queue.request("first")); + + assertEquals(List.of("second", "first"), + queue.drain(3, 0, ignored -> true)); + assertTrue(queue.isEmpty()); + } + + @Test + void enforcesCapacityAndRefillsPerDrain() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(2); + queue.request(1); + queue.request(2); + queue.request(3); + queue.request(4); + + assertEquals(List.of(1, 2), queue.drain(2, 0, ignored -> true)); + assertEquals(List.of(3), queue.drain(2, 1, ignored -> true)); + assertEquals(List.of(4), queue.drain(2, 1, ignored -> true)); + assertTrue(queue.isEmpty()); + } + + @Test + void staleEntriesDoNotConsumeTokens() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(1); + queue.request("stale"); + queue.request("desired"); + + assertEquals(List.of(), queue.drain(1, 0, "desired"::equals)); + assertEquals(List.of("desired"), + queue.drain(1, 0, "desired"::equals)); + assertTrue(queue.isEmpty()); + } + + @Test + void staleInspectionWorkIsBoundedAcrossTicks() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(1, 0L); + for (int index = 0; index < 20; index++) { + queue.request("stale-" + index); + } + queue.request("desired"); + for (int index = 0; index < 20; index++) { + queue.cancel("stale-" + index); + } + + for (long tick = 1L; tick <= 5L; tick++) { + assertEquals(List.of(), queue.drain(4, 0, tick, ignored -> true)); + assertFalse(queue.isEmpty()); + } + assertEquals(List.of("desired"), queue.drain(4, 0, 6L, ignored -> true)); + assertTrue(queue.isEmpty()); + } + + @Test + void anEmptyQueueKeepsItsSpentBucketForTheNextWave() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(2, 0L); + queue.request(1); + queue.request(2); + + assertEquals(List.of(1, 2), queue.drain(2, 1, 1L, ignored -> true)); + assertTrue(queue.isEmpty()); + + queue.request(3); + queue.request(4); + assertEquals(List.of(3), queue.drain(2, 1, 2L, ignored -> true)); + assertEquals(List.of(4), queue.drain(2, 1, 3L, ignored -> true)); + } + + @Test + void disablingLimitCanDrainEverythingStillDesired() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(0); + queue.request("first"); + queue.request("stale"); + queue.request("second"); + + assertEquals(List.of("first", "second"), + queue.drainAll(value -> !value.equals("stale"))); + assertTrue(queue.isEmpty()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/objectholders/ChunkPositionTest.java b/common/src/test/java/com/loohp/interactionvisualizer/objectholders/ChunkPositionTest.java new file mode 100644 index 0000000..ef20984 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/objectholders/ChunkPositionTest.java @@ -0,0 +1,55 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.objectholders; + +import org.bukkit.World; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class ChunkPositionTest { + + @Test + void equalityComparesCoordinatesInsteadOfOnlyHashCodes() { + World world = world(UUID.fromString("10000000-0000-0000-0000-000000000001")); + ChunkPosition first = new ChunkPosition(world, 0, 17); + ChunkPosition colliding = new ChunkPosition(world, 1, 0); + + assertEquals(first.hashCode(), colliding.hashCode()); + assertNotEquals(first, colliding); + assertEquals(first, new ChunkPosition(world, 0, 17)); + } + + private static World world(UUID id) { + return (World) Proxy.newProxyInstance( + World.class.getClassLoader(), new Class[]{World.class}, (proxy, method, arguments) -> { + if (method.getName().equals("getUID")) { + return id; + } + if (method.getName().equals("toString")) { + return "World[" + id + "]"; + } + if (method.getName().equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (method.getName().equals("equals")) { + return proxy == arguments[0]; + } + throw new UnsupportedOperationException(method.getName()); + }); + } + +} From bec189d96d5eadd03fb34c139d2de784d3e40efd Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 13 Jul 2026 03:30:28 +0800 Subject: [PATCH 11/59] Add phase-two performance validation --- .github/workflows/build.yml | 22 + docs/phase2-performance-validation.md | 367 ++++++++ tools/perf/analyze-presentmon.ps1 | 625 ++++++++++++++ tools/perf/phase2-pktmon.ps1 | 1113 +++++++++++++++++++++++++ 4 files changed, 2127 insertions(+) create mode 100644 docs/phase2-performance-validation.md create mode 100644 tools/perf/analyze-presentmon.ps1 create mode 100644 tools/perf/phase2-pktmon.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3670cfd..6cfd537 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,28 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + - name: Validate performance tooling + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $scripts = @( + 'tools/perf/phase2-pktmon.ps1', + 'tools/perf/analyze-presentmon.ps1' + ) + foreach ($script in $scripts) { + [void][scriptblock]::Create((Get-Content -Raw -LiteralPath $script)) + } + $pktmonSelfTestJson = & ./tools/perf/phase2-pktmon.ps1 selftest | Out-String + $pktmonSelfTest = $pktmonSelfTestJson | ConvertFrom-Json + if (-not $pktmonSelfTest.passed) { + throw 'PktMon wrapper self-test did not report passed=true.' + } + $selfTestJson = & ./tools/perf/analyze-presentmon.ps1 -SelfTest | Out-String + $selfTest = $selfTestJson | ConvertFrom-Json + if (-not $selfTest.passed) { + throw 'PresentMon analyzer self-test did not report passed=true.' + } + - name: Run checks and build production jar run: ./gradlew clean check shadowJar --no-daemon --no-build-cache --rerun-tasks diff --git a/docs/phase2-performance-validation.md b/docs/phase2-performance-validation.md new file mode 100644 index 0000000..7523937 --- /dev/null +++ b/docs/phase2-performance-validation.md @@ -0,0 +1,367 @@ +# 第二阶段真实性能与兼容性验证 + +本规范用于验证 InteractionVisualizer 的四类优化:旧 Bukkit 展示实体基线、无服务端锚点的纯发包静态展示、`DisplayManager` 通用可见性限流(另含旧掉落物标签桶回归),以及事件驱动的交互方块更新。目标是用同一套场景同时观察发包、TPS/MSPT、插件热路径和客户端 FPS,避免用单次 microbenchmark 代替真实服务器结论。 + +配套脚本为 [`tools/perf/phase2-pktmon.ps1`](../tools/perf/phase2-pktmon.ps1) 和 [`tools/perf/analyze-presentmon.ps1`](../tools/perf/analyze-presentmon.ps1)。前者只包装 Windows 自带的 `pktmon`,不会删除全局过滤器,也不会替用户启动或停止 Minecraft 服务端;后者只分析单一 Minecraft PID/SwapChain 的逐帧 CSV。 + +## 1. 执行前提 + +- A/B 两个 jar 必须由 GitHub Actions 构建,记录完整 commit SHA 和 artifact SHA-256;不要在测试机本地构建。 +- 固定 Paper build、JDK、JVM 参数、世界快照、插件集合、配置、客户端版本、资源包和网络压缩阈值。 +- 每轮只运行一个服务端实例。不得同时运行 A、B,因为它们会竞争 CPU、内存、磁盘和网络。 +- 每轮从同一个只读模板复制世界和配置,完整重启服务端;不要依赖 `/reload` 清理旧实体、缓存或 JIT 状态。 +- 先预生成并加载测试区块。测试窗口内禁止自动备份、世界保存、语言下载、更新检查及其他计划任务。 +- `pktmon` 的状态、开始、停止和计数器命令需要管理员 PowerShell。转换已有 ETL 通常不需要管理员权限。 +- 当前部署状态不等于待测定义。执行前必须确认: + + - “旧实体”构建和候选构建都包含完全相同的 `/iv perf` 观测代码;观测层本身不得改变渲染行为。 + - “纯发包静态”只替换满足资格条件的逻辑 `Item`:不创建 Bukkit tracker/`ItemDisplay` 锚点。普通 `DisplayEntity` 与 `TextDisplay` 不在该替换范围内,仍沿用原路径;`/iv perf scene static` 这个场景名称本身也不能证明无锚点。 + - 事件驱动候选已覆盖目标事件,并保留低频兜底审计。 + +## 2. 固定测量窗口 + +每个稳定态 run 使用以下时序: + +1. 启动服务端并等待 `Done`。 +2. 预热 JVM、区块和客户端 120 秒。 +3. 建立场景并等待 20 秒稳定。 +4. 启动 `pktmon`。 +5. 执行 `/iv perf start