Initial commit: 在 Minecraft Java 版接入 NVIDIA DLSS 超分与帧生成

This commit is contained in:
WpyQwq
2026-09-19 11:51:46 +08:00
commit 8acb8bbac6
89 changed files with 51201 additions and 0 deletions
@@ -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);
}
}
}
+34
View File
@@ -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": "*"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"required": true,
"minVersion": "0.8",
"package": "dev.wpyw.dlss.mixin",
"compatibilityLevel": "JAVA_17",
"client": [
],
"injectors": {
"defaultRequire": 1
}
}