mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -78,12 +78,14 @@ public static class StartupFlow
|
||||
new StartupStep(isLocalNetwork ? "Find your local network address" : "Set up Cloudflare tunnel"),
|
||||
new StartupStep("Start the server"),
|
||||
new StartupStep("Download client patcher"),
|
||||
new StartupStep("Unlock Rec Room from Steam (if needed)"),
|
||||
new StartupStep("Patch your Rec Room install"),
|
||||
};
|
||||
|
||||
public static ObservableCollection<StartupStep> NewJoinFlow() => new()
|
||||
{
|
||||
new StartupStep("Download client patcher"),
|
||||
new StartupStep("Unlock Rec Room from Steam (if needed)"),
|
||||
new StartupStep("Patch your Rec Room install"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,6 +546,18 @@ public partial class MainWindow : Window
|
||||
CompleteStep(activeStep);
|
||||
|
||||
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(
|
||||
patcherDir, _state.RecRoomPath!,
|
||||
_state.PhotonAppId, _state.PhotonVoiceAppId, _state.PhotonRegion, _hostApex);
|
||||
@@ -749,6 +761,18 @@ public partial class MainWindow : Window
|
||||
CompleteStep(activeStep);
|
||||
|
||||
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(
|
||||
patcherDir, _state.RecRoomPath!,
|
||||
payload.PhotonAppId, payload.PhotonVoiceAppId, payload.PhotonRegion, payload.Host);
|
||||
|
||||
Reference in New Issue
Block a user