Initial commit: 在 Minecraft Java 版接入 NVIDIA DLSS 超分与帧生成
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package dev.wpyw.dlss;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import dev.wpyw.dlss.cfg.MasterSwitch;
|
||||
import dev.wpyw.dlss.gui.DlssSetupScreen;
|
||||
import dev.wpyw.dlss.gui.VideoSettingsHook;
|
||||
import dev.wpyw.dlss.install.DlssStackInstaller;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
|
||||
import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.client.KeyMapping;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Client entrypoint.
|
||||
*
|
||||
* <p>This mod is the installer, the control panel and the diagnostics for the DLSS stack - it is
|
||||
* not the thing that runs DLSS. The renderer is ReShade plus its add-ons, which is why the single
|
||||
* most important fact about this mod is that <b>it cannot activate ReShade in the current
|
||||
* process</b>: ReShade is a proxy DLL for {@code opengl32.dll}, and Windows resolved that import
|
||||
* before any Fabric code ran. Installing therefore always ends with "restart the game", and the
|
||||
* GUI says so rather than pretending otherwise.
|
||||
*
|
||||
* <p>Everything the player needs is reachable three ways, in decreasing order of discoverability:
|
||||
* the buttons this mod injects into <b>Options - Video Settings</b>, the {@code /dlss} command,
|
||||
* and an (unbound by default) key binding.
|
||||
*/
|
||||
public class DlssClientMod implements ClientModInitializer {
|
||||
public static final String MOD_ID = "wpywdlss";
|
||||
public static final Logger LOG = LoggerFactory.getLogger(MOD_ID);
|
||||
|
||||
private static KeyMapping openGuiKey;
|
||||
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
LOG.info("Wpyw DLSS initialising (client)");
|
||||
|
||||
openGuiKey = KeyBindingHelper.registerKeyBinding(new KeyMapping(
|
||||
"key.wpywdlss.settings",
|
||||
InputConstants.Type.KEYSYM,
|
||||
GLFW.GLFW_KEY_UNKNOWN, // unbound by default; the video settings button is the way in
|
||||
"Wpyw DLSS"));
|
||||
|
||||
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||
while (openGuiKey.consumeClick()) {
|
||||
openPanel();
|
||||
}
|
||||
});
|
||||
|
||||
// The entry point the user actually asked for: two buttons inside Options - Video Settings.
|
||||
VideoSettingsHook.register();
|
||||
|
||||
registerCommands();
|
||||
|
||||
// Report the state once at startup, because the interesting question at this point is
|
||||
// always "did ReShade actually load this launch?".
|
||||
DlssStackInstaller.Report r = DlssStackInstaller.analyze();
|
||||
LOG.info("stack target : {}", r.target());
|
||||
LOG.info("stack status : {}", r.status());
|
||||
LOG.info("payload files : {}", r.present().size());
|
||||
if (!r.ngxMissing().isEmpty()) {
|
||||
LOG.warn("NVIDIA runtime(s) missing: {}", r.ngxMissing());
|
||||
}
|
||||
Path reshadeLog = r.target().resolve("ReShade.log");
|
||||
if (r.status() == DlssStackInstaller.Status.INSTALLED
|
||||
&& !Files.isRegularFile(reshadeLog)) {
|
||||
LOG.warn("installed, but no ReShade.log yet - restart Minecraft to let the proxy load");
|
||||
}
|
||||
LOG.info("game dir : {}", FabricLoader.getInstance().getGameDir());
|
||||
LOG.info("open the panel with /dlss, or Options - Video Settings");
|
||||
}
|
||||
|
||||
private void registerCommands() {
|
||||
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) ->
|
||||
dispatcher.register(ClientCommandManager.literal("dlss")
|
||||
.executes(ctx -> {
|
||||
openPanel();
|
||||
return 1;
|
||||
})
|
||||
.then(ClientCommandManager.literal("status")
|
||||
.executes(ctx -> {
|
||||
DlssStackInstaller.Report r = DlssStackInstaller.analyze();
|
||||
LOG.info("\n{}", r.describe());
|
||||
return 1;
|
||||
}))
|
||||
.then(ClientCommandManager.literal("install")
|
||||
.executes(ctx -> {
|
||||
try {
|
||||
DlssStackInstaller.Report r = DlssStackInstaller.install();
|
||||
LOG.info("installed: {}\n{}", r.status(), r.describe());
|
||||
} catch (Exception e) {
|
||||
LOG.error("install failed", e);
|
||||
}
|
||||
VideoSettingsHook.invalidate();
|
||||
return 1;
|
||||
}))
|
||||
.then(ClientCommandManager.literal("uninstall")
|
||||
.executes(ctx -> {
|
||||
try {
|
||||
DlssStackInstaller.Report r = DlssStackInstaller.uninstall();
|
||||
LOG.info("uninstalled: {}", r.status());
|
||||
} catch (Exception e) {
|
||||
LOG.error("uninstall failed", e);
|
||||
}
|
||||
VideoSettingsHook.invalidate();
|
||||
return 1;
|
||||
}))
|
||||
.then(ClientCommandManager.literal("on")
|
||||
.executes(ctx -> {
|
||||
setMasterSwitch(true);
|
||||
return 1;
|
||||
}))
|
||||
.then(ClientCommandManager.literal("off")
|
||||
.executes(ctx -> {
|
||||
setMasterSwitch(false);
|
||||
return 1;
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
private static void setMasterSwitch(boolean on) {
|
||||
MasterSwitch.State s = MasterSwitch.write(DlssStackInstaller.jvmBinDir(), on);
|
||||
if (!s.note().isEmpty()) {
|
||||
LOG.warn("DLSS {} failed: {}", on ? "on" : "off", s.note());
|
||||
}
|
||||
LOG.info("DLSS is now {}", s.on() ? "ON" : "OFF");
|
||||
VideoSettingsHook.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the panel over whatever is on screen now, so closing it returns the player there
|
||||
* instead of dumping them back into the world. Screens may only be replaced on the client
|
||||
* thread, so hop there first when called from a command.
|
||||
*/
|
||||
private static void openPanel() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Screen parent = mc.screen;
|
||||
mc.execute(() -> mc.setScreen(new DlssSetupScreen(parent)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.wpyw.dlss;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
/**
|
||||
* Thin Java facade over the native bridge DLL.
|
||||
*
|
||||
* <p>The bridge does the things Java cannot: create a Vulkan device, share textures with
|
||||
* Minecraft's OpenGL context through Win32 external memory, and (later) drive NVIDIA
|
||||
* Streamline / NGX.
|
||||
*
|
||||
* <p>The DLL is loaded by extracting it from this mod's own jar to a temp directory, because
|
||||
* {@code System.loadLibrary} cannot read inside a jar.
|
||||
*/
|
||||
public final class NativeBridge {
|
||||
private static final String[] RESOURCE_CANDIDATES = {
|
||||
"/assets/wpywdlss/native/windows-x86_64/wpywdlss_bridge.dll",
|
||||
"/assets/wpywdlss/native/windows-x86_64/wpywdlss_bridge_debug.dll",
|
||||
};
|
||||
|
||||
private static boolean attempted;
|
||||
private static boolean loaded;
|
||||
private static String loadError = "not attempted";
|
||||
private static Path loadedFrom;
|
||||
|
||||
private NativeBridge() {
|
||||
}
|
||||
|
||||
public static synchronized boolean isLoaded() {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public static synchronized String loadError() {
|
||||
return loadError;
|
||||
}
|
||||
|
||||
public static synchronized Path loadedFrom() {
|
||||
return loadedFrom;
|
||||
}
|
||||
|
||||
/** Loads the bridge exactly once. Returns true on success. Never throws. */
|
||||
public static synchronized boolean load() {
|
||||
if (attempted) {
|
||||
return loaded;
|
||||
}
|
||||
attempted = true;
|
||||
|
||||
try {
|
||||
// Dev override: point straight at a build output so a rebuild needs no repackaging.
|
||||
String override = System.getProperty("wpywdlss.bridgePath");
|
||||
if (override != null && !override.isBlank()) {
|
||||
Path p = Path.of(override);
|
||||
if (Files.isRegularFile(p)) {
|
||||
System.load(p.toAbsolutePath().toString());
|
||||
loaded = true;
|
||||
loadedFrom = p;
|
||||
loadError = null;
|
||||
return true;
|
||||
}
|
||||
loadError = "wpywdlss.bridgePath set but not a file: " + override;
|
||||
return false;
|
||||
}
|
||||
|
||||
Path dir = Files.createTempDirectory("wpywdlss-bridge");
|
||||
dir.toFile().deleteOnExit();
|
||||
|
||||
for (String resource : RESOURCE_CANDIDATES) {
|
||||
try (InputStream in = NativeBridge.class.getResourceAsStream(resource)) {
|
||||
if (in == null) {
|
||||
continue;
|
||||
}
|
||||
String fileName = resource.substring(resource.lastIndexOf('/') + 1);
|
||||
Path target = dir.resolve(fileName);
|
||||
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
target.toFile().deleteOnExit();
|
||||
System.load(target.toAbsolutePath().toString());
|
||||
loaded = true;
|
||||
loadedFrom = target;
|
||||
loadError = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
loadError = "no bridge DLL found in jar resources (looked for "
|
||||
+ String.join(", ", RESOURCE_CANDIDATES) + ")";
|
||||
} catch (IOException | UnsatisfiedLinkError | SecurityException e) {
|
||||
loadError = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- native surface -------------------------------------------------
|
||||
|
||||
/** Bridge build identifier, e.g. "0.1.0-p0". */
|
||||
public static native String nativeVersion();
|
||||
|
||||
/** Runs the phase-0 Vulkan / interop capability probe and returns a report. */
|
||||
public static native String nativeProbe();
|
||||
|
||||
/**
|
||||
* Creates a hidden WGL context and reports the OpenGL side of the interop story:
|
||||
* whether GL_EXT_memory_object_win32 / GL_EXT_semaphore_win32 are present and
|
||||
* whether the interop entry points resolve. This is the go/no-go check for every
|
||||
* AI-upscaling route, because OpenGL cannot export memory -- a Vulkan/D3D12 device
|
||||
* allocates and exports, and GL imports.
|
||||
*/
|
||||
public static native String nativeProbeOpenGL();
|
||||
|
||||
/** True once a Vulkan device is up and the interop entry points resolved. */
|
||||
public static native boolean nativeIsReady();
|
||||
|
||||
/** Destroys the Vulkan device and instance. */
|
||||
public static native void nativeShutdown();
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package dev.wpyw.dlss.cfg;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Line-preserving editor for the flat {@code key=value} files this stack uses.
|
||||
*
|
||||
* <p>Both {@code dlss5-feed.cfg} and {@code deep-fried-chicken.cfg} are completely flat
|
||||
* (no sections, 600+ keys each) and, crucially, <b>re-read while the game runs</b> - the
|
||||
* DLSS5-Feeder documentation says so explicitly. That means a Minecraft GUI can change most
|
||||
* settings live, without a restart, by rewriting the file.
|
||||
*
|
||||
* <p>These files belong to other programs, so edits are as narrow as they can be:
|
||||
* <ul>
|
||||
* <li>edits are applied per line and unknown lines are kept verbatim - rebuilding the file
|
||||
* from a parsed map would drop the hundreds of keys this mod does not know about and
|
||||
* reorder everything;</li>
|
||||
* <li>the original <b>line ending is preserved</b>. Both live files are CRLF; writing them
|
||||
* back with LF would rewrite all 666 lines of a file Deep Fried Chicken owns, to no
|
||||
* benefit, and risks churn against whatever that add-on does when it saves.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class FlatCfg {
|
||||
|
||||
private final Path path;
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private final String eol;
|
||||
|
||||
private FlatCfg(Path path, List<String> lines, String eol) {
|
||||
this.path = path;
|
||||
this.lines.addAll(lines);
|
||||
this.eol = eol;
|
||||
}
|
||||
|
||||
/** Reads an existing file, or starts an empty one if it does not exist. */
|
||||
public static FlatCfg load(Path path) throws IOException {
|
||||
if (Files.isRegularFile(path)) {
|
||||
byte[] raw = Files.readAllBytes(path);
|
||||
return new FlatCfg(path, decodeLines(raw), detectEol(raw));
|
||||
}
|
||||
// A file this mod creates should look native on the host platform.
|
||||
return new FlatCfg(path, new ArrayList<>(), System.lineSeparator());
|
||||
}
|
||||
|
||||
/** The line terminator that will be used on {@link #save()}, as read from disk. */
|
||||
public String lineEnding() {
|
||||
return eol;
|
||||
}
|
||||
|
||||
public Path path() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Snapshot of the current key/value pairs, in file order. Only simple {@code k=v} lines. */
|
||||
public Map<String, String> asMap() {
|
||||
Map<String, String> m = new LinkedHashMap<>();
|
||||
for (String line : lines) {
|
||||
int eq = line.indexOf('=');
|
||||
if (eq <= 0) {
|
||||
continue;
|
||||
}
|
||||
String k = line.substring(0, eq).trim();
|
||||
if (!k.isEmpty() && !k.startsWith("#") && !k.startsWith(";")) {
|
||||
m.put(k, line.substring(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
public String get(String key, String fallback) {
|
||||
return asMap().getOrDefault(key, fallback);
|
||||
}
|
||||
|
||||
public int getInt(String key, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(get(key, Integer.toString(fallback)).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
public double getDouble(String key, double fallback) {
|
||||
try {
|
||||
return Double.parseDouble(get(key, Double.toString(fallback)).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean getBool(String key, boolean fallback) {
|
||||
String v = get(key, fallback ? "1" : "0").trim();
|
||||
return "1".equals(v) || "true".equalsIgnoreCase(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a key in place, or appends it if absent. Returns {@code true} when the value actually
|
||||
* changed, so callers can avoid pointless disk writes.
|
||||
*/
|
||||
public boolean set(String key, String value) {
|
||||
String wanted = key + "=" + value;
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
String line = lines.get(i);
|
||||
int eq = line.indexOf('=');
|
||||
if (eq > 0 && line.substring(0, eq).trim().equals(key)) {
|
||||
if (line.equals(wanted)) {
|
||||
return false;
|
||||
}
|
||||
lines.set(i, wanted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
lines.add(wanted);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean set(String key, int value) {
|
||||
return set(key, Integer.toString(value));
|
||||
}
|
||||
|
||||
public boolean set(String key, double value) {
|
||||
// The cfg files use plain decimal notation; keep it stable to avoid needless rewrites.
|
||||
return set(key, String.format(java.util.Locale.ROOT, "%.3f", value));
|
||||
}
|
||||
|
||||
public boolean set(String key, boolean value) {
|
||||
return set(key, value ? "1" : "0");
|
||||
}
|
||||
|
||||
/** Writes the file, creating parent directories, using the line ending it was read with. */
|
||||
public void save() throws IOException {
|
||||
Path parent = path.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
Files.write(path, (String.join(eol, lines) + eol).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ line handling
|
||||
|
||||
/**
|
||||
* Splits on LF, CRLF and lone CR, with the same shape as {@code Files.readAllLines}: no
|
||||
* trailing empty element for a final terminator, but empty elements kept for blank lines.
|
||||
*/
|
||||
private static List<String> decodeLines(byte[] raw) {
|
||||
String text = new String(raw, StandardCharsets.UTF_8);
|
||||
List<String> out = new ArrayList<>();
|
||||
int start = 0;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (c != '\n' && c != '\r') {
|
||||
continue;
|
||||
}
|
||||
out.add(text.substring(start, i));
|
||||
if (c == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') {
|
||||
i++;
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
if (start < text.length()) {
|
||||
out.add(text.substring(start));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String detectEol(byte[] raw) {
|
||||
int crlf = 0;
|
||||
int bareLf = 0;
|
||||
int bareCr = 0;
|
||||
for (int i = 0; i < raw.length; i++) {
|
||||
byte b = raw[i];
|
||||
if (b == '\n') {
|
||||
if (i > 0 && raw[i - 1] == '\r') {
|
||||
crlf++;
|
||||
} else {
|
||||
bareLf++;
|
||||
}
|
||||
} else if (b == '\r' && (i + 1 >= raw.length || raw[i + 1] != '\n')) {
|
||||
bareCr++;
|
||||
}
|
||||
}
|
||||
if (crlf > 0 && crlf >= bareLf && crlf >= bareCr) {
|
||||
return "\r\n";
|
||||
}
|
||||
if (bareCr > 0 && bareCr > bareLf) {
|
||||
return "\r";
|
||||
}
|
||||
if (bareLf > 0) {
|
||||
return "\n";
|
||||
}
|
||||
return System.lineSeparator();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package dev.wpyw.dlss.cfg;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Line-preserving, section-aware editor for ReShade's ini files.
|
||||
*
|
||||
* <p>Two shapes have to be handled, and they are different:
|
||||
* <ul>
|
||||
* <li>{@code ReShade.ini} is a normal ini - every key lives under a {@code [Section]}.</li>
|
||||
* <li>{@code ReShadePreset.ini} puts {@code Techniques=} and {@code TechniqueSorting=} in the
|
||||
* file's <b>preamble</b>, before the first {@code [Effect.fx]} section. Those two keys are
|
||||
* the ones that matter most: {@code Techniques=} says what is enabled, and
|
||||
* {@code TechniqueSorting=} defines execution order - which is what decides whether the
|
||||
* motion-vector provider runs before the shader that consumes it.</li>
|
||||
* </ul>
|
||||
* A {@code null} section therefore means "the preamble". Replacing either file wholesale would
|
||||
* destroy state we do not own (there are hundreds of keys here, and ReShade regenerates defaults
|
||||
* on exit), so edits are applied line by line and everything unknown is preserved verbatim.
|
||||
*/
|
||||
public final class IniFile {
|
||||
|
||||
private final Path path;
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
|
||||
private IniFile(Path path, List<String> lines) {
|
||||
this.path = path;
|
||||
this.lines.addAll(lines);
|
||||
}
|
||||
|
||||
public static IniFile load(Path path) throws IOException {
|
||||
if (Files.isRegularFile(path)) {
|
||||
return new IniFile(path, Files.readAllLines(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
return new IniFile(path, new ArrayList<>());
|
||||
}
|
||||
|
||||
public Path path() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public boolean isNew() {
|
||||
return lines.isEmpty();
|
||||
}
|
||||
|
||||
private static boolean isHeader(String line) {
|
||||
String t = line.trim();
|
||||
return t.length() >= 3 && t.startsWith("[") && t.endsWith("]");
|
||||
}
|
||||
|
||||
/** Index of the given section's header line, or -1. */
|
||||
private int headerIndex(String section) {
|
||||
if (section == null || section.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
String want = "[" + section + "]";
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
if (lines.get(i).trim().equalsIgnoreCase(want)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Exclusive end of a section body (the next header, or the end of file). */
|
||||
private int bodyEnd(int headerIdx) {
|
||||
for (int i = headerIdx + 1; i < lines.size(); i++) {
|
||||
if (isHeader(lines.get(i))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return lines.size();
|
||||
}
|
||||
|
||||
/** For the preamble: [first, last) index range that may contain keys. */
|
||||
private int preambleEnd() {
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
if (isHeader(lines.get(i))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return lines.size();
|
||||
}
|
||||
|
||||
private int findKey(String section, String key) {
|
||||
int from;
|
||||
int to;
|
||||
if (section == null || section.isEmpty()) {
|
||||
from = 0;
|
||||
to = preambleEnd();
|
||||
} else {
|
||||
int h = headerIndex(section);
|
||||
if (h < 0) {
|
||||
return -1;
|
||||
}
|
||||
from = h + 1;
|
||||
to = bodyEnd(h);
|
||||
}
|
||||
for (int i = from; i < to; i++) {
|
||||
String line = lines.get(i);
|
||||
int eq = line.indexOf('=');
|
||||
if (eq > 0 && line.substring(0, eq).trim().equalsIgnoreCase(key)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public String get(String section, String key, String fallback) {
|
||||
int i = findKey(section, key);
|
||||
if (i < 0) {
|
||||
return fallback;
|
||||
}
|
||||
String line = lines.get(i);
|
||||
return line.substring(line.indexOf('=') + 1).trim();
|
||||
}
|
||||
|
||||
/** Sets a key, creating the section (or preamble entry) when needed. */
|
||||
public boolean set(String section, String key, String value) {
|
||||
String wanted = key + "=" + value;
|
||||
int i = findKey(section, key);
|
||||
if (i >= 0) {
|
||||
if (lines.get(i).equals(wanted)) {
|
||||
return false;
|
||||
}
|
||||
lines.set(i, wanted);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (section == null || section.isEmpty()) {
|
||||
// Insert at the end of the preamble, i.e. immediately before the first header.
|
||||
lines.add(preambleEnd(), wanted);
|
||||
return true;
|
||||
}
|
||||
|
||||
int h = headerIndex(section);
|
||||
if (h < 0) {
|
||||
if (!lines.isEmpty() && !lines.get(lines.size() - 1).isBlank()) {
|
||||
lines.add("");
|
||||
}
|
||||
lines.add("[" + section + "]");
|
||||
lines.add(wanted);
|
||||
return true;
|
||||
}
|
||||
lines.add(bodyEnd(h), wanted);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates one element of a comma-separated list value, preserving every other element
|
||||
* and their order. Used for {@code PreprocessorDefinitions} and {@code [ADDON] LoadFromDllMain},
|
||||
* where other tools legitimately own entries too.
|
||||
*
|
||||
* @return true when something changed
|
||||
*/
|
||||
public boolean addToListElement(String section, String key, String element) {
|
||||
String current = get(section, key, "");
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (String p : current.split(",")) {
|
||||
String t = p.trim();
|
||||
if (!t.isEmpty()) {
|
||||
parts.add(t);
|
||||
}
|
||||
}
|
||||
String wantedName = element.contains("=") ? element.substring(0, element.indexOf('=')).trim() : element;
|
||||
for (int k = 0; k < parts.size(); k++) {
|
||||
String p = parts.get(k);
|
||||
String name = p.contains("=") ? p.substring(0, p.indexOf('=')).trim() : p;
|
||||
if (name.equalsIgnoreCase(wantedName)) {
|
||||
if (p.equals(element)) {
|
||||
return false;
|
||||
}
|
||||
parts.set(k, element);
|
||||
return set(section, key, String.join(",", parts));
|
||||
}
|
||||
}
|
||||
parts.add(element);
|
||||
return set(section, key, String.join(",", parts));
|
||||
}
|
||||
|
||||
/** Moves an element to the front of a comma-separated list. Used for TechniqueSorting. */
|
||||
public boolean moveToFront(String section, String key, String element) {
|
||||
String current = get(section, key, "");
|
||||
if (current.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (String p : current.split(",")) {
|
||||
String t = p.trim();
|
||||
if (!t.isEmpty()) {
|
||||
parts.add(t);
|
||||
}
|
||||
}
|
||||
int idx = parts.indexOf(element);
|
||||
if (idx == 0) {
|
||||
return false;
|
||||
}
|
||||
if (idx > 0) {
|
||||
parts.remove(idx);
|
||||
}
|
||||
parts.add(0, element);
|
||||
return set(section, key, String.join(",", parts));
|
||||
}
|
||||
|
||||
public void removeKey(String section, String key) {
|
||||
int i = findKey(section, key);
|
||||
if (i >= 0) {
|
||||
lines.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
/** All {@code key=value} pairs of a section (or of the preamble when section is null). */
|
||||
public Map<String, String> section(String section) {
|
||||
Map<String, String> m = new LinkedHashMap<>();
|
||||
int from;
|
||||
int to;
|
||||
if (section == null || section.isEmpty()) {
|
||||
from = 0;
|
||||
to = preambleEnd();
|
||||
} else {
|
||||
int h = headerIndex(section);
|
||||
if (h < 0) {
|
||||
return m;
|
||||
}
|
||||
from = h + 1;
|
||||
to = bodyEnd(h);
|
||||
}
|
||||
for (int i = from; i < to; i++) {
|
||||
String line = lines.get(i);
|
||||
int eq = line.indexOf('=');
|
||||
if (eq > 0) {
|
||||
m.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
public List<String> rawLines() {
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
|
||||
public void save() throws IOException {
|
||||
Path parent = path.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
List<String> out = new ArrayList<>(lines);
|
||||
while (!out.isEmpty() && out.get(out.size() - 1).isBlank()) {
|
||||
out.remove(out.size() - 1);
|
||||
}
|
||||
// ReShade writes CRLF; match it so its own diffs stay clean.
|
||||
Files.write(path, (String.join("\r\n", out) + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package dev.wpyw.dlss.cfg;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* The one switch a player actually wants: "is DLSS on right now?".
|
||||
*
|
||||
* <p>There is no single flag for that. Two independent ReShade add-ons have to be enabled -
|
||||
* DLSS5-Feeder, which turns each frame into a DLSS request, and Deep Fried Chicken, which
|
||||
* is the thing that performs the neural render - and each keeps its own {@code enabled} key
|
||||
* in its own flat cfg file. A half-on stack is a real and confusing failure mode (the feeder
|
||||
* requests frames nobody renders), so both are always read and written together.
|
||||
*
|
||||
* <p>Both files are re-read by their add-ons while the game runs, so this is a live switch:
|
||||
* flipping it does not require a restart. Installing the stack does, because ReShade is a
|
||||
* proxy DLL and Windows resolved {@code opengl32.dll} before any mod code ran.
|
||||
*/
|
||||
public final class MasterSwitch {
|
||||
|
||||
public static final String FEED_CFG = "dlss5-feed.cfg";
|
||||
public static final String CHICKEN_CFG = "deep-fried-chicken.cfg";
|
||||
|
||||
private static final String KEY = "enabled";
|
||||
|
||||
/**
|
||||
* @param on both cfg files exist and both say {@code enabled=1}
|
||||
* @param feedPresent {@code dlss5-feed.cfg} is on disk
|
||||
* @param chickenPresent {@code deep-fried-chicken.cfg} is on disk
|
||||
* @param note empty unless the last operation failed, then the reason
|
||||
*/
|
||||
public record State(boolean on, boolean feedPresent, boolean chickenPresent, String note) {
|
||||
|
||||
/** Both add-on cfgs are present, so there is something to switch. */
|
||||
public boolean switchable() {
|
||||
return this.feedPresent && this.chickenPresent;
|
||||
}
|
||||
}
|
||||
|
||||
private MasterSwitch() {
|
||||
}
|
||||
|
||||
/** Reads the current state. Never throws; a read failure is reported as {@code on=false}. */
|
||||
public static State read(Path binDir) {
|
||||
Path feed = binDir.resolve(FEED_CFG);
|
||||
Path chicken = binDir.resolve(CHICKEN_CFG);
|
||||
boolean feedPresent = Files.isRegularFile(feed);
|
||||
boolean chickenPresent = Files.isRegularFile(chicken);
|
||||
|
||||
if (!feedPresent || !chickenPresent) {
|
||||
// Nothing to read. Deliberately not "on": a missing cfg is an uninstalled stack,
|
||||
// not an enabled one, and reporting it as enabled would hide the real problem.
|
||||
return new State(false, feedPresent, chickenPresent, "");
|
||||
}
|
||||
try {
|
||||
boolean on = FlatCfg.load(feed).getBool(KEY, false)
|
||||
&& FlatCfg.load(chicken).getBool(KEY, false);
|
||||
return new State(on, true, true, "");
|
||||
} catch (IOException e) {
|
||||
return new State(false, true, true, "read failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes {@code enabled} to both cfgs. Returns the state that is actually on disk
|
||||
* afterwards, which is what the caller should display - not what it asked for.
|
||||
*/
|
||||
public static State write(Path binDir, boolean enabled) {
|
||||
Path feed = binDir.resolve(FEED_CFG);
|
||||
Path chicken = binDir.resolve(CHICKEN_CFG);
|
||||
boolean feedPresent = Files.isRegularFile(feed);
|
||||
boolean chickenPresent = Files.isRegularFile(chicken);
|
||||
|
||||
if (!feedPresent || !chickenPresent) {
|
||||
return new State(false, feedPresent, chickenPresent,
|
||||
"add-on cfg missing - install the stack first");
|
||||
}
|
||||
try {
|
||||
// Feeder first, deliberately. If the second write fails we are left with feeder=off,
|
||||
// chicken=on - which is inert (nothing is asking for neural frames) rather than the
|
||||
// other way round, which would leave the feeder requesting frames nobody renders.
|
||||
for (Path p : new Path[]{feed, chicken}) {
|
||||
FlatCfg cfg = FlatCfg.load(p);
|
||||
// Only touch the file when the value really changes, so a no-op toggle does
|
||||
// not rewrite 666 lines and risk racing the add-on's own reader.
|
||||
if (cfg.set(KEY, enabled)) {
|
||||
cfg.save();
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
State now = read(binDir);
|
||||
return new State(now.on(), feedPresent, chickenPresent, "write failed: " + e.getMessage());
|
||||
}
|
||||
return read(binDir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dev.wpyw.dlss.diag;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Reads the tail of the stack's log files so the in-game GUI can show what is actually happening
|
||||
* without the player alt-tabbing to a text editor.
|
||||
*
|
||||
* <p>Three logs matter and each answers a different question:
|
||||
* <ul>
|
||||
* <li>{@code ReShade.log} - did ReShade load at all, and did it register the add-ons?</li>
|
||||
* <li>{@code dlss5-feed.log} - did the feeder open its transport, create the DLSS feature, and
|
||||
* is it getting motion vectors and depth?</li>
|
||||
* <li>{@code deep-fried-chicken.log} - did the neural pass run, and how many frames succeeded?</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class LogTail {
|
||||
|
||||
private LogTail() {
|
||||
}
|
||||
|
||||
public static final String RESHADE = "ReShade.log";
|
||||
public static final String FEEDER = "dlss5-feed.log";
|
||||
public static final String CHICKEN = "deep-fried-chicken.log";
|
||||
|
||||
/** Last {@code maxLines} lines of a log, or an explanatory single line when unusable. */
|
||||
public static List<String> tail(Path logFile, int maxLines) {
|
||||
if (!Files.isRegularFile(logFile)) {
|
||||
return List.of("(no " + logFile.getFileName() + " - the stack has not run yet)");
|
||||
}
|
||||
Deque<String> ring = new ArrayDeque<>();
|
||||
try {
|
||||
for (String line : Files.readAllLines(logFile, StandardCharsets.UTF_8)) {
|
||||
ring.addLast(stripAnsi(line));
|
||||
if (ring.size() > maxLines) {
|
||||
ring.removeFirst();
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
return List.of("(cannot read " + logFile.getFileName() + ": " + e.getMessage() + ")");
|
||||
}
|
||||
return new ArrayList<>(ring);
|
||||
}
|
||||
|
||||
/** Greps a log for the lines that carry a verdict, rather than dumping everything. */
|
||||
public static List<String> highlights(Path logFile, int maxLines) {
|
||||
if (!Files.isRegularFile(logFile)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> hits = new ArrayList<>();
|
||||
try {
|
||||
for (String line : Files.readAllLines(logFile, StandardCharsets.UTF_8)) {
|
||||
String l = stripAnsi(line);
|
||||
if (l.contains("Registered add-on")
|
||||
|| l.contains("neural frame succeeded")
|
||||
|| l.contains("feature ready")
|
||||
|| l.contains("feature 18 created")
|
||||
|| l.contains("session ready")
|
||||
|| l.contains("unified neural frame accepted")
|
||||
|| l.contains("MV probe")
|
||||
|| l.contains("Depth probe")
|
||||
|| l.contains("WARN")
|
||||
|| l.contains("ERROR")) {
|
||||
hits.add(l);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
return List.of("(cannot read " + logFile.getFileName() + ": " + e.getMessage() + ")");
|
||||
}
|
||||
if (hits.size() > maxLines) {
|
||||
return new ArrayList<>(hits.subList(hits.size() - maxLines, hits.size()));
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** ReShade colourises its log; those escapes render as garbage in a plain text widget. */
|
||||
private static String stripAnsi(String s) {
|
||||
return s.replaceAll("\u001B\\[[0-9;]*[A-Za-z]", "");
|
||||
}
|
||||
|
||||
/** One short status word for a log, for the GUI's summary line. */
|
||||
public static String verdict(Path logFile, String successMarker) {
|
||||
if (!Files.isRegularFile(logFile)) {
|
||||
return "absent";
|
||||
}
|
||||
try {
|
||||
for (String line : Files.readAllLines(logFile, StandardCharsets.UTF_8)) {
|
||||
if (line.contains(successMarker)) {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
return "unreadable";
|
||||
}
|
||||
return "no match";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package dev.wpyw.dlss.gui;
|
||||
|
||||
import dev.wpyw.dlss.diag.LogTail;
|
||||
import dev.wpyw.dlss.install.DlssStackInstaller;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Shows the verdict lines from the three logs, in game.
|
||||
*
|
||||
* <p>The point is to remove the alt-tab: whether the stack is actually working is answered by
|
||||
* specific log lines, not by how the picture looks. For example a working setup prints
|
||||
* {@code Registered add-on}, {@code session ready}, {@code feature ready ... DLAA} and
|
||||
* {@code standalone neural frame succeeded}, while the characteristic failure prints
|
||||
* {@code depth is FLAT while the scene moves}.
|
||||
*
|
||||
* <p>Scrolling uses buttons rather than the mouse wheel on purpose: the wheel handler's signature
|
||||
* changed between 1.20.1 and later versions, and getting it wrong breaks the build for no benefit.
|
||||
*/
|
||||
public class DlssLogScreen extends Screen {
|
||||
|
||||
private static final int WHITE = 0xFFFFFFFF;
|
||||
private static final int GREY = 0xFFAAAAAA;
|
||||
private static final int GREEN = 0xFF55FF55;
|
||||
private static final int ORANGE = 0xFFFFAA55;
|
||||
|
||||
private final Screen parent;
|
||||
|
||||
private record Tab(String label, String fileName, String successMarker) {
|
||||
}
|
||||
|
||||
private static final Tab[] TABS = {
|
||||
new Tab("ReShade", LogTail.RESHADE, "Registered add-on"),
|
||||
new Tab("Feeder", LogTail.FEEDER, "session ready"),
|
||||
new Tab("Chicken", LogTail.CHICKEN, "neural frame succeeded"),
|
||||
};
|
||||
|
||||
private int tab = 0;
|
||||
private int scroll = 0;
|
||||
private List<String> lines = new ArrayList<>();
|
||||
private String verdict = "";
|
||||
|
||||
private Button tabButton;
|
||||
private Button upButton;
|
||||
private Button downButton;
|
||||
|
||||
public DlssLogScreen(Screen parent) {
|
||||
super(Component.literal("Wpyw DLSS - 日志与诊断"));
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
int cx = this.width / 2;
|
||||
|
||||
tabButton = Button.builder(Component.literal(tabLabel()), b -> {
|
||||
tab = (tab + 1) % TABS.length;
|
||||
scroll = 0;
|
||||
b.setMessage(Component.literal(tabLabel()));
|
||||
load();
|
||||
updateScrollButtons();
|
||||
}).bounds(cx - 235, 34, 200, 20).build();
|
||||
addRenderableWidget(tabButton);
|
||||
|
||||
addRenderableWidget(Button.builder(Component.literal("只看关键行 / 看全部"), b -> {
|
||||
showAll = !showAll;
|
||||
scroll = 0;
|
||||
load();
|
||||
updateScrollButtons();
|
||||
}).bounds(cx - 27, 34, 140, 20).build());
|
||||
|
||||
addRenderableWidget(Button.builder(Component.literal("复制到剪贴板"), b -> {
|
||||
this.minecraft.keyboardHandler.setClipboard(String.join("\n", lines));
|
||||
}).bounds(cx + 121, 34, 114, 20).build());
|
||||
|
||||
upButton = Button.builder(Component.literal("▲"), b -> {
|
||||
scroll = Math.max(0, scroll - 12);
|
||||
updateScrollButtons();
|
||||
}).bounds(cx + 243, 34 + 30, 20, 20).build();
|
||||
addRenderableWidget(upButton);
|
||||
|
||||
downButton = Button.builder(Component.literal("▼"), b -> {
|
||||
scroll = Math.min(maxScroll(), scroll + 12);
|
||||
updateScrollButtons();
|
||||
}).bounds(cx + 243, this.height - 74, 20, 20).build();
|
||||
addRenderableWidget(downButton);
|
||||
|
||||
addRenderableWidget(Button.builder(Component.literal("返回"), b -> this.onClose())
|
||||
.bounds(cx - 76, this.height - 48, 152, 20).build());
|
||||
|
||||
load();
|
||||
updateScrollButtons();
|
||||
}
|
||||
|
||||
private boolean showAll = false;
|
||||
|
||||
private String tabLabel() {
|
||||
return "日志: " + TABS[tab].label() + "(点击切换)";
|
||||
}
|
||||
|
||||
private void load() {
|
||||
Path dir = DlssStackInstaller.jvmBinDir();
|
||||
List<String> raw = showAll
|
||||
? LogTail.tail(dir.resolve(TABS[tab].fileName()), 400)
|
||||
: LogTail.highlights(dir.resolve(TABS[tab].fileName()), 400);
|
||||
lines = raw.isEmpty() ? List.of("(没有匹配的关键行 - 勾选「看全部」,或该日志尚未生成)") : raw;
|
||||
verdict = LogTail.verdict(dir.resolve(TABS[tab].fileName()), TABS[tab].successMarker());
|
||||
}
|
||||
|
||||
private int visibleRows() {
|
||||
return Math.max(1, (this.height - 120) / 10);
|
||||
}
|
||||
|
||||
private int maxScroll() {
|
||||
return Math.max(0, lines.size() - visibleRows());
|
||||
}
|
||||
|
||||
private void updateScrollButtons() {
|
||||
if (upButton != null) {
|
||||
upButton.active = scroll > 0;
|
||||
}
|
||||
if (downButton != null) {
|
||||
downButton.active = scroll < maxScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics g, int mouseX, int mouseY, float partialTick) {
|
||||
renderBackground(g);
|
||||
g.drawCenteredString(this.minecraft.font, this.title, this.width / 2, 14, WHITE);
|
||||
|
||||
int verdictColour = switch (verdict) {
|
||||
case "ok" -> GREEN;
|
||||
case "absent" -> GREY;
|
||||
default -> ORANGE;
|
||||
};
|
||||
String v = "判据 " + TABS[tab].successMarker() + " -> " + verdict;
|
||||
g.drawString(this.minecraft.font, v, this.width / 2 - 235, 62, verdictColour);
|
||||
|
||||
int y = 78;
|
||||
int rows = visibleRows();
|
||||
int end = Math.min(lines.size(), scroll + rows);
|
||||
for (int i = scroll; i < end; i++) {
|
||||
String line = lines.get(i);
|
||||
int colour = GREY;
|
||||
if (line.contains("ERROR") || line.contains("FAIL")) {
|
||||
colour = ORANGE;
|
||||
} else if (line.contains("Registered add-on") || line.contains("neural frame succeeded")
|
||||
|| line.contains("session ready") || line.contains("feature ready")) {
|
||||
colour = GREEN;
|
||||
} else if (line.contains("WARN")) {
|
||||
colour = ORANGE;
|
||||
}
|
||||
g.drawString(this.minecraft.font, this.minecraft.font.plainSubstrByWidth(line, this.width - 96),
|
||||
this.width / 2 - 235, y, colour);
|
||||
y += 10;
|
||||
}
|
||||
|
||||
g.drawString(this.minecraft.font,
|
||||
String.format("%d 行,显示 %d-%d", lines.size(), scroll + 1, end),
|
||||
this.width / 2 - 235, this.height - 78, GREY);
|
||||
|
||||
super.render(g, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
this.minecraft.setScreen(parent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package dev.wpyw.dlss.gui;
|
||||
|
||||
import dev.wpyw.dlss.cfg.FlatCfg;
|
||||
import dev.wpyw.dlss.install.DlssStackInstaller;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractSliderButton;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Live quality settings.
|
||||
*
|
||||
* <p>Everything here is written into the two {@code .cfg} files that sit beside the add-ons. That
|
||||
* is worth explaining, because it is why this screen can work at all: the DLSS5-Feeder README
|
||||
* states that {@code dlss5-feed.cfg} is "re-read while the game runs, if you prefer editing the
|
||||
* file directly", and Deep Fried Chicken behaves the same way. So most knobs take effect within a
|
||||
* second or two without a restart - the notable exception being {@code arm}, which is
|
||||
* restart-only.
|
||||
*
|
||||
* <p>The defaults are already sane (one neural pass at 100% work scale), so this screen exists
|
||||
* mainly for two things: trading the neural pass off against frame rate, and fixing the HUD if
|
||||
* the neural pass starts eating the hotbar.
|
||||
*/
|
||||
public class DlssSettingsScreen extends Screen {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger("wpywdlss/gui");
|
||||
|
||||
private static final int WHITE = 0xFFFFFFFF;
|
||||
private static final int GREY = 0xFFAAAAAA;
|
||||
private static final int YELLOW = 0xFFFFD166;
|
||||
private static final int GREEN = 0xFF55FF55;
|
||||
|
||||
private final Screen parent;
|
||||
|
||||
private FlatCfg feed;
|
||||
private FlatCfg chicken;
|
||||
private final List<String> notes = new ArrayList<>();
|
||||
|
||||
private int left;
|
||||
private int right;
|
||||
|
||||
public DlssSettingsScreen(Screen parent) {
|
||||
super(Component.literal("Wpyw DLSS - 画质设置"));
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
private Path bin() {
|
||||
return DlssStackInstaller.jvmBinDir();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
notes.clear();
|
||||
try {
|
||||
feed = FlatCfg.load(bin().resolve("dlss5-feed.cfg"));
|
||||
} catch (Exception e) {
|
||||
notes.add("读不到 dlss5-feed.cfg: " + e.getMessage());
|
||||
}
|
||||
try {
|
||||
chicken = FlatCfg.load(bin().resolve("deep-fried-chicken.cfg"));
|
||||
} catch (Exception e) {
|
||||
notes.add("读不到 deep-fried-chicken.cfg: " + e.getMessage());
|
||||
}
|
||||
|
||||
left = this.width / 2 - 235;
|
||||
right = this.width / 2 + 5;
|
||||
int y = 44;
|
||||
final int row = 24;
|
||||
|
||||
// ---- Feed side (left column): the DLSS transport itself ----
|
||||
addRenderableWidget(Button.builder(toggleLabel("Feeder 总开关", feed, "enabled"), b -> {
|
||||
toggle(feed, "enabled");
|
||||
b.setMessage(toggleLabel("Feeder 总开关", feed, "enabled"));
|
||||
}).bounds(left, y, 230, 20).build());
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(new DoubleSlider(left, y, 230, 20, "锐化 (work_sharpness)", 0.0, 1.0, 0.01,
|
||||
() -> feed == null ? 0.30 : feed.getDouble("work_sharpness", 0.30),
|
||||
v -> setDouble(feed, "work_sharpness", v)));
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(new DoubleSlider(left, y, 230, 20, "创建延迟 (create_delay, 帧)", 0, 240, 1,
|
||||
() -> feed == null ? 60 : feed.getInt("create_delay", 60),
|
||||
v -> setInt(feed, "create_delay", (int) Math.round(v))));
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(new DoubleSlider(left, y, 230, 20, "卡顿阈值 (stall_log_ms)", 0, 200, 5,
|
||||
() -> feed == null ? 50 : feed.getInt("stall_log_ms", 50),
|
||||
v -> setInt(feed, "stall_log_ms", (int) Math.round(v))));
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(new DoubleSlider(left, y, 230, 20, "运动矢量放大 (mv_scale)", 0.5, 2.0, 0.05,
|
||||
() -> feed == null ? 1.0 : feed.getDouble("mv_scale_x", 1.0),
|
||||
v -> {
|
||||
setDouble(feed, "mv_scale_x", v);
|
||||
setDouble(feed, "mv_scale_y", v);
|
||||
}));
|
||||
|
||||
// ---- Chicken side (right column): the neural pass ----
|
||||
y = 44;
|
||||
addRenderableWidget(Button.builder(toggleLabel("神经渲染", chicken, "enabled"), b -> {
|
||||
toggle(chicken, "enabled");
|
||||
b.setMessage(toggleLabel("神经渲染", chicken, "enabled"));
|
||||
}).bounds(right, y, 230, 20).build());
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(new DoubleSlider(right, y, 230, 20, "神经 pass 数", 1, 10, 0.1,
|
||||
() -> chicken == null ? 1.0 : chicken.getDouble("passes", 1.0),
|
||||
v -> {
|
||||
setDouble(chicken, "passes", v);
|
||||
setInt(chicken, "layers", Math.max(1, (int) Math.ceil(v)));
|
||||
}));
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(new DoubleSlider(right, y, 230, 20, "神经工作分辨率 %", 25, 150, 5,
|
||||
() -> chicken == null ? 100 : chicken.getInt("neural_work_percent", 100),
|
||||
v -> {
|
||||
setInt(chicken, "neural_work_percent", (int) Math.round(v));
|
||||
// >100% is neural supersampling; <100% can look soft. Both are documented trade-offs.
|
||||
}));
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(new DoubleSlider(right, y, 230, 20, "强度 (layer_1_intensity)", 0.0, 3.0, 0.05,
|
||||
() -> chicken == null ? 1.0 : chicken.getDouble("layer_1_intensity", 1.0),
|
||||
v -> setDouble(chicken, "layer_1_intensity", v)));
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(Button.builder(toggleLabel("HUD 修正 (layer_1_ui_correction)", chicken, "layer_1_ui_correction"), b -> {
|
||||
toggle(chicken, "layer_1_ui_correction");
|
||||
b.setMessage(toggleLabel("HUD 修正 (layer_1_ui_correction)", chicken, "layer_1_ui_correction"));
|
||||
}).bounds(right, y, 230, 20).build());
|
||||
|
||||
y += row;
|
||||
addRenderableWidget(Button.builder(toggleLabel("保留原版色调 Native Look", chicken, "preserve_native_tone_color"), b -> {
|
||||
toggle(chicken, "preserve_native_tone_color");
|
||||
b.setMessage(toggleLabel("保留原版色调 Native Look", chicken, "preserve_native_tone_color"));
|
||||
}).bounds(right, y, 230, 20).build());
|
||||
|
||||
// ---- bottom row ----
|
||||
int by = this.height - 48;
|
||||
addRenderableWidget(Button.builder(Component.literal("重载配置"), b -> {
|
||||
this.rebuildWidgets();
|
||||
}).bounds(this.width / 2 - 155, by, 150, 20).build());
|
||||
|
||||
addRenderableWidget(Button.builder(Component.literal("返回"), b -> this.onClose())
|
||||
.bounds(this.width / 2 + 5, by, 150, 20).build());
|
||||
}
|
||||
|
||||
private Component toggleLabel(String label, FlatCfg cfg, String key) {
|
||||
if (cfg == null) {
|
||||
return Component.literal(label + ": ?");
|
||||
}
|
||||
return Component.literal(label + ": " + (cfg.getBool(key, false) ? "开" : "关"));
|
||||
}
|
||||
|
||||
private void toggle(FlatCfg cfg, String key) {
|
||||
if (cfg == null) {
|
||||
return;
|
||||
}
|
||||
boolean now = cfg.getBool(key, false);
|
||||
cfg.set(key, !now);
|
||||
flush(cfg, key);
|
||||
}
|
||||
|
||||
private void setInt(FlatCfg cfg, String key, int v) {
|
||||
if (cfg != null && cfg.set(key, v)) {
|
||||
flush(cfg, key);
|
||||
}
|
||||
}
|
||||
|
||||
private void setDouble(FlatCfg cfg, String key, double v) {
|
||||
if (cfg != null && cfg.set(key, v)) {
|
||||
// Sliders fire continuously; writing on every pixel of drag would be wasteful.
|
||||
// The value is committed on release (see DoubleSlider.onRelease).
|
||||
pendingFlush = cfg;
|
||||
}
|
||||
}
|
||||
|
||||
private FlatCfg pendingFlush;
|
||||
|
||||
private void flush(FlatCfg cfg, String key) {
|
||||
try {
|
||||
cfg.save();
|
||||
LOG.info("wpywdlss cfg write: {} -> {}", key, cfg.get(key, "?"));
|
||||
} catch (Exception e) {
|
||||
notes.add("写入失败: " + e.getMessage());
|
||||
LOG.warn("cfg write failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics g, int mouseX, int mouseY, float partialTick) {
|
||||
renderBackground(g);
|
||||
g.drawCenteredString(this.minecraft.font, this.title, this.width / 2, 14, WHITE);
|
||||
g.drawString(this.minecraft.font, "DLSS5-Feeder 传输", left, 32, GREY);
|
||||
g.drawString(this.minecraft.font, "Deep Fried Chicken 神经渲染", right, 32, GREY);
|
||||
|
||||
int y = this.height - 78;
|
||||
g.drawString(this.minecraft.font,
|
||||
"多数项即时生效(cfg 在游戏运行时被重读);arm / early_load 类改动需重启。",
|
||||
24, y, GREY);
|
||||
y += 12;
|
||||
g.drawString(this.minecraft.font,
|
||||
"神经工作分辨率 >100% 是超采样(更清晰、更慢),<100% 会变软。",
|
||||
24, y, GREY);
|
||||
y += 12;
|
||||
for (String n : notes) {
|
||||
g.drawString(this.minecraft.font, n, 24, y, YELLOW);
|
||||
y += 12;
|
||||
}
|
||||
|
||||
super.render(g, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
if (pendingFlush != null) {
|
||||
flush(pendingFlush, "(slider release)");
|
||||
pendingFlush = null;
|
||||
}
|
||||
this.minecraft.setScreen(parent);
|
||||
}
|
||||
|
||||
/** Slider bound to a getter/setter pair so it always reflects the file on disk. */
|
||||
private class DoubleSlider extends AbstractSliderButton {
|
||||
private final String label;
|
||||
private final double min;
|
||||
private final double max;
|
||||
private final double step;
|
||||
private final java.util.function.DoubleSupplier getter;
|
||||
private final java.util.function.DoubleConsumer setter;
|
||||
|
||||
DoubleSlider(int x, int y, int w, int h, String label, double min, double max, double step,
|
||||
java.util.function.DoubleSupplier getter, java.util.function.DoubleConsumer setter) {
|
||||
super(x, y, w, h, Component.literal(label), normalise(getter.getAsDouble(), min, max));
|
||||
this.label = label;
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
this.step = step;
|
||||
this.getter = getter;
|
||||
this.setter = setter;
|
||||
updateMessage();
|
||||
}
|
||||
|
||||
private static double normalise(double v, double min, double max) {
|
||||
return max <= min ? 0.0 : Math.max(0.0, Math.min(1.0, (v - min) / (max - min)));
|
||||
}
|
||||
|
||||
private double actual() {
|
||||
double raw = min + this.value * (max - min);
|
||||
if (step > 0) {
|
||||
raw = Math.round(raw / step) * step;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateMessage() {
|
||||
setMessage(Component.literal(String.format(Locale.ROOT, "%s: %.2f", label, actual())));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void applyValue() {
|
||||
setter.accept(actual());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package dev.wpyw.dlss.gui;
|
||||
|
||||
import dev.wpyw.dlss.diag.LogTail;
|
||||
import dev.wpyw.dlss.install.DlssStackInstaller;
|
||||
import dev.wpyw.dlss.install.NgxRuntimeLocator;
|
||||
import net.minecraft.Util;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Main screen: shows whether the stack is installed, offers install / repair / uninstall, and
|
||||
* surfaces the verdict lines from the three logs so the player can see what actually happened
|
||||
* without leaving the game.
|
||||
*/
|
||||
public class DlssSetupScreen extends Screen {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger("wpywdlss/gui");
|
||||
|
||||
private static final int GREEN = 0xFF55FF55;
|
||||
private static final int YELLOW = 0xFFFFD166;
|
||||
private static final int RED = 0xFFFF6B6B;
|
||||
private static final int GREY = 0xFFAAAAAA;
|
||||
private static final int WHITE = 0xFFFFFFFF;
|
||||
|
||||
private final Screen parent;
|
||||
|
||||
private DlssStackInstaller.Report report;
|
||||
private List<String> statusLines = new ArrayList<>();
|
||||
private int statusColour = GREY;
|
||||
private String actionMessage = "";
|
||||
|
||||
public DlssSetupScreen(Screen parent) {
|
||||
super(Component.literal("Wpyw DLSS - 状态与安装"));
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
refresh();
|
||||
|
||||
int cx = this.width / 2;
|
||||
int y = this.height - 58;
|
||||
int gap = 6;
|
||||
|
||||
Button installBtn = Button.builder(Component.literal(installLabel()), b -> {
|
||||
try {
|
||||
DlssStackInstaller.Report r = DlssStackInstaller.install();
|
||||
actionMessage = "安装完成:" + r.present().size() + " 个文件就位"
|
||||
+ (r.ngxMissing().isEmpty() ? "" : ",但缺 " + r.ngxMissing().size() + " 个 NVIDIA 运行时");
|
||||
LOG.info("install finished: {}", r.status());
|
||||
LOG.info("\n{}", r.describe());
|
||||
} catch (Exception e) {
|
||||
actionMessage = "安装失败:" + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
LOG.error("install failed", e);
|
||||
}
|
||||
this.rebuildWidgets();
|
||||
}).bounds(cx - 235, y, 150, 20).build();
|
||||
|
||||
Button settingsBtn = Button.builder(Component.literal("画质设置"), b ->
|
||||
this.minecraft.setScreen(new DlssSettingsScreen(this))).bounds(cx - 79, y, 150, 20).build();
|
||||
|
||||
Button logsBtn = Button.builder(Component.literal("日志与诊断"), b ->
|
||||
this.minecraft.setScreen(new DlssLogScreen(this))).bounds(cx + 77, y, 150, 20).build();
|
||||
|
||||
Button uninstallBtn = Button.builder(Component.literal("卸载"), b -> {
|
||||
try {
|
||||
DlssStackInstaller.Report r = DlssStackInstaller.uninstall();
|
||||
actionMessage = "已卸载(" + r.notes().get(r.notes().size() - 1) + ")";
|
||||
} catch (Exception e) {
|
||||
actionMessage = "卸载失败:" + e.getMessage();
|
||||
}
|
||||
this.rebuildWidgets();
|
||||
}).bounds(cx - 235, y + 24, 150, 20).build();
|
||||
|
||||
Button openBtn = Button.builder(Component.literal("打开安装目录"), b -> {
|
||||
try {
|
||||
Util.getPlatform().openFile(DlssStackInstaller.jvmBinDir().toFile());
|
||||
} catch (Exception e) {
|
||||
actionMessage = "无法打开目录:" + e.getMessage();
|
||||
}
|
||||
}).bounds(cx - 79, y + 24, 150, 20).build();
|
||||
|
||||
Button doneBtn = Button.builder(Component.literal("完成"), b -> this.onClose())
|
||||
.bounds(cx + 77, y + 24, 150, 20).build();
|
||||
|
||||
addRenderableWidget(installBtn);
|
||||
addRenderableWidget(settingsBtn);
|
||||
addRenderableWidget(logsBtn);
|
||||
addRenderableWidget(uninstallBtn);
|
||||
addRenderableWidget(openBtn);
|
||||
addRenderableWidget(doneBtn);
|
||||
|
||||
// Only an unsupported target is a dead end; otherwise let the user re-verify at will.
|
||||
installBtn.active = report.status() != DlssStackInstaller.Status.UNSUPPORTED;
|
||||
}
|
||||
|
||||
private String installLabel() {
|
||||
if (report == null) {
|
||||
return "安装 / 修复";
|
||||
}
|
||||
return switch (report.status()) {
|
||||
case NOT_INSTALLED -> "安装";
|
||||
case NEEDS_REPAIR -> "修复";
|
||||
case INSTALLED -> "重新校验 / 修复运行时";
|
||||
case UNSUPPORTED -> "无法安装";
|
||||
};
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
report = DlssStackInstaller.analyze();
|
||||
statusLines = buildStatusLines();
|
||||
// The video settings buttons cache their probe; anything that happens here can change
|
||||
// the answer they show, so drop that cache on every entry into this panel.
|
||||
VideoSettingsHook.invalidate();
|
||||
}
|
||||
|
||||
private List<String> buildStatusLines() {
|
||||
List<String> out = new ArrayList<>();
|
||||
Path target = report.target();
|
||||
|
||||
statusColour = switch (report.status()) {
|
||||
case INSTALLED -> GREEN;
|
||||
case NEEDS_REPAIR -> YELLOW;
|
||||
default -> RED;
|
||||
};
|
||||
|
||||
out.add("状态: " + report.status());
|
||||
out.add("安装目标: " + target);
|
||||
out.add("载荷: " + report.present().size() + " 个文件已就位"
|
||||
+ (report.missing().isEmpty() ? "" : ",缺 " + report.missing().size())
|
||||
+ (report.modified().isEmpty() ? "" : ",被改动 " + report.modified().size()));
|
||||
|
||||
for (NgxRuntimeLocator.Found f : report.ngx()) {
|
||||
out.add("NVIDIA: " + f.name() + " " + String.format("%,d B", f.size())
|
||||
+ (f.sizeMatchesNominal() ? "" : " (与验证过的 SDK 大小不同)"));
|
||||
}
|
||||
for (String m : report.ngxMissing()) {
|
||||
out.add("NVIDIA: 缺失 " + m + " —— 需要放在 javaw.exe 旁");
|
||||
}
|
||||
|
||||
boolean runningNow = Files.isRegularFile(target.resolve("opengl32.dll"));
|
||||
if (report.status() == DlssStackInstaller.Status.INSTALLED) {
|
||||
out.add("");
|
||||
out.add("ReShade 是代理 DLL,只在 javaw.exe 启动时加载。");
|
||||
out.add("本次启动是否已生效,取决于启动时它是否已经就位:");
|
||||
Path reshadeLog = target.resolve(LogTail.RESHADE);
|
||||
String rv = LogTail.verdict(reshadeLog, "Registered add-on");
|
||||
out.add(" ReShade.log: " + (rv.equals("ok") ? "已加载并注册 add-on" : "尚未生效 —— 请重启游戏"));
|
||||
}
|
||||
if (!report.notes().isEmpty()) {
|
||||
out.add("");
|
||||
for (String n : report.notes()) {
|
||||
out.add("· " + n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics g, int mouseX, int mouseY, float partialTick) {
|
||||
renderBackground(g);
|
||||
|
||||
g.drawCenteredString(this.minecraft.font, this.title, this.width / 2, 14, WHITE);
|
||||
|
||||
int y = 38;
|
||||
int left = 24;
|
||||
int maxWidth = this.width - 48;
|
||||
int colour = statusColour;
|
||||
for (String line : statusLines) {
|
||||
if (line.isEmpty()) {
|
||||
y += 6;
|
||||
colour = GREY;
|
||||
continue;
|
||||
}
|
||||
for (var wrapped : this.minecraft.font.split(Component.literal(line), maxWidth)) {
|
||||
g.drawString(this.minecraft.font, wrapped, left, y, colour);
|
||||
y += 11;
|
||||
}
|
||||
y += 1;
|
||||
colour = GREY; // only the status line is coloured
|
||||
}
|
||||
|
||||
if (!actionMessage.isEmpty()) {
|
||||
g.drawString(this.minecraft.font, actionMessage, left, this.height - 74, YELLOW);
|
||||
}
|
||||
|
||||
super.render(g, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
this.minecraft.setScreen(parent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package dev.wpyw.dlss.gui;
|
||||
|
||||
import dev.wpyw.dlss.cfg.MasterSwitch;
|
||||
import dev.wpyw.dlss.install.DlssStackInstaller;
|
||||
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
|
||||
import net.fabricmc.fabric.api.client.screen.v1.Screens;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.gui.screens.VideoSettingsScreen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Puts the DLSS controls into <b>Options - Video Settings</b>, which is where a player looks
|
||||
* for anything that changes how the game is drawn.
|
||||
*
|
||||
* <p>Two buttons flank the vanilla "Done" button:
|
||||
* <ul>
|
||||
* <li><b>left</b> - the master on/off switch (only shown once the stack is installed, because
|
||||
* before that there is nothing to switch);</li>
|
||||
* <li><b>right</b> - opens the DLSS panel, and its label carries the live install state so the
|
||||
* answer to "is it actually working" is visible without clicking anything.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Why this is safe to do from an event instead of a mixin</h2>
|
||||
* {@code Screens.getButtons(screen)} is a {@code ButtonList} built over the screen's own
|
||||
* {@code drawables}, {@code selectables} and {@code children} lists, and its {@code add} inserts
|
||||
* into all three. That is exactly what {@code Screen.addRenderableWidget} does, so an injected
|
||||
* button renders, takes clicks and takes keyboard focus on vanilla's normal code path - no
|
||||
* accessor, no {@code @Invoker}, and nothing to break when the screen is resized.
|
||||
*
|
||||
* <h2>Geometry</h2>
|
||||
* Taken from the 1.20.1 bytecode rather than guessed: {@code VideoSettingsScreen.init()} builds
|
||||
* {@code new OptionsList(minecraft, width, height, 32, height - 32, 25)} and then adds the Done
|
||||
* button at {@code (width/2 - 100, height - 27, 200, 20)}. The option rows stop at
|
||||
* {@code height - 32}, so the strip the Done button lives in is free on both sides of it - which
|
||||
* is where these two go. When the window is too narrow to fit anything beside Done, nothing is
|
||||
* injected; {@code /dlss} always works.
|
||||
*/
|
||||
public final class VideoSettingsHook {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger("wpywdlss/gui");
|
||||
|
||||
// Vanilla geometry, verified against the compiled class.
|
||||
private static final int DONE_WIDTH = 200;
|
||||
private static final int ROW_HEIGHT = 20;
|
||||
private static final int DONE_Y_FROM_BOTTOM = 27;
|
||||
|
||||
private static final int GAP = 6;
|
||||
private static final int EDGE_INSET = 4;
|
||||
private static final int MIN_WIDTH = 64;
|
||||
private static final int MAX_WIDTH = 170;
|
||||
|
||||
/** Probing means hashing ~10 MB of payload; do not do it on every screen open. */
|
||||
private static final long CACHE_TTL_MS = 1500L;
|
||||
|
||||
private static DlssStackInstaller.Status cachedStatus = DlssStackInstaller.Status.UNSUPPORTED;
|
||||
private static String cachedDetail = "";
|
||||
private static boolean cachedSwitchable;
|
||||
private static boolean cachedOn;
|
||||
private static long cachedAt;
|
||||
|
||||
private VideoSettingsHook() {
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
ScreenEvents.AFTER_INIT.register((client, screen, width, height) -> {
|
||||
if (!(screen instanceof VideoSettingsScreen)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
inject(screen, width, height);
|
||||
} catch (Throwable t) {
|
||||
// A broken button must never take the vanilla video settings screen down with it.
|
||||
LOG.warn("could not inject the DLSS buttons into the video settings screen", t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void inject(Screen screen, int width, int height) {
|
||||
int side = width / 2 - DONE_WIDTH / 2 - GAP - EDGE_INSET;
|
||||
int buttonWidth = Math.min(MAX_WIDTH, side);
|
||||
if (buttonWidth < MIN_WIDTH) {
|
||||
LOG.info("window too narrow ({}x{}) for the DLSS buttons in video settings; use /dlss",
|
||||
width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
int y = height - DONE_Y_FROM_BOTTOM;
|
||||
int leftX = width / 2 - DONE_WIDTH / 2 - GAP - buttonWidth;
|
||||
int rightX = width / 2 + DONE_WIDTH / 2 + GAP;
|
||||
|
||||
if (cachedSwitchable) {
|
||||
Screens.getButtons(screen).add(switchButton(leftX, y, buttonWidth));
|
||||
}
|
||||
Screens.getButtons(screen).add(panelButton(screen, rightX, y, buttonWidth));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ buttons
|
||||
|
||||
private static Button switchButton(int x, int y, int width) {
|
||||
return Button.builder(Component.literal(switchLabel(cachedOn)), b -> {
|
||||
Path bin = DlssStackInstaller.jvmBinDir();
|
||||
MasterSwitch.State before = MasterSwitch.read(bin);
|
||||
MasterSwitch.State after = MasterSwitch.write(bin, !before.on());
|
||||
b.setMessage(Component.literal(switchLabel(after.on())));
|
||||
cachedOn = after.on();
|
||||
cachedAt = 0L; // force a re-probe the next time the screen opens
|
||||
if (!after.note().isEmpty()) {
|
||||
LOG.warn("master switch: {}", after.note());
|
||||
} else {
|
||||
LOG.info("DLSS master switch -> {}", after.on() ? "on" : "off");
|
||||
}
|
||||
}).bounds(x, y, width, ROW_HEIGHT)
|
||||
.tooltip(Tooltip.create(Component.literal(
|
||||
"DLSS 总开关\n"
|
||||
+ "同时写入 DLSS5-Feeder 与 Deep Fried Chicken 两个 cfg 的 enabled。\n"
|
||||
+ "两个 add-on 都会在游戏运行时重读自己的 cfg,所以这是即时生效的开关,不需要重启。\n"
|
||||
+ "分开开关会出现「半开」状态:Feeder 在请求神经帧,但没人渲染它们。")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Button panelButton(Screen parent, int x, int y, int width) {
|
||||
return Button.builder(Component.literal(panelLabel(cachedStatus)), b ->
|
||||
Minecraft.getInstance().setScreen(new DlssSetupScreen(parent)))
|
||||
.bounds(x, y, width, ROW_HEIGHT)
|
||||
.tooltip(Tooltip.create(Component.literal(
|
||||
"Wpyw DLSS —— 状态、安装、画质设置、日志诊断\n\n" + cachedDetail)))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String switchLabel(boolean on) {
|
||||
return on ? "DLSS: 开" : "DLSS: 关";
|
||||
}
|
||||
|
||||
private static String panelLabel(DlssStackInstaller.Status status) {
|
||||
return switch (status) {
|
||||
case INSTALLED -> "DLSS · 已安装";
|
||||
case NOT_INSTALLED -> "DLSS · 未安装";
|
||||
case NEEDS_REPAIR -> "DLSS · 需修复";
|
||||
case UNSUPPORTED -> "DLSS · 不可用";
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ probe
|
||||
|
||||
private static void refresh() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (cachedAt != 0L && now - cachedAt < CACHE_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
cachedAt = now;
|
||||
try {
|
||||
DlssStackInstaller.Report r = DlssStackInstaller.analyze();
|
||||
MasterSwitch.State sw = MasterSwitch.read(DlssStackInstaller.jvmBinDir());
|
||||
|
||||
cachedStatus = r.status();
|
||||
cachedSwitchable = sw.switchable();
|
||||
cachedOn = sw.on();
|
||||
cachedDetail = describe(r);
|
||||
} catch (Throwable t) {
|
||||
cachedStatus = DlssStackInstaller.Status.UNSUPPORTED;
|
||||
cachedSwitchable = false;
|
||||
cachedOn = false;
|
||||
cachedDetail = "状态探测失败: " + t;
|
||||
LOG.warn("probe failed", t);
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(DlssStackInstaller.Report r) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("状态: ").append(switch (r.status()) {
|
||||
case INSTALLED -> "已安装";
|
||||
case NOT_INSTALLED -> "未安装";
|
||||
case NEEDS_REPAIR -> "需要修复";
|
||||
case UNSUPPORTED -> "目标不可用";
|
||||
}).append('\n');
|
||||
sb.append("安装目标: ").append(r.target()).append('\n');
|
||||
sb.append("载荷: ").append(r.present().size()).append(" 个文件已就位");
|
||||
if (!r.missing().isEmpty()) {
|
||||
sb.append(",缺 ").append(r.missing().size());
|
||||
}
|
||||
if (!r.modified().isEmpty()) {
|
||||
sb.append(",被改动 ").append(r.modified().size());
|
||||
}
|
||||
sb.append('\n');
|
||||
if (!r.ngxMissing().isEmpty()) {
|
||||
sb.append("NVIDIA 运行时缺失: ").append(String.join(", ", r.ngxMissing())).append('\n');
|
||||
}
|
||||
sb.append("\n命令行同样可用: /dlss");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Drops the cached probe so the next screen open re-reads the disk. */
|
||||
public static void invalidate() {
|
||||
cachedAt = 0L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
package dev.wpyw.dlss.install;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import dev.wpyw.dlss.cfg.IniFile;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Installs the DLSS stack into the running JVM so that Minecraft's OpenGL renderer picks it up.
|
||||
*
|
||||
* <h2>Why the JVM's own {@code bin/} directory</h2>
|
||||
* ReShade is not a library the game links against - it is a <b>proxy DLL</b>. Windows resolves
|
||||
* {@code opengl32.dll} by searching the directory of the running executable first, so the proxy
|
||||
* must sit next to {@code javaw.exe}, and ReShade then loads its add-ons from that same directory.
|
||||
* The NGX runtimes must be there too, for the same reason (the feeder and Deep Fried Chicken both
|
||||
* look beside the executable).
|
||||
*
|
||||
* <h2>Why a restart is always required</h2>
|
||||
* A Fabric mod initialises long after {@code javaw.exe} has resolved its imports. By the time this
|
||||
* code runs, {@code opengl32.dll} has already been loaded - the stock system one, if ReShade was
|
||||
* not there at process start. Nothing can retro-fit a proxy DLL into a running process, so the
|
||||
* only honest design is: install now, activate on the next launch. Deep Fried Chicken's own
|
||||
* installer reaches the same conclusion in its logs
|
||||
* ("will only prepare the next launch").
|
||||
*/
|
||||
public final class DlssStackInstaller {
|
||||
|
||||
private static final String PAYLOAD_ROOT = "/assets/wpywdlss/payload";
|
||||
private static final String MANIFEST_RES = PAYLOAD_ROOT + "/payload-manifest.txt";
|
||||
private static final String RECORD_NAME = "wpywdlss-install.json";
|
||||
private static final String BACKUP_DIR_NAME = "wpywdlss-backup";
|
||||
/** Payload paths are stored relative to the payload root, prefixed with the target subtree. */
|
||||
private static final String TARGET_SUBTREE = "jvm/";
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private DlssStackInstaller() {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ model
|
||||
|
||||
public enum Status {
|
||||
/** Nothing of ours is present. */
|
||||
NOT_INSTALLED,
|
||||
/** Every payload file is present with the expected hash. */
|
||||
INSTALLED,
|
||||
/** Partially installed, or files were changed/removed since install. */
|
||||
NEEDS_REPAIR,
|
||||
/** Blocked: the target does not look like a writable 64-bit JVM directory. */
|
||||
UNSUPPORTED
|
||||
}
|
||||
|
||||
public record Entry(String relPath, String sha256, long size) {
|
||||
}
|
||||
|
||||
public record Report(
|
||||
Status status,
|
||||
Path target,
|
||||
List<Entry> present,
|
||||
List<Entry> missing,
|
||||
List<Entry> modified,
|
||||
List<NgxRuntimeLocator.Found> ngx,
|
||||
List<String> ngxMissing,
|
||||
Path foreignProxyBackup,
|
||||
List<String> notes,
|
||||
boolean restartRequired) {
|
||||
|
||||
public boolean ok() {
|
||||
return status == Status.INSTALLED;
|
||||
}
|
||||
|
||||
/** Multi-line human-readable summary, used by the GUI and the log. */
|
||||
public String describe() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("target : ").append(target).append('\n');
|
||||
sb.append("status : ").append(status).append('\n');
|
||||
sb.append("payload files : ").append(present.size()).append(" ok");
|
||||
if (!missing.isEmpty()) {
|
||||
sb.append(", ").append(missing.size()).append(" missing");
|
||||
}
|
||||
if (!modified.isEmpty()) {
|
||||
sb.append(", ").append(modified.size()).append(" modified");
|
||||
}
|
||||
sb.append('\n');
|
||||
for (NgxRuntimeLocator.Found f : ngx) {
|
||||
sb.append("ngx runtime : ").append(f.name())
|
||||
.append(" ").append(String.format("%,d B", f.size()))
|
||||
.append(f.sizeMatchesNominal() ? "" : " (size differs from verified SDK)")
|
||||
.append('\n');
|
||||
}
|
||||
for (String m : ngxMissing) {
|
||||
sb.append("ngx runtime : MISSING ").append(m).append('\n');
|
||||
}
|
||||
if (foreignProxyBackup != null) {
|
||||
sb.append("previous opengl32.dll backed up to: ").append(foreignProxyBackup).append('\n');
|
||||
}
|
||||
for (String n : notes) {
|
||||
sb.append("note : ").append(n).append('\n');
|
||||
}
|
||||
if (restartRequired) {
|
||||
sb.append("action : RESTART Minecraft - ReShade only loads at process start\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ paths
|
||||
|
||||
/** The directory holding {@code java.exe} / {@code javaw.exe} for the running JVM. */
|
||||
public static Path jvmBinDir() {
|
||||
return Path.of(System.getProperty("java.home"), "bin");
|
||||
}
|
||||
|
||||
public static Path installRecordPath() {
|
||||
return jvmBinDir().resolve(RECORD_NAME);
|
||||
}
|
||||
|
||||
private static boolean looksLikeJvmBin(Path bin) {
|
||||
return Files.isRegularFile(bin.resolve("java.exe")) || Files.isRegularFile(bin.resolve("javaw.exe"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ manifest
|
||||
|
||||
/** Parses the SHA-256 manifest generated by Gradle at build time. */
|
||||
public static List<Entry> readManifest() throws IOException {
|
||||
try (InputStream in = DlssStackInstaller.class.getResourceAsStream(MANIFEST_RES)) {
|
||||
if (in == null) {
|
||||
throw new IOException("payload manifest missing from the jar (" + MANIFEST_RES + ")");
|
||||
}
|
||||
List<Entry> out = new ArrayList<>();
|
||||
for (String line : new String(in.readAllBytes(), StandardCharsets.UTF_8).split("\n")) {
|
||||
String t = line.trim();
|
||||
if (t.isEmpty() || t.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = t.split("\\s{2,}");
|
||||
if (parts.length < 2) {
|
||||
continue;
|
||||
}
|
||||
long size = parts.length > 2 ? Long.parseLong(parts[2].trim()) : -1L;
|
||||
out.add(new Entry(parts[1].trim(), parts[0].trim(), size));
|
||||
}
|
||||
out.sort(Comparator.comparing(Entry::relPath));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
private static String targetRelative(String payloadRelPath) {
|
||||
return payloadRelPath.startsWith(TARGET_SUBTREE)
|
||||
? payloadRelPath.substring(TARGET_SUBTREE.length())
|
||||
: payloadRelPath;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ analyze
|
||||
|
||||
/** Read-only inspection. Never writes anything. */
|
||||
public static Report analyze() {
|
||||
Path target = jvmBinDir();
|
||||
List<String> notes = new ArrayList<>();
|
||||
List<Entry> present = new ArrayList<>();
|
||||
List<Entry> missing = new ArrayList<>();
|
||||
List<Entry> modified = new ArrayList<>();
|
||||
List<NgxRuntimeLocator.Found> ngx = new ArrayList<>();
|
||||
List<String> ngxMissing = new ArrayList<>();
|
||||
|
||||
if (!looksLikeJvmBin(target)) {
|
||||
notes.add("java.home does not point at a JVM bin directory containing java.exe/javaw.exe");
|
||||
return new Report(Status.UNSUPPORTED, target, present, missing, modified, ngx, ngxMissing,
|
||||
null, notes, false);
|
||||
}
|
||||
|
||||
List<Entry> manifest;
|
||||
try {
|
||||
manifest = readManifest();
|
||||
} catch (IOException e) {
|
||||
notes.add("cannot read payload manifest: " + e.getMessage());
|
||||
return new Report(Status.UNSUPPORTED, target, present, missing, modified, ngx, ngxMissing,
|
||||
null, notes, false);
|
||||
}
|
||||
|
||||
if (manifest.isEmpty()) {
|
||||
notes.add("the jar contains no payload - build with the payload directory present");
|
||||
}
|
||||
|
||||
for (Entry e : manifest) {
|
||||
Path p = target.resolve(targetRelative(e.relPath()));
|
||||
if (!Files.isRegularFile(p)) {
|
||||
missing.add(e);
|
||||
continue;
|
||||
}
|
||||
String actual = sha256(p);
|
||||
if (actual != null && actual.equalsIgnoreCase(e.sha256())) {
|
||||
present.add(e);
|
||||
} else {
|
||||
modified.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
NgxRuntimeLocator loc = new NgxRuntimeLocator(target,
|
||||
FabricLoader.getInstance().getGameDir(),
|
||||
FabricLoader.getInstance().getConfigDir());
|
||||
for (NgxRuntimeLocator.Found f : loc.locateAll()) {
|
||||
ngx.add(f);
|
||||
}
|
||||
for (String name : NgxRuntimeLocator.runtimeNames()) {
|
||||
boolean found = ngx.stream().anyMatch(f -> f.name().equals(name));
|
||||
if (!found) {
|
||||
ngxMissing.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
Status status;
|
||||
if (present.isEmpty() && missing.size() == manifest.size()) {
|
||||
status = Status.NOT_INSTALLED;
|
||||
} else if (missing.isEmpty() && modified.isEmpty()) {
|
||||
status = Status.INSTALLED;
|
||||
} else {
|
||||
status = Status.NEEDS_REPAIR;
|
||||
}
|
||||
|
||||
// A foreign proxy (a ReShade build we did not put there, or another injector) matters.
|
||||
Path proxy = target.resolve("opengl32.dll");
|
||||
boolean proxyIsOurs = present.stream().anyMatch(e -> targetRelative(e.relPath()).equalsIgnoreCase("opengl32.dll"));
|
||||
if (Files.isRegularFile(proxy) && !proxyIsOurs) {
|
||||
notes.add("a different opengl32.dll is already present; installing will back it up");
|
||||
}
|
||||
|
||||
return new Report(status, target, present, missing, modified, ngx, ngxMissing, null, notes, false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ install
|
||||
|
||||
/**
|
||||
* Copies the payload into the JVM bin directory and writes the ReShade configuration.
|
||||
* Idempotent: re-running repairs missing or altered files and leaves everything else alone.
|
||||
*/
|
||||
public static Report install() throws IOException {
|
||||
Path target = jvmBinDir();
|
||||
List<String> notes = new ArrayList<>();
|
||||
List<Entry> present = new ArrayList<>();
|
||||
List<Entry> missing = new ArrayList<>();
|
||||
List<Entry> modified = new ArrayList<>();
|
||||
List<NgxRuntimeLocator.Found> ngx = new ArrayList<>();
|
||||
List<String> ngxMissing = new ArrayList<>();
|
||||
|
||||
if (!looksLikeJvmBin(target)) {
|
||||
notes.add("refusing to install: " + target + " is not a JVM bin directory");
|
||||
return new Report(Status.UNSUPPORTED, target, present, missing, modified, ngx, ngxMissing,
|
||||
null, notes, false);
|
||||
}
|
||||
|
||||
Files.createDirectories(target);
|
||||
Path backupDir = target.resolve(BACKUP_DIR_NAME);
|
||||
Path foreignProxyBackup = null;
|
||||
|
||||
List<Entry> manifest = readManifest();
|
||||
if (manifest.isEmpty()) {
|
||||
notes.add("nothing to install: the jar contains no payload");
|
||||
return new Report(Status.UNSUPPORTED, target, present, missing, modified, ngx, ngxMissing,
|
||||
null, notes, false);
|
||||
}
|
||||
|
||||
// --- 1. preserve any opengl32.dll we did not put there -----------------
|
||||
boolean payloadOwnsProxy = manifest.stream()
|
||||
.anyMatch(e -> targetRelative(e.relPath()).equalsIgnoreCase("opengl32.dll"));
|
||||
Path proxy = target.resolve("opengl32.dll");
|
||||
if (payloadOwnsProxy && Files.isRegularFile(proxy)) {
|
||||
String existing = sha256(proxy);
|
||||
Entry expected = manifest.stream()
|
||||
.filter(e -> targetRelative(e.relPath()).equalsIgnoreCase("opengl32.dll"))
|
||||
.findFirst().orElse(null);
|
||||
if (expected != null && existing != null && !existing.equalsIgnoreCase(expected.sha256())) {
|
||||
Files.createDirectories(backupDir);
|
||||
Path bak = backupDir.resolve("opengl32.dll." + stamp() + ".bak");
|
||||
Files.copy(proxy, bak, StandardCopyOption.REPLACE_EXISTING);
|
||||
foreignProxyBackup = bak;
|
||||
notes.add("existing opengl32.dll was not ours and has been backed up");
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. extract / repair every payload file ---------------------------
|
||||
long copied = 0;
|
||||
long bytes = 0;
|
||||
for (Entry e : manifest) {
|
||||
Path dest = target.resolve(targetRelative(e.relPath()));
|
||||
boolean needWrite = true;
|
||||
if (Files.isRegularFile(dest)) {
|
||||
String actual = sha256(dest);
|
||||
if (actual != null && actual.equalsIgnoreCase(e.sha256())) {
|
||||
needWrite = false;
|
||||
present.add(e);
|
||||
} else {
|
||||
modified.add(e);
|
||||
}
|
||||
} else {
|
||||
missing.add(e);
|
||||
}
|
||||
if (!needWrite) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try (InputStream in = DlssStackInstaller.class.getResourceAsStream(PAYLOAD_ROOT + "/" + e.relPath())) {
|
||||
if (in == null) {
|
||||
notes.add("payload resource missing from jar: " + e.relPath());
|
||||
continue;
|
||||
}
|
||||
Files.createDirectories(dest.getParent());
|
||||
Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING);
|
||||
copied++;
|
||||
bytes += Files.size(dest);
|
||||
present.add(e);
|
||||
missing.remove(e);
|
||||
modified.remove(e);
|
||||
}
|
||||
}
|
||||
notes.add(String.format("wrote %d file(s), %,.2f MB", copied, bytes / 1048576.0));
|
||||
|
||||
// --- 3. NVIDIA NGX runtimes ------------------------------------------
|
||||
NgxRuntimeLocator loc = new NgxRuntimeLocator(target,
|
||||
FabricLoader.getInstance().getGameDir(),
|
||||
FabricLoader.getInstance().getConfigDir());
|
||||
for (NgxRuntimeLocator.Found f : loc.locateAll()) {
|
||||
Path dest = target.resolve(f.name());
|
||||
if (!dest.equals(f.path())) {
|
||||
Files.copy(f.path(), dest, StandardCopyOption.REPLACE_EXISTING);
|
||||
notes.add("copied " + f.name() + " from " + f.path());
|
||||
}
|
||||
ngx.add(new NgxRuntimeLocator.Found(f.name(), dest, Files.size(dest),
|
||||
f.sizeMatchesNominal(), f.origin()));
|
||||
}
|
||||
for (String name : NgxRuntimeLocator.runtimeNames()) {
|
||||
if (ngx.stream().noneMatch(f -> f.name().equals(name))) {
|
||||
ngxMissing.add(name);
|
||||
notes.add("NVIDIA runtime not found anywhere: " + name
|
||||
+ " - place an official copy next to javaw.exe, or rebuild with -PbundleNvidia=true");
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. ReShade configuration ----------------------------------------
|
||||
writeReshadeIni(target, backupDir, notes);
|
||||
writeReshadePreset(target, backupDir, notes);
|
||||
|
||||
// --- 5. install record ------------------------------------------------
|
||||
writeRecord(target, manifest, ngx);
|
||||
|
||||
Status status = (missing.isEmpty() && modified.isEmpty())
|
||||
? Status.INSTALLED
|
||||
: Status.NEEDS_REPAIR;
|
||||
return new Report(status, target, present, missing, modified, ngx, ngxMissing,
|
||||
foreignProxyBackup, notes, true);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ini writers
|
||||
|
||||
private static final String DEFAULT_RESHADE_INI = """
|
||||
[DEPTH]
|
||||
DepthCopyAtClearIndex=0
|
||||
DepthCopyBeforeClears=1
|
||||
DrawStatsHeuristic=0
|
||||
UseAspectRatioHeuristics=1
|
||||
|
||||
[GENERAL]
|
||||
EffectSearchPaths=.\\reshade-shaders\\Shaders\\**
|
||||
NoDebugInfo=1
|
||||
NoEffectCache=0
|
||||
NoReloadOnInit=0
|
||||
PerformanceMode=0
|
||||
PresetPath=.\\ReShadePreset.ini
|
||||
PreprocessorDefinitions=RESHADE_DEPTH_LINEARIZATION_FAR_PLANE=1000.0,RESHADE_DEPTH_INPUT_IS_UPSIDE_DOWN=0,RESHADE_DEPTH_INPUT_IS_REVERSED=0,RESHADE_DEPTH_INPUT_IS_LOGARITHMIC=0,DLSS5_MV_PROVIDER=3
|
||||
TextureSearchPaths=.\\reshade-shaders\\Textures\\**
|
||||
|
||||
[INPUT]
|
||||
GamepadNavigation=0
|
||||
KeyOverlay=36,0,0,0
|
||||
|
||||
[PROXY]
|
||||
EnableProxyLibrary=0
|
||||
ProxyLibrary=
|
||||
""";
|
||||
|
||||
private static void writeReshadeIni(Path target, Path backupDir, List<String> notes) throws IOException {
|
||||
Path ini = target.resolve("ReShade.ini");
|
||||
boolean existed = Files.isRegularFile(ini);
|
||||
IniFile f = IniFile.load(ini);
|
||||
|
||||
if (!existed || f.isNew()) {
|
||||
Files.writeString(ini, DEFAULT_RESHADE_INI, StandardCharsets.UTF_8);
|
||||
notes.add("wrote a fresh ReShade.ini");
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge rather than replace: ReShade and Deep Fried Chicken both own keys in this file.
|
||||
Files.createDirectories(backupDir);
|
||||
Files.copy(ini, backupDir.resolve("ReShade.ini." + stamp() + ".bak"), StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
boolean changed = false;
|
||||
// The motion-vector provider is what makes DLSS5_Feed.fx able to find its input.
|
||||
changed |= f.addToListElement("GENERAL", "PreprocessorDefinitions", "DLSS5_MV_PROVIDER=3");
|
||||
// Minecraft's depth is not reversed - confirmed by direct observation in-game.
|
||||
changed |= f.addToListElement("GENERAL", "PreprocessorDefinitions", "RESHADE_DEPTH_INPUT_IS_REVERSED=0");
|
||||
changed |= f.set("GENERAL", "EffectSearchPaths", ".\\reshade-shaders\\Shaders\\**");
|
||||
changed |= f.set("GENERAL", "TextureSearchPaths", ".\\reshade-shaders\\Textures\\**");
|
||||
changed |= f.set("GENERAL", "PresetPath", ".\\ReShadePreset.ini");
|
||||
changed |= f.set("DEPTH", "UseAspectRatioHeuristics", "1");
|
||||
changed |= f.set("INPUT", "KeyOverlay", "36,0,0,0");
|
||||
if (changed) {
|
||||
f.save();
|
||||
notes.add("merged required keys into the existing ReShade.ini (backed up)");
|
||||
} else {
|
||||
notes.add("existing ReShade.ini already had every required key");
|
||||
}
|
||||
}
|
||||
|
||||
private static final String PROVIDER = "Lumenite_Kernel@lumenite_Kernel.fx";
|
||||
private static final String FEEDER = "DLSS5_Feed@DLSS5_Feed.fx";
|
||||
|
||||
private static void writeReshadePreset(Path target, Path backupDir, List<String> notes) throws IOException {
|
||||
Path preset = target.resolve("ReShadePreset.ini");
|
||||
boolean existed = Files.isRegularFile(preset);
|
||||
IniFile f = IniFile.load(preset);
|
||||
|
||||
if (!existed || f.isNew()) {
|
||||
Files.writeString(preset,
|
||||
"# Managed by wpywdlss.\n"
|
||||
+ "# Techniques= lists what is ENABLED; TechniqueSorting= defines EXECUTION ORDER,\n"
|
||||
+ "# and the motion-vector provider must run before the consumer that reads it.\n"
|
||||
+ "Techniques=" + PROVIDER + "," + FEEDER + "\n"
|
||||
+ "TechniqueSorting=" + PROVIDER + "," + FEEDER + "\n",
|
||||
StandardCharsets.UTF_8);
|
||||
notes.add("wrote a fresh ReShadePreset.ini with the provider ordered before the feeder");
|
||||
return;
|
||||
}
|
||||
|
||||
Files.createDirectories(backupDir);
|
||||
Files.copy(preset, backupDir.resolve("ReShadePreset.ini." + stamp() + ".bak"), StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
boolean changed = false;
|
||||
// Enable both, without disabling whatever else the user had enabled.
|
||||
// null section = the file's preamble, which is where these two keys live.
|
||||
changed |= f.addToListElement(null, "Techniques", PROVIDER);
|
||||
changed |= f.addToListElement(null, "Techniques", FEEDER);
|
||||
// The provider must execute before the consumer that reads its texture.
|
||||
changed |= f.moveToFront(null, "TechniqueSorting", PROVIDER);
|
||||
if (changed) {
|
||||
f.save();
|
||||
notes.add("merged the motion-vector provider and feeder into the existing preset (backed up)");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ record / uninstall
|
||||
|
||||
private static void writeRecord(Path target, List<Entry> manifest,
|
||||
List<NgxRuntimeLocator.Found> ngx) throws IOException {
|
||||
JsonObject root = new JsonObject();
|
||||
root.addProperty("schema", 1);
|
||||
root.addProperty("installedUtc", Instant.now().toString());
|
||||
root.addProperty("target", target.toString());
|
||||
root.addProperty("javaHome", System.getProperty("java.home"));
|
||||
root.addProperty("javaVersion", System.getProperty("java.version"));
|
||||
|
||||
JsonArray files = new JsonArray();
|
||||
for (Entry e : manifest) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("path", targetRelative(e.relPath()));
|
||||
o.addProperty("sha256", e.sha256());
|
||||
o.addProperty("size", e.size());
|
||||
files.add(o);
|
||||
}
|
||||
root.add("files", files);
|
||||
|
||||
JsonArray runtimes = new JsonArray();
|
||||
for (NgxRuntimeLocator.Found f : ngx) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("name", f.name());
|
||||
o.addProperty("origin", f.origin());
|
||||
o.addProperty("source", f.path().toString());
|
||||
runtimes.add(o);
|
||||
}
|
||||
root.add("ngxRuntimes", runtimes);
|
||||
|
||||
Files.writeString(target.resolve(RECORD_NAME), GSON.toJson(root), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Reads back the install record, or null when there is none / it is unreadable. */
|
||||
public static JsonObject readRecord() {
|
||||
Path p = installRecordPath();
|
||||
if (!Files.isRegularFile(p)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return GSON.fromJson(Files.readString(p, StandardCharsets.UTF_8), JsonObject.class);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only the files this installer recorded, and restores any proxy it displaced.
|
||||
* Files it does not own (ReShade's own later additions, other tools) are left alone.
|
||||
*/
|
||||
public static Report uninstall() throws IOException {
|
||||
Path target = jvmBinDir();
|
||||
List<String> notes = new ArrayList<>();
|
||||
List<Entry> removed = new ArrayList<>();
|
||||
JsonObject rec = readRecord();
|
||||
if (rec == null) {
|
||||
notes.add("no install record found - nothing to remove");
|
||||
return new Report(Status.NOT_INSTALLED, target, List.of(), List.of(), List.of(),
|
||||
List.of(), List.of(), null, notes, true);
|
||||
}
|
||||
if (rec.has("files")) {
|
||||
for (var el : rec.getAsJsonArray("files")) {
|
||||
String rel = el.getAsJsonObject().get("path").getAsString();
|
||||
Path p = target.resolve(rel);
|
||||
if (Files.isRegularFile(p) && Files.deleteIfExists(p)) {
|
||||
removed.add(new Entry(rel, "", 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.deleteIfExists(target.resolve(RECORD_NAME));
|
||||
|
||||
// Restore a displaced proxy, if we made a backup.
|
||||
Path backupDir = target.resolve(BACKUP_DIR_NAME);
|
||||
if (Files.isDirectory(backupDir)) {
|
||||
try (Stream<Path> s = Files.list(backupDir)) {
|
||||
Path newest = s.filter(p -> p.getFileName().toString().startsWith("opengl32.dll."))
|
||||
.max(Comparator.comparing(p -> p.getFileName().toString()))
|
||||
.orElse(null);
|
||||
if (newest != null) {
|
||||
Files.copy(newest, target.resolve("opengl32.dll"), StandardCopyOption.REPLACE_EXISTING);
|
||||
notes.add("restored the previous opengl32.dll from " + newest.getFileName());
|
||||
}
|
||||
}
|
||||
}
|
||||
notes.add("removed " + removed.size() + " file(s); ReShade's own later additions were left alone");
|
||||
return new Report(Status.NOT_INSTALLED, target, List.of(), List.of(), List.of(),
|
||||
List.of(), List.of(), null, notes, true);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ util
|
||||
|
||||
public static String sha256(Path p) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream in = Files.newInputStream(p)) {
|
||||
byte[] buf = new byte[1 << 16];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
md.update(buf, 0, n);
|
||||
}
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : md.digest()) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String stamp() {
|
||||
return java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
.format(Instant.now());
|
||||
}
|
||||
|
||||
/** Convenience for the GUI: a short one-line state. */
|
||||
public static String shortState() {
|
||||
Report r = analyze();
|
||||
if (r.status() == Status.INSTALLED) {
|
||||
return "installed";
|
||||
}
|
||||
if (r.status() == Status.NOT_INSTALLED) {
|
||||
return "not installed";
|
||||
}
|
||||
if (r.status() == Status.UNSUPPORTED) {
|
||||
return "unsupported";
|
||||
}
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
counts.put("missing", r.missing().size());
|
||||
counts.put("modified", r.modified().size());
|
||||
return "needs repair (" + counts.get("missing") + " missing, " + counts.get("modified") + " modified)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package dev.wpyw.dlss.install;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Locates the NVIDIA NGX runtimes this stack needs.
|
||||
*
|
||||
* <p>Both files must end up <b>next to the game executable</b> - for Minecraft Java that is the
|
||||
* JVM's own {@code bin/} directory, because that is where {@code java.exe}/{@code javaw.exe}
|
||||
* live. Deep Fried Chicken's installer says the same thing in its own words ("Copy these beside
|
||||
* the actual game executable"), and it is why its host helper looks for the runtimes next to
|
||||
* itself rather than in the game folder.
|
||||
*
|
||||
* <p>We deliberately do not ship these DLLs by default: together they are ~225 MB, they are
|
||||
* NVIDIA proprietary binaries, and on any machine that has already run this stack once they are
|
||||
* sitting in the target directory already. When they are missing this class searches a bounded
|
||||
* set of plausible roots, and the installer copies whatever it finds.
|
||||
*
|
||||
* <p>The nominal sizes come from the SDK that was actually verified on this machine, so a
|
||||
* wildly different file is rejected rather than copied.
|
||||
*/
|
||||
public final class NgxRuntimeLocator {
|
||||
|
||||
/** name -> {minimum plausible size, nominal size seen in the verified SDK} */
|
||||
private static final Object[][] KNOWN = {
|
||||
{"nvngx_dlss.dll", 20L * 1024 * 1024, 58_956_912L},
|
||||
{"nvngx_dlssnr.dll", 40L * 1024 * 1024, 165_840_496L},
|
||||
};
|
||||
|
||||
/** A located runtime: where it is and whether it is trustworthy enough to copy. */
|
||||
public record Found(String name, Path path, long size, boolean sizeMatchesNominal, String origin) {
|
||||
public String describe() {
|
||||
return String.format("%s %,d B%s [%s]%n %s",
|
||||
name, size, sizeMatchesNominal ? "" : " (size differs from verified SDK)", origin, path);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path target;
|
||||
private final List<Path> extraRoots = new ArrayList<>();
|
||||
|
||||
public NgxRuntimeLocator(Path targetBinDir, Path gameDir, Path modConfigDir) {
|
||||
this.target = targetBinDir;
|
||||
if (gameDir != null) {
|
||||
extraRoots.add(gameDir);
|
||||
}
|
||||
if (modConfigDir != null) {
|
||||
extraRoots.add(modConfigDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a user-configured extra search root. */
|
||||
public void addRoot(Path root) {
|
||||
if (root != null) {
|
||||
extraRoots.add(root);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<String> runtimeNames() {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Object[] row : KNOWN) {
|
||||
names.add((String) row[0]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** The path a runtime must occupy for the feeder and DFC to load it. */
|
||||
public Path expectedPath(String name) {
|
||||
return target.resolve(name);
|
||||
}
|
||||
|
||||
public boolean isInPlace(String name) {
|
||||
return verify(expectedPath(name)) != null;
|
||||
}
|
||||
|
||||
/** Returns null when the file is unusable, otherwise a short verdict string. */
|
||||
public static String verify(Path p) {
|
||||
if (p == null || !Files.isRegularFile(p)) {
|
||||
return null;
|
||||
}
|
||||
long size;
|
||||
try {
|
||||
size = Files.size(p);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
String name = p.getFileName().toString();
|
||||
long min = 1L * 1024 * 1024;
|
||||
for (Object[] row : KNOWN) {
|
||||
if (row[0].equals(name)) {
|
||||
min = (Long) row[1];
|
||||
}
|
||||
}
|
||||
if (size < min) {
|
||||
return "too small (" + size + " B)";
|
||||
}
|
||||
if (!PeProbe.isX64Pe(p)) {
|
||||
return "not a 64-bit PE image";
|
||||
}
|
||||
return String.format("%,d B, x64 PE", size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a usable copy of every runtime, preferring the target directory (already correct),
|
||||
* then the extra roots, then a bounded scan of each root.
|
||||
*/
|
||||
public List<Found> locateAll() {
|
||||
List<Found> out = new ArrayList<>();
|
||||
for (Object[] row : KNOWN) {
|
||||
String name = (String) row[0];
|
||||
long nominal = (Long) row[2];
|
||||
|
||||
Path inPlace = expectedPath(name);
|
||||
if (Files.isRegularFile(inPlace)) {
|
||||
long sz = sizeOf(inPlace);
|
||||
out.add(new Found(name, inPlace, sz, sz == nominal, "already in the target directory"));
|
||||
continue;
|
||||
}
|
||||
|
||||
Found found = null;
|
||||
for (Path root : searchRoots()) {
|
||||
Path direct = root.resolve(name);
|
||||
if (Files.isRegularFile(direct)) {
|
||||
found = new Found(name, direct, sizeOf(direct), sizeOf(direct) == nominal, "direct hit");
|
||||
break;
|
||||
}
|
||||
Path scanned = scanFor(root, name);
|
||||
if (scanned != null) {
|
||||
found = new Found(name, scanned, sizeOf(scanned), sizeOf(scanned) == nominal,
|
||||
"found by bounded scan under " + root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found != null) {
|
||||
out.add(found);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Set<Path> searchRoots() {
|
||||
// Ordered, de-duplicated. The target comes first so an existing correct install wins.
|
||||
Set<Path> roots = new LinkedHashSet<>();
|
||||
roots.add(target);
|
||||
for (Path p : extraRoots) {
|
||||
if (p != null && Files.isDirectory(p)) {
|
||||
roots.add(p);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static long sizeOf(Path p) {
|
||||
try {
|
||||
return Files.size(p);
|
||||
} catch (IOException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-bounded recursive search. Minecraft mod folders contain a lot of jars, and the whole
|
||||
* point is to stay fast, so this stays shallow.
|
||||
*/
|
||||
private static Path scanFor(Path root, String name) {
|
||||
try (Stream<Path> s = Files.walk(root, 4)) {
|
||||
return s.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().equalsIgnoreCase(name))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal PE header probe: DOS magic, PE signature, COFF machine == AMD64. */
|
||||
public static final class PeProbe {
|
||||
private PeProbe() {
|
||||
}
|
||||
|
||||
public static boolean isX64Pe(Path p) {
|
||||
try (InputStream in = Files.newInputStream(p)) {
|
||||
byte[] head = in.readNBytes(0x1000);
|
||||
if (head.length < 0x40 || head[0] != 'M' || head[1] != 'Z') {
|
||||
return false;
|
||||
}
|
||||
int peOff = u32(head, 0x3C);
|
||||
if (peOff < 0 || peOff + 6 > head.length) {
|
||||
return false;
|
||||
}
|
||||
if (head[peOff] != 'P' || head[peOff + 1] != 'E' || head[peOff + 2] != 0 || head[peOff + 3] != 0) {
|
||||
return false;
|
||||
}
|
||||
int machine = (head[peOff + 4] & 0xFF) | ((head[peOff + 5] & 0xFF) << 8);
|
||||
return machine == 0x8664; // IMAGE_FILE_MACHINE_AMD64
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int u32(byte[] b, int off) {
|
||||
return (b[off] & 0xFF) | ((b[off + 1] & 0xFF) << 8) | ((b[off + 2] & 0xFF) << 16) | ((b[off + 3] & 0xFF) << 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "wpywdlss",
|
||||
"version": "${version}",
|
||||
"name": "Wpyw DLSS",
|
||||
"description": "Adds NVIDIA DLSS to Minecraft Java by installing and driving a ReShade + DLSS5-Feeder + Deep Fried Chicken stack: a DLSS on/off switch and a control panel inside Options - Video Settings, plus /dlss for install, status and settings. The renderer is ReShade, so installing requires one game restart. NVIDIA runtime binaries are NOT bundled and are located on disk by the installer.",
|
||||
"authors": [
|
||||
"wpy"
|
||||
],
|
||||
"contact": {},
|
||||
"license": "MIT",
|
||||
"environment": "client",
|
||||
"entrypoints": {
|
||||
"client": [
|
||||
"dev.wpyw.dlss.DlssClientMod"
|
||||
]
|
||||
},
|
||||
"mixins": [
|
||||
{
|
||||
"config": "wpywdlss.mixins.json",
|
||||
"environment": "client"
|
||||
}
|
||||
],
|
||||
"depends": {
|
||||
"fabricloader": ">=0.14.21",
|
||||
"minecraft": "~1.20.1",
|
||||
"java": ">=17",
|
||||
"fabric-api": "*"
|
||||
},
|
||||
"suggests": {
|
||||
"sodium": "*",
|
||||
"iris": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"required": true,
|
||||
"minVersion": "0.8",
|
||||
"package": "dev.wpyw.dlss.mixin",
|
||||
"compatibilityLevel": "JAVA_17",
|
||||
"client": [
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user