diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java index 05203522..1892d673 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java @@ -1,11 +1,14 @@ package dev.loki.lovisual.features.module.modules.misc.optimize; +import dev.loki.lovisual.config.values.mode.EnumValue; import dev.loki.lovisual.config.values.primitive.BooleanValue; import dev.loki.lovisual.config.values.primitive.NumberValue; import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.phase.Notifier; +import dev.loki.lovisual.render.engine.optimize.profile.FrameBudget; +import dev.loki.lovisual.render.engine.optimize.profile.OptimizePropertyBridge; import dev.loki.lovisual.render.engine.optimize.OptimizeState; import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; @@ -16,17 +19,18 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; * particle LOD, spawner tick-cache, etc.). * *
No auto-degradation by FPS — the owner's rule. Render code reads - * {@link OptimizeState} (the original cuts) and {@link OptimizeToggles} (the - * new transparent helpers), never this class; this module only pushes - * state into them on enable/disable and once per frame. + * {@link OptimizeState} (the original cuts), {@link OptimizeToggles} (the + * Stage 1 transparent helpers), {@link FrameBudget} (Stage 2 frame budget) + * — never this class; this module only pushes state into them on + * enable/disable and once per frame. * - *
Stage 1 of {@code mod/docs/optimize-layer-design.md}: the toggles below - * wire the engine-side state holders up; the actual hooks (mixins on - * {@code LivingEntityRenderer}, {@code ParticleGroup}, {@code SpawnerRenderer}, - * {@code BlockEntity}, {@code ClientLanguage}, etc.) are added in Stage 3 - * alongside the GL path. The helpers themselves (LRU, LOD math, workers, HUD - * hysteresis) are already in place under - * {@code features/module/modules/misc/optimize/{core,entity,particle,hud,block,text}/}. + *
Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module now also
+ * pushes a {@link FrameBudget} snapshot (mode + target FPS) and resolves
+ * {@code -Dlovisual.opt.*} system-property overrides via
+ * {@link OptimizePropertyBridge}. The frame profiler ({@code FrameProfiler})
+ * is fed by the {@code MinecraftFrameMixin} (Stage 3 — not yet present); the
+ * F3 overlay ({@code OptimizeDebugHudMixin}) reads the profiler's snapshot
+ * whenever the user opens F3.
*/
@ModuleInfo(
id = "optimize",
@@ -54,6 +58,14 @@ public final class Optimize extends Module {
private final BooleanValue preparedTextCache = bool("prepared_text_cache", true);
private final BooleanValue microOpts = bool("micro_opts", true);
+ // --- Stage 2: frame budget + profiling ---
+ private final EnumValue This is NOT auto-degradation by FPS — the user explicitly chooses a mode
+ * (default {@link Mode#OFF}) and a target FPS; we never silently lower
+ * quality based on measured frame time. The owner's Phase 9 rule stands.
+ *
+ * Stage 2 of {@code mod/docs/optimize-layer-design.md}.
+ */
+public enum FrameBudget {
+ ;
+
+ /** Aggressiveness level — user-facing setting in the {@code Optimize} module. */
+ public enum Mode {
+ /** No budget enforcement — every subsystem runs at full quality. */
+ OFF,
+ /** Tighten the most expensive caps by a small margin (~10%) below the baseline. */
+ SOFT,
+ /** Aggressive caps tuned for the target FPS — may visually reduce far detail. */
+ AGGRESSIVE;
+
+ public boolean active() {
+ return this != OFF;
+ }
+ }
+
+ /** Default target FPS when the user hasn't picked one. 60 is the safe baseline. */
+ public static final int DEFAULT_TARGET_FPS = 60;
+
+ /** Multiplier applied to baseline caps when SOFT mode is on (slightly tighter). */
+ static final double SOFT_SCALE = 0.9;
+
+ /** Multiplier applied to baseline caps when AGGRESSIVE mode is on. */
+ static final double AGGRESSIVE_SCALE = 0.6;
+
+ // Defaults match ZakoOpt's stage-1 caps so the behavior is familiar.
+ private static final int BASE_ENTITY_LOD_DISTANCE = 35;
+ private static final int BASE_PARTICLE_NEAR_BLOCKS = 16; // NEAR_SQ = 16²
+ private static final int BASE_PARTICLE_MID_BLOCKS = 32; // MID_SQ = 32²
+ private static final int BASE_SPAWNER_DISTANCE = 16;
+ private static final int BASE_TRAIL_MAX_POINTS = 256;
+ private static final int BASE_TRAIL_STEP_NS = 350_000_000;
+
+ private static volatile Mode mode = Mode.OFF;
+ private static volatile int targetFps = DEFAULT_TARGET_FPS;
+
+ public static Mode mode() {
+ return mode;
+ }
+
+ public static int targetFps() {
+ return targetFps;
+ }
+
+ /** The scale factor applied to baseline caps — 1.0 in OFF, 0.9 in SOFT, 0.6 in AGGRESSIVE. */
+ public static double scale() {
+ return switch (mode) {
+ case OFF -> 1.0;
+ case SOFT -> SOFT_SCALE;
+ case AGGRESSIVE -> AGGRESSIVE_SCALE;
+ };
+ }
+
+ /** Apply a new budget snapshot — called by {@code Optimize} on enable/disable/frame. */
+ public static void apply(Mode mode, int targetFps) {
+ FrameBudget.mode = mode;
+ FrameBudget.targetFps = Math.max(15, Math.min(360, targetFps));
+ }
+
+ /** Reset to defaults — used by {@code Optimize.onDisable}. */
+ public static void reset() {
+ apply(Mode.OFF, DEFAULT_TARGET_FPS);
+ }
+
+ // --- Per-subsystem caps (read by render code every frame) ---
+
+ /** Squared LOD distance threshold for entities — beyond this, layers/anim get skipped. */
+ public static double entityLodDistanceSq() {
+ double d = BASE_ENTITY_LOD_DISTANCE * scale();
+ return d * d;
+ }
+
+ /** Squared distance under which particles are always kept (never thinned). */
+ public static double particleNearSq() {
+ double d = BASE_PARTICLE_NEAR_BLOCKS * scale();
+ return d * d;
+ }
+
+ /** Squared distance at which mid-range particle thinning kicks in. */
+ public static double particleMidSq() {
+ double d = BASE_PARTICLE_MID_BLOCKS * scale();
+ return d * d;
+ }
+
+ /** Distance beyond which a spawner's display entity is fully culled. */
+ public static int spawnerDistance() {
+ return Math.max(4, (int) Math.round(BASE_SPAWNER_DISTANCE * scale()));
+ }
+
+ /** Max points recorded per trail (ProjectileTrails, ElytraTrails). */
+ public static int trailMaxPoints() {
+ return Math.max(8, (int) Math.round(BASE_TRAIL_MAX_POINTS * scale()));
+ }
+
+ /** Min step between two recorded trail samples, in nanoseconds. */
+ public static long trailStepNs() {
+ // Step grows (less frequent sampling) as the budget tightens.
+ long base = BASE_TRAIL_STEP_NS;
+ return Math.round(base / scale());
+ }
+
+ /**
+ * True when the budget actively limits this subsystem. Renderers can use
+ * this to skip work entirely (e.g. parallel particles disabled below
+ * a threshold when AGGRESSIVE).
+ */
+ public static boolean limits(SubSystem subsystem) {
+ if (!mode.active()) return false;
+ return switch (subsystem) {
+ case ENTITIES, PARTICLES, HUD, EFFECTS, TEXT -> true;
+ };
+ }
+
+ /** Subsystems tracked by the frame profiler — used by {@link #limits(SubSystem)}. */
+ public enum SubSystem {
+ ENTITIES,
+ PARTICLES,
+ HUD,
+ EFFECTS,
+ TEXT
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfiler.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfiler.java
new file mode 100644
index 00000000..9334bc08
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfiler.java
@@ -0,0 +1,195 @@
+package dev.loki.lovisual.render.engine.optimize.profile;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * Section-level frame profiler: times five render subsystems
+ * ({@link FrameBudget.SubSystem#ENTITIES ENTITIES}, {@link FrameBudget.SubSystem#PARTICLES PARTICLES},
+ * {@link FrameBudget.SubSystem#HUD HUD}, {@link FrameBudget.SubSystem#EFFECTS EFFECTS},
+ * {@link FrameBudget.SubSystem#TEXT TEXT}) per frame and exposes a stable
+ * EMA-averaged snapshot for the F3 debug overlay (see {@code OptimizeDebugHudMixin}).
+ *
+ * Use the {@link Section} try-with-resources idiom:
+ * Stage 2 of {@code mod/docs/optimize-layer-design.md}.
+ *
+ * Thread-safety: render-thread only — the same thread that calls
+ * {@link #beginFrame()} / {@link #endFrame()} is the one that opens/closes
+ * sections. The snapshot is published via a single volatile field so a
+ * concurrent F3-thread read always sees a consistent map.
+ */
+public enum FrameProfiler {
+ ;
+
+ /** EMA weight for the per-section timing — same as {@code HudWorth}'s 0.95/0.05. */
+ private static final double EMA_ALPHA = 0.05;
+
+ private static final int SAMPLE_DECAY_MS = 2000; // forget a section if idle for 2 s
+
+ /** Read by the F3 overlay via {@link #snapshot()}. */
+ public static final class Snapshot {
+ private final double entityMs;
+ private final double particleMs;
+ private final double hudMs;
+ private final double effectMs;
+ private final double textMs;
+ private final double frameMs;
+
+ Snapshot(double e, double p, double h, double f, double t, double frame) {
+ this.entityMs = e;
+ this.particleMs = p;
+ this.hudMs = h;
+ this.effectMs = f;
+ this.textMs = t;
+ this.frameMs = frame;
+ }
+
+ public double entityMs() { return entityMs; }
+ public double particleMs(){ return particleMs; }
+ public double hudMs() { return hudMs; }
+ public double effectMs() { return effectMs; }
+ public double textMs() { return textMs; }
+ public double frameMs() { return frameMs; }
+ }
+
+ private static final EnumMap System-property overrides are the benchmark escape hatch — they let you
+ * flip a toggle or a budget cap without touching the in-game {@code Optimize}
+ * module UI. Useful for A/B testing in Stage 5.
+ *
+ * Supported properties (all optional):
+ * Properties are resolved once per frame via {@link #resolve(Snapshot)} and
+ * the resulting {@link Snapshot} is applied to {@link OptimizeToggles} and
+ * {@link FrameBudget}. {@code Optimize} module reads the snapshot in its
+ * {@code onFrame(float)} lifecycle.
+ *
+ * Stage 2 of {@code mod/docs/optimize-layer-design.md}.
+ */
+public enum OptimizePropertyBridge {
+ ;
+
+ /** Cached parsed values — {@link System#getProperty} is too slow for the hot path. */
+ private static volatile Snapshot cached = new Snapshot();
+
+ /** True when the user wants every override disabled (forced off via {@code all=false}). */
+ public static boolean allOff() {
+ return "false".equals(System.getProperty("lovisual.opt.all"));
+ }
+
+ /**
+ * Read the current {@code -Dlovisual.opt.*} properties into a fresh snapshot.
+ * The Optimize module calls this once per frame (mirrors ZakoOpt's
+ * {@code ZakoOptConfig.refresh()}).
+ */
+ public static Snapshot resolve() {
+ Snapshot s = new Snapshot();
+ s.allOff = allOff();
+
+ s.entityLod = boolOverride("entity_lod");
+ s.particleLod = boolOverride("particle_lod");
+ s.spawnerCull = boolOverride("spawner_cull");
+ s.spawnerTickCache = boolOverride("spawner_tick_cache");
+ s.blockEntityCache = boolOverride("block_entity_cache");
+ s.preparedTextCache = boolOverride("prepared_text_cache");
+ s.microOpts = boolOverride("micro_opts");
+
+ s.entityLodDistance = intOverride("entity_lod_distance", -1);
+ s.spawnerDistance = intOverride("spawner_distance", -1);
+
+ s.budgetMode = budgetModeOverride();
+ s.targetFps = intOverride("target_fps", -1);
+ cached = s;
+ return s;
+ }
+
+ /** Last resolved snapshot — render code can read this without re-parsing. */
+ public static Snapshot lastResolved() {
+ return cached;
+ }
+
+ /** Reset cache — used by tests. */
+ static void resetCache() {
+ cached = new Snapshot();
+ }
+
+ /** True if any system property is currently set, false otherwise. */
+ public static boolean hasOverrides() {
+ for (String key : OVERRIDE_KEYS) {
+ if (System.getProperty("lovisual.opt." + key) != null) return true;
+ }
+ return System.getProperty("lovisual.opt.all") != null;
+ }
+
+ // ---- helpers ----
+
+ private static final String[] OVERRIDE_KEYS = {
+ "entity_lod", "particle_lod", "spawner_cull", "spawner_tick_cache",
+ "block_entity_cache", "prepared_text_cache", "micro_opts",
+ "entity_lod_distance", "spawner_distance", "budget_mode", "target_fps"
+ };
+
+ /**
+ * Returns {@code null} when the property isn't set (use the module-supplied
+ * value); {@code Boolean.TRUE}/{@code FALSE} when it's set.
+ */
+ private static Boolean boolOverride(String key) {
+ String p = System.getProperty("lovisual.opt." + key);
+ return p == null ? null : Boolean.parseBoolean(p);
+ }
+
+ private static int intOverride(String key, int fallback) {
+ String p = System.getProperty("lovisual.opt." + key);
+ if (p == null) return fallback;
+ try {
+ return (int) Double.parseDouble(p);
+ } catch (NumberFormatException e) {
+ return fallback;
+ }
+ }
+
+ private static FrameBudget.Mode budgetModeOverride() {
+ String p = System.getProperty("lovisual.opt.budget_mode");
+ if (p == null) return null;
+ return switch (p.toLowerCase()) {
+ case "off" -> FrameBudget.Mode.OFF;
+ case "soft" -> FrameBudget.Mode.SOFT;
+ case "aggressive" -> FrameBudget.Mode.AGGRESSIVE;
+ default -> null;
+ };
+ }
+
+ /**
+ * Resolved snapshot. Each field is one of:
+ * Three assertions matter most:
+ * Stage 2 of {@code mod/docs/optimize-layer-design.md}. The profiler is
+ * render-thread only; tests run on the JUnit main thread so the same thread
+ * owns both {@code beginFrame}/{@code endFrame} and the section lifecycle.
+ */
+public final class FrameProfilerTest {
+
+ @AfterEach
+ void reset() {
+ FrameProfiler.reset();
+ }
+
+ @Test
+ void snapshotStartsAtZero() {
+ FrameProfiler.Snapshot s = FrameProfiler.snapshot();
+ assertEquals(0, s.entityMs(), 1e-9);
+ assertEquals(0, s.particleMs(), 1e-9);
+ assertEquals(0, s.hudMs(), 1e-9);
+ assertEquals(0, s.effectMs(), 1e-9);
+ assertEquals(0, s.textMs(), 1e-9);
+ assertEquals(0, s.frameMs(), 1e-9);
+ }
+
+ @Test
+ void sectionTimeIsRecordedAfterClose() throws InterruptedException {
+ FrameProfiler.beginFrame();
+ try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.PARTICLES)) {
+ Thread.sleep(5);
+ }
+ FrameProfiler.endFrame();
+
+ FrameProfiler.Snapshot snap = FrameProfiler.snapshot();
+ assertTrue(snap.particleMs() > 0,
+ "Particle section should record >0 ms, got " + snap.particleMs());
+ assertTrue(snap.particleMs() < 50,
+ "5ms sleep should not produce huge EMA, got " + snap.particleMs());
+ }
+
+ @Test
+ void frameMsReflectsBeginEndGap() throws InterruptedException {
+ FrameProfiler.beginFrame();
+ Thread.sleep(10);
+ FrameProfiler.endFrame();
+ double frameMs = FrameProfiler.snapshot().frameMs();
+ assertTrue(frameMs >= 10,
+ "Frame time should be >= 10ms, got " + frameMs);
+ assertTrue(frameMs < 200,
+ "Frame time should be reasonable (<200ms), got " + frameMs);
+ }
+
+ @Test
+ void nestedSectionsAreClosed() throws InterruptedException {
+ FrameProfiler.beginFrame();
+ try (FrameProfiler.Section outer = FrameProfiler.section(FrameBudget.SubSystem.ENTITIES)) {
+ Thread.sleep(2);
+ // Opening a new section implicitly closes the previous one.
+ try (FrameProfiler.Section inner = FrameProfiler.section(FrameBudget.SubSystem.HUD)) {
+ Thread.sleep(2);
+ }
+ }
+ FrameProfiler.endFrame();
+ FrameProfiler.Snapshot s = FrameProfiler.snapshot();
+ assertTrue(s.entityMs() > 0, "Entity section should be recorded");
+ assertTrue(s.hudMs() > 0, "HUD section should be recorded");
+ }
+
+ @Test
+ void idleSectionDecaysAfter2Seconds() throws InterruptedException {
+ FrameProfiler.beginFrame();
+ try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.TEXT)) {
+ Thread.sleep(2);
+ }
+ FrameProfiler.endFrame();
+ assertTrue(FrameProfiler.snapshot().textMs() > 0, "Text section should be recorded first");
+
+ // Wait long enough for the decay threshold (2000 ms) to trigger.
+ Thread.sleep(2200);
+ FrameProfiler.beginFrame();
+ FrameProfiler.endFrame();
+ assertEquals(0, FrameProfiler.snapshot().textMs(), 1e-9,
+ "Text section should decay to 0 after 2s idle");
+ }
+
+ @Test
+ void emaSmoothsSingleOutlier() throws InterruptedException {
+ FrameProfiler.beginFrame();
+ try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.HUD)) {
+ Thread.sleep(1);
+ }
+ FrameProfiler.endFrame();
+ // Repeat several short frames; the EMA should converge towards the short value.
+ for (int i = 0; i < 10; i++) {
+ FrameProfiler.beginFrame();
+ try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.HUD)) {
+ Thread.sleep(1);
+ }
+ FrameProfiler.endFrame();
+ }
+ double hudMs = FrameProfiler.snapshot().hudMs();
+ assertTrue(hudMs > 0 && hudMs < 10,
+ "EMA of HUD section should be small after 10 short frames, got " + hudMs);
+ }
+
+ @Test
+ void resetClearsAllState() throws InterruptedException {
+ FrameProfiler.beginFrame();
+ try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.EFFECTS)) {
+ Thread.sleep(3);
+ }
+ FrameProfiler.endFrame();
+ FrameProfiler.reset();
+ FrameProfiler.Snapshot snap = FrameProfiler.snapshot();
+ assertEquals(0, snap.effectMs(), 1e-9);
+ assertEquals(0, snap.frameMs(), 1e-9);
+ }
+
+ @Test
+ void sectionCloseIsIdempotent() throws InterruptedException {
+ FrameProfiler.beginFrame();
+ FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.PARTICLES);
+ Thread.sleep(2);
+ s.close();
+ s.close(); // Should not throw or double-count.
+ FrameProfiler.endFrame();
+ assertTrue(FrameProfiler.snapshot().particleMs() > 0);
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java
new file mode 100644
index 00000000..bf4a03be
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java
@@ -0,0 +1,168 @@
+package dev.loki.lovisual.render.engine.optimize.profile;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link OptimizePropertyBridge} — the {@code -Dlovisual.opt.*}
+ * override resolver. Each test sets the system property, calls {@link
+ * OptimizePropertyBridge#resolve()}, then clears the property.
+ */
+public final class OptimizePropertyBridgeTest {
+
+ @AfterEach
+ void cleanup() {
+ for (String k : new String[]{
+ "lovisual.opt.all",
+ "lovisual.opt.entity_lod", "lovisual.opt.particle_lod",
+ "lovisual.opt.spawner_cull", "lovisual.opt.spawner_tick_cache",
+ "lovisual.opt.block_entity_cache", "lovisual.opt.prepared_text_cache",
+ "lovisual.opt.micro_opts",
+ "lovisual.opt.entity_lod_distance", "lovisual.opt.spawner_distance",
+ "lovisual.opt.budget_mode", "lovisual.opt.target_fps"
+ }) {
+ System.clearProperty(k);
+ }
+ OptimizePropertyBridge.resetCache();
+ }
+
+ @Test
+ void noPropertiesMeansNoOverrides() {
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ assertFalse(s.allOff);
+ assertFalse(OptimizePropertyBridge.hasOverrides());
+ assertNull(s.entityLod);
+ assertNull(s.particleLod);
+ assertNull(s.budgetMode);
+ assertEquals(-1, s.entityLodDistance);
+ }
+
+ @Test
+ void allFalseDisablesEverything() {
+ System.setProperty("lovisual.opt.all", "false");
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ assertTrue(s.allOff);
+ // overrides() should return false for every boolean regardless of the per-key override.
+ System.setProperty("lovisual.opt.entity_lod", "true");
+ s = OptimizePropertyBridge.resolve();
+ assertFalse(s.overrides(s.entityLod, true),
+ "all=false should disable even an explicit true override");
+ }
+
+ @Test
+ void entityLodBooleanOverride() {
+ System.setProperty("lovisual.opt.entity_lod", "true");
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ assertEquals(Boolean.TRUE, s.entityLod);
+ assertTrue(s.overrides(s.entityLod, false));
+
+ System.setProperty("lovisual.opt.entity_lod", "false");
+ s = OptimizePropertyBridge.resolve();
+ assertEquals(Boolean.FALSE, s.entityLod);
+ assertFalse(s.overrides(s.entityLod, true));
+ }
+
+ @Test
+ void distanceOverride() {
+ System.setProperty("lovisual.opt.entity_lod_distance", "100");
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ assertEquals(100, s.entityLodDistance);
+ assertEquals(100, s.overrides(s.entityLodDistance, 35));
+ }
+
+ @Test
+ void distanceOverrideFallsBackWhenUnset() {
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ // -1 means "use the module value".
+ assertEquals(35, s.overrides(s.entityLodDistance, 35));
+ }
+
+ @Test
+ void invalidDistanceFallsBack() {
+ System.setProperty("lovisual.opt.entity_lod_distance", "not-a-number");
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ // The bridge returns -1 (the fallback) when parsing fails, and overrides()
+ // uses the module value when override <= 0.
+ assertEquals(-1, s.entityLodDistance);
+ assertEquals(35, s.overrides(s.entityLodDistance, 35));
+ }
+
+ @Test
+ void budgetModeOverride() {
+ System.setProperty("lovisual.opt.budget_mode", "soft");
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ assertEquals(FrameBudget.Mode.SOFT, s.budgetMode);
+
+ System.setProperty("lovisual.opt.budget_mode", "aggressive");
+ s = OptimizePropertyBridge.resolve();
+ assertEquals(FrameBudget.Mode.AGGRESSIVE, s.budgetMode);
+
+ System.setProperty("lovisual.opt.budget_mode", "off");
+ s = OptimizePropertyBridge.resolve();
+ assertEquals(FrameBudget.Mode.OFF, s.budgetMode);
+ }
+
+ @Test
+ void budgetModeUnknownStringIsNull() {
+ System.setProperty("lovisual.opt.budget_mode", "turbo");
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ assertNull(s.budgetMode);
+ // The bridge falls back to the module value when budgetMode is null.
+ assertEquals(FrameBudget.Mode.SOFT, s.overrides(s.budgetMode, FrameBudget.Mode.SOFT));
+ }
+
+ @Test
+ void targetFpsOverride() {
+ System.setProperty("lovisual.opt.target_fps", "144");
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ assertEquals(144, s.targetFps);
+ assertEquals(144, s.resolveTargetFps(60));
+ }
+
+ @Test
+ void targetFpsFallsBackWhenUnset() {
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ // Unset = -1 sentinel (the bridge never silently substitutes DEFAULT_TARGET_FPS,
+ // so the module value always wins when the property is unset).
+ assertEquals(-1, s.targetFps);
+ assertEquals(120, s.resolveTargetFps(120));
+ assertEquals(60, s.resolveTargetFps(60));
+ }
+
+ @Test
+ void hasOverridesDetectsAnyProperty() {
+ assertFalse(OptimizePropertyBridge.hasOverrides());
+ System.setProperty("lovisual.opt.micro_opts", "false");
+ assertTrue(OptimizePropertyBridge.hasOverrides());
+ }
+
+ @Test
+ void allFalseSetsHasOverrides() {
+ System.setProperty("lovisual.opt.all", "false");
+ assertTrue(OptimizePropertyBridge.hasOverrides());
+ }
+
+ @Test
+ void lastResolvedMatchesLatest() {
+ System.setProperty("lovisual.opt.spawner_cull", "true");
+ OptimizePropertyBridge.resolve();
+ assertEquals(Boolean.TRUE, OptimizePropertyBridge.lastResolved().spawnerCull);
+ System.setProperty("lovisual.opt.spawner_cull", "false");
+ OptimizePropertyBridge.resolve();
+ assertEquals(Boolean.FALSE, OptimizePropertyBridge.lastResolved().spawnerCull);
+ }
+
+ @Test
+ void snapshotToStringDoesNotCrash() {
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ String str = s.toString();
+ assertTrue(str.contains("entity_lod"));
+ assertTrue(str.contains("budget_mode"));
+ }
+}
{@code
+ * try (FrameBudget.Section s = FrameProfiler.section(FrameBudget.SubSystem.PARTICLES)) {
+ * // ... extract particles ...
+ * }
+ * }
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+ public static final class Snapshot {
+ public boolean allOff;
+ public Boolean entityLod;
+ public Boolean particleLod;
+ public Boolean spawnerCull;
+ public Boolean spawnerTickCache;
+ public Boolean blockEntityCache;
+ public Boolean preparedTextCache;
+ public Boolean microOpts;
+ public int entityLodDistance = -1;
+ public int spawnerDistance = -1;
+ public FrameBudget.Mode budgetMode; // null = use module value
+ public int targetFps = -1;
+
+ /** True when this snapshot should override the given module-side boolean. */
+ public boolean overrides(Boolean override, boolean moduleValue) {
+ if (allOff) return false;
+ if (override != null) return override;
+ return moduleValue;
+ }
+
+ public int overrides(int override, int moduleValue) {
+ if (allOff) return moduleValue;
+ return override > 0 ? override : moduleValue;
+ }
+
+ public FrameBudget.Mode overrides(FrameBudget.Mode override, FrameBudget.Mode moduleValue) {
+ if (allOff) return FrameBudget.Mode.OFF;
+ return override != null ? override : moduleValue;
+ }
+
+ public int resolveTargetFps(int moduleValue) {
+ if (allOff) return moduleValue;
+ return targetFps > 0 ? targetFps : moduleValue;
+ }
+
+ /** Test-only: pretty-print. */
+ @Override
+ public String toString() {
+ Map
+ *
+ */
+public final class FrameBudgetTest {
+
+ @AfterEach
+ void reset() {
+ FrameBudget.reset();
+ }
+
+ @Test
+ void offIsFullQualityByDefault() {
+ FrameBudget.reset();
+ assertEquals(FrameBudget.Mode.OFF, FrameBudget.mode());
+ assertEquals(FrameBudget.DEFAULT_TARGET_FPS, FrameBudget.targetFps());
+ assertEquals(1.0, FrameBudget.scale(), 1e-9);
+ assertFalse(FrameBudget.limits(FrameBudget.SubSystem.ENTITIES));
+ assertFalse(FrameBudget.limits(FrameBudget.SubSystem.PARTICLES));
+ assertFalse(FrameBudget.limits(FrameBudget.SubSystem.HUD));
+ }
+
+ @Test
+ void softScalesBy09() {
+ FrameBudget.apply(FrameBudget.Mode.SOFT, 60);
+ assertEquals(0.9, FrameBudget.scale(), 1e-9);
+ assertTrue(FrameBudget.limits(FrameBudget.SubSystem.ENTITIES));
+ assertTrue(FrameBudget.limits(FrameBudget.SubSystem.PARTICLES));
+ // Distance caps shrink with the scale.
+ double d = 35 * 0.9;
+ assertEquals(d * d, FrameBudget.entityLodDistanceSq(), 0.5);
+ }
+
+ @Test
+ void aggressiveScalesBy06() {
+ FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 120);
+ assertEquals(0.6, FrameBudget.scale(), 1e-9);
+ assertTrue(FrameBudget.limits(FrameBudget.SubSystem.EFFECTS));
+ assertTrue(FrameBudget.limits(FrameBudget.SubSystem.TEXT));
+ // Particle near/mid thresholds shrink with the scale.
+ assertEquals((16 * 0.6) * (16 * 0.6), FrameBudget.particleNearSq(), 0.5);
+ assertEquals((32 * 0.6) * (32 * 0.6), FrameBudget.particleMidSq(), 0.5);
+ }
+
+ @Test
+ void targetFpsIsClamped() {
+ FrameBudget.apply(FrameBudget.Mode.SOFT, 5);
+ assertEquals(15, FrameBudget.targetFps());
+ FrameBudget.apply(FrameBudget.Mode.SOFT, 1000);
+ assertEquals(360, FrameBudget.targetFps());
+ FrameBudget.apply(FrameBudget.Mode.SOFT, -10);
+ assertEquals(15, FrameBudget.targetFps());
+ }
+
+ @Test
+ void trailMaxPointsShrinkWithBudget() {
+ FrameBudget.apply(FrameBudget.Mode.OFF, 60);
+ assertEquals(256, FrameBudget.trailMaxPoints());
+ FrameBudget.apply(FrameBudget.Mode.SOFT, 60);
+ // 256 * 0.9 = 230.4 -> rounded
+ assertTrue(FrameBudget.trailMaxPoints() >= 200 && FrameBudget.trailMaxPoints() <= 240,
+ "SOFT trail max should be ~230, got " + FrameBudget.trailMaxPoints());
+ FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 60);
+ // 256 * 0.6 = 153.6 -> rounded
+ assertTrue(FrameBudget.trailMaxPoints() >= 140 && FrameBudget.trailMaxPoints() <= 170,
+ "AGGRESSIVE trail max should be ~154, got " + FrameBudget.trailMaxPoints());
+ }
+
+ @Test
+ void trailStepGrowsWhenBudgetTightens() {
+ // Step is divided by the scale — AGGRESSIVE (0.6) gives a longer step (less frequent sampling).
+ FrameBudget.apply(FrameBudget.Mode.OFF, 60);
+ long baseStep = FrameBudget.trailStepNs();
+ FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 60);
+ assertTrue(FrameBudget.trailStepNs() > baseStep,
+ "Step should grow with AGGRESSIVE: base=" + baseStep + ", aggressive=" + FrameBudget.trailStepNs());
+ }
+
+ @Test
+ void resetReturnsToOffAndDefaultFps() {
+ FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 240);
+ FrameBudget.reset();
+ assertEquals(FrameBudget.Mode.OFF, FrameBudget.mode());
+ assertEquals(FrameBudget.DEFAULT_TARGET_FPS, FrameBudget.targetFps());
+ assertEquals(1.0, FrameBudget.scale(), 1e-9);
+ }
+
+ @Test
+ void modeActiveFlag() {
+ assertFalse(FrameBudget.Mode.OFF.active());
+ assertTrue(FrameBudget.Mode.SOFT.active());
+ assertTrue(FrameBudget.Mode.AGGRESSIVE.active());
+ }
+
+ @Test
+ void spawnerDistanceNeverGoesBelow4() {
+ FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 30);
+ assertTrue(FrameBudget.spawnerDistance() >= 4,
+ "Spawner distance should be clamped at 4, got " + FrameBudget.spawnerDistance());
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfilerTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfilerTest.java
new file mode 100644
index 00000000..1ac5b35a
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfilerTest.java
@@ -0,0 +1,137 @@
+package dev.loki.lovisual.render.engine.optimize.profile;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link FrameProfiler} — the per-section frame profiler.
+ *
+ *