From 1bfe489b58a7ed5019e1b8b8dc9e3e738503f33d Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 10:56:51 +0800 Subject: [PATCH 01/10] Optimize dropped-item section candidates --- .github/workflows/phase2-runtime-ab.yml | 201 +++++++++- .../loohp/interactionvisualizer/Commands.java | 18 +- .../debug/PerformanceScene.java | 65 +++- .../entities/DroppedItemDisplay.java | 358 +++++++++++++++--- .../entities/DroppedItemSpatialIndex.java | 4 + .../managers/PerformanceMetrics.java | 32 +- .../managers/PreferenceManager.java | 70 +++- common/src/main/resources/config.yml | 2 + .../DroppedItemCandidateWindowTest.java | 118 ++++++ .../entities/DroppedItemSpatialIndexTest.java | 14 + .../DroppedItemVisibilityPolicyTest.java | 4 +- .../PerformanceMetricsSlowestTickTest.java | 7 +- .../PreferenceManagerSessionGateTest.java | 21 + tools/perf/analyze-phase2-abba.ps1 | 37 +- tools/perf/run-phase2-runtime-once.sh | 92 ++++- 15 files changed, 924 insertions(+), 119 deletions(-) create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemCandidateWindowTest.java diff --git a/.github/workflows/phase2-runtime-ab.yml b/.github/workflows/phase2-runtime-ab.yml index ef11386b..728fc0af 100644 --- a/.github/workflows/phase2-runtime-ab.yml +++ b/.github/workflows/phase2-runtime-ab.yml @@ -5,18 +5,24 @@ on: types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: + paper_version: + description: "Stable Paper runtime under test" + required: true + default: "26.1.2" + type: choice + options: ["26.1.2", "26.2"] ab_factor: - description: "Independent A/B factor (text cache requires block-active)" + description: "Independent A/B factor with an isolated matching workload" required: true default: "scenario-config" type: choice - options: ["scenario-config", "legacy-text-component-cache"] + options: ["scenario-config", "legacy-text-component-cache", "dropped-item-section-candidates"] scenario: - description: "Runtime workload (choose block-active for text cache)" + description: "Runtime workload (factor-specific scenarios are isolated)" required: true default: "static-steady" type: choice - options: ["static-steady", "block-idle", "block-active", "block-direct-write"] + options: ["static-steady", "dropped-items", "block-idle", "block-active", "block-direct-write"] runs: description: "Restart-isolated runs (4, 8, or formal 12)" required: true @@ -24,7 +30,7 @@ on: type: choice options: ["4", "8", "12"] items: - description: "Logical items (max 8192) or fully tracked workload blocks (max 1024)" + description: "Logical/dropped items (max 8192) or fully tracked workload blocks (max 1024)" required: true default: "1024" type: string @@ -55,7 +61,7 @@ permissions: jobs: clean-runtime-ab: - name: Paper 26.1.2 runtime ABBA + name: Paper runtime ABBA if: >- github.event_name == 'workflow_dispatch' || github.event.action != 'labeled' || @@ -64,6 +70,7 @@ jobs: timeout-minutes: 130 env: CAMPAIGN_AB_FACTOR: ${{ github.event_name == 'workflow_dispatch' && inputs.ab_factor || 'scenario-config' }} + CAMPAIGN_PAPER_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.paper_version || '26.1.2' }} CAMPAIGN_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || 'static-steady' }} CAMPAIGN_RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || github.event.action == 'labeled' && '12' || '4' }} CAMPAIGN_ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items || github.event.action == 'labeled' && '4096' || '1024' }} @@ -104,14 +111,15 @@ jobs: - name: Build and test the production plugin run: ./gradlew clean check shadowJar --no-daemon --no-build-cache --rerun-tasks - - name: Download stable Paper 26.1.2 + - name: Download selected stable Paper runtime env: PAPER_USER_AGENT: InteractionVisualizer-Phase2/1.0 (https://github.com/EllanServer/InteractionVisualizer) + PAPER_VERSION: ${{ env.CAMPAIGN_PAPER_VERSION }} run: | mkdir -p phase2-dependencies BUILDS=$(curl --fail --silent --show-error \ -H "User-Agent: $PAPER_USER_AGENT" \ - https://fill.papermc.io/v3/projects/paper/versions/26.1.2/builds) + "https://fill.papermc.io/v3/projects/paper/versions/$PAPER_VERSION/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 \ @@ -124,6 +132,7 @@ jobs: - name: Run restart-isolated runtime campaign env: AB_FACTOR: ${{ env.CAMPAIGN_AB_FACTOR }} + PAPER_VERSION: ${{ env.CAMPAIGN_PAPER_VERSION }} SCENARIO: ${{ env.CAMPAIGN_SCENARIO }} RUNS: ${{ env.CAMPAIGN_RUNS }} ITEMS: ${{ env.CAMPAIGN_ITEMS }} @@ -134,9 +143,12 @@ jobs: EVIDENCE_KIND: ${{ env.CAMPAIGN_EVIDENCE_KIND }} run: | set -euo pipefail - [[ "$AB_FACTOR" == scenario-config || "$AB_FACTOR" == legacy-text-component-cache ]] + [[ "$PAPER_VERSION" == 26.1.2 || "$PAPER_VERSION" == 26.2 ]] + [[ "$AB_FACTOR" == scenario-config || "$AB_FACTOR" == legacy-text-component-cache || \ + "$AB_FACTOR" == dropped-item-section-candidates ]] [[ "$SCENARIO" == static-steady || "$SCENARIO" == block-idle || \ - "$SCENARIO" == block-active || "$SCENARIO" == block-direct-write ]] + "$SCENARIO" == block-active || "$SCENARIO" == block-direct-write || \ + "$SCENARIO" == dropped-items ]] [[ "$RUNS" =~ ^(4|8|12)$ ]] [[ "$ITEMS" =~ ^[0-9]+$ ]] if [[ "$SCENARIO" == block-* ]]; then @@ -169,14 +181,36 @@ jobs: exit 64 fi fi + if [[ "$AB_FACTOR" == dropped-item-section-candidates ]]; then + if [[ "$SCENARIO" != dropped-items ]]; then + echo "dropped-item-section-candidates A/B is isolated to dropped-items" >&2 + exit 64 + fi + if [[ "$SPARK_PROFILE_MODE" == none && "$RUNS" != 12 ]]; then + echo "clean dropped-item-section-candidates evidence is formal-only and requires runs=12" >&2 + exit 64 + fi + if [[ "$SPARK_PROFILE_MODE" != none && "$RUNS" != 4 ]]; then + echo "profiled dropped-item-section-candidates diagnostics require runs=4" >&2 + exit 64 + fi + if (( ITEMS != 2048 )); then + echo "dropped-item-section-candidates A/B requires exactly 2048 dropped items" >&2 + exit 64 + fi + elif [[ "$SCENARIO" == dropped-items ]]; then + echo "dropped-items is reserved for dropped-item-section-candidates A/B" >&2 + exit 64 + fi if [[ "$SPARK_PROFILE_MODE" == cpu-all && \ - "$SCENARIO" != block-active && "$SCENARIO" != block-direct-write ]]; then - echo "Spark cpu-all profiling is isolated to block-active or block-direct-write" >&2 + "$SCENARIO" != block-active && "$SCENARIO" != block-direct-write && \ + "$SCENARIO" != dropped-items ]]; then + echo "Spark cpu-all profiling is isolated to block-active, block-direct-write, or dropped-items" >&2 exit 64 fi if [[ "$SPARK_PROFILE_MODE" != none && "$SPARK_PROFILE_MODE" != cpu-all && \ - "$SCENARIO" != block-direct-write ]]; then - echo "Spark cpu/alloc profiling is isolated to block-direct-write" >&2 + "$SCENARIO" != block-direct-write && "$SCENARIO" != dropped-items ]]; then + echo "Spark cpu/alloc profiling is isolated to block-direct-write or dropped-items" >&2 exit 64 fi if [[ "$SPARK_PROFILE_MODE" != none && "$RUNS" != 4 ]]; then @@ -202,7 +236,7 @@ jobs: EVIDENCE_ROOT="phase2-results/$EVIDENCE_KIND" mkdir -p "$EVIDENCE_ROOT" MANIFEST="$EVIDENCE_ROOT/abba-manifest.csv" - printf 'Scenario,Block,Position,Variant,RunId,AbFactor,LegacyTextComponentCacheDisableProperty,LegacyTextComponentCacheEnabled,LegacyTextCacheRequests,LegacyTextCacheMisses,LegacyTextCacheHits,LegacyTextCacheHitRate,LegacyTextSameRawFastPaths,ConfigSha256,JvmArgumentsSha256,JvmArgumentsNormalizedSha256,StackSha256,ArtifactSha256,CaptureMethod,SourcePath\n' > "$MANIFEST" + printf 'Scenario,Block,Position,Variant,RunId,AbFactor,DroppedSourceOwnedSectionCandidates,LegacyTextComponentCacheDisableProperty,LegacyTextComponentCacheEnabled,LegacyTextCacheRequests,LegacyTextCacheMisses,LegacyTextCacheHits,LegacyTextCacheHitRate,LegacyTextSameRawFastPaths,ConfigSha256,JvmArgumentsSha256,JvmArgumentsNormalizedSha256,StackSha256,ArtifactSha256,CaptureMethod,SourcePath\n' > "$MANIFEST" ARTIFACT_SHA=$(sha256sum "$PLUGIN_JAR" | awk '{print $1}') STACK_SHA=$( { @@ -220,14 +254,21 @@ jobs: position=$(( (run_number - 1) % 4 + 1 )) if (( block % 2 == 1 )); then pattern=ABBA; else pattern=BAAB; fi variant=${pattern:$((position - 1)):1} - if [[ "$AB_FACTOR" == scenario-config ]]; then - run_id=$(printf '%s_%s_%02d' "${SCENARIO//-/_}" "$variant" "$run_number") - else - run_id=$(printf '%s_text_cache_%s_%02d' "${SCENARIO//-/_}" "$variant" "$run_number") - fi + case "$AB_FACTOR" in + scenario-config) + run_id=$(printf '%s_%s_%02d' "${SCENARIO//-/_}" "$variant" "$run_number") + ;; + legacy-text-component-cache) + run_id=$(printf '%s_text_cache_%s_%02d' "${SCENARIO//-/_}" "$variant" "$run_number") + ;; + dropped-item-section-candidates) + run_id=$(printf '%s_section_candidates_%s_%02d' "${SCENARIO//-/_}" "$variant" "$run_number") + ;; + esac PHASE2_PLUGIN_JAR="$PLUGIN_JAR" \ PHASE2_PAPER_JAR=phase2-dependencies/paper.jar \ + PHASE2_PAPER_VERSION="$PAPER_VERSION" \ PHASE2_CLIENT_ROOT=phase2-dependencies/protocol-client \ PHASE2_OUTPUT_ROOT="$EVIDENCE_ROOT" \ PHASE2_RUN_ID="$run_id" \ @@ -273,6 +314,14 @@ jobs: cache = provenance.get("legacyTextComponentCache", {}) if provenance.get("abFactor") != ab_factor or metrics.get("abFactor") != ab_factor: raise SystemExit(f"A/B factor provenance mismatch for {run_id}") + candidate_source = provenance.get("droppedSourceOwnedSectionCandidates") + if candidate_source is not metrics.get("droppedSourceOwnedSectionCandidates"): + raise SystemExit(f"dropped candidate-source provenance mismatch for {run_id}") + expected_candidate_source = ( + ab_factor == "dropped-item-section-candidates" and variant == "B" + ) + if candidate_source is not expected_candidate_source: + raise SystemExit(f"dropped candidate-source treatment mismatch for {run_id}") cache_metric_mapping = { "enabled": "legacyTextComponentCache", "requests": "legacyTextCacheRequests", @@ -308,6 +357,7 @@ jobs: variant, run_id, ab_factor, + str(candidate_source).lower(), str(cache.get("disableProperty")).lower(), str(cache.get("enabled")).lower(), cache.get("requests"), @@ -346,6 +396,115 @@ jobs: -OutputJson "$EVIDENCE_ROOT/observedTps.analysis.json" -Overwrite fi + if [[ "$SCENARIO" == dropped-items && "$SPARK_PROFILE_MODE" == none ]]; then + pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 "$MANIFEST" \ + -Scenario "$SCENARIO" -Metric droppedItemMs -Direction LowerIsBetter \ + -MinimumSeconds "$minimum_seconds" "${incomplete[@]}" \ + -OutputJson "$EVIDENCE_ROOT/droppedItemMs.analysis.json" -Overwrite + + python3 - "$MANIFEST" "$EVIDENCE_ROOT" <<'PY' + import csv + import json + import os + from pathlib import Path + import sys + + manifest_path = Path(sys.argv[1]) + evidence_root = Path(sys.argv[2]) + + def load_analysis(name): + return json.loads((evidence_root / f"{name}.analysis.json").read_text(encoding="utf-8")) + + dropped = load_analysis("droppedItemMs") + p95 = load_analysis("msptP95") + p99 = load_analysis("msptP99") + checks = { + "droppedItemMedianRatioAtMost0_50": dropped["medianBRatioToA"] <= 0.50, + "droppedItemCiExcludesRegression": dropped["ratioBootstrap95Ci"][1] < 1.0, + "msptP95CiUpperAtMost1_02": p95["ratioBootstrap95Ci"][1] <= 1.02, + "msptP99CiUpperAtMost1_05": p99["ratioBootstrap95Ci"][1] <= 1.05, + } + + candidate_runs = [] + with manifest_path.open(encoding="utf-8", newline="") as stream: + for row in csv.DictReader(stream): + metrics_path = manifest_path.parent / row["SourcePath"] + metrics = json.loads(metrics_path.read_text(encoding="utf-8")) + candidate_runs.append({ + "runId": row["RunId"], + "variant": row["Variant"], + "sourceOwned": metrics["droppedSourceOwnedSectionCandidates"], + "trackedItemsMax": metrics["droppedTrackedItemsMax"], + "labelsMax": metrics["droppedLabelsMax"], + "fullScanCandidates": metrics["droppedFullScanCandidates"], + "spatialCandidates": metrics["droppedSpatialCandidates"], + "viewerDistanceChecks": metrics["droppedViewerDistanceChecks"], + }) + checks["allRunsTracked2048Labels"] = all( + run["trackedItemsMax"] == 2048 and run["labelsMax"] == 2048 + for run in candidate_runs + ) + checks["candidateHasZeroFullScans"] = all( + run["fullScanCandidates"] == 0 + for run in candidate_runs if run["variant"] == "B" + ) + checks["baselineExercisedFullScans"] = all( + run["fullScanCandidates"] > 0 + for run in candidate_runs if run["variant"] == "A" + ) + checks["candidateTreatmentIsolated"] = all( + run["sourceOwned"] is (run["variant"] == "B") + for run in candidate_runs + ) + passed = all(checks.values()) + gate = { + "schemaVersion": 1, + "scenario": "dropped-items", + "abFactor": "dropped-item-section-candidates", + "formalComplete": dropped.get("formalComplete") is True, + "serverRuntimeGatePassed": passed, + "checks": checks, + "ratios": { + "droppedItemMs": { + "medianBRatioToA": dropped["medianBRatioToA"], + "ratioBootstrap95Ci": dropped["ratioBootstrap95Ci"], + }, + "msptP95": { + "medianBRatioToA": p95["medianBRatioToA"], + "ratioBootstrap95Ci": p95["ratioBootstrap95Ci"], + }, + "msptP99": { + "medianBRatioToA": p99["medianBRatioToA"], + "ratioBootstrap95Ci": p99["ratioBootstrap95Ci"], + }, + }, + "runs": candidate_runs, + "scope": "Server runtime gate only; allocation and native-client frame gates remain separate.", + } + (evidence_root / "dropped-item-section-candidates.gate.json").write_text( + json.dumps(gate, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8", newline="\n") as stream: + stream.write("### Dropped-item section-candidate server gate\n\n") + stream.write(f"Result: `{'pass' if passed else 'fail'}`.\n\n") + stream.write( + f"droppedItemMs B/A: `{dropped['medianBRatioToA']}` " + f"(95% CI `{dropped['ratioBootstrap95Ci']}`).\n\n" + ) + stream.write( + f"MSPT p95 CI upper: `{p95['ratioBootstrap95Ci'][1]}`; " + f"p99 CI upper: `{p99['ratioBootstrap95Ci'][1]}`.\n" + ) + if not gate["formalComplete"]: + raise SystemExit("dropped-item gate is not a complete 12-run campaign") + if not passed: + failed = ", ".join(name for name, value in checks.items() if not value) + raise SystemExit(f"dropped-item server gate failed: {failed}") + PY + fi + if [[ "$SCENARIO" == block-* ]]; then # Active/direct-write CPU time has a meaningful LowerIsBetter # direction after lifecycle/config assertions pass. An idle @@ -756,7 +915,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: phase2-${{ env.CAMPAIGN_EVIDENCE_KIND }}-runtime-${{ env.CAMPAIGN_SCENARIO }}-${{ env.CAMPAIGN_AB_FACTOR }}-${{ github.sha }}-${{ github.run_id }} + name: phase2-${{ env.CAMPAIGN_EVIDENCE_KIND }}-runtime-paper-${{ env.CAMPAIGN_PAPER_VERSION }}-${{ env.CAMPAIGN_SCENARIO }}-${{ env.CAMPAIGN_AB_FACTOR }}-${{ github.sha }}-${{ github.run_id }} path: | phase2-results/${{ env.CAMPAIGN_EVIDENCE_KIND }} phase2-dependencies/protocol-client/client-build-manifest.json diff --git a/common/src/main/java/com/loohp/interactionvisualizer/Commands.java b/common/src/main/java/com/loohp/interactionvisualizer/Commands.java index 36909215..e0c3e8f2 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/Commands.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/Commands.java @@ -307,7 +307,7 @@ private boolean handlePerformanceCommand(CommandSender sender, String[] args) { case "scene" -> { if (args.length < 4) { sender.sendMessage(Component.text( - "Usage: /iv perf scene " + + "Usage: /iv perf scene " + " [lifetimeTicks] [player]")); return true; } @@ -315,9 +315,10 @@ private boolean handlePerformanceCommand(CommandSender sender, String[] args) { boolean staticItem = args[2].equalsIgnoreCase("static"); boolean itemDisplay = args[2].equalsIgnoreCase("itemdisplay"); boolean textDisplay = args[2].equalsIgnoreCase("textdisplay"); - if (!moving && !staticItem && !itemDisplay && !textDisplay) { + boolean dropped = args[2].equalsIgnoreCase("dropped"); + if (!moving && !staticItem && !itemDisplay && !textDisplay && !dropped) { sender.sendMessage(Component.text( - "[InteractionVisualizer] Scene type must be static, motion, itemdisplay, or textdisplay.")); + "[InteractionVisualizer] Scene type must be static, motion, itemdisplay, textdisplay, or dropped.")); return true; } long defaultLifetime = moving ? 80L : 200L; @@ -343,12 +344,15 @@ private boolean handlePerformanceCommand(CommandSender sender, String[] args) { return true; } int count = parseInteger(args[3], 1); - int spawned = itemDisplay || textDisplay + int spawned = dropped + ? PerformanceScene.spawnDroppedItems(player, count, lifetime) + : itemDisplay || textDisplay ? PerformanceScene.spawnDisplay(player, textDisplay, count, lifetime) : PerformanceScene.spawn(player, moving, count, lifetime); String sceneName = moving ? "moving" : staticItem ? "static" - : itemDisplay ? "itemdisplay" : "textdisplay"; - String entityLabel = staticItem || moving ? " benchmark items" : " benchmark entities"; + : itemDisplay ? "itemdisplay" : textDisplay ? "textdisplay" : "dropped"; + String entityLabel = staticItem || moving || dropped + ? " benchmark items" : " benchmark entities"; sender.sendMessage(Component.text("[InteractionVisualizer] Spawned " + spawned + " " + sceneName + entityLabel + " for " + lifetime + " ticks.")); } @@ -708,7 +712,7 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe case 3: if (args[0].equalsIgnoreCase("perf") && args[1].equalsIgnoreCase("scene") && sender.hasPermission("interactionvisualizer.performance")) { - for (String option : List.of("static", "motion", "itemdisplay", "textdisplay")) { + for (String option : List.of("static", "motion", "itemdisplay", "textdisplay", "dropped")) { if (option.startsWith(args[2].toLowerCase())) { tab.add(option); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java index 1e0b79a1..d8649926 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java @@ -21,6 +21,7 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.util.Vector; @@ -40,6 +41,7 @@ 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 static final Map> droppedScenes = new HashMap<>(); private PerformanceScene() { } @@ -121,6 +123,44 @@ public static int spawnDisplay(Player owner, boolean text, int requestedCount, return count; } + /** Creates real Paper Item entities for the dropped-label runtime factor. */ + public static int spawnDroppedItems(Player owner, 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); + + World world = owner.getWorld(); + Location center = owner.getLocation().add(0.0D, 1.5D, 0.0D); + int width = (int) Math.ceil(Math.sqrt(count)); + double spacing = 1.25D; + double offset = (width - 1) * spacing / 2.0D; + Set items = new HashSet<>(count); + for (int index = 0; index < count; index++) { + double x = center.getX() + index % width * spacing - offset; + double z = center.getZ() + index / width * spacing - offset; + Location location = new Location(world, x, center.getY(), z); + location.getChunk().load(); + ItemStack stack = new ItemStack(Material.STONE, index % 64 + 1); + int ordinal = index; + stack.editMeta(meta -> meta.customName(Component.text("iv-dropped-benchmark-" + ordinal))); + org.bukkit.entity.Item item = world.dropItem(location, stack, entity -> { + entity.setGravity(false); + entity.setVelocity(new Vector()); + entity.setPickupDelay(Short.MAX_VALUE - 1); + entity.setUnlimitedLifetime(true); + entity.setPersistent(false); + }); + items.add(item); + } + + UUID ownerId = owner.getUniqueId(); + droppedScenes.put(ownerId, items); + Scheduler.runTaskLater(InteractionVisualizer.plugin, + () -> expireDropped(ownerId, items), lifetimeTicks, owner.getLocation()); + return count; + } + public static void clear(Player owner) { clear(owner.getUniqueId()); } @@ -131,6 +171,10 @@ public static void clear(UUID ownerId) { if (previous != null) { removeEntities(previous); } + Set dropped = droppedScenes.remove(ownerId); + if (dropped != null) { + removeDroppedItems(dropped); + } } /** Deterministically removes every disposable benchmark scene. */ @@ -140,10 +184,15 @@ public static void shutdown() { for (Set entities : remaining) { removeEntities(entities); } + List> remainingDropped = new ArrayList<>(droppedScenes.values()); + droppedScenes.clear(); + for (Set items : remainingDropped) { + removeDroppedItems(items); + } } public static int retainedStateCount() { - return scenes.size(); + return scenes.size() + droppedScenes.size(); } private static void expire(UUID ownerId, Set entities) { @@ -152,6 +201,12 @@ private static void expire(UUID ownerId, Set entities) { } } + private static void expireDropped(UUID ownerId, Set items) { + if (droppedScenes.remove(ownerId, items)) { + removeDroppedItems(items); + } + } + private static void removeEntities(Set entities) { for (VisualizerEntity entity : entities) { if (entity instanceof Item item) { @@ -161,4 +216,12 @@ private static void removeEntities(Set entities) { } } } + + private static void removeDroppedItems(Set items) { + for (org.bukkit.entity.Item item : items) { + if (item.isValid()) { + item.remove(); + } + } + } } 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 df53fde8..027c8aee 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -68,14 +68,17 @@ /** * Event-maintained dropped-item labels rendered as Paper TextDisplays. - * One low-frequency pass over known items replaces world entity scans and - * per-player metadata packet rewriting. + * With culling enabled, Paper's movement-maintained entity section index is + * the candidate source; IV never rebuilds or scans a parallel world item map. */ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implements Listener { public static final EntryKey KEY = new EntryKey("item"); private static final int DEFAULT_TRACKING_DISTANCE = 64; - private static final int VIEW_DISTANCE_HYSTERESIS = 16; + static final int VIEW_DISTANCE_HYSTERESIS = 16; + static final int OUTSIDE_CANDIDATE_RANGE = 0; + static final int RETAINED_CANDIDATE = 1; + static final int DESIRED_CANDIDATE = 2; private final Map trackedItems = new HashMap<>(); private final Map labels = new HashMap<>(); @@ -85,6 +88,10 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private final Map visibilityStates = new HashMap<>(); private final Map> viewersByLabel = new HashMap<>(); private final Set pendingVisibilityViewers = new HashSet<>(); + private final Set candidateItems = new HashSet<>(); + private final Set crampIndexedItems = new HashSet<>(); + private final DroppedItemSpatialIndex crampIndex = new DroppedItemSpatialIndex(); + private final NamespacedKey visualEntityKey; private String regularFormatting; private String singularFormatting; @@ -95,13 +102,17 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private int cramp = 6; private double labelYOffset = 0.8D; private int updateRate = 20; - private int ticksUntilUpdate; private int despawnTicks = 6000; private DroppedItemVisibilityPolicy visibilityPolicy = DroppedItemVisibilityPolicy.legacyDefaults(); + private boolean sourceOwnedSectionCandidates; private boolean stripColorBlacklist; private DroppedItemBlacklist blacklist = DroppedItemBlacklist.compile(List.of(), DroppedItemDisplay::warn); + private ScheduledTask updateTask; + private ScheduledTask visibilityDrainTask; + private Runnable viewerGroupUnsubscribe; public DroppedItemDisplay() { + visualEntityKey = new NamespacedKey(InteractionVisualizer.plugin, "visual_entity"); onReload(new InteractionVisualizerReloadEvent()); } @@ -133,6 +144,9 @@ public void onReload(InteractionVisualizerReloadEvent event) { .getBoolean("Entities.Item.Options.VisibilityRateLimit.Enabled"), configuredBucketSize, configuredRefill); + sourceOwnedSectionCandidates = InteractionVisualizer.plugin.getConfiguration().getBoolean( + "Entities.Item.Options.VisibilityCulling.SourceOwnedSectionCandidates"); + PerformanceMetrics.droppedLabelCandidateSource(sourceOwnedSectionCandidates); PerformanceMetrics.droppedLabelVisibilityConfig( visibilityPolicy.cullingEnabled(), visibilityPolicy.viewDistance(), @@ -151,6 +165,9 @@ public void onReload(InteractionVisualizerReloadEvent event) { InteractionVisualizer.plugin.getConfiguration().getList("Entities.Item.Options.Blacklist.List"), DroppedItemDisplay::warn); contentCache.clear(); + if (!trackedItems.isEmpty()) { + wakeUpdate(); + } } private static String configString(String path) { @@ -181,18 +198,8 @@ public ScheduledTask run() { } } } - return new ScheduledRunnable() { - @Override - public void run() { - if (visibilityPolicy.controlsPerViewerVisibility()) { - drainVisibilityQueues(); - } - if (--ticksUntilUpdate <= 0) { - ticksUntilUpdate = updateRate; - tickAll(); - } - } - }.runTaskTimer(InteractionVisualizer.plugin, 1, 1); + scheduleUpdate(1L); + return null; } @EventHandler(ignoreCancelled = true) @@ -200,16 +207,22 @@ public void onItemSpawn(ItemSpawnEvent event) { Item item = event.getEntity(); if (!isOwned(item)) { track(item); + wakeUpdate(); } } @EventHandler public void onEntitiesLoad(EntitiesLoadEvent event) { + boolean added = false; for (Entity entity : event.getEntities()) { if (entity instanceof Item item && !isOwned(item)) { track(item); + added = true; } } + if (added) { + wakeUpdate(); + } } @EventHandler @@ -243,11 +256,61 @@ private void track(Item item) { trackedItems.put(item.getUniqueId(), item); } + private void scheduleUpdate(long delay) { + if (updateTask == null || updateTask.isCancelled()) { + updateTask = Scheduler.runTaskLater( + InteractionVisualizer.plugin, this::runScheduledUpdate, delay); + } + } + + private void wakeUpdate() { + // Coalesce spawn/load/preference bursts. When the periodic pass is + // already armed, its configured UpdateRate remains the visual SLA. + scheduleUpdate(1L); + } + + private void runScheduledUpdate() { + updateTask = null; + ensureViewerGroupListener(); + tickAll(); + if (!trackedItems.isEmpty() && (viewerGroupUnsubscribe == null + || !desiredViewers.isEmpty() || !pendingVisibilityViewers.isEmpty() + || !labels.isEmpty())) { + scheduleUpdate(updateRate); + } + } + + private void ensureViewerGroupListener() { + if (viewerGroupUnsubscribe != null || InteractionVisualizer.preferenceManager == null) { + return; + } + viewerGroupUnsubscribe = InteractionVisualizer.preferenceManager + .addViewerGroupChangeListener(Modules.HOLOGRAM, KEY, + () -> Scheduler.executeOrScheduleSync( + InteractionVisualizer.plugin, this::wakeUpdate)); + } + + private void scheduleVisibilityDrain() { + if (pendingVisibilityViewers.isEmpty() + || visibilityDrainTask != null && !visibilityDrainTask.isCancelled()) { + return; + } + visibilityDrainTask = Scheduler.runTaskLater( + InteractionVisualizer.plugin, this::runVisibilityDrain, 1L); + } + + private void runVisibilityDrain() { + visibilityDrainTask = null; + drainVisibilityQueues(); + scheduleVisibilityDrain(); + } + private void tickAll() { long started = PerformanceMetrics.isCollecting() ? System.nanoTime() : 0L; try { tickAllInternal(); } finally { + PerformanceMetrics.droppedItemState(trackedItems.size(), labels.size()); if (started != 0L) { PerformanceMetrics.droppedItemNanos(System.nanoTime() - started); } @@ -260,6 +323,37 @@ private void tickAllInternal() { removeAllLabels(); return; } + if (!sourceOwnedSectionCandidates || !visibilityPolicy.cullingEnabled()) { + tickAllLegacy(viewers); + return; + } + tickAllSectionCandidates(viewers); + } + + private void tickAllSectionCandidates(Collection viewers) { + candidateItems.clear(); + crampIndexedItems.clear(); + crampIndex.clear(); + if (visibilityPolicy.cullingEnabled()) { + collectNearbyCandidates(viewers); + } else { + collectAllCandidates(); + } + + DroppedItemSpatialIndex itemIndex = cramp > 0 ? crampIndex : null; + for (UUID itemId : candidateItems) { + Item item = trackedItems.get(itemId); + if (item != null && item.isValid() && !item.isDead()) { + update(itemId, item, itemIndex); + } + } + removeLabelsOutsideCandidates(); + if (visibilityPolicy.controlsPerViewerVisibility()) { + reconcileLabelVisibility(viewers); + } + } + + private void tickAllLegacy(Collection viewers) { List validItems = new ArrayList<>(trackedItems.size()); UUID singleItemWorldId = null; int singleWorldItemCount = 0; @@ -290,6 +384,7 @@ private void tickAllInternal() { itemCountsByWorld.merge(worldId, 1, Integer::sum); } } + PerformanceMetrics.droppedFullScanCandidates(validItems.size()); DroppedItemSpatialIndex.ViewerIndex viewerIndex = null; if (visibilityPolicy.cullingEnabled()) { @@ -310,8 +405,7 @@ private void tickAllInternal() { } } int remainingSingleWorldItems = singleWorldItemCount; - for (int trackedIndex = 0; trackedIndex < validItems.size(); trackedIndex++) { - TrackedItem tracked = validItems.get(trackedIndex); + for (TrackedItem tracked : validItems) { int remainingWorldItems; if (itemCountsByWorld == null) { remainingWorldItems = --remainingSingleWorldItems; @@ -319,7 +413,7 @@ private void tickAllInternal() { remainingWorldItems = itemCountsByWorld.get(tracked.worldId()) - 1; itemCountsByWorld.put(tracked.worldId(), remainingWorldItems); } - update(tracked, itemIndex, viewerIndex, remainingWorldItems); + updateLegacy(tracked, itemIndex, viewerIndex, remainingWorldItems); } if (viewerIndex != null) { PerformanceMetrics.droppedViewerDistanceChecks(viewerIndex.candidateChecks()); @@ -337,16 +431,17 @@ private void tickAllInternal() { } } } - reconcileLabelVisibility(viewers, validItems, visibilityIndex); + reconcileLabelVisibilityLegacy(viewers, validItems, visibilityIndex); } } - private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, - DroppedItemSpatialIndex.ViewerIndex viewerIndex, int remainingWorldItems) { + private void updateLegacy(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, + DroppedItemSpatialIndex.ViewerIndex viewerIndex, + int remainingWorldItems) { Item item = tracked.item(); Location itemLocation = tracked.location(); TextDisplay label = labels.get(tracked.itemId()); - if (visibilityPolicy.cullingEnabled()) { + if (viewerIndex != null) { int trackingDistance = InteractionVisualizer.playerTrackingRange .getOrDefault(item.getWorld(), DEFAULT_TRACKING_DISTANCE); int effectiveViewDistance = visibilityPolicy.effectiveViewDistance(trackingDistance); @@ -360,28 +455,134 @@ private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, return; } } + update(tracked.itemId(), item, itemIndex); + } + + private void collectNearbyCandidates(Collection viewers) { + for (Player player : viewers) { + UUID playerId = player.getUniqueId(); + VisibilityState state = visibilityStates.computeIfAbsent(playerId, + ignored -> new VisibilityState(visibilityPolicy.bucketSize())); + Set desired = state.nextDesired; + desired.clear(); + + World world = player.getWorld(); + Location viewerLocation = player.getLocation(); + int trackingDistance = InteractionVisualizer.playerTrackingRange + .getOrDefault(world, DEFAULT_TRACKING_DISTANCE); + double viewDistance = visibilityPolicy.effectiveViewDistance(trackingDistance); + double queryDistance = sourceQueryDistance(viewDistance, cramp > 0); + Collection nearby = world.getNearbyEntitiesByType( + Item.class, viewerLocation, queryDistance, + item -> item.isValid() && !item.isDead() && !isOwned(item)); + PerformanceMetrics.droppedSpatialCandidates(nearby.size()); + PerformanceMetrics.droppedViewerDistanceChecks(nearby.size()); + + double viewerX = viewerLocation.getX(); + double viewerY = viewerLocation.getY(); + double viewerZ = viewerLocation.getZ(); + for (Item item : nearby) { + UUID itemId = item.getUniqueId(); + track(item); + indexCrampItem(itemId, item); + double deltaX = item.getX() - viewerX; + double deltaY = item.getY() - viewerY; + double deltaZ = item.getZ() - viewerZ; + int classification = classifyCandidate( + deltaX, deltaY, deltaZ, viewDistance, labels.containsKey(itemId)); + if (classification == DESIRED_CANDIDATE) { + desired.add(itemId); + candidateItems.add(itemId); + } else if (classification == RETAINED_CANDIDATE) { + candidateItems.add(itemId); + } + } + } + } + + static double sourceQueryDistance(double viewDistance, boolean cramping) { + return viewDistance + VIEW_DISTANCE_HYSTERESIS + (cramping ? 0.5D : 0.0D); + } + + static int classifyCandidate(double deltaX, double deltaY, double deltaZ, + double viewDistance, boolean hasLabel) { + double distanceSquared = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ; + if (distanceSquared <= viewDistance * viewDistance) { + return DESIRED_CANDIDATE; + } + double retainedDistance = viewDistance + VIEW_DISTANCE_HYSTERESIS; + if (hasLabel && distanceSquared <= retainedDistance * retainedDistance) { + return RETAINED_CANDIDATE; + } + return OUTSIDE_CANDIDATE_RANGE; + } + + private void collectAllCandidates() { + PerformanceMetrics.droppedFullScanCandidates(trackedItems.size()); + Iterator> iterator = trackedItems.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + UUID itemId = entry.getKey(); + Item item = entry.getValue(); + if (!item.isValid() || item.isDead()) { + iterator.remove(); + contentCache.remove(itemId); + removeLabel(itemId); + continue; + } + candidateItems.add(itemId); + indexCrampItem(itemId, item); + } + } + + private void indexCrampItem(UUID itemId, Item item) { + if (cramp <= 0 || !crampIndexedItems.add(itemId)) { + return; + } + crampIndex.addItem(item.getWorld().getUID(), item.getX(), item.getY(), item.getZ()); + } + + private void removeLabelsOutsideCandidates() { + Iterator> iterator = labels.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (candidateItems.contains(entry.getKey())) { + continue; + } + iterator.remove(); + forgetLabelVisibility(entry.getKey(), entry.getValue()); + removeLabel(entry.getValue()); + } + } + + private void update(UUID itemId, Item item, DroppedItemSpatialIndex itemIndex) { + UUID worldId = item.getWorld().getUID(); + double itemX = item.getX(); + double itemY = item.getY(); + double itemZ = item.getZ(); + TextDisplay label = labels.get(itemId); ItemStack stack = item.getItemStack(); int ticksLeft = despawnTicks - item.getTicksLived(); if (stack.isEmpty() || item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 - || (itemIndex != null && itemIndex.exceedsItemLimit(tracked.worldId(), - itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), cramp))) { - contentCache.remove(tracked.itemId()); - removeLabel(item.getUniqueId()); + || (itemIndex != null && itemIndex.exceedsItemLimit( + worldId, itemX, itemY, itemZ, cramp))) { + contentCache.remove(itemId); + removeLabel(itemId); return; } - CachedItemContent content = cachedContent(tracked.itemId(), stack); + CachedItemContent content = cachedContent(itemId, stack); if (content.blacklisted) { - removeLabel(item.getUniqueId()); + removeLabel(itemId); return; } Component text = format(content, ticksLeft); boolean created = false; if (label == null || !label.isValid() || !label.getWorld().equals(item.getWorld())) { - removeLabel(item.getUniqueId()); + removeLabel(itemId); label = spawnLabel(item); - labels.put(item.getUniqueId(), label); + labels.put(itemId, label); created = true; } if (!text.equals(label.text())) { @@ -437,7 +638,8 @@ private TextDisplay spawnLabel(Item item) { display.setBackgroundColor(Color.fromARGB(0)); display.setAlignment(TextDisplay.TextAlignment.CENTER); display.setLineWidth(240); - display.getPersistentDataContainer().set(ownerKey(), PersistentDataType.STRING, "dropped_item_label"); + display.getPersistentDataContainer().set( + visualEntityKey, PersistentDataType.STRING, "dropped_item_label"); }); } @@ -537,8 +739,9 @@ private static void setLabelVisible(Player player, TextDisplay label, boolean vi } } - private void reconcileLabelVisibility(Collection viewers, List validItems, - DroppedItemVisibilityIndex visibilityIndex) { + private void reconcileLabelVisibilityLegacy(Collection viewers, + List validItems, + DroppedItemVisibilityIndex visibilityIndex) { for (Player player : viewers) { UUID playerId = player.getUniqueId(); VisibilityState state = visibilityStates.computeIfAbsent(playerId, @@ -548,10 +751,8 @@ private void reconcileLabelVisibility(Collection viewers, List viewers, List shownIterator = state.shown.iterator(); - while (shownIterator.hasNext()) { - UUID itemId = shownIterator.next(); - if (!desired.contains(itemId)) { - TextDisplay label = labels.get(itemId); - if (label != null && label.isValid()) { - setLabelVisible(player, label, false); + private void reconcileLabelVisibility(Collection viewers) { + for (Player player : viewers) { + UUID playerId = player.getUniqueId(); + VisibilityState state = visibilityStates.computeIfAbsent(playerId, + ignored -> new VisibilityState(visibilityPolicy.bucketSize())); + Set desired = state.nextDesired; + if (visibilityPolicy.cullingEnabled()) { + Iterator desiredIterator = desired.iterator(); + while (desiredIterator.hasNext()) { + TextDisplay label = labels.get(desiredIterator.next()); + if (label == null || !label.isValid()) { + desiredIterator.remove(); } - shownIterator.remove(); } - } - for (UUID itemId : state.desired) { - if (!desired.contains(itemId)) { - state.pending.cancel(itemId); - unlinkLabelViewer(itemId, playerId); + } else { + desired.clear(); + PerformanceMetrics.droppedFullScanCandidates(labels.size()); + for (Map.Entry entry : labels.entrySet()) { + TextDisplay label = entry.getValue(); + if (label.isValid() && label.getWorld().equals(player.getWorld())) { + desired.add(entry.getKey()); + } } } - state.nextDesired = state.desired; - state.desired = desired; - for (UUID itemId : desired) { - linkLabelViewer(itemId, playerId); - if (!state.shown.contains(itemId)) { - state.pending.request(itemId); - pendingVisibilityViewers.add(playerId); + + finishLabelVisibility(player, state, desired); + } + scheduleVisibilityDrain(); + } + + private void finishLabelVisibility(Player player, VisibilityState state, Set desired) { + UUID playerId = player.getUniqueId(); + Iterator shownIterator = state.shown.iterator(); + while (shownIterator.hasNext()) { + UUID itemId = shownIterator.next(); + if (!desired.contains(itemId)) { + TextDisplay label = labels.get(itemId); + if (label != null && label.isValid()) { + setLabelVisible(player, label, false); } + shownIterator.remove(); + } + } + for (UUID itemId : state.desired) { + if (!desired.contains(itemId)) { + state.pending.cancel(itemId); + unlinkLabelViewer(itemId, playerId); + } + } + state.nextDesired = state.desired; + state.desired = desired; + for (UUID itemId : desired) { + linkLabelViewer(itemId, playerId); + if (!state.shown.contains(itemId)) { + state.pending.request(itemId); + pendingVisibilityViewers.add(playerId); } } } @@ -808,12 +1044,8 @@ private void removeLabel(TextDisplay label) { } } - private static boolean isOwned(Entity entity) { - return entity.getPersistentDataContainer().has(ownerKey(), PersistentDataType.STRING); - } - - private static NamespacedKey ownerKey() { - return new NamespacedKey(InteractionVisualizer.plugin, "visual_entity"); + private boolean isOwned(Entity entity) { + return entity.getPersistentDataContainer().has(visualEntityKey, PersistentDataType.STRING); } private record TrackedItem(UUID itemId, Item item, UUID worldId, 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 index e6dbad85..cb75666f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java @@ -25,6 +25,10 @@ final class DroppedItemSpatialIndex { private final Map> itemCells = new HashMap<>(); + void clear() { + itemCells.clear(); + } + 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)); 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 4d373274..1423d4f6 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -55,6 +55,8 @@ public final class PerformanceMetrics implements Listener { private DroppedLabelVisibilityConfig droppedLabelVisibilityConfig = new DroppedLabelVisibilityConfig(false, 64, false, 128, 32); private DroppedLabelVisibilityConfig configDroppedLabelVisibility = droppedLabelVisibilityConfig; + private boolean droppedSourceOwnedSectionCandidates; + private boolean configDroppedSourceOwnedSectionCandidates; private boolean configEventDrivenBlockUpdates; private int configBlockUpdateMaxDirtyPerTick; private long startedNanos; @@ -86,6 +88,8 @@ public final class PerformanceMetrics implements Listener { private long droppedViewerDistanceChecks; private long droppedSpatialCandidates; private long droppedFullScanCandidates; + private int droppedTrackedItemsMax; + private int droppedLabelsMax; private long blockUpdateChecks; private long blockUpdateNanos; private int blockUpdateCoordinatorLanesMax; @@ -117,6 +121,10 @@ public static void droppedLabelVisibilityConfig(boolean cullingEnabled, cullingEnabled, viewDistance, rateLimitEnabled, bucketSize, restorePerTick); } + public static void droppedLabelCandidateSource(boolean sourceOwnedSectionCandidates) { + INSTANCE.droppedSourceOwnedSectionCandidates = sourceOwnedSectionCandidates; + } + public static boolean start(String requestedLabel) { if (INSTANCE.collecting) { return false; @@ -130,6 +138,8 @@ public static boolean start(String requestedLabel) { INSTANCE.configVisibilityBucketSize = InteractionVisualizer.visibilityRateLimitBucketSize; INSTANCE.configVisibilityRestorePerTick = InteractionVisualizer.visibilityRateLimitRestorePerTick; INSTANCE.configDroppedLabelVisibility = INSTANCE.droppedLabelVisibilityConfig; + INSTANCE.configDroppedSourceOwnedSectionCandidates = + INSTANCE.droppedSourceOwnedSectionCandidates; INSTANCE.configEventDrivenBlockUpdates = InteractionVisualizer.eventDrivenBlockUpdates; INSTANCE.configBlockUpdateMaxDirtyPerTick = InteractionVisualizer.blockUpdateMaxDirtyPerTick; INSTANCE.startedNanos = System.nanoTime(); @@ -161,6 +171,8 @@ public static boolean start(String requestedLabel) { INSTANCE.droppedViewerDistanceChecks = 0; INSTANCE.droppedSpatialCandidates = 0; INSTANCE.droppedFullScanCandidates = 0; + INSTANCE.droppedTrackedItemsMax = 0; + INSTANCE.droppedLabelsMax = 0; INSTANCE.blockUpdateChecks = 0; INSTANCE.blockUpdateNanos = 0; INSTANCE.blockUpdateCoordinatorLanesMax = 0; @@ -343,6 +355,13 @@ public static void droppedFullScanCandidates(long candidates) { } } + public static void droppedItemState(int trackedItems, int labels) { + if (INSTANCE.collecting) { + INSTANCE.droppedTrackedItemsMax = Math.max(INSTANCE.droppedTrackedItemsMax, trackedItems); + INSTANCE.droppedLabelsMax = Math.max(INSTANCE.droppedLabelsMax, labels); + } + } + public static void blockUpdateChecks(int checks, long nanos) { if (INSTANCE.collecting) { INSTANCE.blockUpdateChecks += checks; @@ -467,6 +486,7 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) return new Snapshot(label, configStaticAnchor, configPacketOnlyStatic, configHideIfViewObstructed, configVisibilityRateLimit, configVisibilityBucketSize, configVisibilityRestorePerTick, configDroppedLabelVisibility, + configDroppedSourceOwnedSectionCandidates, configEventDrivenBlockUpdates, configBlockUpdateMaxDirtyPerTick, LegacyTextComponentCache.isEnabled(), elapsedNanos, samples, tickSamplesDropped, @@ -484,6 +504,7 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) retainedCullingRegistrations, itemAnimationNanos, droppedItemNanos, droppedViewerDistanceChecks, droppedSpatialCandidates, droppedFullScanCandidates, + droppedTrackedItemsMax, droppedLabelsMax, blockUpdateChecks, blockUpdateNanos, blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, @@ -591,6 +612,7 @@ public record Snapshot( int visibilityBucketSize, int visibilityRestorePerTick, DroppedLabelVisibilityConfig droppedLabelVisibility, + boolean droppedSourceOwnedSectionCandidates, boolean eventDrivenBlockUpdates, int blockUpdateMaxDirtyPerTick, boolean legacyTextComponentCache, @@ -635,6 +657,8 @@ public record Snapshot( long droppedViewerDistanceChecks, long droppedSpatialCandidates, long droppedFullScanCandidates, + int droppedTrackedItemsMax, + int droppedLabelsMax, long blockUpdateChecks, long blockUpdateNanos, int blockUpdateCoordinatorLanesMax, @@ -695,6 +719,7 @@ public String json() { "\"droppedLabelVisibilityRateLimit\":%b," + "\"droppedLabelVisibilityBucketSize\":%d," + "\"droppedLabelVisibilityRestorePerTick\":%d," + + "\"droppedSourceOwnedSectionCandidates\":%b," + "\"eventDrivenBlockUpdates\":%b,\"blockUpdateMaxDirtyPerTick\":%d," + "\"legacyTextComponentCache\":%b," + "\"seconds\":%.3f,\"tickSamples\":%d,\"observedTps\":%.6f," + @@ -721,6 +746,8 @@ public String json() { "\"droppedViewerDistanceChecks\":%d," + "\"droppedSpatialCandidates\":%d," + "\"droppedFullScanCandidates\":%d," + + "\"droppedTrackedItemsMax\":%d," + + "\"droppedLabelsMax\":%d," + "\"blockUpdateChecks\":%d,\"blockUpdateMs\":%.6f," + "\"blockUpdateCoordinatorLanesMax\":%d," + "\"blockUpdateDirtyQueueMax\":%d," + @@ -738,7 +765,8 @@ public String json() { visibilityBucketSize, visibilityRestorePerTick, droppedLabelVisibility.cullingEnabled(), droppedLabelVisibility.viewDistance(), droppedLabelVisibility.rateLimitEnabled(), droppedLabelVisibility.bucketSize(), - droppedLabelVisibility.restorePerTick(), eventDrivenBlockUpdates, + droppedLabelVisibility.restorePerTick(), droppedSourceOwnedSectionCandidates, + eventDrivenBlockUpdates, blockUpdateMaxDirtyPerTick, legacyTextComponentCache, seconds(), tickSamples, observedTps(), droppedTickSamples, msptP50, msptP95, msptP99, @@ -753,7 +781,7 @@ public String json() { craftEngineCullingRetainedRegistrations, itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D, droppedViewerDistanceChecks, droppedSpatialCandidates, - droppedFullScanCandidates, + droppedFullScanCandidates, droppedTrackedItemsMax, droppedLabelsMax, blockUpdateChecks, blockUpdateNanos / 1_000_000.0D, blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java index aea576f8..63436008 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java @@ -49,10 +49,12 @@ import java.util.Map.Entry; import java.util.Objects; import java.util.Queue; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -140,6 +142,7 @@ public synchronized void close() { preferences.clear(); backingPlayerList.clear(); for (EnumMap groups : viewerGroups.values()) { + groups.values().forEach(ViewerGroup::clearChangeListeners); groups.values().forEach(ViewerGroup::clear); } for (Entry> entry : snapshots.entrySet()) { @@ -177,13 +180,15 @@ public synchronized void close() { /** Number of player/session/cache/I/O roots still retained after close. */ public int retainedStateCount() { int groupMembers = backingPlayerList.size(); + int groupListeners = 0; for (EnumMap groups : viewerGroups.values()) { for (ViewerGroup group : groups.values()) { groupMembers += group.size(); + groupListeners += group.changeListenerCount(); } } synchronized (entries) { - return preferences.size() + groupMembers + viewerGroups.size() + return preferences.size() + groupMembers + groupListeners + viewerGroups.size() + entries.size() + entryIndexes.size() + registeredEntries.size() + playerSessions.size() + playerIo.retainedOperationCount(); } @@ -628,6 +633,23 @@ public Collection getPlayerList() { return backingPlayerList.view(); } + /** Registers an internal wake-up callback for one cached viewer group. */ + public Runnable addViewerGroupChangeListener(Modules module, EntryKey entry, Runnable listener) { + Objects.requireNonNull(module, "module"); + Objects.requireNonNull(entry, "entry"); + Objects.requireNonNull(listener, "listener"); + ViewerGroup group; + synchronized (viewerGroups) { + EnumMap groups = viewerGroups.get(entry); + group = groups == null ? null : groups.get(module); + if (group == null) { + return null; + } + group.addChangeListener(listener); + } + return () -> group.removeChangeListener(listener); + } + private int entryCount() { synchronized (entries) { return entries.size(); @@ -748,6 +770,7 @@ interface ViewerMembership { static final class ViewerGroup extends AbstractCollection implements ViewerMembership { private final ConcurrentHashMap players = new ConcurrentHashMap<>(); + private final Set changeListeners = new CopyOnWriteArraySet<>(); private final Collection readOnly = Collections.unmodifiableCollection(this); private final Collection serverView; @@ -762,7 +785,11 @@ static final class ViewerGroup extends AbstractCollection implements Vie @Override public boolean add(Player player) { Objects.requireNonNull(player, "player"); - return players.put(player.getUniqueId(), player) != player; + boolean changed = players.put(player.getUniqueId(), player) != player; + if (changed) { + notifyChanged(); + } + return changed; } @Override @@ -770,11 +797,19 @@ public boolean remove(Object value) { if (!(value instanceof Player player)) { return false; } - return players.remove(player.getUniqueId(), player); + boolean changed = players.remove(player.getUniqueId(), player); + if (changed) { + notifyChanged(); + } + return changed; } boolean remove(UUID uuid) { - return players.remove(uuid) != null; + boolean changed = players.remove(uuid) != null; + if (changed) { + notifyChanged(); + } + return changed; } Player get(UUID uuid) { @@ -820,7 +855,32 @@ public boolean containsViewer(UUID viewer) { @Override public void clear() { - players.clear(); + if (!players.isEmpty()) { + players.clear(); + notifyChanged(); + } + } + + void addChangeListener(Runnable listener) { + changeListeners.add(listener); + } + + void removeChangeListener(Runnable listener) { + changeListeners.remove(listener); + } + + void clearChangeListeners() { + changeListeners.clear(); + } + + int changeListenerCount() { + return changeListeners.size(); + } + + private void notifyChanged() { + for (Runnable listener : changeListeners) { + listener.run(); + } } private static final class ServerSettingView extends AbstractCollection diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index d6c479d4..27cf0611 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -286,6 +286,8 @@ Entities: #Independent rollback switch; existing explicit false values are preserved during migration Enabled: true ViewDistance: 64 + #Use Paper's movement-maintained entity sections instead of rebuilding a global IV candidate pass + SourceOwnedSectionCandidates: false #Smoothing for dropped-label visibility bursts VisibilityRateLimit: #Independent from Settings.Performance.VisibilityRateLimit diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemCandidateWindowTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemCandidateWindowTest.java new file mode 100644 index 00000000..82ace14f --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemCandidateWindowTest.java @@ -0,0 +1,118 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DroppedItemCandidateWindowTest { + + @Test + void retainedBoundaryIncludesEveryPossibleCrampNeighbourAndNoRequiredOutsider() { + UUID world = UUID.randomUUID(); + double viewDistance = 64.0D; + double retainedBoundary = viewDistance + DroppedItemDisplay.VIEW_DISTANCE_HYSTERESIS; + double queryDistance = DroppedItemDisplay.sourceQueryDistance(viewDistance, true); + Point candidate = new Point(retainedBoundary, 0.0D, 0.0D); + List points = List.of( + candidate, + new Point(retainedBoundary + 0.5D, 0.0D, 0.0D), + new Point(retainedBoundary - 0.5D, 0.5D, -0.5D), + new Point(retainedBoundary + 0.500_001D, 0.0D, 0.0D), + new Point(retainedBoundary + 4.0D, 0.0D, 0.0D)); + + DroppedItemSpatialIndex full = new DroppedItemSpatialIndex(); + DroppedItemSpatialIndex local = new DroppedItemSpatialIndex(); + for (Point point : points) { + full.addItem(world, point.x(), point.y(), point.z()); + if (Math.abs(point.x()) <= queryDistance + && Math.abs(point.y()) <= queryDistance + && Math.abs(point.z()) <= queryDistance) { + local.addItem(world, point.x(), point.y(), point.z()); + } + } + + assertEquals(DroppedItemDisplay.RETAINED_CANDIDATE, + DroppedItemDisplay.classifyCandidate(candidate.x(), candidate.y(), candidate.z(), + viewDistance, true)); + for (int maximum = 1; maximum <= 4; maximum++) { + assertEquals( + full.exceedsItemLimit(world, candidate.x(), candidate.y(), candidate.z(), maximum), + local.exceedsItemLimit(world, candidate.x(), candidate.y(), candidate.z(), maximum)); + } + } + + @Test + void sourceOwnedSectionQueryPreservesBruteForceCandidateAndCrampResults() { + Random random = new Random(0x495643524f50534cL); + UUID world = UUID.randomUUID(); + double viewDistance = 64.0D; + double queryDistance = DroppedItemDisplay.sourceQueryDistance(viewDistance, true); + + for (int run = 0; run < 100; run++) { + List points = new ArrayList<>(); + for (int index = 0; index < 512; index++) { + points.add(new Point( + random.nextDouble(-96.0D, 96.0D), + random.nextDouble(-96.0D, 96.0D), + random.nextDouble(-96.0D, 96.0D))); + } + + DroppedItemSpatialIndex full = new DroppedItemSpatialIndex(); + DroppedItemSpatialIndex local = new DroppedItemSpatialIndex(); + for (Point point : points) { + full.addItem(world, point.x(), point.y(), point.z()); + if (Math.abs(point.x()) <= queryDistance + && Math.abs(point.y()) <= queryDistance + && Math.abs(point.z()) <= queryDistance) { + local.addItem(world, point.x(), point.y(), point.z()); + } + } + + for (Point point : points) { + boolean hasLabel = random.nextBoolean(); + int expected = bruteClassification(point, viewDistance, hasLabel); + int actual = DroppedItemDisplay.classifyCandidate( + point.x(), point.y(), point.z(), viewDistance, hasLabel); + assertEquals(expected, actual); + if (actual != DroppedItemDisplay.OUTSIDE_CANDIDATE_RANGE) { + for (int maximum = 1; maximum <= 6; maximum++) { + assertEquals( + full.exceedsItemLimit(world, point.x(), point.y(), point.z(), maximum), + local.exceedsItemLimit(world, point.x(), point.y(), point.z(), maximum)); + } + } + } + } + } + + private static int bruteClassification(Point point, double viewDistance, boolean hasLabel) { + double distanceSquared = point.x() * point.x() + + point.y() * point.y() + point.z() * point.z(); + if (distanceSquared <= viewDistance * viewDistance) { + return DroppedItemDisplay.DESIRED_CANDIDATE; + } + double retained = viewDistance + DroppedItemDisplay.VIEW_DISTANCE_HYSTERESIS; + return hasLabel && distanceSquared <= retained * retained + ? DroppedItemDisplay.RETAINED_CANDIDATE + : DroppedItemDisplay.OUTSIDE_CANDIDATE_RANGE; + } + + private record Point(double x, double y, double z) { + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java index 8e41cd53..d8418998 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java @@ -44,6 +44,20 @@ void excludesItemsOutsideTheBoxAndItemsInOtherWorlds() { assertFalse(index.exceedsItemLimit(world, 0.0D, 64.0D, 0.0D, 1)); } + @Test + void reusableCrampIndexDoesNotRetainThePreviousRefresh() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex index = new DroppedItemSpatialIndex(); + index.addItem(world, 0.0D, 64.0D, 0.0D); + index.addItem(world, 0.25D, 64.0D, 0.25D); + assertTrue(index.exceedsItemLimit(world, 0.0D, 64.0D, 0.0D, 1)); + + index.clear(); + index.addItem(world, 0.0D, 64.0D, 0.0D); + + assertFalse(index.exceedsItemLimit(world, 0.0D, 64.0D, 0.0D, 1)); + } + @Test void findsViewersAcrossChunkBoundariesUsingExactDistance() { UUID world = UUID.randomUUID(); diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java index ba2a9c10..8f9055fa 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java @@ -73,7 +73,9 @@ void bundledConfigurationEnablesControlsForNewInstalls() throws IOException { " VisibilityCulling:\n" + " #Independent rollback switch; existing explicit false values are preserved during migration\n" + " Enabled: true\n" - + " ViewDistance: 64\n")); + + " ViewDistance: 64\n" + + " #Use Paper's movement-maintained entity sections instead of rebuilding a global IV candidate pass\n" + + " SourceOwnedSectionCandidates: false\n")); assertTrue(droppedItemOptions.contains( " VisibilityRateLimit:\n" + " #Independent from Settings.Performance.VisibilityRateLimit\n" diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java index f64ab6ed..a89f652f 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java @@ -78,7 +78,7 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { PerformanceMetrics.Snapshot snapshot = new PerformanceMetrics.Snapshot( "diagnostic", false, false, false, false, 128, 32, new PerformanceMetrics.DroppedLabelVisibilityConfig(true, 64, true, 128, 32), - true, 64, true, + true, true, 64, true, 1_000_000_000L, 20, 0L, 1.0D, 2.0D, 3.0D, 4.0D, 50.25D, 4242, 1_783_951_200_123L, 7L, 12_345_678L, @@ -87,7 +87,7 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0, - 0L, 0L, 0L, 0L, 0L, 7L, 12_345_678L, 0, 0, 0, + 0L, 0L, 0L, 0L, 0L, 2048, 2048, 7L, 12_345_678L, 0, 0, 0, 0L, 0L, 0, 0L, 0L, 100L, 5L, 200L); String json = snapshot.json(); @@ -99,12 +99,15 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { assertTrue(json.contains("\"msptMaxBlockUpdateMs\":12.345678")); assertTrue(json.contains("\"droppedLabelVisibilityCulling\":true")); assertTrue(json.contains("\"droppedLabelViewDistance\":64")); + assertTrue(json.contains("\"droppedSourceOwnedSectionCandidates\":true")); assertTrue(json.contains("\"droppedLabelVisibilityRateLimit\":true")); assertTrue(json.contains("\"droppedLabelVisibilityBucketSize\":128")); assertTrue(json.contains("\"droppedLabelVisibilityRestorePerTick\":32")); assertTrue(json.contains("\"viewerFullReconciles\":0")); assertTrue(json.contains("\"craftEngineCullingRetainedRegistrations\":0")); assertTrue(json.contains("\"droppedSpatialCandidates\":0")); + assertTrue(json.contains("\"droppedTrackedItemsMax\":2048")); + assertTrue(json.contains("\"droppedLabelsMax\":2048")); assertTrue(json.contains("\"blockUpdateDirtyQueueMax\":0")); assertTrue(json.contains("\"preferenceSqlStatements\":0")); assertTrue(json.contains("\"legacyTextCacheHits\":95")); diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java index 117b62ef..908a9955 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java @@ -188,6 +188,27 @@ void viewerGroupReplacesReconnectInstancesWithoutAllocatingNewViews() { assertTrue(view.isEmpty()); } + @Test + void viewerGroupNotifiesOnlyRealMembershipChangesAndReleasesListeners() { + PreferenceManager.ViewerGroup group = new PreferenceManager.ViewerGroup(); + UUID uuid = UUID.randomUUID(); + Player player = player(uuid, "viewer"); + java.util.concurrent.atomic.AtomicInteger changes = new java.util.concurrent.atomic.AtomicInteger(); + group.addChangeListener(changes::incrementAndGet); + + assertTrue(group.add(player)); + assertFalse(group.add(player)); + assertFalse(group.remove(player(UUID.randomUUID(), "other"))); + assertTrue(group.remove(player)); + assertEquals(2, changes.get()); + assertEquals(1, group.changeListenerCount()); + + group.clearChangeListeners(); + group.add(player); + assertEquals(2, changes.get()); + assertEquals(0, group.changeListenerCount()); + } + @Test void cachedServerViewTracksReloadedModuleSettingsWithoutRebuildingTheGroup() { boolean previousEnabled = InteractionVisualizer.hologramsEnabled; diff --git a/tools/perf/analyze-phase2-abba.ps1 b/tools/perf/analyze-phase2-abba.ps1 index 6c5798ed..4b612d42 100644 --- a/tools/perf/analyze-phase2-abba.ps1 +++ b/tools/perf/analyze-phase2-abba.ps1 @@ -430,8 +430,10 @@ function Convert-ManifestRows { $legacyTextComponentCacheEnabled = $null if ($hasExtendedProvenance) { $abFactor = (Get-CellValue -Row $row -Column $provenanceColumns["AbFactor"]).Trim().ToLowerInvariant() - if ($abFactor -ne "scenario-config" -and $abFactor -ne "legacy-text-component-cache") { - throw "Manifest row $rowNumber AbFactor must be scenario-config or legacy-text-component-cache, but was '$abFactor'." + if ($abFactor -ne "scenario-config" -and + $abFactor -ne "legacy-text-component-cache" -and + $abFactor -ne "dropped-item-section-candidates") { + throw "Manifest row $rowNumber has unsupported AbFactor '$abFactor'." } $configSha256 = (Get-CellValue -Row $row -Column $provenanceColumns["ConfigSha256"]).Trim().ToLowerInvariant() $jvmArgumentsSha256 = (Get-CellValue -Row $row -Column $provenanceColumns["JvmArgumentsSha256"]).Trim().ToLowerInvariant() @@ -532,7 +534,8 @@ function Get-ScenarioResult { if ($hasExtendedProvenance) { foreach ($run in $Runs) { if ($run.AbFactor -ne "scenario-config" -and - $run.AbFactor -ne "legacy-text-component-cache") { + $run.AbFactor -ne "legacy-text-component-cache" -and + $run.AbFactor -ne "dropped-item-section-candidates") { throw "Scenario '$ScenarioName' run '$($run.RunId)' has invalid AbFactor '$($run.AbFactor)'." } foreach ($hash in @( @@ -578,24 +581,25 @@ function Get-ScenarioResult { ForEach-Object LegacyTextComponentCacheEnabled | Sort-Object -Unique) } - if ($abFactor -eq "scenario-config") { + if ($abFactor -eq "scenario-config" -or + $abFactor -eq "dropped-item-section-candidates") { $allJvmArgumentHashes = @($Runs.JvmArgumentsSha256 | Sort-Object -Unique) if ($allJvmArgumentHashes.Count -ne 1) { - throw "Scenario '$ScenarioName' scenario-config factor must keep JvmArgumentsSha256 identical across all runs." + throw "Scenario '$ScenarioName' config-based factor '$abFactor' must keep JvmArgumentsSha256 identical across all runs." } foreach ($variant in @("A", "B")) { if ($configHashesByVariant[$variant].Count -ne 1) { - throw "Scenario '$ScenarioName' scenario-config variant $variant must use exactly one ConfigSha256." + throw "Scenario '$ScenarioName' config-based factor '$abFactor' variant $variant must use exactly one ConfigSha256." } $disableValues = $legacyTextComponentCacheTreatment.byVariant[$variant].disableProperty $enabledValues = $legacyTextComponentCacheTreatment.byVariant[$variant].enabled if ($disableValues.Count -ne 1 -or $enabledValues.Count -ne 1 -or [bool]$disableValues[0] -ne $false -or [bool]$enabledValues[0] -ne $true) { - throw "Scenario '$ScenarioName' scenario-config variant $variant must keep the legacy text cache enabled." + throw "Scenario '$ScenarioName' config-based factor '$abFactor' variant $variant must keep the legacy text cache enabled." } } if ($configHashesByVariant.A[0] -eq $configHashesByVariant.B[0]) { - throw "Scenario '$ScenarioName' scenario-config variants A and B must use different ConfigSha256 values." + throw "Scenario '$ScenarioName' config-based factor '$abFactor' variants A and B must use different ConfigSha256 values." } } else { if ($configHashes.Count -ne 1) { @@ -805,7 +809,7 @@ function New-SelfTestPreparedRuns { param( [Parameter(Mandatory = $true)][string]$ScenarioName, [switch]$Extended, - [ValidateSet("scenario-config", "legacy-text-component-cache")] + [ValidateSet("scenario-config", "legacy-text-component-cache", "dropped-item-section-candidates")] [string]$AbFactor = "legacy-text-component-cache" ) @@ -831,7 +835,7 @@ function New-SelfTestPreparedRuns { if ($Extended) { $run["HasExtendedProvenance"] = $true $run["AbFactor"] = $AbFactor - $run["ConfigSha256"] = if ($AbFactor -eq "scenario-config" -and $variant -eq "B") { + $run["ConfigSha256"] = if ($AbFactor -ne "legacy-text-component-cache" -and $variant -eq "B") { ('4' * 64) } else { ('3' * 64) @@ -1000,6 +1004,18 @@ function Invoke-AnalyzerSelfTest { throw "Self-test failed to accept and preserve scenario-config provenance." } + $droppedCandidateRuns = @(New-SelfTestPreparedRuns -ScenarioName "DROPPED_CANDIDATES" ` + -Extended -AbFactor "dropped-item-section-candidates") + $droppedCandidateResult = Get-ScenarioResult -Runs $droppedCandidateRuns ` + -ScenarioName "DROPPED_CANDIDATES" -MetricPath "metric" ` + -MetricDirection "LowerIsBetter" -PermitIncomplete $false ` + -Iterations 1000 -RandomSeed 33 -Prepared + if ($droppedCandidateResult.abFactor -ne "dropped-item-section-candidates" -or + $droppedCandidateResult.configSha256ByVariant.A[0] -eq + $droppedCandidateResult.configSha256ByVariant.B[0]) { + throw "Self-test failed to accept dropped-item candidate-source provenance." + } + $badScenarioConfigDrift = @(New-SelfTestPreparedRuns -ScenarioName "BAD_SCENARIO_DRIFT" ` -Extended -AbFactor "scenario-config") $badScenarioConfigDrift[0].ConfigSha256 = ('8' * 64) @@ -1067,6 +1083,7 @@ function Invoke-AnalyzerSelfTest { acceptsLegacyNineColumnManifest = $true acceptsCacheFactorProvenance = $true acceptsScenarioConfigProvenance = $true + acceptsDroppedCandidateProvenance = $true rejectsPartialProvenanceColumns = $true enforcesConfigAndJvmProvenance = $true enforcesNormalizedJvmArguments = $true diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index 5d6d3d17..4a42b7db 100644 --- a/tools/perf/run-phase2-runtime-once.sh +++ b/tools/perf/run-phase2-runtime-once.sh @@ -25,6 +25,7 @@ done plugin_jar="$(realpath "$PHASE2_PLUGIN_JAR")" paper_jar="$(realpath "$PHASE2_PAPER_JAR")" +paper_version="${PHASE2_PAPER_VERSION:-26.1.2}" client_root="$(realpath "$PHASE2_CLIENT_ROOT")" output_root="$(realpath -m "$PHASE2_OUTPUT_ROOT")" run_id="$PHASE2_RUN_ID" @@ -51,13 +52,17 @@ case "$variant" in A|B) ;; *) echo "PHASE2_VARIANT must be A or B" >&2; exit 64 ;; esac +case "$paper_version" in + 26.1.2|26.2) ;; + *) echo "PHASE2_PAPER_VERSION must be 26.1.2 or 26.2" >&2; exit 64 ;; +esac case "$scenario" in - static-steady|static-spawn|visibility-return|visibility-itemdisplay-return|visibility-textdisplay-return|block-idle|block-active|block-direct-write) ;; + static-steady|static-spawn|visibility-return|visibility-itemdisplay-return|visibility-textdisplay-return|dropped-items|block-idle|block-active|block-direct-write) ;; *) echo "Unsupported PHASE2_SCENARIO: $scenario" >&2; exit 64 ;; esac case "$ab_factor" in - scenario-config|legacy-text-component-cache) ;; - *) echo "PHASE2_AB_FACTOR must be scenario-config or legacy-text-component-cache" >&2; exit 64 ;; + scenario-config|legacy-text-component-cache|dropped-item-section-candidates) ;; + *) echo "PHASE2_AB_FACTOR must be scenario-config, legacy-text-component-cache, or dropped-item-section-candidates" >&2; exit 64 ;; esac if [[ "$ab_factor" == legacy-text-component-cache && "$scenario" != block-active ]]; then echo "legacy-text-component-cache A/B is isolated to block-active" >&2 @@ -112,13 +117,14 @@ case "$spark_profile_mode" in *) echo "PHASE2_SPARK_PROFILE_MODE must be none, cpu, cpu-all, or alloc" >&2; exit 64 ;; esac if [[ "$spark_profile_mode" == cpu-all && \ - "$scenario" != block-active && "$scenario" != block-direct-write ]]; then - echo "Spark cpu-all profiling is isolated to block-active or block-direct-write" >&2 + "$scenario" != block-active && "$scenario" != block-direct-write && \ + "$scenario" != dropped-items ]]; then + echo "Spark cpu-all profiling is isolated to block-active, block-direct-write, or dropped-items" >&2 exit 64 fi if [[ "$spark_profile_mode" != none && "$spark_profile_mode" != cpu-all && \ - "$scenario" != block-direct-write ]]; then - echo "Spark cpu/alloc profiling is isolated to block-direct-write" >&2 + "$scenario" != block-direct-write && "$scenario" != dropped-items ]]; then + echo "Spark cpu/alloc profiling is isolated to block-direct-write or dropped-items" >&2 exit 64 fi [[ -f "$plugin_jar" && -f "$paper_jar" ]] \ @@ -164,6 +170,10 @@ legacy_text_cache_enabled=true if [[ "$legacy_text_cache_disable_property" == true ]]; then legacy_text_cache_enabled=false fi +dropped_source_owned_candidates=false +if [[ "$ab_factor" == dropped-item-section-candidates && "$variant" == B ]]; then + dropped_source_owned_candidates=true +fi legacy_text_cache_jvm_argument="-Dinteractionvisualizer.disableLegacyTextComponentCache=$legacy_text_cache_disable_property" legacy_text_cache_jvm_argument_template="-Dinteractionvisualizer.disableLegacyTextComponentCache=" jvm_arguments=( @@ -227,9 +237,17 @@ visibility_limit = scenario.startswith("visibility-") and variant == "B" event_driven = scenario.startswith("block-") and ( ab_factor == "legacy-text-component-cache" or variant == "B" ) +source_owned_candidates = ( + ab_factor == "dropped-item-section-candidates" and variant == "B" +) replace_boolean_once(" PacketOnlyStatic: ", packet_only) replace_boolean_once(" Enabled: ", visibility_limit) replace_boolean_once(" EventDriven: ", event_driven) +replace_boolean_once( + " SourceOwnedSectionCandidates: ", source_owned_candidates +) +if scenario == "dropped-items": + replace_once(" DespawnTicks: 6000", " DespawnTicks: 12000") replace_once(" Updater: true", " Updater: false") replace_once(" DownloadLanguageFiles: true", " DownloadLanguageFiles: false") path.write_text(text, encoding="utf-8", newline="\n") @@ -658,6 +676,14 @@ if [[ "$protocol_trace_enabled" == 1 ]]; then "PHASE2_PROTOCOL_TRACE_AGGREGATE_PACKET_ALLOWLIST=$protocol_trace_aggregate_packet_allowlist" ) fi +if [[ "$ab_factor" == dropped-item-section-candidates && "$scenario" != dropped-items ]]; then + echo "dropped-item-section-candidates A/B is isolated to dropped-items" >&2 + exit 64 +fi +if [[ "$scenario" == dropped-items && "$ab_factor" != dropped-item-section-candidates ]]; then + echo "dropped-items is reserved for the dropped-item-section-candidates factor" >&2 + exit 64 +fi env \ "PHASE2_MC_PROTOCOL_MODULE=$client_root/node-minecraft-protocol" \ PHASE2_SERVER_HOST=127.0.0.1 \ @@ -726,6 +752,9 @@ else scene_type=textdisplay scene_entity_label=entities ;; + dropped-items) + scene_type=dropped + ;; esac scene_spawn_log="Spawned $item_count $scene_type benchmark $scene_entity_label" @@ -1006,12 +1035,19 @@ expected_limiter = visibility_scenario and variant == "B" expected_event_driven = block_scenario and ( ab_factor == "legacy-text-component-cache" or variant == "B" ) +expected_dropped_source = ( + ab_factor == "dropped-item-section-candidates" and variant == "B" +) if metrics.get("packetOnlyStatic") is not expected_packet_only: raise SystemExit(f"packetOnlyStatic does not match {scenario}/{variant}") if metrics.get("visibilityRateLimit") is not expected_limiter: raise SystemExit(f"visibilityRateLimit does not match {scenario}/{variant}") if metrics.get("eventDrivenBlockUpdates") is not expected_event_driven: raise SystemExit(f"eventDrivenBlockUpdates does not match {scenario}/{variant}") +if metrics.get("droppedSourceOwnedSectionCandidates") is not expected_dropped_source: + raise SystemExit( + f"droppedSourceOwnedSectionCandidates does not match {scenario}/{variant}" + ) if metrics.get("legacyTextComponentCache") is not expected_cache_enabled: raise SystemExit( "legacyTextComponentCache does not match the injected disable property: " @@ -1073,6 +1109,38 @@ if block_scenario: ) if block_checks > 0 and block_ms <= 0: raise SystemExit(f"block updater recorded {block_checks} checks but no elapsed time") +if scenario == "dropped-items": + dropped_ms = metrics.get("droppedItemMs") + distance_checks = metrics.get("droppedViewerDistanceChecks") + spatial_candidates = metrics.get("droppedSpatialCandidates") + full_scan_candidates = metrics.get("droppedFullScanCandidates") + tracked_max = metrics.get("droppedTrackedItemsMax") + labels_max = metrics.get("droppedLabelsMax") + if (isinstance(dropped_ms, bool) or not isinstance(dropped_ms, (int, float)) + or not math.isfinite(dropped_ms) or dropped_ms <= 0): + raise SystemExit(f"invalid droppedItemMs={dropped_ms!r}") + for field, value in ( + ("droppedViewerDistanceChecks", distance_checks), + ("droppedSpatialCandidates", spatial_candidates), + ("droppedFullScanCandidates", full_scan_candidates), + ("droppedTrackedItemsMax", tracked_max), + ("droppedLabelsMax", labels_max), + ): + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise SystemExit(f"invalid {field}={value!r}") + if distance_checks <= 0 or spatial_candidates <= 0: + raise SystemExit("dropped-item workload performed no candidate checks") + if tracked_max != item_count or labels_max != item_count: + raise SystemExit( + "dropped-item workload did not retain every requested label: " + f"tracked={tracked_max} labels={labels_max} expected={item_count}" + ) + if variant == "A" and full_scan_candidates <= 0: + raise SystemExit("legacy dropped-item path performed no full candidate scans") + if variant == "B" and full_scan_candidates != 0: + raise SystemExit( + f"section-candidate path performed {full_scan_candidates} full candidate scans" + ) if scenario == "static-spawn": if variant == "B": if metrics.get("packetOnlyItemSyncs") != item_count or metrics.get("bukkitEntitySpawns") != 0: @@ -1652,8 +1720,10 @@ cat > "$run_directory/run-manifest.json" < Date: Sun, 19 Jul 2026 11:01:35 +0800 Subject: [PATCH 02/10] Select versioned Paper benchmark channels --- .github/workflows/phase2-runtime-ab.yml | 24 ++++++++++++++++++++---- tools/perf/run-phase2-runtime-once.sh | 10 ++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/phase2-runtime-ab.yml b/.github/workflows/phase2-runtime-ab.yml index 728fc0af..46027e61 100644 --- a/.github/workflows/phase2-runtime-ab.yml +++ b/.github/workflows/phase2-runtime-ab.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: paper_version: - description: "Stable Paper runtime under test" + description: "Paper runtime under test (26.2 currently uses the BETA channel)" required: true default: "26.1.2" type: choice @@ -111,20 +111,32 @@ jobs: - name: Build and test the production plugin run: ./gradlew clean check shadowJar --no-daemon --no-build-cache --rerun-tasks - - name: Download selected stable Paper runtime + - name: Download selected Paper runtime env: PAPER_USER_AGENT: InteractionVisualizer-Phase2/1.0 (https://github.com/EllanServer/InteractionVisualizer) PAPER_VERSION: ${{ env.CAMPAIGN_PAPER_VERSION }} run: | mkdir -p phase2-dependencies + case "$PAPER_VERSION" in + 26.1.2) PAPER_CHANNEL=STABLE ;; + 26.2) PAPER_CHANNEL=BETA ;; + *) echo "Unsupported Paper version: $PAPER_VERSION" >&2; exit 64 ;; + esac BUILDS=$(curl --fail --silent --show-error \ -H "User-Agent: $PAPER_USER_AGENT" \ "https://fill.papermc.io/v3/projects/paper/versions/$PAPER_VERSION/builds") - PAPER_URL=$(echo "$BUILDS" | jq -r 'first(.[] | select(.channel == "STABLE") | .downloads."server:default".url) // empty') - test -n "$PAPER_URL" + PAPER_RECORD=$(echo "$BUILDS" | jq -c --arg channel "$PAPER_CHANNEL" \ + 'first(.[] | select(.channel == $channel)) // empty') + test -n "$PAPER_RECORD" + PAPER_URL=$(echo "$PAPER_RECORD" | jq -r '.downloads."server:default".url') + PAPER_SHA256=$(echo "$PAPER_RECORD" | jq -r '.downloads."server:default".checksums.sha256') + PAPER_BUILD_ID=$(echo "$PAPER_RECORD" | jq -r '.id') curl --fail --location --show-error \ -H "User-Agent: $PAPER_USER_AGENT" \ --output phase2-dependencies/paper.jar "$PAPER_URL" + echo "$PAPER_SHA256 phase2-dependencies/paper.jar" | sha256sum --check + echo "SELECTED_PAPER_CHANNEL=$PAPER_CHANNEL" >> "$GITHUB_ENV" + echo "SELECTED_PAPER_BUILD_ID=$PAPER_BUILD_ID" >> "$GITHUB_ENV" - name: Prepare immutable protocol client artifact run: bash tools/perf/prepare-phase2-protocol-client.sh phase2-dependencies/protocol-client @@ -133,6 +145,8 @@ jobs: env: AB_FACTOR: ${{ env.CAMPAIGN_AB_FACTOR }} PAPER_VERSION: ${{ env.CAMPAIGN_PAPER_VERSION }} + PAPER_CHANNEL: ${{ env.SELECTED_PAPER_CHANNEL }} + PAPER_BUILD_ID: ${{ env.SELECTED_PAPER_BUILD_ID }} SCENARIO: ${{ env.CAMPAIGN_SCENARIO }} RUNS: ${{ env.CAMPAIGN_RUNS }} ITEMS: ${{ env.CAMPAIGN_ITEMS }} @@ -269,6 +283,8 @@ jobs: PHASE2_PLUGIN_JAR="$PLUGIN_JAR" \ PHASE2_PAPER_JAR=phase2-dependencies/paper.jar \ PHASE2_PAPER_VERSION="$PAPER_VERSION" \ + PHASE2_PAPER_CHANNEL="$PAPER_CHANNEL" \ + PHASE2_PAPER_BUILD_ID="$PAPER_BUILD_ID" \ PHASE2_CLIENT_ROOT=phase2-dependencies/protocol-client \ PHASE2_OUTPUT_ROOT="$EVIDENCE_ROOT" \ PHASE2_RUN_ID="$run_id" \ diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index 4a42b7db..e6ed9416 100644 --- a/tools/perf/run-phase2-runtime-once.sh +++ b/tools/perf/run-phase2-runtime-once.sh @@ -26,6 +26,8 @@ done plugin_jar="$(realpath "$PHASE2_PLUGIN_JAR")" paper_jar="$(realpath "$PHASE2_PAPER_JAR")" paper_version="${PHASE2_PAPER_VERSION:-26.1.2}" +paper_channel="${PHASE2_PAPER_CHANNEL:-UNKNOWN}" +paper_build_id="${PHASE2_PAPER_BUILD_ID:-0}" client_root="$(realpath "$PHASE2_CLIENT_ROOT")" output_root="$(realpath -m "$PHASE2_OUTPUT_ROOT")" run_id="$PHASE2_RUN_ID" @@ -56,6 +58,12 @@ case "$paper_version" in 26.1.2|26.2) ;; *) echo "PHASE2_PAPER_VERSION must be 26.1.2 or 26.2" >&2; exit 64 ;; esac +case "$paper_channel" in + STABLE|BETA|UNKNOWN) ;; + *) echo "PHASE2_PAPER_CHANNEL must be STABLE, BETA, or UNKNOWN" >&2; exit 64 ;; +esac +[[ "$paper_build_id" =~ ^[0-9]+$ ]] \ + || { echo "PHASE2_PAPER_BUILD_ID must be numeric" >&2; exit 64; } case "$scenario" in static-steady|static-spawn|visibility-return|visibility-itemdisplay-return|visibility-textdisplay-return|dropped-items|block-idle|block-active|block-direct-write) ;; *) echo "Unsupported PHASE2_SCENARIO: $scenario" >&2; exit 64 ;; @@ -1721,6 +1729,8 @@ cat > "$run_directory/run-manifest.json" < Date: Sun, 19 Jul 2026 11:35:34 +0800 Subject: [PATCH 03/10] Support packet text displays on Paper 26.2 --- .../packet/ClientTextDisplayBridge.java | 10 ++++++- .../packet/ClientTextDisplayBridgeTest.java | 3 +- .../minecraft/world/entity/EntityType.java | 2 -- .../craftbukkit/entity/CraftEntityType.java | 29 +++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 common/src/test/java/org/bukkit/craftbukkit/entity/CraftEntityType.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridge.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridge.java index 85e7984b..57f4f08c 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridge.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridge.java @@ -16,6 +16,7 @@ import net.momirealms.sparrow.heart.util.SelfIncreaseEntityID; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import java.lang.reflect.Constructor; @@ -241,7 +242,14 @@ private static Resolution resolve() { Class entityTypeClass = Class.forName( "net.minecraft.world.entity.EntityType", false, serverLoader); - Object textDisplayEntityType = entityTypeClass.getField("TEXT_DISPLAY").get(null); + Class craftEntityTypeClass = Class.forName( + "org.bukkit.craftbukkit.entity.CraftEntityType", false, serverLoader); + Method bukkitToMinecraft = craftEntityTypeClass.getMethod( + "bukkitToMinecraft", EntityType.class); + Object textDisplayEntityType = bukkitToMinecraft.invoke(null, EntityType.TEXT_DISPLAY); + if (!entityTypeClass.isInstance(textDisplayEntityType)) { + throw new IllegalStateException("CraftEntityType returned an invalid text-display entity type"); + } Class vec3Class = Class.forName( "net.minecraft.world.phys.Vec3", false, serverLoader); Constructor vec3Constructor = vec3Class.getConstructor( diff --git a/common/src/test/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridgeTest.java b/common/src/test/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridgeTest.java index 8f696e0f..3ea9a9f7 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridgeTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridgeTest.java @@ -20,7 +20,6 @@ import net.minecraft.network.syncher.SynchedEntityData; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.network.ServerGamePacketListenerImpl; -import net.minecraft.world.entity.EntityType; import net.minecraft.world.phys.Vec3; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -94,7 +93,7 @@ void bundlesSpawnWithTheCompleteLegacyNameTagProfile() { assertEquals(12.0F, add.xRot()); assertEquals(90.0F, add.yRot()); assertEquals(90.0, add.yHeadRot()); - assertSame(EntityType.TEXT_DISPLAY, add.type()); + assertEquals("text_display", add.type().key()); assertSame(Vec3.ZERO, add.movement()); ClientboundSetEntityDataPacket metadata = assertInstanceOf( diff --git a/common/src/test/java/net/minecraft/world/entity/EntityType.java b/common/src/test/java/net/minecraft/world/entity/EntityType.java index 14602a2a..d6d54396 100644 --- a/common/src/test/java/net/minecraft/world/entity/EntityType.java +++ b/common/src/test/java/net/minecraft/world/entity/EntityType.java @@ -12,6 +12,4 @@ package net.minecraft.world.entity; public record EntityType(String key) { - - public static final EntityType TEXT_DISPLAY = new EntityType<>("text_display"); } diff --git a/common/src/test/java/org/bukkit/craftbukkit/entity/CraftEntityType.java b/common/src/test/java/org/bukkit/craftbukkit/entity/CraftEntityType.java new file mode 100644 index 00000000..0cb83beb --- /dev/null +++ b/common/src/test/java/org/bukkit/craftbukkit/entity/CraftEntityType.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 org.bukkit.craftbukkit.entity; + +import net.minecraft.world.entity.EntityType; + +public final class CraftEntityType { + + private static final EntityType TEXT_DISPLAY = new EntityType<>("text_display"); + + private CraftEntityType() { + } + + public static EntityType bukkitToMinecraft(org.bukkit.entity.EntityType type) { + if (type != org.bukkit.entity.EntityType.TEXT_DISPLAY) { + throw new IllegalArgumentException("Unsupported test entity type: " + type); + } + return TEXT_DISPLAY; + } +} From 2120e4556d395f5ad685975d0373ca308752eeb9 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 12:29:26 +0800 Subject: [PATCH 04/10] Match runtime readiness to Paper version --- .github/workflows/build.yml | 1 + .github/workflows/phase2-packet-capture.yml | 1 + .github/workflows/phase2-runtime-ab.yml | 1 + tools/perf/run-phase2-runtime-once.sh | 26 ++++++++++++++++++++- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d64c6e5..41cedba6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,6 +80,7 @@ jobs: run: | bash -n tools/perf/prepare-phase2-protocol-client.sh bash -n tools/perf/run-phase2-runtime-once.sh + bash tools/perf/run-phase2-runtime-once.sh --self-test node --check tools/perf/phase2-protocol-client.js - name: Run checks and build production jar diff --git a/.github/workflows/phase2-packet-capture.yml b/.github/workflows/phase2-packet-capture.yml index c1793b7b..51f6b002 100644 --- a/.github/workflows/phase2-packet-capture.yml +++ b/.github/workflows/phase2-packet-capture.yml @@ -104,6 +104,7 @@ jobs: run: | bash -n tools/perf/prepare-phase2-protocol-client.sh bash -n tools/perf/run-phase2-runtime-once.sh + bash tools/perf/run-phase2-runtime-once.sh --self-test node --check tools/perf/phase2-protocol-client.js node --check tools/perf/analyze-phase2-protocol-trace.js node tools/perf/analyze-phase2-protocol-trace.js --self-test diff --git a/.github/workflows/phase2-runtime-ab.yml b/.github/workflows/phase2-runtime-ab.yml index 46027e61..0f1ca50e 100644 --- a/.github/workflows/phase2-runtime-ab.yml +++ b/.github/workflows/phase2-runtime-ab.yml @@ -104,6 +104,7 @@ jobs: run: | bash -n tools/perf/prepare-phase2-protocol-client.sh bash -n tools/perf/run-phase2-runtime-once.sh + bash tools/perf/run-phase2-runtime-once.sh --self-test node --check tools/perf/phase2-protocol-client.js pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 -SelfTest pwsh -NoProfile -File tools/perf/analyze-phase2-pcap.ps1 -SelfTest diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index e6ed9416..7d2f4dfd 100644 --- a/tools/perf/run-phase2-runtime-once.sh +++ b/tools/perf/run-phase2-runtime-once.sh @@ -5,6 +5,30 @@ set -euo pipefail # invoke this script sequentially in the desired ABBA order; it deliberately # owns only one JVM/run so cleanup failures cannot leak state into the next one. +plugin_enabled_log_marker() { + case "${1:-}" in + 26.1.2|26.2) + printf '[InteractionVisualizer] Enabled for Paper %s!' "$1" + ;; + *) + return 64 + ;; + esac +} + +if [[ "${1:-}" == --self-test ]]; then + [[ "$(plugin_enabled_log_marker 26.1.2)" == \ + '[InteractionVisualizer] Enabled for Paper 26.1.2!' ]] + [[ "$(plugin_enabled_log_marker 26.2)" == \ + '[InteractionVisualizer] Enabled for Paper 26.2!' ]] + if plugin_enabled_log_marker 26.3 >/dev/null 2>&1; then + echo 'runtime harness accepted an unsupported Paper version' >&2 + exit 1 + fi + printf '{"passed":true,"paperEnableMarkers":["26.1.2","26.2"]}\n' + exit 0 +fi + script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" protocol_client_source="$script_directory/phase2-protocol-client.js" protocol_trace_analyzer_source="$script_directory/analyze-phase2-protocol-trace.js" @@ -590,7 +614,7 @@ fi ) > "$server_log" 2>&1 & server_pid=$! -wait_for_log "[InteractionVisualizer] Enabled for Paper 26.1.2!" 240 +wait_for_log "$(plugin_enabled_log_marker "$paper_version")" 240 wait_for_log "Done (" 240 python3 - "/proc/$server_pid/cmdline" "$jvm_command_line_metadata" "$server_pid" \ From a8fd4c9dc3335a6b846c61cbff10b9ad2014a60b Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 13:17:32 +0800 Subject: [PATCH 05/10] Pin runtime benchmarks to Paper 26.1.2 --- .github/workflows/phase2-runtime-ab.yml | 8 +------- tools/perf/run-phase2-runtime-once.sh | 14 ++++++-------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/phase2-runtime-ab.yml b/.github/workflows/phase2-runtime-ab.yml index 0f1ca50e..6e36def1 100644 --- a/.github/workflows/phase2-runtime-ab.yml +++ b/.github/workflows/phase2-runtime-ab.yml @@ -5,12 +5,6 @@ on: types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: - paper_version: - description: "Paper runtime under test (26.2 currently uses the BETA channel)" - required: true - default: "26.1.2" - type: choice - options: ["26.1.2", "26.2"] ab_factor: description: "Independent A/B factor with an isolated matching workload" required: true @@ -70,7 +64,7 @@ jobs: timeout-minutes: 130 env: CAMPAIGN_AB_FACTOR: ${{ github.event_name == 'workflow_dispatch' && inputs.ab_factor || 'scenario-config' }} - CAMPAIGN_PAPER_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.paper_version || '26.1.2' }} + CAMPAIGN_PAPER_VERSION: "26.1.2" CAMPAIGN_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || 'static-steady' }} CAMPAIGN_RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || github.event.action == 'labeled' && '12' || '4' }} CAMPAIGN_ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items || github.event.action == 'labeled' && '4096' || '1024' }} diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index 7d2f4dfd..545f9599 100644 --- a/tools/perf/run-phase2-runtime-once.sh +++ b/tools/perf/run-phase2-runtime-once.sh @@ -7,7 +7,7 @@ set -euo pipefail plugin_enabled_log_marker() { case "${1:-}" in - 26.1.2|26.2) + 26.1.2) printf '[InteractionVisualizer] Enabled for Paper %s!' "$1" ;; *) @@ -19,13 +19,11 @@ plugin_enabled_log_marker() { if [[ "${1:-}" == --self-test ]]; then [[ "$(plugin_enabled_log_marker 26.1.2)" == \ '[InteractionVisualizer] Enabled for Paper 26.1.2!' ]] - [[ "$(plugin_enabled_log_marker 26.2)" == \ - '[InteractionVisualizer] Enabled for Paper 26.2!' ]] - if plugin_enabled_log_marker 26.3 >/dev/null 2>&1; then - echo 'runtime harness accepted an unsupported Paper version' >&2 + if plugin_enabled_log_marker 26.2 >/dev/null 2>&1; then + echo 'runtime harness accepted a non-canonical Paper version' >&2 exit 1 fi - printf '{"passed":true,"paperEnableMarkers":["26.1.2","26.2"]}\n' + printf '{"passed":true,"canonicalPaperVersion":"26.1.2","paperEnableMarkers":["26.1.2"]}\n' exit 0 fi @@ -79,8 +77,8 @@ case "$variant" in *) echo "PHASE2_VARIANT must be A or B" >&2; exit 64 ;; esac case "$paper_version" in - 26.1.2|26.2) ;; - *) echo "PHASE2_PAPER_VERSION must be 26.1.2 or 26.2" >&2; exit 64 ;; + 26.1.2) ;; + *) echo "PHASE2_PAPER_VERSION must be the canonical benchmark version 26.1.2" >&2; exit 64 ;; esac case "$paper_channel" in STABLE|BETA|UNKNOWN) ;; From 975660042704554323d0cdfbb7041bf5ca53bf6f Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 14:18:18 +0800 Subject: [PATCH 06/10] Fix dropped-item culling benchmark workload --- .github/workflows/phase2-runtime-ab.yml | 117 +------ .../loohp/interactionvisualizer/Commands.java | 28 +- .../debug/PerformanceScene.java | 171 ++++++++-- .../PerformanceSceneDroppedLayoutTest.java | 63 ++++ tools/perf/evaluate-dropped-item-gate.py | 319 ++++++++++++++++++ tools/perf/run-phase2-runtime-once.sh | 34 +- 6 files changed, 589 insertions(+), 143 deletions(-) create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/debug/PerformanceSceneDroppedLayoutTest.java create mode 100644 tools/perf/evaluate-dropped-item-gate.py diff --git a/.github/workflows/phase2-runtime-ab.yml b/.github/workflows/phase2-runtime-ab.yml index 6e36def1..4bfd6f75 100644 --- a/.github/workflows/phase2-runtime-ab.yml +++ b/.github/workflows/phase2-runtime-ab.yml @@ -68,6 +68,7 @@ jobs: CAMPAIGN_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || 'static-steady' }} CAMPAIGN_RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || github.event.action == 'labeled' && '12' || '4' }} CAMPAIGN_ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items || github.event.action == 'labeled' && '4096' || '1024' }} + CAMPAIGN_DROPPED_NEARBY_ITEMS: "128" CAMPAIGN_WARMUP_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.warmup_seconds || github.event.action == 'labeled' && '120' || '10' }} CAMPAIGN_SETTLE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.settle_seconds || github.event.action == 'labeled' && '20' || '5' }} CAMPAIGN_MEASURE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.measure_seconds || github.event.action == 'labeled' && '180' || '10' }} @@ -99,6 +100,7 @@ jobs: bash -n tools/perf/prepare-phase2-protocol-client.sh bash -n tools/perf/run-phase2-runtime-once.sh bash tools/perf/run-phase2-runtime-once.sh --self-test + python3 tools/perf/evaluate-dropped-item-gate.py --self-test node --check tools/perf/phase2-protocol-client.js pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 -SelfTest pwsh -NoProfile -File tools/perf/analyze-phase2-pcap.ps1 -SelfTest @@ -145,6 +147,7 @@ jobs: SCENARIO: ${{ env.CAMPAIGN_SCENARIO }} RUNS: ${{ env.CAMPAIGN_RUNS }} ITEMS: ${{ env.CAMPAIGN_ITEMS }} + DROPPED_NEARBY_ITEMS: ${{ env.CAMPAIGN_DROPPED_NEARBY_ITEMS }} WARMUP_SECONDS: ${{ env.CAMPAIGN_WARMUP_SECONDS }} SETTLE_SECONDS: ${{ env.CAMPAIGN_SETTLE_SECONDS }} MEASURE_SECONDS: ${{ env.CAMPAIGN_MEASURE_SECONDS }} @@ -160,6 +163,7 @@ jobs: "$SCENARIO" == dropped-items ]] [[ "$RUNS" =~ ^(4|8|12)$ ]] [[ "$ITEMS" =~ ^[0-9]+$ ]] + [[ "$DROPPED_NEARBY_ITEMS" =~ ^[0-9]+$ ]] if [[ "$SCENARIO" == block-* ]]; then # TileEntityUpdate.CheckingRange=1 covers the centered 32x32 # block footprint completely; larger command scenes are valid but @@ -207,6 +211,10 @@ jobs: echo "dropped-item-section-candidates A/B requires exactly 2048 dropped items" >&2 exit 64 fi + if (( DROPPED_NEARBY_ITEMS != 128 )); then + echo "dropped-item-section-candidates A/B requires exactly 128 nearby labels" >&2 + exit 64 + fi elif [[ "$SCENARIO" == dropped-items ]]; then echo "dropped-items is reserved for dropped-item-section-candidates A/B" >&2 exit 64 @@ -252,9 +260,11 @@ jobs: sha256sum phase2-dependencies/paper.jar sha256sum phase2-dependencies/protocol-client/client-build-manifest.json sha256sum tools/perf/run-phase2-runtime-once.sh + sha256sum tools/perf/evaluate-dropped-item-gate.py java -version 2>&1 printf '%s\n' \ - "abFactor=$AB_FACTOR" "scenario=$SCENARIO" "sparkProfile=$SPARK_PROFILE_MODE" + "abFactor=$AB_FACTOR" "scenario=$SCENARIO" \ + "droppedNearbyItems=$DROPPED_NEARBY_ITEMS" "sparkProfile=$SPARK_PROFILE_MODE" } | sha256sum | awk '{print $1}' ) @@ -287,6 +297,7 @@ jobs: PHASE2_VARIANT="$variant" \ PHASE2_AB_FACTOR="$AB_FACTOR" \ PHASE2_ITEM_COUNT="$ITEMS" \ + PHASE2_DROPPED_NEARBY_ITEM_COUNT="$DROPPED_NEARBY_ITEMS" \ PHASE2_WARMUP_SECONDS="$WARMUP_SECONDS" \ PHASE2_SETTLE_SECONDS="$SETTLE_SECONDS" \ PHASE2_MEASURE_SECONDS="$MEASURE_SECONDS" \ @@ -413,107 +424,9 @@ jobs: -MinimumSeconds "$minimum_seconds" "${incomplete[@]}" \ -OutputJson "$EVIDENCE_ROOT/droppedItemMs.analysis.json" -Overwrite - python3 - "$MANIFEST" "$EVIDENCE_ROOT" <<'PY' - import csv - import json - import os - from pathlib import Path - import sys - - manifest_path = Path(sys.argv[1]) - evidence_root = Path(sys.argv[2]) - - def load_analysis(name): - return json.loads((evidence_root / f"{name}.analysis.json").read_text(encoding="utf-8")) - - dropped = load_analysis("droppedItemMs") - p95 = load_analysis("msptP95") - p99 = load_analysis("msptP99") - checks = { - "droppedItemMedianRatioAtMost0_50": dropped["medianBRatioToA"] <= 0.50, - "droppedItemCiExcludesRegression": dropped["ratioBootstrap95Ci"][1] < 1.0, - "msptP95CiUpperAtMost1_02": p95["ratioBootstrap95Ci"][1] <= 1.02, - "msptP99CiUpperAtMost1_05": p99["ratioBootstrap95Ci"][1] <= 1.05, - } - - candidate_runs = [] - with manifest_path.open(encoding="utf-8", newline="") as stream: - for row in csv.DictReader(stream): - metrics_path = manifest_path.parent / row["SourcePath"] - metrics = json.loads(metrics_path.read_text(encoding="utf-8")) - candidate_runs.append({ - "runId": row["RunId"], - "variant": row["Variant"], - "sourceOwned": metrics["droppedSourceOwnedSectionCandidates"], - "trackedItemsMax": metrics["droppedTrackedItemsMax"], - "labelsMax": metrics["droppedLabelsMax"], - "fullScanCandidates": metrics["droppedFullScanCandidates"], - "spatialCandidates": metrics["droppedSpatialCandidates"], - "viewerDistanceChecks": metrics["droppedViewerDistanceChecks"], - }) - checks["allRunsTracked2048Labels"] = all( - run["trackedItemsMax"] == 2048 and run["labelsMax"] == 2048 - for run in candidate_runs - ) - checks["candidateHasZeroFullScans"] = all( - run["fullScanCandidates"] == 0 - for run in candidate_runs if run["variant"] == "B" - ) - checks["baselineExercisedFullScans"] = all( - run["fullScanCandidates"] > 0 - for run in candidate_runs if run["variant"] == "A" - ) - checks["candidateTreatmentIsolated"] = all( - run["sourceOwned"] is (run["variant"] == "B") - for run in candidate_runs - ) - passed = all(checks.values()) - gate = { - "schemaVersion": 1, - "scenario": "dropped-items", - "abFactor": "dropped-item-section-candidates", - "formalComplete": dropped.get("formalComplete") is True, - "serverRuntimeGatePassed": passed, - "checks": checks, - "ratios": { - "droppedItemMs": { - "medianBRatioToA": dropped["medianBRatioToA"], - "ratioBootstrap95Ci": dropped["ratioBootstrap95Ci"], - }, - "msptP95": { - "medianBRatioToA": p95["medianBRatioToA"], - "ratioBootstrap95Ci": p95["ratioBootstrap95Ci"], - }, - "msptP99": { - "medianBRatioToA": p99["medianBRatioToA"], - "ratioBootstrap95Ci": p99["ratioBootstrap95Ci"], - }, - }, - "runs": candidate_runs, - "scope": "Server runtime gate only; allocation and native-client frame gates remain separate.", - } - (evidence_root / "dropped-item-section-candidates.gate.json").write_text( - json.dumps(gate, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" - ) - summary = os.environ.get("GITHUB_STEP_SUMMARY") - if summary: - with open(summary, "a", encoding="utf-8", newline="\n") as stream: - stream.write("### Dropped-item section-candidate server gate\n\n") - stream.write(f"Result: `{'pass' if passed else 'fail'}`.\n\n") - stream.write( - f"droppedItemMs B/A: `{dropped['medianBRatioToA']}` " - f"(95% CI `{dropped['ratioBootstrap95Ci']}`).\n\n" - ) - stream.write( - f"MSPT p95 CI upper: `{p95['ratioBootstrap95Ci'][1]}`; " - f"p99 CI upper: `{p99['ratioBootstrap95Ci'][1]}`.\n" - ) - if not gate["formalComplete"]: - raise SystemExit("dropped-item gate is not a complete 12-run campaign") - if not passed: - failed = ", ".join(name for name, value in checks.items() if not value) - raise SystemExit(f"dropped-item server gate failed: {failed}") - PY + python3 tools/perf/evaluate-dropped-item-gate.py \ + "$MANIFEST" "$EVIDENCE_ROOT" "$ITEMS" "$DROPPED_NEARBY_ITEMS" \ + "$EVIDENCE_ROOT/dropped-item-section-candidates.gate.json" fi if [[ "$SCENARIO" == block-* ]]; then diff --git a/common/src/main/java/com/loohp/interactionvisualizer/Commands.java b/common/src/main/java/com/loohp/interactionvisualizer/Commands.java index e0c3e8f2..6cf4a705 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/Commands.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/Commands.java @@ -308,7 +308,7 @@ private boolean handlePerformanceCommand(CommandSender sender, String[] args) { if (args.length < 4) { sender.sendMessage(Component.text( "Usage: /iv perf scene " + - " [lifetimeTicks] [player]")); + " [lifetimeTicks] [player] [nearbyCount]")); return true; } boolean moving = args[2].equalsIgnoreCase("motion"); @@ -321,6 +321,11 @@ private boolean handlePerformanceCommand(CommandSender sender, String[] args) { "[InteractionVisualizer] Scene type must be static, motion, itemdisplay, textdisplay, or dropped.")); return true; } + if (args.length > 7 || args.length == 7 && !dropped) { + sender.sendMessage(Component.text( + "[InteractionVisualizer] nearbyCount is only valid for dropped scenes.")); + return true; + } long defaultLifetime = moving ? 80L : 200L; long lifetime = defaultLifetime; String requestedPlayer = args.length >= 6 ? args[5] : null; @@ -344,8 +349,23 @@ private boolean handlePerformanceCommand(CommandSender sender, String[] args) { return true; } int count = parseInteger(args[3], 1); + int requestedNearbyCount = count; + if (args.length == 7) { + try { + requestedNearbyCount = Integer.parseInt(args[6]); + } catch (NumberFormatException ignored) { + sender.sendMessage(Component.text( + "[InteractionVisualizer] nearbyCount must be an integer.")); + return true; + } + if (requestedNearbyCount < 1) { + sender.sendMessage(Component.text( + "[InteractionVisualizer] nearbyCount must be positive.")); + return true; + } + } int spawned = dropped - ? PerformanceScene.spawnDroppedItems(player, count, lifetime) + ? PerformanceScene.spawnDroppedItems(player, count, requestedNearbyCount, lifetime) : itemDisplay || textDisplay ? PerformanceScene.spawnDisplay(player, textDisplay, count, lifetime) : PerformanceScene.spawn(player, moving, count, lifetime); @@ -353,8 +373,10 @@ private boolean handlePerformanceCommand(CommandSender sender, String[] args) { : itemDisplay ? "itemdisplay" : textDisplay ? "textdisplay" : "dropped"; String entityLabel = staticItem || moving || dropped ? " benchmark items" : " benchmark entities"; + String nearbyLabel = dropped && requestedNearbyCount != spawned + ? " (" + Math.min(spawned, requestedNearbyCount) + " nearby)" : ""; sender.sendMessage(Component.text("[InteractionVisualizer] Spawned " + spawned + " " - + sceneName + entityLabel + " for " + lifetime + " ticks.")); + + sceneName + entityLabel + nearbyLabel + " for " + lifetime + " ticks.")); } case "clear" -> { String requestedOwner = args.length >= 3 ? args[2] : null; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java index d8649926..e4df613f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java @@ -40,8 +40,11 @@ public final class PerformanceScene { private static final int MAX_ITEMS = 8_192; private static final long MAX_LIFETIME_TICKS = 12_000L; + private static final double DROPPED_ITEM_SPACING = 1.25D; + private static final double DROPPED_FAR_CENTER_OFFSET = 192.0D; private static final Map> scenes = new HashMap<>(); - private static final Map> droppedScenes = new HashMap<>(); + private static final Map droppedScenes = new HashMap<>(); + private static final Map droppedChunkTicketReferences = new HashMap<>(); private PerformanceScene() { } @@ -126,41 +129,97 @@ public static int spawnDisplay(Player owner, boolean text, int requestedCount, /** Creates real Paper Item entities for the dropped-label runtime factor. */ public static int spawnDroppedItems(Player owner, int requestedCount, long requestedLifetimeTicks) { + return spawnDroppedItems(owner, requestedCount, requestedCount, requestedLifetimeTicks); + } + + /** + * Creates a dropped-item scene whose globally tracked population can be + * larger than the population near the viewer. This makes the benchmark + * exercise spatial candidate selection instead of placing every source + * entity inside the query radius. + */ + public static int spawnDroppedItems(Player owner, int requestedCount, + int requestedNearbyCount, + long requestedLifetimeTicks) { int count = Math.max(1, Math.min(MAX_ITEMS, requestedCount)); + int nearbyCount = Math.max(1, Math.min(count, requestedNearbyCount)); long lifetimeTicks = Math.max(1L, Math.min(MAX_LIFETIME_TICKS, requestedLifetimeTicks)); clear(owner); World world = owner.getWorld(); Location center = owner.getLocation().add(0.0D, 1.5D, 0.0D); - int width = (int) Math.ceil(Math.sqrt(count)); - double spacing = 1.25D; - double offset = (width - 1) * spacing / 2.0D; + List placements = droppedItemPlacements(count, nearbyCount); + Set chunkTickets = new HashSet<>(); + for (DroppedItemPlacement placement : placements) { + int blockX = (int) Math.floor(center.getX() + placement.xOffset()); + int blockZ = (int) Math.floor(center.getZ() + placement.zOffset()); + chunkTickets.add(new ChunkCoordinates(blockX >> 4, blockZ >> 4)); + } + Set acquiredTickets = new HashSet<>(chunkTickets.size()); Set items = new HashSet<>(count); - for (int index = 0; index < count; index++) { - double x = center.getX() + index % width * spacing - offset; - double z = center.getZ() + index / width * spacing - offset; - Location location = new Location(world, x, center.getY(), z); - location.getChunk().load(); - ItemStack stack = new ItemStack(Material.STONE, index % 64 + 1); - int ordinal = index; - stack.editMeta(meta -> meta.customName(Component.text("iv-dropped-benchmark-" + ordinal))); - org.bukkit.entity.Item item = world.dropItem(location, stack, entity -> { - entity.setGravity(false); - entity.setVelocity(new Vector()); - entity.setPickupDelay(Short.MAX_VALUE - 1); - entity.setUnlimitedLifetime(true); - entity.setPersistent(false); - }); - items.add(item); + try { + for (ChunkCoordinates chunk : chunkTickets) { + ChunkTicketKey key = new ChunkTicketKey(world, chunk.x(), chunk.z()); + acquireChunkTicket(key); + acquiredTickets.add(key); + world.getChunkAt(chunk.x(), chunk.z()); + } + for (int index = 0; index < placements.size(); index++) { + DroppedItemPlacement placement = placements.get(index); + Location location = new Location(world, + center.getX() + placement.xOffset(), center.getY(), + center.getZ() + placement.zOffset()); + ItemStack stack = new ItemStack(Material.STONE, index % 64 + 1); + int ordinal = index; + stack.editMeta(meta -> meta.customName(Component.text("iv-dropped-benchmark-" + ordinal))); + org.bukkit.entity.Item item = world.dropItem(location, stack, entity -> { + entity.setGravity(false); + entity.setVelocity(new Vector()); + entity.setPickupDelay(Short.MAX_VALUE - 1); + entity.setUnlimitedLifetime(true); + entity.setPersistent(false); + }); + items.add(item); + } + } catch (RuntimeException exception) { + removeDroppedItems(items); + releaseChunkTickets(acquiredTickets); + throw exception; } UUID ownerId = owner.getUniqueId(); - droppedScenes.put(ownerId, items); + DroppedScene scene = new DroppedScene(items, acquiredTickets); + droppedScenes.put(ownerId, scene); Scheduler.runTaskLater(InteractionVisualizer.plugin, - () -> expireDropped(ownerId, items), lifetimeTicks, owner.getLocation()); + () -> expireDropped(ownerId, scene), lifetimeTicks, owner.getLocation()); return count; } + static List droppedItemPlacements(int requestedCount, + int requestedNearbyCount) { + int count = Math.max(1, Math.min(MAX_ITEMS, requestedCount)); + int nearbyCount = Math.max(1, Math.min(count, requestedNearbyCount)); + List placements = new ArrayList<>(count); + addDroppedItemGrid(placements, nearbyCount, 0.0D, true); + addDroppedItemGrid(placements, count - nearbyCount, DROPPED_FAR_CENTER_OFFSET, false); + return placements; + } + + private static void addDroppedItemGrid(List placements, int count, + double centerXOffset, boolean nearby) { + if (count <= 0) { + return; + } + int width = (int) Math.ceil(Math.sqrt(count)); + double offset = (width - 1) * DROPPED_ITEM_SPACING / 2.0D; + for (int index = 0; index < count; index++) { + placements.add(new DroppedItemPlacement( + centerXOffset + index % width * DROPPED_ITEM_SPACING - offset, + index / width * DROPPED_ITEM_SPACING - offset, + nearby)); + } + } + public static void clear(Player owner) { clear(owner.getUniqueId()); } @@ -171,9 +230,9 @@ public static void clear(UUID ownerId) { if (previous != null) { removeEntities(previous); } - Set dropped = droppedScenes.remove(ownerId); + DroppedScene dropped = droppedScenes.remove(ownerId); if (dropped != null) { - removeDroppedItems(dropped); + removeDroppedScene(dropped); } } @@ -184,15 +243,15 @@ public static void shutdown() { for (Set entities : remaining) { removeEntities(entities); } - List> remainingDropped = new ArrayList<>(droppedScenes.values()); + List remainingDropped = new ArrayList<>(droppedScenes.values()); droppedScenes.clear(); - for (Set items : remainingDropped) { - removeDroppedItems(items); + for (DroppedScene scene : remainingDropped) { + removeDroppedScene(scene); } } public static int retainedStateCount() { - return scenes.size() + droppedScenes.size(); + return scenes.size() + droppedScenes.size() + droppedChunkTicketReferences.size(); } private static void expire(UUID ownerId, Set entities) { @@ -201,9 +260,9 @@ private static void expire(UUID ownerId, Set entities) { } } - private static void expireDropped(UUID ownerId, Set items) { - if (droppedScenes.remove(ownerId, items)) { - removeDroppedItems(items); + private static void expireDropped(UUID ownerId, DroppedScene scene) { + if (droppedScenes.remove(ownerId, scene)) { + removeDroppedScene(scene); } } @@ -224,4 +283,54 @@ private static void removeDroppedItems(Set items) { } } } + + private static void removeDroppedScene(DroppedScene scene) { + try { + removeDroppedItems(scene.items()); + } finally { + releaseChunkTickets(scene.chunkTickets()); + } + } + + private static void acquireChunkTicket(ChunkTicketKey key) { + Integer references = droppedChunkTicketReferences.get(key); + if (references != null) { + droppedChunkTicketReferences.put(key, references + 1); + return; + } + if (!key.world().addPluginChunkTicket(key.x(), key.z(), InteractionVisualizer.plugin)) { + throw new IllegalStateException("Could not retain dropped-item benchmark chunk " + + key.x() + "," + key.z()); + } + droppedChunkTicketReferences.put(key, 1); + } + + private static void releaseChunkTickets(Set chunks) { + for (ChunkTicketKey chunk : chunks) { + Integer references = droppedChunkTicketReferences.get(chunk); + if (references == null) { + continue; + } + if (references > 1) { + droppedChunkTicketReferences.put(chunk, references - 1); + } else { + droppedChunkTicketReferences.remove(chunk); + chunk.world().removePluginChunkTicket( + chunk.x(), chunk.z(), InteractionVisualizer.plugin); + } + } + } + + static record DroppedItemPlacement(double xOffset, double zOffset, boolean nearby) { + } + + private record ChunkCoordinates(int x, int z) { + } + + private record ChunkTicketKey(World world, int x, int z) { + } + + private record DroppedScene(Set items, + Set chunkTickets) { + } } diff --git a/common/src/test/java/com/loohp/interactionvisualizer/debug/PerformanceSceneDroppedLayoutTest.java b/common/src/test/java/com/loohp/interactionvisualizer/debug/PerformanceSceneDroppedLayoutTest.java new file mode 100644 index 00000000..7dce6a66 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/debug/PerformanceSceneDroppedLayoutTest.java @@ -0,0 +1,63 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PerformanceSceneDroppedLayoutTest { + + @Test + void separatesGlobalTrackedPopulationFromNearbyLabels() { + List placements = + PerformanceScene.droppedItemPlacements(2_048, 128); + + assertEquals(2_048, placements.size()); + assertEquals(128, placements.stream().filter( + PerformanceScene.DroppedItemPlacement::nearby).count()); + assertTrue(placements.stream().filter( + PerformanceScene.DroppedItemPlacement::nearby) + .allMatch(placement -> distance(placement) < 16.0D)); + assertTrue(placements.stream().filter( + placement -> !placement.nearby()) + .allMatch(placement -> distance(placement) > 128.0D)); + } + + @Test + void largestSupportedFarGridStaysOutsideCandidateQueryMargin() { + List placements = + PerformanceScene.droppedItemPlacements(8_192, 1); + + assertEquals(8_192, placements.size()); + assertTrue(placements.stream().filter( + placement -> !placement.nearby()) + .allMatch(placement -> distance(placement) > 128.0D)); + } + + @Test + void legacyAllNearbyLayoutRemainsAvailable() { + List placements = + PerformanceScene.droppedItemPlacements(2_048, 2_048); + + assertEquals(2_048, placements.size()); + assertTrue(placements.stream().allMatch( + PerformanceScene.DroppedItemPlacement::nearby)); + } + + private static double distance(PerformanceScene.DroppedItemPlacement placement) { + return Math.hypot(placement.xOffset(), placement.zOffset()); + } +} diff --git a/tools/perf/evaluate-dropped-item-gate.py b/tools/perf/evaluate-dropped-item-gate.py new file mode 100644 index 00000000..5a4aaf9e --- /dev/null +++ b/tools/perf/evaluate-dropped-item-gate.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Evaluate the formal dropped-item section-candidate runtime gate.""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +from pathlib import Path +import statistics +import sys +import tempfile +from typing import Any + + +SCENARIO = "dropped-items" +AB_FACTOR = "dropped-item-section-candidates" + + +def read_json(path: Path) -> dict[str, Any]: + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError(f"JSON root must be an object: {path}") + return document + + +def load_analysis_result(evidence_root: Path, metric: str) -> dict[str, Any]: + path = evidence_root / f"{metric}.analysis.json" + document = read_json(path) + if document.get("schemaVersion") != 2: + raise ValueError(f"{path.name} must use analysis schemaVersion=2") + if document.get("metric") != metric: + raise ValueError(f"{path.name} metric does not match {metric}") + if document.get("abFactor") != AB_FACTOR: + raise ValueError(f"{path.name} A/B factor does not match {AB_FACTOR}") + results = document.get("results") + if not isinstance(results, list) or len(results) != 1: + raise ValueError(f"{path.name} must contain exactly one analysis result") + result = results[0] + if not isinstance(result, dict) or result.get("scenario") != SCENARIO: + raise ValueError(f"{path.name} does not contain the {SCENARIO} result") + if result.get("metric") != metric or result.get("abFactor") != AB_FACTOR: + raise ValueError(f"{path.name} result provenance does not match its envelope") + return result + + +def integer_field(document: dict[str, Any], field: str, source: Path) -> int: + value = document.get(field) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{source}: {field} must be a non-negative integer") + return value + + +def boolean_field(document: dict[str, Any], field: str, source: Path) -> bool: + value = document.get(field) + if not isinstance(value, bool): + raise ValueError(f"{source}: {field} must be a boolean") + return value + + +def load_runs(manifest_path: Path, evidence_root: Path, + expected_items: int, expected_nearby_items: int) -> list[dict[str, Any]]: + runs: list[dict[str, Any]] = [] + evidence_root_resolved = evidence_root.resolve() + with manifest_path.open(encoding="utf-8", newline="") as stream: + for row in csv.DictReader(stream): + if row.get("Scenario") != SCENARIO: + continue + source_text = row.get("SourcePath") + if not source_text: + raise ValueError("manifest row is missing SourcePath") + metrics_path = (manifest_path.parent / source_text).resolve() + try: + metrics_path.relative_to(evidence_root_resolved) + except ValueError as exception: + raise ValueError(f"metrics path escapes evidence root: {metrics_path}") from exception + metrics = read_json(metrics_path) + provenance_path = metrics_path.with_name("run-manifest.json") + provenance = read_json(provenance_path) + if provenance.get("itemCount") != expected_items: + raise ValueError(f"{provenance_path}: itemCount provenance drifted") + if provenance.get("droppedNearbyItemCount") != expected_nearby_items: + raise ValueError(f"{provenance_path}: nearby-item provenance drifted") + variant = row.get("Variant") + if variant not in {"A", "B"}: + raise ValueError(f"manifest contains invalid variant {variant!r}") + runs.append({ + "runId": row.get("RunId"), + "variant": variant, + "sourceOwned": boolean_field( + metrics, "droppedSourceOwnedSectionCandidates", metrics_path), + "trackedItemsMax": integer_field(metrics, "droppedTrackedItemsMax", metrics_path), + "labelsMax": integer_field(metrics, "droppedLabelsMax", metrics_path), + "fullScanCandidates": integer_field( + metrics, "droppedFullScanCandidates", metrics_path), + "spatialCandidates": integer_field( + metrics, "droppedSpatialCandidates", metrics_path), + "viewerDistanceChecks": integer_field( + metrics, "droppedViewerDistanceChecks", metrics_path), + }) + if len(runs) != 12: + raise ValueError(f"formal dropped-item gate requires 12 runs, found {len(runs)}") + if sum(run["variant"] == "A" for run in runs) != 6 \ + or sum(run["variant"] == "B" for run in runs) != 6: + raise ValueError("formal dropped-item gate requires six runs per variant") + return runs + + +def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, + expected_nearby_items: int, output_path: Path) -> dict[str, Any]: + if expected_items < 1 or not 1 <= expected_nearby_items <= expected_items: + raise ValueError("expected global/local item counts are invalid") + dropped = load_analysis_result(evidence_root, "droppedItemMs") + p95 = load_analysis_result(evidence_root, "msptP95") + p99 = load_analysis_result(evidence_root, "msptP99") + runs = load_runs(manifest_path, evidence_root, expected_items, expected_nearby_items) + baseline_full_scan = statistics.median( + run["fullScanCandidates"] for run in runs if run["variant"] == "A") + candidate_spatial = statistics.median( + run["spatialCandidates"] for run in runs if run["variant"] == "B") + candidate_viewer_checks = statistics.median( + run["viewerDistanceChecks"] for run in runs if run["variant"] == "B") + if baseline_full_scan <= 0: + raise ValueError("baseline did not record a positive full-scan population") + candidate_spatial_ratio = candidate_spatial / baseline_full_scan + candidate_viewer_ratio = candidate_viewer_checks / baseline_full_scan + + checks = { + "droppedItemMedianRatioAtMost0_50": dropped["medianBRatioToA"] <= 0.50, + "droppedItemCiExcludesRegression": dropped["ratioBootstrap95Ci"][1] < 1.0, + "msptP95CiUpperAtMost1_02": p95["ratioBootstrap95Ci"][1] <= 1.02, + "msptP99CiUpperAtMost1_05": p99["ratioBootstrap95Ci"][1] <= 1.05, + "allRunsRetainedGlobalPopulation": all( + run["trackedItemsMax"] == expected_items for run in runs), + "allRunsRenderedNearbyPopulation": all( + run["labelsMax"] == expected_nearby_items for run in runs), + "candidateHasZeroFullScans": all( + run["fullScanCandidates"] == 0 for run in runs if run["variant"] == "B"), + "baselineExercisedFullScans": all( + run["fullScanCandidates"] > 0 for run in runs if run["variant"] == "A"), + "candidateTreatmentIsolated": all( + run["sourceOwned"] is (run["variant"] == "B") for run in runs), + "candidateSpatialCandidatesAtMost10PercentOfBaseline": candidate_spatial_ratio <= 0.10, + "candidateViewerChecksAtMost10PercentOfBaseline": candidate_viewer_ratio <= 0.10, + } + formal_complete = all( + result.get("formalComplete") is True for result in (dropped, p95, p99)) + passed = formal_complete and all(checks.values()) + gate = { + "schemaVersion": 2, + "scenario": SCENARIO, + "abFactor": AB_FACTOR, + "formalComplete": formal_complete, + "serverRuntimeGatePassed": passed, + "workload": { + "trackedItems": expected_items, + "nearbyLabels": expected_nearby_items, + }, + "checks": checks, + "ratios": { + "droppedItemMs": { + "medianBRatioToA": dropped["medianBRatioToA"], + "ratioBootstrap95Ci": dropped["ratioBootstrap95Ci"], + }, + "msptP95": { + "medianBRatioToA": p95["medianBRatioToA"], + "ratioBootstrap95Ci": p95["ratioBootstrap95Ci"], + }, + "msptP99": { + "medianBRatioToA": p99["medianBRatioToA"], + "ratioBootstrap95Ci": p99["ratioBootstrap95Ci"], + }, + "candidateSpatialToBaselineFullScan": candidate_spatial_ratio, + "candidateViewerChecksToBaselineFullScan": candidate_viewer_ratio, + }, + "runs": runs, + "scope": "Server runtime gate only; allocation and native-client frame gates remain separate.", + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(gate, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8", newline="\n") as stream: + stream.write("### Dropped-item section-candidate server gate\n\n") + stream.write(f"Result: `{'pass' if passed else 'fail'}`.\n\n") + stream.write( + f"Workload: `{expected_items}` tracked / `{expected_nearby_items}` nearby labels.\n\n") + stream.write( + f"droppedItemMs B/A: `{dropped['medianBRatioToA']}` " + f"(95% CI `{dropped['ratioBootstrap95Ci']}`).\n\n") + stream.write( + f"Candidate/full-scan ratio: `{candidate_spatial_ratio:.6f}`.\n\n") + stream.write( + f"MSPT p95 CI upper: `{p95['ratioBootstrap95Ci'][1]}`; " + f"p99 CI upper: `{p99['ratioBootstrap95Ci'][1]}`.\n") + return gate + + +def self_test() -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def analysis(metric: str, ratio: float, interval: list[float]) -> dict[str, Any]: + result = { + "scenario": SCENARIO, + "metric": metric, + "abFactor": AB_FACTOR, + "formalComplete": True, + "medianBRatioToA": ratio, + "ratioBootstrap95Ci": interval, + } + return { + "schemaVersion": 2, + "metric": metric, + "abFactor": AB_FACTOR, + "results": [result], + } + + valid = analysis("droppedItemMs", 0.40, [0.35, 0.45]) + for metric, document in { + "droppedItemMs": valid, + "msptP95": analysis("msptP95", 0.99, [0.98, 1.01]), + "msptP99": analysis("msptP99", 1.00, [0.99, 1.04]), + }.items(): + (root / f"{metric}.analysis.json").write_text( + json.dumps(document), encoding="utf-8") + parsed = load_analysis_result(root, "droppedItemMs") + assert parsed["scenario"] == SCENARIO + + manifest_path = root / "abba-manifest.csv" + with manifest_path.open("w", encoding="utf-8", newline="") as stream: + writer = csv.DictWriter( + stream, fieldnames=["Scenario", "Variant", "RunId", "SourcePath"]) + writer.writeheader() + for index in range(12): + variant = "A" if index % 4 in {0, 3} else "B" + run_id = f"self_test_{variant}_{index + 1:02d}" + run_root = root / run_id + run_root.mkdir() + metrics = { + "droppedSourceOwnedSectionCandidates": variant == "B", + "droppedTrackedItemsMax": 2_048, + "droppedLabelsMax": 128, + "droppedFullScanCandidates": 204_800 if variant == "A" else 0, + "droppedSpatialCandidates": 12_800, + "droppedViewerDistanceChecks": 12_800, + } + (run_root / "iv-perf.json").write_text( + json.dumps(metrics), encoding="utf-8") + (run_root / "run-manifest.json").write_text(json.dumps({ + "itemCount": 2_048, + "droppedNearbyItemCount": 128, + }), encoding="utf-8") + writer.writerow({ + "Scenario": SCENARIO, + "Variant": variant, + "RunId": run_id, + "SourcePath": f"{run_id}/iv-perf.json", + }) + summary = os.environ.pop("GITHUB_STEP_SUMMARY", None) + try: + gate = evaluate(manifest_path, root, 2_048, 128, root / "gate.json") + finally: + if summary is not None: + os.environ["GITHUB_STEP_SUMMARY"] = summary + assert gate["serverRuntimeGatePassed"] is True + assert gate["ratios"]["candidateSpatialToBaselineFullScan"] == 0.0625 + + invalid = dict(valid) + invalid.pop("results") + (root / "droppedItemMs.analysis.json").write_text( + json.dumps(invalid), encoding="utf-8") + try: + load_analysis_result(root, "droppedItemMs") + except ValueError as exception: + assert "exactly one analysis result" in str(exception) + else: + raise AssertionError("analysis parser accepted a missing schema-v2 results envelope") + print(json.dumps({"passed": True, "analysisSchemaVersion": 2}, separators=(",", ":"))) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("manifest", nargs="?", type=Path) + parser.add_argument("evidence_root", nargs="?", type=Path) + parser.add_argument("expected_items", nargs="?", type=int) + parser.add_argument("expected_nearby_items", nargs="?", type=int) + parser.add_argument("output", nargs="?", type=Path) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.self_test: + self_test() + return 0 + required = ( + args.manifest, args.evidence_root, args.expected_items, + args.expected_nearby_items, args.output, + ) + if any(value is None for value in required): + raise SystemExit( + "manifest, evidence_root, expected_items, expected_nearby_items, and output are required") + gate = evaluate( + args.manifest, args.evidence_root, args.expected_items, + args.expected_nearby_items, args.output) + if not gate["formalComplete"]: + raise SystemExit("dropped-item gate is not a complete 12-run campaign") + if not gate["serverRuntimeGatePassed"]: + failed = ", ".join( + name for name, value in gate["checks"].items() if not value) + raise SystemExit(f"dropped-item server gate failed: {failed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index 545f9599..002d7aa1 100644 --- a/tools/perf/run-phase2-runtime-once.sh +++ b/tools/perf/run-phase2-runtime-once.sh @@ -57,6 +57,7 @@ scenario="$PHASE2_SCENARIO" variant="$PHASE2_VARIANT" server_port="${PHASE2_SERVER_PORT:-25566}" item_count="${PHASE2_ITEM_COUNT:-4096}" +dropped_nearby_item_count="${PHASE2_DROPPED_NEARBY_ITEM_COUNT:-128}" warmup_seconds="${PHASE2_WARMUP_SECONDS:-120}" settle_seconds="${PHASE2_SETTLE_SECONDS:-20}" measure_seconds="${PHASE2_MEASURE_SECONDS:-180}" @@ -98,7 +99,8 @@ if [[ "$ab_factor" == legacy-text-component-cache && "$scenario" != block-active echo "legacy-text-component-cache A/B is isolated to block-active" >&2 exit 64 fi -for value in "$server_port" "$item_count" "$warmup_seconds" "$settle_seconds" \ +for value in "$server_port" "$item_count" "$dropped_nearby_item_count" \ + "$warmup_seconds" "$settle_seconds" \ "$measure_seconds" "$capture_snaplen" "$protocol_trace_max_events"; do [[ "$value" =~ ^[0-9]+$ ]] || { echo "Numeric input is invalid: $value" >&2; exit 64; } done @@ -110,6 +112,11 @@ if [[ "$scenario" == block-* ]]; then fi (( item_count >= 1 && item_count <= maximum_item_count )) \ || { echo "PHASE2_ITEM_COUNT is outside 1..$maximum_item_count for $scenario" >&2; exit 64; } +if [[ "$scenario" == dropped-items ]] && \ + (( dropped_nearby_item_count < 1 || dropped_nearby_item_count > item_count )); then + echo "PHASE2_DROPPED_NEARBY_ITEM_COUNT is outside 1..$item_count" >&2 + exit 64 +fi if [[ "$ab_factor" == legacy-text-component-cache ]] && (( item_count < 100 )); then echo "legacy-text-component-cache A/B requires at least 100 workload blocks" >&2 exit 64 @@ -788,7 +795,11 @@ else esac scene_spawn_log="Spawned $item_count $scene_type benchmark $scene_entity_label" - send_console "iv perf scene $scene_type $item_count $lifetime_ticks IVBench" + if [[ "$scenario" == dropped-items ]]; then + send_console "iv perf scene $scene_type $item_count $lifetime_ticks IVBench $dropped_nearby_item_count" + else + send_console "iv perf scene $scene_type $item_count $lifetime_ticks IVBench" + fi wait_for_log "$scene_spawn_log" 60 fi sleep "$warmup_seconds" @@ -990,7 +1001,7 @@ PY fi python3 - "$server_log" "$run_id" "$run_directory/iv-perf.json" "$scenario" "$variant" \ - "$item_count" "$ab_factor" "$legacy_text_cache_disable_property" \ + "$item_count" "$dropped_nearby_item_count" "$ab_factor" "$legacy_text_cache_disable_property" \ "$legacy_text_cache_enabled" "$jvm_arguments_sha256" \ "$jvm_arguments_normalized_sha256" <<'PY' import json @@ -1003,6 +1014,7 @@ import sys scenario, variant, item_count_text, + dropped_nearby_item_count_text, ab_factor, disable_property_text, expected_cache_enabled_text, @@ -1010,6 +1022,7 @@ import sys jvm_arguments_normalized_sha256, ) = sys.argv[1:] item_count = int(item_count_text) +dropped_nearby_item_count = int(dropped_nearby_item_count_text) disable_property = disable_property_text == "true" expected_cache_enabled = expected_cache_enabled_text == "true" matches = [] @@ -1160,10 +1173,11 @@ if scenario == "dropped-items": raise SystemExit(f"invalid {field}={value!r}") if distance_checks <= 0 or spatial_candidates <= 0: raise SystemExit("dropped-item workload performed no candidate checks") - if tracked_max != item_count or labels_max != item_count: + if tracked_max != item_count or labels_max != dropped_nearby_item_count: raise SystemExit( - "dropped-item workload did not retain every requested label: " - f"tracked={tracked_max} labels={labels_max} expected={item_count}" + "dropped-item workload did not retain its global/local split: " + f"tracked={tracked_max}/{item_count} " + f"labels={labels_max}/{dropped_nearby_item_count}" ) if variant == "A" and full_scan_candidates <= 0: raise SystemExit("legacy dropped-item path performed no full candidate scans") @@ -1745,9 +1759,14 @@ print(json.dumps({ PY )" +dropped_nearby_manifest=null +if [[ "$scenario" == dropped-items ]]; then + dropped_nearby_manifest="$dropped_nearby_item_count" +fi + cat > "$run_directory/run-manifest.json" < "$run_directory/run-manifest.json" < Date: Sun, 19 Jul 2026 14:51:50 +0800 Subject: [PATCH 07/10] Compile dropped-item label templates --- .../entities/DroppedItemDisplay.java | 71 +++---- .../entities/DroppedItemLabelFormatter.java | 146 +++++++++++++++ .../DroppedItemLabelFormatterTest.java | 174 ++++++++++++++++++ 3 files changed, 344 insertions(+), 47 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemLabelFormatter.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemLabelFormatterTest.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 027c8aee..f3b264c8 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -22,12 +22,10 @@ import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.utils.ItemNameUtils; -import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; import com.loohp.interactionvisualizer.scheduler.ScheduledRunnable; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.TextReplacementConfig; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Bukkit; @@ -93,12 +91,10 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private final DroppedItemSpatialIndex crampIndex = new DroppedItemSpatialIndex(); private final NamespacedKey visualEntityKey; - private String regularFormatting; - private String singularFormatting; - private String toolsFormatting; private String highColor = ""; private String mediumColor = ""; private String lowColor = ""; + private DroppedItemLabelFormatter labelFormatter; private int cramp = 6; private double labelYOffset = 0.8D; private int updateRate = 20; @@ -119,12 +115,15 @@ public DroppedItemDisplay() { @EventHandler public void onReload(InteractionVisualizerReloadEvent event) { DroppedItemVisibilityPolicy previousVisibilityPolicy = visibilityPolicy; - regularFormatting = configString("Entities.Item.Options.RegularFormat"); - singularFormatting = configString("Entities.Item.Options.SingularFormat"); - toolsFormatting = configString("Entities.Item.Options.ToolsFormat"); + String regularFormatting = configString("Entities.Item.Options.RegularFormat"); + String singularFormatting = configString("Entities.Item.Options.SingularFormat"); + String toolsFormatting = configString("Entities.Item.Options.ToolsFormat"); highColor = configString("Entities.Item.Options.Color.High"); mediumColor = configString("Entities.Item.Options.Color.Medium"); lowColor = configString("Entities.Item.Options.Color.Low"); + labelFormatter = new DroppedItemLabelFormatter( + regularFormatting, singularFormatting, toolsFormatting, + highColor, mediumColor, lowColor); cramp = InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.Cramping"); double configuredLabelYOffset = InteractionVisualizer.plugin.getConfiguration() .getDouble("Entities.Item.Options.LabelYOffset"); @@ -577,7 +576,7 @@ private void update(UUID itemId, Item item, DroppedItemSpatialIndex itemIndex) { return; } - Component text = format(content, ticksLeft); + Component text = labelFormatter.format(content.formatState, ticksLeft); boolean created = false; if (label == null || !label.isValid() || !label.getWorld().equals(item.getWorld())) { removeLabel(itemId); @@ -585,8 +584,14 @@ private void update(UUID itemId, Item item, DroppedItemSpatialIndex itemIndex) { labels.put(itemId, label); created = true; } - if (!text.equals(label.text())) { + // The formatter returns the same immutable component instance while + // the rendered state is unchanged. Avoid a CraftTextDisplay read on + // every refresh; it serializes the NMS component back through Paper. + int labelId = label.getEntityId(); + if (content.appliedLabelId != labelId || content.appliedText != text) { label.text(text); + content.appliedLabelId = labelId; + content.appliedText = text; } float targetViewRange = labelViewRange(); if (Math.abs(label.getViewRange() - targetViewRange) > 1.0E-4F) { @@ -912,42 +917,16 @@ private CachedItemContent cachedContent(UUID itemId, ItemStack stack) { NamespacedKey customItemId = blacklist.requiresCustomItemId() ? CustomContentManager.customItemId(stack).orElse(null) : null; + String durability = durability(stack); + Component itemName = ItemNameUtils.getDisplayName(stack); CachedItemContent replacement = new CachedItemContent( stack.clone(), blacklist.matches(matchingName, stack.getType(), customItemId), - durability(stack), - ItemNameUtils.getDisplayName(stack)); + labelFormatter.state(stack.getAmount(), durability, itemName)); contentCache.put(itemId, replacement); return replacement; } - private Component format(CachedItemContent content, int ticksLeft) { - int secondsLeft = Math.max(0, ticksLeft / 20); - if (content.lastSeconds == secondsLeft && content.lastFormatted != null) { - return content.lastFormatted; - } - ItemStack stack = content.stack; - int amount = stack.getAmount(); - String timerColor = secondsLeft <= 30 ? lowColor : secondsLeft <= 120 ? mediumColor : highColor; - String timer = timerColor + String.format(java.util.Locale.ROOT, "%02d:%02d", secondsLeft / 60, secondsLeft % 60); - - String template; - if (ticksLeft >= 600 && content.durability != null) { - template = toolsFormatting.replace("{Durability}", content.durability); - } else { - template = amount == 1 ? singularFormatting : regularFormatting; - } - String rendered = template.replace("{Amount}", Integer.toString(amount)).replace("{Timer}", timer); - Component component = LegacyTextComponentCache.parse(rendered); - Component formatted = component.replaceText(TextReplacementConfig.builder() - .matchLiteral("{Item}") - .replacement(content.itemName) - .build()); - content.lastSeconds = secondsLeft; - content.lastFormatted = formatted; - return formatted; - } - private String durability(ItemStack stack) { if (stack.getType().getMaxDurability() <= 0 || !(stack.getItemMeta() instanceof Damageable damageable)) { return null; @@ -1055,17 +1034,15 @@ private static final class CachedItemContent { private final ItemStack stack; private final boolean blacklisted; - private final String durability; - private final Component itemName; - private int lastSeconds = Integer.MIN_VALUE; - private Component lastFormatted; + private final DroppedItemLabelFormatter.State formatState; + private int appliedLabelId = Integer.MIN_VALUE; + private Component appliedText; - private CachedItemContent(ItemStack stack, boolean blacklisted, String durability, - Component itemName) { + private CachedItemContent(ItemStack stack, boolean blacklisted, + DroppedItemLabelFormatter.State formatState) { this.stack = stack; this.blacklisted = blacklisted; - this.durability = durability; - this.itemName = itemName; + this.formatState = formatState; } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemLabelFormatter.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemLabelFormatter.java new file mode 100644 index 00000000..87ba5a8b --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemLabelFormatter.java @@ -0,0 +1,146 @@ +/* + * 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 com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextReplacementConfig; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Reload-scoped compiler for dropped-item label templates. */ +final class DroppedItemLabelFormatter { + + private static final String TIMER_TOKEN = "{IV_INTERNAL_TIMER_6D42D50B}"; + private static final int MAX_SHARED_TEMPLATES = 512; + private static final int REGULAR_VARIANT = 0; + private static final int SINGULAR_VARIANT = 1; + private static final int TOOLS_VARIANT = 2; + + private final String regularTemplate; + private final String singularTemplate; + private final String toolsTemplate; + private final String highColor; + private final String mediumColor; + private final String lowColor; + private final Map sharedTemplates = new LinkedHashMap<>(64, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_SHARED_TEMPLATES; + } + }; + + DroppedItemLabelFormatter(String regularTemplate, String singularTemplate, + String toolsTemplate, String highColor, + String mediumColor, String lowColor) { + this.regularTemplate = Objects.requireNonNull(regularTemplate, "regularTemplate"); + this.singularTemplate = Objects.requireNonNull(singularTemplate, "singularTemplate"); + this.toolsTemplate = Objects.requireNonNull(toolsTemplate, "toolsTemplate"); + this.highColor = Objects.requireNonNull(highColor, "highColor"); + this.mediumColor = Objects.requireNonNull(mediumColor, "mediumColor"); + this.lowColor = Objects.requireNonNull(lowColor, "lowColor"); + } + + State state(int amount, String durability, Component itemName) { + return new State(amount, durability, Objects.requireNonNull(itemName, "itemName")); + } + + int sharedTemplateCount() { + return sharedTemplates.size(); + } + + Component format(State state, int ticksLeft) { + int secondsLeft = Math.max(0, ticksLeft / 20); + int variant = ticksLeft >= 600 && state.durability != null + ? TOOLS_VARIANT : state.amount == 1 ? SINGULAR_VARIANT : REGULAR_VARIANT; + String template = switch (variant) { + case TOOLS_VARIANT -> toolsTemplate; + case SINGULAR_VARIANT -> singularTemplate; + default -> regularTemplate; + }; + boolean hasTimer = template.contains("{Timer}"); + String timerColor = hasTimer ? timerColor(secondsLeft) : null; + if (state.compiled == null || state.compiledVariant != variant + || !Objects.equals(state.compiledTimerColor, timerColor)) { + compile(state, variant, template, timerColor, hasTimer); + } + if (!state.compiledHasTimer) { + return state.compiled; + } + if (state.lastSeconds == secondsLeft && state.lastFormatted != null) { + return state.lastFormatted; + } + String timerText = timerText(secondsLeft); + Component formatted = state.compiled.replaceText(TextReplacementConfig.builder() + .matchLiteral(TIMER_TOKEN) + .replacement(builder -> builder.content(timerText)) + .build()).compact(); + state.lastSeconds = secondsLeft; + state.lastFormatted = formatted; + return formatted; + } + + private void compile(State state, int variant, String template, + String timerColor, boolean hasTimer) { + String raw = template; + if (variant == TOOLS_VARIANT) { + raw = raw.replace("{Durability}", state.durability); + } + raw = raw.replace("{Amount}", Integer.toString(state.amount)); + if (hasTimer) { + raw = raw.replace("{Timer}", timerColor + TIMER_TOKEN); + } + Component parsed = sharedTemplates.computeIfAbsent(raw, LegacyTextComponentCache::parse); + state.compiled = parsed.replaceText(TextReplacementConfig.builder() + .matchLiteral("{Item}") + .replacement(state.itemName) + .build()); + state.compiledVariant = variant; + state.compiledTimerColor = timerColor; + state.compiledHasTimer = hasTimer; + state.lastSeconds = Integer.MIN_VALUE; + state.lastFormatted = null; + } + + private String timerColor(int secondsLeft) { + return secondsLeft <= 30 ? lowColor : secondsLeft <= 120 ? mediumColor : highColor; + } + + static String timerText(int secondsLeft) { + int minutes = secondsLeft / 60; + int seconds = secondsLeft % 60; + String minuteText = minutes < 10 ? "0" + minutes : Integer.toString(minutes); + String secondText = seconds < 10 ? "0" + seconds : Integer.toString(seconds); + return minuteText + ":" + secondText; + } + + static final class State { + + private final int amount; + private final String durability; + private final Component itemName; + private int compiledVariant = Integer.MIN_VALUE; + private String compiledTimerColor; + private boolean compiledHasTimer; + private Component compiled; + private int lastSeconds = Integer.MIN_VALUE; + private Component lastFormatted; + + private State(int amount, String durability, Component itemName) { + this.amount = amount; + this.durability = durability; + this.itemName = itemName; + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemLabelFormatterTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemLabelFormatterTest.java new file mode 100644 index 00000000..635bb063 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemLabelFormatterTest.java @@ -0,0 +1,174 @@ +/* + * 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 com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextReplacementConfig; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; + +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +@ResourceLock("legacy-text-component-cache") +class DroppedItemLabelFormatterTest { + + private static final String REGULAR = "\u00A7f{Item} \u00A7bx{Amount} \u00A76[{Timer}\u00A76]"; + private static final String SINGULAR = "\u00A7f{Item} \u00A76[{Timer}\u00A76]"; + private static final String TOOLS = "\u00A7f{Item} \u00A76[{Durability}\u00A76]"; + private static final String HIGH = "\u00A7a"; + private static final String MEDIUM = "\u00A7e"; + private static final String LOW = "\u00A7c"; + + @BeforeEach + void setUp() { + LegacyTextComponentCache.stopMeasurement(); + LegacyTextComponentCache.invalidateAll(); + } + + @AfterEach + void tearDown() { + LegacyTextComponentCache.stopMeasurement(); + LegacyTextComponentCache.invalidateAll(); + } + + @Test + void compiledTemplatesMatchLegacyRendering() { + Component itemName = Component.text("Benchmark Pick") + .color(NamedTextColor.AQUA) + .decorate(TextDecoration.BOLD); + DroppedItemLabelFormatter formatter = formatter(REGULAR, SINGULAR, TOOLS); + + assertEquivalent(formatter, itemName, 64, null, 12_000, REGULAR); + assertEquivalent(formatter, itemName, 1, null, 2_400, SINGULAR); + assertEquivalent(formatter, itemName, 1, "\u00A7a1400/1561", 12_000, TOOLS); + assertEquivalent(formatter, itemName, 1, "\u00A7c5/1561", 599, SINGULAR); + } + + @Test + void preservesTimerColorForFollowingLiteralTextAndMultipleTokens() { + String regular = "\u00A77{Item}:{Timer}tail/{Timer}/{Amount}"; + Component itemName = Component.text("Ruby", NamedTextColor.LIGHT_PURPLE); + DroppedItemLabelFormatter formatter = formatter(regular, regular, TOOLS); + + assertEquivalent(formatter, itemName, 32, null, 2_400, regular); + assertEquivalent(formatter, itemName, 32, null, 600, regular); + } + + @Test + void preservesFontTagsAroundDynamicTokens() { + String regular = "[font=minecraft:uniform]\u00A77{Item}:{Timer}" + + "[font=minecraft:default]/{Amount}"; + Component itemName = Component.text("Map"); + DroppedItemLabelFormatter formatter = formatter(regular, regular, TOOLS); + + assertEquivalent(formatter, itemName, 16, null, 2_400, regular); + } + + @Test + void reusesRenderedComponentUntilItsVisibleStateChanges() { + Component itemName = Component.text("Stone"); + DroppedItemLabelFormatter formatter = formatter(REGULAR, SINGULAR, TOOLS); + DroppedItemLabelFormatter.State timed = formatter.state(64, null, itemName); + + Component first = formatter.format(timed, 12_019); + assertSame(first, formatter.format(timed, 12_000)); + assertNotSame(first, formatter.format(timed, 11_999)); + + DroppedItemLabelFormatter.State tool = formatter.state( + 1, "\u00A7a1400/1561", itemName); + Component toolFirst = formatter.format(tool, 12_000); + assertSame(toolFirst, formatter.format(tool, 11_980)); + } + + @Test + void parsesOnlyUniqueReloadScopedTemplates() { + DroppedItemLabelFormatter formatter = formatter(REGULAR, SINGULAR, TOOLS); + LegacyTextComponentCache.startMeasurement(); + DroppedItemLabelFormatter.State[] states = new DroppedItemLabelFormatter.State[128]; + for (int index = 0; index < states.length; index++) { + int amount = index % 64 + 1; + states[index] = formatter.state(amount, null, Component.text("Item " + index)); + formatter.format(states[index], 12_000); + } + for (DroppedItemLabelFormatter.State state : states) { + formatter.format(state, 11_980); + } + + LegacyTextComponentCache.CacheMetrics metrics = LegacyTextComponentCache.stopMeasurement(); + assertEquals(64L, metrics.requests()); + assertEquals(64L, metrics.misses()); + } + + @Test + void reloadScopedTemplateCacheStaysBounded() { + DroppedItemLabelFormatter formatter = formatter(REGULAR, SINGULAR, TOOLS); + for (int amount = 2; amount < 1_026; amount++) { + formatter.format(formatter.state( + amount, null, Component.text("Item " + amount)), 12_000); + } + + assertEquals(512, formatter.sharedTemplateCount()); + } + + @Test + void timerTextKeepsLegacyTwoDigitMinimumWidth() { + assertEquals("00:00", DroppedItemLabelFormatter.timerText(0)); + assertEquals("09:05", DroppedItemLabelFormatter.timerText(545)); + assertEquals("100:03", DroppedItemLabelFormatter.timerText(6_003)); + } + + private static DroppedItemLabelFormatter formatter(String regular, String singular, + String tools) { + return new DroppedItemLabelFormatter( + regular, singular, tools, HIGH, MEDIUM, LOW); + } + + private static void assertEquivalent(DroppedItemLabelFormatter formatter, + Component itemName, int amount, + String durability, int ticksLeft, + String expectedTemplate) { + Component expected = legacyFormat( + expectedTemplate, itemName, amount, durability, ticksLeft); + Component actual = formatter.format( + formatter.state(amount, durability, itemName), ticksLeft); + assertEquals(expected.compact(), actual); + } + + private static Component legacyFormat(String template, Component itemName, + int amount, String durability, + int ticksLeft) { + int secondsLeft = Math.max(0, ticksLeft / 20); + String timerColor = secondsLeft <= 30 ? LOW : secondsLeft <= 120 ? MEDIUM : HIGH; + String timer = timerColor + String.format( + Locale.ROOT, "%02d:%02d", secondsLeft / 60, secondsLeft % 60); + String rendered = template; + if (ticksLeft >= 600 && durability != null) { + rendered = rendered.replace("{Durability}", durability); + } + rendered = rendered.replace("{Amount}", Integer.toString(amount)) + .replace("{Timer}", timer); + return LegacyTextComponentCache.parse(rendered).replaceText( + TextReplacementConfig.builder() + .matchLiteral("{Item}") + .replacement(itemName) + .build()); + } +} From 8d3431cb0561ccebb80b15f18fe472f3dede0787 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 20 Jul 2026 12:14:31 +0800 Subject: [PATCH 08/10] Harden dropped-item runtime validation Close shutdown and evidence-provenance gaps so formal runtime results and Paper compatibility checks are merge-ready. --- .github/workflows/build.yml | 98 +++ .github/workflows/phase2-runtime-ab.yml | 51 +- .../api/VisualizerRunnableDisplay.java | 64 +- .../entities/DroppedItemDisplay.java | 220 +++++- .../managers/PerformanceMetrics.java | 85 +- .../managers/TaskManager.java | 27 +- ...isualizerRunnableDisplayLifecycleTest.java | 175 +++++ .../DroppedItemCandidateWindowTest.java | 12 + .../PerformanceMetricsSlowestTickTest.java | 46 +- .../managers/TaskManagerShutdownTest.java | 38 +- tools/perf/evaluate-dropped-item-gate.py | 727 ++++++++++++++++-- tools/perf/run-paper-compatibility-smoke.sh | 361 +++++++++ tools/perf/run-phase2-runtime-once.sh | 111 ++- 13 files changed, 1876 insertions(+), 139 deletions(-) create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/api/VisualizerRunnableDisplayLifecycleTest.java create mode 100644 tools/perf/run-paper-compatibility-smoke.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41cedba6..4b6d4f31 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,7 +80,9 @@ jobs: run: | bash -n tools/perf/prepare-phase2-protocol-client.sh bash -n tools/perf/run-phase2-runtime-once.sh + bash -n tools/perf/run-paper-compatibility-smoke.sh bash tools/perf/run-phase2-runtime-once.sh --self-test + bash tools/perf/run-paper-compatibility-smoke.sh --self-test node --check tools/perf/phase2-protocol-client.js - name: Run checks and build production jar @@ -94,3 +96,99 @@ jobs: path: build/libs/InteractionVisualizer-*.jar if-no-files-found: error retention-days: 14 + + paper-26-2-compatibility: + name: Paper 26.2 compatibility smoke + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + PAPER_VERSION: "26.2" + PAPER_BUILD_ID: "62" + PAPER_CHANNEL: BETA + PAPER_SHA256: 597cb54eef27b318dfb7daef924f9bbe2816e6b0547f0f861b15b42337b6ccd5 + PAPER_USER_AGENT: InteractionVisualizer-CI/1.0 (https://github.com/EllanServer/InteractionVisualizer) + + steps: + - name: Check out compatibility harness + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Java 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + + - name: Download current production jar + uses: actions/download-artifact@v4 + with: + name: InteractionVisualizer-${{ github.sha }} + path: compatibility-dependencies/plugin + + - name: Select production jar + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + candidates=() + for candidate in compatibility-dependencies/plugin/InteractionVisualizer-*.jar; do + case "$candidate" in + *-sources.jar|*-benchmark.jar|*-runtime-compare.jar) continue ;; + esac + candidates+=("$candidate") + done + if (( ${#candidates[@]} != 1 )); then + printf 'Expected exactly one production jar, found %d\n' "${#candidates[@]}" >&2 + printf '%s\n' "${candidates[@]}" >&2 + exit 1 + fi + echo "PAPER_COMPAT_PLUGIN_JAR=${candidates[0]}" >> "$GITHUB_ENV" + + - name: Download pinned Paper 26.2 build 62 + shell: bash + run: | + set -euo pipefail + mkdir -p compatibility-dependencies/paper + curl --fail --silent --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + --output compatibility-dependencies/paper/builds.json \ + "https://fill.papermc.io/v3/projects/paper/versions/$PAPER_VERSION/builds" + PAPER_RECORD=$(jq -ce --argjson build_id "$PAPER_BUILD_ID" \ + 'first(.[] | select(.id == $build_id)) // error("pinned Paper build is missing")' \ + compatibility-dependencies/paper/builds.json) + [[ "$(jq -r '.id' <<< "$PAPER_RECORD")" == "$PAPER_BUILD_ID" ]] + [[ "$(jq -r '.channel' <<< "$PAPER_RECORD")" == "$PAPER_CHANNEL" ]] + [[ "$(jq -r '.downloads."server:default".checksums.sha256' <<< "$PAPER_RECORD")" == "$PAPER_SHA256" ]] + [[ "$(jq -r '.downloads."server:default".name' <<< "$PAPER_RECORD")" == "paper-$PAPER_VERSION-$PAPER_BUILD_ID.jar" ]] + PAPER_URL=$(jq -er '.downloads."server:default".url' <<< "$PAPER_RECORD") + PAPER_COMMIT_SHA=$(jq -er '.commits[0].sha' <<< "$PAPER_RECORD") + [[ "$PAPER_COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]] + curl --fail --location --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + --output compatibility-dependencies/paper/paper.jar \ + "$PAPER_URL" + echo "$PAPER_SHA256 compatibility-dependencies/paper/paper.jar" | sha256sum --check + printf '%s\n' "$PAPER_RECORD" > compatibility-dependencies/paper/build-62.json + echo "PAPER_COMPAT_PAPER_COMMIT_SHA=$PAPER_COMMIT_SHA" >> "$GITHUB_ENV" + + - name: Run real Paper 26.2 compatibility smoke + shell: bash + env: + PAPER_COMPAT_PAPER_JAR: compatibility-dependencies/paper/paper.jar + PAPER_COMPAT_OUTPUT_ROOT: compatibility-results + PAPER_COMPAT_SOURCE_SHA: ${{ github.sha }} + run: bash tools/perf/run-paper-compatibility-smoke.sh + + - name: Upload Paper 26.2 compatibility evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: paper-26.2-build-62-compatibility-${{ github.sha }}-${{ github.run_id }} + path: | + compatibility-results/server.log + compatibility-results/compatibility-manifest.json + compatibility-dependencies/paper/build-62.json + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/phase2-runtime-ab.yml b/.github/workflows/phase2-runtime-ab.yml index 4bfd6f75..2b7c98ef 100644 --- a/.github/workflows/phase2-runtime-ab.yml +++ b/.github/workflows/phase2-runtime-ab.yml @@ -55,24 +55,27 @@ permissions: jobs: clean-runtime-ab: - name: Paper runtime ABBA + name: >- + ${{ github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' && 'Dropped-item formal runtime gate' || github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-formal' && 'Paper runtime formal ABBA' || github.event_name == 'workflow_dispatch' && inputs.spark_profile_mode == 'alloc' && 'Allocation diagnostic runtime ABBA' || github.event_name == 'workflow_dispatch' && inputs.spark_profile_mode != 'none' && 'Profile diagnostic runtime ABBA' || github.event_name == 'workflow_dispatch' && inputs.ab_factor == 'dropped-item-section-candidates' && 'Dropped-item formal runtime gate' || 'Paper runtime ABBA smoke' }} if: >- github.event_name == 'workflow_dispatch' || github.event.action != 'labeled' || - github.event.label.name == 'phase2-runtime-formal' + github.event.label.name == 'phase2-runtime-formal' || + github.event.label.name == 'phase2-runtime-dropped-item-formal' runs-on: ubuntu-latest timeout-minutes: 130 env: - CAMPAIGN_AB_FACTOR: ${{ github.event_name == 'workflow_dispatch' && inputs.ab_factor || 'scenario-config' }} + CAMPAIGN_AB_FACTOR: ${{ github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' && 'dropped-item-section-candidates' || github.event_name == 'workflow_dispatch' && inputs.ab_factor || 'scenario-config' }} CAMPAIGN_PAPER_VERSION: "26.1.2" - CAMPAIGN_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || 'static-steady' }} - CAMPAIGN_RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || github.event.action == 'labeled' && '12' || '4' }} - CAMPAIGN_ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items || github.event.action == 'labeled' && '4096' || '1024' }} + CAMPAIGN_PAPER_BUILD_ID: "74" + CAMPAIGN_SCENARIO: ${{ (github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' || github.event_name == 'workflow_dispatch' && inputs.ab_factor == 'dropped-item-section-candidates') && 'dropped-items' || github.event_name == 'workflow_dispatch' && inputs.scenario || 'static-steady' }} + CAMPAIGN_RUNS: ${{ (github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' || github.event_name == 'workflow_dispatch' && inputs.ab_factor == 'dropped-item-section-candidates' && inputs.spark_profile_mode == 'none') && '12' || github.event_name == 'workflow_dispatch' && inputs.ab_factor == 'dropped-item-section-candidates' && '4' || github.event_name == 'workflow_dispatch' && inputs.runs || github.event.action == 'labeled' && '12' || '4' }} + CAMPAIGN_ITEMS: ${{ (github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' || github.event_name == 'workflow_dispatch' && inputs.ab_factor == 'dropped-item-section-candidates') && '2048' || github.event_name == 'workflow_dispatch' && inputs.items || github.event.action == 'labeled' && '4096' || '1024' }} CAMPAIGN_DROPPED_NEARBY_ITEMS: "128" - CAMPAIGN_WARMUP_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.warmup_seconds || github.event.action == 'labeled' && '120' || '10' }} - CAMPAIGN_SETTLE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.settle_seconds || github.event.action == 'labeled' && '20' || '5' }} - CAMPAIGN_MEASURE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.measure_seconds || github.event.action == 'labeled' && '180' || '10' }} - CAMPAIGN_SPARK_PROFILE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.spark_profile_mode || 'none' }} + CAMPAIGN_WARMUP_SECONDS: ${{ (github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' || github.event_name == 'workflow_dispatch' && inputs.ab_factor == 'dropped-item-section-candidates' && inputs.spark_profile_mode == 'none') && '120' || github.event_name == 'workflow_dispatch' && inputs.warmup_seconds || github.event.action == 'labeled' && '120' || '10' }} + CAMPAIGN_SETTLE_SECONDS: ${{ (github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' || github.event_name == 'workflow_dispatch' && inputs.ab_factor == 'dropped-item-section-candidates' && inputs.spark_profile_mode == 'none') && '20' || github.event_name == 'workflow_dispatch' && inputs.settle_seconds || github.event.action == 'labeled' && '20' || '5' }} + CAMPAIGN_MEASURE_SECONDS: ${{ (github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' || github.event_name == 'workflow_dispatch' && inputs.ab_factor == 'dropped-item-section-candidates' && inputs.spark_profile_mode == 'none') && '180' || github.event_name == 'workflow_dispatch' && inputs.measure_seconds || github.event.action == 'labeled' && '180' || '10' }} + CAMPAIGN_SPARK_PROFILE_MODE: ${{ github.event.action == 'labeled' && github.event.label.name == 'phase2-runtime-dropped-item-formal' && 'none' || github.event_name == 'workflow_dispatch' && inputs.spark_profile_mode || 'none' }} CAMPAIGN_EVIDENCE_KIND: ${{ github.event_name == 'workflow_dispatch' && inputs.spark_profile_mode != 'none' && 'diagnostic' || 'clean' }} steps: @@ -112,28 +115,30 @@ jobs: env: PAPER_USER_AGENT: InteractionVisualizer-Phase2/1.0 (https://github.com/EllanServer/InteractionVisualizer) PAPER_VERSION: ${{ env.CAMPAIGN_PAPER_VERSION }} + PINNED_PAPER_BUILD_ID: ${{ env.CAMPAIGN_PAPER_BUILD_ID }} run: | mkdir -p phase2-dependencies case "$PAPER_VERSION" in 26.1.2) PAPER_CHANNEL=STABLE ;; - 26.2) PAPER_CHANNEL=BETA ;; *) echo "Unsupported Paper version: $PAPER_VERSION" >&2; exit 64 ;; esac BUILDS=$(curl --fail --silent --show-error \ -H "User-Agent: $PAPER_USER_AGENT" \ "https://fill.papermc.io/v3/projects/paper/versions/$PAPER_VERSION/builds") - PAPER_RECORD=$(echo "$BUILDS" | jq -c --arg channel "$PAPER_CHANNEL" \ - 'first(.[] | select(.channel == $channel)) // empty') + PAPER_RECORD=$(echo "$BUILDS" | jq -c \ + --arg channel "$PAPER_CHANNEL" --argjson build_id "$PINNED_PAPER_BUILD_ID" \ + 'first(.[] | select(.channel == $channel and .id == $build_id)) // empty') test -n "$PAPER_RECORD" PAPER_URL=$(echo "$PAPER_RECORD" | jq -r '.downloads."server:default".url') PAPER_SHA256=$(echo "$PAPER_RECORD" | jq -r '.downloads."server:default".checksums.sha256') - PAPER_BUILD_ID=$(echo "$PAPER_RECORD" | jq -r '.id') + SELECTED_PAPER_BUILD_ID=$(echo "$PAPER_RECORD" | jq -r '.id') + [[ "$SELECTED_PAPER_BUILD_ID" == "$PINNED_PAPER_BUILD_ID" ]] curl --fail --location --show-error \ -H "User-Agent: $PAPER_USER_AGENT" \ --output phase2-dependencies/paper.jar "$PAPER_URL" echo "$PAPER_SHA256 phase2-dependencies/paper.jar" | sha256sum --check echo "SELECTED_PAPER_CHANNEL=$PAPER_CHANNEL" >> "$GITHUB_ENV" - echo "SELECTED_PAPER_BUILD_ID=$PAPER_BUILD_ID" >> "$GITHUB_ENV" + echo "SELECTED_PAPER_BUILD_ID=$SELECTED_PAPER_BUILD_ID" >> "$GITHUB_ENV" - name: Prepare immutable protocol client artifact run: bash tools/perf/prepare-phase2-protocol-client.sh phase2-dependencies/protocol-client @@ -199,11 +204,17 @@ jobs: echo "dropped-item-section-candidates A/B is isolated to dropped-items" >&2 exit 64 fi - if [[ "$SPARK_PROFILE_MODE" == none && "$RUNS" != 12 ]]; then - echo "clean dropped-item-section-candidates evidence is formal-only and requires runs=12" >&2 - exit 64 - fi - if [[ "$SPARK_PROFILE_MODE" != none && "$RUNS" != 4 ]]; then + if [[ "$SPARK_PROFILE_MODE" == none ]]; then + if [[ "$RUNS" != 12 ]]; then + echo "clean dropped-item-section-candidates evidence is formal-only and requires runs=12" >&2 + exit 64 + fi + if [[ "$WARMUP_SECONDS" != 120 || "$SETTLE_SECONDS" != 20 || \ + "$MEASURE_SECONDS" != 180 ]]; then + echo "clean dropped-item-section-candidates evidence requires canonical 120/20/180 windows" >&2 + exit 64 + fi + elif [[ "$RUNS" != 4 ]]; then echo "profiled dropped-item-section-candidates diagnostics require runs=4" >&2 exit 64 fi diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/VisualizerRunnableDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/api/VisualizerRunnableDisplay.java index 5f4bf864..d99b4b55 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/api/VisualizerRunnableDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/VisualizerRunnableDisplay.java @@ -35,7 +35,8 @@ public abstract class VisualizerRunnableDisplay implements VisualizerDisplay { /** * DO NOT CHANGE THESE FIELD */ - private Set tasks; + private Set tasks = new HashSet<>(); + private boolean unregistered; /** * This method is used for cleaning up, return the ScheduledTask, return null to disable. @@ -56,6 +57,7 @@ public final void register() { } InteractionVisualizerAPI.getPreferenceManager().registerEntry(key()); TaskManager.runnables.add(this); + this.unregistered = false; this.tasks = new HashSet<>(); ScheduledTask gc = gc(); if (gc != null) { @@ -70,6 +72,7 @@ public final void register() { @Deprecated public final EntryKey registerNative() { TaskManager.runnables.add(this); + this.unregistered = false; this.tasks = new HashSet<>(); ScheduledTask gc = gc(); if (gc != null) { @@ -82,14 +85,67 @@ public final EntryKey registerNative() { return key(); } + /** + * Called once while this display is being unregistered, after the tasks + * returned by {@link #gc()} and {@link #run()} have been cancelled. + */ + protected void onUnregister() { + } + /** * Unregister this custom display to InteractionVisualizer. * You don't have to use this normally. */ @Deprecated - public final void unregister() { - TaskManager.runnables.remove(this); - this.tasks.forEach(each -> each.cancel()); + public final synchronized void unregister() { + if (unregistered) { + return; + } + unregistered = true; + + Throwable failure = null; + Set registeredTasks = tasks; + tasks = new HashSet<>(); + for (ScheduledTask task : registeredTasks) { + try { + task.cancel(); + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + } + try { + onUnregister(); + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + try { + TaskManager.runnables.removeIf(each -> each == this); + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + rethrow(failure); + } + + private static Throwable appendFailure(Throwable current, Throwable addition) { + if (current == null) { + return addition; + } + if (current != addition) { + current.addSuppressed(addition); + } + return current; + } + + private static void rethrow(Throwable failure) { + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof RuntimeException exception) { + throw exception; + } + if (failure != null) { + throw new RuntimeException(failure); + } } } 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 f3b264c8..7ace5390 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -41,9 +41,11 @@ import org.bukkit.entity.TextDisplay; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityRemoveEvent; import org.bukkit.event.entity.ItemSpawnEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.world.EntitiesLoadEvent; import org.bukkit.event.world.EntitiesUnloadEvent; import org.bukkit.inventory.ItemStack; @@ -80,6 +82,7 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private final Map trackedItems = new HashMap<>(); private final Map labels = new HashMap<>(); + private final Map pendingRemovalLabels = new HashMap<>(); private final Map contentCache = new HashMap<>(); private final Set eligibleViewers = new HashSet<>(); private final Map desiredViewers = new HashMap<>(); @@ -106,6 +109,7 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private ScheduledTask updateTask; private ScheduledTask visibilityDrainTask; private Runnable viewerGroupUnsubscribe; + private volatile boolean closed; public DroppedItemDisplay() { visualEntityKey = new NamespacedKey(InteractionVisualizer.plugin, "visual_entity"); @@ -114,6 +118,9 @@ public DroppedItemDisplay() { @EventHandler public void onReload(InteractionVisualizerReloadEvent event) { + if (closed) { + return; + } DroppedItemVisibilityPolicy previousVisibilityPolicy = visibilityPolicy; String regularFormatting = configString("Entities.Item.Options.RegularFormat"); String singularFormatting = configString("Entities.Item.Options.SingularFormat"); @@ -190,6 +197,9 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (closed) { + return null; + } for (World world : Bukkit.getWorlds()) { for (Item item : world.getEntitiesByClass(Item.class)) { if (!isOwned(item)) { @@ -203,6 +213,9 @@ public ScheduledTask run() { @EventHandler(ignoreCancelled = true) public void onItemSpawn(ItemSpawnEvent event) { + if (closed) { + return; + } Item item = event.getEntity(); if (!isOwned(item)) { track(item); @@ -212,6 +225,9 @@ public void onItemSpawn(ItemSpawnEvent event) { @EventHandler public void onEntitiesLoad(EntitiesLoadEvent event) { + if (closed) { + return; + } boolean added = false; for (Entity entity : event.getEntities()) { if (entity instanceof Item item && !isOwned(item)) { @@ -226,6 +242,9 @@ public void onEntitiesLoad(EntitiesLoadEvent event) { @EventHandler public void onEntitiesUnload(EntitiesUnloadEvent event) { + if (closed) { + return; + } for (Entity entity : event.getEntities()) { if (entity instanceof Item item) { remove(item.getUniqueId()); @@ -235,6 +254,9 @@ public void onEntitiesUnload(EntitiesUnloadEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onEntityRemove(EntityRemoveEvent event) { + if (closed) { + return; + } if (event.getEntity() instanceof Item item) { UUID itemId = item.getUniqueId(); trackedItems.remove(itemId); @@ -243,20 +265,36 @@ public void onEntityRemove(EntityRemoveEvent event) { if (label != null) { // EntityRemoveEvent is monitoring-only. Defer entity mutation // until Paper has finished removing the item's passengers. + pendingRemovalLabels.put(itemId, label); Scheduler.runTask(InteractionVisualizer.plugin, () -> { - forgetLabelVisibility(itemId, label); - removeLabel(label); + if (pendingRemovalLabels.remove(itemId, label)) { + forgetLabelVisibility(itemId, label); + removeLabel(label); + } }); } } } + @EventHandler + public void onPlayerChangedWorld(PlayerChangedWorldEvent event) { + if (closed || trackedItems.isEmpty()) { + return; + } + if (InteractionVisualizerAPI.getPlayerModuleList(Modules.HOLOGRAM, KEY, false) + .contains(event.getPlayer())) { + wakeUpdateImmediately(); + } + } + private void track(Item item) { - trackedItems.put(item.getUniqueId(), item); + if (!closed) { + trackedItems.put(item.getUniqueId(), item); + } } private void scheduleUpdate(long delay) { - if (updateTask == null || updateTask.isCancelled()) { + if (!closed && (updateTask == null || updateTask.isCancelled())) { updateTask = Scheduler.runTaskLater( InteractionVisualizer.plugin, this::runScheduledUpdate, delay); } @@ -268,29 +306,70 @@ private void wakeUpdate() { scheduleUpdate(1L); } + private void wakeUpdateImmediately() { + if (closed) { + return; + } + if (updateTask != null && !updateTask.isCancelled()) { + updateTask.cancel(); + } + updateTask = null; + scheduleUpdate(1L); + } + private void runScheduledUpdate() { updateTask = null; + if (closed) { + return; + } ensureViewerGroupListener(); tickAll(); - if (!trackedItems.isEmpty() && (viewerGroupUnsubscribe == null - || !desiredViewers.isEmpty() || !pendingVisibilityViewers.isEmpty() - || !labels.isEmpty())) { + boolean hasActiveViewerState = !desiredViewers.isEmpty() + || !pendingVisibilityViewers.isEmpty() || !labels.isEmpty(); + boolean hasOnlineViewerGroupMember = !hasActiveViewerState + && hasOnlineViewerGroupMember(); + if (shouldScheduleUpdate(!trackedItems.isEmpty(), viewerGroupUnsubscribe == null, + hasActiveViewerState, hasOnlineViewerGroupMember)) { scheduleUpdate(updateRate); } } + static boolean shouldScheduleUpdate(boolean hasTrackedItems, + boolean viewerGroupListenerMissing, + boolean hasActiveViewerState, + boolean hasOnlineViewerGroupMember) { + return hasTrackedItems && (viewerGroupListenerMissing + || hasActiveViewerState || hasOnlineViewerGroupMember); + } + + private boolean hasOnlineViewerGroupMember() { + for (Player player : InteractionVisualizerAPI.getPlayerModuleList( + Modules.HOLOGRAM, KEY, false)) { + if (player.isOnline()) { + return true; + } + } + return false; + } + private void ensureViewerGroupListener() { - if (viewerGroupUnsubscribe != null || InteractionVisualizer.preferenceManager == null) { + if (closed || viewerGroupUnsubscribe != null + || InteractionVisualizer.preferenceManager == null) { return; } viewerGroupUnsubscribe = InteractionVisualizer.preferenceManager .addViewerGroupChangeListener(Modules.HOLOGRAM, KEY, - () -> Scheduler.executeOrScheduleSync( - InteractionVisualizer.plugin, this::wakeUpdate)); + this::onViewerGroupChanged); + } + + private synchronized void onViewerGroupChanged() { + if (!closed) { + Scheduler.executeOrScheduleSync(InteractionVisualizer.plugin, this::wakeUpdate); + } } private void scheduleVisibilityDrain() { - if (pendingVisibilityViewers.isEmpty() + if (closed || pendingVisibilityViewers.isEmpty() || visibilityDrainTask != null && !visibilityDrainTask.isCancelled()) { return; } @@ -300,6 +379,9 @@ private void scheduleVisibilityDrain() { private void runVisibilityDrain() { visibilityDrainTask = null; + if (closed) { + return; + } drainVisibilityQueues(); scheduleVisibilityDrain(); } @@ -1023,6 +1105,122 @@ private void removeLabel(TextDisplay label) { } } + @Override + protected void onUnregister() { + closed = true; + ScheduledTask pendingUpdate = updateTask; + ScheduledTask pendingVisibilityDrain = visibilityDrainTask; + Runnable unsubscribe = viewerGroupUnsubscribe; + updateTask = null; + visibilityDrainTask = null; + viewerGroupUnsubscribe = null; + + Throwable failure = null; + if (pendingUpdate != null) { + try { + pendingUpdate.cancel(); + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + } + if (pendingVisibilityDrain != null) { + try { + pendingVisibilityDrain.cancel(); + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + } + if (unsubscribe != null) { + try { + unsubscribe.run(); + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + } + try { + HandlerList.unregisterAll(this); + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + try { + for (Map.Entry entry : new ArrayList<>(labels.entrySet())) { + failure = removeLabelOnUnregister(entry.getKey(), entry.getValue(), failure); + } + for (Map.Entry entry : new ArrayList<>(pendingRemovalLabels.entrySet())) { + failure = removeLabelOnUnregister(entry.getKey(), entry.getValue(), failure); + } + } finally { + trackedItems.clear(); + labels.clear(); + pendingRemovalLabels.clear(); + contentCache.clear(); + eligibleViewers.clear(); + desiredViewers.clear(); + visibilityStates.clear(); + viewersByLabel.clear(); + pendingVisibilityViewers.clear(); + candidateItems.clear(); + crampIndexedItems.clear(); + crampIndex.clear(); + } + rethrow(failure); + } + + private Throwable removeLabelOnUnregister(UUID itemId, TextDisplay label, Throwable failure) { + if (label == null) { + return failure; + } + Set viewers = new HashSet<>(); + if (visibilityPolicy.controlsPerViewerVisibility()) { + for (Map.Entry entry : visibilityStates.entrySet()) { + if (entry.getValue().shown.contains(itemId)) { + viewers.add(entry.getKey()); + } + } + } else { + viewers.addAll(eligibleViewers); + } + for (UUID viewerId : viewers) { + try { + Player player = Bukkit.getPlayer(viewerId); + if (player != null && label.isValid()) { + setLabelVisible(player, label, false); + } + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + } + try { + PerformanceMetrics.bukkitEntityRemove(); + label.remove(); + } catch (Throwable throwable) { + failure = appendFailure(failure, throwable); + } + return failure; + } + + private static Throwable appendFailure(Throwable current, Throwable addition) { + if (current == null) { + return addition; + } + if (current != addition) { + current.addSuppressed(addition); + } + return current; + } + + private static void rethrow(Throwable failure) { + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof RuntimeException exception) { + throw exception; + } + if (failure != null) { + throw new RuntimeException(failure); + } + } + private boolean isOwned(Entity entity) { return entity.getPersistentDataContainer().has(visualEntityKey, PersistentDataType.STRING); } 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 1423d4f6..813644f3 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -43,6 +43,8 @@ public final class PerformanceMetrics implements Listener { private final double[] tickDurations = new double[MAX_TICK_SAMPLES]; private final SlowestTickTracker slowestTickTracker = new SlowestTickTracker(); + private final PopulationTracker droppedTrackedItems = new PopulationTracker(); + private final PopulationTracker droppedLabels = new PopulationTracker(); private volatile boolean collecting; private String label = ""; @@ -88,8 +90,6 @@ public final class PerformanceMetrics implements Listener { private long droppedViewerDistanceChecks; private long droppedSpatialCandidates; private long droppedFullScanCandidates; - private int droppedTrackedItemsMax; - private int droppedLabelsMax; private long blockUpdateChecks; private long blockUpdateNanos; private int blockUpdateCoordinatorLanesMax; @@ -171,8 +171,8 @@ public static boolean start(String requestedLabel) { INSTANCE.droppedViewerDistanceChecks = 0; INSTANCE.droppedSpatialCandidates = 0; INSTANCE.droppedFullScanCandidates = 0; - INSTANCE.droppedTrackedItemsMax = 0; - INSTANCE.droppedLabelsMax = 0; + INSTANCE.droppedTrackedItems.reset(); + INSTANCE.droppedLabels.reset(); INSTANCE.blockUpdateChecks = 0; INSTANCE.blockUpdateNanos = 0; INSTANCE.blockUpdateCoordinatorLanesMax = 0; @@ -357,8 +357,8 @@ public static void droppedFullScanCandidates(long candidates) { public static void droppedItemState(int trackedItems, int labels) { if (INSTANCE.collecting) { - INSTANCE.droppedTrackedItemsMax = Math.max(INSTANCE.droppedTrackedItemsMax, trackedItems); - INSTANCE.droppedLabelsMax = Math.max(INSTANCE.droppedLabelsMax, labels); + INSTANCE.droppedTrackedItems.sample(trackedItems); + INSTANCE.droppedLabels.sample(labels); } } @@ -504,7 +504,9 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) retainedCullingRegistrations, itemAnimationNanos, droppedItemNanos, droppedViewerDistanceChecks, droppedSpatialCandidates, droppedFullScanCandidates, - droppedTrackedItemsMax, droppedLabelsMax, + droppedTrackedItems.min(), droppedTrackedItems.max(), droppedTrackedItems.end(), + droppedTrackedItems.sampleCount(), + droppedLabels.min(), droppedLabels.max(), droppedLabels.end(), droppedLabels.sampleCount(), blockUpdateChecks, blockUpdateNanos, blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, @@ -657,8 +659,14 @@ public record Snapshot( long droppedViewerDistanceChecks, long droppedSpatialCandidates, long droppedFullScanCandidates, + int droppedTrackedItemsMin, int droppedTrackedItemsMax, + int droppedTrackedItemsEnd, + long droppedTrackedItemsSampleCount, + int droppedLabelsMin, int droppedLabelsMax, + int droppedLabelsEnd, + long droppedLabelsSampleCount, long blockUpdateChecks, long blockUpdateNanos, int blockUpdateCoordinatorLanesMax, @@ -746,8 +754,14 @@ public String json() { "\"droppedViewerDistanceChecks\":%d," + "\"droppedSpatialCandidates\":%d," + "\"droppedFullScanCandidates\":%d," + + "\"droppedTrackedItemsMin\":%d," + "\"droppedTrackedItemsMax\":%d," + + "\"droppedTrackedItemsEnd\":%d," + + "\"droppedTrackedItemsSampleCount\":%d," + + "\"droppedLabelsMin\":%d," + "\"droppedLabelsMax\":%d," + + "\"droppedLabelsEnd\":%d," + + "\"droppedLabelsSampleCount\":%d," + "\"blockUpdateChecks\":%d,\"blockUpdateMs\":%.6f," + "\"blockUpdateCoordinatorLanesMax\":%d," + "\"blockUpdateDirtyQueueMax\":%d," + @@ -781,7 +795,10 @@ public String json() { craftEngineCullingRetainedRegistrations, itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D, droppedViewerDistanceChecks, droppedSpatialCandidates, - droppedFullScanCandidates, droppedTrackedItemsMax, droppedLabelsMax, + droppedFullScanCandidates, + droppedTrackedItemsMin, droppedTrackedItemsMax, droppedTrackedItemsEnd, + droppedTrackedItemsSampleCount, + droppedLabelsMin, droppedLabelsMax, droppedLabelsEnd, droppedLabelsSampleCount, blockUpdateChecks, blockUpdateNanos / 1_000_000.0D, blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, @@ -791,6 +808,58 @@ legacyTextCacheRequests, legacyTextCacheMisses, legacyTextCacheHits(), } } + /** + * Constant-space population summary for a complete measurement window. + * Package visibility keeps first-sample and reset behavior unit-testable + * without starting a server. + */ + static final class PopulationTracker { + + private int min; + private int max; + private int end; + private long sampleCount; + + PopulationTracker() { + reset(); + } + + void reset() { + min = 0; + max = 0; + end = 0; + sampleCount = 0; + } + + void sample(int population) { + if (sampleCount == 0) { + min = population; + max = population; + } else { + min = Math.min(min, population); + max = Math.max(max, population); + } + end = population; + sampleCount++; + } + + int min() { + return min; + } + + int max() { + return max; + } + + int end() { + return end; + } + + long sampleCount() { + return sampleCount; + } + } + /** * Constant-space attribution for block-update work performed during the * slowest completed tick. This tracker is main-thread confined by the diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java index 9e88b916..0f1cfa53 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java @@ -471,6 +471,7 @@ public static void shutdown() { } } + @SuppressWarnings("deprecation") static void clearRuntimeState() { pendingInventoryOpenProcesses.clear(); pendingInventoryRefreshes.clear(); @@ -478,9 +479,33 @@ static void clearRuntimeState() { displays.clear(); } processes.clear(); - runnables.clear(); + List runnableSnapshot = new ArrayList<>(runnables); + Throwable failure = null; + try { + for (VisualizerRunnableDisplay runnable : runnableSnapshot) { + try { + runnable.unregister(); + } catch (Throwable throwable) { + if (failure == null) { + failure = throwable; + } else if (failure != throwable) { + failure.addSuppressed(throwable); + } + } + } + } finally { + runnables.clear(); + } EnderchestDisplay.shutdown(); EnchantmentTableAnimation.shutdown(); + if (failure != null && plugin != null) { + try { + plugin.getLogger().log(java.util.logging.Level.SEVERE, + "One or more runnable displays failed while releasing shutdown state", failure); + } catch (Throwable ignored) { + // Cleanup diagnostics must not interrupt the remaining shutdown sequence. + } + } } /** Number of display registrations or delayed inventory requests retained. */ diff --git a/common/src/test/java/com/loohp/interactionvisualizer/api/VisualizerRunnableDisplayLifecycleTest.java b/common/src/test/java/com/loohp/interactionvisualizer/api/VisualizerRunnableDisplayLifecycleTest.java new file mode 100644 index 00000000..f45b2979 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/api/VisualizerRunnableDisplayLifecycleTest.java @@ -0,0 +1,175 @@ +/* + * 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; + +import com.loohp.interactionvisualizer.managers.TaskManager; +import com.loohp.interactionvisualizer.objectholders.EntryKey; +import com.loohp.interactionvisualizer.scheduler.ScheduledTask; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VisualizerRunnableDisplayLifecycleTest { + + @Test + @SuppressWarnings("deprecation") + void unregisterCancelsBaseTasksAndRunsHookOnlyOnce() throws Exception { + List originalRunnables = TaskManager.runnables; + AtomicInteger cancellations = new AtomicInteger(); + AtomicInteger unregisterCalls = new AtomicInteger(); + TaskManager.runnables = new ArrayList<>(); + try { + ScheduledTask gcTask = scheduledTask(cancellations); + ScheduledTask runTask = scheduledTask(cancellations); + VisualizerRunnableDisplay display = new VisualizerRunnableDisplay() { + @Override + public EntryKey key() { + return new EntryKey("runnable_lifecycle_test"); + } + + @Override + public ScheduledTask gc() { + return gcTask; + } + + @Override + public ScheduledTask run() { + return runTask; + } + + @Override + protected void onUnregister() { + unregisterCalls.incrementAndGet(); + } + }; + + display.registerNative(); + assertTrue(TaskManager.runnables.contains(display)); + + display.unregister(); + display.unregister(); + + assertEquals(2, cancellations.get()); + assertEquals(1, unregisterCalls.get()); + assertFalse(TaskManager.runnables.contains(display)); + } finally { + TaskManager.runnables = originalRunnables; + } + } + + @Test + @SuppressWarnings("deprecation") + void unregisterContinuesAfterCancelErrorAndRethrowsOriginalError() throws Exception { + List originalRunnables = TaskManager.runnables; + AtomicInteger cancellations = new AtomicInteger(); + AtomicInteger unregisterCalls = new AtomicInteger(); + AssertionError cancellationFailure = new AssertionError("expected cancel failure"); + TaskManager.runnables = new ArrayList<>(); + try { + ScheduledTask failingTask = scheduledTask(cancellations, cancellationFailure); + ScheduledTask successfulTask = scheduledTask(cancellations); + VisualizerRunnableDisplay display = new VisualizerRunnableDisplay() { + @Override + public EntryKey key() { + return new EntryKey("runnable_error_lifecycle_test"); + } + + @Override + public ScheduledTask gc() { + return failingTask; + } + + @Override + public ScheduledTask run() { + return successfulTask; + } + + @Override + protected void onUnregister() { + unregisterCalls.incrementAndGet(); + } + }; + + display.registerNative(); + + AssertionError thrown = assertThrows(AssertionError.class, display::unregister); + + assertSame(cancellationFailure, thrown); + assertEquals(2, cancellations.get()); + assertEquals(1, unregisterCalls.get()); + assertFalse(TaskManager.runnables.contains(display)); + } finally { + TaskManager.runnables = originalRunnables; + } + } + + private static ScheduledTask scheduledTask(AtomicInteger cancellations) throws Exception { + return scheduledTask(cancellations, null); + } + + private static ScheduledTask scheduledTask(AtomicInteger cancellations, Throwable failure) throws Exception { + AtomicBoolean cancelled = new AtomicBoolean(); + BukkitTask bukkitTask = (BukkitTask) Proxy.newProxyInstance( + BukkitTask.class.getClassLoader(), new Class[]{BukkitTask.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "cancel" -> { + cancellations.incrementAndGet(); + cancelled.set(true); + if (failure != null) { + throw failure; + } + yield null; + } + case "isCancelled" -> cancelled.get(); + case "getTaskId" -> 1; + case "getOwner" -> null; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + case "toString" -> "LifecycleTestBukkitTask"; + default -> defaultValue(method.getReturnType()); + }); + Constructor constructor = ScheduledTask.class.getDeclaredConstructor(BukkitTask.class); + constructor.setAccessible(true); + return constructor.newInstance(bukkitTask); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + if (type == byte.class || type == short.class || type == int.class || type == long.class) { + return 0; + } + if (type == float.class || type == double.class) { + return 0.0D; + } + return null; + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemCandidateWindowTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemCandidateWindowTest.java index 82ace14f..18a0f5c7 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemCandidateWindowTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemCandidateWindowTest.java @@ -19,6 +19,8 @@ import java.util.UUID; 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 DroppedItemCandidateWindowTest { @@ -101,6 +103,16 @@ void sourceOwnedSectionQueryPreservesBruteForceCandidateAndCrampResults() { } } + @Test + void disabledWorldViewerGroupKeepsTrackedItemsScheduled() { + assertTrue(DroppedItemDisplay.shouldScheduleUpdate(true, false, false, true), + "an online unfiltered viewer must keep the low-frequency update alive"); + assertFalse(DroppedItemDisplay.shouldScheduleUpdate(true, false, false, false), + "without active or underlying viewers the display may sleep"); + assertFalse(DroppedItemDisplay.shouldScheduleUpdate(false, true, true, true), + "there is no update work without tracked items"); + } + private static int bruteClassification(Point point, double viewDistance, boolean hasLabel) { double distanceSquared = point.x() * point.x() + point.y() * point.y() + point.z() * point.z(); diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java index a89f652f..080b758e 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java @@ -73,6 +73,32 @@ void resetRestoresNoSampleSentinel() { assertEquals(-1L, tracker.slowestEndEpochMillis()); } + @Test + void populationTrackerInitializesFromFirstSampleAndSummarizesWindow() { + PerformanceMetrics.PopulationTracker tracker = new PerformanceMetrics.PopulationTracker(); + + tracker.sample(128); + assertPopulation(tracker, 128, 128, 128, 1L); + + tracker.sample(96); + tracker.sample(160); + tracker.sample(144); + + assertPopulation(tracker, 96, 160, 144, 4L); + } + + @Test + void populationTrackerResetRestoresEmptyWindow() { + PerformanceMetrics.PopulationTracker tracker = new PerformanceMetrics.PopulationTracker(); + tracker.sample(32); + + tracker.reset(); + + assertPopulation(tracker, 0, 0, 0, 0L); + tracker.sample(7); + assertPopulation(tracker, 7, 7, 7, 1L); + } + @Test void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { PerformanceMetrics.Snapshot snapshot = new PerformanceMetrics.Snapshot( @@ -87,7 +113,9 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0, - 0L, 0L, 0L, 0L, 0L, 2048, 2048, 7L, 12_345_678L, 0, 0, 0, + 0L, 0L, 0L, 0L, 0L, + 2048, 2048, 2048, 3L, 128, 128, 128, 3L, + 7L, 12_345_678L, 0, 0, 0, 0L, 0L, 0, 0L, 0L, 100L, 5L, 200L); String json = snapshot.json(); @@ -106,8 +134,14 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { assertTrue(json.contains("\"viewerFullReconciles\":0")); assertTrue(json.contains("\"craftEngineCullingRetainedRegistrations\":0")); assertTrue(json.contains("\"droppedSpatialCandidates\":0")); + assertTrue(json.contains("\"droppedTrackedItemsMin\":2048")); assertTrue(json.contains("\"droppedTrackedItemsMax\":2048")); - assertTrue(json.contains("\"droppedLabelsMax\":2048")); + assertTrue(json.contains("\"droppedTrackedItemsEnd\":2048")); + assertTrue(json.contains("\"droppedTrackedItemsSampleCount\":3")); + assertTrue(json.contains("\"droppedLabelsMin\":128")); + assertTrue(json.contains("\"droppedLabelsMax\":128")); + assertTrue(json.contains("\"droppedLabelsEnd\":128")); + assertTrue(json.contains("\"droppedLabelsSampleCount\":3")); assertTrue(json.contains("\"blockUpdateDirtyQueueMax\":0")); assertTrue(json.contains("\"preferenceSqlStatements\":0")); assertTrue(json.contains("\"legacyTextCacheHits\":95")); @@ -115,6 +149,14 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { assertTrue(json.contains("\"legacyTextSameRawFastPaths\":200")); } + private static void assertPopulation(PerformanceMetrics.PopulationTracker tracker, + int min, int max, int end, long sampleCount) { + assertEquals(min, tracker.min()); + assertEquals(max, tracker.max()); + assertEquals(end, tracker.end()); + assertEquals(sampleCount, tracker.sampleCount()); + } + private static void assertSlowest(PerformanceMetrics.SlowestTickTracker tracker, int tick, long checks, long nanos) { assertEquals(tick, tracker.slowestBukkitTick()); diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/TaskManagerShutdownTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/TaskManagerShutdownTest.java index a2c30d59..51713e5f 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/TaskManagerShutdownTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/TaskManagerShutdownTest.java @@ -18,50 +18,54 @@ import org.junit.jupiter.api.Test; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class TaskManagerShutdownTest { @Test - void clearsRegistriesAndPendingPlayerRequests() { + @SuppressWarnings("deprecation") + void clearsRegistriesAndInvokesRunnableShutdownHooks() { UUID playerId = UUID.randomUUID(); Map> originalProcesses = TaskManager.processes; - Map> originalProcessEntries = - new HashMap<>(); - originalProcesses.forEach((type, displays) -> - originalProcessEntries.put(type, new ArrayList<>(displays))); - List originalRunnables = new ArrayList<>(TaskManager.runnables); + List originalRunnables = TaskManager.runnables; TrackingMap> processes = new TrackingMap<>(); + AtomicInteger failingUnregisterCalls = new AtomicInteger(); + AtomicInteger successfulUnregisterCalls = new AtomicInteger(); try { TaskManager.processes = processes; - TaskManager.runnables.add(runnableDisplay()); + TaskManager.runnables = new ArrayList<>(); + runnableDisplay(failingUnregisterCalls, true).registerNative(); + runnableDisplay(successfulUnregisterCalls, false).registerNative(); assertTrue(TaskManager.markInventoryOpenProcessQueued(playerId)); - TaskManager.clearRuntimeState(); + assertDoesNotThrow(TaskManager::clearRuntimeState); assertTrue(processes.cleared); assertTrue(TaskManager.processes.isEmpty()); assertTrue(TaskManager.runnables.isEmpty()); + assertEquals(1, failingUnregisterCalls.get()); + assertEquals(1, successfulUnregisterCalls.get()); assertTrue(TaskManager.markInventoryOpenProcessQueued(playerId), "shutdown must release the pending request token"); } finally { TaskManager.clearRuntimeState(); TaskManager.processes = originalProcesses; - originalProcesses.clear(); - originalProcesses.putAll(originalProcessEntries); - TaskManager.runnables.addAll(originalRunnables); + TaskManager.runnables = originalRunnables; } } - private static VisualizerRunnableDisplay runnableDisplay() { + private static VisualizerRunnableDisplay runnableDisplay(AtomicInteger unregisterCalls, + boolean failOnUnregister) { return new VisualizerRunnableDisplay() { @Override public EntryKey key() { @@ -77,6 +81,14 @@ public ScheduledTask gc() { public ScheduledTask run() { return null; } + + @Override + protected void onUnregister() { + unregisterCalls.incrementAndGet(); + if (failOnUnregister) { + throw new AssertionError("expected shutdown test failure"); + } + } }; } diff --git a/tools/perf/evaluate-dropped-item-gate.py b/tools/perf/evaluate-dropped-item-gate.py index 5a4aaf9e..69b80703 100644 --- a/tools/perf/evaluate-dropped-item-gate.py +++ b/tools/perf/evaluate-dropped-item-gate.py @@ -5,17 +5,32 @@ import argparse import csv +import hashlib import json import os from pathlib import Path +import re import statistics import sys import tempfile -from typing import Any +from typing import Any, Callable SCENARIO = "dropped-items" AB_FACTOR = "dropped-item-section-candidates" +PAPER_VERSION = "26.1.2" +PAPER_CHANNEL = "STABLE" +PAPER_BUILD_ID = 74 +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +ANALYSIS_METRICS = ("droppedItemMs", "msptP95", "msptP99") +CANONICAL_WINDOWS = { + "warmupSeconds": 120, + "settleSeconds": 20, + "measureSeconds": 180, +} +FORMAL_RUN_COUNT = 12 +FORMAL_SAMPLING_MODE = "paired-adjacent" +FORMAL_PAIR_COUNT = 6 def read_json(path: Path) -> dict[str, Any]: @@ -42,6 +57,33 @@ def load_analysis_result(evidence_root: Path, metric: str) -> dict[str, Any]: raise ValueError(f"{path.name} does not contain the {SCENARIO} result") if result.get("metric") != metric or result.get("abFactor") != AB_FACTOR: raise ValueError(f"{path.name} result provenance does not match its envelope") + if result.get("captureMethod") != "none": + raise ValueError(f"{path.name} must use captureMethod=none for clean performance evidence") + sha256_field(result, "stackSha256", path) + artifact_hashes = result.get("artifactSha256") + if not isinstance(artifact_hashes, dict) or set(artifact_hashes) != {"A", "B"}: + raise ValueError(f"{path.name} must declare artifactSha256 for variants A and B") + for variant in ("A", "B"): + sha256_field(artifact_hashes, variant, path) + if result.get("runCount") != FORMAL_RUN_COUNT: + raise ValueError(f"{path.name} must declare runCount={FORMAL_RUN_COUNT}") + if result.get("samplingMode") != FORMAL_SAMPLING_MODE: + raise ValueError( + f"{path.name} must use samplingMode={FORMAL_SAMPLING_MODE}") + if result.get("pairCount") != FORMAL_PAIR_COUNT: + raise ValueError(f"{path.name} must declare pairCount={FORMAL_PAIR_COUNT}") + analysis_runs = result.get("runs") + if not isinstance(analysis_runs, list) or len(analysis_runs) != FORMAL_RUN_COUNT: + raise ValueError(f"{path.name} must contain {FORMAL_RUN_COUNT} analysis runs") + run_ids: set[str] = set() + for run in analysis_runs: + if not isinstance(run, dict): + raise ValueError(f"{path.name} analysis runs must be objects") + run_id = run.get("runId") + if not isinstance(run_id, str) or not run_id or run_id in run_ids: + raise ValueError(f"{path.name} analysis runId values must be unique non-empty strings") + run_ids.add(run_id) + sha256_field(run, "sourceSha256", path) return result @@ -59,12 +101,188 @@ def boolean_field(document: dict[str, Any], field: str, source: Path) -> bool: return value +def sha256_field(document: dict[str, Any], field: str, source: Path) -> str: + value = document.get(field) + if not isinstance(value, str) or SHA256_PATTERN.fullmatch(value) is None: + raise ValueError(f"{source}: {field} must be a lowercase SHA-256") + return value + + +def row_sha256(row: dict[str, str], field: str, source: Path) -> str: + value = row.get(field, "") + if SHA256_PATTERN.fullmatch(value) is None: + raise ValueError(f"{source}: manifest {field} must be a lowercase SHA-256") + return value + + +def row_boolean(row: dict[str, str], field: str, source: Path) -> bool: + value = row.get(field) + if value == "true": + return True + if value == "false": + return False + raise ValueError(f"{source}: manifest {field} must be true or false") + + +def population(metrics: dict[str, Any], source: Path, prefix: str) -> dict[str, int]: + summary = { + "min": integer_field(metrics, f"{prefix}Min", source), + "max": integer_field(metrics, f"{prefix}Max", source), + "end": integer_field(metrics, f"{prefix}End", source), + "sampleCount": integer_field(metrics, f"{prefix}SampleCount", source), + } + if summary["sampleCount"] <= 0: + raise ValueError(f"{source}: {prefix}SampleCount must be positive") + if not summary["min"] <= summary["end"] <= summary["max"]: + raise ValueError(f"{source}: {prefix} min/max/end are inconsistent") + return summary + + +def validate_clean_provenance( + provenance: dict[str, Any], provenance_path: Path, + metrics: dict[str, Any], metrics_path: Path, + row: dict[str, str], variant: str, run_id: str, + expected_items: int, expected_nearby_items: int) -> dict[str, Any]: + if provenance.get("schemaVersion") != 7: + raise ValueError(f"{provenance_path}: run manifest must use schemaVersion=7") + expected_fields = { + "runId": run_id, + "scenario": SCENARIO, + "variant": variant, + "abFactor": AB_FACTOR, + "paperVersion": PAPER_VERSION, + "paperChannel": PAPER_CHANNEL, + "paperBuildId": PAPER_BUILD_ID, + "itemCount": expected_items, + "droppedNearbyItemCount": expected_nearby_items, + "workloadCount": expected_items, + **CANONICAL_WINDOWS, + } + for field, expected in expected_fields.items(): + if provenance.get(field) != expected: + raise ValueError( + f"{provenance_path}: {field} provenance drifted: " + f"{provenance.get(field)!r} != {expected!r}") + if provenance.get("paperChannel") not in {"STABLE", "BETA", "UNKNOWN"}: + raise ValueError(f"{provenance_path}: invalid paperChannel provenance") + integer_field(provenance, "paperBuildId", provenance_path) + + expected_source_owned = variant == "B" + source_owned = boolean_field( + provenance, "droppedSourceOwnedSectionCandidates", provenance_path) + if source_owned is not expected_source_owned: + raise ValueError(f"{provenance_path}: dropped-item treatment does not match variant {variant}") + if boolean_field(metrics, "droppedSourceOwnedSectionCandidates", metrics_path) is not source_owned: + raise ValueError(f"{metrics_path}: dropped-item treatment differs from run manifest") + if metrics.get("label") != run_id or metrics.get("abFactor") != AB_FACTOR: + raise ValueError(f"{metrics_path}: run/scenario factor provenance drifted") + + hash_fields = ( + "pluginSha256", + "paperSha256", + "clientManifestSha256", + "configSha256", + "jvmArgumentsSha256", + "jvmArgumentsNormalizedSha256", + ) + hashes = {field: sha256_field(provenance, field, provenance_path) for field in hash_fields} + if row_sha256(row, "ArtifactSha256", metrics_path) != hashes["pluginSha256"]: + raise ValueError(f"{metrics_path}: artifact SHA differs from plugin provenance") + stack_sha256 = row_sha256(row, "StackSha256", metrics_path) + for row_field, manifest_field in ( + ("ConfigSha256", "configSha256"), + ("JvmArgumentsSha256", "jvmArgumentsSha256"), + ("JvmArgumentsNormalizedSha256", "jvmArgumentsNormalizedSha256"), + ): + if row_sha256(row, row_field, metrics_path) != hashes[manifest_field]: + raise ValueError(f"{metrics_path}: manifest {row_field} provenance drifted") + for field in ("jvmArgumentsSha256", "jvmArgumentsNormalizedSha256"): + if metrics.get(field) != hashes[field]: + raise ValueError(f"{metrics_path}: IV_PERF {field} provenance drifted") + + if row.get("Scenario") != SCENARIO or row.get("RunId") != run_id: + raise ValueError(f"{metrics_path}: CSV scenario/run provenance drifted") + if row.get("AbFactor") != AB_FACTOR or row.get("CaptureMethod") != "none": + raise ValueError(f"{metrics_path}: formal manifest must use the canonical factor and captureMethod=none") + if row_boolean(row, "DroppedSourceOwnedSectionCandidates", metrics_path) is not source_owned: + raise ValueError(f"{metrics_path}: CSV dropped-item treatment provenance drifted") + + spark = provenance.get("sparkProfile") + if not isinstance(spark, dict): + raise ValueError(f"{provenance_path}: missing Spark profile provenance") + if (spark.get("enabled") is not False + or spark.get("mode") != "none" + or spark.get("profileEvidenceReady") is not None + or spark.get("performanceEvidenceReady") is not True + or spark.get("metadataPath") is not None + or spark.get("metadataSha256") is not None): + raise ValueError( + f"{provenance_path}: profiler evidence cannot masquerade as clean performance evidence") + + diagnostics = provenance.get("jvmDiagnostics") + if not isinstance(diagnostics, dict): + raise ValueError(f"{provenance_path}: missing JVM diagnostics provenance") + if (diagnostics.get("formalEvidenceReady") is not True + or diagnostics.get("finalizedAfterServerExit") is not True + or diagnostics.get("serverStoppedCleanly") is not True): + raise ValueError(f"{provenance_path}: JVM diagnostics are not formal-evidence ready") + for field in ("jvmArgumentsSha256", "jvmArgumentsNormalizedSha256"): + if diagnostics.get(field) != hashes[field]: + raise ValueError(f"{provenance_path}: JVM diagnostics {field} provenance drifted") + sha256_field(diagnostics, "metadataSha256", provenance_path) + gc_log = diagnostics.get("gcSafepointLog") + if not isinstance(gc_log, dict): + raise ValueError(f"{provenance_path}: missing GC/safepoint provenance") + sha256_field(gc_log, "sha256", provenance_path) + process = diagnostics.get("processCommandLine") + if not isinstance(process, dict): + raise ValueError(f"{provenance_path}: missing live JVM command-line provenance") + if (process.get("formalEvidenceReady") is not True + or process.get("capturedFromProcCmdline") is not True): + raise ValueError(f"{provenance_path}: live JVM command line is not formal-evidence ready") + for field in ("jvmArgumentsSha256", "jvmArgumentsNormalizedSha256"): + if process.get(field) != hashes[field]: + raise ValueError(f"{provenance_path}: live JVM command line {field} drifted") + sha256_field(process, "metadataSha256", provenance_path) + + cache = provenance.get("legacyTextComponentCache") + if not isinstance(cache, dict): + raise ValueError(f"{provenance_path}: missing legacy text cache provenance") + cache_mapping = { + "disableProperty": "legacyTextComponentCacheDisableProperty", + "enabled": "legacyTextComponentCache", + "requests": "legacyTextCacheRequests", + "misses": "legacyTextCacheMisses", + "hits": "legacyTextCacheHits", + "hitRate": "legacyTextCacheHitRate", + "sameRawFastPaths": "legacyTextSameRawFastPaths", + } + for manifest_field, metrics_field in cache_mapping.items(): + if cache.get(manifest_field) != metrics.get(metrics_field): + raise ValueError( + f"{provenance_path}: cache {manifest_field}/{metrics_field} provenance drifted") + if row_boolean(row, "LegacyTextComponentCacheDisableProperty", metrics_path) \ + is not cache.get("disableProperty"): + raise ValueError(f"{metrics_path}: CSV cache disable-property provenance drifted") + if row_boolean(row, "LegacyTextComponentCacheEnabled", metrics_path) \ + is not cache.get("enabled"): + raise ValueError(f"{metrics_path}: CSV cache enabled provenance drifted") + return { + **hashes, + "stackSha256": stack_sha256, + "sourceOwned": source_owned, + "sourceSha256": hashlib.sha256(metrics_path.read_bytes()).hexdigest(), + } + + def load_runs(manifest_path: Path, evidence_root: Path, expected_items: int, expected_nearby_items: int) -> list[dict[str, Any]]: runs: list[dict[str, Any]] = [] + run_ids: set[str] = set() evidence_root_resolved = evidence_root.resolve() with manifest_path.open(encoding="utf-8", newline="") as stream: - for row in csv.DictReader(stream): + reader = csv.DictReader(stream) + for row in reader: if row.get("Scenario") != SCENARIO: continue source_text = row.get("SourcePath") @@ -78,20 +296,43 @@ def load_runs(manifest_path: Path, evidence_root: Path, metrics = read_json(metrics_path) provenance_path = metrics_path.with_name("run-manifest.json") provenance = read_json(provenance_path) - if provenance.get("itemCount") != expected_items: - raise ValueError(f"{provenance_path}: itemCount provenance drifted") - if provenance.get("droppedNearbyItemCount") != expected_nearby_items: - raise ValueError(f"{provenance_path}: nearby-item provenance drifted") variant = row.get("Variant") if variant not in {"A", "B"}: raise ValueError(f"manifest contains invalid variant {variant!r}") + run_id = row.get("RunId") + if not run_id: + raise ValueError("manifest row is missing RunId") + if run_id in run_ids: + raise ValueError(f"manifest contains duplicate RunId {run_id!r}") + run_ids.add(run_id) + evidence = validate_clean_provenance( + provenance, provenance_path, metrics, metrics_path, row, + variant, run_id, expected_items, expected_nearby_items) + tracked = population(metrics, metrics_path, "droppedTrackedItems") + labels = population(metrics, metrics_path, "droppedLabels") + if tracked["sampleCount"] != labels["sampleCount"]: + raise ValueError(f"{metrics_path}: tracked/label population sample counts differ") runs.append({ - "runId": row.get("RunId"), + "runId": run_id, "variant": variant, - "sourceOwned": boolean_field( - metrics, "droppedSourceOwnedSectionCandidates", metrics_path), - "trackedItemsMax": integer_field(metrics, "droppedTrackedItemsMax", metrics_path), - "labelsMax": integer_field(metrics, "droppedLabelsMax", metrics_path), + "sourcePath": source_text, + "sourceOwned": evidence["sourceOwned"], + "sourceSha256": evidence["sourceSha256"], + "stackSha256": evidence["stackSha256"], + "artifactSha256": evidence["pluginSha256"], + "paperSha256": evidence["paperSha256"], + "clientManifestSha256": evidence["clientManifestSha256"], + "configSha256": evidence["configSha256"], + "jvmArgumentsSha256": evidence["jvmArgumentsSha256"], + "jvmArgumentsNormalizedSha256": evidence["jvmArgumentsNormalizedSha256"], + "trackedItemsMin": tracked["min"], + "trackedItemsMax": tracked["max"], + "trackedItemsEnd": tracked["end"], + "trackedItemsSampleCount": tracked["sampleCount"], + "labelsMin": labels["min"], + "labelsMax": labels["max"], + "labelsEnd": labels["end"], + "labelsSampleCount": labels["sampleCount"], "fullScanCandidates": integer_field( metrics, "droppedFullScanCandidates", metrics_path), "spatialCandidates": integer_field( @@ -107,14 +348,114 @@ def load_runs(manifest_path: Path, evidence_root: Path, return runs +def single_run_hash(runs: list[dict[str, Any]], field: str, scope: str) -> str: + values = {run[field] for run in runs} + if len(values) != 1: + raise ValueError(f"formal dropped-item runs mix {field} provenance in {scope}") + return next(iter(values)) + + +def validate_analysis_closure( + analyses: dict[str, dict[str, Any]], runs: list[dict[str, Any]]) -> dict[str, Any]: + stack_sha256 = single_run_hash(runs, "stackSha256", "the campaign") + paper_sha256 = single_run_hash(runs, "paperSha256", "the campaign") + client_manifest_sha256 = single_run_hash( + runs, "clientManifestSha256", "the campaign") + normalized_jvm_sha256 = single_run_hash( + runs, "jvmArgumentsNormalizedSha256", "the campaign") + by_variant: dict[str, dict[str, str]] = {} + for variant in ("A", "B"): + variant_runs = [run for run in runs if run["variant"] == variant] + by_variant[variant] = { + "artifactSha256": single_run_hash( + variant_runs, "artifactSha256", f"variant {variant}"), + "configSha256": single_run_hash( + variant_runs, "configSha256", f"variant {variant}"), + "jvmArgumentsSha256": single_run_hash( + variant_runs, "jvmArgumentsSha256", f"variant {variant}"), + } + run_by_id = {run["runId"]: run for run in runs} + expected_run_ids = set(run_by_id) + expected_config_hashes = sorted({run["configSha256"] for run in runs}) + expected_config_by_variant = { + variant: [by_variant[variant]["configSha256"]] for variant in ("A", "B") + } + expected_jvm_by_variant = { + variant: [by_variant[variant]["jvmArgumentsSha256"]] for variant in ("A", "B") + } + expected_artifacts = { + variant: by_variant[variant]["artifactSha256"] for variant in ("A", "B") + } + + for metric, analysis in analyses.items(): + source = Path(f"{metric}.analysis.json") + if analysis.get("stackSha256") != stack_sha256: + raise ValueError(f"{source.name}: analysis stackSha256 does not match CSV evidence") + if analysis.get("artifactSha256") != expected_artifacts: + raise ValueError(f"{source.name}: analysis artifactSha256 does not match variant evidence") + if analysis.get("configSha256") != expected_config_hashes: + raise ValueError(f"{source.name}: analysis configSha256 does not match CSV evidence") + if analysis.get("configSha256ByVariant") != expected_config_by_variant: + raise ValueError( + f"{source.name}: analysis configSha256ByVariant does not match variant evidence") + if analysis.get("jvmArgumentsSha256ByVariant") != expected_jvm_by_variant: + raise ValueError( + f"{source.name}: analysis jvmArgumentsSha256ByVariant does not match variant evidence") + if analysis.get("jvmArgumentsNormalizedSha256") != normalized_jvm_sha256: + raise ValueError( + f"{source.name}: analysis jvmArgumentsNormalizedSha256 does not match run evidence") + + analysis_runs = analysis["runs"] + analysis_run_ids = {entry["runId"] for entry in analysis_runs} + if analysis_run_ids != expected_run_ids: + raise ValueError(f"{source.name}: analysis runId set does not match the 12 CSV runs") + for entry in analysis_runs: + run = run_by_id[entry["runId"]] + if entry.get("sourceSha256") != run["sourceSha256"]: + raise ValueError( + f"{source.name}: analysis sourceSha256 for {run['runId']} " + "does not match current IV_PERF evidence") + expected_run_fields = { + "variant": run["variant"], + "abFactor": AB_FACTOR, + "configSha256": run["configSha256"], + "jvmArgumentsSha256": run["jvmArgumentsSha256"], + "jvmArgumentsNormalizedSha256": run["jvmArgumentsNormalizedSha256"], + } + for field, expected in expected_run_fields.items(): + if entry.get(field) != expected: + raise ValueError( + f"{source.name}: analysis {field} for {run['runId']} " + "does not match current run evidence") + + return { + "stackSha256": stack_sha256, + "paperSha256": paper_sha256, + "clientManifestSha256": client_manifest_sha256, + "jvmArgumentsNormalizedSha256": normalized_jvm_sha256, + "byVariant": by_variant, + } + + +def population_matches(run: dict[str, Any], prefix: str, expected: int) -> bool: + return run[f"{prefix}Min"] == expected \ + and run[f"{prefix}Max"] == expected \ + and run[f"{prefix}End"] == expected \ + and run[f"{prefix}SampleCount"] > 0 + + def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, expected_nearby_items: int, output_path: Path) -> dict[str, Any]: if expected_items < 1 or not 1 <= expected_nearby_items <= expected_items: raise ValueError("expected global/local item counts are invalid") - dropped = load_analysis_result(evidence_root, "droppedItemMs") - p95 = load_analysis_result(evidence_root, "msptP95") - p99 = load_analysis_result(evidence_root, "msptP99") + analyses = { + metric: load_analysis_result(evidence_root, metric) for metric in ANALYSIS_METRICS + } + dropped = analyses["droppedItemMs"] + p95 = analyses["msptP95"] + p99 = analyses["msptP99"] runs = load_runs(manifest_path, evidence_root, expected_items, expected_nearby_items) + provenance = validate_analysis_closure(analyses, runs) baseline_full_scan = statistics.median( run["fullScanCandidates"] for run in runs if run["variant"] == "A") candidate_spatial = statistics.median( @@ -132,9 +473,9 @@ def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, "msptP95CiUpperAtMost1_02": p95["ratioBootstrap95Ci"][1] <= 1.02, "msptP99CiUpperAtMost1_05": p99["ratioBootstrap95Ci"][1] <= 1.05, "allRunsRetainedGlobalPopulation": all( - run["trackedItemsMax"] == expected_items for run in runs), + population_matches(run, "trackedItems", expected_items) for run in runs), "allRunsRenderedNearbyPopulation": all( - run["labelsMax"] == expected_nearby_items for run in runs), + population_matches(run, "labels", expected_nearby_items) for run in runs), "candidateHasZeroFullScans": all( run["fullScanCandidates"] == 0 for run in runs if run["variant"] == "B"), "baselineExercisedFullScans": all( @@ -145,7 +486,7 @@ def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, "candidateViewerChecksAtMost10PercentOfBaseline": candidate_viewer_ratio <= 0.10, } formal_complete = all( - result.get("formalComplete") is True for result in (dropped, p95, p99)) + result.get("formalComplete") is True for result in analyses.values()) passed = formal_complete and all(checks.values()) gate = { "schemaVersion": 2, @@ -156,7 +497,9 @@ def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, "workload": { "trackedItems": expected_items, "nearbyLabels": expected_nearby_items, + **CANONICAL_WINDOWS, }, + "provenance": provenance, "checks": checks, "ratios": { "droppedItemMs": { @@ -198,86 +541,336 @@ def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, return gate +def assert_rejected(action: Callable[[], Any], expected_text: str) -> None: + try: + action() + except ValueError as exception: + if expected_text not in str(exception): + raise AssertionError( + f"expected rejection containing {expected_text!r}, got {exception!r}") from exception + else: + raise AssertionError(f"expected rejection containing {expected_text!r}") + + def self_test() -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) - def analysis(metric: str, ratio: float, interval: list[float]) -> dict[str, Any]: - result = { - "scenario": SCENARIO, - "metric": metric, - "abFactor": AB_FACTOR, - "formalComplete": True, - "medianBRatioToA": ratio, - "ratioBootstrap95Ci": interval, - } - return { - "schemaVersion": 2, - "metric": metric, - "abFactor": AB_FACTOR, - "results": [result], - } + hash_values = { + "pluginA": "a" * 64, + "pluginB": "6" * 64, + "paper": "b" * 64, + "client": "c" * 64, + "configA": "d" * 64, + "configB": "5" * 64, + "jvm": "e" * 64, + "jvmNormalized": "f" * 64, + "stack": "1" * 64, + "metadata": "2" * 64, + "command": "3" * 64, + "gc": "4" * 64, + } + manifest_path = root / "abba-manifest.csv" + fieldnames = [ + "Scenario", "Block", "Position", "Variant", "RunId", "AbFactor", + "DroppedSourceOwnedSectionCandidates", + "LegacyTextComponentCacheDisableProperty", + "LegacyTextComponentCacheEnabled", + "ConfigSha256", "JvmArgumentsSha256", "JvmArgumentsNormalizedSha256", + "StackSha256", "ArtifactSha256", "CaptureMethod", "SourcePath", + ] + manifest_rows: list[dict[str, str]] = [] + run_roots: list[Path] = [] + patterns = ("ABBA", "BAAB", "ABBA") - valid = analysis("droppedItemMs", 0.40, [0.35, 0.45]) - for metric, document in { - "droppedItemMs": valid, - "msptP95": analysis("msptP95", 0.99, [0.98, 1.01]), - "msptP99": analysis("msptP99", 1.00, [0.99, 1.04]), - }.items(): - (root / f"{metric}.analysis.json").write_text( - json.dumps(document), encoding="utf-8") - parsed = load_analysis_result(root, "droppedItemMs") - assert parsed["scenario"] == SCENARIO + def write_manifest() -> None: + with manifest_path.open("w", encoding="utf-8", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(manifest_rows) - manifest_path = root / "abba-manifest.csv" - with manifest_path.open("w", encoding="utf-8", newline="") as stream: - writer = csv.DictWriter( - stream, fieldnames=["Scenario", "Variant", "RunId", "SourcePath"]) - writer.writeheader() - for index in range(12): - variant = "A" if index % 4 in {0, 3} else "B" - run_id = f"self_test_{variant}_{index + 1:02d}" + for block, pattern in enumerate(patterns, start=1): + for position, variant in enumerate(pattern, start=1): + run_id = f"self_test_{block}_{position}_{variant}" run_root = root / run_id run_root.mkdir() + run_roots.append(run_root) + config_sha = hash_values[f"config{variant}"] + plugin_sha = hash_values[f"plugin{variant}"] metrics = { + "label": run_id, + "abFactor": AB_FACTOR, "droppedSourceOwnedSectionCandidates": variant == "B", + "droppedTrackedItemsMin": 2_048, "droppedTrackedItemsMax": 2_048, + "droppedTrackedItemsEnd": 2_048, + "droppedTrackedItemsSampleCount": 180, + "droppedLabelsMin": 128, "droppedLabelsMax": 128, + "droppedLabelsEnd": 128, + "droppedLabelsSampleCount": 180, "droppedFullScanCandidates": 204_800 if variant == "A" else 0, "droppedSpatialCandidates": 12_800, "droppedViewerDistanceChecks": 12_800, + "legacyTextComponentCacheDisableProperty": False, + "legacyTextComponentCache": True, + "legacyTextCacheRequests": 100, + "legacyTextCacheMisses": 5, + "legacyTextCacheHits": 95, + "legacyTextCacheHitRate": 0.95, + "legacyTextSameRawFastPaths": 50, + "jvmArgumentsSha256": hash_values["jvm"], + "jvmArgumentsNormalizedSha256": hash_values["jvmNormalized"], } (run_root / "iv-perf.json").write_text( json.dumps(metrics), encoding="utf-8") - (run_root / "run-manifest.json").write_text(json.dumps({ + run_manifest = { + "schemaVersion": 7, + "runId": run_id, + "scenario": SCENARIO, + "paperVersion": PAPER_VERSION, + "paperChannel": PAPER_CHANNEL, + "paperBuildId": PAPER_BUILD_ID, + "variant": variant, + "abFactor": AB_FACTOR, + "droppedSourceOwnedSectionCandidates": variant == "B", "itemCount": 2_048, "droppedNearbyItemCount": 128, - }), encoding="utf-8") - writer.writerow({ + "workloadCount": 2_048, + **CANONICAL_WINDOWS, + "pluginSha256": plugin_sha, + "paperSha256": hash_values["paper"], + "clientManifestSha256": hash_values["client"], + "configSha256": config_sha, + "jvmArgumentsSha256": hash_values["jvm"], + "jvmArgumentsNormalizedSha256": hash_values["jvmNormalized"], + "legacyTextComponentCache": { + "disableProperty": False, + "enabled": True, + "requests": 100, + "misses": 5, + "hits": 95, + "hitRate": 0.95, + "sameRawFastPaths": 50, + }, + "jvmDiagnostics": { + "formalEvidenceReady": True, + "finalizedAfterServerExit": True, + "serverStoppedCleanly": True, + "jvmArgumentsSha256": hash_values["jvm"], + "jvmArgumentsNormalizedSha256": hash_values["jvmNormalized"], + "metadataSha256": hash_values["metadata"], + "gcSafepointLog": {"sha256": hash_values["gc"]}, + "processCommandLine": { + "formalEvidenceReady": True, + "capturedFromProcCmdline": True, + "jvmArgumentsSha256": hash_values["jvm"], + "jvmArgumentsNormalizedSha256": hash_values["jvmNormalized"], + "metadataSha256": hash_values["command"], + }, + }, + "sparkProfile": { + "enabled": False, + "mode": "none", + "profileEvidenceReady": None, + "performanceEvidenceReady": True, + "metadataPath": None, + "metadataSha256": None, + }, + } + (run_root / "run-manifest.json").write_text( + json.dumps(run_manifest), encoding="utf-8") + manifest_rows.append({ "Scenario": SCENARIO, + "Block": str(block), + "Position": str(position), "Variant": variant, "RunId": run_id, + "AbFactor": AB_FACTOR, + "DroppedSourceOwnedSectionCandidates": str(variant == "B").lower(), + "LegacyTextComponentCacheDisableProperty": "false", + "LegacyTextComponentCacheEnabled": "true", + "ConfigSha256": config_sha, + "JvmArgumentsSha256": hash_values["jvm"], + "JvmArgumentsNormalizedSha256": hash_values["jvmNormalized"], + "StackSha256": hash_values["stack"], + "ArtifactSha256": plugin_sha, + "CaptureMethod": "none", "SourcePath": f"{run_id}/iv-perf.json", }) + write_manifest() + + analysis_parameters = { + "droppedItemMs": (0.40, [0.35, 0.45]), + "msptP95": (0.99, [0.98, 1.01]), + "msptP99": (1.00, [0.99, 1.04]), + } + + def write_analyses() -> None: + evidence_runs = [] + for row in manifest_rows: + metrics_path = root / row["SourcePath"] + evidence_runs.append({ + "block": int(row["Block"]), + "position": int(row["Position"]), + "variant": row["Variant"], + "runId": row["RunId"], + "value": 1.0, + "sourcePath": str(metrics_path.resolve()), + "sourceSha256": hashlib.sha256(metrics_path.read_bytes()).hexdigest(), + "abFactor": AB_FACTOR, + "configSha256": row["ConfigSha256"], + "jvmArgumentsSha256": row["JvmArgumentsSha256"], + "jvmArgumentsNormalizedSha256": row["JvmArgumentsNormalizedSha256"], + "legacyTextComponentCacheDisableProperty": False, + "legacyTextComponentCacheEnabled": True, + }) + for metric, (ratio, interval) in analysis_parameters.items(): + result = { + "scenario": SCENARIO, + "metric": metric, + "abFactor": AB_FACTOR, + "configSha256": sorted( + {row["ConfigSha256"] for row in manifest_rows}), + "configSha256ByVariant": { + variant: sorted({ + row["ConfigSha256"] for row in manifest_rows + if row["Variant"] == variant + }) for variant in ("A", "B") + }, + "jvmArgumentsSha256ByVariant": { + variant: sorted({ + row["JvmArgumentsSha256"] for row in manifest_rows + if row["Variant"] == variant + }) for variant in ("A", "B") + }, + "jvmArgumentsNormalizedSha256": hash_values["jvmNormalized"], + "captureMethod": "none", + "formalComplete": True, + "stackSha256": hash_values["stack"], + "artifactSha256": { + variant: next( + row["ArtifactSha256"] for row in manifest_rows + if row["Variant"] == variant) + for variant in ("A", "B") + }, + "runCount": FORMAL_RUN_COUNT, + "samplingMode": FORMAL_SAMPLING_MODE, + "pairCount": FORMAL_PAIR_COUNT, + "medianBRatioToA": ratio, + "ratioBootstrap95Ci": interval, + "runs": evidence_runs, + } + document = { + "schemaVersion": 2, + "metric": metric, + "abFactor": AB_FACTOR, + "results": [result], + } + (root / f"{metric}.analysis.json").write_text( + json.dumps(document), encoding="utf-8") + write_analyses() + summary = os.environ.pop("GITHUB_STEP_SUMMARY", None) try: gate = evaluate(manifest_path, root, 2_048, 128, root / "gate.json") + assert gate["serverRuntimeGatePassed"] is True + assert gate["ratios"]["candidateSpatialToBaselineFullScan"] == 0.0625 + + declining_metrics_path = run_roots[0] / "iv-perf.json" + declining_metrics = read_json(declining_metrics_path) + declining_metrics["droppedTrackedItemsMin"] = 2_047 + declining_metrics_path.write_text(json.dumps(declining_metrics), encoding="utf-8") + write_analyses() + declining_gate = evaluate( + manifest_path, root, 2_048, 128, root / "declining-gate.json") + assert declining_gate["serverRuntimeGatePassed"] is False + assert declining_gate["checks"]["allRunsRetainedGlobalPopulation"] is False + declining_metrics["droppedTrackedItemsMin"] = 2_048 + declining_metrics_path.write_text(json.dumps(declining_metrics), encoding="utf-8") + write_analyses() + + noncanonical_path = run_roots[0] / "run-manifest.json" + noncanonical = read_json(noncanonical_path) + noncanonical["warmupSeconds"] = 119 + noncanonical_path.write_text(json.dumps(noncanonical), encoding="utf-8") + assert_rejected( + lambda: load_runs(manifest_path, root, 2_048, 128), + "warmupSeconds provenance drifted") + noncanonical["warmupSeconds"] = CANONICAL_WINDOWS["warmupSeconds"] + noncanonical_path.write_text(json.dumps(noncanonical), encoding="utf-8") + + analysis_path = root / "droppedItemMs.analysis.json" + replaced_analysis = read_json(analysis_path) + replaced_analysis["results"][0]["runs"][0]["runId"] = "replacement-run" + analysis_path.write_text(json.dumps(replaced_analysis), encoding="utf-8") + assert_rejected( + lambda: evaluate(manifest_path, root, 2_048, 128, root / "bad-run.json"), + "analysis runId set") + write_analyses() + + replaced_analysis = read_json(analysis_path) + replaced_analysis["results"][0]["runs"][0]["sourceSha256"] = "9" * 64 + analysis_path.write_text(json.dumps(replaced_analysis), encoding="utf-8") + assert_rejected( + lambda: evaluate(manifest_path, root, 2_048, 128, root / "bad-source.json"), + "analysis sourceSha256") + write_analyses() + + manifest_rows[0]["StackSha256"] = "9" * 64 + write_manifest() + assert_rejected( + lambda: evaluate(manifest_path, root, 2_048, 128, root / "mixed-stack.json"), + "mix stackSha256") + manifest_rows[0]["StackSha256"] = hash_values["stack"] + write_manifest() + + for field, expected_text in ( + ("paperSha256", "mix paperSha256"), + ("clientManifestSha256", "mix clientManifestSha256")): + mixed_path = run_roots[0] / "run-manifest.json" + mixed = read_json(mixed_path) + original = mixed[field] + mixed[field] = "9" * 64 + mixed_path.write_text(json.dumps(mixed), encoding="utf-8") + assert_rejected( + lambda: evaluate( + manifest_path, root, 2_048, 128, root / f"mixed-{field}.json"), + expected_text) + mixed[field] = original + mixed_path.write_text(json.dumps(mixed), encoding="utf-8") + + wrong_capture = read_json(analysis_path) + wrong_capture["results"][0]["captureMethod"] = "spark-cpu" + analysis_path.write_text(json.dumps(wrong_capture), encoding="utf-8") + assert_rejected( + lambda: load_analysis_result(root, "droppedItemMs"), "captureMethod=none") + write_analyses() + + profiler_manifest_path = run_roots[1] / "run-manifest.json" + profiler_manifest = read_json(profiler_manifest_path) + profiler_manifest["sparkProfile"] = { + "enabled": True, + "mode": "alloc", + "profileEvidenceReady": True, + "performanceEvidenceReady": False, + } + profiler_manifest_path.write_text(json.dumps(profiler_manifest), encoding="utf-8") + assert_rejected( + lambda: load_runs(manifest_path, root, 2_048, 128), + "cannot masquerade as clean performance evidence") finally: if summary is not None: os.environ["GITHUB_STEP_SUMMARY"] = summary - assert gate["serverRuntimeGatePassed"] is True - assert gate["ratios"]["candidateSpatialToBaselineFullScan"] == 0.0625 - - invalid = dict(valid) - invalid.pop("results") - (root / "droppedItemMs.analysis.json").write_text( - json.dumps(invalid), encoding="utf-8") - try: - load_analysis_result(root, "droppedItemMs") - except ValueError as exception: - assert "exactly one analysis result" in str(exception) - else: - raise AssertionError("analysis parser accepted a missing schema-v2 results envelope") - print(json.dumps({"passed": True, "analysisSchemaVersion": 2}, separators=(",", ":"))) + print(json.dumps({ + "passed": True, + "analysisSchemaVersion": 2, + "canonicalWindowGate": True, + "analysisEvidenceClosure": True, + "mixedStackPaperClientRejected": True, + "populationWindowGate": True, + "captureMethodGate": True, + "profilerMasqueradeRejected": True, + }, separators=(",", ":"))) def parse_args() -> argparse.Namespace: diff --git a/tools/perf/run-paper-compatibility-smoke.sh b/tools/perf/run-paper-compatibility-smoke.sh new file mode 100644 index 00000000..65e0645d --- /dev/null +++ b/tools/perf/run-paper-compatibility-smoke.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly EXPECTED_PAPER_VERSION="26.2" +readonly EXPECTED_PAPER_BUILD_ID="62" +readonly EXPECTED_PAPER_CHANNEL="BETA" +readonly EXPECTED_PAPER_SHA256="597cb54eef27b318dfb7daef924f9bbe2816e6b0547f0f861b15b42337b6ccd5" + +fail() { + echo "$*" >&2 + exit 1 +} + +validate_server_log() { + local log_path="$1" + + grep -Fq -- "[InteractionVisualizer] Enabled for Paper 26.2!" "$log_path" \ + || { echo "Paper 26.2 enable confirmation is missing" >&2; return 1; } + if grep -Eqi -- "ClientTextDisplayBridge|exact packet-only legacy text displays" "$log_path"; then + echo "ClientTextDisplayBridge initialization warning or exception was logged" >&2 + return 1 + fi + if grep -Eqi -- "Error occurred while enabling InteractionVisualizer|Could not load.*InteractionVisualizer|Exception encountered when loading plugin.*InteractionVisualizer" "$log_path"; then + echo "InteractionVisualizer failed during plugin loading or enable" >&2 + return 1 + fi + grep -Fq -- "Stopping server" "$log_path" \ + || { echo "Paper did not log a normal stop" >&2; return 1; } + grep -Fq -- "[InteractionVisualizer] Disabled; all display entities removed." "$log_path" \ + || { echo "InteractionVisualizer disable confirmation is missing" >&2; return 1; } + + python3 - "$log_path" <<'PY' +import json +from pathlib import Path +import sys + +marker = "IV_PERF_SHUTDOWN " +records = [] +for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines(): + if marker in line: + records.append(json.loads(line.split(marker, 1)[1])) +if len(records) != 1: + raise SystemExit(f"expected exactly one shutdown audit, found {len(records)}") +if records[0].get("totalRetained") != 0: + raise SystemExit(f"shutdown audit retained state: {records[0]!r}") +PY +} + +self_test() { + local test_root + test_root="$(mktemp -d)" + trap 'rm -rf -- "$test_root"' RETURN + + cat > "$test_root/pass.log" <<'EOF' +[Server thread/INFO]: [InteractionVisualizer] Enabled for Paper 26.2! +[Server thread/INFO]: Stopping server +[Server thread/INFO]: [InteractionVisualizer] IV_PERF_SHUTDOWN {"schedulerTasks":0,"totalRetained":0} +[Server thread/INFO]: [InteractionVisualizer] Disabled; all display entities removed. +EOF + validate_server_log "$test_root/pass.log" + + cp "$test_root/pass.log" "$test_root/bridge-failure.log" + printf '%s\n' '[Server thread/ERROR]: ClientTextDisplayBridge initialization failed' \ + >> "$test_root/bridge-failure.log" + if validate_server_log "$test_root/bridge-failure.log" 2>/dev/null; then + fail "self-test accepted a bridge initialization failure" + fi + + cat > "$test_root/retained.log" <<'EOF' +[Server thread/INFO]: [InteractionVisualizer] Enabled for Paper 26.2! +[Server thread/INFO]: Stopping server +[Server thread/INFO]: [InteractionVisualizer] IV_PERF_SHUTDOWN {"schedulerTasks":1,"totalRetained":1} +[Server thread/INFO]: [InteractionVisualizer] Disabled; all display entities removed. +EOF + if validate_server_log "$test_root/retained.log" 2>/dev/null; then + fail "self-test accepted retained shutdown state" + fi + + printf '%s\n' '{"passed":true,"checks":["success-log","bridge-failure","retained-shutdown"]}' +} + +if [[ "${1:-}" == "--self-test" ]]; then + self_test + exit 0 +fi +if [[ $# -ne 0 ]]; then + echo "Usage: $0 [--self-test]" >&2 + exit 64 +fi +case "$(uname -s)" in + Linux*) ;; + *) fail "The real Paper compatibility smoke requires Linux FIFO semantics; use --self-test on this platform" ;; +esac + +for variable in PAPER_COMPAT_PAPER_JAR PAPER_COMPAT_PLUGIN_JAR PAPER_COMPAT_OUTPUT_ROOT; do + [[ -n "${!variable:-}" ]] || { echo "$variable is required" >&2; exit 64; } +done +for command_name in java python3 sha256sum mkfifo unzip; do + command -v "$command_name" >/dev/null \ + || { echo "$command_name is required" >&2; exit 69; } +done + +paper_jar="$(realpath "$PAPER_COMPAT_PAPER_JAR")" +plugin_jar="$(realpath "$PAPER_COMPAT_PLUGIN_JAR")" +output_root="$(realpath -m "$PAPER_COMPAT_OUTPUT_ROOT")" +server_port="${PAPER_COMPAT_SERVER_PORT:-25566}" +startup_timeout_seconds="${PAPER_COMPAT_STARTUP_TIMEOUT_SECONDS:-300}" +shutdown_timeout_seconds="${PAPER_COMPAT_SHUTDOWN_TIMEOUT_SECONDS:-120}" +paper_commit_sha="${PAPER_COMPAT_PAPER_COMMIT_SHA:-unknown}" +source_sha="${PAPER_COMPAT_SOURCE_SHA:-unknown}" + +[[ -f "$paper_jar" ]] || { echo "Paper jar is missing: $paper_jar" >&2; exit 66; } +[[ -f "$plugin_jar" ]] || { echo "Plugin jar is missing: $plugin_jar" >&2; exit 66; } +[[ "$server_port" =~ ^[0-9]+$ ]] && (( server_port >= 1 && server_port <= 65535 )) \ + || { echo "PAPER_COMPAT_SERVER_PORT must be between 1 and 65535" >&2; exit 64; } +for timeout_value in "$startup_timeout_seconds" "$shutdown_timeout_seconds"; do + [[ "$timeout_value" =~ ^[0-9]+$ ]] && (( timeout_value >= 10 && timeout_value <= 600 )) \ + || { echo "Compatibility smoke timeouts must be between 10 and 600 seconds" >&2; exit 64; } +done + +paper_sha256="$(sha256sum "$paper_jar" | awk '{print $1}')" +[[ "$paper_sha256" == "$EXPECTED_PAPER_SHA256" ]] \ + || fail "Paper build $EXPECTED_PAPER_BUILD_ID SHA-256 mismatch: $paper_sha256" +plugin_sha256="$(sha256sum "$plugin_jar" | awk '{print $1}')" +script_sha256="$(sha256sum "${BASH_SOURCE[0]}" | awk '{print $1}')" + +[[ ! -e "$output_root" ]] || fail "Compatibility output already exists: $output_root" +mkdir -p "$output_root" +runtime_directory="$output_root/runtime" +server_log="$output_root/server.log" +manifest_path="$output_root/compatibility-manifest.json" +console_fifo="$runtime_directory/console.pipe" +mkdir -p "$runtime_directory/plugins/InteractionVisualizer" "$runtime_directory/plugins/bStats" +cp "$paper_jar" "$runtime_directory/server.jar" +cp "$plugin_jar" "$runtime_directory/plugins/InteractionVisualizer.jar" +unzip -p "$plugin_jar" config.yml > "$runtime_directory/plugins/InteractionVisualizer/config.yml" +python3 - "$runtime_directory/plugins/InteractionVisualizer/config.yml" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text(encoding="utf-8") +for old, new in ( + (" Updater: true", " Updater: false"), + (" DownloadLanguageFiles: true", " DownloadLanguageFiles: false"), +): + if text.count(old) != 1: + raise SystemExit(f"expected exactly one production config entry: {old!r}") + text = text.replace(old, new) +path.write_text(text, encoding="utf-8") +PY + +cat > "$runtime_directory/plugins/bStats/config.yml" <<'EOF' +enabled: false +serverUuid: 00000000-0000-0000-0000-000000000000 +logFailedRequests: false +logSentData: false +logResponseStatusText: false +EOF +cat > "$runtime_directory/eula.txt" <<'EOF' +eula=true +EOF +cat > "$runtime_directory/server.properties" </dev/null || return 0 + sleep 1 + done + ! kill -0 "$pid" 2>/dev/null +} + +terminate_pid_bounded() { + local pid="$1" + kill -0 "$pid" 2>/dev/null || return 0 + kill -TERM "$pid" 2>/dev/null || true + if ! wait_for_pid_exit "$pid" 10; then + kill -KILL "$pid" 2>/dev/null || true + fi + wait "$pid" 2>/dev/null || true +} + +prune_runtime_payload() { + rm -rf -- \ + "$runtime_directory/cache" \ + "$runtime_directory/libraries" \ + "$runtime_directory/logs" \ + "$runtime_directory/versions" \ + "$runtime_directory/compatibility-world" \ + "$runtime_directory/compatibility-world_nether" \ + "$runtime_directory/compatibility-world_the_end" \ + "$runtime_directory/plugins/.paper-remapped" + rm -f -- \ + "$runtime_directory/server.jar" \ + "$runtime_directory/plugins/InteractionVisualizer.jar" +} + +cleanup() { + local exit_status=$? + if [[ "$cleanup_complete" == 1 ]]; then + return "$exit_status" + fi + cleanup_complete=1 + set +e + if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then + if [[ "$console_open" == 1 ]]; then + printf 'stop\n' >&3 + fi + wait_for_pid_exit "$server_pid" 30 || terminate_pid_bounded "$server_pid" + fi + if [[ "$console_open" == 1 ]]; then + exec 3>&- + console_open=0 + fi + rm -f -- "$console_fifo" + prune_runtime_payload + return "$exit_status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +wait_for_log() { + local pattern="$1" + local timeout_seconds="$2" + local deadline=$(( SECONDS + timeout_seconds )) + while (( SECONDS < deadline )); do + if [[ -f "$server_log" ]] && grep -Fq -- "$pattern" "$server_log"; then + return 0 + fi + if [[ -n "$server_pid" ]] && ! kill -0 "$server_pid" 2>/dev/null; then + echo "Paper exited while waiting for: $pattern" >&2 + tail -n 200 "$server_log" >&2 || true + return 1 + fi + sleep 1 + done + echo "Timed out waiting for Paper log: $pattern" >&2 + tail -n 200 "$server_log" >&2 || true + return 1 +} + +started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +mkfifo "$console_fifo" +exec 3<>"$console_fifo" +console_open=1 +( + cd "$runtime_directory" + exec java -Xms512M -Xmx1G -Dfile.encoding=UTF-8 -jar server.jar --nogui < console.pipe +) > "$server_log" 2>&1 & +server_pid=$! + +wait_for_log "[InteractionVisualizer] Enabled for Paper 26.2!" "$startup_timeout_seconds" +wait_for_log "Done (" "$startup_timeout_seconds" +printf 'stop\n' >&3 + +if ! wait_for_pid_exit "$server_pid" "$shutdown_timeout_seconds"; then + fail "Paper did not stop within $shutdown_timeout_seconds seconds" +fi +set +e +wait "$server_pid" +server_exit_code=$? +set -e +server_pid="" +exec 3>&- +console_open=0 +rm -f -- "$console_fifo" +[[ "$server_exit_code" == 0 ]] || fail "Paper exited with status $server_exit_code" +validate_server_log "$server_log" +completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +python3 - "$manifest_path" "$paper_sha256" "$plugin_sha256" "$script_sha256" \ + "$paper_commit_sha" "$source_sha" "$started_at" "$completed_at" "$server_exit_code" <<'PY' +import json +from pathlib import Path +import sys + +( + output, + paper_sha, + plugin_sha, + script_sha, + paper_commit_sha, + source_sha, + started_at, + completed_at, + server_exit_code, +) = sys.argv[1:] +manifest = { + "schemaVersion": 1, + "result": "passed", + "paper": { + "project": "paper", + "version": "26.2", + "buildId": 62, + "channel": "BETA", + "sha256": paper_sha, + "commitSha": paper_commit_sha, + }, + "plugin": { + "artifact": "current-production-jar", + "sha256": plugin_sha, + "sourceSha": source_sha, + }, + "harness": { + "sha256": script_sha, + "clientRequired": False, + "bridgeEagerInitialization": True, + "startupTimeoutSeconds": int(__import__("os").environ.get( + "PAPER_COMPAT_STARTUP_TIMEOUT_SECONDS", "300" + )), + "shutdownTimeoutSeconds": int(__import__("os").environ.get( + "PAPER_COMPAT_SHUTDOWN_TIMEOUT_SECONDS", "120" + )), + }, + "assertions": { + "enabledForPaper26_2": True, + "clientTextDisplayBridgeInitializationClean": True, + "normalStop": True, + "shutdownTotalRetained": 0, + "serverExitCode": int(server_exit_code), + }, + "serverLog": "server.log", + "startedAt": started_at, + "completedAt": completed_at, +} +Path(output).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") +PY diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index 002d7aa1..b1dcdb8c 100644 --- a/tools/perf/run-phase2-runtime-once.sh +++ b/tools/perf/run-phase2-runtime-once.sh @@ -1003,7 +1003,7 @@ fi python3 - "$server_log" "$run_id" "$run_directory/iv-perf.json" "$scenario" "$variant" \ "$item_count" "$dropped_nearby_item_count" "$ab_factor" "$legacy_text_cache_disable_property" \ "$legacy_text_cache_enabled" "$jvm_arguments_sha256" \ - "$jvm_arguments_normalized_sha256" <<'PY' + "$jvm_arguments_normalized_sha256" "$spark_profile_mode" <<'PY' import json import math import sys @@ -1020,6 +1020,7 @@ import sys expected_cache_enabled_text, jvm_arguments_sha256, jvm_arguments_normalized_sha256, + spark_profile_mode, ) = sys.argv[1:] item_count = int(item_count_text) dropped_nearby_item_count = int(dropped_nearby_item_count_text) @@ -1157,8 +1158,22 @@ if scenario == "dropped-items": distance_checks = metrics.get("droppedViewerDistanceChecks") spatial_candidates = metrics.get("droppedSpatialCandidates") full_scan_candidates = metrics.get("droppedFullScanCandidates") - tracked_max = metrics.get("droppedTrackedItemsMax") - labels_max = metrics.get("droppedLabelsMax") + population_fields = { + "tracked": { + "min": metrics.get("droppedTrackedItemsMin"), + "max": metrics.get("droppedTrackedItemsMax"), + "end": metrics.get("droppedTrackedItemsEnd"), + "samples": metrics.get("droppedTrackedItemsSampleCount"), + "expected": item_count, + }, + "labels": { + "min": metrics.get("droppedLabelsMin"), + "max": metrics.get("droppedLabelsMax"), + "end": metrics.get("droppedLabelsEnd"), + "samples": metrics.get("droppedLabelsSampleCount"), + "expected": dropped_nearby_item_count, + }, + } if (isinstance(dropped_ms, bool) or not isinstance(dropped_ms, (int, float)) or not math.isfinite(dropped_ms) or dropped_ms <= 0): raise SystemExit(f"invalid droppedItemMs={dropped_ms!r}") @@ -1166,19 +1181,30 @@ if scenario == "dropped-items": ("droppedViewerDistanceChecks", distance_checks), ("droppedSpatialCandidates", spatial_candidates), ("droppedFullScanCandidates", full_scan_candidates), - ("droppedTrackedItemsMax", tracked_max), - ("droppedLabelsMax", labels_max), ): if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise SystemExit(f"invalid {field}={value!r}") + for population, values in population_fields.items(): + for statistic in ("min", "max", "end", "samples"): + value = values[statistic] + field = f"dropped{population.title()}{'SampleCount' if statistic == 'samples' else statistic.title()}" + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise SystemExit(f"invalid {field}={value!r}") + if values["samples"] <= 0: + raise SystemExit(f"dropped-item {population} population has no samples") + if not values["min"] <= values["end"] <= values["max"]: + raise SystemExit( + f"dropped-item {population} population summary is inconsistent: {values!r}" + ) + if spark_profile_mode == "none" and any( + values[statistic] != values["expected"] for statistic in ("min", "max", "end")): + raise SystemExit( + f"clean dropped-item sample did not retain its {population} population: " + f"min/max/end={values['min']}/{values['max']}/{values['end']} " + f"expected={values['expected']}" + ) if distance_checks <= 0 or spatial_candidates <= 0: raise SystemExit("dropped-item workload performed no candidate checks") - if tracked_max != item_count or labels_max != dropped_nearby_item_count: - raise SystemExit( - "dropped-item workload did not retain its global/local split: " - f"tracked={tracked_max}/{item_count} " - f"labels={labels_max}/{dropped_nearby_item_count}" - ) if variant == "A" and full_scan_candidates <= 0: raise SystemExit("legacy dropped-item path performed no full candidate scans") if variant == "B" and full_scan_candidates != 0: @@ -1828,7 +1854,16 @@ python3 - \ "$jvm_arguments_normalized_sha256" \ "$spark_profile_mode" \ "$spark_profile_metadata" \ - "$spark_profile_output" <<'PY' + "$spark_profile_output" \ + "$scenario" \ + "$variant" \ + "$run_id" \ + "$item_count" \ + "$dropped_nearby_item_count" \ + "$paper_version" \ + "$warmup_seconds" \ + "$settle_seconds" \ + "$measure_seconds" <<'PY' from pathlib import Path import hashlib import json @@ -1847,6 +1882,15 @@ expected_jvm_arguments_normalized_sha = sys.argv[10] spark_mode = sys.argv[11] spark_metadata_path = Path(sys.argv[12]) spark_profile_path = Path(sys.argv[13]) +expected_scenario = sys.argv[14] +expected_variant = sys.argv[15] +expected_run_id = sys.argv[16] +expected_item_count = int(sys.argv[17]) +expected_nearby_item_count = int(sys.argv[18]) +expected_paper_version = sys.argv[19] +expected_warmup_seconds = int(sys.argv[20]) +expected_settle_seconds = int(sys.argv[21]) +expected_measure_seconds = int(sys.argv[22]) with manifest_path.open(encoding="utf-8") as stream: manifest = json.load(stream) with metadata_path.open(encoding="utf-8") as stream: @@ -1854,6 +1898,44 @@ with metadata_path.open(encoding="utf-8") as stream: with metrics_path.open(encoding="utf-8") as stream: metrics = json.load(stream) +expected_manifest_fields = { + "schemaVersion": 7, + "runId": expected_run_id, + "scenario": expected_scenario, + "paperVersion": expected_paper_version, + "paperChannel": "STABLE", + "paperBuildId": 74, + "variant": expected_variant, + "abFactor": expected_ab_factor, + "itemCount": expected_item_count, + "workloadCount": expected_item_count, + "warmupSeconds": expected_warmup_seconds, + "settleSeconds": expected_settle_seconds, + "measureSeconds": expected_measure_seconds, + "droppedNearbyItemCount": ( + expected_nearby_item_count if expected_scenario == "dropped-items" else None + ), +} +for field, expected in expected_manifest_fields.items(): + if manifest.get(field) != expected: + raise SystemExit( + f"run manifest {field} mismatch: {manifest.get(field)!r} != {expected!r}" + ) +if expected_paper_version != "26.1.2": + raise SystemExit("run manifest is not pinned to canonical Paper 26.1.2") +for field in ( + "pluginSha256", + "paperSha256", + "clientManifestSha256", + "configSha256", + "jvmArgumentsSha256", + "jvmArgumentsNormalizedSha256", +): + value = manifest.get(field) + if (not isinstance(value, str) or len(value) != 64 + or any(character not in "0123456789abcdef" for character in value)): + raise SystemExit(f"run manifest has invalid {field} provenance") + actual_metadata_sha = hashlib.sha256(metadata_path.read_bytes()).hexdigest() if actual_metadata_sha != expected_metadata_sha: raise SystemExit("JVM diagnostic metadata changed before manifest validation") @@ -1926,7 +2008,10 @@ if not isinstance(embedded_spark, dict): if spark_mode == "none": if (embedded_spark.get("enabled") is not False or embedded_spark.get("mode") != "none" - or embedded_spark.get("performanceEvidenceReady") is not True): + or embedded_spark.get("profileEvidenceReady") is not None + or embedded_spark.get("performanceEvidenceReady") is not True + or embedded_spark.get("metadataPath") is not None + or embedded_spark.get("metadataSha256") is not None): raise SystemExit("run manifest incorrectly enables Spark profiling") else: if embedded_spark.get("enabled") is not True or embedded_spark.get("mode") != spark_mode: From 70d24e59106338fee90596f6b3d5acf0c851bbf9 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 20 Jul 2026 12:25:03 +0800 Subject: [PATCH 09/10] Fix packet harness Paper provenance Pass the pinned Paper version, channel, and build through packet runs so strengthened manifest validation accepts only the intended runtime. --- .github/workflows/phase2-packet-capture.yml | 27 ++++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/phase2-packet-capture.yml b/.github/workflows/phase2-packet-capture.yml index 51f6b002..07395618 100644 --- a/.github/workflows/phase2-packet-capture.yml +++ b/.github/workflows/phase2-packet-capture.yml @@ -63,6 +63,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 100 env: + CAMPAIGN_PAPER_VERSION: "26.1.2" + CAMPAIGN_PAPER_BUILD_ID: "74" + CAMPAIGN_PAPER_CHANNEL: STABLE CAMPAIGN_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || contains(github.event.label.name, 'visibility-itemdisplay') && 'visibility-itemdisplay-return' || contains(github.event.label.name, 'visibility-textdisplay') && 'visibility-textdisplay-return' || contains(github.event.label.name, 'visibility') && 'visibility-return' || 'static-spawn' }} CAMPAIGN_RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || contains(github.event.label.name, '-formal') && '12' || '4' }} CAMPAIGN_ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items || contains(github.event.label.name, '-formal') && '4096' || '1024' }} @@ -114,19 +117,29 @@ jobs: - name: Build and test the production plugin run: ./gradlew clean check shadowJar --no-daemon --no-build-cache --rerun-tasks - - name: Download stable Paper 26.1.2 + - name: Download pinned stable Paper 26.1.2 build 74 env: PAPER_USER_AGENT: InteractionVisualizer-Phase2/1.0 (https://github.com/EllanServer/InteractionVisualizer) + PAPER_VERSION: ${{ env.CAMPAIGN_PAPER_VERSION }} + PAPER_BUILD_ID: ${{ env.CAMPAIGN_PAPER_BUILD_ID }} + PAPER_CHANNEL: ${{ env.CAMPAIGN_PAPER_CHANNEL }} run: | mkdir -p phase2-dependencies 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" + "https://fill.papermc.io/v3/projects/paper/versions/$PAPER_VERSION/builds") + PAPER_RECORD=$(echo "$BUILDS" | jq -c \ + --arg channel "$PAPER_CHANNEL" --argjson build_id "$PAPER_BUILD_ID" \ + 'first(.[] | select(.channel == $channel and .id == $build_id)) // empty') + test -n "$PAPER_RECORD" + [[ "$(echo "$PAPER_RECORD" | jq -r '.id')" == "$PAPER_BUILD_ID" ]] + [[ "$(echo "$PAPER_RECORD" | jq -r '.channel')" == "$PAPER_CHANNEL" ]] + PAPER_URL=$(echo "$PAPER_RECORD" | jq -r '.downloads."server:default".url') + PAPER_SHA256=$(echo "$PAPER_RECORD" | jq -r '.downloads."server:default".checksums.sha256') curl --fail --location --show-error \ -H "User-Agent: $PAPER_USER_AGENT" \ --output phase2-dependencies/paper.jar "$PAPER_URL" + echo "$PAPER_SHA256 phase2-dependencies/paper.jar" | sha256sum --check - name: Prepare immutable protocol client artifact run: bash tools/perf/prepare-phase2-protocol-client.sh phase2-dependencies/protocol-client @@ -134,6 +147,9 @@ jobs: - name: Run restart-isolated packet ABBA campaign id: packet_campaign env: + PAPER_VERSION: ${{ env.CAMPAIGN_PAPER_VERSION }} + PAPER_BUILD_ID: ${{ env.CAMPAIGN_PAPER_BUILD_ID }} + PAPER_CHANNEL: ${{ env.CAMPAIGN_PAPER_CHANNEL }} SCENARIO: ${{ env.CAMPAIGN_SCENARIO }} RUNS: ${{ env.CAMPAIGN_RUNS }} ITEMS: ${{ env.CAMPAIGN_ITEMS }} @@ -189,6 +205,9 @@ jobs: PHASE2_PLUGIN_JAR="$PLUGIN_JAR" \ PHASE2_PAPER_JAR=phase2-dependencies/paper.jar \ + PHASE2_PAPER_VERSION="$PAPER_VERSION" \ + PHASE2_PAPER_CHANNEL="$PAPER_CHANNEL" \ + PHASE2_PAPER_BUILD_ID="$PAPER_BUILD_ID" \ PHASE2_CLIENT_ROOT=phase2-dependencies/protocol-client \ PHASE2_OUTPUT_ROOT=phase2-results/packet \ PHASE2_RUN_ID="$run_id" \ From 8b183d196631efeb3542365eac75383489f6d4d7 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Mon, 20 Jul 2026 14:00:51 +0800 Subject: [PATCH 10/10] Fix dropped-item runtime gate policy Align the specialized gate with the documented 5% effect-size rule while retaining confidence, MSPT, provenance, population, and candidate-work safeguards. --- docs/phase2-performance-validation.md | 3 +- tools/perf/evaluate-dropped-item-gate.py | 63 +++++++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/phase2-performance-validation.md b/docs/phase2-performance-validation.md index fff2aeed..aecb892d 100644 --- a/docs/phase2-performance-validation.md +++ b/docs/phase2-performance-validation.md @@ -361,7 +361,7 @@ Get-NetTCPConnection -OwningProcess $serverPid -LocalPort 25566 -State Establish - `displaySyncs`、`itemSyncs`、`virtualViewerChecks`。 - 通用限流的 `visibilityShowsQueued`、`visibilityShowsDrained`;旧 `DroppedItemDisplay` 标签桶不使用这两个计数器。 - `blockUpdateChecks`、`blockUpdateMs` 只统计各 display type 子 updater 的实际检查/更新调用;legacy parent collection iteration 和旧 task scheduling 不在该计时器内,必须从 MSPT/整体热路径判断。`Settings.Performance.BlockUpdates.MaxDirtyPerTick` 是每个 display type 的独立 dirty budget,不是所有熔炉类型共享的全局上限。 -- `itemAnimationMs`、`droppedItemMs`,需要除以 seconds、item 数和 viewer 数后比较。 +- `itemAnimationMs`、`droppedItemMs`,需要除以 seconds、item 数和 viewer 数后比较。`droppedItemMs` 是整个标签刷新 pass 的累计耗时,既包含候选来源,也包含对保留标签的格式化与 Paper metadata 更新;候选来源专项必须同时用 full-scan/spatial/viewer-check 计数证明复杂度变化,不能把整个 pass 的任意固定降幅当作候选查询本身的代理。 `knownVirtualPackets` 是插件已知的顶层 Sparrow 调用/Bundle 数,不等于 Bundle 内逻辑包数、TCP segment 数或编码后字节。 @@ -435,6 +435,7 @@ $json = "D:\iv-ab\2026-07-13\S2_A_01.presentmon.json" - 静止动画锚点:`bukkitEntityTeleports` 至少下降 95%;`virtualTeleportBundles` 与 A 相差不超过 1%;客户端轨迹和拾取终点一致。 - 通用 128/32 可见性:每玩家首个 drain tick show 不超过 128,后续每 tick 不超过 32;2048 个 `DisplayManager` 实体不超过 63 ticks 完成;`visibilityShowsQueued/Drained` 与去重、取消结果一致;50ms 峰值下行 payload 至少下降 70%,完整收敛窗口总 payload 与 A 相差不超过 5%。 - 旧 dropped-label 128/32 回归:同样满足 128/32 与 63-tick 收敛上限,但使用真实标签的 Bukkit show/hide、ghost 检查和抓包计数,不得引用通用队列计数器作为证据。 +- dropped-item section candidate source:固定 2048 个全局跟踪 item、128 个 nearby label;B 的 full-scan 必须为 0,B 的 spatial candidates 与 viewer distance checks 各自不超过 A full-scan 的 10%。整个 `droppedItemMs` pass 仍按统一目标门执行:配对中位数至少改善 5%,且 bootstrap 95% CI 上界低于 ratio 1.0;MSPT 继续使用 1.02/1.05 守门。allocation profiler 仅用于归因,不能冒充 clean effect size。 - 事件驱动 idle:1024 idle block 的相关 CPU/累计耗时至少下降 70%;100% active 的 p95/p99 不超过通用守门;标准事件在 2 ticks 内反映,非标准直接写入在约定 fallback audit 周期内修正。 - FPS:p95 frame time 不恶化超过 2%,1% low 不下降超过 3%,且没有视觉正确性回归。 diff --git a/tools/perf/evaluate-dropped-item-gate.py b/tools/perf/evaluate-dropped-item-gate.py index 69b80703..170723c4 100644 --- a/tools/perf/evaluate-dropped-item-gate.py +++ b/tools/perf/evaluate-dropped-item-gate.py @@ -31,6 +31,9 @@ FORMAL_RUN_COUNT = 12 FORMAL_SAMPLING_MODE = "paired-adjacent" FORMAL_PAIR_COUNT = 6 +MINIMUM_TARGET_IMPROVEMENT = 0.05 +MAXIMUM_TARGET_MEDIAN_RATIO = 1.0 - MINIMUM_TARGET_IMPROVEMENT +MAXIMUM_CANDIDATE_WORK_RATIO = 0.10 def read_json(path: Path) -> dict[str, Any]: @@ -468,8 +471,10 @@ def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, candidate_viewer_ratio = candidate_viewer_checks / baseline_full_scan checks = { - "droppedItemMedianRatioAtMost0_50": dropped["medianBRatioToA"] <= 0.50, - "droppedItemCiExcludesRegression": dropped["ratioBootstrap95Ci"][1] < 1.0, + "droppedItemMedianImprovementAtLeast5Percent": + dropped["medianBRatioToA"] <= MAXIMUM_TARGET_MEDIAN_RATIO, + "droppedItemBootstrap95CiUpperBelow1_00": + dropped["ratioBootstrap95Ci"][1] < 1.0, "msptP95CiUpperAtMost1_02": p95["ratioBootstrap95Ci"][1] <= 1.02, "msptP99CiUpperAtMost1_05": p99["ratioBootstrap95Ci"][1] <= 1.05, "allRunsRetainedGlobalPopulation": all( @@ -482,18 +487,29 @@ def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, run["fullScanCandidates"] > 0 for run in runs if run["variant"] == "A"), "candidateTreatmentIsolated": all( run["sourceOwned"] is (run["variant"] == "B") for run in runs), - "candidateSpatialCandidatesAtMost10PercentOfBaseline": candidate_spatial_ratio <= 0.10, - "candidateViewerChecksAtMost10PercentOfBaseline": candidate_viewer_ratio <= 0.10, + "candidateSpatialCandidatesAtMost10PercentOfBaseline": + candidate_spatial_ratio <= MAXIMUM_CANDIDATE_WORK_RATIO, + "candidateViewerChecksAtMost10PercentOfBaseline": + candidate_viewer_ratio <= MAXIMUM_CANDIDATE_WORK_RATIO, } formal_complete = all( result.get("formalComplete") is True for result in analyses.values()) passed = formal_complete and all(checks.values()) gate = { - "schemaVersion": 2, + "schemaVersion": 3, "scenario": SCENARIO, "abFactor": AB_FACTOR, "formalComplete": formal_complete, "serverRuntimeGatePassed": passed, + "acceptancePolicy": { + "targetMetric": "droppedItemMs", + "minimumMedianImprovementPercent": MINIMUM_TARGET_IMPROVEMENT * 100.0, + "bootstrap95CiUpperMustBeBelow": 1.0, + "msptP95CiUpperAtMost": 1.02, + "msptP99CiUpperAtMost": 1.05, + "candidateWorkRatioAtMost": MAXIMUM_CANDIDATE_WORK_RATIO, + "basis": "docs/phase2-performance-validation.md section 7", + }, "workload": { "trackedItems": expected_items, "nearbyLabels": expected_nearby_items, @@ -532,9 +548,11 @@ def evaluate(manifest_path: Path, evidence_root: Path, expected_items: int, f"Workload: `{expected_items}` tracked / `{expected_nearby_items}` nearby labels.\n\n") stream.write( f"droppedItemMs B/A: `{dropped['medianBRatioToA']}` " - f"(95% CI `{dropped['ratioBootstrap95Ci']}`).\n\n") + f"(95% CI `{dropped['ratioBootstrap95Ci']}`; required median " + f"`<= {MAXIMUM_TARGET_MEDIAN_RATIO:.2f}` and CI upper `< 1.0`).\n\n") stream.write( - f"Candidate/full-scan ratio: `{candidate_spatial_ratio:.6f}`.\n\n") + f"Candidate/full-scan ratio: `{candidate_spatial_ratio:.6f}` " + f"(required `<= {MAXIMUM_CANDIDATE_WORK_RATIO:.2f}`).\n\n") stream.write( f"MSPT p95 CI upper: `{p95['ratioBootstrap95Ci'][1]}`; " f"p99 CI upper: `{p99['ratioBootstrap95Ci'][1]}`.\n") @@ -700,7 +718,7 @@ def write_manifest() -> None: write_manifest() analysis_parameters = { - "droppedItemMs": (0.40, [0.35, 0.45]), + "droppedItemMs": (0.90, [0.85, 0.94]), "msptP95": (0.99, [0.98, 1.01]), "msptP99": (1.00, [0.99, 1.04]), } @@ -773,9 +791,35 @@ def write_analyses() -> None: summary = os.environ.pop("GITHUB_STEP_SUMMARY", None) try: gate = evaluate(manifest_path, root, 2_048, 128, root / "gate.json") + assert gate["schemaVersion"] == 3 assert gate["serverRuntimeGatePassed"] is True + assert gate["checks"]["droppedItemMedianImprovementAtLeast5Percent"] is True + assert gate["checks"]["droppedItemBootstrap95CiUpperBelow1_00"] is True assert gate["ratios"]["candidateSpatialToBaselineFullScan"] == 0.0625 + analysis_parameters["droppedItemMs"] = (0.96, [0.92, 0.99]) + write_analyses() + small_effect_gate = evaluate( + manifest_path, root, 2_048, 128, root / "small-effect-gate.json") + assert small_effect_gate["serverRuntimeGatePassed"] is False + assert small_effect_gate["checks"][ + "droppedItemMedianImprovementAtLeast5Percent"] is False + assert small_effect_gate["checks"][ + "droppedItemBootstrap95CiUpperBelow1_00"] is True + + analysis_parameters["droppedItemMs"] = (0.90, [0.85, 1.01]) + write_analyses() + uncertain_effect_gate = evaluate( + manifest_path, root, 2_048, 128, root / "uncertain-effect-gate.json") + assert uncertain_effect_gate["serverRuntimeGatePassed"] is False + assert uncertain_effect_gate["checks"][ + "droppedItemMedianImprovementAtLeast5Percent"] is True + assert uncertain_effect_gate["checks"][ + "droppedItemBootstrap95CiUpperBelow1_00"] is False + + analysis_parameters["droppedItemMs"] = (0.90, [0.85, 0.94]) + write_analyses() + declining_metrics_path = run_roots[0] / "iv-perf.json" declining_metrics = read_json(declining_metrics_path) declining_metrics["droppedTrackedItemsMin"] = 2_047 @@ -864,6 +908,9 @@ def write_analyses() -> None: print(json.dumps({ "passed": True, "analysisSchemaVersion": 2, + "gateSchemaVersion": 3, + "documentedEffectSizeGate": True, + "confidenceIntervalImprovementGate": True, "canonicalWindowGate": True, "analysisEvidenceClosure": True, "mixedStackPaperClientRejected": True,