Files
DorkNet/launcher/Backend/SteamlessInstaller.cs
T
Alexa 26786527cd 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.
2026-05-27 12:10:27 +03:00

108 lines
4.1 KiB
C#

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;
}
}
}