From a265cb3b3e7accf1740a463fe86c6c5712ffae2c Mon Sep 17 00:00:00 2001 From: Ada Vale Date: Thu, 28 May 2026 15:58:29 -0400 Subject: [PATCH] feat(KitsunePackRelayLauncher): v0.1 scaffold + verified hook on XUiC_MainMenu.OnOpen (#227) New sibling mod to KitsuneJoinDiag -- the in-game half of the "make every Connect button actually one-click into the server" loop tracked as Kitsunebi #227. ## What this lands (v0.1, scaffold) - `src/KitsunePackRelayLauncher/KitsunePackRelayLauncher.csproj` -- mirrors KitsuneJoinDiag's csproj shape; targets net48, references the shared refs/ dir DLLs. - `src/KitsunePackRelayLauncher/ModInfo.xml`. - `src/KitsunePackRelayLauncher/ModEntry.cs` -- standard Harmony.PatchAll bootstrap. - `src/KitsunePackRelayLauncher/Patches/MainMenuOpenedPatch.cs` -- the hook + sentinel-file reader + freshness gate. ## Reflection findings Spelunked `Assembly-CSharp.dll` via Mono.Cecil from PowerShell during the 2026-05-28 session. The two key types confirmed: - `XUiC_MainMenu.OnOpen()` -- fires when the main menu becomes visible after the splash dismisses. The hook this mod uses. - `XUiC_ServerBrowserDirectConnect` -- the Direct Connect dialog. Fields: `txtIp` (XUiC_TextInput), `txtPort` (XUiC_TextInput), `btnDirectConnectConnect` (XUiC_SimpleButton), `btnCancel`, `ipPortMatcher` (Regex). This is what v0.2 will drive. Full notes: `D:\Claude\Claude\runbooks\packrelay-quickjoin-mod-plan.md`. ## Hook + sentinel-file convention Postfix on `XUiC_MainMenu.OnOpen()` checks for `/packrelay-quickjoin.json`: ``` { "host": "play.kitsuneden.net", "port": 26900, "writtenAt": "2026-05-28T19:30:00Z" } ``` with a 60s freshness gate so an interrupted launch followed by the user manually opening the game later doesn't surprise-join, and a sentinel from yesterday doesn't fire today. On fire, the mod deletes the sentinel so a re-open of the main menu (player quitting back from a server, say) doesn't re-fire. The patch is also guarded by a session-static `_firedThisSession` flag for the same reason: only the FIRST OnOpen of the process checks the sentinel. ## Why this is v0.1 stub-only v0.1 logs what it WOULD do and deletes the sentinel; it doesn't actually fire the Direct Connect. v0.2 will fill in the UI driving: 1. Locate `XUiC_ServerBrowserDirectConnect` window in the live XUi tree. 2. Set `txtIp` / `txtPort` field text to the sentinel values. 3. Invoke `btnDirectConnectConnect.OnPressed` -- same delegate the actual button click fires. Splitting v0.1 / v0.2 lets us verify the hook attaches + the sentinel-reading round-trip works end-to-end before adding the UI manipulation that's harder to debug. ## Launcher side (NOT in this PR) The PackRelay launcher's `launch_game` Tauri command (in `packrelay-launcher` repo) will need to start writing `packrelay-quickjoin.json` into the active profile's userdatafolder before spawning 7DTD. That's a small Rust change deferred to the v0.2 push so this PR stays mod-only. ## Safety - Postfix is wrapped in try/catch -- a parse / read / delete failure logs a warning but never breaks 7DTD's main menu open path. The player can always use the UI normally. - Stale sentinel detection + deletion means a launcher that crashed before launching 7DTD doesn't leave a time bomb for the next manual launch. - Harmless on dedicated servers (XUiC code path doesn't fire server-side), so safe to ship in a both-sides pack. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../KitsunePackRelayLauncher.csproj | 53 ++++ src/KitsunePackRelayLauncher/ModEntry.cs | 46 ++++ src/KitsunePackRelayLauncher/ModInfo.xml | 9 + .../Patches/MainMenuOpenedPatch.cs | 247 ++++++++++++++++++ 4 files changed, 355 insertions(+) create mode 100644 src/KitsunePackRelayLauncher/KitsunePackRelayLauncher.csproj create mode 100644 src/KitsunePackRelayLauncher/ModEntry.cs create mode 100644 src/KitsunePackRelayLauncher/ModInfo.xml create mode 100644 src/KitsunePackRelayLauncher/Patches/MainMenuOpenedPatch.cs 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) + "..."; + } + } +}