diff --git a/launcher/Backend/StartupStep.cs b/launcher/Backend/StartupStep.cs index 1d81631..b97ca17 100644 --- a/launcher/Backend/StartupStep.cs +++ b/launcher/Backend/StartupStep.cs @@ -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 NewJoinFlow() => new() { new StartupStep("Download client patcher"), + new StartupStep("Unlock Rec Room from Steam (if needed)"), new StartupStep("Patch your Rec Room install"), }; } diff --git a/launcher/Backend/SteamDrmStripper.cs b/launcher/Backend/SteamDrmStripper.cs new file mode 100644 index 0000000..bbb91e5 --- /dev/null +++ b/launcher/Backend/SteamDrmStripper.cs @@ -0,0 +1,140 @@ +using System.Diagnostics; +using System.IO; + +namespace DorkNet.Launcher.Backend; + +/// Removes the SteamStub DRM wrapper from +/// Recroom_Release.exe 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). +/// +/// Detection is a PE-section walk: SteamStub Variant 3.1 +/// (the variant used for the 2020 Rec Room build) adds a .bind +/// section that contains the encrypted entry-point stub. The presence +/// of .bind + the standard packed-PE signature is a reliable +/// "needs Steamless" indicator without launching Steamless itself. +/// +/// When stripping: the original exe is renamed to +/// Recroom_Release.exe.steamwrapped as a backup, and +/// Steamless's .unpacked.exe output is renamed in its place. +/// Re-runs detect the (now-unpacked) exe and skip. +public static class SteamDrmStripper +{ + /// Strip Steam DRM from the Rec Room exe in + /// 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. *_Data's parent, + /// which contains Recroom_Release.exe). + public static async Task StripIfNeededAsync( + string recRoomDir, + IProgress? 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; + } + + /// Walk PE section headers looking for a .bind + /// 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. + 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 +{ + /// No Recroom_Release.exe at the expected path — + /// caller should treat this as a soft skip (Oculus install + /// shouldn't fail just because the exe name is different). + NotApplicable, + + /// The exe was already unwrapped (no .bind section). No + /// work done. Common on second runs and on non-Steam installs. + AlreadyUnpacked, + + /// SteamStub was detected and Steamless successfully + /// produced an unwrapped binary. Original backed up to + /// Recroom_Release.exe.steamwrapped. + Stripped, +} diff --git a/launcher/Backend/SteamlessInstaller.cs b/launcher/Backend/SteamlessInstaller.cs new file mode 100644 index 0000000..9bd7dc4 --- /dev/null +++ b/launcher/Backend/SteamlessInstaller.cs @@ -0,0 +1,107 @@ +using System.IO; +using System.IO.Compression; +using System.Net.Http; + +namespace DorkNet.Launcher.Backend; + +/// Resolves a usable Steamless.CLI.exe: prefers a +/// side-by-side install, falls back to the cached copy under +/// %LOCALAPPDATA%\DorkNet\Steamless\, otherwise downloads the +/// pinned release zip from atom0s/Steamless on GitHub. +/// +/// Steamless strips the SteamStub Variant 3.1 DRM wrapper from +/// Rec Room's Recroom_Release.exe 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 +/// and only invoke Steamless when the +/// .bind section is actually present. +/// +/// 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. +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 EnsureAsync( + IProgress? 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? 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; + } + } +} diff --git a/launcher/MainWindow.xaml.cs b/launcher/MainWindow.xaml.cs index b081528..9df6c2b 100644 --- a/launcher/MainWindow.xaml.cs +++ b/launcher/MainWindow.xaml.cs @@ -546,6 +546,18 @@ public partial class MainWindow : Window CompleteStep(activeStep); activeStep = StartStep(_hostSteps, 4); + var stripProgress = new Progress(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(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);