Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add idle detection and mode #141

Merged
merged 1 commit into from
Dec 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ dependencies {
modApi libs.cloth.config
}

loom {
accessWidenerPath = file("src/main/resources/dynamic_fps.accesswidener")
}

processResources {
inputs.property "version", generateVersion()

Expand Down
55 changes: 53 additions & 2 deletions src/main/java/dynamic_fps/impl/DynamicFPSMod.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
import dynamic_fps.impl.util.Logging;
import dynamic_fps.impl.util.ModCompatibility;
import dynamic_fps.impl.util.OptionsHolder;
import dynamic_fps.impl.util.WindowObserver;
import dynamic_fps.impl.util.event.InputObserver;
import dynamic_fps.impl.util.event.WindowObserver;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
Expand All @@ -32,10 +34,15 @@ public class DynamicFPSMod implements ClientModInitializer {
private static boolean isForcingLowFPS = false;

private static Minecraft minecraft;

private static WindowObserver window;
private static InputObserver devices;

private static long lastRender;

private static boolean wasIdle = false;
private static boolean tickEventRegistered = false;

// we always render one last frame before actually reducing FPS, so the hud text
// shows up instantly when forcing low fps.
// additionally, this would enable mods which render differently while mc is
Expand Down Expand Up @@ -65,6 +72,7 @@ public void onInitializeClient() {
toggleForcedKeyBinding.register();
toggleDisabledKeyBinding.register();

registerTickEvent();
HudRenderCallback.EVENT.register(new HudInfoRenderer());
}

Expand All @@ -78,6 +86,11 @@ public static void onStatusChanged() {
checkForStateChanges();
}

public static void onConfigChanged() {
modConfig.save();
registerTickEvent();
}

public static PowerState powerState() {
return state;
}
Expand All @@ -88,6 +101,7 @@ public static boolean isForcingLowFPS() {

public static void setWindow(long address) {
window = new WindowObserver(address);
devices = new InputObserver(address);
}

public static boolean checkForRender() {
Expand Down Expand Up @@ -128,10 +142,43 @@ private static boolean isLevelCoveredByScreen() {
return minecraft.screen != null && minecraft.screen.dynamic_fps$rendersBackground();
}

private static boolean isIdle() {
var idleTime = modConfig.idleTime();

if (idleTime == 0) {
return false;
}

return (Util.getEpochMillis() - devices.lastActionTime()) >= idleTime * 1000;
}

private static boolean isLevelCoveredByOverlay() {
return OVERLAY_OPTIMIZATION_ACTIVE && minecraft.getOverlay() instanceof LoadingOverlay loadingOverlay && loadingOverlay.dynamic_fps$isReloadComplete();
}

private static void registerTickEvent() {
if (tickEventRegistered) {
return;
}

if (modConfig.idleTime() == -1) {
return;
}

tickEventRegistered = true;

// I assume world ticks only happen 20 times a second
// Instead of whatever amount of FPS we get at the moment?
ClientTickEvents.START_WORLD_TICK.register((minecraft) -> {
var idle = isIdle();

if (idle != wasIdle) {
wasIdle = idle;
onStatusChanged();
}
});
}

@SuppressWarnings("squid:S1215") // Garbage collector call
public static void handleStateChange(PowerState previous, PowerState current) {
if (DEBUG) {
Expand Down Expand Up @@ -187,7 +234,11 @@ private static void checkForStateChanges0() {
} else if (isForcingLowFPS) {
current = PowerState.UNFOCUSED;
} else if (window.isFocused()) {
current = PowerState.FOCUSED;
if (!isIdle()) {
current = PowerState.FOCUSED;
} else {
current = PowerState.ABANDONED;
}
} else if (window.isHovered()) {
current = PowerState.HOVERED;
} else if (!window.isIconified()) {
Expand Down
13 changes: 9 additions & 4 deletions src/main/java/dynamic_fps/impl/PowerState.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@
* Power states are prioritized based on their order here, see DynamicFPSMod.checkForStateChanges for impl details.
*/
public enum PowerState {
/*
/**
* Window is currently focused.
*/
FOCUSED(false),

/*
/**
* Mouse positioned over unfocused window.
*/
HOVERED(true),

/*
/**
* Another application is focused.
*/
UNFOCUSED(true),

/*
/**
* User hasn't sent input for some time.
*/
ABANDONED(true),

/**
* Window minimized or otherwise hidden.
*/
INVISIBLE(true);
Expand Down
27 changes: 26 additions & 1 deletion src/main/java/dynamic_fps/impl/compat/ClothConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,27 @@ public static Screen genConfigScreen(Screen parent) {
var builder = ConfigBuilder.create()
.setParentScreen(parent)
.setTitle(localized("config", "title"))
.setSavingRunnable(DynamicFPSMod.modConfig::save);
.setSavingRunnable(DynamicFPSMod::onConfigChanged);

var entryBuilder = builder.entryBuilder();

var general = builder.getOrCreateCategory(
localized("config", "category.general")
);

general.addEntry(
entryBuilder.startIntSlider(
localized("config", "idle_time"),
DynamicFPSMod.modConfig.idleTime() / 60,
0, 30
)
.setDefaultValue(0)
.setSaveConsumer(value -> DynamicFPSMod.modConfig.setIdleTime(value * 60))
.setTextGetter(ClothConfig::idleTimeMessage)
.setTooltip(localized("config", "idle_time_tooltip"))
.build()
);

for (var state : PowerState.values()) {
if (!state.configurable) {
continue;
Expand Down Expand Up @@ -107,6 +124,14 @@ public static Screen genConfigScreen(Screen parent) {
return builder.build();
}

private static Component idleTimeMessage(int value) {
if (value == 0) {
return localized("config", "disabled");
} else {
return localized("config", "minutes", value);
}
}

// Convert magic -1 number to 61 (and reverse)
// So the "unlocked" FPS value is on the right
private static int toConfigFpsTarget(int value) {
Expand Down
26 changes: 24 additions & 2 deletions src/main/java/dynamic_fps/impl/config/DynamicFPSConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import net.minecraft.sounds.SoundSource;

public final class DynamicFPSConfig {
private int idleTime; // Seconds
private Map<PowerState, Config> configs;

private static final Path CONFIGS = FabricLoader.getInstance().getConfigDir();
Expand All @@ -33,10 +34,12 @@ public final class DynamicFPSConfig {
private static final Codec<Map<PowerState, Config>> STATES_CODEC = Codec.unboundedMap(new EnumCodec<>(PowerState.values()), Config.CODEC);

private static final Codec<DynamicFPSConfig> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(0, 30 * 60).fieldOf("idle_time").forGetter(DynamicFPSConfig::idleTime),
STATES_CODEC.fieldOf("states").forGetter(DynamicFPSConfig::configs)
).apply(instance, DynamicFPSConfig::new));

private DynamicFPSConfig(Map<PowerState, Config> configs) {
private DynamicFPSConfig(int abandonTime, Map<PowerState, Config> configs) {
this.idleTime = abandonTime;
this.configs = new EnumMap<>(configs);

for (var state : PowerState.values()) {
Expand All @@ -54,6 +57,14 @@ public Config get(PowerState state) {
}
}

public int idleTime() {
return this.idleTime;
}

public void setIdleTime(int value) {
this.idleTime = value;
}

private Map<PowerState, Config> configs() {
return this.configs;
}
Expand All @@ -64,7 +75,7 @@ public static DynamicFPSConfig load() {
try {
data = Files.readString(CONFIG_FILE);
} catch (NoSuchFileException e) {
var config = new DynamicFPSConfig(new EnumMap<>(PowerState.class));
var config = new DynamicFPSConfig(0, new EnumMap<>(PowerState.class));
config.save();
return config;
} catch (IOException e) {
Expand Down Expand Up @@ -103,6 +114,9 @@ public static Config getDefaultConfig(PowerState state) {
case UNFOCUSED: {
return new Config(1, withMasterVolume(0.25f), GraphicsState.DEFAULT, false, false);
}
case ABANDONED: {
return new Config(10, withMasterVolume(1.0f), GraphicsState.DEFAULT, false, false);
}
case INVISIBLE: {
return new Config(0, withMasterVolume(0.0f), GraphicsState.DEFAULT, false, false);
}
Expand All @@ -119,9 +133,17 @@ private static Map<SoundSource, Float> withMasterVolume(float value) {
}

private static void upgradeConfig(JsonObject root) {
upgradeIdleTime(root);
upgradeVolumeMultiplier(root);
}

private static void upgradeIdleTime(JsonObject root) {
// Add idle_time field if it's missing
if (!root.has("idle_time")) {
root.addProperty("idle_time", 0);
}
}

private static void upgradeVolumeMultiplier(JsonObject root) {
// Convert each old power state config
// - { "volume_multiplier": 0.0, ... }
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/dynamic_fps/impl/mixin/MinecraftMixin.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
package dynamic_fps.impl.mixin;

import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;

import com.mojang.blaze3d.platform.Window;

import dynamic_fps.impl.DynamicFPSMod;
import net.minecraft.client.Minecraft;

@Mixin(Minecraft.class)
public class MinecraftMixin {
@Shadow
@Final
private Window window;

@Inject(method = "<init>", at = @At("TAIL"))
private void onInit(CallbackInfo callbackInfo) {
DynamicFPSMod.setWindow(this.window.window);
}

@Inject(method = "setScreen", at = @At("TAIL"))
private void onSetScreen(CallbackInfo callbackInfo) {
DynamicFPSMod.onStatusChanged();
Expand Down
12 changes: 0 additions & 12 deletions src/main/java/dynamic_fps/impl/mixin/WindowMixin.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
package dynamic_fps.impl.mixin;

import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
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 com.mojang.blaze3d.platform.Window;
Expand All @@ -14,15 +11,6 @@

@Mixin(Window.class)
public class WindowMixin {
@Shadow
@Final
private long window;

@Inject(method = "<init>", at = @At("TAIL"))
private void postinit(CallbackInfo callbackInfo) {
DynamicFPSMod.setWindow(this.window);
}

/**
* Sets a frame rate limit while we're cancelling some or all rendering.
*/
Expand Down
Loading