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
38 changes: 38 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
}

Expand Down
41 changes: 21 additions & 20 deletions src/rider/main/kotlin/run/RunConfiguration.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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 <gamePath>" 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());
Expand Down
102 changes: 85 additions & 17 deletions src/rider/main/kotlin/run/RunState.kt
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
Expand All @@ -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() {
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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 <current folder>\output_log.txt
redirect_output_log=false
Expand All @@ -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]
Expand Down
Loading