diff --git a/src/KitsunePackRelayLauncher/KitsunePackRelayLauncher.csproj b/src/KitsunePackRelayLauncher/KitsunePackRelayLauncher.csproj
new file mode 100644
index 0000000..40aaef8
--- /dev/null
+++ b/src/KitsunePackRelayLauncher/KitsunePackRelayLauncher.csproj
@@ -0,0 +1,53 @@
+
+
+
+ net48
+ 11.0
+ KitsunePackRelayLauncher
+ KitsunePackRelayLauncher
+ Library
+ disable
+ disable
+ false
+ false
+
+
+
+
+
+ ..\KitsuneCommand\refs\Assembly-CSharp.dll
+ false
+
+
+ ..\KitsuneCommand\refs\Assembly-CSharp-firstpass.dll
+ false
+
+
+ ..\KitsuneCommand\refs\LogLibrary.dll
+ false
+
+
+ ..\KitsuneCommand\refs\UnityEngine.dll
+ false
+
+
+ ..\KitsuneCommand\refs\UnityEngine.CoreModule.dll
+ false
+
+
+ ..\KitsuneCommand\refs\0Harmony.dll
+ false
+
+
+
+
+
+
+
+
+
diff --git a/src/KitsunePackRelayLauncher/ModEntry.cs b/src/KitsunePackRelayLauncher/ModEntry.cs
new file mode 100644
index 0000000..8aa68f2
--- /dev/null
+++ b/src/KitsunePackRelayLauncher/ModEntry.cs
@@ -0,0 +1,46 @@
+using System;
+using HarmonyLib;
+
+namespace KitsunePackRelayLauncher
+{
+ ///
+ /// Mod entry — 7DTD calls at mod-load. We
+ /// install Harmony patches and bow out.
+ ///
+ /// On a dedicated server, the patches install fine but the
+ /// patched method (XUiC_MainMenu.OnOpen) only fires
+ /// client-side. So the mod is safe to ship in a pack that's
+ /// installed on both clients and the server -- it just no-ops
+ /// on the server.
+ ///
+ /// v0.1 ships the scaffold + a verified-but-stubbed hook on
+ /// XUiC_MainMenu.OnOpen that reads the sentinel file and logs
+ /// what it would do. v0.2 adds the actual UI-click simulation
+ /// against XUiC_ServerBrowserDirectConnect (Direct Connect
+ /// dialog) -- see the patch class doc for details.
+ ///
+ public class ModEntry : IModApi
+ {
+ private static Harmony _harmony;
+
+ public void InitMod(Mod _modInstance)
+ {
+ Log.Out("[KitsunePackRelayLauncher] Initializing v0.1...");
+ try
+ {
+ _harmony = new Harmony("net.kitsuneden.packrelaylauncher");
+ _harmony.PatchAll(typeof(ModEntry).Assembly);
+ Log.Out("[KitsunePackRelayLauncher] Harmony patches applied. " +
+ "When PackRelay launcher writes packrelay-quickjoin.json " +
+ "into the userdatafolder before launching 7DTD, the next " +
+ "main-menu-ready event will pick it up. v0.1 logs only; " +
+ "v0.2 will actually drive the Direct Connect dialog.");
+ }
+ catch (Exception ex)
+ {
+ Log.Error("[KitsunePackRelayLauncher] Failed to apply Harmony patches: " + ex.Message);
+ Log.Exception(ex);
+ }
+ }
+ }
+}
diff --git a/src/KitsunePackRelayLauncher/ModInfo.xml b/src/KitsunePackRelayLauncher/ModInfo.xml
new file mode 100644
index 0000000..cea96e3
--- /dev/null
+++ b/src/KitsunePackRelayLauncher/ModInfo.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/KitsunePackRelayLauncher/Patches/MainMenuOpenedPatch.cs b/src/KitsunePackRelayLauncher/Patches/MainMenuOpenedPatch.cs
new file mode 100644
index 0000000..64c004d
--- /dev/null
+++ b/src/KitsunePackRelayLauncher/Patches/MainMenuOpenedPatch.cs
@@ -0,0 +1,247 @@
+using System;
+using System.IO;
+using HarmonyLib;
+using UnityEngine;
+
+namespace KitsunePackRelayLauncher.Patches
+{
+ ///
+ /// Postfix on XUiC_MainMenu.OnOpen() -- the moment the
+ /// main menu UI becomes visible after the splash dismisses.
+ /// Found via Mono.Cecil reflection on Assembly-CSharp during the
+ /// 2026-05-28 spelunk session (see vault runbook
+ /// `packrelay-quickjoin-mod-plan.md`).
+ ///
+ /// v0.1 (this patch): reads the sentinel file, freshness-checks
+ /// it (60s window), logs what it would do, deletes the file.
+ /// **No actual connect.**
+ ///
+ /// v0.2: after this logs "would auto-connect," fill in the UI
+ /// driving code:
+ /// 1. Locate the XUiC_ServerBrowserDirectConnect window
+ /// in the live XUi tree.
+ /// 2. Set its txtIp / txtPort field text values.
+ /// 3. Invoke btnDirectConnectConnect's OnPressed event
+ /// (the same delegate the actual button click fires).
+ ///
+ /// Sentinel file convention (written by the PackRelay launcher
+ /// before spawning 7DTD):
+ ///
+ /// <userdatafolder>/packrelay-quickjoin.json
+ /// {
+ /// "host": "play.kitsuneden.net",
+ /// "port": 26900,
+ /// "writtenAt": "2026-05-28T19:30:00Z"
+ /// }
+ ///
+ /// Freshness gate is 60s: an interrupted launch followed by the
+ /// user manually opening the game later shouldn't surprise-join,
+ /// and a sentinel from yesterday shouldn't fire today.
+ ///
+ [HarmonyPatch(typeof(XUiC_MainMenu), nameof(XUiC_MainMenu.OnOpen))]
+ public static class MainMenuOpenedPatch
+ {
+ ///
+ /// Tracks whether we've already fired this 7DTD session. The
+ /// main menu can re-open (player quits a server back to menu;
+ /// XUiC_MainMenu.OnOpen fires again). We only want auto-join
+ /// to fire on the FIRST main-menu-ready event of the process,
+ /// not every time the menu reappears.
+ ///
+ private static bool _firedThisSession;
+
+ private const int FreshnessSeconds = 60;
+
+ [HarmonyPostfix]
+ public static void Postfix()
+ {
+ if (_firedThisSession) return;
+ _firedThisSession = true;
+
+ try
+ {
+ string sentinelPath = ResolveSentinelPath();
+ if (string.IsNullOrEmpty(sentinelPath) || !File.Exists(sentinelPath))
+ {
+ // No sentinel = normal launch path. Quiet -- this
+ // is the common case and shouldn't spam the log.
+ return;
+ }
+
+ string raw;
+ try
+ {
+ raw = File.ReadAllText(sentinelPath);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning("[KitsunePackRelayLauncher] Couldn't read sentinel " +
+ sentinelPath + ": " + ex.Message);
+ return;
+ }
+
+ if (!TryParseSentinel(raw, out string host, out int port, out DateTime writtenAt))
+ {
+ Log.Warning("[KitsunePackRelayLauncher] Sentinel file malformed (raw='" +
+ Truncate(raw, 200) + "'); deleting and ignoring.");
+ TryDelete(sentinelPath);
+ return;
+ }
+
+ double ageSec = (DateTime.UtcNow - writtenAt.ToUniversalTime()).TotalSeconds;
+ if (ageSec > FreshnessSeconds)
+ {
+ Log.Out("[KitsunePackRelayLauncher] Sentinel is stale (" +
+ ageSec.ToString("F1") + "s old, gate=" + FreshnessSeconds +
+ "s). Ignoring + deleting so it doesn't fire on a future " +
+ "manual launch.");
+ TryDelete(sentinelPath);
+ return;
+ }
+
+ // STUB: v0.1 logs what it would do, then deletes the
+ // sentinel so the launch sequence is clean for v0.2.
+ Log.Out(
+ "\n" +
+ "================================================================\n" +
+ "[KitsunePackRelayLauncher] v0.1 stub: WOULD auto-connect now.\n" +
+ " host: " + host + "\n" +
+ " port: " + port + "\n" +
+ " writtenAt: " + writtenAt.ToString("u") + "\n" +
+ " age: " + ageSec.ToString("F1") + "s\n" +
+ "\n" +
+ " v0.2 will fill in the UI driving here:\n" +
+ " 1. Find XUiC_ServerBrowserDirectConnect in the XUi tree\n" +
+ " 2. Set its txtIp + txtPort field text\n" +
+ " 3. Fire btnDirectConnectConnect.OnPressed\n" +
+ "================================================================");
+
+ TryDelete(sentinelPath);
+ }
+ catch (Exception ex)
+ {
+ // Never let our mod break the main-menu-open path --
+ // the player needs to be able to use the game UI
+ // regardless of what our mod thinks.
+ Log.Warning("[KitsunePackRelayLauncher] Postfix threw: " + ex.Message);
+ }
+ }
+
+ ///
+ /// Resolves <userdatafolder>/packrelay-quickjoin.json.
+ /// 7DTD exposes the user data folder via GameIO.GetUserGameDataDir()
+ /// (returns the same value as UserDataFolder printed in
+ /// the boot log). Falls back to null if the call throws -- in
+ /// which case we just skip the sentinel check rather than
+ /// guess at the path.
+ ///
+ private static string ResolveSentinelPath()
+ {
+ try
+ {
+ string userdata = GameIO.GetUserGameDataDir();
+ if (string.IsNullOrEmpty(userdata)) return null;
+ return Path.Combine(userdata, "packrelay-quickjoin.json");
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Minimalist JSON parsing for the three fields we care
+ /// about. Avoids pulling Newtonsoft into the mod's dep
+ /// list -- the sentinel is a tiny three-field object,
+ /// hand-parsing is fine.
+ ///
+ /// Format expected (whitespace tolerant):
+ /// { "host": "...", "port": NNN, "writtenAt": "ISO8601" }
+ ///
+ private static bool TryParseSentinel(
+ string raw,
+ out string host,
+ out int port,
+ out DateTime writtenAt)
+ {
+ host = null;
+ port = 0;
+ writtenAt = DateTime.MinValue;
+
+ if (string.IsNullOrWhiteSpace(raw)) return false;
+
+ host = ExtractStringField(raw, "host");
+ string portStr = ExtractRawField(raw, "port");
+ string writtenAtStr = ExtractStringField(raw, "writtenAt");
+
+ if (string.IsNullOrEmpty(host)) return false;
+ if (!int.TryParse(portStr, out port) || port <= 0 || port > 65535) return false;
+ if (!DateTime.TryParse(
+ writtenAtStr,
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.RoundtripKind,
+ out writtenAt))
+ {
+ return false;
+ }
+ return true;
+ }
+
+ /// Extract "key": "value" from a JSON-ish blob.
+ private static string ExtractStringField(string raw, string key)
+ {
+ // Quick-and-dirty regex would be cleaner but we're keeping
+ // the dep surface minimal. Find `"key"`, skip ahead to the
+ // first `"`, read until next unescaped `"`.
+ string marker = "\"" + key + "\"";
+ int i = raw.IndexOf(marker, StringComparison.Ordinal);
+ if (i < 0) return null;
+ i = raw.IndexOf(':', i + marker.Length);
+ if (i < 0) return null;
+ i = raw.IndexOf('"', i + 1);
+ if (i < 0) return null;
+ int end = raw.IndexOf('"', i + 1);
+ if (end < 0) return null;
+ return raw.Substring(i + 1, end - i - 1);
+ }
+
+ /// Extract "key": token (unquoted token) from JSON-ish.
+ private static string ExtractRawField(string raw, string key)
+ {
+ string marker = "\"" + key + "\"";
+ int i = raw.IndexOf(marker, StringComparison.Ordinal);
+ if (i < 0) return null;
+ i = raw.IndexOf(':', i + marker.Length);
+ if (i < 0) return null;
+ i++;
+ // Skip whitespace
+ while (i < raw.Length && char.IsWhiteSpace(raw[i])) i++;
+ int start = i;
+ while (i < raw.Length && (char.IsDigit(raw[i]) || raw[i] == '-' || raw[i] == '+'))
+ {
+ i++;
+ }
+ if (i == start) return null;
+ return raw.Substring(start, i - start);
+ }
+
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning("[KitsunePackRelayLauncher] Couldn't delete sentinel " +
+ path + ": " + ex.Message);
+ }
+ }
+
+ private static string Truncate(string s, int max)
+ {
+ if (string.IsNullOrEmpty(s)) return s;
+ return s.Length <= max ? s : s.Substring(0, max) + "...";
+ }
+ }
+}