launcher: auto-strip SteamStub DRM before patching

Steam-purchased Rec Room ships with SteamStub Variant 3.1 wrapping
Recroom_Release.exe so it refuses to launch without Steam loaded.
DorkNet spawns the exe directly, so for Steam installs we have to
unwrap it first or the user gets a silent crash on Launch.

- SteamDrmStripper.StripIfNeededAsync: PE-walks the exe's section
  table looking for the .bind section SteamStub adds, and only
  invokes Steamless when that section is present. Re-runs detect
  the already-unwrapped exe and short-circuit. Backs up the
  original next to the exe as Recroom_Release.exe.steamwrapped.
- SteamlessInstaller: resolves a usable Steamless.CLI.exe via
  side-by-side -> %LOCALAPPDATA%\DorkNet\Steamless\ cache ->
  download v3.1.0.5 from atom0s/Steamless GitHub releases. Same
  pattern as CloudflaredInstaller.
- Inserted as a step between "Download client patcher" and "Patch
  your Rec Room install" in both host (5 -> 6 steps) and join
  (2 -> 3 steps). Step detail reports Stripped / AlreadyUnpacked /
  NotApplicable so the user sees what happened.

Oculus / Quest builds and already-unwrapped installs skip the
Steamless download entirely.
This commit is contained in:
Alexa
2026-05-27 12:10:27 +03:00
parent de98ea694f
commit 26786527cd
4 changed files with 273 additions and 0 deletions
+2
View File
@@ -78,12 +78,14 @@ public static class StartupFlow
new StartupStep(isLocalNetwork ? "Find your local network address" : "Set up Cloudflare tunnel"), new StartupStep(isLocalNetwork ? "Find your local network address" : "Set up Cloudflare tunnel"),
new StartupStep("Start the server"), new StartupStep("Start the server"),
new StartupStep("Download client patcher"), new StartupStep("Download client patcher"),
new StartupStep("Unlock Rec Room from Steam (if needed)"),
new StartupStep("Patch your Rec Room install"), new StartupStep("Patch your Rec Room install"),
}; };
public static ObservableCollection<StartupStep> NewJoinFlow() => new() public static ObservableCollection<StartupStep> NewJoinFlow() => new()
{ {
new StartupStep("Download client patcher"), new StartupStep("Download client patcher"),
new StartupStep("Unlock Rec Room from Steam (if needed)"),
new StartupStep("Patch your Rec Room install"), new StartupStep("Patch your Rec Room install"),
}; };
} }
+140
View File
@@ -0,0 +1,140 @@
using System.Diagnostics;
using System.IO;
namespace DorkNet.Launcher.Backend;
/// <summary>Removes the SteamStub DRM wrapper from
/// <c>Recroom_Release.exe</c> so the launcher can spawn it without
/// Steam loaded. No-op when the exe isn't packed (Oculus / Quest
/// builds, or installs already unwrapped by a prior run).
///
/// <para>Detection is a PE-section walk: SteamStub Variant 3.1
/// (the variant used for the 2020 Rec Room build) adds a <c>.bind</c>
/// section that contains the encrypted entry-point stub. The presence
/// of <c>.bind</c> + the standard packed-PE signature is a reliable
/// "needs Steamless" indicator without launching Steamless itself.</para>
///
/// <para>When stripping: the original exe is renamed to
/// <c>Recroom_Release.exe.steamwrapped</c> as a backup, and
/// Steamless's <c>.unpacked.exe</c> output is renamed in its place.
/// Re-runs detect the (now-unpacked) exe and skip.</para></summary>
public static class SteamDrmStripper
{
/// <summary>Strip Steam DRM from the Rec Room exe in
/// <paramref name="recRoomDir"/> if needed. Returns a result the
/// caller can use to skip/show progress steps. The Rec Room dir is
/// the same path the user picks (i.e. <c>*_Data</c>'s parent,
/// which contains <c>Recroom_Release.exe</c>).</summary>
public static async Task<StripResult> StripIfNeededAsync(
string recRoomDir,
IProgress<DownloadProgress>? downloadProgress = null,
CancellationToken ct = default)
{
// The user picks the *_Data folder; the .exe lives in its
// parent. RecRoomPicker.Validate ensures this layout exists
// before we ever get here.
var dataFolder = new DirectoryInfo(recRoomDir);
var installRoot = dataFolder.Parent?.FullName ?? recRoomDir;
var exe = Path.Combine(installRoot, "Recroom_Release.exe");
if (!File.Exists(exe)) return StripResult.NotApplicable;
if (!HasSteamStub(exe)) return StripResult.AlreadyUnpacked;
var steamlessCli = await SteamlessInstaller.EnsureAsync(downloadProgress, ct);
var unpacked = exe + ".unpacked.exe";
if (File.Exists(unpacked)) File.Delete(unpacked);
var psi = new ProcessStartInfo
{
FileName = steamlessCli,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = installRoot,
};
psi.ArgumentList.Add("--quiet");
psi.ArgumentList.Add(exe);
using var proc = Process.Start(psi) ??
throw new InvalidOperationException("Failed to spawn Steamless.CLI.exe");
await proc.WaitForExitAsync(ct);
if (!File.Exists(unpacked))
{
// Steamless prints its findings to stdout, so include that
// tail in the error to help users figure out what went wrong.
var stdout = await proc.StandardOutput.ReadToEndAsync(ct);
var stderr = await proc.StandardError.ReadToEndAsync(ct);
throw new InvalidOperationException(
"Steamless ran but produced no .unpacked.exe — install may not be the Steam build, " +
"or the SteamStub variant isn't supported.\n" +
$"stdout: {stdout.TrimEnd()}\nstderr: {stderr.TrimEnd()}");
}
var backup = exe + ".steamwrapped";
if (File.Exists(backup)) File.Delete(backup);
File.Move(exe, backup);
File.Move(unpacked, exe);
return StripResult.Stripped;
}
/// <summary>Walk PE section headers looking for a <c>.bind</c>
/// section, which SteamStub Variant 3.1 adds during packing.
/// Returns false on any read error — the patcher's later steps
/// will catch a malformed exe with a clearer message.</summary>
private static bool HasSteamStub(string exePath)
{
try
{
using var fs = File.OpenRead(exePath);
using var br = new BinaryReader(fs);
// DOS header at offset 0; PE-header offset stored at 0x3C.
fs.Position = 0x3C;
var peOffset = br.ReadInt32();
fs.Position = peOffset;
if (br.ReadUInt32() != 0x00004550) return false; // "PE\0\0"
// Skip COFF header (20 bytes) to get to the optional
// header; we need its size to find the section table.
fs.Position = peOffset + 4 + 2; // skip Machine
var sectionCount = br.ReadUInt16();
fs.Position += 12; // skip TimeStamp, PtrToSymTab, NumSyms
var optHeaderSize = br.ReadUInt16();
fs.Position += 2; // skip Characteristics
// Sections start right after the optional header.
fs.Position = peOffset + 4 + 20 + optHeaderSize;
// Each section header is 40 bytes, name is the first 8.
var nameBytes = new byte[8];
for (var i = 0; i < sectionCount; i++)
{
fs.Read(nameBytes, 0, 8);
var name = System.Text.Encoding.ASCII.GetString(nameBytes).TrimEnd('\0');
if (name == ".bind") return true;
fs.Position += 32; // rest of section header
}
return false;
}
catch { return false; }
}
}
public enum StripResult
{
/// <summary>No <c>Recroom_Release.exe</c> at the expected path —
/// caller should treat this as a soft skip (Oculus install
/// shouldn't fail just because the exe name is different).</summary>
NotApplicable,
/// <summary>The exe was already unwrapped (no .bind section). No
/// work done. Common on second runs and on non-Steam installs.</summary>
AlreadyUnpacked,
/// <summary>SteamStub was detected and Steamless successfully
/// produced an unwrapped binary. Original backed up to
/// <c>Recroom_Release.exe.steamwrapped</c>.</summary>
Stripped,
}
+107
View File
@@ -0,0 +1,107 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
namespace DorkNet.Launcher.Backend;
/// <summary>Resolves a usable <c>Steamless.CLI.exe</c>: prefers a
/// side-by-side install, falls back to the cached copy under
/// <c>%LOCALAPPDATA%\DorkNet\Steamless\</c>, otherwise downloads the
/// pinned release zip from atom0s/Steamless on GitHub.
///
/// <para>Steamless strips the SteamStub Variant 3.1 DRM wrapper from
/// Rec Room's <c>Recroom_Release.exe</c> so the launcher can run it
/// without Steam loaded. Rec Room users on Oculus or non-Steam builds
/// don't need this; we detect the wrapper presence in
/// <see cref="SteamDrmStripper"/> and only invoke Steamless when the
/// .bind section is actually present.</para>
///
/// <para>Pinned to v3.1.0.5 — current stable that handles 2020-era
/// Steam DRM. Newer versions are drop-in compatible but we pin so a
/// breaking upstream change doesn't surprise hosts.</para></summary>
public static class SteamlessInstaller
{
private const string PinnedVersion = "v3.1.0.5";
private const string ZipUrl =
"https://github.com/atom0s/Steamless/releases/download/" +
PinnedVersion + "/Steamless." + PinnedVersion + ".zip";
private static readonly HttpClient Http = new()
{
Timeout = TimeSpan.FromMinutes(5),
};
public static async Task<string> EnsureAsync(
IProgress<DownloadProgress>? progress = null,
CancellationToken ct = default)
{
if (TryFindLocal(out var existing)) return existing;
var cacheDir = Path.Combine(AppPaths.LocalRoot, "Steamless");
var zipPath = Path.Combine(AppPaths.LocalRoot, $"Steamless.{PinnedVersion}.zip");
Directory.CreateDirectory(AppPaths.LocalRoot);
await DownloadAsync(ZipUrl, zipPath, progress, ct);
if (!File.Exists(zipPath) || new FileInfo(zipPath).Length < 500_000)
throw new InvalidOperationException(
$"Steamless download produced an unexpectedly small file at {zipPath}. " +
"Check internet connection + retry.");
if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true);
Directory.CreateDirectory(cacheDir);
ZipFile.ExtractToDirectory(zipPath, cacheDir);
var cli = Path.Combine(cacheDir, "Steamless.CLI.exe");
if (!File.Exists(cli))
throw new InvalidOperationException(
$"Extracted Steamless is missing Steamless.CLI.exe at {cli} — release layout may have changed");
return cli;
}
private static bool TryFindLocal(out string path)
{
var sideBySide = Path.Combine(AppContext.BaseDirectory, "Steamless", "Steamless.CLI.exe");
if (File.Exists(sideBySide)) { path = sideBySide; return true; }
var cached = Path.Combine(AppPaths.LocalRoot, "Steamless", "Steamless.CLI.exe");
if (File.Exists(cached)) { path = cached; return true; }
path = string.Empty;
return false;
}
private static async Task DownloadAsync(
string url, string destPath,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
{
var temp = destPath + ".download";
try
{
using var resp = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
var total = resp.Content.Headers.ContentLength ?? -1L;
await using (var src = await resp.Content.ReadAsStreamAsync(ct))
await using (var dst = File.Create(temp))
{
var buf = new byte[81920];
long readTotal = 0;
int read;
while ((read = await src.ReadAsync(buf, ct)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, read), ct);
readTotal += read;
progress?.Report(new DownloadProgress(readTotal, total));
}
}
if (File.Exists(destPath)) File.Delete(destPath);
File.Move(temp, destPath);
}
catch
{
try { if (File.Exists(temp)) File.Delete(temp); } catch { }
throw;
}
}
}
+24
View File
@@ -546,6 +546,18 @@ public partial class MainWindow : Window
CompleteStep(activeStep); CompleteStep(activeStep);
activeStep = StartStep(_hostSteps, 4); activeStep = StartStep(_hostSteps, 4);
var stripProgress = new Progress<DownloadProgress>(p => UpdateStepProgress(activeStep, p));
var stripResult = await SteamDrmStripper.StripIfNeededAsync(
_state.RecRoomPath!, stripProgress);
activeStep.Detail = stripResult switch
{
StripResult.Stripped => "Steam DRM removed; backup saved next to the exe.",
StripResult.AlreadyUnpacked => "Already unwrapped — skipped.",
_ => "Not a Steam build — skipped.",
};
CompleteStep(activeStep);
activeStep = StartStep(_hostSteps, 5);
var patch = await _patcher.ApplyAsync( var patch = await _patcher.ApplyAsync(
patcherDir, _state.RecRoomPath!, patcherDir, _state.RecRoomPath!,
_state.PhotonAppId, _state.PhotonVoiceAppId, _state.PhotonRegion, _hostApex); _state.PhotonAppId, _state.PhotonVoiceAppId, _state.PhotonRegion, _hostApex);
@@ -749,6 +761,18 @@ public partial class MainWindow : Window
CompleteStep(activeStep); CompleteStep(activeStep);
activeStep = StartStep(_joinSteps, 1); activeStep = StartStep(_joinSteps, 1);
var stripProgress = new Progress<DownloadProgress>(p => UpdateStepProgress(activeStep, p));
var stripResult = await SteamDrmStripper.StripIfNeededAsync(
_state.RecRoomPath!, stripProgress);
activeStep.Detail = stripResult switch
{
StripResult.Stripped => "Steam DRM removed; backup saved next to the exe.",
StripResult.AlreadyUnpacked => "Already unwrapped — skipped.",
_ => "Not a Steam build — skipped.",
};
CompleteStep(activeStep);
activeStep = StartStep(_joinSteps, 2);
var patch = await _patcher.ApplyAsync( var patch = await _patcher.ApplyAsync(
patcherDir, _state.RecRoomPath!, patcherDir, _state.RecRoomPath!,
payload.PhotonAppId, payload.PhotonVoiceAppId, payload.PhotonRegion, payload.Host); payload.PhotonAppId, payload.PhotonVoiceAppId, payload.PhotonRegion, payload.Host);