diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f24c70d..5190842 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,8 +10,8 @@ jobs: build: strategy: matrix: - java: [17] - runs-on: ubuntu-latest + java: [21] + runs-on: ubuntu-22.04 steps: - name: checkout repository uses: actions/checkout@v4 @@ -27,7 +27,7 @@ jobs: - name: build run: ./gradlew build - name: capture build artifacts - if: ${{ matrix.java == '17' }} + if: ${{ matrix.java == '21' }} uses: actions/upload-artifact@v4 with: name: Artifacts diff --git a/README.md b/README.md index 82109ab..be01bec 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # GFX Debuggers This version-agnostic Fabric mod allows you to inject NSight or Renderdoc into Minecraft, without having to fiddle around with launchers, args or environment variables. At launch, it will ask you which debugger you want to attach, and injection can be skipped by closing the dialog. -Supports both Linux and Windows, and should work with any version of Minecraft, as long as Fabric was ported to it. -Requires Fabric Loader 0.10.7+ and Java 17+. +Supports both Linux and Windows, and should work with any version of Minecraft[citation needed], as long as Fabric was ported to it. +Requires Fabric Loader 0.14.0+ and Java 17+. ### Usage diff --git a/gradle.properties b/gradle.properties index 6ddf2c0..2339d00 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,8 +3,11 @@ org.gradle.jvmargs=-Xmx1G org.gradle.parallel=true # Fabric Properties -loader_version=0.10.7 +# check these on https://fabricmc.net/develop +minecraft_version=1.18.2 +loader_version=0.14.0 +loom_version=1.14-SNAPSHOT -mod_version=2.1.1 +mod_version=3.0.0 maven_group=dev.xirreal archives_base_name=gfx-debuggers diff --git a/settings.gradle b/settings.gradle index 502d158..dfacbd1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,10 @@ -rootProject.name = 'gfx-debuggers' +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} diff --git a/src/main/java/dev/xirreal/DebuggerPicker.java b/src/main/java/dev/xirreal/DebuggerPicker.java new file mode 100644 index 0000000..175e076 --- /dev/null +++ b/src/main/java/dev/xirreal/DebuggerPicker.java @@ -0,0 +1,373 @@ +package dev.xirreal; + +import java.awt.*; +import java.awt.event.*; +import java.awt.geom.*; +import java.awt.image.*; +import java.util.concurrent.*; +import javax.imageio.*; +import javax.swing.*; +import javax.swing.border.*; + +public class DebuggerPicker extends JFrame { + + private final CountDownLatch latch = new CountDownLatch(1); + + public static enum DebuggerSelection { + GPU_TRACE, + FRAME_DEBUGGER, + RENDERDOC, + NONE, + } + + private DebuggerSelection selectedDebugger = DebuggerSelection.NONE; + + private static final Color BG_PRIMARY = new Color(17, 17, 21); + private static final Color BG_SURFACE = new Color(28, 28, 35); + private static final Color BG_HOVER = new Color(40, 40, 50); + private static final Color BORDER_COLOR = new Color(55, 55, 70); + private static final Color TEXT_PRIMARY = new Color(237, 237, 242); + private static final Color TEXT_SECONDARY = new Color(145, 145, 165); + private static final Color ACCENT_BLUE = new Color(96, 165, 250); + private static final Color ACCENT_GREEN = new Color(74, 222, 128); + private static final Color ACCENT_ORANGE = new Color(251, 146, 60); + + private static final Font FONT_BODY = new Font(Font.SANS_SERIF, Font.PLAIN, 13); + private static final Font FONT_BUTTON = new Font(Font.SANS_SERIF, Font.BOLD, 13); + + public DebuggerPicker() { + setTitle("Graphics Debugger Selector"); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + setResizable(false); + try { + BufferedImage rawIcon = ImageIO.read(DebuggerPicker.class.getResourceAsStream("/assets/gfx-debuggers/icon.png")); + int size = rawIcon.getWidth(); + BufferedImage roundedIcon = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D ig = roundedIcon.createGraphics(); + ig.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + ig.setColor(Color.WHITE); + ig.fill(new RoundRectangle2D.Float(0, 0, size, size, size * 0.25f, size * 0.25f)); + ig.setComposite(AlphaComposite.SrcIn); + ig.drawImage(rawIcon, 0, 0, null); + ig.dispose(); + setIconImage(roundedIcon); + } catch (Exception ignored) {} + addWindowListener( + new WindowAdapter() { + @Override + public void windowClosed(WindowEvent e) { + latch.countDown(); + } + } + ); + + JPanel root = new JPanel(new BorderLayout()) { + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(BG_PRIMARY); + g2.fill(new Rectangle2D.Float(0, 0, getWidth(), getHeight())); + g2.dispose(); + } + }; + root.setOpaque(false); + root.setBorder(BorderFactory.createEmptyBorder(16, 24, 16, 24)); + + JPanel header = new JPanel(); + header.setLayout(new BoxLayout(header, BoxLayout.Y_AXIS)); + header.setOpaque(false); + header.setBorder(BorderFactory.createEmptyBorder(0, 0, 18, 0)); + + JLabel subtitle = new JLabel("Select a debugger to attach to this session"); + subtitle.setFont(FONT_BODY); + subtitle.setForeground(TEXT_SECONDARY); + JPanel subtitleCenter = new JPanel(new FlowLayout(FlowLayout.CENTER, 0, 0)); + subtitleCenter.setOpaque(false); + subtitleCenter.add(subtitle); + header.add(subtitleCenter); + + root.add(header, BorderLayout.NORTH); + + JPanel cards = new JPanel(); + cards.setLayout(new BoxLayout(cards, BoxLayout.Y_AXIS)); + cards.setOpaque(false); + + cards.add( + createDebuggerCard( + "NSight GPU Trace Profiler", + "Record and analyze GPU frame timings and performance metrics", + ACCENT_ORANGE, + DebuggerSelection.GPU_TRACE, + createImageIcon("/assets/gfx-debuggers/gpu-trace.png") + ) + ); + cards.add(Box.createVerticalStrut(8)); + cards.add( + createDebuggerCard( + "NSight Frame Debugger", + "Capture and inspect individual rendered frames", + ACCENT_BLUE, + DebuggerSelection.FRAME_DEBUGGER, + createImageIcon("/assets/gfx-debuggers/frame-debugger.png") + ) + ); + cards.add(Box.createVerticalStrut(8)); + cards.add( + createDebuggerCard( + "RenderDoc", + "Open-source and cross-vendor graphics debugging tool", + ACCENT_GREEN, + DebuggerSelection.RENDERDOC, + createImageIcon("/assets/gfx-debuggers/renderdoc.png") + ) + ); + + cards.add(Box.createVerticalStrut(16)); + + JSeparator sep = new JSeparator(SwingConstants.HORIZONTAL) { + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setColor(BORDER_COLOR); + g2.fillRect(0, getHeight() / 2, getWidth(), 1); + g2.dispose(); + } + }; + sep.setMaximumSize(new Dimension(Integer.MAX_VALUE, 1)); + sep.setPreferredSize(new Dimension(0, 1)); + sep.setAlignmentX(Component.LEFT_ALIGNMENT); + cards.add(sep); + cards.add(Box.createVerticalStrut(16)); + + JPanel skipBtn = createSkipButton(); + cards.add(skipBtn); + + root.add(cards, BorderLayout.CENTER); + setContentPane(root); + + final Point[] dragOffset = { null }; + root.addMouseListener( + new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + dragOffset[0] = e.getPoint(); + } + } + ); + root.addMouseMotionListener( + new MouseMotionAdapter() { + @Override + public void mouseDragged(MouseEvent e) { + if (dragOffset[0] != null) { + Point loc = getLocation(); + setLocation(loc.x + e.getX() - dragOffset[0].x, loc.y + e.getY() - dragOffset[0].y); + } + } + } + ); + + pack(); + setLocationRelativeTo(null); + } + + private JPanel createDebuggerCard(String name, String description, Color accent, DebuggerSelection value, Icon icon) { + JPanel card = new JPanel(new BorderLayout(12, 0)) { + private boolean hovered = false; + + { + setOpaque(false); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14)); + setMaximumSize(new Dimension(Integer.MAX_VALUE, 72)); + + addMouseListener( + new MouseAdapter() { + @Override + public void mouseEntered(MouseEvent e) { + hovered = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) { + hovered = false; + repaint(); + } + + @Override + public void mouseClicked(MouseEvent e) { + selectedDebugger = value; + dispose(); + } + } + ); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + Color bg = hovered ? BG_HOVER : BG_SURFACE; + g2.setColor(bg); + g2.fill(new RoundRectangle2D.Float(0, 0, getWidth(), getHeight(), 12, 12)); + Color border = hovered ? accent.darker() : BORDER_COLOR; + g2.setColor(border); + g2.setStroke(new BasicStroke(1f)); + g2.draw(new RoundRectangle2D.Float(0.5f, 0.5f, getWidth() - 1, getHeight() - 1, 12, 12)); + g2.dispose(); + } + }; + + JLabel iconLabel = new JLabel(icon); + iconLabel.setVerticalAlignment(SwingConstants.CENTER); + card.add(iconLabel, BorderLayout.WEST); + + JPanel textPanel = new JPanel(); + textPanel.setLayout(new BoxLayout(textPanel, BoxLayout.Y_AXIS)); + textPanel.setOpaque(false); + + JLabel nameLabel = new JLabel(name); + nameLabel.setFont(FONT_BUTTON); + nameLabel.setForeground(TEXT_PRIMARY); + nameLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + textPanel.add(nameLabel); + textPanel.add(Box.createVerticalStrut(2)); + + JLabel descLabel = new JLabel(description); + descLabel.setFont(FONT_BODY); + descLabel.setForeground(TEXT_SECONDARY); + descLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + textPanel.add(descLabel); + + card.add(textPanel, BorderLayout.CENTER); + + JPanel arrow = new JPanel() { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(TEXT_SECONDARY); + g2.setStroke(new BasicStroke(1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + int midY = getHeight() / 2; + int midX = getWidth() / 2; + g2.drawLine(midX - 2, midY - 5, midX + 1, midY); + g2.drawLine(midX + 1, midY, midX - 2, midY + 5); + g2.dispose(); + } + }; + arrow.setOpaque(false); + arrow.setPreferredSize(new Dimension(16, 16)); + JPanel arrowWrapper = new JPanel(new BorderLayout()); + arrowWrapper.setOpaque(false); + arrowWrapper.setBorder(BorderFactory.createEmptyBorder(0, 16, 0, 0)); + arrowWrapper.add(arrow, BorderLayout.CENTER); + card.add(arrowWrapper, BorderLayout.EAST); + + card.setAlignmentX(Component.LEFT_ALIGNMENT); + return card; + } + + private JPanel createSkipButton() { + JPanel btn = new JPanel(new BorderLayout()) { + private boolean hovered = false; + + { + setOpaque(false); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setBorder(BorderFactory.createEmptyBorder(10, 14, 10, 14)); + setMaximumSize(new Dimension(Integer.MAX_VALUE, 44)); + + addMouseListener( + new MouseAdapter() { + @Override + public void mouseEntered(MouseEvent e) { + hovered = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) { + hovered = false; + repaint(); + } + + @Override + public void mouseClicked(MouseEvent e) { + selectedDebugger = DebuggerSelection.NONE; + dispose(); + } + } + ); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + Color bg = hovered ? BG_HOVER : BG_SURFACE; + g2.setColor(bg); + g2.fill(new RoundRectangle2D.Float(0, 0, getWidth(), getHeight(), 10, 10)); + g2.dispose(); + } + }; + + JLabel label = new JLabel("Skip injection"); + label.setFont(FONT_BODY); + label.setForeground(TEXT_SECONDARY); + label.setHorizontalAlignment(SwingConstants.CENTER); + btn.add(label, BorderLayout.CENTER); + btn.setAlignmentX(Component.LEFT_ALIGNMENT); + return btn; + } + + private static Icon createImageIcon(String resourcePath) { + return new Icon() { + private BufferedImage masked; + + { + try { + BufferedImage raw = ImageIO.read(DebuggerPicker.class.getResourceAsStream(resourcePath)); + masked = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB); + Graphics2D mg = masked.createGraphics(); + mg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + mg.setColor(Color.WHITE); + mg.fill(new RoundRectangle2D.Float(0, 0, 32, 32, 8, 8)); + mg.setComposite(AlphaComposite.SrcIn); + mg.drawImage(raw, 0, 0, null); + mg.dispose(); + } catch (Exception e) { + masked = null; + } + } + + @Override + public void paintIcon(Component c, Graphics g, int x, int y) { + if (masked != null) { + g.drawImage(masked, x, y, null); + } + } + + @Override + public int getIconWidth() { + return 32; + } + + @Override + public int getIconHeight() { + return 32; + } + }; + } + + public DebuggerSelection getSelection() { + setVisible(true); + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return selectedDebugger; + } +} diff --git a/src/main/java/dev/xirreal/GfxDebuggers.java b/src/main/java/dev/xirreal/GfxDebuggers.java index 28a819d..e439651 100644 --- a/src/main/java/dev/xirreal/GfxDebuggers.java +++ b/src/main/java/dev/xirreal/GfxDebuggers.java @@ -1,21 +1,22 @@ package dev.xirreal; +import static dev.xirreal.DebuggerPicker.DebuggerSelection; import static dev.xirreal.PlatformUtils.*; +import java.awt.*; import java.io.BufferedReader; -import java.io.BufferedWriter; import java.io.IOException; import java.io.InputStreamReader; +import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import javax.swing.*; +import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.entrypoint.PreLaunchEntrypoint; +import org.lwjgl.util.tinyfd.TinyFileDialogs; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,79 +40,95 @@ public void onPreLaunch() { return; } - String renderdocMarker = System.getenv(RENDERDOC_MARKER_ENV); - if ("1".equals(renderdocMarker)) { - LOGGER.info("Game was re-launched with RenderDoc LD_PRELOAD marker. Checking for library..."); - if (IS_LINUX && isLibraryLoaded("librenderdoc")) { - LOGGER.info("Renderdoc injection successful."); - } else if (IS_LINUX) { - LOGGER.warn("Renderdoc marker found but library not loaded. Injection failed."); + if (System.getenv(RENDERDOC_MARKER_ENV) != null) { + LOGGER.info("Process relaunched with Renderdoc marker. Checking if library is loaded..."); + if (isLibraryLoaded("librenderdoc")) { + LOGGER.info("Renderdoc library is loaded. Continuing with normal launch."); + } else { + LOGGER.error("Renderdoc marker environment variable is set but library is not loaded. Something went wrong with the injection."); + throw new IllegalStateException("Renderdoc injection failed"); } return; + } else if (System.getenv(NSIGHT_MARKER_ENV) != null) { + LOGGER.info("Process relaunched with NSight Graphics marker. Continuing with normal launch."); + return; } - String nsightMarker = System.getenv(NSIGHT_MARKER_ENV); - if ("1".equals(nsightMarker)) { - LOGGER.info("Game was launched via ngfx. NSight Graphics injection is active."); - return; + ProcessHandle processHandle = ProcessHandle.current(); + String javaExecutable = processHandle.info().command().orElse("java"); + List jvmArgs = ManagementFactory.getRuntimeMXBean().getInputArguments(); + + List fullArgs = new ArrayList<>(); + fullArgs.addAll(jvmArgs); + if (System.getProperty("java.library.path") != null) { + fullArgs.add("-Djava.library.path=" + System.getProperty("java.library.path")); } + fullArgs.add("-cp"); + fullArgs.add(System.getProperty("java.class.path")); + + // Bypass launcher shims and launch fabric directly as god intended + fullArgs.add("net.fabricmc.loader.impl.launch.knot.KnotClient"); + + String[] args = FabricLoader.getInstance().getLaunchArguments(false); + fullArgs.addAll(Arrays.asList(args)); + + DebuggerSelection choice = DebuggerSelection.NONE; - int option = -1; String optionString = System.getProperty("debugger"); if (optionString != null) { if (optionString.equalsIgnoreCase("renderdoc")) { - option = JOptionPane.CANCEL_OPTION; + choice = DebuggerSelection.RENDERDOC; } else if (optionString.equalsIgnoreCase("nsight-gpu")) { - option = JOptionPane.YES_OPTION; + choice = DebuggerSelection.GPU_TRACE; } else if (optionString.equalsIgnoreCase("nsight-frame")) { - option = JOptionPane.NO_OPTION; + choice = DebuggerSelection.FRAME_DEBUGGER; } } - if (option == -1) { - System.setProperty("java.awt.headless", "false"); + if (choice == DebuggerSelection.NONE) { + String originalHeadless = System.getProperty("java.awt.headless"); + String originalAA = System.getProperty("awt.useSystemAAFontSettings"); + String originalAAText = System.getProperty("swing.aatext"); try { - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); - } catch (ReflectiveOperationException | UnsupportedLookAndFeelException ignored) {} - - String[] options = { "NSight GPU Trace", "NSight Frame Profiler", "Renderdoc" }; - - JFrame frame = new JFrame("Choose a debugger to be loaded"); - frame.setUndecorated(true); - frame.setVisible(true); - frame.setLocationRelativeTo(null); - frame.requestFocus(); - - option = JOptionPane.showOptionDialog( - frame, - "Closing the dialog will skip injection.\n\nNSight or Renderdoc must be installed for this to work properly.", - "Shader debugging", - JOptionPane.YES_NO_CANCEL_OPTION, - JOptionPane.QUESTION_MESSAGE, - null, - options, - null - ); - - frame.dispose(); - System.setProperty("java.awt.headless", "true"); - } - - if (option == JOptionPane.CLOSED_OPTION) { - LOGGER.info("Modal closed, skipping injection..."); - return; + System.setProperty("java.awt.headless", "false"); + System.setProperty("awt.useSystemAAFontSettings", "on"); + System.setProperty("swing.aatext", "true"); + + DebuggerPicker picker = new DebuggerPicker(); + choice = picker.getSelection(); + } catch (Exception e) { + LOGGER.error("Could not open Swing window. Falling back to command line selection.", e); + } finally { + if (originalHeadless != null) { + System.setProperty("java.awt.headless", originalHeadless); + } else { + System.clearProperty("java.awt.headless"); + } + if (originalAA != null) { + System.setProperty("awt.useSystemAAFontSettings", originalAA); + } else { + System.clearProperty("awt.useSystemAAFontSettings"); + } + if (originalAAText != null) { + System.setProperty("swing.aatext", originalAAText); + } else { + System.clearProperty("swing.aatext"); + } + } } - if (option == JOptionPane.CANCEL_OPTION) { - injectRenderdoc(); + if (choice == DebuggerSelection.NONE) { + LOGGER.warn("Injection skipped! No debugger will be injected and the game will launch normally."); return; + } else if (choice == DebuggerSelection.RENDERDOC) { + launchRenderdoc(javaExecutable, fullArgs); + } else { + launchViaNgfx(javaExecutable, fullArgs, choice); } - - launchViaNgfx(option); } - private void injectRenderdoc() { + private void launchRenderdoc(String javaExecutable, List args) { LOGGER.info("Injecting Renderdoc..."); try { if (IS_LINUX) { @@ -119,7 +136,7 @@ private void injectRenderdoc() { if (renderdocPath == null) { LOGGER.error("Renderdoc library not found. Checked standard system paths."); LOGGER.error("Set -Drenderdoc.path= or RENDERDOC_PATH env var to your RenderDoc install directory or librenderdoc.so path."); - return; + throw new IllegalStateException("Renderdoc library not found"); } LOGGER.info("Found Renderdoc at: {}", renderdocPath); @@ -128,32 +145,30 @@ private void injectRenderdoc() { return; } - Map env = new HashMap<>(); - env.put(RENDERDOC_MARKER_ENV, "1"); - - if (reExecWithPreload(renderdocPath, env)) { - LOGGER.error("Re-exec with LD_PRELOAD failed. RenderDoc cannot be injected."); - LOGGER.error("Try launching the game manually with: LD_PRELOAD={} ", renderdocPath); + if (!relaunchWithExtraLD_PRELOAD(javaExecutable, args, renderdocPath, RENDERDOC_MARKER_ENV)) { + LOGGER.error("Re-exec with LD_PRELOAD failed."); + LOGGER.error("Try launching the game manually with this environment variable set: LD_PRELOAD={}", renderdocPath); + throw new IllegalStateException("Failed to relaunch with Renderdoc"); } } else { String renderdocDll = RenderdocLocator.findRenderdocDll(); if (renderdocDll == null) { LOGGER.error("Renderdoc installation not found in common paths."); LOGGER.error("Set -Drenderdoc.path= or RENDERDOC_PATH env var to your RenderDoc install directory."); - return; + throw new IllegalStateException("Renderdoc DLL not found"); } - LOGGER.info("Found Renderdoc installation at: {}", renderdocDll); + LOGGER.info("Found Renderdoc shared library at: {}", renderdocDll); System.load(renderdocDll); LOGGER.info("Renderdoc loaded successfully."); } - } catch (UnsatisfiedLinkError e) { - LOGGER.error("Failed to load Renderdoc: ", e); + } catch (Exception e) { + LOGGER.error("Failed to launch with Renderdoc: ", e); + throw new IllegalStateException("Failed to launch with Renderdoc", e); } } - private void launchViaNgfx(int option) { - String activity = (option == JOptionPane.YES_OPTION) ? "GPU Trace Profiler" : "Frame Debugger"; - LOGGER.info("Launching game via ngfx CLI for {}...", activity); + private void launchViaNgfx(String exe, List args, DebuggerSelection activity) { + LOGGER.info("Launching game via ngfx CLI for {}...", activity.name()); Path ngfx = NgfxLocator.findNgfxExecutable(); if (ngfx == null) { @@ -163,61 +178,38 @@ private void launchViaNgfx(int option) { } else { LOGGER.error("Expected in: Program Files/NVIDIA Corporation/Nsight Graphics */host/windows-desktop-nomad-x64/ngfx.exe"); } - return; + throw new IllegalStateException("ngfx executable not found"); } - LOGGER.info("Found ngfx at: {}", ngfx); - String exe; - try { - exe = getCurrentExe(); - } catch (IOException e) { - LOGGER.error("Failed to read current process exe: ", e); - return; - } + LOGGER.info("Found ngfx at: {}", ngfx); String workDir = System.getProperty("user.dir"); - String platform = IS_LINUX ? "Linux (x86_64)" : "Windows"; List cmd = new ArrayList<>(); cmd.add(ngfx.toAbsolutePath().toString()); - cmd.add("--activity=" + activity); - cmd.add("--platform=" + platform); + cmd.add("--activity=" + (activity == DebuggerSelection.GPU_TRACE ? "GPU Trace Profiler" : "Frame Debugger")); cmd.add("--exe=" + exe); Path argFile = null; try { - List rawArgs = readCurrentArgsList(); - if (!rawArgs.isEmpty()) { - List javaArgs = filterJavaArgs(rawArgs); - argFile = writeArgFile(javaArgs); - LOGGER.info("Wrote {} args to argfile: {}", javaArgs.size(), argFile); - cmd.add("--args=@" + argFile.toAbsolutePath()); - } - } catch (IOException e) { - LOGGER.error("Failed to create argfile, falling back to inline args", e); - try { - String argsString = getCurrentArgsString(); - argsString = stripTheseusFromArgsString(argsString); - if (!argsString.isEmpty()) { - if (IS_WINDOWS) { - argsString = argsString.replace("\"", "\\\""); - } - cmd.add("--args=" + argsString); - } - } catch (IOException e2) { - LOGGER.error("Failed to read current args: ", e2); - return; - } + argFile = writeArgFile(args); + cmd.add("--args=@" + argFile.toAbsolutePath()); + } catch (Exception e) { + LOGGER.error("Failed to create argfile for ngfx: ", e); + throw new IllegalStateException("Failed to create argfile for ngfx", e); } cmd.add("--dir=" + workDir); - cmd.add("--env=" + NSIGHT_MARKER_ENV + "=1;"); + cmd.add("--env=" + NSIGHT_MARKER_ENV + "=1"); cmd.add("--launch-detached"); - if (activity.equals("GPU Trace Profiler")) { + if (activity == DebuggerSelection.GPU_TRACE) { + cmd.add("--limit-to-frames"); + cmd.add("5"); + cmd.add("--multi-pass-metrics"); cmd.add("--start-after-hotkey"); } - LOGGER.info("Running ngfx: {}", censorArgList(cmd)); + LOGGER.info("Running ngfx with command: {}", String.join(" ", cmd)); try { ProcessBuilder pb = new ProcessBuilder(cmd); @@ -237,113 +229,29 @@ private void launchViaNgfx(int option) { if (exitCode != 0) { LOGGER.error("ngfx exited with code {}. Check that NSight Graphics is installed correctly.", exitCode); - return; + throw new IllegalStateException("ngfx exited with code " + exitCode); } LOGGER.info("Game re-launched via ngfx (exit code 0). Terminating current process."); System.exit(0); } catch (IOException e) { LOGGER.error("Failed to run ngfx: ", e); + throw new IllegalStateException("Failed to run ngfx", e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); LOGGER.error("Interrupted while waiting for ngfx: ", e); + throw new IllegalStateException("Interrupted while waiting for ngfx", e); + } catch (Exception e) { + LOGGER.error("Failed to launch with NSight Graphics: ", e); + throw new IllegalStateException("Failed to launch with NSight Graphics", e); } finally { if (argFile != null) { try { Files.deleteIfExists(argFile); - LOGGER.info("Cleaned up argfile: {}", argFile); } catch (IOException e) { - LOGGER.warn("Failed to clean up argfile: {}", argFile, e); - } - } - } - } - - static List filterJavaArgs(List args) { - List filtered = new ArrayList<>(); - for (int i = 0; i < args.size(); i++) { - String arg = args.get(i); - - if (arg.matches("-javaagent:.*theseus\\.jar.*")) { - LOGGER.info("Stripping theseus javaagent: {}", arg); - continue; - } - if (arg.equals("com.modrinth.theseus.MinecraftLaunch")) { - LOGGER.info("Stripping theseus main class"); - continue; - } - if (arg.startsWith("-Dmodrinth.internal.")) { - LOGGER.info("Stripping Modrinth property: {}", arg); - continue; - } - - if ((arg.equals("-cp") || arg.equals("-classpath")) && i + 1 < args.size()) { - filtered.add(arg); - i++; - String cp = args.get(i); - String separator = IS_WINDOWS ? ";" : ":"; - String cleaned = Arrays.stream(cp.split(IS_WINDOWS ? ";" : ":")) - .filter(entry -> !entry.contains("theseus.jar")) - .collect(Collectors.joining(separator)); - if (!cleaned.equals(cp)) { - LOGGER.info("Stripped theseus.jar from classpath"); + LOGGER.error("Failed to delete temporary arg file: {}", argFile, e); } - filtered.add(cleaned); - continue; - } - - filtered.add(arg); - } - return filtered; - } - - static Path writeArgFile(List args) throws IOException { - Path argFile = Files.createTempFile("gfx-debuggers-", ".args"); - try (BufferedWriter writer = Files.newBufferedWriter(argFile)) { - for (String arg : args) { - writer.write(quoteForArgFile(arg)); - writer.newLine(); - } - } - return argFile; - } - - static String quoteForArgFile(String arg) { - if (arg.isEmpty()) { - return "\"\""; - } - boolean needsQuoting = false; - for (int i = 0; i < arg.length(); i++) { - char c = arg.charAt(i); - if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '"' || c == '\'' || c == '\\' || c == '#') { - needsQuoting = true; - break; } } - if (!needsQuoting) { - return arg; - } - StringBuilder sb = new StringBuilder(arg.length() + 8); - sb.append('"'); - for (int i = 0; i < arg.length(); i++) { - char c = arg.charAt(i); - if (c == '\\' || c == '"') { - sb.append('\\'); - } - sb.append(c); - } - sb.append('"'); - return sb.toString(); - } - - static String stripTheseusFromArgsString(String argsString) { - argsString = argsString.replaceAll("-javaagent:[^\\s]+theseus\\.jar", ""); - argsString = argsString.replaceAll("com\\.modrinth\\.theseus\\.MinecraftLaunch", ""); - argsString = argsString.replaceAll("-Dmodrinth\\.internal\\.[^\\s]+", ""); - argsString = argsString.replaceAll("[^\\s:;]*theseus\\.jar[;:]?", ""); - argsString = argsString.replaceAll("::+", ":").replaceAll(";;+", ";"); - argsString = argsString.replaceAll("(-cp\\s+|--classpath\\s+)[;:]", "$1"); - argsString = argsString.replaceAll(" +", " ").trim(); - return argsString; } } diff --git a/src/main/java/dev/xirreal/PlatformUtils.java b/src/main/java/dev/xirreal/PlatformUtils.java index c5299ff..8cf5047 100644 --- a/src/main/java/dev/xirreal/PlatformUtils.java +++ b/src/main/java/dev/xirreal/PlatformUtils.java @@ -4,14 +4,17 @@ import com.sun.jna.Native; import com.sun.jna.Pointer; import com.sun.jna.StringArray; +import java.io.*; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.*; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.List; import java.util.Map; import java.util.Set; @@ -26,12 +29,6 @@ public interface LibC extends Library { int execv(String pathname, StringArray argv); } - public interface Kernel32 extends Library { - Pointer GetCommandLineW(); - } - - public static final Kernel32 KERNEL32 = IS_WINDOWS ? Native.load("kernel32", Kernel32.class) : null; - public static boolean isLibraryLoaded(String needle) { try { String maps = Files.readString(Paths.get("/proc/self/maps")); @@ -41,201 +38,71 @@ public static boolean isLibraryLoaded(String needle) { } } - public static List readCurrentCmdline() throws IOException { - byte[] cmdlineBytes = Files.readAllBytes(Paths.get("/proc/self/cmdline")); - List args = new ArrayList<>(); - int start = 0; - for (int i = 0; i < cmdlineBytes.length; i++) { - if (cmdlineBytes[i] == 0) { - if (i > start) { - args.add(new String(cmdlineBytes, start, i - start, StandardCharsets.UTF_8)); - } - start = i + 1; - } - } - if (start < cmdlineBytes.length) { - args.add(new String(cmdlineBytes, start, cmdlineBytes.length - start, StandardCharsets.UTF_8)); + public static boolean relaunchWithExtraLD_PRELOAD(String exe, List args, String preloadLib, String markerEnvVar) { + String currentPreload = System.getenv("LD_PRELOAD"); + String newPreload; + if (currentPreload != null && !currentPreload.isEmpty()) { + newPreload = preloadLib + ":" + currentPreload; + } else { + newPreload = preloadLib; } - return args; - } - public static boolean reExecWithPreload(String preloadLib, Map extraEnv) { - try { - List args = readCurrentCmdline(); - if (args.isEmpty()) { - GfxDebuggers.LOGGER.error("Failed to read /proc/self/cmdline: no arguments found"); - return true; - } - - String exe = Paths.get("/proc/self/exe").toRealPath().toString(); - - String currentPreload = System.getenv("LD_PRELOAD"); - String newPreload; - if (currentPreload != null && !currentPreload.isEmpty()) { - newPreload = preloadLib + ":" + currentPreload; - } else { - newPreload = preloadLib; - } + LibC.INSTANCE.setenv("LD_PRELOAD", newPreload, 1); + LibC.INSTANCE.setenv(markerEnvVar, "1", 1); - LibC.INSTANCE.setenv("LD_PRELOAD", newPreload, 1); + GfxDebuggers.LOGGER.info("Replacing process..."); - if (extraEnv != null) { - for (Map.Entry entry : extraEnv.entrySet()) { - LibC.INSTANCE.setenv(entry.getKey(), entry.getValue(), 1); - } - } - - GfxDebuggers.LOGGER.info("Re-executing process with LD_PRELOAD={}", newPreload); - GfxDebuggers.LOGGER.info("Executable: {}", exe); - GfxDebuggers.LOGGER.info("Arguments: {}", censorArgList(args)); + StringArray argv = new StringArray(args.toArray(new String[0])); + LibC.INSTANCE.execv(exe, argv); - StringArray argv = new StringArray(args.toArray(new String[0])); - LibC.INSTANCE.execv(exe, argv); - - int errno = Native.getLastError(); - GfxDebuggers.LOGGER.error("execv failed with errno {}", errno); - return true; - } catch (IOException e) { - GfxDebuggers.LOGGER.error("Failed to re-exec with LD_PRELOAD: ", e); - return true; - } + int errno = Native.getLastError(); + GfxDebuggers.LOGGER.error("execv failed with errno {}", errno); + return false; } - public static List readCurrentArgsList() throws IOException { - if (IS_LINUX) { - List cmdline = readCurrentCmdline(); - if (cmdline.size() <= 1) return List.of(); - return new ArrayList<>(cmdline.subList(1, cmdline.size())); - } else { - String fullCmd = KERNEL32.GetCommandLineW().getWideString(0); - List all = parseWindowsCommandLine(fullCmd); - if (all.size() <= 1) return List.of(); - return new ArrayList<>(all.subList(1, all.size())); - } - } + static Path writeArgFile(List args) throws IOException { + Path argFile = Files.createTempFile("gfx-debuggers-", ".args"); + + argFile.toFile().deleteOnExit(); - public static List parseWindowsCommandLine(String commandLine) { - List args = new ArrayList<>(); - if (commandLine == null || commandLine.isEmpty()) return args; - - StringBuilder current = new StringBuilder(); - boolean inQuotes = false; - int i = 0; - - while (i < commandLine.length()) { - char c = commandLine.charAt(i); - - if (c == '\\') { - int numBackslashes = 0; - while (i < commandLine.length() && commandLine.charAt(i) == '\\') { - numBackslashes++; - i++; - } - if (i < commandLine.length() && commandLine.charAt(i) == '"') { - for (int j = 0; j < numBackslashes / 2; j++) { - current.append('\\'); - } - if (numBackslashes % 2 == 1) { - current.append('"'); - i++; - } - } else { - for (int j = 0; j < numBackslashes; j++) { - current.append('\\'); - } - } - } else if (c == '"') { - inQuotes = !inQuotes; - i++; - } else if ((c == ' ' || c == '\t') && !inQuotes) { - if (current.length() > 0) { - args.add(current.toString()); - current.setLength(0); - } - i++; - } else { - current.append(c); - i++; + try (BufferedWriter writer = Files.newBufferedWriter(argFile, StandardCharsets.UTF_8)) { + for (String arg : args) { + writer.write(quoteForArgFile(arg)); + writer.newLine(); } } - if (current.length() > 0) { - args.add(current.toString()); - } - return args; + return argFile; } - public static String getCurrentExe() throws IOException { - if (IS_LINUX) { - return Paths.get("/proc/self/exe").toRealPath().toString(); - } else { - return ProcessHandle.current().info().command().orElse(Paths.get(System.getProperty("java.home"), "bin", "java.exe").toString()); + // https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html#java-command-line-argument-files + static String quoteForArgFile(String arg) { + if (arg.isEmpty()) { + return "\"\""; } - } - public static String getCurrentArgsString() throws IOException { - if (IS_LINUX) { - List cmdline = readCurrentCmdline(); - if (cmdline.size() <= 1) return ""; - StringBuilder sb = new StringBuilder(); - for (int i = 1; i < cmdline.size(); i++) { - if (i > 1) sb.append(' '); - sb.append(shellQuote(cmdline.get(i))); + boolean needsQuoting = false; + for (int i = 0; i < arg.length(); i++) { + char c = arg.charAt(i); + if (Character.isWhitespace(c) || c == '"' || c == '\'' || c == '\\' || c == '#') { + needsQuoting = true; + break; } - return sb.toString(); - } else { - String fullCmd = KERNEL32.GetCommandLineW().getWideString(0); - return stripExeFromCommandLine(fullCmd); } - } - - public static String shellQuote(String arg) { - if (arg.isEmpty()) return "''"; - if (arg.matches("[a-zA-Z0-9._/=:@%,+-]+")) return arg; - return "'" + arg.replace("'", "'\\''") + "'"; - } - - private static final Set SENSITIVE_ARGS = new HashSet<>(Arrays.asList("--accessToken", "--uuid", "--username", "--xuid", "--clientId")); - public static String censorArgList(List args) { - List censored = new ArrayList<>(args.size()); - boolean censorNext = false; - for (String arg : args) { - if (censorNext) { - censored.add(""); - censorNext = false; - continue; - } - boolean handled = false; - for (String sensitive : SENSITIVE_ARGS) { - if (arg.startsWith(sensitive + "=")) { - censored.add(sensitive + "="); - handled = true; - break; - } - if (arg.equals(sensitive)) { - censored.add(arg); - censorNext = true; - handled = true; - break; - } - } - if (!handled) { - censored.add(arg); - } + if (!needsQuoting) { + return arg; } - return censored.toString(); - } - public static String stripExeFromCommandLine(String cmdLine) { - if (cmdLine == null || cmdLine.isEmpty()) return ""; - String trimmed = cmdLine.trim(); - if (trimmed.startsWith("\"")) { - int endQuote = trimmed.indexOf('"', 1); - if (endQuote >= 0) { - return trimmed.substring(endQuote + 1).trim(); + StringBuilder sb = new StringBuilder(arg.length() + 8); + sb.append('"'); + for (int i = 0; i < arg.length(); i++) { + char c = arg.charAt(i); + if (c == '\\' || c == '"') { + sb.append('\\'); } + sb.append(c); } - int space = trimmed.indexOf(' '); - return space >= 0 ? trimmed.substring(space + 1).trim() : ""; + sb.append('"'); + return sb.toString(); } } diff --git a/src/main/resources/assets/gfx-debuggers/frame-debugger.png b/src/main/resources/assets/gfx-debuggers/frame-debugger.png new file mode 100644 index 0000000..2282964 Binary files /dev/null and b/src/main/resources/assets/gfx-debuggers/frame-debugger.png differ diff --git a/src/main/resources/assets/gfx-debuggers/gpu-trace.png b/src/main/resources/assets/gfx-debuggers/gpu-trace.png new file mode 100644 index 0000000..f479904 Binary files /dev/null and b/src/main/resources/assets/gfx-debuggers/gpu-trace.png differ diff --git a/src/main/resources/assets/gfx-debuggers/renderdoc.png b/src/main/resources/assets/gfx-debuggers/renderdoc.png new file mode 100644 index 0000000..76f7934 Binary files /dev/null and b/src/main/resources/assets/gfx-debuggers/renderdoc.png differ diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index f68efbc..0cdbce5 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -15,8 +15,7 @@ "preLaunch": ["dev.xirreal.GfxDebuggers"] }, "depends": { - "fabricloader": ">=0.10.7", - "minecraft": "*", + "fabricloader": ">=0.14.0", "java": ">=17" } }