From 1ca11a706e038688024cfff15eb8654c68c8cc35 Mon Sep 17 00:00:00 2001 From: Dean Mahmood Date: Sun, 22 Mar 2026 13:11:34 +0000 Subject: [PATCH] ## 1.2.4 Task Manager 1.2.4 focuses on conflict analysis, clearer attribution, and more reliable session capture. ### Added - Pairwise lock attribution that records both the waiting thread and the lock owner thread when the JVM exposes that information - Repeated conflict tracking across the rolling capture window so the mod can highlight recurring `waiter mod -> owner mod` contention instead of isolated one-off waits - Broader thread role classification for worker pools, IO pools, chunk generation, chunk meshing, chunk upload, network, GC, render, and main logic threads - Alternate owner candidates in thread drilldown so low-confidence ownership can be reviewed instead of being flattened into a single guess - Dedicated conflict findings in the UI and HTML export, split into confirmed contention, repeated conflict candidates, weak heuristics, and unrelated slowdown causes ### Changed - Scheduling-conflict detection no longer depends only on `Worker-Main-*` thread names and now uses thread names plus stack ancestry - Conflict wording is more conservative and now distinguishes `Measured`, `Inferred`, `Pairwise inferred`, `Weak heuristic`, and reserved support for `Known incompatibility` - Lock summaries prefer pairwise conflict phrasing when both sides of a wait can be identified - Export summaries and diagnosis text now include top conflict candidates instead of only generic lock warnings - Thread detail views now expose role labels, role-source labels, and alternate owner candidates ### Fixed - `MANUAL_DEEP` recording now continues to capture session samples after recording starts, even if the Task Manager screen is closed - Session exports no longer fall back to `No session samples were captured` for valid `MANUAL_DEEP` recordings started with `F11` - Conflict and slowdown reporting no longer overstates certainty by calling inferred waits confirmed mod conflicts ### Notes - `Known incompatibility` is currently a confidence tier and UI label, not a populated incompatibility database. In 1.2.4, conflict findings are still based on measured contention plus inferred ownership rather than a bundled registry of hardcoded bad mod pairs. --- .../gradle-9.2.1-bin.zip.lck | 0 .../gradle-9.2.1-bin.zip.part | 0 CHANGELOG.md | 31 + README.md | 5 +- gradle.properties | 2 +- .../client/AttributionInsights.java | 376 +++ .../client/AttributionModelBuilder.java | 450 +++ .../taskmanager/client/ChunkWorkProfiler.java | 114 + .../taskmanager/client/CollectorMath.java | 35 + .../client/CpuSamplingProfiler.java | 247 +- .../client/EntityCostProfiler.java | 156 + .../client/FlamegraphProfiler.java | 68 +- .../client/FrameTimelineProfiler.java | 96 +- .../client/HardwareInfoResolver.java | 62 + .../client/HudOverlayRenderer.java | 271 +- .../taskmanager/client/MemoryProfiler.java | 188 +- .../client/ModalDialogRenderer.java | 17 + .../client/NativeWindowsSensors.java | 144 +- .../taskmanager/client/ProfilerManager.java | 1719 +++++++---- .../client/RenderPhaseProfiler.java | 143 +- .../wueffi/taskmanager/client/RuleEngine.java | 306 ++ .../taskmanager/client/SearchState.java | 28 + .../taskmanager/client/SessionExporter.java | 241 ++ .../client/ShaderCompilationProfiler.java | 117 + .../client/SystemMetricsProfiler.java | 726 ++++- .../taskmanager/client/TaskManagerScreen.java | 2747 +++++++---------- .../client/TelemetryTextFormatter.java | 46 + .../client/TextureUploadProfiler.java | 91 + .../client/ThreadLoadProfiler.java | 39 +- .../client/ThreadSnapshotCollector.java | 179 ++ .../taskmanager/client/TickProfiler.java | 4 +- .../taskmanager/client/TooltipManager.java | 63 + .../client/WindowsTelemetryBridge.java | 395 ++- .../client/mixin/ArrayBackedEventMixin.java | 71 +- .../client/mixin/ChunkGeneratorMixin.java | 51 + .../taskmanager/client/mixin/EntityMixin.java | 29 + .../mixin/EntityRenderManagerMixin.java | 30 + .../client/mixin/GameRendererMixin.java | 16 +- ...isDeferredWorldRenderingPipelineMixin.java | 88 + .../IrisNewWorldRenderingPipelineMixin.java | 88 + .../IrisVanillaRenderingPipelineMixin.java | 88 + .../mixin/MinecraftServerSaveMixin.java | 29 + .../client/mixin/ParticleManagerMixin.java | 31 + .../client/mixin/ServerChunkManagerMixin.java | 29 + .../client/mixin/ShaderProgramMixin.java | 31 + .../client/mixin/SkyRenderingMixin.java | 2 +- .../mixin/SodiumWorldRendererMixin.java | 94 + .../client/mixin/TextureManagerMixin.java | 63 + .../client/mixin/WorldRendererMixin.java | 152 +- .../client/tabs/DiskTabRenderer.java | 34 + .../client/tabs/FlamegraphTabRenderer.java | 42 + .../client/tabs/GpuTabRenderer.java | 126 + .../client/tabs/MemoryTabRenderer.java | 183 ++ .../client/tabs/NetworkTabRenderer.java | 63 + .../client/tabs/RenderTabRenderer.java | 80 + .../client/tabs/SettingsTabRenderer.java | 179 ++ .../client/tabs/StartupTabRenderer.java | 104 + .../client/tabs/SystemTabRenderer.java | 189 ++ .../taskmanager/client/tabs/TabRenderer.java | 25 + .../client/tabs/TasksTabRenderer.java | 114 + .../client/tabs/ThreadsTabRenderer.java | 96 + .../client/tabs/TimelineTabRenderer.java | 84 + .../client/tabs/WorldTabRenderer.java | 132 + .../taskmanager/client/util/BoundedMaps.java | 51 + .../client/util/ConfigManager.java | 180 +- .../taskmanager/client/util/GpuTimer.java | 87 +- .../client/util/ModClassIndex.java | 87 +- .../resources/taskmanager.client.mixins.json | 11 + .../client/AttributionInsightsTests.java | 67 + .../client/AttributionModelBuilderTests.java | 79 + .../taskmanager/client/BoundedMapsTests.java | 52 + .../client/CollectorMathTests.java | 38 + .../client/ConfigManagerMigrationTests.java | 164 +- .../client/CpuSamplingProfilerTests.java | 59 + .../client/FrameTimelineProfilerTests.java | 33 + .../client/HardwareInfoResolverTests.java | 18 + .../client/ProfilerManagerTests.java | 8 + .../client/RenderPhaseProfilerTests.java | 71 + .../taskmanager/client/RuleEngineTests.java | 106 + .../client/SessionExporterTests.java | 70 + .../client/SystemMetricsProfilerTests.java | 52 + .../client/TaskManagerScreenLayoutTests.java | 1 + .../client/TaskManagerTestRunner.java | 11 + .../client/WindowsTelemetryBridgeTests.java | 51 + taskmanager-test-config.json | 8 +- 85 files changed, 9995 insertions(+), 2758 deletions(-) delete mode 100644 .gradle-user-home-verify-tests/wrapper/dists/gradle-9.2.1-bin/2t0n5ozlw9xmuyvbp7dnzaxug/gradle-9.2.1-bin.zip.lck delete mode 100644 .gradle-user-home-verify-tests/wrapper/dists/gradle-9.2.1-bin/2t0n5ozlw9xmuyvbp7dnzaxug/gradle-9.2.1-bin.zip.part create mode 100644 CHANGELOG.md create mode 100644 src/client/java/wueffi/taskmanager/client/AttributionInsights.java create mode 100644 src/client/java/wueffi/taskmanager/client/AttributionModelBuilder.java create mode 100644 src/client/java/wueffi/taskmanager/client/ChunkWorkProfiler.java create mode 100644 src/client/java/wueffi/taskmanager/client/CollectorMath.java create mode 100644 src/client/java/wueffi/taskmanager/client/EntityCostProfiler.java create mode 100644 src/client/java/wueffi/taskmanager/client/HardwareInfoResolver.java create mode 100644 src/client/java/wueffi/taskmanager/client/ModalDialogRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/RuleEngine.java create mode 100644 src/client/java/wueffi/taskmanager/client/SearchState.java create mode 100644 src/client/java/wueffi/taskmanager/client/SessionExporter.java create mode 100644 src/client/java/wueffi/taskmanager/client/ShaderCompilationProfiler.java create mode 100644 src/client/java/wueffi/taskmanager/client/TelemetryTextFormatter.java create mode 100644 src/client/java/wueffi/taskmanager/client/TextureUploadProfiler.java create mode 100644 src/client/java/wueffi/taskmanager/client/ThreadSnapshotCollector.java create mode 100644 src/client/java/wueffi/taskmanager/client/TooltipManager.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/ChunkGeneratorMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/EntityMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/EntityRenderManagerMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/IrisDeferredWorldRenderingPipelineMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/IrisNewWorldRenderingPipelineMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/IrisVanillaRenderingPipelineMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/MinecraftServerSaveMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/ParticleManagerMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/ServerChunkManagerMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/ShaderProgramMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/SodiumWorldRendererMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/mixin/TextureManagerMixin.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/DiskTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/FlamegraphTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/GpuTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/MemoryTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/NetworkTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/RenderTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/SettingsTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/StartupTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/SystemTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/TabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/TasksTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/ThreadsTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/TimelineTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/tabs/WorldTabRenderer.java create mode 100644 src/client/java/wueffi/taskmanager/client/util/BoundedMaps.java create mode 100644 src/test/java/wueffi/taskmanager/client/AttributionInsightsTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/AttributionModelBuilderTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/BoundedMapsTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/CollectorMathTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/CpuSamplingProfilerTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/HardwareInfoResolverTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/RenderPhaseProfilerTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/RuleEngineTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/SessionExporterTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/SystemMetricsProfilerTests.java create mode 100644 src/test/java/wueffi/taskmanager/client/WindowsTelemetryBridgeTests.java diff --git a/.gradle-user-home-verify-tests/wrapper/dists/gradle-9.2.1-bin/2t0n5ozlw9xmuyvbp7dnzaxug/gradle-9.2.1-bin.zip.lck b/.gradle-user-home-verify-tests/wrapper/dists/gradle-9.2.1-bin/2t0n5ozlw9xmuyvbp7dnzaxug/gradle-9.2.1-bin.zip.lck deleted file mode 100644 index e69de29..0000000 diff --git a/.gradle-user-home-verify-tests/wrapper/dists/gradle-9.2.1-bin/2t0n5ozlw9xmuyvbp7dnzaxug/gradle-9.2.1-bin.zip.part b/.gradle-user-home-verify-tests/wrapper/dists/gradle-9.2.1-bin/2t0n5ozlw9xmuyvbp7dnzaxug/gradle-9.2.1-bin.zip.part deleted file mode 100644 index e69de29..0000000 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0f5e92e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +## 1.2.4 + +Task Manager 1.2.4 focuses on conflict analysis, clearer attribution, and more reliable session capture. + +### Added + +- Pairwise lock attribution that records both the waiting thread and the lock owner thread when the JVM exposes that information +- Repeated conflict tracking across the rolling capture window so the mod can highlight recurring `waiter mod -> owner mod` contention instead of isolated one-off waits +- Broader thread role classification for worker pools, IO pools, chunk generation, chunk meshing, chunk upload, network, GC, render, and main logic threads +- Alternate owner candidates in thread drilldown so low-confidence ownership can be reviewed instead of being flattened into a single guess +- Dedicated conflict findings in the UI and HTML export, split into confirmed contention, repeated conflict candidates, weak heuristics, and unrelated slowdown causes + +### Changed + +- Scheduling-conflict detection no longer depends only on `Worker-Main-*` thread names and now uses thread names plus stack ancestry +- Conflict wording is more conservative and now distinguishes `Measured`, `Inferred`, `Pairwise inferred`, `Weak heuristic`, and reserved support for `Known incompatibility` +- Lock summaries prefer pairwise conflict phrasing when both sides of a wait can be identified +- Export summaries and diagnosis text now include top conflict candidates instead of only generic lock warnings +- Thread detail views now expose role labels, role-source labels, and alternate owner candidates + +### Fixed + +- `MANUAL_DEEP` recording now continues to capture session samples after recording starts, even if the Task Manager screen is closed +- Session exports no longer fall back to `No session samples were captured` for valid `MANUAL_DEEP` recordings started with `F11` +- Conflict and slowdown reporting no longer overstates certainty by calling inferred waits confirmed mod conflicts + +### Notes + +- `Known incompatibility` is currently a confidence tier and UI label, not a populated incompatibility database. In 1.2.4, conflict findings are still based on measured contention plus inferred ownership rather than a bundled registry of hardcoded bad mod pairs. diff --git a/README.md b/README.md index 8175fbb..bf82112 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ ![Static Badge](https://img.shields.io/badge/Version-1.2.3-blue) ![Modrinth Downloads](https://img.shields.io/modrinth/dt/taskmanager) + # Task Manager Task Manager is a Fabric client profiler for modded Minecraft that helps you identify frame drops, high MSPT, hot mods, rendering spikes, block-entity pressure, and JVM/runtime issues while the game is still running. @@ -65,8 +66,11 @@ Session exports include a polished summary with: - top memory mods - hot chunk summary - block entity classes +- repeated conflict edges and pairwise lock contention summaries - rule findings and sensor diagnostics +For `MANUAL_DEEP`, press `F11` to start recording, play normally while reproducing the issue, then press `F11` again to stop. The session will continue recording even if the Task Manager screen is closed. + ## Notes on Accuracy Task Manager aims to be honest about what is measured versus estimated: @@ -78,4 +82,3 @@ Task Manager aims to be honest about what is measured versus estimated: - For CPU temperature on Windows, running Core Temp is the primary recommended setup; LibreHardwareMonitor/OpenHardwareMonitor and HWiNFO can also work That means the profiler is very useful for finding hotspots and spikes, but some values, especially GPU-per-mod and shared JVM memory, should be interpreted as guidance rather than perfect ground truth. - diff --git a/gradle.properties b/gradle.properties index b733113..6b573cd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ minecraft_version=1.21.11 yarn_mappings=1.21.11+build.1 loader_version=0.18.4 # Mod Properties -mod_version=1.2.3 +mod_version=1.2.4 maven_group=wueffi archives_base_name=TaskManager # Dependencies diff --git a/src/client/java/wueffi/taskmanager/client/AttributionInsights.java b/src/client/java/wueffi/taskmanager/client/AttributionInsights.java new file mode 100644 index 0000000..249efd8 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/AttributionInsights.java @@ -0,0 +1,376 @@ +package wueffi.taskmanager.client; + +import wueffi.taskmanager.client.util.ModClassIndex; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +final class AttributionInsights { + + enum Confidence { + MEASURED("Measured"), + INFERRED("Inferred"), + PAIRWISE_INFERRED("Pairwise inferred"), + KNOWN_INCOMPATIBILITY("Known incompatibility"), + WEAK_HEURISTIC("Weak heuristic"); + + private final String label; + + Confidence(String label) { + this.label = label; + } + + String label() { + return label; + } + } + + record AttributionCandidate(String modId, Confidence confidence, String reasonFrame, int weightPercent) { + String displayLabel() { + return modId + " " + weightPercent + "% | " + confidence.label() + " | " + reasonFrame; + } + } + + record ThreadAttribution(String ownerMod, Confidence confidence, String reasonFrame, List topFrames, List candidates) { + List candidateLabels() { + return candidates.stream() + .map(AttributionCandidate::displayLabel) + .toList(); + } + } + + private AttributionInsights() { + } + + static Confidence cpuConfidence(String modId, CpuSamplingProfiler.DetailSnapshot detail, long rawSamples, long shownSamples, long redistributedSamples) { + if (modId == null) { + return Confidence.WEAK_HEURISTIC; + } + if (isSharedAttributionBucket(modId)) { + return Confidence.INFERRED; + } + if (isLowConfidenceCpuAttribution(detail, rawSamples, shownSamples, redistributedSamples)) { + return Confidence.WEAK_HEURISTIC; + } + if (redistributedSamples > 0L || isRenderSubmissionHeavy(detail)) { + return Confidence.INFERRED; + } + return rawSamples > 0L ? Confidence.MEASURED : Confidence.WEAK_HEURISTIC; + } + + static Confidence gpuConfidence(String modId, long rawGpuNanos, long displayGpuNanos, long redistributedGpuNanos, long rawRenderSamples, long displayRenderSamples) { + if (modId == null) { + return Confidence.WEAK_HEURISTIC; + } + if (isSharedAttributionBucket(modId)) { + return Confidence.INFERRED; + } + if ((rawGpuNanos <= 0L && redistributedGpuNanos > 0L) + || (displayGpuNanos > 0L && redistributedGpuNanos * 10L >= displayGpuNanos * 7L) + || (rawRenderSamples < 4L && displayRenderSamples > 0L && displayRenderSamples > rawRenderSamples)) { + return Confidence.WEAK_HEURISTIC; + } + if (redistributedGpuNanos > 0L || displayRenderSamples > rawRenderSamples) { + return Confidence.INFERRED; + } + return rawGpuNanos > 0L ? Confidence.MEASURED : Confidence.WEAK_HEURISTIC; + } + + static Confidence memoryConfidence(String modId, long rawBytes, long displayBytes, long redistributedBytes, long memoryAgeMillis) { + if (modId == null) { + return Confidence.WEAK_HEURISTIC; + } + if (isSharedAttributionBucket(modId)) { + return Confidence.INFERRED; + } + if (memoryAgeMillis > 15_000L || (rawBytes <= 0L && redistributedBytes > 0L && displayBytes > 0L)) { + return Confidence.WEAK_HEURISTIC; + } + if (redistributedBytes > 0L) { + return Confidence.INFERRED; + } + return rawBytes > 0L ? Confidence.MEASURED : Confidence.WEAK_HEURISTIC; + } + + static String cpuProvenance(long rawSamples, long redistributedSamples, CpuSamplingProfiler.DetailSnapshot detail) { + long renderSubmission = countOpaqueFrames(detail, true); + return rawSamples + " raw | " + redistributedSamples + " redist | " + renderSubmission + " render-sub"; + } + + static String gpuProvenance(long rawGpuNanos, long redistributedGpuNanos, long rawRenderSamples, long displayRenderSamples) { + return String.format(Locale.ROOT, "%.2f ms raw | %.2f ms redist | %d/%d render samp", + rawGpuNanos / 1_000_000.0, + redistributedGpuNanos / 1_000_000.0, + rawRenderSamples, + displayRenderSamples); + } + + static String memoryProvenance(long rawBytes, long redistributedBytes, long memoryAgeMillis) { + return String.format(Locale.ROOT, "%.1f MB raw | %.1f MB redist | age %d ms", + rawBytes / (1024.0 * 1024.0), + redistributedBytes / (1024.0 * 1024.0), + memoryAgeMillis); + } + + static ThreadAttribution attributeThread(String threadName, StackTraceElement[] stack) { + if (stack == null || stack.length == 0) { + String fallback = fallbackThreadOwner(threadName); + return new ThreadAttribution( + fallback, + Confidence.WEAK_HEURISTIC, + "unknown-frame", + List.of(), + List.of(new AttributionCandidate(fallback, Confidence.WEAK_HEURISTIC, "unknown-frame", 100)) + ); + } + String firstConcrete = null; + String firstFramework = null; + String firstKnown = null; + String reason = "unknown-frame"; + List topFrames = new ArrayList<>(3); + Map weightsByMod = new LinkedHashMap<>(); + Map reasonsByMod = new LinkedHashMap<>(); + int relevantDepth = 0; + for (StackTraceElement frame : stack) { + String className = frame.getClassName(); + if (className.startsWith("wueffi.taskmanager.")) { + continue; + } + relevantDepth++; + if (topFrames.size() < 3 && !isOpaqueRuntimeFrame(className)) { + topFrames.add(formatFrameReason(frame)); + } + String mod = resolveModForClassName(className); + if (firstKnown == null && mod != null) { + firstKnown = mod; + reason = formatFrameReason(frame); + } + if (mod == null || "unknown".equals(mod)) { + continue; + } + double weight = frameWeight(relevantDepth, className, mod); + weightsByMod.merge(mod, weight, Double::sum); + reasonsByMod.putIfAbsent(mod, formatFrameReason(frame)); + if (isFrameworkMod(mod, className)) { + if (firstFramework == null) { + firstFramework = mod; + } + continue; + } + if (!isSharedAttributionBucket(mod) && firstConcrete == null) { + firstConcrete = mod; + reason = formatFrameReason(frame); + } + } + if (topFrames.isEmpty()) { + for (StackTraceElement frame : stack) { + if (!frame.getClassName().startsWith("wueffi.taskmanager.")) { + topFrames.add(formatFrameReason(frame)); + } + if (topFrames.size() >= 3) { + break; + } + } + } + List candidates = buildCandidates(weightsByMod, reasonsByMod); + AttributionCandidate leadCandidate = candidates.isEmpty() ? null : candidates.getFirst(); + if (firstConcrete != null) { + Confidence confidence = isOpaqueRenderSubmissionFrame(topFrames.isEmpty() ? reason : topFrames.getFirst()) + ? Confidence.WEAK_HEURISTIC + : (leadCandidate != null && firstConcrete.equals(leadCandidate.modId()) && leadCandidate.weightPercent() >= 55 + ? Confidence.INFERRED + : Confidence.WEAK_HEURISTIC); + return new ThreadAttribution(firstConcrete, confidence, reason, List.copyOf(topFrames), candidates); + } + if (firstFramework != null) { + return new ThreadAttribution(firstFramework, Confidence.WEAK_HEURISTIC, reason, List.copyOf(topFrames), candidates); + } + String fallback = firstKnown == null ? fallbackThreadOwner(threadName) : firstKnown; + if (candidates.isEmpty()) { + candidates = List.of(new AttributionCandidate(fallback, Confidence.WEAK_HEURISTIC, reason, 100)); + } + return new ThreadAttribution(fallback, Confidence.WEAK_HEURISTIC, reason, List.copyOf(topFrames), candidates); + } + + static boolean isLowConfidenceCpuAttribution(CpuSamplingProfiler.DetailSnapshot detail, long rawSamples, long shownSamples, long redistributedSamples) { + if (detail == null || detail.topFrames() == null || detail.topFrames().isEmpty()) { + return rawSamples <= 0L && redistributedSamples > 0L && shownSamples > 0L; + } + boolean smallRawSampleSet = rawSamples > 0L && rawSamples < 12L; + boolean redistributedHeavy = redistributedSamples > 0L && redistributedSamples * 10L >= Math.max(1L, shownSamples) * 4L; + long opaqueFrames = countOpaqueFrames(detail, false); + long totalFrames = detail.topFrames().values().stream().mapToLong(Long::longValue).sum(); + boolean opaqueFrameHeavy = totalFrames > 0L && opaqueFrames * 10L >= totalFrames * 6L; + return smallRawSampleSet && redistributedHeavy && opaqueFrameHeavy; + } + + static boolean isRenderSubmissionHeavy(CpuSamplingProfiler.DetailSnapshot detail) { + if (detail == null || detail.topFrames() == null || detail.topFrames().isEmpty()) { + return false; + } + boolean renderThreadDominant = detail.topThreads() != null && detail.topThreads().keySet().stream() + .findFirst() + .map(name -> name.toLowerCase(Locale.ROOT).contains("render")) + .orElse(false); + if (!renderThreadDominant) { + return false; + } + long opaqueFrames = countOpaqueFrames(detail, true); + long totalFrames = detail.topFrames().values().stream().mapToLong(Long::longValue).sum(); + return totalFrames > 0L && opaqueFrames * 2L >= totalFrames; + } + + static long countOpaqueFrames(CpuSamplingProfiler.DetailSnapshot detail, boolean renderOnly) { + if (detail == null || detail.topFrames() == null) { + return 0L; + } + return detail.topFrames().entrySet().stream() + .filter(entry -> renderOnly ? isOpaqueRenderSubmissionFrame(entry.getKey()) : isOpaqueCpuAttributionFrame(entry.getKey())) + .mapToLong(Map.Entry::getValue) + .sum(); + } + + static boolean isOpaqueCpuAttributionFrame(String frame) { + if (frame == null) { + return false; + } + String lower = frame.toLowerCase(Locale.ROOT); + return isOpaqueRenderSubmissionFrame(frame) + || lower.startsWith("native#") + || lower.contains("operatingsystemimpl#") + || lower.contains("processcpuload") + || lower.contains("managementfactory") + || lower.contains("spark") + || lower.contains("oshi") + || lower.contains("jna") + || lower.contains("hwinfo") + || lower.contains("telemetry") + || lower.contains("sensor") + || lower.contains("perf"); + } + + static boolean isOpaqueRenderSubmissionFrame(String frame) { + if (frame == null) { + return false; + } + String lower = frame.toLowerCase(Locale.ROOT); + return lower.startsWith("gl") + || lower.startsWith("jni#") + || lower.contains("org.lwjgl") + || lower.contains("framebuffer") + || lower.contains("blaze3d") + || lower.contains("fencesync"); + } + + private static boolean isSharedAttributionBucket(String modId) { + return modId != null && (modId.startsWith("shared/") || modId.startsWith("runtime/")); + } + + private static double frameWeight(int depth, String className, String mod) { + double base = switch (depth) { + case 1 -> 1.0; + case 2 -> 0.82; + case 3 -> 0.68; + case 4 -> 0.56; + case 5 -> 0.46; + case 6 -> 0.38; + default -> Math.max(0.14, 0.32 - ((depth - 7) * 0.02)); + }; + if (isOpaqueRuntimeFrame(className)) { + base *= 0.35; + } + if (isFrameworkMod(mod, className)) { + base *= 0.65; + } else if (isSharedAttributionBucket(mod)) { + base *= 0.5; + } + return base; + } + + private static List buildCandidates(Map weightsByMod, Map reasonsByMod) { + if (weightsByMod.isEmpty()) { + return List.of(); + } + double total = weightsByMod.values().stream().mapToDouble(Double::doubleValue).sum(); + if (total <= 0.0) { + return List.of(); + } + return weightsByMod.entrySet().stream() + .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) + .limit(3) + .map(entry -> new AttributionCandidate( + entry.getKey(), + candidateConfidence(entry.getKey(), entry.getValue(), total), + reasonsByMod.getOrDefault(entry.getKey(), "unknown-frame"), + (int) Math.round(entry.getValue() * 100.0 / total) + )) + .collect(Collectors.toList()); + } + + private static Confidence candidateConfidence(String modId, double weight, double total) { + if (modId == null || total <= 0.0) { + return Confidence.WEAK_HEURISTIC; + } + if (isSharedAttributionBucket(modId)) { + return Confidence.WEAK_HEURISTIC; + } + return weight * 100.0 / total >= 55.0 ? Confidence.INFERRED : Confidence.WEAK_HEURISTIC; + } + + private static String fallbackThreadOwner(String threadName) { + String normalized = threadName == null ? "" : threadName.toLowerCase(Locale.ROOT); + if (normalized.contains("render")) { + return "shared/render"; + } + if (normalized.contains("server")) { + return "minecraft"; + } + return "shared/jvm"; + } + + private static String resolveModForClassName(String className) { + String mod = ModClassIndex.getModForClassName(className); + if (mod != null) { + return mod; + } + if (className.startsWith("net.minecraft.") || className.startsWith("com.mojang.")) { + return "minecraft"; + } + if (className.startsWith("java.") || className.startsWith("javax.") || className.startsWith("jdk.") || className.startsWith("sun.") || className.startsWith("org.lwjgl.")) { + return "shared/jvm"; + } + if (className.startsWith("net.fabricmc.") || className.startsWith("org.spongepowered.asm.")) { + return "shared/framework"; + } + return "unknown"; + } + + private static boolean isFrameworkMod(String mod, String className) { + return "shared/framework".equals(mod) + || "fabricloader".equals(mod) + || mod.startsWith("fabric-") + || mod.startsWith("fabric_api") + || className.startsWith("net.fabricmc.") + || className.startsWith("org.spongepowered.asm."); + } + + private static boolean isOpaqueRuntimeFrame(String className) { + return className.startsWith("java.") + || className.startsWith("javax.") + || className.startsWith("jdk.") + || className.startsWith("sun.") + || className.startsWith("org.lwjgl.") + || className.startsWith("com.mojang.blaze3d."); + } + + private static String formatFrameReason(StackTraceElement frame) { + String className = frame.getClassName(); + int lastDot = className.lastIndexOf('.'); + String simpleName = lastDot >= 0 ? className.substring(lastDot + 1) : className; + return simpleName + "#" + frame.getMethodName(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/AttributionModelBuilder.java b/src/client/java/wueffi/taskmanager/client/AttributionModelBuilder.java new file mode 100644 index 0000000..69841d0 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/AttributionModelBuilder.java @@ -0,0 +1,450 @@ +package wueffi.taskmanager.client; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.StringJoiner; +import java.util.function.Function; +import java.util.function.ToLongFunction; +import wueffi.taskmanager.client.util.ModTimingSnapshot; + +public final class AttributionModelBuilder { + + public record EffectiveCpuAttribution( + Map displaySnapshots, + Map redistributedSamplesByMod, + Map redistributedRenderSamplesByMod, + long totalSamples, + long totalRenderSamples + ) {} + + public record EffectiveMemoryAttribution( + Map displayBytes, + Map redistributedBytesByMod, + long totalBytes + ) {} + + public record EffectiveGpuAttribution( + Map gpuNanosByMod, + Map renderSamplesByMod, + Map redistributedGpuNanosByMod, + Map redistributedRenderSamplesByMod, + long totalGpuNanos, + long totalRenderSamples + ) {} + + private AttributionModelBuilder() { + } + + public static EffectiveCpuAttribution buildEffectiveCpuAttribution( + Map rawCpu, + Map cpuDetails, + Map invokes + ) { + LinkedHashMap concrete = new LinkedHashMap<>(); + LinkedHashMap carriedShared = new LinkedHashMap<>(); + long sharedTotalSamples = 0L; + long sharedClientSamples = 0L; + long sharedRenderSamples = 0L; + long sharedTotalCpuNanos = 0L; + long sharedClientCpuNanos = 0L; + long sharedRenderCpuNanos = 0L; + long rawTotalSamples = 0L; + long rawTotalRenderSamples = 0L; + for (Map.Entry entry : rawCpu.entrySet()) { + String modId = entry.getKey(); + CpuSamplingProfiler.Snapshot sample = entry.getValue(); + rawTotalSamples += sample.totalSamples(); + rawTotalRenderSamples += sample.renderSamples(); + if ("shared/gpu-stall".equals(modId)) { + carriedShared.put(modId, sample); + continue; + } + if (isSharedAttributionBucket(modId)) { + sharedTotalSamples += sample.totalSamples(); + sharedClientSamples += sample.clientSamples(); + sharedRenderSamples += sample.renderSamples(); + sharedTotalCpuNanos += sample.totalCpuNanos(); + sharedClientCpuNanos += sample.clientCpuNanos(); + sharedRenderCpuNanos += sample.renderCpuNanos(); + } else { + concrete.put(modId, sample); + } + } + if (concrete.isEmpty()) { + return new EffectiveCpuAttribution(new LinkedHashMap<>(rawCpu), Map.of(), Map.of(), rawTotalSamples, rawTotalRenderSamples); + } + + Map totalWeights = buildCpuWeightMap(concrete, cpuDetails, invokes, CpuSamplingProfiler.Snapshot::totalSamples); + Map clientWeights = buildCpuWeightMap(concrete, cpuDetails, invokes, CpuSamplingProfiler.Snapshot::clientSamples); + Map renderWeights = buildCpuWeightMap(concrete, cpuDetails, invokes, CpuSamplingProfiler.Snapshot::renderSamples); + Map totalCpuWeights = buildCpuWeightMap(concrete, cpuDetails, invokes, CpuSamplingProfiler.Snapshot::totalCpuNanos); + Map clientCpuWeights = buildCpuWeightMap(concrete, cpuDetails, invokes, CpuSamplingProfiler.Snapshot::clientCpuNanos); + Map renderCpuWeights = buildCpuWeightMap(concrete, cpuDetails, invokes, CpuSamplingProfiler.Snapshot::renderCpuNanos); + Map redistributedTotals = distributeLongProportionally(sharedTotalSamples, totalWeights); + Map redistributedClients = distributeLongProportionally(sharedClientSamples, clientWeights); + Map redistributedRenders = distributeLongProportionally(sharedRenderSamples, renderWeights); + Map redistributedTotalCpuNanos = distributeLongProportionally(sharedTotalCpuNanos, totalCpuWeights); + Map redistributedClientCpuNanos = distributeLongProportionally(sharedClientCpuNanos, clientCpuWeights); + Map redistributedRenderCpuNanos = distributeLongProportionally(sharedRenderCpuNanos, renderCpuWeights); + + LinkedHashMap display = new LinkedHashMap<>(); + for (Map.Entry entry : concrete.entrySet()) { + String modId = entry.getKey(); + CpuSamplingProfiler.Snapshot sample = entry.getValue(); + display.put(modId, new CpuSamplingProfiler.Snapshot( + sample.totalSamples() + redistributedTotals.getOrDefault(modId, 0L), + sample.clientSamples() + redistributedClients.getOrDefault(modId, 0L), + sample.renderSamples() + redistributedRenders.getOrDefault(modId, 0L), + sample.totalCpuNanos() + redistributedTotalCpuNanos.getOrDefault(modId, 0L), + sample.clientCpuNanos() + redistributedClientCpuNanos.getOrDefault(modId, 0L), + sample.renderCpuNanos() + redistributedRenderCpuNanos.getOrDefault(modId, 0L) + )); + } + display.putAll(carriedShared); + + return new EffectiveCpuAttribution(display, redistributedTotals, redistributedRenders, rawTotalSamples, rawTotalRenderSamples); + } + + public static EffectiveGpuAttribution buildEffectiveGpuAttribution( + Map renderPhases, + Map cpu, + EffectiveCpuAttribution effectiveCpu, + boolean effectiveView + ) { + Map renderSource = effectiveView ? effectiveCpu.displaySnapshots() : cpu; + LinkedHashMap renderSamplesByMod = new LinkedHashMap<>(); + long totalRenderSamples = 0L; + renderSource.forEach((modId, sample) -> { + if (sample.renderSamples() > 0L) { + renderSamplesByMod.put(modId, sample.renderSamples()); + } + }); + for (long renderSamples : renderSamplesByMod.values()) { + totalRenderSamples += renderSamples; + } + + LinkedHashMap directGpuByMod = new LinkedHashMap<>(); + long sharedGpuNanos = 0L; + long directGpuTotal = 0L; + for (RenderPhaseProfiler.PhaseSnapshot phase : renderPhases.values()) { + if (phase.gpuNanos() <= 0L) { + continue; + } + String ownerMod = effectiveGpuPhaseOwner(phase); + if (isSharedAttributionBucket(ownerMod)) { + sharedGpuNanos += phase.gpuNanos(); + } else { + directGpuByMod.merge(ownerMod, phase.gpuNanos(), Long::sum); + directGpuTotal += phase.gpuNanos(); + } + } + + if (!effectiveView) { + long totalGpuNanos = directGpuTotal; + if (sharedGpuNanos > 0L) { + directGpuByMod.merge("shared/render", sharedGpuNanos, Long::sum); + renderSamplesByMod.putIfAbsent("shared/render", 0L); + totalGpuNanos += sharedGpuNanos; + } + return new EffectiveGpuAttribution(directGpuByMod, renderSamplesByMod, Map.of(), Map.of(), Math.max(1L, totalGpuNanos), Math.max(1L, totalRenderSamples)); + } + + LinkedHashMap effectiveGpuByMod = new LinkedHashMap<>(); + renderSamplesByMod.keySet().forEach(modId -> effectiveGpuByMod.put(modId, directGpuByMod.getOrDefault(modId, 0L))); + directGpuByMod.forEach((modId, gpuNanos) -> effectiveGpuByMod.putIfAbsent(modId, gpuNanos)); + Map weights = buildGpuWeightMap(renderSamplesByMod, effectiveGpuByMod); + if (weights.isEmpty()) { + if (sharedGpuNanos > 0L) { + effectiveGpuByMod.merge("shared/render", sharedGpuNanos, Long::sum); + renderSamplesByMod.putIfAbsent("shared/render", 0L); + } + effectiveGpuByMod.entrySet().removeIf(entry -> entry.getValue() <= 0L && !"shared/render".equals(entry.getKey())); + long totalGpuNanos = directGpuTotal + sharedGpuNanos; + return new EffectiveGpuAttribution(effectiveGpuByMod, renderSamplesByMod, Map.of(), Map.of(), Math.max(1L, totalGpuNanos), Math.max(1L, totalRenderSamples)); + } + Map redistributedGpu = distributeLongProportionally(sharedGpuNanos, weights); + redistributedGpu.forEach((modId, gpuNanos) -> effectiveGpuByMod.merge(modId, gpuNanos, Long::sum)); + effectiveGpuByMod.entrySet().removeIf(entry -> entry.getValue() <= 0L && renderSamplesByMod.getOrDefault(entry.getKey(), 0L) <= 0L); + long totalGpuNanos = directGpuTotal + sharedGpuNanos; + return new EffectiveGpuAttribution(effectiveGpuByMod, renderSamplesByMod, redistributedGpu, effectiveCpu.redistributedRenderSamplesByMod(), Math.max(1L, totalGpuNanos), Math.max(1L, totalRenderSamples)); + } + + public static EffectiveMemoryAttribution buildEffectiveMemoryAttribution(Map rawMemoryMods) { + LinkedHashMap concrete = new LinkedHashMap<>(); + long sharedBytes = 0L; + long totalBytes = 0L; + for (Map.Entry entry : rawMemoryMods.entrySet()) { + totalBytes += entry.getValue(); + if (isSharedAttributionBucket(entry.getKey())) { + sharedBytes += entry.getValue(); + } else { + concrete.put(entry.getKey(), entry.getValue()); + } + } + if (concrete.isEmpty()) { + return new EffectiveMemoryAttribution(new LinkedHashMap<>(rawMemoryMods), Map.of(), totalBytes); + } + Map weights = buildMemoryWeightMap(concrete); + Map redistributed = distributeLongProportionally(sharedBytes, weights); + LinkedHashMap display = new LinkedHashMap<>(); + for (Map.Entry entry : concrete.entrySet()) { + display.put(entry.getKey(), entry.getValue() + redistributed.getOrDefault(entry.getKey(), 0L)); + } + return new EffectiveMemoryAttribution(display, redistributed, totalBytes); + } + + public static String effectiveGpuPhaseOwner(RenderPhaseProfiler.PhaseSnapshot phaseSnapshot) { + if (phaseSnapshot == null) { + return "shared/render"; + } + String owner = phaseSnapshot.ownerMod() == null || phaseSnapshot.ownerMod().isBlank() ? "shared/render" : phaseSnapshot.ownerMod(); + Map owners = phaseSnapshot.likelyOwners(); + if (!isSharedAttributionBucket(owner) || owners == null || owners.isEmpty()) { + return owner; + } + Map.Entry topOwner = owners.entrySet().stream() + .filter(entry -> entry.getKey() != null && !isSharedAttributionBucket(entry.getKey())) + .max(Map.Entry.comparingByValue()) + .orElse(null); + if (topOwner == null) { + return owner; + } + long totalHints = owners.values().stream().mapToLong(Long::longValue).sum(); + if (topOwner.getValue() < 2L || totalHints <= 0L || topOwner.getValue() * 100L < totalHints * 65L) { + return owner; + } + return topOwner.getKey(); + } + + public static boolean hasPromotedLikelyOwner(RenderPhaseProfiler.PhaseSnapshot phaseSnapshot) { + if (phaseSnapshot == null) { + return false; + } + String rawOwner = phaseSnapshot.ownerMod() == null || phaseSnapshot.ownerMod().isBlank() ? "shared/render" : phaseSnapshot.ownerMod(); + return isSharedAttributionBucket(rawOwner) && !effectiveGpuPhaseOwner(phaseSnapshot).equals(rawOwner); + } + + public static List buildGpuPhaseBreakdownLines( + Map renderPhases, + String modId, + Function displayNameFn + ) { + return renderPhases.entrySet().stream() + .filter(entry -> { + String owner = effectiveGpuPhaseOwner(entry.getValue()); + return modId.equals(owner) || ("shared/render".equals(modId) && isSharedAttributionBucket(owner)); + }) + .sorted((a, b) -> Long.compare(b.getValue().gpuNanos(), a.getValue().gpuNanos())) + .limit(5) + .map(entry -> String.format(Locale.ROOT, "%s | %.2f ms%s", + entry.getKey(), + entry.getValue().gpuNanos() / 1_000_000.0, + isSharedAttributionBucket(modId) ? formatLikelyOwnerSuffix(entry.getValue(), displayNameFn) : "")) + .toList(); + } + + public static Map buildSharedRenderLikelyOwners( + Map renderPhases, + Function displayNameFn + ) { + Map totals = new LinkedHashMap<>(); + renderPhases.forEach((phase, phaseSnapshot) -> { + String owner = effectiveGpuPhaseOwner(phaseSnapshot); + if (!isSharedAttributionBucket(owner) || phaseSnapshot.gpuNanos() <= 0L || phaseSnapshot.likelyOwners() == null) { + return; + } + phaseSnapshot.likelyOwners().forEach((key, value) -> totals.merge(displayNameFn.apply(key), value, Long::sum)); + }); + return topLongEntries(totals, 5); + } + + public static Map buildSharedRenderLikelyFrames(Map renderPhases) { + Map totals = new LinkedHashMap<>(); + renderPhases.forEach((phase, phaseSnapshot) -> { + String owner = effectiveGpuPhaseOwner(phaseSnapshot); + if (!isSharedAttributionBucket(owner) || phaseSnapshot.gpuNanos() <= 0L) { + return; + } + mergeLongTotals(totals, phaseSnapshot.likelyFrames()); + }); + return topLongEntries(totals, 5); + } + + public static String describeGpuOwnerSource(Map renderPhases, String modId) { + if (modId == null || modId.isBlank()) { + return "unknown"; + } + if ("shared/render".equals(modId)) { + return "shared/render fallback bucket from phases without a concrete owner"; + } + if (isSharedAttributionBucket(modId)) { + return "shared bucket carried through raw ownership view"; + } + long promotedPhases = renderPhases.values().stream() + .filter(AttributionModelBuilder::hasPromotedLikelyOwner) + .filter(phase -> modId.equals(effectiveGpuPhaseOwner(phase))) + .count(); + if (promotedPhases > 0L) { + return "claimed from shared/render by a strong likely-owner signal in " + promotedPhases + " phase" + (promotedPhases == 1L ? "" : "s"); + } + long taggedPhases = renderPhases.values().stream() + .filter(phase -> modId.equals(effectiveGpuPhaseOwner(phase))) + .count(); + return taggedPhases > 0 ? "directly tagged by " + taggedPhases + " render phase" + (taggedPhases == 1 ? "" : "s") : "redistributed from shared render work"; + } + + private static String formatLikelyOwnerSuffix(RenderPhaseProfiler.PhaseSnapshot snapshot, Function displayNameFn) { + if (snapshot == null || snapshot.likelyOwners() == null || snapshot.likelyOwners().isEmpty()) { + return ""; + } + StringJoiner joiner = new StringJoiner(", "); + snapshot.likelyOwners().entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(2) + .forEach(entry -> joiner.add(displayNameFn.apply(entry.getKey()) + " " + entry.getValue())); + String summary = joiner.toString(); + return summary.isBlank() ? "" : " | likely " + summary; + } + + public static Map buildCpuWeightMap( + Map concrete, + Map cpuDetails, + Map invokes, + ToLongFunction extractor + ) { + LinkedHashMap weights = new LinkedHashMap<>(); + double total = 0.0; + for (Map.Entry entry : concrete.entrySet()) { + double weight = Math.max(0L, extractor.applyAsLong(entry.getValue())); + CpuSamplingProfiler.DetailSnapshot detail = cpuDetails == null ? null : cpuDetails.get(entry.getKey()); + if (AttributionInsights.isRenderSubmissionHeavy(detail)) { + weight *= 0.25; + } + weights.put(entry.getKey(), weight); + total += weight; + } + if (total > 0.0) { + return weights; + } + double invokeTotal = 0.0; + for (String modId : concrete.keySet()) { + double weight = Math.max(0L, invokes.getOrDefault(modId, new ModTimingSnapshot(0, 0)).calls()); + CpuSamplingProfiler.DetailSnapshot detail = cpuDetails == null ? null : cpuDetails.get(modId); + if (AttributionInsights.isRenderSubmissionHeavy(detail)) { + weight *= 0.25; + } + weights.put(modId, weight); + invokeTotal += weight; + } + if (invokeTotal > 0.0) { + return weights; + } + for (String modId : concrete.keySet()) { + weights.put(modId, 1.0); + } + return weights; + } + + public static Map buildMemoryWeightMap(Map concrete) { + LinkedHashMap weights = new LinkedHashMap<>(); + double total = 0.0; + for (Map.Entry entry : concrete.entrySet()) { + double weight = Math.max(0L, entry.getValue()); + weights.put(entry.getKey(), weight); + total += weight; + } + if (total > 0.0) { + return weights; + } + for (String modId : concrete.keySet()) { + weights.put(modId, 1.0); + } + return weights; + } + + public static Map buildGpuWeightMap(Map renderSamplesByMod, Map directGpuByMod) { + LinkedHashMap weights = new LinkedHashMap<>(); + double total = 0.0; + double nonMinecraftTotal = 0.0; + for (Map.Entry entry : directGpuByMod.entrySet()) { + if (isSharedAttributionBucket(entry.getKey())) { + continue; + } + double weight = Math.max(0L, entry.getValue()); + weights.put(entry.getKey(), weight); + total += weight; + if (!"minecraft".equals(entry.getKey())) { + nonMinecraftTotal += weight; + } + } + if (nonMinecraftTotal > 0.0) { + weights.entrySet().removeIf(entry -> "minecraft".equals(entry.getKey())); + if (!weights.isEmpty()) { + return weights; + } + } + if (total > 0.0) { + return weights; + } + return Map.of(); + } + + public static Map distributeLongProportionally(long total, Map weights) { + if (total <= 0L || weights.isEmpty()) { + return Map.of(); + } + LinkedHashMap result = new LinkedHashMap<>(); + ArrayList> entries = new ArrayList<>(weights.entrySet()); + double weightSum = entries.stream().mapToDouble(entry -> Math.max(0.0, entry.getValue())).sum(); + if (weightSum <= 0.0) { + for (Map.Entry entry : entries) { + result.put(entry.getKey(), 0L); + } + return result; + } + LinkedHashMap remainders = new LinkedHashMap<>(); + long assigned = 0L; + for (Map.Entry entry : entries) { + double exact = total * Math.max(0.0, entry.getValue()) / weightSum; + long whole = (long) Math.floor(exact); + result.put(entry.getKey(), whole); + remainders.put(entry.getKey(), exact - whole); + assigned += whole; + } + long remainder = total - assigned; + if (remainder > 0L) { + entries.sort((a, b) -> Double.compare(remainders.getOrDefault(b.getKey(), 0.0), remainders.getOrDefault(a.getKey(), 0.0))); + for (int i = 0; i < remainder; i++) { + String modId = entries.get(i % entries.size()).getKey(); + result.put(modId, result.getOrDefault(modId, 0L) + 1L); + } + } + return result; + } + + private static void mergeLongTotals(Map target, Map source) { + if (source == null) { + return; + } + source.forEach((key, value) -> target.merge(key, value, Long::sum)); + } + + private static Map topLongEntries(Map source, int limit) { + if (source == null || source.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + source.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(limit) + .forEach(entry -> result.put(entry.getKey(), entry.getValue())); + return result; + } + + public static boolean isSharedAttributionBucket(String modId) { + return modId != null && (modId.startsWith("shared/") || modId.startsWith("runtime/")); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/ChunkWorkProfiler.java b/src/client/java/wueffi/taskmanager/client/ChunkWorkProfiler.java new file mode 100644 index 0000000..9a2f7be --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/ChunkWorkProfiler.java @@ -0,0 +1,114 @@ +package wueffi.taskmanager.client; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +public final class ChunkWorkProfiler { + + private static final ChunkWorkProfiler INSTANCE = new ChunkWorkProfiler(); + private static final int MAX_ROWS = 8; + + private final Map durationNanosByLabel = new ConcurrentHashMap<>(); + private final Map callsByLabel = new ConcurrentHashMap<>(); + private final ThreadLocal> contexts = ThreadLocal.withInitial(ArrayDeque::new); + private final AtomicLong lastUpdatedAtMillis = new AtomicLong(0L); + + public static ChunkWorkProfiler getInstance() { + return INSTANCE; + } + + private ChunkWorkProfiler() { + } + + public void beginPhase(String label) { + Deque stack = contexts.get(); + if (stack.size() > 16) { + stack.clear(); + } + stack.push(new Context(System.nanoTime(), sanitizeLabel(label))); + } + + public void endPhase() { + Deque stack = contexts.get(); + if (stack.isEmpty()) { + return; + } + Context context = stack.pop(); + long durationNs = Math.max(0L, System.nanoTime() - context.startedAtNs()); + durationNanosByLabel.computeIfAbsent(context.label(), ignored -> new LongAdder()).add(durationNs); + callsByLabel.computeIfAbsent(context.label(), ignored -> new LongAdder()).increment(); + lastUpdatedAtMillis.set(System.currentTimeMillis()); + if (stack.isEmpty()) { + contexts.remove(); + } + } + + public void cleanupThread() { + contexts.remove(); + } + + public Snapshot getSnapshot() { + return new Snapshot(topEntries(durationNanosByLabel), topEntries(callsByLabel), getLastSampleAgeMillis()); + } + + public List buildTopLines() { + Snapshot snapshot = getSnapshot(); + if (snapshot.durationNanosByLabel().isEmpty()) { + return List.of("No chunk generation/load timings captured in the current window."); + } + return snapshot.durationNanosByLabel().entrySet().stream() + .map(entry -> String.format( + java.util.Locale.ROOT, + "%s | %.2f ms | %d calls", + entry.getKey(), + entry.getValue() / 1_000_000.0, + snapshot.callsByLabel().getOrDefault(entry.getKey(), 0L) + )) + .toList(); + } + + public void reset() { + durationNanosByLabel.clear(); + callsByLabel.clear(); + lastUpdatedAtMillis.set(0L); + contexts.remove(); + } + + private long getLastSampleAgeMillis() { + long updatedAt = lastUpdatedAtMillis.get(); + if (updatedAt == 0L) { + return Long.MAX_VALUE; + } + return Math.max(0L, System.currentTimeMillis() - updatedAt); + } + + private static String sanitizeLabel(String label) { + if (label == null || label.isBlank()) { + return "chunk-work"; + } + return label; + } + + private static Map topEntries(Map source) { + LinkedHashMap snapshot = new LinkedHashMap<>(); + source.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), entry.getValue().sum())) + .filter(entry -> entry.getValue() > 0L) + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(MAX_ROWS) + .forEach(entry -> snapshot.put(entry.getKey(), entry.getValue())); + return snapshot; + } + + private record Context(long startedAtNs, String label) { + } + + public record Snapshot(Map durationNanosByLabel, Map callsByLabel, long sampleAgeMillis) { + } +} diff --git a/src/client/java/wueffi/taskmanager/client/CollectorMath.java b/src/client/java/wueffi/taskmanager/client/CollectorMath.java new file mode 100644 index 0000000..d879409 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/CollectorMath.java @@ -0,0 +1,35 @@ +package wueffi.taskmanager.client; + +final class CollectorMath { + + private CollectorMath() { + } + + static long[] splitBudget(long totalBudget, int parts) { + if (totalBudget <= 0L || parts <= 0) { + return new long[0]; + } + long[] shares = new long[parts]; + long baseShare = totalBudget / parts; + long remainder = totalBudget % parts; + for (int i = 0; i < parts; i++) { + shares[i] = baseShare + (i < remainder ? 1L : 0L); + } + return shares; + } + + static long computeAdaptiveWorldScanCadenceMillis(boolean detailedMetrics, boolean sessionLogging, boolean selfProtecting, long lastScanDurationMillis) { + long baseCadenceMillis = selfProtecting ? 750L : (detailedMetrics || sessionLogging ? 125L : 250L); + return Math.max(baseCadenceMillis, baseCadenceMillis + Math.min(750L, Math.max(0L, lastScanDurationMillis) * 40L)); + } + + static long computeAdaptiveMemoryCadenceMillis(String governorMode, boolean screenOpen, boolean sessionLogging) { + return switch (governorMode == null ? "normal" : governorMode) { + case "self-protect" -> screenOpen ? 6_000L : 15_000L; + case "burst" -> screenOpen || sessionLogging ? 1_500L : 3_000L; + case "tight" -> screenOpen || sessionLogging ? 2_500L : 4_500L; + case "light" -> screenOpen ? 6_000L : 12_000L; + default -> screenOpen ? 2_000L : (sessionLogging ? 5_000L : 8_000L); + }; + } +} diff --git a/src/client/java/wueffi/taskmanager/client/CpuSamplingProfiler.java b/src/client/java/wueffi/taskmanager/client/CpuSamplingProfiler.java index 6c120b4..9843bb8 100644 --- a/src/client/java/wueffi/taskmanager/client/CpuSamplingProfiler.java +++ b/src/client/java/wueffi/taskmanager/client/CpuSamplingProfiler.java @@ -1,8 +1,13 @@ package wueffi.taskmanager.client; import wueffi.taskmanager.client.util.ModClassIndex; +import wueffi.taskmanager.client.util.BoundedMaps; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadMXBean; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -14,39 +19,60 @@ public class CpuSamplingProfiler { public static CpuSamplingProfiler getInstance() { return INSTANCE; } private static final int SAMPLE_INTERVAL_MS = 2; + private static final int MAX_ATTRIBUTION_SNAPSHOTS = 4; private static final int READY_CPU_SAMPLES = 300; private static final int READY_RENDER_SAMPLES = 200; + private static final long THREAD_REFRESH_INTERVAL_MS = 250L; + private static final int GPU_STALL_FRAME_SCAN_DEPTH = 4; + private static final int GPU_STALL_MAX_CPU_UTILIZATION_PERCENT = 35; + private static final int MAX_CLASS_MOD_CACHE_ENTRIES = 8_192; + private final ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); private final Map counters = new ConcurrentHashMap<>(); - private final Map classModCache = new ConcurrentHashMap<>(); + private final Map classModCache = BoundedMaps.synchronizedLru(MAX_CLASS_MOD_CACHE_ENTRIES); + private final Map lastThreadCpuTimes = new ConcurrentHashMap<>(); private final AtomicLong lastSampleAtMillis = new AtomicLong(0); private final Map> threadReasonsByMod = new ConcurrentHashMap<>(); private final Map> frameReasonsByMod = new ConcurrentHashMap<>(); private final Map> renderReasonsByMod = new ConcurrentHashMap<>(); + private final AtomicLong lastThreadRefreshAtMillis = new AtomicLong(0L); + private volatile long[] cachedThreadIds = new long[0]; private volatile boolean running = false; private Thread samplerThread; - private volatile Thread clientThread; - private volatile Thread renderThread; private static class Counter { final LongAdder totalSamples = new LongAdder(); final LongAdder clientSamples = new LongAdder(); final LongAdder renderSamples = new LongAdder(); + final LongAdder totalCpuNanos = new LongAdder(); + final LongAdder clientCpuNanos = new LongAdder(); + final LongAdder renderCpuNanos = new LongAdder(); } private record SampleAttribution(String modId, String threadName, String frameReason) {} - public record Snapshot(long totalSamples, long clientSamples, long renderSamples) {} + public record Snapshot(long totalSamples, long clientSamples, long renderSamples, long totalCpuNanos, long clientCpuNanos, long renderCpuNanos) { + public Snapshot(long totalSamples, long clientSamples, long renderSamples) { + this(totalSamples, clientSamples, renderSamples, 0L, 0L, 0L); + } + } public record DetailSnapshot(Map topThreads, Map topFrames, int sampledThreadCount) {} public record WindowSnapshot(Map samples, Map detailsByMod, long lastSampleAgeMillis) {} + private CpuSamplingProfiler() { + if (threadBean.isThreadCpuTimeSupported() && !threadBean.isThreadCpuTimeEnabled()) { + try { + threadBean.setThreadCpuTimeEnabled(true); + } catch (UnsupportedOperationException ignored) { + } + } + } + public synchronized void start() { if (running) return; running = true; - refreshThreads(); - samplerThread = new Thread(this::runSampler, "TaskManager-CPU-Sampler"); samplerThread.setDaemon(true); samplerThread.start(); @@ -61,6 +87,7 @@ public void reset() { threadReasonsByMod.clear(); frameReasonsByMod.clear(); renderReasonsByMod.clear(); + lastThreadCpuTimes.clear(); lastSampleAtMillis.set(0); } @@ -70,7 +97,10 @@ public WindowSnapshot drainWindow() { counters.forEach((mod, counter) -> result.put(mod, new Snapshot( counter.totalSamples.sum(), counter.clientSamples.sum(), - counter.renderSamples.sum() + counter.renderSamples.sum(), + counter.totalCpuNanos.sum(), + counter.clientCpuNanos.sum(), + counter.renderCpuNanos.sum() ))); Map details = new LinkedHashMap<>(); for (String mod : result.keySet()) { @@ -101,7 +131,7 @@ private void runSampler() { while (running) { try { if (ProfilerManager.getInstance().isCaptureActive()) { - sampleThreads(); + sampleBusyThreads(); } Thread.sleep(SAMPLE_INTERVAL_MS); } catch (InterruptedException ignored) { @@ -112,60 +142,107 @@ private void runSampler() { } } - private void sampleThreads() { - Thread client = ensureThread(clientThread, "Client thread"); - if (client != null) { - clientThread = client; - sampleThread(client, false); + private void sampleBusyThreads() { + if (!threadBean.isThreadCpuTimeSupported() || !threadBean.isThreadCpuTimeEnabled()) { + return; } - - Thread render = ensureThread(renderThread, "Render thread"); - if (render != null) { - renderThread = render; - sampleThread(render, true); + boolean sampledAnyThread = false; + long sampleStartedAtMillis = System.currentTimeMillis(); + long previousSampleAtMillis = lastSampleAtMillis.get(); + long wallIntervalNanos = Math.max(1L, (previousSampleAtMillis <= 0L ? SAMPLE_INTERVAL_MS : Math.max(1L, sampleStartedAtMillis - previousSampleAtMillis)) * 1_000_000L); + ThreadSnapshotCollector collector = ThreadSnapshotCollector.getInstance(); + long[] threadIds = getCachedThreadIds(sampleStartedAtMillis); + for (long threadId : threadIds) { + long cpuTimeNs = threadBean.getThreadCpuTime(threadId); + if (cpuTimeNs < 0L) { + continue; + } + Long previousCpuTime = lastThreadCpuTimes.put(threadId, cpuTimeNs); + long deltaCpuNs = previousCpuTime == null ? 0L : Math.max(0L, cpuTimeNs - previousCpuTime); + if (deltaCpuNs <= 0L) { + continue; + } + List threadSnapshots = collector.getRecentThreadSnapshots( + threadId, + Math.max(0L, lastSampleAtMillis.get()), + MAX_ATTRIBUTION_SNAPSHOTS + ); + sampleThread(threadId, threadSnapshots, deltaCpuNs, wallIntervalNanos); + sampledAnyThread = true; + } + if (sampledAnyThread) { + lastSampleAtMillis.set(sampleStartedAtMillis); } } - private void refreshThreads() { - clientThread = ensureThread(null, "Client thread"); - renderThread = ensureThread(null, "Render thread"); + private long[] getCachedThreadIds(long nowMillis) { + long refreshedAt = lastThreadRefreshAtMillis.get(); + long[] threadIds = cachedThreadIds; + if (threadIds.length == 0 || refreshedAt == 0L || nowMillis - refreshedAt >= THREAD_REFRESH_INTERVAL_MS) { + threadIds = threadBean.getAllThreadIds(); + cachedThreadIds = threadIds; + lastThreadRefreshAtMillis.set(nowMillis); + } + return threadIds; } - private Thread ensureThread(Thread current, String name) { - if (current != null && current.isAlive()) { - return current; + private void sampleThread(long threadId, List threadSnapshots, long cpuBudgetNanos, long wallIntervalNanos) { + if (threadSnapshots == null || threadSnapshots.isEmpty() || cpuBudgetNanos <= 0L) { + return; } - for (Thread thread : Thread.getAllStackTraces().keySet()) { - if (name.equals(thread.getName())) { - return thread; + List attributions = new ArrayList<>(threadSnapshots.size()); + for (ThreadSnapshotCollector.ThreadStackSnapshot threadSnapshot : threadSnapshots) { + StackTraceElement[] stack = threadSnapshot.stack(); + if (stack == null || stack.length == 0) { + continue; } + attributions.add(attributeStack(stack, threadSnapshot.threadName(), cpuBudgetNanos, wallIntervalNanos)); + } + if (attributions.isEmpty()) { + return; } - return null; - } - private void sampleThread(Thread thread, boolean render) { - StackTraceElement[] stack = thread.getStackTrace(); - if (stack.length == 0) return; + long[] shares = CollectorMath.splitBudget(cpuBudgetNanos, attributions.size()); + for (int i = 0; i < attributions.size(); i++) { + SampleAttribution attribution = attributions.get(i); + recordAttribution(attribution, shares[i]); + if (isRenderThread(attribution.threadName()) && !isSharedAttributionMod(attribution.modId())) { + RenderPhaseProfiler.getInstance().recordLikelyOwnerSample(threadId, attribution.modId(), attribution.frameReason()); + } + } + } - SampleAttribution attribution = attributeStack(stack, thread.getName()); + private void recordAttribution(SampleAttribution attribution, long cpuBudgetNanos) { Counter counter = counters.computeIfAbsent(attribution.modId(), ignored -> new Counter()); counter.totalSamples.increment(); + counter.totalCpuNanos.add(cpuBudgetNanos); + boolean render = isRenderThread(attribution.threadName()); + boolean client = isClientThread(attribution.threadName()); if (render) { counter.renderSamples.increment(); - } else { + counter.renderCpuNanos.add(cpuBudgetNanos); + } + if (client) { counter.clientSamples.increment(); + counter.clientCpuNanos.add(cpuBudgetNanos); } incrementReason(threadReasonsByMod, attribution.modId(), attribution.threadName()); incrementReason(render ? renderReasonsByMod : frameReasonsByMod, attribution.modId(), attribution.frameReason()); - lastSampleAtMillis.set(System.currentTimeMillis()); } - private SampleAttribution attributeStack(StackTraceElement[] stack, String threadName) { + private SampleAttribution attributeStack(StackTraceElement[] stack, String threadName, long cpuBudgetNanos, long wallIntervalNanos) { + String gpuStallReason = findGpuStallReason(stack); + if (gpuStallReason != null && isRenderThread(threadName) && isGpuStallBudget(cpuBudgetNanos, wallIntervalNanos)) { + return new SampleAttribution("shared/gpu-stall", threadName, gpuStallReason); + } + String firstConcrete = null; + String firstConcreteReason = null; String firstFramework = null; + String firstFrameworkReason = null; String firstKnown = null; - String reasonFrame = null; + String firstKnownReason = null; for (StackTraceElement frame : stack) { String className = frame.getClassName(); @@ -173,46 +250,112 @@ private SampleAttribution attributeStack(StackTraceElement[] stack, String threa continue; } - String mod = classModCache.computeIfAbsent(className, this::resolveModForClassName); + String mod = BoundedMaps.getOrCompute(classModCache, className, this::resolveModForClassName); if (mod == null || "unknown".equals(mod)) { continue; } if (firstKnown == null) { firstKnown = mod; + firstKnownReason = formatFrameReason(frame); } if (isFrameworkMod(mod, className)) { if (firstFramework == null) { firstFramework = mod; - } - if (reasonFrame == null) { - reasonFrame = formatFrameReason(frame); + firstFrameworkReason = formatFrameReason(frame); } continue; } if (firstConcrete == null) { firstConcrete = mod; - reasonFrame = formatFrameReason(frame); + firstConcreteReason = formatFrameReason(frame); } } - if (firstConcrete != null) return new SampleAttribution(firstConcrete, threadName, reasonFrame == null ? "unknown-frame" : reasonFrame); - if (firstFramework != null) return new SampleAttribution(firstFramework, threadName, reasonFrame == null ? findFallbackFrame(stack) : reasonFrame); - return new SampleAttribution(firstKnown == null ? "minecraft" : firstKnown, threadName, reasonFrame == null ? findFallbackFrame(stack) : reasonFrame); + if (firstConcrete != null) { + return new SampleAttribution(firstConcrete, threadName, firstConcreteReason == null ? findFallbackFrame(stack) : firstConcreteReason); + } + if (firstFramework != null) { + return new SampleAttribution(firstFramework, threadName, firstFrameworkReason == null ? findFallbackFrame(stack) : firstFrameworkReason); + } + return new SampleAttribution(firstKnown == null ? "minecraft" : firstKnown, threadName, firstKnownReason == null ? findFallbackFrame(stack) : firstKnownReason); + } + + private boolean isGpuStallBudget(long cpuBudgetNanos, long wallIntervalNanos) { + if (cpuBudgetNanos <= 0L || wallIntervalNanos <= 0L) { + return false; + } + return cpuBudgetNanos * 100L <= wallIntervalNanos * GPU_STALL_MAX_CPU_UTILIZATION_PERCENT; + } + + private String findGpuStallReason(StackTraceElement[] stack) { + if (stack == null || stack.length == 0) { + return null; + } + int inspected = 0; + int gpuFrames = 0; + StackTraceElement firstGpuFrame = null; + for (StackTraceElement frame : stack) { + String className = frame.getClassName(); + if (className.startsWith("wueffi.taskmanager.")) { + continue; + } + if (inspected >= GPU_STALL_FRAME_SCAN_DEPTH) { + break; + } + inspected++; + if (!isGpuDriverFrame(className)) { + break; + } + gpuFrames++; + if (firstGpuFrame == null) { + firstGpuFrame = frame; + } + } + if (gpuFrames >= 2 && firstGpuFrame != null) { + return formatFrameReason(firstGpuFrame); + } + return null; + } + + private boolean isGpuDriverFrame(String className) { + return className.startsWith("org.lwjgl.opengl.") + || className.startsWith("org.lwjgl.system.JNI") + || className.startsWith("com.mojang.blaze3d.systems.") + || className.startsWith("com.mojang.blaze3d.platform.") + || className.startsWith("com.mojang.blaze3d.opengl."); } private String findFallbackFrame(StackTraceElement[] stack) { + StackTraceElement runtimeFallback = null; for (StackTraceElement frame : stack) { String className = frame.getClassName(); if (!className.startsWith("wueffi.taskmanager.")) { - return formatFrameReason(frame); + if (!isOpaqueRuntimeFrame(className)) { + return formatFrameReason(frame); + } + if (runtimeFallback == null) { + runtimeFallback = frame; + } } } + if (runtimeFallback != null) { + return formatFrameReason(runtimeFallback); + } return "unknown-frame"; } + private boolean isOpaqueRuntimeFrame(String className) { + return className.startsWith("java.") + || className.startsWith("javax.") + || className.startsWith("jdk.") + || className.startsWith("sun.") + || className.startsWith("org.lwjgl.") + || className.startsWith("com.mojang.blaze3d."); + } + private String formatFrameReason(StackTraceElement frame) { String className = frame.getClassName(); int lastDot = className.lastIndexOf('.'); @@ -281,4 +424,16 @@ private boolean isFrameworkMod(String mod, String className) { || className.startsWith("net.fabricmc.") || className.startsWith("org.spongepowered.asm."); } + + private boolean isRenderThread(String threadName) { + return threadName != null && threadName.toLowerCase().contains("render"); + } + + private boolean isClientThread(String threadName) { + return "Client thread".equals(threadName); + } + + private boolean isSharedAttributionMod(String modId) { + return modId == null || modId.isBlank() || modId.startsWith("shared/"); + } } diff --git a/src/client/java/wueffi/taskmanager/client/EntityCostProfiler.java b/src/client/java/wueffi/taskmanager/client/EntityCostProfiler.java new file mode 100644 index 0000000..79d4d16 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/EntityCostProfiler.java @@ -0,0 +1,156 @@ +package wueffi.taskmanager.client; + +import net.minecraft.entity.Entity; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +public final class EntityCostProfiler { + + private static final EntityCostProfiler INSTANCE = new EntityCostProfiler(); + private static final int MAX_ROWS = 8; + + private final Map tickNanosByType = new ConcurrentHashMap<>(); + private final Map tickCallsByType = new ConcurrentHashMap<>(); + private final Map renderPrepNanosByType = new ConcurrentHashMap<>(); + private final Map renderPrepCallsByType = new ConcurrentHashMap<>(); + private final ThreadLocal tickStartNanos = new ThreadLocal<>(); + private final ThreadLocal tickType = new ThreadLocal<>(); + private final ThreadLocal renderPrepStartNanos = new ThreadLocal<>(); + private final ThreadLocal renderPrepType = new ThreadLocal<>(); + private final AtomicLong lastUpdatedAtMillis = new AtomicLong(0L); + + public static EntityCostProfiler getInstance() { + return INSTANCE; + } + + private EntityCostProfiler() { + } + + public void beginEntityTick(Entity entity) { + tickStartNanos.set(System.nanoTime()); + tickType.set(describeEntity(entity)); + } + + public void endEntityTick() { + Long startedAt = tickStartNanos.get(); + String entityType = tickType.get(); + tickStartNanos.remove(); + tickType.remove(); + if (startedAt == null || entityType == null || entityType.isBlank()) { + return; + } + long durationNs = Math.max(0L, System.nanoTime() - startedAt); + tickNanosByType.computeIfAbsent(entityType, ignored -> new LongAdder()).add(durationNs); + tickCallsByType.computeIfAbsent(entityType, ignored -> new LongAdder()).increment(); + lastUpdatedAtMillis.set(System.currentTimeMillis()); + } + + public void beginEntityRenderPrep(Entity entity) { + renderPrepStartNanos.set(System.nanoTime()); + renderPrepType.set(describeEntity(entity)); + } + + public void endEntityRenderPrep() { + Long startedAt = renderPrepStartNanos.get(); + String entityType = renderPrepType.get(); + renderPrepStartNanos.remove(); + renderPrepType.remove(); + if (startedAt == null || entityType == null || entityType.isBlank()) { + return; + } + long durationNs = Math.max(0L, System.nanoTime() - startedAt); + renderPrepNanosByType.computeIfAbsent(entityType, ignored -> new LongAdder()).add(durationNs); + renderPrepCallsByType.computeIfAbsent(entityType, ignored -> new LongAdder()).increment(); + lastUpdatedAtMillis.set(System.currentTimeMillis()); + } + + public Snapshot getSnapshot() { + return new Snapshot( + topEntries(tickNanosByType), + topEntries(tickCallsByType), + topEntries(renderPrepNanosByType), + topEntries(renderPrepCallsByType), + getLastSampleAgeMillis() + ); + } + + public void reset() { + tickNanosByType.clear(); + tickCallsByType.clear(); + renderPrepNanosByType.clear(); + renderPrepCallsByType.clear(); + lastUpdatedAtMillis.set(0L); + tickStartNanos.remove(); + tickType.remove(); + renderPrepStartNanos.remove(); + renderPrepType.remove(); + } + + public List buildTopTickLines() { + Snapshot snapshot = getSnapshot(); + return buildLines(snapshot.tickNanosByType(), snapshot.tickCallsByType(), "tick"); + } + + public List buildTopRenderPrepLines() { + Snapshot snapshot = getSnapshot(); + return buildLines(snapshot.renderPrepNanosByType(), snapshot.renderPrepCallsByType(), "render prep"); + } + + private List buildLines(Map nanosByType, Map callsByType, String label) { + List lines = new ArrayList<>(); + nanosByType.forEach((type, nanos) -> lines.add(String.format( + java.util.Locale.ROOT, + "%s | %s %.2f ms | %d calls", + type, + label, + nanos / 1_000_000.0, + callsByType.getOrDefault(type, 0L) + ))); + return lines; + } + + private long getLastSampleAgeMillis() { + long updatedAt = lastUpdatedAtMillis.get(); + if (updatedAt == 0L) { + return Long.MAX_VALUE; + } + return Math.max(0L, System.currentTimeMillis() - updatedAt); + } + + private static Map topEntries(Map source) { + LinkedHashMap snapshot = new LinkedHashMap<>(); + source.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), entry.getValue().sum())) + .filter(entry -> entry.getValue() > 0L) + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(MAX_ROWS) + .forEach(entry -> snapshot.put(entry.getKey(), entry.getValue())); + return snapshot; + } + + private static String describeEntity(Entity entity) { + if (entity == null) { + return "unknown"; + } + String simpleName = entity.getClass().getSimpleName(); + if (simpleName != null && !simpleName.isBlank()) { + return simpleName; + } + return entity.getType().toString(); + } + + public record Snapshot( + Map tickNanosByType, + Map tickCallsByType, + Map renderPrepNanosByType, + Map renderPrepCallsByType, + long sampleAgeMillis + ) { + } +} diff --git a/src/client/java/wueffi/taskmanager/client/FlamegraphProfiler.java b/src/client/java/wueffi/taskmanager/client/FlamegraphProfiler.java index af4f9b9..e6194e4 100644 --- a/src/client/java/wueffi/taskmanager/client/FlamegraphProfiler.java +++ b/src/client/java/wueffi/taskmanager/client/FlamegraphProfiler.java @@ -1,10 +1,12 @@ package wueffi.taskmanager.client; import wueffi.taskmanager.client.util.ModClassIndex; +import wueffi.taskmanager.client.util.BoundedMaps; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.LongAdder; @@ -15,23 +17,22 @@ public class FlamegraphProfiler { private static final int SAMPLE_INTERVAL_MS = 5; private static final int MAX_STACK_DEPTH = 20; + private static final int MAX_CLASS_MOD_CACHE_ENTRIES = 8_192; + private static final int MAX_METHOD_CACHE_ENTRIES = 16_384; private final Map stacks = new ConcurrentHashMap<>(); - private final Map classModCache = new ConcurrentHashMap<>(); - private final Map methodCache = new ConcurrentHashMap<>(); + private final Map classModCache = BoundedMaps.synchronizedLru(MAX_CLASS_MOD_CACHE_ENTRIES); + private final Map methodCache = BoundedMaps.synchronizedLru(MAX_METHOD_CACHE_ENTRIES); private volatile boolean running = false; private Thread samplerThread; - private Thread targetThread; - private final StringBuilder stackBuilder = new StringBuilder(512); public void start() { if (running) return; running = true; - targetThread = findMinecraftThread(); samplerThread = new Thread(this::runSampler, "TaskManager-Flamegraph"); samplerThread.setDaemon(true); @@ -42,6 +43,10 @@ public void stop() { running = false; } + public boolean isRunning() { + return running; + } + private void runSampler() { while (running) { try { @@ -53,26 +58,28 @@ private void runSampler() { } private void sample() { - Thread thread = targetThread; - if (thread == null) return; - - StackTraceElement[] stack = thread.getStackTrace(); - if (stack.length == 0) return; - - stackBuilder.setLength(0); - - int depth = Math.min(stack.length, MAX_STACK_DEPTH); - for (int i = depth - 1; i >= 0; i--) { - StackTraceElement e = stack[i]; - String className = e.getClassName(); - String mod = classModCache.computeIfAbsent(className, this::resolveMod); - String methodKey = className + "#" + e.getMethodName(); - String method = methodCache.computeIfAbsent(methodKey, k -> formatMethodEntry(mod, className, e.getMethodName())); - stackBuilder.append(method).append(';'); - } + for (ThreadSnapshotCollector.ThreadStackSnapshot threadSnapshot : findTrackedThreads()) { + StackTraceElement[] stack = threadSnapshot.stack(); + if (stack.length == 0) { + continue; + } - String key = stackBuilder.toString(); - stacks.computeIfAbsent(key, k -> new LongAdder()).increment(); + stackBuilder.setLength(0); + stackBuilder.append("{").append(threadSnapshot.threadName()).append("};"); + + int depth = Math.min(stack.length, MAX_STACK_DEPTH); + for (int i = depth - 1; i >= 0; i--) { + StackTraceElement e = stack[i]; + String className = e.getClassName(); + String mod = BoundedMaps.getOrCompute(classModCache, className, this::resolveMod); + String methodKey = className + "#" + e.getMethodName(); + String method = BoundedMaps.getOrCompute(methodCache, methodKey, k -> formatMethodEntry(mod, className, e.getMethodName())); + stackBuilder.append(method).append(';'); + } + + String key = stackBuilder.toString(); + stacks.computeIfAbsent(key, k -> new LongAdder()).increment(); + } } private String formatMethodEntry(String mod, String className, String methodName) { @@ -97,7 +104,8 @@ private String tagFor(String className, String methodName, String mod) { private String resolveMod(String className) { try { - Class clazz = Class.forName(className, false, targetThread.getContextClassLoader()); + ClassLoader contextLoader = Thread.currentThread().getContextClassLoader(); + Class clazz = Class.forName(className, false, contextLoader != null ? contextLoader : FlamegraphProfiler.class.getClassLoader()); String mod = ModClassIndex.getModForClassName(clazz); if (mod == null) return "minecraft"; return mod; @@ -116,13 +124,7 @@ public void reset() { stacks.clear(); } - private Thread findMinecraftThread() { - for (Thread thread : Thread.getAllStackTraces().keySet()) { - String name = thread.getName(); - if ("Render thread".equals(name) || "Client thread".equals(name)) { - return thread; - } - } - return Thread.currentThread(); + private List findTrackedThreads() { + return ThreadSnapshotCollector.getInstance().getLatestNamedSnapshots("Render thread", "Client thread"); } } diff --git a/src/client/java/wueffi/taskmanager/client/FrameTimelineProfiler.java b/src/client/java/wueffi/taskmanager/client/FrameTimelineProfiler.java index 6ec1abd..235d627 100644 --- a/src/client/java/wueffi/taskmanager/client/FrameTimelineProfiler.java +++ b/src/client/java/wueffi/taskmanager/client/FrameTimelineProfiler.java @@ -14,73 +14,79 @@ public class FrameTimelineProfiler { private final long[] frameTimes = new long[SIZE]; private final long[] frameTimestamps = new long[SIZE]; private final double[] fpsHistory = new double[SIZE]; + private final long[] selfCostHistory = new long[SIZE]; private int index = 0; private int count = 0; private long latestFrameNs = 0; private long frameSequence = 0; private double currentFps = 0.0; + private long pendingSelfCostNs = 0L; private long frameStart; - public void beginFrame() { + public synchronized void beginFrame() { frameStart = System.nanoTime(); } - public void endFrame() { + public synchronized void endFrame() { long now = System.nanoTime(); recordFrame(now - frameStart, now); } - void recordFrame(long durationNs, long timestampNs) { + synchronized void recordFrame(long durationNs, long timestampNs) { frameTimes[index] = durationNs; frameTimestamps[index] = timestampNs; latestFrameNs = durationNs; frameSequence++; currentFps = computeRollingFps(timestampNs, index, Math.min(count + 1, SIZE)); fpsHistory[index] = currentFps > 0.0 ? currentFps : 1_000_000_000.0 / Math.max(1L, durationNs); + selfCostHistory[index] = pendingSelfCostNs; + pendingSelfCostNs = 0L; index = (index + 1) % SIZE; if (count < SIZE) { count++; } } - void reset() { + synchronized void reset() { Arrays.fill(frameTimes, 0L); Arrays.fill(frameTimestamps, 0L); Arrays.fill(fpsHistory, 0.0); + Arrays.fill(selfCostHistory, 0L); index = 0; count = 0; latestFrameNs = 0L; frameSequence = 0L; currentFps = 0.0; + pendingSelfCostNs = 0L; frameStart = 0L; } - public long[] getFrames() { - return frameTimes; + public synchronized long[] getFrames() { + return Arrays.copyOf(frameTimes, frameTimes.length); } - public double[] getFpsHistory() { - return fpsHistory; + public synchronized double[] getFpsHistory() { + return Arrays.copyOf(fpsHistory, fpsHistory.length); } - public int getIndex() { + public synchronized int getIndex() { return index; } - public int getCount() { + public synchronized int getCount() { return count; } - public long getLatestFrameNs() { + public synchronized long getLatestFrameNs() { return latestFrameNs; } - public long getFrameSequence() { + public synchronized long getFrameSequence() { return frameSequence; } - public double getCurrentFps() { + public synchronized double getCurrentFps() { if (count == 0) { return 0.0; } @@ -89,7 +95,7 @@ public double getCurrentFps() { return rolling > 0.0 ? rolling : (currentFps > 0.0 ? currentFps : getAverageFps()); } - public double getAverageFps() { + public synchronized double getAverageFps() { if (count == 0) { return 0.0; } @@ -106,17 +112,17 @@ public double getAverageFps() { return 1_000_000_000.0 / averageFrameNs; } - public double getOnePercentLowFps() { + public synchronized double getOnePercentLowFps() { long percentileNs = getPercentileFrameNs(0.99); return percentileNs <= 0L ? 0.0 : 1_000_000_000.0 / percentileNs; } - public double getPointOnePercentLowFps() { + public synchronized double getPointOnePercentLowFps() { long percentileNs = getPercentileFrameNs(0.999); return percentileNs <= 0L ? 0.0 : 1_000_000_000.0 / percentileNs; } - public long getAverageFrameNs() { + public synchronized long getAverageFrameNs() { if (count == 0) return 0; long total = 0; @@ -126,7 +132,7 @@ public long getAverageFrameNs() { return total / count; } - public double getFrameVarianceMs() { + public synchronized double getFrameVarianceMs() { if (count == 0) return 0; double meanMs = getAverageFrameNs() / 1_000_000.0; @@ -139,16 +145,16 @@ public double getFrameVarianceMs() { return variance / count; } - public double getFrameStdDevMs() { + public synchronized double getFrameStdDevMs() { return Math.sqrt(getFrameVarianceMs()); } - public double getStutterScore() { + public synchronized double getStutterScore() { double stdDevMs = getFrameStdDevMs(); return Math.min(100.0, stdDevMs * 8.0); } - public long getMaxFrameNs() { + public synchronized long getMaxFrameNs() { long max = 0; for (int i = 0; i < count; i++) { if (frameTimes[i] > max) { @@ -158,7 +164,7 @@ public long getMaxFrameNs() { return max; } - public double getMaxFps() { + public synchronized double getMaxFps() { double max = 0.0; for (int i = 0; i < count; i++) { if (fpsHistory[i] > max) { @@ -168,7 +174,7 @@ public double getMaxFps() { return max; } - public long getPercentileFrameNs(double percentile) { + public synchronized long getPercentileFrameNs(double percentile) { if (count == 0) return 0; long[] copy = new long[count]; @@ -179,7 +185,7 @@ public long getPercentileFrameNs(double percentile) { return copy[idx]; } - public double[] getOrderedFrameMsHistory() { + public synchronized double[] getOrderedFrameMsHistory() { double[] ordered = new double[count]; for (int i = 0; i < count; i++) { int sourceIndex = (index - count + i + SIZE) % SIZE; @@ -188,7 +194,7 @@ public double[] getOrderedFrameMsHistory() { return ordered; } - public long[] getOrderedFrameTimestampHistory() { + public synchronized long[] getOrderedFrameTimestampHistory() { long[] ordered = new long[count]; for (int i = 0; i < count; i++) { int sourceIndex = (index - count + i + SIZE) % SIZE; @@ -197,7 +203,7 @@ public long[] getOrderedFrameTimestampHistory() { return ordered; } - public double[] getOrderedFpsHistory() { + public synchronized double[] getOrderedFpsHistory() { double[] ordered = new double[count]; for (int i = 0; i < count; i++) { int sourceIndex = (index - count + i + SIZE) % SIZE; @@ -206,7 +212,41 @@ public double[] getOrderedFpsHistory() { return ordered; } - public double getHistorySpanSeconds() { + public synchronized void addSelfCost(long durationNs) { + pendingSelfCostNs += Math.max(0L, durationNs); + } + + public synchronized double[] getOrderedSelfCostMsHistory() { + double[] ordered = new double[count]; + for (int i = 0; i < count; i++) { + int sourceIndex = (index - count + i + SIZE) % SIZE; + ordered[i] = selfCostHistory[sourceIndex] / 1_000_000.0; + } + return ordered; + } + + public synchronized double getSelfCostAvgMs() { + if (count == 0) { + return 0.0; + } + long total = 0L; + for (int i = 0; i < count; i++) { + total += selfCostHistory[i]; + } + return total / (double) count / 1_000_000.0; + } + + public synchronized double getSelfCostMaxMs() { + long max = 0L; + for (int i = 0; i < count; i++) { + if (selfCostHistory[i] > max) { + max = selfCostHistory[i]; + } + } + return max / 1_000_000.0; + } + + public synchronized double getHistorySpanSeconds() { if (count < 2) { return 0.0; } @@ -275,7 +315,7 @@ private double computeRollingFps(long endTimestampNs, int newestIndex, int avail return framesInWindow * 1_000_000_000.0 / Math.max(1L, endTimestampNs - oldestTimestamp); } - public java.util.Map getFrameTimeHistogram() { + public synchronized java.util.Map getFrameTimeHistogram() { if (count == 0) { return java.util.Map.of("<8ms", 0.0, "8-16ms", 0.0, "16-32ms", 0.0, ">32ms", 0.0); } diff --git a/src/client/java/wueffi/taskmanager/client/HardwareInfoResolver.java b/src/client/java/wueffi/taskmanager/client/HardwareInfoResolver.java new file mode 100644 index 0000000..07561c6 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/HardwareInfoResolver.java @@ -0,0 +1,62 @@ +package wueffi.taskmanager.client; + +import com.sun.jna.platform.win32.Advapi32Util; +import com.sun.jna.platform.win32.WinReg; + +import java.util.Locale; + +final class HardwareInfoResolver { + + private static final String UNKNOWN_CPU = "Unknown CPU"; + private static volatile String cachedCpuDisplayName; + + private HardwareInfoResolver() { + } + + static String getCpuDisplayName() { + String cached = cachedCpuDisplayName; + if (cached != null && !cached.isBlank()) { + return cached; + } + String resolved = resolveCpuDisplayName(); + cachedCpuDisplayName = resolved; + return resolved; + } + + private static String resolveCpuDisplayName() { + String registryName = readWindowsCpuRegistryName(); + if (!registryName.isBlank()) { + return registryName; + } + String envIdentifier = sanitizeCpuLabel(System.getenv("PROCESSOR_IDENTIFIER")); + if (!envIdentifier.isBlank()) { + return envIdentifier; + } + String architecture = sanitizeCpuLabel(System.getProperty("os.arch", "")); + return architecture.isBlank() ? UNKNOWN_CPU : architecture; + } + + private static String readWindowsCpuRegistryName() { + String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (!osName.contains("win")) { + return ""; + } + try { + String value = Advapi32Util.registryGetStringValue( + WinReg.HKEY_LOCAL_MACHINE, + "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0", + "ProcessorNameString" + ); + return sanitizeCpuLabel(value); + } catch (Throwable ignored) { + return ""; + } + } + + static String sanitizeCpuLabel(String value) { + if (value == null) { + return ""; + } + return value.replace('\0', ' ').replaceAll("\\s+", " ").trim(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/HudOverlayRenderer.java b/src/client/java/wueffi/taskmanager/client/HudOverlayRenderer.java index 30bbdbd..84142c6 100644 --- a/src/client/java/wueffi/taskmanager/client/HudOverlayRenderer.java +++ b/src/client/java/wueffi/taskmanager/client/HudOverlayRenderer.java @@ -5,12 +5,13 @@ import net.minecraft.client.gui.DrawContext; import wueffi.taskmanager.client.util.ConfigManager; -import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.Deque; -import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.Supplier; public final class HudOverlayRenderer { @@ -37,38 +38,119 @@ public final class HudOverlayRenderer { private static final int LABEL_VALUE_GAP = 8; private static final int VALUE_RIGHT_PADDING = 4; private static final int MAX_MEMORY_RATE_SAMPLES = 24; - private static final Map displayCache = new HashMap<>(); - private static final Deque memoryRateSamples = new ArrayDeque<>(); - private static final Map rateSamples = new HashMap<>(); - private static final Map sensorRateSamples = new HashMap<>(); + private static final int MAX_DISPLAY_CACHE_ENTRIES = 192; + private static final int MAX_RATE_CACHE_ENTRIES = 128; + private static final Map displayCache = new ConcurrentHashMap<>(); + private static final ConcurrentLinkedDeque displayCacheOrder = new ConcurrentLinkedDeque<>(); + private static final ConcurrentLinkedDeque memoryRateSamples = new ConcurrentLinkedDeque<>(); + private static final Map rateSamples = new ConcurrentHashMap<>(); + private static final ConcurrentLinkedDeque rateSampleOrder = new ConcurrentLinkedDeque<>(); + private static final Map sensorRateSamples = new ConcurrentHashMap<>(); + private static final ConcurrentLinkedDeque sensorRateSampleOrder = new ConcurrentLinkedDeque<>(); + private static HudLayoutCache cachedLayout; + private static long currentHudNowMillis; private HudOverlayRenderer() { } public static void render(DrawContext ctx) { MinecraftClient client = MinecraftClient.getInstance(); - if (!ConfigManager.isHudEnabled()) { - return; - } if (client.options.hudHidden || client.currentScreen instanceof TaskManagerScreen) { return; } - ProfilerManager profilerManager = ProfilerManager.getInstance(); - ProfilerManager.ProfilerSnapshot snapshot = profilerManager.getCurrentSnapshot(); - FrameTimelineProfiler frame = FrameTimelineProfiler.getInstance(); - MemoryProfiler.Snapshot memory = snapshot.memory(); - SystemMetricsProfiler.Snapshot system = snapshot.systemMetrics(); - refreshDisplayedMetrics(frame, memory, snapshot, system); - ProfilerManager.RuleFinding highestFinding = highestFinding(profilerManager); - double latestFrameMs = frame.getLatestFrameNs() / 1_000_000.0; - long recentSpikeAge = snapshot.spikes().isEmpty() ? Long.MAX_VALUE : Math.max(0L, System.currentTimeMillis() - snapshot.spikes().get(0).capturedAtEpochMillis()); - boolean actionableWarning = hasActionableWarning(snapshot, highestFinding, latestFrameMs, recentSpikeAge); - int alertColor = severityColor(highestFinding == null ? null : highestFinding.severity(), actionableWarning); + long renderNow = System.currentTimeMillis(); + currentHudNowMillis = renderNow; + try { + ProfilerManager profilerManager = ProfilerManager.getInstance(); + renderPerformanceAlertBanner(ctx, client, profilerManager); + if (!ConfigManager.isHudEnabled()) { + return; + } + ProfilerManager.ProfilerSnapshot snapshot = profilerManager.getCurrentSnapshot(); + FrameTimelineProfiler frame = FrameTimelineProfiler.getInstance(); + MemoryProfiler.Snapshot memory = snapshot.memory(); + SystemMetricsProfiler.Snapshot system = snapshot.systemMetrics(); + refreshDisplayedMetrics(frame, memory, snapshot, system); + ProfilerManager.RuleFinding highestFinding = highestFinding(profilerManager); + double latestFrameMs = frame.getLatestFrameNs() / 1_000_000.0; + long recentSpikeAge = snapshot.spikes().isEmpty() ? Long.MAX_VALUE : Math.max(0L, renderNow - snapshot.spikes().get(0).capturedAtEpochMillis()); + boolean actionableWarning = hasActionableWarning(snapshot, highestFinding, latestFrameMs, recentSpikeAge); + int alertColor = severityColor(highestFinding == null ? null : highestFinding.severity(), actionableWarning); + + if (!shouldRenderHud(snapshot, highestFinding, latestFrameMs, recentSpikeAge)) { + return; + } - if (!shouldRenderHud(snapshot, highestFinding, latestFrameMs, recentSpikeAge)) { + HudLayoutCache layout = prepareHudLayout(client, profilerManager, snapshot, frame, memory, system, highestFinding, latestFrameMs, actionableWarning, alertColor); + TextRenderer textRenderer = client.textRenderer; + int backgroundColor = applyHudTransparency(BG); + int borderFillColor = applyHudTransparency(layout.borderColor()); + int dividerColor = applyHudTransparency(0x443A3F46); + ctx.fill(layout.x(), layout.y(), layout.x() + layout.width(), layout.y() + layout.height(), backgroundColor); + ctx.fill(layout.x(), layout.y(), layout.x() + layout.width(), layout.y() + 1, borderFillColor); + ctx.fill(layout.x(), layout.y(), layout.x() + 1, layout.y() + layout.height(), borderFillColor); + ctx.fill(layout.x() + layout.width() - 1, layout.y(), layout.x() + layout.width(), layout.y() + layout.height(), borderFillColor); + ctx.fill(layout.x(), layout.y() + layout.height() - 1, layout.x() + layout.width(), layout.y() + layout.height(), borderFillColor); + ctx.fill(layout.x(), layout.y() + HEADER_HEIGHT, layout.x() + layout.width(), layout.y() + HEADER_HEIGHT + 1, dividerColor); + + ctx.drawText(textRenderer, "Task Manager", layout.x() + PADDING, layout.y() + 5, actionableWarning ? HEADER : DIM, false); + String modeText = snapshot.mode().name().replace('_', ' '); + ctx.drawText(textRenderer, modeText, layout.x() + layout.width() - PADDING - textRenderer.getWidth(modeText), layout.y() + 5, actionableWarning ? alertColor : DIM, false); + + int rowY = layout.y() + HEADER_HEIGHT + 6; + for (Row row : layout.rows()) { + if (row.fullWidth()) { + drawEntry(ctx, textRenderer, layout.x() + PADDING, rowY, layout.contentWidth(), row.entries().getFirst(), true); + } else { + for (int i = 0; i < row.entries().size(); i++) { + int cellX = layout.x() + PADDING + i * (layout.cellWidth() + GAP); + drawEntry(ctx, textRenderer, cellX, rowY, layout.cellWidth(), row.entries().get(i), false); + } + } + rowY += ROW_HEIGHT; + } + } finally { + currentHudNowMillis = 0L; + } + } + + private static void renderPerformanceAlertBanner(DrawContext ctx, MinecraftClient client, ProfilerManager profilerManager) { + ProfilerManager.PerformanceAlert alert = profilerManager.getLatestPerformanceAlert(); + if (alert == null) { return; } + TextRenderer textRenderer = client.textRenderer; + String label = alert.label() + " alert"; + String message = textRenderer.trimToWidth(alert.message(), Math.max(220, client.getWindow().getScaledWidth() - 80)); + int width = Math.min(client.getWindow().getScaledWidth() - 24, Math.max(240, textRenderer.getWidth(message) + 24)); + int x = Math.max(12, (client.getWindow().getScaledWidth() - width) / 2); + int y = 10; + int bg = applyHudTransparency(profilerManager.isPerformanceAlertFlashActive() ? 0xCC5B1717 : 0xB0331A1A); + int border = severityColor(alert.severity(), true); + ctx.fill(x, y, x + width, y + 30, bg); + ctx.fill(x, y, x + width, y + 1, border); + ctx.fill(x, y + 29, x + width, y + 30, border); + ctx.fill(x, y, x + 1, y + 30, border); + ctx.fill(x + width - 1, y, x + width, y + 30, border); + ctx.drawText(textRenderer, label, x + 8, y + 5, HEADER, false); + ctx.drawText(textRenderer, message, x + 8, y + 17, TEXT, false); + } + + private static HudLayoutCache prepareHudLayout(MinecraftClient client, ProfilerManager profilerManager, ProfilerManager.ProfilerSnapshot snapshot, FrameTimelineProfiler frame, MemoryProfiler.Snapshot memory, SystemMetricsProfiler.Snapshot system, ProfilerManager.RuleFinding highestFinding, double latestFrameMs, boolean actionableWarning, int alertColor) { + int screenW = client.getWindow().getScaledWidth(); + int screenH = client.getWindow().getScaledHeight(); + int columns = ConfigManager.getHudLayoutMode().columns(); + int configHash = computeHudConfigHash(); + if (cachedLayout != null + && cachedLayout.snapshot() == snapshot + && cachedLayout.configHash() == configHash + && cachedLayout.screenW() == screenW + && cachedLayout.screenH() == screenH + && cachedLayout.columns() == columns + && cachedLayout.actionableWarning() == actionableWarning) { + return cachedLayout; + } List entries = new ArrayList<>(); if (ConfigManager.getHudConfigMode() == ConfigManager.HudConfigMode.PRESET) { @@ -91,10 +173,7 @@ public static void render(DrawContext ctx) { appendExpandedDetails(entries, profilerManager, snapshot, highestFinding, alertColor, autoFocusEntry != null); } - int screenW = client.getWindow().getScaledWidth(); - int screenH = client.getWindow().getScaledHeight(); int maxContentWidth = Math.max(160, screenW - 16 - (PADDING * 2)); - int columns = ConfigManager.getHudLayoutMode().columns(); int cellWidth = getCellWidth(columns, maxContentWidth); List layoutEntries = normalizeEntriesForColumns(entries, client.textRenderer, columns, cellWidth, maxContentWidth); List rows = buildRows(layoutEntries, columns); @@ -116,33 +195,41 @@ public static void render(DrawContext ctx) { } } - int backgroundColor = applyHudTransparency(BG); - int borderFillColor = applyHudTransparency(borderColor); - int dividerColor = applyHudTransparency(0x443A3F46); - ctx.fill(x, y, x + width, y + height, backgroundColor); - ctx.fill(x, y, x + width, y + 1, borderFillColor); - ctx.fill(x, y, x + 1, y + height, borderFillColor); - ctx.fill(x + width - 1, y, x + width, y + height, borderFillColor); - ctx.fill(x, y + height - 1, x + width, y + height, borderFillColor); - ctx.fill(x, y + HEADER_HEIGHT, x + width, y + HEADER_HEIGHT + 1, dividerColor); - - TextRenderer textRenderer = client.textRenderer; - ctx.drawText(textRenderer, "Task Manager", x + PADDING, y + 5, actionableWarning ? HEADER : DIM, false); - String modeText = snapshot.mode().name().replace('_', ' '); - ctx.drawText(textRenderer, modeText, x + width - PADDING - textRenderer.getWidth(modeText), y + 5, actionableWarning ? alertColor : DIM, false); - - int rowY = y + HEADER_HEIGHT + 6; - for (Row row : rows) { - if (row.fullWidth()) { - drawEntry(ctx, textRenderer, x + PADDING, rowY, contentWidth, row.entries().getFirst(), true); - } else { - for (int i = 0; i < row.entries().size(); i++) { - int cellX = x + PADDING + i * (cellWidth + GAP); - drawEntry(ctx, textRenderer, cellX, rowY, cellWidth, row.entries().get(i), false); - } - } - rowY += ROW_HEIGHT; - } + cachedLayout = new HudLayoutCache(snapshot, configHash, screenW, screenH, columns, actionableWarning, rows, contentWidth, cellWidth, width, height, x, y, borderColor); + return cachedLayout; + } + + private static long hudNow() { + return currentHudNowMillis > 0L ? currentHudNowMillis : System.currentTimeMillis(); + } + + private static int computeHudConfigHash() { + return Objects.hash( + ConfigManager.getHudConfigMode(), + ConfigManager.getHudPreset(), + ConfigManager.getHudLayoutMode(), + ConfigManager.getHudPosition(), + ConfigManager.getHudTransparencyPercent(), + ConfigManager.isHudAutoFocusAlertRow(), + ConfigManager.isHudExpandedOnWarning(), + ConfigManager.isHudShowFps(), + ConfigManager.isHudShowFrame(), + ConfigManager.isHudShowTicks(), + ConfigManager.isHudShowUtilization(), + ConfigManager.isHudShowLogic(), + ConfigManager.isHudShowBackground(), + ConfigManager.isHudShowParallelism(), + ConfigManager.isHudShowFrameBudget(), + ConfigManager.isHudShowMemory(), + ConfigManager.isHudShowVram(), + ConfigManager.isHudShowNetwork(), + ConfigManager.isHudShowChunkActivity(), + ConfigManager.isHudShowWorld(), + ConfigManager.isHudShowDiskIo(), + ConfigManager.isHudShowInputLatency(), + ConfigManager.isHudShowSession(), + ConfigManager.isHudShowTemperatures(), + ConfigManager.isHudBudgetColorMode()); } private static void buildCompactEntries(List entries, ProfilerManager.ProfilerSnapshot snapshot, FrameTimelineProfiler frame, MemoryProfiler.Snapshot memory, SystemMetricsProfiler.Snapshot system) { @@ -152,6 +239,9 @@ private static void buildCompactEntries(List entries, ProfilerManager.Pro entries.add(new Entry("GPU", formatUtilAndTemp(system, false), utilizationColor(system, false), false)); entries.add(new Entry("Memory", displayedMemoryText(memory), memoryColor(memory), false)); entries.add(new Entry("VRAM", displayedMetric("vram", () -> vramText(system)), vramColor(system), false)); + if (frame.getSelfCostAvgMs() > 0.1) { + entries.add(new Entry("Profiler", displayedMetric("profiler.cost", () -> String.format(Locale.ROOT, "%.2f ms", frame.getSelfCostAvgMs())), DIM, false)); + } if (snapshot.sessionLogging()) { entries.add(new Entry("Session", displayedMetric("session", () -> formatDuration(snapshot.sessionLoggingElapsedMillis()) + " / " + formatDuration(ConfigManager.getSessionDurationSeconds() * 1000L)), WARN, false)); } @@ -169,6 +259,9 @@ private static void buildPresetFullEntries(List entries, ProfilerManager. entries.add(new Entry("Input Latency", displayedMetric("input.latency", () -> inputLatencyText(system)), inputLatencyColor(system), false)); entries.add(new Entry("Memory", displayedMemoryText(memory), memoryColor(memory), false)); entries.add(new Entry("VRAM", displayedMetric("vram", () -> vramText(system)), vramColor(system), false)); + if (frame.getSelfCostAvgMs() > 0.1) { + entries.add(new Entry("Profiler", displayedMetric("profiler.cost", () -> String.format(Locale.ROOT, "%.2f ms", frame.getSelfCostAvgMs())), DIM, false)); + } entries.add(networkEntry(system)); entries.add(new Entry("Chunk Activity", displayedMetric("chunk.activity", () -> chunkActivityText(system)), chunkActivityColor(system), true)); entries.add(new Entry("Entities", displayedMetric("world.entities", () -> worldEntitiesText(snapshot)), DIM, false)); @@ -218,6 +311,9 @@ private static void buildCustomEntries(List entries, ProfilerManager.Prof if (ConfigManager.isHudShowVram()) { entries.add(new Entry("VRAM", displayedMetric("vram", () -> vramText(system)), vramColor(system), false)); } + if (frame.getSelfCostAvgMs() > 0.1) { + entries.add(new Entry("Profiler", displayedMetric("profiler.cost", () -> String.format(Locale.ROOT, "%.2f ms", frame.getSelfCostAvgMs())), DIM, false)); + } if (ConfigManager.isHudShowNetwork()) { entries.add(networkEntry(system)); } @@ -407,7 +503,7 @@ private static String trimWithEllipsis(TextRenderer textRenderer, String value, private static String displayedFpsText(FrameTimelineProfiler frame) { return displayedMetric("fps.primary", () -> { - long now = System.currentTimeMillis(); + long now = hudNow(); return format0(frame.getCurrentFps()) + " now | " + format0(frame.getAverageFps()) + " avg" + rateSuffix("fps", frame.getCurrentFps(), now, "fps/s", ConfigManager.isHudShowFpsRateOfChange()); }); } @@ -417,7 +513,7 @@ private static String displayedLowFpsText(FrameTimelineProfiler frame) { } private static String displayedMemoryText(MemoryProfiler.Snapshot memory) { - return displayedMetric("memory.primary", () -> formatMemoryDisplay(memory, System.currentTimeMillis())); + return displayedMetric("memory.primary", () -> formatMemoryDisplay(memory, hudNow())); } private static void refreshDisplayedMetrics(FrameTimelineProfiler frame, MemoryProfiler.Snapshot memory, ProfilerManager.ProfilerSnapshot snapshot, SystemMetricsProfiler.Snapshot system) { @@ -441,13 +537,14 @@ private static void refreshDisplayedMetrics(FrameTimelineProfiler frame, MemoryP } private static String displayedMetric(String key, Supplier supplier) { - long now = System.currentTimeMillis(); + long now = hudNow(); DisplayCacheEntry cached = displayCache.get(key); if (cached != null && !shouldRefreshDisplayedMetric(now, cached.updatedAtMillis(), ConfigManager.getMetricsUpdateIntervalMs())) { return cached.value(); } String value = supplier.get(); displayCache.put(key, new DisplayCacheEntry(now, value)); + recordCacheKey(displayCacheOrder, key, MAX_DISPLAY_CACHE_ENTRIES, displayCache); return value; } @@ -457,19 +554,17 @@ static boolean shouldRefreshDisplayedMetric(long nowMillis, long lastUpdatedMill private static String compactFrameText(FrameTimelineProfiler frame) { double avgFrameMs = frame.getAverageFrameNs() / 1_000_000.0; - return format1(avgFrameMs) + " avg | " + format1(frame.getMaxFrameNs() / 1_000_000.0) + " max" + rateSuffix("frame", avgFrameMs, System.currentTimeMillis(), "ms/s", ConfigManager.isHudShowFrameRateOfChange()); + return format1(avgFrameMs) + " avg | " + format1(frame.getMaxFrameNs() / 1_000_000.0) + " max" + rateSuffix("frame", avgFrameMs, hudNow(), "ms/s", ConfigManager.isHudShowFrameRateOfChange()); } private static String formatMemoryDisplay(MemoryProfiler.Snapshot memory, long now) { long usedBytes = Math.max(0L, memory.heapUsedBytes()); long maxBytes = Math.max(usedBytes, memory.heapMaxBytes() > 0 ? memory.heapMaxBytes() : memory.heapCommittedBytes()); memoryRateSamples.addLast(new MemoryRateSample(now, usedBytes)); - while (memoryRateSamples.size() > MAX_MEMORY_RATE_SAMPLES) { - memoryRateSamples.removeFirst(); - } + trimDeque(memoryRateSamples, MAX_MEMORY_RATE_SAMPLES); long cutoff = now - 4000L; while (memoryRateSamples.size() > 2 && memoryRateSamples.peekFirst() != null && memoryRateSamples.peekFirst().capturedAtMillis() < cutoff) { - memoryRateSamples.removeFirst(); + memoryRateSamples.pollFirst(); } String base = formatMegabytes(usedBytes) + "/" + formatMegabytes(maxBytes) + " MB"; @@ -484,7 +579,7 @@ private static String formatMemoryDisplay(MemoryProfiler.Snapshot memory, long n } private static String formatUtilAndTemp(SystemMetricsProfiler.Snapshot system, boolean cpu) { - long now = System.currentTimeMillis(); + long now = hudNow(); String sensorKey = cpu ? "cpu" : "gpu"; double loadPercent = cpu ? system.cpuCoreLoadPercent() : system.gpuCoreLoadPercent(); double temperatureC = cpu ? system.cpuTemperatureC() : system.gpuTemperatureC(); @@ -495,7 +590,9 @@ private static String formatUtilAndTemp(SystemMetricsProfiler.Snapshot system, b if (!ConfigManager.isHudShowTemperatures()) { return load + appendSuffix(loadRateSuffix); } - String temp = temperatureC >= 0.0 ? format0(temperatureC) + "C" : "n/a"; + String temp = cpu + ? (temperatureC >= 0.0 ? format0(temperatureC) + "C" : "n/a") + : TelemetryTextFormatter.formatGpuTemperatureCompact(system); if (!ConfigManager.isHudShowUtilizationRateOfChange()) { return load + " / " + temp; } @@ -524,7 +621,7 @@ private static Entry buildAutoFocusEntry(ProfilerManager profilerManager, Profil } private static String frameBudgetText(FrameTimelineProfiler frame) { - long now = System.currentTimeMillis(); + long now = hudNow(); double currentFrameMs = frame.getLatestFrameNs() / 1_000_000.0; double targetFrameMs = targetFrameBudgetMs(); double overBudgetMs = currentFrameMs - targetFrameMs; @@ -533,7 +630,7 @@ private static String frameBudgetText(FrameTimelineProfiler frame) { } private static String vramText(SystemMetricsProfiler.Snapshot system) { - long now = System.currentTimeMillis(); + long now = hudNow(); if (system.vramUsedBytes() < 0 || system.vramTotalBytes() <= 0) { return "n/a"; } @@ -548,7 +645,7 @@ private static String vramText(SystemMetricsProfiler.Snapshot system) { } private static String networkText(SystemMetricsProfiler.Snapshot system) { - long now = System.currentTimeMillis(); + long now = hudNow(); String down = compactIo(system.bytesReceivedPerSecond()); String up = compactIo(system.bytesSentPerSecond()); String base = "D " + down + " | U " + up; @@ -561,7 +658,7 @@ private static String networkText(SystemMetricsProfiler.Snapshot system) { } private static String chunkActivityText(SystemMetricsProfiler.Snapshot system) { - long now = System.currentTimeMillis(); + long now = hudNow(); String base = "G " + system.chunksGenerating() + " | M " + system.chunksMeshing() + " | U " + system.chunksUploading(); if (!ConfigManager.isHudShowChunkActivityRateOfChange()) { return base; @@ -578,7 +675,7 @@ private static String inputLatencyText(SystemMetricsProfiler.Snapshot system) { if (latencyMs < 0.0) { return "n/a"; } - return format1(latencyMs) + " ms" + rateSuffix("input.latency", latencyMs, System.currentTimeMillis(), "ms/s", ConfigManager.isHudShowInputLatencyRateOfChange()); + return format1(latencyMs) + " ms" + rateSuffix("input.latency", latencyMs, hudNow(), "ms/s", ConfigManager.isHudShowInputLatencyRateOfChange()); } private static int frameBudgetColor(FrameTimelineProfiler frame) { @@ -693,6 +790,7 @@ private static String optionalByteRateSuffix(String key, long currentValue, long if (previous == null) { String initial = ConfigManager.isHudShowZeroRateOfChange() ? "~0 B" + units : ""; rateSamples.put(key, new RateSample(now, currentValue, initial)); + recordCacheKey(rateSampleOrder, key, MAX_RATE_CACHE_ENTRIES, rateSamples); return initial; } long elapsedMillis = now - previous.capturedAtMillis(); @@ -704,6 +802,7 @@ private static String optionalByteRateSuffix(String key, long currentValue, long ? "" : signedByteRate(deltaPerSecond, units); rateSamples.put(key, new RateSample(now, currentValue, suffix)); + recordCacheKey(rateSampleOrder, key, MAX_RATE_CACHE_ENTRIES, rateSamples); return suffix; } @@ -794,12 +893,12 @@ private static String signedSensorRate(double value, String units) { } private static String worldEntitiesText(ProfilerManager.ProfilerSnapshot snapshot) { - long now = System.currentTimeMillis(); + long now = hudNow(); return Integer.toString(snapshot.entityCounts().totalEntities()) + rateSuffix("world.entities", snapshot.entityCounts().totalEntities(), now, "/s", ConfigManager.isHudShowWorldRateOfChange()); } private static String worldChunksText(ProfilerManager.ProfilerSnapshot snapshot) { - long now = System.currentTimeMillis(); + long now = hudNow(); String loaded = Long.toString(snapshot.chunkCounts().loadedChunks()); String rendered = Long.toString(snapshot.chunkCounts().renderedChunks()); if (!ConfigManager.isHudShowWorldRateOfChange()) { @@ -812,7 +911,7 @@ private static String worldChunksText(ProfilerManager.ProfilerSnapshot snapshot) } private static String diskIoText(SystemMetricsProfiler.Snapshot system) { - long now = System.currentTimeMillis(); + long now = hudNow(); String read = compactIo(system.diskReadBytesPerSecond()); String write = compactIo(system.diskWriteBytesPerSecond()); if (!ConfigManager.isHudShowDiskIoRateOfChange()) { @@ -858,7 +957,7 @@ private static String networkRateSuffixText(SystemMetricsProfiler.Snapshot syste if (!ConfigManager.isHudShowNetworkRateOfChange()) { return ""; } - long now = System.currentTimeMillis(); + long now = hudNow(); String downRate = optionalByteRateSuffix("network.down", system.bytesReceivedPerSecond(), now, "/s/s"); String upRate = optionalByteRateSuffix("network.up", system.bytesSentPerSecond(), now, "/s/s"); return appendSuffix(joinRateParts(downRate, upRate)); @@ -868,14 +967,14 @@ private static String diskRateSuffixText(SystemMetricsProfiler.Snapshot system) if (!ConfigManager.isHudShowDiskIoRateOfChange()) { return ""; } - long now = System.currentTimeMillis(); + long now = hudNow(); String readRate = optionalRateSuffix("disk.read", system.diskReadBytesPerSecond(), now, "B/s/s"); String writeRate = optionalRateSuffix("disk.write", system.diskWriteBytesPerSecond(), now, "B/s/s"); return appendSuffix(joinRateParts(readRate, writeRate)); } private static String tickText(String key, double millis) { - return format1(millis) + " ms" + rateSuffix(key, millis, System.currentTimeMillis(), "ms/s", ConfigManager.isHudShowTickRateOfChange()); + return format1(millis) + " ms" + rateSuffix(key, millis, hudNow(), "ms/s", ConfigManager.isHudShowTickRateOfChange()); } private static String rateSuffix(String key, double currentValue, long now, String units, boolean enabled) { @@ -891,6 +990,7 @@ private static String optionalRateSuffix(String key, double currentValue, long n if (previous == null) { String initial = ConfigManager.isHudShowZeroRateOfChange() ? "~0 " + units : ""; rateSamples.put(key, new RateSample(now, currentValue, initial)); + recordCacheKey(rateSampleOrder, key, MAX_RATE_CACHE_ENTRIES, rateSamples); return initial; } @@ -904,6 +1004,7 @@ private static String optionalRateSuffix(String key, double currentValue, long n ? "" : signedDynamicRate(deltaPerSecond, units); rateSamples.put(key, new RateSample(now, currentValue, suffix)); + recordCacheKey(rateSampleOrder, key, MAX_RATE_CACHE_ENTRIES, rateSamples); return suffix; } @@ -925,11 +1026,13 @@ private static String heldSensorRateSuffix(String key, double currentValue, long if (previous == null) { String initial = ConfigManager.isHudShowZeroRateOfChange() ? "~0 " + units : ""; sensorRateSamples.put(key, new SensorRateSample(now, currentValue, now, currentValue, initial)); + recordCacheKey(sensorRateSampleOrder, key, MAX_RATE_CACHE_ENTRIES, sensorRateSamples); return initial; } if (Math.abs(currentValue - previous.lastObservedValue()) < 0.05) { sensorRateSamples.put(key, previous.withObserved(now, currentValue)); + recordCacheKey(sensorRateSampleOrder, key, MAX_RATE_CACHE_ENTRIES, sensorRateSamples); return previous.displaySuffix(); } @@ -939,9 +1042,28 @@ private static String heldSensorRateSuffix(String key, double currentValue, long ? "" : signedDynamicRate(deltaPerSecond, units); sensorRateSamples.put(key, new SensorRateSample(now, currentValue, now, currentValue, suffix)); + recordCacheKey(sensorRateSampleOrder, key, MAX_RATE_CACHE_ENTRIES, sensorRateSamples); return suffix; } + private static void recordCacheKey(ConcurrentLinkedDeque order, String key, int maxEntries, Map cache) { + order.remove(key); + order.addLast(key); + while (cache.size() > maxEntries) { + String eldest = order.pollFirst(); + if (eldest == null) { + break; + } + cache.remove(eldest); + } + } + + private static void trimDeque(ConcurrentLinkedDeque deque, int maxEntries) { + while (deque.size() > maxEntries) { + deque.pollFirst(); + } + } + private static String joinRateParts(String first, String second) { if ((first == null || first.isBlank()) && (second == null || second.isBlank())) { return ""; @@ -1021,6 +1143,9 @@ private record MemoryRateSample(long capturedAtMillis, long usedBytes) { private record DisplayCacheEntry(long updatedAtMillis, String value) { } + private record HudLayoutCache(ProfilerManager.ProfilerSnapshot snapshot, int configHash, int screenW, int screenH, int columns, boolean actionableWarning, List rows, int contentWidth, int cellWidth, int width, int height, int x, int y, int borderColor) { + } + private record ValueSegment(String text, int color) { } diff --git a/src/client/java/wueffi/taskmanager/client/MemoryProfiler.java b/src/client/java/wueffi/taskmanager/client/MemoryProfiler.java index 85c2f98..dda1c72 100644 --- a/src/client/java/wueffi/taskmanager/client/MemoryProfiler.java +++ b/src/client/java/wueffi/taskmanager/client/MemoryProfiler.java @@ -1,5 +1,6 @@ package wueffi.taskmanager.client; +import com.sun.management.ThreadMXBean; import wueffi.taskmanager.client.util.ModClassIndex; import javax.management.MBeanServer; @@ -14,6 +15,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; public class MemoryProfiler { @@ -24,6 +28,7 @@ public class MemoryProfiler { private static final ObjectName DIAGNOSTIC_COMMAND_NAME; private static final int MAX_SHARED_FAMILIES = 8; private static final int MAX_CLASSES_PER_FAMILY = 8; + private static final long MIN_MOD_SAMPLE_INTERVAL_MS = 10_000L; static { ObjectName name = null; @@ -42,8 +47,35 @@ public class MemoryProfiler { private final Map classModCache = new ConcurrentHashMap<>(); private final Map lastGcCountsByName = new ConcurrentHashMap<>(); private final Map lastGcTimesByName = new ConcurrentHashMap<>(); + private final Map lastAllocatedBytesByThread = new ConcurrentHashMap<>(); private final AtomicLong lastJvmSampleAtMillis = new AtomicLong(0); private final AtomicLong lastModSampleAtMillis = new AtomicLong(0); + private final AtomicLong lastAllocationSampleAtMillis = new AtomicLong(0); + private final AtomicLong lastModSampleDurationMillis = new AtomicLong(0); + private final ExecutorService modHistogramExecutor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "taskmanager-memory-histogram"); + thread.setDaemon(true); + return thread; + }); + private final AtomicBoolean modSampleInFlight = new AtomicBoolean(false); + private final ThreadMXBean threadBean; + private volatile Map modAllocationRateBytesPerSecond = Map.of(); + private volatile Map threadAllocationRateBytesPerSecond = Map.of(); + + private MemoryProfiler() { + ThreadMXBean detected = null; + try { + java.lang.management.ThreadMXBean platformBean = ManagementFactory.getThreadMXBean(); + if (platformBean instanceof ThreadMXBean sunBean) { + detected = sunBean; + if (sunBean.isThreadAllocatedMemorySupported() && !sunBean.isThreadAllocatedMemoryEnabled()) { + sunBean.setThreadAllocatedMemoryEnabled(true); + } + } + } catch (Throwable ignored) { + } + this.threadBean = detected; + } public void sampleJvm() { try { @@ -87,12 +119,20 @@ public void sampleJvm() { long directBufferBytes = 0; long mappedBufferBytes = 0; + long directBufferCount = 0; + long mappedBufferCount = 0; + long directBufferCapacityBytes = 0; + long mappedBufferCapacityBytes = 0; for (BufferPoolMXBean pool : bufferPools) { String name = pool.getName().toLowerCase(); if (name.contains("direct")) { directBufferBytes += Math.max(0, pool.getMemoryUsed()); + directBufferCount += Math.max(0, pool.getCount()); + directBufferCapacityBytes += Math.max(0, pool.getTotalCapacity()); } else if (name.contains("mapped")) { mappedBufferBytes += Math.max(0, pool.getMemoryUsed()); + mappedBufferCount += Math.max(0, pool.getCount()); + mappedBufferCapacityBytes += Math.max(0, pool.getTotalCapacity()); } } @@ -127,6 +167,10 @@ public void sampleJvm() { gcType, directBufferBytes, mappedBufferBytes, + directBufferCount, + mappedBufferCount, + directBufferCapacityBytes, + mappedBufferCapacityBytes, SystemMetricsProfiler.getInstance().getSnapshot().directMemoryMaxBytes(), metaspaceBytes, codeCacheBytes, @@ -134,15 +178,33 @@ public void sampleJvm() { Math.max(0, heapCommitted - heapUsed) ); lastJvmSampleAtMillis.set(System.currentTimeMillis()); + sampleAllocationRates(); } catch (Throwable ignored) { } } - public void samplePerMod() { + public void requestPerModSample() { + if (getLastModSampleAgeMillis() < MIN_MOD_SAMPLE_INTERVAL_MS) { + return; + } + if (!modSampleInFlight.compareAndSet(false, true)) { + return; + } + modHistogramExecutor.execute(() -> { + try { + samplePerMod(); + } finally { + modSampleInFlight.set(false); + } + }); + } + + private void samplePerMod() { if (DIAGNOSTIC_COMMAND_NAME == null) { return; } + long startedAtMillis = System.currentTimeMillis(); try { MBeanServer server = ManagementFactory.getPlatformMBeanServer(); Object result = server.invoke( @@ -191,7 +253,8 @@ public void samplePerMod() { } } - addRuntimeBucket(bytesByMod, "runtime/native-direct", snapshot.directBufferBytes() + snapshot.mappedBufferBytes()); + addRuntimeBucket(bytesByMod, "runtime/native-direct-buffers", snapshot.directBufferBytes()); + addRuntimeBucket(bytesByMod, "runtime/native-mapped-buffers", snapshot.mappedBufferBytes()); addRuntimeBucket(bytesByMod, "runtime/metaspace", snapshot.metaspaceBytes()); addRuntimeBucket(bytesByMod, "runtime/code-cache", snapshot.codeCacheBytes()); addRuntimeBucket(bytesByMod, "runtime/class-space", snapshot.classSpaceBytes()); @@ -227,6 +290,7 @@ public void samplePerMod() { topClassesByMod = trimmedClassesByMod; lastModSampleAtMillis.set(System.currentTimeMillis()); + lastModSampleDurationMillis.set(Math.max(0L, System.currentTimeMillis() - startedAtMillis)); } catch (Throwable ignored) { } } @@ -251,6 +315,18 @@ public Map> getTopClassesByMod() { return topClassesByMod; } + public Map getModAllocationRateBytesPerSecond() { + return modAllocationRateBytesPerSecond; + } + + public Map getThreadAllocationRateBytesPerSecond() { + return threadAllocationRateBytesPerSecond; + } + + public long getLastModSampleDurationMillis() { + return lastModSampleDurationMillis.get(); + } + public long getLastModSampleAgeMillis() { long last = lastModSampleAtMillis.get(); if (last == 0) return Long.MAX_VALUE; @@ -263,10 +339,112 @@ public void reset() { sharedClassFamilies = Map.of(); sharedFamilyClasses = Map.of(); topClassesByMod = Map.of(); + modAllocationRateBytesPerSecond = Map.of(); + threadAllocationRateBytesPerSecond = Map.of(); lastGcCountsByName.clear(); lastGcTimesByName.clear(); + lastAllocatedBytesByThread.clear(); lastJvmSampleAtMillis.set(0); lastModSampleAtMillis.set(0); + lastAllocationSampleAtMillis.set(0); + lastModSampleDurationMillis.set(0); + } + + private void sampleAllocationRates() { + ThreadMXBean bean = threadBean; + if (bean == null || !bean.isThreadAllocatedMemorySupported() || !bean.isThreadAllocatedMemoryEnabled()) { + return; + } + + long now = System.currentTimeMillis(); + long previous = lastAllocationSampleAtMillis.getAndSet(now); + ThreadSnapshotCollector collector = ThreadSnapshotCollector.getInstance(); + ThreadSnapshotCollector.Snapshot latestSnapshot = collector.getLatestSnapshot(); + if (previous <= 0L) { + primeAllocatedBytes(bean, latestSnapshot.threadsById().keySet()); + return; + } + long elapsedMillis = Math.max(1L, now - previous); + Map bytesByMod = new LinkedHashMap<>(); + Map bytesByThread = new LinkedHashMap<>(); + Map nextAllocatedBytes = new ConcurrentHashMap<>(); + for (long threadId : latestSnapshot.threadsById().keySet()) { + long allocatedBytes = bean.getThreadAllocatedBytes(threadId); + if (allocatedBytes < 0L) { + continue; + } + nextAllocatedBytes.put(threadId, allocatedBytes); + long previousBytes = lastAllocatedBytesByThread.getOrDefault(threadId, allocatedBytes); + long delta = Math.max(0L, allocatedBytes - previousBytes); + if (delta <= 0L) { + continue; + } + long bytesPerSecond = Math.round(delta * 1000.0 / elapsedMillis); + bytesByThread.put(threadId, bytesPerSecond); + distributeAllocationDelta(bytesByMod, collector.getRecentThreadSnapshots(threadId, previous, 4), bytesPerSecond); + } + lastAllocatedBytesByThread.clear(); + lastAllocatedBytesByThread.putAll(nextAllocatedBytes); + modAllocationRateBytesPerSecond = bytesByMod.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .collect(LinkedHashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), LinkedHashMap::putAll); + threadAllocationRateBytesPerSecond = bytesByThread.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .collect(LinkedHashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), LinkedHashMap::putAll); + } + + private void distributeAllocationDelta(Map bytesByMod, List threadSnapshots, long bytesPerSecond) { + if (threadSnapshots == null || threadSnapshots.isEmpty() || bytesPerSecond <= 0L) { + return; + } + long[] shares = CollectorMath.splitBudget(bytesPerSecond, threadSnapshots.size()); + for (int i = 0; i < threadSnapshots.size(); i++) { + ThreadSnapshotCollector.ThreadStackSnapshot threadSnapshot = threadSnapshots.get(i); + String mod = resolveAllocatingMod(threadSnapshot.threadName(), threadSnapshot.stack()); + bytesByMod.merge(mod, shares[i], Long::sum); + } + } + + private void primeAllocatedBytes(ThreadMXBean bean, Iterable threadIds) { + lastAllocatedBytesByThread.clear(); + for (Long threadId : threadIds) { + if (threadId == null) { + continue; + } + long allocatedBytes = bean.getThreadAllocatedBytes(threadId); + if (allocatedBytes >= 0L) { + lastAllocatedBytesByThread.put(threadId, allocatedBytes); + } + } + } + + private String resolveAllocatingMod(String threadName, StackTraceElement[] stack) { + if (stack != null) { + for (StackTraceElement element : stack) { + String mod = ModClassIndex.getModForClassName(element.getClassName()); + if (mod != null) { + if ("fabricloader".equals(mod) || mod.startsWith("fabric-") || mod.startsWith("fabric_api")) { + return "shared/framework"; + } + return mod; + } + String className = element.getClassName(); + if (className.startsWith("net.minecraft.") || className.startsWith("com.mojang.")) { + return "minecraft"; + } + if (className.startsWith("java.") || className.startsWith("javax.") || className.startsWith("jdk.") || className.startsWith("sun.") || className.startsWith("org.lwjgl.")) { + continue; + } + } + } + String normalizedThreadName = threadName == null ? "" : threadName.toLowerCase(); + if (normalizedThreadName.contains("render")) { + return "shared/render"; + } + if (normalizedThreadName.contains("server")) { + return "minecraft"; + } + return "shared/jvm"; } private boolean isOldGenCollector(String name) { @@ -384,6 +562,10 @@ public record Snapshot( String gcType, long directBufferBytes, long mappedBufferBytes, + long directBufferCount, + long mappedBufferCount, + long directBufferCapacityBytes, + long mappedBufferCapacityBytes, long directMemoryMaxBytes, long metaspaceBytes, long codeCacheBytes, @@ -391,7 +573,7 @@ public record Snapshot( long gcHeadroomBytes ) { static Snapshot empty() { - return new Snapshot(0, 0, 0, 0, 0, 0, 0, 0, 0, "none", 0, 0, -1, 0, 0, 0, 0); + return new Snapshot(0, 0, 0, 0, 0, 0, 0, 0, 0, "none", 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0); } } } diff --git a/src/client/java/wueffi/taskmanager/client/ModalDialogRenderer.java b/src/client/java/wueffi/taskmanager/client/ModalDialogRenderer.java new file mode 100644 index 0000000..884ea32 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/ModalDialogRenderer.java @@ -0,0 +1,17 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +final class ModalDialogRenderer { + + private ModalDialogRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int width, int height, int mouseX, int mouseY) { + if (screen.attributionHelpOpen) { + screen.renderAttributionHelpOverlay(ctx, width, height, mouseX, mouseY); + } else if (screen.activeDrilldownTable != null) { + screen.renderRowDrilldownOverlay(ctx, width, height, mouseX, mouseY); + } + } +} diff --git a/src/client/java/wueffi/taskmanager/client/NativeWindowsSensors.java b/src/client/java/wueffi/taskmanager/client/NativeWindowsSensors.java index 3f8ae8a..faee685 100644 --- a/src/client/java/wueffi/taskmanager/client/NativeWindowsSensors.java +++ b/src/client/java/wueffi/taskmanager/client/NativeWindowsSensors.java @@ -29,13 +29,17 @@ record Sample( String counterSource, String sensorSource, String sensorErrorCode, + String cpuTemperatureProvider, + String gpuTemperatureProvider, + String gpuHotSpotTemperatureProvider, double cpuCoreLoadPercent, double gpuCoreLoadPercent, double gpuTemperatureC, + double gpuHotSpotTemperatureC, double cpuTemperatureC ) { static Sample empty() { - return new Sample(false, "Unavailable", "Unavailable", "No bridge data", -1.0, -1.0, -1.0, -1.0); + return new Sample(false, "Unavailable", "Unavailable", "No bridge data", "Unavailable", "Unavailable", "Unavailable", -1.0, -1.0, -1.0, -1.0, -1.0); } } @@ -61,9 +65,13 @@ Sample sample(String activeRenderer, String activeVendor) { counterSource, sensors.buildSensorSource(), sensors.buildErrorSummary(), + sensors.cpuTemperatureProvider, + sensors.gpuTemperatureProvider, + sensors.gpuHotSpotTemperatureProvider, cpuLoad, sensors.gpuLoad, sensors.gpuTemperature, + sensors.gpuHotSpotTemperature, sensors.cpuTemperature ); } @@ -181,27 +189,13 @@ private void readCoreTempSharedMemory(SensorAccumulator sensors) { } private byte[] readMapping(String mappingName, int size) { - WinNT.HANDLE mapping = null; - Pointer view = null; - try { - mapping = Kernel32.INSTANCE.OpenFileMapping(FILE_MAP_READ, false, mappingName); - if (mapping == null) { + try (MappedView mappedView = MappedView.open(mappingName, size)) { + if (mappedView == null || mappedView.view() == null) { return null; } - view = Kernel32.INSTANCE.MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, size); - if (view == null) { - return null; - } - return view.getByteArray(0, size); + return mappedView.view().getByteArray(0, size); } catch (Throwable ignored) { return null; - } finally { - if (view != null) { - try { Kernel32.INSTANCE.UnmapViewOfFile(view); } catch (Throwable ignored) {} - } - if (mapping != null) { - try { Kernel32.INSTANCE.CloseHandle(mapping); } catch (Throwable ignored) {} - } } } @@ -225,13 +219,16 @@ private static final class SensorAccumulator { private final String normalizedTarget; private final List attempts = new ArrayList<>(); private final List errors = new ArrayList<>(); - private String cpuSensorSource = "Unavailable"; - private String gpuSensorSource = "Unavailable"; - private String cpuSensorMatch = "none"; - private String gpuSensorMatch = "none"; + private String cpuTemperatureProvider = "Unavailable"; + private String gpuTemperatureProvider = "Unavailable"; + private String gpuHotSpotTemperatureProvider = "Unavailable"; + private String cpuTemperatureMatch = "none"; + private String gpuTemperatureMatch = "none"; + private String gpuHotSpotTemperatureMatch = "none"; private boolean preferredGpuMatchFound; private double cpuTemperature = -1.0; private double gpuTemperature = -1.0; + private double gpuHotSpotTemperature = -1.0; private double gpuLoad = -1.0; private SensorAccumulator(String activeRenderer, String activeVendor) { @@ -258,24 +255,37 @@ private void acceptSensor(String origin, String hardwareName, String sensorName, boolean cpuMatch = search.matches(".*(cpu package|package id|tctl|tdie|ccd|die|cpu core|core max|core average|processor|socket|ryzen|intel).*") || ((hardwareName + " " + sensorName).toLowerCase(Locale.ROOT).matches(".*(cpu|processor).*") && sensorName.toLowerCase(Locale.ROOT).matches(".*(temperature|tdie|tctl|package|cpu).*")) || sensorName.equalsIgnoreCase("cpu"); - boolean gpuMatch = search.matches(".*(gpu temperature|gpu core|hot spot|hotspot|junction|edge|graphics|mem junction|radeon|nvidia|intel graphics).*") + boolean gpuMatch = search.matches(".*(gpu temperature|gpu core|hot spot|hotspot|junction|junction temperature|edge|graphics|mem junction|memory junction|radeon|nvidia|intel graphics|hot point).*") || ((hardwareName + " " + sensorName).toLowerCase(Locale.ROOT).matches(".*(gpu|graphics|radeon|nvidia|intel).*") && sensorName.toLowerCase(Locale.ROOT).matches(".*(temperature|edge|junction|hot).*")); if (cpuMatch && (cpuTemperature < 0.0 || value > cpuTemperature)) { acceptCpuTemperature(origin, hardwareName + " / " + sensorName, value); } if (gpuMatch) { + boolean hotSpotMatch = search.matches(".*(hot spot|hotspot|junction|junction temperature|mem junction|memory junction|hot point).*"); boolean preferred = isPreferredGpu(hardwareName, sensorName, sensorIdentifier); if (preferred) { - if (!preferredGpuMatchFound || gpuTemperature < 0.0 || value > gpuTemperature) { + if (hotSpotMatch) { + if (gpuHotSpotTemperature < 0.0 || value > gpuHotSpotTemperature) { + gpuHotSpotTemperature = roundOneDecimal(value); + gpuHotSpotTemperatureProvider = origin; + gpuHotSpotTemperatureMatch = hardwareName + " / " + sensorName; + } + } else if (!preferredGpuMatchFound || gpuTemperature < 0.0 || value > gpuTemperature) { gpuTemperature = roundOneDecimal(value); - gpuSensorSource = origin; - gpuSensorMatch = hardwareName + " / " + sensorName; - preferredGpuMatchFound = true; + gpuTemperatureProvider = origin; + gpuTemperatureMatch = hardwareName + " / " + sensorName; + } + preferredGpuMatchFound = true; + } else if (hotSpotMatch) { + if (gpuHotSpotTemperature < 0.0 || value > gpuHotSpotTemperature) { + gpuHotSpotTemperature = roundOneDecimal(value); + gpuHotSpotTemperatureProvider = origin; + gpuHotSpotTemperatureMatch = hardwareName + " / " + sensorName; } } else if (!preferredGpuMatchFound && (gpuTemperature < 0.0 || value > gpuTemperature)) { gpuTemperature = roundOneDecimal(value); - gpuSensorSource = origin; - gpuSensorMatch = hardwareName + " / " + sensorName; + gpuTemperatureProvider = origin; + gpuTemperatureMatch = hardwareName + " / " + sensorName; } } } @@ -287,16 +297,16 @@ private void acceptSensor(String origin, String hardwareName, String sensorName, if (!preferredGpuMatchFound || gpuLoad < 0.0 || value > gpuLoad) { gpuLoad = Math.max(0.0, Math.min(100.0, value)); preferredGpuMatchFound = true; - if ("Unavailable".equals(gpuSensorSource)) { - gpuSensorSource = origin; - gpuSensorMatch = hardwareName + " / " + sensorName; + if ("Unavailable".equals(gpuTemperatureProvider)) { + gpuTemperatureProvider = origin; + gpuTemperatureMatch = hardwareName + " / " + sensorName; } } } else if (!preferredGpuMatchFound && value > gpuLoad) { gpuLoad = Math.max(0.0, Math.min(100.0, value)); - if ("Unavailable".equals(gpuSensorSource)) { - gpuSensorSource = origin; - gpuSensorMatch = hardwareName + " / " + sensorName; + if ("Unavailable".equals(gpuTemperatureProvider)) { + gpuTemperatureProvider = origin; + gpuTemperatureMatch = hardwareName + " / " + sensorName; } } } @@ -305,8 +315,8 @@ private void acceptSensor(String origin, String hardwareName, String sensorName, private void acceptCpuTemperature(String origin, String match, double value) { cpuTemperature = roundOneDecimal(value); - cpuSensorSource = origin; - cpuSensorMatch = match; + cpuTemperatureProvider = origin; + cpuTemperatureMatch = match; } private void acceptFallbackGpuLoad(String origin, String match, double value) { @@ -314,15 +324,18 @@ private void acceptFallbackGpuLoad(String origin, String match, double value) { return; } gpuLoad = Math.max(0.0, Math.min(100.0, value)); - if ("Unavailable".equals(gpuSensorSource)) { - gpuSensorSource = origin; - gpuSensorMatch = match; + if ("Unavailable".equals(gpuTemperatureProvider)) { + gpuTemperatureProvider = origin; + gpuTemperatureMatch = match; } } private String buildSensorSource() { String attemptText = attempts.isEmpty() ? "" : " | Tried: " + String.join(", ", attempts); - return "CPU: " + cpuSensorSource + " [" + cpuSensorMatch + "] | GPU: " + gpuSensorSource + " [" + gpuSensorMatch + "]" + attemptText; + return "CPU: " + cpuTemperatureProvider + " [" + cpuTemperatureMatch + "]" + + " | GPU: " + gpuTemperatureProvider + " [" + gpuTemperatureMatch + "]" + + " | GPU Hot Spot: " + gpuHotSpotTemperatureProvider + " [" + gpuHotSpotTemperatureMatch + "]" + + attemptText; } private String buildErrorSummary() { @@ -358,6 +371,55 @@ private static String normalizeName(String value) { } } + private static final class MappedView implements AutoCloseable { + private final WinNT.HANDLE mapping; + private final Pointer view; + + private MappedView(WinNT.HANDLE mapping, Pointer view) { + this.mapping = mapping; + this.view = view; + } + + static MappedView open(String mappingName, int size) { + WinNT.HANDLE mapping = Kernel32.INSTANCE.OpenFileMapping(FILE_MAP_READ, false, mappingName); + if (mapping == null) { + return null; + } + Pointer view = null; + boolean success = false; + try { + view = Kernel32.INSTANCE.MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, size); + if (view == null) { + return null; + } + MappedView mappedView = new MappedView(mapping, view); + success = true; + return mappedView; + } finally { + if (!success) { + if (view != null) { + try { Kernel32.INSTANCE.UnmapViewOfFile(view); } catch (Throwable ignored) {} + } + try { Kernel32.INSTANCE.CloseHandle(mapping); } catch (Throwable ignored) {} + } + } + } + + Pointer view() { + return view; + } + + @Override + public void close() { + if (view != null) { + try { Kernel32.INSTANCE.UnmapViewOfFile(view); } catch (Throwable ignored) {} + } + if (mapping != null) { + try { Kernel32.INSTANCE.CloseHandle(mapping); } catch (Throwable ignored) {} + } + } + } + private static final class GpuEngineSampler { private static final long INSTANCE_REFRESH_MS = 5_000L; private WinNT.HANDLE queryHandle; diff --git a/src/client/java/wueffi/taskmanager/client/ProfilerManager.java b/src/client/java/wueffi/taskmanager/client/ProfilerManager.java index b0a81b6..b031fef 100644 --- a/src/client/java/wueffi/taskmanager/client/ProfilerManager.java +++ b/src/client/java/wueffi/taskmanager/client/ProfilerManager.java @@ -13,6 +13,9 @@ import net.minecraft.entity.LivingEntity; import net.minecraft.util.math.ChunkPos; import net.minecraft.registry.entry.RegistryEntry; +import wueffi.taskmanager.client.AttributionModelBuilder.EffectiveCpuAttribution; +import wueffi.taskmanager.client.AttributionModelBuilder.EffectiveGpuAttribution; +import wueffi.taskmanager.client.AttributionModelBuilder.EffectiveMemoryAttribution; import wueffi.taskmanager.client.util.ConfigManager; import wueffi.taskmanager.client.util.ModTimingSnapshot; @@ -27,7 +30,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.function.ToLongFunction; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -73,6 +77,41 @@ public record EntityHotspot(String className, int count, String heuristic) {} public record BlockEntityHotspot(String className, int count, String heuristic) {} public record RuleFinding(String severity, String category, String message, String confidence, String details, String nextStep, String metricSummary) {} + public record ConflictObservation( + String waiterMod, + String ownerMod, + String lockName, + String waiterThreadName, + String ownerThreadName, + String waiterRole, + String ownerRole, + long blockedTimeDeltaMs, + long waitedTimeDeltaMs, + boolean slowdownOverlap, + String confidence, + List waiterCandidates, + List ownerCandidates + ) {} + public record ConflictEdge( + String waiterMod, + String ownerMod, + String lockName, + String waiterThreadName, + String ownerThreadName, + String waiterRole, + String ownerRole, + long observations, + long slowdownObservations, + long blockedTimeMs, + long waitedTimeMs, + String confidence, + List waiterCandidates, + List ownerCandidates + ) {} + public record PerformanceAlert(String key, String label, String message, String severity, long triggeredAtEpochMillis, double value, double threshold, int consecutiveBreaches) {} + private record WorldScanResult(EntityCounts entityCounts, List hotChunks, List entityHotspots, List blockEntityHotspots) {} + + record ExportResult(String status, Path directory, Path htmlReport, Path chromeTrace) {} public record ExportMetadata( String taskManagerVersion, @@ -114,6 +153,40 @@ public record SpikeCapture( List findings ) {} + public record SessionBaseline( + double avgFps, + double onePercentLowFps, + double avgMspt, + double msptP95, + long avgHeapUsedBytes, + Map cpuEffectivePercentByMod, + Map gpuEffectivePercentByMod, + Map memoryEffectiveMbByMod, + long capturedAtEpochMillis, + String label + ) {} + + public record SessionDelta( + double fpsChange, + double onePercentLowFpsChange, + double msptChange, + double msptP95Change, + double heapChangeMb, + Map cpuDeltaByMod, + Map gpuDeltaByMod, + Map memoryDeltaMbByMod + ) {} + + public record SpikeDelta( + double frameDurationDeltaMs, + double stutterScoreDelta, + List newTopCpuMods, + List removedTopCpuMods, + String bottleneckChange + ) {} + + public record SaveEvent(long startedAtEpochMillis, long durationMs, String type) {} + public record SessionPoint( long capturedAtEpochMillis, long sampleIntervalMs, @@ -188,15 +261,20 @@ public record SessionPoint( Map gpuRawPercentByMod, Map gpuEffectivePercentByMod, Map memoryRawMbByMod, - Map memoryEffectiveMbByMod + Map memoryEffectiveMbByMod, + Map cpuConfidenceByMod, + Map gpuConfidenceByMod, + Map memoryConfidenceByMod, + Map cpuProvenanceByMod, + Map gpuProvenanceByMod, + Map memoryProvenanceByMod, + String collectorGovernorMode, + double profilerSelfCostMs, + boolean isSaveEvent, + long saveDurationMs, + String saveType ) {} - private record EffectiveCpuAttribution(Map displaySnapshots, Map redistributedSamplesByMod, Map redistributedRenderSamplesByMod, long totalSamples, long totalRenderSamples) {} - - private record EffectiveGpuAttribution(Map gpuNanosByMod, Map renderSamplesByMod, Map redistributedGpuNanosByMod, Map redistributedRenderSamplesByMod, long totalGpuNanos, long totalRenderSamples) {} - - private record EffectiveMemoryAttribution(Map displayBytes, Map redistributedBytesByMod, long totalBytes) {} - public record ProfilerSnapshot( long capturedAtEpochMillis, CaptureMode mode, @@ -271,6 +349,10 @@ static ProfilerSnapshot empty() { private static final int WINDOW_SIZE = 20; private static final long SPIKE_THRESHOLD_NS = 50_000_000L; private static final int MAX_SPIKES = 8; + private static final int MAX_WORLD_SCAN_ENTITIES = 2_000; + private static final int MAX_WORLD_SCAN_BLOCK_ENTITIES = 1_000; + private static final int WORLD_SCAN_ENTITY_SAMPLE_STRIDE = 4; + private static final int WORLD_SCAN_BLOCK_ENTITY_SAMPLE_STRIDE = 2; private static final Gson EXPORT_GSON = new GsonBuilder().serializeSpecialFloatingPointValues().setPrettyPrinting().create(); private static final Pattern CHUNK_DEBUG_PATTERN = Pattern.compile("C:\\s*(\\d+)/(\\d+)"); @@ -278,6 +360,7 @@ static ProfilerSnapshot empty() { private final Deque> cpuDetailWindows = new ArrayDeque<>(); private final Deque> modWindows = new ArrayDeque<>(); private final Deque> renderWindows = new ArrayDeque<>(); + private final Deque> conflictWindows = new ArrayDeque<>(); private final Deque spikes = new ArrayDeque<>(); private final Deque sessionHistory = new ArrayDeque<>(); private final Deque> hotChunkHistory = new ArrayDeque<>(); @@ -287,6 +370,7 @@ static ProfilerSnapshot empty() { private volatile List latestHotChunks = List.of(); private volatile List latestEntityHotspots = List.of(); private volatile List latestBlockEntityHotspots = List.of(); + private volatile List latestConflictEdges = List.of(); private volatile List latestLockSummaries = List.of(); private volatile List latestRuleFindings = List.of(); private final Deque> stutterJumpSnapshots = new ArrayDeque<>(); @@ -297,6 +381,8 @@ static ProfilerSnapshot empty() { private volatile String lastExportStatus = ""; private volatile Path lastExportDirectory; private volatile Path lastExportHtmlReport; + private volatile SessionBaseline sessionBaseline; + private volatile SpikeCapture pinnedSpike; private volatile EntityCounts latestEntityCounts = EntityCounts.empty(); private volatile ChunkCounts latestChunkCounts = ChunkCounts.empty(); private volatile boolean sessionLogging; @@ -306,11 +392,30 @@ static ProfilerSnapshot empty() { private volatile int sessionMissedSamples; private volatile long sessionMaxSampleGapMillis; private volatile long sessionExpectedSampleIntervalMillis = 50L; + private volatile PerformanceAlert latestPerformanceAlert; + private volatile long performanceAlertFlashUntilMillis; private long lastSeenFrameSequence = 0; private long lastSnapshotPublishedAtMillis = 0L; + private long lastWorldScanAtMillis = 0L; + private long lastWorldScanDurationMillis = 0L; + private long lastChunkCountsAtMillis = 0L; + private int frameAlertConsecutiveBreaches = 0; + private int serverAlertConsecutiveBreaches = 0; + private volatile long activeSaveStartedAtMillis; + private volatile long activeSaveStartedAtEpochMillis; + private volatile String activeSaveType = ""; + private volatile long lastCompletedSaveStartedAtMillis; + private volatile long lastCompletedSaveDurationMillis; + private volatile String lastCompletedSaveType = ""; + private final Map lastPerformanceAlertAtMillis = new ConcurrentHashMap<>(); + private final Deque recentSaves = new ArrayDeque<>(); + private final RuleEngine ruleEngine = new RuleEngine(); + private final SessionExporter sessionExporter = new SessionExporter(); public void initialize() { mode = ConfigManager.getCaptureMode(); + sessionBaseline = sessionExporter.loadBaseline(); + ThreadSnapshotCollector.getInstance().start(); CpuSamplingProfiler.getInstance().start(); publishSnapshot(true); } @@ -318,21 +423,19 @@ public void initialize() { public void onScreenOpened() { screenOpen = true; if (mode == CaptureMode.OPEN_ONLY || mode == CaptureMode.MANUAL_DEEP) { - clearRollingWindows(); + clearLiveWindows(); RenderPhaseProfiler.getInstance().reset(); ModTimingProfiler.getInstance().reset(); CpuSamplingProfiler.getInstance().reset(); FlamegraphProfiler.getInstance().reset(); TickProfiler.getInstance().reset(); } - MemoryProfiler.getInstance().sampleJvm(); - publishSnapshot(true); } public void onScreenClosed() { screenOpen = false; if (mode == CaptureMode.OPEN_ONLY) { - clearRollingWindows(); + clearLiveWindows(); publishSnapshot(true); } } @@ -344,11 +447,18 @@ public void cycleMode() { public void setMode(CaptureMode mode) { this.mode = mode; ConfigManager.setCaptureMode(mode); - clearRollingWindows(); + clearLiveWindows(); publishSnapshot(true); } public boolean isCaptureActive() { + return computeCaptureActive(mode, screenOpen, sessionLogging); + } + + static boolean computeCaptureActive(CaptureMode mode, boolean screenOpen, boolean sessionLogging) { + if (sessionLogging) { + return true; + } return switch (mode) { case OFF -> false; case OPEN_ONLY -> screenOpen; @@ -382,16 +492,209 @@ public boolean shouldCollectFrameMetrics() { } public boolean shouldCollectDetailedMetrics() { + if (isProfilerSelfProtectionActive() && !screenOpen && !sessionLogging) { + return false; + } return switch (getLiveCollectionMode()) { case SCREEN, SESSION, CAPTURE -> true; default -> false; }; } + public String getCollectorGovernorMode() { + if (isProfilerSelfProtectionActive()) { + return "self-protect"; + } + double latestFrameMs = FrameTimelineProfiler.getInstance().getLatestFrameNs() / 1_000_000.0; + double targetFrameMs = ConfigManager.getFrameBudgetTargetFrameMs(); + if (sessionLogging || screenOpen || isPerformanceAlertFlashActive() || latestFrameMs >= targetFrameMs * 1.75) { + return "burst"; + } + if (latestFrameMs >= targetFrameMs * 1.2) { + return "tight"; + } + if (!shouldCollectFrameMetrics()) { + return "light"; + } + return "normal"; + } + + public boolean isProfilerSelfProtectionActive() { + if (screenOpen || sessionLogging) { + return false; + } + SystemMetricsProfiler.Snapshot system = currentSnapshot.systemMetrics(); + return system != null && (system.profilerCpuLoadPercent() >= 4.0 || system.worldScanCostMillis() >= 12L || system.memoryHistogramCostMillis() >= 20L); + } + + public long getLastWorldScanDurationMillis() { + return lastWorldScanDurationMillis; + } + public ProfilerSnapshot getCurrentSnapshot() { return currentSnapshot; } + public SessionBaseline getSessionBaseline() { + return sessionBaseline; + } + + public void setBaseline(SessionBaseline baseline) { + sessionBaseline = baseline; + sessionExporter.saveBaseline(baseline); + requestSnapshotPublish(); + } + + public void clearBaseline() { + sessionBaseline = null; + sessionExporter.clearBaseline(); + requestSnapshotPublish(); + } + + public SessionBaseline captureBaseline(String label) { + List points = getSessionHistory(); + if (points.isEmpty()) { + return new SessionBaseline(0.0, 0.0, 0.0, 0.0, 0L, Map.of(), Map.of(), Map.of(), System.currentTimeMillis(), label == null || label.isBlank() ? "current" : label); + } + double avgFps = averageDouble(points, SessionPoint::averageFps); + double onePercentLow = averageDouble(points, SessionPoint::onePercentLowFps); + double avgMspt = averageDouble(points, SessionPoint::msptAvg); + double msptP95 = averageDouble(points, SessionPoint::msptP95); + long avgHeapUsedBytes = Math.round(points.stream().mapToLong(SessionPoint::heapUsedBytes).average().orElse(0.0)); + return new SessionBaseline( + avgFps, + onePercentLow, + avgMspt, + msptP95, + avgHeapUsedBytes, + averageMap(points, SessionPoint::cpuEffectivePercentByMod), + averageMap(points, SessionPoint::gpuEffectivePercentByMod), + averageMap(points, SessionPoint::memoryEffectiveMbByMod), + System.currentTimeMillis(), + label == null || label.isBlank() ? "baseline" : label + ); + } + + public SessionDelta compareToBaseline(SessionBaseline baseline) { + if (baseline == null) { + return null; + } + SessionBaseline current = captureBaseline("current"); + return new SessionDelta( + current.avgFps() - baseline.avgFps(), + current.onePercentLowFps() - baseline.onePercentLowFps(), + current.avgMspt() - baseline.avgMspt(), + current.msptP95() - baseline.msptP95(), + (current.avgHeapUsedBytes() - baseline.avgHeapUsedBytes()) / (1024.0 * 1024.0), + subtractMaps(current.cpuEffectivePercentByMod(), baseline.cpuEffectivePercentByMod()), + subtractMaps(current.gpuEffectivePercentByMod(), baseline.gpuEffectivePercentByMod()), + subtractMaps(current.memoryEffectiveMbByMod(), baseline.memoryEffectiveMbByMod()) + ); + } + + public boolean importBaselineFromLatestExport() { + SessionBaseline imported = sessionExporter.importLatestSessionBaseline(); + if (imported == null) { + return false; + } + setBaseline(imported); + return true; + } + + private double averageDouble(List points, java.util.function.ToDoubleFunction extractor) { + return points.stream().mapToDouble(extractor).average().orElse(0.0); + } + + private Map averageMap(List points, java.util.function.Function> extractor) { + Map totals = new LinkedHashMap<>(); + Map counts = new LinkedHashMap<>(); + for (SessionPoint point : points) { + Map values = extractor.apply(point); + if (values == null) { + continue; + } + values.forEach((key, value) -> { + totals.merge(key, value == null ? 0.0 : value, Double::sum); + counts.merge(key, 1, Integer::sum); + }); + } + Map averages = new LinkedHashMap<>(); + totals.forEach((key, total) -> averages.put(key, total / Math.max(1, counts.getOrDefault(key, points.size())))); + return averages; + } + + private Map subtractMaps(Map current, Map baseline) { + Map delta = new LinkedHashMap<>(); + current.forEach((key, value) -> delta.put(key, value - baseline.getOrDefault(key, 0.0))); + baseline.forEach((key, value) -> delta.putIfAbsent(key, -(value == null ? 0.0 : value))); + return delta; + } + + public void pinSpike(SpikeCapture spike) { + pinnedSpike = spike; + requestSnapshotPublish(); + } + + public void clearPinnedSpike() { + pinnedSpike = null; + requestSnapshotPublish(); + } + + public SpikeCapture getPinnedSpike() { + return pinnedSpike; + } + + public SpikeDelta compareSpikeToPinned(SpikeCapture spike) { + if (spike == null || pinnedSpike == null) { + return null; + } + List newTopCpuMods = spike.topCpuMods().stream() + .filter(mod -> !pinnedSpike.topCpuMods().contains(mod)) + .toList(); + List removedTopCpuMods = pinnedSpike.topCpuMods().stream() + .filter(mod -> !spike.topCpuMods().contains(mod)) + .toList(); + String bottleneckChange = Objects.equals(pinnedSpike.likelyBottleneck(), spike.likelyBottleneck()) + ? "Same bottleneck" + : pinnedSpike.likelyBottleneck() + " -> " + spike.likelyBottleneck(); + return new SpikeDelta( + spike.frameDurationMs() - pinnedSpike.frameDurationMs(), + spike.stutterScore() - pinnedSpike.stutterScore(), + newTopCpuMods, + removedTopCpuMods, + bottleneckChange + ); + } + + public List getRecentSaves() { + return List.copyOf(recentSaves); + } + + public void beginSaveEvent(String type) { + activeSaveStartedAtMillis = System.nanoTime(); + activeSaveStartedAtEpochMillis = System.currentTimeMillis(); + activeSaveType = type == null || type.isBlank() ? "save" : type; + } + + public void endSaveEvent() { + long startedAtNanos = activeSaveStartedAtMillis; + if (startedAtNanos <= 0L) { + return; + } + long durationMs = Math.max(0L, (System.nanoTime() - startedAtNanos) / 1_000_000L); + SaveEvent saveEvent = new SaveEvent(activeSaveStartedAtEpochMillis, durationMs, activeSaveType == null || activeSaveType.isBlank() ? "save" : activeSaveType); + activeSaveStartedAtMillis = 0L; + activeSaveStartedAtEpochMillis = 0L; + activeSaveType = ""; + lastCompletedSaveStartedAtMillis = saveEvent.startedAtEpochMillis(); + lastCompletedSaveDurationMillis = saveEvent.durationMs(); + lastCompletedSaveType = saveEvent.type(); + recentSaves.addFirst(saveEvent); + while (recentSaves.size() > 16) { + recentSaves.removeLast(); + } + } + public boolean isSessionLogging() { return sessionLogging; } @@ -420,11 +723,7 @@ public void toggleSessionLogging() { publishSnapshot(true); return; } - sessionHistory.clear(); - hotChunkHistory.clear(); - chunkActivityHistory.clear(); - latestHotChunks = List.of(); - latestRuleFindings = List.of(); + clearSessionState(); sessionLogging = true; sessionRecorded = false; sessionRecordedAtMillis = 0L; @@ -436,53 +735,122 @@ public void toggleSessionLogging() { } public void onClientTickEnd(MinecraftClient client) { - latestEntityCounts = sampleEntityCounts(client); - latestChunkCounts = sampleChunkCounts(client); - latestHotChunks = sampleHotChunks(client); - latestEntityHotspots = sampleEntityHotspots(client); - latestBlockEntityHotspots = sampleBlockEntityHotspots(client); + long selfCostStartedAt = System.nanoTime(); + try { + WorldScanResult worldScan = sampleWorldData(client); + latestEntityCounts = worldScan.entityCounts(); + latestChunkCounts = sampleChunkCounts(client); + latestHotChunks = worldScan.hotChunks(); + latestEntityHotspots = worldScan.entityHotspots(); + latestBlockEntityHotspots = worldScan.blockEntityHotspots(); + String collectorGovernorMode = getCollectorGovernorMode(); + + boolean selfProtecting = "self-protect".equals(collectorGovernorMode); + if ((!selfProtecting && shouldCollectFrameMetrics()) || sessionLogging || screenOpen || (client.world != null && client.world.getTime() % 200 == 0)) { + MemoryProfiler.getInstance().sampleJvm(); + } + MemoryProfiler.Snapshot memorySnapshot = MemoryProfiler.getInstance().getDetailedSnapshot(); + ThreadLoadProfiler.getInstance().sample(); + SystemMetricsProfiler.getInstance().sample(memorySnapshot, latestEntityCounts, latestChunkCounts); + NetworkPacketProfiler.getInstance().drainWindow(); + updateConflictTracking(SystemMetricsProfiler.getInstance().getSnapshot()); + latestLockSummaries = buildLockSummaries(SystemMetricsProfiler.getInstance().getSnapshot()); + + if (!isCaptureActive()) { + enforceSessionWindow(client); + publishSnapshot(false); + return; + } - if (shouldCollectFrameMetrics() || (client.world != null && client.world.getTime() % 200 == 0)) { - MemoryProfiler.getInstance().sampleJvm(); - } - SystemMetricsProfiler.getInstance().sample(MemoryProfiler.getInstance().getDetailedSnapshot(), latestEntityCounts, latestChunkCounts); - ThreadLoadProfiler.getInstance().sample(); - NetworkPacketProfiler.getInstance().drainWindow(); - latestLockSummaries = buildLockSummaries(SystemMetricsProfiler.getInstance().getSnapshot()); + CpuSamplingProfiler.WindowSnapshot cpuWindow = CpuSamplingProfiler.getInstance().drainWindow(); + Map modWindow = ModTimingProfiler.getInstance().drainSnapshot(); + Map renderWindow = RenderPhaseProfiler.getInstance().drainSnapshot(); - if (!isCaptureActive()) { + pushWindow(cpuWindows, cpuWindow.samples()); + pushWindow(cpuDetailWindows, cpuWindow.detailsByMod()); + pushWindow(modWindows, modWindow); + pushWindow(renderWindows, renderWindow); + + boolean shouldSamplePerModMemory = shouldCollectDetailedMetrics() || sessionLogging; + long memoryCadenceMillis = CollectorMath.computeAdaptiveMemoryCadenceMillis(collectorGovernorMode, screenOpen, sessionLogging); + boolean allowExpensiveMemoryCollection = (!"light".equals(collectorGovernorMode) && !"self-protect".equals(collectorGovernorMode)) || screenOpen || sessionLogging; + if (shouldSamplePerModMemory && allowExpensiveMemoryCollection && MemoryProfiler.getInstance().getLastModSampleAgeMillis() > memoryCadenceMillis) { + MemoryProfiler.getInstance().requestPerModSample(); + } + + latestRuleFindings = ruleEngine.buildRuleFindings(this, memorySnapshot); + evaluatePerformanceAlerts(client); + captureSpikeIfNeeded(); + captureStutterJumpSnapshot(client); + recordSessionPoint(); enforceSessionWindow(client); - publishSnapshot(false); - return; + publishSnapshot(false, cpuWindow.lastSampleAgeMillis()); + } finally { + FrameTimelineProfiler.getInstance().addSelfCost(System.nanoTime() - selfCostStartedAt); } + } - CpuSamplingProfiler.WindowSnapshot cpuWindow = CpuSamplingProfiler.getInstance().drainWindow(); - Map modWindow = ModTimingProfiler.getInstance().drainSnapshot(); - Map renderWindow = RenderPhaseProfiler.getInstance().drainSnapshot(); - - pushWindow(cpuWindows, cpuWindow.samples()); - pushWindow(cpuDetailWindows, cpuWindow.detailsByMod()); - pushWindow(modWindows, modWindow); - pushWindow(renderWindows, renderWindow); + public java.util.List getSessionHistory() { + return java.util.List.copyOf(sessionHistory); + } - boolean allowDeepMemory = screenOpen || mode == CaptureMode.MANUAL_DEEP; - if (allowDeepMemory && TaskManagerScreen.isMemoryTabActive(client) && MemoryProfiler.getInstance().getLastModSampleAgeMillis() > 2000) { - MemoryProfiler.getInstance().samplePerMod(); + public PerformanceAlert getLatestPerformanceAlert() { + PerformanceAlert alert = latestPerformanceAlert; + if (alert == null) { + return null; } + return System.currentTimeMillis() - alert.triggeredAtEpochMillis() > 6_000L ? null : alert; + } - latestRuleFindings = buildRuleFindings(); - captureSpikeIfNeeded(); - captureStutterJumpSnapshot(client); - recordSessionPoint(); - enforceSessionWindow(client); - publishSnapshot(false, cpuWindow.lastSampleAgeMillis()); + public boolean isPerformanceAlertFlashActive() { + return performanceAlertFlashUntilMillis > System.currentTimeMillis(); } - public java.util.List getSessionHistory() { - return java.util.List.copyOf(sessionHistory); + public java.util.List getJvmTuningAdvisor() { + return ruleEngine.buildJvmTuningAdvisor(currentSnapshot.memory(), SystemMetricsProfiler.getInstance().getSnapshot()); + } + + public java.util.List getEnabledResourcePackNames() { + return detectEnabledResourcePacks(MinecraftClient.getInstance()); + } + + public String getGraphicsPipelineSummary() { + String shaderPack = detectShaderPackName(); + String sodiumThreads = detectSodiumChunkThreads(); + boolean irisLoaded = FabricLoader.getInstance().isModLoaded("iris"); + boolean sodiumLoaded = FabricLoader.getInstance().isModLoaded("sodium"); + return "Iris " + (irisLoaded ? "on" : "off") + + " | Sodium " + (sodiumLoaded ? "on" : "off") + + " | shader " + shaderPack + + " | chunk threads " + sodiumThreads; } public String exportSession() { + return sessionExporter.exportSession(this); + } + + void beginExport() { + lastExportStatus = "Exporting session..."; + lastExportDirectory = null; + lastExportHtmlReport = null; + } + + String lastExportStatus() { + return lastExportStatus == null || lastExportStatus.isBlank() ? "Export already in progress..." : lastExportStatus; + } + + void finishExport(ExportResult result) { + lastExportDirectory = result.directory(); + lastExportHtmlReport = result.htmlReport(); + lastExportStatus = result.status(); + requestSnapshotPublish(); + } + + ExportResult runSessionExport() { + return sessionExporter.runSessionExport(this); + } + + Map buildSessionExportPayload() { Map export = new LinkedHashMap<>(); export.put("generatedAtEpochMillis", System.currentTimeMillis()); export.put("executiveSummary", buildExecutiveSummary()); @@ -511,10 +879,15 @@ public String exportSession() { export.put("exportSummary", buildExportSummary()); export.put("diagnosis", buildDiagnosis()); export.put("spikes", new ArrayList<>(spikes)); + export.put("pinnedSpike", pinnedSpike); + export.put("baseline", sessionBaseline); + export.put("baselineDelta", compareToBaseline(sessionBaseline)); + export.put("recentSaves", new ArrayList<>(recentSaves)); export.put("sessionPoints", new ArrayList<>(sessionHistory)); export.put("frameTimeTimelineMs", FrameTimelineProfiler.getInstance().getOrderedFrameMsHistory()); export.put("frameTimestampTimelineNs", FrameTimelineProfiler.getInstance().getOrderedFrameTimestampHistory()); export.put("fpsTimeline", FrameTimelineProfiler.getInstance().getOrderedFpsHistory()); + export.put("profilerSelfCostTimelineMs", FrameTimelineProfiler.getInstance().getOrderedSelfCostMsHistory()); export.put("chunkPipelineTimeline", buildChunkPipelineTimeline()); export.put("startupRows", currentSnapshot.startupRows()); export.put("startupSummary", buildStartupSummary()); @@ -522,35 +895,86 @@ public String exportSession() { export.put("attribution", buildAttributionExport()); export.put("renderPhaseOwners", buildRenderPhaseOwnerSummary()); export.put("sharedBucketBreakdowns", buildSharedBucketBreakdownExport()); + export.put("chunkWork", ChunkWorkProfiler.getInstance().getSnapshot()); + export.put("entityCosts", EntityCostProfiler.getInstance().getSnapshot()); + export.put("shaderCompiles", ShaderCompilationProfiler.getInstance().getSnapshot()); + export.put("textureUploads", TextureUploadProfiler.getInstance().getSnapshot()); + return export; + } - Path dir = FabricLoader.getInstance().getGameDir().resolve("taskmanager-sessions"); - try { - Files.createDirectories(dir); - long exportTimestamp = System.currentTimeMillis(); - Path file = dir.resolve("taskmanager-session-" + exportTimestamp + ".json"); - Files.writeString(file, EXPORT_GSON.toJson(export)); - Path htmlFile = dir.resolve("taskmanager-session-" + exportTimestamp + ".html"); - Files.writeString(htmlFile, buildHtmlReport(export)); - lastExportDirectory = dir; - lastExportHtmlReport = htmlFile; - lastExportStatus = "Exported " + file.getFileName() + " + " + htmlFile.getFileName(); - taskmanagerClient.LOGGER.info( - "TaskManager export {} entities total/living/block {}/{}/{} chunks loaded/rendered {}/{} stutterScore {}", - file.getFileName(), - latestEntityCounts.totalEntities(), - latestEntityCounts.livingEntities(), - latestEntityCounts.blockEntities(), - latestChunkCounts.loadedChunks(), - latestChunkCounts.renderedChunks(), - String.format("%.1f", FrameTimelineProfiler.getInstance().getStutterScore()) - ); - } catch (Exception e) { - lastExportDirectory = null; - lastExportHtmlReport = null; - lastExportStatus = "Export failed: " + e.getMessage(); + String exportJson(Map export) { + return EXPORT_GSON.toJson(export); + } + + String buildSessionHtmlReport(Map export) { + return buildHtmlReport(export); + } + + String buildChromeTraceJson() { + List> events = new ArrayList<>(); + for (SessionPoint point : sessionHistory) { + appendTraceSpan(events, "Frame", "render", point.capturedAtEpochMillis(), point.frameTimeMs()); + appendTraceSpan(events, "GPU Frame", "gpu", point.capturedAtEpochMillis(), point.gpuFrameTimeMs()); + appendTraceSpan(events, "Client Tick", "client", point.capturedAtEpochMillis(), point.clientTickMs()); + appendTraceSpan(events, "Server Tick", "server", point.capturedAtEpochMillis(), point.serverTickMs()); + if (point.gcPauseDurationMs() > 0L) { + appendTraceSpan(events, "GC Pause: " + point.gcType(), "jvm", point.capturedAtEpochMillis(), point.gcPauseDurationMs()); + } + } + for (SpikeCapture spike : spikes) { + appendTraceSpan(events, "Spike: " + spike.likelyBottleneck(), "markers", spike.capturedAtEpochMillis(), spike.frameDurationMs()); + } + for (ShaderCompilationProfiler.CompileEvent event : ShaderCompilationProfiler.getInstance().getSnapshot().recentEvents()) { + appendTraceSpan(events, "Shader Compile: " + event.label(), "render", event.completedAtEpochMillis(), event.durationNs() / 1_000_000.0); + } + for (SaveEvent saveEvent : recentSaves) { + appendTraceSpan(events, "Save: " + saveEvent.type(), "io", saveEvent.startedAtEpochMillis(), saveEvent.durationMs()); + } + return EXPORT_GSON.toJson(events); + } + + void logSessionExport(Path file) { + taskmanagerClient.LOGGER.info( + "TaskManager export {} entities total/living/block {}/{}/{} chunks loaded/rendered {}/{} stutterScore {}", + file.getFileName(), + latestEntityCounts.totalEntities(), + latestEntityCounts.livingEntities(), + latestEntityCounts.blockEntities(), + latestChunkCounts.loadedChunks(), + latestChunkCounts.renderedChunks(), + String.format("%.1f", FrameTimelineProfiler.getInstance().getStutterScore()) + ); + } + + void notifyExportFinished(MinecraftClient client, ExportResult result) { + if (client.player == null) { + return; + } + client.player.sendMessage(Text.literal("Task Manager: " + result.status()), false); + if (result.htmlReport() != null) { + Text openReport = Text.literal("[Open Session Report]") + .setStyle(Style.EMPTY + .withColor(Formatting.AQUA) + .withUnderline(true) + .withClickEvent(new ClickEvent.OpenFile(result.htmlReport().toAbsolutePath().toString()))); + client.player.sendMessage(openReport, false); + } + if (result.chromeTrace() != null) { + Text openTrace = Text.literal("[Open Chrome Trace]") + .setStyle(Style.EMPTY + .withColor(Formatting.GOLD) + .withUnderline(true) + .withClickEvent(new ClickEvent.OpenFile(result.chromeTrace().toAbsolutePath().toString()))); + client.player.sendMessage(openTrace, false); + } + if (result.directory() != null) { + Text openFolder = Text.literal("[Open Session Logs Folder]") + .setStyle(Style.EMPTY + .withColor(Formatting.GREEN) + .withUnderline(true) + .withClickEvent(new ClickEvent.OpenFile(result.directory().toAbsolutePath().toString()))); + client.player.sendMessage(openFolder, false); } - publishSnapshot(); - return lastExportStatus; } private void recordSessionPoint() { @@ -558,9 +982,9 @@ private void recordSessionPoint() { Map cpuDetails = aggregateCpuDetailWindows(); Map modInvokes = aggregateModWindows(); Map renderPhases = aggregateRenderWindows(); - EffectiveCpuAttribution effectiveCpu = buildEffectiveCpuAttribution(cpu, modInvokes); - EffectiveGpuAttribution rawGpuAttribution = buildEffectiveGpuAttribution(renderPhases, cpu, modInvokes, false); - EffectiveGpuAttribution effectiveGpuAttribution = buildEffectiveGpuAttribution(renderPhases, cpu, modInvokes, true); + EffectiveCpuAttribution effectiveCpu = AttributionModelBuilder.buildEffectiveCpuAttribution(cpu, cpuDetails, modInvokes); + EffectiveGpuAttribution rawGpuAttribution = AttributionModelBuilder.buildEffectiveGpuAttribution(renderPhases, cpu, effectiveCpu, false); + EffectiveGpuAttribution effectiveGpuAttribution = AttributionModelBuilder.buildEffectiveGpuAttribution(renderPhases, cpu, effectiveCpu, true); long totalRenderSamples = cpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::renderSamples).sum(); List topCpu = effectiveCpu.displaySnapshots().entrySet().stream() .sorted((a, b) -> Long.compare(b.getValue().totalSamples(), a.getValue().totalSamples())) @@ -575,7 +999,7 @@ private void recordSessionPoint() { MemoryProfiler.Snapshot memory = MemoryProfiler.getInstance().getDetailedSnapshot(); Map rawMemoryMods = MemoryProfiler.getInstance().getModMemoryBytes(); - EffectiveMemoryAttribution effectiveMemory = buildEffectiveMemoryAttribution(rawMemoryMods); + EffectiveMemoryAttribution effectiveMemory = AttributionModelBuilder.buildEffectiveMemoryAttribution(rawMemoryMods); double usedHeapMb = memory.heapUsedBytes() / (1024.0 * 1024.0); double allocatedHeapMb = memory.heapCommittedBytes() / (1024.0 * 1024.0); SessionPoint previous = sessionHistory.peekLast(); @@ -617,6 +1041,12 @@ private void recordSessionPoint() { Map gpuEffectivePercentByMod = buildGpuPercentByMod(effectiveGpuAttribution); Map memoryRawMbByMod = buildMemoryMbByMod(rawMemoryMods); Map memoryEffectiveMbByMod = buildMemoryMbByMod(effectiveMemory.displayBytes()); + Map cpuConfidenceByMod = buildCpuConfidenceByMod(cpu, effectiveCpu, cpuDetails); + Map gpuConfidenceByMod = buildGpuConfidenceByMod(rawGpuAttribution, effectiveGpuAttribution); + Map memoryConfidenceByMod = buildMemoryConfidenceByMod(rawMemoryMods, effectiveMemory, MemoryProfiler.getInstance().getLastModSampleAgeMillis()); + Map cpuProvenanceByMod = buildCpuProvenanceByMod(cpu, effectiveCpu, cpuDetails); + Map gpuProvenanceByMod = buildGpuProvenanceByMod(rawGpuAttribution, effectiveGpuAttribution); + Map memoryProvenanceByMod = buildMemoryProvenanceByMod(rawMemoryMods, effectiveMemory, MemoryProfiler.getInstance().getLastModSampleAgeMillis()); long capturedAtMillis = System.currentTimeMillis(); SessionPoint previousPoint = sessionHistory.peekLast(); @@ -624,6 +1054,9 @@ private void recordSessionPoint() { int missedSamplesSincePrevious = computeMissedSamples(previousPoint == null ? 0L : previousPoint.capturedAtEpochMillis(), capturedAtMillis, (int) sessionExpectedSampleIntervalMillis); sessionMissedSamples += missedSamplesSincePrevious; sessionMaxSampleGapMillis = Math.max(sessionMaxSampleGapMillis, sampleIntervalMs); + boolean saveEventComplete = lastCompletedSaveDurationMillis > 0L + && lastCompletedSaveStartedAtMillis > 0L + && (previousPoint == null || lastCompletedSaveStartedAtMillis > previousPoint.capturedAtEpochMillis()); sessionHistory.addLast(new SessionPoint( capturedAtMillis, @@ -699,7 +1132,18 @@ private void recordSessionPoint() { gpuRawPercentByMod, gpuEffectivePercentByMod, memoryRawMbByMod, - memoryEffectiveMbByMod + memoryEffectiveMbByMod, + cpuConfidenceByMod, + gpuConfidenceByMod, + memoryConfidenceByMod, + cpuProvenanceByMod, + gpuProvenanceByMod, + memoryProvenanceByMod, + getCollectorGovernorMode(), + frameTimeline.getSelfCostAvgMs(), + saveEventComplete, + saveEventComplete ? lastCompletedSaveDurationMillis : 0L, + saveEventComplete ? lastCompletedSaveType : "" )); } @@ -779,6 +1223,7 @@ private Map buildExportSummary() { summary.put("topHotChunk", latestHotChunks.isEmpty() ? null : latestHotChunks.getFirst()); summary.put("topEntityHotspots", latestEntityHotspots); summary.put("topBlockEntityHotspots", latestBlockEntityHotspots); + summary.put("conflictEdges", latestConflictEdges); summary.put("lockSummaries", latestLockSummaries); summary.put("networkSpikeBookmarks", NetworkPacketProfiler.getInstance().getSpikeHistory()); summary.put("ruleFindingsBySeverity", buildRuleFindingSeverityBreakdown()); @@ -791,6 +1236,7 @@ private Map buildExportSummary() { summary.put("lightUpdateQueue", system.lightUpdateQueue()); summary.put("maxEntitiesInHotChunk", system.maxEntitiesInHotChunk()); summary.put("sensorErrors", system.sensorErrorCode()); + summary.put("jvmTuningAdvisor", buildJvmTuningAdvisor(currentSnapshot.memory(), system)); summary.put("redFlagThresholds", buildRedFlagThresholds()); double frameAvg = FrameTimelineProfiler.getInstance().getAverageFrameNs() / 1_000_000.0; double frameP95 = FrameTimelineProfiler.getInstance().getPercentileFrameNs(0.95) / 1_000_000.0; @@ -888,10 +1334,7 @@ private ExportMetadata buildExportMetadata() { String loaderVersion = FabricLoader.getInstance().getModContainer("fabricloader") .map(mod -> mod.getMetadata().getVersion().getFriendlyString()) .orElse("unknown"); - String cpuInfo = System.getenv("PROCESSOR_IDENTIFIER"); - if (cpuInfo == null || cpuInfo.isBlank()) { - cpuInfo = System.getProperty("os.arch", "unknown"); - } + String cpuInfo = HardwareInfoResolver.getCpuDisplayName(); SystemMetricsProfiler.Snapshot system = SystemMetricsProfiler.getInstance().getSnapshot(); String gpuInfo = (system.gpuVendor() == null || system.gpuVendor().isBlank() ? "Unknown GPU" : system.gpuVendor()) + " | " + (system.gpuRenderer() == null || system.gpuRenderer().isBlank() ? "Unknown renderer" : system.gpuRenderer()); @@ -983,6 +1426,7 @@ private Map buildGraphicsModSettings(MinecraftClient client) { settings.put("sodiumDetected", FabricLoader.getInstance().isModLoaded("sodium")); settings.put("shaderPack", detectShaderPackName()); settings.put("chunkUpdateThreads", detectSodiumChunkThreads()); + settings.put("resourcePacks", detectEnabledResourcePacks(client)); return settings; } @@ -1044,271 +1488,171 @@ private String readConfigValue(Path path, String... keys) { return null; } - private List> buildChunkPipelineTimeline() { - List> timeline = new ArrayList<>(); - for (SessionPoint point : sessionHistory) { - Map row = new LinkedHashMap<>(); - row.put("capturedAtEpochMillis", point.capturedAtEpochMillis()); - row.put("chunksGenerating", point.chunksGenerating()); - row.put("chunksMeshing", point.chunksMeshing()); - row.put("chunksUploading", point.chunksUploading()); - row.put("lightsUpdatePending", point.lightsUpdatePending()); - row.put("chunkMeshesRebuilt", point.chunkMeshesRebuilt()); - row.put("chunkMeshesUploaded", point.chunkMeshesUploaded()); - timeline.add(row); + private List detectEnabledResourcePacks(MinecraftClient client) { + if (client == null) { + return List.of(); + } + try { + Object manager = client.getClass().getMethod("getResourcePackManager").invoke(client); + if (manager == null) { + return List.of(); + } + Object enabledProfiles = manager.getClass().getMethod("getEnabledProfiles").invoke(manager); + if (!(enabledProfiles instanceof Iterable iterable)) { + return List.of(); + } + List packs = new ArrayList<>(); + for (Object profile : iterable) { + if (profile == null) { + continue; + } + String name = extractPackDisplayName(profile); + if (name != null && !name.isBlank()) { + packs.add(name); + } + } + return packs; + } catch (ReflectiveOperationException ignored) { + return List.of(); } - return timeline; } - private Map buildSensorDiagnostics(SystemMetricsProfiler.Snapshot system) { - Map diagnostics = new LinkedHashMap<>(); - diagnostics.put("activeSource", system.sensorSource()); - diagnostics.put("status", system.cpuSensorStatus()); - diagnostics.put("lastError", system.sensorErrorCode()); - diagnostics.put("cpuTemperatureReason", system.cpuTemperatureUnavailableReason()); - return diagnostics; + void requestSnapshotPublish() { + publishSnapshot(); } - private boolean isSharedAttributionBucket(String modId) { - return modId != null && (modId.startsWith("shared/") || modId.startsWith("runtime/")); - } - - private EffectiveCpuAttribution buildEffectiveCpuAttribution(Map rawCpu, Map invokes) { - LinkedHashMap concrete = new LinkedHashMap<>(); - long sharedTotalSamples = 0L; - long sharedClientSamples = 0L; - long sharedRenderSamples = 0L; - for (Map.Entry entry : rawCpu.entrySet()) { - String modId = entry.getKey(); - CpuSamplingProfiler.Snapshot sample = entry.getValue(); - if (isSharedAttributionBucket(modId)) { - sharedTotalSamples += sample.totalSamples(); - sharedClientSamples += sample.clientSamples(); - sharedRenderSamples += sample.renderSamples(); - } else { - concrete.put(modId, sample); + private String extractPackDisplayName(Object profile) { + for (String methodName : List.of("getDisplayName", "getName", "getId")) { + try { + Object value = profile.getClass().getMethod(methodName).invoke(profile); + if (value instanceof Text text) { + return text.getString(); + } + if (value != null) { + return value.toString(); + } + } catch (ReflectiveOperationException ignored) { } } - if (concrete.isEmpty()) { - long totalSamples = rawCpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum(); - long totalRenderSamples = rawCpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::renderSamples).sum(); - return new EffectiveCpuAttribution(new LinkedHashMap<>(rawCpu), Map.of(), Map.of(), totalSamples, totalRenderSamples); - } - - Map totalWeights = buildCpuWeightMap(concrete, invokes, CpuSamplingProfiler.Snapshot::totalSamples); - Map clientWeights = buildCpuWeightMap(concrete, invokes, CpuSamplingProfiler.Snapshot::clientSamples); - Map renderWeights = buildCpuWeightMap(concrete, invokes, CpuSamplingProfiler.Snapshot::renderSamples); - Map redistributedTotals = distributeLongProportionally(sharedTotalSamples, totalWeights); - Map redistributedClients = distributeLongProportionally(sharedClientSamples, clientWeights); - Map redistributedRenders = distributeLongProportionally(sharedRenderSamples, renderWeights); - - LinkedHashMap display = new LinkedHashMap<>(); - for (Map.Entry entry : concrete.entrySet()) { - String modId = entry.getKey(); - CpuSamplingProfiler.Snapshot sample = entry.getValue(); - display.put(modId, new CpuSamplingProfiler.Snapshot( - sample.totalSamples() + redistributedTotals.getOrDefault(modId, 0L), - sample.clientSamples() + redistributedClients.getOrDefault(modId, 0L), - sample.renderSamples() + redistributedRenders.getOrDefault(modId, 0L) - )); - } + return null; + } - long totalSamples = display.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum(); - long totalRenderSamples = display.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::renderSamples).sum(); - return new EffectiveCpuAttribution(display, redistributedTotals, redistributedRenders, totalSamples, totalRenderSamples); + private List buildJvmTuningAdvisor(MemoryProfiler.Snapshot memory, SystemMetricsProfiler.Snapshot system) { + return ruleEngine.buildJvmTuningAdvisor(memory, system); } - private EffectiveGpuAttribution buildEffectiveGpuAttribution(Map renderPhases, Map rawCpu, Map invokes, boolean effectiveView) { - EffectiveCpuAttribution effectiveCpu = buildEffectiveCpuAttribution(rawCpu, invokes); - Map renderSource = effectiveView ? effectiveCpu.displaySnapshots() : rawCpu; - LinkedHashMap renderSamplesByMod = new LinkedHashMap<>(); - renderSource.forEach((modId, sample) -> { - if (sample.renderSamples() > 0L) { - renderSamplesByMod.put(modId, sample.renderSamples()); + private String firstJvmArgValue(List args, String prefix) { + for (String arg : args) { + if (arg != null && arg.startsWith(prefix)) { + return arg.substring(prefix.length()); } - }); + } + return null; + } - LinkedHashMap directGpuByMod = new LinkedHashMap<>(); - long sharedGpuNanos = 0L; - for (RenderPhaseProfiler.PhaseSnapshot phase : renderPhases.values()) { - if (phase.gpuNanos() <= 0L) { - continue; - } - String ownerMod = phase.ownerMod() == null || phase.ownerMod().isBlank() ? "shared/render" : phase.ownerMod(); - if (isSharedAttributionBucket(ownerMod)) { - sharedGpuNanos += phase.gpuNanos(); - } else { - directGpuByMod.merge(ownerMod, phase.gpuNanos(), Long::sum); - } + private void evaluatePerformanceAlerts(MinecraftClient client) { + if (!ConfigManager.isPerformanceAlertsEnabled()) { + latestPerformanceAlert = null; + performanceAlertFlashUntilMillis = 0L; + frameAlertConsecutiveBreaches = 0; + serverAlertConsecutiveBreaches = 0; + return; } - if (!effectiveView) { - if (sharedGpuNanos > 0L) { - directGpuByMod.merge("shared/render", sharedGpuNanos, Long::sum); - renderSamplesByMod.putIfAbsent("shared/render", 0L); - } - long totalGpuNanos = Math.max(1L, directGpuByMod.values().stream().mapToLong(Long::longValue).sum()); - long totalRenderSamples = Math.max(1L, renderSamplesByMod.values().stream().mapToLong(Long::longValue).sum()); - return new EffectiveGpuAttribution(directGpuByMod, renderSamplesByMod, Map.of(), Map.of(), totalGpuNanos, totalRenderSamples); - } - - LinkedHashMap effectiveGpuByMod = new LinkedHashMap<>(); - renderSamplesByMod.keySet().forEach(modId -> effectiveGpuByMod.put(modId, directGpuByMod.getOrDefault(modId, 0L))); - directGpuByMod.forEach(effectiveGpuByMod::putIfAbsent); - Map weights = buildGpuWeightMap(renderSamplesByMod, effectiveGpuByMod); - Map redistributedGpu = distributeLongProportionally(sharedGpuNanos, weights); - redistributedGpu.forEach((modId, gpuNanos) -> effectiveGpuByMod.merge(modId, gpuNanos, Long::sum)); - effectiveGpuByMod.entrySet().removeIf(entry -> entry.getValue() <= 0L && renderSamplesByMod.getOrDefault(entry.getKey(), 0L) <= 0L); - long totalGpuNanos = Math.max(1L, effectiveGpuByMod.values().stream().mapToLong(Long::longValue).sum()); - long totalRenderSamples = Math.max(1L, renderSamplesByMod.values().stream().mapToLong(Long::longValue).sum()); - return new EffectiveGpuAttribution(effectiveGpuByMod, renderSamplesByMod, redistributedGpu, effectiveCpu.redistributedRenderSamplesByMod(), totalGpuNanos, totalRenderSamples); - } - - private EffectiveMemoryAttribution buildEffectiveMemoryAttribution(Map rawMemoryMods) { - LinkedHashMap concrete = new LinkedHashMap<>(); - long sharedBytes = 0L; - for (Map.Entry entry : rawMemoryMods.entrySet()) { - if (isSharedAttributionBucket(entry.getKey())) { - sharedBytes += entry.getValue(); - } else { - concrete.put(entry.getKey(), entry.getValue()); + long now = System.currentTimeMillis(); + double latestFrameMs = FrameTimelineProfiler.getInstance().getLatestFrameNs() / 1_000_000.0; + double serverTickMs = TickProfiler.getInstance().getAverageServerTickNs() / 1_000_000.0; + int required = ConfigManager.getPerformanceAlertConsecutiveTicks(); + frameAlertConsecutiveBreaches = latestFrameMs >= ConfigManager.getPerformanceAlertFrameThresholdMs() ? frameAlertConsecutiveBreaches + 1 : 0; + serverAlertConsecutiveBreaches = serverTickMs >= ConfigManager.getPerformanceAlertServerThresholdMs() ? serverAlertConsecutiveBreaches + 1 : 0; + + PerformanceAlert nextAlert = null; + if (serverAlertConsecutiveBreaches >= required) { + nextAlert = createPerformanceAlert("server-mspt", "Server MSPT", serverTickMs, ConfigManager.getPerformanceAlertServerThresholdMs(), serverAlertConsecutiveBreaches); + } + if (frameAlertConsecutiveBreaches >= required) { + PerformanceAlert frameAlert = createPerformanceAlert("frame-ms", "Frame Time", latestFrameMs, ConfigManager.getPerformanceAlertFrameThresholdMs(), frameAlertConsecutiveBreaches); + if (nextAlert == null || frameAlert.value() > nextAlert.value()) { + nextAlert = frameAlert; } } - if (concrete.isEmpty()) { - long totalBytes = rawMemoryMods.values().stream().mapToLong(Long::longValue).sum(); - return new EffectiveMemoryAttribution(new LinkedHashMap<>(rawMemoryMods), Map.of(), totalBytes); - } - Map weights = buildMemoryWeightMap(concrete); - Map redistributed = distributeLongProportionally(sharedBytes, weights); - LinkedHashMap display = new LinkedHashMap<>(); - for (Map.Entry entry : concrete.entrySet()) { - display.put(entry.getKey(), entry.getValue() + redistributed.getOrDefault(entry.getKey(), 0L)); - } - long totalBytes = display.values().stream().mapToLong(Long::longValue).sum(); - return new EffectiveMemoryAttribution(display, redistributed, totalBytes); - } - private Map buildCpuWeightMap(Map concrete, Map invokes, ToLongFunction extractor) { - LinkedHashMap weights = new LinkedHashMap<>(); - double total = 0.0; - for (Map.Entry entry : concrete.entrySet()) { - double weight = Math.max(0L, extractor.applyAsLong(entry.getValue())); - weights.put(entry.getKey(), weight); - total += weight; - } - if (total > 0.0) { - return weights; - } - double invokeTotal = 0.0; - for (String modId : concrete.keySet()) { - double weight = Math.max(0L, invokes.getOrDefault(modId, new ModTimingSnapshot(0, 0)).calls()); - weights.put(modId, weight); - invokeTotal += weight; + if (nextAlert == null) { + if (latestPerformanceAlert != null && now - latestPerformanceAlert.triggeredAtEpochMillis() > 6_000L) { + latestPerformanceAlert = null; + } + return; } - if (invokeTotal > 0.0) { - return weights; + + long lastAlertAt = lastPerformanceAlertAtMillis.getOrDefault(nextAlert.key(), 0L); + if (now - lastAlertAt < 8_000L) { + return; } - for (String modId : concrete.keySet()) { - weights.put(modId, 1.0); + lastPerformanceAlertAtMillis.put(nextAlert.key(), now); + latestPerformanceAlert = nextAlert; + performanceAlertFlashUntilMillis = now + 2_500L; + if (ConfigManager.isPerformanceAlertChatEnabled() && client != null && client.inGameHud != null) { + client.inGameHud.getChatHud().addMessage(Text.literal("[Task Manager] " + nextAlert.message()).formatted(Formatting.YELLOW)); } - return weights; } - private Map buildMemoryWeightMap(Map concrete) { - LinkedHashMap weights = new LinkedHashMap<>(); - double total = 0.0; - for (Map.Entry entry : concrete.entrySet()) { - double weight = Math.max(0L, entry.getValue()); - weights.put(entry.getKey(), weight); - total += weight; - } - if (total > 0.0) { - return weights; - } - for (String modId : concrete.keySet()) { - weights.put(modId, 1.0); - } - return weights; + private PerformanceAlert createPerformanceAlert(String key, String label, double value, double threshold, int consecutiveBreaches) { + double ratio = threshold <= 0.0 ? 0.0 : value / threshold; + String severity = ratio >= 1.75 ? "critical" : ratio >= 1.25 ? "warning" : "info"; + String message = label + " stayed above " + String.format(Locale.ROOT, "%.1f", threshold) + + " ms for " + consecutiveBreaches + " consecutive ticks (now " + String.format(Locale.ROOT, "%.1f", value) + " ms)."; + return new PerformanceAlert(key, label, message, severity, System.currentTimeMillis(), value, threshold, consecutiveBreaches); } - private Map buildGpuWeightMap(Map renderSamplesByMod, Map directGpuByMod) { - LinkedHashMap weights = new LinkedHashMap<>(); - double total = 0.0; - for (Map.Entry entry : renderSamplesByMod.entrySet()) { - if (isSharedAttributionBucket(entry.getKey())) { - continue; - } - double weight = Math.max(0L, entry.getValue()); - weights.put(entry.getKey(), weight); - total += weight; - } - if (total > 0.0) { - return weights; - } - for (Map.Entry entry : directGpuByMod.entrySet()) { - if (isSharedAttributionBucket(entry.getKey())) { - continue; - } - double weight = Math.max(0L, entry.getValue()); - weights.put(entry.getKey(), weight); - total += weight; - } - if (total > 0.0) { - return weights; - } - for (String modId : renderSamplesByMod.keySet()) { - if (!isSharedAttributionBucket(modId)) { - weights.put(modId, 1.0); - } - } - if (weights.isEmpty()) { - directGpuByMod.keySet().stream().filter(modId -> !isSharedAttributionBucket(modId)).forEach(modId -> weights.put(modId, 1.0)); + private List> buildChunkPipelineTimeline() { + List> timeline = new ArrayList<>(); + for (SessionPoint point : sessionHistory) { + Map row = new LinkedHashMap<>(); + row.put("capturedAtEpochMillis", point.capturedAtEpochMillis()); + row.put("chunksGenerating", point.chunksGenerating()); + row.put("chunksMeshing", point.chunksMeshing()); + row.put("chunksUploading", point.chunksUploading()); + row.put("lightsUpdatePending", point.lightsUpdatePending()); + row.put("chunkMeshesRebuilt", point.chunkMeshesRebuilt()); + row.put("chunkMeshesUploaded", point.chunkMeshesUploaded()); + timeline.add(row); } - return weights; + return timeline; } - private Map distributeLongProportionally(long total, Map weights) { - if (total <= 0L || weights.isEmpty()) { - return Map.of(); - } - LinkedHashMap result = new LinkedHashMap<>(); - ArrayList> entries = new ArrayList<>(weights.entrySet()); - double weightSum = entries.stream().mapToDouble(entry -> Math.max(0.0, entry.getValue())).sum(); - if (weightSum <= 0.0) { - for (Map.Entry entry : entries) { - result.put(entry.getKey(), 0L); - } - return result; - } - LinkedHashMap remainders = new LinkedHashMap<>(); - long assigned = 0L; - for (Map.Entry entry : entries) { - double exact = total * Math.max(0.0, entry.getValue()) / weightSum; - long whole = (long) Math.floor(exact); - result.put(entry.getKey(), whole); - remainders.put(entry.getKey(), exact - whole); - assigned += whole; - } - long remainder = total - assigned; - if (remainder > 0L) { - entries.sort((a, b) -> Double.compare(remainders.getOrDefault(b.getKey(), 0.0), remainders.getOrDefault(a.getKey(), 0.0))); - for (int i = 0; i < remainder; i++) { - String modId = entries.get(i % entries.size()).getKey(); - result.put(modId, result.getOrDefault(modId, 0L) + 1L); - } - } - return result; + private Map buildSensorDiagnostics(SystemMetricsProfiler.Snapshot system) { + Map diagnostics = new LinkedHashMap<>(); + diagnostics.put("activeSource", system.sensorSource()); + diagnostics.put("status", system.cpuSensorStatus()); + diagnostics.put("lastError", system.sensorErrorCode()); + diagnostics.put("cpuTemperatureReason", system.cpuTemperatureUnavailableReason()); + return diagnostics; } private Map buildCpuPercentByMod(Map snapshots) { LinkedHashMap result = new LinkedHashMap<>(); - double total = Math.max(1L, snapshots.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum()); + double total = Math.max(1L, totalCpuMetric(snapshots)); snapshots.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue().totalSamples(), a.getValue().totalSamples())) - .forEach(entry -> result.put(entry.getKey(), entry.getValue().totalSamples() * 100.0 / total)); + .sorted((a, b) -> Long.compare(cpuMetricValue(b.getValue()), cpuMetricValue(a.getValue()))) + .forEach(entry -> result.put(entry.getKey(), cpuMetricValue(entry.getValue()) * 100.0 / total)); return result; } + private long cpuMetricValue(CpuSamplingProfiler.Snapshot snapshot) { + if (snapshot == null) { + return 0L; + } + return snapshot.totalCpuNanos() > 0L ? snapshot.totalCpuNanos() : snapshot.totalSamples(); + } + + private long totalCpuMetric(Map snapshots) { + long totalCpuNanos = snapshots.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalCpuNanos).sum(); + if (totalCpuNanos > 0L) { + return totalCpuNanos; + } + return snapshots.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum(); + } + private Map buildGpuPercentByMod(EffectiveGpuAttribution attribution) { LinkedHashMap result = new LinkedHashMap<>(); double total = Math.max(1L, attribution.totalGpuNanos()); @@ -1326,29 +1670,118 @@ private Map buildMemoryMbByMod(Map memoryByMod) { return result; } + private Map buildCpuConfidenceByMod(Map rawCpu, EffectiveCpuAttribution effectiveCpu, Map cpuDetails) { + LinkedHashMap result = new LinkedHashMap<>(); + effectiveCpu.displaySnapshots().forEach((modId, snapshot) -> { + long rawSamples = rawCpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)).totalSamples(); + long shownSamples = snapshot.totalSamples(); + long redistributedSamples = effectiveCpu.redistributedSamplesByMod().getOrDefault(modId, 0L); + AttributionInsights.Confidence confidence = AttributionInsights.cpuConfidence(modId, cpuDetails.get(modId), rawSamples, shownSamples, redistributedSamples); + result.put(modId, confidence.label()); + }); + return result; + } + + private Map buildGpuConfidenceByMod(EffectiveGpuAttribution rawGpu, EffectiveGpuAttribution effectiveGpu) { + LinkedHashMap result = new LinkedHashMap<>(); + effectiveGpu.gpuNanosByMod().forEach((modId, displayGpuNanos) -> { + AttributionInsights.Confidence confidence = AttributionInsights.gpuConfidence( + modId, + rawGpu.gpuNanosByMod().getOrDefault(modId, 0L), + displayGpuNanos, + effectiveGpu.redistributedGpuNanosByMod().getOrDefault(modId, 0L), + rawGpu.renderSamplesByMod().getOrDefault(modId, 0L), + effectiveGpu.renderSamplesByMod().getOrDefault(modId, 0L) + ); + result.put(modId, confidence.label()); + }); + return result; + } + + private Map buildMemoryConfidenceByMod(Map rawMemory, EffectiveMemoryAttribution effectiveMemory, long memoryAgeMillis) { + LinkedHashMap result = new LinkedHashMap<>(); + effectiveMemory.displayBytes().forEach((modId, displayBytes) -> { + AttributionInsights.Confidence confidence = AttributionInsights.memoryConfidence( + modId, + rawMemory.getOrDefault(modId, 0L), + displayBytes, + effectiveMemory.redistributedBytesByMod().getOrDefault(modId, 0L), + memoryAgeMillis + ); + result.put(modId, confidence.label()); + }); + return result; + } + + private Map buildCpuProvenanceByMod(Map rawCpu, EffectiveCpuAttribution effectiveCpu, Map cpuDetails) { + LinkedHashMap result = new LinkedHashMap<>(); + effectiveCpu.displaySnapshots().forEach((modId, snapshot) -> result.put( + modId, + AttributionInsights.cpuProvenance( + rawCpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)).totalSamples(), + effectiveCpu.redistributedSamplesByMod().getOrDefault(modId, 0L), + cpuDetails.get(modId) + ) + )); + return result; + } + + private Map buildGpuProvenanceByMod(EffectiveGpuAttribution rawGpu, EffectiveGpuAttribution effectiveGpu) { + LinkedHashMap result = new LinkedHashMap<>(); + effectiveGpu.gpuNanosByMod().forEach((modId, displayGpuNanos) -> result.put( + modId, + AttributionInsights.gpuProvenance( + rawGpu.gpuNanosByMod().getOrDefault(modId, 0L), + effectiveGpu.redistributedGpuNanosByMod().getOrDefault(modId, 0L), + rawGpu.renderSamplesByMod().getOrDefault(modId, 0L), + effectiveGpu.renderSamplesByMod().getOrDefault(modId, 0L) + ) + )); + return result; + } + + private Map buildMemoryProvenanceByMod(Map rawMemory, EffectiveMemoryAttribution effectiveMemory, long memoryAgeMillis) { + LinkedHashMap result = new LinkedHashMap<>(); + effectiveMemory.displayBytes().forEach((modId, displayBytes) -> result.put( + modId, + AttributionInsights.memoryProvenance( + rawMemory.getOrDefault(modId, 0L), + effectiveMemory.redistributedBytesByMod().getOrDefault(modId, 0L), + memoryAgeMillis + ) + )); + return result; + } + private Map buildAttributionExport() { Map export = new LinkedHashMap<>(); - EffectiveCpuAttribution effectiveCpu = buildEffectiveCpuAttribution(currentSnapshot.cpuMods(), currentSnapshot.modInvokes()); - EffectiveGpuAttribution rawGpu = buildEffectiveGpuAttribution(currentSnapshot.renderPhases(), currentSnapshot.cpuMods(), currentSnapshot.modInvokes(), false); - EffectiveGpuAttribution effectiveGpu = buildEffectiveGpuAttribution(currentSnapshot.renderPhases(), currentSnapshot.cpuMods(), currentSnapshot.modInvokes(), true); - EffectiveMemoryAttribution effectiveMemory = buildEffectiveMemoryAttribution(currentSnapshot.memoryMods()); + EffectiveCpuAttribution effectiveCpu = AttributionModelBuilder.buildEffectiveCpuAttribution(currentSnapshot.cpuMods(), currentSnapshot.cpuDetails(), currentSnapshot.modInvokes()); + EffectiveGpuAttribution rawGpu = AttributionModelBuilder.buildEffectiveGpuAttribution(currentSnapshot.renderPhases(), currentSnapshot.cpuMods(), effectiveCpu, false); + EffectiveGpuAttribution effectiveGpu = AttributionModelBuilder.buildEffectiveGpuAttribution(currentSnapshot.renderPhases(), currentSnapshot.cpuMods(), effectiveCpu, true); + EffectiveMemoryAttribution effectiveMemory = AttributionModelBuilder.buildEffectiveMemoryAttribution(currentSnapshot.memoryMods()); Map cpu = new LinkedHashMap<>(); - cpu.put("rawTop", topCpuRows(currentSnapshot.cpuMods(), Math.max(1L, currentSnapshot.cpuMods().values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum()))); - cpu.put("effectiveTop", topCpuRows(effectiveCpu.displaySnapshots(), Math.max(1L, effectiveCpu.totalSamples()))); + cpu.put("sampleAgeMs", currentSnapshot.cpuSampleAgeMillis()); + cpu.put("collectorSource", "hybrid ThreadMXBean CPU budgets + sampled busy-thread stacks"); + cpu.put("rawTop", topCpuRows(currentSnapshot.cpuMods(), Math.max(1L, totalCpuMetric(currentSnapshot.cpuMods())), currentSnapshot.cpuMods(), effectiveCpu, false)); + cpu.put("effectiveTop", topCpuRows(effectiveCpu.displaySnapshots(), Math.max(1L, totalCpuMetric(effectiveCpu.displaySnapshots())), currentSnapshot.cpuMods(), effectiveCpu, true)); cpu.put("redistributedSamplesByMod", effectiveCpu.redistributedSamplesByMod()); export.put("cpu", cpu); Map gpu = new LinkedHashMap<>(); - gpu.put("rawTop", topGpuRows(rawGpu)); - gpu.put("effectiveTop", topGpuRows(effectiveGpu)); + gpu.put("sampleAgeMs", currentSnapshot.cpuSampleAgeMillis()); + gpu.put("collectorSource", "GPU timer queries on tagged render phases + sampled render-thread ownership"); + gpu.put("rawTop", topGpuRows(rawGpu, rawGpu, effectiveGpu, false)); + gpu.put("effectiveTop", topGpuRows(effectiveGpu, rawGpu, effectiveGpu, true)); gpu.put("redistributedGpuNanosByMod", effectiveGpu.redistributedGpuNanosByMod()); gpu.put("redistributedRenderSamplesByMod", effectiveGpu.redistributedRenderSamplesByMod()); export.put("gpu", gpu); Map memory = new LinkedHashMap<>(); - memory.put("rawTop", topMemoryRows(currentSnapshot.memoryMods())); - memory.put("effectiveTop", topMemoryRows(effectiveMemory.displayBytes())); + memory.put("sampleAgeMs", currentSnapshot.memoryAgeMillis()); + memory.put("collectorSource", "live heap histogram + per-thread allocated-byte deltas"); + memory.put("rawTop", topMemoryRows(currentSnapshot.memoryMods(), effectiveMemory, false)); + memory.put("effectiveTop", topMemoryRows(effectiveMemory.displayBytes(), effectiveMemory, true)); memory.put("redistributedBytesByMod", effectiveMemory.redistributedBytesByMod()); export.put("memory", memory); return export; @@ -1360,11 +1793,13 @@ private List> buildRenderPhaseOwnerSummary() { .map(entry -> { Map row = new LinkedHashMap<>(); row.put("phase", entry.getKey()); - row.put("owner", entry.getValue().ownerMod() == null || entry.getValue().ownerMod().isBlank() ? "shared/render" : entry.getValue().ownerMod()); + row.put("owner", AttributionModelBuilder.effectiveGpuPhaseOwner(entry.getValue())); row.put("cpuMs", entry.getValue().cpuNanos() / 1_000_000.0); row.put("gpuMs", entry.getValue().gpuNanos() / 1_000_000.0); row.put("cpuCalls", entry.getValue().cpuCalls()); row.put("gpuCalls", entry.getValue().gpuCalls()); + row.put("likelyOwners", entry.getValue().likelyOwners()); + row.put("likelyFrames", entry.getValue().likelyFrames()); return row; }) .toList(); @@ -1374,20 +1809,22 @@ private Map buildSharedBucketBreakdownExport() { Map export = new LinkedHashMap<>(); Map cpu = new LinkedHashMap<>(); currentSnapshot.cpuDetails().entrySet().stream() - .filter(entry -> isSharedAttributionBucket(entry.getKey())) + .filter(entry -> AttributionModelBuilder.isSharedAttributionBucket(entry.getKey())) .forEach(entry -> cpu.put(entry.getKey(), entry.getValue().topFrames())); export.put("cpu", cpu); Map gpu = new LinkedHashMap<>(); currentSnapshot.renderPhases().entrySet().stream() .filter(entry -> { - String owner = entry.getValue().ownerMod() == null || entry.getValue().ownerMod().isBlank() ? "shared/render" : entry.getValue().ownerMod(); - return isSharedAttributionBucket(owner); + String owner = AttributionModelBuilder.effectiveGpuPhaseOwner(entry.getValue()); + return AttributionModelBuilder.isSharedAttributionBucket(owner); }) .forEach(entry -> gpu.put(entry.getKey(), Map.of( - "owner", entry.getValue().ownerMod() == null || entry.getValue().ownerMod().isBlank() ? "shared/render" : entry.getValue().ownerMod(), + "owner", AttributionModelBuilder.effectiveGpuPhaseOwner(entry.getValue()), "cpuMs", entry.getValue().cpuNanos() / 1_000_000.0, - "gpuMs", entry.getValue().gpuNanos() / 1_000_000.0 + "gpuMs", entry.getValue().gpuNanos() / 1_000_000.0, + "likelyOwners", entry.getValue().likelyOwners(), + "likelyFrames", entry.getValue().likelyFrames() ))); export.put("gpu", gpu); @@ -1400,71 +1837,115 @@ private Map buildSharedBucketBreakdownExport() { return export; } - private List> topCpuRows(Map snapshots, long totalSamples) { + private List> topCpuRows(Map snapshots, long totalSamples, Map rawCpu, EffectiveCpuAttribution effectiveCpu, boolean effectiveView) { return snapshots.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue().totalSamples(), a.getValue().totalSamples())) + .sorted((a, b) -> Long.compare(cpuMetricValue(b.getValue()), cpuMetricValue(a.getValue()))) .limit(8) .map(entry -> { + String modId = entry.getKey(); + long rawSamples = rawCpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)).totalSamples(); + long shownSamples = entry.getValue().totalSamples(); + long redistributedSamples = effectiveCpu.redistributedSamplesByMod().getOrDefault(modId, 0L); + CpuSamplingProfiler.DetailSnapshot detail = currentSnapshot.cpuDetails().get(modId); Map row = new LinkedHashMap<>(); - row.put("mod", entry.getKey()); + row.put("mod", modId); row.put("samples", entry.getValue().totalSamples()); - row.put("percent", entry.getValue().totalSamples() * 100.0 / Math.max(1L, totalSamples)); + row.put("cpuNanos", entry.getValue().totalCpuNanos()); + row.put("percent", cpuMetricValue(entry.getValue()) * 100.0 / Math.max(1L, totalSamples)); + row.put("rawSamples", rawSamples); + row.put("effectiveSamples", effectiveView ? shownSamples : rawSamples); + row.put("redistributedSamples", redistributedSamples); + row.put("confidence", AttributionInsights.cpuConfidence(modId, detail, rawSamples, shownSamples, redistributedSamples).label()); + row.put("provenance", AttributionInsights.cpuProvenance(rawSamples, redistributedSamples, detail)); + row.put("collectorSource", "hybrid ThreadMXBean CPU budgets + sampled busy-thread stacks"); + row.put("sampleAgeMs", currentSnapshot.cpuSampleAgeMillis()); return row; }) .toList(); } - private List> topGpuRows(EffectiveGpuAttribution attribution) { - return attribution.gpuNanosByMod().entrySet().stream() + private List> topGpuRows(EffectiveGpuAttribution displayAttribution, EffectiveGpuAttribution rawAttribution, EffectiveGpuAttribution effectiveAttribution, boolean effectiveView) { + return displayAttribution.gpuNanosByMod().entrySet().stream() .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) .limit(8) .map(entry -> { + String modId = entry.getKey(); + long rawGpuNanos = rawAttribution.gpuNanosByMod().getOrDefault(modId, 0L); + long displayGpuNanos = entry.getValue(); + long redistributedGpuNanos = effectiveAttribution.redistributedGpuNanosByMod().getOrDefault(modId, 0L); + long rawRenderSamples = rawAttribution.renderSamplesByMod().getOrDefault(modId, 0L); + long displayRenderSamples = displayAttribution.renderSamplesByMod().getOrDefault(modId, 0L); Map row = new LinkedHashMap<>(); - row.put("mod", entry.getKey()); + row.put("mod", modId); row.put("gpuMs", entry.getValue() / 1_000_000.0); - row.put("percent", entry.getValue() * 100.0 / Math.max(1L, attribution.totalGpuNanos())); - row.put("renderSamples", attribution.renderSamplesByMod().getOrDefault(entry.getKey(), 0L)); + row.put("percent", entry.getValue() * 100.0 / Math.max(1L, displayAttribution.totalGpuNanos())); + row.put("renderSamples", displayRenderSamples); + row.put("rawGpuMs", rawGpuNanos / 1_000_000.0); + row.put("effectiveGpuMs", effectiveView ? displayGpuNanos / 1_000_000.0 : rawGpuNanos / 1_000_000.0); + row.put("redistributedGpuMs", redistributedGpuNanos / 1_000_000.0); + row.put("confidence", AttributionInsights.gpuConfidence(modId, rawGpuNanos, displayGpuNanos, redistributedGpuNanos, rawRenderSamples, displayRenderSamples).label()); + row.put("provenance", AttributionInsights.gpuProvenance(rawGpuNanos, redistributedGpuNanos, rawRenderSamples, displayRenderSamples)); + row.put("collectorSource", "GPU timer queries on tagged render phases + sampled render-thread ownership"); + row.put("sampleAgeMs", currentSnapshot.cpuSampleAgeMillis()); return row; }) .toList(); } - private List> topMemoryRows(Map memoryByMod) { + private List> topMemoryRows(Map memoryByMod, EffectiveMemoryAttribution effectiveMemory, boolean effectiveView) { long total = Math.max(1L, memoryByMod.values().stream().mapToLong(Long::longValue).sum()); return memoryByMod.entrySet().stream() .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) .limit(8) .map(entry -> { + String modId = entry.getKey(); + long rawBytes = currentSnapshot.memoryMods().getOrDefault(modId, 0L); + long displayBytes = entry.getValue(); + long redistributedBytes = effectiveMemory.redistributedBytesByMod().getOrDefault(modId, 0L); Map row = new LinkedHashMap<>(); - row.put("mod", entry.getKey()); + row.put("mod", modId); row.put("memoryMb", entry.getValue() / (1024.0 * 1024.0)); row.put("percent", entry.getValue() * 100.0 / total); + row.put("rawMemoryMb", rawBytes / (1024.0 * 1024.0)); + row.put("effectiveMemoryMb", effectiveView ? displayBytes / (1024.0 * 1024.0) : rawBytes / (1024.0 * 1024.0)); + row.put("redistributedMemoryMb", redistributedBytes / (1024.0 * 1024.0)); + row.put("confidence", AttributionInsights.memoryConfidence(modId, rawBytes, displayBytes, redistributedBytes, currentSnapshot.memoryAgeMillis()).label()); + row.put("provenance", AttributionInsights.memoryProvenance(rawBytes, redistributedBytes, currentSnapshot.memoryAgeMillis())); + row.put("collectorSource", "live heap histogram + per-thread allocated-byte deltas"); + row.put("sampleAgeMs", currentSnapshot.memoryAgeMillis()); return row; }) .toList(); } private List> buildTopCpuModSummary() { - EffectiveCpuAttribution effectiveCpu = buildEffectiveCpuAttribution(currentSnapshot.cpuMods(), currentSnapshot.modInvokes()); - long totalCpuSamples = Math.max(1L, effectiveCpu.totalSamples()); + EffectiveCpuAttribution effectiveCpu = AttributionModelBuilder.buildEffectiveCpuAttribution(currentSnapshot.cpuMods(), currentSnapshot.cpuDetails(), currentSnapshot.modInvokes()); + long totalCpuSamples = Math.max(1L, totalCpuMetric(effectiveCpu.displaySnapshots())); return effectiveCpu.displaySnapshots().entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue().totalSamples(), a.getValue().totalSamples())) + .sorted((a, b) -> Long.compare(cpuMetricValue(b.getValue()), cpuMetricValue(a.getValue()))) .limit(5) .map(entry -> { Map row = new LinkedHashMap<>(); row.put("mod", entry.getKey()); row.put("samples", entry.getValue().totalSamples()); - row.put("percentCpu", entry.getValue().totalSamples() * 100.0 / totalCpuSamples); + row.put("cpuNanos", entry.getValue().totalCpuNanos()); + row.put("percentCpu", cpuMetricValue(entry.getValue()) * 100.0 / totalCpuSamples); row.put("rawSamples", currentSnapshot.cpuMods().getOrDefault(entry.getKey(), new CpuSamplingProfiler.Snapshot(0, 0, 0)).totalSamples()); row.put("redistributedSamples", effectiveCpu.redistributedSamplesByMod().getOrDefault(entry.getKey(), 0L)); row.put("threadCount", currentSnapshot.cpuDetails().get(entry.getKey()) == null ? 0 : currentSnapshot.cpuDetails().get(entry.getKey()).sampledThreadCount()); + row.put("confidence", buildCpuConfidenceByMod(currentSnapshot.cpuMods(), effectiveCpu, currentSnapshot.cpuDetails()).getOrDefault(entry.getKey(), "Unknown")); + row.put("provenance", buildCpuProvenanceByMod(currentSnapshot.cpuMods(), effectiveCpu, currentSnapshot.cpuDetails()).getOrDefault(entry.getKey(), "Unknown")); + row.put("sampleAgeMs", currentSnapshot.cpuSampleAgeMillis()); + row.put("collectorSource", "hybrid ThreadMXBean CPU budgets + sampled busy-thread stacks"); return row; }) .toList(); } private List> buildTopGpuModSummary() { - EffectiveGpuAttribution effectiveGpu = buildEffectiveGpuAttribution(currentSnapshot.renderPhases(), currentSnapshot.cpuMods(), currentSnapshot.modInvokes(), true); + EffectiveCpuAttribution effectiveCpu = AttributionModelBuilder.buildEffectiveCpuAttribution(currentSnapshot.cpuMods(), currentSnapshot.cpuDetails(), currentSnapshot.modInvokes()); + EffectiveGpuAttribution rawGpu = AttributionModelBuilder.buildEffectiveGpuAttribution(currentSnapshot.renderPhases(), currentSnapshot.cpuMods(), effectiveCpu, false); + EffectiveGpuAttribution effectiveGpu = AttributionModelBuilder.buildEffectiveGpuAttribution(currentSnapshot.renderPhases(), currentSnapshot.cpuMods(), effectiveCpu, true); long totalGpuNs = Math.max(1L, effectiveGpu.totalGpuNanos()); return effectiveGpu.gpuNanosByMod().entrySet().stream() .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) @@ -1474,16 +1955,21 @@ private List> buildTopGpuModSummary() { row.put("mod", entry.getKey()); row.put("gpuFrameTimeMsEstimate", entry.getValue() / 1_000_000.0); row.put("percentGpuEstimate", entry.getValue() * 100.0 / totalGpuNs); - row.put("rawGpuFrameTimeMs", buildEffectiveGpuAttribution(currentSnapshot.renderPhases(), currentSnapshot.cpuMods(), currentSnapshot.modInvokes(), false).gpuNanosByMod().getOrDefault(entry.getKey(), 0L) / 1_000_000.0); + row.put("rawGpuFrameTimeMs", rawGpu.gpuNanosByMod().getOrDefault(entry.getKey(), 0L) / 1_000_000.0); row.put("renderSamples", effectiveGpu.renderSamplesByMod().getOrDefault(entry.getKey(), 0L)); row.put("redistributedGpuNanos", effectiveGpu.redistributedGpuNanosByMod().getOrDefault(entry.getKey(), 0L)); row.put("threadCount", currentSnapshot.cpuDetails().get(entry.getKey()) == null ? 0 : currentSnapshot.cpuDetails().get(entry.getKey()).sampledThreadCount()); + row.put("confidence", buildGpuConfidenceByMod(rawGpu, effectiveGpu).getOrDefault(entry.getKey(), "Unknown")); + row.put("provenance", buildGpuProvenanceByMod(rawGpu, effectiveGpu).getOrDefault(entry.getKey(), "Unknown")); + row.put("sampleAgeMs", currentSnapshot.cpuSampleAgeMillis()); + row.put("collectorSource", "GPU timer queries on tagged render phases + sampled render-thread ownership"); return row; }) .toList(); } private List> buildTopMemoryModSummary() { + EffectiveMemoryAttribution effectiveMemory = AttributionModelBuilder.buildEffectiveMemoryAttribution(currentSnapshot.memoryMods()); long totalMemory = Math.max(1L, currentSnapshot.memoryMods().values().stream().mapToLong(Long::longValue).sum()); return currentSnapshot.memoryMods().entrySet().stream() .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) @@ -1494,6 +1980,10 @@ private List> buildTopMemoryModSummary() { row.put("memoryMb", entry.getValue() / (1024.0 * 1024.0)); row.put("percentAttributedMemory", entry.getValue() * 100.0 / totalMemory); row.put("classCount", currentSnapshot.memoryClassesByMod().getOrDefault(entry.getKey(), Map.of()).size()); + row.put("confidence", buildMemoryConfidenceByMod(currentSnapshot.memoryMods(), effectiveMemory, currentSnapshot.memoryAgeMillis()).getOrDefault(entry.getKey(), "Unknown")); + row.put("provenance", buildMemoryProvenanceByMod(currentSnapshot.memoryMods(), effectiveMemory, currentSnapshot.memoryAgeMillis()).getOrDefault(entry.getKey(), "Unknown")); + row.put("sampleAgeMs", currentSnapshot.memoryAgeMillis()); + row.put("collectorSource", "live heap histogram + per-thread allocated-byte deltas"); return row; }) .toList(); @@ -1599,6 +2089,15 @@ private String buildDiagnosis() { SystemMetricsProfiler.Snapshot system = SystemMetricsProfiler.getInstance().getSnapshot(); MemoryProfiler.Snapshot memory = MemoryProfiler.getInstance().getDetailedSnapshot(); double serverTickMs = TickProfiler.getInstance().getAverageServerTickNs() / 1_000_000.0; + RuleFinding highestFinding = latestRuleFindings.stream() + .sorted((a, b) -> Integer.compare(severityRank(b.severity()), severityRank(a.severity()))) + .findFirst() + .orElse(null); + String status = highestFinding == null ? "Stable" : switch (highestFinding.severity()) { + case "critical" -> "Critical"; + case "warning", "error" -> "Warning"; + default -> "Stable"; + }; String systemBound = system.gpuCoreLoadPercent() > 90.0 && serverTickMs < 15.0 ? "GPU-bound" : serverTickMs > 40.0 ? "CPU-bound" : "Balanced"; String thermal = system.gpuTemperatureC() > 85.0 || system.cpuTemperatureC() > 90.0 ? "Thermal warning." : "Thermals are optimal."; boolean memoryPressure = false; @@ -1610,8 +2109,25 @@ private String buildDiagnosis() { String logicText = serverTickMs > 40.0 ? String.format("Entity logic overhead is high (%.1fms).", serverTickMs) : String.format("Entity logic overhead is low (%.1fms).", serverTickMs); String schedulingText = system.schedulingConflictSummary() == null || system.schedulingConflictSummary().isBlank() ? "" : (" " + system.schedulingConflictSummary() + "."); String overscheduleText = sessionParallelismFlag(system, FrameTimelineProfiler.getInstance().getStutterScore()).equals("Thread Overscheduling Warning") ? " Thread overscheduling is likely." : ""; - return "Status: Healthy. System is " + systemBound + ". " + thermal + " " + memoryText + " " + logicText + " Parallelism Efficiency: " + system.parallelismEfficiency() + schedulingText + overscheduleText; + String conflictText = latestConflictEdges.isEmpty() ? "" : (" Top conflict candidate: " + latestConflictEdges.getFirst().waiterMod() + " waiting on " + latestConflictEdges.getFirst().ownerMod() + " via " + latestConflictEdges.getFirst().lockName() + "."); + return "Status: " + status + ". System is " + systemBound + ". " + thermal + " " + memoryText + " " + logicText + " Parallelism Efficiency: " + system.parallelismEfficiency() + schedulingText + overscheduleText + conflictText; + } + + private void appendTraceSpan(List> events, String name, String threadName, long completedAtEpochMillis, double durationMs) { + if (completedAtEpochMillis <= 0L || durationMs <= 0.0) { + return; + } + Map event = new LinkedHashMap<>(); + event.put("name", name); + event.put("cat", "taskmanager"); + event.put("ph", "X"); + event.put("pid", 1); + event.put("tid", threadName); + event.put("ts", Math.max(0L, completedAtEpochMillis * 1000L - Math.round(durationMs * 1000.0))); + event.put("dur", Math.max(1L, Math.round(durationMs * 1000.0))); + events.add(event); } + private void enforceSessionWindow(MinecraftClient client) { int maxSessionPoints = Math.max(60, ConfigManager.getSessionDurationSeconds() * 20); while (sessionHistory.size() > maxSessionPoints) { @@ -1624,22 +2140,6 @@ private void enforceSessionWindow(MinecraftClient client) { String exportStatus = exportSession(); if (client != null && client.player != null) { client.player.sendMessage(Text.literal("Task Manager: Session recorded. " + exportStatus), false); - if (lastExportHtmlReport != null) { - Text openReport = Text.literal("[Open Session Report]") - .setStyle(Style.EMPTY - .withColor(Formatting.AQUA) - .withUnderline(true) - .withClickEvent(new ClickEvent.OpenFile(lastExportHtmlReport.toAbsolutePath().toString()))); - client.player.sendMessage(openReport, false); - } - if (lastExportDirectory != null) { - Text openFolder = Text.literal("[Open Session Logs Folder]") - .setStyle(Style.EMPTY - .withColor(Formatting.GREEN) - .withUnderline(true) - .withClickEvent(new ClickEvent.OpenFile(lastExportDirectory.toAbsolutePath().toString()))); - client.player.sendMessage(openFolder, false); - } } } } @@ -1750,6 +2250,10 @@ public List getLatestHotChunks() { return latestHotChunks; } + public List getSpikes() { + return List.copyOf(spikes); + } + public List getChunkActivityHistory(ChunkPos chunkPos) { if (chunkPos == null) { return List.of(); @@ -1770,6 +2274,10 @@ public List getLatestBlockEntityHotspots() { return latestBlockEntityHotspots; } + public List getLatestConflictEdges() { + return latestConflictEdges; + } + public List getLatestLockSummaries() { return latestLockSummaries; } @@ -1849,127 +2357,8 @@ private List> buildHotChunkHistoryExport() { return export; } - private List buildRuleFindings() { - List findings = new ArrayList<>(); - SystemMetricsProfiler.Snapshot system = SystemMetricsProfiler.getInstance().getSnapshot(); - double latestFrameMs = FrameTimelineProfiler.getInstance().getLatestFrameNs() / 1_000_000.0; - double serverTickMs = TickProfiler.getInstance().getAverageServerTickNs() / 1_000_000.0; - double clientTickMs = TickProfiler.getInstance().getAverageClientTickNs() / 1_000_000.0; - double stutterScore = FrameTimelineProfiler.getInstance().getStutterScore(); - double heapUsedPct = currentSnapshot.memory().heapCommittedBytes() > 0 - ? currentSnapshot.memory().heapUsedBytes() * 100.0 / currentSnapshot.memory().heapCommittedBytes() - : 0.0; - - if (system.gpuCoreLoadPercent() > 90.0 && latestFrameMs > ConfigManager.getFrameBudgetTargetFrameMs() && serverTickMs < 15.0) { - findings.add(new RuleFinding(latestFrameMs > 33.0 && system.gpuCoreLoadPercent() > 97.0 ? "critical" : "warning", "gpu", "GPU appears saturated while logic stays healthy.", "measured", - "The render path is spending time on the GPU while server-side logic remains within budget.", - "Check heavy shader packs, high-resolution effects, and the GPU tab's hottest render phases.", - String.format(Locale.ROOT, "GPU %.0f%% | frame %.1f ms | server %.1f ms", system.gpuCoreLoadPercent(), latestFrameMs, serverTickMs))); - } - if (serverTickMs > 40.0) { - findings.add(new RuleFinding(serverTickMs > 80.0 ? "critical" : "warning", "logic", String.format(Locale.ROOT, "Server tick is elevated at %.1f ms.", serverTickMs), "measured", - "Integrated-server work is exceeding a comfortable frame budget and will usually show up as simulation hitching.", - "Inspect the World tab for hot chunks, block entities, and thread wait activity around the same window.", - String.format(Locale.ROOT, "server %.1f ms | client %.1f ms | stutter %.1f", serverTickMs, clientTickMs, stutterScore))); - } - if (system.diskWriteBytesPerSecond() > 8L * 1024L * 1024L && latestFrameMs > 50.0) { - findings.add(new RuleFinding(system.diskWriteBytesPerSecond() > 24L * 1024L * 1024L ? "critical" : "warning", "io", "Heavy disk writes overlap with a bad frame spike.", "measured", - "High write throughput is coinciding with a visible hitch and may point to saves, chunk flushes, or logging bursts.", - "Check the Disk tab and world activity for saves, region writes, or mods with frequent persistence.", - String.format(Locale.ROOT, "writes %s | frame %.1f ms", formatBytesPerSecond(system.diskWriteBytesPerSecond()), latestFrameMs))); - } - if (system.activeHighLoadThreads() > Math.max(1, system.estimatedPhysicalCores() / 2) && stutterScore > 10.0) { - findings.add(new RuleFinding(system.activeHighLoadThreads() > Math.max(2, system.estimatedPhysicalCores()) ? "critical" : "warning", "threads", "Thread overscheduling warning: too many high-load threads are active for the estimated physical core budget.", "inferred", - "Multiple hot threads are competing for a limited physical-core budget during a stutter window.", - "Inspect the System tab's top threads and worker activity to see whether chunk builders or async workers are crowding the CPU.", - String.format(Locale.ROOT, "high-load threads %d | est. physical cores %d | stutter %.1f", system.activeHighLoadThreads(), system.estimatedPhysicalCores(), stutterScore))); - } - if (!latestHotChunks.isEmpty() && serverTickMs > 20.0) { - HotChunkSnapshot hot = latestHotChunks.getFirst(); - findings.add(new RuleFinding("info", "chunks", String.format(Locale.ROOT, "Hot chunk %d,%d has %d entities and %d block entities.", hot.chunkX(), hot.chunkZ(), hot.entityCount(), hot.blockEntityCount()), "measured", - "A single chunk is standing out in the current window and may be central to the slowdown.", - "Select the chunk in the World tab and inspect entity density, block entities, and thread load together.", - String.format(Locale.ROOT, "activity %.1f | entities %d | block entities %d", hot.activityScore(), hot.entityCount(), hot.blockEntityCount()))); - } - if (!latestEntityHotspots.isEmpty()) { - EntityHotspot hotspot = latestEntityHotspots.getFirst(); - if (!"none".equals(hotspot.heuristic())) { - findings.add(new RuleFinding(hotspot.count() >= 100 ? "critical" : "warning", "entities", hotspot.className() + " is dominating recent entity cost signals: " + hotspot.heuristic(), "inferred", - "Recent world samples point to one entity family as the strongest source of per-chunk entity pressure.", - "Inspect mob AI density, farms, and clustered spawns in the World tab near the hot chunk.", - String.format(Locale.ROOT, "%s x%d", hotspot.className(), hotspot.count()))); - } - } - if (!latestBlockEntityHotspots.isEmpty()) { - BlockEntityHotspot hotspot = latestBlockEntityHotspots.getFirst(); - if (hotspot.count() >= 20) { - findings.add(new RuleFinding(hotspot.count() >= 60 ? "critical" : "warning", "block-entities", hotspot.className() + " is dense across loaded chunks and may be ticking heavily.", "inferred", - "A block entity class is showing up frequently enough to plausibly drive ticking or storage pressure.", - "Open the Block Entities mini-tab and inspect the selected chunk plus the global hotspot list.", - String.format(Locale.ROOT, "%s x%d | %s", hotspot.className(), hotspot.count(), hotspot.heuristic()))); - } - } - if (!latestLockSummaries.isEmpty()) { - findings.add(new RuleFinding("info", "locks", latestLockSummaries.getFirst(), "measured", - "A thread spent time blocked or waiting in the current window.", - "Use the System tab to check the owning thread and see whether the wait lines up with chunk IO or background workers.", - latestLockSummaries.getFirst())); - boolean chunkIoLock = latestLockSummaries.stream() - .map(summary -> summary.toLowerCase(Locale.ROOT)) - .anyMatch(summary -> summary.contains("region") || summary.contains("chunk") || summary.contains("poi") || summary.contains("anvil") || summary.contains("storage")); - if (chunkIoLock && (serverTickMs > 20.0 || latestFrameMs > 25.0)) { - findings.add(new RuleFinding((serverTickMs > 50.0 || latestFrameMs > 40.0) ? "critical" : "warning", "chunk-io", "Threads are waiting on chunk or region style locks during a slow window.", "inferred", - "The lock names look chunk-storage related and overlap with a visible slowdown.", - "Check async chunk mods, world storage activity, and the Disk tab for matching spikes.", - String.format(Locale.ROOT, "server %.1f ms | frame %.1f ms | lock count %d", serverTickMs, latestFrameMs, latestLockSummaries.size()))); - } - } - if (system.bytesReceivedPerSecond() > 512L * 1024L && latestFrameMs > 20.0) { - findings.add(new RuleFinding("info", "network", "A network burst overlaps with a slower frame window.", "measured", - "Inbound traffic is elevated enough to plausibly disturb the client if packet handling or chunk delivery is busy.", - "Inspect the Network tab's packet types and recent spike bookmarks.", - String.format(Locale.ROOT, "inbound %s | packet latency %.1f ms", formatBytesPerSecond(system.bytesReceivedPerSecond()), system.packetProcessingLatencyMs()))); - } - if ((system.chunksGenerating() > 0 || system.chunksMeshing() > 0 || system.chunksUploading() > 0) && (latestFrameMs > 20.0 || serverTickMs > 20.0)) { - findings.add(new RuleFinding("info", "chunk-pipeline", "Chunk generation, meshing, or upload work is active during the current slow window.", "measured", - "World streaming work is non-idle and may be contributing to a hitch, especially while moving quickly or exploring new terrain.", - "Check the World tab and render metrics for generation, meshing, upload, and lighting pressure.", - String.format(Locale.ROOT, "gen %d | mesh %d | upload %d | lights %d", system.chunksGenerating(), system.chunksMeshing(), system.chunksUploading(), system.lightsUpdatePending()))); - } - if (heapUsedPct > 85.0) { - findings.add(new RuleFinding("info", "memory", "Heap usage is high relative to committed memory.", "measured", - "Live heap usage is near the current committed ceiling, which can increase GC pressure or mask leaks.", - "Inspect the Memory tab for dominant mods and shared JVM buckets, especially if GC pauses are appearing too.", - String.format(Locale.ROOT, "heap %.0f%% | used %s", heapUsedPct, formatBytesMb(currentSnapshot.memory().heapUsedBytes())))); - } - if (currentSnapshot.memory().gcPauseDurationMs() > 0) { - findings.add(new RuleFinding("info", "gc", "Recent GC pause detected: " + currentSnapshot.memory().gcType() + " " + currentSnapshot.memory().gcPauseDurationMs() + " ms.", "measured", - "A garbage-collection pause occurred recently and may explain a hitch if it aligns with frame or tick spikes.", - "Correlate the pause with frame-time spikes and high heap usage in the Timeline and Memory tabs.", - String.format(Locale.ROOT, "%s | pause %d ms", currentSnapshot.memory().gcType(), currentSnapshot.memory().gcPauseDurationMs()))); - } - if (system.cpuTemperatureC() < 0 && system.gpuTemperatureC() < 0) { - findings.add(new RuleFinding("info", "sensors", "Temperature sensors are unavailable on this machine/provider combination; falling back to load-only telemetry.", "unavailable", - "The profiler can still report utilization, but package/core temperatures are not currently exposed by any detected provider.", - "Open the System tab's Sensors panel to see provider attempts and the last bridge error.", - system.cpuTemperatureUnavailableReason())); - } else if (system.cpuTemperatureC() >= 85.0 || system.gpuTemperatureC() >= 85.0) { - findings.add(new RuleFinding((system.cpuTemperatureC() >= 92.0 || system.gpuTemperatureC() >= 90.0) ? "critical" : "warning", "thermals", "A CPU or GPU temperature is entering a throttling-prone range.", "measured", - "Sustained temperatures in the mid-80s or higher can cause clocks to drop and make spikes harder to explain from software alone.", - "Check cooling, fan curves, and whether the slowdown lines up with a thermal ramp in exported sessions.", - String.format(Locale.ROOT, "CPU %s | GPU %s", system.cpuTemperatureC() >= 0 ? String.format(Locale.ROOT, "%.1f C", system.cpuTemperatureC()) : "N/A", system.gpuTemperatureC() >= 0 ? String.format(Locale.ROOT, "%.1f C", system.gpuTemperatureC()) : "N/A"))); - } - findings.sort((a, b) -> Integer.compare(severityRank(b.severity()), severityRank(a.severity()))); - return findings; - } - private int severityRank(String severity) { - return switch (severity == null ? "info" : severity.toLowerCase(Locale.ROOT)) { - case "critical" -> 3; - case "error" -> 2; - case "warning" -> 1; - default -> 0; - }; + return RuleEngine.severityRank(severity); } private String formatBytesPerSecond(long value) { if (value < 0) { @@ -2032,7 +2421,154 @@ private List sampleBlockEntityHotspots(MinecraftClient clien .toList(); } + private void updateConflictTracking(SystemMetricsProfiler.Snapshot system) { + boolean slowdownOverlap = FrameTimelineProfiler.getInstance().getLatestFrameNs() / 1_000_000.0 > Math.max(20.0, ConfigManager.getFrameBudgetTargetFrameMs() * 1.25) + || TickProfiler.getInstance().getAverageServerTickNs() / 1_000_000.0 > 20.0 + || FrameTimelineProfiler.getInstance().getStutterScore() > 10.0; + List observations = system.contentionSamples().stream() + .map(sample -> new ConflictObservation( + sample.waiterMod(), + sample.ownerMod(), + sample.lockName(), + sample.waiterThreadName(), + sample.ownerThreadName(), + sample.waiterRole(), + sample.ownerRole(), + sample.blockedTimeDeltaMs(), + sample.waitedTimeDeltaMs(), + slowdownOverlap, + sample.confidence(), + List.copyOf(sample.waiterCandidates()), + List.copyOf(sample.ownerCandidates()) + )) + .toList(); + conflictWindows.addLast(observations); + while (conflictWindows.size() > WINDOW_SIZE) { + conflictWindows.removeFirst(); + } + latestConflictEdges = aggregateConflictEdges(); + } + + private List aggregateConflictEdges() { + record Aggregate( + String waiterMod, + String ownerMod, + String lockName, + String waiterThreadName, + String ownerThreadName, + String waiterRole, + String ownerRole, + long observations, + long slowdownObservations, + long blockedTimeMs, + long waitedTimeMs, + String confidence, + List waiterCandidates, + List ownerCandidates + ) {} + Map aggregates = new LinkedHashMap<>(); + for (List window : conflictWindows) { + for (ConflictObservation observation : window) { + String key = observation.waiterMod() + "|" + observation.ownerMod() + "|" + observation.lockName(); + Aggregate existing = aggregates.get(key); + if (existing == null) { + aggregates.put(key, new Aggregate( + observation.waiterMod(), + observation.ownerMod(), + observation.lockName(), + observation.waiterThreadName(), + observation.ownerThreadName(), + observation.waiterRole(), + observation.ownerRole(), + 1L, + observation.slowdownOverlap() ? 1L : 0L, + observation.blockedTimeDeltaMs(), + observation.waitedTimeDeltaMs(), + observation.confidence(), + observation.waiterCandidates(), + observation.ownerCandidates() + )); + continue; + } + aggregates.put(key, new Aggregate( + existing.waiterMod(), + existing.ownerMod(), + existing.lockName(), + observation.waiterThreadName() == null || observation.waiterThreadName().isBlank() ? existing.waiterThreadName() : observation.waiterThreadName(), + observation.ownerThreadName() == null || observation.ownerThreadName().isBlank() ? existing.ownerThreadName() : observation.ownerThreadName(), + observation.waiterRole() == null || observation.waiterRole().isBlank() ? existing.waiterRole() : observation.waiterRole(), + observation.ownerRole() == null || observation.ownerRole().isBlank() ? existing.ownerRole() : observation.ownerRole(), + existing.observations() + 1L, + existing.slowdownObservations() + (observation.slowdownOverlap() ? 1L : 0L), + existing.blockedTimeMs() + observation.blockedTimeDeltaMs(), + existing.waitedTimeMs() + observation.waitedTimeDeltaMs(), + strongerConfidence(existing.confidence(), observation.confidence()), + existing.waiterCandidates().isEmpty() ? observation.waiterCandidates() : existing.waiterCandidates(), + existing.ownerCandidates().isEmpty() ? observation.ownerCandidates() : existing.ownerCandidates() + )); + } + } + return aggregates.values().stream() + .map(aggregate -> new ConflictEdge( + aggregate.waiterMod(), + aggregate.ownerMod(), + aggregate.lockName(), + aggregate.waiterThreadName(), + aggregate.ownerThreadName(), + aggregate.waiterRole(), + aggregate.ownerRole(), + aggregate.observations(), + aggregate.slowdownObservations(), + aggregate.blockedTimeMs(), + aggregate.waitedTimeMs(), + aggregate.confidence(), + aggregate.waiterCandidates(), + aggregate.ownerCandidates() + )) + .sorted((a, b) -> { + int slowdownCompare = Long.compare(b.slowdownObservations(), a.slowdownObservations()); + if (slowdownCompare != 0) { + return slowdownCompare; + } + int observationCompare = Long.compare(b.observations(), a.observations()); + if (observationCompare != 0) { + return observationCompare; + } + return Long.compare((b.blockedTimeMs() + b.waitedTimeMs()), (a.blockedTimeMs() + a.waitedTimeMs())); + }) + .limit(10) + .toList(); + } + + private String strongerConfidence(String left, String right) { + return confidenceRank(right) > confidenceRank(left) ? right : left; + } + + private int confidenceRank(String confidence) { + if (confidence == null) { + return 0; + } + return switch (confidence.toLowerCase(Locale.ROOT)) { + case "known incompatibility" -> 4; + case "pairwise inferred", "measured" -> 3; + case "inferred" -> 2; + case "weak heuristic" -> 1; + default -> 0; + }; + } + private List buildLockSummaries(SystemMetricsProfiler.Snapshot system) { + if (system.contentionSamples() != null && !system.contentionSamples().isEmpty()) { + return system.contentionSamples().stream() + .limit(5) + .map(sample -> { + String waiter = formatConflictParty(sample.waiterMod(), sample.waiterThreadName(), sample.waiterRole()); + String owner = formatConflictParty(sample.ownerMod(), sample.ownerThreadName(), sample.ownerRole()); + return waiter + " waiting on " + owner + " via " + sample.lockName() + + " (" + sample.confidence() + ", blocked " + sample.blockedTimeDeltaMs() + " ms, waited " + sample.waitedTimeDeltaMs() + " ms)"; + }) + .toList(); + } return system.threadDetailsByName().entrySet().stream() .filter(entry -> entry.getValue().blockedCountDelta() > 0 || entry.getValue().waitedCountDelta() > 0 || "BLOCKED".equals(entry.getValue().state()) || "WAITING".equals(entry.getValue().state())) .limit(5) @@ -2045,32 +2581,19 @@ private List buildLockSummaries(SystemMetricsProfiler.Snapshot system) { .toList(); } + private String formatConflictParty(String modId, String threadName, String role) { + String mod = modId == null || modId.isBlank() ? "unknown" : modId; + String thread = threadName == null || threadName.isBlank() ? "unknown thread" : threadName; + String roleText = role == null || role.isBlank() ? "unknown role" : role; + return mod + " [" + roleText + " | " + thread + "]"; + } + private String classifyEntityHeuristic(String className) { - String lower = className.toLowerCase(Locale.ROOT); - if (lower.contains("villager") || lower.contains("bee") || lower.contains("piglin") || lower.contains("warden") || lower.contains("zombie") || lower.contains("creeper") || lower.contains("animal")) { - return "AI/pathfinding-heavy mob cluster"; - } - if (lower.contains("item") || lower.contains("experience_orb") || lower.contains("projectile") || lower.contains("arrow")) { - return "High transient entity count"; - } - if (lower.contains("boat") || lower.contains("minecart")) { - return "Collision-heavy vehicle cluster"; - } - return "none"; + return RuleEngine.classifyEntityHeuristic(className); } private String classifyBlockEntityHeuristic(String className) { - String lower = className.toLowerCase(Locale.ROOT); - if (lower.contains("hopper") || lower.contains("pipe") || lower.contains("conveyor")) { - return "Inventory transfer / item routing"; - } - if (lower.contains("chest") || lower.contains("storage") || lower.contains("barrel")) { - return "Storage dense chunk"; - } - if (lower.contains("spawner") || lower.contains("beacon") || lower.contains("furnace") || lower.contains("machine")) { - return "Ticking machine / utility block entity"; - } - return "General block entity density"; + return RuleEngine.classifyBlockEntityHeuristic(className); } @@ -2126,7 +2649,7 @@ private List sampleClosestEntities(MinecraftClient client, int limit) { private List> buildRedFlagThresholds() { List> rows = new ArrayList<>(); - rows.add(redFlag("Sync-Lock Latency", "Catches mod conflicts.", "> 10ms")); + rows.add(redFlag("Sync-Lock Latency", "Flags contention windows that can indicate mod interactions or storage stalls.", "> 10ms")); rows.add(redFlag("Draw Call Counter", "Tells you if your base has too many chests/signs.", "> 8,000")); rows.add(redFlag("Lighting Queue", "Detects lag caused by light/shadow recalculations.", "> 500 updates")); rows.add(redFlag("Thread State Ratio", "Shows if your CPU is working or just waiting.", "< 0.5 ratio")); @@ -2142,6 +2665,121 @@ private Map redFlag(String feature, String why, String value) { return row; } + private WorldScanResult sampleWorldData(MinecraftClient client) { + if (client.world == null) { + hotChunkHistory.clear(); + chunkActivityHistory.clear(); + lastWorldScanAtMillis = System.currentTimeMillis(); + lastWorldScanDurationMillis = 0L; + return new WorldScanResult(EntityCounts.empty(), List.of(), List.of(), List.of()); + } + + long now = System.currentTimeMillis(); + long cadenceMillis = CollectorMath.computeAdaptiveWorldScanCadenceMillis(shouldCollectDetailedMetrics(), sessionLogging, isProfilerSelfProtectionActive(), lastWorldScanDurationMillis); + if (lastWorldScanAtMillis > 0L && now - lastWorldScanAtMillis < cadenceMillis && !latestHotChunks.isEmpty()) { + return new WorldScanResult(latestEntityCounts, latestHotChunks, latestEntityHotspots, latestBlockEntityHotspots); + } + lastWorldScanAtMillis = now; + long scanStartedAtMillis = now; + + Map chunkCounts = new LinkedHashMap<>(); + Map> chunkEntityClasses = new LinkedHashMap<>(); + Map> chunkBlockEntityClasses = new LinkedHashMap<>(); + Map globalEntityCounts = new LinkedHashMap<>(); + Map globalBlockEntityCounts = new LinkedHashMap<>(); + int totalEntities = 0; + int livingEntities = 0; + int sampledEntities = 0; + for (Entity entity : client.world.getEntities()) { + totalEntities++; + if (entity instanceof LivingEntity) { + livingEntities++; + } + boolean includeInHotspotScan = sampledEntities < MAX_WORLD_SCAN_ENTITIES + || (totalEntities - MAX_WORLD_SCAN_ENTITIES) % WORLD_SCAN_ENTITY_SAMPLE_STRIDE == 0; + if (!includeInHotspotScan) { + continue; + } + sampledEntities++; + String entityKey = entity.getType().toString(); + globalEntityCounts.merge(entityKey, 1, Integer::sum); + ChunkPos pos = entity.getChunkPos(); + long key = chunkKey(pos.x, pos.z); + chunkCounts.computeIfAbsent(key, ignored -> new int[2])[0]++; + chunkEntityClasses.computeIfAbsent(key, ignored -> new LinkedHashMap<>()).merge(entity.getClass().getSimpleName(), 1, Integer::sum); + } + + int blockEntities = 0; + int sampledBlockEntities = 0; + for (BlockEntity blockEntity : client.world.getBlockEntities()) { + blockEntities++; + boolean includeInHotspotScan = sampledBlockEntities < MAX_WORLD_SCAN_BLOCK_ENTITIES + || (blockEntities - MAX_WORLD_SCAN_BLOCK_ENTITIES) % WORLD_SCAN_BLOCK_ENTITY_SAMPLE_STRIDE == 0; + if (!includeInHotspotScan) { + continue; + } + sampledBlockEntities++; + String blockEntityKey = blockEntity.getClass().getSimpleName(); + globalBlockEntityCounts.merge(blockEntityKey, 1, Integer::sum); + ChunkPos pos = new ChunkPos(blockEntity.getPos()); + long key = chunkKey(pos.x, pos.z); + chunkCounts.computeIfAbsent(key, ignored -> new int[2])[1]++; + chunkBlockEntityClasses.computeIfAbsent(key, ignored -> new LinkedHashMap<>()).merge(blockEntityKey, 1, Integer::sum); + } + + List hotChunks = chunkCounts.entrySet().stream() + .sorted((a, b) -> Integer.compare((b.getValue()[0] + (b.getValue()[1] * 2)), (a.getValue()[0] + (a.getValue()[1] * 2)))) + .limit(8) + .map(entry -> new HotChunkSnapshot( + (int) (entry.getKey() >> 32), + (int) (long) entry.getKey(), + entry.getValue()[0], + entry.getValue()[1], + topClassName(chunkEntityClasses.get(entry.getKey())), + topClassName(chunkBlockEntityClasses.get(entry.getKey())), + entry.getValue()[0] + (entry.getValue()[1] * 2.0) + )) + .toList(); + + hotChunkHistory.addLast(hotChunks); + while (hotChunkHistory.size() > 120) { + hotChunkHistory.removeFirst(); + } + for (HotChunkSnapshot chunk : hotChunks) { + long key = chunkKey(chunk.chunkX(), chunk.chunkZ()); + Deque history = chunkActivityHistory.computeIfAbsent(key, ignored -> new ArrayDeque<>()); + history.addLast(chunk.entityCount() + chunk.blockEntityCount() * 2); + while (history.size() > 120) { + history.removeFirst(); + } + } + if (chunkActivityHistory.size() > 64) { + List keep = hotChunks.stream().map(chunk -> chunkKey(chunk.chunkX(), chunk.chunkZ())).toList(); + chunkActivityHistory.keySet().removeIf(key -> !keep.contains(key)); + } + + List entityHotspots = globalEntityCounts.entrySet().stream() + .sorted((a, b) -> Integer.compare(b.getValue(), a.getValue())) + .limit(8) + .map(entry -> new EntityHotspot(entry.getKey(), entry.getValue(), classifyEntityHeuristic(entry.getKey()))) + .toList(); + + List blockEntityHotspots = globalBlockEntityCounts.entrySet().stream() + .sorted((a, b) -> Integer.compare(b.getValue(), a.getValue())) + .limit(8) + .map(entry -> new BlockEntityHotspot(entry.getKey(), entry.getValue(), classifyBlockEntityHeuristic(entry.getKey()))) + .toList(); + + WorldScanResult result = new WorldScanResult( + new EntityCounts(totalEntities, livingEntities, blockEntities), + hotChunks, + entityHotspots, + blockEntityHotspots + ); + lastWorldScanDurationMillis = Math.max(0L, System.currentTimeMillis() - scanStartedAtMillis); + return result; + } + private EntityCounts sampleEntityCounts(MinecraftClient client) { if (client.world == null) { @@ -2166,6 +2804,12 @@ private ChunkCounts sampleChunkCounts(MinecraftClient client) { return ChunkCounts.empty(); } + long now = System.currentTimeMillis(); + if (lastChunkCountsAtMillis > 0L && now - lastChunkCountsAtMillis < 250L && latestChunkCounts.loadedChunks() > 0) { + return latestChunkCounts; + } + lastChunkCountsAtMillis = now; + String debug = client.worldRenderer.getChunksDebugString(); if (debug == null || debug.isBlank()) { return ChunkCounts.empty(); @@ -2189,17 +2833,20 @@ private Map aggregateCpuWindows() { Map totals = new LinkedHashMap<>(); for (Map window : cpuWindows) { window.forEach((mod, snapshot) -> { - long[] value = totals.computeIfAbsent(mod, ignored -> new long[3]); + long[] value = totals.computeIfAbsent(mod, ignored -> new long[6]); value[0] += snapshot.totalSamples(); value[1] += snapshot.clientSamples(); value[2] += snapshot.renderSamples(); + value[3] += snapshot.totalCpuNanos(); + value[4] += snapshot.clientCpuNanos(); + value[5] += snapshot.renderCpuNanos(); }); } Map result = new LinkedHashMap<>(); totals.entrySet().stream() .sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])) - .forEach(entry -> result.put(entry.getKey(), new CpuSamplingProfiler.Snapshot(entry.getValue()[0], entry.getValue()[1], entry.getValue()[2]))); + .forEach(entry -> result.put(entry.getKey(), new CpuSamplingProfiler.Snapshot(entry.getValue()[0], entry.getValue()[1], entry.getValue()[2], entry.getValue()[3], entry.getValue()[4], entry.getValue()[5]))); return result; } @@ -2264,6 +2911,8 @@ private Map aggregateModWindows() { private Map aggregateRenderWindows() { Map totals = new LinkedHashMap<>(); + Map> likelyOwnerTotals = new LinkedHashMap<>(); + Map> likelyFrameTotals = new LinkedHashMap<>(); for (Map window : renderWindows) { window.forEach((phase, snapshot) -> { long[] value = totals.computeIfAbsent(phase, ignored -> new long[4]); @@ -2271,6 +2920,8 @@ private Map aggregateRenderWindows() value[1] += snapshot.cpuCalls(); value[2] += snapshot.gpuNanos(); value[3] += snapshot.gpuCalls(); + mergeLongMap(likelyOwnerTotals.computeIfAbsent(phase, ignored -> new LinkedHashMap<>()), snapshot.likelyOwners()); + mergeLongMap(likelyFrameTotals.computeIfAbsent(phase, ignored -> new LinkedHashMap<>()), snapshot.likelyFrames()); }); } @@ -2288,7 +2939,9 @@ private Map aggregateRenderWindows() .map(RenderPhaseProfiler.PhaseSnapshot::ownerMod) .filter(owner -> owner != null && !owner.isBlank()) .findFirst() - .orElse("shared/render") + .orElse("shared/render"), + topEntries(likelyOwnerTotals.get(entry.getKey()), 4), + topEntries(likelyFrameTotals.get(entry.getKey()), 4) ))); return result; } @@ -2350,6 +3003,21 @@ private String buildHtmlReport(Map export) { .append(escapeHtml(finding.message())).append(" ").append(escapeHtml(finding.confidence())).append("
").append(escapeHtml(finding.metricSummary())).append("
").append(escapeHtml(finding.details())).append("
Next: ").append(escapeHtml(finding.nextStep())).append(""); } html.append(""); + html.append("

Conflict Findings

    "); + for (ConflictEdge edge : latestConflictEdges) { + html.append("
  • ") + .append(escapeHtml(edge.waiterMod())) + .append(" -> ") + .append(escapeHtml(edge.ownerMod())) + .append(" via ") + .append(escapeHtml(edge.lockName())) + .append(" ") + .append(escapeHtml(edge.confidence())) + .append("
    ") + .append(escapeHtml(String.format(Locale.ROOT, "obs %d | slowdown %d | blocked %d ms | waited %d ms", edge.observations(), edge.slowdownObservations(), edge.blockedTimeMs(), edge.waitedTimeMs()))) + .append("
  • "); + } + html.append("
"); html.append("

Entity Hotspots

    "); for (EntityHotspot hotspot : latestEntityHotspots) { html.append("
  • ").append(escapeHtml(hotspot.className())).append(" x").append(hotspot.count()).append(" - ").append(escapeHtml(hotspot.heuristic())).append("
  • "); @@ -2413,25 +3081,39 @@ private String escapeHtml(String value) { .replace("\"", """); } - private void clearRollingWindows() { + private void clearLiveWindows() { cpuWindows.clear(); cpuDetailWindows.clear(); modWindows.clear(); renderWindows.clear(); + conflictWindows.clear(); spikes.clear(); - sessionHistory.clear(); - hotChunkHistory.clear(); - chunkActivityHistory.clear(); latestHotChunks = List.of(); latestEntityHotspots = List.of(); latestBlockEntityHotspots = List.of(); + latestConflictEdges = List.of(); latestLockSummaries = List.of(); latestRuleFindings = List.of(); stutterJumpSnapshots.clear(); lastStutterScore = 0.0; + latestPerformanceAlert = null; + performanceAlertFlashUntilMillis = 0L; + frameAlertConsecutiveBreaches = 0; + serverAlertConsecutiveBreaches = 0; + lastWorldScanDurationMillis = 0L; + lastChunkCountsAtMillis = 0L; NetworkPacketProfiler.getInstance().reset(); ThreadLoadProfiler.getInstance().reset(); + ChunkWorkProfiler.getInstance().reset(); + EntityCostProfiler.getInstance().reset(); + ShaderCompilationProfiler.getInstance().reset(); lastSeenFrameSequence = 0; + } + + private void clearSessionState() { + sessionHistory.clear(); + hotChunkHistory.clear(); + chunkActivityHistory.clear(); sessionLoggingStartedAtMillis = 0L; sessionLogging = false; sessionRecorded = false; @@ -2439,6 +3121,7 @@ private void clearRollingWindows() { sessionMissedSamples = 0; sessionMaxSampleGapMillis = 0L; sessionExpectedSampleIntervalMillis = 50L; + lastPerformanceAlertAtMillis.clear(); } } diff --git a/src/client/java/wueffi/taskmanager/client/RenderPhaseProfiler.java b/src/client/java/wueffi/taskmanager/client/RenderPhaseProfiler.java index 764c33e..2ddd1cb 100644 --- a/src/client/java/wueffi/taskmanager/client/RenderPhaseProfiler.java +++ b/src/client/java/wueffi/taskmanager/client/RenderPhaseProfiler.java @@ -6,6 +6,8 @@ import java.util.LinkedHashSet; import java.util.Locale; import java.util.Map; +import java.util.ArrayDeque; +import java.util.Deque; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.LongAdder; @@ -20,30 +22,67 @@ private static class Counter { final LongAdder cpuCalls = new LongAdder(); final LongAdder gpuNanos = new LongAdder(); final LongAdder gpuCalls = new LongAdder(); + final Map likelyOwners = new ConcurrentHashMap<>(); + final Map likelyFrames = new ConcurrentHashMap<>(); volatile String ownerMod; } - public record PhaseSnapshot(long cpuNanos, long cpuCalls, long gpuNanos, long gpuCalls, String ownerMod) {} + public record PhaseSnapshot(long cpuNanos, long cpuCalls, long gpuNanos, long gpuCalls, String ownerMod, Map likelyOwners, Map likelyFrames) { + public PhaseSnapshot(long cpuNanos, long cpuCalls, long gpuNanos, long gpuCalls, String ownerMod) { + this(cpuNanos, cpuCalls, gpuNanos, gpuCalls, ownerMod, Map.of(), Map.of()); + } + } private final Map counters = new ConcurrentHashMap<>(); - private final Map cpuStart = new ConcurrentHashMap<>(); + private final Map> cpuScopesByThread = new ConcurrentHashMap<>(); + private final Map> contextOwnersByThread = new ConcurrentHashMap<>(); private volatile Set knownModIds; + private record PhaseScope(String phase, long startedAtNanos) {} + public void beginCpuPhase(String phase) { - ensureCounter(phase); - cpuStart.put(phase, System.nanoTime()); + Counter counter = ensureCounter(phase); + recordContextOwnerHint(counter); + cpuScopesByThread.computeIfAbsent(Thread.currentThread().threadId(), ignored -> new ArrayDeque<>()) + .addLast(new PhaseScope(phase, System.nanoTime())); } public void beginCpuPhase(String phase, String ownerMod) { - ensureCounter(phase, ownerMod); - cpuStart.put(phase, System.nanoTime()); + Counter counter = ensureCounter(phase, ownerMod); + recordContextOwnerHint(counter); + cpuScopesByThread.computeIfAbsent(Thread.currentThread().threadId(), ignored -> new ArrayDeque<>()) + .addLast(new PhaseScope(phase, System.nanoTime())); } public void endCpuPhase(String phase) { - Long start = cpuStart.remove(phase); - if (start == null) return; + Deque scopes = cpuScopesByThread.get(Thread.currentThread().threadId()); + if (scopes == null || scopes.isEmpty()) { + return; + } + + PhaseScope scope = null; + if (phase.equals(scopes.peekLast().phase())) { + scope = scopes.removeLast(); + } else { + Deque skipped = new ArrayDeque<>(); + while (!scopes.isEmpty()) { + PhaseScope candidate = scopes.removeLast(); + if (phase.equals(candidate.phase())) { + scope = candidate; + break; + } + skipped.addFirst(candidate); + } + scopes.addAll(skipped); + } + if (scope == null) { + return; + } + if (scopes.isEmpty()) { + cpuScopesByThread.remove(Thread.currentThread().threadId()); + } - long duration = System.nanoTime() - start; + long duration = System.nanoTime() - scope.startedAtNanos(); Counter counter = ensureCounter(phase); counter.cpuNanos.add(duration); counter.cpuCalls.increment(); @@ -55,10 +94,52 @@ public void recordGpuResult(String phase, long nanoseconds) { counter.gpuCalls.increment(); } + public void recordLikelyOwnerSample(long threadId, String ownerMod, String frameReason) { + if (ownerMod == null || ownerMod.isBlank()) { + return; + } + Deque scopes = cpuScopesByThread.get(threadId); + if (scopes == null || scopes.isEmpty()) { + return; + } + LinkedHashSet activePhases = new LinkedHashSet<>(); + for (PhaseScope scope : scopes) { + activePhases.add(scope.phase()); + } + for (String phase : activePhases) { + Counter counter = ensureCounter(phase); + counter.likelyOwners.computeIfAbsent(ownerMod, ignored -> new LongAdder()).increment(); + if (frameReason != null && !frameReason.isBlank()) { + counter.likelyFrames.computeIfAbsent(frameReason, ignored -> new LongAdder()).increment(); + } + } + } + public void registerPhaseOwner(String phase, String ownerMod) { ensureCounter(phase, ownerMod); } + public void pushContextOwner(String ownerMod) { + String normalizedOwner = normalizeContextOwner(ownerMod); + if (normalizedOwner == null) { + return; + } + contextOwnersByThread.computeIfAbsent(Thread.currentThread().threadId(), ignored -> new ArrayDeque<>()) + .addLast(normalizedOwner); + } + + public void popContextOwner() { + long threadId = Thread.currentThread().threadId(); + Deque owners = contextOwnersByThread.get(threadId); + if (owners == null || owners.isEmpty()) { + return; + } + owners.removeLast(); + if (owners.isEmpty()) { + contextOwnersByThread.remove(threadId); + } + } + public Map getSnapshot() { Map result = new LinkedHashMap<>(); counters.forEach((phase, counter) -> result.put(phase, new PhaseSnapshot( @@ -66,7 +147,9 @@ public Map getSnapshot() { counter.cpuCalls.sum(), counter.gpuNanos.sum(), counter.gpuCalls.sum(), - counter.ownerMod + counter.ownerMod, + snapshotReasonMap(counter.likelyOwners), + snapshotReasonMap(counter.likelyFrames) ))); return result; } @@ -103,7 +186,8 @@ public Map getGpuCalls() { public void reset() { counters.clear(); - cpuStart.clear(); + cpuScopesByThread.clear(); + contextOwnersByThread.clear(); } private Counter ensureCounter(String phase) { @@ -118,6 +202,29 @@ private Counter ensureCounter(String phase, String ownerMod) { return counter; } + private void recordContextOwnerHint(Counter counter) { + String contextOwner = getCurrentContextOwner(); + if (contextOwner == null) { + return; + } + counter.likelyOwners.computeIfAbsent(contextOwner, ignored -> new LongAdder()).increment(); + } + + private String getCurrentContextOwner() { + Deque owners = contextOwnersByThread.get(Thread.currentThread().threadId()); + if (owners == null || owners.isEmpty()) { + return null; + } + return normalizeContextOwner(owners.peekLast()); + } + + private String normalizeContextOwner(String ownerMod) { + if (ownerMod == null || ownerMod.isBlank() || ownerMod.startsWith("shared/") || ownerMod.startsWith("runtime/")) { + return null; + } + return ownerMod; + } + private String normalizeOwnerMod(String ownerMod) { if (ownerMod == null || ownerMod.isBlank()) { return "shared/render"; @@ -133,7 +240,7 @@ private String resolveOwnerMod(String phase) { if (normalized.startsWith("minecraft.") || normalized.startsWith("minecraft:")) { return "minecraft"; } - if (normalized.startsWith("frame.") || normalized.startsWith("gamerenderer.") || normalized.startsWith("worldrenderer.") || normalized.startsWith("sky.")) { + if (normalized.startsWith("frame.")) { return "minecraft"; } int separator = normalized.indexOf(':'); @@ -169,4 +276,16 @@ private Set getKnownModIds() { knownModIds = ids; return ids; } + + private Map snapshotReasonMap(Map source) { + if (source == null || source.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + source.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue().sum(), a.getValue().sum())) + .limit(4) + .forEach(entry -> result.put(entry.getKey(), entry.getValue().sum())); + return result; + } } diff --git a/src/client/java/wueffi/taskmanager/client/RuleEngine.java b/src/client/java/wueffi/taskmanager/client/RuleEngine.java new file mode 100644 index 0000000..7ffe304 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/RuleEngine.java @@ -0,0 +1,306 @@ +package wueffi.taskmanager.client; + +import net.fabricmc.loader.api.FabricLoader; +import wueffi.taskmanager.client.util.ConfigManager; + +import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class RuleEngine { + + List buildJvmTuningAdvisor(MemoryProfiler.Snapshot memory, SystemMetricsProfiler.Snapshot system) { + List advice = new ArrayList<>(); + double heapUsedPct = memory.heapCommittedBytes() > 0 ? memory.heapUsedBytes() * 100.0 / memory.heapCommittedBytes() : 0.0; + double directPct = memory.directMemoryMaxBytes() > 0 ? (memory.directBufferBytes() + memory.mappedBufferBytes()) * 100.0 / memory.directMemoryMaxBytes() : -1.0; + List args = ManagementFactory.getRuntimeMXBean().getInputArguments(); + String xmx = firstJvmArgValue(args, "-Xmx"); + String xms = firstJvmArgValue(args, "-Xms"); + + if (heapUsedPct >= 85.0) { + advice.add("Heap is running hot at " + String.format(Locale.ROOT, "%.0f%%", heapUsedPct) + " of committed memory. Raise -Xmx only if this pressure is sustained and GC pauses are appearing."); + } + if (memory.gcPauseDurationMs() >= 75L || memory.oldGcCount() > 0L) { + advice.add("Recent GC pressure is visible (" + memory.gcType() + " " + memory.gcPauseDurationMs() + " ms). Prefer moderate heap sizing over very large -Xmx values so collections stay cheaper."); + } + if (directPct >= 80.0) { + advice.add("Direct/off-heap buffers are near their cap at " + String.format(Locale.ROOT, "%.0f%%", directPct) + ". Check shaders, Sodium/Iris, and high-resolution packs before tweaking heap flags."); + } + if (xmx == null) { + advice.add("No explicit -Xmx flag was detected. Automatic heap sizing is usually fine unless you can prove the JVM is under-allocating for this modpack."); + } else if (xms != null && xms.equalsIgnoreCase(xmx)) { + advice.add("Xms matches Xmx (" + xmx + "). Keeping the whole heap committed from startup is not always helpful; reduce Xms if startup memory commit is a concern."); + } + if (system.vramPagingActive() && FabricLoader.getInstance().isModLoaded("iris")) { + advice.add("VRAM paging is active with Iris/shaders enabled. Reduce shader or texture load before reaching for JVM-only tuning."); + } + if (advice.isEmpty()) { + advice.add("No obvious JVM tuning red flags were detected in the current window. Keep defaults or small, measured changes unless a repeatable bottleneck says otherwise."); + } + return advice.stream().limit(4).toList(); + } + + List buildRuleFindings(ProfilerManager manager, MemoryProfiler.Snapshot memorySnapshot) { + List findings = new ArrayList<>(); + SystemMetricsProfiler.Snapshot system = SystemMetricsProfiler.getInstance().getSnapshot(); + double latestFrameMs = FrameTimelineProfiler.getInstance().getLatestFrameNs() / 1_000_000.0; + double serverTickMs = TickProfiler.getInstance().getAverageServerTickNs() / 1_000_000.0; + double clientTickMs = TickProfiler.getInstance().getAverageClientTickNs() / 1_000_000.0; + double stutterScore = FrameTimelineProfiler.getInstance().getStutterScore(); + double heapUsedPct = memorySnapshot.heapCommittedBytes() > 0 + ? memorySnapshot.heapUsedBytes() * 100.0 / memorySnapshot.heapCommittedBytes() + : 0.0; + + if (system.gpuCoreLoadPercent() > 90.0 && latestFrameMs > ConfigManager.getFrameBudgetTargetFrameMs() && serverTickMs < 15.0) { + findings.add(new ProfilerManager.RuleFinding(latestFrameMs > 33.0 && system.gpuCoreLoadPercent() > 97.0 ? "critical" : "warning", "gpu", "GPU appears saturated while logic stays healthy.", "measured", + "The render path is spending time on the GPU while server-side logic remains within budget.", + "Check heavy shader packs, high-resolution effects, and the GPU tab's hottest render phases.", + String.format(Locale.ROOT, "GPU %.0f%% | frame %.1f ms | server %.1f ms", system.gpuCoreLoadPercent(), latestFrameMs, serverTickMs))); + } + if (serverTickMs > 40.0) { + findings.add(new ProfilerManager.RuleFinding(serverTickMs > 80.0 ? "critical" : "warning", "logic", String.format(Locale.ROOT, "Server tick is elevated at %.1f ms.", serverTickMs), "measured", + "Integrated-server work is exceeding a comfortable frame budget and will usually show up as simulation hitching.", + "Inspect the World tab for hot chunks, block entities, and thread wait activity around the same window.", + String.format(Locale.ROOT, "server %.1f ms | client %.1f ms | stutter %.1f", serverTickMs, clientTickMs, stutterScore))); + } + if (system.diskWriteBytesPerSecond() > 8L * 1024L * 1024L && latestFrameMs > 50.0) { + findings.add(new ProfilerManager.RuleFinding(system.diskWriteBytesPerSecond() > 24L * 1024L * 1024L ? "critical" : "warning", "io", "Heavy disk writes overlap with a bad frame spike.", "measured", + "High write throughput is coinciding with a visible hitch and may point to saves, chunk flushes, or logging bursts.", + "Check the Disk tab and world activity for saves, region writes, or mods with frequent persistence.", + String.format(Locale.ROOT, "writes %s | frame %.1f ms", formatBytesPerSecond(system.diskWriteBytesPerSecond()), latestFrameMs))); + } + for (ProfilerManager.SaveEvent saveEvent : manager.getRecentSaves()) { + if (saveEvent.durationMs() <= 100L) { + continue; + } + findings.add(new ProfilerManager.RuleFinding(saveEvent.durationMs() >= 500L ? "critical" : "warning", "save-stall", + saveEvent.type() + " took " + saveEvent.durationMs() + " ms.", + "measured", + "A world save completed recently with a duration long enough to overlap noticeable stutter or MSPT spikes.", + "Check save cadence, world-storage mods, and disk throughput when this repeats.", + String.format(Locale.ROOT, "%s | %d ms", saveEvent.type(), saveEvent.durationMs()))); + break; + } + for (ProfilerManager.ConflictEdge edge : manager.getLatestConflictEdges()) { + boolean concretePair = isConcreteMod(edge.waiterMod()) && isConcreteMod(edge.ownerMod()) && !edge.waiterMod().equals(edge.ownerMod()); + long totalWaitMs = edge.blockedTimeMs() + edge.waitedTimeMs(); + if (concretePair && edge.slowdownObservations() > 0L) { + boolean repeated = edge.observations() >= 3L || edge.slowdownObservations() >= 2L; + findings.add(new ProfilerManager.RuleFinding( + repeated ? "warning" : "info", + repeated ? "conflict-repeated" : "conflict-confirmed", + edge.waiterMod() + " is repeatedly waiting on " + edge.ownerMod() + " through " + edge.lockName() + ".", + edge.confidence(), + "Observed pairwise lock contention links the waiting and owning threads to different mods in the same slowdown window.", + "Open the Threads and System tabs, inspect " + edge.waiterThreadName() + " vs " + edge.ownerThreadName() + ", and check whether one mod can reduce async world/storage overlap.", + String.format(Locale.ROOT, "obs %d | slowdown %d | blocked %d ms | waited %d ms", edge.observations(), edge.slowdownObservations(), edge.blockedTimeMs(), edge.waitedTimeMs()))); + continue; + } + if (totalWaitMs > 0L) { + findings.add(new ProfilerManager.RuleFinding( + "info", + "conflict-weak", + "Contention hints involve " + edge.waiterMod() + " and " + edge.ownerMod() + " around " + edge.lockName() + ".", + "weak heuristic", + "The lock wait is real, but one or both mod owners are still low-confidence candidates rather than a clean mod-to-mod match.", + "Use the thread detail panel to review alternate owner candidates before treating this as a confirmed incompatibility.", + String.format(Locale.ROOT, "obs %d | blocked %d ms | waited %d ms", edge.observations(), edge.blockedTimeMs(), edge.waitedTimeMs()))); + } + } + if (system.activeHighLoadThreads() > Math.max(1, system.estimatedPhysicalCores() / 2) && stutterScore > 10.0) { + findings.add(new ProfilerManager.RuleFinding(system.activeHighLoadThreads() > Math.max(2, system.estimatedPhysicalCores()) ? "critical" : "warning", "threads", "Thread overscheduling warning: too many high-load threads are active for the estimated physical core budget.", "weak heuristic", + "Multiple hot threads are competing for a limited physical-core budget during a stutter window.", + "Inspect the System tab's top threads and worker activity to see whether chunk builders or async workers are crowding the CPU.", + String.format(Locale.ROOT, "high-load threads %d | est. physical cores %d | stutter %.1f", system.activeHighLoadThreads(), system.estimatedPhysicalCores(), stutterScore))); + } + if (!manager.getLatestHotChunks().isEmpty() && serverTickMs > 20.0) { + ProfilerManager.HotChunkSnapshot hot = manager.getLatestHotChunks().getFirst(); + findings.add(new ProfilerManager.RuleFinding("info", "chunks", String.format(Locale.ROOT, "Hot chunk %d,%d has %d entities and %d block entities.", hot.chunkX(), hot.chunkZ(), hot.entityCount(), hot.blockEntityCount()), "measured", + "A single chunk is standing out in the current window and may be central to the slowdown.", + "Select the chunk in the World tab and inspect entity density, block entities, and thread load together.", + String.format(Locale.ROOT, "activity %.1f | entities %d | block entities %d", hot.activityScore(), hot.entityCount(), hot.blockEntityCount()))); + } + if (!manager.getLatestEntityHotspots().isEmpty()) { + ProfilerManager.EntityHotspot hotspot = manager.getLatestEntityHotspots().getFirst(); + if (!"none".equals(hotspot.heuristic())) { + findings.add(new ProfilerManager.RuleFinding(hotspot.count() >= 100 ? "critical" : "warning", "entities", hotspot.className() + " is dominating recent entity cost signals: " + hotspot.heuristic(), "inferred", + "Recent world samples point to one entity family as the strongest source of per-chunk entity pressure.", + "Inspect mob AI density, farms, and clustered spawns in the World tab near the hot chunk.", + String.format(Locale.ROOT, "%s x%d", hotspot.className(), hotspot.count()))); + } + } + if (!manager.getLatestBlockEntityHotspots().isEmpty()) { + ProfilerManager.BlockEntityHotspot hotspot = manager.getLatestBlockEntityHotspots().getFirst(); + if (hotspot.count() >= 20) { + findings.add(new ProfilerManager.RuleFinding(hotspot.count() >= 60 ? "critical" : "warning", "block-entities", hotspot.className() + " is dense across loaded chunks and may be ticking heavily.", "inferred", + "A block entity class is showing up frequently enough to plausibly drive ticking or storage pressure.", + "Open the Block Entities mini-tab and inspect the selected chunk plus the global hotspot list.", + String.format(Locale.ROOT, "%s x%d | %s", hotspot.className(), hotspot.count(), hotspot.heuristic()))); + } + } + if (manager.getLatestConflictEdges().isEmpty() && !manager.getLatestLockSummaries().isEmpty()) { + findings.add(new ProfilerManager.RuleFinding("info", "locks", manager.getLatestLockSummaries().getFirst(), "measured", + "A thread spent time blocked or waiting in the current window.", + "Use the System tab to check the owning thread and see whether the wait lines up with chunk IO or background workers.", + manager.getLatestLockSummaries().getFirst())); + boolean chunkIoLock = manager.getLatestLockSummaries().stream() + .map(summary -> summary.toLowerCase(Locale.ROOT)) + .anyMatch(summary -> summary.contains("region") || summary.contains("chunk") || summary.contains("poi") || summary.contains("anvil") || summary.contains("storage")); + if (chunkIoLock && (serverTickMs > 20.0 || latestFrameMs > 25.0)) { + findings.add(new ProfilerManager.RuleFinding((serverTickMs > 50.0 || latestFrameMs > 40.0) ? "critical" : "warning", "chunk-io", "Threads are waiting on chunk or region style locks during a slow window.", "weak heuristic", + "The lock names look chunk-storage related and overlap with a visible slowdown.", + "Check async chunk mods, world storage activity, and the Disk tab for matching spikes.", + String.format(Locale.ROOT, "server %.1f ms | frame %.1f ms | lock count %d", serverTickMs, latestFrameMs, manager.getLatestLockSummaries().size()))); + } + } + if (system.bytesReceivedPerSecond() > 512L * 1024L && latestFrameMs > 20.0) { + findings.add(new ProfilerManager.RuleFinding("info", "network", "A network burst overlaps with a slower frame window.", "measured", + "Inbound traffic is elevated enough to plausibly disturb the client if packet handling or chunk delivery is busy.", + "Inspect the Network tab's packet types and recent spike bookmarks.", + String.format(Locale.ROOT, "inbound %s | packet latency %.1f ms", formatBytesPerSecond(system.bytesReceivedPerSecond()), system.packetProcessingLatencyMs()))); + } + if ((system.chunksGenerating() > 0 || system.chunksMeshing() > 0 || system.chunksUploading() > 0) && (latestFrameMs > 20.0 || serverTickMs > 20.0)) { + findings.add(new ProfilerManager.RuleFinding("info", "chunk-pipeline", "Chunk generation, meshing, or upload work is active during the current slow window.", "measured", + "World streaming work is non-idle and may be contributing to a hitch, especially while moving quickly or exploring new terrain.", + "Check the World tab and render metrics for generation, meshing, upload, and lighting pressure.", + String.format(Locale.ROOT, "gen %d | mesh %d | upload %d | lights %d", system.chunksGenerating(), system.chunksMeshing(), system.chunksUploading(), system.lightsUpdatePending()))); + } + if (heapUsedPct > 85.0) { + findings.add(new ProfilerManager.RuleFinding("info", "memory", "Heap usage is high relative to committed memory.", "measured", + "Live heap usage is near the current committed ceiling, which can increase GC pressure or mask leaks.", + "Inspect the Memory tab for dominant mods and shared JVM buckets, especially if GC pauses are appearing too.", + String.format(Locale.ROOT, "heap %.0f%% | used %s", heapUsedPct, formatBytesMb(memorySnapshot.heapUsedBytes())))); + } + if (memorySnapshot.gcPauseDurationMs() > 0) { + findings.add(new ProfilerManager.RuleFinding("info", "gc", "Recent GC pause detected: " + memorySnapshot.gcType() + " " + memorySnapshot.gcPauseDurationMs() + " ms.", "measured", + "A garbage-collection pause occurred recently and may explain a hitch if it aligns with frame or tick spikes.", + "Correlate the pause with frame-time spikes and high heap usage in the Timeline and Memory tabs.", + String.format(Locale.ROOT, "%s | pause %d ms", memorySnapshot.gcType(), memorySnapshot.gcPauseDurationMs()))); + if (latestFrameMs > ConfigManager.getFrameBudgetTargetFrameMs()) { + findings.add(new ProfilerManager.RuleFinding(memorySnapshot.gcPauseDurationMs() >= 75L ? "warning" : "info", "gc-stutter", "A GC pause overlaps with a slow frame window.", "measured", + "Frame time is currently above budget and the JVM reported a recent collection pause in the same sampling window.", + "Treat this as a likely stutter source before blaming raw mod CPU alone; check heap pressure and allocation-heavy mods.", + String.format(Locale.ROOT, "frame %.1f ms | %s %d ms", latestFrameMs, memorySnapshot.gcType(), memorySnapshot.gcPauseDurationMs()))); + } + } + ShaderCompilationProfiler.CompileEvent latestShaderCompile = ShaderCompilationProfiler.getInstance().getLatestEvent(); + if (latestShaderCompile != null + && ShaderCompilationProfiler.getInstance().hasRecentCompilation(1_500L) + && latestFrameMs > Math.max(25.0, ConfigManager.getFrameBudgetTargetFrameMs() * 1.5)) { + findings.add(new ProfilerManager.RuleFinding(latestShaderCompile.durationNs() >= 25_000_000L ? "warning" : "info", "shader-compile", + "Shader compilation lined up with a frame spike: " + latestShaderCompile.label() + ".", + "measured", + "A recent shader/program creation completed close to the current slow frame, which is a common cause of one-off freezes.", + "Check shader toggles, resource reloads, or first-use render paths if this repeats.", + String.format(Locale.ROOT, "frame %.1f ms | shader %.2f ms", latestFrameMs, latestShaderCompile.durationNs() / 1_000_000.0))); + } + EntityCostProfiler.Snapshot entityCosts = EntityCostProfiler.getInstance().getSnapshot(); + if (!entityCosts.tickNanosByType().isEmpty()) { + Map.Entry topTickEntity = entityCosts.tickNanosByType().entrySet().iterator().next(); + long calls = entityCosts.tickCallsByType().getOrDefault(topTickEntity.getKey(), 0L); + if (topTickEntity.getValue() >= 5_000_000L) { + findings.add(new ProfilerManager.RuleFinding(topTickEntity.getValue() >= 20_000_000L ? "warning" : "info", "entity-cost", + topTickEntity.getKey() + " is consuming measurable entity tick time in the current window.", + "measured", + "Per-entity-type timing shows one entity family standing out beyond simple count-based hotspots.", + "Inspect mob farms, villager halls, or custom AI-heavy entities before assuming the whole world is equally expensive.", + String.format(Locale.ROOT, "%s | %.2f ms | %d calls", topTickEntity.getKey(), topTickEntity.getValue() / 1_000_000.0, calls))); + } + } + ChunkWorkProfiler.Snapshot chunkWork = ChunkWorkProfiler.getInstance().getSnapshot(); + if (!chunkWork.durationNanosByLabel().isEmpty() && (serverTickMs > 20.0 || latestFrameMs > 20.0)) { + Map.Entry topChunkPhase = chunkWork.durationNanosByLabel().entrySet().iterator().next(); + findings.add(new ProfilerManager.RuleFinding(topChunkPhase.getValue() >= 25_000_000L ? "warning" : "info", "chunk-work", + "Chunk loading or generation work is visible in the current slow window: " + topChunkPhase.getKey() + ".", + "measured", + "Direct chunk-phase timing captured synchronous chunk work instead of relying only on thread-name heuristics.", + "Use the World tab's chunk cost section to see whether generation or main-thread chunk loads dominate.", + String.format(Locale.ROOT, "%s | %.2f ms | %d calls", topChunkPhase.getKey(), topChunkPhase.getValue() / 1_000_000.0, chunkWork.callsByLabel().getOrDefault(topChunkPhase.getKey(), 0L)))); + } + if (system.cpuTemperatureC() < 0 && system.gpuTemperatureC() < 0) { + findings.add(new ProfilerManager.RuleFinding("info", "sensors", "Temperature sensors are unavailable on this machine/provider combination; falling back to load-only telemetry.", "unavailable", + "The profiler can still report utilization, but package/core temperatures are not currently exposed by any detected provider.", + "Open the System tab's Sensors panel to see provider attempts and the last bridge error.", + system.cpuTemperatureUnavailableReason())); + } else if (system.cpuTemperatureC() >= 85.0 || system.gpuTemperatureC() >= 85.0) { + findings.add(new ProfilerManager.RuleFinding((system.cpuTemperatureC() >= 92.0 || system.gpuTemperatureC() >= 90.0) ? "critical" : "warning", "thermals", "A CPU or GPU temperature is entering a throttling-prone range.", "measured", + "Sustained temperatures in the mid-80s or higher can cause clocks to drop and make spikes harder to explain from software alone.", + "Check cooling, fan curves, and whether the slowdown lines up with a thermal ramp in exported sessions.", + String.format(Locale.ROOT, "CPU %s | GPU %s", system.cpuTemperatureC() >= 0 ? String.format(Locale.ROOT, "%.1f C", system.cpuTemperatureC()) : "N/A", system.gpuTemperatureC() >= 0 ? String.format(Locale.ROOT, "%.1f C", system.gpuTemperatureC()) : "N/A"))); + } + findings.sort((a, b) -> Integer.compare(severityRank(b.severity()), severityRank(a.severity()))); + return findings; + } + + static int severityRank(String severity) { + return switch (severity == null ? "info" : severity.toLowerCase(Locale.ROOT)) { + case "critical" -> 3; + case "error" -> 2; + case "warning" -> 1; + default -> 0; + }; + } + + static String classifyEntityHeuristic(String className) { + String lower = className.toLowerCase(Locale.ROOT); + if (lower.contains("villager") || lower.contains("bee") || lower.contains("piglin") || lower.contains("warden") || lower.contains("zombie") || lower.contains("creeper") || lower.contains("animal")) { + return "AI/pathfinding-heavy mob cluster"; + } + if (lower.contains("item") || lower.contains("experience_orb") || lower.contains("projectile") || lower.contains("arrow")) { + return "High transient entity count"; + } + if (lower.contains("boat") || lower.contains("minecart")) { + return "Collision-heavy vehicle cluster"; + } + return "none"; + } + + static String classifyBlockEntityHeuristic(String className) { + String lower = className.toLowerCase(Locale.ROOT); + if (lower.contains("hopper") || lower.contains("pipe") || lower.contains("conveyor")) { + return "Inventory transfer / item routing"; + } + if (lower.contains("chest") || lower.contains("storage") || lower.contains("barrel")) { + return "Storage dense chunk"; + } + if (lower.contains("spawner") || lower.contains("beacon") || lower.contains("furnace") || lower.contains("machine")) { + return "Ticking machine / utility block entity"; + } + return "General block entity density"; + } + + private String firstJvmArgValue(List args, String prefix) { + for (String arg : args) { + if (arg != null && arg.startsWith(prefix)) { + return arg.substring(prefix.length()); + } + } + return null; + } + + private static String formatBytesPerSecond(long value) { + if (value < 0) { + return "N/A"; + } + if (value >= 1024L * 1024L) { + return String.format(Locale.ROOT, "%.2f MB/s", value / (1024.0 * 1024.0)); + } + if (value >= 1024L) { + return String.format(Locale.ROOT, "%.1f KB/s", value / 1024.0); + } + return value + " B/s"; + } + + private static String formatBytesMb(long bytes) { + if (bytes < 0) { + return "N/A"; + } + return String.format(Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0)); + } + + private boolean isConcreteMod(String modId) { + return modId != null && !modId.isBlank() && !modId.startsWith("shared/") && !modId.startsWith("runtime/"); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/SearchState.java b/src/client/java/wueffi/taskmanager/client/SearchState.java new file mode 100644 index 0000000..5e15f23 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/SearchState.java @@ -0,0 +1,28 @@ +package wueffi.taskmanager.client; + +import java.util.Locale; + +final class SearchState { + + private SearchState() { + } + + static boolean matchesCombinedSearch(String haystack, String globalQuery, String localQuery) { + String normalizedHaystack = haystack == null ? "" : haystack.toLowerCase(Locale.ROOT); + return matchesQuery(normalizedHaystack, globalQuery) && matchesQuery(normalizedHaystack, localQuery); + } + + static boolean matchesQuery(String haystack, String query) { + if (query == null || query.isBlank()) { + return true; + } + String normalizedHaystack = haystack == null ? "" : haystack.toLowerCase(Locale.ROOT); + String[] parts = query.toLowerCase(Locale.ROOT).trim().split("\\s+"); + for (String part : parts) { + if (!part.isBlank() && !normalizedHaystack.contains(part)) { + return false; + } + } + return true; + } +} diff --git a/src/client/java/wueffi/taskmanager/client/SessionExporter.java b/src/client/java/wueffi/taskmanager/client/SessionExporter.java new file mode 100644 index 0000000..a337fad --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/SessionExporter.java @@ -0,0 +1,241 @@ +package wueffi.taskmanager.client; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +final class SessionExporter { + + private static final long EXPORT_TIMEOUT_MILLIS = 30_000L; + private static final Gson GSON = new GsonBuilder().serializeSpecialFloatingPointValues().setPrettyPrinting().create(); + + private final AtomicBoolean exportInFlight = new AtomicBoolean(false); + private volatile ExecutorService exportExecutor = createExecutor(); + private volatile Future exportFuture; + private volatile long exportStartedAtMillis; + + String exportSession(ProfilerManager manager) { + long now = System.currentTimeMillis(); + if (!exportInFlight.compareAndSet(false, true)) { + if (isTimedOut(now)) { + cancelHungExport(); + } else { + return manager.lastExportStatus(); + } + } + + manager.beginExport(); + MinecraftClient client = MinecraftClient.getInstance(); + exportStartedAtMillis = now; + exportFuture = exportExecutor.submit(() -> { + ProfilerManager.ExportResult result = manager.runSessionExport(); + if (client != null) { + client.execute(() -> manager.notifyExportFinished(client, result)); + } + manager.finishExport(result); + exportInFlight.set(false); + exportStartedAtMillis = 0L; + }); + manager.requestSnapshotPublish(); + return manager.lastExportStatus(); + } + + ProfilerManager.ExportResult runSessionExport(ProfilerManager manager) { + Map export = manager.buildSessionExportPayload(); + Path dir = sessionDirectory(); + try { + Files.createDirectories(dir); + long exportTimestamp = System.currentTimeMillis(); + Path file = dir.resolve("taskmanager-session-" + exportTimestamp + ".json"); + Files.writeString(file, manager.exportJson(export)); + Path htmlFile = dir.resolve("taskmanager-session-" + exportTimestamp + ".html"); + Files.writeString(htmlFile, manager.buildSessionHtmlReport(export)); + Path traceFile = dir.resolve("taskmanager-session-" + exportTimestamp + ".trace.json"); + Files.writeString(traceFile, manager.buildChromeTraceJson()); + manager.logSessionExport(file); + return new ProfilerManager.ExportResult("Exported " + file.getFileName() + " + " + htmlFile.getFileName() + " + " + traceFile.getFileName(), dir, htmlFile, traceFile); + } catch (Exception e) { + return new ProfilerManager.ExportResult("Export failed: " + e.getMessage(), null, null, null); + } + } + + void saveBaseline(ProfilerManager.SessionBaseline baseline) { + Path file = baselineFile(); + try { + Files.createDirectories(file.getParent()); + Files.writeString(file, GSON.toJson(baseline)); + } catch (IOException ignored) { + } + } + + ProfilerManager.SessionBaseline loadBaseline() { + Path file = baselineFile(); + if (!Files.exists(file)) { + return null; + } + try { + return GSON.fromJson(Files.readString(file), ProfilerManager.SessionBaseline.class); + } catch (Exception ignored) { + return null; + } + } + + void clearBaseline() { + try { + Files.deleteIfExists(baselineFile()); + } catch (IOException ignored) { + } + } + + ProfilerManager.SessionBaseline importLatestSessionBaseline() { + try { + Path dir = sessionDirectory(); + if (!Files.isDirectory(dir)) { + return null; + } + return Files.list(dir) + .filter(path -> path.getFileName().toString().startsWith("taskmanager-session-")) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .filter(path -> !path.getFileName().toString().endsWith(".trace.json")) + .max(Comparator.comparing(Path::getFileName)) + .map(this::importSession) + .orElse(null); + } catch (IOException ignored) { + return null; + } + } + + ProfilerManager.SessionBaseline importSession(Path path) { + if (path == null || !Files.exists(path)) { + return null; + } + try { + JsonObject root = JsonParser.parseString(Files.readString(path)).getAsJsonObject(); + JsonElement baselineElement = root.get("baseline"); + if (baselineElement != null && baselineElement.isJsonObject()) { + ProfilerManager.SessionBaseline baseline = GSON.fromJson(baselineElement, ProfilerManager.SessionBaseline.class); + if (baseline != null) { + return baseline; + } + } + JsonElement sessionPointsElement = root.get("sessionPoints"); + if (sessionPointsElement == null || !sessionPointsElement.isJsonArray() || sessionPointsElement.getAsJsonArray().isEmpty()) { + return null; + } + double avgFps = 0.0; + double avgOnePercentLow = 0.0; + double avgMspt = 0.0; + double avgMsptP95 = 0.0; + double avgHeapBytes = 0.0; + int count = 0; + Map cpuTotals = new LinkedHashMap<>(); + Map gpuTotals = new LinkedHashMap<>(); + Map memoryTotals = new LinkedHashMap<>(); + Map cpuCounts = new LinkedHashMap<>(); + Map gpuCounts = new LinkedHashMap<>(); + Map memoryCounts = new LinkedHashMap<>(); + for (JsonElement element : sessionPointsElement.getAsJsonArray()) { + if (!element.isJsonObject()) { + continue; + } + JsonObject point = element.getAsJsonObject(); + avgFps += getDouble(point, "averageFps"); + avgOnePercentLow += getDouble(point, "onePercentLowFps"); + avgMspt += getDouble(point, "msptAvg"); + avgMsptP95 += getDouble(point, "msptP95"); + avgHeapBytes += getDouble(point, "heapUsedBytes"); + mergeAverageMap(cpuTotals, cpuCounts, point.getAsJsonObject("cpuEffectivePercentByMod")); + mergeAverageMap(gpuTotals, gpuCounts, point.getAsJsonObject("gpuEffectivePercentByMod")); + mergeAverageMap(memoryTotals, memoryCounts, point.getAsJsonObject("memoryEffectiveMbByMod")); + count++; + } + if (count == 0) { + return null; + } + String label = path.getFileName().toString().replace(".json", ""); + return new ProfilerManager.SessionBaseline( + avgFps / count, + avgOnePercentLow / count, + avgMspt / count, + avgMsptP95 / count, + Math.round(avgHeapBytes / count), + finalizeAverageMap(cpuTotals, cpuCounts), + finalizeAverageMap(gpuTotals, gpuCounts), + finalizeAverageMap(memoryTotals, memoryCounts), + Files.getLastModifiedTime(path).toMillis(), + label + ); + } catch (Exception ignored) { + return null; + } + } + + private boolean isTimedOut(long now) { + return exportStartedAtMillis > 0L && now - exportStartedAtMillis > EXPORT_TIMEOUT_MILLIS; + } + + private void cancelHungExport() { + Future future = exportFuture; + if (future != null) { + future.cancel(true); + } + exportExecutor.shutdownNow(); + exportExecutor = createExecutor(); + exportFuture = null; + exportStartedAtMillis = 0L; + exportInFlight.set(false); + } + + private static ExecutorService createExecutor() { + return Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "taskmanager-session-export"); + thread.setDaemon(true); + return thread; + }); + } + + private static Path sessionDirectory() { + return FabricLoader.getInstance().getGameDir().resolve("taskmanager-sessions"); + } + + private static Path baselineFile() { + return sessionDirectory().resolve("baseline.json"); + } + + private static double getDouble(JsonObject object, String key) { + JsonElement element = object.get(key); + return element == null || !element.isJsonPrimitive() ? 0.0 : element.getAsDouble(); + } + + private static void mergeAverageMap(Map totals, Map counts, JsonObject values) { + if (values == null) { + return; + } + values.entrySet().forEach(entry -> { + double value = entry.getValue() == null || !entry.getValue().isJsonPrimitive() ? 0.0 : entry.getValue().getAsDouble(); + totals.merge(entry.getKey(), value, Double::sum); + counts.merge(entry.getKey(), 1, Integer::sum); + }); + } + + private static Map finalizeAverageMap(Map totals, Map counts) { + Map averages = new LinkedHashMap<>(); + totals.forEach((key, total) -> averages.put(key, total / Math.max(1, counts.getOrDefault(key, 1)))); + return averages; + } +} diff --git a/src/client/java/wueffi/taskmanager/client/ShaderCompilationProfiler.java b/src/client/java/wueffi/taskmanager/client/ShaderCompilationProfiler.java new file mode 100644 index 0000000..7ae38b8 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/ShaderCompilationProfiler.java @@ -0,0 +1,117 @@ +package wueffi.taskmanager.client; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +public final class ShaderCompilationProfiler { + + private static final ShaderCompilationProfiler INSTANCE = new ShaderCompilationProfiler(); + private static final int MAX_RECENT_EVENTS = 32; + private static final int MAX_TOP_LABELS = 8; + + private final Map durationNanosByLabel = new ConcurrentHashMap<>(); + private final Map compileCountByLabel = new ConcurrentHashMap<>(); + private final ConcurrentLinkedDeque recentEvents = new ConcurrentLinkedDeque<>(); + private final ThreadLocal> contexts = ThreadLocal.withInitial(ArrayDeque::new); + private final AtomicLong lastCompletedAtMillis = new AtomicLong(0L); + + public static ShaderCompilationProfiler getInstance() { + return INSTANCE; + } + + private ShaderCompilationProfiler() { + } + + public void beginCompile(String label) { + contexts.get().push(new CompileContext(System.nanoTime(), sanitizeLabel(label))); + } + + public void endCompile() { + Deque stack = contexts.get(); + if (stack.isEmpty()) { + return; + } + CompileContext context = stack.pop(); + long durationNs = Math.max(0L, System.nanoTime() - context.startedAtNs()); + durationNanosByLabel.computeIfAbsent(context.label(), ignored -> new LongAdder()).add(durationNs); + compileCountByLabel.computeIfAbsent(context.label(), ignored -> new LongAdder()).increment(); + long completedAtMillis = System.currentTimeMillis(); + recentEvents.addFirst(new CompileEvent(completedAtMillis, context.label(), durationNs)); + while (recentEvents.size() > MAX_RECENT_EVENTS) { + recentEvents.pollLast(); + } + lastCompletedAtMillis.set(completedAtMillis); + } + + public Snapshot getSnapshot() { + return new Snapshot( + topEntries(durationNanosByLabel), + topEntries(compileCountByLabel), + List.copyOf(recentEvents), + getLastSampleAgeMillis() + ); + } + + public boolean hasRecentCompilation(long withinMillis) { + return getLastSampleAgeMillis() <= withinMillis; + } + + public CompileEvent getLatestEvent() { + return recentEvents.peekFirst(); + } + + public void reset() { + durationNanosByLabel.clear(); + compileCountByLabel.clear(); + recentEvents.clear(); + lastCompletedAtMillis.set(0L); + contexts.remove(); + } + + private long getLastSampleAgeMillis() { + long lastCompleted = lastCompletedAtMillis.get(); + if (lastCompleted == 0L) { + return Long.MAX_VALUE; + } + return Math.max(0L, System.currentTimeMillis() - lastCompleted); + } + + private static Map topEntries(Map source) { + LinkedHashMap snapshot = new LinkedHashMap<>(); + source.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), entry.getValue().sum())) + .filter(entry -> entry.getValue() > 0L) + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(MAX_TOP_LABELS) + .forEach(entry -> snapshot.put(entry.getKey(), entry.getValue())); + return snapshot; + } + + private static String sanitizeLabel(String label) { + if (label == null || label.isBlank()) { + return "unnamed-shader"; + } + return label; + } + + private record CompileContext(long startedAtNs, String label) { + } + + public record CompileEvent(long completedAtEpochMillis, String label, long durationNs) { + } + + public record Snapshot( + Map durationNanosByLabel, + Map compileCountByLabel, + List recentEvents, + long sampleAgeMillis + ) { + } +} diff --git a/src/client/java/wueffi/taskmanager/client/SystemMetricsProfiler.java b/src/client/java/wueffi/taskmanager/client/SystemMetricsProfiler.java index b6642a6..1d74da5 100644 --- a/src/client/java/wueffi/taskmanager/client/SystemMetricsProfiler.java +++ b/src/client/java/wueffi/taskmanager/client/SystemMetricsProfiler.java @@ -8,6 +8,7 @@ import java.lang.management.ManagementFactory; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.ArrayDeque; import java.util.Deque; import java.util.LinkedHashMap; @@ -17,6 +18,43 @@ public class SystemMetricsProfiler { + public record ThreadDrilldown( + long threadId, + String threadName, + String canonicalThreadName, + double cpuLoadPercent, + long allocationRateBytesPerSecond, + String state, + long blockedTimeDeltaMs, + long waitedTimeDeltaMs, + String ownerMod, + String confidence, + String reasonFrame, + List topFrames, + String threadRole, + String roleSource, + List ownerCandidates + ) {} + + public record ContentionSample( + long waiterThreadId, + String waiterThreadName, + String waiterMod, + String waiterConfidence, + String waiterRole, + long ownerThreadId, + String ownerThreadName, + String ownerMod, + String ownerConfidence, + String ownerRole, + String lockName, + long blockedTimeDeltaMs, + long waitedTimeDeltaMs, + List waiterCandidates, + List ownerCandidates, + String confidence + ) {} + public record Snapshot( String gpuVendor, String gpuRenderer, @@ -35,6 +73,7 @@ public record Snapshot( double cpuCoreLoadPercent, double gpuCoreLoadPercent, double gpuTemperatureC, + double gpuHotSpotTemperatureC, double cpuTemperatureC, double cpuLoadChangePerSecond, double gpuLoadChangePerSecond, @@ -77,7 +116,20 @@ public record Snapshot( long textureUploadRate, double playerSpeedBlocksPerSecond, int chunksEnteredLastSecond, - double distanceTravelledBlocks + double distanceTravelledBlocks, + Map metricProvenance, + String telemetryHelperStatus, + long telemetrySampleAgeMillis, + String gpuTemperatureProvider, + String gpuHotSpotProvider, + double profilerCpuLoadPercent, + long worldScanCostMillis, + long memoryHistogramCostMillis, + long telemetryHelperCostMillis, + String collectorGovernorMode, + String gpuCoverageSummary, + List threadDrilldown, + List contentionSamples ) { public static Snapshot empty() { return new Snapshot( @@ -99,6 +151,7 @@ public static Snapshot empty() { -1.0, -1.0, -1.0, + -1.0, 0.0, 0.0, 0.0, @@ -140,7 +193,20 @@ public static Snapshot empty() { -1L, -1.0, -1, - 0.0 + 0.0, + Map.of(), + "stopped", + Long.MAX_VALUE, + "Unavailable", + "Unavailable", + 0.0, + 0L, + 0L, + 0L, + "idle", + "No tagged phases yet", + List.of(), + List.of() ); } } @@ -148,9 +214,33 @@ public static Snapshot empty() { private static final SystemMetricsProfiler INSTANCE = new SystemMetricsProfiler(); public static SystemMetricsProfiler getInstance() { return INSTANCE; } + private record ThreadRoleAnalysis( + String label, + String source, + boolean mainLogic, + boolean workerPool, + boolean ioPool, + boolean chunkGeneration, + boolean chunkMeshing, + boolean chunkUpload + ) { + boolean countsAsWorker() { + return workerPool || ioPool || chunkGeneration || chunkMeshing || chunkUpload; + } + } + + private record ThreadObservation( + ThreadLoadProfiler.RawThreadSnapshot raw, + ThreadSnapshotCollector.ThreadStackSnapshot stackSnapshot, + AttributionInsights.ThreadAttribution attribution, + ThreadRoleAnalysis role, + long allocationRateBytesPerSecond + ) {} + private static final int GPU_MEMORY_INFO_TOTAL_AVAILABLE_MEMORY_NVX = 0x9048; private static final int GPU_MEMORY_INFO_CURRENT_AVAILABLE_VIDMEM_NVX = 0x9049; private static final int HISTORY_SIZE = 180; + private static final long SENSOR_RETAIN_MILLIS = 10_000L; private final long[] networkInHistory = new long[HISTORY_SIZE]; private final long[] networkOutHistory = new long[HISTORY_SIZE]; @@ -179,6 +269,9 @@ public static Snapshot empty() { private final TrendTracker gpuLoadTrendTracker = new TrendTracker(1_000L, 100.0); private final TrendTracker cpuTemperatureTrendTracker = new TrendTracker(2_000L, 30.0); private final TrendTracker gpuTemperatureTrendTracker = new TrendTracker(2_000L, 30.0); + private final TemperatureRetention gpuTemperatureRetention = new TemperatureRetention(); + private final TemperatureRetention gpuHotSpotTemperatureRetention = new TemperatureRetention(); + private final TemperatureRetention cpuTemperatureRetention = new TemperatureRetention(); private double lastPlayerX; private double lastPlayerY; private double lastPlayerZ; @@ -189,10 +282,19 @@ public static Snapshot empty() { private boolean lastPlayerChunkValid; private double distanceTravelledBlocks; private final Deque chunkEntryTimes = new ArrayDeque<>(); + private String cachedGpuVendor = ""; + private String cachedGpuRenderer = ""; public void sample(MemoryProfiler.Snapshot memorySnapshot, ProfilerManager.EntityCounts entityCounts, ProfilerManager.ChunkCounts chunkCounts) { long now = System.currentTimeMillis(); - int sampleIntervalMillis = ProfilerManager.getInstance().shouldCollectFrameMetrics() ? 50 : ConfigManager.getMetricsUpdateIntervalMs(); + String collectorGovernorMode = ProfilerManager.getInstance().getCollectorGovernorMode(); + int sampleIntervalMillis = switch (collectorGovernorMode) { + case "self-protect" -> Math.max(300, ConfigManager.getMetricsUpdateIntervalMs() * 2); + case "burst" -> 50; + case "tight" -> Math.max(100, ConfigManager.getMetricsUpdateIntervalMs()); + case "light" -> Math.max(200, ConfigManager.getMetricsUpdateIntervalMs()); + default -> ProfilerManager.getInstance().shouldCollectFrameMetrics() ? 50 : ConfigManager.getMetricsUpdateIntervalMs(); + }; if (now - lastSampleAtMillis < sampleIntervalMillis) { return; } @@ -201,8 +303,8 @@ public void sample(MemoryProfiler.Snapshot memorySnapshot, ProfilerManager.Entit lastSampleAtMillis = now; lastSampleIntervalMillis = sampleIntervalMillis; - String vendor = stringOrEmpty(GL11.glGetString(GL11.GL_VENDOR)); - String renderer = stringOrEmpty(GL11.glGetString(GL11.GL_RENDERER)); + String vendor = resolveGpuVendor(); + String renderer = resolveGpuRenderer(); long vramUsedBytes = -1; long vramTotalBytes = -1; @@ -224,22 +326,53 @@ public void sample(MemoryProfiler.Snapshot memorySnapshot, ProfilerManager.Entit long directMemoryUsedBytes = memorySnapshot.directBufferBytes() + memorySnapshot.mappedBufferBytes(); long directMemoryMaxBytes = lookupDirectMemoryMaxBytes(); - windowsBridge.requestRefreshIfNeeded(); - WindowsTelemetryBridge.Sample bridgeSample = windowsBridge.getLatest(); NativeWindowsSensors.Sample nativeSample = nativeWindowsSensors.sample(renderer, vendor); + windowsBridge.requestRefreshIfNeeded(nativeSample.active()); + WindowsTelemetryBridge.Sample bridgeSample = windowsBridge.getLatest(); WindowsTelemetryBridge.Sample mergedSample = mergeTelemetrySamples(bridgeSample, nativeSample); + WindowsTelemetryBridge.Health telemetryHealth = windowsBridge.getHealth(); + TemperatureReading gpuTemperatureReading = gpuTemperatureRetention.resolve( + mergedSample.gpuTemperatureC(), + mergedSample.gpuTemperatureProvider(), + now, + SENSOR_RETAIN_MILLIS + ); + TemperatureReading gpuHotSpotTemperatureReading = gpuHotSpotTemperatureRetention.resolve( + mergedSample.gpuHotSpotTemperatureC(), + mergedSample.gpuHotSpotTemperatureProvider(), + now, + SENSOR_RETAIN_MILLIS + ); + TemperatureReading cpuTemperatureReading = cpuTemperatureRetention.resolve( + mergedSample.cpuTemperatureC(), + mergedSample.cpuTemperatureProvider(), + now, + SENSOR_RETAIN_MILLIS + ); Map threadDetails = new LinkedHashMap<>(ThreadLoadProfiler.getInstance().getLatestThreadSnapshots()); + ThreadSnapshotCollector.Snapshot latestStacks = ThreadSnapshotCollector.getInstance().getLatestSnapshot(); + List threadObservations = buildThreadObservations(latestStacks); + List threadDrilldown = buildThreadDrilldown(threadObservations); + List contentionSamples = buildContentionSamples(threadObservations); Map threadLoads = new LinkedHashMap<>(); threadDetails.forEach((name, details) -> threadLoads.put(name, details.loadPercent())); - double totalThreadLoad = threadDetails.values().stream().mapToDouble(ThreadLoadProfiler.ThreadSnapshot::loadPercent).sum(); + double totalThreadLoad = threadObservations.stream() + .map(ThreadObservation::raw) + .map(ThreadLoadProfiler.RawThreadSnapshot::snapshot) + .mapToDouble(ThreadLoadProfiler.ThreadSnapshot::loadPercent) + .sum(); + double profilerCpuLoad = sumProfilerThreadLoad(); long offHeapAllocationRate = 0L; if (lastDirectMemoryUsedBytes >= 0L) { offHeapAllocationRate = Math.max(0L, Math.round((directMemoryUsedBytes - lastDirectMemoryUsedBytes) * 1000.0 / elapsedMillis)); } lastDirectMemoryUsedBytes = directMemoryUsedBytes; - ThreadLoadProfiler.ThreadSnapshot serverThread = threadDetails.get("Server Thread"); - int activeWorkers = countWorkers(threadDetails, true); - int idleWorkers = countWorkers(threadDetails, false); + ThreadObservation serverThread = threadObservations.stream() + .filter(observation -> "Server Thread".equals(observation.raw().canonicalThreadName())) + .findFirst() + .orElse(null); + int activeWorkers = countWorkers(threadObservations, true); + int idleWorkers = countWorkers(threadObservations, false); double workerRatio = idleWorkers > 0 ? activeWorkers / (double) idleWorkers : activeWorkers; int totalEntities = entityCounts.totalEntities(); double bytesPerEntity = totalEntities > 0 && mergedSample.bytesReceivedPerSecond() >= 0 ? mergedSample.bytesReceivedPerSecond() / (double) totalEntities : -1.0; @@ -250,20 +383,21 @@ public void sample(MemoryProfiler.Snapshot memorySnapshot, ProfilerManager.Entit String biome = sampleBiome(); String lightUpdateQueue = sampleLightQueue(); int lightsUpdatePending = parseLeadingInt(lightUpdateQueue); - int chunksGenerating = countThreadsMatching(threadDetails, "gen", "generation", "worldgen"); - int chunksMeshing = countThreadsMatching(threadDetails, "mesh", "builder", "chunk build"); - int chunksUploading = countThreadsMatching(threadDetails, "upload", "uploader"); + int chunksGenerating = countRoleMatches(threadObservations, ThreadRoleAnalysis::chunkGeneration); + int chunksMeshing = countRoleMatches(threadObservations, ThreadRoleAnalysis::chunkMeshing); + int chunksUploading = countRoleMatches(threadObservations, ThreadRoleAnalysis::chunkUpload); Map renderPhases = RenderPhaseProfiler.getInstance().getSnapshot(); long chunkMeshesRebuilt = sumPhaseCalls(renderPhases, "chunk", "mesh", "build", "rebuild"); long chunkMeshesUploaded = sumPhaseCalls(renderPhases, "upload"); long textureUploadRate = sumPhaseCalls(renderPhases, "texture", "upload"); + String gpuCoverageSummary = buildGpuCoverageSummary(renderPhases); PlayerMotionSnapshot motion = samplePlayerMotion(now); List hotChunks = ProfilerManager.getInstance().getLatestHotChunks(); int maxEntitiesInHotChunk = hotChunks.isEmpty() ? 0 : hotChunks.getFirst().entityCount(); double cpuLoadChangePerSecond = cpuLoadTrendTracker.update(mergedSample.cpuCoreLoadPercent(), now); double gpuLoadChangePerSecond = gpuLoadTrendTracker.update(mergedSample.gpuCoreLoadPercent(), now); - double cpuTemperatureChangePerSecond = cpuTemperatureTrendTracker.update(mergedSample.cpuTemperatureC(), now); - double gpuTemperatureChangePerSecond = gpuTemperatureTrendTracker.update(mergedSample.gpuTemperatureC(), now); + double cpuTemperatureChangePerSecond = cpuTemperatureTrendTracker.update(cpuTemperatureReading.value(), now); + double gpuTemperatureChangePerSecond = gpuTemperatureTrendTracker.update(gpuTemperatureReading.value(), now); snapshot = new Snapshot( vendor, @@ -279,11 +413,12 @@ public void sample(MemoryProfiler.Snapshot memorySnapshot, ProfilerManager.Entit mergedSample.counterSource(), mergedSample.sensorSource(), mergedSample.sensorErrorCode(), - buildCpuTemperatureUnavailableReason(mergedSample), + buildCpuTemperatureUnavailableReason(cpuTemperatureReading, mergedSample), mergedSample.cpuCoreLoadPercent(), mergedSample.gpuCoreLoadPercent(), - mergedSample.gpuTemperatureC(), - mergedSample.cpuTemperatureC(), + gpuTemperatureReading.value(), + gpuHotSpotTemperatureReading.value(), + cpuTemperatureReading.value(), cpuLoadChangePerSecond, gpuLoadChangePerSecond, cpuTemperatureChangePerSecond, @@ -295,17 +430,17 @@ public void sample(MemoryProfiler.Snapshot memorySnapshot, ProfilerManager.Entit mergedSample.diskWriteBytesPerSecond(), threadLoads, threadDetails, - buildSchedulingConflictSummary(threadDetails), - buildParallelismFlag(threadDetails), + buildSchedulingConflictSummary(threadObservations), + buildParallelismFlag(threadObservations), buildCpuSensorStatus(mergedSample.sensorSource()), - countHighLoadThreads(threadDetails), + countHighLoadThreads(threadObservations), estimatePhysicalCores(), - buildMainLogicSummary(threadDetails), - buildBackgroundSummary(threadDetails), + buildMainLogicSummary(threadObservations), + buildBackgroundSummary(threadObservations), totalThreadLoad, - buildParallelismEfficiency(totalThreadLoad), - serverThread == null ? 0L : serverThread.blockedTimeDeltaMs(), - serverThread == null ? 0L : serverThread.waitedTimeDeltaMs(), + buildParallelismEfficiency(totalThreadLoad, activeWorkers, idleWorkers), + serverThread == null ? 0L : serverThread.raw().snapshot().blockedTimeDeltaMs(), + serverThread == null ? 0L : serverThread.raw().snapshot().waitedTimeDeltaMs(), activeWorkers, idleWorkers, workerRatio, @@ -325,7 +460,20 @@ public void sample(MemoryProfiler.Snapshot memorySnapshot, ProfilerManager.Entit textureUploadRate, motion.speedBlocksPerSecond(), motion.chunksEnteredLastSecond(), - motion.distanceTravelledBlocks() + motion.distanceTravelledBlocks(), + buildMetricProvenance(), + telemetryHealth.helperStatus(), + telemetryHealth.latestSampleAgeMillis(), + gpuTemperatureReading.provider(), + gpuHotSpotTemperatureReading.provider(), + profilerCpuLoad, + ProfilerManager.getInstance().getLastWorldScanDurationMillis(), + MemoryProfiler.getInstance().getLastModSampleDurationMillis(), + Math.max(telemetryHealth.latestSampleDurationMillis(), mergedSample.sampleDurationMillis()), + collectorGovernorMode, + gpuCoverageSummary, + threadDrilldown, + contentionSamples ); pushHistory(networkInHistory, Math.max(0L, snapshot.bytesReceivedPerSecond())); @@ -334,8 +482,8 @@ public void sample(MemoryProfiler.Snapshot memorySnapshot, ProfilerManager.Entit pushHistory(diskWriteHistory, Math.max(0L, snapshot.diskWriteBytesPerSecond())); pushHistory(cpuLoadHistory, Math.max(0.0, snapshot.cpuCoreLoadPercent())); pushHistory(gpuLoadHistory, Math.max(0.0, snapshot.gpuCoreLoadPercent())); - pushHistory(cpuTemperatureHistory, snapshot.cpuTemperatureC()); - pushHistory(gpuTemperatureHistory, snapshot.gpuTemperatureC()); + pushHistory(cpuTemperatureHistory, cpuTemperatureReading.value()); + pushHistory(gpuTemperatureHistory, gpuTemperatureReading.value()); pushHistory(vramUsedHistory, snapshot.vramUsedBytes() >= 0L ? Math.max(0.0, snapshot.vramUsedBytes() / (1024.0 * 1024.0)) : -1.0); pushHistory(memoryUsedHistory, Math.max(0.0, memorySnapshot.heapUsedBytes() / (1024.0 * 1024.0))); pushHistory(memoryCommittedHistory, Math.max(0.0, memorySnapshot.heapCommittedBytes() / (1024.0 * 1024.0))); @@ -349,6 +497,22 @@ public Snapshot getSnapshot() { return snapshot; } + private String resolveGpuVendor() { + if (!cachedGpuVendor.isBlank()) { + return cachedGpuVendor; + } + cachedGpuVendor = stringOrEmpty(GL11.glGetString(GL11.GL_VENDOR)); + return cachedGpuVendor; + } + + private String resolveGpuRenderer() { + if (!cachedGpuRenderer.isBlank()) { + return cachedGpuRenderer; + } + cachedGpuRenderer = stringOrEmpty(GL11.glGetString(GL11.GL_RENDERER)); + return cachedGpuRenderer; + } + public long[] getNetworkInHistory() { return networkInHistory; } public long[] getNetworkOutHistory() { return networkOutHistory; } public long[] getDiskReadHistory() { return diskReadHistory; } @@ -421,6 +585,27 @@ private double update(double currentValue, long now) { } } + private record TemperatureReading(double value, String provider) {} + + private static final class TemperatureRetention { + private double lastValue = -1.0; + private long lastCapturedAtMillis; + private String lastProvider = "Unavailable"; + + private synchronized TemperatureReading resolve(double currentValue, String currentProvider, long now, long retainMillis) { + if (Double.isFinite(currentValue) && currentValue >= 0.0) { + lastValue = currentValue; + lastCapturedAtMillis = now; + lastProvider = currentProvider == null || currentProvider.isBlank() ? "Unavailable" : currentProvider; + return new TemperatureReading(currentValue, lastProvider); + } + if (lastCapturedAtMillis > 0L && now - lastCapturedAtMillis <= retainMillis && Double.isFinite(lastValue) && lastValue >= 0.0) { + return new TemperatureReading(lastValue, lastProvider); + } + return new TemperatureReading(-1.0, "Unavailable"); + } + } + private void advanceHistory() { historyIndex = (historyIndex + 1) % HISTORY_SIZE; if (historyCount < HISTORY_SIZE) { @@ -514,22 +699,31 @@ private WindowsTelemetryBridge.Sample mergeTelemetrySamples(WindowsTelemetryBrid if (bridgeSample == null) { bridgeSample = WindowsTelemetryBridge.Sample.empty(); } + if (bridgeSample.capturedAtEpochMillis() > 0L && System.currentTimeMillis() - bridgeSample.capturedAtEpochMillis() > 3_000L) { + bridgeSample = WindowsTelemetryBridge.Sample.empty(); + } if (nativeSample == null) { nativeSample = NativeWindowsSensors.Sample.empty(); } return new WindowsTelemetryBridge.Sample( + bridgeSample.capturedAtEpochMillis(), bridgeSample.bridgeActive() || nativeSample.active(), chooseTelemetryText(nativeSample.counterSource(), bridgeSample.counterSource(), "Unavailable"), chooseTelemetryText(nativeSample.sensorSource(), bridgeSample.sensorSource(), "Unavailable"), mergeTelemetryErrors(nativeSample.sensorErrorCode(), bridgeSample.sensorErrorCode()), + chooseTelemetryProvider(nativeSample.cpuTemperatureC(), nativeSample.cpuTemperatureProvider(), bridgeSample.cpuTemperatureC(), bridgeSample.cpuTemperatureProvider()), + chooseTelemetryProvider(nativeSample.gpuTemperatureC(), nativeSample.gpuTemperatureProvider(), bridgeSample.gpuTemperatureC(), bridgeSample.gpuTemperatureProvider()), + chooseTelemetryProvider(nativeSample.gpuHotSpotTemperatureC(), nativeSample.gpuHotSpotTemperatureProvider(), bridgeSample.gpuHotSpotTemperatureC(), bridgeSample.gpuHotSpotTemperatureProvider()), chooseTelemetryDouble(nativeSample.cpuCoreLoadPercent(), bridgeSample.cpuCoreLoadPercent()), chooseTelemetryDouble(nativeSample.gpuCoreLoadPercent(), bridgeSample.gpuCoreLoadPercent()), chooseTelemetryDouble(nativeSample.gpuTemperatureC(), bridgeSample.gpuTemperatureC()), + chooseTelemetryDouble(nativeSample.gpuHotSpotTemperatureC(), bridgeSample.gpuHotSpotTemperatureC()), chooseTelemetryDouble(nativeSample.cpuTemperatureC(), bridgeSample.cpuTemperatureC()), bridgeSample.bytesReceivedPerSecond(), bridgeSample.bytesSentPerSecond(), bridgeSample.diskReadBytesPerSecond(), - bridgeSample.diskWriteBytesPerSecond() + bridgeSample.diskWriteBytesPerSecond(), + bridgeSample.sampleDurationMillis() ); } @@ -561,8 +755,94 @@ private String mergeTelemetryErrors(String preferred, String fallback) { private double chooseTelemetryDouble(double preferred, double fallback) { return Double.isFinite(preferred) && preferred >= 0.0 ? preferred : fallback; } - private String buildCpuTemperatureUnavailableReason(WindowsTelemetryBridge.Sample bridgeSample) { - if (bridgeSample.cpuTemperatureC() >= 0) { + + private String chooseTelemetryProvider(double preferredValue, String preferredProvider, double fallbackValue, String fallbackProvider) { + if (Double.isFinite(preferredValue) && preferredValue >= 0.0) { + return normalizeTemperatureProvider(preferredProvider); + } + if (Double.isFinite(fallbackValue) && fallbackValue >= 0.0) { + return normalizeTemperatureProvider(fallbackProvider); + } + return "Unavailable"; + } + + private Map buildMetricProvenance() { + Map provenance = new LinkedHashMap<>(); + provenance.put("packetProcessingLatencyMs", "inferred from packet volume and client tick pressure"); + provenance.put("networkBufferSaturation", "inferred from packet burst thresholds"); + provenance.put("bytesPerEntity", "derived from inbound bytes divided by loaded entity count"); + provenance.put("chunkPipeline", "heuristic from thread names and render phase call counts"); + provenance.put("estimatedPhysicalCores", "heuristic estimate from logical processor count"); + provenance.put("parallelismEfficiency", "heuristic based on aggregate thread load"); + provenance.put("lightQueue", "best-effort parsed from chunk debug text"); + provenance.put("chunkCounts", "best-effort parsed from chunk debug text"); + return provenance; + } + + private double sumProfilerThreadLoad() { + return ThreadLoadProfiler.getInstance().getLatestRawThreadSnapshots().values().stream() + .filter(raw -> raw.threadName() != null && raw.threadName().toLowerCase(Locale.ROOT).contains("taskmanager")) + .mapToDouble(raw -> raw.snapshot().loadPercent()) + .sum(); + } + + private String extractTelemetryProvider(String source, String devicePrefix) { + if (source == null || source.isBlank()) { + return "Unavailable"; + } + String prefix = devicePrefix == null ? "" : devicePrefix + ": "; + for (String part : source.split("\\|")) { + String trimmed = part.trim(); + if (!prefix.isBlank() && trimmed.startsWith(prefix)) { + int bracket = trimmed.indexOf('['); + String provider = bracket >= 0 ? trimmed.substring(prefix.length(), bracket).trim() : trimmed.substring(prefix.length()).trim(); + return normalizeTemperatureProvider(provider); + } + } + return "Unavailable"; + } + + private String normalizeTemperatureProvider(String provider) { + if (provider == null || provider.isBlank()) { + return "Unavailable"; + } + String lower = provider.toLowerCase(Locale.ROOT); + if (lower.contains("windows pdh gpu counters") || lower.contains("windows performance counters") || lower.contains("jvm mxbean")) { + return "Unavailable"; + } + return provider; + } + + private String buildGpuCoverageSummary(Map renderPhases) { + if (renderPhases == null || renderPhases.isEmpty()) { + return "No tagged phases yet"; + } + long totalGpuNanos = renderPhases.values().stream().mapToLong(RenderPhaseProfiler.PhaseSnapshot::gpuNanos).sum(); + long sharedGpuNanos = renderPhases.values().stream() + .filter(phase -> phase.ownerMod() == null || phase.ownerMod().isBlank() || phase.ownerMod().startsWith("shared/")) + .mapToLong(RenderPhaseProfiler.PhaseSnapshot::gpuNanos) + .sum(); + long taggedPhases = renderPhases.values().stream() + .filter(phase -> phase.ownerMod() != null && !phase.ownerMod().isBlank() && !phase.ownerMod().startsWith("shared/")) + .count(); + double sharedPct = totalGpuNanos > 0L ? sharedGpuNanos * 100.0 / totalGpuNanos : 0.0; + boolean irisLoaded = net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("iris"); + boolean sodiumLoaded = net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("sodium"); + List coveredPaths = renderPhases.keySet().stream() + .filter(name -> name != null && (name.contains("world") || name.contains("sky") || name.contains("particle") || name.contains("outline"))) + .limit(4) + .toList(); + return String.format(Locale.ROOT, + "%d tagged phases | %.1f%% shared/render carry-over | Iris %s | Sodium %s | paths %s", + taggedPhases, + sharedPct, + irisLoaded ? "on" : "off", + sodiumLoaded ? "on" : "off", + coveredPaths.isEmpty() ? "warming up" : String.join(", ", coveredPaths)); + } + + private String buildCpuTemperatureUnavailableReason(TemperatureReading cpuTemperatureReading, WindowsTelemetryBridge.Sample bridgeSample) { + if (cpuTemperatureReading != null && cpuTemperatureReading.value() >= 0.0) { return "CPU temperature provider active"; } String source = bridgeSample.sensorSource() == null ? "" : bridgeSample.sensorSource(); @@ -573,18 +853,91 @@ private String buildCpuTemperatureUnavailableReason(WindowsTelemetryBridge.Sampl return "CPU temperature unavailable. Source: " + source + " | Last bridge error: " + error; } - private int countThreadsMatching(Map threadDetails, String... needles) { - return (int) threadDetails.keySet().stream() - .map(name -> name.toLowerCase(Locale.ROOT)) - .filter(name -> { - for (String needle : needles) { - if (name.contains(needle)) { - return true; - } - } - return false; + private List buildThreadObservations(ThreadSnapshotCollector.Snapshot latestStacks) { + Map threadAllocations = MemoryProfiler.getInstance().getThreadAllocationRateBytesPerSecond(); + return ThreadLoadProfiler.getInstance().getLatestRawThreadSnapshots().values().stream() + .sorted((a, b) -> Double.compare(b.snapshot().loadPercent(), a.snapshot().loadPercent())) + .map(raw -> { + ThreadSnapshotCollector.ThreadStackSnapshot stackSnapshot = latestStacks.threadsById().get(raw.threadId()); + StackTraceElement[] stack = stackSnapshot == null ? null : stackSnapshot.stack(); + AttributionInsights.ThreadAttribution attribution = AttributionInsights.attributeThread(raw.threadName(), stack); + ThreadRoleAnalysis role = classifyThreadRoleAnalysis(raw.threadName(), stack); + return new ThreadObservation( + raw, + stackSnapshot, + attribution, + role, + threadAllocations.getOrDefault(raw.threadId(), 0L) + ); }) - .count(); + .toList(); + } + + private List buildThreadDrilldown(List observations) { + return observations.stream() + .map(observation -> new ThreadDrilldown( + observation.raw().threadId(), + observation.raw().threadName(), + observation.raw().canonicalThreadName(), + observation.raw().snapshot().loadPercent(), + observation.allocationRateBytesPerSecond(), + observation.raw().snapshot().state(), + observation.raw().snapshot().blockedTimeDeltaMs(), + observation.raw().snapshot().waitedTimeDeltaMs(), + observation.attribution().ownerMod(), + observation.attribution().confidence().label(), + observation.attribution().reasonFrame(), + observation.attribution().topFrames(), + observation.role().label(), + observation.role().source(), + observation.attribution().candidateLabels() + )) + .toList(); + } + + private List buildContentionSamples(List observations) { + Map byThreadId = new LinkedHashMap<>(); + for (ThreadObservation observation : observations) { + byThreadId.put(observation.raw().threadId(), observation); + } + List result = new ArrayList<>(); + for (ThreadObservation waiter : observations) { + ThreadLoadProfiler.ThreadSnapshot waiterSnapshot = waiter.raw().snapshot(); + boolean waiting = waiterSnapshot.blockedCountDelta() > 0 + || waiterSnapshot.waitedCountDelta() > 0 + || "BLOCKED".equals(waiterSnapshot.state()) + || "WAITING".equals(waiterSnapshot.state()); + if (!waiting || waiterSnapshot.lockOwnerThreadId() <= 0L) { + continue; + } + ThreadObservation owner = byThreadId.get(waiterSnapshot.lockOwnerThreadId()); + AttributionInsights.ThreadAttribution ownerAttribution = owner == null + ? AttributionInsights.attributeThread(waiterSnapshot.lockOwnerName(), null) + : owner.attribution(); + ThreadRoleAnalysis ownerRole = owner == null + ? classifyThreadRoleAnalysis(waiterSnapshot.lockOwnerName(), null) + : owner.role(); + String lockName = waiterSnapshot.lockName() == null || waiterSnapshot.lockName().isBlank() ? "unknown lock" : waiterSnapshot.lockName(); + result.add(new ContentionSample( + waiter.raw().threadId(), + waiter.raw().threadName(), + waiter.attribution().ownerMod(), + waiter.attribution().confidence().label(), + waiter.role().label(), + owner == null ? waiterSnapshot.lockOwnerThreadId() : owner.raw().threadId(), + owner == null ? blankToUnknown(waiterSnapshot.lockOwnerName()) : owner.raw().threadName(), + ownerAttribution.ownerMod(), + ownerAttribution.confidence().label(), + ownerRole.label(), + lockName, + waiterSnapshot.blockedTimeDeltaMs(), + waiterSnapshot.waitedTimeDeltaMs(), + waiter.attribution().candidateLabels(), + ownerAttribution.candidateLabels(), + pairwiseConfidence(waiter.attribution(), ownerAttribution).label() + )); + } + return result; } private long sumPhaseCalls(Map phases, String... needles) { @@ -658,93 +1011,161 @@ private PlayerMotionSnapshot samplePlayerMotion(long now) { } } - private String buildSchedulingConflictSummary(Map threadDetails) { - ThreadLoadProfiler.ThreadSnapshot server = threadDetails.get("Server Thread"); - ThreadLoadProfiler.ThreadSnapshot render = threadDetails.get("Render Thread"); - double workerLoad = threadDetails.entrySet().stream() - .filter(entry -> entry.getKey().startsWith("Worker-Main-")) - .mapToDouble(entry -> entry.getValue().loadPercent()) - .sum(); - int processors = Runtime.getRuntime().availableProcessors(); - if (server != null && server.loadPercent() > 35.0 && workerLoad > 50.0 && processors <= 8) { - return String.format(Locale.ROOT, "Possible scheduling conflict: Server Thread %.1f%% with Worker-Main load %.1f%% across %d logical cores", server.loadPercent(), workerLoad, processors); + static String classifyThreadRole(String threadName, StackTraceElement[] stack) { + return classifyThreadRoleAnalysis(threadName, stack).label(); + } + + private static ThreadRoleAnalysis classifyThreadRoleAnalysis(String threadName, StackTraceElement[] stack) { + String lowerName = threadName == null ? "" : threadName.toLowerCase(Locale.ROOT); + String stackText = buildStackText(stack); + String lowerStack = stackText.toLowerCase(Locale.ROOT); + boolean render = lowerName.contains("render") || lowerStack.contains("gamerenderer") || lowerStack.contains("worldrenderer"); + boolean server = lowerName.contains("server") || lowerName.contains("main thread") || lowerStack.contains("minecraftserver"); + boolean profiler = lowerName.contains("taskmanager") || lowerStack.contains("wueffi.taskmanager"); + boolean gc = containsAny(lowerName, "g1", "gc") || containsAny(lowerStack, "sun.jvm", "g1"); + boolean network = containsAny(lowerName, "netty", "network") || containsAny(lowerStack, "clientconnection", "packet", "netty"); + boolean ioPool = containsAny(lowerName, "io", "file", "save", "storage") + || containsAny(lowerStack, "region", "anvil", "storage", "filechannel", "asynchronousfilechannel", "nio", "zip", "compress", "flush"); + boolean chunkGeneration = containsAny(lowerName, "worldgen", "gen") + || containsAny(lowerStack, "chunkstatus", "noisechunk", "worldgen", "generator"); + boolean chunkMeshing = containsAny(lowerName, "chunk build", "builder", "mesh") + || containsAny(lowerStack, "chunkbuilder", "rebuild", "meshing", "compileterrain"); + boolean chunkUpload = containsAny(lowerName, "upload") + || containsAny(lowerStack, "vertexbuffer", "upload", "bufferbuilder", "glbuffer"); + boolean workerPool = containsAny(lowerName, "worker", "executor", "pool", "forkjoin", "c2me", "async") + || containsAny(lowerStack, "threadpoolexecutor", "forkjoin", "completablefuture", "executor", "worker", "c2me", "mailbox"); + + if (profiler) { + return new ThreadRoleAnalysis("Profiler", sourceForRole(lowerName, lowerStack, "taskmanager"), false, false, false, false, false, false); + } + if (render) { + return new ThreadRoleAnalysis("Render", sourceForRole(lowerName, lowerStack, "render"), true, false, false, false, false, false); + } + if (server) { + return new ThreadRoleAnalysis("Main Logic", sourceForRole(lowerName, lowerStack, "server"), true, false, false, false, false, false); } - if (render != null && render.loadPercent() > 35.0 && workerLoad > 50.0 && processors <= 8) { - return String.format(Locale.ROOT, "Possible render scheduling conflict: Render Thread %.1f%% with Worker-Main load %.1f%% across %d logical cores", render.loadPercent(), workerLoad, processors); + if (chunkUpload) { + return new ThreadRoleAnalysis("Chunk Upload Worker", sourceForRole(lowerName, lowerStack, "upload"), false, true, false, false, false, true); + } + if (chunkMeshing) { + return new ThreadRoleAnalysis("Chunk Meshing Worker", sourceForRole(lowerName, lowerStack, "mesh"), false, true, false, false, true, false); + } + if (chunkGeneration) { + return new ThreadRoleAnalysis("Chunk Generation Worker", sourceForRole(lowerName, lowerStack, "worldgen"), false, true, false, true, false, false); + } + if (ioPool) { + return new ThreadRoleAnalysis("IO Pool", sourceForRole(lowerName, lowerStack, "storage"), false, false, true, false, false, false); + } + if (network) { + return new ThreadRoleAnalysis("Network", sourceForRole(lowerName, lowerStack, "netty"), false, false, false, false, false, false); + } + if (gc) { + return new ThreadRoleAnalysis("GC", sourceForRole(lowerName, lowerStack, "gc"), false, false, false, false, false, false); + } + if (workerPool) { + return new ThreadRoleAnalysis("Worker Pool", sourceForRole(lowerName, lowerStack, "worker"), false, true, false, false, false, false); + } + return new ThreadRoleAnalysis("Other", "fallback", false, false, false, false, false, false); + } + + private String buildSchedulingConflictSummary(List observations) { + ThreadObservation hottestMain = observations.stream() + .filter(observation -> observation.role().mainLogic()) + .max((a, b) -> Double.compare(a.raw().snapshot().loadPercent(), b.raw().snapshot().loadPercent())) + .orElse(null); + double workerLoad = observations.stream() + .filter(observation -> observation.role().countsAsWorker()) + .mapToDouble(observation -> observation.raw().snapshot().loadPercent()) + .sum(); + int activeWorkers = countWorkers(observations, true); + int physicalCores = estimatePhysicalCores(); + if (hottestMain != null + && hottestMain.raw().snapshot().loadPercent() > 35.0 + && workerLoad > Math.max(45.0, physicalCores * 12.0) + && activeWorkers >= Math.max(2, physicalCores / 2)) { + return String.format( + Locale.ROOT, + "Possible scheduling conflict: %s %.1f%% with %s worker load %.1f%% across %d estimated physical cores", + hottestMain.raw().canonicalThreadName(), + hottestMain.raw().snapshot().loadPercent(), + dominantWorkerLabel(observations), + workerLoad, + physicalCores + ); } return "No scheduling conflict detected"; } - private String buildParallelismFlag(Map threadDetails) { - long activeWorkers = threadDetails.entrySet().stream() - .filter(entry -> entry.getKey().startsWith("Worker-Main-") && entry.getValue().loadPercent() >= 5.0) - .count(); - long blockedWorkers = threadDetails.entrySet().stream() - .filter(entry -> entry.getKey().startsWith("Worker-Main-") && (entry.getValue().blockedCountDelta() > 0 || entry.getValue().waitedCountDelta() > 0 || "BLOCKED".equals(entry.getValue().state()) || "WAITING".equals(entry.getValue().state()))) + private String buildParallelismFlag(List observations) { + int activeWorkers = countWorkers(observations, true); + int idleWorkers = countWorkers(observations, false); + long blockedWorkers = observations.stream() + .filter(observation -> observation.role().countsAsWorker()) + .filter(observation -> { + ThreadLoadProfiler.ThreadSnapshot snapshot = observation.raw().snapshot(); + return snapshot.blockedCountDelta() > 0 + || snapshot.waitedCountDelta() > 0 + || "BLOCKED".equals(snapshot.state()) + || "WAITING".equals(snapshot.state()); + }) .count(); - double workerLoad = threadDetails.entrySet().stream() - .filter(entry -> entry.getKey().startsWith("Worker-Main-")) - .mapToDouble(entry -> entry.getValue().loadPercent()) + double workerLoad = observations.stream() + .filter(observation -> observation.role().countsAsWorker()) + .mapToDouble(observation -> observation.raw().snapshot().loadPercent()) .sum(); - ThreadLoadProfiler.ThreadSnapshot server = threadDetails.get("Server Thread"); - double serverLoad = server == null ? 0.0 : server.loadPercent(); + ThreadObservation hottestMain = observations.stream() + .filter(observation -> observation.role().mainLogic()) + .max((a, b) -> Double.compare(a.raw().snapshot().loadPercent(), b.raw().snapshot().loadPercent())) + .orElse(null); + double mainLoad = hottestMain == null ? 0.0 : hottestMain.raw().snapshot().loadPercent(); if (activeWorkers == 0 && workerLoad < 5.0) { - return String.format(Locale.ROOT, "Parallelism low (%d high-load threads)", countHighLoadThreads(threadDetails)); + return String.format(Locale.ROOT, "Parallelism low (%d high-load threads)", countHighLoadThreads(observations)); } if (blockedWorkers >= Math.max(2, activeWorkers) && activeWorkers > 0) { return String.format(Locale.ROOT, "Parallelism blocked (%d active / %d waiting)", activeWorkers, blockedWorkers); } - if (serverLoad > 35.0 && workerLoad > 80.0) { - return String.format(Locale.ROOT, "Parallelism saturated (%d workers / %d high-load threads)", activeWorkers, countHighLoadThreads(threadDetails)); + if (mainLoad > 35.0 && workerLoad > Math.max(80.0, estimatePhysicalCores() * 18.0)) { + return String.format(Locale.ROOT, "Parallelism saturated (%d workers / %d idle / %d high-load threads)", activeWorkers, idleWorkers, countHighLoadThreads(observations)); } - return String.format(Locale.ROOT, "Parallelism healthy (%d workers / %d high-load threads)", activeWorkers, countHighLoadThreads(threadDetails)); + return String.format(Locale.ROOT, "Parallelism healthy (%d workers / %d idle / %d high-load threads)", activeWorkers, idleWorkers, countHighLoadThreads(observations)); } private int estimatePhysicalCores() { return Math.max(1, Runtime.getRuntime().availableProcessors() / 2); } - private int countHighLoadThreads(Map threadDetails) { - return (int) threadDetails.values().stream() + private int countHighLoadThreads(List observations) { + return (int) observations.stream() + .map(ThreadObservation::raw) + .map(ThreadLoadProfiler.RawThreadSnapshot::snapshot) .filter(snapshot -> snapshot.loadPercent() >= 50.0) .count(); } - private String buildMainLogicSummary(Map threadDetails) { - Map.Entry main = threadDetails.entrySet().stream() - .filter(entry -> isMainLogicThread(entry.getKey())) - .max((a, b) -> Double.compare(a.getValue().loadPercent(), b.getValue().loadPercent())) + private String buildMainLogicSummary(List observations) { + ThreadObservation main = observations.stream() + .filter(observation -> observation.role().mainLogic()) + .max((a, b) -> Double.compare(a.raw().snapshot().loadPercent(), b.raw().snapshot().loadPercent())) .orElse(null); if (main == null) { return "Main Logic: n/a"; } - return String.format(Locale.ROOT, "Main Logic: %s (%.0f%%)", main.getKey(), main.getValue().loadPercent()); + return String.format(Locale.ROOT, "Main Logic: %s (%.0f%%)", main.raw().threadName(), main.raw().snapshot().loadPercent()); } - private String buildBackgroundSummary(Map threadDetails) { - double backgroundLoad = threadDetails.entrySet().stream() - .filter(entry -> !isMainLogicThread(entry.getKey())) - .mapToDouble(entry -> entry.getValue().loadPercent()) + private String buildBackgroundSummary(List observations) { + double backgroundLoad = observations.stream() + .filter(observation -> !observation.role().mainLogic()) + .mapToDouble(observation -> observation.raw().snapshot().loadPercent()) .sum(); - String label = threadDetails.entrySet().stream() - .filter(entry -> !isMainLogicThread(entry.getKey())) - .map(Map.Entry::getKey) + String label = observations.stream() + .filter(observation -> !observation.role().mainLogic()) + .map(observation -> observation.role().label()) .findFirst() - .orElse("Workers/JVM"); - if (label.startsWith("Worker-Main-") || label.startsWith("C2ME")) { - label = "Workers/C2ME"; - } else if (label.startsWith("G1") || label.contains("GC")) { - label = "Workers/GC"; - } else if (label.equals("unknown")) { - label = "Infrastructure"; - } + .orElse("Infrastructure"); return String.format(Locale.ROOT, "Background: %s (%.0f%%)", label, backgroundLoad); } - private boolean isMainLogicThread(String threadName) { - return "Server Thread".equals(threadName) || "Render Thread".equals(threadName) || threadName.toLowerCase(Locale.ROOT).contains("main thread"); - } - private String buildCpuSensorStatus(String sensorSource) { String lower = sensorSource == null ? "" : sensorSource.toLowerCase(Locale.ROOT); if (lower.contains("cpu: core temp shared memory")) { @@ -767,25 +1188,112 @@ private String buildCpuSensorStatus(String sensorSource) { - private String buildParallelismEfficiency(double totalThreadLoad) { - if (totalThreadLoad > 800.0) { - return "Heavy Multithreading Active (C2ME)."; + private String buildParallelismEfficiency(double totalThreadLoad, int activeWorkers, int idleWorkers) { + if (activeWorkers >= Math.max(4, estimatePhysicalCores()) && totalThreadLoad > 800.0) { + return "Heavy multithreading active."; + } + if (activeWorkers == 0 && totalThreadLoad < 300.0) { + return "Light multithreading active."; } - if (totalThreadLoad < 300.0) { - return "Light Multithreading Active."; + if (idleWorkers > activeWorkers && activeWorkers > 0) { + return "Moderate multithreading active with spare worker capacity."; } - return "Moderate Multithreading Active."; + return "Moderate multithreading active."; + } + + private int countWorkers(List observations, boolean active) { + return (int) observations.stream() + .filter(observation -> observation.role().countsAsWorker()) + .filter(observation -> active + ? observation.raw().snapshot().loadPercent() >= 5.0 || "RUNNABLE".equals(observation.raw().snapshot().state()) + : observation.raw().snapshot().loadPercent() < 5.0 && ("WAITING".equals(observation.raw().snapshot().state()) || "TIMED_WAITING".equals(observation.raw().snapshot().state()))) + .count(); } - private int countWorkers(Map threadDetails, boolean active) { - return (int) threadDetails.entrySet().stream() - .filter(entry -> entry.getKey().startsWith("Worker-Main-") || entry.getKey().toLowerCase(Locale.ROOT).contains("worker") || entry.getKey().contains("C2ME")) - .filter(entry -> active - ? entry.getValue().loadPercent() >= 5.0 || "RUNNABLE".equals(entry.getValue().state()) - : entry.getValue().loadPercent() < 5.0 && ("WAITING".equals(entry.getValue().state()) || "TIMED_WAITING".equals(entry.getValue().state()))) + private int countRoleMatches(List observations, java.util.function.Predicate predicate) { + return (int) observations.stream() + .map(ThreadObservation::role) + .filter(predicate) .count(); } + private AttributionInsights.Confidence pairwiseConfidence(AttributionInsights.ThreadAttribution waiter, AttributionInsights.ThreadAttribution owner) { + if (waiter == null || owner == null) { + return AttributionInsights.Confidence.WEAK_HEURISTIC; + } + boolean waiterConcrete = isConcreteMod(waiter.ownerMod()); + boolean ownerConcrete = isConcreteMod(owner.ownerMod()); + if (waiterConcrete && ownerConcrete + && waiter.confidence() != AttributionInsights.Confidence.WEAK_HEURISTIC + && owner.confidence() != AttributionInsights.Confidence.WEAK_HEURISTIC) { + return AttributionInsights.Confidence.PAIRWISE_INFERRED; + } + if (waiterConcrete || ownerConcrete) { + return AttributionInsights.Confidence.INFERRED; + } + return AttributionInsights.Confidence.WEAK_HEURISTIC; + } + + private boolean isConcreteMod(String modId) { + return modId != null && !modId.isBlank() && !modId.startsWith("shared/") && !modId.startsWith("runtime/"); + } + + private static String buildStackText(StackTraceElement[] stack) { + if (stack == null || stack.length == 0) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (StackTraceElement frame : stack) { + if (frame == null) { + continue; + } + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(frame.getClassName()).append('#').append(frame.getMethodName()); + } + return builder.toString(); + } + + private static String sourceForRole(String lowerName, String lowerStack, String marker) { + boolean inName = marker != null && !marker.isBlank() && lowerName.contains(marker); + boolean inStack = marker != null && !marker.isBlank() && lowerStack.contains(marker); + if (inName && inStack) { + return "thread name + stack ancestry"; + } + if (inStack) { + return "stack ancestry"; + } + if (inName) { + return "thread name"; + } + return "heuristic"; + } + + private static boolean containsAny(String text, String... needles) { + if (text == null || text.isBlank()) { + return false; + } + for (String needle : needles) { + if (needle != null && !needle.isBlank() && text.contains(needle)) { + return true; + } + } + return false; + } + + private String dominantWorkerLabel(List observations) { + return observations.stream() + .filter(observation -> observation.role().countsAsWorker()) + .map(observation -> observation.role().label()) + .findFirst() + .orElse("background"); + } + + private String blankToUnknown(String value) { + return value == null || value.isBlank() ? "unknown" : value; + } + private String sampleBiome() { try { MinecraftClient client = MinecraftClient.getInstance(); diff --git a/src/client/java/wueffi/taskmanager/client/TaskManagerScreen.java b/src/client/java/wueffi/taskmanager/client/TaskManagerScreen.java index 7199a61..3207538 100644 --- a/src/client/java/wueffi/taskmanager/client/TaskManagerScreen.java +++ b/src/client/java/wueffi/taskmanager/client/TaskManagerScreen.java @@ -2,6 +2,7 @@ import net.fabricmc.loader.api.FabricLoader; import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; import net.minecraft.client.gl.RenderPipelines; import net.minecraft.client.gui.Click; import net.minecraft.client.gui.DrawContext; @@ -12,9 +13,11 @@ import net.minecraft.entity.Entity; import net.minecraft.util.math.ChunkPos; import net.minecraft.text.Text; -import net.minecraft.text.OrderedText; import net.minecraft.util.Identifier; import org.lwjgl.opengl.GL11; +import wueffi.taskmanager.client.AttributionModelBuilder.EffectiveCpuAttribution; +import wueffi.taskmanager.client.AttributionModelBuilder.EffectiveGpuAttribution; +import wueffi.taskmanager.client.AttributionModelBuilder.EffectiveMemoryAttribution; import wueffi.taskmanager.client.util.ConfigManager; import wueffi.taskmanager.client.util.ModIconCache; import wueffi.taskmanager.client.util.ModTimingSnapshot; @@ -29,53 +32,50 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.StringJoiner; public class TaskManagerScreen extends Screen { - private record LagMapLayout(int left, int miniTabY, int summaryY, int mapRenderY, int mapWidth, int mapHeight, int cell, int radius, int mapTop) {} + record LagMapLayout(int left, int miniTabY, int summaryY, int mapRenderY, int mapWidth, int mapHeight, int cell, int radius, int mapTop) {} - private record FindingClickTarget(int x, int y, int width, int height, String key) {} + record FindingClickTarget(int x, int y, int width, int height, String key) {} - private record TooltipTarget(int x, int y, int width, int height, String text) {} + record SpikePinClickTarget(int x, int y, int width, int height, ProfilerManager.SpikeCapture spike, boolean clearPin) {} - private record ModalLayout(int x, int y, int width, int height) {} + record ModalLayout(int x, int y, int width, int height) {} - private record MemoryListLayout(int tableWidth, int searchY, int headerY, int listY, int listHeight) {} + record AttributionListLayout(int listWidth, int headerY, int listY, int listHeight) {} - private record SliderLayout(int x, int y, int width, int height) {} + record MemoryListLayout(int tableWidth, int searchY, int headerY, int listY, int listHeight) {} - private record EffectiveCpuAttribution(Map displaySnapshots, Map redistributedSamplesByMod, Map redistributedRenderSamplesByMod, long totalSamples, long totalRenderSamples) {} + record SliderLayout(int x, int y, int width, int height) {} - private record EffectiveMemoryAttribution(Map displayBytes, Map redistributedBytesByMod, long totalBytes) {} - - private record EffectiveGpuAttribution(Map gpuNanosByMod, Map renderSamplesByMod, Map redistributedGpuNanosByMod, Map redistributedRenderSamplesByMod, long totalGpuNanos, long totalRenderSamples) {} - - private enum TableId { + public enum TableId { TASKS, GPU, MEMORY } - private enum WorldMiniTab { + public enum WorldMiniTab { LAG_MAP, ENTITIES, CHUNKS, BLOCK_ENTITIES } - private enum SystemMiniTab { + public enum SystemMiniTab { OVERVIEW, CPU_GRAPH, GPU_GRAPH, MEMORY_GRAPH } - private enum GraphMetricTab { + public enum GraphMetricTab { LOAD, TEMPERATURE } - private enum ColorSetting { + public enum ColorSetting { CPU, GPU, WORLD_ENTITIES, @@ -83,7 +83,7 @@ private enum ColorSetting { WORLD_CHUNKS_RENDERED } - private enum StartupSort { + public enum StartupSort { NAME, START, END, @@ -92,7 +92,7 @@ private enum StartupSort { REGISTRATIONS } - private enum TaskSort { + public enum TaskSort { NAME, CPU, THREADS, @@ -105,7 +105,7 @@ TaskSort next() { } } - private enum GpuSort { + public enum GpuSort { NAME, EST_GPU, THREADS, @@ -118,7 +118,7 @@ GpuSort next() { } } - private enum MemorySort { + public enum MemorySort { NAME, MEMORY_MB, CLASS_COUNT, @@ -130,14 +130,28 @@ MemorySort next() { } } + public enum ThreadSort { + NAME, + CPU, + ALLOC, + BLOCKED, + WAITED; + + ThreadSort next() { + ThreadSort[] values = values(); + return values[(ordinal() + 1) % values.length]; + } + } + private static final int TAB_HEIGHT = 24; - private static final int PADDING = 8; + static final int PADDING = 8; private static final int ROW_HEIGHT = 20; + static final int ATTRIBUTION_ROW_HEIGHT = 30; private static final int STARTUP_ROW_HEIGHT = 28; private static final int ICON_SIZE = 16; private static final int GRAPH_TOP = 34; private static final int GRAPH_HEIGHT = 60; - private static final String[] TAB_NAMES = {"Tasks", "GPU", "Render", "Startup", "Memory", "Flame", "Timeline", "Network", "Disk", "World", "System", "Settings"}; + private static final String[] TAB_NAMES = {"Tasks", "GPU", "Render", "Startup", "Memory", "Flame", "Timeline", "Network", "Disk", "World", "Threads", "System", "Settings"}; private static final int ATTRIBUTION_TREND_WINDOW_SECONDS = 30; private static final int BG_COLOR = 0xE0101010; @@ -145,66 +159,96 @@ MemorySort next() { private static final int TAB_ACTIVE = 0xFF2A2A2A; private static final int TAB_INACTIVE = 0xFF161616; private static final int BORDER_COLOR = 0xFF3A3A3A; - private static final int TEXT_PRIMARY = 0xFFE0E0E0; - private static final int TEXT_DIM = 0xFF888888; - private static final int ACCENT_GREEN = 0xFF4CAF50; - private static final int ACCENT_YELLOW = 0xFFFFB300; + static final int TEXT_PRIMARY = 0xFFE0E0E0; + static final int TEXT_DIM = 0xFF888888; + static final int ACCENT_GREEN = 0xFF4CAF50; + static final int ACCENT_YELLOW = 0xFFFFB300; private static final int ACCENT_RED = 0xFFFF6666; - private static final int HEADER_COLOR = 0xFF222222; + static final int HEADER_COLOR = 0xFF222222; private static final int ROW_ALT = 0x11FFFFFF; private static final int PANEL_SOFT = 0x99161616; private static final int PANEL_OUTLINE = 0x55343434; private static final int AMD_COLOR = 0xFFFF6B6B; - private static final int INTEL_COLOR = 0xFF5EA9FF; + static final int INTEL_COLOR = 0xFF5EA9FF; private static final int NVIDIA_COLOR = 0xFF77DD77; private static int lastOpenedTab = 0; - - private int activeTab = 0; - private int scrollOffset = 0; - private String selectedTaskMod; - private String selectedGpuMod; - private String selectedMemoryMod; - private String selectedSharedFamily; - private ChunkPos selectedLagChunk; - private String tasksSearch = ""; - private String gpuSearch = ""; - private String memorySearch = ""; - private String startupSearch = ""; - private TableId focusedSearchTable; - private boolean startupSearchFocused; - private ColorSetting focusedColorSetting; - private String colorEditValue = ""; - private TaskSort taskSort = TaskSort.CPU; - private boolean taskSortDescending = true; - private GpuSort gpuSort = GpuSort.EST_GPU; - private boolean gpuSortDescending = true; - private MemorySort memorySort = MemorySort.MEMORY_MB; - private boolean memorySortDescending = true; - private StartupSort startupSort = StartupSort.ACTIVE; - private boolean startupSortDescending = true; - private WorldMiniTab worldMiniTab = WorldMiniTab.LAG_MAP; - private SystemMiniTab systemMiniTab = SystemMiniTab.OVERVIEW; - private GraphMetricTab cpuGraphMetricTab = GraphMetricTab.LOAD; - private GraphMetricTab gpuGraphMetricTab = GraphMetricTab.LOAD; - private boolean taskEffectiveView = true; - private boolean taskShowSharedRows; - private boolean gpuEffectiveView = true; - private boolean gpuShowSharedRows; - private boolean memoryEffectiveView = true; - private boolean memoryShowSharedRows; - private float uiScale = 1.0f; - private float uiOffsetX = 0.0f; - private float uiOffsetY = 0.0f; - private int layoutWidth; - private int layoutHeight; - private final List findingClickTargets = new ArrayList<>(); - private final List tooltipTargets = new ArrayList<>(); - private String selectedFindingKey; - private TableId activeDrilldownTable; - private boolean attributionHelpOpen; - private ProfilerManager.ProfilerSnapshot snapshot = ProfilerManager.getInstance().getCurrentSnapshot(); - private LagMapLayout lastRenderedLagMapLayout; - private boolean draggingHudTransparency; + private static final TabRenderer[] TAB_RENDERERS = new TabRenderer[] { + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderTasks(ctx, x, y, w, h, mouseX, mouseY), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderGpu(ctx, x, y, w, h, mouseX, mouseY), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderRender(ctx, x, y, w, h, mouseX, mouseY), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderStartup(ctx, x, y, w, h, mouseX, mouseY), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderMemory(ctx, x, y, w, h, mouseX, mouseY), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderFlamegraph(ctx, x, y, w, h), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderTimeline(ctx, x, y, w, h), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderNetwork(ctx, x, y, w, h), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderDisk(ctx, x, y, w, h), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderWorldTab(ctx, x, y, w, h), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderThreads(ctx, x, y, w, h, mouseX, mouseY), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderSystem(ctx, x, y, w, h), + (ctx, screen, x, y, w, h, mouseX, mouseY) -> screen.renderSettings(ctx, x, y, w, h, mouseX, mouseY) + }; + + int activeTab = 0; + int scrollOffset = 0; + String selectedTaskMod; + String selectedGpuMod; + String selectedMemoryMod; + long selectedThreadId = -1L; + String selectedSharedFamily; + ChunkPos selectedLagChunk; + String tasksSearch = ""; + String gpuSearch = ""; + String memorySearch = ""; + String startupSearch = ""; + String globalSearch = ""; + TableId focusedSearchTable; + boolean startupSearchFocused; + boolean globalSearchFocused; + ColorSetting focusedColorSetting; + String colorEditValue = ""; + TaskSort taskSort = TaskSort.CPU; + boolean taskSortDescending = true; + GpuSort gpuSort = GpuSort.EST_GPU; + boolean gpuSortDescending = true; + MemorySort memorySort = MemorySort.MEMORY_MB; + boolean memorySortDescending = true; + ThreadSort threadSort = ThreadSort.CPU; + boolean threadSortDescending = true; + StartupSort startupSort = StartupSort.ACTIVE; + boolean startupSortDescending = true; + WorldMiniTab worldMiniTab = WorldMiniTab.LAG_MAP; + SystemMiniTab systemMiniTab = SystemMiniTab.OVERVIEW; + GraphMetricTab cpuGraphMetricTab = GraphMetricTab.LOAD; + GraphMetricTab gpuGraphMetricTab = GraphMetricTab.LOAD; + boolean taskEffectiveView = true; + boolean taskShowSharedRows; + boolean gpuEffectiveView = true; + boolean gpuShowSharedRows; + boolean memoryEffectiveView = true; + boolean memoryShowSharedRows; + boolean threadFreeze; + float uiScale = 1.0f; + float uiOffsetX = 0.0f; + float uiOffsetY = 0.0f; + int layoutWidth; + int layoutHeight; + final List findingClickTargets = new ArrayList<>(); + final TooltipManager tooltipManager = new TooltipManager(); + final List spikePinClickTargets = new ArrayList<>(); + String selectedFindingKey; + TableId activeDrilldownTable; + boolean attributionHelpOpen; + ProfilerManager.ProfilerSnapshot snapshot = ProfilerManager.getInstance().getCurrentSnapshot(); + ProfilerManager.ProfilerSnapshot cachedAttributionSnapshot; + EffectiveCpuAttribution cachedEffectiveCpuAttribution; + EffectiveGpuAttribution cachedRawGpuAttribution; + EffectiveGpuAttribution cachedEffectiveGpuAttribution; + EffectiveMemoryAttribution cachedEffectiveMemoryAttribution; + long cachedRawCpuTotalMetric = 1L; + long cachedRawMemoryTotalBytes = 1L; + LagMapLayout lastRenderedLagMapLayout; + boolean draggingHudTransparency; + List frozenThreadRows = List.of(); public TaskManagerScreen() { this(lastOpenedTab); @@ -218,6 +262,7 @@ public TaskManagerScreen(int initialTab) { this.gpuSearch = ConfigManager.getGpuSearch(); this.memorySearch = ConfigManager.getMemorySearch(); this.startupSearch = ConfigManager.getStartupSearch(); + this.globalSearch = ConfigManager.getGlobalSearch(); try { this.taskSort = TaskSort.valueOf(ConfigManager.getTaskSort()); } catch (Exception ignored) { this.taskSort = TaskSort.CPU; } this.taskSortDescending = ConfigManager.isTaskSortDescending(); try { this.gpuSort = GpuSort.valueOf(ConfigManager.getGpuSort()); } catch (Exception ignored) { this.gpuSort = GpuSort.EST_GPU; } @@ -251,6 +296,7 @@ public void close() { ConfigManager.setGpuSearch(gpuSearch); ConfigManager.setMemorySearch(memorySearch); ConfigManager.setStartupSearch(startupSearch); + ConfigManager.setGlobalSearch(globalSearch); ConfigManager.setTaskSortState(taskSort.name(), taskSortDescending); ConfigManager.setGpuSortState(gpuSort.name(), gpuSortDescending); ConfigManager.setMemorySortState(memorySort.name(), memorySortDescending); @@ -264,8 +310,10 @@ public void close() { @Override public void render(DrawContext ctx, int mouseX, int mouseY, float delta) { snapshot = ProfilerManager.getInstance().getCurrentSnapshot(); + invalidateDerivedAttributionCacheIfSnapshotChanged(); findingClickTargets.clear(); - tooltipTargets.clear(); + tooltipManager.clear(); + spikePinClickTargets.clear(); updateUiScale(); int logicalMouseX = toLogicalX(mouseX); int logicalMouseY = toLogicalY(mouseY); @@ -285,6 +333,7 @@ public void render(DrawContext ctx, int mouseX, int mouseY, float delta) { renderModeButton(ctx, logicalMouseX, logicalMouseY); renderHudToggle(ctx, logicalMouseX, logicalMouseY); renderExportButton(ctx, logicalMouseX, logicalMouseY); + renderGlobalSearchBox(ctx, logicalMouseX, logicalMouseY, w); double clientTickMs = TickProfiler.getInstance().getAverageClientTickNs() / 1_000_000.0; double serverTickMs = TickProfiler.getInstance().getAverageServerTickNs() / 1_000_000.0; @@ -319,25 +368,10 @@ public void render(DrawContext ctx, int mouseX, int mouseY, float delta) { ctx.fill(0, contentY, w, h, PANEL_COLOR); ctx.fill(0, contentY, w, contentY + 1, BORDER_COLOR); - if (activeTab == 0) renderTasks(ctx, 0, contentY, w, contentH, logicalMouseX, logicalMouseY); - else if (activeTab == 1) renderGpu(ctx, 0, contentY, w, contentH, logicalMouseX, logicalMouseY); - else if (activeTab == 2) renderRender(ctx, 0, contentY, w, contentH, logicalMouseX, logicalMouseY); - else if (activeTab == 3) renderStartup(ctx, 0, contentY, w, contentH, logicalMouseX, logicalMouseY); - else if (activeTab == 4) renderMemory(ctx, 0, contentY, w, contentH, logicalMouseX, logicalMouseY); - else if (activeTab == 5) renderFlamegraph(ctx, 0, contentY, w, contentH); - else if (activeTab == 6) renderTimeline(ctx, 0, contentY, w, contentH); - else if (activeTab == 7) renderNetwork(ctx, 0, contentY, w, contentH); - else if (activeTab == 8) renderDisk(ctx, 0, contentY, w, contentH); - else if (activeTab == 9) renderWorldTab(ctx, 0, contentY, w, contentH); - else if (activeTab == 10) renderSystem(ctx, 0, contentY, w, contentH); - else renderSettings(ctx, 0, contentY, w, contentH, logicalMouseX, logicalMouseY); - - if (attributionHelpOpen) { - renderAttributionHelpOverlay(ctx, w, h, logicalMouseX, logicalMouseY); - } else if (activeDrilldownTable != null) { - renderRowDrilldownOverlay(ctx, w, h, logicalMouseX, logicalMouseY); - } + TAB_RENDERERS[Math.max(0, Math.min(TAB_RENDERERS.length - 1, activeTab))] + .render(ctx, this, 0, contentY, w, contentH, logicalMouseX, logicalMouseY); + ModalDialogRenderer.render(this, ctx, w, h, logicalMouseX, logicalMouseY); renderTooltipOverlay(ctx, logicalMouseX, logicalMouseY); ctx.getMatrices().popMatrix(); super.render(ctx, mouseX, mouseY, delta); @@ -380,7 +414,7 @@ private int getContentY() { return getTabY() + TAB_HEIGHT + 1; } - private void drawTopChip(DrawContext ctx, int x, int y, int width, int height, boolean hovered) { + void drawTopChip(DrawContext ctx, int x, int y, int width, int height, boolean hovered) { ctx.fill(x, y, x + width, y + height, hovered ? 0x66404040 : 0x3A202020); ctx.fill(x, y, x + width, y + 1, PANEL_OUTLINE); ctx.fill(x, y + height - 1, x + width, y + height, PANEL_OUTLINE); @@ -394,7 +428,7 @@ private void drawInsetPanel(DrawContext ctx, int x, int y, int width, int height ctx.fill(x + width - 1, y, x + width, y + height, PANEL_OUTLINE); } - private int renderSectionHeader(DrawContext ctx, int x, int y, String title, String subtitle) { + int renderSectionHeader(DrawContext ctx, int x, int y, String title, String subtitle) { ctx.drawText(textRenderer, title, x, y, TEXT_PRIMARY, false); if (subtitle != null && !subtitle.isBlank()) { ctx.drawText(textRenderer, textRenderer.trimToWidth(subtitle, Math.max(120, getScreenWidth() - (x * 2))), x, y + 12, TEXT_DIM, false); @@ -436,468 +470,39 @@ private void renderExportButton(DrawContext ctx, int mouseX, int mouseY) { ctx.drawText(textRenderer, "Export Session", x + 10, y + 3, hovered ? TEXT_PRIMARY : TEXT_DIM, false); } - private void renderTasks(DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { - Map cpu = snapshot.cpuMods(); - Map cpuDetails = snapshot.cpuDetails(); - Map invokes = snapshot.modInvokes(); - EffectiveCpuAttribution effectiveCpu = buildEffectiveCpuAttribution(cpu, invokes); - Map displayCpu = effectiveCpu.displaySnapshots(); - List rows = getTaskRows(displayCpu, cpuDetails, invokes, !taskEffectiveView && taskShowSharedRows); - - int detailW = Math.min(420, Math.max(320, w / 3)); - int gap = PADDING; - int listW = w - detailW - gap; - int infoY = y + PADDING; - int descriptionBottomY = renderWrappedText(ctx, x + PADDING, infoY, Math.max(260, listW - 16), taskEffectiveView ? "Effective CPU share by mod from rolling sampled stack windows. Shared/framework work is folded into concrete mods for comparison." : "Raw CPU ownership by mod from rolling sampled stack windows. Shared/framework buckets stay separate until you switch back to Effective view.", TEXT_DIM); - ctx.drawText(textRenderer, cpuStatusText(snapshot.cpuReady(), snapshot.totalCpuSamples(), snapshot.cpuSampleAgeMillis()), x + PADDING, descriptionBottomY + 2, getCpuStatusColor(snapshot.cpuReady()), false); - ctx.drawText(textRenderer, "Tip: Effective view proportionally folds shared/runtime work into mod rows for readability.", x + PADDING, descriptionBottomY + 12, TEXT_DIM, false); - addTooltip(x + PADDING, descriptionBottomY + 12, 420, 10, "Effective view proportionally folds shared/runtime work into concrete mods. Raw view keeps true-owned and shared buckets separate."); - int controlsY = descriptionBottomY + 26; - drawTopChip(ctx, x + PADDING, controlsY, 78, 16, false); - ctx.drawText(textRenderer, "CPU Graph", x + PADDING + 18, controlsY + 4, TEXT_DIM, false); - drawTopChip(ctx, x + PADDING + 84, controlsY, 98, 16, taskEffectiveView); - ctx.drawText(textRenderer, taskEffectiveView ? "Effective" : "Raw", x + PADDING + 110, controlsY + 4, taskEffectiveView ? TEXT_PRIMARY : TEXT_DIM, false); - addTooltip(x + PADDING + 84, controlsY, 98, 16, "Toggle between raw ownership and effective ownership with redistributed shared/framework samples."); - drawTopChip(ctx, x + PADDING + 188, controlsY, 112, 16, !taskEffectiveView && taskShowSharedRows); - ctx.drawText(textRenderer, taskShowSharedRows ? "Shared Rows" : "Hide Shared", x + PADDING + 204, controlsY + 4, (!taskEffectiveView && taskShowSharedRows) ? TEXT_PRIMARY : TEXT_DIM, false); - addTooltip(x + PADDING + 188, controlsY, 112, 16, "In Raw view, show or hide shared/jvm, shared/framework, and runtime rows. Effective view already folds them into mod rows."); - renderSearchBox(ctx, x + listW - 160, controlsY, 152, 16, "Search mods", tasksSearch, focusedSearchTable == TableId.TASKS); - renderResetButton(ctx, x + listW - 214, controlsY, 48, 16, hasTaskFilter()); - renderSortSummary(ctx, x + PADDING, controlsY + 22, "Sort", formatSort(taskSort, taskSortDescending), TEXT_DIM); - ctx.drawText(textRenderer, rows.size() + " rows", x + PADDING + 108, controlsY + 22, TEXT_DIM, false); - - if (!rows.isEmpty() && (selectedTaskMod == null || !rows.contains(selectedTaskMod))) { - selectedTaskMod = rows.getFirst(); - } - - int headerY = controlsY + 42; - ctx.fill(x, headerY, x + listW, headerY + 14, HEADER_COLOR); - ctx.drawText(textRenderer, headerLabel("MOD", taskSort == TaskSort.NAME, taskSortDescending), x + PADDING + ICON_SIZE + 6, headerY + 3, TEXT_DIM, false); - addTooltip(x + PADDING + ICON_SIZE + 6, headerY + 1, 44, 14, "Sort by mod display name."); - int pctX = x + listW - 206; - int threadsX = x + listW - 146; - int samplesX = x + listW - 92; - int invokesX = x + listW - 42; - if (isColumnVisible(TableId.TASKS, "cpu")) { ctx.drawText(textRenderer, headerLabel("%CPU", taskSort == TaskSort.CPU, taskSortDescending), pctX, headerY + 3, TEXT_DIM, false); addTooltip(pctX, headerY + 1, 42, 14, "Sampled CPU share from rolling stack windows."); } - if (isColumnVisible(TableId.TASKS, "threads")) { ctx.drawText(textRenderer, headerLabel("THREADS", taskSort == TaskSort.THREADS, taskSortDescending), threadsX, headerY + 3, TEXT_DIM, false); addTooltip(threadsX, headerY + 1, 58, 14, "Distinct sampled threads attributed to this mod."); } - if (isColumnVisible(TableId.TASKS, "samples")) { ctx.drawText(textRenderer, headerLabel("SAMPLES", taskSort == TaskSort.SAMPLES, taskSortDescending), samplesX, headerY + 3, TEXT_DIM, false); addTooltip(samplesX, headerY + 1, 56, 14, "Total CPU samples attributed in the rolling window."); } - if (isColumnVisible(TableId.TASKS, "invokes")) { ctx.drawText(textRenderer, headerLabel("INVOKES", taskSort == TaskSort.INVOKES, taskSortDescending), invokesX, headerY + 3, TEXT_DIM, false); addTooltip(invokesX, headerY + 1, 54, 14, "Tracked event invokes, shown separately from sampled CPU ownership."); } - - int listY = headerY + 16; - int listH = h - (listY - y); - if (rows.isEmpty()) { - ctx.drawText(textRenderer, tasksSearch.isBlank() ? "Waiting for CPU samples..." : "No task rows match the current search/filter.", x + PADDING, listY + 6, TEXT_DIM, false); - } else { - ctx.enableScissor(x, listY, x + listW, listY + listH); - int rowY = listY - scrollOffset; - int rowIdx = 0; - for (String modId : rows) { - if (rowY + ROW_HEIGHT > listY && rowY < listY + listH) { - renderStripedRow(ctx, x, listW, rowY, rowIdx, mouseX, mouseY); - if (modId.equals(selectedTaskMod)) { - ctx.fill(x, rowY, x + 3, rowY + ROW_HEIGHT, ACCENT_GREEN); - } - Identifier icon = ModIconCache.getInstance().getIcon(modId); - ctx.drawTexture(RenderPipelines.GUI_TEXTURED, icon, x + PADDING, rowY + 2, 0f, 0f, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE, 0xFFFFFFFF); - - CpuSamplingProfiler.Snapshot cpuSnapshot = displayCpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)); - CpuSamplingProfiler.DetailSnapshot detailSnapshot = cpuDetails.get(modId); - long invokesCount = invokes.getOrDefault(modId, new ModTimingSnapshot(0, 0)).calls(); - double pct = cpuSnapshot.totalSamples() * 100.0 / Math.max(1L, displayCpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum()); - int threadCount = detailSnapshot == null ? 0 : detailSnapshot.sampledThreadCount(); - - ctx.drawText(textRenderer, getDisplayName(modId), x + PADDING + ICON_SIZE + 6, rowY + 6, TEXT_PRIMARY, false); - if (isColumnVisible(TableId.TASKS, "cpu")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f%%", pct), pctX, rowY + 6, getHeatColor(pct), false); - if (isColumnVisible(TableId.TASKS, "threads")) ctx.drawText(textRenderer, Integer.toString(threadCount), threadsX, rowY + 6, TEXT_DIM, false); - if (isColumnVisible(TableId.TASKS, "samples")) ctx.drawText(textRenderer, formatCount(cpuSnapshot.totalSamples()), samplesX, rowY + 6, TEXT_DIM, false); - if (isColumnVisible(TableId.TASKS, "invokes")) ctx.drawText(textRenderer, formatCount(invokesCount), invokesX, rowY + 6, TEXT_DIM, false); - } - if (rowY > listY + listH) break; - rowY += ROW_HEIGHT; - rowIdx++; - } - ctx.disableScissor(); + private void renderGlobalSearchBox(DrawContext ctx, int mouseX, int mouseY, int screenWidth) { + int width = 176; + int height = 14; + int x = screenWidth - 438; + int y = 3; + renderSearchBox(ctx, x, y, width, height, "Search all tabs", globalSearch, globalSearchFocused); + if (!globalSearch.isBlank()) { + ctx.drawText(textRenderer, textRenderer.trimToWidth("All tabs", 48), x + width + 6, y + 3, TEXT_DIM, false); } + addTooltip(x, y, width, height, "Universal search travels across tabs. It combines with local tab filters instead of replacing them."); + } - renderCpuDetailPanel(ctx, x + listW + gap, y + PADDING, detailW, h - (PADDING * 2), selectedTaskMod, selectedTaskMod == null ? null : cpu.get(selectedTaskMod), selectedTaskMod == null ? null : effectiveCpu.displaySnapshots().get(selectedTaskMod), selectedTaskMod == null ? null : displayCpu.get(selectedTaskMod), selectedTaskMod == null ? 0L : effectiveCpu.redistributedSamplesByMod().getOrDefault(selectedTaskMod, 0L), selectedTaskMod == null ? null : cpuDetails.get(selectedTaskMod), selectedTaskMod == null ? null : invokes.get(selectedTaskMod), Math.max(1L, displayCpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum()), taskEffectiveView); + private void renderTasks(DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + TasksTabRenderer.render(this, ctx, x, y, w, h, mouseX, mouseY); } private void renderGpu(DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { - Map cpuDetails = snapshot.cpuDetails(); - EffectiveGpuAttribution rawGpu = buildEffectiveGpuAttribution(false); - EffectiveGpuAttribution displayGpu = buildEffectiveGpuAttribution(gpuEffectiveView); - List rows = getGpuRows(displayGpu, cpuDetails, !gpuEffectiveView && gpuShowSharedRows); - - int detailW = Math.min(420, Math.max(320, w / 3)); - int gap = PADDING; - int listW = w - detailW - gap; - int infoY = y + PADDING; - int descriptionBottomY = renderWrappedText(ctx, x + PADDING, infoY, Math.max(260, listW - 16), gpuEffectiveView ? "Estimated GPU share by tagged render phases with shared render work folded into concrete mods." : "Raw GPU ownership by tagged render phases. Shared render buckets stay separate until you switch back to Effective view.", TEXT_DIM); - ctx.drawText(textRenderer, cpuStatusText(snapshot.gpuReady(), displayGpu.totalRenderSamples(), snapshot.cpuSampleAgeMillis()), x + PADDING, descriptionBottomY + 2, getGpuStatusColor(snapshot.gpuReady()), false); - ctx.drawText(textRenderer, "Tip: phase ownership assigns direct GPU time first, then Effective view redistributes leftover shared render work.", x + PADDING, descriptionBottomY + 12, TEXT_DIM, false); - addTooltip(x + PADDING, descriptionBottomY + 12, 460, 10, "GPU rows now use tagged render-phase ownership. Effective view redistributes leftover shared render work into concrete mods for readability."); - int controlsY = descriptionBottomY + 26; - drawTopChip(ctx, x + PADDING, controlsY, 78, 16, false); - ctx.drawText(textRenderer, "GPU Graph", x + PADDING + 18, controlsY + 4, TEXT_DIM, false); - drawTopChip(ctx, x + PADDING + 84, controlsY, 98, 16, gpuEffectiveView); - ctx.drawText(textRenderer, gpuEffectiveView ? "Effective" : "Raw", x + PADDING + 110, controlsY + 4, gpuEffectiveView ? TEXT_PRIMARY : TEXT_DIM, false); - addTooltip(x + PADDING + 84, controlsY, 98, 16, "Toggle between raw tagged ownership and effective ownership with redistributed shared render work."); - drawTopChip(ctx, x + PADDING + 188, controlsY, 112, 16, !gpuEffectiveView && gpuShowSharedRows); - ctx.drawText(textRenderer, gpuShowSharedRows ? "Shared Rows" : "Hide Shared", x + PADDING + 204, controlsY + 4, (!gpuEffectiveView && gpuShowSharedRows) ? TEXT_PRIMARY : TEXT_DIM, false); - addTooltip(x + PADDING + 188, controlsY, 112, 16, "In Raw view, show or hide shared/render rows. Effective view already folds them into mod rows."); - renderSearchBox(ctx, x + listW - 160, controlsY, 152, 16, "Search mods", gpuSearch, focusedSearchTable == TableId.GPU); - renderResetButton(ctx, x + listW - 214, controlsY, 48, 16, hasGpuFilter()); - renderSortSummary(ctx, x + PADDING, controlsY + 22, "Sort", formatSort(gpuSort, gpuSortDescending), TEXT_DIM); - ctx.drawText(textRenderer, rows.size() + " rows", x + PADDING + 108, controlsY + 22, TEXT_DIM, false); - - if (displayGpu.totalGpuNanos() <= 0L) { - ctx.drawText(textRenderer, "No GPU attribution yet. Render some frames with timer queries enabled.", x + PADDING, infoY + 52, TEXT_DIM, false); - renderGpuDetailPanel(ctx, x + listW + gap, y + PADDING, detailW, h - (PADDING * 2), selectedGpuMod, 0L, 0L, 0L, 0L, 0L, 0L, selectedGpuMod == null ? null : cpuDetails.get(selectedGpuMod), displayGpu.totalRenderSamples(), displayGpu.totalGpuNanos(), gpuEffectiveView); - return; - } - - if (!rows.isEmpty() && (selectedGpuMod == null || !rows.contains(selectedGpuMod))) { - selectedGpuMod = rows.getFirst(); - } - - int headerY = controlsY + 42; - ctx.fill(x, headerY, x + listW, headerY + 14, HEADER_COLOR); - ctx.drawText(textRenderer, headerLabel("MOD", gpuSort == GpuSort.NAME, gpuSortDescending), x + PADDING + ICON_SIZE + 6, headerY + 3, TEXT_DIM, false); - addTooltip(x + PADDING + ICON_SIZE + 6, headerY + 1, 44, 14, "Sort by mod display name."); - int pctX = x + listW - 232; - int threadsX = x + listW - 172; - int gpuMsX = x + listW - 108; - int renderSamplesX = x + listW - 42; - if (isColumnVisible(TableId.GPU, "pct")) { ctx.drawText(textRenderer, headerLabel("EST %GPU", gpuSort == GpuSort.EST_GPU, gpuSortDescending), pctX, headerY + 3, TEXT_DIM, false); addTooltip(pctX, headerY + 1, 58, 14, gpuEffectiveView ? "Tagged GPU share in the effective ownership view." : "Tagged GPU share in the raw ownership view."); } - if (isColumnVisible(TableId.GPU, "threads")) { ctx.drawText(textRenderer, headerLabel("THREADS", gpuSort == GpuSort.THREADS, gpuSortDescending), threadsX, headerY + 3, TEXT_DIM, false); addTooltip(threadsX, headerY + 1, 58, 14, "Distinct sampled render threads contributing to this row."); } - if (isColumnVisible(TableId.GPU, "gpums")) { ctx.drawText(textRenderer, headerLabel("Est ms", gpuSort == GpuSort.GPU_MS, gpuSortDescending), gpuMsX, headerY + 3, TEXT_DIM, false); addTooltip(gpuMsX, headerY + 1, 48, 14, "Attributed GPU milliseconds in the rolling window."); } - if (isColumnVisible(TableId.GPU, "rsamples")) { ctx.drawText(textRenderer, headerLabel("R.S", gpuSort == GpuSort.RENDER_SAMPLES, gpuSortDescending), renderSamplesX, headerY + 3, TEXT_DIM, false); addTooltip(renderSamplesX, headerY + 1, 26, 14, "Render samples attributed to this row."); } - - int listY = headerY + 16; - int listH = h - (listY - y); - if (rows.isEmpty()) { - ctx.drawText(textRenderer, gpuSearch.isBlank() ? "Waiting for render-thread samples..." : "No GPU rows match the current search/filter.", x + PADDING, listY + 6, TEXT_DIM, false); - } else { - ctx.enableScissor(x, listY, x + listW, listY + listH); - int rowY = listY - scrollOffset; - int rowIdx = 0; - for (String modId : rows) { - if (rowY + ROW_HEIGHT > listY && rowY < listY + listH) { - renderStripedRow(ctx, x, listW, rowY, rowIdx, mouseX, mouseY); - if (modId.equals(selectedGpuMod)) { - ctx.fill(x, rowY, x + 3, rowY + ROW_HEIGHT, ACCENT_GREEN); - } - long gpuNanos = displayGpu.gpuNanosByMod().getOrDefault(modId, 0L); - long renderSamples = displayGpu.renderSamplesByMod().getOrDefault(modId, 0L); - CpuSamplingProfiler.DetailSnapshot detailSnapshot = cpuDetails.get(modId); - double pct = gpuNanos * 100.0 / Math.max(1L, displayGpu.totalGpuNanos()); - double gpuMs = gpuNanos / 1_000_000.0; - int threadCount = detailSnapshot == null ? 0 : detailSnapshot.sampledThreadCount(); - - Identifier icon = ModIconCache.getInstance().getIcon(modId); - ctx.drawTexture(RenderPipelines.GUI_TEXTURED, icon, x + PADDING, rowY + 2, 0f, 0f, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE, 0xFFFFFFFF); - ctx.drawText(textRenderer, getDisplayName(modId), x + PADDING + ICON_SIZE + 6, rowY + 6, TEXT_PRIMARY, false); - if (isColumnVisible(TableId.GPU, "pct")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f%%", pct), pctX, rowY + 6, getHeatColor(pct), false); - if (isColumnVisible(TableId.GPU, "threads")) ctx.drawText(textRenderer, Integer.toString(threadCount), threadsX, rowY + 6, TEXT_DIM, false); - if (isColumnVisible(TableId.GPU, "gpums")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.2f", gpuMs), gpuMsX, rowY + 6, TEXT_DIM, false); - if (isColumnVisible(TableId.GPU, "rsamples")) ctx.drawText(textRenderer, formatCount(renderSamples), renderSamplesX, rowY + 6, TEXT_DIM, false); - } - if (rowY > listY + listH) break; - rowY += ROW_HEIGHT; - rowIdx++; - } - ctx.disableScissor(); - } - - renderGpuDetailPanel(ctx, x + listW + gap, y + PADDING, detailW, h - (PADDING * 2), selectedGpuMod, - selectedGpuMod == null ? 0L : rawGpu.gpuNanosByMod().getOrDefault(selectedGpuMod, 0L), - selectedGpuMod == null ? 0L : displayGpu.gpuNanosByMod().getOrDefault(selectedGpuMod, 0L), - selectedGpuMod == null ? 0L : rawGpu.renderSamplesByMod().getOrDefault(selectedGpuMod, 0L), - selectedGpuMod == null ? 0L : displayGpu.renderSamplesByMod().getOrDefault(selectedGpuMod, 0L), - selectedGpuMod == null ? 0L : displayGpu.redistributedGpuNanosByMod().getOrDefault(selectedGpuMod, 0L), - selectedGpuMod == null ? 0L : displayGpu.redistributedRenderSamplesByMod().getOrDefault(selectedGpuMod, 0L), - selectedGpuMod == null ? null : cpuDetails.get(selectedGpuMod), displayGpu.totalRenderSamples(), displayGpu.totalGpuNanos(), gpuEffectiveView); + GpuTabRenderer.render(this, ctx, x, y, w, h, mouseX, mouseY); } private void renderRender(DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { - List phases = new ArrayList<>(snapshot.renderPhases().keySet()); - if (phases.isEmpty()) { - ctx.drawText(textRenderer, "No render data.", x + PADDING, y + PADDING + 4, TEXT_DIM, false); - return; - } - - long totalCpuNanos = snapshot.renderPhases().values().stream().mapToLong(RenderPhaseProfiler.PhaseSnapshot::cpuNanos).sum(); - ctx.drawText(textRenderer, "Owner shows the tagged mod bucket used by GPU attribution. Shared / Render means the phase still falls back to the shared render pool.", x + PADDING, y + PADDING, TEXT_DIM, false); - - int headerY = y + PADDING + 18; - ctx.fill(x, headerY, x + w, headerY + 14, HEADER_COLOR); - ctx.drawText(textRenderer, "PHASE", x + PADDING, headerY + 3, TEXT_DIM, false); - addTooltip(x + PADDING, headerY + 1, 44, 14, "Render phase name."); - int ownerX = w - 300; - int shareX = w - 175; - int cpuMsX = w - 120; - int gpuMsX = w - 72; - int callsX = w - 34; - ctx.drawText(textRenderer, "OWNER", ownerX, headerY + 3, TEXT_DIM, false); - addTooltip(ownerX, headerY + 1, 42, 14, "Tagged owner mod for this phase. The GPU tab uses this first before redistributing any leftover shared render work."); - ctx.drawText(textRenderer, "%CPU", shareX, headerY + 3, TEXT_DIM, false); - addTooltip(shareX, headerY + 1, 38, 14, "CPU share of this render phase in the current window."); - ctx.drawText(textRenderer, "CPU", cpuMsX, headerY + 3, TEXT_DIM, false); - addTooltip(cpuMsX, headerY + 1, 28, 14, "Average CPU milliseconds per call for this phase."); - ctx.drawText(textRenderer, "GPU", gpuMsX, headerY + 3, TEXT_DIM, false); - addTooltip(gpuMsX, headerY + 1, 28, 14, "Average GPU milliseconds per call when timer queries are available."); - ctx.drawText(textRenderer, "C", callsX, headerY + 3, TEXT_DIM, false); - addTooltip(callsX, headerY + 1, 12, 14, "Approximate call count for this phase in the rolling window."); - - int listY = headerY + 16; - int listH = h - (listY - y); - ctx.enableScissor(x, listY, x + w, listY + listH); - - int rowY = listY - scrollOffset; - int rowIdx = 0; - for (String phase : phases) { - if (rowY + ROW_HEIGHT > listY && rowY < listY + listH) { - renderStripedRow(ctx, x, w, rowY, rowIdx, mouseX, mouseY); - RenderPhaseProfiler.PhaseSnapshot phaseSnapshot = snapshot.renderPhases().get(phase); - long phaseCalls = Math.max(phaseSnapshot.cpuCalls(), phaseSnapshot.gpuCalls()); - double pct = totalCpuNanos > 0 ? phaseSnapshot.cpuNanos() * 100.0 / totalCpuNanos : 0; - double avgCpuMs = phaseCalls > 0 ? (phaseSnapshot.cpuNanos() / 1_000_000.0) / phaseCalls : 0; - double avgGpuMs = phaseCalls > 0 ? (phaseSnapshot.gpuNanos() / 1_000_000.0) / phaseCalls : 0; - String owner = phaseSnapshot.ownerMod() == null || phaseSnapshot.ownerMod().isBlank() ? "shared/render" : phaseSnapshot.ownerMod(); - String phaseLabel = textRenderer.trimToWidth(phase, Math.max(120, ownerX - (x + PADDING) - 8)); - - ctx.drawText(textRenderer, phaseLabel, x + PADDING, rowY + 6, TEXT_PRIMARY, false); - ctx.drawText(textRenderer, textRenderer.trimToWidth(getDisplayName(owner), Math.max(70, shareX - ownerX - 6)), ownerX, rowY + 6, isSharedAttributionBucket(owner) ? TEXT_DIM : TEXT_PRIMARY, false); - ctx.drawText(textRenderer, String.format("%.1f%%", pct), shareX, rowY + 6, getHeatColor(pct), false); - ctx.drawText(textRenderer, String.format("%.2f", avgCpuMs), cpuMsX, rowY + 6, TEXT_DIM, false); - ctx.drawText(textRenderer, phaseSnapshot.gpuNanos() > 0 ? String.format("%.2f", avgGpuMs) : "-", gpuMsX, rowY + 6, TEXT_DIM, false); - ctx.drawText(textRenderer, formatCount(phaseCalls), callsX, rowY + 6, TEXT_DIM, false); - } - if (rowY > listY + listH) break; - rowY += ROW_HEIGHT; - rowIdx++; - } - ctx.disableScissor(); + RenderTabRenderer.render(this, ctx, x, y, w, h, mouseX, mouseY); } private void renderStartup(DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { - beginFullPageScissor(ctx, x, y, w, h); - int left = x + PADDING; - int top = getFullPageScrollTop(y); - boolean measuredEntrypoints = snapshot.startupRows().stream().anyMatch(StartupTimingProfiler.StartupRow::measuredEntrypoints); - String startupIntro = measuredEntrypoints - ? "Measured Fabric startup activity by mod in explicit wall-clock milliseconds. Search, sort, and compare entrypoint timing here." - : "Observed startup registration timing by mod in explicit wall-clock milliseconds. Search and sort rows to isolate slow paths."; - top = renderSectionHeader(ctx, left, top, "Startup", startupIntro); - - java.util.List rows = getStartupRows(); - long totalSpan = Math.max(snapshot.startupLast() - snapshot.startupFirst(), 1); - int searchY = top; - renderSearchBox(ctx, x + w - 160, searchY, 152, 16, "Search mods", startupSearch, startupSearchFocused); - renderResetButton(ctx, x + w - 214, searchY, 48, 16, hasStartupFilter()); - int sortY = searchY + 20; - renderSortSummary(ctx, left, sortY + 4, "Sort", formatSort(startupSort, startupSortDescending), TEXT_DIM); - ctx.drawText(textRenderer, rows.size() + " mods", left + 132, sortY + 4, TEXT_DIM, false); - - int headerY = sortY + 20; - ctx.fill(x, headerY, x + w, headerY + 14, HEADER_COLOR); - int regsX = x + w - 34; - int epX = regsX - 30; - int activeMsX = epX - 62; - int endMsX = activeMsX - 56; - int startMsX = endMsX - 56; - int barW = Math.max(110, Math.min(180, w / 8)); - int barX = startMsX - barW - 22; - int nameW = Math.max(150, barX - (left + ICON_SIZE + 16)); - ctx.drawText(textRenderer, headerLabel("MOD", startupSort == StartupSort.NAME, startupSortDescending), left + ICON_SIZE + 6, headerY + 3, TEXT_DIM, false); - addTooltip(left + ICON_SIZE + 6, headerY + 1, 44, 14, "Sort by mod display name."); - ctx.drawText(textRenderer, "TIMELINE", barX, headerY + 3, TEXT_DIM, false); - addTooltip(barX, headerY + 1, 64, 14, "Observed startup span across the global startup window."); - ctx.drawText(textRenderer, headerLabel("START", startupSort == StartupSort.START, startupSortDescending), startMsX, headerY + 3, TEXT_DIM, false); - addTooltip(startMsX, headerY + 1, 42, 14, "Milliseconds from startup begin until this mod first became active."); - ctx.drawText(textRenderer, headerLabel("END", startupSort == StartupSort.END, startupSortDescending), endMsX, headerY + 3, TEXT_DIM, false); - addTooltip(endMsX, headerY + 1, 34, 14, "Milliseconds from startup begin until this mod last appeared active."); - ctx.drawText(textRenderer, headerLabel("ACTIVE", startupSort == StartupSort.ACTIVE, startupSortDescending), activeMsX, headerY + 3, TEXT_DIM, false); - addTooltip(activeMsX, headerY + 1, 48, 14, "Measured active wall-clock milliseconds attributed to this mod."); - ctx.drawText(textRenderer, headerLabel("EP", startupSort == StartupSort.ENTRYPOINTS, startupSortDescending), epX, headerY + 3, TEXT_DIM, false); - addTooltip(epX, headerY + 1, 18, 14, "Entrypoint count observed for this mod."); - ctx.drawText(textRenderer, headerLabel("REG", startupSort == StartupSort.REGISTRATIONS, startupSortDescending), regsX, headerY + 3, TEXT_DIM, false); - addTooltip(regsX, headerY + 1, 24, 14, "Registration events observed during startup fallback timing."); - - int listY = headerY + 16; - int listH = h - (listY - y) - 16; - if (rows.isEmpty()) { - ctx.drawText(textRenderer, startupSearch.isBlank() ? "No startup data captured yet." : "No startup rows match the current search/filter.", left, listY + 6, TEXT_DIM, false); - } else { - ctx.enableScissor(x, listY, x + w, listY + listH); - int rowY = listY - scrollOffset; - int rowIdx = 0; - for (StartupTimingProfiler.StartupRow row : rows) { - if (rowY + STARTUP_ROW_HEIGHT > listY && rowY < listY + listH) { - renderStripedRowVariable(ctx, x, w, rowY, STARTUP_ROW_HEIGHT, rowIdx, mouseX, mouseY); - Identifier icon = ModIconCache.getInstance().getIcon(row.modId()); - ctx.drawTexture(RenderPipelines.GUI_TEXTURED, icon, left, rowY + 5, 0f, 0f, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE, 0xFFFFFFFF); - ctx.drawText(textRenderer, textRenderer.trimToWidth(getDisplayName(row.modId()), nameW), left + ICON_SIZE + 6, rowY + 3, TEXT_PRIMARY, false); - String startupMeta = row.measuredEntrypoints() ? row.stageSummary() : "fallback registration timing"; - String startupHint = row.definitionSummary().isBlank() ? startupMeta : (startupMeta + " | " + row.definitionSummary()); - ctx.drawText(textRenderer, textRenderer.trimToWidth(startupHint, nameW), left + ICON_SIZE + 6, rowY + 14, TEXT_DIM, false); - - int barStart = (int) ((row.first() - snapshot.startupFirst()) * barW / totalSpan); - int barLen = Math.max(1, (int) ((row.last() - row.first()) * barW / totalSpan)); - ctx.fill(barX, rowY + 11, barX + barW, rowY + 16, 0x33FFFFFF); - ctx.fill(barX + barStart, rowY + 10, Math.min(barX + barW, barX + barStart + barLen), rowY + 17, ACCENT_YELLOW); - - double startMs = (row.first() - snapshot.startupFirst()) / 1_000_000.0; - double endMs = (row.last() - snapshot.startupFirst()) / 1_000_000.0; - double activeMs = row.activeNanos() / 1_000_000.0; - ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f", startMs), startMsX, rowY + 8, TEXT_DIM, false); - ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f", endMs), endMsX, rowY + 8, TEXT_DIM, false); - ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f", activeMs), activeMsX, rowY + 8, ACCENT_YELLOW, false); - ctx.drawText(textRenderer, String.valueOf(row.entrypoints()), epX, rowY + 8, TEXT_DIM, false); - ctx.drawText(textRenderer, String.valueOf(row.registrations()), regsX, rowY + 8, TEXT_DIM, false); - } - if (rowY > listY + listH) break; - rowY += STARTUP_ROW_HEIGHT; - rowIdx++; - } - ctx.disableScissor(); - } - - ctx.fill(x, y + h - 14, x + w, y + h, HEADER_COLOR); - ctx.drawText(textRenderer, String.format(Locale.ROOT, "Startup span %.1f ms | %d mods | %s", totalSpan / 1_000_000.0, snapshot.startupRows().size(), measuredEntrypoints ? "measured entrypoints" : "fallback registration path"), left, y + h - 10, TEXT_DIM, false); - ctx.disableScissor(); + StartupTabRenderer.render(this, ctx, x, y, w, h, mouseX, mouseY); } private void renderMemory(DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { - MemoryProfiler.Snapshot memory = snapshot.memory(); - Map rawMemoryMods = snapshot.memoryMods(); - Map sharedFamilies = snapshot.sharedMemoryFamilies(); - Map> sharedFamilyClasses = snapshot.sharedFamilyClasses(); - Map> memoryClassesByMod = snapshot.memoryClassesByMod(); - EffectiveMemoryAttribution effectiveMemory = buildEffectiveMemoryAttribution(rawMemoryMods); - Map memoryMods = memoryEffectiveView ? effectiveMemory.displayBytes() : rawMemoryMods; - - if (selectedSharedFamily == null && !sharedFamilies.isEmpty()) { - selectedSharedFamily = sharedFamilies.keySet().iterator().next(); - } - - List rows = getMemoryRows(memoryMods, memoryClassesByMod, !memoryEffectiveView && memoryShowSharedRows); - if (selectedMemoryMod == null || !rows.contains(selectedMemoryMod)) { - selectedMemoryMod = rows.isEmpty() ? null : rows.getFirst(); - } - - int sharedPanelW = sharedFamilies.isEmpty() ? 0 : Math.min(280, Math.max(220, w / 4)); - int detailH = 116; - int panelGap = sharedPanelW > 0 ? PADDING : 0; - int tableW = w - sharedPanelW - panelGap; - int left = x + PADDING; - int top = y + PADDING; - int descriptionBottomY = renderWrappedText(ctx, left, top, Math.max(260, tableW - 16), memoryEffectiveView ? "Effective live heap by mod with shared/runtime buckets folded into concrete mods for comparison. Updated asynchronously." : "Raw live heap by owner/class family. Shared/runtime buckets stay separate until you switch back to Effective view.", TEXT_DIM); - ctx.drawText(textRenderer, memoryStatusText(snapshot.memoryAgeMillis()), left, descriptionBottomY + 2, snapshot.memoryAgeMillis() <= 15000 ? ACCENT_GREEN : ACCENT_YELLOW, false); - - long heapMax = memory.heapMaxBytes() > 0 ? memory.heapMaxBytes() : memory.heapCommittedBytes(); - double usedPct = heapMax > 0 ? (memory.heapUsedBytes() * 100.0 / heapMax) : 0; - - ctx.drawText(textRenderer, "Tip: Effective view proportionally folds shared/runtime memory into mod rows for readability.", left, descriptionBottomY + 12, TEXT_DIM, false); - addTooltip(left, descriptionBottomY + 12, 430, 10, "Effective view proportionally folds shared/runtime memory into concrete mods. Raw view keeps true-owned and shared buckets separate."); - int controlsTopY = descriptionBottomY + 26; - drawTopChip(ctx, x + tableW - 222, controlsTopY, 112, 16, !memoryEffectiveView && memoryShowSharedRows); - ctx.drawText(textRenderer, memoryShowSharedRows ? "Shared Rows" : "Hide Shared", x + tableW - 206, controlsTopY + 4, (!memoryEffectiveView && memoryShowSharedRows) ? TEXT_PRIMARY : TEXT_DIM, false); - addTooltip(x + tableW - 222, controlsTopY, 112, 16, "In Raw view, show or hide shared/jvm, shared/framework, and runtime rows. Effective view already folds them into mod rows."); - drawTopChip(ctx, x + tableW - 112, controlsTopY, 98, 16, memoryEffectiveView); - ctx.drawText(textRenderer, memoryEffectiveView ? "Effective" : "Raw", x + tableW - 86, controlsTopY + 4, memoryEffectiveView ? TEXT_PRIMARY : TEXT_DIM, false); - addTooltip(x + tableW - 112, controlsTopY, 98, 16, "Toggle between raw memory ownership and effective ownership with redistributed shared/runtime memory."); - drawTopChip(ctx, x + tableW - 106, controlsTopY + 18, 98, 16, false); - ctx.drawText(textRenderer, "Memory Graph", x + tableW - 92, controlsTopY + 22, TEXT_DIM, false); - - int controlsY = controlsTopY + 42; - - renderSearchBox(ctx, x + tableW - 160, controlsY, 152, 16, "Search mods", memorySearch, focusedSearchTable == TableId.MEMORY); - renderResetButton(ctx, x + tableW - 214, controlsY, 48, 16, hasMemoryFilter()); - renderSortSummary(ctx, left, controlsY + 4, "Sort", formatSort(memorySort, memorySortDescending), TEXT_DIM); - ctx.drawText(textRenderer, rows.size() + " rows", left + 108, controlsY + 4, TEXT_DIM, false); - - int barY = controlsY + 24; - int barW = Math.min(320, tableW - (PADDING * 2)); - ctx.fill(left, barY, left + barW, barY + 10, 0x33FFFFFF); - ctx.fill(left, barY, left + (int) (barW * Math.min(1.0, usedPct / 100.0)), barY + 10, usedPct > 85 ? 0x99FF4444 : usedPct > 70 ? 0x99FFB300 : 0x994CAF50); - - ctx.drawText(textRenderer, String.format(Locale.ROOT, "Heap used %.1f MB / allocated %.1f MB | Non-heap %.1f MB | GC %d (%d ms)", - memory.heapUsedBytes() / (1024.0 * 1024.0), - memory.heapCommittedBytes() / (1024.0 * 1024.0), - memory.nonHeapUsedBytes() / (1024.0 * 1024.0), - memory.gcCount(), - memory.gcTimeMillis()), left, barY + 16, TEXT_PRIMARY, false); - ctx.drawText(textRenderer, String.format(Locale.ROOT, "Young GC %d | Old/Full GC %d | Last pause %d ms | Last GC %s", - memory.youngGcCount(), - memory.oldGcCount(), - memory.gcPauseDurationMs(), - memory.gcType()), left, barY + 28, TEXT_DIM, false); - ctx.drawText(textRenderer, String.format(Locale.ROOT, "Off-heap direct %.1f MB / %s", - (memory.directBufferBytes() + memory.mappedBufferBytes()) / (1024.0 * 1024.0), - formatBytesMb(memory.directMemoryMaxBytes())), left, barY + 40, TEXT_DIM, false); - - if (sharedPanelW > 0) { - renderSharedFamiliesPanel(ctx, x + tableW + panelGap, y + PADDING, sharedPanelW, h - (PADDING * 2), sharedFamilies); - } - - if (rows.isEmpty()) { - ctx.drawText(textRenderer, memorySearch.isBlank() ? "Per-mod memory attribution is still warming up. Open Memory Graph for live JVM totals in the meantime." : "No memory rows match the current search/filter.", left, barY + 48, TEXT_DIM, false); - return; - } - - int headerY = barY + 58; - ctx.fill(x, headerY, x + tableW, headerY + 14, HEADER_COLOR); - ctx.drawText(textRenderer, "MOD", x + PADDING + ICON_SIZE + 6, headerY + 3, TEXT_DIM, false); - addTooltip(x + PADDING + ICON_SIZE + 6, headerY + 1, 44, 14, "Sort by mod display name."); - int classesX = x + tableW - 140; - int mbX = x + tableW - 94; - int pctX = x + tableW - 42; - if (isColumnVisible(TableId.MEMORY, "classes")) { ctx.drawText(textRenderer, headerLabel("CLS", memorySort == MemorySort.CLASS_COUNT, memorySortDescending), classesX, headerY + 3, TEXT_DIM, false); addTooltip(classesX, headerY + 1, 28, 14, "Distinct live class families attributed to this mod."); } - if (isColumnVisible(TableId.MEMORY, "mb")) { ctx.drawText(textRenderer, headerLabel("MB", memorySort == MemorySort.MEMORY_MB, memorySortDescending), mbX, headerY + 3, TEXT_DIM, false); addTooltip(mbX, headerY + 1, 22, 14, "Attributed live heap in megabytes."); } - if (isColumnVisible(TableId.MEMORY, "pct")) { ctx.drawText(textRenderer, headerLabel("%", memorySort == MemorySort.PERCENT, memorySortDescending), pctX, headerY + 3, TEXT_DIM, false); addTooltip(pctX, headerY + 1, 16, 14, memoryEffectiveView ? "Share of effective attributed live heap." : "Share of raw attributed live heap."); } - - long totalAttributedBytes = Math.max(1, memoryMods.values().stream().mapToLong(Long::longValue).sum()); - int listY = headerY + 16; - int listH = h - (listY - y) - detailH; - ctx.enableScissor(x, listY, x + tableW, listY + listH); - - int rowY = listY - scrollOffset; - int rowIdx = 0; - for (String modId : rows) { - long bytes = memoryMods.getOrDefault(modId, 0L); - if (rowY + ROW_HEIGHT > listY && rowY < listY + listH) { - renderStripedRow(ctx, x, tableW, rowY, rowIdx, mouseX, mouseY); - if (modId.equals(selectedMemoryMod)) { - ctx.fill(x, rowY, x + 3, rowY + ROW_HEIGHT, ACCENT_GREEN); - } - double mb = bytes / (1024.0 * 1024.0); - double pct = bytes * 100.0 / totalAttributedBytes; - int classCount = memoryClassesByMod.getOrDefault(modId, Map.of()).size(); - - Identifier icon = ModIconCache.getInstance().getIcon(modId); - ctx.drawTexture(RenderPipelines.GUI_TEXTURED, icon, x + PADDING, rowY + 2, 0f, 0f, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE, 0xFFFFFFFF); - ctx.drawText(textRenderer, getDisplayName(modId), x + PADDING + ICON_SIZE + 6, rowY + 6, TEXT_PRIMARY, false); - if (isColumnVisible(TableId.MEMORY, "classes")) ctx.drawText(textRenderer, Integer.toString(classCount), classesX, rowY + 6, TEXT_DIM, false); - if (isColumnVisible(TableId.MEMORY, "mb")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f", mb), mbX, rowY + 6, TEXT_DIM, false); - if (isColumnVisible(TableId.MEMORY, "pct")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f%%", pct), pctX, rowY + 6, getHeatColor(pct), false); - } - if (rowY > listY + listH) break; - rowY += ROW_HEIGHT; - rowIdx++; - } - ctx.disableScissor(); - - if (detailH > 0) { - if (selectedMemoryMod != null) { - renderMemoryDetailPanel(ctx, x, y + h - detailH, tableW, detailH, selectedMemoryMod, rawMemoryMods.getOrDefault(selectedMemoryMod, 0L), effectiveMemory.displayBytes().getOrDefault(selectedMemoryMod, rawMemoryMods.getOrDefault(selectedMemoryMod, 0L)), memoryMods.getOrDefault(selectedMemoryMod, 0L), memoryClassesByMod.getOrDefault(selectedMemoryMod, Map.of()), effectiveMemory.redistributedBytesByMod().getOrDefault(selectedMemoryMod, 0L), totalAttributedBytes, memoryEffectiveView); - } else { - renderSharedFamilyDetail(ctx, x, y + h - detailH, tableW, detailH, sharedFamilyClasses.getOrDefault(selectedSharedFamily, Map.of())); - } - } + MemoryTabRenderer.render(this, ctx, x, y, w, h, mouseX, mouseY); } - private void renderCpuDetailPanel(DrawContext ctx, int x, int y, int width, int height, String modId, CpuSamplingProfiler.Snapshot rawCpuSnapshot, CpuSamplingProfiler.Snapshot effectiveCpuSnapshot, CpuSamplingProfiler.Snapshot displayCpuSnapshot, long redistributedSamples, CpuSamplingProfiler.DetailSnapshot detail, ModTimingSnapshot invokeSnapshot, long totalCpuSamples, boolean effectiveView) { + void renderCpuDetailPanel(DrawContext ctx, int x, int y, int width, int height, String modId, CpuSamplingProfiler.Snapshot rawCpuSnapshot, CpuSamplingProfiler.Snapshot effectiveCpuSnapshot, CpuSamplingProfiler.Snapshot displayCpuSnapshot, long redistributedSamples, CpuSamplingProfiler.DetailSnapshot detail, ModTimingSnapshot invokeSnapshot, long totalCpuMetric, boolean effectiveView) { drawInsetPanel(ctx, x, y, width, height); if (modId == null || displayCpuSnapshot == null) { ctx.drawText(textRenderer, "Select a row to inspect sampled CPU causes.", x + 8, y + 8, TEXT_DIM, false); @@ -909,13 +514,17 @@ private void renderCpuDetailPanel(DrawContext ctx, int x, int y, int width, int ctx.drawText(textRenderer, textRenderer.trimToWidth(getDisplayName(modId), width - 112), x + 8, y + 8, TEXT_PRIMARY, false); long rawSamples = rawCpuSnapshot == null ? 0L : rawCpuSnapshot.totalSamples(); long effectiveSamples = effectiveCpuSnapshot == null ? rawSamples : effectiveCpuSnapshot.totalSamples(); - double pct = displayCpuSnapshot.totalSamples() * 100.0 / Math.max(1L, totalCpuSamples); + double pct = cpuMetricValue(displayCpuSnapshot) * 100.0 / Math.max(1L, totalCpuMetric); int rowY = renderWrappedText(ctx, x + 8, y + 20, width - 16, String.format(Locale.ROOT, "%s view | %.1f%% CPU | %s threads | %s shown samples | %s invokes", effectiveView ? "Effective" : "Raw", pct, detail == null ? 0 : detail.sampledThreadCount(), formatCount(displayCpuSnapshot.totalSamples()), formatCount(invokeSnapshot == null ? 0 : invokeSnapshot.calls())), TEXT_DIM); rowY = renderWrappedText(ctx, x + 8, rowY, width - 16, "Raw owned samples: " + formatCount(rawSamples) + " | Effective samples: " + formatCount(effectiveSamples), TEXT_DIM); if (redistributedSamples > 0) { rowY = renderWrappedText(ctx, x + 8, rowY, width - 16, "Redistributed shared/framework samples: " + formatCount(redistributedSamples), TEXT_DIM); } rowY = renderWrappedText(ctx, x + 8, rowY, width - 16, effectiveView ? "Attribution: sampled stack ownership with shared/framework time proportionally folded into concrete mods [measured/inferred]" : "Attribution: sampled stack ownership without redistribution. Shared/framework rows remain separate [measured/inferred]", TEXT_DIM) + 6; + String attributionHint = cpuAttributionHint(modId, detail, rawSamples, displayCpuSnapshot.totalSamples(), redistributedSamples); + if (attributionHint != null) { + rowY = renderWrappedText(ctx, x + 8, rowY, width - 16, attributionHint, ACCENT_YELLOW) + 4; + } rowY = renderReasonSection(ctx, x + 8, rowY, width - 16, "Top threads [sampled]", effectiveThreadBreakdown(modId, detail)); if ("shared/render".equals(modId)) { rowY = renderStringListSection(ctx, x + 8, rowY + 6, width - 16, "Shared bucket sources", buildGpuPhaseBreakdownLines(modId)); @@ -929,7 +538,36 @@ private void renderCpuDetailPanel(DrawContext ctx, int x, int y, int width, int ctx.disableScissor(); } - private void renderGpuDetailPanel(DrawContext ctx, int x, int y, int width, int height, String modId, long rawGpuNanos, long displayGpuNanos, long rawRenderSamples, long displayRenderSamples, long redistributedGpuNanos, long redistributedRenderSamples, CpuSamplingProfiler.DetailSnapshot detail, long totalRenderSamples, long totalGpuNanos, boolean effectiveView) { + void renderThreadDetailPanel(DrawContext ctx, int x, int y, int width, int height, SystemMetricsProfiler.ThreadDrilldown thread) { + drawInsetPanel(ctx, x, y, width, height); + if (thread == null) { + ctx.drawText(textRenderer, "Select a thread to inspect CPU time, allocation rate, and sampled ownership.", x + 8, y + 8, TEXT_DIM, false); + return; + } + ctx.enableScissor(x, y, x + width, y + height); + ctx.drawText(textRenderer, textRenderer.trimToWidth(cleanProfilerLabel(thread.threadName()), width - 16), x + 8, y + 8, TEXT_PRIMARY, false); + int rowY = renderWrappedText(ctx, x + 8, y + 22, width - 16, + String.format(Locale.ROOT, "tid %d | %.1f%% CPU | %s alloc | %s | blocked %d ms | waited %d ms", + thread.threadId(), + thread.cpuLoadPercent(), + formatBytesPerSecond(thread.allocationRateBytesPerSecond()), + blankToUnknown(thread.state()), + thread.blockedTimeDeltaMs(), + thread.waitedTimeDeltaMs()), + TEXT_DIM); + rowY = renderWrappedText(ctx, x + 8, rowY, width - 16, + "Owner: " + getDisplayName(thread.ownerMod()) + " | Confidence: " + blankToUnknown(thread.confidence()), + TEXT_DIM); + rowY = renderWrappedText(ctx, x + 8, rowY, width - 16, + "Role: " + blankToUnknown(thread.threadRole()) + " | Source: " + blankToUnknown(thread.roleSource()), + TEXT_DIM); + rowY = renderWrappedText(ctx, x + 8, rowY, width - 16, "Reason frame: " + cleanProfilerLabel(thread.reasonFrame()), TEXT_DIM) + 6; + rowY = renderStringListSection(ctx, x + 8, rowY, width - 16, "Top sampled frames", thread.topFrames()) + 6; + renderStringListSection(ctx, x + 8, rowY, width - 16, "Owner candidates", thread.ownerCandidates()); + ctx.disableScissor(); + } + + void renderGpuDetailPanel(DrawContext ctx, int x, int y, int width, int height, String modId, long rawGpuNanos, long displayGpuNanos, long rawRenderSamples, long displayRenderSamples, long redistributedGpuNanos, long redistributedRenderSamples, CpuSamplingProfiler.DetailSnapshot detail, long totalRenderSamples, long totalGpuNanos, boolean effectiveView) { drawInsetPanel(ctx, x, y, width, height); if (modId == null || (displayGpuNanos <= 0L && displayRenderSamples <= 0L)) { ctx.drawText(textRenderer, "Select a row to inspect estimated GPU work.", x + 8, y + 8, TEXT_DIM, false); @@ -949,12 +587,19 @@ private void renderGpuDetailPanel(DrawContext ctx, int x, int y, int width, int } rowY = renderWrappedText(ctx, x + 8, rowY, width - 16, effectiveView ? "Attribution: tagged render-phase ownership plus proportional redistribution of shared render work [estimated]" : "Attribution: tagged render-phase ownership without redistribution. Shared render rows remain separate [estimated]", TEXT_DIM) + 6; rowY = renderReasonSection(ctx, x + 8, rowY, width - 16, "Render threads [sampled]", effectiveThreadBreakdown(modId, detail)); - rowY = renderStringListSection(ctx, x + 8, rowY + 6, width - 16, isSharedAttributionBucket(modId) ? "Top shared owner phases [tagged]" : "Top owner phases [tagged]", buildGpuPhaseBreakdownLines(modId)); - renderReasonSection(ctx, x + 8, rowY + 6, width - 16, "Top sampled render frames [sampled]", detail == null ? Map.of() : detail.topFrames()); + if (isSharedAttributionBucket(modId)) { + rowY = renderReasonSection(ctx, x + 8, rowY + 6, width - 16, "Likely owners during shared/render [sampled]", buildSharedRenderLikelyOwners()); + rowY = renderStringListSection(ctx, x + 8, rowY + 6, width - 16, "Top shared owner phases [tagged]", buildGpuPhaseBreakdownLines(modId)); + rowY = renderReasonSection(ctx, x + 8, rowY + 6, width - 16, "Likely render frames during shared/render [sampled]", buildSharedRenderLikelyFrames()); + renderReasonSection(ctx, x + 8, rowY + 6, width - 16, "Top sampled render frames [sampled]", detail == null ? Map.of() : detail.topFrames()); + } else { + rowY = renderStringListSection(ctx, x + 8, rowY + 6, width - 16, "Top owner phases [tagged]", buildGpuPhaseBreakdownLines(modId)); + renderReasonSection(ctx, x + 8, rowY + 6, width - 16, "Top sampled render frames [sampled]", detail == null ? Map.of() : detail.topFrames()); + } ctx.disableScissor(); } - private void renderMemoryDetailPanel(DrawContext ctx, int x, int y, int width, int height, String modId, long rawBytes, long effectiveBytes, long displayBytes, Map topClasses, long redistributedBytes, long totalAttributedBytes, boolean effectiveView) { + void renderMemoryDetailPanel(DrawContext ctx, int x, int y, int width, int height, String modId, long rawBytes, long effectiveBytes, long displayBytes, Map topClasses, long redistributedBytes, long totalAttributedBytes, boolean effectiveView) { drawInsetPanel(ctx, x, y, width, height); if (modId == null) { ctx.drawText(textRenderer, "Select a row to inspect top live classes.", x + 8, y + 8, TEXT_DIM, false); @@ -977,12 +622,15 @@ private void renderMemoryDetailPanel(DrawContext ctx, int x, int y, int width, i private int renderThreadSnapshotSection(DrawContext ctx, int x, int y, int width, String title, Map data) { ctx.drawText(textRenderer, title, x, y, TEXT_DIM, false); int rowY = y + 12; - if (data == null || data.isEmpty()) { - ctx.drawText(textRenderer, "No thread diagnostics captured in the current window.", x + 6, rowY, TEXT_DIM, false); + List> filtered = data == null ? List.of() : data.entrySet().stream() + .filter(entry -> matchesGlobalSearch(entry.getKey().toLowerCase(Locale.ROOT))) + .toList(); + if (filtered.isEmpty()) { + ctx.drawText(textRenderer, globalSearch.isBlank() ? "No thread diagnostics captured in the current window." : "No thread rows match the universal search.", x + 6, rowY, TEXT_DIM, false); return rowY + 12; } int shown = 0; - for (Map.Entry entry : data.entrySet()) { + for (Map.Entry entry : filtered) { ThreadLoadProfiler.ThreadSnapshot details = entry.getValue(); String summary = entry.getKey() + " | " + String.format(Locale.ROOT, "%.1f%% %s", details.loadPercent(), blankToUnknown(details.state())); rowY = renderWrappedText(ctx, x + 6, rowY, width - 12, summary, getHeatColor(details.loadPercent())); @@ -1004,12 +652,20 @@ private int renderThreadSnapshotSection(DrawContext ctx, int x, int y, int width private int renderReasonSection(DrawContext ctx, int x, int y, int width, String title, Map data) { ctx.drawText(textRenderer, title, x, y, TEXT_DIM, false); int rowY = y + 12; - if (data == null || data.isEmpty()) { - ctx.drawText(textRenderer, "No detail captured in the current window.", x + 6, rowY, TEXT_DIM, false); + java.util.List> filtered = new ArrayList<>(); + if (data != null) { + for (Map.Entry entry : data.entrySet()) { + if (matchesGlobalSearch(entry.getKey().toLowerCase(Locale.ROOT))) { + filtered.add(entry); + } + } + } + if (filtered.isEmpty()) { + ctx.drawText(textRenderer, globalSearch.isBlank() ? "No detail captured in the current window." : "No detail matches the universal search.", x + 6, rowY, TEXT_DIM, false); return rowY + 12; } int shown = 0; - for (Map.Entry entry : data.entrySet()) { + for (Map.Entry entry : filtered) { String label = textRenderer.trimToWidth(entry.getKey(), Math.max(80, width - 50)); ctx.drawText(textRenderer, label, x + 6, rowY, TEXT_PRIMARY, false); String value = formatDetailValue(entry.getValue()); @@ -1023,15 +679,18 @@ private int renderReasonSection(DrawContext ctx, int x, int y, int width, String return rowY; } - private int renderStringListSection(DrawContext ctx, int x, int y, int width, String title, java.util.List lines) { + int renderStringListSection(DrawContext ctx, int x, int y, int width, String title, java.util.List lines) { ctx.drawText(textRenderer, title, x, y, TEXT_DIM, false); int rowY = y + 12; - if (lines == null || lines.isEmpty()) { - ctx.drawText(textRenderer, "No detail captured in the current window.", x + 6, rowY, TEXT_DIM, false); + java.util.List filtered = lines == null ? List.of() : lines.stream() + .filter(line -> matchesGlobalSearch(line.toLowerCase(Locale.ROOT))) + .toList(); + if (filtered.isEmpty()) { + ctx.drawText(textRenderer, globalSearch.isBlank() ? "No detail captured in the current window." : "No detail matches the universal search.", x + 6, rowY, TEXT_DIM, false); return rowY + 12; } int shown = 0; - for (String line : lines) { + for (String line : filtered) { rowY = renderWrappedText(ctx, x + 6, rowY, Math.max(80, width - 12), line, TEXT_PRIMARY); shown++; if (shown >= 4) { @@ -1041,7 +700,7 @@ private int renderStringListSection(DrawContext ctx, int x, int y, int width, St return rowY; } - private int renderWrappedText(DrawContext ctx, int x, int y, int width, String text, int color) { + int renderWrappedText(DrawContext ctx, int x, int y, int width, String text, int color) { if (text == null || text.isBlank()) { return y; } @@ -1059,7 +718,7 @@ private int measureWrappedHeight(int width, String text) { return lineCount * 12; } - private String describeLock(ThreadLoadProfiler.ThreadSnapshot detail) { + String describeLock(ThreadLoadProfiler.ThreadSnapshot detail) { if (detail == null) { return "unknown lock"; } @@ -1092,6 +751,9 @@ private Map buildCpuBucketBreakdown(String modId, CpuSamplingPro if (detail != null && detail.topFrames() != null && !detail.topFrames().isEmpty()) { return new LinkedHashMap<>(detail.topFrames()); } + if ("shared/gpu-stall".equals(modId)) { + return Map.of("Render-thread GPU driver wait", 1); + } if ("shared/jvm".equals(modId) || "shared/framework".equals(modId)) { Map result = new LinkedHashMap<>(); snapshot.systemMetrics().threadLoadPercentByName().entrySet().stream().limit(5).forEach(entry -> result.put(entry.getKey(), entry.getValue())); @@ -1103,32 +765,104 @@ private Map buildCpuBucketBreakdown(String modId, CpuSamplingPro return Map.of(); } - private java.util.List buildGpuPhaseBreakdownLines(String modId) { - return snapshot.renderPhases().entrySet().stream() - .filter(entry -> { - String owner = entry.getValue().ownerMod() == null || entry.getValue().ownerMod().isBlank() ? "shared/render" : entry.getValue().ownerMod(); - return modId.equals(owner) || ("shared/render".equals(modId) && isSharedAttributionBucket(owner)); - }) - .sorted((a, b) -> Long.compare(b.getValue().gpuNanos(), a.getValue().gpuNanos())) - .limit(5) - .map(entry -> String.format(Locale.ROOT, "%s | %.2f ms", entry.getKey(), entry.getValue().gpuNanos() / 1_000_000.0)) - .toList(); + private String cpuAttributionHint(String modId, CpuSamplingProfiler.DetailSnapshot detail, long rawSamples, long shownSamples, long redistributedSamples) { + if ("shared/gpu-stall".equals(modId)) { + return "GPU-stall bucket: these samples were dominated by LWJGL/Blaze3D/JNI driver frames while the render thread used little actual CPU time, so they are kept separate instead of inflating whichever mod hook happened to be on the stack."; + } + if (modId == null || detail == null || detail.topFrames() == null || detail.topFrames().isEmpty()) { + return null; + } + StringJoiner hints = new StringJoiner(" "); + if (isLowConfidenceCpuAttribution(detail, rawSamples, shownSamples, redistributedSamples)) { + hints.add("Low-confidence attribution: this row is built from a small raw sample set with heavy shared/framework redistribution and mostly runtime/native frames, so treat it as a clue rather than direct proof that this mod is spending this much CPU."); + } + if (isRenderSubmissionHeavy(detail)) { + hints.add("Render-thread submission hint: most sampled frames here are OpenGL/JNI driver calls, so this row likely reflects a mod-associated render hook or submission path rather than direct Java CPU loops."); + } + String combined = hints.toString(); + return combined.isBlank() ? null : combined; } - private String describeGpuOwnerSource(String modId) { - if (modId == null || modId.isBlank()) { - return "unknown"; + private boolean isLowConfidenceCpuAttribution(CpuSamplingProfiler.DetailSnapshot detail, long rawSamples, long shownSamples, long redistributedSamples) { + if (detail == null || detail.topFrames() == null || detail.topFrames().isEmpty()) { + return false; } - if ("shared/render".equals(modId)) { - return "shared/render fallback bucket from phases without a concrete owner"; + boolean smallRawSampleSet = rawSamples > 0L && rawSamples < 12L; + boolean redistributedHeavy = redistributedSamples > 0L && redistributedSamples * 10L >= Math.max(1L, shownSamples) * 4L; + long opaqueFrames = detail.topFrames().entrySet().stream() + .filter(entry -> isOpaqueCpuAttributionFrame(entry.getKey())) + .mapToLong(Map.Entry::getValue) + .sum(); + long totalFrames = detail.topFrames().values().stream().mapToLong(Long::longValue).sum(); + boolean opaqueFrameHeavy = totalFrames > 0L && opaqueFrames * 10L >= totalFrames * 6L; + return smallRawSampleSet && redistributedHeavy && opaqueFrameHeavy; + } + + private boolean isRenderSubmissionHeavy(CpuSamplingProfiler.DetailSnapshot detail) { + if (detail == null || detail.topFrames() == null || detail.topFrames().isEmpty()) { + return false; } - if (isSharedAttributionBucket(modId)) { - return "shared bucket carried through raw ownership view"; + boolean renderThreadDominant = detail.topThreads() != null && detail.topThreads().keySet().stream() + .findFirst() + .map(name -> name.toLowerCase(Locale.ROOT).contains("render")) + .orElse(false); + if (!renderThreadDominant) { + return false; + } + long opaqueFrames = detail.topFrames().entrySet().stream() + .filter(entry -> isOpaqueRenderSubmissionFrame(entry.getKey())) + .mapToLong(Map.Entry::getValue) + .sum(); + long totalFrames = detail.topFrames().values().stream().mapToLong(Long::longValue).sum(); + return totalFrames > 0L && opaqueFrames * 2L >= totalFrames; + } + + private boolean isOpaqueCpuAttributionFrame(String frame) { + if (frame == null) { + return false; + } + String lower = frame.toLowerCase(Locale.ROOT); + return isOpaqueRenderSubmissionFrame(frame) + || lower.startsWith("native#") + || lower.contains("operatingsystemimpl#") + || lower.contains("processcpuload") + || lower.contains("managementfactory") + || lower.contains("spark") + || lower.contains("oshi") + || lower.contains("jna") + || lower.contains("hwinfo") + || lower.contains("telemetry") + || lower.contains("perf") + || lower.contains("sensor"); + } + + private boolean isOpaqueRenderSubmissionFrame(String frame) { + if (frame == null) { + return false; } - long taggedPhases = snapshot.renderPhases().values().stream() - .filter(phase -> modId.equals(phase.ownerMod())) - .count(); - return taggedPhases > 0 ? "directly tagged by " + taggedPhases + " render phase" + (taggedPhases == 1 ? "" : "s") : "redistributed from shared render work"; + String lower = frame.toLowerCase(Locale.ROOT); + return lower.startsWith("gl") + || lower.startsWith("jni#") + || lower.contains("org.lwjgl") + || lower.contains("framebuffer") + || lower.contains("blaze3d") + || lower.contains("fencesync"); + } + + private java.util.List buildGpuPhaseBreakdownLines(String modId) { + return AttributionModelBuilder.buildGpuPhaseBreakdownLines(snapshot.renderPhases(), modId, this::getDisplayName); + } + + private Map buildSharedRenderLikelyOwners() { + return AttributionModelBuilder.buildSharedRenderLikelyOwners(snapshot.renderPhases(), this::getDisplayName); + } + + private Map buildSharedRenderLikelyFrames() { + return AttributionModelBuilder.buildSharedRenderLikelyFrames(snapshot.renderPhases()); + } + + private String describeGpuOwnerSource(String modId) { + return AttributionModelBuilder.describeGpuOwnerSource(snapshot.renderPhases(), modId); } private String formatDetailValue(Number value) { @@ -1141,7 +875,7 @@ private String formatDetailValue(Number value) { return formatCount(value.longValue()); } - private int getFullPageScrollTop(int y) { + int getFullPageScrollTop(int y) { return y + PADDING - scrollOffset; } @@ -1149,7 +883,7 @@ private int getFullPageContentHeight(int h) { return Math.max(1, h - PADDING); } - private void beginFullPageScissor(DrawContext ctx, int x, int y, int w, int h) { + void beginFullPageScissor(DrawContext ctx, int x, int y, int w, int h) { ctx.enableScissor(x, y, x + w, y + h); } @@ -1158,232 +892,33 @@ private void endFullPageScissor(DrawContext ctx) { } private void renderNetwork(DrawContext ctx, int x, int y, int w, int h) { - SystemMetricsProfiler metrics = SystemMetricsProfiler.getInstance(); - int left = x + PADDING; - int top = getFullPageScrollTop(y); - int graphWidth = getPreferredGraphWidth(w); - int graphX = x + Math.max(PADDING, (w - graphWidth) / 2); - int columnGap = 20; - int columnWidth = Math.max(120, (w - (PADDING * 2) - columnGap) / 2); - int rightColumnX = left + columnWidth + columnGap; - beginFullPageScissor(ctx, x, y, w, h); - ctx.drawText(textRenderer, "Network throughput and packet/channel attribution during capture.", left, top, TEXT_DIM, false); - top += 14; - if (snapshot.systemMetrics().bytesReceivedPerSecond() < 0 && snapshot.systemMetrics().bytesSentPerSecond() < 0) { - ctx.drawText(textRenderer, "Network counters are unavailable right now. Packet attribution can still populate while throughput stays unavailable.", left, top, ACCENT_YELLOW, false); - top += 14; - } - drawMetricRow(ctx, left, top, w - 16, "Inbound", formatBytesPerSecond(snapshot.systemMetrics().bytesReceivedPerSecond())); - top += 16; - drawMetricRow(ctx, left, top, w - 16, "Outbound", formatBytesPerSecond(snapshot.systemMetrics().bytesSentPerSecond())); - top += 20; - int graphHeight = 132; - renderMetricGraph(ctx, graphX - PADDING, top, graphWidth + (PADDING * 2), graphHeight, metrics.getOrderedNetworkInHistory(), metrics.getOrderedNetworkOutHistory(), "Network In/Out", "B/s", metrics.getHistorySpanSeconds()); - top += graphHeight + 2; - top += renderGraphLegend(ctx, graphX, top, new String[]{"Inbound", "Outbound"}, new int[]{INTEL_COLOR, ACCENT_YELLOW}) + 14; - - java.util.List packetHistory = NetworkPacketProfiler.getInstance().getHistory(); - NetworkPacketProfiler.Snapshot latestPackets = packetHistory.isEmpty() ? null : packetHistory.get(packetHistory.size() - 1); - ctx.drawText(textRenderer, "Inbound categories", left, top, TEXT_PRIMARY, false); - ctx.drawText(textRenderer, "Outbound categories", rightColumnX, top, TEXT_PRIMARY, false); - top += 14; - int categoryHeight = Math.max( - renderPacketBreakdownColumn(ctx, left, top, columnWidth, latestPackets != null ? latestPackets.inboundByCategory() : Map.of()), - renderPacketBreakdownColumn(ctx, rightColumnX, top, columnWidth, latestPackets != null ? latestPackets.outboundByCategory() : Map.of()) - ); - top += categoryHeight + 12; - - ctx.drawText(textRenderer, "Inbound packet types", left, top, TEXT_PRIMARY, false); - ctx.drawText(textRenderer, "Outbound packet types", rightColumnX, top, TEXT_PRIMARY, false); - top += 14; - int typeHeight = Math.max( - renderPacketBreakdownColumn(ctx, left, top, columnWidth, latestPackets != null ? latestPackets.inboundByType() : Map.of()), - renderPacketBreakdownColumn(ctx, rightColumnX, top, columnWidth, latestPackets != null ? latestPackets.outboundByType() : Map.of()) - ); - top += typeHeight + 12; - - ctx.drawText(textRenderer, "Packet spike bookmarks", left, top, TEXT_PRIMARY, false); - top += 14; - top += renderPacketSpikeBookmarks(ctx, left, top, w - 16, NetworkPacketProfiler.getInstance().getSpikeHistory()); - ctx.disableScissor(); + NetworkTabRenderer.render(this, ctx, x, y, w, h); } private void renderDisk(DrawContext ctx, int x, int y, int w, int h) { - SystemMetricsProfiler metrics = SystemMetricsProfiler.getInstance(); - int left = x + PADDING; - int top = getFullPageScrollTop(y); - int graphWidth = getPreferredGraphWidth(w); - int graphX = x + Math.max(PADDING, (w - graphWidth) / 2); - beginFullPageScissor(ctx, x, y, w, h); - ctx.drawText(textRenderer, "Disk throughput from OS counters during capture. Unsupported platforms may show unavailable.", left, top, TEXT_DIM, false); - top += 14; - if (snapshot.systemMetrics().diskReadBytesPerSecond() < 0 && snapshot.systemMetrics().diskWriteBytesPerSecond() < 0) { - ctx.drawText(textRenderer, "Disk throughput counters are unavailable on this provider right now.", left, top, ACCENT_YELLOW, false); - top += 14; - } - drawMetricRow(ctx, left, top, w - 16, "Read", formatBytesPerSecond(snapshot.systemMetrics().diskReadBytesPerSecond())); - top += 16; - drawMetricRow(ctx, left, top, w - 16, "Write", formatBytesPerSecond(snapshot.systemMetrics().diskWriteBytesPerSecond())); - top += 20; - int graphHeight = 132; - renderMetricGraph(ctx, graphX - PADDING, top, graphWidth + (PADDING * 2), graphHeight, metrics.getOrderedDiskReadHistory(), metrics.getOrderedDiskWriteHistory(), "Disk Read/Write", "B/s", metrics.getHistorySpanSeconds()); - top += graphHeight + 2; - renderGraphLegend(ctx, graphX, top, new String[]{"Read", "Write"}, new int[]{INTEL_COLOR, ACCENT_YELLOW}); - ctx.disableScissor(); + DiskTabRenderer.render(this, ctx, x, y, w, h); + } + private void renderThreads(DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + ThreadsTabRenderer.render(this, ctx, x, y, w, h, mouseX, mouseY); } - private void renderSystem(DrawContext ctx, int x, int y, int w, int h) { - int left = x + PADDING; - int top = getFullPageScrollTop(y); - beginFullPageScissor(ctx, x, y, w, h); - SystemMetricsProfiler.Snapshot system = snapshot.systemMetrics(); - SystemMetricsProfiler metrics = SystemMetricsProfiler.getInstance(); - - top = renderSectionHeader(ctx, left, top, "System", "Runtime health, sensors, and CPU/GPU load history."); - drawTopChip(ctx, left, top, 78, 16, systemMiniTab == SystemMiniTab.OVERVIEW); - drawTopChip(ctx, left + 84, top, 88, 16, systemMiniTab == SystemMiniTab.CPU_GRAPH); - drawTopChip(ctx, left + 178, top, 88, 16, systemMiniTab == SystemMiniTab.GPU_GRAPH); - drawTopChip(ctx, left + 272, top, 108, 16, systemMiniTab == SystemMiniTab.MEMORY_GRAPH); - ctx.drawText(textRenderer, "Overview", left + 14, top + 4, systemMiniTab == SystemMiniTab.OVERVIEW ? TEXT_PRIMARY : TEXT_DIM, false); - ctx.drawText(textRenderer, "CPU Graph", left + 100, top + 4, systemMiniTab == SystemMiniTab.CPU_GRAPH ? TEXT_PRIMARY : TEXT_DIM, false); - ctx.drawText(textRenderer, "GPU Graph", left + 194, top + 4, systemMiniTab == SystemMiniTab.GPU_GRAPH ? TEXT_PRIMARY : TEXT_DIM, false); - ctx.drawText(textRenderer, "Memory Graph", left + 286, top + 4, systemMiniTab == SystemMiniTab.MEMORY_GRAPH ? TEXT_PRIMARY : TEXT_DIM, false); - top += 24; - - if (systemMiniTab == SystemMiniTab.CPU_GRAPH) { - int graphWidth = getPreferredGraphWidth(w); - int graphLeft = x + Math.max(PADDING, (w - graphWidth) / 2); - renderGraphMetricTabs(ctx, graphLeft, top, graphWidth, cpuGraphMetricTab); - top += 24; - if (cpuGraphMetricTab == GraphMetricTab.LOAD) { - renderFixedScaleSeriesGraph(ctx, graphLeft, top, graphWidth, 146, metrics.getOrderedCpuLoadHistory(), "CPU Load", "%", getCpuGraphColor(), 100.0, metrics.getHistorySpanSeconds()); - top += 164; - top += renderGraphLegend(ctx, graphLeft, top, new String[]{"CPU Load"}, new int[]{getCpuGraphColor()}) + 8; - } else { - renderSensorSeriesGraph(ctx, graphLeft, top, graphWidth, 146, metrics.getOrderedCpuTemperatureHistory(), "CPU Temperature", "C", getCpuGraphColor(), 110.0, metrics.getHistorySpanSeconds(), system.cpuTemperatureC() >= 0.0, textRenderer.trimToWidth(system.cpuTemperatureUnavailableReason(), Math.max(80, graphWidth - 12))); - top += 164; - top += renderGraphLegend(ctx, graphLeft, top, new String[]{"CPU Temperature"}, new int[]{getCpuGraphColor()}) + 8; - } - drawMetricRow(ctx, graphLeft, top, graphWidth, "Current CPU Load", formatPercentWithTrend(system.cpuCoreLoadPercent(), system.cpuLoadChangePerSecond())); - top += 16; - drawMetricRow(ctx, graphLeft, top, graphWidth, "CPU Temperature", formatTemperatureWithTrend(system.cpuTemperatureC(), system.cpuTemperatureChangePerSecond())); - top += 16; - drawMetricRow(ctx, graphLeft, top, graphWidth, "CPU Info", formatCpuInfo()); - ctx.disableScissor(); - return; - } - - if (systemMiniTab == SystemMiniTab.GPU_GRAPH) { - int graphWidth = getPreferredGraphWidth(w); - int graphLeft = x + Math.max(PADDING, (w - graphWidth) / 2); - renderGraphMetricTabs(ctx, graphLeft, top, graphWidth, gpuGraphMetricTab); - top += 24; - if (gpuGraphMetricTab == GraphMetricTab.LOAD) { - renderFixedScaleSeriesGraph(ctx, graphLeft, top, graphWidth, 146, metrics.getOrderedGpuLoadHistory(), "GPU Load", "%", getGpuGraphColor(), 100.0, metrics.getHistorySpanSeconds()); - top += 164; - top += renderGraphLegend(ctx, graphLeft, top, new String[]{"GPU Load"}, new int[]{getGpuGraphColor()}) + 8; - } else { - renderSensorSeriesGraph(ctx, graphLeft, top, graphWidth, 146, metrics.getOrderedGpuTemperatureHistory(), "GPU Temperature", "C", getGpuGraphColor(), 110.0, metrics.getHistorySpanSeconds(), system.gpuTemperatureC() >= 0.0, "Sensor is not available"); - top += 164; - top += renderGraphLegend(ctx, graphLeft, top, new String[]{"GPU Temperature"}, new int[]{getGpuGraphColor()}) + 8; - } - double vramMaxMb = Math.max(1.0, system.vramTotalBytes() > 0L ? system.vramTotalBytes() / (1024.0 * 1024.0) : 1.0); - renderFixedScaleSeriesGraph(ctx, graphLeft, top, graphWidth, 118, metrics.getOrderedVramUsedHistory(), "VRAM Usage", "MB", getGpuGraphColor(), vramMaxMb, metrics.getHistorySpanSeconds()); - top += 132; - top += renderGraphLegend(ctx, graphLeft, top, new String[]{"VRAM Used"}, new int[]{getGpuGraphColor()}) + 8; - drawMetricRow(ctx, graphLeft, top, graphWidth, "Current GPU Load", formatPercentWithTrend(system.gpuCoreLoadPercent(), system.gpuLoadChangePerSecond())); - top += 16; - drawMetricRow(ctx, graphLeft, top, graphWidth, "GPU Temperature", formatTemperatureWithTrend(system.gpuTemperatureC(), system.gpuTemperatureChangePerSecond())); - top += 16; - drawMetricRow(ctx, graphLeft, top, graphWidth, "GPU Info", blankToUnknown(system.gpuVendor()) + " | " + blankToUnknown(system.gpuRenderer())); - top += 16; - drawMetricRow(ctx, graphLeft, top, graphWidth, "VRAM Usage", formatBytesMb(system.vramUsedBytes()) + " / " + formatBytesMb(system.vramTotalBytes())); - ctx.disableScissor(); - return; - } - if (systemMiniTab == SystemMiniTab.MEMORY_GRAPH) { - int graphWidth = getPreferredGraphWidth(w); - int graphLeft = x + Math.max(PADDING, (w - graphWidth) / 2); - long heapMaxBytes = snapshot.memory().heapMaxBytes() > 0 ? snapshot.memory().heapMaxBytes() : Runtime.getRuntime().maxMemory(); - double heapMaxMb = Math.max(1.0, heapMaxBytes / (1024.0 * 1024.0)); - renderFixedScaleSeriesGraph(ctx, graphLeft, top, graphWidth, 146, - metrics.getOrderedMemoryUsedHistory(), - metrics.getOrderedMemoryCommittedHistory(), - "Memory Load", "MB", getMemoryGraphColor(), 0x6688B5FF, heapMaxMb, - metrics.getHistorySpanSeconds()); - top += 164; - top += renderGraphLegend(ctx, graphLeft, top, new String[]{"Heap Used", "Heap Allocated"}, new int[]{getMemoryGraphColor(), 0x6688B5FF}) + 8; - drawMetricRow(ctx, graphLeft, top, graphWidth, "Heap Used", formatBytesMb(snapshot.memory().heapUsedBytes())); - top += 16; - drawMetricRow(ctx, graphLeft, top, graphWidth, "Heap Allocated", formatBytesMb(snapshot.memory().heapCommittedBytes())); - top += 16; - drawMetricRow(ctx, graphLeft, top, graphWidth, "Heap Max", formatBytesMb(heapMaxBytes)); - top += 16; - drawMetricRow(ctx, graphLeft, top, graphWidth, "Non-Heap", formatBytesMb(snapshot.memory().nonHeapUsedBytes())); - ctx.disableScissor(); - return; - } + void renderThreadToolbar(DrawContext ctx, int x, int y) { + renderThreadToolbarChip(ctx, x, y, 70, "CPU", threadSort == ThreadSort.CPU); + renderThreadToolbarChip(ctx, x + 74, y, 82, "Alloc", threadSort == ThreadSort.ALLOC); + renderThreadToolbarChip(ctx, x + 160, y, 86, "Blocked", threadSort == ThreadSort.BLOCKED); + renderThreadToolbarChip(ctx, x + 250, y, 82, "Waited", threadSort == ThreadSort.WAITED); + renderThreadToolbarChip(ctx, x + 336, y, 76, "Name", threadSort == ThreadSort.NAME); + renderThreadToolbarChip(ctx, x + 418, y, 86, threadFreeze ? "Unfreeze" : "Freeze", threadFreeze); + } + private void renderThreadToolbarChip(DrawContext ctx, int x, int y, int width, String label, boolean active) { + drawTopChip(ctx, x, y, width, 16, active); + ctx.drawText(textRenderer, label, x + 10, y + 4, active ? TEXT_PRIMARY : TEXT_DIM, false); + } - drawMetricRow(ctx, left, top, w - 32, "CPU Info", formatCpuInfo()); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "GPU Info", blankToUnknown(system.gpuVendor()) + " | " + blankToUnknown(system.gpuRenderer())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "VRAM Usage", formatBytesMb(system.vramUsedBytes()) + " / " + formatBytesMb(system.vramTotalBytes())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "VRAM Paging", system.vramPagingActive() ? formatBytesMb(system.vramPagingBytes()) : "none detected"); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Committed Virtual Memory", formatBytesMb(system.committedVirtualMemoryBytes())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Off-Heap Direct", formatBytesMb(system.directMemoryUsedBytes()) + " / " + formatBytesMb(system.directMemoryMaxBytes())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "CPU", formatCpuGpuSummary(system, true)); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "GPU", formatCpuGpuSummary(system, false)); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "CPU Temperature", formatTemperature(system.cpuTemperatureC())); - top += 16; - if (system.cpuTemperatureC() < 0) { - ctx.drawText(textRenderer, textRenderer.trimToWidth("Why CPU temp is unavailable: " + blankToUnknown(system.cpuTemperatureUnavailableReason()), w - 24), left + 6, top, ACCENT_YELLOW, false); - top += 14; - } - drawMetricRow(ctx, left, top, w - 32, "GPU Temperature", formatTemperature(system.gpuTemperatureC())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Main Logic", blankToUnknown(system.mainLogicSummary())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Background", blankToUnknown(system.backgroundSummary())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "CPU Parallelism", blankToUnknown(system.cpuParallelismFlag())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Parallelism Efficiency", blankToUnknown(system.parallelismEfficiency())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Thread Load", String.format(Locale.ROOT, "%.1f%% total", system.totalThreadLoadPercent())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "High-Load Threads", system.activeHighLoadThreads() + " >50% | est physical cores " + system.estimatedPhysicalCores()); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Server Wait-Time", system.serverThreadWaitMs() + " ms waited | " + system.serverThreadBlockedMs() + " ms blocked"); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Worker Ratio", system.activeWorkers() + " active / " + system.idleWorkers() + " idle (" + String.format(Locale.ROOT, "%.2f", system.activeToIdleWorkerRatio()) + ")"); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "CPU Sensor Status", blankToUnknown(system.cpuSensorStatus())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Off-Heap Allocation Rate", formatBytesPerSecond(system.offHeapAllocationRateBytesPerSecond())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Current Biome", prettifyKey(blankToUnknown(system.currentBiome()))); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Light Update Queue", blankToUnknown(system.lightUpdateQueue())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Max Entities In Hot Chunk", String.valueOf(system.maxEntitiesInHotChunk())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Packet Latency", system.packetProcessingLatencyMs() < 0 ? "unavailable" : String.format(Locale.ROOT, "%.1f ms [estimated]", system.packetProcessingLatencyMs())); - top += 16; - drawMetricRow(ctx, left, top, w - 32, "Packet Buffer Pressure", blankToUnknown(system.networkBufferSaturation())); - top += 22; - renderSensorsPanel(ctx, left, top, w - 24, system); - top += 124; - ctx.drawText(textRenderer, textRenderer.trimToWidth("Export sessions keep the current runtime summary, findings, hotspots, and HTML report for offline inspection.", w - 24), left, top, TEXT_DIM, false); - ctx.disableScissor(); + private void renderSystem(DrawContext ctx, int x, int y, int w, int h) { + SystemTabRenderer.render(this, ctx, x, y, w, h); } private void renderBlockEntities(DrawContext ctx, int x, int y, int w, int h) { @@ -1432,117 +967,10 @@ private void renderBlockEntities(DrawContext ctx, int x, int y, int w, int h) { } private void renderWorldTab(DrawContext ctx, int x, int y, int w, int h) { - int left = x + PADDING; - beginFullPageScissor(ctx, x, y, w, h); - LagMapLayout layout = getLagMapLayout(y, w, h); - lastRenderedLagMapLayout = layout; - int top = getFullPageScrollTop(y); - top = renderSectionHeader(ctx, left, top, "World", "Chunk pressure, entity hotspots, and block-entity drilldown grouped into world-focused views."); - int lagTabW = 76; - int entitiesTabW = 70; - int chunksTabW = 72; - int blockTabW = 108; - int tabX = left; - drawTopChip(ctx, tabX, layout.miniTabY(), lagTabW, 16, worldMiniTab == WorldMiniTab.LAG_MAP); - tabX += lagTabW + 6; - drawTopChip(ctx, tabX, layout.miniTabY(), entitiesTabW, 16, worldMiniTab == WorldMiniTab.ENTITIES); - tabX += entitiesTabW + 6; - drawTopChip(ctx, tabX, layout.miniTabY(), chunksTabW, 16, worldMiniTab == WorldMiniTab.CHUNKS); - tabX += chunksTabW + 6; - drawTopChip(ctx, tabX, layout.miniTabY(), blockTabW, 16, worldMiniTab == WorldMiniTab.BLOCK_ENTITIES); - tabX = left; - ctx.drawText(textRenderer, "Lag Map", tabX + 16, layout.miniTabY() + 4, worldMiniTab == WorldMiniTab.LAG_MAP ? TEXT_PRIMARY : TEXT_DIM, false); - tabX += lagTabW + 6; - ctx.drawText(textRenderer, "Entities", tabX + 12, layout.miniTabY() + 4, worldMiniTab == WorldMiniTab.ENTITIES ? TEXT_PRIMARY : TEXT_DIM, false); - tabX += entitiesTabW + 6; - ctx.drawText(textRenderer, "Chunks", tabX + 14, layout.miniTabY() + 4, worldMiniTab == WorldMiniTab.CHUNKS ? TEXT_PRIMARY : TEXT_DIM, false); - tabX += chunksTabW + 6; - ctx.drawText(textRenderer, "Block Entities", tabX + 14, layout.miniTabY() + 4, worldMiniTab == WorldMiniTab.BLOCK_ENTITIES ? TEXT_PRIMARY : TEXT_DIM, false); - int findingsCount = ProfilerManager.getInstance().getLatestRuleFindings().size(); - ctx.drawText(textRenderer, String.format(Locale.ROOT, "Selected chunk: %s | hot chunks: %d | findings: %d", selectedLagChunk == null ? "none" : (selectedLagChunk.x + "," + selectedLagChunk.z), ProfilerManager.getInstance().getLatestHotChunks().size(), findingsCount), left, layout.summaryY(), TEXT_DIM, false); - SystemMetricsProfiler metrics = SystemMetricsProfiler.getInstance(); - int worldGraphWidth = getPreferredGraphWidth(w); - int worldGraphX = x + Math.max(PADDING, (w - worldGraphWidth) / 2); - top = layout.summaryY() + 18; - - if (worldMiniTab == WorldMiniTab.LAG_MAP) { - renderLagMap(ctx, layout.left(), layout.mapRenderY(), layout.mapWidth(), layout.mapHeight()); - top = layout.mapTop() + (layout.cell() * ((layout.radius() * 2) + 1)) + 18; - top = renderLagChunkDetail(ctx, left, top, w - 24, h - 40) + 8; - ctx.drawText(textRenderer, "Top thread CPU load", left, top, TEXT_PRIMARY, false); - top += 16; - if (snapshot.systemMetrics().threadLoadPercentByName().isEmpty()) { - ctx.drawText(textRenderer, "Waiting for JVM thread CPU samples...", left, top, TEXT_DIM, false); - ctx.disableScissor(); - return; - } - int shown = 0; - for (Map.Entry entry : snapshot.systemMetrics().threadDetailsByName().entrySet()) { - ThreadLoadProfiler.ThreadSnapshot details = entry.getValue(); - String summary = cleanProfilerLabel(entry.getKey()) + " | " + String.format(Locale.ROOT, "%.1f%% %s", details.loadPercent(), details.state()); - top = renderWrappedText(ctx, left, top, w - 24, summary, getHeatColor(details.loadPercent())); - String waitLine = "blocked " + details.blockedCountDelta() + " / " + details.blockedTimeDeltaMs() + "ms | waited " + details.waitedCountDelta() + " / " + details.waitedTimeDeltaMs() + "ms | lock " + describeLock(details); - top = renderWrappedText(ctx, left + 8, top, w - 32, waitLine, TEXT_DIM); - shown++; - if (shown >= 5) { - break; - } - } - top += 8; - top = renderEntityHotspotSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestEntityHotspots(), "Entity Hotspots") + 8; - top += renderRuleFindingsSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestRuleFindings()) + 8; - } else if (worldMiniTab == WorldMiniTab.ENTITIES) { - drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Entities", Integer.toString(snapshot.entityCounts().totalEntities())); - top += 18; - renderSeriesGraph(ctx, worldGraphX, top, worldGraphWidth, 120, metrics.getOrderedEntityCountHistory(), null, "Entities Over Time", "entities", getWorldEntityGraphColor(), 0, metrics.getHistorySpanSeconds()); - top += 138; - top += renderGraphLegend(ctx, worldGraphX, top, new String[]{"Entities"}, new int[]{getWorldEntityGraphColor()}) + 8; - drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Living", Integer.toString(snapshot.entityCounts().livingEntities())); - top += 16; - drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Block Entities", Integer.toString(snapshot.entityCounts().blockEntities())); - top += 20; - top = renderEntityHotspotSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestEntityHotspots(), "Entity Hotspots") + 8; - top += renderRuleFindingsSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestRuleFindings()) + 8; - } else if (worldMiniTab == WorldMiniTab.CHUNKS) { - drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Chunks", snapshot.chunkCounts().loadedChunks() + " loaded | " + snapshot.chunkCounts().renderedChunks() + " rendered"); - top += 18; - renderSeriesGraph(ctx, worldGraphX, top, worldGraphWidth, 120, metrics.getOrderedLoadedChunkHistory(), metrics.getOrderedRenderedChunkHistory(), "Chunks Over Time", "chunks", getWorldLoadedChunkGraphColor(), getWorldRenderedChunkGraphColor(), metrics.getHistorySpanSeconds()); - top += 138; - top += renderGraphLegend(ctx, worldGraphX, top, new String[]{"Loaded", "Rendered"}, new int[]{getWorldLoadedChunkGraphColor(), getWorldRenderedChunkGraphColor()}) + 8; - drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Chunk Generating", Integer.toString(snapshot.systemMetrics().chunksGenerating())); - top += 16; - drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Chunk Meshing", Integer.toString(snapshot.systemMetrics().chunksMeshing())); - top += 16; - drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Chunk Uploading", Integer.toString(snapshot.systemMetrics().chunksUploading())); - top += 16; - drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Light Updates", Integer.toString(snapshot.systemMetrics().lightsUpdatePending())); - top += 20; - top += renderRuleFindingsSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestRuleFindings()) + 8; - } else { - top = renderBlockEntityHotspotSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestBlockEntityHotspots(), "Block Entity Hotspots") + 8; - if (selectedLagChunk != null) { - ctx.drawText(textRenderer, "Selected chunk block entities", left, top, TEXT_PRIMARY, false); - top += 14; - MinecraftClient client = MinecraftClient.getInstance(); - Map blockEntityCounts = new HashMap<>(); - if (client.world != null) { - for (BlockEntity blockEntity : client.world.getBlockEntities()) { - ChunkPos chunkPos = new ChunkPos(blockEntity.getPos()); - if (chunkPos.x == selectedLagChunk.x && chunkPos.z == selectedLagChunk.z) { - blockEntityCounts.merge(cleanProfilerLabel(blockEntity.getClass().getSimpleName()), 1, Integer::sum); - } - } - } - top = renderCountMap(ctx, left, top, w - 24, "Top block entities in selected chunk [measured counts]", blockEntityCounts) + 8; - } else { - top = renderWrappedText(ctx, left, top, w - 24, "Select a chunk from the Lag Map mini-tab to inspect block entities for that chunk.", TEXT_DIM) + 8; - } - top += renderRuleFindingsSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestRuleFindings()) + 8; - } - ctx.disableScissor(); + WorldTabRenderer.render(this, ctx, x, y, w, h); } - private void renderLagMap(DrawContext ctx, int x, int y, int width, int height) { + void renderLagMap(DrawContext ctx, int x, int y, int width, int height) { MinecraftClient client = MinecraftClient.getInstance(); ctx.drawText(textRenderer, "Lag Map", x, y, TEXT_PRIMARY, false); if (client.player == null || client.world == null) { @@ -1595,8 +1023,8 @@ private void renderLagMap(DrawContext ctx, int x, int y, int width, int height) ctx.drawText(textRenderer, legend, x, mapTop + (cell * ((radius * 2) + 1)) + 4, TEXT_DIM, false); } - private void renderSensorsPanel(DrawContext ctx, int x, int y, int width, SystemMetricsProfiler.Snapshot system) { - ctx.fill(x, y, x + width, y + 116, 0x14000000); + void renderSensorsPanel(DrawContext ctx, int x, int y, int width, SystemMetricsProfiler.Snapshot system) { + ctx.fill(x, y, x + width, y + 134, 0x14000000); String source = blankToUnknown(system.sensorSource()); String[] sourceParts = source.split("\\| Tried: ", 2); String activeSource = sourceParts[0].trim(); @@ -1604,29 +1032,166 @@ private void renderSensorsPanel(DrawContext ctx, int x, int y, int width, System String status = blankToUnknown(system.cpuSensorStatus()); String availability = system.cpuTemperatureC() >= 0 || system.gpuTemperatureC() >= 0 ? "Measured temperatures available" : "Falling back to load-only telemetry"; ctx.fill(x, y, x + width, y + 16, 0x22000000); - ctx.drawText(textRenderer, "Sensors", x + 6, y + 4, TEXT_PRIMARY, false); - addTooltip(x + 6, y + 2, 50, 14, "Sensor diagnostics shows provider availability, fallback path, and the last bridge error."); + ctx.drawText(textRenderer, "Sensors & Telemetry Health", x + 6, y + 4, TEXT_PRIMARY, false); + addTooltip(x + 6, y + 2, 146, 14, "Sensor diagnostics shows provider availability, helper health, fallback path, and the last bridge error."); String statusLabel = textRenderer.trimToWidth(status, width - 12); ctx.drawText(textRenderer, statusLabel, x + width - 6 - textRenderer.getWidth(statusLabel), y + 4, TEXT_DIM, false); ctx.drawText(textRenderer, textRenderer.trimToWidth("Provider: " + activeSource, width - 12), x + 6, y + 22, TEXT_DIM, false); ctx.drawText(textRenderer, textRenderer.trimToWidth("Availability: " + availability, width - 12), x + 6, y + 36, system.cpuTemperatureC() >= 0 || system.gpuTemperatureC() >= 0 ? ACCENT_GREEN : ACCENT_YELLOW, false); - String tempSummary = "CPU temp " + formatTemperature(system.cpuTemperatureC()) + " | GPU temp " + formatTemperature(system.gpuTemperatureC()) + " | CPU load " + formatPercent(system.cpuCoreLoadPercent()) + " | GPU load " + formatPercent(system.gpuCoreLoadPercent()); + String tempSummary = "CPU temp " + formatTemperature(system.cpuTemperatureC()) + " | GPU " + TelemetryTextFormatter.formatGpuTemperatureCompact(system) + " | CPU load " + formatPercent(system.cpuCoreLoadPercent()) + " | GPU load " + formatPercent(system.gpuCoreLoadPercent()); ctx.drawText(textRenderer, textRenderer.trimToWidth(tempSummary, width - 12), x + 6, y + 50, TEXT_DIM, false); - ctx.drawText(textRenderer, textRenderer.trimToWidth("Counter source: " + blankToUnknown(system.counterSource()), width - 12), x + 6, y + 64, TEXT_DIM, false); - ctx.drawText(textRenderer, textRenderer.trimToWidth("Attempts: " + attempts, width - 12), x + 6, y + 78, TEXT_DIM, false); - ctx.drawText(textRenderer, textRenderer.trimToWidth("Reason: " + blankToUnknown(system.cpuTemperatureUnavailableReason()), width - 12), x + 6, y + 92, TEXT_DIM, false); - ctx.drawText(textRenderer, textRenderer.trimToWidth("Last provider error: " + blankToUnknown(system.sensorErrorCode()), width - 12), x + 6, y + 106, ACCENT_YELLOW, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth("GPU core temp provider: " + blankToUnknown(system.gpuTemperatureProvider()) + " | GPU hot spot provider: " + blankToUnknown(system.gpuHotSpotProvider()), width - 12), x + 6, y + 64, TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth("Windows helper: " + blankToUnknown(system.telemetryHelperStatus()) + " | Last sample age: " + formatDuration(system.telemetrySampleAgeMillis()), width - 12), x + 6, y + 78, TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth("Counter source: " + blankToUnknown(system.counterSource()) + " | helper cost " + system.telemetryHelperCostMillis() + " ms", width - 12), x + 6, y + 92, TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth("Attempts: " + attempts, width - 12), x + 6, y + 106, TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth("Reason / error: " + blankToUnknown(system.cpuTemperatureUnavailableReason()) + " | " + blankToUnknown(system.sensorErrorCode()), width - 12), x + 6, y + 120, ACCENT_YELLOW, false); } + String summarizeResourcePackAndTextureState(SystemMetricsProfiler.Snapshot system) { + List packs = ProfilerManager.getInstance().getEnabledResourcePackNames(); + String packSummary = packs.isEmpty() + ? "default/unknown packs" + : textRenderer.trimToWidth(String.join(", ", packs), 220); + return packSummary + " | uploads " + formatCount(system.textureUploadRate()); + } + + String summarizeAllocationPressure() { + Map allocationRates = MemoryProfiler.getInstance().getModAllocationRateBytesPerSecond(); + if (allocationRates.isEmpty()) { + return "warming up"; + } + return allocationRates.entrySet().stream() + .limit(2) + .map(entry -> getDisplayName(entry.getKey()) + " " + formatBytesPerSecond(entry.getValue())) + .reduce((left, right) -> left + " | " + right) + .orElse("warming up"); + } + + void renderProfilerSelfCostPanel(DrawContext ctx, int x, int y, int width, SystemMetricsProfiler.Snapshot system) { + ctx.fill(x, y, x + width, y + 76, 0x14000000); + ctx.fill(x, y, x + width, y + 16, 0x22000000); + ctx.drawText(textRenderer, "Profiler Self-Cost", x + 6, y + 4, TEXT_PRIMARY, false); + addTooltip(x + 6, y + 2, 90, 14, "Shows TaskManager's own visible overhead so the profiler stays accountable."); + ctx.drawText(textRenderer, textRenderer.trimToWidth(String.format(Locale.ROOT, "Profiler CPU %.1f%% | governor %s | world scan %d ms", system.profilerCpuLoadPercent(), blankToUnknown(system.collectorGovernorMode()), system.worldScanCostMillis()), width - 12), x + 6, y + 22, TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth(String.format(Locale.ROOT, "Memory histogram %d ms | telemetry helper %d ms", system.memoryHistogramCostMillis(), system.telemetryHelperCostMillis()), width - 12), x + 6, y + 36, TEXT_DIM, false); + String protectionLine = "self-protect".equals(system.collectorGovernorMode()) + ? "Self-protection is active: expensive collectors are backing off to keep TaskManager from adding more lag." + : "Adaptive mode slows expensive collectors during stable periods and bursts into higher detail for alerts, recording, or active inspection."; + ctx.drawText(textRenderer, textRenderer.trimToWidth(protectionLine, width - 12), x + 6, y + 50, "self-protect".equals(system.collectorGovernorMode()) ? ACCENT_YELLOW : TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth("GPU coverage: " + blankToUnknown(system.gpuCoverageSummary()), width - 12), x + 6, y + 62, ACCENT_YELLOW, false); + } + + List buildThreadDrilldownLines(SystemMetricsProfiler.Snapshot system) { + if (system.threadDrilldown() == null || system.threadDrilldown().isEmpty()) { + return List.of("Waiting for live thread CPU, allocation, and stack snapshots."); + } + List lines = new ArrayList<>(); + for (SystemMetricsProfiler.ThreadDrilldown thread : system.threadDrilldown()) { + String topFrames = thread.topFrames() == null || thread.topFrames().isEmpty() + ? "no sampled frames yet" + : String.join(" > ", thread.topFrames()); + String candidates = thread.ownerCandidates() == null || thread.ownerCandidates().isEmpty() + ? "no alternate candidates" + : String.join(" ; ", thread.ownerCandidates()); + lines.add(String.format(Locale.ROOT, + "%s [tid %d] | %.1f%% CPU | %s alloc | %s | role %s | owner %s | %s | reason %s | top %s | candidates %s", + cleanProfilerLabel(thread.threadName()), + thread.threadId(), + thread.cpuLoadPercent(), + formatBytesPerSecond(thread.allocationRateBytesPerSecond()), + blankToUnknown(thread.state()), + blankToUnknown(thread.threadRole()), + getDisplayName(thread.ownerMod()), + blankToUnknown(thread.confidence()), + cleanProfilerLabel(thread.reasonFrame()), + topFrames, + candidates)); + } + return lines; + } + + List buildShaderCompileLines() { + ShaderCompilationProfiler.Snapshot snapshot = ShaderCompilationProfiler.getInstance().getSnapshot(); + if (snapshot.durationNanosByLabel().isEmpty()) { + return List.of("No shader compilation captured in the current window."); + } + List lines = new ArrayList<>(); + snapshot.durationNanosByLabel().forEach((label, durationNs) -> lines.add(String.format( + Locale.ROOT, + "%s | %.2f ms | %d compiles", + cleanProfilerLabel(label), + durationNs / 1_000_000.0, + snapshot.compileCountByLabel().getOrDefault(label, 0L) + ))); + ShaderCompilationProfiler.CompileEvent latest = snapshot.recentEvents().isEmpty() ? null : snapshot.recentEvents().getFirst(); + if (latest != null) { + lines.add(0, String.format(Locale.ROOT, "Latest compile: %s | %.2f ms | age %d ms", + cleanProfilerLabel(latest.label()), + latest.durationNs() / 1_000_000.0, + snapshot.sampleAgeMillis() == Long.MAX_VALUE ? -1L : snapshot.sampleAgeMillis())); + } + return lines; + } + + List buildChunkPipelineDrilldownLines() { + List lines = new ArrayList<>(); + SystemMetricsProfiler.Snapshot system = snapshot.systemMetrics(); + lines.add(String.format(Locale.ROOT, "Workers [inferred]: gen %d | mesh %d | upload %d | lights %d | texture uploads %s", + system.chunksGenerating(), + system.chunksMeshing(), + system.chunksUploading(), + system.lightsUpdatePending(), + formatCount(system.textureUploadRate()))); + ThreadLoadProfiler.getInstance().getLatestRawThreadSnapshots().values().stream() + .filter(raw -> isChunkPipelineThread(raw.threadName()) || isChunkPipelineThread(raw.canonicalThreadName())) + .sorted((a, b) -> Double.compare(b.snapshot().loadPercent(), a.snapshot().loadPercent())) + .limit(4) + .forEach(raw -> lines.add(String.format(Locale.ROOT, "%s [measured] %.1f%% %s | blocked %d | waited %d", + cleanProfilerLabel(raw.threadName()), + raw.snapshot().loadPercent(), + raw.snapshot().state(), + raw.snapshot().blockedCountDelta(), + raw.snapshot().waitedCountDelta()))); + snapshot.renderPhases().entrySet().stream() + .filter(entry -> isChunkPipelinePhase(entry.getKey())) + .sorted((a, b) -> Long.compare(Math.max(b.getValue().gpuNanos(), b.getValue().cpuNanos()), Math.max(a.getValue().gpuNanos(), a.getValue().cpuNanos()))) + .limit(3) + .forEach(entry -> lines.add(String.format(Locale.ROOT, "%s [tagged] owner %s | CPU %.2f ms | GPU %.2f ms", + cleanProfilerLabel(entry.getKey()), + getDisplayName(entry.getValue().ownerMod() == null ? "shared/render" : entry.getValue().ownerMod()), + entry.getValue().cpuNanos() / 1_000_000.0, + entry.getValue().gpuNanos() / 1_000_000.0))); + return lines; + } - private int renderPacketBreakdownColumn(DrawContext ctx, int x, int y, int width, Map breakdown) { - if (breakdown.isEmpty()) { - ctx.drawText(textRenderer, "No packet attribution yet.", x, y, TEXT_DIM, false); + private boolean isChunkPipelineThread(String threadName) { + if (threadName == null) { + return false; + } + String lower = threadName.toLowerCase(Locale.ROOT); + return lower.contains("chunk") || lower.contains("mesh") || lower.contains("builder") || lower.contains("upload") || lower.contains("light"); + } + + private boolean isChunkPipelinePhase(String phase) { + if (phase == null) { + return false; + } + String lower = phase.toLowerCase(Locale.ROOT); + return lower.contains("chunk") || lower.contains("mesh") || lower.contains("terrain") || lower.contains("section") || lower.contains("upload") || lower.contains("light"); + } + + + int renderPacketBreakdownColumn(DrawContext ctx, int x, int y, int width, Map breakdown) { + List> filtered = breakdown.entrySet().stream() + .filter(entry -> matchesGlobalSearch(entry.getKey().toLowerCase(Locale.ROOT))) + .toList(); + if (filtered.isEmpty()) { + ctx.drawText(textRenderer, globalSearch.isBlank() ? "No packet attribution yet." : "No packet rows match the universal search.", x, y, TEXT_DIM, false); return 12; } int rowY = y; int shown = 0; - for (Map.Entry entry : breakdown.entrySet()) { + for (Map.Entry entry : filtered) { String label = textRenderer.trimToWidth(entry.getKey(), Math.max(60, width - 46)); ctx.drawText(textRenderer, label, x, rowY, TEXT_DIM, false); String value = String.valueOf(entry.getValue()); @@ -1641,7 +1206,7 @@ private int renderPacketBreakdownColumn(DrawContext ctx, int x, int y, int width } - private int renderLagChunkDetail(DrawContext ctx, int x, int y, int width, int height) { + int renderLagChunkDetail(DrawContext ctx, int x, int y, int width, int height) { MinecraftClient client = MinecraftClient.getInstance(); if (client.world == null || selectedLagChunk == null) { ctx.drawText(textRenderer, "Click a chunk in the world map to inspect its entities and block entities.", x, y, TEXT_DIM, false); @@ -1684,19 +1249,23 @@ private int renderLagChunkDetail(DrawContext ctx, int x, int y, int width, int h } - private int renderCountMap(DrawContext ctx, int x, int y, int width, String title, Map counts) { + int renderCountMap(DrawContext ctx, int x, int y, int width, String title, Map counts) { return renderCountMap(ctx, x, y, width, title, counts, true); } - private int renderCountMap(DrawContext ctx, int x, int y, int width, String title, Map counts, boolean normalizeLabels) { + int renderCountMap(DrawContext ctx, int x, int y, int width, String title, Map counts, boolean normalizeLabels) { ctx.drawText(textRenderer, title, x, y, TEXT_DIM, false); int rowY = y + 12; - if (counts.isEmpty()) { - ctx.drawText(textRenderer, "none", x + 6, rowY, TEXT_DIM, false); + List> filtered = counts.entrySet().stream() + .filter(entry -> matchesGlobalSearch((normalizeLabels ? cleanProfilerLabel(entry.getKey()) : entry.getKey()).toLowerCase(Locale.ROOT))) + .sorted((a, b) -> Integer.compare(b.getValue(), a.getValue())) + .toList(); + if (filtered.isEmpty()) { + ctx.drawText(textRenderer, globalSearch.isBlank() ? "none" : "no matches", x + 6, rowY, TEXT_DIM, false); return rowY + 12; } int shown = 0; - for (Map.Entry entry : counts.entrySet().stream().sorted((a, b) -> Integer.compare(b.getValue(), a.getValue())).toList()) { + for (Map.Entry entry : filtered) { String rawLabel = normalizeLabels ? cleanProfilerLabel(entry.getKey()) : entry.getKey(); String label = textRenderer.trimToWidth(rawLabel, Math.max(60, width - 36)); ctx.drawText(textRenderer, label, x + 6, rowY, TEXT_PRIMARY, false); @@ -1712,20 +1281,37 @@ private int renderCountMap(DrawContext ctx, int x, int y, int width, String titl } - private int renderRuleFindingsSection(DrawContext ctx, int x, int y, int width, java.util.List findings) { - ctx.drawText(textRenderer, "Known problem detector", x, y, TEXT_PRIMARY, false); + int renderRuleFindingsSection(DrawContext ctx, int x, int y, int width, java.util.List findings) { + ctx.drawText(textRenderer, "Conflict and slowdown findings", x, y, TEXT_PRIMARY, false); int rowY = y + 14; - if (findings == null || findings.isEmpty()) { - ctx.drawText(textRenderer, "No active findings in the current window.", x + 6, rowY, TEXT_DIM, false); + List filteredFindings = findings == null ? List.of() : findings.stream() + .filter(finding -> matchesGlobalSearch((finding.category() + " " + finding.message() + " " + finding.details() + " " + finding.metricSummary()).toLowerCase(Locale.ROOT))) + .sorted((a, b) -> { + int sectionCompare = Integer.compare(findingSectionRank(a), findingSectionRank(b)); + if (sectionCompare != 0) { + return sectionCompare; + } + return Integer.compare(severitySortRank(b.severity()), severitySortRank(a.severity())); + }) + .toList(); + if (filteredFindings.isEmpty()) { + ctx.drawText(textRenderer, globalSearch.isBlank() ? "No active findings in the current window." : "No findings match the universal search.", x + 6, rowY, TEXT_DIM, false); return 24; } - if (selectedFindingKey == null || findings.stream().noneMatch(finding -> findingKey(finding).equals(selectedFindingKey))) { - selectedFindingKey = findingKey(findings.getFirst()); + if (selectedFindingKey == null || filteredFindings.stream().noneMatch(finding -> findingKey(finding).equals(selectedFindingKey))) { + selectedFindingKey = findingKey(filteredFindings.getFirst()); } boolean stacked = activeTab == 9 || width < 720; int listWidth = stacked ? width : Math.max(180, width / 2); int shown = 0; - for (ProfilerManager.RuleFinding finding : findings) { + String currentSection = null; + for (ProfilerManager.RuleFinding finding : filteredFindings) { + String section = findingSectionTitle(finding); + if (!section.equals(currentSection)) { + ctx.drawText(textRenderer, section, x + 6, rowY, ACCENT_YELLOW, false); + rowY += 12; + currentSection = section; + } int color = switch (finding.severity()) { case "critical" -> 0xFFFF4444; case "warning" -> ACCENT_YELLOW; @@ -1738,17 +1324,17 @@ private int renderRuleFindingsSection(DrawContext ctx, int x, int y, int width, ctx.fill(x + 2, rowY - 2, x + listWidth, rowY + itemHeight - 2, 0x18000000); } findingClickTargets.add(new FindingClickTarget(x + 2, rowY - 2, listWidth - 2, itemHeight, findingKey(finding))); - String heading = prettifyKey(finding.category()) + " | " + finding.severity().toUpperCase(Locale.ROOT) + " | " + finding.confidence(); + String heading = findingListLabel(finding) + " | " + finding.severity().toUpperCase(Locale.ROOT) + " | " + finding.confidence(); ctx.drawText(textRenderer, textRenderer.trimToWidth(heading, listWidth - 12), x + 6, rowY, color, false); rowY += 12; ctx.drawText(textRenderer, textRenderer.trimToWidth(finding.message(), listWidth - 18), x + 12, rowY, TEXT_PRIMARY, false); rowY += 14; shown++; - if (shown >= 5) { + if (shown >= 8) { break; } } - ProfilerManager.RuleFinding selected = findings.stream().filter(finding -> findingKey(finding).equals(selectedFindingKey)).findFirst().orElse(findings.getFirst()); + ProfilerManager.RuleFinding selected = filteredFindings.stream().filter(finding -> findingKey(finding).equals(selectedFindingKey)).findFirst().orElse(filteredFindings.getFirst()); int detailX = stacked ? x : x + listWidth + 8; int detailW = stacked ? width : Math.max(140, width - listWidth - 8); int detailBoxY = stacked ? rowY : y + 12; @@ -1768,6 +1354,47 @@ private int renderRuleFindingsSection(DrawContext ctx, int x, int y, int width, return stacked ? Math.max((detailBoxY + detailHeight + 8) - y, rowY - y) : Math.max(rowY - y, detailHeight + 8); } + private int findingSectionRank(ProfilerManager.RuleFinding finding) { + String category = finding == null || finding.category() == null ? "" : finding.category().toLowerCase(Locale.ROOT); + String confidence = finding == null || finding.confidence() == null ? "" : finding.confidence().toLowerCase(Locale.ROOT); + if (category.startsWith("conflict-confirmed") || confidence.equals("known incompatibility")) { + return 0; + } + if (category.startsWith("conflict-repeated")) { + return 1; + } + if (category.startsWith("conflict-weak") || confidence.equals("weak heuristic")) { + return 2; + } + return 3; + } + + private String findingSectionTitle(ProfilerManager.RuleFinding finding) { + return switch (findingSectionRank(finding)) { + case 0 -> "Confirmed contention"; + case 1 -> "Repeated conflict candidates"; + case 2 -> "Weak heuristics"; + default -> "Unrelated slowdown causes"; + }; + } + + private String findingListLabel(ProfilerManager.RuleFinding finding) { + String category = finding == null || finding.category() == null ? "" : finding.category(); + if (category.startsWith("conflict-")) { + return "Conflict"; + } + return prettifyKey(category); + } + + private int severitySortRank(String severity) { + return switch (severity == null ? "info" : severity.toLowerCase(Locale.ROOT)) { + case "critical" -> 3; + case "error" -> 2; + case "warning" -> 1; + default -> 0; + }; + } + private void renderThreadWaitSection(DrawContext ctx, int x, int y, int width, Map details) { ctx.drawText(textRenderer, "Blocked / waiting analysis", x, y, TEXT_PRIMARY, false); int rowY = y + 14; @@ -1791,14 +1418,17 @@ private void renderThreadWaitSection(DrawContext ctx, int x, int y, int width, M } } - private int renderPacketSpikeBookmarks(DrawContext ctx, int x, int y, int width, java.util.List spikes) { - if (spikes == null || spikes.isEmpty()) { - ctx.drawText(textRenderer, "No packet spike bookmarks yet.", x + 6, y, TEXT_DIM, false); + int renderPacketSpikeBookmarks(DrawContext ctx, int x, int y, int width, java.util.List spikes) { + List filtered = spikes == null ? List.of() : spikes.stream() + .filter(spike -> matchesGlobalSearch((formatPacketSummary(spike.inboundByCategory()) + " " + formatPacketSummary(spike.inboundByType()) + " " + formatPacketSummary(spike.outboundByCategory()) + " " + formatPacketSummary(spike.outboundByType())).toLowerCase(Locale.ROOT))) + .toList(); + if (filtered.isEmpty()) { + ctx.drawText(textRenderer, globalSearch.isBlank() ? "No packet spike bookmarks yet." : "No packet spikes match the universal search.", x + 6, y, TEXT_DIM, false); return 14; } int rowY = y; int shown = 0; - for (NetworkPacketProfiler.SpikeSnapshot spike : spikes) { + for (NetworkPacketProfiler.SpikeSnapshot spike : filtered) { String header = "Spike @ " + formatDuration(Math.max(0L, System.currentTimeMillis() - spike.capturedAtEpochMillis())) + " ago"; ctx.drawText(textRenderer, header, x + 6, rowY, TEXT_DIM, false); rowY += 12; @@ -1822,26 +1452,55 @@ private int renderPacketSpikeBookmarks(DrawContext ctx, int x, int y, int width, return rowY - y; } - private void renderSpikeInspector(DrawContext ctx, int x, int y, int width) { + void renderSpikeInspector(DrawContext ctx, int x, int y, int width) { ctx.drawText(textRenderer, "Spike inspector", x, y, TEXT_PRIMARY, false); int rowY = y + 14; - if (snapshot.spikes().isEmpty()) { - ctx.drawText(textRenderer, "No spike bookmarks yet. Capture a hitch to inspect it here.", x + 6, rowY, TEXT_DIM, false); + List filteredSpikes = snapshot.spikes().stream() + .filter(spike -> matchesGlobalSearch((spike.likelyBottleneck() + " " + String.join(" ", spike.topCpuMods()) + " " + String.join(" ", spike.topRenderPhases()) + " " + String.join(" ", spike.topThreads())).toLowerCase(Locale.ROOT))) + .toList(); + if (filteredSpikes.isEmpty()) { + ctx.drawText(textRenderer, globalSearch.isBlank() ? "No spike bookmarks yet. Capture a hitch to inspect it here." : "No spike bookmarks match the universal search.", x + 6, rowY, TEXT_DIM, false); return; } - ProfilerManager.SpikeCapture latest = snapshot.spikes().getFirst(); - ProfilerManager.SpikeCapture worst = snapshot.spikes().stream().max((a, b) -> Double.compare(a.frameDurationMs(), b.frameDurationMs())).orElse(latest); - ctx.drawText(textRenderer, textRenderer.trimToWidth(String.format(Locale.ROOT, "Latest spike %.1f ms | stutter %.1f | %s", latest.frameDurationMs(), latest.stutterScore(), latest.likelyBottleneck()), width), x + 6, rowY, ACCENT_YELLOW, false); - rowY += 12; - ctx.drawText(textRenderer, textRenderer.trimToWidth(String.format(Locale.ROOT, "Worst spike %.1f ms | entities %d | chunks %d/%d", worst.frameDurationMs(), worst.entityCounts().totalEntities(), worst.chunkCounts().loadedChunks(), worst.chunkCounts().renderedChunks()), width), x + 6, rowY, TEXT_PRIMARY, false); - rowY += 12; - ctx.drawText(textRenderer, textRenderer.trimToWidth("Top threads: " + String.join(" | ", latest.topThreads()), width), x + 6, rowY, TEXT_DIM, false); - rowY += 12; - ctx.drawText(textRenderer, textRenderer.trimToWidth("Top CPU mods: " + String.join(" | ", latest.topCpuMods()), width), x + 6, rowY, TEXT_DIM, false); - rowY += 12; - ctx.drawText(textRenderer, textRenderer.trimToWidth("Top render phases: " + String.join(" | ", latest.topRenderPhases()), width), x + 6, rowY, TEXT_DIM, false); - rowY += 14; - renderRuleFindingsSection(ctx, x + 6, rowY, width - 6, latest.findings()); + ProfilerManager.SpikeCapture pinned = ProfilerManager.getInstance().getPinnedSpike(); + if (pinned != null) { + ctx.drawText(textRenderer, textRenderer.trimToWidth(String.format(Locale.ROOT, "Pinned baseline %.1f ms | stutter %.1f | %s", pinned.frameDurationMs(), pinned.stutterScore(), pinned.likelyBottleneck()), width - 88), x + 6, rowY, ACCENT_GREEN, false); + renderSpikePinButton(ctx, x + width - 78, rowY - 2, 72, 14, "Clear Pin", null, true); + rowY += 16; + } + int shown = 0; + for (ProfilerManager.SpikeCapture spike : filteredSpikes) { + String summary = String.format(Locale.ROOT, "%.1f ms | stutter %.1f | %s", spike.frameDurationMs(), spike.stutterScore(), spike.likelyBottleneck()); + ctx.drawText(textRenderer, textRenderer.trimToWidth(summary, width - 84), x + 6, rowY, shown == 0 ? ACCENT_YELLOW : TEXT_PRIMARY, false); + renderSpikePinButton(ctx, x + width - 70, rowY - 2, 64, 14, spike.equals(pinned) ? "Pinned" : "Pin", spike, false); + rowY += 12; + ProfilerManager.SpikeDelta delta = ProfilerManager.getInstance().compareSpikeToPinned(spike); + if (delta != null) { + int deltaColor = delta.frameDurationDeltaMs() <= 0.0 ? ACCENT_GREEN : ACCENT_RED; + String deltaText = String.format(Locale.ROOT, "%+.1f ms vs pinned | stutter %+.1f | %s", delta.frameDurationDeltaMs(), delta.stutterScoreDelta(), delta.bottleneckChange()); + ctx.drawText(textRenderer, textRenderer.trimToWidth(deltaText, width - 12), x + 12, rowY, deltaColor, false); + rowY += 12; + } + ctx.drawText(textRenderer, textRenderer.trimToWidth("Top CPU: " + String.join(" | ", spike.topCpuMods()), width - 12), x + 12, rowY, TEXT_DIM, false); + rowY += 12; + ctx.drawText(textRenderer, textRenderer.trimToWidth("Top render: " + String.join(" | ", spike.topRenderPhases()), width - 12), x + 12, rowY, TEXT_DIM, false); + rowY += 12; + ctx.drawText(textRenderer, textRenderer.trimToWidth("Threads: " + String.join(" | ", spike.topThreads()), width - 12), x + 12, rowY, TEXT_DIM, false); + rowY += 14; + if (shown == 0) { + rowY += renderRuleFindingsSection(ctx, x + 6, rowY, width - 6, spike.findings()) + 6; + } + shown++; + if (shown >= 3) { + break; + } + } + } + + private void renderSpikePinButton(DrawContext ctx, int x, int y, int width, int height, String label, ProfilerManager.SpikeCapture spike, boolean clearPin) { + drawTopChip(ctx, x, y, width, height, true); + ctx.drawText(textRenderer, label, x + 8, y + 4, TEXT_PRIMARY, false); + spikePinClickTargets.add(new SpikePinClickTarget(x, y, width, height, spike, clearPin)); } private int renderSimpleHistoryGraph(DrawContext ctx, int x, int y, int width, int height, java.util.List history, String title, String units) { @@ -1890,7 +1549,7 @@ private int renderSimpleHistoryGraph(DrawContext ctx, int x, int y, int width, i return height; } - private int renderEntityHotspotSection(DrawContext ctx, int x, int y, int width, java.util.List hotspots, String title) { + int renderEntityHotspotSection(DrawContext ctx, int x, int y, int width, java.util.List hotspots, String title) { ctx.drawText(textRenderer, title, x, y, TEXT_PRIMARY, false); int rowY = y + 14; if (hotspots == null || hotspots.isEmpty()) { @@ -1911,7 +1570,7 @@ private int renderEntityHotspotSection(DrawContext ctx, int x, int y, int width, return rowY; } - private int renderBlockEntityHotspotSection(DrawContext ctx, int x, int y, int width, java.util.List hotspots, String title) { + int renderBlockEntityHotspotSection(DrawContext ctx, int x, int y, int width, java.util.List hotspots, String title) { ctx.drawText(textRenderer, title, x, y, TEXT_PRIMARY, false); int rowY = y + 14; if (hotspots == null || hotspots.isEmpty()) { @@ -1960,6 +1619,7 @@ public boolean mouseClicked(Click click, boolean doubled) { double mouseY = toLogicalY(click.y()); focusedSearchTable = null; startupSearchFocused = false; + globalSearchFocused = false; focusedColorSetting = null; if (attributionHelpOpen || activeDrilldownTable != null) { @@ -1986,6 +1646,11 @@ public boolean mouseClicked(Click click, boolean doubled) { return true; } + if (isInside(mouseX, mouseY, getScreenWidth() - 438, 3, 176, 14)) { + globalSearchFocused = true; + return true; + } + for (FindingClickTarget target : findingClickTargets) { if (isInside(mouseX, mouseY, target.x(), target.y(), target.width(), target.height())) { selectedFindingKey = target.key(); @@ -1993,6 +1658,17 @@ public boolean mouseClicked(Click click, boolean doubled) { } } + for (SpikePinClickTarget target : spikePinClickTargets) { + if (isInside(mouseX, mouseY, target.x(), target.y(), target.width(), target.height())) { + if (target.clearPin()) { + ProfilerManager.getInstance().clearPinnedSpike(); + } else { + ProfilerManager.getInstance().pinSpike(target.spike()); + } + return true; + } + } + int tabY = getTabY(); int tabW = Math.max(66, Math.min(84, (getScreenWidth() - (PADDING * 2) - ((TAB_NAMES.length - 1) * 2)) / TAB_NAMES.length)); for (int i = 0; i < TAB_NAMES.length; i++) { @@ -2009,7 +1685,7 @@ public boolean mouseClicked(Click click, boolean doubled) { int detailW = Math.min(420, Math.max(320, getScreenWidth() / 3)); int listW = getScreenWidth() - detailW - PADDING; if (isInside(mouseX, mouseY, PADDING, getContentY() + PADDING + 34, 78, 16)) { - activeTab = 10; + activeTab = 11; systemMiniTab = SystemMiniTab.CPU_GRAPH; scrollOffset = 0; return true; @@ -2050,7 +1726,7 @@ public boolean mouseClicked(Click click, boolean doubled) { int detailW = Math.min(420, Math.max(320, getScreenWidth() / 3)); int listW = getScreenWidth() - detailW - PADDING; if (isInside(mouseX, mouseY, PADDING, getContentY() + PADDING + 34, 78, 16)) { - activeTab = 10; + activeTab = 11; systemMiniTab = SystemMiniTab.GPU_GRAPH; scrollOffset = 0; return true; @@ -2093,7 +1769,7 @@ public boolean mouseClicked(Click click, boolean doubled) { int memoryDescriptionBottomY = getContentY() + PADDING + measureWrappedHeight(Math.max(260, tableW - 16), memoryEffectiveView ? "Effective live heap by mod with shared/runtime buckets folded into concrete mods for comparison. Updated asynchronously." : "Raw live heap by owner/class family. Shared/runtime buckets stay separate until you switch back to Effective view."); int memoryControlsTopY = memoryDescriptionBottomY + 26; if (isInside(mouseX, mouseY, tableW - 106, memoryControlsTopY + 18, 98, 16)) { - activeTab = 10; + activeTab = 11; systemMiniTab = SystemMiniTab.MEMORY_GRAPH; scrollOffset = 0; return true; @@ -2183,6 +1859,34 @@ public boolean mouseClicked(Click click, boolean doubled) { } if (activeTab == 10) { + if (handleThreadToolbarClick(mouseX, mouseY)) { + return true; + } + long clickedThreadId = findThreadRowAt(mouseX, mouseY); + if (clickedThreadId >= 0L) { + selectedThreadId = clickedThreadId; + return true; + } + } + + if (activeTab == 6) { + int left = PADDING + Math.max(PADDING, (getScreenWidth() - getPreferredGraphWidth(getScreenWidth())) / 2); + int top = getFullPageScrollTop(getContentY()) + 28; + if (isInside(mouseX, mouseY, left, top, 96, 16)) { + ProfilerManager.getInstance().setBaseline(ProfilerManager.getInstance().captureBaseline("manual")); + return true; + } + if (isInside(mouseX, mouseY, left + 102, top, 112, 16)) { + ProfilerManager.getInstance().importBaselineFromLatestExport(); + return true; + } + if (isInside(mouseX, mouseY, left + 220, top, 74, 16)) { + ProfilerManager.getInstance().clearBaseline(); + return true; + } + } + + if (activeTab == 11) { int left = PADDING; int top = getFullPageScrollTop(getContentY()) + 28; if (isInside(mouseX, mouseY, left - 3, top - 2, 84, 20)) { @@ -2225,7 +1929,7 @@ public boolean mouseClicked(Click click, boolean doubled) { } } - if (activeTab == 11) { + if (activeTab == 12) { int left = PADDING; int actionY = getContentY() + PADDING + 18 - scrollOffset; if (isInside(mouseX, mouseY, left + 104, actionY - 2, 108, 16)) { @@ -2281,6 +1985,16 @@ public boolean mouseClicked(Click click, boolean doubled) { actionY += 22; } + actionY += 32; + Runnable[] performanceAlertActions = performanceAlertActions(); + for (Runnable action : performanceAlertActions) { + if (isInside(mouseX, mouseY, left, actionY, getScreenWidth() - 16, 16)) { + action.run(); + return true; + } + actionY += 22; + } + actionY += 32; Runnable[] tableActions = tableActions(); for (Runnable action : tableActions) { @@ -2316,7 +2030,7 @@ public boolean mouseClicked(Click click, boolean doubled) { @Override public boolean mouseDragged(Click click, double deltaX, double deltaY) { - if (draggingHudTransparency && activeTab == 11) { + if (draggingHudTransparency && activeTab == 12) { int left = PADDING; int actionY = getContentY() + PADDING + 18 - scrollOffset; actionY += (22 * sessionActionCount()) + 32 + (22 * hudBaseActionCount()); @@ -2349,18 +2063,19 @@ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmou private int getMaxScrollOffset() { int visibleHeight = Math.max(1, getScreenHeight() - getContentY() - PADDING); int contentHeight = switch (activeTab) { - case 0 -> Math.max(visibleHeight, 40 + (snapshot.cpuMods().size() * ROW_HEIGHT)); - case 1 -> Math.max(visibleHeight, 40 + ((int) snapshot.cpuMods().values().stream().filter(entry -> entry.renderSamples() > 0).count() * ROW_HEIGHT)); + case 0 -> Math.max(visibleHeight, 40 + (getTaskRows().size() * ATTRIBUTION_ROW_HEIGHT)); + case 1 -> Math.max(visibleHeight, 40 + (getGpuRows().size() * ATTRIBUTION_ROW_HEIGHT)); case 2 -> Math.max(visibleHeight, 32 + (snapshot.renderPhases().size() * ROW_HEIGHT)); case 3 -> Math.max(visibleHeight, 68 + (snapshot.startupRows().size() * STARTUP_ROW_HEIGHT)); - case 4 -> Math.max(visibleHeight, 214 + (snapshot.memoryMods().size() * ROW_HEIGHT)); + case 4 -> Math.max(visibleHeight, 214 + (getMemoryRows().size() * ATTRIBUTION_ROW_HEIGHT)); case 5 -> Math.max(visibleHeight, 44 + (Math.min(20, snapshot.flamegraphStacks().size()) * 12)); case 6 -> Math.max(visibleHeight, 430); case 7 -> Math.max(visibleHeight, 560); case 8 -> Math.max(visibleHeight, 240); case 9 -> Math.max(visibleHeight, 980); - case 10 -> Math.max(visibleHeight, 1240); - case 11 -> Math.max(visibleHeight, getSettingsContentHeight()); + case 10 -> Math.max(visibleHeight, 56 + (getThreadRows().size() * ATTRIBUTION_ROW_HEIGHT)); + case 11 -> Math.max(visibleHeight, 1560); + case 12 -> Math.max(visibleHeight, getSettingsContentHeight()); default -> visibleHeight; }; return Math.max(0, contentHeight - visibleHeight); @@ -2386,6 +2101,9 @@ private int getSettingsContentHeight() { contentHeight += hudRateActionCount() * 22; contentHeight += 32; contentHeight += 18; + contentHeight += performanceAlertActionCount() * 22; + contentHeight += 32; + contentHeight += 18; contentHeight += tableActionCount() * 22; contentHeight += 32; contentHeight += 18; @@ -2411,6 +2129,10 @@ static int hudRateActionCount() { return hudRateActions().length; } + static int performanceAlertActionCount() { + return performanceAlertActions().length; + } + static int tableActionCount() { return tableActions().length; } @@ -2487,6 +2209,16 @@ private static Runnable[] hudRateActions() { }; } + private static Runnable[] performanceAlertActions() { + return new Runnable[] { + ConfigManager::togglePerformanceAlertsEnabled, + ConfigManager::togglePerformanceAlertChatEnabled, + ConfigManager::cyclePerformanceAlertFrameThresholdMs, + ConfigManager::cyclePerformanceAlertServerThresholdMs, + ConfigManager::cyclePerformanceAlertConsecutiveTicks + }; + } + private static Runnable[] tableActions() { return new Runnable[] { () -> ConfigManager.toggleTasksColumn("cpu"), @@ -2517,6 +2249,11 @@ public boolean charTyped(CharInput input) { colorEditValue = normalizeColorEdit(colorEditValue + input.asString()); return true; } + if (globalSearchFocused && input.isValidChar()) { + globalSearch += input.asString(); + scrollOffset = 0; + return true; + } if (startupSearchFocused && input.isValidChar()) { startupSearch += input.asString(); scrollOffset = 0; @@ -2542,6 +2279,12 @@ public boolean keyPressed(KeyInput input) { close(); return true; } + if (input.key() == 47) { + globalSearchFocused = true; + focusedSearchTable = null; + startupSearchFocused = false; + return true; + } if (focusedColorSetting != null) { if (input.key() == 259) { if (!colorEditValue.isEmpty()) { @@ -2562,15 +2305,27 @@ public boolean keyPressed(KeyInput input) { return true; } } - if (startupSearchFocused) { + if (startupSearchFocused) { + if (input.key() == 259) { + if (!startupSearch.isEmpty()) { + startupSearch = startupSearch.substring(0, startupSearch.length() - 1); + } + return true; + } + if (input.key() == 256) { + startupSearchFocused = false; + return true; + } + } + if (globalSearchFocused) { if (input.key() == 259) { - if (!startupSearch.isEmpty()) { - startupSearch = startupSearch.substring(0, startupSearch.length() - 1); + if (!globalSearch.isEmpty()) { + globalSearch = globalSearch.substring(0, globalSearch.length() - 1); } return true; } if (input.key() == 256) { - startupSearchFocused = false; + globalSearchFocused = false; return true; } } @@ -2607,18 +2362,29 @@ private void setSearchValue(TableId tableId, String value) { } } - private List getTaskRows(Map cpu, Map cpuDetails, Map invokes, boolean includeShared) { + private boolean matchesCombinedSearch(String haystack, String localQuery) { + return SearchState.matchesCombinedSearch(haystack, globalSearch, localQuery); + } + + boolean matchesGlobalSearch(String haystack) { + return SearchState.matchesQuery(haystack, globalSearch); + } + + private boolean matchesQuery(String haystack, String query) { + return SearchState.matchesQuery(haystack, query); + } + + List getTaskRows(Map cpu, Map cpuDetails, Map invokes, boolean includeShared) { LinkedHashSet mods = new LinkedHashSet<>(); mods.addAll(cpu.keySet()); mods.addAll(invokes.keySet()); - String query = tasksSearch.toLowerCase(Locale.ROOT); List rows = new ArrayList<>(); for (String modId : mods) { - if (!includeShared && isSharedAttributionBucket(modId)) { + if (!includeShared && isSharedAttributionBucket(modId) && !"shared/gpu-stall".equals(modId)) { continue; } String haystack = (modId + " " + getDisplayName(modId)).toLowerCase(Locale.ROOT); - if (query.isBlank() || haystack.contains(query)) { + if (matchesCombinedSearch(haystack, tasksSearch)) { rows.add(modId); } } @@ -2630,28 +2396,30 @@ private List getTaskRows(Map cpu, } private Comparator taskComparator(Map cpu, Map cpuDetails, Map invokes) { - long totalCpuSamples = Math.max(1L, cpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum()); + long totalCpuSamples = Math.max(1L, totalCpuMetric(cpu)); return switch (taskSort) { case NAME -> Comparator.comparing((String modId) -> getDisplayName(modId).toLowerCase(Locale.ROOT)); case THREADS -> Comparator.comparingInt((String modId) -> cpuDetails.get(modId) == null ? 0 : cpuDetails.get(modId).sampledThreadCount()); case SAMPLES -> Comparator.comparingLong((String modId) -> cpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)).totalSamples()); case INVOKES -> Comparator.comparingLong((String modId) -> invokes.getOrDefault(modId, new ModTimingSnapshot(0, 0)).calls()); - case CPU -> Comparator.comparingDouble((String modId) -> cpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)).totalSamples() * 100.0 / totalCpuSamples); + case CPU -> Comparator.comparingDouble((String modId) -> cpuMetricValue(cpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0))) * 100.0 / totalCpuSamples); }; } - private List getGpuRows(EffectiveGpuAttribution gpuAttribution, Map cpuDetails, boolean includeShared) { - String query = gpuSearch.toLowerCase(Locale.ROOT); + List getGpuRows(EffectiveGpuAttribution gpuAttribution, Map cpuDetails, boolean includeShared) { List rows = new ArrayList<>(); + boolean forceShowShared = gpuAttribution.gpuNanosByMod().getOrDefault("shared/render", 0L) > 0L + && gpuAttribution.gpuNanosByMod().entrySet().stream() + .noneMatch(entry -> !isSharedAttributionBucket(entry.getKey()) && entry.getValue() > 0L); for (String modId : gpuAttribution.gpuNanosByMod().keySet()) { if (gpuAttribution.gpuNanosByMod().getOrDefault(modId, 0L) <= 0L && gpuAttribution.renderSamplesByMod().getOrDefault(modId, 0L) <= 0L) { continue; } - if (!includeShared && isSharedAttributionBucket(modId)) { + if (!includeShared && isSharedAttributionBucket(modId) && !forceShowShared) { continue; } String haystack = (modId + " " + getDisplayName(modId)).toLowerCase(Locale.ROOT); - if (query.isBlank() || haystack.contains(query)) { + if (matchesCombinedSearch(haystack, gpuSearch)) { rows.add(modId); } } @@ -2673,16 +2441,15 @@ private Comparator gpuComparator(EffectiveGpuAttribution gpuAttribution, }; } - private List getMemoryRows(Map memoryMods, Map> memoryClassesByMod, boolean includeShared) { + List getMemoryRows(Map memoryMods, Map> memoryClassesByMod, boolean includeShared) { long totalAttributedBytes = Math.max(1L, memoryMods.values().stream().mapToLong(Long::longValue).sum()); - String query = memorySearch.toLowerCase(Locale.ROOT); List rows = new ArrayList<>(); for (String modId : memoryMods.keySet()) { if (!includeShared && isSharedAttributionBucket(modId)) { continue; } String haystack = (modId + " " + getDisplayName(modId)).toLowerCase(Locale.ROOT); - if (query.isBlank() || haystack.contains(query)) { + if (matchesCombinedSearch(haystack, memorySearch)) { rows.add(modId); } } @@ -2702,257 +2469,131 @@ private Comparator memoryComparator(Map memoryMods, Map rawCpu, Map invokes) { - LinkedHashMap concrete = new LinkedHashMap<>(); - long sharedTotalSamples = 0L; - long sharedClientSamples = 0L; - long sharedRenderSamples = 0L; - for (Map.Entry entry : rawCpu.entrySet()) { - String modId = entry.getKey(); - CpuSamplingProfiler.Snapshot sample = entry.getValue(); - if (isSharedAttributionBucket(modId)) { - sharedTotalSamples += sample.totalSamples(); - sharedClientSamples += sample.clientSamples(); - sharedRenderSamples += sample.renderSamples(); - } else { - concrete.put(modId, sample); - } + private void invalidateDerivedAttributionCacheIfSnapshotChanged() { + if (cachedAttributionSnapshot == snapshot) { + return; } - if (concrete.isEmpty()) { - long totalSamples = rawCpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum(); - long totalRenderSamples = rawCpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::renderSamples).sum(); - return new EffectiveCpuAttribution(new LinkedHashMap<>(rawCpu), Map.of(), Map.of(), totalSamples, totalRenderSamples); - } - - Map totalWeights = buildCpuWeightMap(concrete, invokes, CpuSamplingProfiler.Snapshot::totalSamples); - Map clientWeights = buildCpuWeightMap(concrete, invokes, CpuSamplingProfiler.Snapshot::clientSamples); - Map renderWeights = buildCpuWeightMap(concrete, invokes, CpuSamplingProfiler.Snapshot::renderSamples); - Map redistributedTotals = distributeLongProportionally(sharedTotalSamples, totalWeights); - Map redistributedClients = distributeLongProportionally(sharedClientSamples, clientWeights); - Map redistributedRenders = distributeLongProportionally(sharedRenderSamples, renderWeights); - - LinkedHashMap display = new LinkedHashMap<>(); - for (Map.Entry entry : concrete.entrySet()) { - String modId = entry.getKey(); - CpuSamplingProfiler.Snapshot sample = entry.getValue(); - display.put(modId, new CpuSamplingProfiler.Snapshot( - sample.totalSamples() + redistributedTotals.getOrDefault(modId, 0L), - sample.clientSamples() + redistributedClients.getOrDefault(modId, 0L), - sample.renderSamples() + redistributedRenders.getOrDefault(modId, 0L) - )); - } - - long totalSamples = display.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum(); - long totalRenderSamples = display.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::renderSamples).sum(); - return new EffectiveCpuAttribution(display, redistributedTotals, redistributedRenders, totalSamples, totalRenderSamples); - } - - private EffectiveGpuAttribution buildEffectiveGpuAttribution(boolean effectiveView) { - Map cpu = snapshot.cpuMods(); - EffectiveCpuAttribution effectiveCpu = buildEffectiveCpuAttribution(cpu, snapshot.modInvokes()); - Map renderSource = effectiveView ? effectiveCpu.displaySnapshots() : cpu; - LinkedHashMap renderSamplesByMod = new LinkedHashMap<>(); - renderSource.forEach((modId, sample) -> { - if (sample.renderSamples() > 0L) { - renderSamplesByMod.put(modId, sample.renderSamples()); - } - }); + cachedAttributionSnapshot = snapshot; + cachedEffectiveCpuAttribution = null; + cachedRawGpuAttribution = null; + cachedEffectiveGpuAttribution = null; + cachedEffectiveMemoryAttribution = null; + cachedRawCpuTotalMetric = Math.max(1L, totalCpuMetric(snapshot.cpuMods())); + cachedRawMemoryTotalBytes = Math.max(1L, snapshot.memoryMods().values().stream().mapToLong(Long::longValue).sum()); + } - LinkedHashMap directGpuByMod = new LinkedHashMap<>(); - long sharedGpuNanos = 0L; - for (RenderPhaseProfiler.PhaseSnapshot phase : snapshot.renderPhases().values()) { - if (phase.gpuNanos() <= 0L) { - continue; - } - String ownerMod = phase.ownerMod() == null || phase.ownerMod().isBlank() ? "shared/render" : phase.ownerMod(); - if (isSharedAttributionBucket(ownerMod)) { - sharedGpuNanos += phase.gpuNanos(); - } else { - directGpuByMod.merge(ownerMod, phase.gpuNanos(), Long::sum); - } + private void ensureCpuAttributionCache() { + invalidateDerivedAttributionCacheIfSnapshotChanged(); + if (cachedEffectiveCpuAttribution != null) { + return; } + cachedEffectiveCpuAttribution = AttributionModelBuilder.buildEffectiveCpuAttribution(snapshot.cpuMods(), snapshot.cpuDetails(), snapshot.modInvokes()); + } - if (!effectiveView) { - if (sharedGpuNanos > 0L) { - directGpuByMod.merge("shared/render", sharedGpuNanos, Long::sum); - renderSamplesByMod.putIfAbsent("shared/render", 0L); - } - long totalGpuNanos = Math.max(1L, directGpuByMod.values().stream().mapToLong(Long::longValue).sum()); - long totalRenderSamples = Math.max(1L, renderSamplesByMod.values().stream().mapToLong(Long::longValue).sum()); - return new EffectiveGpuAttribution(directGpuByMod, renderSamplesByMod, Map.of(), Map.of(), totalGpuNanos, totalRenderSamples); - } - - LinkedHashMap effectiveGpuByMod = new LinkedHashMap<>(); - renderSamplesByMod.keySet().forEach(modId -> effectiveGpuByMod.put(modId, directGpuByMod.getOrDefault(modId, 0L))); - directGpuByMod.forEach((modId, gpuNanos) -> effectiveGpuByMod.putIfAbsent(modId, gpuNanos)); - Map weights = buildGpuWeightMap(renderSamplesByMod, effectiveGpuByMod); - Map redistributedGpu = distributeLongProportionally(sharedGpuNanos, weights); - redistributedGpu.forEach((modId, gpuNanos) -> effectiveGpuByMod.merge(modId, gpuNanos, Long::sum)); - effectiveGpuByMod.entrySet().removeIf(entry -> entry.getValue() <= 0L && renderSamplesByMod.getOrDefault(entry.getKey(), 0L) <= 0L); - long totalGpuNanos = Math.max(1L, effectiveGpuByMod.values().stream().mapToLong(Long::longValue).sum()); - long totalRenderSamples = Math.max(1L, renderSamplesByMod.values().stream().mapToLong(Long::longValue).sum()); - return new EffectiveGpuAttribution(effectiveGpuByMod, renderSamplesByMod, redistributedGpu, effectiveCpu.redistributedRenderSamplesByMod(), totalGpuNanos, totalRenderSamples); - } - - private EffectiveMemoryAttribution buildEffectiveMemoryAttribution(Map rawMemoryMods) { - LinkedHashMap concrete = new LinkedHashMap<>(); - long sharedBytes = 0L; - for (Map.Entry entry : rawMemoryMods.entrySet()) { - if (isSharedAttributionBucket(entry.getKey())) { - sharedBytes += entry.getValue(); - } else { - concrete.put(entry.getKey(), entry.getValue()); - } + private void ensureGpuAttributionCache() { + invalidateDerivedAttributionCacheIfSnapshotChanged(); + ensureCpuAttributionCache(); + if (cachedRawGpuAttribution == null) { + cachedRawGpuAttribution = AttributionModelBuilder.buildEffectiveGpuAttribution(snapshot.renderPhases(), snapshot.cpuMods(), cachedEffectiveCpuAttribution, false); } - if (concrete.isEmpty()) { - long totalBytes = rawMemoryMods.values().stream().mapToLong(Long::longValue).sum(); - return new EffectiveMemoryAttribution(new LinkedHashMap<>(rawMemoryMods), Map.of(), totalBytes); + if (cachedEffectiveGpuAttribution == null) { + cachedEffectiveGpuAttribution = AttributionModelBuilder.buildEffectiveGpuAttribution(snapshot.renderPhases(), snapshot.cpuMods(), cachedEffectiveCpuAttribution, true); } - Map weights = buildMemoryWeightMap(concrete); - Map redistributed = distributeLongProportionally(sharedBytes, weights); - LinkedHashMap display = new LinkedHashMap<>(); - for (Map.Entry entry : concrete.entrySet()) { - display.put(entry.getKey(), entry.getValue() + redistributed.getOrDefault(entry.getKey(), 0L)); - } - long totalBytes = display.values().stream().mapToLong(Long::longValue).sum(); - return new EffectiveMemoryAttribution(display, redistributed, totalBytes); } - private Map buildCpuWeightMap(Map concrete, Map invokes, java.util.function.ToLongFunction extractor) { - LinkedHashMap weights = new LinkedHashMap<>(); - double total = 0.0; - for (Map.Entry entry : concrete.entrySet()) { - double weight = Math.max(0L, extractor.applyAsLong(entry.getValue())); - weights.put(entry.getKey(), weight); - total += weight; - } - if (total > 0.0) { - return weights; - } - double invokeTotal = 0.0; - for (String modId : concrete.keySet()) { - double weight = Math.max(0L, invokes.getOrDefault(modId, new ModTimingSnapshot(0, 0)).calls()); - weights.put(modId, weight); - invokeTotal += weight; - } - if (invokeTotal > 0.0) { - return weights; - } - for (String modId : concrete.keySet()) { - weights.put(modId, 1.0); + private void ensureMemoryAttributionCache() { + invalidateDerivedAttributionCacheIfSnapshotChanged(); + if (cachedEffectiveMemoryAttribution != null) { + return; } - return weights; + cachedEffectiveMemoryAttribution = AttributionModelBuilder.buildEffectiveMemoryAttribution(snapshot.memoryMods()); } - private Map buildMemoryWeightMap(Map concrete) { - LinkedHashMap weights = new LinkedHashMap<>(); - double total = 0.0; - for (Map.Entry entry : concrete.entrySet()) { - double weight = Math.max(0L, entry.getValue()); - weights.put(entry.getKey(), weight); - total += weight; - } - if (total > 0.0) { - return weights; - } - for (String modId : concrete.keySet()) { - weights.put(modId, 1.0); - } - return weights; + EffectiveCpuAttribution effectiveCpuAttribution() { + ensureCpuAttributionCache(); + return cachedEffectiveCpuAttribution; } - private Map buildGpuWeightMap(Map renderSamplesByMod, Map directGpuByMod) { - LinkedHashMap weights = new LinkedHashMap<>(); - double total = 0.0; - for (Map.Entry entry : renderSamplesByMod.entrySet()) { - if (isSharedAttributionBucket(entry.getKey())) { - continue; - } - double weight = Math.max(0L, entry.getValue()); - weights.put(entry.getKey(), weight); - total += weight; - } - if (total > 0.0) { - return weights; - } - for (Map.Entry entry : directGpuByMod.entrySet()) { - if (isSharedAttributionBucket(entry.getKey())) { - continue; - } - double weight = Math.max(0L, entry.getValue()); - weights.put(entry.getKey(), weight); - total += weight; - } - if (total > 0.0) { - return weights; - } - for (String modId : renderSamplesByMod.keySet()) { - if (!isSharedAttributionBucket(modId)) { - weights.put(modId, 1.0); - } - } - if (weights.isEmpty()) { - directGpuByMod.keySet().stream().filter(modId -> !isSharedAttributionBucket(modId)).forEach(modId -> weights.put(modId, 1.0)); - } - return weights; + EffectiveGpuAttribution rawGpuAttribution() { + ensureGpuAttributionCache(); + return cachedRawGpuAttribution; } - private Map distributeLongProportionally(long total, Map weights) { - if (total <= 0L || weights.isEmpty()) { - return Map.of(); - } - LinkedHashMap result = new LinkedHashMap<>(); - ArrayList> entries = new ArrayList<>(weights.entrySet()); - double weightSum = entries.stream().mapToDouble(entry -> Math.max(0.0, entry.getValue())).sum(); - if (weightSum <= 0.0) { - for (Map.Entry entry : entries) { - result.put(entry.getKey(), 0L); - } - return result; + EffectiveGpuAttribution effectiveGpuAttribution() { + ensureGpuAttributionCache(); + return cachedEffectiveGpuAttribution; + } + + EffectiveMemoryAttribution effectiveMemoryAttribution() { + ensureMemoryAttributionCache(); + return cachedEffectiveMemoryAttribution; + } + + long cpuMetricValue(CpuSamplingProfiler.Snapshot snapshot) { + if (snapshot == null) { + return 0L; } - LinkedHashMap remainders = new LinkedHashMap<>(); - long assigned = 0L; - for (Map.Entry entry : entries) { - double exact = total * Math.max(0.0, entry.getValue()) / weightSum; - long whole = (long) Math.floor(exact); - result.put(entry.getKey(), whole); - remainders.put(entry.getKey(), exact - whole); - assigned += whole; - } - long remainder = total - assigned; - if (remainder > 0L) { - entries.sort((a, b) -> Double.compare(remainders.getOrDefault(b.getKey(), 0.0), remainders.getOrDefault(a.getKey(), 0.0))); - for (int i = 0; i < remainder; i++) { - String modId = entries.get(i % entries.size()).getKey(); - result.put(modId, result.getOrDefault(modId, 0L) + 1L); - } + return snapshot.totalCpuNanos() > 0L ? snapshot.totalCpuNanos() : snapshot.totalSamples(); + } + + long totalCpuMetric(Map snapshots) { + long totalCpuNanos = snapshots.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalCpuNanos).sum(); + if (totalCpuNanos > 0L) { + return totalCpuNanos; } - return result; + return snapshots.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum(); } + + private List getTaskRows() { - Map displayCpu = taskEffectiveView ? buildEffectiveCpuAttribution(snapshot.cpuMods(), snapshot.modInvokes()).displaySnapshots() : snapshot.cpuMods(); + Map displayCpu = taskEffectiveView ? effectiveCpuAttribution().displaySnapshots() : snapshot.cpuMods(); return getTaskRows(displayCpu, snapshot.cpuDetails(), snapshot.modInvokes(), !taskEffectiveView && taskShowSharedRows); } private List getGpuRows() { - return getGpuRows(buildEffectiveGpuAttribution(gpuEffectiveView), snapshot.cpuDetails(), !gpuEffectiveView && gpuShowSharedRows); + return getGpuRows(gpuEffectiveView ? effectiveGpuAttribution() : rawGpuAttribution(), snapshot.cpuDetails(), !gpuEffectiveView && gpuShowSharedRows); } private List getMemoryRows() { - Map displayMemory = memoryEffectiveView ? buildEffectiveMemoryAttribution(snapshot.memoryMods()).displayBytes() : snapshot.memoryMods(); + Map displayMemory = memoryEffectiveView ? effectiveMemoryAttribution().displayBytes() : snapshot.memoryMods(); return getMemoryRows(displayMemory, snapshot.memoryClassesByMod(), !memoryEffectiveView && memoryShowSharedRows); } - private java.util.List getStartupRows() { - String query = startupSearch.toLowerCase(Locale.ROOT); + + List getThreadRows() { + List sourceRows = (threadFreeze && !frozenThreadRows.isEmpty()) + ? frozenThreadRows + : snapshot.systemMetrics().threadDrilldown(); + List rows = sourceRows.stream() + .filter(thread -> matchesGlobalSearch((thread.threadName() + " " + thread.ownerMod() + " " + thread.reasonFrame() + " " + String.join(" ", thread.topFrames())).toLowerCase(Locale.ROOT))) + .sorted(threadComparator()) + .toList(); + if (threadSortDescending) { + List reversed = new ArrayList<>(rows); + Collections.reverse(reversed); + return reversed; + } + return rows; + } + + Comparator threadComparator() { + return switch (threadSort) { + case NAME -> Comparator.comparing(thread -> cleanProfilerLabel(thread.threadName()).toLowerCase(Locale.ROOT)); + case CPU -> Comparator.comparingDouble(SystemMetricsProfiler.ThreadDrilldown::cpuLoadPercent); + case ALLOC -> Comparator.comparingLong(SystemMetricsProfiler.ThreadDrilldown::allocationRateBytesPerSecond); + case BLOCKED -> Comparator.comparingLong(SystemMetricsProfiler.ThreadDrilldown::blockedTimeDeltaMs); + case WAITED -> Comparator.comparingLong(SystemMetricsProfiler.ThreadDrilldown::waitedTimeDeltaMs); + }; + } + java.util.List getStartupRows() { java.util.List rows = new ArrayList<>(); for (StartupTimingProfiler.StartupRow row : snapshot.startupRows()) { String haystack = (row.modId() + " " + getDisplayName(row.modId()) + " " + row.stageSummary() + " " + row.definitionSummary()).toLowerCase(Locale.ROOT); - if (query.isBlank() || haystack.contains(query)) { + if (matchesCombinedSearch(haystack, startupSearch)) { rows.add(row); } } @@ -2974,7 +2615,7 @@ private Comparator startupComparator() { }; } - private boolean isColumnVisible(TableId tableId, String key) { + boolean isColumnVisible(TableId tableId, String key) { return switch (tableId) { case TASKS -> ConfigManager.isTasksColumnVisible(key); case GPU -> ConfigManager.isGpuColumnVisible(key); @@ -3093,18 +2734,16 @@ private String findTaskRowAt(double mouseX, double mouseY) { if (activeTab != 0) { return null; } - int detailW = Math.min(420, Math.max(320, getScreenWidth() / 3)); - int listW = width - detailW - PADDING; - return findRowAt(mouseX, mouseY, getContentY() + PADDING + 92, listW, getTaskRows()); + AttributionListLayout layout = getTaskListLayout(); + return findRowAt(mouseX, mouseY, layout.listY(), layout.listWidth(), getTaskRows(), ATTRIBUTION_ROW_HEIGHT); } private String findGpuRowAt(double mouseX, double mouseY) { if (activeTab != 1) { return null; } - int detailW = Math.min(420, Math.max(320, getScreenWidth() / 3)); - int listW = width - detailW - PADDING; - return findRowAt(mouseX, mouseY, getContentY() + PADDING + 92, listW, getGpuRows()); + AttributionListLayout layout = getGpuListLayout(); + return findRowAt(mouseX, mouseY, layout.listY(), layout.listWidth(), getGpuRows(), ATTRIBUTION_ROW_HEIGHT); } private String findMemoryRowAt(double mouseX, double mouseY) { @@ -3115,7 +2754,51 @@ private String findMemoryRowAt(double mouseX, double mouseY) { if (!isInside(mouseX, mouseY, 0, layout.listY(), layout.tableWidth(), layout.listHeight())) { return null; } - return findRowAt(mouseX, mouseY, layout.listY(), layout.tableWidth(), getMemoryRows()); + return findRowAt(mouseX, mouseY, layout.listY(), layout.tableWidth(), getMemoryRows(), ATTRIBUTION_ROW_HEIGHT); + } + + private long findThreadRowAt(double mouseX, double mouseY) { + if (activeTab != 10) { + return -1L; + } + AttributionListLayout layout = getThreadListLayout(); + int rowY = layout.listY() - scrollOffset; + for (SystemMetricsProfiler.ThreadDrilldown thread : getThreadRows()) { + if (isInside(mouseX, mouseY, 0, rowY, layout.listWidth(), ATTRIBUTION_ROW_HEIGHT)) { + return thread.threadId(); + } + rowY += ATTRIBUTION_ROW_HEIGHT; + } + return -1L; + } + + private boolean handleThreadToolbarClick(double mouseX, double mouseY) { + AttributionListLayout layout = getThreadListLayout(); + int x = PADDING; + int y = layout.headerY() - 22; + if (isInside(mouseX, mouseY, x, y, 70, 16)) { toggleThreadSort(ThreadSort.CPU); return true; } + if (isInside(mouseX, mouseY, x + 74, y, 82, 16)) { toggleThreadSort(ThreadSort.ALLOC); return true; } + if (isInside(mouseX, mouseY, x + 160, y, 86, 16)) { toggleThreadSort(ThreadSort.BLOCKED); return true; } + if (isInside(mouseX, mouseY, x + 250, y, 82, 16)) { toggleThreadSort(ThreadSort.WAITED); return true; } + if (isInside(mouseX, mouseY, x + 336, y, 76, 16)) { toggleThreadSort(ThreadSort.NAME); return true; } + if (isInside(mouseX, mouseY, x + 418, y, 86, 16)) { + threadFreeze = !threadFreeze; + frozenThreadRows = threadFreeze ? new ArrayList<>(snapshot.systemMetrics().threadDrilldown()) : List.of(); + if (threadFreeze && selectedThreadId < 0L && !frozenThreadRows.isEmpty()) { + selectedThreadId = frozenThreadRows.getFirst().threadId(); + } + return true; + } + return false; + } + + private void toggleThreadSort(ThreadSort sort) { + if (threadSort == sort) { + threadSortDescending = !threadSortDescending; + } else { + threadSort = sort; + threadSortDescending = true; + } } private String findSharedFamilyAt(double mouseX, double mouseY) { @@ -3158,17 +2841,58 @@ private ChunkPos findLagMapChunkAt(double mouseX, double mouseY) { return null; } - private String findRowAt(double mouseX, double mouseY, int startY, int width, List rows) { + private String findRowAt(double mouseX, double mouseY, int startY, int width, List rows, int rowHeight) { int rowY = startY - scrollOffset; for (String row : rows) { - if (isInside(mouseX, mouseY, 0, rowY, width, ROW_HEIGHT)) { + if (isInside(mouseX, mouseY, 0, rowY, width, rowHeight)) { return row; } - rowY += ROW_HEIGHT; + rowY += rowHeight; } return null; } + private AttributionListLayout getTaskListLayout() { + int detailW = Math.min(420, Math.max(320, getScreenWidth() / 3)); + int listW = getScreenWidth() - detailW - PADDING; + int top = getContentY() + PADDING; + String description = taskEffectiveView + ? "Effective CPU share by mod from rolling sampled stack windows. Shared/framework work is folded into concrete mods for comparison." + : "Raw CPU ownership by mod from rolling sampled stack windows. Shared/framework buckets stay separate until you switch back to Effective view."; + int descriptionBottomY = top + measureWrappedHeight(Math.max(260, listW - 16), description); + int controlsY = descriptionBottomY + 26; + int headerY = controlsY + 42; + int listY = headerY + 16; + int listH = getScreenHeight() - getContentY() - PADDING - (listY - getContentY()); + return new AttributionListLayout(listW, headerY, listY, listH); + } + + private AttributionListLayout getGpuListLayout() { + int detailW = Math.min(420, Math.max(320, getScreenWidth() / 3)); + int listW = getScreenWidth() - detailW - PADDING; + int top = getContentY() + PADDING; + String description = gpuEffectiveView + ? "Estimated GPU share by tagged render phases with shared render work folded into concrete mods." + : "Raw GPU ownership by tagged render phases. Shared render buckets stay separate until you switch back to Effective view."; + int descriptionBottomY = top + measureWrappedHeight(Math.max(260, listW - 16), description); + int controlsY = descriptionBottomY + 26; + int headerY = controlsY + 42; + int listY = headerY + 16; + int listH = getScreenHeight() - getContentY() - PADDING - (listY - getContentY()); + return new AttributionListLayout(listW, headerY, listY, listH); + } + + AttributionListLayout getThreadListLayout() { + int detailW = Math.min(420, Math.max(320, getScreenWidth() / 3)); + int listW = getScreenWidth() - detailW - PADDING; + int top = getContentY() + PADDING; + int descriptionBottomY = top + measureWrappedHeight(Math.max(260, listW - 16), "Measured live thread CPU/allocation snapshots with sampled owner and confidence. Use this when a mod row is really a thread question."); + int headerY = descriptionBottomY + 52; + int listY = headerY + 16; + int listH = getScreenHeight() - getContentY() - PADDING - (listY - getContentY()); + return new AttributionListLayout(listW, headerY, listY, listH); + } + private MemoryListLayout getMemoryListLayout() { int sharedPanelW = snapshot.sharedMemoryFamilies().isEmpty() ? 0 : Math.min(280, Math.max(220, getScreenWidth() / 4)); int detailH = 116; @@ -3182,12 +2906,76 @@ private MemoryListLayout getMemoryListLayout() { int controlsTopY = descriptionBottomY + 26; int controlsY = controlsTopY + 42; int barY = controlsY + 24; - int headerY = barY + 58; + int headerY = barY + 82; int listY = headerY + 16; int listH = getScreenHeight() - getContentY() - PADDING - (listY - getContentY()) - detailH; return new MemoryListLayout(tableW, controlsY, headerY, listY, listH); } + ProfilerManager.ProfilerSnapshot currentSnapshot() { + return snapshot; + } + + long currentSelectedThreadId() { + return selectedThreadId; + } + + void setCurrentSelectedThreadId(long threadId) { + selectedThreadId = threadId; + } + + String currentGlobalSearch() { + return globalSearch; + } + + int currentScrollOffset() { + return scrollOffset; + } + + boolean isThreadSortCpu() { + return threadSort == ThreadSort.CPU; + } + + boolean isThreadSortAlloc() { + return threadSort == ThreadSort.ALLOC; + } + + boolean isThreadSortBlocked() { + return threadSort == ThreadSort.BLOCKED; + } + + boolean isThreadSortWaited() { + return threadSort == ThreadSort.WAITED; + } + + boolean isThreadSortName() { + return threadSort == ThreadSort.NAME; + } + + String currentThreadSortLabel() { + return threadSort.name().toLowerCase(Locale.ROOT); + } + + boolean isThreadFreezeActive() { + return threadFreeze; + } + + SystemMiniTab currentSystemMiniTab() { + return systemMiniTab; + } + + GraphMetricTab currentCpuGraphMetricTab() { + return cpuGraphMetricTab; + } + + GraphMetricTab currentGpuGraphMetricTab() { + return gpuGraphMetricTab; + } + + TextRenderer uiTextRenderer() { + return textRenderer; + } + public static boolean isProfilingActive() { MinecraftClient client = MinecraftClient.getInstance(); @@ -3206,22 +2994,22 @@ private String formatMode(ProfilerManager.CaptureMode mode) { return mode == null ? "Unknown" : mode.name().replace('_', ' '); } - private String cpuStatusText(boolean ready, long samples, long ageMillis) { + String cpuStatusText(boolean ready, long samples, long ageMillis) { if (!ready) { return "Warming up | " + formatCount(samples) + " samples"; } return "Loaded | " + formatCount(samples) + " samples | updated " + formatDuration(ageMillis) + " ago"; } - private int getCpuStatusColor(boolean ready) { + int getCpuStatusColor(boolean ready) { return ready ? ACCENT_GREEN : ACCENT_YELLOW; } - private int getGpuStatusColor(boolean ready) { + int getGpuStatusColor(boolean ready) { return ready ? ACCENT_GREEN : ACCENT_YELLOW; } - private void renderStripedRowVariable(DrawContext ctx, int x, int width, int rowY, int rowHeight, int rowIdx, int mouseX, int mouseY) { + void renderStripedRowVariable(DrawContext ctx, int x, int width, int rowY, int rowHeight, int rowIdx, int mouseX, int mouseY) { if (rowIdx % 2 == 0) { ctx.fill(x, rowY, x + width, rowY + rowHeight, ROW_ALT); } @@ -3230,7 +3018,7 @@ private void renderStripedRowVariable(DrawContext ctx, int x, int width, int row } } - private LagMapLayout getLagMapLayout(int contentY, int contentW, int contentH) { + LagMapLayout getLagMapLayout(int contentY, int contentW, int contentH) { int left = PADDING; int top = getFullPageScrollTop(contentY); top = renderSectionHeaderOffset(top, true); @@ -3249,7 +3037,7 @@ private int renderSectionHeaderOffset(int y, boolean hasSubtitle) { return hasSubtitle ? y + 28 : y + 16; } - private void drawSettingRow(DrawContext ctx, int x, int y, int width, String label, String value, int mouseX, int mouseY) { + void drawSettingRow(DrawContext ctx, int x, int y, int width, String label, String value, int mouseX, int mouseY) { if (isInside(mouseX, mouseY, x - 4, y - 2, width + 8, 16)) { ctx.fill(x - 4, y - 2, x + width + 4, y + 14, 0x1AFFFFFF); } @@ -3257,12 +3045,12 @@ private void drawSettingRow(DrawContext ctx, int x, int y, int width, String lab } - private void drawSettingRowWithTooltip(DrawContext ctx, int x, int y, int width, String label, String value, int mouseX, int mouseY, String tooltip) { + void drawSettingRowWithTooltip(DrawContext ctx, int x, int y, int width, String label, String value, int mouseX, int mouseY, String tooltip) { drawSettingRow(ctx, x, y, width, label, value, mouseX, mouseY); addTooltip(x - 4, y - 2, width + 8, 16, tooltip); } - private void drawSliderSetting(DrawContext ctx, int x, int y, int width, String label, int percent, int mouseX, int mouseY) { + void drawSliderSetting(DrawContext ctx, int x, int y, int width, String label, int percent, int mouseX, int mouseY) { if (isInside(mouseX, mouseY, x - 4, y - 2, width + 8, 20)) { ctx.fill(x - 4, y - 2, x + width + 4, y + 18, 0x1AFFFFFF); } @@ -3302,7 +3090,7 @@ private void updateHudTransparencyFromMouse(double mouseX, SliderLayout slider) ConfigManager.setHudTransparencyPercent(percent); } - private void renderStripedRow(DrawContext ctx, int x, int width, int rowY, int rowIdx, int mouseX, int mouseY) { + void renderStripedRow(DrawContext ctx, int x, int width, int rowY, int rowIdx, int mouseX, int mouseY) { if (rowIdx % 2 == 0) { ctx.fill(x, rowY, x + width, rowY + ROW_HEIGHT, ROW_ALT); } @@ -3310,19 +3098,59 @@ private void renderStripedRow(DrawContext ctx, int x, int width, int rowY, int r ctx.fill(x, rowY, x + width, rowY + ROW_HEIGHT, 0x22FFFFFF); } } - private boolean hasTaskFilter() { + + void renderConfidenceChip(DrawContext ctx, int x, int y, String confidence) { + int width = confidenceChipWidth(confidence); + int fill = switch (confidence) { + case "Measured" -> 0x334CAF50; + case "Mixed" -> 0x33FFB300; + default -> 0x33FF6666; + }; + int border = switch (confidence) { + case "Measured" -> ACCENT_GREEN; + case "Mixed" -> ACCENT_YELLOW; + default -> ACCENT_RED; + }; + ctx.fill(x, y, x + width, y + 12, fill); + ctx.fill(x, y, x + width, y + 1, border); + ctx.fill(x, y + 11, x + width, y + 12, border); + ctx.drawText(textRenderer, confidence, x + 4, y + 2, TEXT_PRIMARY, false); + } + + int confidenceChipWidth(String confidence) { + return textRenderer.getWidth(confidence == null ? "Unknown" : confidence) + 8; + } + + int firstVisibleMetricX(int fallback, int firstX, boolean firstVisible, int secondX, boolean secondVisible, int thirdX, boolean thirdVisible) { + int visibleX = fallback; + if (firstVisible) { + visibleX = Math.min(visibleX, firstX - 8); + } + if (secondVisible) { + visibleX = Math.min(visibleX, secondX - 8); + } + if (thirdVisible) { + visibleX = Math.min(visibleX, thirdX - 8); + } + return visibleX; + } + + int firstVisibleMetricX(int fallback, int firstX, boolean firstVisible, int secondX, boolean secondVisible, int thirdX, boolean thirdVisible, int fourthX, boolean fourthVisible) { + return firstVisibleMetricX(firstVisibleMetricX(fallback, firstX, firstVisible, secondX, secondVisible, thirdX, thirdVisible), fourthX, fourthVisible, Integer.MAX_VALUE, false, Integer.MAX_VALUE, false); + } + boolean hasTaskFilter() { return !tasksSearch.isBlank() || taskSort != TaskSort.CPU || !taskSortDescending || !taskEffectiveView || taskShowSharedRows; } - private boolean hasGpuFilter() { + boolean hasGpuFilter() { return !gpuSearch.isBlank() || gpuSort != GpuSort.EST_GPU || !gpuSortDescending || !gpuEffectiveView || gpuShowSharedRows; } - private boolean hasMemoryFilter() { + boolean hasMemoryFilter() { return !memorySearch.isBlank() || memorySort != MemorySort.MEMORY_MB || !memorySortDescending || !memoryEffectiveView || memoryShowSharedRows; } - private boolean hasStartupFilter() { + boolean hasStartupFilter() { return !startupSearch.isBlank() || startupSort != StartupSort.ACTIVE || !startupSortDescending; } @@ -3364,14 +3192,14 @@ private String findingKey(ProfilerManager.RuleFinding finding) { return finding.category() + "|" + finding.message(); } - private void renderResetButton(DrawContext ctx, int x, int y, int width, int height, boolean active) { + void renderResetButton(DrawContext ctx, int x, int y, int width, int height, boolean active) { ctx.fill(x, y, x + width, y + height, active ? 0x223A3A3A : 0x14141414); ctx.fill(x, y, x + width, y + 1, PANEL_OUTLINE); ctx.fill(x, y + height - 1, x + width, y + height, PANEL_OUTLINE); ctx.drawText(textRenderer, "Reset", x + 8, y + 4, active ? TEXT_PRIMARY : TEXT_DIM, false); } - private void renderSearchBox(DrawContext ctx, int x, int y, int width, int height, String placeholder, String value, boolean focused) { + void renderSearchBox(DrawContext ctx, int x, int y, int width, int height, String placeholder, String value, boolean focused) { ctx.fill(x, y, x + width, y + height, focused ? 0x442A2A2A : 0x22181818); ctx.fill(x, y, x + width, y + 1, focused ? ACCENT_GREEN : BORDER_COLOR); ctx.fill(x, y + height - 1, x + width, y + height, BORDER_COLOR); @@ -3380,61 +3208,30 @@ private void renderSearchBox(DrawContext ctx, int x, int y, int width, int heigh ctx.drawText(textRenderer, textRenderer.trimToWidth(content, width - 8), x + 4, y + 4, color, false); } - private void renderSortSummary(DrawContext ctx, int x, int y, String label, String value, int color) { + void renderSortSummary(DrawContext ctx, int x, int y, String label, String value, int color) { ctx.drawText(textRenderer, label + ": " + value, x, y, color, false); } - private String headerLabel(String label, boolean active, boolean descending) { + String headerLabel(String label, boolean active, boolean descending) { if (!active) { return label; } return label + (descending ? " v" : " ^"); } - private String formatSort(Enum sort, boolean descending) { + String formatSort(Enum sort, boolean descending) { return prettifyKey(sort.name()) + (descending ? " (desc)" : " (asc)"); } - private void addTooltip(int x, int y, int width, int height, String text) { - if (text == null || text.isBlank()) { - return; - } - tooltipTargets.add(new TooltipTarget(x, y, width, height, text)); + void addTooltip(int x, int y, int width, int height, String text) { + tooltipManager.add(x, y, width, height, text); } private void renderTooltipOverlay(DrawContext ctx, int mouseX, int mouseY) { - TooltipTarget target = null; - for (TooltipTarget candidate : tooltipTargets) { - if (isInside(mouseX, mouseY, candidate.x(), candidate.y(), candidate.width(), candidate.height())) { - target = candidate; - } - } - if (target == null) { - return; - } - int maxWidth = Math.min(320, getScreenWidth() - 24); - List wrapped = textRenderer.wrapLines(Text.literal(target.text()), maxWidth); - int widest = 0; - for (OrderedText line : wrapped) { - widest = Math.max(widest, textRenderer.getWidth(line)); - } - int boxW = Math.min(maxWidth + 10, Math.max(100, widest + 10)); - int boxH = Math.max(18, wrapped.size() * 12 + 6); - int boxX = Math.min(getScreenWidth() - boxW - 8, mouseX + 10); - int boxY = Math.min(getScreenHeight() - boxH - 8, mouseY + 10); - ctx.fill(boxX, boxY, boxX + boxW, boxY + boxH, 0xE0121212); - ctx.fill(boxX, boxY, boxX + boxW, boxY + 1, PANEL_OUTLINE); - ctx.fill(boxX, boxY + boxH - 1, boxX + boxW, boxY + boxH, PANEL_OUTLINE); - ctx.fill(boxX, boxY, boxX + 1, boxY + boxH, PANEL_OUTLINE); - ctx.fill(boxX + boxW - 1, boxY, boxX + boxW, boxY + boxH, PANEL_OUTLINE); - int textY = boxY + 4; - for (OrderedText line : wrapped) { - ctx.drawText(textRenderer, line, boxX + 5, textY, TEXT_PRIMARY, false); - textY += 12; - } - } - - private String getDisplayName(String modId) { + tooltipManager.render(ctx, mouseX, mouseY, getScreenWidth(), getScreenHeight(), textRenderer, TEXT_PRIMARY, PANEL_OUTLINE); + } + + String getDisplayName(String modId) { if (modId == null || modId.isBlank()) { return "Unknown"; } @@ -3443,6 +3240,7 @@ private String getDisplayName(String modId) { case "shared/jvm" -> "Shared / JVM"; case "shared/framework" -> "Shared / Framework"; case "shared/render" -> "Shared / Render"; + case "shared/gpu-stall" -> "Shared / GPU Stall"; default -> "Shared / " + cleanProfilerLabel(modId.substring("shared/".length())); }; } @@ -3454,24 +3252,24 @@ private String getDisplayName(String modId) { .orElseGet(() -> cleanProfilerLabel(modId)); } - private int getHeatColor(double pct) { + int getHeatColor(double pct) { if (pct >= 50.0) return ACCENT_RED; if (pct >= 15.0) return ACCENT_YELLOW; return ACCENT_GREEN; } - private String formatCount(long value) { + String formatCount(long value) { if (value >= 1_000_000L) return String.format(Locale.ROOT, "%.1fM", value / 1_000_000.0); if (value >= 1_000L) return String.format(Locale.ROOT, "%.1fk", value / 1_000.0); return Long.toString(value); } - private String memoryStatusText(long ageMillis) { + String memoryStatusText(long ageMillis) { if (ageMillis == Long.MAX_VALUE) return "No memory sample yet"; return "Updated " + formatDuration(ageMillis) + " ago"; } - private void renderSharedFamiliesPanel(DrawContext ctx, int x, int y, int width, int height, Map sharedFamilies) { + void renderSharedFamiliesPanel(DrawContext ctx, int x, int y, int width, int height, Map sharedFamilies) { drawInsetPanel(ctx, x, y, width, height); ctx.drawText(textRenderer, "Shared family detail", x + 8, y + 8, TEXT_PRIMARY, false); int rowY = y + 20; @@ -3485,7 +3283,7 @@ private void renderSharedFamiliesPanel(DrawContext ctx, int x, int y, int width, } } - private void renderSharedFamilyDetail(DrawContext ctx, int x, int y, int width, int height, Map classes) { + void renderSharedFamilyDetail(DrawContext ctx, int x, int y, int width, int height, Map classes) { drawInsetPanel(ctx, x, y, width, height); ctx.drawText(textRenderer, "Shared family classes", x + 8, y + 8, TEXT_PRIMARY, false); int rowY = y + 20; @@ -3499,7 +3297,7 @@ private void renderSharedFamilyDetail(DrawContext ctx, int x, int y, int width, } } - private void drawMetricRow(DrawContext ctx, int x, int y, int width, String label, String value) { + void drawMetricRow(DrawContext ctx, int x, int y, int width, String label, String value) { ctx.drawText(textRenderer, label, x, y, TEXT_DIM, false); String shown = textRenderer.trimToWidth(value, Math.max(80, width - 120)); @@ -3513,7 +3311,7 @@ private void drawMetricRow(DrawContext ctx, int x, int y, int width, String labe ctx.drawText(textRenderer, shown, x + width - textRenderer.getWidth(shown), y, color, false); } - private String formatBytesMb(long bytes) { + String formatBytesMb(long bytes) { if (bytes < 0) return "N/A"; return String.format(Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0)); } @@ -3523,12 +3321,12 @@ private String formatPercent(double value) { return String.format(Locale.ROOT, "%.1f%%", value); } - private String formatTemperature(double value) { + String formatTemperature(double value) { if (value < 0 || !Double.isFinite(value)) return "N/A"; return String.format(Locale.ROOT, "%.1f C", value); } - private String formatPercentWithTrend(double value, double deltaPerSecond) { + String formatPercentWithTrend(double value, double deltaPerSecond) { String base = formatPercent(value); if ("N/A".equals(base)) { return base; @@ -3536,7 +3334,7 @@ private String formatPercentWithTrend(double value, double deltaPerSecond) { return base + " (" + formatSignedRate(deltaPerSecond, "%/s") + ")"; } - private String formatTemperatureWithTrend(double value, double deltaPerSecond) { + String formatTemperatureWithTrend(double value, double deltaPerSecond) { String base = formatTemperature(value); if ("N/A".equals(base)) { return base; @@ -3544,12 +3342,16 @@ private String formatTemperatureWithTrend(double value, double deltaPerSecond) { return base + " (" + formatSignedRate(deltaPerSecond, "C/s") + ")"; } - private String formatCpuGpuSummary(SystemMetricsProfiler.Snapshot system, boolean cpu) { + String formatCpuGpuSummary(SystemMetricsProfiler.Snapshot system, boolean cpu) { double load = cpu ? system.cpuCoreLoadPercent() : system.gpuCoreLoadPercent(); double temp = cpu ? system.cpuTemperatureC() : system.gpuTemperatureC(); double loadDelta = cpu ? system.cpuLoadChangePerSecond() : system.gpuLoadChangePerSecond(); double tempDelta = cpu ? system.cpuTemperatureChangePerSecond() : system.gpuTemperatureChangePerSecond(); String loadText = formatPercent(load); + if (!cpu) { + String rateText = TelemetryTextFormatter.formatSignedRate(loadDelta, "%/s"); + return loadText + " / " + TelemetryTextFormatter.formatGpuTemperatureCompact(system) + " (" + rateText + ")"; + } String tempText = formatTemperature(temp); String loadRate = formatSignedRate(loadDelta, "%/s"); if ("N/A".equals(tempText)) { @@ -3568,65 +3370,36 @@ private String formatSignedRate(double value, String units) { return String.format(Locale.ROOT, "%+.1f %s", value, units); } - private String formatCpuInfo() { - String identifier = System.getenv("PROCESSOR_IDENTIFIER"); + String formatCpuInfo() { int logicalCores = Runtime.getRuntime().availableProcessors(); - if (identifier == null || identifier.isBlank()) { - return "CPU: " + System.getProperty("os.arch", "Unknown") + " | " + logicalCores + " logical cores"; - } - String lower = identifier.toLowerCase(Locale.ROOT); - String vendor = lower.contains("authenticamd") ? "AMD CPU" : (lower.contains("genuineintel") ? "Intel CPU" : "CPU"); - String family = extractCpuToken(identifier, "Family"); - String model = extractCpuToken(identifier, "Model"); - if (!family.isBlank() && !model.isBlank()) { - return vendor + " | Family " + family + " Model " + model + " | " + logicalCores + " logical cores"; - } - return vendor + " | " + identifier + " | " + logicalCores + " logical cores"; - } - - private String extractCpuToken(String identifier, String token) { - int index = identifier.indexOf(token); - if (index < 0) { - return ""; - } - String tail = identifier.substring(index + token.length()).trim(); - StringBuilder digits = new StringBuilder(); - for (int i = 0; i < tail.length(); i++) { - char ch = tail.charAt(i); - if (Character.isDigit(ch)) { - digits.append(ch); - } else if (digits.length() > 0) { - break; - } - } - return digits.toString(); + return "CPU: " + HardwareInfoResolver.getCpuDisplayName() + " | " + logicalCores + " logical cores"; } - private int getCpuGraphColor() { + int getCpuGraphColor() { return ConfigManager.getCpuGraphColor(); } - private int getGpuGraphColor() { + int getGpuGraphColor() { return ConfigManager.getGpuGraphColor(); } - private int getWorldEntityGraphColor() { + int getWorldEntityGraphColor() { return ConfigManager.getWorldEntityGraphColor(); } - private int getWorldLoadedChunkGraphColor() { + int getWorldLoadedChunkGraphColor() { return ConfigManager.getWorldLoadedChunkGraphColor(); } - private int getWorldRenderedChunkGraphColor() { + int getWorldRenderedChunkGraphColor() { return ConfigManager.getWorldRenderedChunkGraphColor(); } - private int getMemoryGraphColor() { + int getMemoryGraphColor() { return 0xFF325C99; } - private String getColorSettingHex(ColorSetting setting) { + String getColorSettingHex(ColorSetting setting) { return switch (setting) { case CPU -> ConfigManager.getCpuGraphColorHex(); case GPU -> ConfigManager.getGpuGraphColorHex(); @@ -3654,7 +3427,7 @@ private String normalizeColorEdit(String value) { return "#" + cleaned; } - private String stutterBand(double score) { + String stutterBand(double score) { if (score < 5.0) return "Excellent"; if (score < 10.0) return "Good"; if (score < 20.0) return "Noticeable"; @@ -3662,7 +3435,7 @@ private String stutterBand(double score) { return "Severe"; } - private int stutterBandColor(double score) { + int stutterBandColor(double score) { if (score < 5.0) return ACCENT_GREEN; if (score < 10.0) return INTEL_COLOR; if (score < 20.0) return ACCENT_YELLOW; @@ -3670,7 +3443,7 @@ private int stutterBandColor(double score) { return ACCENT_RED; } - private String formatBytesPerSecond(long value) { + String formatBytesPerSecond(long value) { if (value < 0) return "N/A"; if (value >= 1024L * 1024L) return String.format(Locale.ROOT, "%.2f MB/s", value / (1024.0 * 1024.0)); if (value >= 1024L) return String.format(Locale.ROOT, "%.1f KB/s", value / 1024.0); @@ -3681,7 +3454,7 @@ private String currentBottleneckSummary() { return ProfilerManager.getInstance().getCurrentBottleneckLabel(); } - private double percentileGpuFrameLabel() { + double percentileGpuFrameLabel() { java.util.List points = new java.util.ArrayList<>(ProfilerManager.getInstance().getSessionHistory()); if (points.isEmpty()) { return snapshot.renderPhases().values().stream().mapToLong(RenderPhaseProfiler.PhaseSnapshot::gpuNanos).sum() / 1_000_000.0; @@ -3691,7 +3464,7 @@ private double percentileGpuFrameLabel() { return values.get(idx); } - private String formatFrameHistogram(Map buckets) { + String formatFrameHistogram(Map buckets) { if (buckets == null || buckets.isEmpty()) { return "No frame histogram yet"; } @@ -3702,20 +3475,20 @@ private String formatFrameHistogram(Map buckets) { return String.join(" | ", parts); } - private int getPreferredGraphWidth(int availableWidth) { + int getPreferredGraphWidth(int availableWidth) { return Math.max(220, Math.min(availableWidth - (PADDING * 2), 1000)); } - private void renderMetricGraph(DrawContext ctx, int x, int y, int width, int height, long[] primary, long[] secondary, String title, String units, double spanSeconds) { + void renderMetricGraph(DrawContext ctx, int x, int y, int width, int height, long[] primary, long[] secondary, String title, String units, double spanSeconds) { int graphHeight = Math.max(96, height); renderSeriesGraph(ctx, x + PADDING, y, width - (PADDING * 2), graphHeight, toDoubleArray(primary), toDoubleArray(secondary), title, units, INTEL_COLOR, ACCENT_YELLOW, spanSeconds, true); } - private void renderSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] primary, double[] secondary, String title, String units, int primaryColor, int secondaryColor, double spanSeconds) { + void renderSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] primary, double[] secondary, String title, String units, int primaryColor, int secondaryColor, double spanSeconds) { renderSeriesGraph(ctx, x, y, width, height, primary, secondary, title, units, primaryColor, secondaryColor, spanSeconds, false); } - private void renderSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] primary, double[] secondary, String title, String units, int primaryColor, int secondaryColor, double spanSeconds, boolean drawSmallerOnTop) { + void renderSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] primary, double[] secondary, String title, String units, int primaryColor, int secondaryColor, double spanSeconds, boolean drawSmallerOnTop) { ctx.drawText(textRenderer, title + " (" + units + ")", x, y, TEXT_PRIMARY, false); int graphX = x; int graphY = y + 14; @@ -3760,7 +3533,7 @@ private void renderSeriesGraph(DrawContext ctx, int x, int y, int width, int hei } } - private int renderGraphLegend(DrawContext ctx, int x, int y, String[] labels, int[] colors) { + int renderGraphLegend(DrawContext ctx, int x, int y, String[] labels, int[] colors) { int currentX = x; int boxSize = 8; for (int i = 0; i < labels.length; i++) { @@ -3774,11 +3547,11 @@ private int renderGraphLegend(DrawContext ctx, int x, int y, String[] labels, in return 12; } - private void renderFixedScaleSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] values, String title, String units, int color, double fixedMax, double spanSeconds) { + void renderFixedScaleSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] values, String title, String units, int color, double fixedMax, double spanSeconds) { renderFixedScaleSeriesGraph(ctx, x, y, width, height, values, null, title, units, color, 0, fixedMax, spanSeconds); } - private void renderGraphMetricTabs(DrawContext ctx, int x, int y, int width, GraphMetricTab activeMetricTab) { + void renderGraphMetricTabs(DrawContext ctx, int x, int y, int width, GraphMetricTab activeMetricTab) { int loadWidth = 84; int temperatureWidth = 120; drawTopChip(ctx, x, y, loadWidth, 16, activeMetricTab == GraphMetricTab.LOAD); @@ -3823,7 +3596,7 @@ private void renderFixedScaleLineGraph(DrawContext ctx, int x, int y, int width, drawCurrentValueMarker(ctx, graphX, graphY, plotWidth, graphHeight, axisX, values, max, color, units, 0); } - private void renderSensorSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] values, String title, String units, int color, double fixedMax, double spanSeconds, boolean sensorAvailable, String unavailableLabel) { + void renderSensorSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] values, String title, String units, int color, double fixedMax, double spanSeconds, boolean sensorAvailable, String unavailableLabel) { if (sensorAvailable) { renderFixedScaleLineGraph(ctx, x, y, width, height, sanitizeSeries(values), title, units, color, fixedMax, spanSeconds); return; @@ -3874,7 +3647,7 @@ private double[] sanitizeSeries(double[] values) { return sanitized; } - private void renderFixedScaleSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] primary, double[] secondary, String title, String units, int primaryColor, int secondaryColor, double fixedMax, double spanSeconds) { + void renderFixedScaleSeriesGraph(DrawContext ctx, int x, int y, int width, int height, double[] primary, double[] secondary, String title, String units, int primaryColor, int secondaryColor, double fixedMax, double spanSeconds) { ctx.drawText(textRenderer, title + " (" + units + ")", x, y, TEXT_PRIMARY, false); int graphX = x; int graphY = y + 14; @@ -4076,55 +3849,11 @@ private String formatDuration(long millis) { } private void renderFlamegraph(DrawContext ctx, int x, int y, int w, int h) { - beginFullPageScissor(ctx, x, y, w, h); - int top = getFullPageScrollTop(y); - top = renderSectionHeader(ctx, x + PADDING, top, "Flamegraph", "Captured stack samples from the current profiling window."); - int rowY = top + 4; - Map stacks = snapshot.flamegraphStacks(); - if (stacks.isEmpty()) { - ctx.drawText(textRenderer, "No flamegraph samples yet.", x + PADDING, rowY, TEXT_DIM, false); - } else { - int shown = 0; - for (Map.Entry entry : stacks.entrySet()) { - ctx.drawText(textRenderer, textRenderer.trimToWidth(entry.getKey(), w - 120), x + PADDING, rowY, TEXT_DIM, false); - String count = formatCount(entry.getValue()); - ctx.drawText(textRenderer, count, x + w - PADDING - textRenderer.getWidth(count), rowY, TEXT_PRIMARY, false); - rowY += 12; - if (++shown >= 20) break; - } - } - ctx.disableScissor(); + FlamegraphTabRenderer.render(this, ctx, x, y, w, h); } private void renderTimeline(DrawContext ctx, int x, int y, int w, int h) { - beginFullPageScissor(ctx, x, y, w, h); - int graphWidth = getPreferredGraphWidth(w); - int left = x + Math.max(PADDING, (w - graphWidth) / 2); - int top = getFullPageScrollTop(y); - FrameTimelineProfiler frames = FrameTimelineProfiler.getInstance(); - top = renderSectionHeader(ctx, left, top, "Timeline", "Frame pacing, FPS lows, jitter, and spike context over the live capture window."); - - drawMetricRow(ctx, left, top, graphWidth, "FPS", String.format(Locale.ROOT, "current %.1f | avg %.1f | 1%% low %.1f | 0.1%% low %.1f", frames.getCurrentFps(), frames.getAverageFps(), frames.getOnePercentLowFps(), frames.getPointOnePercentLowFps())); - top += 24; - renderSeriesGraph(ctx, left, top, graphWidth, 126, frames.getOrderedFrameMsHistory(), null, "Frame Timeline", "ms/frame", ACCENT_YELLOW, 0, frames.getHistorySpanSeconds()); - top += 144; - renderSeriesGraph(ctx, left, top, graphWidth, 126, frames.getOrderedFpsHistory(), null, "FPS Timeline", "fps", INTEL_COLOR, 0, frames.getHistorySpanSeconds()); - top += 144; - double stutterScore = frames.getStutterScore(); - drawMetricRow(ctx, left, top, graphWidth, "Jitter Variance", String.format(Locale.ROOT, "stddev %.2f ms | variance %.2f ms^2 | stutter %.1f", frames.getFrameStdDevMs(), frames.getFrameVarianceMs(), stutterScore)); - top += 18; - drawMetricRow(ctx, left, top, graphWidth, "Stutter Score", String.format(Locale.ROOT, "%.1f | %s", stutterScore, stutterBand(stutterScore))); - top += 18; - ctx.drawText(textRenderer, "Stutter guide", left, top, TEXT_PRIMARY, false); - top += 14; - top = renderWrappedText(ctx, left + 6, top, graphWidth - 12, "0-5 Excellent | 5-10 Good | 10-20 Noticeable | 20-35 Bad | 35+ Severe", stutterBandColor(stutterScore)) + 4; - top = renderWrappedText(ctx, left + 6, top, graphWidth - 12, "Higher stutter scores mean frame pacing is less consistent even if average FPS still looks healthy.", TEXT_DIM) + 6; - drawMetricRow(ctx, left, top, graphWidth, "Frame / Tick Breakdown", String.format(Locale.ROOT, "frame %.2f ms | p95 %.2f | p99 %.2f | build %.2f | gpu %.2f | gpu p95 %.2f | mspt %.2f | mspt p95 %.2f | mspt p99 %.2f", frames.getLatestFrameNs() / 1_000_000.0, frames.getPercentileFrameNs(0.95) / 1_000_000.0, frames.getPercentileFrameNs(0.99) / 1_000_000.0, snapshot.renderPhases().values().stream().mapToLong(RenderPhaseProfiler.PhaseSnapshot::cpuNanos).sum() / 1_000_000.0, snapshot.renderPhases().values().stream().mapToLong(RenderPhaseProfiler.PhaseSnapshot::gpuNanos).sum() / 1_000_000.0, percentileGpuFrameLabel(), TickProfiler.getInstance().getAverageServerTickNs() / 1_000_000.0, TickProfiler.getInstance().getServerTickP95Ns() / 1_000_000.0, TickProfiler.getInstance().getServerTickP99Ns() / 1_000_000.0)); - top += 22; - drawMetricRow(ctx, left, top, graphWidth, "Frame Histogram", formatFrameHistogram(frames.getFrameTimeHistogram())); - top += 22; - renderSpikeInspector(ctx, left, top, graphWidth); - ctx.disableScissor(); + TimelineTabRenderer.render(this, ctx, x, y, w, h); } private ModalLayout getCenteredModalLayout(int screenWidth, int screenHeight, int preferredWidth, int preferredHeight) { @@ -4135,7 +3864,7 @@ private ModalLayout getCenteredModalLayout(int screenWidth, int screenHeight, in return new ModalLayout(x, y, width, height); } - private void renderAttributionHelpOverlay(DrawContext ctx, int screenWidth, int screenHeight, int mouseX, int mouseY) { + void renderAttributionHelpOverlay(DrawContext ctx, int screenWidth, int screenHeight, int mouseX, int mouseY) { ctx.fill(0, 0, screenWidth, screenHeight, 0xAA000000); ModalLayout modal = getCenteredModalLayout(screenWidth, screenHeight, Math.min(900, screenWidth - 32), Math.min(560, screenHeight - 48)); drawInsetPanel(ctx, modal.x(), modal.y(), modal.width(), modal.height()); @@ -4156,7 +3885,7 @@ private void renderAttributionHelpOverlay(DrawContext ctx, int screenWidth, int ctx.disableScissor(); } - private void renderRowDrilldownOverlay(DrawContext ctx, int screenWidth, int screenHeight, int mouseX, int mouseY) { + void renderRowDrilldownOverlay(DrawContext ctx, int screenWidth, int screenHeight, int mouseX, int mouseY) { String modId = getActiveDrilldownModId(); if (modId == null) { activeDrilldownTable = null; @@ -4196,19 +3925,24 @@ private String getActiveDrilldownModId() { private void renderCpuDrilldownContent(DrawContext ctx, ModalLayout modal, String modId) { Map rawCpu = snapshot.cpuMods(); - EffectiveCpuAttribution effectiveCpu = buildEffectiveCpuAttribution(rawCpu, snapshot.modInvokes()); + EffectiveCpuAttribution effectiveCpu = effectiveCpuAttribution(); CpuSamplingProfiler.Snapshot rawSnapshot = rawCpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)); CpuSamplingProfiler.Snapshot effectiveSnapshot = effectiveCpu.displaySnapshots().getOrDefault(modId, rawSnapshot); CpuSamplingProfiler.Snapshot displaySnapshot = taskEffectiveView ? effectiveSnapshot : rawSnapshot; CpuSamplingProfiler.DetailSnapshot detail = snapshot.cpuDetails().get(modId); + long redistributedSamples = effectiveCpu.redistributedSamplesByMod().getOrDefault(modId, 0L); double[] trend = buildTrendSeries(TableId.TASKS, modId, taskEffectiveView); List recentPoints = getRecentSessionPoints(); int contentX = modal.x() + 12; int contentY = modal.y() + 46; int contentW = modal.width() - 24; ctx.enableScissor(modal.x() + 6, modal.y() + 40, modal.x() + modal.width() - 6, modal.y() + modal.height() - 6); - contentY = renderWrappedText(ctx, contentX, contentY, contentW, String.format(Locale.ROOT, "%s view | current %.1f%% CPU | raw %s samples | effective %s samples", taskEffectiveView ? "Effective" : "Raw", displaySnapshot.totalSamples() * 100.0 / Math.max(1L, (taskEffectiveView ? effectiveCpu.totalSamples() : rawCpu.values().stream().mapToLong(CpuSamplingProfiler.Snapshot::totalSamples).sum())), formatCount(rawSnapshot.totalSamples()), formatCount(effectiveSnapshot.totalSamples())), TEXT_DIM) + 6; + contentY = renderWrappedText(ctx, contentX, contentY, contentW, String.format(Locale.ROOT, "%s view | current %.1f%% CPU | raw %s samples | effective %s samples", taskEffectiveView ? "Effective" : "Raw", cpuMetricValue(displaySnapshot) * 100.0 / Math.max(1L, (taskEffectiveView ? totalCpuMetric(effectiveCpu.displaySnapshots()) : totalCpuMetric(rawCpu))), formatCount(rawSnapshot.totalSamples()), formatCount(effectiveSnapshot.totalSamples())), TEXT_DIM) + 6; contentY = renderWrappedText(ctx, contentX, contentY, contentW, "Why this row: sampled stack ownership points here directly, while shared/framework buckets are proportionally redistributed only in Effective view.", TEXT_DIM) + 8; + String attributionHint = cpuAttributionHint(modId, detail, rawSnapshot.totalSamples(), displaySnapshot.totalSamples(), redistributedSamples); + if (attributionHint != null) { + contentY = renderWrappedText(ctx, contentX, contentY, contentW, attributionHint, ACCENT_YELLOW) + 8; + } renderSeriesGraph(ctx, contentX, contentY, contentW, 124, trend, null, "CPU Trend (last ~30s)", "% CPU", ACCENT_GREEN, 0, getTrendSpanSeconds(recentPoints)); contentY += 142; contentY = renderReasonSection(ctx, contentX, contentY, contentW, "Top threads [sampled]", effectiveThreadBreakdown(modId, detail)) + 6; @@ -4220,8 +3954,8 @@ private void renderCpuDrilldownContent(DrawContext ctx, ModalLayout modal, Strin } private void renderGpuDrilldownContent(DrawContext ctx, ModalLayout modal, String modId) { - EffectiveGpuAttribution rawGpu = buildEffectiveGpuAttribution(false); - EffectiveGpuAttribution displayGpu = buildEffectiveGpuAttribution(gpuEffectiveView); + EffectiveGpuAttribution rawGpu = rawGpuAttribution(); + EffectiveGpuAttribution displayGpu = gpuEffectiveView ? effectiveGpuAttribution() : rawGpu; CpuSamplingProfiler.DetailSnapshot detail = snapshot.cpuDetails().get(modId); long displayGpuNanos = displayGpu.gpuNanosByMod().getOrDefault(modId, 0L); double[] trend = buildTrendSeries(TableId.GPU, modId, gpuEffectiveView); @@ -4236,14 +3970,20 @@ private void renderGpuDrilldownContent(DrawContext ctx, ModalLayout modal, Strin contentY += 142; contentY = renderWrappedText(ctx, contentX, contentY, contentW, "Owner source: " + describeGpuOwnerSource(modId), TEXT_DIM) + 6; contentY = renderReasonSection(ctx, contentX, contentY, contentW, "Render threads [sampled]", effectiveThreadBreakdown(modId, detail)) + 6; - contentY = renderStringListSection(ctx, contentX, contentY, contentW, isSharedAttributionBucket(modId) ? "Top shared owner phases [tagged]" : "Top owner phases [tagged]", buildGpuPhaseBreakdownLines(modId)) + 6; + if (isSharedAttributionBucket(modId)) { + contentY = renderReasonSection(ctx, contentX, contentY, contentW, "Likely owners during shared/render [sampled]", buildSharedRenderLikelyOwners()) + 6; + contentY = renderStringListSection(ctx, contentX, contentY, contentW, "Top shared owner phases [tagged]", buildGpuPhaseBreakdownLines(modId)) + 6; + contentY = renderReasonSection(ctx, contentX, contentY, contentW, "Likely render frames during shared/render [sampled]", buildSharedRenderLikelyFrames()) + 6; + } else { + contentY = renderStringListSection(ctx, contentX, contentY, contentW, "Top owner phases [tagged]", buildGpuPhaseBreakdownLines(modId)) + 6; + } renderReasonSection(ctx, contentX, contentY, contentW, "Top sampled render frames [sampled]", detail == null ? Map.of() : detail.topFrames()); ctx.disableScissor(); } private void renderMemoryDrilldownContent(DrawContext ctx, ModalLayout modal, String modId) { Map rawMemory = snapshot.memoryMods(); - EffectiveMemoryAttribution effectiveMemory = buildEffectiveMemoryAttribution(rawMemory); + EffectiveMemoryAttribution effectiveMemory = effectiveMemoryAttribution(); Map displayMemory = memoryEffectiveView ? effectiveMemory.displayBytes() : rawMemory; double[] trend = buildTrendSeries(TableId.MEMORY, modId, memoryEffectiveView); List recentPoints = getRecentSessionPoints(); @@ -4300,159 +4040,7 @@ private double[] buildTrendSeries(TableId tableId, String modId, boolean effecti } private void renderSettings(DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { - beginFullPageScissor(ctx, x, y, w, h); - int left = x + PADDING; - int top = getFullPageScrollTop(y); - ctx.drawText(textRenderer, "Settings", left, top, TEXT_PRIMARY, false); - top += 18; - ctx.drawText(textRenderer, "Attribution", left, top, TEXT_PRIMARY, false); - drawTopChip(ctx, left + 104, top - 2, 108, 16, attributionHelpOpen); - ctx.drawText(textRenderer, "Open Guide", left + 132, top + 2, attributionHelpOpen ? TEXT_PRIMARY : TEXT_DIM, false); - top += 18; - top = renderWrappedText(ctx, left, top, w - 24, "Raw keeps direct ownership separate. Effective folds shared/runtime buckets back into mods for easier ranking. Use Open Guide for the full measured / inferred / estimated explanation.", TEXT_DIM) + 10; - drawSettingRow(ctx, left, top, w - 24, "Session Logging", ProfilerManager.getInstance().isSessionLogging() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Session Duration", ConfigManager.getSessionDurationSeconds() + "s", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Metrics Update Interval", ConfigManager.getMetricsUpdateIntervalMs() + "ms", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Profiler Update Delay", ConfigManager.getProfilerUpdateDelayMs() + "ms", mouseX, mouseY); - top += 32; - ctx.drawText(textRenderer, "HUD Settings", left, top, TEXT_PRIMARY, false); - top += 18; - drawSettingRow(ctx, left, top, w - 24, "Enabled", ConfigManager.isHudEnabled() ? "Yes" : "No", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Position", String.valueOf(ConfigManager.getHudPosition()), mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Layout", String.valueOf(ConfigManager.getHudLayoutMode()), mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Trigger Mode", String.valueOf(ConfigManager.getHudTriggerMode()), mouseX, mouseY); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Target FPS", ConfigManager.getFrameBudgetTargetFps() + " FPS", mouseX, mouseY, "Sets the frame-budget target used by the HUD, alerts, and exports when judging whether a frame is over budget."); - top += 22; - drawSliderSetting(ctx, left, top, w - 24, "HUD Transparency", ConfigManager.getHudTransparencyPercent(), mouseX, mouseY); - top += 24; - drawSettingRow(ctx, left, top, w - 24, "HUD Mode", String.valueOf(ConfigManager.getHudConfigMode()), mouseX, mouseY); - top += 22; - if (ConfigManager.getHudConfigMode() == ConfigManager.HudConfigMode.PRESET) { - drawSettingRow(ctx, left, top, w - 24, "Preset", String.valueOf(ConfigManager.getHudPreset()), mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Expand On Warning", ConfigManager.isHudExpandedOnWarning() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Budget Color Mode", ConfigManager.isHudBudgetColorMode() ? "On" : "Off", mouseX, mouseY, "Colors HUD rows by pressure so frame, tick, VRAM, and latency problems are easier to spot."); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Auto-Focus Alert", ConfigManager.isHudAutoFocusAlertRow() ? "On" : "Off", mouseX, mouseY, "Pins the strongest current issue to the top of the HUD instead of making you scan every row."); - top += 20; - ctx.drawText(textRenderer, textRenderer.trimToWidth("Preset mode drives the HUD contents for you. Switch HUD Mode to CUSTOM to pick individual sections.", w - 24), left, top, TEXT_DIM, false); - top += 34; - } else { - drawSettingRow(ctx, left, top, w - 24, "FPS", ConfigManager.isHudShowFps() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Frame Stats", ConfigManager.isHudShowFrame() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Tick Stats", ConfigManager.isHudShowTicks() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Utilization", ConfigManager.isHudShowUtilization() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Temperatures", ConfigManager.isHudShowTemperatures() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Parallelism", ConfigManager.isHudShowParallelism() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Logic", ConfigManager.isHudShowLogic() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Background", ConfigManager.isHudShowBackground() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Frame Budget", ConfigManager.isHudShowFrameBudget() ? "On" : "Off", mouseX, mouseY, "Shows the latest frame time against your configured target FPS budget and how far over or under budget it is."); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Memory", ConfigManager.isHudShowMemory() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "VRAM", ConfigManager.isHudShowVram() ? "On" : "Off", mouseX, mouseY, "Shows GPU memory use and paging pressure when the driver exposes VRAM counters."); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Network", ConfigManager.isHudShowNetwork() ? "On" : "Off", mouseX, mouseY, "Shows inbound and outbound throughput so server and packet spikes are easier to correlate."); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Chunk Activity", ConfigManager.isHudShowChunkActivity() ? "On" : "Off", mouseX, mouseY, "Tracks chunk generation, meshing, and upload pressure in the HUD."); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "World", ConfigManager.isHudShowWorld() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Disk I/O", ConfigManager.isHudShowDiskIo() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Input Latency", ConfigManager.isHudShowInputLatency() ? "On" : "Off", mouseX, mouseY, "Shows the latest presented input latency beside the rest of the live performance metrics."); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Session Status", ConfigManager.isHudShowSession() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Expand On Warning", ConfigManager.isHudExpandedOnWarning() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Budget Color Mode", ConfigManager.isHudBudgetColorMode() ? "On" : "Off", mouseX, mouseY, "Colors HUD rows by pressure so frame, tick, VRAM, and latency problems are easier to spot."); - top += 22; - drawSettingRowWithTooltip(ctx, left, top, w - 24, "Auto-Focus Alert", ConfigManager.isHudAutoFocusAlertRow() ? "On" : "Off", mouseX, mouseY, "Pins the strongest current issue to the top of the HUD instead of making you scan every row."); - top += 20; - } - ctx.drawText(textRenderer, "HUD Rate Of Change", left, top, TEXT_PRIMARY, false); - top += 18; - drawSettingRow(ctx, left, top, w - 24, "Display Zero Rates", ConfigManager.isHudShowZeroRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "FPS Rates", ConfigManager.isHudShowFpsRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Frame Rates", ConfigManager.isHudShowFrameRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Tick Rates", ConfigManager.isHudShowTickRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Utilization Rates", ConfigManager.isHudShowUtilizationRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Memory Rates", ConfigManager.isHudShowMemoryAllocationRate() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "VRAM Rates", ConfigManager.isHudShowVramRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Network Rates", ConfigManager.isHudShowNetworkRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Chunk Activity Rates", ConfigManager.isHudShowChunkActivityRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "World Rates", ConfigManager.isHudShowWorldRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Disk I/O Rates", ConfigManager.isHudShowDiskIoRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Input Latency Rates", ConfigManager.isHudShowInputLatencyRateOfChange() ? "On" : "Off", mouseX, mouseY); - top += 32; - ctx.drawText(textRenderer, "Table Columns", left, top, TEXT_PRIMARY, false); - top += 18; - drawSettingRow(ctx, left, top, w - 24, "Tasks: %CPU", ConfigManager.isTasksColumnVisible("cpu") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Tasks: Threads", ConfigManager.isTasksColumnVisible("threads") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Tasks: Samples", ConfigManager.isTasksColumnVisible("samples") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Tasks: Invokes", ConfigManager.isTasksColumnVisible("invokes") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "GPU: %GPU", ConfigManager.isGpuColumnVisible("pct") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "GPU: Threads", ConfigManager.isGpuColumnVisible("threads") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "GPU: Est ms", ConfigManager.isGpuColumnVisible("gpums") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "GPU: R.S", ConfigManager.isGpuColumnVisible("rsamples") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Memory: CLS", ConfigManager.isMemoryColumnVisible("classes") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Memory: MB", ConfigManager.isMemoryColumnVisible("mb") ? "On" : "Off", mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Memory: %", ConfigManager.isMemoryColumnVisible("pct") ? "On" : "Off", mouseX, mouseY); - top += 32; - ctx.drawText(textRenderer, "Graph Colours", left, top, TEXT_PRIMARY, false); - top += 18; - drawSettingRow(ctx, left, top, w - 24, "CPU Colour", focusedColorSetting == ColorSetting.CPU ? colorEditValue + "_" : getColorSettingHex(ColorSetting.CPU), mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "GPU Colour", focusedColorSetting == ColorSetting.GPU ? colorEditValue + "_" : getColorSettingHex(ColorSetting.GPU), mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "World Entities Colour", focusedColorSetting == ColorSetting.WORLD_ENTITIES ? colorEditValue + "_" : getColorSettingHex(ColorSetting.WORLD_ENTITIES), mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Loaded Chunks Colour", focusedColorSetting == ColorSetting.WORLD_CHUNKS_LOADED ? colorEditValue + "_" : getColorSettingHex(ColorSetting.WORLD_CHUNKS_LOADED), mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Rendered Chunks Colour", focusedColorSetting == ColorSetting.WORLD_CHUNKS_RENDERED ? colorEditValue + "_" : getColorSettingHex(ColorSetting.WORLD_CHUNKS_RENDERED), mouseX, mouseY); - top += 22; - drawSettingRow(ctx, left, top, w - 24, "Reset Graph Colours", "Defaults", mouseX, mouseY); - top += 20; - ctx.drawText(textRenderer, textRenderer.trimToWidth("Click a colour row, type a hex value like #5EA9FF, then press Enter to save.", w - 24), left, top, TEXT_DIM, false); - ctx.disableScissor(); + SettingsTabRenderer.render(this, ctx, x, y, w, h, mouseX, mouseY); } @@ -4501,7 +4089,7 @@ private String cleanEntityName(Entity entity) { return cleanProfilerLabel(entity.getType().toString()); } - private String cleanProfilerLabel(String raw) { + String cleanProfilerLabel(String raw) { if (raw == null || raw.isBlank()) { return "unknown"; } @@ -4537,7 +4125,7 @@ private String cleanProfilerLabel(String raw) { return prettifyKey(raw); } - private String prettifyKey(String key) { + String prettifyKey(String key) { String cleaned = key == null ? "unknown" : key.replace('_', ' ').replace('-', ' ').replace(':', ' '); String[] parts = cleaned.split("\\s+"); StringBuilder builder = new StringBuilder(); @@ -4556,76 +4144,7 @@ private String prettifyKey(String key) { return builder.isEmpty() ? "unknown" : builder.toString(); } - private String blankToUnknown(String value) { + String blankToUnknown(String value) { return value == null || value.isBlank() ? "unknown" : value; } } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/client/java/wueffi/taskmanager/client/TelemetryTextFormatter.java b/src/client/java/wueffi/taskmanager/client/TelemetryTextFormatter.java new file mode 100644 index 0000000..21bca80 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/TelemetryTextFormatter.java @@ -0,0 +1,46 @@ +package wueffi.taskmanager.client; + +import java.util.Locale; + +final class TelemetryTextFormatter { + + private TelemetryTextFormatter() { + } + + static String formatTemperature(double value) { + if (value < 0.0 || !Double.isFinite(value)) { + return "N/A"; + } + return String.format(Locale.ROOT, "%.1f C", value); + } + + static String formatGpuTemperatureSummary(SystemMetricsProfiler.Snapshot system) { + return "Core " + formatTemperature(system.gpuTemperatureC()) + " [" + blankToUnavailable(system.gpuTemperatureProvider()) + + "] | Hot Spot " + formatTemperature(system.gpuHotSpotTemperatureC()) + " [" + blankToUnavailable(system.gpuHotSpotProvider()) + "]"; + } + + static String formatGpuTemperatureCompact(SystemMetricsProfiler.Snapshot system) { + String core = system.gpuTemperatureC() >= 0.0 ? String.format(Locale.ROOT, "%.0fC", system.gpuTemperatureC()) : "N/A"; + String hotSpot = system.gpuHotSpotTemperatureC() >= 0.0 ? String.format(Locale.ROOT, "%.0fC", system.gpuHotSpotTemperatureC()) : "N/A"; + return "Core " + core + " | Hot Spot " + hotSpot; + } + + static String formatGpuTemperatureWithTrend(SystemMetricsProfiler.Snapshot system) { + String base = "Core " + formatTemperature(system.gpuTemperatureC()) + " [" + blankToUnavailable(system.gpuTemperatureProvider()) + "]"; + if (system.gpuTemperatureC() >= 0.0 && Double.isFinite(system.gpuTemperatureChangePerSecond()) && Math.abs(system.gpuTemperatureChangePerSecond()) >= 0.05) { + base += " (" + formatSignedRate(system.gpuTemperatureChangePerSecond(), "C/s") + ")"; + } + return base; + } + + static String formatSignedRate(double value, String units) { + if (!Double.isFinite(value) || Math.abs(value) < 0.05) { + return "~0 " + units; + } + return String.format(Locale.ROOT, "%+.1f %s", value, units); + } + + private static String blankToUnavailable(String value) { + return value == null || value.isBlank() ? "Unavailable" : value; + } +} diff --git a/src/client/java/wueffi/taskmanager/client/TextureUploadProfiler.java b/src/client/java/wueffi/taskmanager/client/TextureUploadProfiler.java new file mode 100644 index 0000000..1ce7963 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/TextureUploadProfiler.java @@ -0,0 +1,91 @@ +package wueffi.taskmanager.client; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.LongAdder; + +public final class TextureUploadProfiler { + + public record UploadEvent(long timestampMs, String modId, long bytes, String texturePath, boolean atlasReload) {} + + public record Snapshot( + Map bytesByMod, + Map countsByMod, + long totalBytes, + List recentUploads + ) {} + + private static final int MAX_RECENT_UPLOADS = 24; + private static final TextureUploadProfiler INSTANCE = new TextureUploadProfiler(); + + public static TextureUploadProfiler getInstance() { + return INSTANCE; + } + + private final Map uploadBytesByMod = new ConcurrentHashMap<>(); + private final Map uploadCountByMod = new ConcurrentHashMap<>(); + private final Deque recentUploads = new ConcurrentLinkedDeque<>(); + private volatile boolean atlasReloadActive; + + private TextureUploadProfiler() { + } + + public void markAtlasReloadStart() { + atlasReloadActive = true; + } + + public void markAtlasReloadEnd() { + atlasReloadActive = false; + } + + public void recordUpload(String modId, long bytes, String texturePath) { + String normalizedModId = normalizeModId(modId); + long safeBytes = Math.max(0L, bytes); + String normalizedTexturePath = texturePath == null ? "" : texturePath; + uploadBytesByMod.computeIfAbsent(normalizedModId, ignored -> new LongAdder()).add(safeBytes); + uploadCountByMod.computeIfAbsent(normalizedModId, ignored -> new LongAdder()).increment(); + boolean atlasReload = atlasReloadActive || normalizedTexturePath.toLowerCase(java.util.Locale.ROOT).contains("atlas"); + recentUploads.addFirst(new UploadEvent(System.currentTimeMillis(), normalizedModId, safeBytes, normalizedTexturePath, atlasReload)); + while (recentUploads.size() > MAX_RECENT_UPLOADS) { + recentUploads.removeLast(); + } + } + + public Snapshot getSnapshot() { + Map bytesByMod = toSortedLongMap(uploadBytesByMod); + Map countsByMod = toSortedLongMap(uploadCountByMod); + long totalBytes = bytesByMod.values().stream().mapToLong(Long::longValue).sum(); + return new Snapshot(bytesByMod, countsByMod, totalBytes, List.copyOf(recentUploads)); + } + + public void reset() { + uploadBytesByMod.clear(); + uploadCountByMod.clear(); + recentUploads.clear(); + atlasReloadActive = false; + } + + private static String normalizeModId(String modId) { + if (modId == null || modId.isBlank()) { + return "minecraft"; + } + return modId; + } + + private static Map toSortedLongMap(Map source) { + List> entries = new ArrayList<>(); + source.forEach((key, value) -> entries.add(Map.entry(key, value.sum()))); + entries.sort(Comparator.comparingLong((Map.Entry entry) -> entry.getValue()).reversed()); + Map ordered = new LinkedHashMap<>(); + for (Map.Entry entry : entries) { + ordered.put(entry.getKey(), entry.getValue()); + } + return ordered; + } +} diff --git a/src/client/java/wueffi/taskmanager/client/ThreadLoadProfiler.java b/src/client/java/wueffi/taskmanager/client/ThreadLoadProfiler.java index 0efc629..c87a51a 100644 --- a/src/client/java/wueffi/taskmanager/client/ThreadLoadProfiler.java +++ b/src/client/java/wueffi/taskmanager/client/ThreadLoadProfiler.java @@ -13,11 +13,11 @@ public class ThreadLoadProfiler { - public record ThreadSnapshot(double loadPercent, String state, long blockedCountDelta, long waitedCountDelta, long blockedTimeDeltaMs, long waitedTimeDeltaMs, String lockName, String lockOwnerName) {} + public record ThreadSnapshot(double loadPercent, String state, long blockedCountDelta, long waitedCountDelta, long blockedTimeDeltaMs, long waitedTimeDeltaMs, String lockName, String lockOwnerName, long lockOwnerThreadId) {} + public record RawThreadSnapshot(long threadId, String threadName, String canonicalThreadName, ThreadSnapshot snapshot) {} public record ThreadLoadSample(long capturedAtEpochMillis, Map threadsByName) {} private static final ThreadLoadProfiler INSTANCE = new ThreadLoadProfiler(); - private static final int MAX_THREADS = 10; private static final int MAX_HISTORY = 180; public static ThreadLoadProfiler getInstance() { @@ -32,6 +32,7 @@ public static ThreadLoadProfiler getInstance() { private final Map lastWaitedTimes = new ConcurrentHashMap<>(); private final Deque history = new ArrayDeque<>(); private volatile Map latestThreadSnapshots = Map.of(); + private volatile Map latestRawThreadSnapshots = Map.of(); private volatile long lastSampleTimeNs; private ThreadLoadProfiler() { @@ -67,19 +68,22 @@ public void sample() { } long[] threadIds = threadBean.getAllThreadIds(); + ThreadInfo[] threadInfos = threadBean.getThreadInfo(threadIds); Map loads = new LinkedHashMap<>(); + Map rawSnapshots = new LinkedHashMap<>(); Map nextCpuTimes = new ConcurrentHashMap<>(); Map nextBlocked = new ConcurrentHashMap<>(); Map nextWaited = new ConcurrentHashMap<>(); Map nextBlockedTimes = new ConcurrentHashMap<>(); Map nextWaitedTimes = new ConcurrentHashMap<>(); - for (long threadId : threadIds) { + for (int i = 0; i < threadIds.length; i++) { + long threadId = threadIds[i]; long cpuTimeNs = threadBean.getThreadCpuTime(threadId); if (cpuTimeNs < 0L) { continue; } - ThreadInfo info = threadBean.getThreadInfo(threadId); + ThreadInfo info = threadInfos == null || i >= threadInfos.length ? null : threadInfos[i]; if (info == null) { continue; } @@ -105,12 +109,26 @@ public void sample() { } String threadName = canonicalThreadName(info.getThreadName()); - loads.merge(threadName, new ThreadSnapshot(loadPercent, info.getThreadState().name(), blockedDelta, waitedDelta, blockedTimeDeltaMs, waitedTimeDeltaMs, info.getLockName(), info.getLockOwnerName()), this::mergeSnapshots); + ThreadSnapshot snapshot = new ThreadSnapshot( + loadPercent, + info.getThreadState().name(), + blockedDelta, + waitedDelta, + blockedTimeDeltaMs, + waitedTimeDeltaMs, + info.getLockName(), + info.getLockOwnerName(), + info.getLockOwnerId() + ); + rawSnapshots.put(threadId, new RawThreadSnapshot(threadId, info.getThreadName(), threadName, snapshot)); + loads.merge(threadName, snapshot, this::mergeSnapshots); } latestThreadSnapshots = loads.entrySet().stream() .sorted((a, b) -> Double.compare(scoreSnapshot(b.getValue()), scoreSnapshot(a.getValue()))) - .limit(MAX_THREADS) + .collect(LinkedHashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), LinkedHashMap::putAll); + latestRawThreadSnapshots = rawSnapshots.entrySet().stream() + .sorted((a, b) -> Double.compare(scoreSnapshot(b.getValue().snapshot()), scoreSnapshot(a.getValue().snapshot()))) .collect(LinkedHashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), LinkedHashMap::putAll); history.addLast(new ThreadLoadSample(System.currentTimeMillis(), new LinkedHashMap<>(latestThreadSnapshots))); while (history.size() > MAX_HISTORY) { @@ -139,6 +157,10 @@ public Map getLatestThreadSnapshots() { return latestThreadSnapshots; } + public Map getLatestRawThreadSnapshots() { + return latestRawThreadSnapshots; + } + public java.util.List getHistory() { return java.util.List.copyOf(history); } @@ -150,6 +172,7 @@ public void reset() { lastBlockedTimes.clear(); lastWaitedTimes.clear(); latestThreadSnapshots = Map.of(); + latestRawThreadSnapshots = Map.of(); history.clear(); lastSampleTimeNs = 0L; } @@ -158,6 +181,7 @@ private ThreadSnapshot mergeSnapshots(ThreadSnapshot left, ThreadSnapshot right) String state = left.loadPercent() >= right.loadPercent() ? left.state() : right.state(); String lockName = left.lockName() != null ? left.lockName() : right.lockName(); String lockOwnerName = left.lockOwnerName() != null ? left.lockOwnerName() : right.lockOwnerName(); + long lockOwnerThreadId = left.lockOwnerThreadId() > 0L ? left.lockOwnerThreadId() : right.lockOwnerThreadId(); return new ThreadSnapshot( left.loadPercent() + right.loadPercent(), state, @@ -166,7 +190,8 @@ private ThreadSnapshot mergeSnapshots(ThreadSnapshot left, ThreadSnapshot right) left.blockedTimeDeltaMs() + right.blockedTimeDeltaMs(), left.waitedTimeDeltaMs() + right.waitedTimeDeltaMs(), lockName, - lockOwnerName + lockOwnerName, + lockOwnerThreadId ); } diff --git a/src/client/java/wueffi/taskmanager/client/ThreadSnapshotCollector.java b/src/client/java/wueffi/taskmanager/client/ThreadSnapshotCollector.java new file mode 100644 index 0000000..75ddda0 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/ThreadSnapshotCollector.java @@ -0,0 +1,179 @@ +package wueffi.taskmanager.client; + +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ThreadSnapshotCollector { + + public record ThreadStackSnapshot(long threadId, String threadName, StackTraceElement[] stack) {} + public record Snapshot(long capturedAtEpochMillis, Map threadsById) { + static Snapshot empty() { + return new Snapshot(0L, Map.of()); + } + } + + private static final ThreadSnapshotCollector INSTANCE = new ThreadSnapshotCollector(); + private static final int MAX_HISTORY = 12; + private static final int MAX_STACK_DEPTH = 32; + + public static ThreadSnapshotCollector getInstance() { + return INSTANCE; + } + + private final ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); + private final Object historyLock = new Object(); + private final Deque history = new ArrayDeque<>(); + private volatile Snapshot latestSnapshot = Snapshot.empty(); + private volatile boolean running; + private Thread collectorThread; + + private ThreadSnapshotCollector() { + } + + public synchronized void start() { + if (running) { + return; + } + running = true; + collectorThread = new Thread(this::runCollector, "TaskManager-Thread-Snapshots"); + collectorThread.setDaemon(true); + collectorThread.start(); + } + + public synchronized void stop() { + running = false; + } + + public Snapshot getLatestSnapshot() { + return latestSnapshot; + } + + public List getRecentThreadSnapshots(long threadId, long sinceEpochMillis, int limit) { + if (limit <= 0) { + return List.of(); + } + List result = new ArrayList<>(limit); + synchronized (historyLock) { + var iterator = history.descendingIterator(); + while (iterator.hasNext() && result.size() < limit) { + Snapshot snapshot = iterator.next(); + if (snapshot.capturedAtEpochMillis() < sinceEpochMillis) { + break; + } + ThreadStackSnapshot threadSnapshot = snapshot.threadsById().get(threadId); + if (threadSnapshot != null) { + result.add(0, threadSnapshot); + } + } + } + if (result.isEmpty()) { + ThreadStackSnapshot latest = latestSnapshot.threadsById().get(threadId); + if (latest != null) { + return List.of(latest); + } + } + return List.copyOf(result); + } + + public List getLatestNamedSnapshots(String... threadNames) { + if (threadNames == null || threadNames.length == 0) { + return List.of(); + } + Map byName = new LinkedHashMap<>(); + Snapshot snapshot = latestSnapshot; + for (ThreadStackSnapshot threadSnapshot : snapshot.threadsById().values()) { + for (String threadName : threadNames) { + if (threadName != null && threadName.equals(threadSnapshot.threadName())) { + byName.putIfAbsent(threadName, threadSnapshot); + } + } + } + return List.copyOf(byName.values()); + } + + private void runCollector() { + while (running) { + try { + int intervalMillis = getTargetIntervalMillis(); + if (intervalMillis >= 0) { + collectSnapshot(); + } + Thread.sleep(intervalMillis < 0 ? 250L : intervalMillis); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } catch (Throwable ignored) { + } + } + } + + private int getTargetIntervalMillis() { + ProfilerManager profilerManager = ProfilerManager.getInstance(); + boolean flamegraphRunning = FlamegraphProfiler.getInstance().isRunning(); + if (flamegraphRunning) { + return 2; + } + String governorMode = profilerManager.getCollectorGovernorMode(); + if (profilerManager.shouldCollectDetailedMetrics()) { + return switch (governorMode) { + case "self-protect" -> 16; + case "burst" -> 2; + case "tight" -> 4; + case "light" -> 10; + default -> 6; + }; + } + if (profilerManager.isCaptureActive()) { + return switch (governorMode) { + case "self-protect" -> 20; + case "burst" -> 4; + case "tight" -> 6; + case "light" -> 12; + default -> 8; + }; + } + if (profilerManager.shouldCollectFrameMetrics()) { + return switch (governorMode) { + case "self-protect" -> 32; + case "burst" -> 8; + case "tight" -> 12; + case "light" -> 24; + default -> 16; + }; + } + return "light".equals(governorMode) ? -1 : 48; + } + + private void collectSnapshot() { + long[] threadIds = threadBean.getAllThreadIds(); + ThreadInfo[] infos = threadBean.getThreadInfo(threadIds, MAX_STACK_DEPTH); + Map threadsById = new LinkedHashMap<>(); + for (int i = 0; i < threadIds.length; i++) { + ThreadInfo info = infos == null || i >= infos.length ? null : infos[i]; + if (info == null) { + continue; + } + StackTraceElement[] stack = info.getStackTrace(); + if (stack == null || stack.length == 0) { + continue; + } + threadsById.put(threadIds[i], new ThreadStackSnapshot(threadIds[i], info.getThreadName(), stack)); + } + + Snapshot snapshot = new Snapshot(System.currentTimeMillis(), Map.copyOf(threadsById)); + latestSnapshot = snapshot; + synchronized (historyLock) { + history.addLast(snapshot); + while (history.size() > MAX_HISTORY) { + history.removeFirst(); + } + } + } +} diff --git a/src/client/java/wueffi/taskmanager/client/TickProfiler.java b/src/client/java/wueffi/taskmanager/client/TickProfiler.java index cceba2e..0b03119 100644 --- a/src/client/java/wueffi/taskmanager/client/TickProfiler.java +++ b/src/client/java/wueffi/taskmanager/client/TickProfiler.java @@ -17,8 +17,8 @@ public class TickProfiler { private int serverIndex = 0; private int serverCount = 0; - private long clientStart; - private long serverStart; + private volatile long clientStart; + private volatile long serverStart; public void beginTick() { clientStart = System.nanoTime(); diff --git a/src/client/java/wueffi/taskmanager/client/TooltipManager.java b/src/client/java/wueffi/taskmanager/client/TooltipManager.java new file mode 100644 index 0000000..db64c3b --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/TooltipManager.java @@ -0,0 +1,63 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.text.OrderedText; +import net.minecraft.text.Text; + +import java.util.ArrayList; +import java.util.List; + +final class TooltipManager { + + private record TooltipTarget(int x, int y, int width, int height, String text) {} + + private final List tooltipTargets = new ArrayList<>(); + + void clear() { + tooltipTargets.clear(); + } + + void add(int x, int y, int width, int height, String text) { + if (text == null || text.isBlank()) { + return; + } + tooltipTargets.add(new TooltipTarget(x, y, width, height, text)); + } + + void render(DrawContext ctx, int mouseX, int mouseY, int screenWidth, int screenHeight, TextRenderer textRenderer, int textColor, int borderColor) { + TooltipTarget target = null; + for (TooltipTarget candidate : tooltipTargets) { + if (isInside(mouseX, mouseY, candidate.x(), candidate.y(), candidate.width(), candidate.height())) { + target = candidate; + } + } + if (target == null) { + return; + } + int maxWidth = Math.min(320, screenWidth - 24); + List wrapped = textRenderer.wrapLines(Text.literal(target.text()), maxWidth); + int widest = 0; + for (OrderedText line : wrapped) { + widest = Math.max(widest, textRenderer.getWidth(line)); + } + int boxW = Math.min(maxWidth + 10, Math.max(100, widest + 10)); + int boxH = Math.max(18, wrapped.size() * 12 + 6); + int boxX = Math.max(0, Math.min(screenWidth - boxW - 8, mouseX + 10)); + int boxY = Math.max(0, Math.min(screenHeight - boxH - 8, mouseY + 10)); + ctx.fill(boxX, boxY, boxX + boxW, boxY + boxH, 0xE0121212); + ctx.fill(boxX, boxY, boxX + boxW, boxY + 1, borderColor); + ctx.fill(boxX, boxY + boxH - 1, boxX + boxW, boxY + boxH, borderColor); + ctx.fill(boxX, boxY, boxX + 1, boxY + boxH, borderColor); + ctx.fill(boxX + boxW - 1, boxY, boxX + boxW, boxY + boxH, borderColor); + int textY = boxY + 4; + for (OrderedText line : wrapped) { + ctx.drawText(textRenderer, line, boxX + 5, textY, textColor, false); + textY += 12; + } + } + + private static boolean isInside(int mouseX, int mouseY, int x, int y, int width, int height) { + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } +} diff --git a/src/client/java/wueffi/taskmanager/client/WindowsTelemetryBridge.java b/src/client/java/wueffi/taskmanager/client/WindowsTelemetryBridge.java index aa1c2b7..2fc4263 100644 --- a/src/client/java/wueffi/taskmanager/client/WindowsTelemetryBridge.java +++ b/src/client/java/wueffi/taskmanager/client/WindowsTelemetryBridge.java @@ -4,8 +4,8 @@ import com.google.gson.JsonParser; import wueffi.taskmanager.client.util.ConfigManager; -import java.io.ByteArrayOutputStream; -import java.io.InputStream; +import java.io.BufferedReader; +import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.concurrent.atomic.AtomicBoolean; @@ -13,26 +13,35 @@ final class WindowsTelemetryBridge { record Sample( + long capturedAtEpochMillis, boolean bridgeActive, String counterSource, String sensorSource, String sensorErrorCode, + String cpuTemperatureProvider, + String gpuTemperatureProvider, + String gpuHotSpotTemperatureProvider, double cpuCoreLoadPercent, double gpuCoreLoadPercent, double gpuTemperatureC, + double gpuHotSpotTemperatureC, double cpuTemperatureC, long bytesReceivedPerSecond, long bytesSentPerSecond, long diskReadBytesPerSecond, - long diskWriteBytesPerSecond + long diskWriteBytesPerSecond, + long sampleDurationMillis ) { static Sample empty() { - return new Sample(false, "Unavailable", "Unavailable", "No bridge data", -1, -1, -1, -1, -1, -1, -1, -1); + return new Sample(0L, false, "Unavailable", "Unavailable", "No bridge data", "Unavailable", "Unavailable", "Unavailable", -1, -1, -1, -1, -1, -1, -1, -1, -1, 0L); } } + record Health(String helperStatus, long latestSampleAgeMillis, long helperUptimeMillis, boolean fallbackActive, long latestSampleDurationMillis) {} + private static final String SCRIPT = """ $ErrorActionPreference = 'SilentlyContinue' + $sampleStartedAt = [System.Environment]::TickCount64 $netRecv = 0.0 $netSent = 0.0 $diskRead = 0.0 @@ -41,16 +50,20 @@ static Sample empty() { $gpuLoad = -1.0 $cpuTemp = -1.0 $gpuTemp = -1.0 + $gpuHotSpot = -1.0 $cpuSensorSource = 'Unavailable' - $gpuSensorSource = 'Unavailable' + $gpuTempSensorSource = 'Unavailable' + $gpuHotSpotSensorSource = 'Unavailable' $cpuSensorMatch = 'none' - $gpuSensorMatch = 'none' + $gpuTempSensorMatch = 'none' + $gpuHotSpotSensorMatch = 'none' $sensorAttempts = New-Object 'System.Collections.Generic.List[string]' $sensorErrors = New-Object 'System.Collections.Generic.List[string]' $counterSource = 'Windows Performance Counters' $activeRenderer = if ($env:TM_ACTIVE_RENDERER) { [string]$env:TM_ACTIVE_RENDERER } else { '' } $activeVendor = if ($env:TM_ACTIVE_VENDOR) { [string]$env:TM_ACTIVE_VENDOR } else { '' } $gpuPreferredMatchFound = $false + $loadedAssemblyPaths = New-Object 'System.Collections.Generic.HashSet[string]' function Normalize-Name($text) { if ($null -eq $text) { return '' } @@ -79,6 +92,20 @@ static Sample empty() { } catch {} } + function Ensure-LoadedAssembly($dllPath) { + if ([string]::IsNullOrWhiteSpace($dllPath) -or -not (Test-Path $dllPath)) { return $false } + try { + if (-not $script:loadedAssemblyPaths.Contains($dllPath)) { + [System.Reflection.Assembly]::LoadFrom($dllPath) | Out-Null + $script:loadedAssemblyPaths.Add($dllPath) | Out-Null + } + return $true + } catch { + Add-SensorError(('DLL ' + [System.IO.Path]::GetFileName($dllPath)), $_.Exception.Message) + return $false + } + } + function Update-HardwareTree($hardware) { try { $hardware.Update() } catch {} try { foreach ($sub in $hardware.SubHardware) { Update-HardwareTree $sub } } catch {} @@ -95,18 +122,33 @@ static Sample empty() { $script:cpuSensorMatch = $hardwareName + ' / ' + $sensorName } if ($gpuMatch) { + $hotSpotMatch = $search -match 'hot spot|hotspot|junction temperature|junction|gpu junction|hot spot temperature|mem junction|memory junction|hot point' $preferredGpu = Test-PreferredGpu $hardwareName $sensorName $sensorIdentifier if ($preferredGpu) { - if (-not $script:gpuPreferredMatchFound -or $gpuTemp -lt 0 -or $sensorValue -gt $gpuTemp) { - $script:gpuTemp = [double]$sensorValue - $script:gpuSensorSource = $origin - $script:gpuSensorMatch = $hardwareName + ' / ' + $sensorName - $script:gpuPreferredMatchFound = $true + if ($hotSpotMatch) { + if ($script:gpuHotSpot -lt 0 -or $sensorValue -gt $script:gpuHotSpot) { + $script:gpuHotSpot = [double]$sensorValue + $script:gpuHotSpotSensorSource = $origin + $script:gpuHotSpotSensorMatch = $hardwareName + ' / ' + $sensorName + } + } elseif (-not $script:gpuPreferredMatchFound -or $gpuTemp -lt 0 -or $sensorValue -gt $gpuTemp) { + $script:gpuTemp = [double]$sensorValue + $script:gpuTempSensorSource = $origin + $script:gpuTempSensorMatch = $hardwareName + ' / ' + $sensorName } + $script:gpuPreferredMatchFound = $true } elseif (-not $script:gpuPreferredMatchFound -and ($gpuTemp -lt 0 -or $sensorValue -gt $gpuTemp)) { - $script:gpuTemp = [double]$sensorValue - $script:gpuSensorSource = $origin - $script:gpuSensorMatch = $hardwareName + ' / ' + $sensorName + if ($hotSpotMatch) { + if ($script:gpuHotSpot -lt 0 -or $sensorValue -gt $script:gpuHotSpot) { + $script:gpuHotSpot = [double]$sensorValue + $script:gpuHotSpotSensorSource = $origin + $script:gpuHotSpotSensorMatch = $hardwareName + ' / ' + $sensorName + } + } else { + $script:gpuTemp = [double]$sensorValue + $script:gpuTempSensorSource = $origin + $script:gpuTempSensorMatch = $hardwareName + ' / ' + $sensorName + } } } } @@ -169,12 +211,11 @@ static Sample empty() { try { if (-not (Test-Path $dllPath)) { continue } Add-SensorAttempt ('DLL ' + [System.IO.Path]::GetFileName($dllPath)) + if (-not (Ensure-LoadedAssembly $dllPath)) { continue } if ($dllPath -like '*LibreHardwareMonitor*') { - Add-Type -Path $dllPath $computer = New-Object LibreHardwareMonitor.Hardware.Computer $origin = 'LibreHardwareMonitor DLL' } else { - Add-Type -Path $dllPath $computer = New-Object OpenHardwareMonitor.Hardware.Computer $origin = 'OpenHardwareMonitor DLL' } @@ -372,8 +413,8 @@ static Sample empty() { $utilValue = [double]($selected[3].Trim()) if ($tempValue -gt 0) { $gpuTemp = $tempValue - $gpuSensorSource = 'nvidia-smi' - $gpuSensorMatch = $selected[1].Trim() + $gpuTempSensorSource = 'nvidia-smi' + $gpuTempSensorMatch = $selected[1].Trim() } if ($utilValue -ge 0) { $gpuLoad = $utilValue @@ -386,97 +427,290 @@ static Sample empty() { $attemptText = if ($sensorAttempts.Count -gt 0) { ' | Tried: ' + (($sensorAttempts | Select-Object -Unique) -join ', ') } else { '' } $errorText = if ($sensorErrors.Count -gt 0) { (($sensorErrors | Select-Object -Unique) -join ' | ') } else { 'none' } - $sensorSource = 'CPU: ' + $cpuSensorSource + ' [' + $cpuSensorMatch + '] | GPU: ' + $gpuSensorSource + ' [' + $gpuSensorMatch + ']' + $attemptText + $sensorSource = 'CPU: ' + $cpuSensorSource + ' [' + $cpuSensorMatch + '] | GPU: ' + $gpuTempSensorSource + ' [' + $gpuTempSensorMatch + '] | GPU Hot Spot: ' + $gpuHotSpotSensorSource + ' [' + $gpuHotSpotSensorMatch + ']' + $attemptText $result = @{ bridgeActive = $true counterSource = $counterSource sensorSource = $sensorSource sensorErrorCode = $errorText + cpuTemperatureProvider = $cpuSensorSource + gpuTemperatureProvider = $gpuTempSensorSource + gpuHotSpotTemperatureProvider = $gpuHotSpotSensorSource cpuCoreLoadPercent = $cpuLoad gpuCoreLoadPercent = $gpuLoad gpuTemperatureC = $gpuTemp + gpuHotSpotTemperatureC = $gpuHotSpot cpuTemperatureC = $cpuTemp bytesReceivedPerSecond = [int64][math]::Round($netRecv) bytesSentPerSecond = [int64][math]::Round($netSent) diskReadBytesPerSecond = [int64][math]::Round($diskRead) diskWriteBytesPerSecond = [int64][math]::Round($diskWrite) + sampleDurationMillis = [int64]([System.Environment]::TickCount64 - $sampleStartedAt) } $result | ConvertTo-Json -Compress """; - private final AtomicBoolean requestInFlight = new AtomicBoolean(false); + private final AtomicBoolean helperStarting = new AtomicBoolean(false); + private final AtomicBoolean fallbackRefreshInFlight = new AtomicBoolean(false); + private final Object helperLock = new Object(); private volatile Sample latest = Sample.empty(); private volatile long lastRequestAtMillis; + private volatile long helperStartedAtMillis; + private volatile Process helperProcess; + private volatile Thread helperReaderThread; + private volatile int helperIntervalMillis = -1; + private volatile String helperRenderer = ""; + private volatile String helperVendor = ""; boolean isSupported() { return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); } - void requestRefreshIfNeeded() { + void requestRefreshIfNeeded(boolean fastNativeTelemetryAvailable) { if (!isSupported()) { latest = Sample.empty(); + stopHelper(); return; } long now = System.currentTimeMillis(); - int requestIntervalMillis = ProfilerManager.getInstance().shouldCollectFrameMetrics() ? 50 : ConfigManager.getMetricsUpdateIntervalMs(); - if (now - lastRequestAtMillis < requestIntervalMillis || !requestInFlight.compareAndSet(false, true)) { + int requestIntervalMillis = ProfilerManager.getInstance().isProfilerSelfProtectionActive() + ? 4_000 + : ProfilerManager.getInstance().shouldCollectFrameMetrics() + ? (fastNativeTelemetryAvailable ? 2_000 : 1_000) + : ConfigManager.getMetricsUpdateIntervalMs(); + SystemMetricsProfiler.Snapshot systemSnapshot = SystemMetricsProfiler.getInstance().getSnapshot(); + String activeRenderer = normalizeHelperValue(systemSnapshot.gpuRenderer()); + String activeVendor = normalizeHelperValue(systemSnapshot.gpuVendor()); + if (helperNeedsRestart(requestIntervalMillis, activeRenderer, activeVendor)) { + restartHelper(requestIntervalMillis, activeRenderer, activeVendor); + return; + } + if (helperReaderThread != null && helperReaderThread.isAlive() && now - lastRequestAtMillis < requestIntervalMillis) { + requestFallbackRefreshIfStale(requestIntervalMillis, activeRenderer, activeVendor); return; } lastRequestAtMillis = now; - - Thread thread = new Thread(this::runRefresh, "taskmanager-windows-telemetry"); - thread.setDaemon(true); - thread.start(); + if (!isHelperAlive()) { + restartHelper(requestIntervalMillis, activeRenderer, activeVendor); + } + requestFallbackRefreshIfStale(requestIntervalMillis, activeRenderer, activeVendor); } Sample getLatest() { return latest; } - private void runRefresh() { + Health getHealth() { + long now = System.currentTimeMillis(); + long helperUptimeMillis = helperStartedAtMillis <= 0L ? 0L : Math.max(0L, now - helperStartedAtMillis); + long latestSampleAgeMillis = latest.capturedAtEpochMillis() <= 0L ? Long.MAX_VALUE : Math.max(0L, now - latest.capturedAtEpochMillis()); + String helperStatus; + if (!isSupported()) { + helperStatus = "unsupported"; + } else if (fallbackRefreshInFlight.get()) { + helperStatus = "fallback"; + } else if (isHelperAlive()) { + helperStatus = latestSampleAgeMillis > Math.max(10_000L, helperIntervalMillis * 5L) ? "stale" : "alive"; + } else { + helperStatus = "stopped"; + } + return new Health(helperStatus, latestSampleAgeMillis, helperUptimeMillis, fallbackRefreshInFlight.get(), latest.sampleDurationMillis()); + } + + private boolean helperNeedsRestart(int intervalMillis, String renderer, String vendor) { + if (!isHelperAlive()) { + return true; + } + if (helperIntervalMillis != intervalMillis) { + return true; + } + if (!helperRenderer.equals(renderer)) { + return true; + } + if (!helperVendor.equals(vendor)) { + return true; + } + long now = System.currentTimeMillis(); + long helperUptimeMillis = helperStartedAtMillis <= 0L ? Long.MAX_VALUE : Math.max(0L, now - helperStartedAtMillis); + long startupGraceMillis = Math.max(4_000L, intervalMillis * 3L); + if (latest.capturedAtEpochMillis() <= 0L) { + return helperUptimeMillis > startupGraceMillis * 2L; + } + long age = Math.max(0L, now - latest.capturedAtEpochMillis()); + return helperUptimeMillis > startupGraceMillis && age > Math.max(10_000L, intervalMillis * 5L); + } + + private boolean isHelperAlive() { + Process process = helperProcess; + return process != null && process.isAlive(); + } + + private void restartHelper(int intervalMillis, String renderer, String vendor) { + if (!helperStarting.compareAndSet(false, true)) { + return; + } try { - SystemMetricsProfiler.Snapshot systemSnapshot = SystemMetricsProfiler.getInstance().getSnapshot(); - String activeRenderer = systemSnapshot.gpuRenderer(); - String activeVendor = systemSnapshot.gpuVendor(); - ProcessBuilder processBuilder = new ProcessBuilder("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", SCRIPT) - .redirectErrorStream(true); - if (activeRenderer != null) { - processBuilder.environment().put("TM_ACTIVE_RENDERER", activeRenderer); + synchronized (helperLock) { + stopHelperLocked(); + ProcessBuilder processBuilder = new ProcessBuilder( + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + buildLoopScript(intervalMillis) + ).redirectErrorStream(true); + processBuilder.environment().put("TM_ACTIVE_RENDERER", renderer); + processBuilder.environment().put("TM_ACTIVE_VENDOR", vendor); + Process process = processBuilder.start(); + helperProcess = process; + helperIntervalMillis = intervalMillis; + helperRenderer = renderer; + helperVendor = vendor; + helperStartedAtMillis = System.currentTimeMillis(); + lastRequestAtMillis = helperStartedAtMillis; + Thread readerThread = new Thread(() -> readHelperOutput(process), "taskmanager-windows-telemetry"); + readerThread.setDaemon(true); + helperReaderThread = readerThread; + readerThread.start(); } - if (activeVendor != null) { - processBuilder.environment().put("TM_ACTIVE_VENDOR", activeVendor); + } catch (Exception e) { + taskmanagerClient.LOGGER.debug("Windows telemetry bridge failed to start helper: {}", e.getMessage()); + } finally { + helperStarting.set(false); + } + } + + private void readHelperOutput(Process process) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String json = line.trim(); + if (json.isEmpty() || !json.startsWith("{")) { + continue; + } + Sample parsed = parseSample(json); + if (parsed != null) { + latest = mergeWithPrevious(parsed, latest); + } } - Process process = processBuilder.start(); - byte[] output = readAll(process.getInputStream()); - int exitCode = process.waitFor(); - if (exitCode != 0) { - return; + } catch (Exception e) { + taskmanagerClient.LOGGER.debug("Windows telemetry bridge helper stopped: {}", e.getMessage()); + } finally { + synchronized (helperLock) { + if (helperProcess == process) { + stopHelperLocked(); + } } - String json = new String(output, StandardCharsets.UTF_8).trim(); - if (json.isEmpty()) { - return; + } + } + + private void requestFallbackRefreshIfStale(int intervalMillis, String renderer, String vendor) { + long now = System.currentTimeMillis(); + long helperUptimeMillis = helperStartedAtMillis <= 0L ? Long.MAX_VALUE : Math.max(0L, now - helperStartedAtMillis); + long age = latest.capturedAtEpochMillis() > 0L + ? Math.max(0L, now - latest.capturedAtEpochMillis()) + : helperUptimeMillis; + long fallbackDelayMillis = latest.capturedAtEpochMillis() > 0L + ? Math.max(3_000L, intervalMillis * 2L) + : Math.max(1_500L, intervalMillis); + if (age <= fallbackDelayMillis || !fallbackRefreshInFlight.compareAndSet(false, true)) { + return; + } + Thread thread = new Thread(() -> runFallbackRefresh(renderer, vendor), "taskmanager-windows-telemetry-fallback"); + thread.setDaemon(true); + thread.start(); + } + + private void runFallbackRefresh(String renderer, String vendor) { + try { + ProcessBuilder processBuilder = new ProcessBuilder( + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + SCRIPT + ).redirectErrorStream(true); + processBuilder.environment().put("TM_ACTIVE_RENDERER", renderer); + processBuilder.environment().put("TM_ACTIVE_VENDOR", vendor); + Process process = processBuilder.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String json = line.trim(); + if (json.isEmpty() || !json.startsWith("{")) { + continue; + } + Sample parsed = parseSample(json); + if (parsed != null) { + latest = mergeWithPrevious(parsed, latest); + break; + } + } } + process.waitFor(); + } catch (Exception e) { + taskmanagerClient.LOGGER.debug("Windows telemetry fallback refresh failed: {}", e.getMessage()); + } finally { + fallbackRefreshInFlight.set(false); + } + } + + private Sample parseSample(String json) { + try { JsonObject root = JsonParser.parseString(json).getAsJsonObject(); - Sample parsed = new Sample( + return new Sample( + System.currentTimeMillis(), getBoolean(root, "bridgeActive"), getString(root, "counterSource"), getString(root, "sensorSource"), getString(root, "sensorErrorCode"), + getString(root, "cpuTemperatureProvider"), + getString(root, "gpuTemperatureProvider"), + getString(root, "gpuHotSpotTemperatureProvider"), getDouble(root, "cpuCoreLoadPercent"), getDouble(root, "gpuCoreLoadPercent"), getDouble(root, "gpuTemperatureC"), + getDouble(root, "gpuHotSpotTemperatureC"), getDouble(root, "cpuTemperatureC"), getLong(root, "bytesReceivedPerSecond"), getLong(root, "bytesSentPerSecond"), getLong(root, "diskReadBytesPerSecond"), - getLong(root, "diskWriteBytesPerSecond") + getLong(root, "diskWriteBytesPerSecond"), + getLong(root, "sampleDurationMillis") ); - latest = mergeWithPrevious(parsed, latest); - } catch (Exception e) { - taskmanagerClient.LOGGER.debug("Windows telemetry bridge failed: {}", e.getMessage()); - } finally { - requestInFlight.set(false); + } catch (Exception ignored) { + return null; + } + } + + private String buildLoopScript(int intervalMillis) { + int safeInterval = Math.max(250, intervalMillis); + return "$ProgressPreference='SilentlyContinue'; while ($true) { & { " + SCRIPT + " }; Start-Sleep -Milliseconds " + safeInterval + " }"; + } + + private String normalizeHelperValue(String value) { + return value == null ? "" : value; + } + + private void stopHelper() { + synchronized (helperLock) { + stopHelperLocked(); + } + } + + private void stopHelperLocked() { + Process process = helperProcess; + helperProcess = null; + helperReaderThread = null; + helperIntervalMillis = -1; + helperRenderer = ""; + helperVendor = ""; + helperStartedAtMillis = 0L; + if (process != null) { + process.destroy(); } } @@ -485,44 +719,55 @@ private Sample mergeWithPrevious(Sample current, Sample previous) { previous = Sample.empty(); } return new Sample( - current.bridgeActive() || previous.bridgeActive(), + current.capturedAtEpochMillis() > 0L ? current.capturedAtEpochMillis() : previous.capturedAtEpochMillis(), + current.bridgeActive(), chooseText(current.counterSource(), previous.counterSource(), "Unavailable"), chooseText(current.sensorSource(), previous.sensorSource(), "Unavailable"), chooseText(current.sensorErrorCode(), previous.sensorErrorCode(), "No bridge data"), - chooseDouble(current.cpuCoreLoadPercent(), previous.cpuCoreLoadPercent()), - chooseDouble(current.gpuCoreLoadPercent(), previous.gpuCoreLoadPercent()), - chooseDouble(current.gpuTemperatureC(), previous.gpuTemperatureC()), - chooseDouble(current.cpuTemperatureC(), previous.cpuTemperatureC()), - chooseLong(current.bytesReceivedPerSecond(), previous.bytesReceivedPerSecond()), - chooseLong(current.bytesSentPerSecond(), previous.bytesSentPerSecond()), - chooseLong(current.diskReadBytesPerSecond(), previous.diskReadBytesPerSecond()), - chooseLong(current.diskWriteBytesPerSecond(), previous.diskWriteBytesPerSecond()) + chooseText(current.cpuTemperatureProvider(), previous.cpuTemperatureProvider(), "Unavailable"), + chooseText(current.gpuTemperatureProvider(), previous.gpuTemperatureProvider(), "Unavailable"), + chooseText(current.gpuHotSpotTemperatureProvider(), previous.gpuHotSpotTemperatureProvider(), "Unavailable"), + chooseMetric(current.cpuCoreLoadPercent(), previous.cpuCoreLoadPercent()), + chooseMetric(current.gpuCoreLoadPercent(), previous.gpuCoreLoadPercent()), + chooseMetric(current.gpuTemperatureC(), previous.gpuTemperatureC()), + chooseMetric(current.gpuHotSpotTemperatureC(), previous.gpuHotSpotTemperatureC()), + chooseMetric(current.cpuTemperatureC(), previous.cpuTemperatureC()), + chooseMetric(current.bytesReceivedPerSecond(), previous.bytesReceivedPerSecond()), + chooseMetric(current.bytesSentPerSecond(), previous.bytesSentPerSecond()), + chooseMetric(current.diskReadBytesPerSecond(), previous.diskReadBytesPerSecond()), + chooseMetric(current.diskWriteBytesPerSecond(), previous.diskWriteBytesPerSecond()), + current.sampleDurationMillis() > 0L ? current.sampleDurationMillis() : previous.sampleDurationMillis() ); } - private String chooseText(String current, String previous, String fallback) { - if (current != null && !current.isBlank() && !current.equalsIgnoreCase("Unavailable") && !current.equalsIgnoreCase("No bridge data")) { + private double chooseMetric(double current, double previous) { + if (Double.isFinite(current) && current >= 0.0) { return current; } - if (previous != null && !previous.isBlank() && !previous.equalsIgnoreCase("Unavailable") && !previous.equalsIgnoreCase("No bridge data")) { + if (Double.isFinite(previous) && previous >= 0.0) { return previous; } - return fallback; - } - - private double chooseDouble(double current, double previous) { - return current >= 0.0 ? current : previous; + return -1.0; } - private long chooseLong(long current, long previous) { - return current >= 0L ? current : previous; + private long chooseMetric(long current, long previous) { + if (current >= 0L) { + return current; + } + if (previous >= 0L) { + return previous; + } + return -1L; } - private byte[] readAll(InputStream inputStream) throws Exception { - try (InputStream in = inputStream; ByteArrayOutputStream out = new ByteArrayOutputStream()) { - in.transferTo(out); - return out.toByteArray(); + private String chooseText(String current, String previous, String fallback) { + if (current != null && !current.isBlank() && !current.equalsIgnoreCase("Unavailable") && !current.equalsIgnoreCase("No bridge data")) { + return current; + } + if (previous != null && !previous.isBlank() && !previous.equalsIgnoreCase("Unavailable") && !previous.equalsIgnoreCase("No bridge data")) { + return previous; } + return fallback; } private boolean getBoolean(JsonObject root, String key) { diff --git a/src/client/java/wueffi/taskmanager/client/mixin/ArrayBackedEventMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/ArrayBackedEventMixin.java index 0217c3e..2a2080d 100644 --- a/src/client/java/wueffi/taskmanager/client/mixin/ArrayBackedEventMixin.java +++ b/src/client/java/wueffi/taskmanager/client/mixin/ArrayBackedEventMixin.java @@ -6,15 +6,22 @@ import org.spongepowered.asm.mixin.injection.Redirect; import wueffi.taskmanager.client.ModTimingProfiler; import wueffi.taskmanager.client.StartupTimingProfiler; +import wueffi.taskmanager.client.RenderPhaseProfiler; import wueffi.taskmanager.client.TaskManagerScreen; import wueffi.taskmanager.client.util.ModClassIndex; import java.lang.reflect.Array; import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import java.util.WeakHashMap; @Mixin(targets = "net.fabricmc.fabric.impl.base.event.ArrayBackedEvent", remap = false) public abstract class ArrayBackedEventMixin { + private static final Map, Object>> LISTENER_PROXY_CACHE = Collections.synchronizedMap(new WeakHashMap<>()); + @Redirect( method = "update", at = @At( @@ -24,7 +31,8 @@ public abstract class ArrayBackedEventMixin { ) private Object taskmanager$wrapInvoker(java.util.function.Function factory, Object listeners) { recordStartupListeners(listeners); - Object original = factory.apply(listeners); + Object profilerAwareListeners = maybeWrapListeners(listeners); + Object original = factory.apply(profilerAwareListeners); if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { return original; @@ -45,7 +53,7 @@ public abstract class ArrayBackedEventMixin { return method.invoke(original, args); } finally { long duration = System.nanoTime() - start; - String mod = ModClassIndex.getModForClassName(method.getDeclaringClass()); + String mod = ModClassIndex.getModForClassName(original.getClass()); if (mod == null) mod = "unknown"; ModTimingProfiler.getInstance().record(mod, method.getName(), duration); @@ -54,6 +62,65 @@ public abstract class ArrayBackedEventMixin { ); } + private Object maybeWrapListeners(Object listeners) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics() || listeners == null || !listeners.getClass().isArray()) { + return listeners; + } + + int length = Array.getLength(listeners); + Class componentType = listeners.getClass().getComponentType(); + if (componentType == null || !componentType.isInterface()) { + return listeners; + } + + Object wrapped = Array.newInstance(componentType, length); + for (int i = 0; i < length; i++) { + Object listener = Array.get(listeners, i); + if (listener == null) { + Array.set(wrapped, i, null); + continue; + } + Array.set(wrapped, i, wrapListener(listener, componentType)); + } + return wrapped; + } + + private Object wrapListener(Object listener, Class listenerInterface) { + String mod = ModClassIndex.getModForClassName(listener.getClass()); + if (mod == null) { + mod = "minecraft"; + } + String resolvedMod = mod; + synchronized (LISTENER_PROXY_CACHE) { + Map, Object> proxiesByInterface = LISTENER_PROXY_CACHE.computeIfAbsent(listener, ignored -> new WeakHashMap<>()); + return proxiesByInterface.computeIfAbsent(listenerInterface, ignored -> Proxy.newProxyInstance( + listener.getClass().getClassLoader(), + new Class[] {listenerInterface}, + (proxy, method, args) -> { + long start = System.nanoTime(); + boolean renderThread = isRenderThread(); + if (renderThread) { + RenderPhaseProfiler.getInstance().pushContextOwner(resolvedMod); + } + try { + return method.invoke(listener, args); + } finally { + if (renderThread) { + RenderPhaseProfiler.getInstance().popContextOwner(); + } + long duration = System.nanoTime() - start; + ModTimingProfiler.getInstance().record(resolvedMod, method.getName(), duration); + } + } + )); + } + } + + private boolean isRenderThread() { + String threadName = Thread.currentThread().getName(); + return threadName != null && threadName.toLowerCase(Locale.ROOT).contains("render"); + } + private void recordStartupListeners(Object listeners) { if (StartupTimingProfiler.closed || listeners == null || !listeners.getClass().isArray()) { return; diff --git a/src/client/java/wueffi/taskmanager/client/mixin/ChunkGeneratorMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/ChunkGeneratorMixin.java new file mode 100644 index 0000000..3e5c355 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/ChunkGeneratorMixin.java @@ -0,0 +1,51 @@ +package wueffi.taskmanager.client.mixin; + +import net.minecraft.world.gen.chunk.ChunkGenerator; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import wueffi.taskmanager.client.ChunkWorkProfiler; +import wueffi.taskmanager.client.TaskManagerScreen; +import wueffi.taskmanager.client.util.ModClassIndex; + +@Mixin(ChunkGenerator.class) +public abstract class ChunkGeneratorMixin { + + @Inject(method = "populateBiomes", at = @At("HEAD")) + private void taskmanager$beginPopulateBiomes(CallbackInfoReturnable cir) { + taskmanager$beginPhase("populateBiomes"); + } + + @Inject(method = "populateBiomes", at = @At("RETURN")) + private void taskmanager$endPopulateBiomes(CallbackInfoReturnable cir) { + taskmanager$endPhase(); + } + + @Inject(method = "generateFeatures", at = @At("HEAD")) + private void taskmanager$beginGenerateFeatures(CallbackInfo ci) { + taskmanager$beginPhase("generateFeatures"); + } + + @Inject(method = "generateFeatures", at = @At("RETURN")) + private void taskmanager$endGenerateFeatures(CallbackInfo ci) { + taskmanager$endPhase(); + } + + private void taskmanager$beginPhase(String phase) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + ChunkGenerator generator = (ChunkGenerator) (Object) this; + String ownerMod = ModClassIndex.getModForClassName(generator.getClass()); + ChunkWorkProfiler.getInstance().beginPhase((ownerMod == null ? "minecraft" : ownerMod) + " | " + phase); + } + + private void taskmanager$endPhase() { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + ChunkWorkProfiler.getInstance().endPhase(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/EntityMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/EntityMixin.java new file mode 100644 index 0000000..a563ad6 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/EntityMixin.java @@ -0,0 +1,29 @@ +package wueffi.taskmanager.client.mixin; + +import net.minecraft.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import wueffi.taskmanager.client.EntityCostProfiler; +import wueffi.taskmanager.client.TaskManagerScreen; + +@Mixin(Entity.class) +public abstract class EntityMixin { + + @Inject(method = "tick", at = @At("HEAD")) + private void taskmanager$beginEntityTick(CallbackInfo ci) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + EntityCostProfiler.getInstance().beginEntityTick((Entity) (Object) this); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void taskmanager$endEntityTick(CallbackInfo ci) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + EntityCostProfiler.getInstance().endEntityTick(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/EntityRenderManagerMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/EntityRenderManagerMixin.java new file mode 100644 index 0000000..a8f533e --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/EntityRenderManagerMixin.java @@ -0,0 +1,30 @@ +package wueffi.taskmanager.client.mixin; + +import net.minecraft.client.render.entity.EntityRenderManager; +import net.minecraft.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import wueffi.taskmanager.client.EntityCostProfiler; +import wueffi.taskmanager.client.TaskManagerScreen; + +@Mixin(EntityRenderManager.class) +public abstract class EntityRenderManagerMixin { + + @Inject(method = "getAndUpdateRenderState", at = @At("HEAD")) + private void taskmanager$beginEntityRenderPrep(E entity, float tickProgress, CallbackInfoReturnable cir) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + EntityCostProfiler.getInstance().beginEntityRenderPrep(entity); + } + + @Inject(method = "getAndUpdateRenderState", at = @At("TAIL")) + private void taskmanager$endEntityRenderPrep(CallbackInfoReturnable cir) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + EntityCostProfiler.getInstance().endEntityRenderPrep(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/GameRendererMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/GameRendererMixin.java index 10e7da0..89fd189 100644 --- a/src/client/java/wueffi/taskmanager/client/mixin/GameRendererMixin.java +++ b/src/client/java/wueffi/taskmanager/client/mixin/GameRendererMixin.java @@ -36,7 +36,7 @@ public class GameRendererMixin { @Inject(method = "renderWorld", at = @At("HEAD")) private void taskmanager$onRenderWorldHead(CallbackInfo ci) { if (!TaskManagerScreen.isLiveMetricsActive()) return; - RenderPhaseProfiler.getInstance().beginCpuPhase("gameRenderer.renderWorld", "minecraft"); + RenderPhaseProfiler.getInstance().beginCpuPhase("gameRenderer.renderWorld", "shared/render"); GpuTimer.begin("gameRenderer.renderWorld"); } @@ -46,6 +46,20 @@ public class GameRendererMixin { GpuTimer.end("gameRenderer.renderWorld"); RenderPhaseProfiler.getInstance().endCpuPhase("gameRenderer.renderWorld"); } + + @Inject(method = "renderHand", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderHandHead(CallbackInfo ci) { + if (!TaskManagerScreen.isLiveMetricsActive()) return; + RenderPhaseProfiler.getInstance().beginCpuPhase("gameRenderer.renderHand", "shared/render"); + GpuTimer.begin("gameRenderer.renderHand"); + } + + @Inject(method = "renderHand", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderHandTail(CallbackInfo ci) { + if (!TaskManagerScreen.isLiveMetricsActive()) return; + GpuTimer.end("gameRenderer.renderHand"); + RenderPhaseProfiler.getInstance().endCpuPhase("gameRenderer.renderHand"); + } } diff --git a/src/client/java/wueffi/taskmanager/client/mixin/IrisDeferredWorldRenderingPipelineMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/IrisDeferredWorldRenderingPipelineMixin.java new file mode 100644 index 0000000..a9ca9d7 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/IrisDeferredWorldRenderingPipelineMixin.java @@ -0,0 +1,88 @@ +package wueffi.taskmanager.client.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import wueffi.taskmanager.client.ProfilerManager; +import wueffi.taskmanager.client.RenderPhaseProfiler; +import wueffi.taskmanager.client.util.GpuTimer; + +@Pseudo +@Mixin(targets = "net.irisshaders.iris.pipeline.DeferredWorldRenderingPipeline", remap = false) +public class IrisDeferredWorldRenderingPipelineMixin { + + @Unique + private static void taskmanager$beginPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + RenderPhaseProfiler.getInstance().beginCpuPhase(phase, "iris"); + GpuTimer.begin(phase); + } + + @Unique + private static void taskmanager$endPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + GpuTimer.end(phase); + RenderPhaseProfiler.getInstance().endCpuPhase(phase); + } + + @Inject(method = "renderShadows", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderShadowsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderShadows"); } + + @Inject(method = "renderShadows", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderShadowsTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderShadows"); } + + @Inject(method = "renderTranslucents", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderTranslucentsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderTranslucents"); } + + @Inject(method = "renderTranslucents", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderTranslucentsTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderTranslucents"); } + + @Inject(method = "renderWeather", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderWeatherHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderWeather"); } + + @Inject(method = "renderWeather", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderWeatherTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderWeather"); } + + @Inject(method = "renderHand", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderHandHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderHand"); } + + @Inject(method = "renderHand", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderHandTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderHand"); } + + @Inject(method = "beginSodiumTerrainRendering", at = @At("HEAD"), require = 0) + private void taskmanager$onBeginSodiumTerrainRenderingHead(CallbackInfo ci) { taskmanager$beginPhase("iris.beginSodiumTerrainRendering"); } + + @Inject(method = "beginSodiumTerrainRendering", at = @At("TAIL"), require = 0) + private void taskmanager$onBeginSodiumTerrainRenderingTail(CallbackInfo ci) { taskmanager$endPhase("iris.beginSodiumTerrainRendering"); } + + @Inject(method = "endSodiumTerrainRendering", at = @At("HEAD"), require = 0) + private void taskmanager$onEndSodiumTerrainRenderingHead(CallbackInfo ci) { taskmanager$beginPhase("iris.endSodiumTerrainRendering"); } + + @Inject(method = "endSodiumTerrainRendering", at = @At("TAIL"), require = 0) + private void taskmanager$onEndSodiumTerrainRenderingTail(CallbackInfo ci) { taskmanager$endPhase("iris.endSodiumTerrainRendering"); } + + @Inject(method = "compositePass", at = @At("HEAD"), require = 0) + private void taskmanager$onCompositePassHead(CallbackInfo ci) { taskmanager$beginPhase("iris.compositePass"); } + + @Inject(method = "compositePass", at = @At("TAIL"), require = 0) + private void taskmanager$onCompositePassTail(CallbackInfo ci) { taskmanager$endPhase("iris.compositePass"); } + + @Inject(method = "prepareRenderTargets", at = @At("HEAD"), require = 0) + private void taskmanager$onPrepareRenderTargetsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.prepareRenderTargets"); } + + @Inject(method = "prepareRenderTargets", at = @At("TAIL"), require = 0) + private void taskmanager$onPrepareRenderTargetsTail(CallbackInfo ci) { taskmanager$endPhase("iris.prepareRenderTargets"); } + + @Inject(method = "destroyBuffers", at = @At("HEAD"), require = 0) + private void taskmanager$onDestroyBuffersHead(CallbackInfo ci) { taskmanager$beginPhase("iris.destroyBuffers"); } + + @Inject(method = "destroyBuffers", at = @At("TAIL"), require = 0) + private void taskmanager$onDestroyBuffersTail(CallbackInfo ci) { taskmanager$endPhase("iris.destroyBuffers"); } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/IrisNewWorldRenderingPipelineMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/IrisNewWorldRenderingPipelineMixin.java new file mode 100644 index 0000000..a268cdf --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/IrisNewWorldRenderingPipelineMixin.java @@ -0,0 +1,88 @@ +package wueffi.taskmanager.client.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import wueffi.taskmanager.client.ProfilerManager; +import wueffi.taskmanager.client.RenderPhaseProfiler; +import wueffi.taskmanager.client.util.GpuTimer; + +@Pseudo +@Mixin(targets = "net.irisshaders.iris.pipeline.NewWorldRenderingPipeline", remap = false) +public class IrisNewWorldRenderingPipelineMixin { + + @Unique + private static void taskmanager$beginPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + RenderPhaseProfiler.getInstance().beginCpuPhase(phase, "iris"); + GpuTimer.begin(phase); + } + + @Unique + private static void taskmanager$endPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + GpuTimer.end(phase); + RenderPhaseProfiler.getInstance().endCpuPhase(phase); + } + + @Inject(method = "renderShadows", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderShadowsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderShadows"); } + + @Inject(method = "renderShadows", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderShadowsTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderShadows"); } + + @Inject(method = "renderTranslucents", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderTranslucentsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderTranslucents"); } + + @Inject(method = "renderTranslucents", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderTranslucentsTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderTranslucents"); } + + @Inject(method = "renderWeather", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderWeatherHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderWeather"); } + + @Inject(method = "renderWeather", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderWeatherTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderWeather"); } + + @Inject(method = "renderHand", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderHandHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderHand"); } + + @Inject(method = "renderHand", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderHandTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderHand"); } + + @Inject(method = "beginSodiumTerrainRendering", at = @At("HEAD"), require = 0) + private void taskmanager$onBeginSodiumTerrainRenderingHead(CallbackInfo ci) { taskmanager$beginPhase("iris.beginSodiumTerrainRendering"); } + + @Inject(method = "beginSodiumTerrainRendering", at = @At("TAIL"), require = 0) + private void taskmanager$onBeginSodiumTerrainRenderingTail(CallbackInfo ci) { taskmanager$endPhase("iris.beginSodiumTerrainRendering"); } + + @Inject(method = "endSodiumTerrainRendering", at = @At("HEAD"), require = 0) + private void taskmanager$onEndSodiumTerrainRenderingHead(CallbackInfo ci) { taskmanager$beginPhase("iris.endSodiumTerrainRendering"); } + + @Inject(method = "endSodiumTerrainRendering", at = @At("TAIL"), require = 0) + private void taskmanager$onEndSodiumTerrainRenderingTail(CallbackInfo ci) { taskmanager$endPhase("iris.endSodiumTerrainRendering"); } + + @Inject(method = "compositePass", at = @At("HEAD"), require = 0) + private void taskmanager$onCompositePassHead(CallbackInfo ci) { taskmanager$beginPhase("iris.compositePass"); } + + @Inject(method = "compositePass", at = @At("TAIL"), require = 0) + private void taskmanager$onCompositePassTail(CallbackInfo ci) { taskmanager$endPhase("iris.compositePass"); } + + @Inject(method = "prepareRenderTargets", at = @At("HEAD"), require = 0) + private void taskmanager$onPrepareRenderTargetsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.prepareRenderTargets"); } + + @Inject(method = "prepareRenderTargets", at = @At("TAIL"), require = 0) + private void taskmanager$onPrepareRenderTargetsTail(CallbackInfo ci) { taskmanager$endPhase("iris.prepareRenderTargets"); } + + @Inject(method = "destroyBuffers", at = @At("HEAD"), require = 0) + private void taskmanager$onDestroyBuffersHead(CallbackInfo ci) { taskmanager$beginPhase("iris.destroyBuffers"); } + + @Inject(method = "destroyBuffers", at = @At("TAIL"), require = 0) + private void taskmanager$onDestroyBuffersTail(CallbackInfo ci) { taskmanager$endPhase("iris.destroyBuffers"); } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/IrisVanillaRenderingPipelineMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/IrisVanillaRenderingPipelineMixin.java new file mode 100644 index 0000000..189c896 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/IrisVanillaRenderingPipelineMixin.java @@ -0,0 +1,88 @@ +package wueffi.taskmanager.client.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import wueffi.taskmanager.client.ProfilerManager; +import wueffi.taskmanager.client.RenderPhaseProfiler; +import wueffi.taskmanager.client.util.GpuTimer; + +@Pseudo +@Mixin(targets = "net.irisshaders.iris.pipeline.VanillaRenderingPipeline", remap = false) +public class IrisVanillaRenderingPipelineMixin { + + @Unique + private static void taskmanager$beginPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + RenderPhaseProfiler.getInstance().beginCpuPhase(phase, "iris"); + GpuTimer.begin(phase); + } + + @Unique + private static void taskmanager$endPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + GpuTimer.end(phase); + RenderPhaseProfiler.getInstance().endCpuPhase(phase); + } + + @Inject(method = "renderShadows", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderShadowsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderShadows"); } + + @Inject(method = "renderShadows", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderShadowsTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderShadows"); } + + @Inject(method = "renderTranslucents", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderTranslucentsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderTranslucents"); } + + @Inject(method = "renderTranslucents", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderTranslucentsTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderTranslucents"); } + + @Inject(method = "renderWeather", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderWeatherHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderWeather"); } + + @Inject(method = "renderWeather", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderWeatherTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderWeather"); } + + @Inject(method = "renderHand", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderHandHead(CallbackInfo ci) { taskmanager$beginPhase("iris.renderHand"); } + + @Inject(method = "renderHand", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderHandTail(CallbackInfo ci) { taskmanager$endPhase("iris.renderHand"); } + + @Inject(method = "beginSodiumTerrainRendering", at = @At("HEAD"), require = 0) + private void taskmanager$onBeginSodiumTerrainRenderingHead(CallbackInfo ci) { taskmanager$beginPhase("iris.beginSodiumTerrainRendering"); } + + @Inject(method = "beginSodiumTerrainRendering", at = @At("TAIL"), require = 0) + private void taskmanager$onBeginSodiumTerrainRenderingTail(CallbackInfo ci) { taskmanager$endPhase("iris.beginSodiumTerrainRendering"); } + + @Inject(method = "endSodiumTerrainRendering", at = @At("HEAD"), require = 0) + private void taskmanager$onEndSodiumTerrainRenderingHead(CallbackInfo ci) { taskmanager$beginPhase("iris.endSodiumTerrainRendering"); } + + @Inject(method = "endSodiumTerrainRendering", at = @At("TAIL"), require = 0) + private void taskmanager$onEndSodiumTerrainRenderingTail(CallbackInfo ci) { taskmanager$endPhase("iris.endSodiumTerrainRendering"); } + + @Inject(method = "compositePass", at = @At("HEAD"), require = 0) + private void taskmanager$onCompositePassHead(CallbackInfo ci) { taskmanager$beginPhase("iris.compositePass"); } + + @Inject(method = "compositePass", at = @At("TAIL"), require = 0) + private void taskmanager$onCompositePassTail(CallbackInfo ci) { taskmanager$endPhase("iris.compositePass"); } + + @Inject(method = "prepareRenderTargets", at = @At("HEAD"), require = 0) + private void taskmanager$onPrepareRenderTargetsHead(CallbackInfo ci) { taskmanager$beginPhase("iris.prepareRenderTargets"); } + + @Inject(method = "prepareRenderTargets", at = @At("TAIL"), require = 0) + private void taskmanager$onPrepareRenderTargetsTail(CallbackInfo ci) { taskmanager$endPhase("iris.prepareRenderTargets"); } + + @Inject(method = "destroyBuffers", at = @At("HEAD"), require = 0) + private void taskmanager$onDestroyBuffersHead(CallbackInfo ci) { taskmanager$beginPhase("iris.destroyBuffers"); } + + @Inject(method = "destroyBuffers", at = @At("TAIL"), require = 0) + private void taskmanager$onDestroyBuffersTail(CallbackInfo ci) { taskmanager$endPhase("iris.destroyBuffers"); } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/MinecraftServerSaveMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/MinecraftServerSaveMixin.java new file mode 100644 index 0000000..9de3896 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/MinecraftServerSaveMixin.java @@ -0,0 +1,29 @@ +package wueffi.taskmanager.client.mixin; + +import net.minecraft.server.MinecraftServer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import wueffi.taskmanager.client.ProfilerManager; +import wueffi.taskmanager.client.TaskManagerScreen; + +@Mixin(MinecraftServer.class) +public class MinecraftServerSaveMixin { + + @Inject(method = "save", at = @At("HEAD"), require = 0) + private void taskmanager$onSaveHead(boolean suppressLogs, boolean flush, boolean force, CallbackInfoReturnable cir) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + ProfilerManager.getInstance().beginSaveEvent(force ? "manual-save" : "autosave"); + } + + @Inject(method = "save", at = @At("TAIL"), require = 0) + private void taskmanager$onSaveTail(boolean suppressLogs, boolean flush, boolean force, CallbackInfoReturnable cir) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + ProfilerManager.getInstance().endSaveEvent(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/ParticleManagerMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/ParticleManagerMixin.java new file mode 100644 index 0000000..84db641 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/ParticleManagerMixin.java @@ -0,0 +1,31 @@ +package wueffi.taskmanager.client.mixin; + +import net.minecraft.client.particle.ParticleManager; +import net.minecraft.client.render.Camera; +import net.minecraft.client.render.Frustum; +import net.minecraft.client.render.SubmittableBatch; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import wueffi.taskmanager.client.ProfilerManager; +import wueffi.taskmanager.client.RenderPhaseProfiler; +import wueffi.taskmanager.client.util.GpuTimer; + +@Mixin(ParticleManager.class) +public class ParticleManagerMixin { + + @Inject(method = "addToBatch", at = @At("HEAD")) + private void taskmanager$onParticlesHead(SubmittableBatch batch, Frustum frustum, Camera camera, float tickDelta, CallbackInfo ci) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) return; + RenderPhaseProfiler.getInstance().beginCpuPhase("particles.render", "shared/render"); + GpuTimer.begin("particles.render"); + } + + @Inject(method = "addToBatch", at = @At("TAIL")) + private void taskmanager$onParticlesTail(SubmittableBatch batch, Frustum frustum, Camera camera, float tickDelta, CallbackInfo ci) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) return; + GpuTimer.end("particles.render"); + RenderPhaseProfiler.getInstance().endCpuPhase("particles.render"); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/ServerChunkManagerMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/ServerChunkManagerMixin.java new file mode 100644 index 0000000..33b6c13 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/ServerChunkManagerMixin.java @@ -0,0 +1,29 @@ +package wueffi.taskmanager.client.mixin; + +import net.minecraft.server.world.ServerChunkManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import wueffi.taskmanager.client.ChunkWorkProfiler; +import wueffi.taskmanager.client.TaskManagerScreen; + +@Mixin(ServerChunkManager.class) +public abstract class ServerChunkManagerMixin { + + @Inject(method = "getChunkFutureSyncOnMainThread", at = @At("HEAD")) + private void taskmanager$beginChunkLoad(CallbackInfoReturnable cir) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + ChunkWorkProfiler.getInstance().beginPhase("minecraft | main-thread chunk load"); + } + + @Inject(method = "getChunkFutureSyncOnMainThread", at = @At("RETURN")) + private void taskmanager$endChunkLoad(CallbackInfoReturnable cir) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + ChunkWorkProfiler.getInstance().endPhase(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/ShaderProgramMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/ShaderProgramMixin.java new file mode 100644 index 0000000..decb6b3 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/ShaderProgramMixin.java @@ -0,0 +1,31 @@ +package wueffi.taskmanager.client.mixin; + +import net.minecraft.client.gl.CompiledShader; +import net.minecraft.client.gl.ShaderProgram; +import com.mojang.blaze3d.vertex.VertexFormat; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import wueffi.taskmanager.client.ShaderCompilationProfiler; +import wueffi.taskmanager.client.TaskManagerScreen; + +@Mixin(ShaderProgram.class) +public abstract class ShaderProgramMixin { + + @Inject(method = "create", at = @At("HEAD")) + private static void taskmanager$beginShaderCompile(CompiledShader vertexShader, CompiledShader fragmentShader, VertexFormat vertexFormat, String debugLabel, CallbackInfoReturnable cir) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + ShaderCompilationProfiler.getInstance().beginCompile(debugLabel); + } + + @Inject(method = "create", at = @At("RETURN")) + private static void taskmanager$endShaderCompile(CompiledShader vertexShader, CompiledShader fragmentShader, VertexFormat vertexFormat, String debugLabel, CallbackInfoReturnable cir) { + if (!TaskManagerScreen.isLiveMetricsActive()) { + return; + } + ShaderCompilationProfiler.getInstance().endCompile(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/SkyRenderingMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/SkyRenderingMixin.java index ada3e29..6c0ab7d 100644 --- a/src/client/java/wueffi/taskmanager/client/mixin/SkyRenderingMixin.java +++ b/src/client/java/wueffi/taskmanager/client/mixin/SkyRenderingMixin.java @@ -32,7 +32,7 @@ public class SkyRenderingMixin { CallbackInfo ci) { if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) return; - RenderPhaseProfiler.getInstance().beginCpuPhase("sky.renderCelestialBodies", "minecraft"); + RenderPhaseProfiler.getInstance().beginCpuPhase("sky.renderCelestialBodies", "shared/render"); GpuTimer.begin("sky.renderCelestialBodies"); } diff --git a/src/client/java/wueffi/taskmanager/client/mixin/SodiumWorldRendererMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/SodiumWorldRendererMixin.java new file mode 100644 index 0000000..b3da00c --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/SodiumWorldRendererMixin.java @@ -0,0 +1,94 @@ +package wueffi.taskmanager.client.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import wueffi.taskmanager.client.ProfilerManager; +import wueffi.taskmanager.client.RenderPhaseProfiler; +import wueffi.taskmanager.client.util.GpuTimer; + +@Pseudo +@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.SodiumWorldRenderer", remap = false) +public class SodiumWorldRendererMixin { + + @Unique + private static void taskmanager$beginPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + RenderPhaseProfiler.getInstance().beginCpuPhase(phase, "sodium"); + GpuTimer.begin(phase); + } + + @Unique + private static void taskmanager$endPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + GpuTimer.end(phase); + RenderPhaseProfiler.getInstance().endCpuPhase(phase); + } + + @Inject(method = "drawChunkLayer", at = @At("HEAD"), require = 0) + private void taskmanager$onDrawChunkLayerHead(CallbackInfo ci) { + taskmanager$beginPhase("sodium.drawChunkLayer"); + } + + @Inject(method = "drawChunkLayer", at = @At("TAIL"), require = 0) + private void taskmanager$onDrawChunkLayerTail(CallbackInfo ci) { + taskmanager$endPhase("sodium.drawChunkLayer"); + } + + @Inject(method = "renderBlockEntities", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderBlockEntitiesHead(CallbackInfo ci) { + taskmanager$beginPhase("sodium.renderBlockEntities"); + } + + @Inject(method = "renderBlockEntities", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderBlockEntitiesTail(CallbackInfo ci) { + taskmanager$endPhase("sodium.renderBlockEntities"); + } + + @Inject(method = "renderTileEntities", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderTileEntitiesHead(CallbackInfo ci) { + taskmanager$beginPhase("sodium.renderTileEntities"); + } + + @Inject(method = "renderTileEntities", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderTileEntitiesTail(CallbackInfo ci) { + taskmanager$endPhase("sodium.renderTileEntities"); + } + + @Inject(method = "setupTerrain", at = @At("HEAD"), require = 0) + private void taskmanager$onSetupTerrainHead(CallbackInfo ci) { + taskmanager$beginPhase("sodium.setupTerrain"); + } + + @Inject(method = "setupTerrain", at = @At("TAIL"), require = 0) + private void taskmanager$onSetupTerrainTail(CallbackInfo ci) { + taskmanager$endPhase("sodium.setupTerrain"); + } + + @Inject(method = "updateChunks", at = @At("HEAD"), require = 0) + private void taskmanager$onUpdateChunksHead(CallbackInfo ci) { + taskmanager$beginPhase("sodium.updateChunks"); + } + + @Inject(method = "updateChunks", at = @At("TAIL"), require = 0) + private void taskmanager$onUpdateChunksTail(CallbackInfo ci) { + taskmanager$endPhase("sodium.updateChunks"); + } + + @Inject(method = "renderLayer", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderLayerHead(CallbackInfo ci) { + taskmanager$beginPhase("sodium.renderLayer"); + } + + @Inject(method = "renderLayer", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderLayerTail(CallbackInfo ci) { + taskmanager$endPhase("sodium.renderLayer"); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/TextureManagerMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/TextureManagerMixin.java new file mode 100644 index 0000000..8f85b58 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/mixin/TextureManagerMixin.java @@ -0,0 +1,63 @@ +package wueffi.taskmanager.client.mixin; + +import net.minecraft.client.texture.TextureManager; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import wueffi.taskmanager.client.TextureUploadProfiler; + +import java.lang.reflect.Field; +import java.util.Map; + +@Mixin(TextureManager.class) +public class TextureManagerMixin { + + @Shadow @Final + private Map textures; + + @Inject(method = "registerTexture", at = @At("TAIL"), require = 0) + private void taskmanager$onRegisterTexture(Identifier id, CallbackInfo ci) { + if (id == null) { + return; + } + Object texture = textures == null ? null : textures.get(id); + TextureUploadProfiler.getInstance().recordUpload(id.getNamespace(), taskmanager$estimateTextureBytes(texture), id.toString()); + } + + @Unique + private static long taskmanager$estimateTextureBytes(Object texture) { + if (texture == null) { + return 0L; + } + int width = taskmanager$readIntField(texture, "width", "textureWidth", "field_5204"); + int height = taskmanager$readIntField(texture, "height", "textureHeight", "field_5205"); + if (width <= 0 || height <= 0) { + return 0L; + } + return (long) width * height * 4L; + } + + @Unique + private static int taskmanager$readIntField(Object texture, String... names) { + for (String name : names) { + try { + Field field = texture.getClass().getDeclaredField(name); + field.setAccessible(true); + Object value = field.get(texture); + if (value instanceof Number number) { + int intValue = number.intValue(); + if (intValue > 0) { + return intValue; + } + } + } catch (ReflectiveOperationException ignored) { + } + } + return 0; + } +} diff --git a/src/client/java/wueffi/taskmanager/client/mixin/WorldRendererMixin.java b/src/client/java/wueffi/taskmanager/client/mixin/WorldRendererMixin.java index a65a036..9d507c9 100644 --- a/src/client/java/wueffi/taskmanager/client/mixin/WorldRendererMixin.java +++ b/src/client/java/wueffi/taskmanager/client/mixin/WorldRendererMixin.java @@ -20,6 +20,40 @@ @Mixin(WorldRenderer.class) public class WorldRendererMixin { + @Unique + private static void taskmanager$beginPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + RenderPhaseProfiler.getInstance().beginCpuPhase(phase, "shared/render"); + GpuTimer.begin(phase); + } + + @Unique + private static void taskmanager$endPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + GpuTimer.end(phase); + RenderPhaseProfiler.getInstance().endCpuPhase(phase); + } + + @Unique + private static void taskmanager$beginCpuOnlyPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + RenderPhaseProfiler.getInstance().beginCpuPhase(phase, "shared/render"); + } + + @Unique + private static void taskmanager$endCpuOnlyPhase(String phase) { + if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) { + return; + } + RenderPhaseProfiler.getInstance().endCpuPhase(phase); + } + @Inject( method = "render", at = @At("HEAD") @@ -36,10 +70,7 @@ public class WorldRendererMixin { Vector4f fogColor, boolean shouldRenderSky, CallbackInfo ci) { - - if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) return; - RenderPhaseProfiler.getInstance().beginCpuPhase("worldRenderer.render", "minecraft"); - GpuTimer.begin("worldRenderer.render"); + taskmanager$beginPhase("worldRenderer.render"); } @Inject( @@ -58,10 +89,7 @@ public class WorldRendererMixin { Vector4f fogColor, boolean shouldRenderSky, CallbackInfo ci) { - - if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) return; - GpuTimer.end("worldRenderer.render"); - RenderPhaseProfiler.getInstance().endCpuPhase("worldRenderer.render"); + taskmanager$endPhase("worldRenderer.render"); } @Inject( @@ -69,9 +97,7 @@ public class WorldRendererMixin { at = @At("HEAD") ) private void taskmanager$onOutlinesHead(CallbackInfo ci) { - if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) return; - RenderPhaseProfiler.getInstance().beginCpuPhase("worldRenderer.entityOutlines", "minecraft"); - GpuTimer.begin("worldRenderer.entityOutlines"); + taskmanager$beginPhase("worldRenderer.entityOutlines"); } @Inject( @@ -79,9 +105,107 @@ public class WorldRendererMixin { at = @At("TAIL") ) private void taskmanager$onOutlinesTail(CallbackInfo ci) { - if (!ProfilerManager.getInstance().shouldCollectDetailedMetrics()) return; - GpuTimer.end("worldRenderer.entityOutlines"); - RenderPhaseProfiler.getInstance().endCpuPhase("worldRenderer.entityOutlines"); + taskmanager$endPhase("worldRenderer.entityOutlines"); + } + + @Inject(method = "renderWeather", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderWeatherHead(CallbackInfo ci) { + taskmanager$beginPhase("worldRenderer.renderWeather"); + } + + @Inject(method = "renderWeather", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderWeatherTail(CallbackInfo ci) { + taskmanager$endPhase("worldRenderer.renderWeather"); + } + + @Inject(method = "renderSky", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderSkyHead(CallbackInfo ci) { + taskmanager$beginPhase("worldRenderer.renderSky"); + } + + @Inject(method = "renderSky", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderSkyTail(CallbackInfo ci) { + taskmanager$endPhase("worldRenderer.renderSky"); + } + + @Inject(method = "renderClouds", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderCloudsHead(CallbackInfo ci) { + taskmanager$beginPhase("worldRenderer.renderClouds"); + } + + @Inject(method = "renderClouds", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderCloudsTail(CallbackInfo ci) { + taskmanager$endPhase("worldRenderer.renderClouds"); + } + + @Inject(method = "renderParticles", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderParticlesHead(CallbackInfo ci) { + taskmanager$beginPhase("worldRenderer.renderParticles"); + } + + @Inject(method = "renderParticles", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderParticlesTail(CallbackInfo ci) { + taskmanager$endPhase("worldRenderer.renderParticles"); + } + + @Inject(method = "renderMain", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderMainHead(CallbackInfo ci) { + taskmanager$beginPhase("worldRenderer.renderMain"); + } + + @Inject(method = "renderMain", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderMainTail(CallbackInfo ci) { + taskmanager$endPhase("worldRenderer.renderMain"); + } + + @Inject(method = "renderEntities", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderEntitiesHead(CallbackInfo ci) { + taskmanager$beginPhase("worldRenderer.renderEntities"); + } + + @Inject(method = "renderEntities", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderEntitiesTail(CallbackInfo ci) { + taskmanager$endPhase("worldRenderer.renderEntities"); + } + + @Inject(method = "renderBlockEntities", at = @At("HEAD"), require = 0) + private void taskmanager$onRenderBlockEntitiesHead(CallbackInfo ci) { + taskmanager$beginPhase("worldRenderer.renderBlockEntities"); + } + + @Inject(method = "renderBlockEntities", at = @At("TAIL"), require = 0) + private void taskmanager$onRenderBlockEntitiesTail(CallbackInfo ci) { + taskmanager$endPhase("worldRenderer.renderBlockEntities"); + } + + @Inject(method = "fillEntityRenderStates", at = @At("HEAD"), require = 0) + private void taskmanager$onFillEntityRenderStatesHead(CallbackInfo ci) { + taskmanager$beginCpuOnlyPhase("worldRenderer.fillEntityRenderStates"); + } + + @Inject(method = "fillEntityRenderStates", at = @At("TAIL"), require = 0) + private void taskmanager$onFillEntityRenderStatesTail(CallbackInfo ci) { + taskmanager$endCpuOnlyPhase("worldRenderer.fillEntityRenderStates"); + } + + @Inject(method = "pushEntityRenders", at = @At("HEAD"), require = 0) + private void taskmanager$onPushEntityRendersHead(CallbackInfo ci) { + taskmanager$beginCpuOnlyPhase("worldRenderer.pushEntityRenders"); + } + + @Inject(method = "pushEntityRenders", at = @At("TAIL"), require = 0) + private void taskmanager$onPushEntityRendersTail(CallbackInfo ci) { + taskmanager$endCpuOnlyPhase("worldRenderer.pushEntityRenders"); + } + + @Inject(method = "fillBlockEntityRenderStates", at = @At("HEAD"), require = 0) + private void taskmanager$onFillBlockEntityRenderStatesHead(CallbackInfo ci) { + taskmanager$beginCpuOnlyPhase("worldRenderer.fillBlockEntityRenderStates"); + } + + @Inject(method = "fillBlockEntityRenderStates", at = @At("TAIL"), require = 0) + private void taskmanager$onFillBlockEntityRenderStatesTail(CallbackInfo ci) { + taskmanager$endCpuOnlyPhase("worldRenderer.fillBlockEntityRenderStates"); } } diff --git a/src/client/java/wueffi/taskmanager/client/tabs/DiskTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/DiskTabRenderer.java new file mode 100644 index 0000000..5ec2ff8 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/DiskTabRenderer.java @@ -0,0 +1,34 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +final class DiskTabRenderer { + + private DiskTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h) { + var textRenderer = screen.uiTextRenderer(); + SystemMetricsProfiler metrics = SystemMetricsProfiler.getInstance(); + int left = x + TaskManagerScreen.PADDING; + int top = screen.getFullPageScrollTop(y); + int graphWidth = screen.getPreferredGraphWidth(w); + int graphX = x + Math.max(TaskManagerScreen.PADDING, (w - graphWidth) / 2); + screen.beginFullPageScissor(ctx, x, y, w, h); + ctx.drawText(textRenderer, "Disk throughput from OS counters during capture. Unsupported platforms may show unavailable.", left, top, TaskManagerScreen.TEXT_DIM, false); + top += 14; + if (screen.snapshot.systemMetrics().diskReadBytesPerSecond() < 0 && screen.snapshot.systemMetrics().diskWriteBytesPerSecond() < 0) { + ctx.drawText(textRenderer, "Disk throughput counters are unavailable on this provider right now.", left, top, TaskManagerScreen.ACCENT_YELLOW, false); + top += 14; + } + screen.drawMetricRow(ctx, left, top, w - 16, "Read", screen.formatBytesPerSecond(screen.snapshot.systemMetrics().diskReadBytesPerSecond())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 16, "Write", screen.formatBytesPerSecond(screen.snapshot.systemMetrics().diskWriteBytesPerSecond())); + top += 20; + int graphHeight = 132; + screen.renderMetricGraph(ctx, graphX - TaskManagerScreen.PADDING, top, graphWidth + (TaskManagerScreen.PADDING * 2), graphHeight, metrics.getOrderedDiskReadHistory(), metrics.getOrderedDiskWriteHistory(), "Disk Read/Write", "B/s", metrics.getHistorySpanSeconds()); + top += graphHeight + 2; + screen.renderGraphLegend(ctx, graphX, top, new String[]{"Read", "Write"}, new int[]{TaskManagerScreen.INTEL_COLOR, TaskManagerScreen.ACCENT_YELLOW}); + ctx.disableScissor(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/FlamegraphTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/FlamegraphTabRenderer.java new file mode 100644 index 0000000..532af60 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/FlamegraphTabRenderer.java @@ -0,0 +1,42 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Locale; + +final class FlamegraphTabRenderer { + + private FlamegraphTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h) { + var textRenderer = screen.uiTextRenderer(); + screen.beginFullPageScissor(ctx, x, y, w, h); + int top = screen.getFullPageScrollTop(y); + top = screen.renderSectionHeader(ctx, x + TaskManagerScreen.PADDING, top, "Flamegraph", "Captured stack samples from the current profiling window."); + int rowY = top + 4; + Map stacks = new LinkedHashMap<>(); + screen.snapshot.flamegraphStacks().forEach((stack, count) -> { + if (screen.matchesGlobalSearch(stack.toLowerCase(Locale.ROOT))) { + stacks.put(stack, count); + } + }); + if (stacks.isEmpty()) { + ctx.drawText(textRenderer, screen.globalSearch.isBlank() ? "No flamegraph samples yet." : "No flamegraph stacks match the universal search.", x + TaskManagerScreen.PADDING, rowY, TaskManagerScreen.TEXT_DIM, false); + } else { + int shown = 0; + for (Map.Entry entry : stacks.entrySet()) { + ctx.drawText(textRenderer, textRenderer.trimToWidth(entry.getKey(), w - 120), x + TaskManagerScreen.PADDING, rowY, TaskManagerScreen.TEXT_DIM, false); + String count = screen.formatCount(entry.getValue()); + ctx.drawText(textRenderer, count, x + w - TaskManagerScreen.PADDING - textRenderer.getWidth(count), rowY, TaskManagerScreen.TEXT_PRIMARY, false); + rowY += 12; + if (++shown >= 20) { + break; + } + } + } + ctx.disableScissor(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/GpuTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/GpuTabRenderer.java new file mode 100644 index 0000000..8226db9 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/GpuTabRenderer.java @@ -0,0 +1,126 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import wueffi.taskmanager.client.util.ModIconCache; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class GpuTabRenderer { + + private GpuTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + var textRenderer = screen.uiTextRenderer(); + Map cpuDetails = screen.snapshot.cpuDetails(); + AttributionModelBuilder.EffectiveGpuAttribution rawGpu = screen.rawGpuAttribution(); + AttributionModelBuilder.EffectiveGpuAttribution displayGpu = screen.gpuEffectiveView ? screen.effectiveGpuAttribution() : rawGpu; + List rows = screen.getGpuRows(displayGpu, cpuDetails, !screen.gpuEffectiveView && screen.gpuShowSharedRows); + + int detailW = Math.min(420, Math.max(320, w / 3)); + int gap = TaskManagerScreen.PADDING; + int listW = w - detailW - gap; + int infoY = y + TaskManagerScreen.PADDING; + int descriptionBottomY = screen.renderWrappedText(ctx, x + TaskManagerScreen.PADDING, infoY, Math.max(260, listW - 16), screen.gpuEffectiveView ? "Estimated GPU share by tagged render phases with shared render work folded into concrete mods." : "Raw GPU ownership by tagged render phases. Shared render buckets stay separate until you switch back to Effective view.", TaskManagerScreen.TEXT_DIM); + ctx.drawText(textRenderer, screen.cpuStatusText(screen.snapshot.gpuReady(), displayGpu.totalRenderSamples(), screen.snapshot.cpuSampleAgeMillis()), x + TaskManagerScreen.PADDING, descriptionBottomY + 2, screen.getGpuStatusColor(screen.snapshot.gpuReady()), false); + ctx.drawText(textRenderer, "Tip: phase ownership assigns direct GPU time first, then Effective view redistributes leftover shared render work.", x + TaskManagerScreen.PADDING, descriptionBottomY + 12, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING, descriptionBottomY + 12, 460, 10, "GPU rows now use tagged render-phase ownership. Effective view redistributes leftover shared render work into concrete mods for readability."); + int controlsY = descriptionBottomY + 26; + screen.drawTopChip(ctx, x + TaskManagerScreen.PADDING, controlsY, 78, 16, false); + ctx.drawText(textRenderer, "GPU Graph", x + TaskManagerScreen.PADDING + 18, controlsY + 4, TaskManagerScreen.TEXT_DIM, false); + screen.drawTopChip(ctx, x + TaskManagerScreen.PADDING + 84, controlsY, 98, 16, screen.gpuEffectiveView); + ctx.drawText(textRenderer, screen.gpuEffectiveView ? "Effective" : "Raw", x + TaskManagerScreen.PADDING + 110, controlsY + 4, screen.gpuEffectiveView ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING + 84, controlsY, 98, 16, "Toggle between raw tagged ownership and effective ownership with redistributed shared render work."); + screen.drawTopChip(ctx, x + TaskManagerScreen.PADDING + 188, controlsY, 112, 16, !screen.gpuEffectiveView && screen.gpuShowSharedRows); + ctx.drawText(textRenderer, screen.gpuShowSharedRows ? "Shared Rows" : "Hide Shared", x + TaskManagerScreen.PADDING + 204, controlsY + 4, (!screen.gpuEffectiveView && screen.gpuShowSharedRows) ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING + 188, controlsY, 112, 16, "In Raw view, show or hide shared/render rows. Effective view already folds them into mod rows."); + screen.renderSearchBox(ctx, x + listW - 160, controlsY, 152, 16, "Search mods", screen.gpuSearch, screen.focusedSearchTable == TaskManagerScreen.TableId.GPU); + screen.renderResetButton(ctx, x + listW - 214, controlsY, 48, 16, screen.hasGpuFilter()); + screen.renderSortSummary(ctx, x + TaskManagerScreen.PADDING, controlsY + 22, "Sort", screen.formatSort(screen.gpuSort, screen.gpuSortDescending), TaskManagerScreen.TEXT_DIM); + ctx.drawText(textRenderer, rows.size() + " rows", x + TaskManagerScreen.PADDING + 108, controlsY + 22, TaskManagerScreen.TEXT_DIM, false); + + if (displayGpu.totalGpuNanos() <= 0L) { + ctx.drawText(textRenderer, "No GPU attribution yet. Render some frames with timer queries enabled.", x + TaskManagerScreen.PADDING, infoY + 52, TaskManagerScreen.TEXT_DIM, false); + screen.renderGpuDetailPanel(ctx, x + listW + gap, y + TaskManagerScreen.PADDING, detailW, h - (TaskManagerScreen.PADDING * 2), screen.selectedGpuMod, 0L, 0L, 0L, 0L, 0L, 0L, screen.selectedGpuMod == null ? null : cpuDetails.get(screen.selectedGpuMod), displayGpu.totalRenderSamples(), displayGpu.totalGpuNanos(), screen.gpuEffectiveView); + return; + } + + if (!rows.isEmpty() && (screen.selectedGpuMod == null || !rows.contains(screen.selectedGpuMod))) { + screen.selectedGpuMod = rows.getFirst(); + } + + int headerY = controlsY + 42; + ctx.fill(x, headerY, x + listW, headerY + 14, TaskManagerScreen.HEADER_COLOR); + ctx.drawText(textRenderer, screen.headerLabel("MOD", screen.gpuSort == TaskManagerScreen.GpuSort.NAME, screen.gpuSortDescending), x + TaskManagerScreen.PADDING + 16 + 6, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING + 16 + 6, headerY + 1, 44, 14, "Sort by mod display name."); + int pctX = x + listW - 232; + int threadsX = x + listW - 172; + int gpuMsX = x + listW - 108; + int renderSamplesX = x + listW - 42; + if (screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "pct")) { ctx.drawText(textRenderer, screen.headerLabel("EST %GPU", screen.gpuSort == TaskManagerScreen.GpuSort.EST_GPU, screen.gpuSortDescending), pctX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(pctX, headerY + 1, 58, 14, screen.gpuEffectiveView ? "Tagged GPU share in the effective ownership view." : "Tagged GPU share in the raw ownership view."); } + if (screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "threads")) { ctx.drawText(textRenderer, screen.headerLabel("THREADS", screen.gpuSort == TaskManagerScreen.GpuSort.THREADS, screen.gpuSortDescending), threadsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(threadsX, headerY + 1, 58, 14, "Distinct sampled render threads contributing to this row."); } + if (screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "gpums")) { ctx.drawText(textRenderer, screen.headerLabel("Est ms", screen.gpuSort == TaskManagerScreen.GpuSort.GPU_MS, screen.gpuSortDescending), gpuMsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(gpuMsX, headerY + 1, 48, 14, "Attributed GPU milliseconds in the rolling window."); } + if (screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "rsamples")) { ctx.drawText(textRenderer, screen.headerLabel("R.S", screen.gpuSort == TaskManagerScreen.GpuSort.RENDER_SAMPLES, screen.gpuSortDescending), renderSamplesX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(renderSamplesX, headerY + 1, 26, 14, "Render samples attributed to this row."); } + + int listY = headerY + 16; + int listH = h - (listY - y); + if (rows.isEmpty()) { + ctx.drawText(textRenderer, screen.gpuSearch.isBlank() ? "Waiting for render-thread samples..." : "No GPU rows match the current search/filter.", x + TaskManagerScreen.PADDING, listY + 6, TaskManagerScreen.TEXT_DIM, false); + } else { + ctx.enableScissor(x, listY, x + listW, listY + listH); + int rowY = listY - screen.scrollOffset; + int rowIdx = 0; + for (String modId : rows) { + if (rowY + TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT > listY && rowY < listY + listH) { + screen.renderStripedRowVariable(ctx, x, listW, rowY, TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT, rowIdx, mouseX, mouseY); + if (modId.equals(screen.selectedGpuMod)) { + ctx.fill(x, rowY, x + 3, rowY + TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT, TaskManagerScreen.ACCENT_GREEN); + } + long gpuNanos = displayGpu.gpuNanosByMod().getOrDefault(modId, 0L); + long renderSamples = displayGpu.renderSamplesByMod().getOrDefault(modId, 0L); + long rawGpuNanos = rawGpu.gpuNanosByMod().getOrDefault(modId, 0L); + long rawRenderSamples = rawGpu.renderSamplesByMod().getOrDefault(modId, 0L); + CpuSamplingProfiler.DetailSnapshot detailSnapshot = cpuDetails.get(modId); + double pct = gpuNanos * 100.0 / Math.max(1L, displayGpu.totalGpuNanos()); + double gpuMs = gpuNanos / 1_000_000.0; + int threadCount = detailSnapshot == null ? 0 : detailSnapshot.sampledThreadCount(); + long redistributedGpuNanos = displayGpu.redistributedGpuNanosByMod().getOrDefault(modId, 0L); + String confidence = AttributionInsights.gpuConfidence(modId, rawGpuNanos, gpuNanos, redistributedGpuNanos, rawRenderSamples, renderSamples).label(); + String provenance = AttributionInsights.gpuProvenance(rawGpuNanos, redistributedGpuNanos, rawRenderSamples, renderSamples); + int modRight = screen.firstVisibleMetricX(x + listW - 8, pctX, screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "pct"), threadsX, screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "threads"), gpuMsX, screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "gpums"), renderSamplesX, screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "rsamples")); + int nameX = x + TaskManagerScreen.PADDING + 16 + 6; + int chipWidth = screen.confidenceChipWidth(confidence); + int chipX = Math.max(nameX + 48, modRight - chipWidth); + int nameWidth = Math.max(48, chipX - nameX - 6); + + Identifier icon = ModIconCache.getInstance().getIcon(modId); + ctx.drawTexture(RenderPipelines.GUI_TEXTURED, icon, x + TaskManagerScreen.PADDING, rowY + 6, 0f, 0f, 16, 16, 16, 16, 0xFFFFFFFF); + ctx.drawText(textRenderer, textRenderer.trimToWidth(screen.getDisplayName(modId), nameWidth), nameX, rowY + 4, TaskManagerScreen.TEXT_PRIMARY, false); + screen.renderConfidenceChip(ctx, chipX, rowY + 3, confidence); + ctx.drawText(textRenderer, textRenderer.trimToWidth(provenance, Math.max(60, modRight - nameX)), nameX, rowY + 16, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "pct")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f%%", pct), pctX, rowY + 9, screen.getHeatColor(pct), false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "threads")) ctx.drawText(textRenderer, Integer.toString(threadCount), threadsX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "gpums")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.2f", gpuMs), gpuMsX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.GPU, "rsamples")) ctx.drawText(textRenderer, screen.formatCount(renderSamples), renderSamplesX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + } + if (rowY > listY + listH) break; + rowY += TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT; + rowIdx++; + } + ctx.disableScissor(); + } + + screen.renderGpuDetailPanel(ctx, x + listW + gap, y + TaskManagerScreen.PADDING, detailW, h - (TaskManagerScreen.PADDING * 2), screen.selectedGpuMod, + screen.selectedGpuMod == null ? 0L : rawGpu.gpuNanosByMod().getOrDefault(screen.selectedGpuMod, 0L), + screen.selectedGpuMod == null ? 0L : displayGpu.gpuNanosByMod().getOrDefault(screen.selectedGpuMod, 0L), + screen.selectedGpuMod == null ? 0L : rawGpu.renderSamplesByMod().getOrDefault(screen.selectedGpuMod, 0L), + screen.selectedGpuMod == null ? 0L : displayGpu.renderSamplesByMod().getOrDefault(screen.selectedGpuMod, 0L), + screen.selectedGpuMod == null ? 0L : displayGpu.redistributedGpuNanosByMod().getOrDefault(screen.selectedGpuMod, 0L), + screen.selectedGpuMod == null ? 0L : displayGpu.redistributedRenderSamplesByMod().getOrDefault(screen.selectedGpuMod, 0L), + screen.selectedGpuMod == null ? null : cpuDetails.get(screen.selectedGpuMod), displayGpu.totalRenderSamples(), displayGpu.totalGpuNanos(), screen.gpuEffectiveView); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/MemoryTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/MemoryTabRenderer.java new file mode 100644 index 0000000..191a719 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/MemoryTabRenderer.java @@ -0,0 +1,183 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import wueffi.taskmanager.client.util.ModIconCache; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class MemoryTabRenderer { + + private MemoryTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + var textRenderer = screen.uiTextRenderer(); + MemoryProfiler.Snapshot memory = screen.snapshot.memory(); + TextureUploadProfiler.Snapshot textureUploads = TextureUploadProfiler.getInstance().getSnapshot(); + Map rawMemoryMods = screen.snapshot.memoryMods(); + Map sharedFamilies = screen.snapshot.sharedMemoryFamilies(); + Map> sharedFamilyClasses = screen.snapshot.sharedFamilyClasses(); + Map> memoryClassesByMod = screen.snapshot.memoryClassesByMod(); + AttributionModelBuilder.EffectiveMemoryAttribution effectiveMemory = screen.effectiveMemoryAttribution(); + Map memoryMods = screen.memoryEffectiveView ? effectiveMemory.displayBytes() : rawMemoryMods; + + if (screen.selectedSharedFamily == null && !sharedFamilies.isEmpty()) { + screen.selectedSharedFamily = sharedFamilies.keySet().iterator().next(); + } + + List rows = screen.getMemoryRows(memoryMods, memoryClassesByMod, !screen.memoryEffectiveView && screen.memoryShowSharedRows); + if (screen.selectedMemoryMod == null || !rows.contains(screen.selectedMemoryMod)) { + screen.selectedMemoryMod = rows.isEmpty() ? null : rows.getFirst(); + } + + int sharedPanelW = sharedFamilies.isEmpty() ? 0 : Math.min(280, Math.max(220, w / 4)); + int detailH = 116; + int panelGap = sharedPanelW > 0 ? TaskManagerScreen.PADDING : 0; + int tableW = w - sharedPanelW - panelGap; + int left = x + TaskManagerScreen.PADDING; + int top = y + TaskManagerScreen.PADDING; + int descriptionBottomY = screen.renderWrappedText(ctx, left, top, Math.max(260, tableW - 16), screen.memoryEffectiveView ? "Effective live heap by mod with shared/runtime buckets folded into concrete mods for comparison. Updated asynchronously." : "Raw live heap by owner/class family. Shared/runtime buckets stay separate until you switch back to Effective view.", TaskManagerScreen.TEXT_DIM); + ctx.drawText(textRenderer, screen.memoryStatusText(screen.snapshot.memoryAgeMillis()), left, descriptionBottomY + 2, screen.snapshot.memoryAgeMillis() <= 15000 ? TaskManagerScreen.ACCENT_GREEN : TaskManagerScreen.ACCENT_YELLOW, false); + + long heapMax = memory.heapMaxBytes() > 0 ? memory.heapMaxBytes() : memory.heapCommittedBytes(); + double usedPct = heapMax > 0 ? (memory.heapUsedBytes() * 100.0 / heapMax) : 0; + + ctx.drawText(textRenderer, "Tip: Effective view proportionally folds shared/runtime memory into mod rows for readability.", left, descriptionBottomY + 12, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(left, descriptionBottomY + 12, 430, 10, "Effective view proportionally folds shared/runtime memory into concrete mods. Raw view keeps true-owned and shared buckets separate."); + int controlsTopY = descriptionBottomY + 26; + screen.drawTopChip(ctx, x + tableW - 222, controlsTopY, 112, 16, !screen.memoryEffectiveView && screen.memoryShowSharedRows); + ctx.drawText(textRenderer, screen.memoryShowSharedRows ? "Shared Rows" : "Hide Shared", x + tableW - 206, controlsTopY + 4, (!screen.memoryEffectiveView && screen.memoryShowSharedRows) ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + tableW - 222, controlsTopY, 112, 16, "In Raw view, show or hide shared/jvm, shared/framework, and runtime rows. Effective view already folds them into mod rows."); + screen.drawTopChip(ctx, x + tableW - 112, controlsTopY, 98, 16, screen.memoryEffectiveView); + ctx.drawText(textRenderer, screen.memoryEffectiveView ? "Effective" : "Raw", x + tableW - 86, controlsTopY + 4, screen.memoryEffectiveView ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + tableW - 112, controlsTopY, 98, 16, "Toggle between raw memory ownership and effective ownership with redistributed shared/runtime memory."); + screen.drawTopChip(ctx, x + tableW - 106, controlsTopY + 18, 98, 16, false); + ctx.drawText(textRenderer, "Memory Graph", x + tableW - 92, controlsTopY + 22, TaskManagerScreen.TEXT_DIM, false); + + int controlsY = controlsTopY + 42; + + screen.renderSearchBox(ctx, x + tableW - 160, controlsY, 152, 16, "Search mods", screen.memorySearch, screen.focusedSearchTable == TaskManagerScreen.TableId.MEMORY); + screen.renderResetButton(ctx, x + tableW - 214, controlsY, 48, 16, screen.hasMemoryFilter()); + screen.renderSortSummary(ctx, left, controlsY + 4, "Sort", screen.formatSort(screen.memorySort, screen.memorySortDescending), TaskManagerScreen.TEXT_DIM); + ctx.drawText(textRenderer, rows.size() + " rows", left + 108, controlsY + 4, TaskManagerScreen.TEXT_DIM, false); + + int barY = controlsY + 24; + int barW = Math.min(320, tableW - (TaskManagerScreen.PADDING * 2)); + ctx.fill(left, barY, left + barW, barY + 10, 0x33FFFFFF); + ctx.fill(left, barY, left + (int) (barW * Math.min(1.0, usedPct / 100.0)), barY + 10, usedPct > 85 ? 0x99FF4444 : usedPct > 70 ? 0x99FFB300 : 0x994CAF50); + + ctx.drawText(textRenderer, String.format(Locale.ROOT, "Heap used %.1f MB / allocated %.1f MB | Non-heap %.1f MB | GC %d (%d ms)", + memory.heapUsedBytes() / (1024.0 * 1024.0), + memory.heapCommittedBytes() / (1024.0 * 1024.0), + memory.nonHeapUsedBytes() / (1024.0 * 1024.0), + memory.gcCount(), + memory.gcTimeMillis()), left, barY + 16, TaskManagerScreen.TEXT_PRIMARY, false); + ctx.drawText(textRenderer, String.format(Locale.ROOT, "Young GC %d | Old/Full GC %d | Last pause %d ms | Last GC %s", + memory.youngGcCount(), + memory.oldGcCount(), + memory.gcPauseDurationMs(), + memory.gcType()), left, barY + 28, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, String.format(Locale.ROOT, "Off-heap direct %.1f MB / %s", + (memory.directBufferBytes() + memory.mappedBufferBytes()) / (1024.0 * 1024.0), + screen.formatBytesMb(memory.directMemoryMaxBytes())), left, barY + 40, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth(String.format(Locale.ROOT, + "Native tracked: direct %.1f MB | mapped %.1f MB | metaspace %.1f MB | code cache %.1f MB", + memory.directBufferBytes() / (1024.0 * 1024.0), + memory.mappedBufferBytes() / (1024.0 * 1024.0), + memory.metaspaceBytes() / (1024.0 * 1024.0), + memory.codeCacheBytes() / (1024.0 * 1024.0)), tableW - 24), left, barY + 52, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth(String.format(Locale.ROOT, + "Buffer pools: direct %d (%s cap) | mapped %d (%s cap)", + memory.directBufferCount(), + screen.formatBytesMb(memory.directBufferCapacityBytes()), + memory.mappedBufferCount(), + screen.formatBytesMb(memory.mappedBufferCapacityBytes())), tableW - 24), left, barY + 64, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth("Allocation pressure: " + screen.summarizeAllocationPressure(), tableW - 24), left, barY + 76, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth(String.format(Locale.ROOT, + "VRAM tracked: used %s | reserved %s | uploads %s/s | texture uploads %s", + screen.formatBytesMb(screen.snapshot.systemMetrics().vramUsedBytes()), + screen.formatBytesMb(screen.snapshot.systemMetrics().vramTotalBytes()), + screen.formatBytesMb(screen.snapshot.systemMetrics().textureUploadRate()), + screen.formatBytesMb(textureUploads.totalBytes())), tableW - 24), left, barY + 88, TaskManagerScreen.TEXT_DIM, false); + String topUploadMods = textureUploads.bytesByMod().entrySet().stream() + .limit(3) + .map(entry -> screen.getDisplayName(entry.getKey()) + " " + screen.formatBytesMb(entry.getValue())) + .reduce((a, b) -> a + " | " + b) + .orElse("none"); + ctx.drawText(textRenderer, textRenderer.trimToWidth("Top texture upload mods: " + topUploadMods, tableW - 24), left, barY + 100, TaskManagerScreen.TEXT_DIM, false); + + if (sharedPanelW > 0) { + screen.renderSharedFamiliesPanel(ctx, x + tableW + panelGap, y + TaskManagerScreen.PADDING, sharedPanelW, h - (TaskManagerScreen.PADDING * 2), sharedFamilies); + } + + if (rows.isEmpty()) { + ctx.drawText(textRenderer, screen.memorySearch.isBlank() ? "Per-mod memory attribution is still warming up. Open Memory Graph for live JVM totals in the meantime." : "No memory rows match the current search/filter.", left, barY + 62, TaskManagerScreen.TEXT_DIM, false); + return; + } + + int headerY = barY + 120; + ctx.fill(x, headerY, x + tableW, headerY + 14, TaskManagerScreen.HEADER_COLOR); + ctx.drawText(textRenderer, "MOD", x + TaskManagerScreen.PADDING + 22, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING + 22, headerY + 1, 44, 14, "Sort by mod display name."); + int classesX = x + tableW - 140; + int mbX = x + tableW - 94; + int pctX = x + tableW - 42; + if (screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "classes")) { ctx.drawText(textRenderer, screen.headerLabel("CLS", screen.memorySort == TaskManagerScreen.MemorySort.CLASS_COUNT, screen.memorySortDescending), classesX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(classesX, headerY + 1, 28, 14, "Distinct live class families attributed to this mod."); } + if (screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "mb")) { ctx.drawText(textRenderer, screen.headerLabel("MB", screen.memorySort == TaskManagerScreen.MemorySort.MEMORY_MB, screen.memorySortDescending), mbX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(mbX, headerY + 1, 22, 14, "Attributed live heap in megabytes."); } + if (screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "pct")) { ctx.drawText(textRenderer, screen.headerLabel("%", screen.memorySort == TaskManagerScreen.MemorySort.PERCENT, screen.memorySortDescending), pctX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(pctX, headerY + 1, 16, 14, screen.memoryEffectiveView ? "Share of effective attributed live heap." : "Share of raw attributed live heap."); } + + long totalAttributedBytes = screen.memoryEffectiveView ? effectiveMemory.totalBytes() : screen.cachedRawMemoryTotalBytes; + int listY = headerY + 16; + int listH = h - (listY - y) - detailH; + ctx.enableScissor(x, listY, x + tableW, listY + listH); + + int rowY = listY - screen.scrollOffset; + int rowIdx = 0; + for (String modId : rows) { + long bytes = memoryMods.getOrDefault(modId, 0L); + if (rowY + TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT > listY && rowY < listY + listH) { + screen.renderStripedRowVariable(ctx, x, tableW, rowY, TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT, rowIdx, mouseX, mouseY); + if (modId.equals(screen.selectedMemoryMod)) { + ctx.fill(x, rowY, x + 3, rowY + TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT, TaskManagerScreen.ACCENT_GREEN); + } + double mb = bytes / (1024.0 * 1024.0); + double pct = bytes * 100.0 / totalAttributedBytes; + int classCount = memoryClassesByMod.getOrDefault(modId, Map.of()).size(); + long rawBytes = rawMemoryMods.getOrDefault(modId, 0L); + long redistributedBytes = effectiveMemory.redistributedBytesByMod().getOrDefault(modId, 0L); + String confidence = AttributionInsights.memoryConfidence(modId, rawBytes, bytes, redistributedBytes, screen.snapshot.memoryAgeMillis()).label(); + String provenance = AttributionInsights.memoryProvenance(rawBytes, redistributedBytes, screen.snapshot.memoryAgeMillis()); + int modRight = screen.firstVisibleMetricX(x + tableW - 8, classesX, screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "classes"), mbX, screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "mb"), pctX, screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "pct")); + int nameX = x + TaskManagerScreen.PADDING + 22; + int chipWidth = screen.confidenceChipWidth(confidence); + int chipX = Math.max(nameX + 48, modRight - chipWidth); + int nameWidth = Math.max(48, chipX - nameX - 6); + + Identifier icon = ModIconCache.getInstance().getIcon(modId); + ctx.drawTexture(RenderPipelines.GUI_TEXTURED, icon, x + TaskManagerScreen.PADDING, rowY + 6, 0f, 0f, 16, 16, 16, 16, 0xFFFFFFFF); + ctx.drawText(textRenderer, textRenderer.trimToWidth(screen.getDisplayName(modId), nameWidth), nameX, rowY + 4, TaskManagerScreen.TEXT_PRIMARY, false); + screen.renderConfidenceChip(ctx, chipX, rowY + 3, confidence); + ctx.drawText(textRenderer, textRenderer.trimToWidth(provenance, Math.max(60, modRight - nameX)), nameX, rowY + 16, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "classes")) ctx.drawText(textRenderer, Integer.toString(classCount), classesX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "mb")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f", mb), mbX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.MEMORY, "pct")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f%%", pct), pctX, rowY + 9, screen.getHeatColor(pct), false); + } + if (rowY > listY + listH) break; + rowY += TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT; + rowIdx++; + } + ctx.disableScissor(); + + if (detailH > 0) { + if (screen.selectedMemoryMod != null) { + screen.renderMemoryDetailPanel(ctx, x, y + h - detailH, tableW, detailH, screen.selectedMemoryMod, rawMemoryMods.getOrDefault(screen.selectedMemoryMod, 0L), effectiveMemory.displayBytes().getOrDefault(screen.selectedMemoryMod, rawMemoryMods.getOrDefault(screen.selectedMemoryMod, 0L)), memoryMods.getOrDefault(screen.selectedMemoryMod, 0L), memoryClassesByMod.getOrDefault(screen.selectedMemoryMod, Map.of()), effectiveMemory.redistributedBytesByMod().getOrDefault(screen.selectedMemoryMod, 0L), totalAttributedBytes, screen.memoryEffectiveView); + } else { + screen.renderSharedFamilyDetail(ctx, x, y + h - detailH, tableW, detailH, sharedFamilyClasses.getOrDefault(screen.selectedSharedFamily, Map.of())); + } + } + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/NetworkTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/NetworkTabRenderer.java new file mode 100644 index 0000000..7ad99dd --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/NetworkTabRenderer.java @@ -0,0 +1,63 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +import java.util.Map; + +final class NetworkTabRenderer { + + private NetworkTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h) { + var textRenderer = screen.uiTextRenderer(); + SystemMetricsProfiler metrics = SystemMetricsProfiler.getInstance(); + int left = x + TaskManagerScreen.PADDING; + int top = screen.getFullPageScrollTop(y); + int graphWidth = screen.getPreferredGraphWidth(w); + int graphX = x + Math.max(TaskManagerScreen.PADDING, (w - graphWidth) / 2); + int columnGap = 20; + int columnWidth = Math.max(120, (w - (TaskManagerScreen.PADDING * 2) - columnGap) / 2); + int rightColumnX = left + columnWidth + columnGap; + screen.beginFullPageScissor(ctx, x, y, w, h); + ctx.drawText(textRenderer, "Network throughput and packet/channel attribution during capture.", left, top, TaskManagerScreen.TEXT_DIM, false); + top += 14; + if (screen.snapshot.systemMetrics().bytesReceivedPerSecond() < 0 && screen.snapshot.systemMetrics().bytesSentPerSecond() < 0) { + ctx.drawText(textRenderer, "Network counters are unavailable right now. Packet attribution can still populate while throughput stays unavailable.", left, top, TaskManagerScreen.ACCENT_YELLOW, false); + top += 14; + } + screen.drawMetricRow(ctx, left, top, w - 16, "Inbound", screen.formatBytesPerSecond(screen.snapshot.systemMetrics().bytesReceivedPerSecond())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 16, "Outbound", screen.formatBytesPerSecond(screen.snapshot.systemMetrics().bytesSentPerSecond())); + top += 20; + int graphHeight = 132; + screen.renderMetricGraph(ctx, graphX - TaskManagerScreen.PADDING, top, graphWidth + (TaskManagerScreen.PADDING * 2), graphHeight, metrics.getOrderedNetworkInHistory(), metrics.getOrderedNetworkOutHistory(), "Network In/Out", "B/s", metrics.getHistorySpanSeconds()); + top += graphHeight + 2; + top += screen.renderGraphLegend(ctx, graphX, top, new String[]{"Inbound", "Outbound"}, new int[]{TaskManagerScreen.INTEL_COLOR, TaskManagerScreen.ACCENT_YELLOW}) + 14; + + java.util.List packetHistory = NetworkPacketProfiler.getInstance().getHistory(); + NetworkPacketProfiler.Snapshot latestPackets = packetHistory.isEmpty() ? null : packetHistory.get(packetHistory.size() - 1); + ctx.drawText(textRenderer, "Inbound categories", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + ctx.drawText(textRenderer, "Outbound categories", rightColumnX, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 14; + int categoryHeight = Math.max( + screen.renderPacketBreakdownColumn(ctx, left, top, columnWidth, latestPackets != null ? latestPackets.inboundByCategory() : Map.of()), + screen.renderPacketBreakdownColumn(ctx, rightColumnX, top, columnWidth, latestPackets != null ? latestPackets.outboundByCategory() : Map.of()) + ); + top += categoryHeight + 12; + + ctx.drawText(textRenderer, "Inbound packet types", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + ctx.drawText(textRenderer, "Outbound packet types", rightColumnX, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 14; + int typeHeight = Math.max( + screen.renderPacketBreakdownColumn(ctx, left, top, columnWidth, latestPackets != null ? latestPackets.inboundByType() : Map.of()), + screen.renderPacketBreakdownColumn(ctx, rightColumnX, top, columnWidth, latestPackets != null ? latestPackets.outboundByType() : Map.of()) + ); + top += typeHeight + 12; + + ctx.drawText(textRenderer, "Packet spike bookmarks", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 14; + top += screen.renderPacketSpikeBookmarks(ctx, left, top, w - 16, NetworkPacketProfiler.getInstance().getSpikeHistory()); + ctx.disableScissor(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/RenderTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/RenderTabRenderer.java new file mode 100644 index 0000000..f5638dc --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/RenderTabRenderer.java @@ -0,0 +1,80 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class RenderTabRenderer { + + private RenderTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + var textRenderer = screen.uiTextRenderer(); + List phases = screen.snapshot.renderPhases().entrySet().stream() + .filter(entry -> screen.matchesGlobalSearch((entry.getKey() + " " + screen.getDisplayName(entry.getValue().ownerMod() == null ? "shared/render" : entry.getValue().ownerMod())).toLowerCase(Locale.ROOT))) + .map(Map.Entry::getKey) + .toList(); + if (phases.isEmpty()) { + ctx.drawText(textRenderer, screen.globalSearch.isBlank() ? "No render data." : "No render phases match the universal search.", x + TaskManagerScreen.PADDING, y + TaskManagerScreen.PADDING + 4, TaskManagerScreen.TEXT_DIM, false); + return; + } + + long totalCpuNanos = screen.snapshot.renderPhases().values().stream().mapToLong(RenderPhaseProfiler.PhaseSnapshot::cpuNanos).sum(); + ctx.drawText(textRenderer, "Owner shows the tagged mod bucket used by GPU attribution. Shared / Render means the phase still falls back to the shared render pool.", x + TaskManagerScreen.PADDING, y + TaskManagerScreen.PADDING, TaskManagerScreen.TEXT_DIM, false); + + int headerY = y + TaskManagerScreen.PADDING + 18; + ctx.fill(x, headerY, x + w, headerY + 14, TaskManagerScreen.HEADER_COLOR); + ctx.drawText(textRenderer, "PHASE", x + TaskManagerScreen.PADDING, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING, headerY + 1, 44, 14, "Render phase name."); + int ownerX = w - 300; + int shareX = w - 175; + int cpuMsX = w - 120; + int gpuMsX = w - 72; + int callsX = w - 34; + ctx.drawText(textRenderer, "OWNER", ownerX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(ownerX, headerY + 1, 42, 14, "Tagged owner mod for this phase. The GPU tab uses this first before redistributing any leftover shared render work."); + ctx.drawText(textRenderer, "%CPU", shareX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(shareX, headerY + 1, 38, 14, "CPU share of this render phase in the current window."); + ctx.drawText(textRenderer, "CPU", cpuMsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(cpuMsX, headerY + 1, 28, 14, "Average CPU milliseconds per call for this phase."); + ctx.drawText(textRenderer, "GPU", gpuMsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(gpuMsX, headerY + 1, 28, 14, "Average GPU milliseconds per call when timer queries are available."); + ctx.drawText(textRenderer, "C", callsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(callsX, headerY + 1, 12, 14, "Approximate call count for this phase in the rolling window."); + + int listY = headerY + 16; + int listH = h - (listY - y); + ctx.enableScissor(x, listY, x + w, listY + listH); + + int rowY = listY - screen.scrollOffset; + int rowIdx = 0; + for (String phase : phases) { + if (rowY + 20 > listY && rowY < listY + listH) { + screen.renderStripedRow(ctx, x, w, rowY, rowIdx, mouseX, mouseY); + RenderPhaseProfiler.PhaseSnapshot phaseSnapshot = screen.snapshot.renderPhases().get(phase); + long phaseCalls = Math.max(phaseSnapshot.cpuCalls(), phaseSnapshot.gpuCalls()); + double pct = totalCpuNanos > 0 ? phaseSnapshot.cpuNanos() * 100.0 / totalCpuNanos : 0; + double avgCpuMs = phaseCalls > 0 ? (phaseSnapshot.cpuNanos() / 1_000_000.0) / phaseCalls : 0; + double avgGpuMs = phaseCalls > 0 ? (phaseSnapshot.gpuNanos() / 1_000_000.0) / phaseCalls : 0; + String owner = phaseSnapshot.ownerMod() == null || phaseSnapshot.ownerMod().isBlank() ? "shared/render" : phaseSnapshot.ownerMod(); + String phaseLabel = textRenderer.trimToWidth(phase, Math.max(120, ownerX - (x + TaskManagerScreen.PADDING) - 8)); + + ctx.drawText(textRenderer, phaseLabel, x + TaskManagerScreen.PADDING, rowY + 6, TaskManagerScreen.TEXT_PRIMARY, false); + ctx.drawText(textRenderer, textRenderer.trimToWidth(screen.getDisplayName(owner), Math.max(70, shareX - ownerX - 6)), ownerX, rowY + 6, screen.isSharedAttributionBucket(owner) ? TaskManagerScreen.TEXT_DIM : TaskManagerScreen.TEXT_PRIMARY, false); + ctx.drawText(textRenderer, String.format("%.1f%%", pct), shareX, rowY + 6, screen.getHeatColor(pct), false); + ctx.drawText(textRenderer, String.format("%.2f", avgCpuMs), cpuMsX, rowY + 6, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, phaseSnapshot.gpuNanos() > 0 ? String.format("%.2f", avgGpuMs) : "-", gpuMsX, rowY + 6, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, screen.formatCount(phaseCalls), callsX, rowY + 6, TaskManagerScreen.TEXT_DIM, false); + } + if (rowY > listY + listH) { + break; + } + rowY += 20; + rowIdx++; + } + ctx.disableScissor(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/SettingsTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/SettingsTabRenderer.java new file mode 100644 index 0000000..07a8818 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/SettingsTabRenderer.java @@ -0,0 +1,179 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; +import wueffi.taskmanager.client.util.ConfigManager; + +final class SettingsTabRenderer { + + private SettingsTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + var textRenderer = screen.uiTextRenderer(); + screen.beginFullPageScissor(ctx, x, y, w, h); + int left = x + TaskManagerScreen.PADDING; + int top = screen.getFullPageScrollTop(y); + ctx.drawText(textRenderer, "Settings", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 18; + ctx.drawText(textRenderer, "Attribution", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + screen.drawTopChip(ctx, left + 104, top - 2, 108, 16, screen.attributionHelpOpen); + ctx.drawText(textRenderer, "Open Guide", left + 132, top + 2, screen.attributionHelpOpen ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + top += 18; + top = screen.renderWrappedText(ctx, left, top, w - 24, "Raw keeps direct ownership separate. Effective folds shared/runtime buckets back into mods for easier ranking. Use Open Guide for the full measured / inferred / estimated explanation.", TaskManagerScreen.TEXT_DIM) + 10; + screen.drawSettingRow(ctx, left, top, w - 24, "Session Logging", ProfilerManager.getInstance().isSessionLogging() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Session Duration", ConfigManager.getSessionDurationSeconds() + "s", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Metrics Update Interval", ConfigManager.getMetricsUpdateIntervalMs() + "ms", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Profiler Update Delay", ConfigManager.getProfilerUpdateDelayMs() + "ms", mouseX, mouseY); + top += 32; + ctx.drawText(textRenderer, "HUD Settings", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 18; + screen.drawSettingRow(ctx, left, top, w - 24, "Enabled", ConfigManager.isHudEnabled() ? "Yes" : "No", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Position", String.valueOf(ConfigManager.getHudPosition()), mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Layout", String.valueOf(ConfigManager.getHudLayoutMode()), mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Trigger Mode", String.valueOf(ConfigManager.getHudTriggerMode()), mouseX, mouseY); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Target FPS", ConfigManager.getFrameBudgetTargetFps() + " FPS", mouseX, mouseY, "Sets the frame-budget target used by the HUD, alerts, and exports when judging whether a frame is over budget."); + top += 22; + screen.drawSliderSetting(ctx, left, top, w - 24, "HUD Transparency", ConfigManager.getHudTransparencyPercent(), mouseX, mouseY); + top += 24; + screen.drawSettingRow(ctx, left, top, w - 24, "HUD Mode", String.valueOf(ConfigManager.getHudConfigMode()), mouseX, mouseY); + top += 22; + if (ConfigManager.getHudConfigMode() == ConfigManager.HudConfigMode.PRESET) { + screen.drawSettingRow(ctx, left, top, w - 24, "Preset", String.valueOf(ConfigManager.getHudPreset()), mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Expand On Warning", ConfigManager.isHudExpandedOnWarning() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Budget Color Mode", ConfigManager.isHudBudgetColorMode() ? "On" : "Off", mouseX, mouseY, "Colors HUD rows by pressure so frame, tick, VRAM, and latency problems are easier to spot."); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Auto-Focus Alert", ConfigManager.isHudAutoFocusAlertRow() ? "On" : "Off", mouseX, mouseY, "Pins the strongest current issue to the top of the HUD instead of making you scan every row."); + top += 20; + ctx.drawText(textRenderer, textRenderer.trimToWidth("Preset mode drives the HUD contents for you. Switch HUD Mode to CUSTOM to pick individual sections.", w - 24), left, top, TaskManagerScreen.TEXT_DIM, false); + top += 34; + } else { + screen.drawSettingRow(ctx, left, top, w - 24, "FPS", ConfigManager.isHudShowFps() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Frame Stats", ConfigManager.isHudShowFrame() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Tick Stats", ConfigManager.isHudShowTicks() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Utilization", ConfigManager.isHudShowUtilization() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Temperatures", ConfigManager.isHudShowTemperatures() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Parallelism", ConfigManager.isHudShowParallelism() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Logic", ConfigManager.isHudShowLogic() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Background", ConfigManager.isHudShowBackground() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Frame Budget", ConfigManager.isHudShowFrameBudget() ? "On" : "Off", mouseX, mouseY, "Shows the latest frame time against your configured target FPS budget and how far over or under budget it is."); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Memory", ConfigManager.isHudShowMemory() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "VRAM", ConfigManager.isHudShowVram() ? "On" : "Off", mouseX, mouseY, "Shows GPU memory use and paging pressure when the driver exposes VRAM counters."); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Network", ConfigManager.isHudShowNetwork() ? "On" : "Off", mouseX, mouseY, "Shows inbound and outbound throughput so server and packet spikes are easier to correlate."); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Chunk Activity", ConfigManager.isHudShowChunkActivity() ? "On" : "Off", mouseX, mouseY, "Tracks chunk generation, meshing, and upload pressure in the HUD."); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "World", ConfigManager.isHudShowWorld() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Disk I/O", ConfigManager.isHudShowDiskIo() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Input Latency", ConfigManager.isHudShowInputLatency() ? "On" : "Off", mouseX, mouseY, "Shows the latest presented input latency beside the rest of the live performance metrics."); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Session Status", ConfigManager.isHudShowSession() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Expand On Warning", ConfigManager.isHudExpandedOnWarning() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Budget Color Mode", ConfigManager.isHudBudgetColorMode() ? "On" : "Off", mouseX, mouseY, "Colors HUD rows by pressure so frame, tick, VRAM, and latency problems are easier to spot."); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Auto-Focus Alert", ConfigManager.isHudAutoFocusAlertRow() ? "On" : "Off", mouseX, mouseY, "Pins the strongest current issue to the top of the HUD instead of making you scan every row."); + top += 20; + } + ctx.drawText(textRenderer, "HUD Rate Of Change", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 18; + screen.drawSettingRow(ctx, left, top, w - 24, "Display Zero Rates", ConfigManager.isHudShowZeroRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "FPS Rates", ConfigManager.isHudShowFpsRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Frame Rates", ConfigManager.isHudShowFrameRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Tick Rates", ConfigManager.isHudShowTickRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Utilization Rates", ConfigManager.isHudShowUtilizationRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Memory Rates", ConfigManager.isHudShowMemoryAllocationRate() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "VRAM Rates", ConfigManager.isHudShowVramRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Network Rates", ConfigManager.isHudShowNetworkRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Chunk Activity Rates", ConfigManager.isHudShowChunkActivityRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "World Rates", ConfigManager.isHudShowWorldRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Disk I/O Rates", ConfigManager.isHudShowDiskIoRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Input Latency Rates", ConfigManager.isHudShowInputLatencyRateOfChange() ? "On" : "Off", mouseX, mouseY); + top += 32; + ctx.drawText(textRenderer, "In-Game Alerts", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 18; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Enabled", ConfigManager.isPerformanceAlertsEnabled() ? "On" : "Off", mouseX, mouseY, "Turns lightweight performance warnings on while you play, even if the full HUD is hidden."); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Chat Messages", ConfigManager.isPerformanceAlertChatEnabled() ? "On" : "Off", mouseX, mouseY, "Also posts a short chat line when a sustained frame-time or MSPT breach is detected."); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Frame Threshold", ConfigManager.getPerformanceAlertFrameThresholdMs() + " ms", mouseX, mouseY, "If frame time stays above this threshold for the configured number of consecutive ticks, the overlay banner and optional chat alert fire."); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Server MSPT Threshold", ConfigManager.getPerformanceAlertServerThresholdMs() + " ms", mouseX, mouseY, "If integrated-server MSPT stays above this threshold for the configured number of consecutive ticks, a live alert is raised."); + top += 22; + screen.drawSettingRowWithTooltip(ctx, left, top, w - 24, "Consecutive Ticks", Integer.toString(ConfigManager.getPerformanceAlertConsecutiveTicks()), mouseX, mouseY, "Requires sustained pressure for this many consecutive ticks before alerting so transient one-off spikes do not spam you."); + top += 32; + ctx.drawText(textRenderer, "Table Columns", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 18; + screen.drawSettingRow(ctx, left, top, w - 24, "Tasks: %CPU", ConfigManager.isTasksColumnVisible("cpu") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Tasks: Threads", ConfigManager.isTasksColumnVisible("threads") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Tasks: Samples", ConfigManager.isTasksColumnVisible("samples") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Tasks: Invokes", ConfigManager.isTasksColumnVisible("invokes") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "GPU: %GPU", ConfigManager.isGpuColumnVisible("pct") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "GPU: Threads", ConfigManager.isGpuColumnVisible("threads") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "GPU: Est ms", ConfigManager.isGpuColumnVisible("gpums") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "GPU: R.S", ConfigManager.isGpuColumnVisible("rsamples") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Memory: CLS", ConfigManager.isMemoryColumnVisible("classes") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Memory: MB", ConfigManager.isMemoryColumnVisible("mb") ? "On" : "Off", mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Memory: %", ConfigManager.isMemoryColumnVisible("pct") ? "On" : "Off", mouseX, mouseY); + top += 32; + ctx.drawText(textRenderer, "Graph Colours", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 18; + screen.drawSettingRow(ctx, left, top, w - 24, "CPU Colour", screen.focusedColorSetting == TaskManagerScreen.ColorSetting.CPU ? screen.colorEditValue + "_" : screen.getColorSettingHex(TaskManagerScreen.ColorSetting.CPU), mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "GPU Colour", screen.focusedColorSetting == TaskManagerScreen.ColorSetting.GPU ? screen.colorEditValue + "_" : screen.getColorSettingHex(TaskManagerScreen.ColorSetting.GPU), mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "World Entities Colour", screen.focusedColorSetting == TaskManagerScreen.ColorSetting.WORLD_ENTITIES ? screen.colorEditValue + "_" : screen.getColorSettingHex(TaskManagerScreen.ColorSetting.WORLD_ENTITIES), mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Loaded Chunks Colour", screen.focusedColorSetting == TaskManagerScreen.ColorSetting.WORLD_CHUNKS_LOADED ? screen.colorEditValue + "_" : screen.getColorSettingHex(TaskManagerScreen.ColorSetting.WORLD_CHUNKS_LOADED), mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Rendered Chunks Colour", screen.focusedColorSetting == TaskManagerScreen.ColorSetting.WORLD_CHUNKS_RENDERED ? screen.colorEditValue + "_" : screen.getColorSettingHex(TaskManagerScreen.ColorSetting.WORLD_CHUNKS_RENDERED), mouseX, mouseY); + top += 22; + screen.drawSettingRow(ctx, left, top, w - 24, "Reset Graph Colours", "Defaults", mouseX, mouseY); + top += 20; + ctx.drawText(textRenderer, textRenderer.trimToWidth("Click a colour row, type a hex value like #5EA9FF, then press Enter to save.", w - 24), left, top, TaskManagerScreen.TEXT_DIM, false); + ctx.disableScissor(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/StartupTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/StartupTabRenderer.java new file mode 100644 index 0000000..9d3fa84 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/StartupTabRenderer.java @@ -0,0 +1,104 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import wueffi.taskmanager.client.util.ModIconCache; + +import java.util.List; +import java.util.Locale; + +final class StartupTabRenderer { + + private StartupTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + var textRenderer = screen.uiTextRenderer(); + screen.beginFullPageScissor(ctx, x, y, w, h); + int left = x + TaskManagerScreen.PADDING; + int top = screen.getFullPageScrollTop(y); + boolean measuredEntrypoints = screen.snapshot.startupRows().stream().anyMatch(StartupTimingProfiler.StartupRow::measuredEntrypoints); + String startupIntro = measuredEntrypoints + ? "Measured Fabric startup activity by mod in explicit wall-clock milliseconds. Search, sort, and compare entrypoint timing here." + : "Observed startup registration timing by mod in explicit wall-clock milliseconds. Search and sort rows to isolate slow paths."; + top = screen.renderSectionHeader(ctx, left, top, "Startup", startupIntro); + + List rows = screen.getStartupRows(); + long totalSpan = Math.max(screen.snapshot.startupLast() - screen.snapshot.startupFirst(), 1); + int searchY = top; + screen.renderSearchBox(ctx, x + w - 160, searchY, 152, 16, "Search mods", screen.startupSearch, screen.startupSearchFocused); + screen.renderResetButton(ctx, x + w - 214, searchY, 48, 16, screen.hasStartupFilter()); + int sortY = searchY + 20; + screen.renderSortSummary(ctx, left, sortY + 4, "Sort", screen.formatSort(screen.startupSort, screen.startupSortDescending), TaskManagerScreen.TEXT_DIM); + ctx.drawText(textRenderer, rows.size() + " mods", left + 132, sortY + 4, TaskManagerScreen.TEXT_DIM, false); + + int headerY = sortY + 20; + ctx.fill(x, headerY, x + w, headerY + 14, TaskManagerScreen.HEADER_COLOR); + int regsX = x + w - 34; + int epX = regsX - 30; + int activeMsX = epX - 62; + int endMsX = activeMsX - 56; + int startMsX = endMsX - 56; + int barW = Math.max(110, Math.min(180, w / 8)); + int barX = startMsX - barW - 22; + int nameW = Math.max(150, barX - (left + 32)); + ctx.drawText(textRenderer, screen.headerLabel("MOD", screen.startupSort == TaskManagerScreen.StartupSort.NAME, screen.startupSortDescending), left + 22, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(left + 22, headerY + 1, 44, 14, "Sort by mod display name."); + ctx.drawText(textRenderer, "TIMELINE", barX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(barX, headerY + 1, 64, 14, "Observed startup span across the global startup window."); + ctx.drawText(textRenderer, screen.headerLabel("START", screen.startupSort == TaskManagerScreen.StartupSort.START, screen.startupSortDescending), startMsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(startMsX, headerY + 1, 42, 14, "Milliseconds from startup begin until this mod first became active."); + ctx.drawText(textRenderer, screen.headerLabel("END", screen.startupSort == TaskManagerScreen.StartupSort.END, screen.startupSortDescending), endMsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(endMsX, headerY + 1, 34, 14, "Milliseconds from startup begin until this mod last appeared active."); + ctx.drawText(textRenderer, screen.headerLabel("ACTIVE", screen.startupSort == TaskManagerScreen.StartupSort.ACTIVE, screen.startupSortDescending), activeMsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(activeMsX, headerY + 1, 48, 14, "Measured active wall-clock milliseconds attributed to this mod."); + ctx.drawText(textRenderer, screen.headerLabel("EP", screen.startupSort == TaskManagerScreen.StartupSort.ENTRYPOINTS, screen.startupSortDescending), epX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(epX, headerY + 1, 18, 14, "Entrypoint count observed for this mod."); + ctx.drawText(textRenderer, screen.headerLabel("REG", screen.startupSort == TaskManagerScreen.StartupSort.REGISTRATIONS, screen.startupSortDescending), regsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(regsX, headerY + 1, 24, 14, "Registration events observed during startup fallback timing."); + + int listY = headerY + 16; + int listH = h - (listY - y) - 16; + if (rows.isEmpty()) { + ctx.drawText(textRenderer, screen.startupSearch.isBlank() ? "No startup data captured yet." : "No startup rows match the current search/filter.", left, listY + 6, TaskManagerScreen.TEXT_DIM, false); + } else { + ctx.enableScissor(x, listY, x + w, listY + listH); + int rowY = listY - screen.scrollOffset; + int rowIdx = 0; + for (StartupTimingProfiler.StartupRow row : rows) { + if (rowY + 28 > listY && rowY < listY + listH) { + screen.renderStripedRowVariable(ctx, x, w, rowY, 28, rowIdx, mouseX, mouseY); + Identifier icon = ModIconCache.getInstance().getIcon(row.modId()); + ctx.drawTexture(RenderPipelines.GUI_TEXTURED, icon, left, rowY + 5, 0f, 0f, 16, 16, 16, 16, 0xFFFFFFFF); + ctx.drawText(textRenderer, textRenderer.trimToWidth(screen.getDisplayName(row.modId()), nameW), left + 22, rowY + 3, TaskManagerScreen.TEXT_PRIMARY, false); + String startupMeta = row.measuredEntrypoints() ? row.stageSummary() : "fallback registration timing"; + String startupHint = row.definitionSummary().isBlank() ? startupMeta : (startupMeta + " | " + row.definitionSummary()); + ctx.drawText(textRenderer, textRenderer.trimToWidth(startupHint, nameW), left + 22, rowY + 14, TaskManagerScreen.TEXT_DIM, false); + + int barStart = (int) ((row.first() - screen.snapshot.startupFirst()) * barW / totalSpan); + int barLen = Math.max(1, (int) ((row.last() - row.first()) * barW / totalSpan)); + ctx.fill(barX, rowY + 11, barX + barW, rowY + 16, 0x33FFFFFF); + ctx.fill(barX + barStart, rowY + 10, Math.min(barX + barW, barX + barStart + barLen), rowY + 17, TaskManagerScreen.ACCENT_YELLOW); + + double startMs = (row.first() - screen.snapshot.startupFirst()) / 1_000_000.0; + double endMs = (row.last() - screen.snapshot.startupFirst()) / 1_000_000.0; + double activeMs = row.activeNanos() / 1_000_000.0; + ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f", startMs), startMsX, rowY + 8, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f", endMs), endMsX, rowY + 8, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f", activeMs), activeMsX, rowY + 8, TaskManagerScreen.ACCENT_YELLOW, false); + ctx.drawText(textRenderer, String.valueOf(row.entrypoints()), epX, rowY + 8, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(textRenderer, String.valueOf(row.registrations()), regsX, rowY + 8, TaskManagerScreen.TEXT_DIM, false); + } + if (rowY > listY + listH) break; + rowY += 28; + rowIdx++; + } + ctx.disableScissor(); + } + + ctx.fill(x, y + h - 14, x + w, y + h, TaskManagerScreen.HEADER_COLOR); + ctx.drawText(textRenderer, String.format(Locale.ROOT, "Startup span %.1f ms | %d mods | %s", totalSpan / 1_000_000.0, screen.snapshot.startupRows().size(), measuredEntrypoints ? "measured entrypoints" : "fallback registration path"), left, y + h - 10, TaskManagerScreen.TEXT_DIM, false); + ctx.disableScissor(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/SystemTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/SystemTabRenderer.java new file mode 100644 index 0000000..7acfba3 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/SystemTabRenderer.java @@ -0,0 +1,189 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +import java.util.Locale; + +final class SystemTabRenderer { + + private SystemTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h) { + int left = x + TaskManagerScreen.PADDING; + int top = screen.getFullPageScrollTop(y); + screen.beginFullPageScissor(ctx, x, y, w, h); + ProfilerManager.ProfilerSnapshot snapshot = screen.currentSnapshot(); + SystemMetricsProfiler.Snapshot system = snapshot.systemMetrics(); + SystemMetricsProfiler metrics = SystemMetricsProfiler.getInstance(); + + top = screen.renderSectionHeader(ctx, left, top, "System", "Runtime health, sensors, and CPU/GPU load history."); + screen.drawTopChip(ctx, left, top, 78, 16, screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.OVERVIEW); + screen.drawTopChip(ctx, left + 84, top, 88, 16, screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.CPU_GRAPH); + screen.drawTopChip(ctx, left + 178, top, 88, 16, screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.GPU_GRAPH); + screen.drawTopChip(ctx, left + 272, top, 108, 16, screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.MEMORY_GRAPH); + ctx.drawText(screen.uiTextRenderer(), "Overview", left + 14, top + 4, + screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.OVERVIEW ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), "CPU Graph", left + 100, top + 4, + screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.CPU_GRAPH ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), "GPU Graph", left + 194, top + 4, + screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.GPU_GRAPH ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), "Memory Graph", left + 286, top + 4, + screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.MEMORY_GRAPH ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + top += 24; + + if (screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.CPU_GRAPH) { + int graphWidth = screen.getPreferredGraphWidth(w); + int graphLeft = x + Math.max(TaskManagerScreen.PADDING, (w - graphWidth) / 2); + screen.renderGraphMetricTabs(ctx, graphLeft, top, graphWidth, screen.currentCpuGraphMetricTab()); + top += 24; + if (screen.currentCpuGraphMetricTab() == TaskManagerScreen.GraphMetricTab.LOAD) { + screen.renderFixedScaleSeriesGraph(ctx, graphLeft, top, graphWidth, 146, metrics.getOrderedCpuLoadHistory(), "CPU Load", "%", screen.getCpuGraphColor(), 100.0, metrics.getHistorySpanSeconds()); + top += 164; + top += screen.renderGraphLegend(ctx, graphLeft, top, new String[]{"CPU Load"}, new int[]{screen.getCpuGraphColor()}) + 8; + } else { + screen.renderSensorSeriesGraph(ctx, graphLeft, top, graphWidth, 146, metrics.getOrderedCpuTemperatureHistory(), "CPU Temperature", "C", screen.getCpuGraphColor(), 110.0, metrics.getHistorySpanSeconds(), system.cpuTemperatureC() >= 0.0, screen.uiTextRenderer().trimToWidth(system.cpuTemperatureUnavailableReason(), Math.max(80, graphWidth - 12))); + top += 164; + top += screen.renderGraphLegend(ctx, graphLeft, top, new String[]{"CPU Temperature"}, new int[]{screen.getCpuGraphColor()}) + 8; + } + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "Current CPU Load", screen.formatPercentWithTrend(system.cpuCoreLoadPercent(), system.cpuLoadChangePerSecond())); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "CPU Temperature", screen.formatTemperatureWithTrend(system.cpuTemperatureC(), system.cpuTemperatureChangePerSecond())); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "CPU Info", screen.formatCpuInfo()); + ctx.disableScissor(); + return; + } + + if (screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.GPU_GRAPH) { + int graphWidth = screen.getPreferredGraphWidth(w); + int graphLeft = x + Math.max(TaskManagerScreen.PADDING, (w - graphWidth) / 2); + screen.renderGraphMetricTabs(ctx, graphLeft, top, graphWidth, screen.currentGpuGraphMetricTab()); + top += 24; + if (screen.currentGpuGraphMetricTab() == TaskManagerScreen.GraphMetricTab.LOAD) { + screen.renderFixedScaleSeriesGraph(ctx, graphLeft, top, graphWidth, 146, metrics.getOrderedGpuLoadHistory(), "GPU Load", "%", screen.getGpuGraphColor(), 100.0, metrics.getHistorySpanSeconds()); + top += 164; + top += screen.renderGraphLegend(ctx, graphLeft, top, new String[]{"GPU Load"}, new int[]{screen.getGpuGraphColor()}) + 8; + } else { + screen.renderSensorSeriesGraph(ctx, graphLeft, top, graphWidth, 146, metrics.getOrderedGpuTemperatureHistory(), "GPU Core Temperature", "C", screen.getGpuGraphColor(), 110.0, metrics.getHistorySpanSeconds(), system.gpuTemperatureC() >= 0.0, "GPU core temperature is unavailable"); + top += 164; + top += screen.renderGraphLegend(ctx, graphLeft, top, new String[]{"GPU Core Temperature"}, new int[]{screen.getGpuGraphColor()}) + 8; + } + double vramMaxMb = Math.max(1.0, system.vramTotalBytes() > 0L ? system.vramTotalBytes() / (1024.0 * 1024.0) : 1.0); + screen.renderFixedScaleSeriesGraph(ctx, graphLeft, top, graphWidth, 118, metrics.getOrderedVramUsedHistory(), "VRAM Usage", "MB", screen.getGpuGraphColor(), vramMaxMb, metrics.getHistorySpanSeconds()); + top += 132; + top += screen.renderGraphLegend(ctx, graphLeft, top, new String[]{"VRAM Used"}, new int[]{screen.getGpuGraphColor()}) + 8; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "Current GPU Load", screen.formatPercentWithTrend(system.gpuCoreLoadPercent(), system.gpuLoadChangePerSecond())); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "GPU Core Temperature", TelemetryTextFormatter.formatGpuTemperatureWithTrend(system)); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "GPU Core / Hot Spot", TelemetryTextFormatter.formatGpuTemperatureSummary(system)); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "GPU Info", screen.blankToUnknown(system.gpuVendor()) + " | " + screen.blankToUnknown(system.gpuRenderer())); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "VRAM Usage", screen.formatBytesMb(system.vramUsedBytes()) + " / " + screen.formatBytesMb(system.vramTotalBytes())); + ctx.disableScissor(); + return; + } + + if (screen.currentSystemMiniTab() == TaskManagerScreen.SystemMiniTab.MEMORY_GRAPH) { + int graphWidth = screen.getPreferredGraphWidth(w); + int graphLeft = x + Math.max(TaskManagerScreen.PADDING, (w - graphWidth) / 2); + long heapMaxBytes = snapshot.memory().heapMaxBytes() > 0 ? snapshot.memory().heapMaxBytes() : Runtime.getRuntime().maxMemory(); + double heapMaxMb = Math.max(1.0, heapMaxBytes / (1024.0 * 1024.0)); + screen.renderFixedScaleSeriesGraph(ctx, graphLeft, top, graphWidth, 146, + metrics.getOrderedMemoryUsedHistory(), + metrics.getOrderedMemoryCommittedHistory(), + "Memory Load", "MB", screen.getMemoryGraphColor(), 0x6688B5FF, heapMaxMb, + metrics.getHistorySpanSeconds()); + top += 164; + top += screen.renderGraphLegend(ctx, graphLeft, top, new String[]{"Heap Used", "Heap Allocated"}, new int[]{screen.getMemoryGraphColor(), 0x6688B5FF}) + 8; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "Heap Used", screen.formatBytesMb(snapshot.memory().heapUsedBytes())); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "Heap Allocated", screen.formatBytesMb(snapshot.memory().heapCommittedBytes())); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "Heap Max", screen.formatBytesMb(heapMaxBytes)); + top += 16; + screen.drawMetricRow(ctx, graphLeft, top, graphWidth, "Non-Heap", screen.formatBytesMb(snapshot.memory().nonHeapUsedBytes())); + ctx.disableScissor(); + return; + } + + screen.drawMetricRow(ctx, left, top, w - 32, "CPU Info", screen.formatCpuInfo()); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "GPU Info", screen.blankToUnknown(system.gpuVendor()) + " | " + screen.blankToUnknown(system.gpuRenderer())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "VRAM Usage", screen.formatBytesMb(system.vramUsedBytes()) + " / " + screen.formatBytesMb(system.vramTotalBytes())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "VRAM Paging", system.vramPagingActive() ? screen.formatBytesMb(system.vramPagingBytes()) : "none detected"); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Committed Virtual Memory", screen.formatBytesMb(system.committedVirtualMemoryBytes())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Off-Heap Direct", screen.formatBytesMb(system.directMemoryUsedBytes()) + " / " + screen.formatBytesMb(system.directMemoryMaxBytes())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "CPU", screen.formatCpuGpuSummary(system, true)); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "GPU", screen.formatCpuGpuSummary(system, false)); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "CPU Temperature", screen.formatTemperature(system.cpuTemperatureC())); + top += 16; + if (system.cpuTemperatureC() < 0) { + ctx.drawText(screen.uiTextRenderer(), screen.uiTextRenderer().trimToWidth("Why CPU temp is unavailable: " + screen.blankToUnknown(system.cpuTemperatureUnavailableReason()), w - 24), left + 6, top, TaskManagerScreen.ACCENT_YELLOW, false); + top += 14; + } + screen.drawMetricRow(ctx, left, top, w - 32, "GPU Core Temperature", TelemetryTextFormatter.formatGpuTemperatureWithTrend(system)); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "GPU Core / Hot Spot", TelemetryTextFormatter.formatGpuTemperatureSummary(system)); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Main Logic", screen.blankToUnknown(system.mainLogicSummary())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Background", screen.blankToUnknown(system.backgroundSummary())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "CPU Parallelism", screen.blankToUnknown(system.cpuParallelismFlag())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Parallelism Efficiency [inferred]", screen.blankToUnknown(system.parallelismEfficiency())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Thread Load", String.format(Locale.ROOT, "%.1f%% total", system.totalThreadLoadPercent())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "High-Load Threads [inferred]", system.activeHighLoadThreads() + " >50% | est physical cores " + system.estimatedPhysicalCores()); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Server Wait-Time", system.serverThreadWaitMs() + " ms waited | " + system.serverThreadBlockedMs() + " ms blocked"); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Worker Ratio", system.activeWorkers() + " active / " + system.idleWorkers() + " idle (" + String.format(Locale.ROOT, "%.2f", system.activeToIdleWorkerRatio()) + ")"); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "CPU Sensor Status", screen.blankToUnknown(system.cpuSensorStatus())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Off-Heap Allocation Rate", screen.formatBytesPerSecond(system.offHeapAllocationRateBytesPerSecond())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Current Biome", screen.prettifyKey(screen.blankToUnknown(system.currentBiome()))); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Light Update Queue [best-effort]", screen.blankToUnknown(system.lightUpdateQueue())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Max Entities In Hot Chunk", String.valueOf(system.maxEntitiesInHotChunk())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Packet Latency", system.packetProcessingLatencyMs() < 0 ? "unavailable" : String.format(Locale.ROOT, "%.1f ms [estimated]", system.packetProcessingLatencyMs())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Packet Buffer Pressure", screen.blankToUnknown(system.networkBufferSaturation())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Graphics Stack", screen.blankToUnknown(ProfilerManager.getInstance().getGraphicsPipelineSummary())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Collector Governor", screen.blankToUnknown(system.collectorGovernorMode())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "GPU Coverage", screen.blankToUnknown(system.gpuCoverageSummary())); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Resource Packs / Texture Uploads", screen.summarizeResourcePackAndTextureState(system)); + top += 16; + screen.drawMetricRow(ctx, left, top, w - 32, "Alloc Pressure", screen.summarizeAllocationPressure()); + top += 22; + screen.renderSensorsPanel(ctx, left, top, w - 24, system); + top += 142; + screen.renderProfilerSelfCostPanel(ctx, left, top, w - 24, system); + top += 84; + top = screen.renderStringListSection(ctx, left, top, w - 24, "Thread Drilldown", screen.buildThreadDrilldownLines(system)) + 10; + top = screen.renderStringListSection(ctx, left, top, w - 24, "Shader Compile Stutter [measured CPU]", screen.buildShaderCompileLines()) + 10; + top = screen.renderStringListSection(ctx, left, top, w - 24, "JVM Tuning Advisor", ProfilerManager.getInstance().getJvmTuningAdvisor()) + 10; + top = screen.renderStringListSection(ctx, left, top, w - 24, "Chunk Pipeline Drill-Down", screen.buildChunkPipelineDrilldownLines()) + 10; + ctx.drawText(screen.uiTextRenderer(), screen.uiTextRenderer().trimToWidth("Export sessions keep the current runtime summary, findings, hotspots, and HTML report for offline inspection.", w - 24), left, top, TaskManagerScreen.TEXT_DIM, false); + ctx.disableScissor(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/TabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/TabRenderer.java new file mode 100644 index 0000000..e82b4ab --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/TabRenderer.java @@ -0,0 +1,25 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +@FunctionalInterface +public interface TabRenderer { + + void render(DrawContext ctx, TaskManagerScreen screen, int x, int y, int w, int h, int mouseX, int mouseY); + + default boolean mouseClicked(TaskManagerScreen screen, double mouseX, double mouseY, int button) { + return false; + } + + default boolean mouseScrolled(TaskManagerScreen screen, double mouseX, double mouseY, double amount) { + return false; + } + + default boolean charTyped(TaskManagerScreen screen, char chr, int modifiers) { + return false; + } + + default boolean keyPressed(TaskManagerScreen screen, int keyCode, int scanCode, int modifiers) { + return false; + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/TasksTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/TasksTabRenderer.java new file mode 100644 index 0000000..c5b3beb --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/TasksTabRenderer.java @@ -0,0 +1,114 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import wueffi.taskmanager.client.util.ModIconCache; +import wueffi.taskmanager.client.util.ModTimingSnapshot; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class TasksTabRenderer { + + private TasksTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + var textRenderer = screen.uiTextRenderer(); + Map cpu = screen.snapshot.cpuMods(); + Map cpuDetails = screen.snapshot.cpuDetails(); + Map invokes = screen.snapshot.modInvokes(); + AttributionModelBuilder.EffectiveCpuAttribution effectiveCpu = screen.effectiveCpuAttribution(); + Map displayCpu = screen.taskEffectiveView ? effectiveCpu.displaySnapshots() : cpu; + List rows = screen.getTaskRows(displayCpu, cpuDetails, invokes, !screen.taskEffectiveView && screen.taskShowSharedRows); + + int detailW = Math.min(420, Math.max(320, w / 3)); + int gap = TaskManagerScreen.PADDING; + int listW = w - detailW - gap; + int infoY = y + TaskManagerScreen.PADDING; + int descriptionBottomY = screen.renderWrappedText(ctx, x + TaskManagerScreen.PADDING, infoY, Math.max(260, listW - 16), screen.taskEffectiveView ? "Effective CPU share by mod from rolling sampled stack windows. Shared/framework work is folded into concrete mods for comparison." : "Raw CPU ownership by mod from rolling sampled stack windows. Shared/framework buckets stay separate until you switch back to Effective view.", TaskManagerScreen.TEXT_DIM); + ctx.drawText(textRenderer, screen.cpuStatusText(screen.snapshot.cpuReady(), screen.snapshot.totalCpuSamples(), screen.snapshot.cpuSampleAgeMillis()), x + TaskManagerScreen.PADDING, descriptionBottomY + 2, screen.getCpuStatusColor(screen.snapshot.cpuReady()), false); + ctx.drawText(textRenderer, "Tip: Effective view proportionally folds shared/runtime work into mod rows for readability.", x + TaskManagerScreen.PADDING, descriptionBottomY + 12, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING, descriptionBottomY + 12, 420, 10, "Effective view proportionally folds shared/runtime work into concrete mods. Raw view keeps true-owned and shared buckets separate."); + int controlsY = descriptionBottomY + 26; + screen.drawTopChip(ctx, x + TaskManagerScreen.PADDING, controlsY, 78, 16, false); + ctx.drawText(textRenderer, "CPU Graph", x + TaskManagerScreen.PADDING + 18, controlsY + 4, TaskManagerScreen.TEXT_DIM, false); + screen.drawTopChip(ctx, x + TaskManagerScreen.PADDING + 84, controlsY, 98, 16, screen.taskEffectiveView); + ctx.drawText(textRenderer, screen.taskEffectiveView ? "Effective" : "Raw", x + TaskManagerScreen.PADDING + 110, controlsY + 4, screen.taskEffectiveView ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING + 84, controlsY, 98, 16, "Toggle between raw ownership and effective ownership with redistributed shared/framework samples."); + screen.drawTopChip(ctx, x + TaskManagerScreen.PADDING + 188, controlsY, 112, 16, !screen.taskEffectiveView && screen.taskShowSharedRows); + ctx.drawText(textRenderer, screen.taskShowSharedRows ? "Shared Rows" : "Hide Shared", x + TaskManagerScreen.PADDING + 204, controlsY + 4, (!screen.taskEffectiveView && screen.taskShowSharedRows) ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING + 188, controlsY, 112, 16, "In Raw view, show or hide shared/jvm, shared/framework, and runtime rows. Effective view already folds them into mod rows."); + screen.renderSearchBox(ctx, x + listW - 160, controlsY, 152, 16, "Search mods", screen.tasksSearch, screen.focusedSearchTable == TaskManagerScreen.TableId.TASKS); + screen.renderResetButton(ctx, x + listW - 214, controlsY, 48, 16, screen.hasTaskFilter()); + screen.renderSortSummary(ctx, x + TaskManagerScreen.PADDING, controlsY + 22, "Sort", screen.formatSort(screen.taskSort, screen.taskSortDescending), TaskManagerScreen.TEXT_DIM); + ctx.drawText(textRenderer, rows.size() + " rows", x + TaskManagerScreen.PADDING + 108, controlsY + 22, TaskManagerScreen.TEXT_DIM, false); + + if (!rows.isEmpty() && (screen.selectedTaskMod == null || !rows.contains(screen.selectedTaskMod))) { + screen.selectedTaskMod = rows.getFirst(); + } + + int headerY = controlsY + 42; + ctx.fill(x, headerY, x + listW, headerY + 14, TaskManagerScreen.HEADER_COLOR); + ctx.drawText(textRenderer, screen.headerLabel("MOD", screen.taskSort == TaskManagerScreen.TaskSort.NAME, screen.taskSortDescending), x + TaskManagerScreen.PADDING + 16 + 6, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + screen.addTooltip(x + TaskManagerScreen.PADDING + 16 + 6, headerY + 1, 44, 14, "Sort by mod display name."); + int pctX = x + listW - 206; + int threadsX = x + listW - 146; + int samplesX = x + listW - 92; + int invokesX = x + listW - 42; + if (screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "cpu")) { ctx.drawText(textRenderer, screen.headerLabel("%CPU", screen.taskSort == TaskManagerScreen.TaskSort.CPU, screen.taskSortDescending), pctX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(pctX, headerY + 1, 42, 14, "Sampled CPU share from rolling stack windows."); } + if (screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "threads")) { ctx.drawText(textRenderer, screen.headerLabel("THREADS", screen.taskSort == TaskManagerScreen.TaskSort.THREADS, screen.taskSortDescending), threadsX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(threadsX, headerY + 1, 58, 14, "Distinct sampled threads attributed to this mod."); } + if (screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "samples")) { ctx.drawText(textRenderer, screen.headerLabel("SAMPLES", screen.taskSort == TaskManagerScreen.TaskSort.SAMPLES, screen.taskSortDescending), samplesX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(samplesX, headerY + 1, 56, 14, "Total CPU samples attributed in the rolling window."); } + if (screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "invokes")) { ctx.drawText(textRenderer, screen.headerLabel("INVOKES", screen.taskSort == TaskManagerScreen.TaskSort.INVOKES, screen.taskSortDescending), invokesX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); screen.addTooltip(invokesX, headerY + 1, 54, 14, "Tracked event invokes, shown separately from sampled CPU ownership."); } + + int listY = headerY + 16; + int listH = h - (listY - y); + if (rows.isEmpty()) { + ctx.drawText(textRenderer, screen.tasksSearch.isBlank() ? "Waiting for CPU samples..." : "No task rows match the current search/filter.", x + TaskManagerScreen.PADDING, listY + 6, TaskManagerScreen.TEXT_DIM, false); + } else { + ctx.enableScissor(x, listY, x + listW, listY + listH); + int rowY = listY - screen.scrollOffset; + int rowIdx = 0; + for (String modId : rows) { + if (rowY + TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT > listY && rowY < listY + listH) { + screen.renderStripedRowVariable(ctx, x, listW, rowY, TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT, rowIdx, mouseX, mouseY); + if (modId.equals(screen.selectedTaskMod)) { + ctx.fill(x, rowY, x + 3, rowY + TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT, TaskManagerScreen.ACCENT_GREEN); + } + Identifier icon = ModIconCache.getInstance().getIcon(modId); + ctx.drawTexture(RenderPipelines.GUI_TEXTURED, icon, x + TaskManagerScreen.PADDING, rowY + 6, 0f, 0f, 16, 16, 16, 16, 0xFFFFFFFF); + + CpuSamplingProfiler.Snapshot cpuSnapshot = displayCpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)); + CpuSamplingProfiler.DetailSnapshot detailSnapshot = cpuDetails.get(modId); + CpuSamplingProfiler.Snapshot rawCpuSnapshot = cpu.getOrDefault(modId, new CpuSamplingProfiler.Snapshot(0, 0, 0)); + long invokesCount = invokes.getOrDefault(modId, new ModTimingSnapshot(0, 0)).calls(); + double pct = screen.cpuMetricValue(cpuSnapshot) * 100.0 / Math.max(1L, screen.taskEffectiveView ? screen.totalCpuMetric(displayCpu) : screen.totalCpuMetric(cpu)); + int threadCount = detailSnapshot == null ? 0 : detailSnapshot.sampledThreadCount(); + long redistributedSamples = effectiveCpu.redistributedSamplesByMod().getOrDefault(modId, 0L); + String confidence = AttributionInsights.cpuConfidence(modId, detailSnapshot, rawCpuSnapshot.totalSamples(), cpuSnapshot.totalSamples(), redistributedSamples).label(); + String provenance = AttributionInsights.cpuProvenance(rawCpuSnapshot.totalSamples(), redistributedSamples, detailSnapshot); + int modRight = screen.firstVisibleMetricX(x + listW - 8, pctX, screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "cpu"), threadsX, screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "threads"), samplesX, screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "samples"), invokesX, screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "invokes")); + int nameX = x + TaskManagerScreen.PADDING + 16 + 6; + int chipWidth = screen.confidenceChipWidth(confidence); + int chipX = Math.max(nameX + 48, modRight - chipWidth); + int nameWidth = Math.max(48, chipX - nameX - 6); + ctx.drawText(textRenderer, textRenderer.trimToWidth(screen.getDisplayName(modId), nameWidth), nameX, rowY + 4, TaskManagerScreen.TEXT_PRIMARY, false); + screen.renderConfidenceChip(ctx, chipX, rowY + 3, confidence); + ctx.drawText(textRenderer, textRenderer.trimToWidth(provenance, Math.max(60, modRight - nameX)), nameX, rowY + 16, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "cpu")) ctx.drawText(textRenderer, String.format(Locale.ROOT, "%.1f%%", pct), pctX, rowY + 9, screen.getHeatColor(pct), false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "threads")) ctx.drawText(textRenderer, Integer.toString(threadCount), threadsX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "samples")) ctx.drawText(textRenderer, screen.formatCount(cpuSnapshot.totalSamples()), samplesX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + if (screen.isColumnVisible(TaskManagerScreen.TableId.TASKS, "invokes")) ctx.drawText(textRenderer, screen.formatCount(invokesCount), invokesX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + } + if (rowY > listY + listH) break; + rowY += TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT; + rowIdx++; + } + ctx.disableScissor(); + } + + screen.renderCpuDetailPanel(ctx, x + listW + gap, y + TaskManagerScreen.PADDING, detailW, h - (TaskManagerScreen.PADDING * 2), screen.selectedTaskMod, screen.selectedTaskMod == null ? null : cpu.get(screen.selectedTaskMod), screen.selectedTaskMod == null ? null : effectiveCpu.displaySnapshots().get(screen.selectedTaskMod), screen.selectedTaskMod == null ? null : displayCpu.get(screen.selectedTaskMod), screen.selectedTaskMod == null ? 0L : effectiveCpu.redistributedSamplesByMod().getOrDefault(screen.selectedTaskMod, 0L), screen.selectedTaskMod == null ? null : cpuDetails.get(screen.selectedTaskMod), screen.selectedTaskMod == null ? null : invokes.get(screen.selectedTaskMod), screen.taskEffectiveView ? screen.totalCpuMetric(effectiveCpu.displaySnapshots()) : screen.cachedRawCpuTotalMetric, screen.taskEffectiveView); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/ThreadsTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/ThreadsTabRenderer.java new file mode 100644 index 0000000..8468537 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/ThreadsTabRenderer.java @@ -0,0 +1,96 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +import java.util.List; +import java.util.Locale; + +final class ThreadsTabRenderer { + + private ThreadsTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h, int mouseX, int mouseY) { + List rows = screen.getThreadRows(); + int detailW = Math.min(420, Math.max(320, w / 3)); + int gap = TaskManagerScreen.PADDING; + int listW = w - detailW - gap; + int infoY = y + TaskManagerScreen.PADDING; + int descriptionBottomY = screen.renderWrappedText(ctx, x + TaskManagerScreen.PADDING, infoY, Math.max(260, listW - 16), + "Measured live thread CPU/allocation snapshots with sampled owner and confidence. Use this when a mod row is really a thread question.", + TaskManagerScreen.TEXT_DIM); + ctx.drawText(screen.uiTextRenderer(), "Tip: the universal search filters thread name, owner, reason frame, and sampled top frames.", + x + TaskManagerScreen.PADDING, descriptionBottomY + 2, TaskManagerScreen.TEXT_DIM, false); + String freezeState = screen.isThreadFreezeActive() ? "Frozen snapshot" : "Live snapshot"; + ctx.drawText(screen.uiTextRenderer(), rows.size() + " live threads | sort " + screen.currentThreadSortLabel() + " | " + freezeState, + x + TaskManagerScreen.PADDING, descriptionBottomY + 14, TaskManagerScreen.TEXT_DIM, false); + int controlsY = descriptionBottomY + 26; + screen.renderThreadToolbar(ctx, x + TaskManagerScreen.PADDING, controlsY); + + if (!rows.isEmpty() && rows.stream().noneMatch(row -> row.threadId() == screen.currentSelectedThreadId())) { + screen.setCurrentSelectedThreadId(rows.getFirst().threadId()); + } + + int headerY = descriptionBottomY + 52; + ctx.fill(x, headerY, x + listW, headerY + 14, TaskManagerScreen.HEADER_COLOR); + int ownerX = x + listW - 252; + int cpuX = x + listW - 156; + int allocX = x + listW - 102; + int stateX = x + listW - 54; + ctx.drawText(screen.uiTextRenderer(), "THREAD", x + TaskManagerScreen.PADDING, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), "OWNER", ownerX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), "%CPU", cpuX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), "ALLOC", allocX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), "STATE", stateX, headerY + 3, TaskManagerScreen.TEXT_DIM, false); + + int listY = headerY + 16; + int listH = h - (listY - y); + if (rows.isEmpty()) { + ctx.drawText(screen.uiTextRenderer(), + screen.currentGlobalSearch().isBlank() ? "Waiting for live thread samples..." : "No threads match the current universal search.", + x + TaskManagerScreen.PADDING, listY + 6, TaskManagerScreen.TEXT_DIM, false); + } else { + ctx.enableScissor(x, listY, x + listW, listY + listH); + int rowY = listY - screen.currentScrollOffset(); + int rowIdx = 0; + for (SystemMetricsProfiler.ThreadDrilldown thread : rows) { + if (rowY + TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT > listY && rowY < listY + listH) { + screen.renderStripedRowVariable(ctx, x, listW, rowY, TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT, rowIdx, mouseX, mouseY); + if (thread.threadId() == screen.currentSelectedThreadId()) { + ctx.fill(x, rowY, x + 3, rowY + TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT, TaskManagerScreen.ACCENT_GREEN); + } + String confidence = screen.blankToUnknown(thread.confidence()); + int modRight = screen.firstVisibleMetricX(x + listW - 8, ownerX, true, cpuX, true, allocX, true, stateX, true); + int chipWidth = screen.confidenceChipWidth(confidence); + int chipX = Math.max(x + TaskManagerScreen.PADDING + 96, modRight - chipWidth); + int threadNameX = x + TaskManagerScreen.PADDING; + int threadNameWidth = Math.max(48, chipX - threadNameX - 6); + ctx.drawText(screen.uiTextRenderer(), screen.uiTextRenderer().trimToWidth(screen.cleanProfilerLabel(thread.threadName()), threadNameWidth), + threadNameX, rowY + 4, TaskManagerScreen.TEXT_PRIMARY, false); + screen.renderConfidenceChip(ctx, chipX, rowY + 3, confidence); + ctx.drawText(screen.uiTextRenderer(), + screen.uiTextRenderer().trimToWidth("Reason: " + screen.cleanProfilerLabel(thread.reasonFrame()), Math.max(60, modRight - threadNameX)), + threadNameX, rowY + 16, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), + screen.uiTextRenderer().trimToWidth(screen.getDisplayName(thread.ownerMod()), Math.max(44, cpuX - ownerX - 8)), + ownerX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), String.format(Locale.ROOT, "%.1f%%", thread.cpuLoadPercent()), cpuX, rowY + 9, + screen.getHeatColor(thread.cpuLoadPercent()), false); + ctx.drawText(screen.uiTextRenderer(), screen.uiTextRenderer().trimToWidth(screen.formatBytesPerSecond(thread.allocationRateBytesPerSecond()), 48), + allocX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + ctx.drawText(screen.uiTextRenderer(), screen.uiTextRenderer().trimToWidth(screen.blankToUnknown(thread.state()), 44), + stateX, rowY + 9, TaskManagerScreen.TEXT_DIM, false); + } + if (rowY > listY + listH) { + break; + } + rowY += TaskManagerScreen.ATTRIBUTION_ROW_HEIGHT; + rowIdx++; + } + ctx.disableScissor(); + } + + screen.renderThreadDetailPanel(ctx, x + listW + gap, y + TaskManagerScreen.PADDING, detailW, h - (TaskManagerScreen.PADDING * 2), + rows.stream().filter(row -> row.threadId() == screen.currentSelectedThreadId()).findFirst().orElse(null)); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/TimelineTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/TimelineTabRenderer.java new file mode 100644 index 0000000..e4adcb8 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/TimelineTabRenderer.java @@ -0,0 +1,84 @@ +package wueffi.taskmanager.client; + +import net.minecraft.client.gui.DrawContext; + +import java.util.Locale; + +final class TimelineTabRenderer { + + private TimelineTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h) { + var textRenderer = screen.uiTextRenderer(); + screen.beginFullPageScissor(ctx, x, y, w, h); + int graphWidth = screen.getPreferredGraphWidth(w); + int left = x + Math.max(TaskManagerScreen.PADDING, (w - graphWidth) / 2); + int top = screen.getFullPageScrollTop(y); + FrameTimelineProfiler frames = FrameTimelineProfiler.getInstance(); + ProfilerManager manager = ProfilerManager.getInstance(); + ProfilerManager.SessionBaseline baseline = manager.getSessionBaseline(); + ProfilerManager.SessionDelta delta = manager.compareToBaseline(baseline); + top = screen.renderSectionHeader(ctx, left, top, "Timeline", "Frame pacing, FPS lows, jitter, and spike context over the live capture window."); + screen.drawTopChip(ctx, left, top, 96, 16, baseline != null); + ctx.drawText(textRenderer, "Set Baseline", left + 10, top + 4, baseline != null ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + screen.drawTopChip(ctx, left + 102, top, 112, 16, false); + ctx.drawText(textRenderer, "Import Export", left + 114, top + 4, TaskManagerScreen.TEXT_DIM, false); + screen.drawTopChip(ctx, left + 220, top, 74, 16, baseline != null); + ctx.drawText(textRenderer, "Clear", left + 244, top + 4, baseline != null ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + top += 24; + if (baseline != null && delta != null) { + screen.drawMetricRow(ctx, left, top, graphWidth, "Compare", String.format(Locale.ROOT, + "%s | FPS %s | 1%% low %s | MSPT %s | heap %s", + baseline.label(), + formatSigned(delta.fpsChange(), false, "fps"), + formatSigned(delta.onePercentLowFpsChange(), false, "fps"), + formatSigned(delta.msptChange(), true, "ms"), + formatSigned(delta.heapChangeMb(), true, "MB"))); + top += 18; + screen.drawMetricRow(ctx, left, top, graphWidth, "Top Deltas", "CPU " + formatTopDelta(delta.cpuDeltaByMod()) + " | GPU " + formatTopDelta(delta.gpuDeltaByMod()) + " | Memory " + formatTopDelta(delta.memoryDeltaMbByMod())); + top += 24; + } + screen.drawMetricRow(ctx, left, top, graphWidth, "FPS", String.format(Locale.ROOT, "current %.1f | avg %.1f | 1%% low %.1f | 0.1%% low %.1f", frames.getCurrentFps(), frames.getAverageFps(), frames.getOnePercentLowFps(), frames.getPointOnePercentLowFps())); + top += 24; + screen.renderSeriesGraph(ctx, left, top, graphWidth, 126, frames.getOrderedFrameMsHistory(), frames.getOrderedSelfCostMsHistory(), "Frame Timeline", "ms/frame", TaskManagerScreen.ACCENT_YELLOW, 0xFFFF8A3D, frames.getHistorySpanSeconds(), true); + top += 144; + screen.renderSeriesGraph(ctx, left, top, graphWidth, 126, frames.getOrderedFpsHistory(), null, "FPS Timeline", "fps", TaskManagerScreen.INTEL_COLOR, 0, frames.getHistorySpanSeconds()); + top += 144; + screen.drawMetricRow(ctx, left, top, graphWidth, "Profiler Self-Cost", String.format(Locale.ROOT, "avg %.2f ms | max %.2f ms", frames.getSelfCostAvgMs(), frames.getSelfCostMaxMs())); + top += 18; + double stutterScore = frames.getStutterScore(); + screen.drawMetricRow(ctx, left, top, graphWidth, "Jitter Variance", String.format(Locale.ROOT, "stddev %.2f ms | variance %.2f ms^2 | stutter %.1f", frames.getFrameStdDevMs(), frames.getFrameVarianceMs(), stutterScore)); + top += 18; + screen.drawMetricRow(ctx, left, top, graphWidth, "Stutter Score", String.format(Locale.ROOT, "%.1f | %s", stutterScore, screen.stutterBand(stutterScore))); + top += 18; + ctx.drawText(textRenderer, "Stutter guide", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 14; + top = screen.renderWrappedText(ctx, left + 6, top, graphWidth - 12, "0-5 Excellent | 5-10 Good | 10-20 Noticeable | 20-35 Bad | 35+ Severe", screen.stutterBandColor(stutterScore)) + 4; + top = screen.renderWrappedText(ctx, left + 6, top, graphWidth - 12, "Higher stutter scores mean frame pacing is less consistent even if average FPS still looks healthy.", TaskManagerScreen.TEXT_DIM) + 6; + screen.drawMetricRow(ctx, left, top, graphWidth, "Frame / Tick Breakdown", String.format(Locale.ROOT, "frame %.2f ms | p95 %.2f | p99 %.2f | build %.2f | gpu %.2f | gpu p95 %.2f | mspt %.2f | mspt p95 %.2f | mspt p99 %.2f", frames.getLatestFrameNs() / 1_000_000.0, frames.getPercentileFrameNs(0.95) / 1_000_000.0, frames.getPercentileFrameNs(0.99) / 1_000_000.0, screen.snapshot.renderPhases().values().stream().mapToLong(RenderPhaseProfiler.PhaseSnapshot::cpuNanos).sum() / 1_000_000.0, screen.snapshot.renderPhases().values().stream().mapToLong(RenderPhaseProfiler.PhaseSnapshot::gpuNanos).sum() / 1_000_000.0, screen.percentileGpuFrameLabel(), TickProfiler.getInstance().getAverageServerTickNs() / 1_000_000.0, TickProfiler.getInstance().getServerTickP95Ns() / 1_000_000.0, TickProfiler.getInstance().getServerTickP99Ns() / 1_000_000.0)); + top += 22; + screen.drawMetricRow(ctx, left, top, graphWidth, "Frame Histogram", screen.formatFrameHistogram(frames.getFrameTimeHistogram())); + top += 22; + screen.renderSpikeInspector(ctx, left, top, graphWidth); + ctx.disableScissor(); + } + + private static String formatSigned(double value, boolean lowerIsBetter, String units) { + String sign = value > 0 ? "+" : ""; + String direction = lowerIsBetter ? (value <= 0 ? "better" : "worse") : (value >= 0 ? "better" : "worse"); + return String.format(Locale.ROOT, "%s%.1f %s %s", sign, value, units, direction); + } + + private static String formatTopDelta(java.util.Map deltas) { + if (deltas == null || deltas.isEmpty()) { + return "no change"; + } + return deltas.entrySet().stream() + .sorted((a, b) -> Double.compare(Math.abs(b.getValue()), Math.abs(a.getValue()))) + .limit(2) + .map(entry -> entry.getKey() + " " + String.format(Locale.ROOT, "%+.1f", entry.getValue())) + .reduce((left, right) -> left + " | " + right) + .orElse("no change"); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/tabs/WorldTabRenderer.java b/src/client/java/wueffi/taskmanager/client/tabs/WorldTabRenderer.java new file mode 100644 index 0000000..7569f8f --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/tabs/WorldTabRenderer.java @@ -0,0 +1,132 @@ +package wueffi.taskmanager.client; + +import net.minecraft.block.entity.BlockEntity; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.math.ChunkPos; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +final class WorldTabRenderer { + + private WorldTabRenderer() { + } + + static void render(TaskManagerScreen screen, DrawContext ctx, int x, int y, int w, int h) { + var textRenderer = screen.uiTextRenderer(); + int left = x + TaskManagerScreen.PADDING; + screen.beginFullPageScissor(ctx, x, y, w, h); + TaskManagerScreen.LagMapLayout layout = screen.getLagMapLayout(y, w, h); + screen.lastRenderedLagMapLayout = layout; + int top = screen.getFullPageScrollTop(y); + top = screen.renderSectionHeader(ctx, left, top, "World", "Chunk pressure, entity hotspots, and block-entity drilldown grouped into world-focused views."); + int lagTabW = 76; + int entitiesTabW = 70; + int chunksTabW = 72; + int blockTabW = 108; + int tabX = left; + screen.drawTopChip(ctx, tabX, layout.miniTabY(), lagTabW, 16, screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.LAG_MAP); + tabX += lagTabW + 6; + screen.drawTopChip(ctx, tabX, layout.miniTabY(), entitiesTabW, 16, screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.ENTITIES); + tabX += entitiesTabW + 6; + screen.drawTopChip(ctx, tabX, layout.miniTabY(), chunksTabW, 16, screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.CHUNKS); + tabX += chunksTabW + 6; + screen.drawTopChip(ctx, tabX, layout.miniTabY(), blockTabW, 16, screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.BLOCK_ENTITIES); + tabX = left; + ctx.drawText(textRenderer, "Lag Map", tabX + 16, layout.miniTabY() + 4, screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.LAG_MAP ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + tabX += lagTabW + 6; + ctx.drawText(textRenderer, "Entities", tabX + 12, layout.miniTabY() + 4, screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.ENTITIES ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + tabX += entitiesTabW + 6; + ctx.drawText(textRenderer, "Chunks", tabX + 14, layout.miniTabY() + 4, screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.CHUNKS ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + tabX += chunksTabW + 6; + ctx.drawText(textRenderer, "Block Entities", tabX + 14, layout.miniTabY() + 4, screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.BLOCK_ENTITIES ? TaskManagerScreen.TEXT_PRIMARY : TaskManagerScreen.TEXT_DIM, false); + int findingsCount = ProfilerManager.getInstance().getLatestRuleFindings().size(); + ctx.drawText(textRenderer, String.format(Locale.ROOT, "Selected chunk: %s | hot chunks: %d | findings: %d", screen.selectedLagChunk == null ? "none" : (screen.selectedLagChunk.x + "," + screen.selectedLagChunk.z), ProfilerManager.getInstance().getLatestHotChunks().size(), findingsCount), left, layout.summaryY(), TaskManagerScreen.TEXT_DIM, false); + SystemMetricsProfiler metrics = SystemMetricsProfiler.getInstance(); + int worldGraphWidth = screen.getPreferredGraphWidth(w); + int worldGraphX = x + Math.max(TaskManagerScreen.PADDING, (w - worldGraphWidth) / 2); + top = layout.summaryY() + 18; + + if (screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.LAG_MAP) { + screen.renderLagMap(ctx, layout.left(), layout.mapRenderY(), layout.mapWidth(), layout.mapHeight()); + top = layout.mapTop() + (layout.cell() * ((layout.radius() * 2) + 1)) + 18; + top = screen.renderLagChunkDetail(ctx, left, top, w - 24, h - 40) + 8; + ctx.drawText(textRenderer, "Top thread CPU load", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 16; + if (screen.snapshot.systemMetrics().threadLoadPercentByName().isEmpty()) { + ctx.drawText(textRenderer, "Waiting for JVM thread CPU samples...", left, top, TaskManagerScreen.TEXT_DIM, false); + ctx.disableScissor(); + return; + } + int shown = 0; + for (Map.Entry entry : screen.snapshot.systemMetrics().threadDetailsByName().entrySet()) { + ThreadLoadProfiler.ThreadSnapshot details = entry.getValue(); + String summary = screen.cleanProfilerLabel(entry.getKey()) + " | " + String.format(Locale.ROOT, "%.1f%% %s", details.loadPercent(), details.state()); + top = screen.renderWrappedText(ctx, left, top, w - 24, summary, screen.getHeatColor(details.loadPercent())); + String waitLine = "blocked " + details.blockedCountDelta() + " / " + details.blockedTimeDeltaMs() + "ms | waited " + details.waitedCountDelta() + " / " + details.waitedTimeDeltaMs() + "ms | lock " + screen.describeLock(details); + top = screen.renderWrappedText(ctx, left + 8, top, w - 32, waitLine, TaskManagerScreen.TEXT_DIM); + shown++; + if (shown >= 5) { + break; + } + } + top += 8; + top = screen.renderEntityHotspotSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestEntityHotspots(), "Entity Hotspots") + 8; + top += screen.renderRuleFindingsSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestRuleFindings()) + 8; + } else if (screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.ENTITIES) { + screen.drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Entities", Integer.toString(screen.snapshot.entityCounts().totalEntities())); + top += 18; + screen.renderSeriesGraph(ctx, worldGraphX, top, worldGraphWidth, 120, metrics.getOrderedEntityCountHistory(), null, "Entities Over Time", "entities", screen.getWorldEntityGraphColor(), 0, metrics.getHistorySpanSeconds()); + top += 138; + top += screen.renderGraphLegend(ctx, worldGraphX, top, new String[]{"Entities"}, new int[]{screen.getWorldEntityGraphColor()}) + 8; + screen.drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Living", Integer.toString(screen.snapshot.entityCounts().livingEntities())); + top += 16; + screen.drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Block Entities", Integer.toString(screen.snapshot.entityCounts().blockEntities())); + top += 20; + top = screen.renderEntityHotspotSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestEntityHotspots(), "Entity Hotspots") + 8; + top = screen.renderStringListSection(ctx, left, top, w - 24, "Entity Tick Cost [measured CPU]", EntityCostProfiler.getInstance().buildTopTickLines()) + 8; + top = screen.renderStringListSection(ctx, left, top, w - 24, "Entity Render Prep Cost [measured CPU]", EntityCostProfiler.getInstance().buildTopRenderPrepLines()) + 8; + top += screen.renderRuleFindingsSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestRuleFindings()) + 8; + } else if (screen.worldMiniTab == TaskManagerScreen.WorldMiniTab.CHUNKS) { + screen.drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Chunks", screen.snapshot.chunkCounts().loadedChunks() + " loaded | " + screen.snapshot.chunkCounts().renderedChunks() + " rendered"); + top += 18; + screen.renderSeriesGraph(ctx, worldGraphX, top, worldGraphWidth, 120, metrics.getOrderedLoadedChunkHistory(), metrics.getOrderedRenderedChunkHistory(), "Chunks Over Time", "chunks", screen.getWorldLoadedChunkGraphColor(), screen.getWorldRenderedChunkGraphColor(), metrics.getHistorySpanSeconds()); + top += 138; + top += screen.renderGraphLegend(ctx, worldGraphX, top, new String[]{"Loaded", "Rendered"}, new int[]{screen.getWorldLoadedChunkGraphColor(), screen.getWorldRenderedChunkGraphColor()}) + 8; + screen.drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Chunk Generating", Integer.toString(screen.snapshot.systemMetrics().chunksGenerating())); + top += 16; + screen.drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Chunk Meshing", Integer.toString(screen.snapshot.systemMetrics().chunksMeshing())); + top += 16; + screen.drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Chunk Uploading", Integer.toString(screen.snapshot.systemMetrics().chunksUploading())); + top += 16; + screen.drawMetricRow(ctx, worldGraphX, top, worldGraphWidth, "Light Updates", Integer.toString(screen.snapshot.systemMetrics().lightsUpdatePending())); + top += 20; + top = screen.renderStringListSection(ctx, left, top, w - 24, "Chunk Pipeline Drill-Down", screen.buildChunkPipelineDrilldownLines()) + 8; + top = screen.renderStringListSection(ctx, left, top, w - 24, "Chunk Load / Generation Cost [measured CPU]", ChunkWorkProfiler.getInstance().buildTopLines()) + 8; + top += screen.renderRuleFindingsSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestRuleFindings()) + 8; + } else { + top = screen.renderBlockEntityHotspotSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestBlockEntityHotspots(), "Block Entity Hotspots") + 8; + if (screen.selectedLagChunk != null) { + ctx.drawText(textRenderer, "Selected chunk block entities", left, top, TaskManagerScreen.TEXT_PRIMARY, false); + top += 14; + MinecraftClient client = MinecraftClient.getInstance(); + Map blockEntityCounts = new HashMap<>(); + if (client.world != null) { + for (BlockEntity blockEntity : client.world.getBlockEntities()) { + ChunkPos chunkPos = new ChunkPos(blockEntity.getPos()); + if (chunkPos.x == screen.selectedLagChunk.x && chunkPos.z == screen.selectedLagChunk.z) { + blockEntityCounts.merge(screen.cleanProfilerLabel(blockEntity.getClass().getSimpleName()), 1, Integer::sum); + } + } + } + top = screen.renderCountMap(ctx, left, top, w - 24, "Top block entities in selected chunk [measured counts]", blockEntityCounts) + 8; + } else { + top = screen.renderWrappedText(ctx, left, top, w - 24, "Select a chunk from the Lag Map mini-tab to inspect block entities for that chunk.", TaskManagerScreen.TEXT_DIM) + 8; + } + top += screen.renderRuleFindingsSection(ctx, left, top, w - 24, ProfilerManager.getInstance().getLatestRuleFindings()) + 8; + } + ctx.disableScissor(); + } +} diff --git a/src/client/java/wueffi/taskmanager/client/util/BoundedMaps.java b/src/client/java/wueffi/taskmanager/client/util/BoundedMaps.java new file mode 100644 index 0000000..68f5dd6 --- /dev/null +++ b/src/client/java/wueffi/taskmanager/client/util/BoundedMaps.java @@ -0,0 +1,51 @@ +package wueffi.taskmanager.client.util; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; + +public final class BoundedMaps { + + private BoundedMaps() { + } + + public static Map synchronizedLru(int maxEntries) { + int initialCapacity = Math.max(16, Math.min(maxEntries, 256)); + return Collections.synchronizedMap(new LinkedHashMap<>(initialCapacity, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxEntries; + } + }); + } + + public static V getOrCompute(Map map, K key, Function resolver) { + synchronized (map) { + if (map.containsKey(key)) { + return map.get(key); + } + V value = resolver.apply(key); + map.put(key, value); + return value; + } + } + + public static V get(Map map, K key) { + synchronized (map) { + return map.get(key); + } + } + + public static void put(Map map, K key, V value) { + synchronized (map) { + map.put(key, value); + } + } + + public static void putIfAbsent(Map map, K key, V value) { + synchronized (map) { + map.putIfAbsent(key, value); + } + } +} diff --git a/src/client/java/wueffi/taskmanager/client/util/ConfigManager.java b/src/client/java/wueffi/taskmanager/client/util/ConfigManager.java index 1640128..f3b7c8a 100644 --- a/src/client/java/wueffi/taskmanager/client/util/ConfigManager.java +++ b/src/client/java/wueffi/taskmanager/client/util/ConfigManager.java @@ -75,8 +75,11 @@ public HudConfigMode next() { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final int[] FRAME_BUDGET_TARGET_FPS_OPTIONS = {30, 45, 60, 72, 90, 120, 144, 165, 240}; + private static final int[] PERFORMANCE_ALERT_FRAME_THRESHOLD_OPTIONS = {16, 20, 25, 30, 35, 40, 50}; + private static final int[] PERFORMANCE_ALERT_SERVER_THRESHOLD_OPTIONS = {15, 20, 25, 30, 40, 50, 75}; + private static final int[] PERFORMANCE_ALERT_CONSECUTIVE_OPTIONS = {1, 2, 3, 4, 5, 8}; private static final Path CONFIG_PATH = resolveConfigPath(); - private static ConfigData config = new ConfigData(); + private static volatile ConfigData config = new ConfigData(); private static Path resolveConfigPath() { try { @@ -189,6 +192,47 @@ public static void cycleFrameBudgetTargetFps() { saveConfig(); } + public static boolean isPerformanceAlertsEnabled() { return config.performanceAlertsEnabled; } + + public static void togglePerformanceAlertsEnabled() { + config.performanceAlertsEnabled = !isPerformanceAlertsEnabled(); + saveConfig(); + } + + public static boolean isPerformanceAlertChatEnabled() { return config.performanceAlertChatEnabled; } + + public static void togglePerformanceAlertChatEnabled() { + config.performanceAlertChatEnabled = !isPerformanceAlertChatEnabled(); + saveConfig(); + } + + public static int getPerformanceAlertFrameThresholdMs() { + return Math.clamp(config.performanceAlertFrameThresholdMs, 10, 120); + } + + public static void cyclePerformanceAlertFrameThresholdMs() { + config.performanceAlertFrameThresholdMs = cycleIntOption(getPerformanceAlertFrameThresholdMs(), PERFORMANCE_ALERT_FRAME_THRESHOLD_OPTIONS); + saveConfig(); + } + + public static int getPerformanceAlertServerThresholdMs() { + return Math.clamp(config.performanceAlertServerThresholdMs, 10, 200); + } + + public static void cyclePerformanceAlertServerThresholdMs() { + config.performanceAlertServerThresholdMs = cycleIntOption(getPerformanceAlertServerThresholdMs(), PERFORMANCE_ALERT_SERVER_THRESHOLD_OPTIONS); + saveConfig(); + } + + public static int getPerformanceAlertConsecutiveTicks() { + return Math.clamp(config.performanceAlertConsecutiveTicks, 1, 10); + } + + public static void cyclePerformanceAlertConsecutiveTicks() { + config.performanceAlertConsecutiveTicks = cycleIntOption(getPerformanceAlertConsecutiveTicks(), PERFORMANCE_ALERT_CONSECUTIVE_OPTIONS); + saveConfig(); + } + public static void cycleProfilerUpdateDelayMs() { int current = getProfilerUpdateDelayMs(); int next = switch (current) { @@ -280,31 +324,31 @@ public static void resetGraphColors() { public static boolean isHudShowUtilization() { return config.hudShowUtilization; } public static boolean isHudShowTemperatures() { return config.hudShowTemperatures; } public static boolean isHudShowParallelism() { return config.hudShowParallelism; } - public static boolean isHudShowLogic() { return config.hudShowLogic == null || config.hudShowLogic; } - public static boolean isHudShowBackground() { return config.hudShowBackground == null || config.hudShowBackground; } - public static boolean isHudShowFrameBudget() { return config.hudShowFrameBudget == null || config.hudShowFrameBudget; } + public static boolean isHudShowLogic() { return config.hudShowLogic; } + public static boolean isHudShowBackground() { return config.hudShowBackground; } + public static boolean isHudShowFrameBudget() { return config.hudShowFrameBudget; } public static boolean isHudShowMemory() { return config.hudShowMemory; } - public static boolean isHudShowMemoryAllocationRate() { return config.hudShowMemoryAllocationRate == null || config.hudShowMemoryAllocationRate; } - public static boolean isHudShowVram() { return config.hudShowVram == null || config.hudShowVram; } - public static boolean isHudShowNetwork() { return config.hudShowNetwork != null && config.hudShowNetwork; } - public static boolean isHudShowChunkActivity() { return config.hudShowChunkActivity != null && config.hudShowChunkActivity; } + public static boolean isHudShowMemoryAllocationRate() { return config.hudShowMemoryAllocationRate; } + public static boolean isHudShowVram() { return config.hudShowVram; } + public static boolean isHudShowNetwork() { return config.hudShowNetwork; } + public static boolean isHudShowChunkActivity() { return config.hudShowChunkActivity; } public static boolean isHudShowWorld() { return config.hudShowWorld; } - public static boolean isHudShowDiskIo() { return config.hudShowDiskIo != null && config.hudShowDiskIo; } - public static boolean isHudShowInputLatency() { return config.hudShowInputLatency != null && config.hudShowInputLatency; } + public static boolean isHudShowDiskIo() { return config.hudShowDiskIo; } + public static boolean isHudShowInputLatency() { return config.hudShowInputLatency; } public static boolean isHudShowSession() { return config.hudShowSession; } public static boolean isHudShowFpsRateOfChange() { return config.hudShowFpsRateOfChange; } public static boolean isHudShowFrameRateOfChange() { return config.hudShowFrameRateOfChange; } public static boolean isHudShowTickRateOfChange() { return config.hudShowTickRateOfChange; } public static boolean isHudShowUtilizationRateOfChange() { return config.hudShowUtilizationRateOfChange; } public static boolean isHudShowWorldRateOfChange() { return config.hudShowWorldRateOfChange; } - public static boolean isHudShowVramRateOfChange() { return config.hudShowVramRateOfChange == null || config.hudShowVramRateOfChange; } - public static boolean isHudShowNetworkRateOfChange() { return config.hudShowNetworkRateOfChange == null || config.hudShowNetworkRateOfChange; } - public static boolean isHudShowChunkActivityRateOfChange() { return config.hudShowChunkActivityRateOfChange == null || config.hudShowChunkActivityRateOfChange; } - public static boolean isHudShowDiskIoRateOfChange() { return config.hudShowDiskIoRateOfChange == null || config.hudShowDiskIoRateOfChange; } - public static boolean isHudShowInputLatencyRateOfChange() { return config.hudShowInputLatencyRateOfChange == null || config.hudShowInputLatencyRateOfChange; } + public static boolean isHudShowVramRateOfChange() { return config.hudShowVramRateOfChange; } + public static boolean isHudShowNetworkRateOfChange() { return config.hudShowNetworkRateOfChange; } + public static boolean isHudShowChunkActivityRateOfChange() { return config.hudShowChunkActivityRateOfChange; } + public static boolean isHudShowDiskIoRateOfChange() { return config.hudShowDiskIoRateOfChange; } + public static boolean isHudShowInputLatencyRateOfChange() { return config.hudShowInputLatencyRateOfChange; } public static boolean isHudShowZeroRateOfChange() { return config.hudShowZeroRateOfChange; } - public static boolean isHudAutoFocusAlertRow() { return config.hudAutoFocusAlertRow == null || config.hudAutoFocusAlertRow; } - public static boolean isHudBudgetColorMode() { return config.hudBudgetColorMode == null || config.hudBudgetColorMode; } + public static boolean isHudAutoFocusAlertRow() { return config.hudAutoFocusAlertRow; } + public static boolean isHudBudgetColorMode() { return config.hudBudgetColorMode; } public static HudTriggerMode getHudTriggerMode() { try { @@ -390,6 +434,7 @@ public static void setHudExpandedOnWarning(boolean value) { public static String getGpuSearch() { return config.gpuSearch == null ? "" : config.gpuSearch; } public static String getMemorySearch() { return config.memorySearch == null ? "" : config.memorySearch; } public static String getStartupSearch() { return config.startupSearch == null ? "" : config.startupSearch; } + public static String getGlobalSearch() { return config.globalSearch == null ? "" : config.globalSearch; } public static String getTaskSort() { return config.taskSort == null || config.taskSort.isBlank() ? "CPU" : config.taskSort; } public static boolean isTaskSortDescending() { return config.taskSortDescending; } public static String getGpuSort() { return config.gpuSort == null || config.gpuSort.isBlank() ? "EST_GPU" : config.gpuSort; } @@ -398,17 +443,18 @@ public static void setHudExpandedOnWarning(boolean value) { public static boolean isMemorySortDescending() { return config.memorySortDescending; } public static String getStartupSort() { return config.startupSort == null || config.startupSort.isBlank() ? "ACTIVE" : config.startupSort; } public static boolean isStartupSortDescending() { return config.startupSortDescending; } - public static boolean isTaskEffectiveView() { return config.taskEffectiveView == null || config.taskEffectiveView; } - public static boolean isTaskShowSharedRows() { return config.taskShowSharedRows != null && config.taskShowSharedRows; } - public static boolean isGpuEffectiveView() { return config.gpuEffectiveView == null || config.gpuEffectiveView; } - public static boolean isGpuShowSharedRows() { return config.gpuShowSharedRows != null && config.gpuShowSharedRows; } - public static boolean isMemoryEffectiveView() { return config.memoryEffectiveView == null || config.memoryEffectiveView; } - public static boolean isMemoryShowSharedRows() { return config.memoryShowSharedRows != null && config.memoryShowSharedRows; } + public static boolean isTaskEffectiveView() { return config.taskEffectiveView; } + public static boolean isTaskShowSharedRows() { return config.taskShowSharedRows; } + public static boolean isGpuEffectiveView() { return config.gpuEffectiveView; } + public static boolean isGpuShowSharedRows() { return config.gpuShowSharedRows; } + public static boolean isMemoryEffectiveView() { return config.memoryEffectiveView; } + public static boolean isMemoryShowSharedRows() { return config.memoryShowSharedRows; } public static void setTasksSearch(String value) { config.tasksSearch = value == null ? "" : value; saveConfig(); } public static void setGpuSearch(String value) { config.gpuSearch = value == null ? "" : value; saveConfig(); } public static void setMemorySearch(String value) { config.memorySearch = value == null ? "" : value; saveConfig(); } public static void setStartupSearch(String value) { config.startupSearch = value == null ? "" : value; saveConfig(); } + public static void setGlobalSearch(String value) { config.globalSearch = value == null ? "" : value; saveConfig(); } public static void setTaskSortState(String sort, boolean descending) { config.taskSort = sort; config.taskSortDescending = descending; saveConfig(); } public static void setGpuSortState(String sort, boolean descending) { config.gpuSort = sort; config.gpuSortDescending = descending; saveConfig(); } public static void setMemorySortState(String sort, boolean descending) { config.memorySort = sort; config.memorySortDescending = descending; saveConfig(); } @@ -429,7 +475,7 @@ public static void setOnlyProfileWhenOpen(boolean value) { setCaptureMode(value ? ProfilerManager.CaptureMode.OPEN_ONLY : ProfilerManager.CaptureMode.PASSIVE_LIGHTWEIGHT); } - public static void loadConfig() { + public static synchronized void loadConfig() { if (Files.exists(CONFIG_PATH)) { try { String json = Files.readString(CONFIG_PATH); @@ -447,7 +493,7 @@ public static void loadConfig() { } } - public static void saveConfig() { + public static synchronized void saveConfig() { try { String json = GSON.toJson(config); Files.writeString(CONFIG_PATH, json); @@ -550,24 +596,15 @@ private static void migrateLegacyFields() { if (config.frameBudgetTargetFps <= 0) { config.frameBudgetTargetFps = 60; } - if (config.hudShowMemoryAllocationRate == null) { - config.hudShowMemoryAllocationRate = true; - } - if (config.hudShowLogic == null) config.hudShowLogic = true; - if (config.hudShowBackground == null) config.hudShowBackground = true; - if (config.hudShowFrameBudget == null) config.hudShowFrameBudget = true; - if (config.hudShowVram == null) config.hudShowVram = true; - if (config.hudShowNetwork == null) config.hudShowNetwork = false; - if (config.hudShowChunkActivity == null) config.hudShowChunkActivity = false; - if (config.hudShowDiskIo == null) config.hudShowDiskIo = false; - if (config.hudShowInputLatency == null) config.hudShowInputLatency = false; - if (config.hudShowVramRateOfChange == null) config.hudShowVramRateOfChange = true; - if (config.hudShowNetworkRateOfChange == null) config.hudShowNetworkRateOfChange = true; - if (config.hudShowChunkActivityRateOfChange == null) config.hudShowChunkActivityRateOfChange = true; - if (config.hudShowDiskIoRateOfChange == null) config.hudShowDiskIoRateOfChange = true; - if (config.hudShowInputLatencyRateOfChange == null) config.hudShowInputLatencyRateOfChange = true; - if (config.hudAutoFocusAlertRow == null) config.hudAutoFocusAlertRow = true; - if (config.hudBudgetColorMode == null) config.hudBudgetColorMode = true; + if (config.performanceAlertFrameThresholdMs <= 0) { + config.performanceAlertFrameThresholdMs = 25; + } + if (config.performanceAlertServerThresholdMs <= 0) { + config.performanceAlertServerThresholdMs = 20; + } + if (config.performanceAlertConsecutiveTicks <= 0) { + config.performanceAlertConsecutiveTicks = 3; + } if (config.tasksColumns == null || config.tasksColumns.isBlank()) { config.tasksColumns = "cpu,threads,samples,invokes"; } @@ -581,16 +618,11 @@ private static void migrateLegacyFields() { if (config.gpuSearch == null) config.gpuSearch = ""; if (config.memorySearch == null) config.memorySearch = ""; if (config.startupSearch == null) config.startupSearch = ""; + if (config.globalSearch == null) config.globalSearch = ""; if (config.taskSort == null || config.taskSort.isBlank()) config.taskSort = "CPU"; if (config.gpuSort == null || config.gpuSort.isBlank()) config.gpuSort = "EST_GPU"; if (config.memorySort == null || config.memorySort.isBlank()) config.memorySort = "MEMORY_MB"; if (config.startupSort == null || config.startupSort.isBlank()) config.startupSort = "ACTIVE"; - if (config.taskEffectiveView == null) config.taskEffectiveView = true; - if (config.taskShowSharedRows == null) config.taskShowSharedRows = false; - if (config.gpuEffectiveView == null) config.gpuEffectiveView = true; - if (config.gpuShowSharedRows == null) config.gpuShowSharedRows = false; - if (config.memoryEffectiveView == null) config.memoryEffectiveView = true; - if (config.memoryShowSharedRows == null) config.memoryShowSharedRows = false; if ((config.cpuGraphColor == null || config.cpuGraphColor.isBlank()) && config.cpuIntelColor != null) config.cpuGraphColor = config.cpuIntelColor; if ((config.gpuGraphColor == null || config.gpuGraphColor.isBlank()) && config.gpuNvidiaColor != null) config.gpuGraphColor = config.gpuNvidiaColor; config.cpuGraphColor = normalizeColorHex(config.cpuGraphColor, 0xFF5EA9FF); @@ -614,37 +646,42 @@ private static class ConfigData { public int hudMemoryDisplayDelayMs = 100; public int hudTransparencyPercent = 100; public int frameBudgetTargetFps = 60; + public boolean performanceAlertsEnabled = true; + public boolean performanceAlertChatEnabled = true; + public int performanceAlertFrameThresholdMs = 25; + public int performanceAlertServerThresholdMs = 20; + public int performanceAlertConsecutiveTicks = 3; public boolean hudShowFps = true; public boolean hudShowFrame = true; public boolean hudShowTicks = true; public boolean hudShowUtilization = true; public boolean hudShowTemperatures = true; public boolean hudShowParallelism = false; - public Boolean hudShowLogic = true; - public Boolean hudShowBackground = true; - public Boolean hudShowFrameBudget = true; + public boolean hudShowLogic = true; + public boolean hudShowBackground = true; + public boolean hudShowFrameBudget = true; public boolean hudShowMemory = true; - public Boolean hudShowMemoryAllocationRate = true; - public Boolean hudShowVram = true; - public Boolean hudShowNetwork = false; - public Boolean hudShowChunkActivity = false; + public boolean hudShowMemoryAllocationRate = true; + public boolean hudShowVram = true; + public boolean hudShowNetwork = false; + public boolean hudShowChunkActivity = false; public boolean hudShowWorld = true; - public Boolean hudShowDiskIo = false; - public Boolean hudShowInputLatency = false; + public boolean hudShowDiskIo = false; + public boolean hudShowInputLatency = false; public boolean hudShowSession = true; public boolean hudShowFpsRateOfChange = true; public boolean hudShowFrameRateOfChange = true; public boolean hudShowTickRateOfChange = true; public boolean hudShowUtilizationRateOfChange = true; public boolean hudShowWorldRateOfChange = true; - public Boolean hudShowVramRateOfChange = true; - public Boolean hudShowNetworkRateOfChange = true; - public Boolean hudShowChunkActivityRateOfChange = true; - public Boolean hudShowDiskIoRateOfChange = true; - public Boolean hudShowInputLatencyRateOfChange = true; + public boolean hudShowVramRateOfChange = true; + public boolean hudShowNetworkRateOfChange = true; + public boolean hudShowChunkActivityRateOfChange = true; + public boolean hudShowDiskIoRateOfChange = true; + public boolean hudShowInputLatencyRateOfChange = true; public boolean hudShowZeroRateOfChange = false; - public Boolean hudAutoFocusAlertRow = true; - public Boolean hudBudgetColorMode = true; + public boolean hudAutoFocusAlertRow = true; + public boolean hudBudgetColorMode = true; public boolean hudSpikeOnly = false; public String hudTriggerMode = HudTriggerMode.ALWAYS.name(); public String hudPreset = HudPreset.COMPACT.name(); @@ -657,6 +694,7 @@ private static class ConfigData { public String gpuSearch = ""; public String memorySearch = ""; public String startupSearch = ""; + public String globalSearch = ""; public String taskSort = "CPU"; public boolean taskSortDescending = true; public String gpuSort = "EST_GPU"; @@ -665,12 +703,12 @@ private static class ConfigData { public boolean memorySortDescending = true; public String startupSort = "ACTIVE"; public boolean startupSortDescending = true; - public Boolean taskEffectiveView = true; - public Boolean taskShowSharedRows = false; - public Boolean gpuEffectiveView = true; - public Boolean gpuShowSharedRows = false; - public Boolean memoryEffectiveView = true; - public Boolean memoryShowSharedRows = false; + public boolean taskEffectiveView = true; + public boolean taskShowSharedRows = false; + public boolean gpuEffectiveView = true; + public boolean gpuShowSharedRows = false; + public boolean memoryEffectiveView = true; + public boolean memoryShowSharedRows = false; public String cpuGraphColor = "#5EA9FF"; public String gpuGraphColor = "#77DD77"; public String worldEntityGraphColor = "#FFC857"; diff --git a/src/client/java/wueffi/taskmanager/client/util/GpuTimer.java b/src/client/java/wueffi/taskmanager/client/util/GpuTimer.java index d05a5de..2c02b44 100644 --- a/src/client/java/wueffi/taskmanager/client/util/GpuTimer.java +++ b/src/client/java/wueffi/taskmanager/client/util/GpuTimer.java @@ -3,9 +3,7 @@ import org.lwjgl.opengl.ARBTimerQuery; import org.lwjgl.opengl.GL; import org.lwjgl.opengl.GL15; -import org.lwjgl.opengl.GL33; import wueffi.taskmanager.client.RenderPhaseProfiler; -import wueffi.taskmanager.client.TaskManagerScreen; import wueffi.taskmanager.client.taskmanagerClient; import java.util.ArrayDeque; @@ -18,10 +16,11 @@ public class GpuTimer { private static boolean supported = false; private static boolean checkedSupport = false; - private static final Map> pending = new HashMap<>(); + private static final Map> pending = new HashMap<>(); + private static final Deque freeQueries = new ArrayDeque<>(); + private static final Deque activeStack = new ArrayDeque<>(); - private record ActiveQuery(String phase, int queryId) {} - private static ActiveQuery active = null; + private record ActiveQuery(String phase, int startQueryId, int endQueryId) {} public static boolean isSupported() { if (!checkedSupport) { @@ -38,13 +37,11 @@ public static boolean isSupported() { public static void begin(String phase) { if (!isSupported()) return; - if (active != null) { - return; - } try { - int queryId = GL15.glGenQueries(); - GL33.glBeginQuery(ARBTimerQuery.GL_TIME_ELAPSED, queryId); - active = new ActiveQuery(phase, queryId); + int startQueryId = acquireQuery(); + int endQueryId = acquireQuery(); + ARBTimerQuery.glQueryCounter(startQueryId, ARBTimerQuery.GL_TIMESTAMP); + activeStack.addLast(new ActiveQuery(phase, startQueryId, endQueryId)); } catch (Exception e) { taskmanagerClient.LOGGER.debug("GpuTimer.begin failed: {}", e.getMessage()); } @@ -52,15 +49,31 @@ public static void begin(String phase) { public static void end(String phase) { if (!isSupported()) return; - if (active == null || !active.phase().equals(phase)) return; + if (activeStack.isEmpty()) return; try { - GL33.glEndQuery(ARBTimerQuery.GL_TIME_ELAPSED); + ActiveQuery active = null; + if (phase.equals(activeStack.peekLast().phase())) { + active = activeStack.removeLast(); + } else { + Deque skipped = new ArrayDeque<>(); + while (!activeStack.isEmpty()) { + ActiveQuery candidate = activeStack.removeLast(); + if (phase.equals(candidate.phase())) { + active = candidate; + break; + } + skipped.addFirst(candidate); + } + activeStack.addAll(skipped); + } + if (active == null) { + return; + } + ARBTimerQuery.glQueryCounter(active.endQueryId(), ARBTimerQuery.GL_TIMESTAMP); pending.computeIfAbsent(active.phase(), k -> new ArrayDeque<>()) - .addLast(active.queryId()); - active = null; + .addLast(packQueryPair(active.startQueryId(), active.endQueryId())); } catch (Exception e) { taskmanagerClient.LOGGER.debug("GpuTimer.end failed: {}", e.getMessage()); - active = null; } } @@ -70,13 +83,20 @@ public static void collectResults() { pending.forEach((phase, queue) -> { while (!queue.isEmpty()) { - int queryId = queue.peekFirst(); - int available = GL15.glGetQueryObjecti(queryId, GL15.GL_QUERY_RESULT_AVAILABLE); - if (available == 0) break; // Not ready yet, check next frame + long packedQueries = queue.peekFirst(); + int startQueryId = unpackStartQuery(packedQueries); + int endQueryId = unpackEndQuery(packedQueries); + int availableStart = GL15.glGetQueryObjecti(startQueryId, GL15.GL_QUERY_RESULT_AVAILABLE); + int availableEnd = GL15.glGetQueryObjecti(endQueryId, GL15.GL_QUERY_RESULT_AVAILABLE); + if (availableStart == 0 || availableEnd == 0) break; queue.pollFirst(); - long gpuNs = GL15.glGetQueryObjecti(queryId, GL15.GL_QUERY_RESULT); - GL15.glDeleteQueries(queryId); - profiler.recordGpuResult(phase, gpuNs < 0 ? gpuNs + 0x100000000L : gpuNs); + long startNs = readQuery64(startQueryId); + long endNs = readQuery64(endQueryId); + releaseQuery(startQueryId); + releaseQuery(endQueryId); + if (startNs >= 0L && endNs >= startNs) { + profiler.recordGpuResult(phase, endNs - startNs); + } } }); } @@ -86,4 +106,27 @@ public static long readQuery64(int queryId) { if (available == 0) return -1; return ARBTimerQuery.glGetQueryObjectui64(queryId, GL15.GL_QUERY_RESULT); } + + private static int acquireQuery() { + Integer recycled = freeQueries.pollFirst(); + return recycled != null ? recycled : GL15.glGenQueries(); + } + + private static void releaseQuery(int queryId) { + if (queryId != 0) { + freeQueries.addLast(queryId); + } + } + + private static long packQueryPair(int startQueryId, int endQueryId) { + return (((long) startQueryId) << 32) | (endQueryId & 0xffffffffL); + } + + private static int unpackStartQuery(long packedQueries) { + return (int) (packedQueries >>> 32); + } + + private static int unpackEndQuery(long packedQueries) { + return (int) packedQueries; + } } diff --git a/src/client/java/wueffi/taskmanager/client/util/ModClassIndex.java b/src/client/java/wueffi/taskmanager/client/util/ModClassIndex.java index b60e425..438d98d 100644 --- a/src/client/java/wueffi/taskmanager/client/util/ModClassIndex.java +++ b/src/client/java/wueffi/taskmanager/client/util/ModClassIndex.java @@ -5,13 +5,17 @@ import java.nio.file.Path; import java.security.CodeSource; -import java.util.HashMap; import java.util.Map; public class ModClassIndex { - private static final Map cache = new HashMap<>(); - private static final Map normalizedRootToMod = new HashMap<>(); + private static final String UNKNOWN_SENTINEL = "\u0000"; + private static final int MAX_CLASS_CACHE_ENTRIES = 16_384; + private static final int MAX_ROOT_CACHE_ENTRIES = 1_024; + private static final int MAX_PACKAGE_CACHE_ENTRIES = 8_192; + private static final Map cache = BoundedMaps.synchronizedLru(MAX_CLASS_CACHE_ENTRIES); + private static final Map normalizedRootToMod = BoundedMaps.synchronizedLru(MAX_ROOT_CACHE_ENTRIES); + private static final Map packageCache = BoundedMaps.synchronizedLru(MAX_PACKAGE_CACHE_ENTRIES); private static boolean built = false; public static void build() { @@ -23,7 +27,7 @@ public static void build() { for (Path root : container.getRootPaths()) { try { String normalized = normalizePath(root.toUri().toURL().toString()); - normalizedRootToMod.put(normalized, modId); + BoundedMaps.put(normalizedRootToMod, normalized, modId); } catch (Exception ignored) { } } @@ -37,7 +41,13 @@ public static String getModForClassName(Class clazz) { if (className == null || className.isBlank()) { return null; } - if (cache.containsKey(className)) return cache.get(className); + String cached = BoundedMaps.get(cache, className); + if (cached != null) return decodeCachedValue(cached); + String packageHit = lookupPackageCache(className); + if (packageHit != null) { + BoundedMaps.put(cache, className, encodeCachedValue(packageHit)); + return packageHit; + } String classSource = null; try { @@ -49,15 +59,18 @@ public static String getModForClassName(Class clazz) { } if (classSource != null) { - for (Map.Entry entry : normalizedRootToMod.entrySet()) { - if (classSource.equals(entry.getKey()) || classSource.startsWith(entry.getKey())) { - cache.put(className, entry.getValue()); - return entry.getValue(); + synchronized (normalizedRootToMod) { + for (Map.Entry entry : normalizedRootToMod.entrySet()) { + if (classSource.equals(entry.getKey()) || classSource.startsWith(entry.getKey())) { + BoundedMaps.put(cache, className, encodeCachedValue(entry.getValue())); + cachePackagePrefixes(className, entry.getValue()); + return entry.getValue(); + } } } } - cache.put(className, null); + BoundedMaps.put(cache, className, UNKNOWN_SENTINEL); return null; } @@ -69,15 +82,30 @@ public static String getModForClassName(String rawClassName) { return null; } - if (cache.containsKey(className)) { - return cache.get(className); + String cached = BoundedMaps.get(cache, className); + if (cached != null) { + return decodeCachedValue(cached); + } + String packageHit = lookupPackageCache(className); + if (packageHit != null) { + BoundedMaps.put(cache, className, encodeCachedValue(packageHit)); + return packageHit; + } + + ClassLoader contextLoader = Thread.currentThread().getContextClassLoader(); + if (contextLoader != null) { + try { + Class clazz = Class.forName(className, false, contextLoader); + return getModForClassName(clazz); + } catch (Throwable ignored) { + } } try { Class clazz = Class.forName(className, false, ModClassIndex.class.getClassLoader()); return getModForClassName(clazz); } catch (Throwable ignored) { - cache.put(className, null); + BoundedMaps.put(cache, className, UNKNOWN_SENTINEL); return null; } } @@ -121,4 +149,37 @@ private static String normalizePath(String url) { .replace("!", "") .toLowerCase(); } + + private static String lookupPackageCache(String className) { + int separator = className.length(); + while (separator > 0) { + separator = className.lastIndexOf('.', separator - 1); + if (separator <= 0) { + break; + } + String prefix = className.substring(0, separator); + String cached = BoundedMaps.get(packageCache, prefix); + if (cached != null) { + return cached; + } + } + return null; + } + + private static void cachePackagePrefixes(String className, String modId) { + int separator = className.lastIndexOf('.'); + while (separator > 0) { + String prefix = className.substring(0, separator); + BoundedMaps.putIfAbsent(packageCache, prefix, modId); + separator = className.lastIndexOf('.', separator - 1); + } + } + + private static String encodeCachedValue(String modId) { + return modId == null ? UNKNOWN_SENTINEL : modId; + } + + private static String decodeCachedValue(String cachedValue) { + return UNKNOWN_SENTINEL.equals(cachedValue) ? null : cachedValue; + } } diff --git a/src/client/resources/taskmanager.client.mixins.json b/src/client/resources/taskmanager.client.mixins.json index 05081f0..867d4a3 100644 --- a/src/client/resources/taskmanager.client.mixins.json +++ b/src/client/resources/taskmanager.client.mixins.json @@ -5,11 +5,22 @@ "compatibilityLevel": "JAVA_21", "client": [ "GameRendererMixin", + "IrisDeferredWorldRenderingPipelineMixin", + "IrisNewWorldRenderingPipelineMixin", + "IrisVanillaRenderingPipelineMixin", "MouseMixin", "MinecraftClientMixin", "MinecraftServerMixin", + "MinecraftServerSaveMixin", + "ServerChunkManagerMixin", "SkyRenderingMixin", + "SodiumWorldRendererMixin", "WorldRendererMixin", + "ParticleManagerMixin", + "ChunkGeneratorMixin", + "EntityMixin", + "EntityRenderManagerMixin", + "ShaderProgramMixin", "ArrayBackedEventMixin", "ClientConnectionMixin", "FabricLoaderImplMixin" diff --git a/src/test/java/wueffi/taskmanager/client/AttributionInsightsTests.java b/src/test/java/wueffi/taskmanager/client/AttributionInsightsTests.java new file mode 100644 index 0000000..6672ee8 --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/AttributionInsightsTests.java @@ -0,0 +1,67 @@ +package wueffi.taskmanager.client; + +public final class AttributionInsightsTests { + + private AttributionInsightsTests() { + } + + public static void run() { + attributeThreadPreservesAlternateCandidates(); + attributeThreadFallsBackCleanlyWithoutStack(); + } + + private static void attributeThreadPreservesAlternateCandidates() { + StackTraceElement[] stack = new StackTraceElement[] { + new StackTraceElement("net.minecraft.server.world.ServerChunkManager", "tick", "ServerChunkManager.java", 0), + new StackTraceElement("net.fabricmc.fabric.impl.base.event.ArrayBackedEvent", "update", "ArrayBackedEvent.java", 0), + new StackTraceElement("java.util.concurrent.ThreadPoolExecutor", "runWorker", "ThreadPoolExecutor.java", 0) + }; + + AttributionInsights.ThreadAttribution attribution = AttributionInsights.attributeThread("Chunk Task Executor", stack); + assertEquals("minecraft", attribution.ownerMod(), "primary owner should prefer the first concrete mod frame"); + assertEquals(AttributionInsights.Confidence.INFERRED, attribution.confidence(), "primary owner confidence"); + assertFalse(attribution.candidates().isEmpty(), "candidates should not be empty"); + assertEquals("minecraft", attribution.candidates().getFirst().modId(), "first candidate should preserve the dominant mod"); + assertTrue(attribution.candidates().size() >= 2, "alternate candidates should survive beyond the first concrete frame"); + assertTrue(attribution.candidateLabels().stream().anyMatch(label -> label.contains("minecraft")), "candidate labels should include the dominant mod"); + assertTrue(attribution.candidateLabels().stream().anyMatch(label -> label.contains("shared/framework") || label.contains("shared/jvm")), + "candidate labels should include alternate non-primary owners from deeper frames"); + } + + private static void attributeThreadFallsBackCleanlyWithoutStack() { + AttributionInsights.ThreadAttribution attribution = AttributionInsights.attributeThread("Server Thread", null); + assertEquals("minecraft", attribution.ownerMod(), "server fallback owner"); + assertEquals(AttributionInsights.Confidence.WEAK_HEURISTIC, attribution.confidence(), "fallback confidence should stay conservative"); + assertEquals(1, attribution.candidates().size(), "fallback attribution should still expose one candidate"); + } + + private static void assertTrue(boolean value, String message) { + if (!value) { + throw new AssertionError(message); + } + } + + private static void assertFalse(boolean value, String message) { + if (value) { + throw new AssertionError(message); + } + } + + private static void assertEquals(String expected, String actual, String message) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } + + private static void assertEquals(int expected, int actual, String message) { + if (expected != actual) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } + + private static void assertEquals(AttributionInsights.Confidence expected, AttributionInsights.Confidence actual, String message) { + if (expected != actual) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/AttributionModelBuilderTests.java b/src/test/java/wueffi/taskmanager/client/AttributionModelBuilderTests.java new file mode 100644 index 0000000..20c1ff7 --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/AttributionModelBuilderTests.java @@ -0,0 +1,79 @@ +package wueffi.taskmanager.client; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class AttributionModelBuilderTests { + + private AttributionModelBuilderTests() { + } + + public static void run() { + gpuPhaseOwnerPromotesWhenLikelyOwnerIsStrong(); + gpuPhaseOwnerStaysSharedWhenLikelyOwnerIsWeak(); + cpuRedistributionDownweightsRenderSubmissionHeavyMods(); + } + + private static void gpuPhaseOwnerPromotesWhenLikelyOwnerIsStrong() { + RenderPhaseProfiler.PhaseSnapshot phase = new RenderPhaseProfiler.PhaseSnapshot( + 0L, 0L, 10_000_000L, 1L, "shared/render", + Map.of("iris", 7L, "minecraft", 2L), + Map.of() + ); + assertEquals("iris", AttributionModelBuilder.effectiveGpuPhaseOwner(phase), "strong likely-owner majority should claim the phase"); + } + + private static void gpuPhaseOwnerStaysSharedWhenLikelyOwnerIsWeak() { + RenderPhaseProfiler.PhaseSnapshot phase = new RenderPhaseProfiler.PhaseSnapshot( + 0L, 0L, 10_000_000L, 1L, "shared/render", + Map.of("iris", 1L, "minecraft", 1L), + Map.of() + ); + assertEquals("shared/render", AttributionModelBuilder.effectiveGpuPhaseOwner(phase), "weak likely-owner hints should leave the phase shared"); + } + + private static void cpuRedistributionDownweightsRenderSubmissionHeavyMods() { + Map rawCpu = new LinkedHashMap<>(); + rawCpu.put("chunky", new CpuSamplingProfiler.Snapshot(100L, 0L, 100L, 100L, 0L, 100L)); + rawCpu.put("sodium", new CpuSamplingProfiler.Snapshot(100L, 0L, 100L, 100L, 0L, 100L)); + rawCpu.put("shared/framework", new CpuSamplingProfiler.Snapshot(100L, 0L, 100L, 100L, 0L, 100L)); + + Map cpuDetails = new LinkedHashMap<>(); + cpuDetails.put("chunky", new CpuSamplingProfiler.DetailSnapshot( + linkedLongs("Render thread", 10L), + linkedLongs("GL30C#glBindFramebuffer", 10L, "JNI#invokePV", 5L), + 1 + )); + cpuDetails.put("sodium", new CpuSamplingProfiler.DetailSnapshot( + linkedLongs("Render thread", 10L), + linkedLongs("ChunkBuilder#buildMeshes", 10L, "SectionCompiler#compile", 5L), + 1 + )); + + AttributionModelBuilder.EffectiveCpuAttribution effective = AttributionModelBuilder.buildEffectiveCpuAttribution(rawCpu, cpuDetails, Map.of()); + long chunkySamples = effective.displaySnapshots().get("chunky").totalSamples(); + long sodiumSamples = effective.displaySnapshots().get("sodium").totalSamples(); + if (chunkySamples >= sodiumSamples) { + throw new AssertionError("render-submission-heavy rows should receive less redistributed CPU: chunky=" + chunkySamples + ", sodium=" + sodiumSamples); + } + } + + private static Map linkedLongs(String firstKey, long firstValue) { + Map result = new LinkedHashMap<>(); + result.put(firstKey, firstValue); + return result; + } + + private static Map linkedLongs(String firstKey, long firstValue, String secondKey, long secondValue) { + Map result = new LinkedHashMap<>(); + result.put(firstKey, firstValue); + result.put(secondKey, secondValue); + return result; + } + + private static void assertEquals(String expected, String actual, String message) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/BoundedMapsTests.java b/src/test/java/wueffi/taskmanager/client/BoundedMapsTests.java new file mode 100644 index 0000000..c0af2d4 --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/BoundedMapsTests.java @@ -0,0 +1,52 @@ +package wueffi.taskmanager.client; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import wueffi.taskmanager.client.util.BoundedMaps; + +public final class BoundedMapsTests { + + private BoundedMapsTests() { + } + + public static void run() { + synchronizedLruEvictsLeastRecentlyUsedEntry(); + getOrComputeCachesComputedValue(); + } + + private static void synchronizedLruEvictsLeastRecentlyUsedEntry() { + Map cache = BoundedMaps.synchronizedLru(2); + cache.put("a", "alpha"); + cache.put("b", "beta"); + cache.get("a"); + cache.put("c", "gamma"); + + if (cache.containsKey("b")) { + throw new AssertionError("least recently used entry should be evicted first"); + } + if (!cache.containsKey("a") || !cache.containsKey("c")) { + throw new AssertionError("recent entries should remain present after eviction"); + } + } + + private static void getOrComputeCachesComputedValue() { + Map cache = BoundedMaps.synchronizedLru(4); + AtomicInteger invocations = new AtomicInteger(); + + String first = BoundedMaps.getOrCompute(cache, "mod", key -> { + invocations.incrementAndGet(); + return "resolved"; + }); + String second = BoundedMaps.getOrCompute(cache, "mod", key -> { + invocations.incrementAndGet(); + return "other"; + }); + + if (!"resolved".equals(first) || !"resolved".equals(second)) { + throw new AssertionError("cached value should be returned after the first computation"); + } + if (invocations.get() != 1) { + throw new AssertionError("resolver should only be called once per cached key"); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/CollectorMathTests.java b/src/test/java/wueffi/taskmanager/client/CollectorMathTests.java new file mode 100644 index 0000000..73d2f8f --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/CollectorMathTests.java @@ -0,0 +1,38 @@ +package wueffi.taskmanager.client; + +public final class CollectorMathTests { + + private CollectorMathTests() { + } + + public static void run() { + splitBudgetDistributesRemainderPredictably(); + adaptiveWorldScanCadenceStaysFastWhenCheap(); + adaptiveWorldScanCadenceBacksOffAfterExpensiveScans(); + } + + private static void splitBudgetDistributesRemainderPredictably() { + long[] shares = CollectorMath.splitBudget(10L, 3); + assertEquals(3, shares.length, "share count"); + assertEquals(4L, shares[0], "first share"); + assertEquals(3L, shares[1], "second share"); + assertEquals(3L, shares[2], "third share"); + } + + private static void adaptiveWorldScanCadenceStaysFastWhenCheap() { + assertEquals(125L, CollectorMath.computeAdaptiveWorldScanCadenceMillis(true, false, false, 0L), "detailed cadence baseline"); + assertEquals(250L, CollectorMath.computeAdaptiveWorldScanCadenceMillis(false, false, false, 0L), "light cadence baseline"); + } + + private static void adaptiveWorldScanCadenceBacksOffAfterExpensiveScans() { + assertEquals(325L, CollectorMath.computeAdaptiveWorldScanCadenceMillis(true, false, false, 5L), "detailed cadence should widen after a 5ms scan"); + assertEquals(1000L, CollectorMath.computeAdaptiveWorldScanCadenceMillis(false, false, false, 100L), "light cadence should cap the backoff"); + assertEquals(750L, CollectorMath.computeAdaptiveWorldScanCadenceMillis(false, false, true, 0L), "self-protection should widen the baseline cadence"); + } + + private static void assertEquals(long expected, long actual, String message) { + if (expected != actual) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/ConfigManagerMigrationTests.java b/src/test/java/wueffi/taskmanager/client/ConfigManagerMigrationTests.java index 769fbe0..5a4b15e 100644 --- a/src/test/java/wueffi/taskmanager/client/ConfigManagerMigrationTests.java +++ b/src/test/java/wueffi/taskmanager/client/ConfigManagerMigrationTests.java @@ -12,60 +12,59 @@ private ConfigManagerMigrationTests() { } public static void run() { - migrateLegacyFieldsBackfillsNewHudDefaults(); + primitiveHudDefaultsAreStable(); + migrateLegacyFieldsRepairsZeroThresholds(); frameBudgetTargetDefaultsAndCycles(); } - private static void migrateLegacyFieldsBackfillsNewHudDefaults() { + private static void primitiveHudDefaultsAreStable() { try { Class configDataClass = Class.forName("wueffi.taskmanager.client.util.ConfigManager$ConfigData"); - Constructor constructor = configDataClass.getDeclaredConstructor(); - constructor.setAccessible(true); - Object configData = constructor.newInstance(); - - setField(configDataClass, configData, "hudShowLogic", null); - setField(configDataClass, configData, "hudShowBackground", null); - setField(configDataClass, configData, "hudShowFrameBudget", null); - setField(configDataClass, configData, "hudShowVram", null); - setField(configDataClass, configData, "hudShowNetwork", null); - setField(configDataClass, configData, "hudShowChunkActivity", null); - setField(configDataClass, configData, "hudShowDiskIo", null); - setField(configDataClass, configData, "hudShowInputLatency", null); - setField(configDataClass, configData, "hudShowVramRateOfChange", null); - setField(configDataClass, configData, "hudShowNetworkRateOfChange", null); - setField(configDataClass, configData, "hudShowChunkActivityRateOfChange", null); - setField(configDataClass, configData, "hudShowDiskIoRateOfChange", null); - setField(configDataClass, configData, "hudShowInputLatencyRateOfChange", null); - setField(configDataClass, configData, "hudAutoFocusAlertRow", null); - setField(configDataClass, configData, "hudBudgetColorMode", null); - - Field configField = ConfigManager.class.getDeclaredField("config"); - configField.setAccessible(true); - Object previous = configField.get(null); - configField.set(null, configData); - try { - Method migrateMethod = ConfigManager.class.getDeclaredMethod("migrateLegacyFields"); - migrateMethod.setAccessible(true); - migrateMethod.invoke(null); - - assertTrue(ConfigManager.isHudShowLogic(), "logic should default on"); - assertTrue(ConfigManager.isHudShowBackground(), "background should default on"); - assertTrue(ConfigManager.isHudShowFrameBudget(), "frame budget should default on"); - assertTrue(ConfigManager.isHudShowVram(), "vram should default on"); - assertFalse(ConfigManager.isHudShowNetwork(), "network should default off"); - assertFalse(ConfigManager.isHudShowChunkActivity(), "chunk activity should default off"); - assertFalse(ConfigManager.isHudShowDiskIo(), "disk I/O should default off"); - assertFalse(ConfigManager.isHudShowInputLatency(), "input latency should default off"); - assertTrue(ConfigManager.isHudShowVramRateOfChange(), "vram rate should default on"); - assertTrue(ConfigManager.isHudShowNetworkRateOfChange(), "network rate should default on"); - assertTrue(ConfigManager.isHudShowChunkActivityRateOfChange(), "chunk activity rate should default on"); - assertTrue(ConfigManager.isHudShowDiskIoRateOfChange(), "disk I/O rate should default on"); - assertTrue(ConfigManager.isHudShowInputLatencyRateOfChange(), "input latency rate should default on"); - assertTrue(ConfigManager.isHudAutoFocusAlertRow(), "auto-focus alert should default on"); - assertTrue(ConfigManager.isHudBudgetColorMode(), "budget color mode should default on"); - } finally { - configField.set(null, previous); - } + Object configData = newConfigData(configDataClass); + + assertPrimitiveBoolean(configDataClass, "hudShowLogic"); + assertPrimitiveBoolean(configDataClass, "hudShowBackground"); + assertPrimitiveBoolean(configDataClass, "hudShowFrameBudget"); + assertPrimitiveBoolean(configDataClass, "performanceAlertsEnabled"); + assertPrimitiveBoolean(configDataClass, "performanceAlertChatEnabled"); + + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowLogic"), "logic should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowBackground"), "background should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowFrameBudget"), "frame budget should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowVram"), "vram should default on"); + assertEquals(false, getBooleanField(configDataClass, configData, "hudShowNetwork"), "network should default off"); + assertEquals(false, getBooleanField(configDataClass, configData, "hudShowChunkActivity"), "chunk activity should default off"); + assertEquals(false, getBooleanField(configDataClass, configData, "hudShowDiskIo"), "disk I/O should default off"); + assertEquals(false, getBooleanField(configDataClass, configData, "hudShowInputLatency"), "input latency should default off"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowVramRateOfChange"), "vram rate should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowNetworkRateOfChange"), "network rate should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowChunkActivityRateOfChange"), "chunk activity rate should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowDiskIoRateOfChange"), "disk I/O rate should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudShowInputLatencyRateOfChange"), "input latency rate should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudAutoFocusAlertRow"), "auto-focus alert should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "hudBudgetColorMode"), "budget color mode should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "performanceAlertsEnabled"), "performance alerts should default on"); + assertEquals(true, getBooleanField(configDataClass, configData, "performanceAlertChatEnabled"), "performance alert chat should default on"); + } catch (Exception e) { + throw new AssertionError("config defaults reflection failed", e); + } + } + + private static void migrateLegacyFieldsRepairsZeroThresholds() { + try { + Class configDataClass = Class.forName("wueffi.taskmanager.client.util.ConfigManager$ConfigData"); + Object configData = newConfigData(configDataClass); + + setField(configDataClass, configData, "performanceAlertFrameThresholdMs", 0); + setField(configDataClass, configData, "performanceAlertServerThresholdMs", 0); + setField(configDataClass, configData, "performanceAlertConsecutiveTicks", 0); + + withInjectedConfig(configData, () -> { + invokeMigrateLegacyFields(); + assertEquals(25, ConfigManager.getPerformanceAlertFrameThresholdMs(), "frame alert threshold default"); + assertEquals(20, ConfigManager.getPerformanceAlertServerThresholdMs(), "server alert threshold default"); + assertEquals(3, ConfigManager.getPerformanceAlertConsecutiveTicks(), "performance alert consecutive ticks default"); + }); } catch (Exception e) { throw new AssertionError("config migration reflection failed", e); } @@ -74,46 +73,66 @@ private static void migrateLegacyFieldsBackfillsNewHudDefaults() { private static void frameBudgetTargetDefaultsAndCycles() { try { Class configDataClass = Class.forName("wueffi.taskmanager.client.util.ConfigManager$ConfigData"); - Constructor constructor = configDataClass.getDeclaredConstructor(); - constructor.setAccessible(true); - Object configData = constructor.newInstance(); + Object configData = newConfigData(configDataClass); setField(configDataClass, configData, "frameBudgetTargetFps", 0); - Field configField = ConfigManager.class.getDeclaredField("config"); - configField.setAccessible(true); - Object previous = configField.get(null); - configField.set(null, configData); - try { - Method migrateMethod = ConfigManager.class.getDeclaredMethod("migrateLegacyFields"); - migrateMethod.setAccessible(true); - migrateMethod.invoke(null); - + withInjectedConfig(configData, () -> { + invokeMigrateLegacyFields(); assertEquals(60, ConfigManager.getFrameBudgetTargetFps(), "frame budget target fps default"); ConfigManager.cycleFrameBudgetTargetFps(); assertEquals(72, ConfigManager.getFrameBudgetTargetFps(), "frame budget target fps cycle"); - } finally { - configField.set(null, previous); - } + }); } catch (Exception e) { throw new AssertionError("frame budget target reflection failed", e); } } + private static Object newConfigData(Class configDataClass) throws Exception { + Constructor constructor = configDataClass.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private static void withInjectedConfig(Object configData, ThrowingRunnable body) throws Exception { + Field configField = ConfigManager.class.getDeclaredField("config"); + configField.setAccessible(true); + Object previous = configField.get(null); + configField.set(null, configData); + try { + body.run(); + } finally { + configField.set(null, previous); + } + } + + private static void invokeMigrateLegacyFields() throws Exception { + Method migrateMethod = ConfigManager.class.getDeclaredMethod("migrateLegacyFields"); + migrateMethod.setAccessible(true); + migrateMethod.invoke(null); + } + + private static boolean getBooleanField(Class type, Object target, String name) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + return field.getBoolean(target); + } + private static void setField(Class type, Object target, String name, Object value) throws Exception { Field field = type.getDeclaredField(name); field.setAccessible(true); field.set(target, value); } - private static void assertTrue(boolean value, String message) { - if (!value) { - throw new AssertionError(message); + private static void assertPrimitiveBoolean(Class type, String fieldName) throws Exception { + Field field = type.getDeclaredField(fieldName); + if (field.getType() != boolean.class) { + throw new AssertionError(fieldName + " should be a primitive boolean but was " + field.getType()); } } - private static void assertFalse(boolean value, String message) { - if (value) { - throw new AssertionError(message); + private static void assertEquals(boolean expected, boolean actual, String message) { + if (expected != actual) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); } } @@ -122,4 +141,9 @@ private static void assertEquals(int expected, int actual, String message) { throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); } } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } } diff --git a/src/test/java/wueffi/taskmanager/client/CpuSamplingProfilerTests.java b/src/test/java/wueffi/taskmanager/client/CpuSamplingProfilerTests.java new file mode 100644 index 0000000..3931d3b --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/CpuSamplingProfilerTests.java @@ -0,0 +1,59 @@ +package wueffi.taskmanager.client; + +import java.lang.reflect.Method; + +public final class CpuSamplingProfilerTests { + + private CpuSamplingProfilerTests() { + } + + public static void run() { + fallbackFramePrefersNonRuntimeFrameOverDriverFrame(); + gpuDriverWaitClassifiesIntoSharedStallBucket(); + } + + private static void fallbackFramePrefersNonRuntimeFrameOverDriverFrame() { + try { + CpuSamplingProfiler profiler = CpuSamplingProfiler.getInstance(); + Method findFallbackFrame = CpuSamplingProfiler.class.getDeclaredMethod("findFallbackFrame", StackTraceElement[].class); + findFallbackFrame.setAccessible(true); + StackTraceElement[] stack = new StackTraceElement[] { + new StackTraceElement("org.lwjgl.opengl.GL30C", "glBindFramebuffer", "GL30C.java", 0), + new StackTraceElement("org.lwjgl.system.JNI", "invokePV", "JNI.java", 0), + new StackTraceElement("net.minecraft.client.render.WorldRenderer", "render", "WorldRenderer.java", 1200) + }; + + String reason = (String) findFallbackFrame.invoke(profiler, (Object) stack); + assertEquals("WorldRenderer#render", reason, "attribution should explain the closest non-runtime CPU frame instead of the OpenGL submission frame"); + } catch (ReflectiveOperationException e) { + throw new AssertionError("reflection failure", e); + } + } + + private static void gpuDriverWaitClassifiesIntoSharedStallBucket() { + try { + CpuSamplingProfiler profiler = CpuSamplingProfiler.getInstance(); + Method attributeStack = CpuSamplingProfiler.class.getDeclaredMethod("attributeStack", StackTraceElement[].class, String.class, long.class, long.class); + attributeStack.setAccessible(true); + StackTraceElement[] stack = new StackTraceElement[] { + new StackTraceElement("org.lwjgl.opengl.GL32C", "glFenceSync", "GL32C.java", 0), + new StackTraceElement("org.lwjgl.system.JNI", "invokePV", "JNI.java", 0), + new StackTraceElement("net.fabricmc.fabric.impl.client.rendering.WorldRenderContextImpl", "render", "WorldRenderContextImpl.java", 120), + new StackTraceElement("org.embeddedt.chunky.SomeRenderHook", "draw", "SomeRenderHook.java", 64) + }; + + Object attribution = attributeStack.invoke(profiler, (Object) stack, "Render thread", 200_000L, 2_000_000L); + Method modId = attribution.getClass().getDeclaredMethod("modId"); + String attributedMod = (String) modId.invoke(attribution); + assertEquals("shared/gpu-stall", attributedMod, "GPU driver waits should be carried as a shared stall bucket instead of inflating a mod row"); + } catch (ReflectiveOperationException e) { + throw new AssertionError("reflection failure", e); + } + } + + private static void assertEquals(String expected, String actual, String message) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/FrameTimelineProfilerTests.java b/src/test/java/wueffi/taskmanager/client/FrameTimelineProfilerTests.java index cb409ae..cf744d3 100644 --- a/src/test/java/wueffi/taskmanager/client/FrameTimelineProfilerTests.java +++ b/src/test/java/wueffi/taskmanager/client/FrameTimelineProfilerTests.java @@ -9,6 +9,8 @@ public static void run() { rollingFpsUsesRecentWindow(); averageAndLowFpsTrackRecordedFrames(); orderedFrameTimestampsStayInInsertionOrder(); + selfCostHistoryTracksLastFrame(); + returnedArraysAreDefensiveCopies(); } private static void rollingFpsUsesRecentWindow() { @@ -47,6 +49,37 @@ private static void orderedFrameTimestampsStayInInsertionOrder() { assertEquals(33_000_000L, timestamps[2], "third timestamp"); } + private static void selfCostHistoryTracksLastFrame() { + FrameTimelineProfiler profiler = new FrameTimelineProfiler(); + profiler.reset(); + profiler.addSelfCost(250_000L); + profiler.recordFrame(10_000_000L, 10_000_000L); + profiler.addSelfCost(750_000L); + profiler.recordFrame(11_000_000L, 21_000_000L); + + double[] selfCostHistory = profiler.getOrderedSelfCostMsHistory(); + assertEquals(2, selfCostHistory.length, "self-cost history length"); + assertNear(0.25, selfCostHistory[0], 0.0001, "first self-cost value"); + assertNear(0.75, selfCostHistory[1], 0.0001, "second self-cost value"); + assertNear(0.5, profiler.getSelfCostAvgMs(), 0.0001, "average self-cost"); + assertNear(0.75, profiler.getSelfCostMaxMs(), 0.0001, "max self-cost"); + } + + private static void returnedArraysAreDefensiveCopies() { + FrameTimelineProfiler profiler = new FrameTimelineProfiler(); + profiler.reset(); + profiler.recordFrame(10_000_000L, 10_000_000L); + profiler.recordFrame(11_000_000L, 21_000_000L); + + long[] frames = profiler.getFrames(); + double[] fpsHistory = profiler.getFpsHistory(); + frames[0] = 999L; + fpsHistory[0] = 999L; + + assertEquals(10_000_000L, profiler.getFrames()[0], "frame history should be copied"); + assertNear(100.0, profiler.getFpsHistory()[0], 0.0001, "fps history should be copied"); + } + private static void assertNear(double expected, double actual, double tolerance, String message) { if (Math.abs(expected - actual) > tolerance) { throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); diff --git a/src/test/java/wueffi/taskmanager/client/HardwareInfoResolverTests.java b/src/test/java/wueffi/taskmanager/client/HardwareInfoResolverTests.java new file mode 100644 index 0000000..435c196 --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/HardwareInfoResolverTests.java @@ -0,0 +1,18 @@ +package wueffi.taskmanager.client; + +public final class HardwareInfoResolverTests { + + private HardwareInfoResolverTests() { + } + + public static void run() { + sanitizeCpuLabelCollapsesWhitespace(); + } + + private static void sanitizeCpuLabelCollapsesWhitespace() { + String value = HardwareInfoResolver.sanitizeCpuLabel(" AMD Ryzen 7 7800X3D \0 8-Core Processor "); + if (!"AMD Ryzen 7 7800X3D 8-Core Processor".equals(value)) { + throw new AssertionError("CPU label sanitization should collapse whitespace and nulls: " + value); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/ProfilerManagerTests.java b/src/test/java/wueffi/taskmanager/client/ProfilerManagerTests.java index d946bc1..8aede87 100644 --- a/src/test/java/wueffi/taskmanager/client/ProfilerManagerTests.java +++ b/src/test/java/wueffi/taskmanager/client/ProfilerManagerTests.java @@ -9,6 +9,7 @@ public static void run() { snapshotPublishingHonorsForceAndDelay(); missedSampleCountDetectsDroppedIntervals(); observedSampleIntervalUsesAverageGap(); + sessionLoggingKeepsCaptureActiveInManualDeep(); } private static void snapshotPublishingHonorsForceAndDelay() { @@ -30,6 +31,13 @@ private static void observedSampleIntervalUsesAverageGap() { assertEquals(50L, ProfilerManager.computeObservedSampleIntervalMs(new long[] {1000L}, 50), "fallback interval should be used for tiny samples"); } + private static void sessionLoggingKeepsCaptureActiveInManualDeep() { + assertTrue(ProfilerManager.computeCaptureActive(ProfilerManager.CaptureMode.MANUAL_DEEP, false, true), + "manual deep recording should stay active while a session is being recorded"); + assertFalse(ProfilerManager.computeCaptureActive(ProfilerManager.CaptureMode.MANUAL_DEEP, false, false), + "manual deep without screen or session should stay inactive"); + } + private static void assertTrue(boolean value, String message) { if (!value) { throw new AssertionError(message); diff --git a/src/test/java/wueffi/taskmanager/client/RenderPhaseProfilerTests.java b/src/test/java/wueffi/taskmanager/client/RenderPhaseProfilerTests.java new file mode 100644 index 0000000..93e515a --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/RenderPhaseProfilerTests.java @@ -0,0 +1,71 @@ +package wueffi.taskmanager.client; + +public final class RenderPhaseProfilerTests { + + private RenderPhaseProfilerTests() { + } + + public static void run() { + nestedCpuScopesTrackEachPhaseSeparately(); + renderContextOwnerBecomesLikelyOwnerHint(); + } + + private static void nestedCpuScopesTrackEachPhaseSeparately() { + RenderPhaseProfiler profiler = RenderPhaseProfiler.getInstance(); + profiler.reset(); + + profiler.beginCpuPhase("outer"); + profiler.beginCpuPhase("inner"); + profiler.endCpuPhase("inner"); + profiler.endCpuPhase("outer"); + + RenderPhaseProfiler.PhaseSnapshot outer = profiler.getSnapshot().get("outer"); + RenderPhaseProfiler.PhaseSnapshot inner = profiler.getSnapshot().get("inner"); + + assertNotNull(outer, "outer phase snapshot"); + assertNotNull(inner, "inner phase snapshot"); + assertEquals(1L, outer.cpuCalls(), "outer call count"); + assertEquals(1L, inner.cpuCalls(), "inner call count"); + assertTrue(outer.cpuNanos() > 0L, "outer cpu nanos"); + assertTrue(inner.cpuNanos() > 0L, "inner cpu nanos"); + } + + private static void renderContextOwnerBecomesLikelyOwnerHint() { + RenderPhaseProfiler profiler = RenderPhaseProfiler.getInstance(); + profiler.reset(); + + profiler.pushContextOwner("chunky"); + profiler.beginCpuPhase("worldRenderer.renderEntities", "shared/render"); + profiler.endCpuPhase("worldRenderer.renderEntities"); + profiler.popContextOwner(); + + RenderPhaseProfiler.PhaseSnapshot snapshot = profiler.getSnapshot().get("worldRenderer.renderEntities"); + assertNotNull(snapshot, "render phase snapshot"); + assertEquals("shared/render", snapshot.ownerMod(), "shared phase owner should stay honest"); + assertEquals(1L, snapshot.likelyOwners().getOrDefault("chunky", 0L), "context owner should be captured as a likely owner hint"); + } + + private static void assertTrue(boolean value, String message) { + if (!value) { + throw new AssertionError(message); + } + } + + private static void assertEquals(long expected, long actual, String message) { + if (expected != actual) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } + + private static void assertEquals(String expected, String actual, String message) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } + + private static void assertNotNull(Object value, String message) { + if (value == null) { + throw new AssertionError(message); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/RuleEngineTests.java b/src/test/java/wueffi/taskmanager/client/RuleEngineTests.java new file mode 100644 index 0000000..9cb63c5 --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/RuleEngineTests.java @@ -0,0 +1,106 @@ +package wueffi.taskmanager.client; + +import java.util.List; + +public final class RuleEngineTests { + + private RuleEngineTests() { + } + + public static void run() { + severityRankOrdersFindingsByImportance(); + heuristicsClassifyEntityAndBlockEntityFamilies(); + jvmAdvisorFallsBackCleanlyForHealthySnapshot(); + jvmAdvisorFlagsDirectMemoryPressure(); + } + + private static void severityRankOrdersFindingsByImportance() { + assertEquals(3, RuleEngine.severityRank("critical"), "critical rank"); + assertEquals(2, RuleEngine.severityRank("error"), "error rank"); + assertEquals(1, RuleEngine.severityRank("warning"), "warning rank"); + assertEquals(0, RuleEngine.severityRank("info"), "info rank"); + } + + private static void heuristicsClassifyEntityAndBlockEntityFamilies() { + assertEquals("AI/pathfinding-heavy mob cluster", RuleEngine.classifyEntityHeuristic("minecraft:villager"), "villager heuristic"); + assertEquals("Inventory transfer / item routing", RuleEngine.classifyBlockEntityHeuristic("HopperBlockEntity"), "hopper heuristic"); + assertEquals("none", RuleEngine.classifyEntityHeuristic("minecraft:painting"), "neutral entity heuristic"); + } + + private static void jvmAdvisorFallsBackCleanlyForHealthySnapshot() { + RuleEngine engine = new RuleEngine(); + List advice = engine.buildJvmTuningAdvisor(MemoryProfiler.Snapshot.empty(), SystemMetricsProfiler.Snapshot.empty()); + assertFalse(advice.isEmpty(), "healthy snapshots should still produce fallback guidance"); + assertContainsOneOf(advice, new String[] { + "No obvious JVM tuning red flags", + "No explicit -Xmx flag was detected." + }, "fallback guidance"); + } + + private static void jvmAdvisorFlagsDirectMemoryPressure() { + RuleEngine engine = new RuleEngine(); + MemoryProfiler.Snapshot memory = new MemoryProfiler.Snapshot( + 512L, + 1024L, + 1024L, + 0L, + 0L, + 0L, + 0L, + 0L, + 0L, + "none", + 850L, + 100L, + 0L, + 0L, + 0L, + 0L, + 1000L, + 0L, + 0L, + 0L, + 0L + ); + List advice = engine.buildJvmTuningAdvisor(memory, SystemMetricsProfiler.Snapshot.empty()); + assertContains(advice, "Direct/off-heap buffers are near their cap", "direct memory pressure guidance"); + } + + private static void assertContains(List advice, String needle, String message) { + for (String line : advice) { + if (line.contains(needle)) { + return; + } + } + throw new AssertionError(message + ": did not find '" + needle + "' in " + advice); + } + + private static void assertContainsOneOf(List advice, String[] needles, String message) { + for (String needle : needles) { + for (String line : advice) { + if (line.contains(needle)) { + return; + } + } + } + throw new AssertionError(message + ": did not find any expected guidance in " + advice); + } + + private static void assertFalse(boolean value, String message) { + if (value) { + throw new AssertionError(message); + } + } + + private static void assertEquals(int expected, int actual, String message) { + if (expected != actual) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } + + private static void assertEquals(String expected, String actual, String message) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/SessionExporterTests.java b/src/test/java/wueffi/taskmanager/client/SessionExporterTests.java new file mode 100644 index 0000000..43f21cd --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/SessionExporterTests.java @@ -0,0 +1,70 @@ +package wueffi.taskmanager.client; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public final class SessionExporterTests { + + private SessionExporterTests() { + } + + public static void run() { + importSessionBuildsBaselineFromSessionPoints(); + } + + private static void importSessionBuildsBaselineFromSessionPoints() { + SessionExporter exporter = new SessionExporter(); + try { + Path file = Files.createTempFile("taskmanager-session-export-test", ".json"); + Files.writeString(file, """ + { + "sessionPoints": [ + { + "averageFps": 100.0, + "onePercentLowFps": 70.0, + "msptAvg": 12.0, + "msptP95": 18.0, + "heapUsedBytes": 104857600, + "cpuEffectivePercentByMod": {"moda": 10.0}, + "gpuEffectivePercentByMod": {"moda": 20.0}, + "memoryEffectiveMbByMod": {"moda": 128.0} + }, + { + "averageFps": 80.0, + "onePercentLowFps": 60.0, + "msptAvg": 16.0, + "msptP95": 24.0, + "heapUsedBytes": 209715200, + "cpuEffectivePercentByMod": {"moda": 14.0, "modb": 8.0}, + "gpuEffectivePercentByMod": {"moda": 30.0}, + "memoryEffectiveMbByMod": {"moda": 96.0} + } + ] + } + """); + + ProfilerManager.SessionBaseline baseline = exporter.importSession(file); + if (baseline == null) { + throw new AssertionError("baseline should not be null"); + } + assertNear(90.0, baseline.avgFps(), 0.0001, "average fps"); + assertNear(65.0, baseline.onePercentLowFps(), 0.0001, "one percent low"); + assertNear(14.0, baseline.avgMspt(), 0.0001, "average mspt"); + assertNear(21.0, baseline.msptP95(), 0.0001, "p95 mspt"); + assertNear(12.0, baseline.cpuEffectivePercentByMod().get("moda"), 0.0001, "averaged cpu map"); + assertNear(8.0, baseline.cpuEffectivePercentByMod().get("modb"), 0.0001, "single-entry cpu map"); + assertNear(25.0, baseline.gpuEffectivePercentByMod().get("moda"), 0.0001, "averaged gpu map"); + assertNear(112.0, baseline.memoryEffectiveMbByMod().get("moda"), 0.0001, "averaged memory map"); + Files.deleteIfExists(file); + } catch (IOException e) { + throw new AssertionError("temp file failure", e); + } + } + + private static void assertNear(double expected, double actual, double tolerance, String message) { + if (Math.abs(expected - actual) > tolerance) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/SystemMetricsProfilerTests.java b/src/test/java/wueffi/taskmanager/client/SystemMetricsProfilerTests.java new file mode 100644 index 0000000..fe321c1 --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/SystemMetricsProfilerTests.java @@ -0,0 +1,52 @@ +package wueffi.taskmanager.client; + +public final class SystemMetricsProfilerTests { + + private SystemMetricsProfilerTests() { + } + + public static void run() { + classifyThreadRoleDetectsGeneralExecutorPools(); + classifyThreadRoleDetectsStorageIoWithoutWorkerMainPrefix(); + classifyThreadRoleDetectsChunkMeshingByStackAncestry(); + } + + private static void classifyThreadRoleDetectsGeneralExecutorPools() { + String role = SystemMetricsProfiler.classifyThreadRole( + "ForkJoinPool.commonPool-worker-3", + new StackTraceElement[] { + new StackTraceElement("java.util.concurrent.ThreadPoolExecutor", "runWorker", "ThreadPoolExecutor.java", 0), + new StackTraceElement("java.util.concurrent.CompletableFuture", "asyncSupplyStage", "CompletableFuture.java", 0) + } + ); + assertEquals("Worker Pool", role, "executor-based workers should not depend on Worker-Main naming"); + } + + private static void classifyThreadRoleDetectsStorageIoWithoutWorkerMainPrefix() { + String role = SystemMetricsProfiler.classifyThreadRole( + "Storage-IO-4", + new StackTraceElement[] { + new StackTraceElement("net.minecraft.world.storage.RegionBasedStorage", "sync", "RegionBasedStorage.java", 0), + new StackTraceElement("java.nio.channels.FileChannel", "write", "FileChannel.java", 0) + } + ); + assertEquals("IO Pool", role, "storage threads should classify as IO pools"); + } + + private static void classifyThreadRoleDetectsChunkMeshingByStackAncestry() { + String role = SystemMetricsProfiler.classifyThreadRole( + "terrain-builder-1", + new StackTraceElement[] { + new StackTraceElement("me.jellysquid.mods.sodium.client.render.chunk.compile.ChunkBuilderMeshingTask", "run", "ChunkBuilderMeshingTask.java", 0), + new StackTraceElement("java.util.concurrent.ThreadPoolExecutor", "runWorker", "ThreadPoolExecutor.java", 0) + } + ); + assertEquals("Chunk Meshing Worker", role, "meshing stacks should classify without hardcoded thread names"); + } + + private static void assertEquals(String expected, String actual, String message) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } +} diff --git a/src/test/java/wueffi/taskmanager/client/TaskManagerScreenLayoutTests.java b/src/test/java/wueffi/taskmanager/client/TaskManagerScreenLayoutTests.java index 96eaacf..be55c89 100644 --- a/src/test/java/wueffi/taskmanager/client/TaskManagerScreenLayoutTests.java +++ b/src/test/java/wueffi/taskmanager/client/TaskManagerScreenLayoutTests.java @@ -15,6 +15,7 @@ private static void hudSettingsRowCountsMatchExpectedClickLayout() { assertEquals(4, TaskManagerScreen.hudModeActionCount(true), "HUD preset-mode action count"); assertEquals(20, TaskManagerScreen.hudModeActionCount(false), "HUD custom-mode action count"); assertEquals(12, TaskManagerScreen.hudRateActionCount(), "HUD rate action count"); + assertEquals(5, TaskManagerScreen.performanceAlertActionCount(), "performance alert action count"); assertEquals(11, TaskManagerScreen.tableActionCount(), "table action count"); assertEquals(6, TaskManagerScreen.colorSettingCount(), "color setting count including reset"); } diff --git a/src/test/java/wueffi/taskmanager/client/TaskManagerTestRunner.java b/src/test/java/wueffi/taskmanager/client/TaskManagerTestRunner.java index 2c072e5..a32e5b9 100644 --- a/src/test/java/wueffi/taskmanager/client/TaskManagerTestRunner.java +++ b/src/test/java/wueffi/taskmanager/client/TaskManagerTestRunner.java @@ -8,9 +8,20 @@ private TaskManagerTestRunner() { public static void main(String[] args) { FrameTimelineProfilerTests.run(); ProfilerManagerTests.run(); + CollectorMathTests.run(); + AttributionInsightsTests.run(); + AttributionModelBuilderTests.run(); + BoundedMapsTests.run(); + CpuSamplingProfilerTests.run(); ConfigManagerMigrationTests.run(); + RuleEngineTests.run(); + RenderPhaseProfilerTests.run(); + SystemMetricsProfilerTests.run(); TaskManagerScreenLayoutTests.run(); HudOverlayRendererTests.run(); + WindowsTelemetryBridgeTests.run(); + HardwareInfoResolverTests.run(); + SessionExporterTests.run(); System.out.println("TaskManager tests passed."); } } diff --git a/src/test/java/wueffi/taskmanager/client/WindowsTelemetryBridgeTests.java b/src/test/java/wueffi/taskmanager/client/WindowsTelemetryBridgeTests.java new file mode 100644 index 0000000..9f938f8 --- /dev/null +++ b/src/test/java/wueffi/taskmanager/client/WindowsTelemetryBridgeTests.java @@ -0,0 +1,51 @@ +package wueffi.taskmanager.client; + +import java.lang.reflect.Method; + +public final class WindowsTelemetryBridgeTests { + + private WindowsTelemetryBridgeTests() { + } + + public static void run() { + mergeWithPreviousKeepsLastValidTemperatures(); + } + + private static void mergeWithPreviousKeepsLastValidTemperatures() { + try { + WindowsTelemetryBridge bridge = new WindowsTelemetryBridge(); + Method merge = WindowsTelemetryBridge.class.getDeclaredMethod("mergeWithPrevious", WindowsTelemetryBridge.Sample.class, WindowsTelemetryBridge.Sample.class); + merge.setAccessible(true); + WindowsTelemetryBridge.Sample previous = new WindowsTelemetryBridge.Sample( + 1000L, true, "Windows Performance Counters", "CPU: LibreHardwareMonitor DLL [Package] | GPU: LibreHardwareMonitor DLL [Core] | GPU Hot Spot: HWiNFO Shared Memory [Hot Spot]", "none", + "LibreHardwareMonitor DLL", "LibreHardwareMonitor DLL", "HWiNFO Shared Memory", + 20.0, 60.0, 71.5, 84.0, 65.0, 100L, 200L, 300L, 400L, 25L + ); + WindowsTelemetryBridge.Sample current = new WindowsTelemetryBridge.Sample( + 2000L, true, "Windows Performance Counters", "Unavailable", "none", + "Unavailable", "Unavailable", "Unavailable", + 25.0, 62.0, -1.0, -1.0, -1.0, 120L, 220L, 320L, 420L, 20L + ); + + WindowsTelemetryBridge.Sample merged = (WindowsTelemetryBridge.Sample) merge.invoke(bridge, current, previous); + assertEquals(71.5, merged.gpuTemperatureC(), "GPU temperature should survive transient sensor gaps"); + assertEquals(84.0, merged.gpuHotSpotTemperatureC(), "GPU hot spot temperature should survive transient sensor gaps"); + assertEquals(65.0, merged.cpuTemperatureC(), "CPU temperature should survive transient sensor gaps"); + assertEquals("HWiNFO Shared Memory", merged.gpuHotSpotTemperatureProvider(), "GPU hot spot provider should survive transient sensor gaps"); + } catch (ReflectiveOperationException e) { + throw new AssertionError("reflection failure", e); + } + } + + private static void assertEquals(double expected, double actual, String message) { + if (Math.abs(expected - actual) > 0.0001) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } + + private static void assertEquals(String expected, String actual, String message) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual); + } + } +} diff --git a/taskmanager-test-config.json b/taskmanager-test-config.json index 1dc98c4..b25221b 100644 --- a/taskmanager-test-config.json +++ b/taskmanager-test-config.json @@ -1,7 +1,7 @@ { "onlyProfileWhenOpen": true, "captureMode": "OPEN_ONLY", - "hudEnabled": true, + "hudEnabled": false, "hudPosition": "TOP_LEFT", "hudLayoutMode": "SINGLE_COLUMN", "sessionDurationSeconds": 30, @@ -11,6 +11,11 @@ "hudMemoryDisplayDelayMs": 100, "hudTransparencyPercent": 100, "frameBudgetTargetFps": 72, + "performanceAlertsEnabled": true, + "performanceAlertChatEnabled": true, + "performanceAlertFrameThresholdMs": 25, + "performanceAlertServerThresholdMs": 20, + "performanceAlertConsecutiveTicks": 3, "hudShowFps": true, "hudShowFrame": true, "hudShowTicks": true, @@ -54,6 +59,7 @@ "gpuSearch": "", "memorySearch": "", "startupSearch": "", + "globalSearch": "", "taskSort": "CPU", "taskSortDescending": true, "gpuSort": "EST_GPU",