Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ public final class LauncherHelper {
private boolean showLogs;
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
/// Whether this launch should be wrapped by RenderDoc.
private boolean renderDocMode = false;

public LauncherHelper(Profile profile, Account account, String selectedVersion) {
this.profile = Objects.requireNonNull(profile);
Expand Down Expand Up @@ -124,6 +126,13 @@ public void setDisableOfflineSkin() {
disableOfflineSkin = true;
}

/// Enables RenderDoc capture mode for this launch.
public void setRenderDocMode() {
renderDocMode = true;
launcherVisibility = LauncherVisibility.KEEP;
showLogs = true;
}

public void launch() {
FXUtils.checkFxUserThread();

Expand Down Expand Up @@ -152,6 +161,7 @@ private void launch0() {
List<String> javaArguments = new ArrayList<>(0);

AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
AtomicReference<Path> renderDocExecutableRef = new AtomicReference<>();

TaskExecutor executor = checkGameState(profile, setting, version.get())
.thenComposeAsync(java -> {
Expand Down Expand Up @@ -199,6 +209,10 @@ private void launch0() {
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> renderDocMode
? RenderDoc.prepare()
.thenAcceptAsync(renderDocExecutableRef::set)
: null)
.thenComposeAsync(() -> {
if (config().getAllowAutoAgent()
|| setting.isNoJVMArgs()
Expand Down Expand Up @@ -231,6 +245,11 @@ private void launch0() {
if (quickPlayOption != null) {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
if (renderDocMode) {
Path renderDocExecutable = Objects.requireNonNull(renderDocExecutableRef.get());
Path workingDirectory = repository.getRunDirectory(selectedVersion);
launchOptionsBuilder.setWrapper(RenderDoc.prependWrapper(setting.getWrapper(), renderDocExecutable, workingDirectory));
}

LaunchOptions launchOptions = launchOptionsBuilder.create();

Expand Down Expand Up @@ -277,6 +296,7 @@ private void launch0() {
.withStagesHints(
new Task.StagesHint("launch.state.java"),
new Task.StagesHint("launch.state.dependencies", List.of("hmcl.install.assets", "hmcl.install.libraries", "hmcl.modpack.download")),
new Task.StagesHint("renderdoc.download"),
new Task.StagesHint("launch.state.logging_in"),
new Task.StagesHint("launch.state.waiting_launching"))
.executor();
Expand Down
215 changes: 215 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/RenderDoc.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.game;

import kala.compress.archivers.ArchiveEntry;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.launch.ProcessCreationException;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;

import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/// Provides RenderDoc downloading, extraction, and command wrapping for game launches.
@NotNullByDefault
public final class RenderDoc {
/// RenderDoc version downloaded from the official RenderDoc stable release.
public static final String VERSION = "1.44";

/// The official RenderDoc stable download URL prefix.
private static final String DOWNLOAD_URL_PREFIX = "https://renderdoc.org/stable/";

/// Prevents instantiation.
private RenderDoc() {
}

/// Returns whether the current platform can use the bundled RenderDoc package.
public static boolean isSupported() {
return Architecture.SYSTEM_ARCH == Architecture.X86_64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.LINUX);
}

/// Creates a task that downloads RenderDoc if necessary and returns the renderdoccmd executable.
public static Task<Path> prepare() {
return prepare(getCommandExecutable());
}

/// Creates a task that downloads RenderDoc if necessary and returns the qrenderdoc executable.
public static Task<Path> prepareUI() {
return prepare(getUIExecutable());
}

/// Starts qrenderdoc with the current process environment.
public static Process startUI(Path renderDocUIExecutable) throws ProcessCreationException {
try {
return new ProcessBuilder(FileUtils.getAbsolutePath(renderDocUIExecutable))
.directory(getRenderDocDirectory().toFile())
.start();
} catch (IOException e) {
throw new ProcessCreationException(e);
}
}

/// Creates a task that downloads RenderDoc if necessary and returns the requested executable.
private static Task<Path> prepare(Path executable) {
if (!isSupported()) {
return Task.supplyAsync(() -> {
throw new UnsupportedOperationException("RenderDoc is only supported on Windows x86-64 and Linux x86-64");
});
}

if (Files.isRegularFile(executable)) {
return Task.completed(executable);
}

Path archive = getArchivePath();
Task<?> downloadTask = Files.isRegularFile(archive)
? Task.completed(null)
: new FileDownloadTask(URI.create(getDownloadUrl()), archive);

return downloadTask.thenApplyAsync(Schedulers.io(), ignored -> {
extract(archive, getExtractDirectory());
return executable;
}).withStage("renderdoc.download");
}

/// Builds the command prefix used to make RenderDoc capture the Java process.
public static @Unmodifiable List<String> createWrapper(Path renderDocExecutable, Path workingDirectory) {
return List.of(
FileUtils.getAbsolutePath(renderDocExecutable),
"capture",
"--wait-for-exit",
"--working-dir",
FileUtils.getAbsolutePath(workingDirectory)
);
}

/// Adds a RenderDoc command prefix in front of an existing wrapper command.
public static String prependWrapper(String wrapper, Path renderDocExecutable, Path workingDirectory) {
ArrayList<String> command = new ArrayList<>(createWrapper(renderDocExecutable, workingDirectory));
if (wrapper != null && !wrapper.isBlank()) {
command.addAll(org.jackhuang.hmcl.util.StringUtils.tokenize(wrapper));
}
return new CommandBuilder().addAll(command).toString();
}

/// Extracts the RenderDoc archive into the destination directory.
private static void extract(Path archive, Path destination) throws IOException {
Files.createDirectories(destination);

try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(archive)) {
extractDirectory(tree, tree.getRoot(), destination);
}

FileUtils.setExecutable(getCommandExecutable());
}

/// Extracts an archive directory and all of its children while preserving directory structure.
private static void extractDirectory(ArchiveFileTree<?, ?> tree, ArchiveFileTree.Dir<?> directory, Path destination) throws IOException {
Files.createDirectories(destination);

for (ArchiveFileTree.Dir<?> childDirectory : directory.getSubDirs().values()) {
extractDirectory(tree, childDirectory, destination.resolve(childDirectory.getName()));
}

for (ArchiveEntry entry : directory.getFiles().values()) {
if (entry.isDirectory()) {
continue;
}

Path output = destination.resolve(Path.of(entry.getName()).getFileName().toString());
Files.createDirectories(output.getParent());
extractEntry(tree, entry, output);
}
}

/// Extracts one archive entry and restores executable permission when the archive records it.
@SuppressWarnings({"unchecked", "rawtypes"})
private static void extractEntry(ArchiveFileTree tree, ArchiveEntry entry, Path output) throws IOException {
if (tree.isLink(entry)) {
Files.deleteIfExists(output);
Files.createSymbolicLink(output, Path.of(tree.getLink(entry)));
return;
}

Files.deleteIfExists(output);
tree.extractTo(entry, output);
if (tree.isExecutable(entry)) {
FileUtils.setExecutable(output);
}
}

/// Returns the expected path of renderdoccmd after extraction.
private static Path getCommandExecutable() {
return getRenderDocDirectory().resolve(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "renderdoccmd.exe" : "bin/renderdoccmd");
}

/// Returns the expected path of qrenderdoc after extraction.
private static Path getUIExecutable() {
return getRenderDocDirectory().resolve(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "qrenderdoc.exe" : "bin/qrenderdoc");
}

/// Returns the local archive cache path.
private static Path getArchivePath() {
return getBaseDirectory().resolve(getArchiveName());
}

/// Returns the directory where the archive is extracted.
private static Path getExtractDirectory() {
return getBaseDirectory().resolve("extracted");
}

/// Returns the extracted RenderDoc root directory.
private static Path getRenderDocDirectory() {
return getExtractDirectory().resolve(getBaseName());
}

/// Returns HMCL's dependency directory for this RenderDoc version and operating system.
private static Path getBaseDirectory() {
return Metadata.DEPENDENCIES_DIRECTORY.resolve("renderdoc").resolve(VERSION).resolve(OperatingSystem.CURRENT_OS.getCheckedName());
}

/// Returns the remote RenderDoc archive URL.
private static String getDownloadUrl() {
return URI.create(DOWNLOAD_URL_PREFIX + VERSION + "/" + getArchiveName()).toString();
}

/// Returns the platform-specific RenderDoc archive file name.
private static String getArchiveName() {
return getBaseName() + (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? ".zip" : ".tar.gz");
}

/// Returns the platform-specific RenderDoc archive and root directory base name.
private static String getBaseName() {
return (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "RenderDoc_%s_64" : "renderdoc_%s")
.formatted(VERSION);
}
}
12 changes: 12 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,16 @@ private void generateLaunchScript() {
Versions.generateLaunchScript(getProfile(), getVersion());
}

/// Launches the current instance with RenderDoc capture enabled.
private void launchWithRenderDoc() {
Versions.launchWithRenderDoc(getProfile(), getVersion());
}

/// Starts the RenderDoc UI.
private void startRenderDocUI() {
Versions.startRenderDocUI();
}

private void export() {
Versions.exportVersion(getProfile(), getVersion());
}
Expand Down Expand Up @@ -311,6 +321,8 @@ protected Skin(VersionPage control) {
managementList.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), control::testGame, managementPopup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), control::generateLaunchScript, managementPopup),
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.renderdoc"), control::launchWithRenderDoc, managementPopup),
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.renderdoc.ui"), control::startRenderDocUI, managementPopup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), control::rename, managementPopup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), control::duplicate, managementPopup),
Expand Down
43 changes: 43 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.jackhuang.hmcl.download.game.GameDownloadTask;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.launch.ProcessCreationException;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
Expand Down Expand Up @@ -310,6 +311,48 @@ public static void testGame(Profile profile, String id) {
launch(profile, id, LauncherHelper::setTestMode);
}

/// Launches the selected instance through RenderDoc, downloading RenderDoc first when needed.
public static void launchWithRenderDoc(Profile profile, String id) {
if (!RenderDoc.isSupported()) {
Controllers.dialog(
i18n("version.launch.renderdoc.unsupported"),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
return;
}

launch(profile, id, LauncherHelper::setRenderDocMode);
}

/// Starts the RenderDoc UI, downloading RenderDoc first when needed.
public static void startRenderDocUI() {
if (!RenderDoc.isSupported()) {
Controllers.dialog(
i18n("version.launch.renderdoc.unsupported"),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
return;
}

Controllers.taskDialog(
RenderDoc.prepareUI()
.whenComplete(Schedulers.javafx(), executable -> {
try {
RenderDoc.startUI(executable);
} catch (ProcessCreationException e) {
Controllers.dialog(
i18n("launch.failed.creating_process") + "\n" + e.getLocalizedMessage(),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
}
}, exception -> Controllers.dialog(
DownloadProviders.localizeErrorMessage(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR)),
i18n("renderdoc.download"),
TaskCancellationAction.NORMAL);
}

public static void launchAndEnterWorld(Profile profile, String id, String worldFolderName) {
launch(profile, id, launcherHelper ->
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
Expand Down
6 changes: 5 additions & 1 deletion HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,7 @@ launch.failed.java_version_too_low=The Java version you specified is too low. Pl
launch.failed.no_accepted_java=Failed to find a compatible Java version, do you want to launch the game with the default Java?\nClick "Yes" to launch the game with the default Java.\nOr, you can navigate to "Global/Instance-specific Settings → Java" to choose one yourself.
launch.failed.sigkill=Game was forcibly terminated by the user or system.
launch.state.dependencies=Resolving dependencies
renderdoc.download=Downloading RenderDoc
launch.state.done=Launched
launch.state.java=Checking Java version
launch.state.logging_in=Logging in
Expand Down Expand Up @@ -1704,6 +1705,9 @@ version.launch.empty=Start Game
version.launch.empty.installing=Installing Game
version.launch.empty.tooltip=Install and launch the latest official release
version.launch.test=Test Launch
version.launch.renderdoc=Test Game with RenderDoc
version.launch.renderdoc.ui=Open RenderDoc
version.launch.renderdoc.unsupported=RenderDoc launch is only supported on Windows x86-64 and Linux x86-64.
version.switch=Switch Instance
version.launch_script=Export Launch Script
version.launch_script.failed=Failed to export launch script.
Expand Down Expand Up @@ -1745,4 +1749,4 @@ wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s
wizard.prev=< Prev
wizard.failed=Failed
wizard.finish=Finish
wizard.next=Next >
wizard.next=Next >
Loading