Skip to content

Commit 0f2ddcb

Browse files
committed
Cache unchanged legacy display text
1 parent 92d42aa commit 0f2ddcb

31 files changed

Lines changed: 2139 additions & 189 deletions

.github/workflows/phase2-runtime-ab.yml

Lines changed: 351 additions & 33 deletions
Large diffs are not rendered by default.

abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/DisplayEntity.java

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@
1212

1313
package com.loohp.interactionvisualizer.entityholders;
1414

15-
import com.loohp.interactionvisualizer.utils.ComponentFont;
15+
import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache;
1616
import net.kyori.adventure.text.Component;
17-
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
1817
import org.bukkit.Location;
1918
import org.bukkit.Material;
2019
import org.bukkit.entity.Display;
@@ -39,6 +38,8 @@ public class DisplayEntity extends VisualizerEntity {
3938
private ItemStack helmet;
4039
private ItemStack mainHand;
4140
private Component customName;
41+
private String customNameRawSource;
42+
private boolean customNameRawSourceKnown;
4243
private boolean customNameVisible;
4344
private Vector velocity;
4445
private Display.Billboard billboard;
@@ -67,15 +68,46 @@ public Component getCustomName() {
6768
}
6869

6970
public void setCustomName(String customName) {
70-
setCustomName(customName == null ? null : ComponentFont.parseFont(
71-
LegacyComponentSerializer.legacySection().deserialize(customName)));
71+
updateCustomName(customName);
7272
}
7373

7474
public void setCustomName(Component customName) {
75+
customNameRawSource = null;
76+
customNameRawSourceKnown = false;
77+
assignCustomName(customName);
78+
}
79+
80+
public boolean updateCustomName(String customName) {
81+
if (LegacyTextComponentCache.isEnabled() && customNameRawSourceKnown
82+
&& java.util.Objects.equals(customNameRawSource, customName)) {
83+
if (customName != null) {
84+
LegacyTextComponentCache.recordSameRawFastPath();
85+
}
86+
return false;
87+
}
88+
Component parsed = customName == null ? null : LegacyTextComponentCache.parse(customName);
89+
boolean changed = assignCustomName(parsed);
90+
customNameRawSource = LegacyTextComponentCache.isEnabled() ? customName : null;
91+
customNameRawSourceKnown = LegacyTextComponentCache.isEnabled();
92+
return changed;
93+
}
94+
95+
public boolean updateCustomName(String customName, boolean visible) {
96+
boolean changed = updateCustomName(customName);
97+
if (customNameVisible != visible) {
98+
setCustomNameVisible(visible);
99+
return true;
100+
}
101+
return changed;
102+
}
103+
104+
private boolean assignCustomName(Component customName) {
75105
if (!java.util.Objects.equals(this.customName, customName)) {
76106
this.customName = customName;
77107
markDirty();
108+
return true;
78109
}
110+
return false;
79111
}
80112

81113
public boolean isCustomNameVisible() {

abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/Item.java

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@
1212

1313
package com.loohp.interactionvisualizer.entityholders;
1414

15-
import com.loohp.interactionvisualizer.utils.ComponentFont;
15+
import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache;
1616
import net.kyori.adventure.text.Component;
17-
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
1817
import org.bukkit.Location;
1918
import org.bukkit.Material;
2019
import org.bukkit.inventory.ItemStack;
@@ -31,6 +30,8 @@ public class Item extends VisualizerEntity {
3130
private boolean glowing;
3231
private int pickupDelay;
3332
private Component customName;
33+
private String customNameRawSource;
34+
private boolean customNameRawSourceKnown;
3435
private boolean customNameVisible;
3536
private Vector velocity;
3637

@@ -46,15 +47,37 @@ public Component getCustomName() {
4647
}
4748

4849
public void setCustomName(String name) {
49-
setCustomName(name == null ? null : ComponentFont.parseFont(
50-
LegacyComponentSerializer.legacySection().deserialize(name)));
50+
updateCustomName(name);
5151
}
5252

5353
public void setCustomName(Component name) {
54+
customNameRawSource = null;
55+
customNameRawSourceKnown = false;
56+
assignCustomName(name);
57+
}
58+
59+
public boolean updateCustomName(String name) {
60+
if (LegacyTextComponentCache.isEnabled() && customNameRawSourceKnown
61+
&& java.util.Objects.equals(customNameRawSource, name)) {
62+
if (name != null) {
63+
LegacyTextComponentCache.recordSameRawFastPath();
64+
}
65+
return false;
66+
}
67+
Component parsed = name == null ? null : LegacyTextComponentCache.parse(name);
68+
boolean changed = assignCustomName(parsed);
69+
customNameRawSource = LegacyTextComponentCache.isEnabled() ? name : null;
70+
customNameRawSourceKnown = LegacyTextComponentCache.isEnabled();
71+
return changed;
72+
}
73+
74+
private boolean assignCustomName(Component name) {
5475
if (!java.util.Objects.equals(customName, name)) {
5576
customName = name;
5677
markDirty();
78+
return true;
5779
}
80+
return false;
5881
}
5982

6083
public boolean isGlowing() {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* This file is part of InteractionVisualizer.
3+
*
4+
* Copyright (C) 2026. Contributors
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*/
11+
12+
package com.loohp.interactionvisualizer.utils;
13+
14+
import com.github.benmanes.caffeine.cache.Cache;
15+
import com.github.benmanes.caffeine.cache.Caffeine;
16+
import net.kyori.adventure.text.Component;
17+
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
18+
19+
import java.util.Objects;
20+
import java.util.concurrent.atomic.AtomicReference;
21+
import java.util.concurrent.atomic.LongAdder;
22+
23+
/**
24+
* Shared, bounded conversion cache for legacy display text.
25+
*
26+
* <p>Progress displays tend to move through a small set of synchronized text
27+
* states across hundreds of entities. Caching the immutable Adventure component
28+
* keeps the exact legacy and {@code [font=...]} parsing semantics while avoiding
29+
* the same flatten/compact work for every entity. Very large one-off strings
30+
* bypass the cache, and the maximum size bounds all remaining key cardinality.</p>
31+
*/
32+
public final class LegacyTextComponentCache {
33+
34+
public static final int MAXIMUM_SIZE = 1_024;
35+
public static final int MAXIMUM_CACHEABLE_LENGTH = 2_048;
36+
public static final int ENTRY_BASE_WEIGHT = 256;
37+
public static final long MAXIMUM_WEIGHT = (long) MAXIMUM_SIZE * ENTRY_BASE_WEIGHT;
38+
public static final String DISABLE_PROPERTY = "interactionvisualizer.disableLegacyTextComponentCache";
39+
40+
private static final boolean ENABLED = !Boolean.getBoolean(DISABLE_PROPERTY);
41+
private static final Cache<String, Component> CACHE = Caffeine.newBuilder()
42+
// The base weight preserves the 1,024-entry ceiling; raw length also
43+
// prevents a small number of style-heavy strings from owning it.
44+
.maximumWeight(MAXIMUM_WEIGHT)
45+
.weigher((String rawText, Component ignored) -> ENTRY_BASE_WEIGHT + rawText.length())
46+
// The cache is tiny; keep maintenance deterministic and off the JVM common pool.
47+
.executor(Runnable::run)
48+
.build();
49+
private static final AtomicReference<Measurement> ACTIVE_MEASUREMENT = new AtomicReference<>();
50+
private static volatile CacheMetrics latestMetrics = CacheMetrics.EMPTY;
51+
52+
private LegacyTextComponentCache() {
53+
}
54+
55+
public static Component parse(String rawText) {
56+
Objects.requireNonNull(rawText, "rawText");
57+
Measurement measurement = ACTIVE_MEASUREMENT.get();
58+
if (!ENABLED || rawText.length() > MAXIMUM_CACHEABLE_LENGTH) {
59+
recordCacheOutcome(measurement, false);
60+
return parseUncached(rawText);
61+
}
62+
Component cached = CACHE.getIfPresent(rawText);
63+
if (cached != null) {
64+
recordCacheOutcome(measurement, true);
65+
return cached;
66+
}
67+
recordCacheOutcome(measurement, false);
68+
return CACHE.get(rawText, LegacyTextComponentCache::parseUncached);
69+
}
70+
71+
private static void recordCacheOutcome(Measurement measurement, boolean hit) {
72+
if (measurement == null) {
73+
return;
74+
}
75+
if (hit) {
76+
measurement.hits.increment();
77+
} else {
78+
measurement.misses.increment();
79+
}
80+
}
81+
82+
private static Component parseUncached(String rawText) {
83+
return ComponentFont.parseFont(LegacyComponentSerializer.legacySection().deserialize(rawText));
84+
}
85+
86+
public static boolean isEnabled() {
87+
return ENABLED;
88+
}
89+
90+
public static void recordSameRawFastPath() {
91+
Measurement measurement = ACTIVE_MEASUREMENT.get();
92+
if (measurement != null) {
93+
measurement.sameRawFastPaths.increment();
94+
}
95+
}
96+
97+
public static void startMeasurement() {
98+
latestMetrics = CacheMetrics.EMPTY;
99+
ACTIVE_MEASUREMENT.set(new Measurement());
100+
}
101+
102+
public static CacheMetrics stopMeasurement() {
103+
Measurement measurement = ACTIVE_MEASUREMENT.getAndSet(null);
104+
if (measurement != null) {
105+
latestMetrics = snapshot(measurement);
106+
}
107+
return latestMetrics;
108+
}
109+
110+
public static CacheMetrics metrics() {
111+
Measurement measurement = ACTIVE_MEASUREMENT.get();
112+
return measurement == null ? latestMetrics : snapshot(measurement);
113+
}
114+
115+
public static void invalidateAll() {
116+
CACHE.invalidateAll();
117+
CACHE.cleanUp();
118+
}
119+
120+
static long estimatedSize() {
121+
CACHE.cleanUp();
122+
return CACHE.estimatedSize();
123+
}
124+
125+
private static CacheMetrics snapshot(Measurement measurement) {
126+
long hits = measurement.hits.sum();
127+
long misses = measurement.misses.sum();
128+
return new CacheMetrics(hits + misses, misses, measurement.sameRawFastPaths.sum());
129+
}
130+
131+
private static final class Measurement {
132+
133+
private final LongAdder hits = new LongAdder();
134+
private final LongAdder misses = new LongAdder();
135+
private final LongAdder sameRawFastPaths = new LongAdder();
136+
}
137+
138+
public record CacheMetrics(long requests, long misses, long sameRawFastPaths) {
139+
140+
private static final CacheMetrics EMPTY = new CacheMetrics(0L, 0L, 0L);
141+
142+
public long hits() {
143+
return Math.max(0L, requests - misses);
144+
}
145+
146+
public double hitRate() {
147+
return requests == 0L ? 0.0D : (double) hits() / (double) requests;
148+
}
149+
}
150+
}

build.gradle.kts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ val paper26_1Version = "26.1.2.build.74-stable"
1414
val paper26_2Version = "26.2.build.56-alpha"
1515
val craftEngineVersion = "26.7.2"
1616
val sparrowHeartVersion = "0.72"
17+
val caffeineVersion = "3.2.3"
1718

1819
val paper26_2CompileClasspath = configurations.create("paper26_2CompileClasspath") {
1920
isCanBeConsumed = false
@@ -37,6 +38,7 @@ dependencies {
3738

3839
implementation("net.momirealms:sparrow-yaml:1.0.7")
3940
implementation("net.momirealms:sparrow-heart:$sparrowHeartVersion")
41+
implementation("com.github.ben-manes.caffeine:caffeine:$caffeineVersion")
4042

4143
testImplementation(platform("org.junit:junit-bom:5.13.4"))
4244
testImplementation("org.junit.jupiter:junit-jupiter")
@@ -121,6 +123,22 @@ tasks.withType<Test>().configureEach {
121123
classpath = files(testClasspathJar.flatMap { it.archiveFile })
122124
}
123125

126+
val legacyTextCacheDisableProperty = "interactionvisualizer.disableLegacyTextComponentCache"
127+
128+
tasks.named<Test>("test") {
129+
systemProperty(legacyTextCacheDisableProperty, "false")
130+
exclude("**/LegacyTextComponentCacheDisabledTest.class")
131+
}
132+
133+
val testLegacyTextComponentCacheDisabled = tasks.register<Test>("testLegacyTextComponentCacheDisabled") {
134+
description = "Verifies the isolated JVM rollback path for legacy text component caching."
135+
group = LifecycleBasePlugin.VERIFICATION_GROUP
136+
testClassesDirs = sourceSets.test.get().output.classesDirs
137+
include("**/LegacyTextComponentCacheDisabledTest.class")
138+
systemProperty(legacyTextCacheDisableProperty, "true")
139+
dependsOn(tasks.testClasses)
140+
}
141+
124142
val compilePaper26_2 = tasks.register<JavaCompile>("compilePaper26_2") {
125143
description = "Compiles the same Paper-API-only sources against Paper 26.2."
126144
group = LifecycleBasePlugin.VERIFICATION_GROUP
@@ -214,6 +232,7 @@ tasks.check {
214232
dependsOn(compilePaper26_2)
215233
dependsOn(verifyPaperOnlyArchitecture)
216234
dependsOn(verifyCustomContentIsolation)
235+
dependsOn(testLegacyTextComponentCacheDisabled)
217236
}
218237

219238
tasks.named<ShadowJar>("shadowJar") {
@@ -222,6 +241,9 @@ tasks.named<ShadowJar>("shadowJar") {
222241

223242
relocate("net.momirealms.sparrow.yaml", "com.loohp.interactionvisualizer.libs.sparrow.yaml")
224243
relocate("net.momirealms.sparrow.heart", "com.loohp.interactionvisualizer.libs.sparrow.heart")
244+
relocate("com.github.benmanes.caffeine", "com.loohp.interactionvisualizer.libs.caffeine")
245+
relocate("org.jspecify", "com.loohp.interactionvisualizer.libs.jspecify")
246+
relocate("com.google.errorprone.annotations", "com.loohp.interactionvisualizer.libs.errorprone.annotations")
225247

226248
isPreserveFileTimestamps = false
227249
isReproducibleFileOrder = true
@@ -256,6 +278,49 @@ tasks.named<ShadowJar>("shadowJar") {
256278
check(jar.getEntry("META-INF/licenses/sparrow-heart.txt") != null) {
257279
"The Sparrow Heart MIT license notice is missing"
258280
}
281+
check(jar.getEntry("META-INF/licenses/caffeine.txt") != null) {
282+
"The Caffeine Apache-2.0 license notice is missing"
283+
}
284+
check(jar.getEntry("META-INF/licenses/jspecify.txt") != null) {
285+
"The JSpecify Apache-2.0 license notice is missing"
286+
}
287+
check(jar.getEntry("META-INF/licenses/error-prone-annotations.txt") != null) {
288+
"The Error Prone Annotations Apache-2.0 license notice is missing"
289+
}
290+
check(jar.getEntry(
291+
"com/loohp/interactionvisualizer/libs/caffeine/cache/Caffeine.class",
292+
) != null) {
293+
"The shaded Caffeine runtime is missing"
294+
}
295+
val unrelocatedCaffeine = jar.entries().asSequence()
296+
.map { it.name }
297+
.filter { it.startsWith("com/github/benmanes/caffeine/") }
298+
.toList()
299+
check(unrelocatedCaffeine.isEmpty()) {
300+
"Unrelocated Caffeine classes were bundled:\n" +
301+
unrelocatedCaffeine.joinToString("\n")
302+
}
303+
val unrelocatedAnnotationDependencies = jar.entries().asSequence()
304+
.map { it.name }
305+
.filter {
306+
it.startsWith("org/jspecify/") ||
307+
it.startsWith("com/google/errorprone/annotations/")
308+
}
309+
.toList()
310+
check(unrelocatedAnnotationDependencies.isEmpty()) {
311+
"Unrelocated Caffeine annotation dependencies were bundled:\n" +
312+
unrelocatedAnnotationDependencies.joinToString("\n")
313+
}
314+
check(jar.getEntry(
315+
"com/loohp/interactionvisualizer/libs/jspecify/annotations/NullMarked.class",
316+
) != null) {
317+
"The relocated JSpecify annotations are missing"
318+
}
319+
check(jar.getEntry(
320+
"com/loohp/interactionvisualizer/libs/errorprone/annotations/CanIgnoreReturnValue.class",
321+
) != null) {
322+
"The relocated Error Prone annotations are missing"
323+
}
259324
}
260325
}
261326
}

common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,6 @@ public void onDragBarrel(InventoryDragEvent event) {
308308
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);
309309
item.setVelocity(vector);
310310
item.setItemStack(itemstack);
311-
item.setCustomName(System.currentTimeMillis() + "");
312311
item.setPickupDelay(32767);
313312
item.setGravity(true);
314313
DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item);

0 commit comments

Comments
 (0)