diff --git a/build.gradle.kts b/build.gradle.kts index 383959a..22b6f33 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -220,6 +220,44 @@ tasks.prepareSandbox { val file = file(f) if (!file.exists()) throw RuntimeException("File ${file} does not exist") }) + + // The Rider SDK archive omits certain DLLs that are present in a full Rider installation. + // Copy the missing Unity plugin DotFiles DLL from the local Rider installation so the sandbox can load it. + if (!isWindows) { + val riderInstallCandidates = if (Os.isFamily(Os.FAMILY_MAC)) { + listOf(file("/Applications/Rider.app/Contents")) + } else { + // Linux: check JetBrains Toolbox and common standalone install paths + val toolboxBase = file("${System.getProperty("user.home")}/.local/share/JetBrains/Toolbox/apps/Rider") + val toolboxInstalls = if (toolboxBase.exists()) { + toolboxBase.walkTopDown() + .filter { it.name == "plugins" && it.parentFile?.name?.startsWith("2") == true } + .map { it.parentFile } + .toList() + } else emptyList() + toolboxInstalls + listOf(file("/opt/rider"), file("/usr/share/rider")) + } + + val missingDotFileDlls = listOf( + "JetBrains.ReSharper.Plugins.Unity.Rider.Debugger.PausePoint.Helper.dll", + "JetBrains.ReSharper.Plugins.Unity.Rider.Debugger.Presentation.Texture.dll", + ) + + val destDir = intellijPlatform.platformPath.resolve("plugins/rider-unity/DotFiles").toFile() + destDir.mkdirs() + + for (dllName in missingDotFileDlls) { + val dllRelPath = "plugins/rider-unity/DotFiles/$dllName" + val srcDll = riderInstallCandidates + .map { file("${it}/${dllRelPath}") } + .firstOrNull { it.exists() } + + if (srcDll != null) { + // Copy into the extracted SDK location (platformPath) — that's where Rider loads plugins from at runtime + srcDll.copyTo(file("${destDir}/${srcDll.name}"), overwrite = true) + } + } + } } } diff --git a/src/rider/main/kotlin/run/RunConfiguration.kt b/src/rider/main/kotlin/run/RunConfiguration.kt index 6cfe968..3329c58 100644 --- a/src/rider/main/kotlin/run/RunConfiguration.kt +++ b/src/rider/main/kotlin/run/RunConfiguration.kt @@ -19,8 +19,6 @@ import com.jetbrains.rider.plugins.unity.UnityBundle import com.jetbrains.rider.plugins.unity.run.configurations.unityExe.UnityExeConfiguration import com.jetbrains.rider.run.RiderRunBundle import icons.UnityIcons -import kotlin.io.path.Path - internal class UnityPlayerDebugConfigurationTypeInternal : ConfigurationTypeBase( ID, @@ -108,7 +106,7 @@ class RunConfiguration(project: Project, factory: ConfigurationFactory, name: St getScriptName(), getSaveFilePath(), getModListPath(), - getRimworldState(environment, OS.CURRENT == OS.Linux), + getRimworldState(environment), UnityDebugRemoteConfiguration(), environment, "CustomPlayer" @@ -119,27 +117,30 @@ class RunConfiguration(project: Project, factory: ConfigurationFactory, name: St return RimworldDev.Rider.run.SettingsEditor(project) } - private fun getRimworldState(environment: ExecutionEnvironment, debugInLinux: Boolean = false): CommandLineState { + // Previously this function accepted a `debugWithScript: Boolean` parameter that, when true, + // constructed a "/bin/sh run.sh " command and was used for the Linux/macOS debug path. + // That parameter was removed because RunState.execute() bypasses rimworldState.execute() entirely + // on macOS/Linux (it uses ProcessBuilder directly to avoid silent failures in the Rider sandbox's + // coroutine context). The CommandLineState returned here is now only ever executed on Windows. + // The old approach also had a space-in-path bug: it joined all arguments into one string and then + // split on ' ', which broke paths containing spaces (e.g. "Application Support" in the Steam path). + private fun getRimworldState(environment: ExecutionEnvironment): CommandLineState { return object : CommandLineState(environment) { override fun startProcess(): ProcessHandler { - var pathToRun = getScriptName() - var arguments = getCommandLineOptions() - - // If we're debugging in Rimworld, instead of /pwd/RimWorldLinux ...args we want to run /bin/sh /pwd/run.sh /pwd/RimWorldLinux ...args - if (debugInLinux) { - val bashScriptPath = "${Path(pathToRun).parent}/run.sh" - arguments = "$bashScriptPath $pathToRun $arguments" - pathToRun = "/bin/sh" - } - - if (OS.CURRENT == OS.macOS) { - arguments = "$pathToRun $arguments" - pathToRun = "open" + val scriptName = getScriptName() + // Split extra CLI options by space — these are game flags, not paths, so this is safe + val extraArgs = getCommandLineOptions().split(' ').filter { it.isNotEmpty() } + + val commandLine = when { + OS.CURRENT == OS.macOS -> { + // .app bundles are directories and cannot be exec'd directly; use 'open' to launch them. + val params = if (extraArgs.isEmpty()) listOf(scriptName) + else listOf(scriptName, "--args") + extraArgs + GeneralCommandLine("open").withParameters(params) + } + else -> GeneralCommandLine(scriptName).withParameters(extraArgs) } - val commandLine = GeneralCommandLine(pathToRun) - .withParameters(arguments.split(' ').filter { it.isNotEmpty() }) - EnvironmentVariablesData.create(getEnvData(), true).configureCommandLine(commandLine, true) QuickStartUtils.setup(getModListPath(), getSaveFilePath()); diff --git a/src/rider/main/kotlin/run/RunState.kt b/src/rider/main/kotlin/run/RunState.kt index dcb1586..a1dc11e 100644 --- a/src/rider/main/kotlin/run/RunState.kt +++ b/src/rider/main/kotlin/run/RunState.kt @@ -1,21 +1,27 @@ package RimworldDev.Rider.run import RimworldDev.Rider.helpers.ScopeHelper +import com.intellij.execution.ExecutionException import com.intellij.execution.ExecutionResult import com.intellij.execution.Executor import com.intellij.execution.configurations.RunProfileState import com.intellij.execution.process.* import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.ProgramRunner -import com.intellij.ide.actions.searcheverywhere.evaluate import com.intellij.util.system.OS import com.jetbrains.rd.util.lifetime.Lifetime import com.jetbrains.rider.debugger.DebuggerWorkerProcessHandler import com.jetbrains.rider.plugins.unity.run.configurations.UnityAttachProfileState import com.jetbrains.rider.run.configurations.remote.RemoteConfiguration import com.jetbrains.rider.run.getProcess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import java.net.BindException +import java.net.InetSocketAddress +import java.net.ServerSocket import java.nio.file.Files import kotlin.io.path.Path @@ -58,13 +64,25 @@ class RunState( "Doorstop/Mono.CompilerServices.SymbolWriter.dll", "Doorstop/pdb2mdb.exe", ), + // macOS resource list was previously incomplete. Added: + // - "run.sh" — the launch script that sets DYLD_INSERT_LIBRARIES and resolves the real binary inside the .app bundle + // - "Doorstop/dnlib.dll" — required by Doorstop at load time; omission caused silent injection failure + // - "Doorstop/HotReload.dll" — likewise required but was missing + // - "Doorstop/libdoorstop.dylib" — the native DYLD injection library; without this Doorstop cannot intercept Mono at all + // Also fixed: the config file was previously listed as ".doorstop_config.ini" (with a leading dot). + // getResourceAsStream() returned null for that name (the actual resource has no leading dot), and the + // copyResource() function silently returned on null — so the config file was never copied. OS.macOS to listOf( + "run.sh", ".doorstop_version", - ".doorstop_config.ini", + "doorstop_config.ini", "Doorstop/0Harmony.dll", + "Doorstop/dnlib.dll", "Doorstop/Doorstop.dll", "Doorstop/Doorstop.pdb", + "Doorstop/HotReload.dll", + "Doorstop/libdoorstop.dylib", "Doorstop/Mono.Cecil.dll", "Doorstop/Mono.CompilerServices.SymbolWriter.dll", "Doorstop/pdb2mdb.exe", @@ -77,32 +95,82 @@ class RunState( workerProcessHandler: DebuggerWorkerProcessHandler, lifetime: Lifetime ): ExecutionResult { + // Order matters: Doorstop files and the quick-start mod/save setup must both be in place + // before the game process starts. Previously, setup() was called inside rimworldState's + // startProcess(), but that path is bypassed on macOS/Linux (see ProcessBuilder block below), + // so it is called explicitly here instead. setupDoorstop() - val result = super.execute(executor, runner, workerProcessHandler) - ProcessTerminatedListener.attach(workerProcessHandler.debuggerWorkerRealHandler) - - val rimworldResult = rimworldState.execute(executor, runner) - workerProcessHandler.debuggerWorkerRealHandler.addProcessListener(createProcessListener(rimworldResult?.processHandler)) + QuickStartUtils.setup(modListPath, saveFilePath) + + // On macOS/Linux, launch the game directly via ProcessBuilder rather than going through + // rimworldState.execute(). The IntelliJ process framework can fail silently in the + // Rider sandbox's coroutine context. ProcessBuilder is reliable and redirects + // Doorstop's verbose stdout (MEMORY MAP + BIND_OPCODE dump) directly to a temp file — + // this avoids the 64KB pipe buffer deadlock without needing a drain thread, and + // preserves the output for diagnostics. + val gameProcess: Process? = if (OS.CURRENT == OS.macOS || OS.CURRENT == OS.Linux) { + val bashScriptPath = "${Path(rimworldLocation).parent}/run.sh" + withContext(Dispatchers.IO) { + val logFile = File(System.getProperty("java.io.tmpdir"), "rimworld-doorstop.log") + ProcessBuilder("/bin/sh", bashScriptPath, rimworldLocation) + .redirectErrorStream(true) + .redirectOutput(logFile) + .start() + } + } else { + // Windows: rimworldState handles env vars and process lifecycle normally. + rimworldState.execute(executor, runner)?.processHandler?.getProcess() + } - return result - } + // Poll until the Mono debug server is reachable. With debug_suspend=true in run.sh, + // the port stays open indefinitely once the game starts — no race condition. + if (!waitForMonoDebugServer()) { + throw ExecutionException( + "Timed out waiting for Mono debug server on port 56000. " + + "The game process may have failed to start, or Doorstop may not have injected. " + + "Check that run.sh is executable and that libdoorstop.dylib is present in the game directory." + ) + } - private fun createProcessListener(siblingProcessHandler: ProcessHandler?): ProcessListener { - return object : ProcessAdapter() { + val result = super.execute(executor, runner, workerProcessHandler) + ProcessTerminatedListener.attach(workerProcessHandler.debuggerWorkerRealHandler) + workerProcessHandler.debuggerWorkerRealHandler.addProcessListener(object : ProcessAdapter() { override fun processTerminated(event: ProcessEvent) { - val processHandler = event.processHandler - processHandler.removeProcessListener(this) - + event.processHandler.removeProcessListener(this) if (OS.CURRENT == OS.Linux) { - siblingProcessHandler?.getProcess()?.destroyForcibly() + gameProcess?.destroyForcibly() } else { - siblingProcessHandler?.getProcess()?.destroy() + gameProcess?.destroy() } - QuickStartUtils.tearDown(saveFilePath) removeDoorstep() } + }) + + return result + } + + private suspend fun waitForMonoDebugServer(port: Int = 56000, timeoutMs: Long = 60_000): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val isListening = withContext(Dispatchers.IO) { + try { + // Bind to 127.0.0.1 explicitly — the same address Mono uses. + // On macOS, 0.0.0.0 and 127.0.0.1 are distinct bind targets, so + // ServerSocket(port) (which binds 0.0.0.0) would NOT throw BindException + // even when Mono is already listening on 127.0.0.1:port. + ServerSocket().use { it.bind(InetSocketAddress("127.0.0.1", port)) } + false // bound successfully — nothing listening yet + } catch (_: BindException) { + true // address in use — Mono debug server is up + } catch (_: Exception) { + false + } + } + if (isListening) return true + delay(500) } + return false } private fun setupDoorstop() { diff --git a/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/HotReload.dll b/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/HotReload.dll new file mode 100644 index 0000000..89dd8dc Binary files /dev/null and b/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/HotReload.dll differ diff --git a/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/dnlib.dll b/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/dnlib.dll new file mode 100644 index 0000000..76a7b32 Binary files /dev/null and b/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/dnlib.dll differ diff --git a/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/libdoorstop.dylib b/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/libdoorstop.dylib new file mode 100644 index 0000000..febb487 Binary files /dev/null and b/src/rider/main/resources/UnityDoorstop/macOS/Doorstop/libdoorstop.dylib differ diff --git a/src/rider/main/resources/UnityDoorstop/macOS/doorstop_config.ini b/src/rider/main/resources/UnityDoorstop/macOS/doorstop_config.ini index 462bb6d..1a52572 100644 --- a/src/rider/main/resources/UnityDoorstop/macOS/doorstop_config.ini +++ b/src/rider/main/resources/UnityDoorstop/macOS/doorstop_config.ini @@ -6,7 +6,7 @@ enabled=true # Path to the assembly to load and execute # NOTE: The entrypoint must be of format `static void Doorstop.Entrypoint.Start()` -target_assembly=Doorstop\Doorstop.dll +target_assembly=Doorstop/Doorstop.dll # If true, Unity's output log is redirected to \output_log.txt redirect_output_log=false @@ -33,7 +33,7 @@ debug_enabled=true debug_address=127.0.0.1:56000 # If true and debug_enabled is true, Mono debugger server will suspend the game execution until a debugger is attached -debug_suspend=false +debug_suspend=true # Options sepcific to running under Il2Cpp runtime [Il2Cpp] diff --git a/src/rider/main/resources/UnityDoorstop/macOS/run.sh b/src/rider/main/resources/UnityDoorstop/macOS/run.sh new file mode 100755 index 0000000..fa73512 --- /dev/null +++ b/src/rider/main/resources/UnityDoorstop/macOS/run.sh @@ -0,0 +1,290 @@ +#!/bin/sh +# Doorstop start script +# +# Run the script to start the game with Doorstop enabled +# +# There are two ways to use this script +# +# 1. Via CLI: Run ./run.sh [doorstop arguments] [game arguments] +# 2. Via config: edit the options below and run ./run.sh without any arguments + +# 0 is false, 1 is true + +# LINUX: name of Unity executable +# MACOS: name of the .app directory +executable_name="RimWorldLinux" + +# All of the below can be overriden with command line args + +# General Config Options + +# Enable Doorstop? +enabled="1" + +# Path to the assembly to load and execute +# NOTE: The entrypoint must be of format `static void Doorstop.Entrypoint.Start()` +target_assembly="Doorstop.dll" + +# Overrides the default boot.config file path +boot_config_override= + +# If enabled, DOORSTOP_DISABLE env var value is ignored +# USE THIS ONLY WHEN ASKED TO OR YOU KNOW WHAT THIS MEANS +ignore_disable_switch="0" + +# Mono Options + +# Overrides default Mono DLL search path +# Sometimes it is needed to instruct Mono to seek its assemblies from a different path +# (e.g. mscorlib is stripped in original game) +# This option causes Mono to seek mscorlib and core libraries from a different folder before Managed +# Original Managed folder is added as a secondary folder in the search path +# To specify multiple paths, separate them with colons (:) +dll_search_path_override="" + +# If 1, Mono debugger server will be enabled +debug_enable="1" + +# When debug_enabled is 1, specifies the address to use for the debugger server +debug_address="127.0.0.1:56000" + +# If 1 and debug_enabled is 1, Mono debugger server will suspend the game execution until a debugger is attached +debug_suspend="1" + +# CoreCLR options (IL2CPP) + +# Path to coreclr shared library WITHOUT THE EXTENSION that contains the CoreCLR runtime +coreclr_path="" + +# Path to the directory containing the managed core libraries for CoreCLR (mscorlib, System, etc.) +corlib_dir="" + +################################################################################ +# Everything past this point is the actual script + +# Special case: program is launched via Steam +# In that case rerun the script via their bootstrapper to ensure Steam overlay works +if [ "$2" = "SteamLaunch" ]; then + steam="$1 $2 $3 $4 $0 $5" + shift 5 + $steam "$@" + exit +fi + +# Handle first param being executable name +if [ -x "$1" ] ; then + executable_name="$1" + echo "Target executable: $1" + shift +fi + +if [ -z "${executable_name}" ] || [ ! -x "${executable_name}" ]; then + echo "Please set executable_name to a valid name in a text editor or as the first command line parameter" + exit 1 +fi + +# Use POSIX-compatible way to get the directory of the executable +a="/$0"; a=${a%/*}; a=${a#/}; a=${a:-.}; BASEDIR=$(cd "$a" || exit; pwd -P) + +arch="" +executable_path="" +lib_extension="" + +# Set executable path and the extension to use for the libdoorstop shared object +os_type="$(uname -s)" +case ${os_type} in + Linux*) + executable_path="${executable_name}" + # Handle relative paths + if ! echo "$executable_path" | grep "^/.*$"; then + executable_path="${BASEDIR}/${executable_path}" + fi + lib_extension="so" + ;; + Darwin*) + real_executable_name="${executable_name}" + + # Handle relative directories + if ! echo "$real_executable_name" | grep "^/.*$"; then + real_executable_name="${BASEDIR}/${real_executable_name}" + fi + + # If we're not even an actual executable, check .app Info for actual executable + if ! echo "$real_executable_name" | grep "^.*\.app/Contents/MacOS/.*"; then + # Add .app to the end if not given + if ! echo "$real_executable_name" | grep "^.*\.app$"; then + real_executable_name="${real_executable_name}.app" + fi + inner_executable_name=$(defaults read "${real_executable_name}/Contents/Info" CFBundleExecutable) + executable_path="${real_executable_name}/Contents/MacOS/${inner_executable_name}" + else + executable_path="${executable_name}" + fi + lib_extension="dylib" + ;; + *) + # alright whos running games on freebsd + echo "Unknown operating system ($(uname -s))" + echo "Make an issue at https://github.com/NeighTools/UnityDoorstop" + exit 1 + ;; +esac + +abs_path() { + echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +} + +_readlink() { + # relative links with readlink (without -f) do not preserve the path info + ab_path="$(abs_path "$1")" + link="$(readlink "${ab_path}")" + case $link in + /*);; + *) link="$(dirname "$ab_path")/$link";; + esac + echo "$link" +} + + +resolve_executable_path () { + e_path="$(abs_path "$1")" + + while [ -L "${e_path}" ]; do + e_path=$(_readlink "${e_path}"); + done + echo "${e_path}" +} + +# Get absolute path of executable and show to user +executable_path=$(resolve_executable_path "${executable_path}") +echo "${executable_path}" + +# Figure out the arch of the executable with file +file_out="$(LD_PRELOAD="" file -b "${executable_path}")" +case "${file_out}" in + *64-bit*) + arch="x64" + ;; + *32-bit*) + arch="x86" + ;; + *) + echo "The executable \"${executable_path}\" is not compiled for x86 or x64 (might be ARM?)" + echo "If you think this is a mistake (or would like to encourage support for other architectures)" + echo "Please make an issue at https://github.com/NeighTools/UnityDoorstop" + echo "Got: ${file_out}" + exit 1 + ;; +esac + +# Helper to convert common boolean strings into just 0 and 1 +doorstop_bool() { + case "$1" in + TRUE|true|t|T|1|Y|y|yes) + echo "1" + ;; + FALSE|false|f|F|0|N|n|no) + echo "0" + ;; + esac +} + +# Read from command line +while :; do + case "$1" in + --doorstop_enabled) # For backwards compatibility. Renamed to --doorstop-enabled + enabled="$(doorstop_bool "$2")" + shift + ;; + --doorstop_target_assembly) # For backwards compatibility. Renamed to --doorstop-target-assembly + target_assembly="$2" + shift + ;; + --doorstop-enabled) + enabled="$(doorstop_bool "$2")" + shift + ;; + --doorstop-target-assembly) + target_assembly="$2" + shift + ;; + --doorstop-boot-config-override) + boot_config_override="$2" + shift + ;; + --doorstop-mono-dll-search-path-override) + dll_search_path_override="$2" + shift + ;; + --doorstop-mono-debug-enabled) + debug_enable="$(doorstop_bool "$2")" + shift + ;; + --doorstop-mono-debug-suspend) + debug_suspend="$(doorstop_bool "$2")" + shift + ;; + --doorstop-mono-debug-address) + debug_address="$2" + shift + ;; + --doorstop-clr-runtime-coreclr-path) + coreclr_path="$2" + shift + ;; + --doorstop-clr-corlib-dir) + corlib_dir="$2" + shift + ;; + *) + if [ -z "$1" ]; then + break + fi + rest_args="$rest_args $1" + ;; + esac + shift +done + +# Move variables to environment +export DOORSTOP_ENABLED="$enabled" +export DOORSTOP_TARGET_ASSEMBLY="$target_assembly" +export DOORSTOP_BOOT_CONFIG_OVERRIDE="$boot_config_override" +export DOORSTOP_IGNORE_DISABLED_ENV="$ignore_disable_switch" +export DOORSTOP_MONO_DLL_SEARCH_PATH_OVERRIDE="$dll_search_path_override" +export DOORSTOP_MONO_DEBUG_ENABLED="$debug_enable" +export DOORSTOP_MONO_DEBUG_ADDRESS="$debug_address" +export DOORSTOP_MONO_DEBUG_SUSPEND="$debug_suspend" +export DOORSTOP_CLR_RUNTIME_CORECLR_PATH="$coreclr_path.$lib_extension" +export DOORSTOP_CLR_CORLIB_DIR="$corlib_dir" + +# Final setup +doorstop_directory="${BASEDIR}/Doorstop/" +doorstop_name="libdoorstop.${lib_extension}" + +export LD_LIBRARY_PATH="${doorstop_directory}:${corlib_dir}:${LD_LIBRARY_PATH}" +if [ -z "$LD_PRELOAD" ]; then + export LD_PRELOAD="${doorstop_name}" +else + export LD_PRELOAD="${doorstop_name}:${LD_PRELOAD}" +fi + +export DYLD_LIBRARY_PATH="${doorstop_directory}:${DYLD_LIBRARY_PATH}" +if [ -z "$DYLD_INSERT_LIBRARIES" ]; then + export DYLD_INSERT_LIBRARIES="${doorstop_name}" +else + export DYLD_INSERT_LIBRARIES="${doorstop_name}:${DYLD_INSERT_LIBRARIES}" +fi + +echo "[Doorstop] Launching: $executable_path" +echo "[Doorstop] DYLD_INSERT_LIBRARIES=$DYLD_INSERT_LIBRARIES" +echo "[Doorstop] DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH" +echo "[Doorstop] DOORSTOP_MONO_DEBUG_ENABLED=$DOORSTOP_MONO_DEBUG_ENABLED" +echo "[Doorstop] DOORSTOP_MONO_DEBUG_ADDRESS=$DOORSTOP_MONO_DEBUG_ADDRESS" +echo "[Doorstop] DOORSTOP_MONO_DEBUG_SUSPEND=$DOORSTOP_MONO_DEBUG_SUSPEND" +echo "[Doorstop] Checking libdoorstop exists: $(ls ${doorstop_directory}libdoorstop.dylib 2>&1)" +echo "[Doorstop] Checking codesign entitlements:" +codesign -d --entitlements - "$executable_path" 2>&1 | grep -E "dyld|hardened|cs\." || echo "(no relevant entitlements / not signed)" + +# shellcheck disable=SC2086 +exec "$executable_path" $rest_args