From b155ae5dbe5f1a345c6cd7fb25422e9fe28fd590 Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Wed, 27 May 2026 00:55:22 +0300
Subject: [PATCH] launcher: Rec-Room-styled UI; PowerShell-free patcher; Launch
button
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
UI redesign:
- Warm coral + teal palette over deep purple-navy (closer to Rec Room's
marketing aesthetic). New wordmark gradient (coral -> pink -> teal).
- Chunky 18px rounded cards on the first-run wizard with per-mode
accent tinting (coral for host, teal for join) and clear CTAs.
- Form fields wrapped in elevated panels with sectional titles + pill
status chips in the header (HOST MODE / JOIN MODE / SETTINGS).
- Drop-shadow effects on primary buttons + cards for depth.
Patcher rewrite:
- ClientPatcher.cs no longer shells out to PowerShell; the launcher
unzips MelonLoader, copies the DorkNet plugin DLL, and renders the
user-data config template — all in C# in-process.
- Patcher zip now driven by a manifest.json at root (schema v1).
Per-version branches publish: manifest.json + MelonLoader.zip +
DorkNet.ClientMod.dll + config template.
- RELEASES.md updated with the new MelonLoader manifest contract and
the placeholder substitution rules.
Rec Room launch path:
- New Backend/RecRoomLauncher.cs runs Recroom_Release.exe directly
from the picked install folder's parent dir (no Steam).
- 'Launch Rec Room' button appears after a successful patch in both
host + join modes.
---
launcher/App.xaml | 203 +++++++--
launcher/Backend/ClientPatcher.cs | 213 +++++++--
launcher/Backend/RecRoomLauncher.cs | 51 +++
launcher/MainWindow.xaml | 657 ++++++++++++++++------------
launcher/MainWindow.xaml.cs | 16 +-
launcher/RELEASES.md | 107 +++--
6 files changed, 864 insertions(+), 383 deletions(-)
create mode 100644 launcher/Backend/RecRoomLauncher.cs
diff --git a/launcher/App.xaml b/launcher/App.xaml
index 1127dfd..002d4c0 100644
--- a/launcher/App.xaml
+++ b/launcher/App.xaml
@@ -4,47 +4,118 @@
StartupUri="MainWindow.xaml">
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
diff --git a/launcher/Backend/ClientPatcher.cs b/launcher/Backend/ClientPatcher.cs
index 6832c0c..c4285f0 100644
--- a/launcher/Backend/ClientPatcher.cs
+++ b/launcher/Backend/ClientPatcher.cs
@@ -1,67 +1,192 @@
using System.IO;
-using System.Diagnostics;
+using System.IO.Compression;
+using System.Text.Json;
+using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
-/// Invokes the per-version branch's install-plugin.ps1
-/// (or install-legacy-client.ps1 as a fallback) on the user's
-/// Rec Room install. The patcher zip is unpacked by
-/// first; we just
-/// run the script from inside that unpacked tree.
+/// Applies a DorkNet client patch to the user's Rec Room
+/// install entirely in-process — no PowerShell scripts. The patcher
+/// zip fetched from a per-version branch's release MUST contain a
+/// manifest.json at root describing what to do; the launcher
+/// reads it and performs each step in C#.
+///
+/// DorkNet ships against MelonLoader. The manifest's
+/// default paths target a MelonLoader install layout; per-version
+/// branches can override any path explicitly.
+///
+/// Manifest schema (v1):
+///
+/// {
+/// "$schema_version": 1,
+/// "loader_archive": "MelonLoader.zip", // optional — unzipped into <recroom-root>\
+/// "plugin_dll": "DorkNet.ClientMod.dll", // required — copied to plugin_dest
+/// "plugin_dest": "MelonLoader/Mods", // optional — defaults to "MelonLoader/Mods"
+/// "config_template": "dorknet-clientmod.json.template", // optional — rendered with {HOST}/{PHOTON_APPID}/{PHOTON_VOICE_APPID}
+/// "config_dest": "MelonLoader/UserData/dorknet-clientmod.json", // optional — defaults shown
+/// "old_plugin_paths": ["BepInEx/plugins/DorkNet.ClientPatch.dll"] // optional — deleted before install
+/// }
+///
+/// Config template placeholders: {HOST}, {PHOTON_APPID},
+/// {PHOTON_VOICE_APPID}. Anything else passes through unchanged.
public sealed class ClientPatcher
{
- public async Task ApplyAsync(
+ private const string DefaultPluginDest = "MelonLoader/Mods";
+ private const string DefaultConfigDest = "MelonLoader/UserData/dorknet-clientmod.json";
+
+ public Task ApplyAsync(
string patcherDir,
- string recRoomPath,
+ string recRoomDataPath,
string photonAppId,
string photonVoiceAppId,
string apexHost,
CancellationToken ct = default)
{
- var script = Path.Combine(patcherDir, "install-plugin.ps1");
- if (!File.Exists(script))
+ return Task.Run(() => ApplyCore(patcherDir, recRoomDataPath,
+ photonAppId, photonVoiceAppId, apexHost), ct);
+ }
+
+ private PatchResult ApplyCore(
+ string patcherDir,
+ string recRoomDataPath,
+ string photonAppId,
+ string photonVoiceAppId,
+ string apexHost)
+ {
+ var log = new System.Text.StringBuilder();
+ try
{
- // Some branches may ship only the legacy installer.
- script = Path.Combine(patcherDir, "install-legacy-client.ps1");
- if (!File.Exists(script))
- {
+ if (string.IsNullOrEmpty(recRoomDataPath))
+ return PatchResult.Failure("Rec Room install path is empty.");
+ var recRoomRoot = Path.GetDirectoryName(recRoomDataPath.TrimEnd(Path.DirectorySeparatorChar))
+ ?? throw new InvalidOperationException("Couldn't resolve parent dir of " + recRoomDataPath);
+ if (!Directory.Exists(recRoomRoot))
+ return PatchResult.Failure($"Rec Room root does not exist: {recRoomRoot}");
+
+ var manifestPath = Path.Combine(patcherDir, "manifest.json");
+ if (!File.Exists(manifestPath))
return PatchResult.Failure(
- "Patcher zip is missing install-plugin.ps1 and install-legacy-client.ps1. " +
- "Open an issue with the version key + the contents of " +
- $"{patcherDir}.");
+ $"Patcher zip is missing manifest.json — the per-version " +
+ $"branch's release is on an older PowerShell-based format. " +
+ $"Looked in: {patcherDir}");
+ var manifest = JsonSerializer.Deserialize(
+ File.ReadAllText(manifestPath),
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ if (manifest is null)
+ return PatchResult.Failure("manifest.json parse failed.");
+
+ log.AppendLine($"[patch] manifest schema v{manifest.SchemaVersion}");
+ log.AppendLine($"[patch] target: {recRoomRoot}");
+
+ // 1. Unpack MelonLoader (or whatever loader the branch chose).
+ if (!string.IsNullOrEmpty(manifest.LoaderArchive))
+ {
+ var zipPath = Path.Combine(patcherDir, manifest.LoaderArchive);
+ if (!File.Exists(zipPath))
+ return PatchResult.Failure($"Loader archive listed in manifest but missing on disk: {zipPath}");
+ log.AppendLine($"[patch] extracting loader from {Path.GetFileName(zipPath)} → {recRoomRoot}");
+ ExtractZipOverwrite(zipPath, recRoomRoot);
}
+ else
+ {
+ log.AppendLine("[patch] no loader archive in manifest — assuming user already has MelonLoader installed");
+ }
+
+ // 2. Remove stale plugin paths (e.g. a previous BepInEx-era DLL).
+ foreach (var old in manifest.OldPluginPaths ?? Array.Empty())
+ {
+ var stale = Path.Combine(recRoomRoot, old.Replace('/', Path.DirectorySeparatorChar));
+ if (File.Exists(stale))
+ {
+ File.Delete(stale);
+ log.AppendLine($"[patch] removed stale plugin: {old}");
+ }
+ }
+
+ // 3. Copy the DorkNet plugin DLL.
+ if (string.IsNullOrEmpty(manifest.PluginDll))
+ return PatchResult.Failure("manifest.plugin_dll is required.");
+ var dllSrc = Path.Combine(patcherDir, manifest.PluginDll);
+ if (!File.Exists(dllSrc))
+ return PatchResult.Failure($"plugin DLL listed in manifest but missing on disk: {dllSrc}");
+ var pluginDestDir = Path.Combine(recRoomRoot,
+ (manifest.PluginDest ?? DefaultPluginDest).Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(pluginDestDir);
+ var dllDest = Path.Combine(pluginDestDir, Path.GetFileName(manifest.PluginDll));
+ File.Copy(dllSrc, dllDest, overwrite: true);
+ log.AppendLine($"[patch] installed plugin: {Path.GetFileName(manifest.PluginDll)} → {pluginDestDir}");
+
+ // 4. Render the config template.
+ if (!string.IsNullOrEmpty(manifest.ConfigTemplate))
+ {
+ var templatePath = Path.Combine(patcherDir, manifest.ConfigTemplate);
+ if (!File.Exists(templatePath))
+ return PatchResult.Failure($"config template listed in manifest but missing: {templatePath}");
+
+ var configDestRel = (manifest.ConfigDest ?? DefaultConfigDest)
+ .Replace('/', Path.DirectorySeparatorChar);
+ var configDest = Path.Combine(recRoomRoot, configDestRel);
+ var configDestDir = Path.GetDirectoryName(configDest);
+ if (!string.IsNullOrEmpty(configDestDir)) Directory.CreateDirectory(configDestDir);
+
+ var rendered = File.ReadAllText(templatePath)
+ .Replace("{HOST}", apexHost ?? "")
+ .Replace("{PHOTON_APPID}", photonAppId ?? "")
+ .Replace("{PHOTON_VOICE_APPID}",
+ string.IsNullOrEmpty(photonVoiceAppId) ? photonAppId ?? "" : photonVoiceAppId);
+ File.WriteAllText(configDest, rendered);
+ log.AppendLine($"[patch] wrote config: {Path.GetFileName(configDest)}");
+ }
+
+ log.AppendLine("[patch] done.");
+ return PatchResult.Success(log.ToString());
}
-
- var psi = new ProcessStartInfo
+ catch (Exception ex)
{
- FileName = "powershell.exe",
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- WorkingDirectory = patcherDir,
- };
- psi.ArgumentList.Add("-NoProfile");
- psi.ArgumentList.Add("-ExecutionPolicy"); psi.ArgumentList.Add("Bypass");
- psi.ArgumentList.Add("-File"); psi.ArgumentList.Add(script);
- psi.ArgumentList.Add("-RecRoomPath"); psi.ArgumentList.Add(recRoomPath);
- psi.ArgumentList.Add("-PhotonAppId"); psi.ArgumentList.Add(photonAppId);
- psi.ArgumentList.Add("-PhotonVoiceAppId");
- psi.ArgumentList.Add(string.IsNullOrEmpty(photonVoiceAppId) ? photonAppId : photonVoiceAppId);
- if (!string.IsNullOrEmpty(apexHost))
- {
- psi.ArgumentList.Add("-ServerHost"); psi.ArgumentList.Add(apexHost);
+ log.AppendLine();
+ log.AppendLine("[patch] FAILED: " + ex.Message);
+ return PatchResult.Failure(log.ToString());
}
+ }
- using var p = Process.Start(psi)
- ?? throw new InvalidOperationException("Failed to spawn powershell.exe");
- var stdout = await p.StandardOutput.ReadToEndAsync(ct);
- var stderr = await p.StandardError.ReadToEndAsync(ct);
- await p.WaitForExitAsync(ct);
+ /// Extracts a zip on top of an existing directory, replacing
+ /// any files that collide.
+ /// throws on existing files; we walk entries ourselves. Also guards
+ /// against zip-slip path-traversal entries (file names that try to
+ /// escape destRoot via ../).
+ private static void ExtractZipOverwrite(string zipPath, string destRoot)
+ {
+ var destFull = Path.GetFullPath(destRoot);
+ using var zip = ZipFile.OpenRead(zipPath);
+ foreach (var entry in zip.Entries)
+ {
+ var safeName = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
+ var fullDest = Path.GetFullPath(Path.Combine(destRoot, safeName));
+ if (!fullDest.StartsWith(destFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
+ && fullDest != destFull)
+ throw new InvalidOperationException(
+ $"Refusing to extract '{entry.FullName}' — path escapes destination root.");
- return p.ExitCode == 0
- ? PatchResult.Success(stdout)
- : PatchResult.Failure(string.IsNullOrEmpty(stderr) ? stdout : stderr);
+ if (string.IsNullOrEmpty(entry.Name))
+ {
+ Directory.CreateDirectory(fullDest);
+ continue;
+ }
+ var dir = Path.GetDirectoryName(fullDest);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ entry.ExtractToFile(fullDest, overwrite: true);
+ }
+ }
+
+ private sealed class PatcherManifest
+ {
+ [JsonPropertyName("$schema_version")] public int SchemaVersion { get; set; }
+ [JsonPropertyName("loader_archive")] public string? LoaderArchive { get; set; }
+ [JsonPropertyName("plugin_dll")] public string? PluginDll { get; set; }
+ [JsonPropertyName("plugin_dest")] public string? PluginDest { get; set; }
+ [JsonPropertyName("config_template")] public string? ConfigTemplate { get; set; }
+ [JsonPropertyName("config_dest")] public string? ConfigDest { get; set; }
+ [JsonPropertyName("old_plugin_paths")] public string[]? OldPluginPaths { get; set; }
}
}
diff --git a/launcher/Backend/RecRoomLauncher.cs b/launcher/Backend/RecRoomLauncher.cs
new file mode 100644
index 0000000..4f12d86
--- /dev/null
+++ b/launcher/Backend/RecRoomLauncher.cs
@@ -0,0 +1,51 @@
+using System.Diagnostics;
+using System.IO;
+
+namespace DorkNet.Launcher.Backend;
+
+/// Launches the user's already-patched Rec Room install
+/// directly via Recroom_Release.exe. Never goes through Steam —
+/// DorkNet users may not own the game on Steam.
+public static class RecRoomLauncher
+{
+ /// The picked install path is the Recroom_Release_Data
+ /// folder; the executable lives at <parent>\Recroom_Release.exe.
+ /// Returns true if Process.Start succeeded.
+ public static bool TryLaunch(string recRoomDataPath, out string error)
+ {
+ error = "";
+ if (string.IsNullOrEmpty(recRoomDataPath))
+ {
+ error = "No Rec Room install picked.";
+ return false;
+ }
+ var parent = Path.GetDirectoryName(recRoomDataPath.TrimEnd(Path.DirectorySeparatorChar));
+ if (string.IsNullOrEmpty(parent))
+ {
+ error = $"Couldn't resolve the parent directory of '{recRoomDataPath}'.";
+ return false;
+ }
+ var exe = Path.Combine(parent, "Recroom_Release.exe");
+ if (!File.Exists(exe))
+ {
+ error = $"Recroom_Release.exe not found at '{exe}'. " +
+ "Pick the *_Data folder that sits next to it.";
+ return false;
+ }
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = exe,
+ WorkingDirectory = parent,
+ UseShellExecute = true,
+ });
+ return true;
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ return false;
+ }
+ }
+}
diff --git a/launcher/MainWindow.xaml b/launcher/MainWindow.xaml
index 1b479d9..ba93d40 100644
--- a/launcher/MainWindow.xaml
+++ b/launcher/MainWindow.xaml
@@ -2,284 +2,405 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="DorkNet"
- Width="900" Height="640"
- MinWidth="640" MinHeight="480"
+ Width="960" Height="700"
+ MinWidth="720" MinHeight="540"
WindowStartupLocation="CenterScreen"
Background="{StaticResource Bg}"
TextElement.Foreground="{StaticResource Ink}"
- FontFamily="Segoe UI">
+ FontFamily="Segoe UI Variable, Segoe UI"
+ TextOptions.TextFormattingMode="Ideal"
+ TextOptions.TextRenderingMode="ClearType">
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ FontSize="16"
+ HorizontalAlignment="Center" Margin="0,6,0,0" />
-
-
-
- DorkNet Launcher v0.1 —
- GitHub
- — AGPL-3.0
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Photon Cloud powers in-room realtime sync. Free to register at
+ dashboard.photonengine.com;
+ DorkNet never bundles one.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DorkNet Launcher v0.1 ·
+ github.com/DorkSquadRR/DorkNet
+ · AGPL-3.0
+
+
+ Not affiliated with Rec Room Inc. or Against Gravity.
+
+
+
+
+
diff --git a/launcher/MainWindow.xaml.cs b/launcher/MainWindow.xaml.cs
index 81107f9..dde5d1c 100644
--- a/launcher/MainWindow.xaml.cs
+++ b/launcher/MainWindow.xaml.cs
@@ -238,7 +238,8 @@ public partial class MainWindow : Window
});
JoinCodeText.Text = code;
JoinCodePanel.Visibility = Visibility.Visible;
- HostStatusText.Text = $"Hosting at {publicUrl}";
+ HostStatusText.Text = $"Hosting at {publicUrl}. Share the join code below or launch Rec Room to test.";
+ HostLaunchBtn.Visibility = Visibility.Visible;
}
catch (Exception ex)
{
@@ -253,6 +254,7 @@ public partial class MainWindow : Window
await ShutdownServerAsync();
HostStartBtn.IsEnabled = true;
HostStopBtn.Visibility = Visibility.Collapsed;
+ HostLaunchBtn.Visibility = Visibility.Collapsed;
JoinCodePanel.Visibility = Visibility.Collapsed;
HostStatusText.Text = "Stopped.";
}
@@ -325,12 +327,22 @@ public partial class MainWindow : Window
payload.PhotonAppId, payload.PhotonVoiceAppId, payload.Host);
if (!patch.Ok) { ShowError("Patcher failed: " + Truncate(patch.Log, 500)); return; }
- JoinStatusText.Text = "Patched. Launch Rec Room from Steam to connect.";
+ JoinStatusText.Text = $"Patched and ready. Connecting to {payload.Host}. " +
+ "Click Launch Rec Room when you're ready to play.";
+ JoinLaunchBtn.Visibility = Visibility.Visible;
}
catch (Exception ex) { ShowError(ex.Message); }
finally { RefreshJoinReadiness(); }
}
+ private void OnLaunchRecRoom(object sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrEmpty(_state.RecRoomPath))
+ { ShowError("Pick your Rec Room install first."); return; }
+ if (!RecRoomLauncher.TryLaunch(_state.RecRoomPath, out var error))
+ ShowError(error);
+ }
+
// ── Helpers ─────────────────────────────────────────────────────────
private static string FormatProgress(string label, DownloadProgress p)
{
diff --git a/launcher/RELEASES.md b/launcher/RELEASES.md
index 8328e08..c858354 100644
--- a/launcher/RELEASES.md
+++ b/launcher/RELEASES.md
@@ -6,8 +6,8 @@ branch, then fetches release artifacts from each branch's GitHub
Releases.
Per-version branches must publish releases that follow this contract,
-otherwise the launcher reports "no release found" to users on host /
-join.
+otherwise the launcher reports "no release found" / "manifest missing"
+to users on host / join.
## Tag naming
@@ -26,12 +26,10 @@ Examples (matching the current `release_tag_prefix` values):
| `march-2020-03-10` | `v1-march` | `v1-march-2026.06.15` |
The launcher picks the most-recent release (by `published_at`) whose
-tag starts with the prefix, then looks for the asset names below.
+tag starts with the prefix, then looks for the two asset names below.
## Required assets
-Each release must attach these two files:
-
### 1. `dorknet-server-{version_key}-win-x64.zip`
A self-contained .NET 9 publish of `DorkNet.Server` for `win-x64`.
@@ -42,42 +40,93 @@ Build with:
```pwsh
cd DorkNet.Server
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
-# Then zip the publish dir's contents (NOT the dir itself):
cd bin/Release/net9.0/win-x64/publish
Compress-Archive -Path * -DestinationPath ../../../../../dorknet-server-{version_key}-win-x64.zip
```
The zip's root must contain `DorkNet.Server.exe` (or any `.exe`; the
-launcher scans for it). Folder layout below that is preserved as-is.
+launcher scans for it).
### 2. `dorknet-clientpatch-{version_key}.zip`
-The contents of the per-version branch's `tools/` directory limited
-to the patcher scripts the launcher invokes:
+The MelonLoader-based client patcher, packed into a single zip with a
+**manifest.json** at the zip root telling the launcher what to do.
+The launcher performs every step in C# — no PowerShell scripts run.
-- `install-plugin.ps1` (preferred — BepInEx route)
-- `install-legacy-client.ps1` (fallback — direct byte-patch route)
-- Any helper scripts these reference
-- The compiled `DorkNet.ClientPatch.dll` if `install-plugin.ps1`
- expects a prebuilt DLL
-
-The launcher invokes the patcher script with these arguments:
+**Required layout inside the zip:**
```
-powershell -NoProfile -ExecutionPolicy Bypass -File install-plugin.ps1 `
- -RecRoomPath `
- -PhotonAppId `
- -PhotonVoiceAppId `
- -ServerHost
+manifest.json (described below)
+MelonLoader.zip (optional — the MelonLoader install tree)
+DorkNet.ClientMod.dll (required — the DorkNet MelonLoader plugin)
+dorknet-clientmod.json.template (optional — UserData config template)
```
-So the script must accept those four parameters. If your branch's
-patcher needs additional config, default to safe values.
+**`manifest.json` schema (v1):**
+
+```json
+{
+ "$schema_version": 1,
+ "loader_archive": "MelonLoader.zip",
+ "plugin_dll": "DorkNet.ClientMod.dll",
+ "plugin_dest": "MelonLoader/Mods",
+ "config_template": "dorknet-clientmod.json.template",
+ "config_dest": "MelonLoader/UserData/dorknet-clientmod.json",
+ "old_plugin_paths": [
+ "BepInEx/plugins/DorkNet.ClientPatch.dll"
+ ]
+}
+```
+
+All fields except `$schema_version` and `plugin_dll` are optional.
+Defaults:
+
+| Field | Default |
+|---|---|
+| `plugin_dest` | `MelonLoader/Mods` |
+| `config_dest` | `MelonLoader/UserData/dorknet-clientmod.json` |
+| `loader_archive` | (none — assumes user has MelonLoader installed) |
+| `config_template` | (none — no config written) |
+
+**`config_template` placeholders** rendered before write:
+
+| Placeholder | Source |
+|---|---|
+| `{HOST}` | Host's tunnel hostname (or join code's `host` field) |
+| `{PHOTON_APPID}` | Host's Photon Realtime AppId |
+| `{PHOTON_VOICE_APPID}` | Host's Photon Voice AppId (falls back to Realtime if empty) |
+
+Example template:
+
+```json
+{
+ "ServerHost": "{HOST}",
+ "PhotonAppId": "{PHOTON_APPID}",
+ "PhotonVoiceAppId": "{PHOTON_VOICE_APPID}"
+}
+```
+
+## What the launcher does on Apply
+
+Given the manifest above, the launcher does (in this order):
+
+1. Extracts `loader_archive` over the user's Rec Room install root
+ (the parent of `Recroom_Release_Data`). MelonLoader's `version.dll`
+ and `MelonLoader/` tree land in the right places.
+2. Deletes any `old_plugin_paths` (cleans up an old BepInEx-era DLL,
+ a previous plugin filename, etc.).
+3. Copies `plugin_dll` to `//`.
+4. Renders `config_template` (placeholder substitution) and writes
+ to `/`.
+
+No PowerShell, no separate Steamless invocation, no byte-level
+metadata patching in v1 — keep the patcher zip declarative. Future
+schema versions can add fields for those without breaking older
+launchers (they ignore unknown fields).
## Verifying a release before publishing
```pwsh
-# Locally simulate the launcher's fetch.
$tag = "v1-december-2026.06.15"
gh release view $tag --repo DorkSquadRR/DorkNet
gh release download $tag --repo DorkSquadRR/DorkNet `
@@ -85,12 +134,12 @@ gh release download $tag --repo DorkSquadRR/DorkNet `
--pattern "dorknet-clientpatch-december_2020_12_18.zip"
```
-Then unzip + boot the server locally to confirm it starts, and
-unzip + run the patcher against a clean Rec Room install to confirm
-patching works.
+Then unzip + inspect: the patcher zip should have `manifest.json` at
+root, the named DLL should exist, the `config_template` (if listed)
+should exist, etc.
## Future: GitHub Actions workflow
Each per-version branch should land a `.github/workflows/release.yml`
-that builds + zips + publishes the two artifacts on tag push. Not yet
-implemented; releases are manual for now.
+that builds + packages the artifacts on tag push. Not yet implemented;
+releases are manual for now.