mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Support local domains and bundled client patching
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
build/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.vscode/
|
||||
*.csproj.bak
|
||||
dist/
|
||||
local-releases/
|
||||
## Data / secrets
|
||||
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||
|
||||
@@ -19,17 +19,32 @@
|
||||
they would be derivative works of the game anyway, so we never
|
||||
reference them. Only MelonLoader's own DLLs are needed.
|
||||
|
||||
tools/build-patcher-zip.ps1 downloads the MelonLoader release zip
|
||||
and points MelonLoaderDir at its extracted contents, so a fresh
|
||||
contributor checkout builds without a local Rec Room install.
|
||||
|
||||
To build outside the script (manual dev loop):
|
||||
dotnet build -p:MelonLoaderDir=<path-to-extracted-MelonLoader>
|
||||
By default the project downloads the pinned MelonLoader release
|
||||
into DorkNet.ClientMod/build/ and references those DLLs. Pass
|
||||
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
|
||||
deliberately want to use a local override.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\build\melonloader</MelonLoaderDir>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
OverwriteReadOnlyFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
||||
|
||||
+110
-10
@@ -42,6 +42,7 @@ public class Mod : MelonMod
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _networkPatchesRegistered;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
@@ -75,6 +76,7 @@ public class Mod : MelonMod
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||
LoadConfig();
|
||||
RegisterNetworkPatches();
|
||||
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
|
||||
}
|
||||
|
||||
@@ -82,12 +84,7 @@ public class Mod : MelonMod
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
|
||||
Log.Msg("=== Registering client patches ===");
|
||||
TryPatch("Uri ctor (string)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string), typeof(UriKind) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
RegisterNetworkPatches();
|
||||
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
|
||||
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
|
||||
TryPatchByName("NetworkingPeer", "CallAuthenticate",
|
||||
@@ -189,6 +186,57 @@ public class Mod : MelonMod
|
||||
Log.Msg("=== Client patches registered ===");
|
||||
}
|
||||
|
||||
private void RegisterNetworkPatches()
|
||||
{
|
||||
if (_networkPatchesRegistered) return;
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatch("Uri ctor (string)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string), typeof(UriKind) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
|
||||
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
|
||||
complete &= TryPatchHttpManagerStringSendRequestOverloads();
|
||||
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
|
||||
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
||||
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
|
||||
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPManager");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string): type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = GetPatchMethod(nameof(UriPatches.HttpManagerSendRequestString_Prefix));
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != "SendRequest") continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(string)) continue;
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefix));
|
||||
patched++;
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string)");
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (_diagnosticGameComplete) return;
|
||||
@@ -464,18 +512,70 @@ public class Mod : MelonMod
|
||||
|
||||
internal static class UriPatches
|
||||
{
|
||||
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||
{
|
||||
RewriteString(ref url, "httpmanager-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestObject_Prefix(object request)
|
||||
{
|
||||
RewriteRequestUri(request, "httpmanager-request-rewrite");
|
||||
}
|
||||
|
||||
public static void UriStringCtor_Prefix(ref string uriString)
|
||||
{
|
||||
RewriteString(ref uriString, "uri-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpRequestSend_Prefix(object __instance)
|
||||
{
|
||||
RewriteRequestUri(__instance, "http-rewrite");
|
||||
}
|
||||
|
||||
private static void RewriteRequestUri(object request, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
uriProp.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[{label}] failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteString(ref string uriString, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uriString)) return;
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
var rewritten = uriString.Replace(".rec.net", "." + host);
|
||||
if (rewritten != uriString)
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
}
|
||||
|
||||
private static Uri? RewriteUri(Uri uri)
|
||||
{
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
if (!uri.Host.EndsWith(".rec.net", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var service = uri.Host[..^".rec.net".Length];
|
||||
if (string.IsNullOrWhiteSpace(service)) return null;
|
||||
|
||||
return new UriBuilder(uri)
|
||||
{
|
||||
Mod.Log.Msg($"[uri-rewrite] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
}
|
||||
Host = service + "." + host,
|
||||
}.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@ public class ServiceUrlsController(DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet("services")]
|
||||
public ActionResult<Dictionary<string, string>> Get()
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain.Apex));
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain));
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ public class NsController : ControllerBase
|
||||
// code: locker)" in BootSequence.
|
||||
[HttpGet("/", Order = -1000)]
|
||||
public ActionResult<Dictionary<string, string>> Root() =>
|
||||
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
|
||||
Ok(ConfigService.BuildServiceUrlMap(_domain));
|
||||
|
||||
// Some builds fetch /v1 or /v1/services directly
|
||||
[HttpGet("/v1")]
|
||||
[HttpGet("/v1/services")]
|
||||
public ActionResult<Dictionary<string, string>> Services() =>
|
||||
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
|
||||
Ok(ConfigService.BuildServiceUrlMap(_domain));
|
||||
|
||||
// Ping / health used by the client keep-alive loop
|
||||
[HttpGet("/v1/ping")]
|
||||
|
||||
+60
-19
@@ -87,14 +87,21 @@ var apex =
|
||||
builder.Configuration["Domain:Apex"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||
?? "localhost";
|
||||
builder.Services.AddSingleton(new DomainConfig(apex));
|
||||
var domainScheme =
|
||||
builder.Configuration["Domain:Scheme"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_SCHEME")
|
||||
?? "https";
|
||||
var domainPort =
|
||||
builder.Configuration["Domain:Port"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_PORT");
|
||||
builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort));
|
||||
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
||||
{
|
||||
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
|
||||
opt.AllowEmptyHosts = true;
|
||||
opt.IncludeFailureMessage = true;
|
||||
});
|
||||
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
||||
Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<PlayerService>();
|
||||
@@ -441,28 +448,18 @@ app.Use(async (ctx, next) =>
|
||||
}
|
||||
});
|
||||
|
||||
// ── Migrate DB ────────────────────────────────────────────────────────────────
|
||||
// EF Core migrations under Migrations/. On boot we apply any pending ones —
|
||||
// `Migrate()` is a no-op when the DB is already up to date and applies the
|
||||
// missing migrations in order otherwise. Schema evolution is now exclusively
|
||||
// done via `dotnet ef migrations add <Name>`; never call `EnsureCreated` (it
|
||||
// bypasses the migration history table and leaves the db in a broken
|
||||
// "exists but no __EFMigrationsHistory entries" state that future Migrate
|
||||
// calls can't reason about).
|
||||
//
|
||||
// One-time upgrade path: DBs created by the previous EnsureCreated boot
|
||||
// path already contain the Initial schema but have no __EFMigrationsHistory
|
||||
// entries. The baseline detector below catches that case and inserts a
|
||||
// row marking Initial as already applied, so Migrate() will only apply the
|
||||
// Phase1+Phase2 deltas (column adds) instead of trying to re-CREATE TABLE.
|
||||
// ── Create/repair DB ──────────────────────────────────────────────────────────
|
||||
// Local launcher installs use SQLite as an appliance database. For that path,
|
||||
// create the current EF model directly instead of replaying the migration chain;
|
||||
// then run small idempotent repair patches for DBs made by older launcher builds.
|
||||
// Hosted Postgres keeps its existing EnsureCreated + explicit patch path below.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
// Sqlite-only legacy baseline path — sqlite_master probes inside.
|
||||
BaselineExistingSchemaIfNeeded(db);
|
||||
db.Database.Migrate();
|
||||
db.Database.EnsureCreated();
|
||||
ApplySqliteCompatibilityPatches(db);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1185,3 +1182,47 @@ static void BaselineExistingSchemaIfNeeded(DorkNetDbContext db)
|
||||
Log.Information(
|
||||
"[migrations] Detected legacy EnsureCreated DB; baselined as 20260508092637_Initial. Subsequent migrations will apply normally.");
|
||||
}
|
||||
|
||||
static void ApplySqliteCompatibilityPatches(DorkNetDbContext db)
|
||||
{
|
||||
if (!db.Database.IsSqlite()) return;
|
||||
AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse",
|
||||
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var shouldClose = conn.State == System.Data.ConnectionState.Closed;
|
||||
if (shouldClose) conn.Open();
|
||||
try
|
||||
{
|
||||
var exists = false;
|
||||
using var check = conn.CreateCommand();
|
||||
check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");";
|
||||
{
|
||||
using var reader = check.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (exists)
|
||||
{
|
||||
Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column);
|
||||
return;
|
||||
}
|
||||
|
||||
using var alter = conn.CreateCommand();
|
||||
alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};";
|
||||
alter.ExecuteNonQuery();
|
||||
Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shouldClose) conn.Close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,12 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
|
||||
// the client used to hit us. The baseUrl parameter is kept
|
||||
// for API stability but is no longer dereferenced.
|
||||
_ = baseUrl;
|
||||
var apex = domain.Apex;
|
||||
|
||||
return new RecRoomConfig
|
||||
{
|
||||
MessageOfTheDay = config["Server:MOTD"] ?? "Welcome to the private server!",
|
||||
CdnBaseUri = $"https://{domain.Sub("cdn")}",
|
||||
CdnBaseUri = domain.SubUrl("cdn"),
|
||||
PhotonConfig = photon,
|
||||
ServiceUrls = BuildServiceUrlMap(apex),
|
||||
ServiceUrls = BuildServiceUrlMap(domain),
|
||||
ConfigTable = new Dictionary<string, string>
|
||||
{
|
||||
// Season keys the 2019/2020 client checks at startup
|
||||
@@ -106,4 +104,15 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public static Dictionary<string, string> BuildServiceUrlMap(DomainConfig domain)
|
||||
{
|
||||
var map = new Dictionary<string, string>(ServiceSubdomains.Length);
|
||||
foreach (var (service, sub) in ServiceSubdomains)
|
||||
{
|
||||
var host = string.IsNullOrEmpty(sub) ? domain.Apex : domain.Sub(sub);
|
||||
map[service] = domain.Url(host);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,19 @@ namespace DorkNet.Server.Services;
|
||||
public sealed class DomainConfig
|
||||
{
|
||||
public string Apex { get; }
|
||||
public DomainConfig(string apex) { Apex = apex; }
|
||||
public string Scheme { get; }
|
||||
public string? Port { get; }
|
||||
|
||||
public DomainConfig(string apex, string scheme = "https", string? port = null)
|
||||
{
|
||||
Apex = apex;
|
||||
Scheme = string.IsNullOrWhiteSpace(scheme) ? "https" : scheme.TrimEnd(':', '/', '\\');
|
||||
Port = string.IsNullOrWhiteSpace(port) ? null : port.Trim().TrimStart(':');
|
||||
}
|
||||
|
||||
public string Sub(string prefix) => $"{prefix}.{Apex}";
|
||||
public string Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}";
|
||||
public string SubUrl(string prefix) => Url(Sub(prefix));
|
||||
public static bool MatchesSubdomain(string host, string prefix)
|
||||
=> host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -923,14 +923,27 @@ public class RoomService(DorkNetDbContext db)
|
||||
// Name="BloodMoon" because SaveChanges hadn't fired yet, so
|
||||
// the image binding silently no-op'd. Mutating the already-
|
||||
// loaded entity sidesteps that race.
|
||||
var bloodmoon = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "BloodMoon" || r.Name == "Crescendo");
|
||||
if (bloodmoon is not null)
|
||||
var crescendoRooms = await db.Rooms
|
||||
.Where(r => r.Name.ToLower() == "bloodmoon" || r.Name.ToLower() == "crescendo")
|
||||
.ToListAsync();
|
||||
var crescendo = crescendoRooms.FirstOrDefault(r =>
|
||||
string.Equals(r.Name, "Crescendo", StringComparison.OrdinalIgnoreCase));
|
||||
var bloodMoon = crescendoRooms.FirstOrDefault(r =>
|
||||
string.Equals(r.Name, "BloodMoon", StringComparison.OrdinalIgnoreCase));
|
||||
var shouldRenameBloodMoon = crescendo is null;
|
||||
crescendo ??= bloodMoon;
|
||||
if (crescendo is not null)
|
||||
{
|
||||
if (bloodmoon.Name != "Crescendo") bloodmoon.Name = "Crescendo";
|
||||
if (string.IsNullOrWhiteSpace(bloodmoon.Description))
|
||||
bloodmoon.Description = "Brave the haunted halls of Castle Dracula and survive the night.";
|
||||
if (bloodmoon.ImageName != "image_Crescendo.png")
|
||||
bloodmoon.ImageName = "image_Crescendo.png";
|
||||
if (shouldRenameBloodMoon && crescendo.Name != "Crescendo")
|
||||
crescendo.Name = "Crescendo";
|
||||
if (string.IsNullOrWhiteSpace(crescendo.Description))
|
||||
crescendo.Description = "Brave the haunted halls of Castle Dracula and survive the night.";
|
||||
if (crescendo.ImageName != "image_Crescendo.png")
|
||||
crescendo.ImageName = "image_Crescendo.png";
|
||||
}
|
||||
foreach (var duplicate in crescendoRooms.Where(r => r.Id != crescendo?.Id))
|
||||
{
|
||||
duplicate.HiddenFromBrowse = true;
|
||||
}
|
||||
|
||||
var paintball = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "Paintball");
|
||||
|
||||
@@ -120,6 +120,7 @@ try {
|
||||
'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json'
|
||||
'old_plugin_paths' = @(
|
||||
'BepInEx/plugins/DorkNet.ClientPatch.dll'
|
||||
'MelonLoader/Mods/DorkNet.ClientMod.dll'
|
||||
)
|
||||
'steam_stub' = @{
|
||||
'api_dll' = 'steam_api64.dll'
|
||||
|
||||
Reference in New Issue
Block a user