mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4ce92f1b4 | |||
| c7a087eea1 | |||
| 5bfa463528 | |||
| 891ec89899 | |||
| f0e4679887 | |||
| 260f6605fc | |||
| 12da61407d | |||
| 5b93af4ba9 | |||
| 51104464f0 | |||
| 35768c05ed | |||
| 0a5c3a9a64 | |||
| fd93b4b028 | |||
| 8be0f90dbc | |||
| d0d26c9821 | |||
| 503d838fd1 | |||
| bb22675a91 | |||
| c1000c82aa | |||
| 9076fb9fae | |||
| cf7a021898 | |||
| e29707deeb | |||
| 3b332a6476 | |||
| d295fd5c37 | |||
| 8e12db660b | |||
| bafb380fc8 | |||
| 78c64d117a | |||
| e7c0b076f2 | |||
| 0a3bad7c6c | |||
| 1718912a07 | |||
| 5acad4c9e2 | |||
| ec88a39750 | |||
| 6e5b011059 | |||
| 2e09042344 | |||
| 9c5b574d6e | |||
| 29aca0e88c | |||
| eb73f3c484 | |||
| cc7f77b7de | |||
| 19402459a9 | |||
| 3266ce47f3 | |||
| cf3e6ea025 | |||
| bf0f09ce84 | |||
| fd2153d82b | |||
| 0839d932ce | |||
| 9523525cb8 | |||
| 3880745efd | |||
| 31d5a022c8 | |||
| 83b896ffaf | |||
| fcc734726a | |||
| b1c1239165 | |||
| afad9bbc5a | |||
| e33145307f | |||
| 38335c06a9 | |||
| 5ab28b1d4f | |||
| d60b0642a8 | |||
| a7879d7ede | |||
| 6272844445 | |||
| 2dbd03b9ae | |||
| f4b4836375 | |||
| ee7d614262 | |||
| 93b4e43316 | |||
| 975753deac | |||
| cebe7ae3ee | |||
| 8373e592f4 | |||
| 0c899a1001 | |||
| 34d4c9d115 | |||
| 6ee1d6f9b3 | |||
| 36e64e99ba | |||
| f2e7532853 | |||
| 1b3edf008d | |||
| cf3aa29ba2 | |||
| e6e5c9990f | |||
| 1d7801b3c0 | |||
| c8a7068070 | |||
| bb04782e11 | |||
| 0a51c61f66 | |||
| 0af23c26b1 | |||
| 7ce106ed1b | |||
| 9f173193a7 | |||
| ec37f1c790 | |||
| 4a1ac47007 | |||
| 078d576383 | |||
| 79a2b9844e | |||
| f43ce3f368 | |||
| cc523181d2 | |||
| da7f980166 | |||
| 9c9f1841d6 | |||
| 3968adfcc4 | |||
| 08ab122adc | |||
| dc1f4c3492 | |||
| 936aa061c0 | |||
| 81698180dd | |||
| 933c7eb51c | |||
| 4b5086cdf1 | |||
| 90e069ee4b | |||
| 289072583e | |||
| 49df7393dc | |||
| 327dcb789c | |||
| 674a3fc257 | |||
| e93bbb14cb | |||
| 618bcd9203 | |||
| 6d78b7b115 | |||
| 6ee018b844 | |||
| 690e77f520 | |||
| e4126e38ce | |||
| a2ffb5ef4b | |||
| 1152d9ba48 | |||
| 5460cc3601 | |||
| 2c749c2677 | |||
| 2795d2ed7d | |||
| 757308b44d | |||
| 3d7d7ebdf1 | |||
| 5fcef3ffe7 | |||
| 28b5d5525b | |||
| 35ef784bd9 | |||
| 63e3b11736 | |||
| c472e7f342 | |||
| 1a946f3023 | |||
| e27b06ce5b | |||
| 479ea273ea | |||
| af0d7b6c20 | |||
| 4021f7c6e0 | |||
| 74951d5c30 | |||
| 892c27ffea | |||
| 0defa37928 | |||
| f23df2659d | |||
| 1417651c77 | |||
| a18236d064 | |||
| 3d2deeedf8 | |||
| 7b7eb651e3 | |||
| 8e87a9de82 | |||
| fc60c9bf40 | |||
| 9a1af3a99d | |||
| a8d7ab4944 | |||
| 111ee6a548 | |||
| 77e5ef5678 | |||
| 0986a0a118 | |||
| 628f430645 | |||
| fbfabaf26e | |||
| 7da20f2047 | |||
| 50e03af296 | |||
| a1a29bb64d | |||
| ac1dcf3ad7 | |||
| 273352db08 | |||
| 159ca2c271 | |||
| 5af3fa8fc5 | |||
| c9ed2df278 | |||
| 80f0691516 | |||
| 9d33b1f4d6 | |||
| 44fc9047e0 | |||
| 98fa7b89cf | |||
| ff55baa0c5 | |||
| 312ecbbdc8 | |||
| 1d4777e3da | |||
| 38fbd0faf1 | |||
| 99d3faec5b | |||
| 02f7bc386d | |||
| 27e5e0064c | |||
| 4034449a6d | |||
| 142f52569a | |||
| df73507ee9 | |||
| 70980bf3b6 |
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install protobuf compiler
|
||||
run: |
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||
dotnet DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
||||
|
||||
- name: Wait for server readiness
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Run endpoint contract
|
||||
run: |
|
||||
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
||||
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
|
||||
--server-assembly DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll \
|
||||
--base-url http://127.0.0.1:18080 \
|
||||
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# ── Server self-contained publish ────────────────────────────────
|
||||
- name: Publish DorkNet.Server (win-x64, self-contained)
|
||||
@@ -43,10 +43,10 @@ jobs:
|
||||
Compress-Archive -Path publish/server/* `
|
||||
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||
|
||||
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
||||
# ── Client patcher zip (built via tools/build_pc.py) ─────────────
|
||||
- name: Build client patcher zip
|
||||
run: |
|
||||
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
||||
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
|
||||
|
||||
# ── Publish both as Release assets ───────────────────────────────
|
||||
- name: Upload Release assets
|
||||
|
||||
+5
-2
@@ -12,8 +12,8 @@ dist/
|
||||
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||
# case-insensitive filesystems. Use the precise runtime data dirs.
|
||||
DorkNet.Server/bin/Debug/net9.0/data/
|
||||
DorkNet.Server/bin/Release/net9.0/data/
|
||||
DorkNet.Server/bin/Debug/net10.0/data/
|
||||
DorkNet.Server/bin/Release/net10.0/data/
|
||||
# appsettings.Production.json is intentionally versioned — contains
|
||||
# only the Postgres provider switch + Kestrel bind address. All real
|
||||
# secrets come from Coolify env vars at runtime.
|
||||
@@ -86,3 +86,6 @@ Recovered(*)/
|
||||
|
||||
|
||||
local-releases/**
|
||||
DorkNet.Models/bin-verify/**
|
||||
DorkNet.Models/bin-verify2/**
|
||||
identityserver-signing.pfx
|
||||
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
# DorkNet.Server container image — Coolify deploy target.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Multi-stage: SDK image builds + publishes, runtime image is the
|
||||
# slimmer aspnet:9.0 with only the published output.
|
||||
# slimmer aspnet:10.0 with only the published output.
|
||||
#
|
||||
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
||||
# to ProjectReference + dotnet restore). The .dockerignore at the
|
||||
# repo root keeps the bin/, obj/, and other dev junk out of the
|
||||
# context.
|
||||
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG NODE_VERSION=20
|
||||
ARG DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
|
||||
@@ -76,13 +76,13 @@ WORKDIR /app
|
||||
|
||||
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
||||
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
||||
# both have to be present. The aspnet:9.0 base image strips both by
|
||||
# both have to be present. The aspnet:10.0 base image strips both by
|
||||
# default to keep the surface area small.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
|
||||
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
||||
# Coolify mounts persistent volumes with write permission for this UID.
|
||||
USER app
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG NODE_VERSION=20
|
||||
ARG DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||
WORKDIR /ui
|
||||
|
||||
+399
-2104
@@ -3,9 +3,17 @@
|
||||
// server:
|
||||
//
|
||||
// 1. URI rewrite — `.rec.net` → user-configured host
|
||||
// 2. Photon AppId — override the baked-in Photon Cloud AppId
|
||||
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
|
||||
// 4. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
// 2. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
//
|
||||
// Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash)
|
||||
// needed so a modded client isn't kicked, and three opt-in features that
|
||||
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size,
|
||||
// Rec Royale night mode).
|
||||
//
|
||||
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
|
||||
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
|
||||
// the Mods folder to debug); the dev-menu / dev-debug-tools tooling
|
||||
// lives outside the public repo entirely.
|
||||
//
|
||||
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
|
||||
// install-melon.ps1 wrapper writes that file on install; you can also
|
||||
@@ -22,13 +30,22 @@
|
||||
// Il2CppAssemblies directory just to write the source.
|
||||
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
@@ -39,15 +56,13 @@ public class Mod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static Mod? Instance;
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _networkPatchesRegistered;
|
||||
private bool _joinTracePatchesRegistered;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
private bool _firstUpdateLogged;
|
||||
private bool _antiTamperCallbackPatchesComplete;
|
||||
private int _antiTamperCallbackRetryFrame;
|
||||
private bool _toxModPatchesComplete;
|
||||
private int _toxModRetryFrame;
|
||||
|
||||
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||
// The defaults below match the recommended install, so dropping this
|
||||
@@ -55,38 +70,7 @@ public class Mod : MelonMod
|
||||
public static class Cfg
|
||||
{
|
||||
public static string ServerHost = "localhost";
|
||||
public static string PhotonAppId = "";
|
||||
public static string PhotonVoiceAppId = "";
|
||||
// Forced PhotonServerSettings.PreferredRegion (with HostType
|
||||
// switched to PhotonCloud) so two watches don't auto-ping
|
||||
// themselves into different "best" regions and end up in
|
||||
// parallel Photon rooms sharing only a name. PhotonPatches
|
||||
// .ApplyForcedRegion prefers the server-told region from
|
||||
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
|
||||
// and falls back to this config value on initial boot before
|
||||
// any /goto has returned. Default matches the server's
|
||||
// appsettings.json Photon:CloudRegion ("eu").
|
||||
public static string PhotonCloudRegion = "eu";
|
||||
// Photon Custom Auth injector parked 2026-05-28; see
|
||||
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
||||
public static bool EnableTlsTrustBypass = true;
|
||||
// Log-only trace for the stuck first-dorm "Change Username?"
|
||||
// dialog. When true, RegistrationPatches hooks the dorm
|
||||
// registration-prompt flow + the confirm-dialog buttons and writes
|
||||
// what fires to MelonLoader/UserData/dorknet-diagnostics.log, so a
|
||||
// brand-new account that reproduces the freeze tells us exactly
|
||||
// where it breaks (does the click reach Button_Affirmative? does
|
||||
// the prompt even run?). No behaviour change — purely diagnostic.
|
||||
// See RegistrationPatches and the registration site in
|
||||
// OnLateInitializeMelon.
|
||||
public static bool TraceRegistrationDialog = true;
|
||||
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
|
||||
// (normally dev-gated) and silence CheatManager's tamper detectors
|
||||
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
|
||||
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
|
||||
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
|
||||
// ── Desktop Screen Sharing gadget FPS ──
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
|
||||
@@ -111,29 +95,6 @@ public class Mod : MelonMod
|
||||
public static int DesktopScreenShareResolution = 0; // horizontal px
|
||||
public static int DesktopScreenShareQuality = 0; // JPEG quality
|
||||
|
||||
// One-shot dev-UI diagnostic (no behaviour change). When true,
|
||||
// ~5s after load the mod logs whether the watch's native developer
|
||||
// menu content actually exists in this build (HomeScreenFlow.
|
||||
// devMenuPanel / devButtonPrefab present vs NULL), the dev-flow list
|
||||
// counts, and whether RecRoom.Debugging.DebugConsole is in the scene
|
||||
// + whether its static Execute() command path works. This tells us
|
||||
// stripped-vs-gated so we know whether to un-gate the game's own dev
|
||||
// menu or build a custom watch entry. Output goes to the MelonLoader
|
||||
// console + dorknet-diagnostics.log.
|
||||
public static bool DiagnoseDevMenu = false;
|
||||
|
||||
// Inject a "Dev Console" button into the watch's native dev-menu
|
||||
// panel (HomeScreenFlow.devMenuPanel) by cloning the game's own
|
||||
// devButtonPrefab. The panel/prefab exist in this build (just gated
|
||||
// off), so we instantiate + activate + label, then wire its click to
|
||||
// run console commands. Native Button3D → works in VR and Screen Mode.
|
||||
public static bool EnableDevWatchButton = false;
|
||||
// Preset dev commands shown as watch buttons (when EnableDevWatchButton
|
||||
// is on). Each entry is "Label=command"; clicking runs
|
||||
// DebugConsole.Execute(command). Edit to match this build's actual
|
||||
// DebugConsoleCommandConfig command names/args.
|
||||
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
|
||||
|
||||
// Raise RRO quest party size past the baked 4. The cap lives in each
|
||||
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
|
||||
// ScriptableObject (baked in the client bundle; no server lever). The
|
||||
@@ -149,54 +110,75 @@ public class Mod : MelonMod
|
||||
// generic "Quest" config is intentionally NOT in the default list.
|
||||
public static string[] QuestMaxTeamSizeRooms =
|
||||
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
|
||||
}
|
||||
|
||||
// ── Rec Royale night mode (client-side visual) ──
|
||||
// Re-skins Rec Royale with one of the game's own shipped night
|
||||
// MoodSetting assets (sky dome, sun, fog, bloom, colour grading)
|
||||
// through SceneMoodSettings' apply path, and unlocks the weapon
|
||||
// flashlight prefab while it is on. Purely local: the networked room
|
||||
// mood index is never touched. See RecRoyaleNight.
|
||||
public static bool RecRoyaleNightMode = false;
|
||||
// Resources asset name of the MoodSetting to apply. Shipped options:
|
||||
// Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood,
|
||||
// Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood.
|
||||
public static string RecRoyaleNightMood = "Night_Calm_Outdoor_Mood";
|
||||
// Unity scene names (substring, case-insensitive) that count as Rec
|
||||
// Royale. 2023-03-21 has a single map: RecRoyale_Frontier.
|
||||
public static string[] RecRoyaleNightScenes = { "RecRoyale" };
|
||||
// Force WeaponFlashlightController.FlashlightAllowed while night is on.
|
||||
public static bool RecRoyaleNightFlashlights = true;
|
||||
// Apply the mood's fog block too. Off = the mood's FogSettings are
|
||||
// swapped for a block captured from the scene, so fog stays as-is.
|
||||
public static bool RecRoyaleNightFog = false;
|
||||
// Darkening knobs applied to the mood data before it is used. Baked
|
||||
// lightmaps can't be dimmed, so post-exposure (EV, added to the mood's
|
||||
// ColorGradingSettings.exposure) is what actually darkens the whole
|
||||
// frame; the sun/ambient multipliers tone down realtime lighting.
|
||||
public static float RecRoyaleNightExposure = -3.5f; // EV offset (0 = mood default)
|
||||
public static float RecRoyaleNightSunScale = 0.15f; // × SunSettings.sunIntensity (1 = unchanged)
|
||||
public static float RecRoyaleNightAmbientScale = 0.2f; // × SkyBoxSettings.ambientLightIntensity (1 = unchanged)
|
||||
// Post-exposure only renders if the scene Volume is on and the camera
|
||||
// renders post-processing; force both while night mode is active.
|
||||
public static bool RecRoyaleNightForcePostProcessing = true;
|
||||
// The engine only drives RenderSettings.sun (Frontier: the sun-disc
|
||||
// light). Copy its rotation/colour onto every other directional light
|
||||
// and scale their intensity by RecRoyaleNightSunScale.
|
||||
public static bool RecRoyaleNightMatchSunLights = true;
|
||||
// Turn fog off entirely while night is on (RenderSettings.fog=false and
|
||||
// the mood's fog block pushed out of range). Ignored when
|
||||
// RecRoyaleNightFog is true.
|
||||
public static bool RecRoyaleNightDisableFog = false;
|
||||
// Renderers whose GameObject or material name contains any of these
|
||||
// (case-insensitive) are hidden while night is on. Frontier's daytime
|
||||
// cloud meshes are CloudPlane/Clouds/…_Cloud_RecRoyale_Frontier_Mat.
|
||||
public static string[] RecRoyaleNightHideObjects = { "Cloud" };
|
||||
// Optional UnityEngine.KeyCode name to toggle night at runtime (e.g.
|
||||
// "N"). Empty = no hotkey. A hotkey also arms the feature when
|
||||
// RecRoyaleNightMode is false, so it can be switched on in-game.
|
||||
public static string RecRoyaleNightToggleKey = "";
|
||||
|
||||
// A client with no remembered account for this machine. false: the
|
||||
// title screen opens the sign-in page so the player logs into their
|
||||
// existing account. true: Rec Room's own behaviour, which creates a
|
||||
// new account automatically -- only useful when the server allows
|
||||
// sign-ups (otherwise the title screen keeps retrying and failing).
|
||||
// See TitleScreenLoginPatches.
|
||||
public static bool AutoCreateAccount = false;
|
||||
}
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||
LoadConfig();
|
||||
Log.Msg($"DorkNet ClientMod loaded — host={Cfg.ServerHost}, tlsBypass={Cfg.EnableTlsTrustBypass}");
|
||||
RegisterNetworkPatches();
|
||||
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
|
||||
Log.Msg("=== Registering client patches ===");
|
||||
RegisterNetworkPatches();
|
||||
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
|
||||
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
|
||||
// Hook the watch's own AppSettings + AuthValues builders directly.
|
||||
// Both are private PUNNetworkManager methods with obfuscated 2020.12
|
||||
// names — we know they always run before any Photon connect because
|
||||
// ConnectUsingSettings reads their return values. Postfix logs them
|
||||
// after construction.
|
||||
//
|
||||
// FJOLIPKKIBE returns Photon.Realtime.AppSettings — verified at
|
||||
// PUNNetworkManager.txt:7046. One bool arg (DOJLKMCEFFB — likely
|
||||
// "for voice"). Two call paths in the binary.
|
||||
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
|
||||
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
|
||||
// environment + accessToken AuthGetParameters.
|
||||
//
|
||||
// Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by
|
||||
// return type first so a future platform/reseed does not make these
|
||||
// two critical hooks depend on the private method names.
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings",
|
||||
fallbackMethodName: "FJOLIPKKIBE",
|
||||
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues",
|
||||
fallbackMethodName: "EMGGKBALALH",
|
||||
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
|
||||
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
|
||||
// string-like arg the IL2CPP method takes (declared `string` in
|
||||
// dump.cs but Il2CppInterop wraps it in a way that breaks
|
||||
// Harmony's IL emit when we declare `string` directly). Using
|
||||
// `object` lets Harmony pass through whatever wrapper the IL2CPP
|
||||
// proxy uses without compile-error.
|
||||
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
|
||||
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
|
||||
TryPatchByName("RecRoom.AntiCheat.EACManager",
|
||||
"GenerateChallengeResponse",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix));
|
||||
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
|
||||
// Even when the prefix's return value is "let original run", the
|
||||
// detour itself still has to be JIT-installed when BouncyCastle
|
||||
@@ -214,145 +196,11 @@ public class Mod : MelonMod
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
}
|
||||
// Post-save blob-name refresh. The 2020 watch's
|
||||
// RoomPersistenceManager.RoomDataBlobName is only ever set by
|
||||
// /goto-driven flows — neither the SubscriptionUpdateRoom push
|
||||
// nor SubscriptionUpdatePresence pushes the server fires after
|
||||
// save propagate into this field. So the PostSaveReloading
|
||||
// sub-state's MasterReloadRoomAsync deserializes the cached
|
||||
// bytes from the OLD blob name (the one /goto returned at boot),
|
||||
// wiping any MakerPen object the user just placed even though
|
||||
// the new blob is already on S3.
|
||||
//
|
||||
// Verified live: after a dorm save with object placement, server
|
||||
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
|
||||
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
|
||||
// set_RoomDataBlobName trace immediately after the save shows
|
||||
// old="v(N)" new="v(N)" — no propagation, no fresh download,
|
||||
// no respawn. Leave+return triggers /goto which DOES update
|
||||
// RoomDataBlobName, so the fix is to do the same write
|
||||
// ourselves: when entering the SavingRoomSuccess state on the
|
||||
// RoomPersistenceManager, read the just-pushed
|
||||
// Rooms.LocalRoomScene.DataBlobName and force-set
|
||||
// __instance.RoomDataBlobName to it. That fires the real
|
||||
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
|
||||
// for the new blob → PostSaveReloading picks up the fresh
|
||||
// bytes and respawns the MakerPen objects.
|
||||
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
|
||||
"set_RoomDataBlobName",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
|
||||
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
|
||||
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
|
||||
// stash the just-arrived Scenes[0].DataBlobName into a static
|
||||
// so the set_RoomDataBlobName hijack can use it — otherwise
|
||||
// LocalRoomDetails / LocalRoomScene never get the new blob
|
||||
// name on the second-or-later push for the same (room, sub)
|
||||
// tuple (= every post-save push in a dorm).
|
||||
TryPatchByName("RecNet.Rooms",
|
||||
"OnSubscriptionUpdateRoom",
|
||||
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
|
||||
// Force AccountExtensions.CanLocalPlayerChat to return true. The
|
||||
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
|
||||
// Coroutine d__53 offset 040) gates on this — if false, the
|
||||
// coroutine bails before raising the Photon RPC, so the sender
|
||||
// doesn't even see their own message. The check ANDs three
|
||||
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
|
||||
// 0x40), a PlatformManager flag, and a ModerationManager
|
||||
// singleton field. At least one returns the wrong value against
|
||||
// our server. Force-allow — single-tenant private server, text
|
||||
// moderation isn't a concern.
|
||||
TryPatchByName("RecNet.AccountExtensions",
|
||||
"CanLocalPlayerChat",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
|
||||
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
|
||||
// font asset's character table via TMP_FontAsset.HasCharacters
|
||||
// to strip glyphs the font can't render. On 2020 Rec Room the
|
||||
// chat font field can be null at the moment this fires (font
|
||||
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
|
||||
// inside Unity's TMP — which propagates out through the chat
|
||||
// send callback and aborts the RPC. The watch's own ISIL has
|
||||
// "skipping invalid character check" log paths for missing
|
||||
// assets but doesn't actually short-circuit on the null field
|
||||
// we hit. Skip the method entirely; chat lines will just keep
|
||||
// any glyphs the font can't render (most common: emoji).
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RemoveInvalidCharactersFromMessage",
|
||||
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
|
||||
// Trace hooks so we can see exactly where chat dies. Send path:
|
||||
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
|
||||
// → PlayerEmotes.SendChatEmote(msg, true)
|
||||
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
|
||||
// → b__0 callback (error, cleanVersion) — fires Photon RPC
|
||||
// AND local-echoes via ProcessNewChatMessageReceived
|
||||
// → on each receiver (including self): RpcChatEmote(msg) →
|
||||
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
|
||||
// the chat log buffer.
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"SendChatEmote",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"ProcessNewChatMessageReceived",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RpcChatEmote",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
||||
|
||||
// Diagnostic trace for the stuck first-dorm "Change Username?"
|
||||
// dialog (new accounts can't get past it; "Okay" highlights but
|
||||
// never dismisses the modal, on every device). This is LOG-ONLY —
|
||||
// no behaviour change. It bisects the failure for a brand-new
|
||||
// account that reproduces the freeze, writing to
|
||||
// MelonLoader/UserData/dorknet-diagnostics.log:
|
||||
//
|
||||
// 1. RegistrationModel.IsFullyRegistered() return value — if
|
||||
// false, the dorm prompt path runs; if true, no prompt (so a
|
||||
// freeze here would be something else entirely).
|
||||
// 2. DormroomSceneManager.<PromptForRegistration>b__6_* lambdas —
|
||||
// which prompt branch executed (b__6_1 = a dialog was raised).
|
||||
// 3. ConfirmUIDialog.Button_Affirmative/Negative/NotNow/Cancel —
|
||||
// whether the click actually reaches the dialog's button
|
||||
// handler when the user taps Okay. If these FIRE but the modal
|
||||
// stays, the break is in the resolve/hide completer
|
||||
// (UIDialog`1.KHKDGPHPIND's guard); if they DON'T fire, the
|
||||
// click never routes to the button (input/raycast) and the fix
|
||||
// is elsewhere. Either way the next step is unambiguous.
|
||||
if (Cfg.TraceRegistrationDialog)
|
||||
{
|
||||
TryPatchByName("RRUI.Data.NUX.RegistrationModel",
|
||||
"IsFullyRegistered",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(RegistrationPatches.IsFullyRegistered_Postfix));
|
||||
|
||||
// The two registration dialogs are raised from
|
||||
// DormroomSceneManager's PromptForRegistration via its
|
||||
// compiler-generated <>c lambdas (b__6_0..b__6_3). Reuse the
|
||||
// same nested-lambda patcher the join trace uses.
|
||||
PatchNestedLambdas("DormroomSceneManager", "PromptForRegistration",
|
||||
nameof(RegistrationPatches.PromptLambda_Prefix));
|
||||
|
||||
// The confirm-dialog buttons. Clean, non-generic, no-arg names
|
||||
// on AGUI.StackedUI.Dialog.ConfirmUIDialog — Okay maps to
|
||||
// Button_Affirmative, the screenshot's only button.
|
||||
foreach (var (method, label) in new[]
|
||||
{
|
||||
("Button_Affirmative", nameof(RegistrationPatches.ButtonAffirmative_Prefix)),
|
||||
("Button_Negative", nameof(RegistrationPatches.ButtonNegative_Prefix)),
|
||||
("Button_NotNow", nameof(RegistrationPatches.ButtonNotNow_Prefix)),
|
||||
("Button_Cancel", nameof(RegistrationPatches.ButtonCancel_Prefix)),
|
||||
})
|
||||
{
|
||||
TryPatchByName("AGUI.StackedUI.Dialog.ConfirmUIDialog",
|
||||
method, args: Type.EmptyTypes, prefix: label);
|
||||
}
|
||||
}
|
||||
|
||||
RegisterJoinTracePatches();
|
||||
|
||||
// Anti-tamper / ToxMod / file-hash-checker are anti-cheat BYPASS
|
||||
// patches — core, always on, or the modded client gets kicked.
|
||||
RegisterAntiTamperCallbackPatches(logMisses: true);
|
||||
RegisterToxModPatches(logMisses: true);
|
||||
PatchFileHashCheckerCallback();
|
||||
// Desktop Screen Sharing FPS override. Only register when a target
|
||||
// is set; the postfix on the gadget's Awake rewrites the baked
|
||||
// refresh-frequency field (and, if enabled, raises the Photon
|
||||
@@ -362,15 +210,8 @@ public class Mod : MelonMod
|
||||
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
|
||||
"Awake", args: Type.EmptyTypes,
|
||||
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
|
||||
Log.Msg($"[screenshare] override armed: target {Cfg.DesktopScreenShareFps} fps " +
|
||||
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
|
||||
}
|
||||
|
||||
TryPatchByName("RecRoom.Tools.MakerPenVisuals",
|
||||
"set_LaserPointerEnabled",
|
||||
args: new[] { typeof(bool) },
|
||||
prefix: nameof(MakerPenGiftPreviewPatches.LaserPointerEnabled_Prefix));
|
||||
|
||||
// RRO quest party-size bump. Prefix the runtime config-apply path so
|
||||
// we can read the config's Name (scope to the allowlisted quests) and
|
||||
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
|
||||
@@ -378,159 +219,172 @@ public class Mod : MelonMod
|
||||
if (Cfg.QuestMaxTeamSize > 0)
|
||||
{
|
||||
// The runtime team cap is built from the baked GameConfigurationAsset
|
||||
// (a readable ScriptableObject) by GameConfigurationAsset.GDECPANDBHJ()
|
||||
// (a readable ScriptableObject) by GameConfigurationAsset.FIFOMGAOOHH()
|
||||
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
|
||||
// struct-array) into the protobuf TeamSize. Prefix that builder and
|
||||
// bump the source array IN PLACE before it converts, scoped by
|
||||
// asset.Name. This avoids the obfuscated protobuf entirely (its
|
||||
// generic RepeatedField getter isn't emitted by Il2CppInterop).
|
||||
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
|
||||
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "GDECPANDBHJ",
|
||||
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "FIFOMGAOOHH",
|
||||
prefix: nameof(QuestTeamSize.Build_Prefix));
|
||||
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
|
||||
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
|
||||
TryPatchByName("GameSpawnManager", "KMPAOJGOOCH",
|
||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
|
||||
}
|
||||
|
||||
RegisterDiagnostics();
|
||||
Log.Msg("=== Client patches registered ===");
|
||||
}
|
||||
|
||||
// ── Room-join + watch-button trace ────────────────────────────────
|
||||
// Traces the full "press the Dorm Room button → join the room" chain
|
||||
// so a stuck join (e.g. the watch's Dorm Room button doing nothing)
|
||||
// is debuggable from the log alone. The chain, verified against the
|
||||
// 2020.12.18 IL2CPP dump:
|
||||
//
|
||||
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() (button)
|
||||
// → SessionManager.RunJoinRoom("DormRoom", …) (join entry)
|
||||
// → OJMCBOKJFOF.NHBPIIGDAJP(name) → GET /goto/room/{name}
|
||||
// rejects with "No such room" (OJMCBOKJFOF.txt:958-1019)
|
||||
// → .Then(SessionManager.RunJoinRoom(RoomInstance, …))
|
||||
// → on reject: SessionManager+<>c.<RunJoinRoom>b__165_1(error)
|
||||
// → NotificationManager.Play("An error occured", "…happyfox…")
|
||||
// (the "contact recroom.happyfox.com" toast)
|
||||
//
|
||||
// We can't express the obfuscated RunJoinRoom param types as a Type[]
|
||||
// for AccessTools, so every RunJoinRoom overload + every /goto overload
|
||||
// is patched by iterating declared methods by name, and the prefixes
|
||||
// read arguments generically via Harmony's `object[] __args`.
|
||||
private void RegisterJoinTracePatches()
|
||||
{
|
||||
if (_joinTracePatchesRegistered) return;
|
||||
Log.Msg("=== Registering room-join trace ===");
|
||||
|
||||
// Watch "Dorm Room" button. Void, no params, unique name —
|
||||
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
|
||||
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
|
||||
|
||||
// Every SessionManager.RunJoinRoom overload (string-name entry +
|
||||
// the RoomInstance inner legs). Logs the method + its arguments.
|
||||
PatchAllOverloads("SessionManager", "RunJoinRoom",
|
||||
nameof(JoinPatches.RunJoinRoom_Prefix));
|
||||
|
||||
// The /goto/room/{name|id} promise builders — both the string
|
||||
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
|
||||
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
|
||||
nameof(JoinPatches.Goto_Prefix));
|
||||
|
||||
// The RunJoinRoom promise-chain callbacks compiled onto the
|
||||
// SessionManager <>c display class — most importantly the reject
|
||||
// handler b__165_1(string error) that raises the happyfox toast.
|
||||
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
|
||||
// marker "b__" so we hit the callbacks but not the RunJoinRoom
|
||||
// state-machine MoveNext.
|
||||
PatchNestedLambdas("SessionManager", "RunJoinRoom",
|
||||
nameof(JoinPatches.JoinCallback_Prefix));
|
||||
|
||||
// Additionally capture the REJECT REASON string on b__165_1 (the
|
||||
// happyfox-toast handler). Matches only that one lambda; it ends
|
||||
// up with two prefixes (flow + error string), which is harmless.
|
||||
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
|
||||
nameof(JoinPatches.JoinError_Prefix));
|
||||
|
||||
_joinTracePatchesRegistered = true;
|
||||
Log.Msg("=== Room-join trace registered ===");
|
||||
}
|
||||
|
||||
// Patch every declared method on `typeName` named `methodName`,
|
||||
// regardless of signature, with a single prefix. Used for obfuscated
|
||||
// overload sets whose param types we can't name in a Type[].
|
||||
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
// Title-screen auto-login: with no remembered account, open the
|
||||
// sign-in page instead of creating a new account.
|
||||
if (!Cfg.AutoCreateAccount)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
|
||||
return false;
|
||||
TryPatchByName("RRUI.Data.TitleScreenFlowModel", "IAKHIJIHAPJ",
|
||||
prefix: nameof(TitleScreenLoginPatches.AutoLogin_Prefix));
|
||||
}
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
|
||||
// Rec Royale night mode: armed when enabled OR when a hotkey is set
|
||||
// (so it can be switched on in-game). Scene detection + mood apply
|
||||
// run from OnSceneWasLoaded / OnUpdate below.
|
||||
if (Cfg.RecRoyaleNightMode || !string.IsNullOrWhiteSpace(Cfg.RecRoyaleNightToggleKey))
|
||||
{
|
||||
_recRoyaleNightArmed = true;
|
||||
RecRoyaleNight.Register(HarmonyInstance);
|
||||
}
|
||||
}
|
||||
private bool _recRoyaleNightArmed;
|
||||
public override void OnSceneWasLoaded(int buildIndex, string sceneName)
|
||||
{
|
||||
if (_recRoyaleNightArmed) RecRoyaleNight.OnSceneLoaded(sceneName);
|
||||
}
|
||||
private bool PatchFileHashCheckerCallback()
|
||||
{
|
||||
var prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix));
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
|
||||
if (!MatchesFileHashCheckerCallbackType(type)) continue;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (!MatchesFileHashCheckerCallbackMethod(method)) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
|
||||
Log.Warning("[patch-miss] file hash checker callback");
|
||||
return false;
|
||||
}
|
||||
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Patch compiler-generated lambda methods (names containing "b__")
|
||||
// on the nested display classes of `typeName` whose (de-mangled) name
|
||||
// also contains `nameContains`. Il2CppInterop mangles the angle
|
||||
// brackets of `<RunJoinRoom>b__165_1` to `_003C…_003E` but leaves the
|
||||
// method name itself intact, so a plain substring match still works.
|
||||
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
|
||||
private void RegisterAntiTamperCallbackPatches(bool logMisses)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
|
||||
return false;
|
||||
}
|
||||
var complete = true;
|
||||
complete &= PatchCallbackMethod(
|
||||
"hile warning callback",
|
||||
type => TypeNameContains(type, "FPIBGPIAOBI") && TypeNameContains(type, "KHIIHOIFDMP"),
|
||||
method => MethodNameContains(method, "CreateHileWarning") && MethodNameContains(method, "b__0"),
|
||||
nameof(AntiTamperPatches.CreateHileWarningCallback_Prefix),
|
||||
logMisses);
|
||||
complete &= PatchCallbackMethod(
|
||||
"unknown DLL callback",
|
||||
type => TypeNameContains(type, "CheatManager") && TypeNameContains(type, "PNHDGEKCJCH"),
|
||||
method => MethodNameContains(method, "OnUnknownDLLDetected") && MethodNameContains(method, "b__0"),
|
||||
nameof(AntiTamperPatches.UnknownDllDetectedCallback_Prefix),
|
||||
logMisses);
|
||||
_antiTamperCallbackPatchesComplete = complete;
|
||||
}
|
||||
private void RegisterToxModPatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent",
|
||||
"CanInitializeToxMod",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(ToxModPatches.CanInitializeToxMod_Prefix),
|
||||
logMiss: logMisses);
|
||||
_toxModPatchesComplete = complete;
|
||||
}
|
||||
private bool PatchCallbackMethod(string label,
|
||||
Func<Type, bool> typePredicate,
|
||||
Func<MethodInfo, bool> methodPredicate,
|
||||
string prefix,
|
||||
bool logMiss)
|
||||
{
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(nested))
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
|
||||
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
|
||||
try
|
||||
if (!typePredicate(type)) continue;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (!methodPredicate(method)) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched == 0 && logMiss)
|
||||
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
|
||||
return patched > 0;
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
private static bool TypeNameContains(Type type, string value)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains(value, StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MethodNameContains(MethodInfo method, string value)
|
||||
=> method.Name.Contains(value, StringComparison.Ordinal);
|
||||
private static bool MatchesFileHashCheckerCallbackType(Type type)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains("BEPEAHOOLDB", StringComparison.Ordinal)
|
||||
&& fullName.Contains("PBENKLNHBHL", StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MatchesFileHashCheckerCallbackMethod(MethodInfo method)
|
||||
{
|
||||
if (method.ReturnType != typeof(void)) return false;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 1) return false;
|
||||
|
||||
var name = method.Name;
|
||||
if (name == "<InitializeFileHashChecker>b__0") return true;
|
||||
if (name == "_InitializeFileHashChecker_b__0") return true;
|
||||
return name.Contains("InitializeFileHashChecker", StringComparison.Ordinal)
|
||||
&& name.Contains("b__0", StringComparison.Ordinal);
|
||||
}
|
||||
private void RegisterNetworkPatches()
|
||||
{
|
||||
if (_networkPatchesRegistered) return;
|
||||
@@ -551,7 +405,6 @@ public class Mod : MelonMod
|
||||
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPManager");
|
||||
@@ -578,33 +431,27 @@ public class Mod : MelonMod
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
if (_recRoyaleNightArmed) RecRoyaleNight.Tick();
|
||||
|
||||
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
|
||||
{
|
||||
_firstUpdateLogged = true;
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
_antiTamperCallbackRetryFrame = 0;
|
||||
RegisterAntiTamperCallbackPatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (_diagnosticGameComplete) return;
|
||||
if (_diagnosticRetryFrame++ > 3600) return;
|
||||
if ((_diagnosticRetryFrame % 60) != 0) return;
|
||||
|
||||
var logMisses = (_diagnosticRetryFrame % 300) == 0;
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
|
||||
if (_diagnosticGameComplete)
|
||||
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
|
||||
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
|
||||
{
|
||||
_toxModRetryFrame = 0;
|
||||
RegisterToxModPatches(logMisses: false);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
private static void LoadConfig()
|
||||
{
|
||||
@@ -612,28 +459,21 @@ public class Mod : MelonMod
|
||||
{
|
||||
var userData = ResolveUserDataDirectory();
|
||||
Directory.CreateDirectory(userData);
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
|
||||
return;
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
|
||||
if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
||||
if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
||||
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
@@ -643,28 +483,39 @@ public class Mod : MelonMod
|
||||
rooms.Add(e.GetString()!);
|
||||
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
|
||||
}
|
||||
if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightMode", out v)) Cfg.RecRoyaleNightMode = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightMood", out v)) Cfg.RecRoyaleNightMood = v.GetString() ?? Cfg.RecRoyaleNightMood;
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightFlashlights", out v)) Cfg.RecRoyaleNightFlashlights = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightFog", out v)) Cfg.RecRoyaleNightFog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightExposure", out v)) Cfg.RecRoyaleNightExposure = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightSunScale", out v)) Cfg.RecRoyaleNightSunScale = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightAmbientScale", out v)) Cfg.RecRoyaleNightAmbientScale = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightForcePostProcessing", out v)) Cfg.RecRoyaleNightForcePostProcessing = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightMatchSunLights", out v)) Cfg.RecRoyaleNightMatchSunLights = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightDisableFog", out v)) Cfg.RecRoyaleNightDisableFog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightHideObjects", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var list = new List<string>();
|
||||
var names = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
{
|
||||
var s = e.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
|
||||
}
|
||||
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
names.Add(e.GetString()!);
|
||||
Cfg.RecRoyaleNightHideObjects = names.ToArray(); // explicit (incl. empty = hide nothing)
|
||||
}
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightToggleKey", out v)) Cfg.RecRoyaleNightToggleKey = v.GetString() ?? "";
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightScenes", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var scenes = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
scenes.Add(e.GetString()!);
|
||||
if (scenes.Count > 0) Cfg.RecRoyaleNightScenes = scenes.ToArray();
|
||||
}
|
||||
// "InjectAuthValues" key in the template is now ignored —
|
||||
// see attic/AuthValuesInjector.cs.attic.
|
||||
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveUserDataDirectory()
|
||||
{
|
||||
foreach (var typeName in new[]
|
||||
@@ -684,7 +535,6 @@ public class Mod : MelonMod
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
|
||||
return Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
}
|
||||
|
||||
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
@@ -702,8 +552,6 @@ public class Mod : MelonMod
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Patch dispatch helpers ────────────────────────────────────────
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
@@ -727,7 +575,6 @@ public class Mod : MelonMod
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {label}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -736,13 +583,6 @@ public class Mod : MelonMod
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
|
||||
// by walking every loaded assembly. Tries the exact name first, then
|
||||
// the same name with an `Il2Cpp` prefix, then a substring scan as a
|
||||
// last resort — covers all the IL2CPP interop naming schemes the user
|
||||
// might end up with depending on the MelonLoader / Il2CppInterop
|
||||
// version installed.
|
||||
private bool TryPatchByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
@@ -756,59 +596,6 @@ public class Mod : MelonMod
|
||||
}
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
|
||||
string fallbackMethodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
var label = $"{typeName}.<returns {returnTypeName}>";
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
var matches = new List<MethodInfo>();
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
|
||||
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
|
||||
}
|
||||
|
||||
if (matches.Count == 1)
|
||||
{
|
||||
var method = matches[0];
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.Count > 1)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var method in matches) names.Add(method.Name);
|
||||
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
|
||||
}
|
||||
else if (logMiss)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
|
||||
}
|
||||
|
||||
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
internal static Type? ResolveType(string name)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
@@ -820,6 +607,8 @@ public class Mod : MelonMod
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
|
||||
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
@@ -844,13 +633,11 @@ public class Mod : MelonMod
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type CacheResolvedType(string name, Type type)
|
||||
{
|
||||
ResolvedTypeCache[name] = type;
|
||||
return type;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||
{
|
||||
try
|
||||
@@ -871,25 +658,16 @@ public class Mod : MelonMod
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
var dottedPrefixed = "Il2Cpp." + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static bool ReturnTypeMatches(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
@@ -898,74 +676,16 @@ public class Mod : MelonMod
|
||||
names.Add(arg.Name);
|
||||
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||
}
|
||||
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
// Look in the core + feature patch holder classes.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
if (!_diagnosticCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||
_diagnosticCoreRegistered = true;
|
||||
}
|
||||
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||
Log.Msg("=== Diagnostics registered ===");
|
||||
}
|
||||
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
// CheatManager fires these when its anti-cheat heuristics trip —
|
||||
// logging them surfaces "client thinks it's been tampered with",
|
||||
// a common cause of silent dorm-drops / kicks on a modded client.
|
||||
// Names verified in CheatManager.txt for 2020.12.18; the older
|
||||
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
|
||||
// there's no DeveloperFlag/StreamingAsset callback nor a
|
||||
// BootSequence.RegisterError sink in this build — those legacy
|
||||
// hooks were removed (they only ever logged [patch-miss] forever).
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"OnTimeCheatDetected",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
"OnHeightCheatDetected",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
// methods) MonoMod's lazy CompileMethodHook on the trampoline
|
||||
// crashes the CLR with 0x80131506 the instant Awake JITs. The
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
return complete;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
@@ -982,626 +702,6 @@ public class Mod : MelonMod
|
||||
}
|
||||
}
|
||||
|
||||
// ── Patch holders ─────────────────────────────────────────────────────
|
||||
// Each holder is a static class with public static Prefix methods that
|
||||
// Harmony invokes. Kept separate from Mod for readability and so the
|
||||
// reflection lookup in GetPatchMethod stays simple.
|
||||
|
||||
// ── Debug console access ───────────────────────────────────────────────
|
||||
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
|
||||
// dev console (text input + a DebugConsoleCommandConfig command table:
|
||||
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
|
||||
// …). Retail gates it behind a developer flag. This holder force-toggles
|
||||
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
|
||||
// CheatManager's tamper detectors so movement/time commands don't drop you
|
||||
// to the dorm.
|
||||
//
|
||||
// Everything is resolved by reflection at runtime so the SAME code works on
|
||||
// both client builds despite their differing obfuscation:
|
||||
// * The show/hide toggle is the one non-compiler-generated private
|
||||
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
|
||||
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
|
||||
// one, then fall back to that structural match.
|
||||
// * The live component is found via UnityEngine.Object.FindObjectOfType
|
||||
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
|
||||
// * Execute(string) keeps its readable name on both builds (kept here as
|
||||
// a scripted-command entry point even though the hotkey drives the UI).
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _pollErrorLogged;
|
||||
private static int _probeFrame;
|
||||
private static bool _probeDone;
|
||||
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput();
|
||||
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||
// console object actually exists, so we learn that even if the
|
||||
// hotkey never fires.
|
||||
if (!_probeDone && _probeFrame++ > 180)
|
||||
{
|
||||
_probeDone = true;
|
||||
ProbeConsole();
|
||||
}
|
||||
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||
{
|
||||
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||
// whether the show/hide method resolved — without needing a keypress.
|
||||
private static void ProbeConsole()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
|
||||
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
|
||||
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
|
||||
public static void ExecuteCommand(string command)
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
|
||||
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
|
||||
execute.Invoke(null, new object[] { command });
|
||||
Mod.Log.Msg($"[debugconsole] executed: {command}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void ResolveInput()
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||
}
|
||||
|
||||
private static void ResolveConsole()
|
||||
{
|
||||
_consoleResolved = true;
|
||||
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||
|
||||
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
_findObjectOfType = objType is null ? null
|
||||
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
|
||||
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
// taking exactly one bool, excluding compiler-generated members (the
|
||||
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||
// [CompilerGenerated]).
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null; // ambiguous — don't guess
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
|
||||
try
|
||||
{
|
||||
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
|
||||
if (instance is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
return;
|
||||
}
|
||||
// Best-effort: flip the static "show button" enable so the
|
||||
// game's own Update keeps the console available instead of
|
||||
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||
EnableShowButton();
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||
{
|
||||
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Dev-menu runtime probe (one-shot diagnostic) ────────────────────────
|
||||
// Answers stripped-vs-gated for the watch developer menu. With a real dev
|
||||
// account + forced flag the menu still doesn't appear, so the gate isn't the
|
||||
// account flag — either the native dev-menu prefab content is stripped from
|
||||
// this retail build, or a build-level flag skips its init. This logs the
|
||||
// actual runtime state once ~5s after load (no behaviour change) so we know
|
||||
// whether to un-gate the game's own dev menu or build our own watch entry.
|
||||
// Called from OnUpdate when Cfg.DiagnoseDevMenu is set; self-gates to run
|
||||
// once. Not a Harmony holder — invoked directly, so it's not registered in
|
||||
// GetPatchMethod.
|
||||
internal static class DevMenuProbe
|
||||
{
|
||||
private static int _frame;
|
||||
private static bool _done;
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (_done) return;
|
||||
_frame++;
|
||||
if (_frame < 120) return; // ~2s — let MelonLoader/game boot
|
||||
if ((_frame % 60) != 0) return; // then check ~once per second
|
||||
// Only fire once a REAL, in-scene HomeScreenFlow exists with its
|
||||
// UIBase wired (it gets assigned when the watch StackedUI initialises
|
||||
// in the dorm). The earlier version fired during boot on the loaded
|
||||
// PREFAB (Resources.FindObjectsOfTypeAll returns prefabs/inactive,
|
||||
// whose runtime UIBase is null) and, being one-shot, captured nothing
|
||||
// and never retried. NO timeout now — we wait until the watch is up.
|
||||
var homeType = Mod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? Mod.ResolveType("HomeScreenFlow");
|
||||
var home = homeType != null ? FindLiveHome(homeType) : null;
|
||||
if (home == null)
|
||||
{
|
||||
if ((_frame % 600) == 0)
|
||||
Mod.Log.Msg("[devmenu-probe] waiting for an in-scene HomeScreenFlow — enter your dorm / open the watch…");
|
||||
return;
|
||||
}
|
||||
_done = true;
|
||||
try { Run(homeType!, home); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] failed: {ex}"); }
|
||||
}
|
||||
|
||||
// The live HomeScreenFlow (UIBase wired), skipping the prefab/inactive
|
||||
// copies FindObjectsOfTypeAll also returns.
|
||||
private static object? FindLiveHome(Type homeType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2 = ToIl2CppType(homeType);
|
||||
if (il2 is null) return null;
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2)?.Invoke(null, new[] { il2 });
|
||||
int n = ArrLen(all);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var o = ArrItem(all, i);
|
||||
if (o is null) continue;
|
||||
if (GetMemberValue(homeType, o, "UIBase", "get_UIBase") != null) return o;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Run(Type homeType, object homeInst)
|
||||
{
|
||||
Mod.Log.Msg("=== [devmenu-probe] runtime dev-UI state ===");
|
||||
ProbeType("RecRoom.Core.WatchUI", "WatchUI", DumpWatchUi);
|
||||
Mod.Log.Msg($"[devmenu-probe] HomeScreenFlow: type={homeType.FullName}, liveInstance=FOUND (UIBase wired)");
|
||||
try { DumpHomeScreen(homeType, homeInst); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] HomeScreenFlow dump failed: {ex.Message}"); }
|
||||
ProbeType("RecRoom.Debugging.DebugConsole", "DebugConsole", DumpDebugConsole);
|
||||
Mod.Log.Msg("=== [devmenu-probe] done ===");
|
||||
}
|
||||
|
||||
private static void ProbeType(string fullName, string shortName, Action<Type, object?> dump)
|
||||
{
|
||||
var t = Mod.ResolveType(fullName) ?? Mod.ResolveType(shortName);
|
||||
if (t is null) { Mod.Log.Msg($"[devmenu-probe] {shortName}: TYPE NOT FOUND"); return; }
|
||||
var inst = FindOne(t);
|
||||
Mod.Log.Msg($"[devmenu-probe] {shortName}: type={t.FullName}, liveInstance={(inst is null ? "null" : "FOUND")}");
|
||||
try { dump(t, inst); } catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] {shortName} dump failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void DumpHomeScreen(Type t, object? inst)
|
||||
{
|
||||
if (inst is null) { Mod.Log.Msg(" (no HomeScreenFlow instance live yet — open the watch, then re-probe)"); return; }
|
||||
// Il2CppInterop exposes IL2CPP instance fields as PROPERTIES on the
|
||||
// managed proxy, so a plain AccessTools.Field misses them (the earlier
|
||||
// "field not found" was a false negative). Try property first, then
|
||||
// field. NULL => content stripped from the prefab (can't un-gate);
|
||||
// present => only gated.
|
||||
foreach (var name in new[] { "devMenuPanel", "devButtonPrefab", "reportBugButtonSpacer" })
|
||||
{
|
||||
var (found, mt, v) = GetMember(t, inst, name);
|
||||
if (!found) { Mod.Log.Msg($" {name}: <not found as property or field>"); continue; }
|
||||
var extra = "";
|
||||
if (v != null && name == "devMenuPanel")
|
||||
{
|
||||
try { var cc = v.GetType().GetProperty("childCount")?.GetValue(v); if (cc != null) extra = $" childCount={cc}"; } catch { }
|
||||
}
|
||||
Mod.Log.Msg($" {name} ({mt.Name}): {(v is null ? "NULL <-- stripped/unset" : "present")}{extra}");
|
||||
}
|
||||
// Calibration: are ordinary serialized field names exposed at all on
|
||||
// the runtime proxy? If 'profileButton' (a non-dev [SerializeField])
|
||||
// IS found but devMenuPanel isn't, the dev fields are genuinely absent
|
||||
// in this build. If profileButton is ALSO not found, Il2CppInterop
|
||||
// isn't exposing these fields by name (need a different access path).
|
||||
foreach (var cal in new[] { "profileButton", "settingsButton" })
|
||||
{
|
||||
var (cf, cmt, cv) = GetMember(t, inst, cal);
|
||||
Mod.Log.Msg($" [calib] {cal}: {(cf ? $"FOUND ({cmt.Name}, {(cv is null ? "null" : "present")})" : "not found")}");
|
||||
}
|
||||
// Named lookups miss → dump the proxy's actual member names to see
|
||||
// Il2CppInterop's scheme, then enumerate Button3D children directly
|
||||
// (no field names needed) — those are our clone sources for injection.
|
||||
var fs = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var ps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var fn = new List<string>(); foreach (var f in fs) fn.Add(f.Name);
|
||||
var pn = new List<string>(); foreach (var p in ps) pn.Add(p.Name);
|
||||
Mod.Log.Msg($" [members] fields={fn.Count}, props={pn.Count}");
|
||||
if (fn.Count > 0) Mod.Log.Msg($" [fieldnames] {string.Join(", ", fn.GetRange(0, Math.Min(40, fn.Count)))}");
|
||||
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
|
||||
DumpWatchButtons(t, inst);
|
||||
DumpCollections(t, inst, "HomeScreenFlow");
|
||||
DumpStackedUi(t, inst);
|
||||
}
|
||||
|
||||
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
|
||||
// to build a CUSTOM watch page by cloning a real flow:
|
||||
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
|
||||
// are our clone-target candidates (pick a simple self-contained one).
|
||||
// * the Type->flow cache — what's already instantiated (do NOT mutate
|
||||
// these cached singletons; clone an independent copy instead).
|
||||
// * ScreenHeader presence — proves a cloned flow brings its own title
|
||||
// bar + back button for free.
|
||||
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
|
||||
// Dictionary<Type,flow>) rather than by field name.
|
||||
private static void DumpStackedUi(Type homeType, object homeInst)
|
||||
{
|
||||
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
|
||||
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
|
||||
var uiType = ui.GetType();
|
||||
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
|
||||
|
||||
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
|
||||
if (hdr != null)
|
||||
{
|
||||
var ht = hdr.GetType();
|
||||
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
|
||||
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
|
||||
}
|
||||
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
|
||||
|
||||
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
object? v; try { v = f.GetValue(ui); } catch { continue; }
|
||||
if (v is null) continue;
|
||||
int n = ArrLen(v);
|
||||
// Flow-prefab catalog: a non-empty array whose elements expose a
|
||||
// TypeName string (FlowPrefabResourceReference).
|
||||
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
var first = ArrItem(v, 0);
|
||||
var et = first?.GetType().Name ?? "?";
|
||||
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var e = ArrItem(v, i); if (e is null) continue;
|
||||
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
|
||||
var vt = v.GetType();
|
||||
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
|
||||
{
|
||||
var ga = vt.GetGenericArguments();
|
||||
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
var cp = vt.GetProperty("Count");
|
||||
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Property by name, else parameterless getter method, else null.
|
||||
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
|
||||
{
|
||||
var (found, _, v) = GetMember(t, inst, prop);
|
||||
if (found && v != null) return v;
|
||||
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
null, Type.EmptyTypes, null);
|
||||
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
|
||||
return v;
|
||||
}
|
||||
|
||||
// First non-empty string field/property on an object — for obfuscated
|
||||
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
|
||||
private static string ReadStringMember(object o)
|
||||
{
|
||||
var t = o.GetType();
|
||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
|
||||
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
if (f.FieldType == typeof(string))
|
||||
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||
return "(no string member)";
|
||||
}
|
||||
|
||||
// Enumerate Button3D components under the HomeScreenFlow via Unity's
|
||||
// GetComponentsInChildren(Type, includeInactive) — needs no field names.
|
||||
// Confirms we can grab native buttons to clone for the dev entry.
|
||||
private static void DumpWatchButtons(Type homeType, object homeInst)
|
||||
{
|
||||
var btnType = Mod.ResolveType("AGUI.StackedUI.Button3D");
|
||||
if (btnType is null) { Mod.Log.Msg(" [buttons] Button3D type NOT FOUND"); return; }
|
||||
var il2 = ToIl2CppType(btnType);
|
||||
if (il2 is null) { Mod.Log.Msg(" [buttons] Button3D il2cppType null"); return; }
|
||||
MethodInfo? m = null;
|
||||
foreach (var mi in homeType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (mi.Name != "GetComponentsInChildren") continue;
|
||||
var mp = mi.GetParameters();
|
||||
if (mp.Length == 2 && mp[0].ParameterType.IsInstanceOfType(il2) && mp[1].ParameterType == typeof(bool)) { m = mi; break; }
|
||||
}
|
||||
if (m is null) { Mod.Log.Msg(" [buttons] GetComponentsInChildren(Type,bool) not found"); return; }
|
||||
object? arr;
|
||||
try { arr = m.Invoke(homeInst, new object[] { il2, true }); }
|
||||
catch (Exception ex) { Mod.Log.Msg($" [buttons] GetComponentsInChildren threw: {(ex.InnerException ?? ex).Message}"); return; }
|
||||
int n = ArrLen(arr);
|
||||
Mod.Log.Msg($" [buttons] Button3D children: {n}");
|
||||
for (int i = 0; i < n && i < 20; i++)
|
||||
{
|
||||
var b = ArrItem(arr, i);
|
||||
var nm = b?.GetType().GetProperty("name")?.GetValue(b)?.ToString() ?? "?";
|
||||
Mod.Log.Msg($" [{i}] {nm}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ArrLen(object? arr)
|
||||
{
|
||||
if (arr is null) return 0;
|
||||
if (arr is Array a) return a.Length;
|
||||
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||
return (lp?.GetValue(arr) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? ArrItem(object? arr, int i)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.GetValue(i);
|
||||
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return gi?.Invoke(arr, new object[] { i });
|
||||
}
|
||||
|
||||
private static (bool found, Type type, object? val) GetMember(Type t, object inst, string name)
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ try { return (true, p.PropertyType, p.GetValue(inst)); } catch { } }
|
||||
var f = AccessTools.Field(t, name);
|
||||
if (f != null) { try { return (true, f.FieldType, f.GetValue(inst)); } catch { } }
|
||||
return (false, typeof(object), null);
|
||||
}
|
||||
|
||||
private static void DumpWatchUi(Type t, object? inst)
|
||||
{
|
||||
if (inst is null) { Mod.Log.Msg(" (no WatchUI instance live yet)"); return; }
|
||||
DumpCollections(t, inst, "WatchUI");
|
||||
}
|
||||
|
||||
private static void DumpDebugConsole(Type t, object? inst)
|
||||
{
|
||||
foreach (var name in new[] { "GJIKMPIMDHH", "IEGCNLHPKFH", "ShowButton", "ShowErrorsInHeadset", "ShowUnityDevelopmentConsole" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (p != null && p.CanRead) { Mod.Log.Msg($" static {name} = {p.GetValue(null)}"); continue; }
|
||||
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f != null) Mod.Log.Msg($" static {name} = {f.GetValue(null)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Msg($" {name}: read failed ({ex.Message})"); }
|
||||
}
|
||||
// Does the static command path work without the console UI in-scene?
|
||||
var exec = AccessTools.Method(t, "Execute", new[] { typeof(string) });
|
||||
if (exec is null) { Mod.Log.Msg(" Execute(string): <not found>"); return; }
|
||||
try { exec.Invoke(null, new object[] { "help" }); Mod.Log.Msg(" Execute(\"help\"): invoked OK — static command path is usable"); }
|
||||
catch (Exception ex) { var e = ex.InnerException ?? ex; Mod.Log.Msg($" Execute(\"help\"): threw {e.GetType().Name} ({e.Message})"); }
|
||||
}
|
||||
|
||||
// Log every array / List<> field whose element type mentions Flow/Watch,
|
||||
// with its count — surfaces the dev-flow list (devOnlyFlows-equivalent)
|
||||
// regardless of its obfuscated name, so we can see if dev flows exist.
|
||||
private static void DumpCollections(Type t, object inst, string label)
|
||||
{
|
||||
var members = new List<(string name, object? val)>();
|
||||
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{ try { members.Add((f.Name, f.GetValue(inst))); } catch { } }
|
||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{ if (p.CanRead && p.GetIndexParameters().Length == 0) { try { members.Add((p.Name, p.GetValue(inst))); } catch { } } }
|
||||
|
||||
foreach (var (name, v) in members)
|
||||
{
|
||||
if (v is null) continue;
|
||||
int? count = null; string elem = "";
|
||||
if (v is Array arr) { count = arr.Length; elem = v.GetType().GetElementType()?.Name ?? "?"; }
|
||||
else
|
||||
{
|
||||
var ft = v.GetType();
|
||||
if (ft.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(ft))
|
||||
{
|
||||
var cp = ft.GetProperty("Count");
|
||||
if (cp != null && cp.PropertyType == typeof(int))
|
||||
{
|
||||
count = (int?)cp.GetValue(v);
|
||||
var ga = ft.GetGenericArguments();
|
||||
elem = ga.Length > 0 ? ga[0].Name : "?";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count.HasValue &&
|
||||
(elem.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
elem.IndexOf("Watch", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
Mod.Log.Msg($" {label}.{name}: {elem}[{count}]");
|
||||
}
|
||||
}
|
||||
|
||||
// Under Il2CppInterop, UnityEngine.Object.FindObjectOfType takes an
|
||||
// Il2CppSystem.Type (NOT System.Type), so AccessTools.Method(...,
|
||||
// typeof(Type)) misses — that's the "FindObjectOfType(Type) not found"
|
||||
// in the log. Convert the managed Type to an Il2CppSystem.Type via
|
||||
// Il2CppType.From, then use Resources.FindObjectsOfTypeAll (which also
|
||||
// returns INACTIVE objects + loaded prefab instances — important, the
|
||||
// watch UI is usually inactive until opened). Falls back to the
|
||||
// active-only FindObjectOfType.
|
||||
private static object? FindOne(Type t)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(t);
|
||||
if (il2cppType is null) return null;
|
||||
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType);
|
||||
var first = FirstOf(all?.Invoke(null, new[] { il2cppType }));
|
||||
if (first != null) return first;
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||
return findOne?.Invoke(null, new[] { il2cppType });
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static object? ToIl2CppType(Type managed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||
if (from is null) return null;
|
||||
var args = from.GetParameters().Length == 2
|
||||
? new object[] { managed, true } : new object[] { managed };
|
||||
return from.Invoke(null, args);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Find a public static method `name` whose single parameter accepts `arg`.
|
||||
private static MethodInfo? OneArgMethod(Type? t, string name, object arg)
|
||||
{
|
||||
if (t is null) return null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != name) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// First element of an Il2CppReferenceArray / Array / IList, or null.
|
||||
private static object? FirstOf(object? arr)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.Length > 0 ? a.GetValue(0) : null;
|
||||
var at = arr.GetType();
|
||||
var lenProp = at.GetProperty("Length") ?? at.GetProperty("Count");
|
||||
if (lenProp?.GetValue(arr) is int n && n > 0)
|
||||
{
|
||||
var getItem = at.GetMethod("get_Item", new[] { typeof(int) });
|
||||
if (getItem != null) return getItem.Invoke(arr, new object[] { 0 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desktop Screen Sharing FPS override ─────────────────────────────────
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts the
|
||||
// shared desktop image. Capture cadence is the baked [SerializeField]
|
||||
// float screenShareImageRefreshFrequency; the broadcast tick gates on
|
||||
// if (Time.time - lastSend >= 1f / screenShareImageRefreshFrequency)
|
||||
// (DesktopScreenSharingDisplay.txt:697-703) — so the field is literally the
|
||||
// FPS, with no runtime clamp (the [Range] is editor-only). We rewrite it in
|
||||
// an Awake postfix. The captured frame is transmitted via the component's
|
||||
// IPunObservable OnPhotonSerializeView, which only fires at
|
||||
// PhotonNetwork.SerializationRate (default 10 Hz), so we also lift the
|
||||
// Photon send/serialization rate to cover the target — otherwise a 30 fps
|
||||
// capture still only delivers ~10. Field names are un-obfuscated on both
|
||||
// builds (confirmed in the 2020.12.18 dump), so a plain AccessTools.Field
|
||||
// lookup works.
|
||||
internal static class ScreenSharePatches
|
||||
{
|
||||
private static bool _photonRateApplied;
|
||||
@@ -1620,9 +720,6 @@ internal static class ScreenSharePatches
|
||||
if (Mod.Cfg.DesktopScreenShareQuality > 0)
|
||||
SetField(t, __instance, "screenShareImageCompressionQuality", Mod.Cfg.DesktopScreenShareQuality);
|
||||
|
||||
Mod.Log.Msg($"[screenshare] {t.Name}: refreshFrequency→{fps}fps" +
|
||||
(Mod.Cfg.DesktopScreenShareResolution > 0 ? $" res→{Mod.Cfg.DesktopScreenShareResolution}px" : "") +
|
||||
(Mod.Cfg.DesktopScreenShareQuality > 0 ? $" quality→{Mod.Cfg.DesktopScreenShareQuality}" : ""));
|
||||
|
||||
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
|
||||
RaisePhotonRates((int)Math.Ceiling(fps));
|
||||
@@ -1653,7 +750,6 @@ internal static class ScreenSharePatches
|
||||
WriteInt(pn, "SendRate", newSend);
|
||||
WriteInt(pn, "SerializationRate", target);
|
||||
_photonRateApplied = true;
|
||||
Mod.Log.Msg($"[screenshare] Photon rates raised (GLOBAL): SendRate {send}→{newSend}, SerializationRate {ser}→{target}");
|
||||
}
|
||||
|
||||
private static int ReadInt(Type t, string name)
|
||||
@@ -1673,55 +769,18 @@ internal static class ScreenSharePatches
|
||||
if (f is not null) f.SetValue(null, value);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MakerPenGiftPreviewPatches
|
||||
{
|
||||
private static bool _loggedMissingPointer;
|
||||
private static bool _loggedMissingRestrictionFlag;
|
||||
|
||||
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__instance is null) return true;
|
||||
var t = __instance.GetType();
|
||||
if (ReadField(__instance, t, "pointerLineRenderer") is null)
|
||||
{
|
||||
if (!_loggedMissingPointer)
|
||||
{
|
||||
_loggedMissingPointer = true;
|
||||
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
|
||||
{
|
||||
if (!_loggedMissingRestrictionFlag)
|
||||
{
|
||||
_loggedMissingRestrictionFlag = true;
|
||||
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object? ReadField(object instance, Type type, string name)
|
||||
{
|
||||
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
|
||||
return field?.GetValue(instance);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class UriPatches
|
||||
{
|
||||
public static void HttpRequestSetUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-seturi-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpRequestPrepareUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||
{
|
||||
RewriteString(ref url, "httpmanager-rewrite");
|
||||
@@ -1746,14 +805,24 @@ internal static class UriPatches
|
||||
{
|
||||
try
|
||||
{
|
||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
||||
if (request is null) return;
|
||||
var type = request.GetType();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
|
||||
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
|
||||
|
||||
uriProp.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriProperty(request, prop, label)) return;
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriField(request, field, label)) return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1761,15 +830,51 @@ internal static class UriPatches
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteUriArg(ref Uri uri, string label)
|
||||
{
|
||||
if (uri is null) return;
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
uri = rewritten;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label)
|
||||
{
|
||||
if (prop is null || !prop.CanRead || !prop.CanWrite) return false;
|
||||
if (prop.GetIndexParameters().Length != 0) return false;
|
||||
if (prop.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
prop.SetValue(request, rewritten);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriField(object request, FieldInfo? field, string label)
|
||||
{
|
||||
if (field is null || field.IsInitOnly) return false;
|
||||
if (field.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
field.SetValue(request, rewritten);
|
||||
return true;
|
||||
}
|
||||
|
||||
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 (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
|
||||
|
||||
var rewrittenUri = RewriteUri(uri);
|
||||
if (rewrittenUri is null) return;
|
||||
|
||||
var rewritten = rewrittenUri.ToString();
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
}
|
||||
|
||||
@@ -1788,582 +893,88 @@ internal static class UriPatches
|
||||
}.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PhotonPatches
|
||||
internal static class AntiCheatPatches
|
||||
{
|
||||
public static void PhotonAppIdOverride_Prefix()
|
||||
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return;
|
||||
try
|
||||
{
|
||||
// PhotonNetwork.PhotonServerSettings — accessed via reflection
|
||||
// so the type-name resolution stays in one place. Mutating the
|
||||
// ScriptableObject in-place is what the BepInEx plugin does
|
||||
// too; the Photon Connect path reads AppID / VoiceAppID off
|
||||
// this object immediately after.
|
||||
var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork");
|
||||
if (photonNetwork is null) return;
|
||||
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
|
||||
var settings = settingsProp?.GetValue(null);
|
||||
if (settings is null) return;
|
||||
|
||||
var settingsType = settings.GetType();
|
||||
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
|
||||
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
|
||||
|
||||
var newId = Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
|
||||
|
||||
// Diagnostic: dump every readable AppSettings field before
|
||||
// we mutate, so a Photon Custom Auth failure on first connect
|
||||
// is debuggable from a single log dump. Lists every public
|
||||
// property + field on PhotonServerSettings (Server, Port,
|
||||
// NameServer, AuthMode, HostType, PreferredRegion, AppID,
|
||||
// VoiceAppID, BestRegionSummary, etc.) — covers both 2020.03
|
||||
// (Server/Port) and 2020.12 (AppSettings) layouts.
|
||||
Mod.Log.Msg("[photon-diag] PhotonServerSettings BEFORE override:");
|
||||
DumpSettings(settings, settingsType, indent: " ");
|
||||
|
||||
appIdProp?.SetValue(settings, newId);
|
||||
voiceProp?.SetValue(settings, newVoice);
|
||||
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}");
|
||||
|
||||
// Force a deterministic Photon region so two watches running
|
||||
// this mod end up on the same Photon master and "JoinByName"
|
||||
// produces ONE shared room (not two parallel ones in eu/us
|
||||
// because each watch auto-picked a different best region).
|
||||
//
|
||||
// The 2020 watch's default HostType is BestRegion(4) — every
|
||||
// launch pings all Photon Cloud regions and picks the lowest
|
||||
// latency. Two players geographically apart hit different
|
||||
// "best" regions; same room name, two regions = two rooms.
|
||||
// Server-side our matchmaking response carries
|
||||
// `photonRegionId` but the watch's Photon code never reads
|
||||
// it; the only consumer is Matchmaking.SetPhotonRegionPings
|
||||
// which only reports pings *back* to the server.
|
||||
//
|
||||
// Strategy: switch HostType to PhotonCloud(1) and set
|
||||
// PreferredRegion. Prefer the server-told region (the most
|
||||
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
|
||||
// any) over the config default — that way a server-side
|
||||
// region change propagates without redeploying the mod.
|
||||
ApplyForcedRegion(settings, settingsType);
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); }
|
||||
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
|
||||
/// and writes <c>PreferredRegion</c>. Honors any region the server
|
||||
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
|
||||
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
|
||||
/// instance has been received yet (initial boot).</summary>
|
||||
private static void ApplyForcedRegion(object settings, Type settingsType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostTypeProp = settingsType.GetField("HostType")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
|
||||
var preferredProp = settingsType.GetField("PreferredRegion")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
|
||||
if (hostTypeProp is null || preferredProp is null)
|
||||
{
|
||||
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
|
||||
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
|
||||
// type from the field/property so we don't depend on a
|
||||
// specific Il2Cpp proxy namespace.
|
||||
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)hostTypeProp).PropertyType;
|
||||
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)preferredProp).PropertyType;
|
||||
|
||||
// Prefer server-provided region over config. We read it via
|
||||
// the same reflection style as the existing patches so the
|
||||
// Il2Cpp type-name suffix doesn't bite us.
|
||||
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
|
||||
|
||||
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
|
||||
object regionValue;
|
||||
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
|
||||
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
|
||||
region = "us";
|
||||
}
|
||||
|
||||
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
|
||||
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
|
||||
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
|
||||
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
|
||||
|
||||
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Reads
|
||||
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
|
||||
/// lowercase enum name (eu, us, asia, …). Returns null if no room
|
||||
/// instance has been received yet, or if any link in the chain is
|
||||
/// null. Server-told region wins over the static config default
|
||||
/// because the server is authoritative about which Photon region
|
||||
/// hosts the room the watch is about to join.</summary>
|
||||
private static string? ReadServerProvidedRegion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var matchmaking = FindGameType("RecNet.Matchmaking");
|
||||
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localRoom is null) return null;
|
||||
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
|
||||
if (regionObj is null) return null;
|
||||
// CloudRegionCode enum.ToString() gives the lowercase region
|
||||
// name (eu, us, asia, …) — see CloudRegionCode.cs.
|
||||
return regionObj.ToString();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// AuthValuesInjector_Prefix parked 2026-05-28 — never wired to a
|
||||
// harmony hook in this branch anyway. See
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.FJOLIPKKIBE</c> — the
|
||||
/// 2020.12 watch's private AppSettings builder. <c>__result</c> is the
|
||||
/// <c>Photon.Realtime.AppSettings</c> it just built.
|
||||
///
|
||||
/// **CRITICAL OVERRIDE**: replaces <c>AppIdRealtime</c> and
|
||||
/// <c>AppIdVoice</c> with our configured values before the AppSettings
|
||||
/// reaches <c>PhotonNetwork.ConnectUsingSettings</c>. The 2020.12
|
||||
/// watch hardcodes Rec Room's official AppIds as IL2CPP string
|
||||
/// literals (verified at <c>PUNNetworkManager.txt:7205,7208</c>:
|
||||
/// <c>"9372aa8d-..."</c> and <c>"e93ae440-..."</c>). Patching
|
||||
/// resources.assets at offsets 0x7962480 / 0x79624ac doesn't help —
|
||||
/// those are unrelated leftover strings, NOT the AppIds the SDK
|
||||
/// actually reads. The only reliable swap point is here, after
|
||||
/// FJOLIPKKIBE constructs the AppSettings object but before the
|
||||
/// LoadBalancingClient consumes it.
|
||||
///
|
||||
/// Then dumps every readable field/property for diagnostics.</summary>
|
||||
public static void LogAppSettings_Postfix(object __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__result is null) { Mod.Log.Msg("[photon-diag] FJOLIPKKIBE returned null AppSettings"); return; }
|
||||
|
||||
// ── AppId injection ────────────────────────────────────────
|
||||
var t = __result.GetType();
|
||||
var newPun = string.IsNullOrEmpty(Mod.Cfg.PhotonAppId) ? null : Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId)
|
||||
? (newPun ?? null)
|
||||
: Mod.Cfg.PhotonVoiceAppId;
|
||||
if (newPun is not null)
|
||||
{
|
||||
var realtimeProp = t.GetProperty("AppIdRealtime") ?? t.GetProperty("AppIdFusion");
|
||||
var oldPun = realtimeProp?.GetValue(__result) as string;
|
||||
realtimeProp?.SetValue(__result, newPun);
|
||||
Mod.Log.Msg($"[photon-override] AppIdRealtime: {oldPun} → {newPun}");
|
||||
}
|
||||
if (newVoice is not null)
|
||||
{
|
||||
var voiceProp = t.GetProperty("AppIdVoice");
|
||||
var oldVoice = voiceProp?.GetValue(__result) as string;
|
||||
voiceProp?.SetValue(__result, newVoice);
|
||||
Mod.Log.Msg($"[photon-override] AppIdVoice: {oldVoice} → {newVoice}");
|
||||
}
|
||||
// Also force FixedRegion to the configured region so two clients
|
||||
// never end up on different regions of the same room name.
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.PhotonCloudRegion))
|
||||
{
|
||||
var fixedRegionProp = t.GetProperty("FixedRegion");
|
||||
if (fixedRegionProp is not null && fixedRegionProp.CanWrite)
|
||||
{
|
||||
fixedRegionProp.SetValue(__result, Mod.Cfg.PhotonCloudRegion);
|
||||
Mod.Log.Msg($"[photon-override] FixedRegion → {Mod.Cfg.PhotonCloudRegion}");
|
||||
}
|
||||
}
|
||||
|
||||
Mod.Log.Msg("[photon-diag] FJOLIPKKIBE built AppSettings (post-override):");
|
||||
DumpSettings(__result, t, indent: " ");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAppSettings/override failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.EMGGKBALALH</c> — the
|
||||
/// 2020.12 watch's private AuthenticationValues builder. <c>__result</c>
|
||||
/// is the AuthValues it just built with environment + accessToken
|
||||
/// AuthGetParameters. Logs AuthType, UserId, and the full
|
||||
/// AuthGetParameters / AuthPostData payload that Photon Cloud will
|
||||
/// forward to the configured Custom Auth URL.</summary>
|
||||
public static void LogAuthValues_Postfix(object __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__result is null)
|
||||
{
|
||||
Mod.Log.Msg("[photon-diag] EMGGKBALALH returned null AuthValues (anonymous connect)");
|
||||
return;
|
||||
}
|
||||
var t = __result.GetType();
|
||||
string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? "<null>";
|
||||
var authType = Read("AuthType");
|
||||
var userId = Read("UserId");
|
||||
var getParams = t.GetProperty("AuthGetParameters")?.GetValue(__result)?.ToString() ?? "<null>";
|
||||
var postData = t.GetProperty("AuthPostData")?.GetValue(__result);
|
||||
var postStr = postData is byte[] bytes
|
||||
? $"<{bytes.Length} bytes: {System.Text.Encoding.UTF8.GetString(bytes)}>"
|
||||
: postData?.ToString() ?? "<null>";
|
||||
Mod.Log.Msg($"[photon-diag] EMGGKBALALH built AuthValues: AuthType={authType} UserId={userId}");
|
||||
Mod.Log.Msg($"[photon-diag] AuthGetParameters = {getParams}");
|
||||
Mod.Log.Msg($"[photon-diag] AuthPostData = {postStr}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c>
|
||||
/// — the watch's UI-error formatter only logs a truncated Unity-
|
||||
/// stringified version of the debugMessage. Capturing the raw
|
||||
/// string before the watch's handler runs gives us the FULL
|
||||
/// Photon error including the URL Photon Cloud actually tried to
|
||||
/// call (which is the smoking-gun answer to "is the dashboard URL
|
||||
/// saved or is Photon hitting a stale one").</summary>
|
||||
public static void OnCustomAuthenticationFailed_Prefix(object __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var debugMessage = __0?.ToString() ?? "<null>";
|
||||
Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:");
|
||||
Mod.Log.Error(debugMessage);
|
||||
// Also dump current AppSettings — if Photon rejected the auth
|
||||
// we want to know what the watch's actual final config looked
|
||||
// like (post-override).
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var settings = photonNetwork?.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (settings is not null)
|
||||
{
|
||||
Mod.Log.Error("[photon-diag] PhotonServerSettings at failure:");
|
||||
DumpSettings(settings, settings.GetType(), indent: " ");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Logs every public field + property on <paramref name="obj"/>
|
||||
/// — used by the photon-diag patches above to dump AppSettings in one
|
||||
/// pass without coupling to specific field names that differ across
|
||||
/// PUN versions (Server/Port vs AppSettings.NameServer in newer
|
||||
/// PUN 2.x, etc.).</summary>
|
||||
private static void DumpSettings(object obj, Type t, string indent)
|
||||
{
|
||||
// DeclaredOnly so we don't walk Il2CppObjectBase's properties
|
||||
// (ObjectClass, Pointer, WasCollected) — and, importantly, so
|
||||
// we don't reflect over any property whose getter internally
|
||||
// constructs a System.Uri. The Uri ctor is harmony-patched, so
|
||||
// the FIRST call to a Uri-returning getter triggers MonoMod's
|
||||
// lazy CompileMethodHook for the ctor, which fatal-CLRs
|
||||
// (0x80131506) on this build. The override has already run by
|
||||
// the time DumpSettings is called — the dump is diagnostic
|
||||
// only, so it's safer to stay scoped to user-land properties.
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
|
||||
try
|
||||
{
|
||||
foreach (var p in t.GetProperties(flags))
|
||||
{
|
||||
if (p.GetIndexParameters().Length > 0) continue;
|
||||
if (!p.CanRead) continue;
|
||||
// Defensively skip any property whose declared return
|
||||
// type is Uri (would still trigger ctor on .ToString()
|
||||
// path even if we tried to read it).
|
||||
if (p.PropertyType == typeof(Uri)) continue;
|
||||
object? v;
|
||||
try { v = p.GetValue(obj); } catch { continue; }
|
||||
if (v is Uri) continue;
|
||||
var str = v is null ? "<null>" : v.ToString();
|
||||
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
|
||||
{
|
||||
Mod.Log.Msg($"{indent}{p.Name} (nested):");
|
||||
DumpSettings(v, v.GetType(), indent + " ");
|
||||
continue;
|
||||
}
|
||||
Mod.Log.Msg($"{indent}{p.Name} = {str}");
|
||||
}
|
||||
foreach (var f in t.GetFields(flags))
|
||||
{
|
||||
if (f.FieldType == typeof(Uri)) continue;
|
||||
object? v;
|
||||
try { v = f.GetValue(obj); } catch { continue; }
|
||||
if (v is Uri) continue;
|
||||
var str = v is null ? "<null>" : v.ToString();
|
||||
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
|
||||
{
|
||||
Mod.Log.Msg($"{indent}{f.Name} (nested):");
|
||||
DumpSettings(v, v.GetType(), indent + " ");
|
||||
continue;
|
||||
}
|
||||
Mod.Log.Msg($"{indent}{f.Name} = {str}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] DumpSettings failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// GetCurrentLockToken parked alongside the injector — see
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class SavePatches
|
||||
internal static class AntiTamperPatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||
// would-be no-op setter call into a real transition.
|
||||
private static volatile string? s_lastPushedBlobName;
|
||||
private static long s_lastPushedRoomId;
|
||||
private static long s_lastPushedSubRoomId;
|
||||
|
||||
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||
// every SignalR message before any handler dispatch. Parses the JSON
|
||||
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||
public static void OnNotificationReceived_Prefix(string message)
|
||||
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
// Fast reject: only parse JSON if it could plausibly be a
|
||||
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||
return;
|
||||
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!msg.TryGetProperty("Room", out var room) ||
|
||||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
long roomId = 0;
|
||||
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
roomId = ridEl.GetInt64();
|
||||
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||
scenes.GetArrayLength() == 0) return;
|
||||
var first = scenes[0];
|
||||
long subRoomId = 0;
|
||||
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
subRoomId = sidEl.GetInt64();
|
||||
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||
var blob = blobEl.GetString();
|
||||
if (string.IsNullOrEmpty(blob)) return;
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = blob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||
public static bool UnknownDllDetectedCallback_Prefix()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message is null) return;
|
||||
var dict = message;
|
||||
|
||||
// The watch parses this dict as Dictionary<string, object>
|
||||
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||
// get_Item(key) — call it via reflection.
|
||||
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (getItem is null) return;
|
||||
|
||||
// The push wire shape from server (BuildRoomDetails) is
|
||||
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||
object? roomObj = TryGet(dict, getItem, "Room");
|
||||
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||
if (roomObj is null || scenesObj is null) return;
|
||||
|
||||
long roomId = 0;
|
||||
long subRoomId = 0;
|
||||
try
|
||||
{
|
||||
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (roomItem is not null)
|
||||
{
|
||||
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||
|
||||
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||
// (current dorm has one scene; multi-scene rooms target
|
||||
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||
// push always carries the scene that was just saved).
|
||||
object? firstScene = null;
|
||||
try
|
||||
{
|
||||
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||
}
|
||||
catch { /* leave firstScene null */ }
|
||||
if (firstScene is null) return;
|
||||
|
||||
string? newBlob = null;
|
||||
try
|
||||
{
|
||||
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (sceneItem is not null)
|
||||
{
|
||||
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||
newBlob = blob?.ToString();
|
||||
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* leave newBlob null */ }
|
||||
|
||||
if (string.IsNullOrEmpty(newBlob)) return;
|
||||
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = newBlob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||
{
|
||||
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||
// path only when the new value differs from the cached field. The
|
||||
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||
// save it calls the setter with the OLD blob — a no-op that skips
|
||||
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||
// those over the live world — wiping any MakerPen object the user
|
||||
// just placed.
|
||||
//
|
||||
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||
// push already carried Scenes[0].DataBlobName="v11".
|
||||
//
|
||||
// Strategy: intercept the setter. If the incoming value matches the
|
||||
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||
// newer version (the freshly-pushed one), substitute that into the
|
||||
// value parameter so the real setter sees a transition and fires
|
||||
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||
// Falls through silently when no override is needed.
|
||||
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||
{
|
||||
string currentForLog = "<unread>";
|
||||
string sceneForLog = "<unread>";
|
||||
string scenes0ForLog = "<unread>";
|
||||
try
|
||||
{
|
||||
if (__instance is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var rpmType = __instance.GetType();
|
||||
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (blobNameProp is null)
|
||||
{
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||
return;
|
||||
}
|
||||
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||
|
||||
var roomsType = FindGameType("RecNet.Rooms");
|
||||
if (roomsType is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localScene is not null)
|
||||
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||
|
||||
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localDetails is not null)
|
||||
{
|
||||
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||
if (scenes is not null)
|
||||
{
|
||||
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||
if (first is not null)
|
||||
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional trace of EVERYTHING we read so we can see in
|
||||
// the log exactly why we did or didn't override.
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||
|
||||
// Pick a candidate "newer" blob name. Priority order:
|
||||
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||
// cache gate drops the push).
|
||||
// 2. LocalRoomScene.DataBlobName.
|
||||
// 3. Scenes[0].DataBlobName.
|
||||
string? pushedBlob = s_lastPushedBlobName;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||
|
||||
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||
|
||||
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||
value = pushedBlob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
internal static class FileHashCheckerPatches
|
||||
{
|
||||
public static bool InitializeCallback_Prefix(object? result)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
internal static class TitleScreenLoginPatches
|
||||
{
|
||||
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time
|
||||
// auto-login: the title screen downloads this machine's remembered
|
||||
// accounts (CFILOMKCAPL), picks one (BFHOLLPGBPB) and logs into it here.
|
||||
// With none (null) it creates a brand-new account instead (PJMGFADOEEM ->
|
||||
// grant_type create_account). A server with sign-ups off refuses that, the
|
||||
// error handler (IBDFENJNMAA) shows the connection-error page and the flow
|
||||
// tries again, forever. Open the sign-in page instead (GoToLogin, what the
|
||||
// "Log in" button does). ManuallyLogin carries the player into the game
|
||||
// from there on its own, so the boot step gets a promise that never
|
||||
// settles (MAMJDBJDFAF, the game's non-generic promise).
|
||||
private static Type? _promiseType;
|
||||
|
||||
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
|
||||
{
|
||||
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
|
||||
try
|
||||
{
|
||||
_promiseType ??= Mod.ResolveType("MAMJDBJDFAF");
|
||||
var goToLogin = __instance.GetType().GetMethod("GoToLogin", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
|
||||
if (_promiseType is null || goToLogin is null)
|
||||
{
|
||||
Mod.Log.Warning("[title] no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
|
||||
return true;
|
||||
}
|
||||
// Il2CppInterop wraps the interface return type (LDGADANDBIO) as
|
||||
// its own class, so hand back the new promise's native object
|
||||
// wrapped in that type.
|
||||
var pending = (Il2CppObjectBase)Activator.CreateInstance(_promiseType)!;
|
||||
var returnType = ((MethodInfo)__originalMethod).ReturnType;
|
||||
var result = Activator.CreateInstance(returnType, pending.Pointer);
|
||||
goToLogin.Invoke(__instance, null);
|
||||
__result = result;
|
||||
Mod.Log.Msg("[title] no remembered account; opened the sign-in page instead of creating a new account");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// goToLogin.Invoke wraps the real failure in TargetInvocationException.
|
||||
var cause = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException! : ex;
|
||||
Mod.Log.Warning($"[title] sign-in redirect failed, creating an account as Rec Room does: {cause.GetType().Name}: {cause.Message}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class ToxModPatches
|
||||
{
|
||||
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||
{
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
internal static class TlsPatches
|
||||
{
|
||||
// Both BouncyCastle TLS authenticators have the same `void
|
||||
@@ -2376,319 +987,3 @@ internal static class TlsPatches
|
||||
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||
try { Mod.Log.Msg(line); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
|
||||
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ChatPatches
|
||||
{
|
||||
private static bool s_logged;
|
||||
|
||||
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||
// before raising the in-room Photon chat RPC; if it returns false
|
||||
// the coroutine bails silently and the sender doesn't even see
|
||||
// their own message echo.
|
||||
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||
{
|
||||
if (!__result && !s_logged)
|
||||
{
|
||||
s_logged = true;
|
||||
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||
}
|
||||
__result = true;
|
||||
}
|
||||
|
||||
// Skip the original. Returning false from a Harmony prefix stops
|
||||
// the underlying call.
|
||||
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||
{
|
||||
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SendChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||
}
|
||||
|
||||
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||
}
|
||||
|
||||
public static void RpcChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
|
||||
// Log-only diagnostics for the stuck first-dorm "Change Username?" dialog.
|
||||
// See the registration site in OnLateInitializeMelon and
|
||||
// Cfg.TraceRegistrationDialog. Nothing here changes game behaviour — every
|
||||
// hook only writes to the diagnostics log.
|
||||
internal static class RegistrationPatches
|
||||
{
|
||||
private static bool s_loggedFullyReg;
|
||||
|
||||
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
|
||||
// (does NOT modify) the return value. False ⇒ the dorm raises the
|
||||
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
|
||||
// stays readable (it's polled from several call sites every boot).
|
||||
public static void IsFullyRegistered_Postfix(bool __result)
|
||||
{
|
||||
if (!s_loggedFullyReg)
|
||||
{
|
||||
s_loggedFullyReg = true;
|
||||
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
|
||||
}
|
||||
}
|
||||
|
||||
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
|
||||
// the dialog-response handler (a dialog was shown). Names reach the
|
||||
// prefix via __originalMethod so we can tell which branch ran.
|
||||
public static void PromptLambda_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// ConfirmUIDialog button handlers. If one of these fires when the user
|
||||
// taps a dialog button but the modal stays open, the break is in the
|
||||
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
|
||||
// routing. "Okay" on the screenshot is Button_Affirmative.
|
||||
public static bool ButtonAffirmative_Prefix(object __instance)
|
||||
{
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
|
||||
|
||||
if (!IsRegistrationWarningDialog(__instance))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveAffirmativeWithoutClose(__instance))
|
||||
{
|
||||
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
|
||||
return false;
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ButtonNegative_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
|
||||
public static void ButtonNotNow_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
|
||||
public static void ButtonCancel_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
|
||||
|
||||
private static bool IsRegistrationWarningDialog(object instance)
|
||||
{
|
||||
var title = ReadTextField(instance, "titleText");
|
||||
var body = ReadTextField(instance, "bodyText");
|
||||
|
||||
return Contains(title, "Change Username")
|
||||
&& Contains(body, "permanently lost")
|
||||
&& Contains(body, "password");
|
||||
}
|
||||
|
||||
private static bool TryResolveAffirmativeWithoutClose(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var respond = AccessTools.Method(instance.GetType(), "Respond");
|
||||
if (respond is null)
|
||||
{
|
||||
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
|
||||
}
|
||||
|
||||
if (respond is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = respond.GetParameters();
|
||||
if (parameters.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
|
||||
respond.Invoke(instance, new[] { response, (object)false });
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreateAffirmativeResponse(Type responseType)
|
||||
{
|
||||
if (responseType.IsEnum)
|
||||
{
|
||||
return Enum.ToObject(responseType, 1);
|
||||
}
|
||||
|
||||
return Convert.ChangeType(1, responseType);
|
||||
}
|
||||
|
||||
private static string? ReadTextField(object instance, string fieldName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var field = AccessTools.Field(instance.GetType(), fieldName);
|
||||
var textObject = field?.GetValue(instance);
|
||||
if (textObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var property = AccessTools.Property(textObject.GetType(), "text");
|
||||
if (property?.GetValue(textObject) is string text)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
var getter = AccessTools.Method(textObject.GetType(), "get_text");
|
||||
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string fragment)
|
||||
{
|
||||
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Trace hooks for the watch's room-join flow. Registered by
|
||||
// Mod.RegisterJoinTracePatches — see that method for the verified call
|
||||
// chain. Everything here is log-only; no prefix returns false, so the
|
||||
// game's own join logic runs unchanged.
|
||||
internal static class JoinPatches
|
||||
{
|
||||
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
|
||||
// "Dorm Room" button. If this never fires, the button's UnityEvent
|
||||
// binding is the problem, not the join chain.
|
||||
public static void ButtonDormRoom_Prefix()
|
||||
{
|
||||
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
|
||||
}
|
||||
|
||||
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
|
||||
// values here: Harmony's `object[] __args` forces a trampoline that
|
||||
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
|
||||
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
|
||||
// when the boot-time dorm auto-join first calls this method. Logging
|
||||
// the overload's parameter *types* (pure reflection metadata, no value
|
||||
// boxing) is enough to tell which overload fired — the entry overload
|
||||
// (String,…) is the "DormRoom" press; the others are inner legs.
|
||||
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
|
||||
// resolver that feeds the join. __0 is the single first param: a room
|
||||
// NAME string (String overload) or room ID (Int64 overload). Reading
|
||||
// it via `object __0` is safe — it's a string/long, not one of the
|
||||
// obfuscated ref-types that crashed `object[] __args`. This is the
|
||||
// value we need to compare boot (works) vs button (fails).
|
||||
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
|
||||
{
|
||||
var arg = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
|
||||
// b__165_1 is the reject handler that raises the "An error occured /
|
||||
// contact recroom.happyfox.com" toast — so if THIS name appears, the
|
||||
// join failed; the success legs (other b__*) firing means it advanced.
|
||||
// (Same __args/0x80131506 constraint as above — name only.)
|
||||
public static void JoinCallback_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// Dedicated capture for the reject handler b__165_1(string error) —
|
||||
// the answer to "why did the join fail". Single string param, so
|
||||
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
|
||||
// is the string that becomes the happyfox toast.
|
||||
public static void JoinError_Prefix(object __0)
|
||||
{
|
||||
var err = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
|
||||
}
|
||||
|
||||
// Parameter-type list from reflection metadata only — never reads
|
||||
// instance values, so it can't trigger the MonoMod trampoline crash.
|
||||
private static string Sig(MethodBase m)
|
||||
{
|
||||
var ps = m.GetParameters();
|
||||
var names = new List<string>(ps.Length);
|
||||
foreach (var p in ps) names.Add(p.ParameterType.Name);
|
||||
return $"{ps.Length} args: {string.Join(",", names)}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
// Raise RRO quest party size past the baked 4 — precisely, only for the
|
||||
// allowlisted quests (Cfg.QuestMaxTeamSizeRooms).
|
||||
//
|
||||
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL):
|
||||
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL; names
|
||||
// re-verified against the March-2023 dump — 2023 obfuscated names below):
|
||||
// GameConfigurationTool.Awake()
|
||||
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset, the
|
||||
// Unity ScriptableObject @0x58)
|
||||
// → calls GameConfigurationAsset.GDECPANDBHJ() ← builds the runtime
|
||||
// protobuf GameConfigurationData (PJPAPHKPKGM) FROM the asset
|
||||
// → stores it @0x88 and applies it (OAHJOLJOKGI) to the team manager.
|
||||
// Inside GDECPANDBHJ the build loop reads asset.TeamConfigurations[i]
|
||||
// .MaxTeamSize (a typed int struct-array: [arr+0x20 + i*4]) and writes each
|
||||
// straight into a new GameTeamConfigurationData.TeamSize (HFBCEJLLDLI @0x10).
|
||||
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset,
|
||||
// a Unity ScriptableObject)
|
||||
// → calls GameConfigurationAsset.FIFOMGAOOHH() ← builds the runtime
|
||||
// protobuf GameConfigurationData (GPIGCAJICEF) FROM the asset
|
||||
// → stores and applies it to the team manager.
|
||||
// Inside FIFOMGAOOHH the build loop reads asset.TeamConfigurations[i]
|
||||
// .MaxTeamSize (a typed int struct-array) and writes each straight into a
|
||||
// new GameTeamConfigurationData.TeamSize.
|
||||
//
|
||||
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
|
||||
// READABLE, typed struct array — NOT from anything that needs the obfuscated
|
||||
// protobuf. Earlier attempts failed because:
|
||||
// • bumping the asset on a 2s poll happened AFTER GDECPANDBHJ already built &
|
||||
// • bumping the asset on a 2s poll happened AFTER the builder already built &
|
||||
// cached the protobuf (timing, not the wrong field);
|
||||
// • patching the protobuf getters (get_FNJLAGHPMFL / get_FBOCAPLIIDH) is
|
||||
// impossible — Il2CppInterop doesn't emit the generic RepeatedField<T>
|
||||
// getter, and the simple int getter wasn't emitted either.
|
||||
// • patching the protobuf getters is impossible — Il2CppInterop doesn't emit
|
||||
// the generic RepeatedField<T> getter, and the simple int getter wasn't
|
||||
// emitted either.
|
||||
//
|
||||
// THE FIX: Harmony-PREFIX GameConfigurationAsset.GDECPANDBHJ() and bump the
|
||||
// THE FIX: Harmony-PREFIX GameConfigurationAsset.FIFOMGAOOHH() and bump the
|
||||
// asset's TeamConfigurations[i].MaxTeamSize to the target IN PLACE, right
|
||||
// before it builds the protobuf. The built config then carries the new cap and
|
||||
// the team manager / pre-game roster / open-slots all honour it. Scoped by
|
||||
@@ -42,87 +43,18 @@ internal static class QuestTeamSize
|
||||
{
|
||||
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
private static bool _loggedBump;
|
||||
private static bool _activeAllowedQuestConfig;
|
||||
private static int _teamIndexNormalizeLogCount;
|
||||
private static int _spawnModeDataNormalizeLogCount;
|
||||
private static int _requiredSpawnIndexNormalizeLogCount;
|
||||
private static int _spawnRelaxLogCount;
|
||||
|
||||
// 2023 spawn path (verified in the March-2023 ISIL): the spawn driver
|
||||
// HCPLAILIHEL reads the team-player index from the roster entry
|
||||
// (GameTeamManager/BNBIBPFPJPJ.JPIHHGBLLNG()) and passes it straight into
|
||||
// the KMPAOJGOOCH spawn-point filter. December's intermediate helpers are
|
||||
// gone from this path — the out-param resolver is inlined (zero call
|
||||
// sites), and GetTeamPlayerIndex is only called by Charades/RecRally, so
|
||||
// normalizing it would touch non-quest games. The KMPAOJGOOCH index
|
||||
// argument is the single choke point: normalize it in a prefix and keep
|
||||
// the empty-filter relax postfix as the backstop.
|
||||
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
var ok = true;
|
||||
ok &= TryPatchTeamIndexNormalizer(harmony);
|
||||
ok &= TryPatchSpawnModeDataNormalizer(harmony);
|
||||
ok &= TryPatchRequiredSpawnIndexNormalizer(harmony);
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static bool TryPatchTeamIndexNormalizer(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var managerType = Mod.ResolveType("GameTeamManager");
|
||||
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||
if (managerType is null || indexType is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var target = AccessTools.Method(managerType, "GetTeamPlayerIndex");
|
||||
if (target is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patch = typeof(QuestTeamSize)
|
||||
.GetMethod(nameof(NormalizeTeamPlayerIndex_Postfix), BindingFlags.Public | BindingFlags.Static)!
|
||||
.MakeGenericMethod(indexType);
|
||||
harmony.Patch(target, postfix: new HarmonyMethod(patch));
|
||||
Mod.Log.Msg("[patch-ok] GameTeamManager.GetTeamPlayerIndex");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] GameTeamManager.GetTeamPlayerIndex: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryPatchSpawnModeDataNormalizer(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spawnType = Mod.ResolveType("GameSpawnManager");
|
||||
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||
if (spawnType is null || indexType is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var target = AccessTools.Method(spawnType, "ALCADGJKKHJ");
|
||||
if (target is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patch = typeof(QuestTeamSize)
|
||||
.GetMethod(nameof(NormalizeSpawnModeData_Postfix), BindingFlags.Public | BindingFlags.Static)!
|
||||
.MakeGenericMethod(indexType);
|
||||
harmony.Patch(target, postfix: new HarmonyMethod(patch));
|
||||
Mod.Log.Msg("[patch-ok] GameSpawnManager.ALCADGJKKHJ");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] GameSpawnManager.ALCADGJKKHJ: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
return TryPatchRequiredSpawnIndexNormalizer(harmony);
|
||||
}
|
||||
|
||||
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
|
||||
@@ -130,17 +62,17 @@ internal static class QuestTeamSize
|
||||
try
|
||||
{
|
||||
var spawnType = Mod.ResolveType("GameSpawnManager");
|
||||
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||
var indexType = Mod.ResolveType("NBCOFGIKKHM");
|
||||
if (spawnType is null || indexType is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer: type not found");
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var target = AccessTools.Method(spawnType, "FKEICBPCMPJ");
|
||||
var target = AccessTools.Method(spawnType, "KMPAOJGOOCH");
|
||||
if (target is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer");
|
||||
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -148,74 +80,21 @@ internal static class QuestTeamSize
|
||||
.GetMethod(nameof(NormalizeRequiredSpawnIndex_Prefix), BindingFlags.Public | BindingFlags.Static)!
|
||||
.MakeGenericMethod(indexType);
|
||||
harmony.Patch(target, prefix: new HarmonyMethod(patch));
|
||||
Mod.Log.Msg("[patch-ok] GameSpawnManager.FKEICBPCMPJ index normalizer");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] GameSpawnManager.FKEICBPCMPJ index normalizer: {ex.Message}");
|
||||
Mod.Log.Error($"[patch-fail] GameSpawnManager.KMPAOJGOOCH index normalizer: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// POSTFIX on GameTeamManager.GetTeamPlayerIndex(Player). The RRO quest
|
||||
// scenes only have spawn points for indexes 0-3. After raising the quest
|
||||
// team cap, players 5+ legitimately receive INDEX_4+, but those values
|
||||
// cannot match any baked quest spawn point. Normalize just the enum result
|
||||
// that spawn code asks for; the actual team roster/cap remains raised.
|
||||
public static void NormalizeTeamPlayerIndex_Postfix<T>(ref T __result) where T : struct
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||
|
||||
var value = Convert.ToInt32(__result);
|
||||
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||
|
||||
__result = (T)Enum.ToObject(typeof(T), normalized);
|
||||
|
||||
if (_teamIndexNormalizeLogCount < 12)
|
||||
{
|
||||
_teamIndexNormalizeLogCount++;
|
||||
Mod.Log.Msg($"[questsize] normalized team-player index {value} -> {normalized} for quest spawn lookup " +
|
||||
$"activeQuest={_activeAllowedQuestConfig}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[questsize] team-player index normalize failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// POSTFIX on GameSpawnManager.ALCADGJKKHJ(..., out CCMLKDOHKJE index, ...).
|
||||
// FPBFJMMKPPJ calls this private helper immediately before FKEICBPCMPJ, so
|
||||
// this is the exact spawn-path value seen in the "TeamIndex INDEX_4" log.
|
||||
public static void NormalizeSpawnModeData_Postfix<T>(ref T __3) where T : struct
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||
|
||||
var value = Convert.ToInt32(__3);
|
||||
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||
|
||||
__3 = (T)Enum.ToObject(typeof(T), normalized);
|
||||
|
||||
if (_spawnModeDataNormalizeLogCount < 12)
|
||||
{
|
||||
_spawnModeDataNormalizeLogCount++;
|
||||
Mod.Log.Msg($"[questsize] normalized spawn-mode team-player index {value} -> {normalized} " +
|
||||
$"activeQuest={_activeAllowedQuestConfig}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[questsize] spawn-mode index normalize failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// PREFIX on GameSpawnManager.FKEICBPCMPJ(..., CCMLKDOHKJE requiredIndex, ...).
|
||||
// This is the last guard before the spawn point field compare at +0x88.
|
||||
// PREFIX on GameSpawnManager.KMPAOJGOOCH(..., NBCOFGIKKHM index, ...).
|
||||
// The RRO quest scenes only have spawn points for indexes 0-3. After
|
||||
// raising the quest team cap, players 5+ legitimately receive INDEX_4+,
|
||||
// but those values cannot match any baked quest spawn point. Normalize
|
||||
// just the index the spawn-point filter compares against; the actual team
|
||||
// roster/cap remains raised.
|
||||
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
|
||||
{
|
||||
try
|
||||
@@ -226,13 +105,6 @@ internal static class QuestTeamSize
|
||||
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||
|
||||
__2 = (T)Enum.ToObject(typeof(T), normalized);
|
||||
|
||||
if (_requiredSpawnIndexNormalizeLogCount < 12)
|
||||
{
|
||||
_requiredSpawnIndexNormalizeLogCount++;
|
||||
Mod.Log.Msg($"[questsize] normalized required spawn index {value} -> {normalized} " +
|
||||
$"activeQuest={_activeAllowedQuestConfig}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -246,7 +118,7 @@ internal static class QuestTeamSize
|
||||
return value >= 4;
|
||||
}
|
||||
|
||||
// PREFIX on GameConfigurationAsset.GDECPANDBHJ() (asset → protobuf builder).
|
||||
// PREFIX on GameConfigurationAsset.FIFOMGAOOHH() (asset → protobuf builder).
|
||||
public static void Build_Prefix(object __instance)
|
||||
{
|
||||
try
|
||||
@@ -255,11 +127,7 @@ internal static class QuestTeamSize
|
||||
if (target <= 0 || __instance is null) return;
|
||||
|
||||
var name = GetMember(__instance, "Name") as string ?? "";
|
||||
var allowed = IsAllowed(name);
|
||||
_activeAllowedQuestConfig = allowed;
|
||||
if (_seen.Add(name))
|
||||
Mod.Log.Msg($"[questsize] config build '{name}' allowed={allowed}");
|
||||
if (!allowed) return;
|
||||
if (!IsAllowed(name)) return;
|
||||
|
||||
var arr = GetMember(__instance, "TeamConfigurations");
|
||||
int len = ArrLen(arr);
|
||||
@@ -276,24 +144,18 @@ internal static class QuestTeamSize
|
||||
SetArrItem(arr!, i, elem)) // write the mutated struct back
|
||||
bumped++;
|
||||
}
|
||||
|
||||
if (bumped > 0 && !_loggedBump)
|
||||
{
|
||||
_loggedBump = true;
|
||||
Mod.Log.Msg($"[questsize] '{name}': bumped {bumped} team slot(s) → {target}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// POSTFIX on GameSpawnManager.FKEICBPCMPJ(..., outputList). That method
|
||||
// POSTFIX on GameSpawnManager.KMPAOJGOOCH(..., outputList). That method
|
||||
// filters spawn points by state/game-mode/spectator/team/team-player-index/
|
||||
// custom-tag before the spawn selector runs. The RRO quest prefabs only
|
||||
// have indexed start points for players 0-3; once we raise the team size,
|
||||
// player index 4+ can filter every point out before the built-in overlap
|
||||
// fallback has a chance to help.
|
||||
//
|
||||
// December decompile: FPBFJMMKPPJ calls this filter, then if output.Count
|
||||
// The caller (2023: HCPLAILIHEL) runs this filter, then if output.Count
|
||||
// is still 0 it logs "due to spawn point requirement tests" and falls back
|
||||
// to the unfiltered source list as an arbitrary spawn set. Filling the
|
||||
// output list here keeps the normal overlap pass and spawn-selection mode
|
||||
@@ -315,14 +177,7 @@ internal static class QuestTeamSize
|
||||
var sourceCount = Count(__0);
|
||||
if (sourceCount <= 0) return;
|
||||
|
||||
var copied = CopyListItems(__0, __7);
|
||||
if (_spawnRelaxLogCount < 12)
|
||||
{
|
||||
_spawnRelaxLogCount++;
|
||||
Mod.Log.Msg($"[questsize] relaxed empty quest spawn filter; activeQuest={_activeAllowedQuestConfig} " +
|
||||
$"source={sourceCount} copied={copied} " +
|
||||
$"sourceType={TypeName(__0)} outputType={TypeName(__7)}");
|
||||
}
|
||||
CopyListItems(__0, __7);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,1401 @@
|
||||
// Rec Royale night mode — client-side, opt-in.
|
||||
//
|
||||
// Rec Royale never shipped a night variant, but the March-2023 client
|
||||
// carries everything needed to build one from the game's own tooling:
|
||||
//
|
||||
// • Every room scene owns a RecRoomSceneManager (name-preserved field
|
||||
// `sceneMoodSettings` → RecRoom.Core.Scene.SceneMoodSettings). That
|
||||
// object is the room "mood" engine behind the Maker Pen sky/sun/fog
|
||||
// settings: it holds `availableMoods` (MoodSetting[]), `defaultMood`
|
||||
// and `CurrentMood`, plus blenders for skybox, sun, fog, bloom and
|
||||
// colour grading.
|
||||
// • SceneMoodSettings.MLLBALMKEGJ(MoodSetting) is the apply step
|
||||
// (ISIL: SceneMoodSettings.txt:3025-3581). It pushes the mood's six
|
||||
// settings blocks into the blenders (ALCMNGKOEBK), swaps the URP
|
||||
// VolumeProfile (OEKNGEMDMDB), sets RenderSettings.skybox to the
|
||||
// global MoodConfig skybox material (LNMDIIFOFPD.NHAEHKJINFH, shader
|
||||
// "Custom/SkyBox"), re-aims RenderSettings.sun, sets every
|
||||
// ReflectionProbe.customBakedTexture to the mood's baked cubemap and
|
||||
// force-updates the blenders. It does NOT touch the networked mood
|
||||
// index, so calling it is purely local.
|
||||
// • The build ships ready-made night moods as Resources assets
|
||||
// (resources.assets; ResourceManager table in globalgamemanagers):
|
||||
// Night_Calm_Outdoor_Mood ("Calm Night"), Night_Spooky_Outdoor_Mood
|
||||
// ("Spooky Night"), Night_Wild_Outdoor_Mood ("Wild Night"),
|
||||
// Night_StuntRunner_Outdoor_Mood and OuterSpace_Mood ("Outer Space").
|
||||
// • The only Rec Royale map in this build is
|
||||
// Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity.
|
||||
//
|
||||
// RRO SCENES HAVE NO ROOM DATA BLOB. The mood engine's `isEnabled` flag
|
||||
// (SceneMoodSettings+0x38) is only switched on by the room-data loader
|
||||
// (EIMFCPIHEPF.JPKEMACDNCF, ISIL :2136-2310), which runs for custom rooms
|
||||
// only. While it is off, get_CurrentMood returns null and nothing ticks.
|
||||
// The loader's bring-up sequence is:
|
||||
// LKIKMLNPCFK(false) // SetEnabled(false): applies a null mood
|
||||
// LIEDFOLBGHL() // capture the scene's own skybox/sun/fog as the
|
||||
// // blenders' "previous" data (checks shader name
|
||||
// // "Custom/SkyBox" → HasRecRoomSkyboxShader)
|
||||
// LKIKMLNPCFK(true) // SetEnabled(true)
|
||||
// This mod replays exactly that before applying the night mood.
|
||||
//
|
||||
// Deliberately NOT used: SceneMoodSettings.IPAMPMBDPNB(Guid) /
|
||||
// set_CurrentRoomMoodIndex. `_currentRoomMoodIndex` is a
|
||||
// RecRoom.Networking.SynchronizedFields DFAOKLDJPEL<int>, i.e. it
|
||||
// replicates to every player in the instance. Applying the mood directly
|
||||
// keeps night mode a per-client visual choice.
|
||||
//
|
||||
// Weapon flashlights: WeaponFlashlightController.get_FlashlightAllowed
|
||||
// (ISIL: WeaponFlashlightController.txt:3-75) is
|
||||
// `Matchmaking.FBELDNLEKAC() != null && [+0x28] == 15` — a game-mode
|
||||
// gate for an activity that isn't Rec Royale. A Harmony postfix forces it
|
||||
// true while night mode is active, so weapons picked up in the dark get
|
||||
// the game's own flashlight prefab.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
|
||||
namespace DorkNet.ClientMod;
|
||||
|
||||
internal static class RecRoyaleNight
|
||||
{
|
||||
private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
|
||||
// Runtime state. `Active` starts from Cfg.RecRoyaleNightMode and flips
|
||||
// with the hotkey; `_inRoyale` tracks whether the current scene is one
|
||||
// of Cfg.RecRoyaleNightScenes.
|
||||
public static bool Active { get; private set; }
|
||||
private static bool _inRoyale;
|
||||
private static string _sceneName = "";
|
||||
private static bool _appliedThisScene;
|
||||
private static int _frame;
|
||||
private static int _sceneFrames; // frames since the Royale scene loaded
|
||||
private static int _reassertFrames; // frames since the last re-apply
|
||||
|
||||
// Per-scene bring-up bookkeeping so toggling off can put things back.
|
||||
private static bool _engineWasDisabled; // we enabled the mood engine ourselves
|
||||
private static object? _originalSkybox; // RenderSettings.skybox before we touched it
|
||||
private static bool _sunWasNull; // we assigned RenderSettings.sun ourselves
|
||||
private static object? _originalFog; // MoodSetting.FogSettings before we swapped it
|
||||
private static object? _fogPatchedMood; // the mood asset whose FogSettings we replaced
|
||||
private static object? _darkPatchedMood; // the mood asset whose exposure/sun/ambient we changed
|
||||
private static float? _origExposure, _origSunIntensity, _origAmbient;
|
||||
private static bool _diagLogged;
|
||||
|
||||
private const int PollEveryFrames = 30; // ~0.5 s @ 60 fps
|
||||
private const int SettleFrames = 90; // let the scene's own lighting settle first
|
||||
private const int PollGiveUpFrames = 60 * 120; // stop polling after ~2 min
|
||||
private const int ReassertEveryFrames = 60 * 10; // re-apply every ~10 s
|
||||
|
||||
// Resolved reflection handles (lazy, cached).
|
||||
private static bool _resolved;
|
||||
private static Type? _sceneManagerType;
|
||||
private static PropertyInfo? _sceneManagerInstance;
|
||||
private static FieldInfo? _sceneMoodSettingsField;
|
||||
private static PropertyInfo? _sceneMoodSettingsProp;
|
||||
private static Type? _moodSettingType;
|
||||
private static MethodInfo? _applyMood; // MLLBALMKEGJ(MoodSetting)
|
||||
private static MethodInfo? _setEnabled; // LKIKMLNPCFK(bool)
|
||||
private static MethodInfo? _capture; // LIEDFOLBGHL()
|
||||
private static FieldInfo? _isEnabled; // isEnabled
|
||||
private static PropertyInfo? _roomMoodsEnabled;
|
||||
private static PropertyInfo? _hasSkyShader;
|
||||
private static PropertyInfo? _hasSunSource;
|
||||
private static PropertyInfo? _volume;
|
||||
private static FieldInfo? _availableMoods;
|
||||
private static PropertyInfo? _currentMood;
|
||||
private static PropertyInfo? _friendlyName;
|
||||
private static PropertyInfo? _renderSkybox; // UnityEngine.RenderSettings.skybox
|
||||
private static PropertyInfo? _renderSun; // UnityEngine.RenderSettings.sun
|
||||
private static Type? _lightType; // UnityEngine.Light
|
||||
private static Type? _fogSettingsType; // FogMoodSettings
|
||||
private static MethodInfo? _fogCapture; // FogMoodSettings.NLDDIDEIEFH() — read RenderSettings fog
|
||||
private static PropertyInfo? _moodFog; // MoodSetting.FogSettings
|
||||
private static Type? _materialType; // UnityEngine.Material
|
||||
private static Type? _shaderType; // UnityEngine.Shader
|
||||
private static Type? _moodConfigHolder; // LNMDIIFOFPD (static MoodConfig holder)
|
||||
private static Type? _cameraDataType; // UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
|
||||
private static object? _ourSkybox; // Material we created for the mood skybox
|
||||
private static object? _nightMood; // MoodSetting interop wrapper
|
||||
private static string _nightMoodLoadedName = "";
|
||||
|
||||
// Hotkey
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown;
|
||||
private static object? _toggleKey;
|
||||
|
||||
// ── registration ──────────────────────────────────────────────────
|
||||
public static void Register(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
Active = Mod.Cfg.RecRoyaleNightMode;
|
||||
Mod.Log.Msg($"[royale-night] enabled={Active} mood={Mod.Cfg.RecRoyaleNightMood} " +
|
||||
$"scenes=[{string.Join(",", Mod.Cfg.RecRoyaleNightScenes)}] " +
|
||||
$"flashlights={Mod.Cfg.RecRoyaleNightFlashlights} " +
|
||||
$"hotkey={(string.IsNullOrWhiteSpace(Mod.Cfg.RecRoyaleNightToggleKey) ? "<none>" : Mod.Cfg.RecRoyaleNightToggleKey)}");
|
||||
|
||||
if (Mod.Cfg.RecRoyaleNightFlashlights)
|
||||
TryPatchFlashlightGate(harmony);
|
||||
}
|
||||
|
||||
// POSTFIX on WeaponFlashlightController.get_FlashlightAllowed. The
|
||||
// interop property carries the obfuscated name (KIMMMCCKMJL in the
|
||||
// 2023-03-21 build) while the il2cpp method keeps `get_FlashlightAllowed`,
|
||||
// so resolve by name first and fall back to "the only read-only bool
|
||||
// property declared on the type".
|
||||
private static void TryPatchFlashlightGate(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = Mod.ResolveType("WeaponFlashlightController");
|
||||
if (type is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] WeaponFlashlightController: type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
MethodInfo? getter = AccessTools.PropertyGetter(type, "FlashlightAllowed")
|
||||
?? AccessTools.PropertyGetter(type, "KIMMMCCKMJL");
|
||||
if (getter is null)
|
||||
{
|
||||
PropertyInfo? only = null;
|
||||
int count = 0;
|
||||
foreach (var p in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (p.PropertyType != typeof(bool) || !p.CanRead || p.CanWrite || p.GetIndexParameters().Length != 0) continue;
|
||||
only = p; count++;
|
||||
}
|
||||
if (count == 1) getter = only!.GetGetMethod(true);
|
||||
}
|
||||
if (getter is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] WeaponFlashlightController.get_FlashlightAllowed");
|
||||
return;
|
||||
}
|
||||
|
||||
var postfix = typeof(RecRoyaleNight).GetMethod(nameof(FlashlightAllowed_Postfix), BindingFlags.Public | BindingFlags.Static)!;
|
||||
harmony.Patch(getter, postfix: new HarmonyMethod(postfix));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] WeaponFlashlightController.get_FlashlightAllowed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void FlashlightAllowed_Postfix(ref bool __result)
|
||||
{
|
||||
if (Active && _inRoyale) __result = true;
|
||||
}
|
||||
|
||||
// ── lifecycle (called from Mod) ───────────────────────────────────
|
||||
public static void OnSceneLoaded(string sceneName)
|
||||
{
|
||||
_sceneName = sceneName ?? "";
|
||||
var was = _inRoyale;
|
||||
_inRoyale = MatchesScene(_sceneName);
|
||||
if (_inRoyale)
|
||||
{
|
||||
_appliedThisScene = false;
|
||||
_sceneFrames = 0;
|
||||
_reassertFrames = 0;
|
||||
_engineWasDisabled = false;
|
||||
_originalSkybox = null;
|
||||
_sunWasNull = false;
|
||||
_diagLogged = false;
|
||||
RestoreMoodPatches();
|
||||
Mod.Log.Msg($"[royale-night] scene '{_sceneName}' loaded — night mode {(Active ? "will apply" : "is off (hotkey to enable)")}");
|
||||
}
|
||||
else if (was)
|
||||
{
|
||||
_appliedThisScene = false;
|
||||
_engineWasDisabled = false;
|
||||
_originalSkybox = null;
|
||||
_sunWasNull = false;
|
||||
RestoreMoodPatches();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
_frame++;
|
||||
PollHotkey();
|
||||
|
||||
if (!_inRoyale || !Active) return;
|
||||
_sceneFrames++;
|
||||
if (_frame % PollEveryFrames != 0) return;
|
||||
|
||||
if (!_appliedThisScene)
|
||||
{
|
||||
if (_sceneFrames < SettleFrames || _sceneFrames > PollGiveUpFrames) return;
|
||||
if (TryApply(true)) _appliedThisScene = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cheap safeguard: the game re-applies its own mood on quality
|
||||
// changes / studio events. Re-assert every ~10 s; when the blenders
|
||||
// are already at target this is visually a no-op.
|
||||
_reassertFrames += PollEveryFrames;
|
||||
if (_reassertFrames >= ReassertEveryFrames)
|
||||
{
|
||||
_reassertFrames = 0;
|
||||
TryApply(true);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
Active = !Active;
|
||||
Mod.Log.Msg($"[royale-night] toggled {(Active ? "ON" : "OFF")}");
|
||||
if (!_inRoyale) return;
|
||||
if (TryApply(Active)) _appliedThisScene = Active;
|
||||
else if (!Active) _appliedThisScene = false;
|
||||
}
|
||||
|
||||
// ── the actual apply ──────────────────────────────────────────────
|
||||
// night=true → bring the mood engine up if needed, apply the night mood
|
||||
// night=false → restore: scene mood if the engine was already running,
|
||||
// otherwise disable the engine again + put the skybox back
|
||||
private static bool TryApply(bool night)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_resolved && !Resolve()) return false;
|
||||
if (_applyMood is null) return false;
|
||||
|
||||
var manager = GetSceneManager();
|
||||
if (manager is null) { Diag("scene manager instance is null"); return false; }
|
||||
|
||||
var moods = _sceneMoodSettingsField?.GetValue(manager) ?? _sceneMoodSettingsProp?.GetValue(manager);
|
||||
if (moods is null) { Diag("sceneMoodSettings is null"); return false; }
|
||||
|
||||
// `isEnabled` reads differently through the interop property vs the
|
||||
// field (seen True/False for the same state), so don't gate on it.
|
||||
// RoomMoodsEnabled (= isEnabled && defaultMood != null) is computed
|
||||
// game-side and is false for every RRO scene; bring the engine up
|
||||
// exactly once per Royale scene whenever it is not true.
|
||||
var enabled = GetMember(moods, "isEnabled") is true;
|
||||
var roomMoodsEnabled = Read(_roomMoodsEnabled, moods) is true;
|
||||
LogState(moods, enabled);
|
||||
|
||||
if (night)
|
||||
{
|
||||
var target = GetNightMood();
|
||||
if (target is null) return false;
|
||||
|
||||
if (!roomMoodsEnabled && !_engineWasDisabled)
|
||||
{
|
||||
// Replay the room-data loader's bring-up. Remember the
|
||||
// scene's own skybox so toggle-off can restore it.
|
||||
if (_originalSkybox is null && _renderSkybox is not null)
|
||||
_originalSkybox = _renderSkybox.GetValue(null);
|
||||
if (_setEnabled is null || _capture is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] mood engine is disabled in this scene and SetEnabled/Capture are unresolved — cannot bring it up");
|
||||
return false;
|
||||
}
|
||||
|
||||
// RRO scenes leave RenderSettings.sun unset, and the engine
|
||||
// skips every sun change (direction, colour, intensity) when
|
||||
// HasSunSource is false. Point it at the scene's directional
|
||||
// light BEFORE capture so the flag comes out true.
|
||||
EnsureSun();
|
||||
|
||||
// Keep the scene's own fog unless asked otherwise: replace
|
||||
// the mood's FogSettings with a block captured from the
|
||||
// current RenderSettings before anything changes them.
|
||||
if (!Mod.Cfg.RecRoyaleNightFog) PreserveFog(target);
|
||||
|
||||
// Baked lightmaps can't be dimmed, so darken the frame via
|
||||
// the mood's own post-exposure and tone down sun/ambient.
|
||||
PatchDarkness(target);
|
||||
|
||||
_setEnabled.Invoke(moods, new object[] { false });
|
||||
_capture.Invoke(moods, null);
|
||||
|
||||
// The per-frame update (CEKLEBHDAHA, ISIL :3895-4031) returns
|
||||
// at once while get_CurrentMood() is null, and the one-shot
|
||||
// apply only sets blend TARGETS — the update is what writes
|
||||
// sky colours, rotates the sun and pushes exposure. In RRO
|
||||
// scenes CurrentMood is null because defaultMood is unset.
|
||||
// Register the night mood as this scene's defaultMood and
|
||||
// sole availableMoods entry so the engine runs like a
|
||||
// one-mood custom room.
|
||||
InstallSceneMood(moods, target);
|
||||
|
||||
_setEnabled.Invoke(moods, new object[] { true });
|
||||
_engineWasDisabled = true;
|
||||
ProbeCircuitsSupport();
|
||||
var cm = _currentMood?.GetValue(moods);
|
||||
Mod.Log.Msg($"[royale-night] mood engine brought up (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)} " +
|
||||
$"roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} currentMood={(cm is null || IsUnityNull(cm) ? "<null>" : Describe(cm))})");
|
||||
|
||||
// First apply swaps RenderSettings.skybox to
|
||||
// MoodConfig.SkyBoxMaterial. In RRO scenes that material is
|
||||
// not loaded (it lives in the custom-room scene's shared
|
||||
// assets), so the skybox ends up null → black sky and the
|
||||
// engine's HasRecRoomSkyboxShader / HasSunSource stay false
|
||||
// (both need shader "Custom/SkyBox" on RenderSettings.skybox).
|
||||
// Supply a material built from that shader, re-capture so
|
||||
// the flags flip, then fall through to the real apply.
|
||||
_applyMood.Invoke(moods, new[] { target });
|
||||
EnsureMoodSkybox();
|
||||
_capture.Invoke(moods, null);
|
||||
Mod.Log.Msg($"[royale-night] re-captured (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} skybox={DescribeSkybox()})");
|
||||
EnsurePostProcessing(moods);
|
||||
}
|
||||
|
||||
_applyMood.Invoke(moods, new[] { target });
|
||||
if (Mod.Cfg.RecRoyaleNightMatchSunLights) MatchOtherSunLights();
|
||||
if (Mod.Cfg.RecRoyaleNightDisableFog && !Mod.Cfg.RecRoyaleNightFog) ForceFogOff();
|
||||
HideObjects();
|
||||
Mod.Log.Msg($"[royale-night] applied mood '{Describe(target)}' in '{_sceneName}'");
|
||||
return true;
|
||||
}
|
||||
|
||||
// restore
|
||||
if (_engineWasDisabled)
|
||||
{
|
||||
RestoreOtherSunLights();
|
||||
ShowObjects();
|
||||
RestoreFogState();
|
||||
_setEnabled?.Invoke(moods, new object[] { false }); // applies the captured "previous" data
|
||||
UninstallSceneMood(moods);
|
||||
if (_originalSkybox is not null && _renderSkybox is not null)
|
||||
_renderSkybox.SetValue(null, _originalSkybox);
|
||||
if (_sunWasNull && _renderSun is not null)
|
||||
_renderSun.SetValue(null, null);
|
||||
RestoreMoodPatches();
|
||||
_engineWasDisabled = false;
|
||||
_originalSkybox = null;
|
||||
_sunWasNull = false;
|
||||
Mod.Log.Msg($"[royale-night] mood engine disabled again, scene skybox/sun restored in '{_sceneName}'");
|
||||
return true;
|
||||
}
|
||||
|
||||
var current = _currentMood?.GetValue(moods);
|
||||
if (current is null || IsUnityNull(current)) { Diag("no CurrentMood to restore"); return false; }
|
||||
_applyMood.Invoke(moods, new[] { current });
|
||||
Mod.Log.Msg($"[royale-night] restored scene mood '{Describe(current)}' in '{_sceneName}'");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] apply failed: {(ex.InnerException ?? ex).Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogState(object moods, bool enabled)
|
||||
{
|
||||
if (_diagLogged) return;
|
||||
_diagLogged = true;
|
||||
try
|
||||
{
|
||||
var arr = GetMember(moods, "availableMoods");
|
||||
var cur = _currentMood?.GetValue(moods);
|
||||
Mod.Log.Msg($"[royale-night] engine state: isEnabled={enabled} roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} " +
|
||||
$"availableMoods={Count(arr)} currentMood={(cur is null || IsUnityNull(cur) ? "<null>" : Describe(cur))} " +
|
||||
$"skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] state log failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static object? Read(PropertyInfo? p, object o)
|
||||
{
|
||||
try { return p?.GetValue(o); } catch { return null; }
|
||||
}
|
||||
|
||||
private static void Diag(string why)
|
||||
{
|
||||
if (_frame % (PollEveryFrames * 20) == 0) Mod.Log.Msg($"[royale-night] waiting: {why}");
|
||||
}
|
||||
|
||||
private static bool Resolve()
|
||||
{
|
||||
_resolved = true;
|
||||
|
||||
_sceneManagerType = Mod.ResolveType("RecRoomSceneManager");
|
||||
var moodSettingsType = Mod.ResolveType("RecRoom.Core.Scene.SceneMoodSettings");
|
||||
_moodSettingType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodSetting");
|
||||
if (_sceneManagerType is null || moodSettingsType is null || _moodSettingType is null)
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] types missing — sceneManager={_sceneManagerType is not null} " +
|
||||
$"sceneMoodSettings={moodSettingsType is not null} moodSetting={_moodSettingType is not null}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// RecRoomSceneManager.Instance: the il2cpp getter is get_Instance but
|
||||
// the interop property name is obfuscated (PDCOCJBICGC in 2023-03-21).
|
||||
// Pick the static property whose type is RecRoomSceneManager.
|
||||
_sceneManagerInstance = AccessTools.Property(_sceneManagerType, "Instance");
|
||||
if (_sceneManagerInstance is null)
|
||||
{
|
||||
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (p.PropertyType == _sceneManagerType && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ _sceneManagerInstance = p; break; }
|
||||
}
|
||||
}
|
||||
|
||||
_sceneMoodSettingsField = AccessTools.Field(_sceneManagerType, "sceneMoodSettings");
|
||||
if (_sceneMoodSettingsField is null)
|
||||
{
|
||||
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
if (p.PropertyType == moodSettingsType && p.CanRead) { _sceneMoodSettingsProp = p; break; }
|
||||
}
|
||||
|
||||
_currentMood = AccessTools.Property(moodSettingsType, "CurrentMood");
|
||||
_roomMoodsEnabled = AccessTools.Property(moodSettingsType, "RoomMoodsEnabled");
|
||||
_hasSkyShader = AccessTools.Property(moodSettingsType, "HasRecRoomSkyboxShader");
|
||||
_hasSunSource = AccessTools.Property(moodSettingsType, "HasSunSource");
|
||||
_volume = AccessTools.Property(moodSettingsType, "Volume");
|
||||
_availableMoods = AccessTools.Field(moodSettingsType, "availableMoods");
|
||||
_isEnabled = AccessTools.Field(moodSettingsType, "isEnabled");
|
||||
_friendlyName = AccessTools.Property(_moodSettingType, "FriendlyName");
|
||||
|
||||
// Apply step: MLLBALMKEGJ(MoodSetting) in 2023-03-21. Fallback: the
|
||||
// only non-setter void method taking exactly one MoodSetting.
|
||||
_applyMood = AccessTools.Method(moodSettingsType, "MLLBALMKEGJ", new[] { _moodSettingType });
|
||||
if (_applyMood is null)
|
||||
{
|
||||
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 1 && ps[0].ParameterType == _moodSettingType) { _applyMood = m; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnabled(bool): LKIKMLNPCFK in 2023-03-21. Fallback: the only
|
||||
// public non-setter void(bool) declared on the type.
|
||||
_setEnabled = AccessTools.Method(moodSettingsType, "LKIKMLNPCFK", new[] { typeof(bool) });
|
||||
if (_setEnabled is null)
|
||||
{
|
||||
MethodInfo? only = null; int n = 0;
|
||||
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 1 && ps[0].ParameterType == typeof(bool)) { only = m; n++; }
|
||||
}
|
||||
if (n == 1) _setEnabled = only;
|
||||
}
|
||||
|
||||
// Capture current scene lighting: LIEDFOLBGHL() in 2023-03-21. Too
|
||||
// many void() methods on the type to pick by shape — name only.
|
||||
_capture = AccessTools.Method(moodSettingsType, "LIEDFOLBGHL", Type.EmptyTypes);
|
||||
|
||||
var renderSettings = Mod.ResolveType("UnityEngine.RenderSettings");
|
||||
_renderSkybox = renderSettings is null ? null : AccessTools.Property(renderSettings, "skybox");
|
||||
_renderSun = renderSettings is null ? null : AccessTools.Property(renderSettings, "sun");
|
||||
_lightType = Mod.ResolveType("UnityEngine.Light");
|
||||
|
||||
// Fog preservation: FogMoodSettings() + NLDDIDEIEFH() reads
|
||||
// RenderSettings.fog/fogStartDistance/fogEndDistance/fogColor into the
|
||||
// block (ISIL FogMoodSettings.txt, called right after the ctor in
|
||||
// LIEDFOLBGHL). MoodSetting.FogSettings is the serialized reference
|
||||
// the apply step hands to the fog blender.
|
||||
_fogSettingsType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodBlendData.FogMoodSettings");
|
||||
_fogCapture = _fogSettingsType is null ? null : AccessTools.Method(_fogSettingsType, "NLDDIDEIEFH", Type.EmptyTypes);
|
||||
_moodFog = AccessTools.Property(_moodSettingType, "FogSettings");
|
||||
|
||||
_materialType = Mod.ResolveType("UnityEngine.Material");
|
||||
_shaderType = Mod.ResolveType("UnityEngine.Shader");
|
||||
_moodConfigHolder = Mod.ResolveType("LNMDIIFOFPD");
|
||||
_cameraDataType = Mod.ResolveType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData");
|
||||
|
||||
var ok = _sceneManagerInstance is not null
|
||||
&& (_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null)
|
||||
&& _applyMood is not null;
|
||||
Mod.Log.Msg($"[royale-night] resolved: instance={_sceneManagerInstance is not null} " +
|
||||
$"moodSettings={_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null} " +
|
||||
$"apply={_applyMood is not null} setEnabled={_setEnabled is not null} capture={_capture is not null} " +
|
||||
$"isEnabled={_isEnabled is not null} currentMood={_currentMood is not null} skybox={_renderSkybox is not null} " +
|
||||
$"sun={_renderSun is not null} light={_lightType is not null} fogCapture={_fogCapture is not null} moodFog={_moodFog is not null}");
|
||||
return ok;
|
||||
}
|
||||
|
||||
// RenderSettings.sun ← the scene's directional Light when unset. Frontier
|
||||
// ships without the reference ("Sun Source reference has not been set in
|
||||
// Lighting settings" is the engine's own warning), so nothing sun-related
|
||||
// from the mood would otherwise apply.
|
||||
private static void EnsureSun()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_renderSun is null || _lightType is null) return;
|
||||
var current = _renderSun.GetValue(null);
|
||||
if (current is not null && !IsUnityNull(current))
|
||||
{
|
||||
Mod.Log.Msg($"[royale-night] RenderSettings.sun already set: '{ObjectName(current)}'");
|
||||
return;
|
||||
}
|
||||
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
var il2cppLight = Il2CppTypeOf(_lightType);
|
||||
if (unityObject is null || il2cppLight is null) return;
|
||||
|
||||
object? directional = null;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1) continue;
|
||||
var arr = m.Invoke(null, new object[] { il2cppLight });
|
||||
var n = Count(arr);
|
||||
float best = -1f;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var light = AsType(item, _lightType);
|
||||
if (light is null) continue;
|
||||
// LightType.Directional == 1
|
||||
var kind = GetMember(light, "type");
|
||||
if (kind is null || Convert.ToInt32(kind) != 1) continue;
|
||||
var intensity = GetMember(light, "intensity") is float f ? f : 0f;
|
||||
if (intensity > best) { best = intensity; directional = light; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (directional is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] no directional Light found in scene — sun direction/colour will not change");
|
||||
return;
|
||||
}
|
||||
_renderSun.SetValue(null, directional);
|
||||
_sunWasNull = true;
|
||||
Mod.Log.Msg($"[royale-night] RenderSettings.sun ← '{ObjectName(directional)}'");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureSun failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
// Swap the mood asset's FogSettings for a block captured from the
|
||||
// scene's current RenderSettings so applying the mood leaves fog as-is.
|
||||
// The asset is shared for the session, so RestoreFog puts the original
|
||||
// block back when we leave or toggle off.
|
||||
private static void PreserveFog(object mood)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_fogSettingsType is null || _fogCapture is null || _moodFog is null || !_moodFog.CanWrite)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] fog preservation unavailable (FogMoodSettings/NLDDIDEIEFH/FogSettings unresolved) — mood fog will apply");
|
||||
return;
|
||||
}
|
||||
if (ReferenceEquals(_fogPatchedMood, mood) && _originalFog is not null) return;
|
||||
|
||||
var captured = Activator.CreateInstance(_fogSettingsType);
|
||||
if (captured is null) return;
|
||||
_fogCapture.Invoke(captured, null);
|
||||
if (Mod.Cfg.RecRoyaleNightDisableFog)
|
||||
{
|
||||
// The block has no exposed enabled flag; push it out of range so
|
||||
// even if the blender re-enables fog each frame it is invisible.
|
||||
SetMember(captured, "startDistance", 1_000_000f);
|
||||
SetMember(captured, "fadeDistance", 1_000_000f);
|
||||
}
|
||||
|
||||
_originalFog = _moodFog.GetValue(mood);
|
||||
_fogPatchedMood = mood;
|
||||
_moodFog.SetValue(mood, captured);
|
||||
Mod.Log.Msg("[royale-night] mood fog replaced with the scene's own fog settings");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PreserveFog failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void RestoreFog()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_fogPatchedMood is not null && _originalFog is not null && _moodFog is not null && _moodFog.CanWrite)
|
||||
_moodFog.SetValue(_fogPatchedMood, _originalFog);
|
||||
}
|
||||
catch { }
|
||||
_fogPatchedMood = null;
|
||||
_originalFog = null;
|
||||
}
|
||||
|
||||
private static void RestoreMoodPatches()
|
||||
{
|
||||
RestoreFog();
|
||||
RestoreDarkness();
|
||||
RestoreOtherSunLights();
|
||||
// Scene objects die with the scene; just forget them.
|
||||
_probed = false;
|
||||
_hidden.Clear();
|
||||
_hideScanned = false;
|
||||
_fogStateSaved = false;
|
||||
_origFogEnabled = null;
|
||||
// The scene's SceneMoodSettings dies with the scene; just forget it.
|
||||
_sceneMoodInstalled = false;
|
||||
_sceneMoodsObj = null;
|
||||
_origDefaultMood = _origAvailableMoods = null;
|
||||
}
|
||||
|
||||
// The engine drives exactly one light: RenderSettings.sun (Frontier:
|
||||
// "SkyboxLight", the sun-disc light). The map's actual key light is a
|
||||
// separate directional Light it never touches, so daylight keeps
|
||||
// casting. Copy the engine-driven sun's rotation and colour onto every
|
||||
// other directional light and scale their intensity down. Originals are
|
||||
// kept per light and restored on toggle-off / scene change.
|
||||
private sealed class LightState
|
||||
{
|
||||
public object Light = null!;
|
||||
public object? Intensity, Color, Rotation, ShadowStrength;
|
||||
}
|
||||
private static readonly List<LightState> _otherSuns = new();
|
||||
|
||||
private static void MatchOtherSunLights()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_renderSun is null || _lightType is null) return;
|
||||
var sun = _renderSun.GetValue(null);
|
||||
if (sun is null || IsUnityNull(sun)) return;
|
||||
var sunPtr = (sun as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
|
||||
var sunTransform = GetMember(sun, "transform");
|
||||
var sunRotation = sunTransform is null ? null : GetMember(sunTransform, "rotation");
|
||||
var sunColor = GetMember(sun, "color");
|
||||
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
var il2cppLight = Il2CppTypeOf(_lightType);
|
||||
if (unityObject is null || il2cppLight is null) return;
|
||||
|
||||
MethodInfo? findAll = null;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||
if (findAll is null) return;
|
||||
|
||||
var arr = findAll.Invoke(null, new object[] { il2cppLight });
|
||||
var n = Count(arr);
|
||||
var touched = new List<string>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var light = AsType(item, _lightType);
|
||||
if (light is null) continue;
|
||||
var kind = GetMember(light, "type");
|
||||
if (kind is null || Convert.ToInt32(kind) != 1) continue; // Directional only
|
||||
var ptr = (light as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
|
||||
if (ptr != IntPtr.Zero && ptr == sunPtr) continue; // engine already drives this one
|
||||
|
||||
var state = _otherSuns.Find(s => (s.Light as Il2CppObjectBase)?.Pointer == ptr);
|
||||
if (state is null)
|
||||
{
|
||||
state = new LightState
|
||||
{
|
||||
Light = light,
|
||||
Intensity = GetMember(light, "intensity"),
|
||||
Color = GetMember(light, "color"),
|
||||
ShadowStrength = GetMember(light, "shadowStrength"),
|
||||
Rotation = GetMember(light, "transform") is object tr ? GetMember(tr, "rotation") : null,
|
||||
};
|
||||
_otherSuns.Add(state);
|
||||
}
|
||||
|
||||
if (state.Intensity is float orig)
|
||||
SetMember(light, "intensity", orig * Mod.Cfg.RecRoyaleNightSunScale);
|
||||
if (sunColor is not null) SetMember(light, "color", sunColor);
|
||||
if (sunRotation is not null && GetMember(light, "transform") is object t)
|
||||
SetMember(t, "rotation", sunRotation);
|
||||
touched.Add(ObjectName(light));
|
||||
}
|
||||
if (touched.Count > 0 && !_otherSunsLogged)
|
||||
{
|
||||
_otherSunsLogged = true;
|
||||
Mod.Log.Msg($"[royale-night] matched {touched.Count} other directional light(s) to the mood sun: {string.Join(", ", touched)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] MatchOtherSunLights failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
private static bool _otherSunsLogged;
|
||||
|
||||
// Register the mood as the scene's defaultMood + sole availableMoods entry
|
||||
// (get_CurrentMood returns availableMoods[index] when the synced index is
|
||||
// set, else defaultMood — either way our mood). Originals are kept so
|
||||
// UninstallSceneMood can put the scene back to "no room moods".
|
||||
private static object? _sceneMoodsObj;
|
||||
private static object? _origDefaultMood, _origAvailableMoods;
|
||||
private static bool _sceneMoodInstalled;
|
||||
|
||||
private static void InstallSceneMood(object moods, object mood)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_sceneMoodInstalled && ReferenceEquals(_sceneMoodsObj, moods)) return;
|
||||
_sceneMoodsObj = moods;
|
||||
_origDefaultMood = GetMember(moods, "defaultMood");
|
||||
_origAvailableMoods = GetMember(moods, "availableMoods");
|
||||
|
||||
var okDefault = SetMember(moods, "defaultMood", mood);
|
||||
|
||||
// availableMoods is an Il2CppReferenceArray<MoodSetting>; build a
|
||||
// one-element array of the same interop type and fill slot 0.
|
||||
var okArray = false;
|
||||
var arrProp = moods.GetType().GetProperty("availableMoods", Any);
|
||||
var arrType = arrProp?.PropertyType ?? _origAvailableMoods?.GetType();
|
||||
if (arrType is not null)
|
||||
{
|
||||
object? arr = null;
|
||||
foreach (var sig in new[] { new[] { typeof(long) }, new[] { typeof(int) } })
|
||||
{
|
||||
var ctor = arrType.GetConstructor(sig);
|
||||
if (ctor is null) continue;
|
||||
try { arr = ctor.Invoke(new object[] { sig[0] == typeof(long) ? 1L : 1 }); break; } catch { }
|
||||
}
|
||||
if (arr is not null)
|
||||
{
|
||||
var set = arrType.GetMethod("set_Item", new[] { typeof(int), _moodSettingType! });
|
||||
if (set is null)
|
||||
{
|
||||
foreach (var m in arrType.GetMethods())
|
||||
if (m.Name == "set_Item" && m.GetParameters().Length == 2 && m.GetParameters()[0].ParameterType == typeof(int)) { set = m; break; }
|
||||
}
|
||||
try { set?.Invoke(arr, new[] { 0, mood }); okArray = set is not null && SetMember(moods, "availableMoods", arr); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
_sceneMoodInstalled = okDefault || okArray;
|
||||
Mod.Log.Msg($"[royale-night] installed mood as scene default (defaultMood={okDefault} availableMoods={okArray})");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] InstallSceneMood failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void UninstallSceneMood(object moods)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_sceneMoodInstalled) return;
|
||||
if (_origDefaultMood is not null) SetMember(moods, "defaultMood", _origDefaultMood);
|
||||
else SetMember(moods, "defaultMood", null!);
|
||||
if (_origAvailableMoods is not null) SetMember(moods, "availableMoods", _origAvailableMoods);
|
||||
}
|
||||
catch { }
|
||||
_sceneMoodInstalled = false;
|
||||
_sceneMoodsObj = null;
|
||||
_origDefaultMood = _origAvailableMoods = null;
|
||||
}
|
||||
|
||||
// ── fog off / hide objects ────────────────────────────────────────
|
||||
private static bool _fogStateSaved;
|
||||
private static object? _origFogEnabled;
|
||||
private static Type? _renderSettingsType;
|
||||
|
||||
private static void ForceFogOff()
|
||||
{
|
||||
try
|
||||
{
|
||||
_renderSettingsType ??= Mod.ResolveType("UnityEngine.RenderSettings");
|
||||
if (_renderSettingsType is null) return;
|
||||
var p = _renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static);
|
||||
if (p is null) return;
|
||||
if (!_fogStateSaved) { _origFogEnabled = p.GetValue(null); _fogStateSaved = true; }
|
||||
if (p.GetValue(null) is true) p.SetValue(null, false);
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] ForceFogOff failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void RestoreFogState()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_fogStateSaved && _renderSettingsType is not null && _origFogEnabled is bool b)
|
||||
_renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static)?.SetValue(null, b);
|
||||
}
|
||||
catch { }
|
||||
_fogStateSaved = false;
|
||||
_origFogEnabled = null;
|
||||
}
|
||||
|
||||
// Disable every Renderer whose GameObject or material name contains one
|
||||
// of Cfg.RecRoyaleNightHideObjects. Runs each apply cycle but only scans
|
||||
// once per scene (the matches are static scenery); ShowObjects re-enables.
|
||||
private static readonly List<object> _hidden = new();
|
||||
private static bool _hideScanned;
|
||||
|
||||
private static void HideObjects()
|
||||
{
|
||||
if (_hideScanned) return;
|
||||
_hideScanned = true;
|
||||
try
|
||||
{
|
||||
var patterns = new List<string>();
|
||||
foreach (var s in Mod.Cfg.RecRoyaleNightHideObjects)
|
||||
if (!string.IsNullOrWhiteSpace(s)) patterns.Add(s.Trim());
|
||||
if (patterns.Count == 0) return;
|
||||
|
||||
var rendererType = Mod.ResolveType("UnityEngine.Renderer");
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
var il2cppRenderer = rendererType is null ? null : Il2CppTypeOf(rendererType);
|
||||
if (rendererType is null || unityObject is null || il2cppRenderer is null) return;
|
||||
|
||||
MethodInfo? findAll = null;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||
if (findAll is null) return;
|
||||
|
||||
var arr = findAll.Invoke(null, new object[] { il2cppRenderer });
|
||||
var n = Count(arr);
|
||||
var names = new HashSet<string>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var r = AsType(item, rendererType);
|
||||
if (r is null) continue;
|
||||
var name = ObjectName(r);
|
||||
var matName = "";
|
||||
try { var mat = GetMember(r, "sharedMaterial"); if (mat is not null && !IsUnityNull(mat)) matName = ObjectName(mat); } catch { }
|
||||
var hit = false;
|
||||
foreach (var p in patterns)
|
||||
if (name.Contains(p, StringComparison.OrdinalIgnoreCase) || matName.Contains(p, StringComparison.OrdinalIgnoreCase)) { hit = true; break; }
|
||||
if (!hit) continue;
|
||||
if (GetMember(r, "enabled") is true && SetMember(r, "enabled", false))
|
||||
{
|
||||
_hidden.Add(r);
|
||||
names.Add(name);
|
||||
}
|
||||
}
|
||||
Mod.Log.Msg($"[royale-night] hid {_hidden.Count} renderer(s) matching [{string.Join(",", patterns)}]: {string.Join(", ", names)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] HideObjects failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void ShowObjects()
|
||||
{
|
||||
foreach (var r in _hidden)
|
||||
{
|
||||
try { if (!IsUnityNull(r)) SetMember(r, "enabled", true); } catch { }
|
||||
}
|
||||
_hidden.Clear();
|
||||
_hideScanned = false;
|
||||
}
|
||||
|
||||
// ── CV2 room-settings probe ───────────────────────────────────────
|
||||
// The Maker Pen mood chips ("Skydome Constant", "Room Skydome Modify",
|
||||
// Sun/Fog/Background Objects equivalents) drive RoomSkydome / RoomSun /
|
||||
// RoomFog components that hang off RoomElementSettingsManager, which
|
||||
// CircuitsV2Manager brings up. Log whether any of that exists in this
|
||||
// scene so we know what a chips-in-Rec-Royale build would have to add.
|
||||
private static bool _probed;
|
||||
private static void ProbeCircuitsSupport()
|
||||
{
|
||||
if (_probed) return;
|
||||
_probed = true;
|
||||
try
|
||||
{
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
if (unityObject is null) return;
|
||||
MethodInfo? findAll = null;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||
if (findAll is null) return;
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"RecRoom.CircuitsV2.CircuitsV2Manager",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomElementSettingsManager",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomSkydome",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomSun",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomFog",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomBackgroundObjects",
|
||||
"MakerPen",
|
||||
})
|
||||
{
|
||||
var t = Mod.ResolveType(name);
|
||||
if (t is null) { parts.Add($"{name.Substring(name.LastIndexOf('.') + 1)}=<type missing>"); continue; }
|
||||
var il2 = Il2CppTypeOf(t);
|
||||
if (il2 is null) { parts.Add($"{t.Name}=?"); continue; }
|
||||
int n = 0;
|
||||
try { n = Count(findAll.Invoke(null, new object[] { il2 })); } catch (Exception ex) { parts.Add($"{t.Name}=err:{(ex.InnerException ?? ex).Message}"); continue; }
|
||||
parts.Add($"{t.Name}={n}");
|
||||
}
|
||||
Mod.Log.Msg($"[royale-night] CV2 probe in '{_sceneName}': {string.Join(" ", parts)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] CV2 probe failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void RestoreOtherSunLights()
|
||||
{
|
||||
foreach (var s in _otherSuns)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsUnityNull(s.Light)) continue;
|
||||
if (s.Intensity is not null) SetMember(s.Light, "intensity", s.Intensity);
|
||||
if (s.Color is not null) SetMember(s.Light, "color", s.Color);
|
||||
if (s.ShadowStrength is not null) SetMember(s.Light, "shadowStrength", s.ShadowStrength);
|
||||
if (s.Rotation is not null && GetMember(s.Light, "transform") is object t) SetMember(t, "rotation", s.Rotation);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
_otherSuns.Clear();
|
||||
_otherSunsLogged = false;
|
||||
}
|
||||
|
||||
// Make sure RenderSettings.skybox is a material on the "Custom/SkyBox"
|
||||
// shader. The apply step assigns MoodConfig.SkyBoxMaterial
|
||||
// (LNMDIIFOFPD.NHAEHKJINFH → SkyBoxMaterial); in RRO scenes that
|
||||
// reference resolves to nothing, leaving the skybox null. Prefer any
|
||||
// already-loaded material on that shader, else create one, and also
|
||||
// write it into MoodConfig so later applies keep using it.
|
||||
private static void EnsureMoodSkybox()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_renderSkybox is null || _materialType is null || _shaderType is null) return;
|
||||
|
||||
var current = _renderSkybox.GetValue(null);
|
||||
var currentShader = current is null || IsUnityNull(current) ? "" : ShaderNameOf(current);
|
||||
if (currentShader == "Custom/SkyBox") return;
|
||||
|
||||
object? mat = _ourSkybox is not null && !IsUnityNull(_ourSkybox) ? _ourSkybox : null;
|
||||
|
||||
// 1. Any loaded material already on the shader (e.g. the real
|
||||
// Skybox_Moods_Sunny_Default_Mat if some scene brought it in).
|
||||
if (mat is null)
|
||||
{
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var il2cppMat = Il2CppTypeOf(_materialType);
|
||||
if (resources is not null && il2cppMat is not null)
|
||||
{
|
||||
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
|
||||
var arr = m.Invoke(null, new object[] { il2cppMat });
|
||||
var n = Count(arr);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var wrapped = AsType(item, _materialType);
|
||||
if (wrapped is not null && ShaderNameOf(wrapped) == "Custom/SkyBox") { mat = wrapped; break; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mat is not null) Mod.Log.Msg($"[royale-night] using loaded skybox material '{ObjectName(mat)}'");
|
||||
}
|
||||
|
||||
// 2. Build one: new Material(Shader.Find("Custom/SkyBox")).
|
||||
if (mat is null)
|
||||
{
|
||||
var find = AccessTools.Method(_shaderType, "Find", new[] { typeof(string) });
|
||||
var shader = find?.Invoke(null, new object[] { "Custom/SkyBox" });
|
||||
if (shader is null || IsUnityNull(shader))
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] Shader.Find(\"Custom/SkyBox\") returned nothing — sky colours will not apply");
|
||||
return;
|
||||
}
|
||||
var ctor = _materialType.GetConstructor(new[] { _shaderType }) ?? _materialType.GetConstructor(new[] { shader.GetType() });
|
||||
if (ctor is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] Material(Shader) constructor not found");
|
||||
return;
|
||||
}
|
||||
mat = ctor.Invoke(new[] { shader });
|
||||
SetMember(mat, "name", "DorkNet_RecRoyaleNight_Skybox");
|
||||
Mod.Log.Msg("[royale-night] created skybox material on Custom/SkyBox");
|
||||
}
|
||||
|
||||
_ourSkybox = mat;
|
||||
_renderSkybox.SetValue(null, mat);
|
||||
|
||||
// Keep the engine's own apply path pointing at it too.
|
||||
if (_moodConfigHolder is not null)
|
||||
{
|
||||
foreach (var p in _moodConfigHolder.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (!p.CanRead || p.GetIndexParameters().Length != 0) continue;
|
||||
var cfg = p.GetValue(null);
|
||||
if (cfg is null || IsUnityNull(cfg)) continue;
|
||||
if (SetMember(cfg, "SkyBoxMaterial", mat)) Mod.Log.Msg("[royale-night] MoodConfig.SkyBoxMaterial ← our material");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureMoodSkybox failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static string ShaderNameOf(object material)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shader = GetMember(material, "shader");
|
||||
return shader is null || IsUnityNull(shader) ? "" : ObjectName(shader);
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
private static string DescribeSkybox()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sky = _renderSkybox?.GetValue(null);
|
||||
if (sky is null || IsUnityNull(sky)) return "<null>";
|
||||
return $"'{ObjectName(sky)}' [{ShaderNameOf(sky)}]";
|
||||
}
|
||||
catch { return "?"; }
|
||||
}
|
||||
|
||||
// Post-exposure lives in the URP Volume the mood engine drives. It only
|
||||
// shows if the Volume is enabled with weight and the camera renders
|
||||
// post-processing (Rec Room's graphics settings can turn that off).
|
||||
private static void EnsurePostProcessing(object moods)
|
||||
{
|
||||
try
|
||||
{
|
||||
var volume = Read(_volume, moods);
|
||||
if (volume is not null && !IsUnityNull(volume))
|
||||
{
|
||||
var enabled = GetMember(volume, "enabled");
|
||||
var weight = GetMember(volume, "weight");
|
||||
var isGlobal = GetMember(volume, "isGlobal");
|
||||
var profile = GetMember(volume, "profile");
|
||||
Mod.Log.Msg($"[royale-night] volume: enabled={enabled} weight={weight} global={isGlobal} profile={(profile is null ? "<null>" : ObjectName(profile))}");
|
||||
if (Mod.Cfg.RecRoyaleNightForcePostProcessing)
|
||||
{
|
||||
if (enabled is false) SetMember(volume, "enabled", true);
|
||||
if (weight is float w && w < 1f) SetMember(volume, "weight", 1f);
|
||||
}
|
||||
}
|
||||
else Mod.Log.Warning("[royale-night] scene mood Volume is null — post-exposure darkening unavailable");
|
||||
|
||||
if (_cameraDataType is null) { Mod.Log.Warning("[royale-night] UniversalAdditionalCameraData type not found"); return; }
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
var il2cppCam = Il2CppTypeOf(_cameraDataType);
|
||||
if (unityObject is null || il2cppCam is null) return;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
|
||||
var arr = m.Invoke(null, new object[] { il2cppCam });
|
||||
var n = Count(arr);
|
||||
int forced = 0;
|
||||
var states = new List<string>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var cam = AsType(item, _cameraDataType);
|
||||
if (cam is null) continue;
|
||||
var pp = GetMember(cam, "renderPostProcessing");
|
||||
states.Add($"{ObjectName(cam)}={pp}");
|
||||
if (pp is false && Mod.Cfg.RecRoyaleNightForcePostProcessing && SetMember(cam, "renderPostProcessing", true)) forced++;
|
||||
}
|
||||
Mod.Log.Msg($"[royale-night] camera post-processing: {string.Join(", ", states)}{(forced > 0 ? $" → forced on ({forced})" : "")}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsurePostProcessing failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
// Darken the mood data in place before it is applied:
|
||||
// ColorGradingSettings.exposure += RecRoyaleNightExposure (EV; the
|
||||
// ColorGrading blender writes it into ColorAdjustments.postExposure
|
||||
// via VolumeParameter.Override — the only knob that also dims baked
|
||||
// lightmaps, since it's a full-frame post effect)
|
||||
// SunSettings.sunIntensity *= RecRoyaleNightSunScale
|
||||
// SkyBoxSettings.ambientLightIntensity *= RecRoyaleNightAmbientScale
|
||||
// Originals are kept and restored by RestoreDarkness so the shared asset
|
||||
// is clean again for any custom room that uses the same mood.
|
||||
private static void PatchDarkness(object mood)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ReferenceEquals(_darkPatchedMood, mood)) return;
|
||||
RestoreDarkness();
|
||||
|
||||
var grading = GetMember(mood, "ColorGradingSettings");
|
||||
var sun = GetMember(mood, "SunSettings");
|
||||
var sky = GetMember(mood, "SkyBoxSettings");
|
||||
|
||||
if (grading is not null && Mod.Cfg.RecRoyaleNightExposure != 0f && GetMember(grading, "exposure") is float ev)
|
||||
{
|
||||
_origExposure = ev;
|
||||
SetMember(grading, "exposure", ev + Mod.Cfg.RecRoyaleNightExposure);
|
||||
}
|
||||
if (sun is not null && Mod.Cfg.RecRoyaleNightSunScale != 1f && GetMember(sun, "sunIntensity") is float si)
|
||||
{
|
||||
_origSunIntensity = si;
|
||||
SetMember(sun, "sunIntensity", si * Mod.Cfg.RecRoyaleNightSunScale);
|
||||
}
|
||||
if (sky is not null && Mod.Cfg.RecRoyaleNightAmbientScale != 1f && GetMember(sky, "ambientLightIntensity") is float ai)
|
||||
{
|
||||
_origAmbient = ai;
|
||||
SetMember(sky, "ambientLightIntensity", ai * Mod.Cfg.RecRoyaleNightAmbientScale);
|
||||
}
|
||||
_darkPatchedMood = mood;
|
||||
Mod.Log.Msg($"[royale-night] darkened mood: exposure {_origExposure?.ToString("0.##") ?? "?"}→{(_origExposure + Mod.Cfg.RecRoyaleNightExposure)?.ToString("0.##") ?? "?"} EV, " +
|
||||
$"sun ×{Mod.Cfg.RecRoyaleNightSunScale} ({_origSunIntensity?.ToString("0.##") ?? "?"}), ambient ×{Mod.Cfg.RecRoyaleNightAmbientScale} ({_origAmbient?.ToString("0.##") ?? "?"})");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PatchDarkness failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void RestoreDarkness()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_darkPatchedMood is not null)
|
||||
{
|
||||
var grading = GetMember(_darkPatchedMood, "ColorGradingSettings");
|
||||
var sun = GetMember(_darkPatchedMood, "SunSettings");
|
||||
var sky = GetMember(_darkPatchedMood, "SkyBoxSettings");
|
||||
if (grading is not null && _origExposure is float e) SetMember(grading, "exposure", e);
|
||||
if (sun is not null && _origSunIntensity is float s) SetMember(sun, "sunIntensity", s);
|
||||
if (sky is not null && _origAmbient is float a) SetMember(sky, "ambientLightIntensity", a);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
_darkPatchedMood = null;
|
||||
_origExposure = _origSunIntensity = _origAmbient = null;
|
||||
}
|
||||
|
||||
private static bool SetMember(object o, string name, object value)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, Any);
|
||||
if (p is not null && p.CanWrite) { try { p.SetValue(o, value); return true; } catch { } }
|
||||
var f = t.GetField(name, Any);
|
||||
if (f is not null) { try { f.SetValue(o, value); return true; } catch { } }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Il2CppInterop exposes il2cpp fields as properties (and, on some
|
||||
// versions, as fields). Read either.
|
||||
private static object? GetMember(object o, string name)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, Any);
|
||||
if (p is not null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ try { return p.GetValue(o); } catch { } }
|
||||
var f = t.GetField(name, Any);
|
||||
if (f is not null) { try { return f.GetValue(o); } catch { } }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Il2CppType.From(Type) → Il2CppSystem.Type, via reflection so this
|
||||
// project keeps not referencing Il2Cppmscorlib at compile time.
|
||||
private static object? Il2CppTypeOf(Type type)
|
||||
{
|
||||
foreach (var m in typeof(Il2CppType).GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "From" || m.IsGenericMethodDefinition) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length >= 1 && ps[0].ParameterType == typeof(Type))
|
||||
{
|
||||
var args = new object?[ps.Length];
|
||||
args[0] = type;
|
||||
for (int i = 1; i < ps.Length; i++) args[i] = ps[i].HasDefaultValue ? ps[i].DefaultValue : (ps[i].ParameterType.IsValueType ? Activator.CreateInstance(ps[i].ParameterType) : null);
|
||||
var r = m.Invoke(null, args);
|
||||
if (r is not null) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? GetSceneManager()
|
||||
{
|
||||
var inst = _sceneManagerInstance?.GetValue(null);
|
||||
if (inst is null || IsUnityNull(inst)) return null;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// Loads the configured MoodSetting asset. Resources.Load(path, Type)
|
||||
// pulls it out of resources.assets by name (the ResourceManager table
|
||||
// keys are the lower-cased asset names, no folder). Fallback:
|
||||
// Resources.FindObjectsOfTypeAll(MoodSetting) by name, which only sees
|
||||
// assets already in memory.
|
||||
private static object? GetNightMood()
|
||||
{
|
||||
var wanted = (Mod.Cfg.RecRoyaleNightMood ?? "").Trim();
|
||||
if (wanted.Length == 0) wanted = "Night_Calm_Outdoor_Mood";
|
||||
if (_nightMood is not null && _nightMoodLoadedName == wanted && !IsUnityNull(_nightMood))
|
||||
return _nightMood;
|
||||
|
||||
_nightMood = null;
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
if (resources is null || _moodSettingType is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] UnityEngine.Resources not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
var il2cppType = Il2CppTypeOf(_moodSettingType);
|
||||
if (il2cppType is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] Il2CppType.From(Type) not found");
|
||||
return null;
|
||||
}
|
||||
object? found = null;
|
||||
|
||||
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "Load" || m.IsGenericMethodDefinition) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 2 || ps[0].ParameterType != typeof(string)) continue;
|
||||
try
|
||||
{
|
||||
found = m.Invoke(null, new object[] { wanted, il2cppType });
|
||||
if (found is not null && !IsUnityNull(found)) break;
|
||||
found = null;
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] Resources.Load failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
if (found is null)
|
||||
{
|
||||
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1) continue;
|
||||
try
|
||||
{
|
||||
var arr = m.Invoke(null, new object[] { il2cppType });
|
||||
var n = Count(arr);
|
||||
var names = new List<string>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var name = ObjectName(item);
|
||||
names.Add(name);
|
||||
if (string.Equals(name, wanted, StringComparison.OrdinalIgnoreCase)) { found = item; break; }
|
||||
}
|
||||
if (found is null)
|
||||
Mod.Log.Warning($"[royale-night] mood '{wanted}' not in memory; loaded moods: {string.Join(", ", names)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] FindObjectsOfTypeAll failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found is null)
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] could not load MoodSetting '{wanted}'");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resources.Load hands back a UnityEngine.Object wrapper; re-wrap the
|
||||
// same il2cpp pointer as MoodSetting so the apply method accepts it.
|
||||
_nightMood = AsType(found, _moodSettingType);
|
||||
_nightMoodLoadedName = wanted;
|
||||
return _nightMood;
|
||||
}
|
||||
|
||||
private static object? AsType(object obj, Type target)
|
||||
{
|
||||
if (target.IsInstanceOfType(obj)) return obj;
|
||||
if (obj is Il2CppObjectBase il2cpp)
|
||||
{
|
||||
var ctor = target.GetConstructor(new[] { typeof(IntPtr) });
|
||||
if (ctor is not null) return ctor.Invoke(new object[] { il2cpp.Pointer });
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ── hotkey ────────────────────────────────────────────────────────
|
||||
private static void PollHotkey()
|
||||
{
|
||||
var key = Mod.Cfg.RecRoyaleNightToggleKey;
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput(key);
|
||||
if (_getKeyDown is null || _toggleKey is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKey }) is true) Toggle();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] hotkey poll failed: {ex.Message}");
|
||||
_getKeyDown = null; // stop spamming
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResolveInput(string key)
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKey = Enum.Parse(keyCodeType, key, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] '{key}' is not a UnityEngine.KeyCode — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) Mod.Log.Warning("[royale-night] Input.GetKeyDown(KeyCode) not found — hotkey disabled");
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────
|
||||
private static bool MatchesScene(string sceneName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sceneName)) return false;
|
||||
foreach (var s in Mod.Cfg.RecRoyaleNightScenes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) continue;
|
||||
if (sceneName.Contains(s.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// UnityEngine.Object wrappers are non-null C# objects even when the
|
||||
// native object is destroyed/missing; `== null` is overloaded in the
|
||||
// interop, so route through the static op_Equality when present.
|
||||
private static bool IsUnityNull(object obj)
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = obj.GetType();
|
||||
var eq = t.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
|
||||
if (eq is not null && eq.GetParameters().Length == 2)
|
||||
return eq.Invoke(null, new[] { obj, null }) is true;
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ObjectName(object obj)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = obj.GetType().GetProperty("name", Any);
|
||||
return p?.GetValue(obj) as string ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
private static string Describe(object mood)
|
||||
{
|
||||
var friendly = "";
|
||||
try { friendly = _friendlyName?.GetValue(mood) as string ?? ""; } catch { }
|
||||
var name = ObjectName(mood);
|
||||
return friendly.Length > 0 ? $"{friendly} ({name})" : name;
|
||||
}
|
||||
|
||||
private static int Count(object? arr)
|
||||
{
|
||||
if (arr is null) return 0;
|
||||
if (arr is Array a) return a.Length;
|
||||
var p = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||
return (p?.GetValue(arr) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? Item(object? arr, int i)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.GetValue(i);
|
||||
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return gi?.Invoke(arr, new object[] { i });
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
|
||||
// doesn't pick it up. Don't rename to .cs unless you're putting the
|
||||
// feature back.
|
||||
//
|
||||
// What this used to do
|
||||
// --------------------
|
||||
// Built a Photon AuthenticationValues with `userid` + `LoginLock`
|
||||
// custom-auth parameters from the in-game RecNet account state. The
|
||||
// server's /photon/customauth controller (PhotonCustomAuthController.cs)
|
||||
// validated those parameters against the player's /player/login lock.
|
||||
//
|
||||
// Status in this branch
|
||||
// ---------------------
|
||||
// The method existed but was **never actually wired to a harmony
|
||||
// hook** in this branch's OnLateInitializeMelon. December's Photon
|
||||
// AuthValues path goes through PhotonPatches.LogAuthValues_Postfix
|
||||
// (postfix on PUNNetworkManager.EMGGKBALALH), which only logs — it
|
||||
// doesn't inject. So this code was effectively dead in december.
|
||||
//
|
||||
// Removed because:
|
||||
// 1. Photon Cloud is currently configured with Custom Authentication
|
||||
// disabled at the AppId level (anonymous connects allowed), so
|
||||
// no server-side auth probe.
|
||||
// 2. Even when the watch did send AuthValues, the upstream Cloudflare
|
||||
// / Localtunnel layer 403'd Photon's auth probe before our
|
||||
// controller could decide anything.
|
||||
//
|
||||
// How to put it back
|
||||
// ------------------
|
||||
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic suffix).
|
||||
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the LoadConfig
|
||||
// line.
|
||||
// 3. Wire the harmony hook in OnLateInitializeMelon. The march branch's
|
||||
// pick of NetworkingPeer.CallAuthenticate doesn't fire in IL2CPP, so
|
||||
// consider hooking PUNNetworkManager.EMGGKBALALH postfix instead and
|
||||
// mutating its returned AuthValues in place — that's the mangled
|
||||
// method december verifies fires (LogAuthValues_Postfix already
|
||||
// proves this entry point works).
|
||||
// 4. Put `"InjectAuthValues": true` back in dorknet-clientmod.json.template.
|
||||
// 5. If you also want the server to enforce the lock, set
|
||||
// Photon__RequireCustomAuth=true on the server.
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace DorkNet.ClientMod.Attic;
|
||||
|
||||
internal static class AuthValuesInjector
|
||||
{
|
||||
public static void AuthValuesInjector_Prefix(object __instance)
|
||||
{
|
||||
if (!Mod.Cfg.InjectAuthValues) return;
|
||||
try
|
||||
{
|
||||
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
|
||||
// watch never sets them itself, so we set them right before
|
||||
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
|
||||
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
|
||||
// from PlayerPrefs key LoginLockTokenV2.
|
||||
// RecNet.Core.LocalAccountId is declared `int` in the game
|
||||
// build (dump.cs:573622 → 0x30 backing field). Convert via
|
||||
// Convert.ToInt64 so boxing as int still gives us the long we
|
||||
// need below; a direct (long) cast on boxed int would throw.
|
||||
var coreType = FindGameType("RecNet.Core");
|
||||
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
|
||||
if (accountId <= 0)
|
||||
{
|
||||
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
|
||||
return;
|
||||
}
|
||||
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
|
||||
var existing = avProp?.GetValue(null);
|
||||
if (existing is not null)
|
||||
{
|
||||
var userIdProp = existing.GetType().GetProperty("UserId");
|
||||
var existingUserId = userIdProp?.GetValue(existing) as string;
|
||||
if (!string.IsNullOrEmpty(existingUserId)) return;
|
||||
}
|
||||
|
||||
var token = GetCurrentLockToken();
|
||||
|
||||
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
|
||||
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
|
||||
var av = Activator.CreateInstance(authValuesType);
|
||||
var authTypeProp = authValuesType.GetProperty("AuthType");
|
||||
if (authTypeProp is not null)
|
||||
{
|
||||
var customAuthValue = authTypeProp.PropertyType.IsEnum
|
||||
? Enum.ToObject(authTypeProp.PropertyType, 0)
|
||||
: Convert.ChangeType(0, authTypeProp.PropertyType);
|
||||
authTypeProp.SetValue(av, customAuthValue);
|
||||
}
|
||||
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
|
||||
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
|
||||
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
|
||||
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
|
||||
|
||||
avProp?.SetValue(null, av);
|
||||
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static string? GetCurrentLockToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
|
||||
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
|
||||
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
const string key = "\"LockToken\":\"";
|
||||
var ix = raw.IndexOf(key, StringComparison.Ordinal);
|
||||
if (ix < 0) return null;
|
||||
ix += key.Length;
|
||||
var end = raw.IndexOf('"', ix);
|
||||
return end < 0 ? null : raw[ix..end];
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// FindGameType in the live Mod.cs is a delegate to Mod.ResolveType.
|
||||
// Use that on restore; this is just a placeholder to keep the file
|
||||
// compilable if someone untreats it without re-checking the wiring.
|
||||
private static Type? FindGameType(string name) => null;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"PhotonAppId": "{PHOTON_APPID}",
|
||||
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
|
||||
"PhotonCloudRegion": "{PHOTON_REGION}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"AutoCreateAccount": false,
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DevCommands": [],
|
||||
"QuestMaxTeamSize": 0,
|
||||
"QuestMaxTeamSizeRooms": [
|
||||
"CrimsonCauldron",
|
||||
@@ -11,5 +12,18 @@
|
||||
"GoldenTrophy",
|
||||
"IsleOfLostSkulls",
|
||||
"TheRiseofJumbotron"
|
||||
]
|
||||
],
|
||||
"RecRoyaleNightMode": false,
|
||||
"RecRoyaleNightMood": "Night_Calm_Outdoor_Mood",
|
||||
"RecRoyaleNightScenes": ["RecRoyale"],
|
||||
"RecRoyaleNightFlashlights": true,
|
||||
"RecRoyaleNightFog": false,
|
||||
"RecRoyaleNightExposure": -3.5,
|
||||
"RecRoyaleNightSunScale": 0.15,
|
||||
"RecRoyaleNightAmbientScale": 0.2,
|
||||
"RecRoyaleNightForcePostProcessing": true,
|
||||
"RecRoyaleNightMatchSunLights": true,
|
||||
"RecRoyaleNightDisableFog": false,
|
||||
"RecRoyaleNightHideObjects": ["Cloud"],
|
||||
"RecRoyaleNightToggleKey": ""
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -46,6 +46,12 @@ public static class DorkNetRouteOwnership
|
||||
"/discover",
|
||||
"/featuredrooms",
|
||||
"/goto",
|
||||
// 2023 clients renamed the whole goto/* matchmaking surface
|
||||
// to matchmake/* (matchmake/room, /instance, /invite,
|
||||
// /chatinvite, …). Same controller as /goto, so it belongs
|
||||
// to the match service; without this it fell through to the
|
||||
// Monolith default owner.
|
||||
"/matchmake",
|
||||
"/hot_rooms",
|
||||
"/hot_roomsandplaylists",
|
||||
"/invite",
|
||||
@@ -115,9 +121,14 @@ public static class DorkNetRouteOwnership
|
||||
"/api/gamerewards",
|
||||
"/api/inventions",
|
||||
"/api/purchase",
|
||||
"/api/roomconsumables",
|
||||
"/api/roomcurrencies",
|
||||
"/api/storefronts",
|
||||
"/commerce",
|
||||
"/econ",
|
||||
"/purchase",
|
||||
"/purchasecampaign",
|
||||
"/reminder",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Platform,
|
||||
|
||||
@@ -0,0 +1,3751 @@
|
||||
// DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
|
||||
//
|
||||
// The shipping ClientMod carries ONLY the patches needed to connect and
|
||||
// survive anti-cheat. All of the verbose diagnostic/tracing code lives
|
||||
// here: HTTP header/request tracing, join/quit/studio/room-load traces,
|
||||
// the process-level exception sink, auth-token tracing, the registration
|
||||
// dialog trace, and the in-game DebugConsole enable/toggle path. Drop
|
||||
// DorkNet.DebugMod.dll into the Mods folder next to DorkNet.ClientMod.dll
|
||||
// to turn all of it on; remove it to ship a quiet, minimal client.
|
||||
//
|
||||
// It is a fully self-contained MelonMod (its own Harmony instance + its
|
||||
// own copy of the reflection/patch helpers) so it neither needs nor
|
||||
// couples to ClientMod at load time. Debug knobs are read from the same
|
||||
// <MelonLoader>\UserData\dorknet-clientmod.json the ClientMod uses.
|
||||
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.DebugMod;
|
||||
|
||||
public class DebugMod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static DebugMod? Instance;
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _httpDiagnosticsRegistered;
|
||||
private bool _joinTracePatchesRegistered;
|
||||
private bool _quitTraceCoreRegistered;
|
||||
private bool _quitTraceGameComplete;
|
||||
private int _quitTraceRetryFrame;
|
||||
private bool _debugConsolePatchesComplete;
|
||||
private int _debugConsoleRetryFrame;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private bool _processExceptionHandlersRegistered;
|
||||
private bool _firstUpdateLogged;
|
||||
private bool _devCommandsDumped;
|
||||
private int _devCommandsDumpFrame;
|
||||
private int _devCommandsDumpTries;
|
||||
|
||||
public static class Cfg
|
||||
{
|
||||
// Log-only trace for the stuck first-dorm "Change Username?" dialog.
|
||||
public static bool TraceRegistrationDialog = true;
|
||||
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
|
||||
// (normally dev-gated) and silence CheatManager's tamper detectors so
|
||||
// movement/time commands don't drop you to the dorm. Default OFF. The
|
||||
// toggle key is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
public static string[] DevCommands = Array.Empty<string>();
|
||||
// Read-only one-shot: dump the full runtime command table to
|
||||
// UserData/dorknet-devcommands.txt. Default OFF.
|
||||
public static bool DumpDevCommands = false;
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet DebugMod loading ===");
|
||||
LoadConfig();
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostic patches ===");
|
||||
RegisterProcessExceptionHandlers();
|
||||
RegisterHttpDiagnostics();
|
||||
RegisterQuitTracePatches(logMisses: true);
|
||||
RegisterStudioTracePatches();
|
||||
RegisterJoinTracePatches();
|
||||
// Auth-token setter trace (LBNJFPOLCDL.JHLKGBGJKKK(access, refresh, key)).
|
||||
// The shipping ClientMod used to register this unconditionally; it now
|
||||
// lives here with the rest of the diagnostics.
|
||||
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
|
||||
args: new[] { typeof(string), typeof(string), typeof(string) },
|
||||
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
|
||||
// Deep game-side diagnostics: CheatManager tamper-detection logging,
|
||||
// the Unity Debug.LogError/LogException sink, Photon connect/join
|
||||
// tracing, and the room-load MoveNext state dumps. Unwired in the
|
||||
// shipping client (kept out of the minimal footprint); this is their
|
||||
// home now that diagnostics are opt-in.
|
||||
RegisterDiagnostics();
|
||||
if (Cfg.EnableDebugConsole)
|
||||
RegisterDebugConsolePatches(logMisses: true);
|
||||
Log.Msg("=== Diagnostic patches registered ===");
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
{
|
||||
_firstUpdateLogged = true;
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
}
|
||||
|
||||
if (!_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
|
||||
{
|
||||
_quitTraceRetryFrame = 0;
|
||||
RegisterQuitTracePatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (Cfg.EnableDebugConsole && !_debugConsolePatchesComplete && ++_debugConsoleRetryFrame >= 300)
|
||||
{
|
||||
_debugConsoleRetryFrame = 0;
|
||||
RegisterDebugConsolePatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (Cfg.EnableDebugConsole)
|
||||
DebugConsolePatches.PollToggleKey();
|
||||
|
||||
// Retry the read-only command dump every ~5s (the config singleton only
|
||||
// exists after the ~26s load + dorm entry). Stops as soon as it finds a
|
||||
// populated runtime table, or after ~2.5 min of attempts.
|
||||
if (Cfg.DumpDevCommands && !_devCommandsDumped && ++_devCommandsDumpFrame >= 300)
|
||||
{
|
||||
_devCommandsDumpFrame = 0;
|
||||
if (DebugConsolePatches.DumpCommandDiagnostics() || ++_devCommandsDumpTries >= 30)
|
||||
_devCommandsDumped = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userData = ResolveUserDataDirectory();
|
||||
Directory.CreateDirectory(userData);
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no file at {path}; using debug defaults");
|
||||
return;
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (TryGetConfigValue(r, "TraceRegistrationDialog", out var v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (TryGetConfigValue(r, "DumpDevCommands", out v)) Cfg.DumpDevCommands = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var commands = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
commands.Add(e.GetString()!);
|
||||
Cfg.DevCommands = commands.ToArray();
|
||||
}
|
||||
Log.Msg($"[config] debug: debugConsole={Cfg.EnableDebugConsole}, " +
|
||||
$"dumpDevCommands={Cfg.DumpDevCommands}, devCommands={Cfg.DevCommands.Length}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
foreach (var holder in new[] { typeof(HttpTracePatches), typeof(AuthPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(MakerPenGiftPreviewPatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = methodName == "ctor"
|
||||
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
|
||||
: (args is null ? AccessTools.Method(target, methodName)
|
||||
: AccessTools.Method(target, methodName, args));
|
||||
if (method is null)
|
||||
{
|
||||
// `logMiss` is throttled by the retry-loop caller — once per
|
||||
// 5 seconds in OnUpdate, every time in the one-shot initial
|
||||
// RegisterDiagnostics. Without the gate the per-second retry
|
||||
// loop would spam `[patch-miss]` lines forever for any method
|
||||
// whose name drifted in a newer game build.
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {label}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {label}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private bool TryPatchByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||
}
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
|
||||
return false;
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
internal static Type? ResolveType(string name)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
return cached;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
|
||||
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
|
||||
var dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
|
||||
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
|
||||
}
|
||||
}
|
||||
catch { /* unloaded / dynamic asm — skip */ }
|
||||
}
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var t in GetLoadableTypes(asm))
|
||||
{
|
||||
if (MatchesRequestedType(t, name))
|
||||
return CacheResolvedType(name, t);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static Type CacheResolvedType(string name, Type type)
|
||||
{
|
||||
ResolvedTypeCache[name] = type;
|
||||
return type;
|
||||
}
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||
{
|
||||
try
|
||||
{
|
||||
return asm.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
var types = new List<Type>();
|
||||
foreach (var t in ex.Types)
|
||||
{
|
||||
if (t is not null) types.Add(t);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
var dottedPrefixed = "Il2Cpp." + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
var names = new List<string>();
|
||||
foreach (var arg in args)
|
||||
names.Add(arg.Name);
|
||||
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||
}
|
||||
private static string FormatParameterTypes(ParameterInfo[] parameters)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var parameter in parameters)
|
||||
names.Add(parameter.ParameterType.Name);
|
||||
return string.Join(",", names);
|
||||
}
|
||||
private static bool TypeNameContains(Type type, string value)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains(value, StringComparison.Ordinal);
|
||||
}
|
||||
private static bool MethodNameContains(MethodInfo method, string value)
|
||||
=> method.Name.Contains(value, StringComparison.Ordinal);
|
||||
private static string ResolveUserDataDirectory()
|
||||
{
|
||||
foreach (var typeName in new[]
|
||||
{
|
||||
"MelonLoader.MelonEnvironment, MelonLoader",
|
||||
"MelonLoader.MelonUtils, MelonLoader",
|
||||
})
|
||||
{
|
||||
var type = Type.GetType(typeName);
|
||||
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
|
||||
return Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
}
|
||||
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
private void RegisterJoinTracePatches()
|
||||
{
|
||||
if (_joinTracePatchesRegistered) return;
|
||||
Log.Msg("=== Registering room-join trace ===");
|
||||
|
||||
// Watch "Dorm Room" button. Void, no params, unique name —
|
||||
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
|
||||
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
|
||||
|
||||
// Every SessionManager.RunJoinRoom overload (string-name entry +
|
||||
// the RoomInstance inner legs). Logs the method + its arguments.
|
||||
PatchAllOverloads("SessionManager", "RunJoinRoom",
|
||||
nameof(JoinPatches.RunJoinRoom_Prefix));
|
||||
|
||||
// The /goto/room/{name|id} promise builders — both the string
|
||||
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
|
||||
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
|
||||
nameof(JoinPatches.Goto_Prefix));
|
||||
|
||||
// The RunJoinRoom promise-chain callbacks compiled onto the
|
||||
// SessionManager <>c display class — most importantly the reject
|
||||
// handler b__165_1(string error) that raises the happyfox toast.
|
||||
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
|
||||
// marker "b__" so we hit the callbacks but not the RunJoinRoom
|
||||
// state-machine MoveNext.
|
||||
PatchNestedLambdas("SessionManager", "RunJoinRoom",
|
||||
nameof(JoinPatches.JoinCallback_Prefix));
|
||||
|
||||
// Additionally capture the REJECT REASON string on b__165_1 (the
|
||||
// happyfox-toast handler). Matches only that one lambda; it ends
|
||||
// up with two prefixes (flow + error string), which is harmless.
|
||||
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
|
||||
nameof(JoinPatches.JoinError_Prefix));
|
||||
|
||||
_joinTracePatchesRegistered = true;
|
||||
Log.Msg("=== Room-join trace registered ===");
|
||||
}
|
||||
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
|
||||
return false;
|
||||
}
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
|
||||
}
|
||||
}
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
|
||||
return false;
|
||||
}
|
||||
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
|
||||
return true;
|
||||
}
|
||||
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
|
||||
return false;
|
||||
}
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
|
||||
{
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(nested))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
|
||||
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched == 0 && logMiss)
|
||||
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
|
||||
return patched > 0;
|
||||
}
|
||||
private void RegisterHttpDiagnostics()
|
||||
{
|
||||
if (_httpDiagnosticsRegistered) return;
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatchBndRequestBuilderDiagnostics();
|
||||
complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback",
|
||||
postfix: nameof(HttpTracePatches.CallCallback_Postfix),
|
||||
finalizer: nameof(HttpTracePatches.CallCallback_Finalizer));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
|
||||
_httpDiagnosticsRegistered = complete;
|
||||
}
|
||||
private bool TryPatchBndRequestBuilderDiagnostics()
|
||||
{
|
||||
const string label = "BNDIAONDFFF.request-builder";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType("BNDIAONDFFF");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)));
|
||||
var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)));
|
||||
var patched = 0;
|
||||
|
||||
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var parameters = ctor.GetParameters();
|
||||
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != "FILJELOLBKK") continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}");
|
||||
return true;
|
||||
}
|
||||
private void RegisterQuitTracePatches(bool logMisses)
|
||||
{
|
||||
if (!_quitTraceCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(QuitTracePatches.ApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(QuitTracePatches.ApplicationQuitInt_Prefix),
|
||||
logMiss: logMisses);
|
||||
_quitTraceCoreRegistered = true;
|
||||
}
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM",
|
||||
prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "DGHJGMBNHCL",
|
||||
prefix: nameof(QuitTracePatches.CreateExitTask_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
_quitTraceGameComplete = complete;
|
||||
}
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
if (!_diagnosticCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||
_diagnosticCoreRegistered = true;
|
||||
}
|
||||
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||
Log.Msg("=== Diagnostics registered ===");
|
||||
}
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
// CheatManager fires these when its anti-cheat heuristics trip —
|
||||
// logging them surfaces "client thinks it's been tampered with",
|
||||
// a common cause of silent dorm-drops / kicks on a modded client.
|
||||
// Names verified in CheatManager.txt for 2020.12.18; the older
|
||||
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
|
||||
// there's no DeveloperFlag/StreamingAsset callback nor a
|
||||
// BootSequence.RegisterError sink in this build — those legacy
|
||||
// hooks were removed (they only ever logged [patch-miss] forever).
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"OnTimeCheatDetected",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
"OnHeightCheatDetected",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
})
|
||||
{
|
||||
TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
RegisterDeepRoomDiagnostics(logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
// methods) MonoMod's lazy CompileMethodHook on the trampoline
|
||||
// crashes the CLR with 0x80131506 the instant Awake JITs. The
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
return true;
|
||||
}
|
||||
private void RegisterDebugConsolePatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
|
||||
"Awake",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.Awake_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
|
||||
"InputField_OnEndEdit",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.InputFieldOnEndEdit_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
// Dev commands that change movement/time trip retail anti-cheat
|
||||
// callbacks. Suppress the three callbacks present in the 2023.03.21
|
||||
// dump; keep the legacy height detector as a best-effort optional
|
||||
// patch for adjacent builds without keeping this retry loop alive.
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnTimeCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnObscuredTypeCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("CheatManager",
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchDiagnosticByName("CheatManager",
|
||||
"OnHeightCheatDetected",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
|
||||
logMiss: false);
|
||||
|
||||
_debugConsolePatchesComplete = complete;
|
||||
if (complete)
|
||||
Log.Msg("[debugconsole] restoration patches armed");
|
||||
}
|
||||
private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
string? finalizer = null, bool logMiss = true)
|
||||
{
|
||||
var label = $"{typeName}.{methodName}(*)";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)),
|
||||
finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer)));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] {label} x{patched}");
|
||||
return true;
|
||||
}
|
||||
private bool RegisterDeepRoomDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError",
|
||||
args: new[] { typeof(object) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogError_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException",
|
||||
args: new[] { typeof(Exception) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogException_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"ConnectUsingSettings",
|
||||
"ConnectToRegion",
|
||||
"JoinRoom",
|
||||
"JoinOrCreateRoom",
|
||||
"CreateRoom",
|
||||
"LeaveRoom",
|
||||
"Disconnect",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
foreach (var item in new (string Type, string Method)[]
|
||||
{
|
||||
("CMMIJKCDKPL", "CDNPLAGEDBK"),
|
||||
("KGHEDLNPANK", "CDNPLAGEDBK"),
|
||||
("HMPNINFEDFB", "IJIOJAIBPKE"),
|
||||
("PhotonNetwork", "Disconnect"),
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= PatchKnownRoomLoadMoveNext(logMisses);
|
||||
return true;
|
||||
}
|
||||
private void RegisterStudioTracePatches()
|
||||
{
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix));
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix));
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix));
|
||||
TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD",
|
||||
prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix));
|
||||
}
|
||||
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
|
||||
{
|
||||
const string label = "room-load-state-machines.MoveNext";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var nameNeedles = new[]
|
||||
{
|
||||
"OMJKHJLFOCO", "AFMPFLKGABO",
|
||||
"AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI",
|
||||
"MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM",
|
||||
"JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG",
|
||||
"IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL",
|
||||
"BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM",
|
||||
};
|
||||
|
||||
var patched = 0;
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
var match = false;
|
||||
foreach (var needle in nameNeedles)
|
||||
{
|
||||
if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
|
||||
IEnumerable<MethodInfo> methods;
|
||||
try { methods = AccessTools.GetDeclaredMethods(type); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
if (method.Name != "MoveNext") continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer))));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
private void RegisterProcessExceptionHandlers()
|
||||
{
|
||||
if (_processExceptionHandlersRegistered) return;
|
||||
_processExceptionHandlersRegistered = true;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RoomLoadDiagnostics
|
||||
{
|
||||
private static readonly Dictionary<string, int> Counts = new();
|
||||
private static int _globalCount;
|
||||
|
||||
public static void LogError_Prefix(object? message)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "<null>", 1600)}");
|
||||
WriteStack("unity-error");
|
||||
}
|
||||
|
||||
public static void LogException_Prefix(Exception exception)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}");
|
||||
WriteStack("unity-exception");
|
||||
}
|
||||
|
||||
public static void NamedCall_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("call:" + name, 80)) return;
|
||||
|
||||
DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]");
|
||||
if (IsImportantCall(name))
|
||||
WriteStack("runtime-call");
|
||||
}
|
||||
|
||||
public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}");
|
||||
WriteStack("runtime-call-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("enter:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("exit:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " +
|
||||
$"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}");
|
||||
WriteStack("room-load-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
// ── Studio baked-asset load trace ──────────────────────────────────
|
||||
// Pinpoints where the baked-Studio-scene load chain breaks. The room
|
||||
// joins but renders no custom geometry and logs "Expected a prefab,
|
||||
// but found none"; the server serves everything but the client never
|
||||
// requests a bundle. These probes answer: (1) does the client ever
|
||||
// call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it
|
||||
// ever register baked prefabs; (3) which prefab GUIDs does it look up
|
||||
// and miss.
|
||||
|
||||
/// <summary>RecNet unity-asset getter
|
||||
/// <c>NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken)</c>
|
||||
/// — the call that builds <c>unity_assets/{id}/{target}/{version}</c>. If
|
||||
/// this never fires, the client decided not to fetch the bundle at all
|
||||
/// (trigger missing upstream); if it fires, the URL/host/response is the
|
||||
/// problem.</summary>
|
||||
public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? "<null>"} " +
|
||||
$"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}");
|
||||
WriteStack("studio-assetfetch");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.RegisterPrefabs</c> — baked
|
||||
/// prefabs being registered after a bundle loads. If this never fires,
|
||||
/// no bundle was loaded.</summary>
|
||||
public static void StudioRegisterPrefabs_Postfix(object? __0)
|
||||
{
|
||||
int count = -1;
|
||||
try
|
||||
{
|
||||
if (__0 is not null)
|
||||
{
|
||||
var p = __0.GetType().GetProperty("Count");
|
||||
if (p?.GetValue(__0) is int c) count = c;
|
||||
}
|
||||
}
|
||||
catch { /* best-effort count */ }
|
||||
DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}");
|
||||
WriteStack("studio-registerprefabs");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject)</c>
|
||||
/// — the lookup that fails with "Expected a prefab, but found none".
|
||||
/// Logs the GUID + whether it resolved.</summary>
|
||||
public static void StudioTryGetPrefab_Postfix(object? __0, bool __result)
|
||||
{
|
||||
if (!ShouldLog("studio-trygetprefab", 200)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] TryGetPrefab guid={__0?.ToString() ?? "<null>"} found={__result}");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.IsKnownPrefab(Guid)</c>.</summary>
|
||||
public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result)
|
||||
{
|
||||
if (!ShouldLog("studio-isknownprefab", 200)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] IsKnownPrefab guid={__0?.ToString() ?? "<null>"} known={__result}");
|
||||
}
|
||||
|
||||
public static string FormatException(Exception? ex, object? fallback)
|
||||
{
|
||||
if (ex is null) return fallback?.ToString() ?? "<null>";
|
||||
var builder = new StringBuilder();
|
||||
AppendException(builder, ex, 0);
|
||||
return Sanitize(builder.ToString(), 5000);
|
||||
}
|
||||
|
||||
private static void AppendException(StringBuilder builder, Exception ex, int depth)
|
||||
{
|
||||
if (depth > 4) return;
|
||||
if (builder.Length > 0) builder.Append(" | ");
|
||||
builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
|
||||
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
|
||||
builder.Append(" stack=").Append(ex.StackTrace);
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
AppendException(builder, inner, depth + 1);
|
||||
}
|
||||
else if (ex.InnerException is not null)
|
||||
{
|
||||
AppendException(builder, ex.InnerException, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldLog(string key, int perKeyLimit)
|
||||
{
|
||||
lock (Counts)
|
||||
{
|
||||
_globalCount++;
|
||||
if (_globalCount > 12000)
|
||||
return false;
|
||||
|
||||
Counts.TryGetValue(key, out var count);
|
||||
if (count >= perKeyLimit)
|
||||
return false;
|
||||
|
||||
Counts[key] = count + 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpInstanceFields(object? instance, int maxFields)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
var described = DescribeValue(value, 400);
|
||||
if (ShouldExpandField(field, value))
|
||||
described += " " + DumpNestedFields(value, 16);
|
||||
parts.Add($"{field.Name}={described}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 400)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldExpandField(FieldInfo field, object? value)
|
||||
{
|
||||
if (value is null) return false;
|
||||
if (value is string) return false;
|
||||
|
||||
var valueType = value.GetType();
|
||||
if (valueType.IsPrimitive || valueType.IsEnum || value is decimal)
|
||||
return false;
|
||||
if (value is IEnumerable)
|
||||
return false;
|
||||
|
||||
var fieldName = field.Name ?? string.Empty;
|
||||
var typeName = valueType.FullName ?? valueType.Name;
|
||||
return fieldName.IndexOf("this", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
typeName.IndexOf("OMJKHJLFOCO", StringComparison.Ordinal) >= 0 ||
|
||||
typeName.IndexOf("RoomPermissions", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static string DumpNestedFields(object value, int maxFields)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = value.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? nested;
|
||||
try { nested = field.GetValue(value); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={DescribeValue(nested, 220)}");
|
||||
}
|
||||
|
||||
return "{nested " + string.Join("; ", parts) + "}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{{nested dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 220)}}}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
|
||||
var type = value.GetType();
|
||||
if (value is string text) return "\"" + Sanitize(text, limit) + "\"";
|
||||
if (value is Exception ex) return FormatException(ex, ex);
|
||||
if (type.IsPrimitive || type.IsEnum || value is decimal)
|
||||
return Sanitize(value.ToString() ?? "<null>", limit);
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return DescribeEnumerable(enumerable, type, limit);
|
||||
|
||||
var rendered = value.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName)
|
||||
return "<" + type.FullName + ">";
|
||||
return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">";
|
||||
}
|
||||
|
||||
private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var count = 0;
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (count++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
parts.Add(DescribeValue(item, 120));
|
||||
}
|
||||
return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>";
|
||||
}
|
||||
|
||||
private static string Signature(MethodBase method)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
var parts = new List<string>(parameters.Length);
|
||||
foreach (var parameter in parameters)
|
||||
parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name);
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "<signature unread>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FullMethodName(MethodBase method) =>
|
||||
$"{method.DeclaringType?.FullName ?? "<null>"}.{method.Name}";
|
||||
|
||||
private static bool IsImportantCall(string name) =>
|
||||
name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 ||
|
||||
name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0;
|
||||
|
||||
private static void WriteStack(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value, int limit)
|
||||
{
|
||||
var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1<redacted>");
|
||||
redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1=<redacted>");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= limit ? redacted : redacted[..limit] + "...<truncated>";
|
||||
}
|
||||
}
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _pollErrorLogged;
|
||||
private static int _probeFrame;
|
||||
private static bool _probeDone;
|
||||
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
|
||||
private static List<ConfiguredCommand>? _commandCache;
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
public static bool Awake_Prefix()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool InputFieldOnEndEdit_Prefix(object __instance)
|
||||
{
|
||||
var command = ReadConsoleInput(__instance);
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return true;
|
||||
|
||||
ExecuteCommand(command, __instance);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string FormatConfiguredCommandsForLog()
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
if (commands.Count == 0) return "<none>";
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var command in commands)
|
||||
parts.Add($"{command.Label}={command.Command}");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
// Polled every frame from DebugMod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput();
|
||||
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||
// console object actually exists, so we learn that even if the
|
||||
// hotkey never fires.
|
||||
if (!_probeDone && _probeFrame++ > 180)
|
||||
{
|
||||
_probeDone = true;
|
||||
ProbeConsole();
|
||||
}
|
||||
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||
{
|
||||
DebugMod.Log.Msg($"[debugconsole] toggle key '{DebugMod.Cfg.DebugConsoleToggleKey}' detected");
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_pollErrorLogged) { _pollErrorLogged = true; DebugMod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||
// whether the show/hide method resolved — without needing a keypress.
|
||||
private static void ProbeConsole()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = FindConsoleInstance();
|
||||
DebugMod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public static void ExecuteCommand(string command) => ExecuteCommand(command, consoleInstance: null);
|
||||
|
||||
private static void ExecuteCommand(string command, object? consoleInstance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return;
|
||||
|
||||
var output = RunCommand(command);
|
||||
if (consoleInstance is null)
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
consoleInstance = FindConsoleInstance();
|
||||
}
|
||||
if (consoleInstance is not null)
|
||||
WriteConsoleOutput(consoleInstance, output);
|
||||
|
||||
var parts = SplitCommandLine(command);
|
||||
var verb = parts.Count > 0 ? parts[0] : "<empty>";
|
||||
DebugMod.Log.Msg($"[debugconsole] command '{CleanLabel(verb)}': {FirstLine(output)}");
|
||||
}
|
||||
|
||||
private static string RunCommand(string commandLine)
|
||||
{
|
||||
var tokens = SplitCommandLine(commandLine);
|
||||
if (tokens.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var verb = tokens[0];
|
||||
if (IsHelpCommand(verb))
|
||||
return BuildCommandReferenceText("Debug Console Commands", maxCommands: 120);
|
||||
|
||||
var args = tokens.GetRange(1, tokens.Count - 1);
|
||||
if (!TryFindCommand(verb, out var command))
|
||||
return $"Unknown debug command: {verb}\n\n{BuildCommandReferenceText("Available Debug Console Commands", maxCommands: 80)}";
|
||||
|
||||
if (!command.HasRuntimeTarget)
|
||||
{
|
||||
var expanded = SplitCommandLine(command.Command);
|
||||
if (expanded.Count == 0)
|
||||
return $"Debug command '{verb}' has no backing command.";
|
||||
|
||||
var expandedArgs = expanded.Count > 1 ? expanded.GetRange(1, expanded.Count - 1) : new List<string>();
|
||||
expandedArgs.AddRange(args);
|
||||
if (!TryFindCommand(expanded[0], out command) || !command.HasRuntimeTarget)
|
||||
return $"Debug command '{verb}' points at '{command.Command}', but no runtime command metadata was found.";
|
||||
args = expandedArgs;
|
||||
}
|
||||
|
||||
if (!TryResolveCommandType(command, out var targetType, out var typeError))
|
||||
return typeError;
|
||||
|
||||
if (!TryFindCallableMethod(targetType!, command.MethodName, args, out var method, out var converted, out var methodError))
|
||||
return methodError;
|
||||
|
||||
object? target = null;
|
||||
if (!method!.IsStatic)
|
||||
{
|
||||
target = FindFirstInstance(targetType!);
|
||||
if (target is null)
|
||||
return $"Command '{command.MethodName}' needs a live {targetType!.Name} instance, but none was found.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = method.Invoke(target, converted);
|
||||
var suffix = result is null ? string.Empty : $"\nResult: {FormatResult(result)}";
|
||||
return $"Executed: {command.Label}{suffix}";
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
var inner = ex.InnerException ?? ex;
|
||||
return $"Command '{command.MethodName}' failed: {inner.GetType().Name}: {inner.Message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Command '{command.MethodName}' failed: {ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHelpCommand(string verb) =>
|
||||
string.Equals(verb, "help", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(verb, "?", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(verb, "ListRegisteredCommands", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool TryResolveCommandType(ConfiguredCommand command, out Type? type, out string error)
|
||||
{
|
||||
foreach (var name in new[] { command.ObfuscatedDeclaringType, command.DeclaringType })
|
||||
{
|
||||
var normalized = NormalizeTypeName(name);
|
||||
if (string.IsNullOrWhiteSpace(normalized)) continue;
|
||||
type = DebugMod.ResolveType(normalized);
|
||||
if (type is not null)
|
||||
{
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type = null;
|
||||
error = $"Command '{command.MethodName}' has no resolvable declaring type (declaring='{command.DeclaringType}', obfuscated='{command.ObfuscatedDeclaringType}').";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeTypeName(string value)
|
||||
{
|
||||
value = (value ?? string.Empty).Trim();
|
||||
var comma = value.IndexOf(',');
|
||||
return comma >= 0 ? value[..comma].Trim() : value;
|
||||
}
|
||||
|
||||
private static bool TryFindCallableMethod(Type type, string methodName, List<string> args,
|
||||
out MethodInfo? method, out object?[] converted, out string error)
|
||||
{
|
||||
foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
|
||||
{
|
||||
if (candidate.Name != methodName) continue;
|
||||
if (candidate.IsAbstract || candidate.ContainsGenericParameters) continue;
|
||||
if (TryConvertArguments(candidate.GetParameters(), args, out converted))
|
||||
{
|
||||
method = candidate;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
method = null;
|
||||
converted = Array.Empty<object?>();
|
||||
error = $"No overload of {type.Name}.{methodName} accepts {args.Count} argument(s): {string.Join(" ", args)}";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryConvertArguments(ParameterInfo[] parameters, List<string> args, out object?[] converted)
|
||||
{
|
||||
converted = new object?[parameters.Length];
|
||||
var argIndex = 0;
|
||||
|
||||
for (var i = 0; i < parameters.Length; i++)
|
||||
{
|
||||
var parameter = parameters[i];
|
||||
if (IsVector3(parameter.ParameterType) && args.Count - argIndex >= 3)
|
||||
{
|
||||
if (!TryParseFloat(args[argIndex], out var x) ||
|
||||
!TryParseFloat(args[argIndex + 1], out var y) ||
|
||||
!TryParseFloat(args[argIndex + 2], out var z) ||
|
||||
!TryCreateVector3(parameter.ParameterType, x, y, z, out converted[i]))
|
||||
return false;
|
||||
argIndex += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argIndex >= args.Count)
|
||||
{
|
||||
if (parameter.HasDefaultValue)
|
||||
{
|
||||
converted[i] = parameter.DefaultValue;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryConvertArgument(args[argIndex], parameter.ParameterType, out converted[i]))
|
||||
return false;
|
||||
argIndex++;
|
||||
}
|
||||
|
||||
return argIndex == args.Count;
|
||||
}
|
||||
|
||||
private static bool TryConvertArgument(string value, Type targetType, out object? converted)
|
||||
{
|
||||
converted = null;
|
||||
|
||||
if (targetType == typeof(string) || targetType.FullName == "System.String")
|
||||
{
|
||||
converted = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
var nullable = Nullable.GetUnderlyingType(targetType);
|
||||
if (nullable is not null)
|
||||
targetType = nullable;
|
||||
|
||||
if (targetType == typeof(bool))
|
||||
{
|
||||
if (bool.TryParse(value, out var b))
|
||||
{
|
||||
converted = b;
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) ||
|
||||
value == "1")
|
||||
{
|
||||
converted = true;
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) ||
|
||||
value == "0")
|
||||
{
|
||||
converted = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (targetType.IsEnum)
|
||||
{
|
||||
try
|
||||
{
|
||||
converted = Enum.Parse(targetType, value, ignoreCase: true);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (targetType == typeof(int)) { converted = int.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(long)) { converted = long.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(float)) { converted = float.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(double)) { converted = double.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(short)) { converted = short.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
if (targetType == typeof(byte)) { converted = byte.Parse(value, CultureInfo.InvariantCulture); return true; }
|
||||
}
|
||||
catch { return false; }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsVector3(Type type) =>
|
||||
type.Name == "Vector3" && (type.Namespace == "UnityEngine" || type.FullName?.EndsWith(".Vector3", StringComparison.Ordinal) == true);
|
||||
|
||||
private static bool TryParseFloat(string value, out float result) =>
|
||||
float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result);
|
||||
|
||||
private static bool TryCreateVector3(Type vectorType, float x, float y, float z, out object? vector)
|
||||
{
|
||||
vector = null;
|
||||
try
|
||||
{
|
||||
var ctor = vectorType.GetConstructor(new[] { typeof(float), typeof(float), typeof(float) });
|
||||
if (ctor is null) return false;
|
||||
vector = ctor.Invoke(new object[] { x, y, z });
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static object? FindFirstInstance(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(type);
|
||||
if (il2cppType is null) return null;
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var item = ArrItem(all, i);
|
||||
if (item is not null) return item;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatResult(object result)
|
||||
{
|
||||
var text = result.ToString() ?? result.GetType().Name;
|
||||
return text.Length <= 500 ? text : text[..500] + "...";
|
||||
}
|
||||
|
||||
private static string FirstLine(string value)
|
||||
{
|
||||
value = value.Replace("\r", "");
|
||||
var idx = value.IndexOf('\n');
|
||||
return idx >= 0 ? value[..idx] : value;
|
||||
}
|
||||
|
||||
private static List<string> SplitCommandLine(string value)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(value)) return result;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
var quote = '\0';
|
||||
var escaping = false;
|
||||
|
||||
foreach (var ch in value.Trim())
|
||||
{
|
||||
if (escaping)
|
||||
{
|
||||
sb.Append(ch);
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '\\')
|
||||
{
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inQuotes)
|
||||
{
|
||||
if (ch == quote)
|
||||
{
|
||||
inQuotes = false;
|
||||
continue;
|
||||
}
|
||||
sb.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch is '"' or '\'')
|
||||
{
|
||||
inQuotes = true;
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
result.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(ch);
|
||||
}
|
||||
|
||||
if (escaping) sb.Append('\\');
|
||||
if (sb.Length > 0) result.Add(sb.ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveInput()
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = DebugMod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = DebugMod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKeyValue = Enum.Parse(keyCodeType, DebugMod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] '{DebugMod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) DebugMod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||
else DebugMod.Log.Msg($"[debugconsole] hotkey armed: '{DebugMod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||
}
|
||||
|
||||
private static void ResolveConsole()
|
||||
{
|
||||
_consoleResolved = true;
|
||||
_consoleType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||
|
||||
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) DebugMod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
// taking exactly one bool, excluding compiler-generated members (the
|
||||
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||
// [CompilerGenerated]).
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null; // ambiguous — don't guess
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null) return;
|
||||
try
|
||||
{
|
||||
var instance = FindConsoleInstance();
|
||||
if (instance is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
return;
|
||||
}
|
||||
// Best-effort: flip the static "show button" enable so the
|
||||
// game's own Update keeps the console available instead of
|
||||
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||
EnableShowButton();
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
if (_consoleVisible)
|
||||
ShowConfiguredCommands(instance);
|
||||
DebugMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static object? FindConsoleInstance()
|
||||
{
|
||||
if (_consoleType is null) return null;
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(_consoleType);
|
||||
if (il2cppType is null) return null;
|
||||
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var item = ArrItem(all, i);
|
||||
if (item is not null) return item;
|
||||
}
|
||||
|
||||
var objType = DebugMod.ResolveType("UnityEngine.Object");
|
||||
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||
return findOne?.Invoke(null, new[] { il2cppType });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowConfiguredCommands(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||
if (_setConsoleText is null)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
|
||||
return;
|
||||
}
|
||||
|
||||
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
|
||||
DebugMod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildConfiguredCommandText()
|
||||
{
|
||||
return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter.";
|
||||
}
|
||||
|
||||
private static string ReadConsoleInput(object consoleInstance)
|
||||
{
|
||||
var input = FindConsoleInput(consoleInstance);
|
||||
return input is null ? string.Empty : ReadText(input);
|
||||
}
|
||||
|
||||
private static void WriteConsoleOutput(object consoleInstance, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_consoleType is not null)
|
||||
{
|
||||
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||
if (_setConsoleText is not null && _setConsoleText.DeclaringType?.IsInstanceOfType(consoleInstance) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
_setConsoleText.Invoke(consoleInstance, new object[] { text });
|
||||
return;
|
||||
}
|
||||
catch (TargetException)
|
||||
{
|
||||
// The live IL2CPP proxy can differ from the resolved wrapper type.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var input = FindConsoleInput(consoleInstance);
|
||||
if (input is not null)
|
||||
SetText(input, text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] output write failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? FindConsoleInput(object consoleInstance)
|
||||
{
|
||||
foreach (var name in new[] { "KECJGKLKFGP", "inputField", "InputField" })
|
||||
{
|
||||
var value = GetMemberValue(consoleInstance, name);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
|
||||
var type = consoleInstance.GetType();
|
||||
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (!LooksLikeInputField(prop.PropertyType) || !prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try { if (prop.GetValue(consoleInstance) is { } value) return value; } catch { }
|
||||
}
|
||||
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (!LooksLikeInputField(field.FieldType)) continue;
|
||||
try { if (field.GetValue(consoleInstance) is { } value) return value; } catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeInputField(Type type) =>
|
||||
type.Name.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
type.FullName?.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
private static string ReadText(object textObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = textObject.GetType();
|
||||
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanRead && prop.GetValue(textObject) is string s)
|
||||
return s;
|
||||
|
||||
var getter = type.GetMethod("get_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null)
|
||||
?? type.GetMethod("get_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null);
|
||||
return getter?.Invoke(textObject, Array.Empty<object>()) as string ?? string.Empty;
|
||||
}
|
||||
catch { return string.Empty; }
|
||||
}
|
||||
|
||||
private static void SetText(object textObject, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = textObject.GetType();
|
||||
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(textObject, text);
|
||||
return;
|
||||
}
|
||||
|
||||
var setter = type.GetMethod("set_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null)
|
||||
?? type.GetMethod("set_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null);
|
||||
setter?.Invoke(textObject, new object[] { text });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue)
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(header);
|
||||
sb.AppendLine();
|
||||
if (commands.Count == 0)
|
||||
{
|
||||
sb.AppendLine("No DebugConsoleCommandConfig commands found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var visible = Math.Min(commands.Count, maxCommands);
|
||||
for (var i = 0; i < visible; i++)
|
||||
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
|
||||
if (visible < commands.Count)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console.");
|
||||
}
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
internal static List<ConfiguredCommand> GetAvailableCommands()
|
||||
{
|
||||
if (_commandCache is not null) return _commandCache;
|
||||
|
||||
var commands = new List<ConfiguredCommand>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddConfiguredCommands(commands, seen);
|
||||
AddRuntimeCommands(commands, seen);
|
||||
commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase));
|
||||
_commandCache = commands;
|
||||
DebugMod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Read-only one-shot diagnostic (Cfg.DumpDevCommands). Writes a full report
|
||||
// to UserData/dorknet-devcommands.txt: does a live DebugConsole exist, and
|
||||
// for every runtime command meta, does its declaring type resolve and does
|
||||
// a method with that name exist on it (i.e. is it actually callable on this
|
||||
// obfuscated build). Invokes NOTHING and touches no game UI — safe to run
|
||||
// in a live session. This is what turns the text-console path from guessing
|
||||
// into engineering: the resolvable/total tally says whether the feature is
|
||||
// buildable as-is or needs an obfuscation name map.
|
||||
// Returns true once it observes a populated runtime command table (so the
|
||||
// caller can stop retrying); false while the config singleton isn't up yet.
|
||||
public static bool DumpCommandDiagnostics()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int runtime = 0;
|
||||
void W(string s) { sb.Append(s).Append('\n'); DebugMod.Log.Msg("[devcmd] " + s); }
|
||||
|
||||
try
|
||||
{
|
||||
// Force a fresh scan — an early call (before the config singleton
|
||||
// loads) must not poison the cache with an empty table.
|
||||
_commandCache = null;
|
||||
W("=== DorkNet dev-command diagnostic ===");
|
||||
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
object? liveConsole = null;
|
||||
try { liveConsole = FindConsoleInstance(); } catch (Exception ex) { W($"FindConsoleInstance threw: {ex.Message}"); }
|
||||
W($"DebugConsole type resolved : {_consoleType?.FullName ?? "<none>"}");
|
||||
W($"DebugConsole live instance : {(liveConsole is null ? "NULL (UI shell not instantiated — text console can't be shown, would need our own UI)" : "FOUND (UI can be driven)")}");
|
||||
W($"toggle method : {_toggleMethod?.Name ?? "<none>"}");
|
||||
W("");
|
||||
|
||||
List<ConfiguredCommand> commands;
|
||||
try { commands = GetAvailableCommands(); }
|
||||
catch (Exception ex) { W($"GetAvailableCommands threw: {ex}"); commands = new List<ConfiguredCommand>(); }
|
||||
|
||||
int typeOk = 0, methodOk = 0;
|
||||
var samplesOk = new List<string>();
|
||||
var samplesBad = new List<string>();
|
||||
|
||||
foreach (var c in commands)
|
||||
{
|
||||
if (!c.HasRuntimeTarget) continue;
|
||||
runtime++;
|
||||
|
||||
var resolvedType = TryResolveCommandType(c, out var t, out _) ? t : null;
|
||||
if (resolvedType is null)
|
||||
{
|
||||
if (samplesBad.Count < 30)
|
||||
samplesBad.Add($" [no-type] {c.MethodName} (declaring='{c.DeclaringType}' obf='{c.ObfuscatedDeclaringType}')");
|
||||
continue;
|
||||
}
|
||||
typeOk++;
|
||||
|
||||
var hasMethod = false;
|
||||
foreach (var m in resolvedType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
|
||||
if (m.Name == c.MethodName) { hasMethod = true; break; }
|
||||
|
||||
if (hasMethod)
|
||||
{
|
||||
methodOk++;
|
||||
if (samplesOk.Count < 40) samplesOk.Add($" [OK] {resolvedType.Name}.{c.MethodName}");
|
||||
}
|
||||
else if (samplesBad.Count < 30)
|
||||
{
|
||||
samplesBad.Add($" [no-method] {resolvedType.Name}.{c.MethodName} (type resolved, method name not present → obfuscated/stripped)");
|
||||
}
|
||||
}
|
||||
|
||||
W($"total commands in table : {commands.Count}");
|
||||
W($"runtime-target commands : {runtime}");
|
||||
W($" declaring type resolves : {typeOk}");
|
||||
W($" method callable by name : {methodOk} <-- this number decides feasibility");
|
||||
W("");
|
||||
W("--- sample CALLABLE commands ---");
|
||||
foreach (var s in samplesOk) W(s);
|
||||
W("");
|
||||
W("--- sample UNRESOLVED commands ---");
|
||||
foreach (var s in samplesBad) W(s);
|
||||
W("");
|
||||
W("--- FULL runtime command list (label | methodName | declaringType | obfuscatedDeclaringType) ---");
|
||||
foreach (var c in commands)
|
||||
{
|
||||
if (!c.HasRuntimeTarget) continue;
|
||||
sb.Append(" ").Append(c.Label).Append(" | ").Append(c.MethodName)
|
||||
.Append(" | ").Append(c.DeclaringType).Append(" | ").Append(c.ObfuscatedDeclaringType).Append('\n');
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sb.Append("DIAGNOSTIC FAILED: ").Append(ex).Append('\n');
|
||||
DebugMod.Log.Warning($"[devcmd] diagnostic failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Only persist the report once the table is actually populated, so an
|
||||
// early empty attempt doesn't overwrite a good report from a later try.
|
||||
if (runtime > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(DebugMod.DiagnosticsLogPath) ?? ".";
|
||||
var outPath = Path.Combine(dir, "dorknet-devcommands.txt");
|
||||
File.WriteAllText(outPath, sb.ToString());
|
||||
DebugMod.Log.Msg($"[devcmd] wrote {outPath} ({runtime} runtime commands)");
|
||||
}
|
||||
catch (Exception ex) { DebugMod.Log.Warning($"[devcmd] could not write report: {ex.Message}"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
DebugMod.Log.Msg("[devcmd] command table not populated yet — will retry");
|
||||
}
|
||||
|
||||
return runtime > 0;
|
||||
}
|
||||
|
||||
private static bool TryFindCommand(string verb, out ConfiguredCommand command)
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
foreach (var candidate in commands)
|
||||
{
|
||||
if (!candidate.HasRuntimeTarget) continue;
|
||||
if (CommandMatches(candidate, verb))
|
||||
{
|
||||
command = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
foreach (var candidate in commands)
|
||||
{
|
||||
if (CommandMatches(candidate, verb))
|
||||
{
|
||||
command = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
command = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CommandMatches(ConfiguredCommand command, string verb)
|
||||
{
|
||||
if (string.Equals(command.MethodName, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(command.Command, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(command.Label, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var shortLabel = command.Label;
|
||||
var dot = shortLabel.LastIndexOf('.');
|
||||
if (dot >= 0 && dot < shortLabel.Length - 1)
|
||||
shortLabel = shortLabel[(dot + 1)..];
|
||||
if (string.Equals(shortLabel, verb, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var commandTokens = SplitCommandLine(command.Command);
|
||||
return commandTokens.Count > 0 && string.Equals(commandTokens[0], verb, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
foreach (var entry in DebugMod.Cfg.DevCommands)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
var split = entry.IndexOf('=');
|
||||
var label = split >= 0 ? entry[..split].Trim() : entry.Trim();
|
||||
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
|
||||
if (string.IsNullOrWhiteSpace(command)) continue;
|
||||
if (string.IsNullOrWhiteSpace(label)) label = command;
|
||||
var parts = SplitCommandLine(command);
|
||||
var methodName = parts.Count > 0 ? parts[0] : command;
|
||||
AddCommand(commands, seen, label, command, declaringType: string.Empty, obfuscatedDeclaringType: string.Empty, methodName, isRuntime: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfgType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
|
||||
if (cfgType is null)
|
||||
{
|
||||
DebugMod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (method.GetParameters().Length != 0) continue;
|
||||
if (!cfgType.IsAssignableFrom(method.ReturnType)) continue;
|
||||
try { AddCommandConfig(method.Invoke(null, Array.Empty<object>()), commands, seen); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
var il2cppType = ToIl2CppType(cfgType);
|
||||
var resources = DebugMod.ResolveType("UnityEngine.Resources");
|
||||
if (il2cppType is not null && resources is not null)
|
||||
{
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
AddCommandConfig(ArrItem(all, i), commands, seen);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCommandConfig(object? config, List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
if (config is null) return;
|
||||
var metas = GetMemberValue(config, "Metas");
|
||||
var count = ArrLen(metas);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var meta = ArrItem(metas, i);
|
||||
if (meta is null) continue;
|
||||
var methodName = ReadStringMember(meta, "MethodName");
|
||||
if (string.IsNullOrWhiteSpace(methodName)) continue;
|
||||
var declaringType = ReadStringMember(meta, "DeclaringType");
|
||||
var obfuscatedDeclaringType = ReadStringMember(meta, "ObfuscatedDeclaringType");
|
||||
var label = string.IsNullOrWhiteSpace(declaringType)
|
||||
? methodName
|
||||
: $"{ShortTypeName(declaringType)}.{methodName}";
|
||||
AddCommand(commands, seen, label, methodName, declaringType, obfuscatedDeclaringType, methodName, isRuntime: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command,
|
||||
string declaringType, string obfuscatedDeclaringType, string methodName, bool isRuntime)
|
||||
{
|
||||
label = CleanLabel(label);
|
||||
command = (command ?? string.Empty).Trim();
|
||||
methodName = (methodName ?? string.Empty).Trim();
|
||||
if (command.Length == 0 || methodName.Length == 0) return;
|
||||
|
||||
var key = isRuntime
|
||||
? $"{NormalizeTypeName(obfuscatedDeclaringType)}|{NormalizeTypeName(declaringType)}|{methodName}"
|
||||
: $"configured|{label}|{command}";
|
||||
if (!seen.Add(key)) return;
|
||||
|
||||
commands.Add(new ConfiguredCommand(label, command, declaringType, obfuscatedDeclaringType, methodName, isRuntime));
|
||||
}
|
||||
|
||||
private static object? GetMemberValue(object instance, string name)
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||
{
|
||||
try { return prop.GetValue(instance); } catch { }
|
||||
}
|
||||
|
||||
var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field is not null)
|
||||
{
|
||||
try { return field.GetValue(instance); } catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadStringMember(object instance, string name)
|
||||
{
|
||||
return GetMemberValue(instance, name)?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CleanLabel(string label)
|
||||
{
|
||||
label = (label ?? string.Empty).Trim();
|
||||
if (label.Length == 0) return "Command";
|
||||
|
||||
var cleaned = new char[label.Length];
|
||||
for (var i = 0; i < label.Length; i++)
|
||||
{
|
||||
var ch = label[i];
|
||||
cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_';
|
||||
}
|
||||
|
||||
return new string(cleaned);
|
||||
}
|
||||
|
||||
private static string ShortTypeName(string typeName)
|
||||
{
|
||||
var idx = typeName.LastIndexOf('.');
|
||||
return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName;
|
||||
}
|
||||
|
||||
internal readonly struct ConfiguredCommand
|
||||
{
|
||||
public ConfiguredCommand(string label, string command, string declaringType,
|
||||
string obfuscatedDeclaringType, string methodName, bool hasRuntimeTarget)
|
||||
{
|
||||
Label = label;
|
||||
Command = command;
|
||||
DeclaringType = declaringType;
|
||||
ObfuscatedDeclaringType = obfuscatedDeclaringType;
|
||||
MethodName = methodName;
|
||||
HasRuntimeTarget = hasRuntimeTarget;
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
public string Command { get; }
|
||||
public string DeclaringType { get; }
|
||||
public string ObfuscatedDeclaringType { get; }
|
||||
public string MethodName { get; }
|
||||
public bool HasRuntimeTarget { get; }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||
{
|
||||
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
|
||||
private static object? ToIl2CppType(Type managed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppTypeType = DebugMod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||
if (from is null) return null;
|
||||
var args = from.GetParameters().Length == 2
|
||||
? new object[] { managed, true }
|
||||
: new object[] { managed };
|
||||
return from.Invoke(null, args);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static MethodInfo? OneArgMethod(Type? type, string name, object arg)
|
||||
{
|
||||
if (type is null) return null;
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (method.Name != name) continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg))
|
||||
return method;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ArrLen(object? array)
|
||||
{
|
||||
if (array is null) return 0;
|
||||
if (array is Array managedArray) return managedArray.Length;
|
||||
var type = array.GetType();
|
||||
var length = type.GetProperty("Length") ?? type.GetProperty("Count");
|
||||
return (length?.GetValue(array) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? ArrItem(object? array, int index)
|
||||
{
|
||||
if (array is null) return null;
|
||||
if (array is Array managedArray) return managedArray.GetValue(index);
|
||||
var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return getItem?.Invoke(array, new object[] { index });
|
||||
}
|
||||
}
|
||||
internal static class MakerPenGiftPreviewPatches
|
||||
{
|
||||
private static bool _loggedMissingPointer;
|
||||
private static bool _loggedMissingRestrictionFlag;
|
||||
|
||||
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__instance is null) return true;
|
||||
var t = __instance.GetType();
|
||||
if (ReadField(__instance, t, "pointerLineRenderer") is null)
|
||||
{
|
||||
if (!_loggedMissingPointer)
|
||||
{
|
||||
_loggedMissingPointer = true;
|
||||
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
|
||||
{
|
||||
if (!_loggedMissingRestrictionFlag)
|
||||
{
|
||||
_loggedMissingRestrictionFlag = true;
|
||||
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object? ReadField(object instance, Type type, string name)
|
||||
{
|
||||
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
|
||||
return field?.GetValue(instance);
|
||||
}
|
||||
}
|
||||
internal static class AuthPatches
|
||||
{
|
||||
private static string? _latestAccessToken;
|
||||
private static int _tokenLogCount;
|
||||
|
||||
public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var accessToken = __0?.ToString();
|
||||
if (LooksLikeJwt(accessToken))
|
||||
_latestAccessToken = accessToken;
|
||||
|
||||
if (_tokenLogCount++ < 3)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
"[auth-trace] token setter " +
|
||||
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LooksLikeJwt(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
|
||||
var dots = 0;
|
||||
foreach (var ch in value)
|
||||
if (ch == '.') dots++;
|
||||
return dots == 2;
|
||||
}
|
||||
|
||||
private static string Presence(object? value) =>
|
||||
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
|
||||
}
|
||||
internal static class HttpTracePatches
|
||||
{
|
||||
private const int BodyPreviewBytes = 700;
|
||||
private static readonly Regex SensitivePairRegex = new(
|
||||
@"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex BearerRegex = new(
|
||||
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveHeaderRegex = new(
|
||||
@"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveJsonStringRegex = new(
|
||||
@"(?i)""([^""]*(?:access_token|accessToken|refresh_token|refreshToken|authorization|token|secret|client_secret|key)[^""]*)""\s*:\s*""[^""]*""",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Dictionary<IntPtr, string> RecNetRequestInfoByPtr = new();
|
||||
private static int _requestId;
|
||||
private static int _failureLogCount;
|
||||
private static int _responseTupleLogCount;
|
||||
private static int _builderSendLogCount;
|
||||
private static int _callbackLogCount;
|
||||
private static int _uriUnreadDumpCount;
|
||||
|
||||
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Postfix(object __instance, object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(__instance);
|
||||
if (ptr == IntPtr.Zero) return;
|
||||
|
||||
var info =
|
||||
$"ptr=0x{ptr.ToInt64():X} method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}";
|
||||
lock (RecNetRequestInfoByPtr)
|
||||
{
|
||||
RecNetRequestInfoByPtr[ptr] = info;
|
||||
if (RecNetRequestInfoByPtr.Count > 256)
|
||||
RecNetRequestInfoByPtr.Clear();
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write($"[recnet-request-map] {info}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-request-map", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetBuilderSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _builderSendLogCount) > 120) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-send] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-send-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetBuilderSend_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_builderSendLogCount > 120) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-send] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-send-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_responseTupleLogCount > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||
$"state={DumpObjectFields(__instance, 36, 1200)}");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = __0?.ToString() ?? "<null>";
|
||||
var value = RedactHeaderValue(name, __1?.ToString());
|
||||
var uri = TryReadRequestUri(__instance)?.ToString() ?? "<uri unread>";
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-header] {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name} " +
|
||||
$"{SanitizeInline(uri)} {name}: {value}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ShouldLogCallback(__instance)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)} " +
|
||||
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||
$"state={DumpObjectFields(__instance, 32, 900)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-finalizer", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void TraceUrl(string? url, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write($"[http-trace] #{id} {source} url={SanitizeInline(url)}");
|
||||
}
|
||||
|
||||
public static void TraceRequestObject(object? request, string source)
|
||||
{
|
||||
if (request is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var id = NextRequestId();
|
||||
var uriValue = TryReadRequestUri(request);
|
||||
var uri = uriValue?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var headers = TryReadHeaders(request);
|
||||
var body = TryReadBodyPreview(request);
|
||||
var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40
|
||||
? $" requestFields={DumpObjectFields(request, 24, 260)}"
|
||||
: "";
|
||||
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " +
|
||||
$"headers={headers} body={body}{fallback}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure(source, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static int NextRequestId() => System.Threading.Interlocked.Increment(ref _requestId);
|
||||
|
||||
private static Uri? TryReadRequestUri(object request)
|
||||
{
|
||||
if (TryInvokeUriMethod(request, "KPKNHFBHPHO", out var uri)) return uri;
|
||||
if (TryInvokeUriMethod(request, "IBLHNHHBNNE", out uri)) return uri;
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var type = request.GetType();
|
||||
|
||||
if (TryReadUriValue(type.GetProperty("Uri", flags)?.GetValue(request), out uri)) return uri;
|
||||
if (TryReadUriValue(type.GetProperty("CurrentUri", flags)?.GetValue(request), out uri)) return uri;
|
||||
if (TryReadUriValue(type.GetField("<Uri>k__BackingField", flags)?.GetValue(request), out uri)) return uri;
|
||||
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
if (TryReadUriValue(prop.GetValue(request), out uri)) return uri;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
if (TryReadUriValue(field.GetValue(request), out uri)) return uri;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryInvokeUriMethod(object request, string methodName, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||
if (method is null) return false;
|
||||
return TryReadUriValue(method.Invoke(request, Array.Empty<object>()), out uri);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadUriValue(object? value, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
if (value is null) return false;
|
||||
if (value is Uri direct)
|
||||
{
|
||||
uri = direct;
|
||||
return true;
|
||||
}
|
||||
|
||||
var text = value.ToString();
|
||||
if (Uri.TryCreate(text, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string TryReadHttpMethod(object request)
|
||||
{
|
||||
foreach (var name in new[] { "MethodType", "Method", "LNJILMKOBIP" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value))
|
||||
return SanitizeInline(value?.ToString() ?? "<null>");
|
||||
}
|
||||
|
||||
return "<method unread>";
|
||||
}
|
||||
|
||||
private static string TryReadHeaders(object request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes);
|
||||
if (dump?.Invoke(request, Array.Empty<object>()) is object rawDump)
|
||||
return SanitizeInline(rawDump.ToString() ?? "<empty>");
|
||||
}
|
||||
catch { }
|
||||
|
||||
var known = new List<string>();
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"Authorization", "Cookie", "X-RNSIG", "Accept-Language",
|
||||
"Content-Type", "User-Agent"
|
||||
})
|
||||
{
|
||||
var value = TryReadHeaderValue(request, name);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
known.Add($"{name}:{RedactHeaderValue(name, value)}");
|
||||
}
|
||||
|
||||
return known.Count == 0 ? "<headers unread>" : string.Join("; ", known);
|
||||
}
|
||||
|
||||
private static string? TryReadHeaderValue(object request, string name)
|
||||
{
|
||||
foreach (var methodName in new[] { "GetFirstHeaderValue", "FKHEHIMHHBP" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, new[] { typeof(string) });
|
||||
var value = method?.Invoke(request, new object[] { name })?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TryReadBodyPreview(object request)
|
||||
{
|
||||
foreach (var methodName in new[] { "BGMFJOKNJAJ", "GetEntityBody" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||
if (method is null) continue;
|
||||
var body = method.Invoke(request, Array.Empty<object>());
|
||||
var bytes = TryCopyBytes(body, BodyPreviewBytes + 1);
|
||||
if (bytes is null) continue;
|
||||
if (bytes.Length == 0) return "<empty>";
|
||||
|
||||
var shown = Math.Min(bytes.Length, BodyPreviewBytes);
|
||||
var text = Encoding.UTF8.GetString(bytes, 0, shown);
|
||||
var suffix = bytes.Length > BodyPreviewBytes ? "...<truncated>" : "";
|
||||
return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\"";
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return "<body unread>";
|
||||
}
|
||||
|
||||
private static bool ShouldLogCallback(object request)
|
||||
{
|
||||
var response = TryReadResponseObject(request);
|
||||
var status = response is null ? null : TryReadStatusCode(response);
|
||||
if (status >= 400) return true;
|
||||
return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80;
|
||||
}
|
||||
|
||||
private static string DescribeRequestAndResponse(object request)
|
||||
{
|
||||
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var response = TryReadResponseObject(request);
|
||||
var responseText = response is null ? "<response unread>" : DescribeResponseObject(response);
|
||||
var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested");
|
||||
return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}";
|
||||
}
|
||||
|
||||
private static object? TryReadResponseObject(object request)
|
||||
{
|
||||
foreach (var name in new[] { "Response", "response", "_response", "<Response>k__BackingField" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value) && value is not null)
|
||||
return value;
|
||||
}
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var prop in request.GetType().GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
var value = prop.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in request.GetType().GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
var value = field.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? TryReadStatusCode(object response)
|
||||
{
|
||||
if (TryReadMemberValue(response, "StatusCode", out var value))
|
||||
{
|
||||
try { return Convert.ToInt32(value); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeResponseObject(object response)
|
||||
{
|
||||
var known = DescribeKnownMembers(response,
|
||||
"StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error");
|
||||
return $"{response.GetType().FullName} {{{known}}}";
|
||||
}
|
||||
|
||||
private static string DescribeKnownMembers(object obj, params string[] names)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryReadMemberValue(obj, name, out var value)) continue;
|
||||
parts.Add($"{name}={FormatValue(value, 700)}");
|
||||
}
|
||||
return parts.Count == 0 ? "<no known members>" : string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static string FormatValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
if (value is byte[] bytes)
|
||||
return $"byte[{bytes.Length}]";
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return FormatEnumerable(enumerable, value.GetType(), limit);
|
||||
var text = value is Exception ex
|
||||
? $"{ex.GetType().Name}: {ex.Message}"
|
||||
: value.ToString() ?? "<null>";
|
||||
text = SanitizeInline(text);
|
||||
return text.Length <= limit ? text : text[..limit] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?";
|
||||
var shown = 0;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (shown++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
parts.Add(FormatValue(item, 180));
|
||||
}
|
||||
|
||||
var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]";
|
||||
return rendered.Length <= limit ? rendered : rendered[..limit] + "...<truncated>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{type.FullName} <enumeration failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count)
|
||||
{
|
||||
count = 0;
|
||||
if (enumerable is ICollection collection)
|
||||
{
|
||||
count = collection.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop?.GetValue(enumerable) is int value)
|
||||
{
|
||||
count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static byte[]? TryCopyBytes(object? value, int limit)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value is byte[] direct)
|
||||
return CopyLimited(direct, limit);
|
||||
|
||||
if (value is IEnumerable enumerable)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (item is null) continue;
|
||||
try { bytes.Add(Convert.ToByte(item)); }
|
||||
catch { return null; }
|
||||
if (bytes.Count >= limit) break;
|
||||
}
|
||||
return bytes.ToArray();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] CopyLimited(byte[] source, int limit)
|
||||
{
|
||||
var count = Math.Min(source.Length, limit);
|
||||
var copy = new byte[count];
|
||||
Array.Copy(source, copy, count);
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static bool TryReadMemberValue(object request, string name, out object? value)
|
||||
{
|
||||
value = null;
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var type = request.GetType();
|
||||
|
||||
try
|
||||
{
|
||||
var prop = type.GetProperty(name, flags);
|
||||
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||
{
|
||||
value = prop.GetValue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var field = type.GetField(name, flags);
|
||||
if (field is not null)
|
||||
{
|
||||
value = field.GetValue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(type, name, Type.EmptyTypes);
|
||||
if (method is not null)
|
||||
{
|
||||
value = method.Invoke(request, Array.Empty<object>());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string DumpObjectFields(object? instance, int maxFields, int valueLimit)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={FormatValue(value, valueLimit)}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<field dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string LookupRecNetRequestInfo(object instance)
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(instance);
|
||||
if (ptr == IntPtr.Zero) return "ptr=<unread>";
|
||||
|
||||
lock (RecNetRequestInfoByPtr)
|
||||
{
|
||||
return RecNetRequestInfoByPtr.TryGetValue(ptr, out var info)
|
||||
? info
|
||||
: $"ptr=0x{ptr.ToInt64():X} request=<unmapped>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpBndNativeFields(object? instance)
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(instance);
|
||||
if (ptr == IntPtr.Zero) return "native=<unread>";
|
||||
|
||||
try
|
||||
{
|
||||
var method = Marshal.ReadByte(ptr, 16);
|
||||
var service = Marshal.ReadInt32(ptr, 20);
|
||||
var pathPtr = Marshal.ReadIntPtr(ptr, 24);
|
||||
var fieldsPtr = Marshal.ReadIntPtr(ptr, 32);
|
||||
var filesPtr = Marshal.ReadIntPtr(ptr, 40);
|
||||
var objectFieldsPtr = Marshal.ReadIntPtr(ptr, 48);
|
||||
var bodyKind = Marshal.ReadInt32(ptr, 56);
|
||||
var callbackPtr = Marshal.ReadIntPtr(ptr, 64);
|
||||
var path = ReadIl2CppString(pathPtr);
|
||||
|
||||
return
|
||||
"native={" +
|
||||
$"method={method}; service={service}; path={SanitizeInline(path)}; " +
|
||||
$"pathPtr=0x{pathPtr.ToInt64():X}; fieldsPtr=0x{fieldsPtr.ToInt64():X}; " +
|
||||
$"filesPtr=0x{filesPtr.ToInt64():X}; objectFieldsPtr=0x{objectFieldsPtr.ToInt64():X}; " +
|
||||
$"bodyKind={bodyKind}; callbackPtr=0x{callbackPtr.ToInt64():X}" +
|
||||
"}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"native=<dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadIl2CppString(IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
return IL2CPP.Il2CppStringToManaged(ptr) ?? "<null>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<string read failed 0x{ptr.ToInt64():X} {ex.GetType().Name}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr TryGetIl2CppPtr(object? instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
return instance is Il2CppObjectBase il2CppObject
|
||||
? IL2CPP.Il2CppObjectBaseToPtrNotNull(il2CppObject)
|
||||
: IntPtr.Zero;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactHeaderValue(string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
|
||||
if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "<redacted>";
|
||||
}
|
||||
|
||||
return SanitizeInline(value);
|
||||
}
|
||||
|
||||
private static string SanitizeInline(string value)
|
||||
{
|
||||
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
|
||||
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
|
||||
redacted = SensitiveHeaderRegex.Replace(redacted, "$1: <redacted>");
|
||||
redacted = SensitiveJsonStringRegex.Replace(redacted, "\"$1\":\"<redacted>\"");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static void LogFailure(string source, Exception ex)
|
||||
{
|
||||
if (_failureLogCount++ < 10)
|
||||
DebugMod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
internal static class SavePatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||
// would-be no-op setter call into a real transition.
|
||||
private static volatile string? s_lastPushedBlobName;
|
||||
private static long s_lastPushedRoomId;
|
||||
private static long s_lastPushedSubRoomId;
|
||||
|
||||
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||
// every SignalR message before any handler dispatch. Parses the JSON
|
||||
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||
public static void OnNotificationReceived_Prefix(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
// Fast reject: only parse JSON if it could plausibly be a
|
||||
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||
return;
|
||||
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!msg.TryGetProperty("Room", out var room) ||
|
||||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
long roomId = 0;
|
||||
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
roomId = ridEl.GetInt64();
|
||||
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||
scenes.GetArrayLength() == 0) return;
|
||||
var first = scenes[0];
|
||||
long subRoomId = 0;
|
||||
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
subRoomId = sidEl.GetInt64();
|
||||
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||
var blob = blobEl.GetString();
|
||||
if (string.IsNullOrEmpty(blob)) return;
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = blob;
|
||||
DebugMod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message is null) return;
|
||||
var dict = message;
|
||||
|
||||
// The watch parses this dict as Dictionary<string, object>
|
||||
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||
// get_Item(key) — call it via reflection.
|
||||
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (getItem is null) return;
|
||||
|
||||
// The push wire shape from server (BuildRoomDetails) is
|
||||
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||
object? roomObj = TryGet(dict, getItem, "Room");
|
||||
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||
if (roomObj is null || scenesObj is null) return;
|
||||
|
||||
long roomId = 0;
|
||||
long subRoomId = 0;
|
||||
try
|
||||
{
|
||||
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (roomItem is not null)
|
||||
{
|
||||
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||
|
||||
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||
// (current dorm has one scene; multi-scene rooms target
|
||||
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||
// push always carries the scene that was just saved).
|
||||
object? firstScene = null;
|
||||
try
|
||||
{
|
||||
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||
}
|
||||
catch { /* leave firstScene null */ }
|
||||
if (firstScene is null) return;
|
||||
|
||||
string? newBlob = null;
|
||||
try
|
||||
{
|
||||
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (sceneItem is not null)
|
||||
{
|
||||
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||
newBlob = blob?.ToString();
|
||||
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* leave newBlob null */ }
|
||||
|
||||
if (string.IsNullOrEmpty(newBlob)) return;
|
||||
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = newBlob;
|
||||
DebugMod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||
{
|
||||
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||
// path only when the new value differs from the cached field. The
|
||||
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||
// save it calls the setter with the OLD blob — a no-op that skips
|
||||
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||
// those over the live world — wiping any MakerPen object the user
|
||||
// just placed.
|
||||
//
|
||||
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||
// push already carried Scenes[0].DataBlobName="v11".
|
||||
//
|
||||
// Strategy: intercept the setter. If the incoming value matches the
|
||||
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||
// newer version (the freshly-pushed one), substitute that into the
|
||||
// value parameter so the real setter sees a transition and fires
|
||||
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||
// Falls through silently when no override is needed.
|
||||
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||
{
|
||||
string currentForLog = "<unread>";
|
||||
string sceneForLog = "<unread>";
|
||||
string scenes0ForLog = "<unread>";
|
||||
try
|
||||
{
|
||||
if (__instance is null)
|
||||
{
|
||||
DebugMod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var rpmType = __instance.GetType();
|
||||
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (blobNameProp is null)
|
||||
{
|
||||
DebugMod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||
return;
|
||||
}
|
||||
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||
|
||||
var roomsType = FindGameType("RecNet.Rooms");
|
||||
if (roomsType is null)
|
||||
{
|
||||
DebugMod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localScene is not null)
|
||||
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||
|
||||
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localDetails is not null)
|
||||
{
|
||||
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||
if (scenes is not null)
|
||||
{
|
||||
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||
if (first is not null)
|
||||
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional trace of EVERYTHING we read so we can see in
|
||||
// the log exactly why we did or didn't override.
|
||||
DebugMod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||
|
||||
// Pick a candidate "newer" blob name. Priority order:
|
||||
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||
// cache gate drops the push).
|
||||
// 2. LocalRoomScene.DataBlobName.
|
||||
// 3. Scenes[0].DataBlobName.
|
||||
string? pushedBlob = s_lastPushedBlobName;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||
|
||||
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||
|
||||
DebugMod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||
value = pushedBlob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugMod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? FindGameType(string name) => DebugMod.ResolveType(name);
|
||||
}
|
||||
internal static class QuitTracePatches
|
||||
{
|
||||
public static bool ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit-trace] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit-trace] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
if (exitCode == 1550502249 || exitCode == 533223478)
|
||||
{
|
||||
Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ApplicationWantsToQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)} wants-to-quit handler entered");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void CreateExitTask_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)} creating exit task");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
private static string FormatOriginal(MethodBase method)
|
||||
{
|
||||
return $"{method.DeclaringType?.FullName}.{method.Name}";
|
||||
}
|
||||
|
||||
private static void Write(string message)
|
||||
{
|
||||
try { DebugMod.Log.Msg(message); } catch { }
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[quit-trace-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[quit-trace-stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||
try { DebugMod.Log.Msg(line); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(DebugMod.DiagnosticsLogPath))
|
||||
File.AppendAllText(DebugMod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static bool ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class ChatPatches
|
||||
{
|
||||
private static bool s_logged;
|
||||
|
||||
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||
// before raising the in-room Photon chat RPC; if it returns false
|
||||
// the coroutine bails silently and the sender doesn't even see
|
||||
// their own message echo.
|
||||
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||
{
|
||||
if (!__result && !s_logged)
|
||||
{
|
||||
s_logged = true;
|
||||
DebugMod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||
}
|
||||
__result = true;
|
||||
}
|
||||
|
||||
// Skip the original. Returning false from a Harmony prefix stops
|
||||
// the underlying call.
|
||||
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||
{
|
||||
DebugMod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SendChatEmote_Prefix(string message)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||
}
|
||||
|
||||
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||
}
|
||||
|
||||
public static void RpcChatEmote_Prefix(string message)
|
||||
{
|
||||
DebugMod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
internal static class RegistrationPatches
|
||||
{
|
||||
private static bool s_loggedFullyReg;
|
||||
|
||||
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
|
||||
// (does NOT modify) the return value. False ⇒ the dorm raises the
|
||||
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
|
||||
// stays readable (it's polled from several call sites every boot).
|
||||
public static void IsFullyRegistered_Postfix(bool __result)
|
||||
{
|
||||
if (!s_loggedFullyReg)
|
||||
{
|
||||
s_loggedFullyReg = true;
|
||||
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
|
||||
}
|
||||
}
|
||||
|
||||
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
|
||||
// the dialog-response handler (a dialog was shown). Names reach the
|
||||
// prefix via __originalMethod so we can tell which branch ran.
|
||||
public static void PromptLambda_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// ConfirmUIDialog button handlers. If one of these fires when the user
|
||||
// taps a dialog button but the modal stays open, the break is in the
|
||||
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
|
||||
// routing. "Okay" on the screenshot is Button_Affirmative.
|
||||
public static bool ButtonAffirmative_Prefix(object __instance)
|
||||
{
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
|
||||
|
||||
if (!IsRegistrationWarningDialog(__instance))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveAffirmativeWithoutClose(__instance))
|
||||
{
|
||||
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
|
||||
return false;
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ButtonNegative_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
|
||||
public static void ButtonNotNow_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
|
||||
public static void ButtonCancel_Prefix() =>
|
||||
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
|
||||
|
||||
private static bool IsRegistrationWarningDialog(object instance)
|
||||
{
|
||||
var title = ReadTextField(instance, "titleText");
|
||||
var body = ReadTextField(instance, "bodyText");
|
||||
|
||||
return Contains(title, "Change Username")
|
||||
&& Contains(body, "permanently lost")
|
||||
&& Contains(body, "password");
|
||||
}
|
||||
|
||||
private static bool TryResolveAffirmativeWithoutClose(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var respond = AccessTools.Method(instance.GetType(), "Respond");
|
||||
if (respond is null)
|
||||
{
|
||||
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
|
||||
}
|
||||
|
||||
if (respond is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = respond.GetParameters();
|
||||
if (parameters.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
|
||||
respond.Invoke(instance, new[] { response, (object)false });
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreateAffirmativeResponse(Type responseType)
|
||||
{
|
||||
if (responseType.IsEnum)
|
||||
{
|
||||
return Enum.ToObject(responseType, 1);
|
||||
}
|
||||
|
||||
return Convert.ChangeType(1, responseType);
|
||||
}
|
||||
|
||||
private static string? ReadTextField(object instance, string fieldName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var field = AccessTools.Field(instance.GetType(), fieldName);
|
||||
var textObject = field?.GetValue(instance);
|
||||
if (textObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var property = AccessTools.Property(textObject.GetType(), "text");
|
||||
if (property?.GetValue(textObject) is string text)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
var getter = AccessTools.Method(textObject.GetType(), "get_text");
|
||||
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string fragment)
|
||||
{
|
||||
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
internal static class JoinPatches
|
||||
{
|
||||
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
|
||||
// "Dorm Room" button. If this never fires, the button's UnityEvent
|
||||
// binding is the problem, not the join chain.
|
||||
public static void ButtonDormRoom_Prefix()
|
||||
{
|
||||
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
|
||||
}
|
||||
|
||||
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
|
||||
// values here: Harmony's `object[] __args` forces a trampoline that
|
||||
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
|
||||
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
|
||||
// when the boot-time dorm auto-join first calls this method. Logging
|
||||
// the overload's parameter *types* (pure reflection metadata, no value
|
||||
// boxing) is enough to tell which overload fired — the entry overload
|
||||
// (String,…) is the "DormRoom" press; the others are inner legs.
|
||||
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
|
||||
// resolver that feeds the join. __0 is the single first param: a room
|
||||
// NAME string (String overload) or room ID (Int64 overload). Reading
|
||||
// it via `object __0` is safe — it's a string/long, not one of the
|
||||
// obfuscated ref-types that crashed `object[] __args`. This is the
|
||||
// value we need to compare boot (works) vs button (fails).
|
||||
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
|
||||
{
|
||||
var arg = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
|
||||
}
|
||||
|
||||
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
|
||||
// b__165_1 is the reject handler that raises the "An error occured /
|
||||
// contact recroom.happyfox.com" toast — so if THIS name appears, the
|
||||
// join failed; the success legs (other b__*) firing means it advanced.
|
||||
// (Same __args/0x80131506 constraint as above — name only.)
|
||||
public static void JoinCallback_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
// Dedicated capture for the reject handler b__165_1(string error) —
|
||||
// the answer to "why did the join fail". Single string param, so
|
||||
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
|
||||
// is the string that becomes the happyfox toast.
|
||||
public static void JoinError_Prefix(object __0)
|
||||
{
|
||||
var err = __0?.ToString() ?? "<null>";
|
||||
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
|
||||
}
|
||||
|
||||
// Parameter-type list from reflection metadata only — never reads
|
||||
// instance values, so it can't trigger the MonoMod trampoline crash.
|
||||
private static string Sig(MethodBase m)
|
||||
{
|
||||
var ps = m.GetParameters();
|
||||
var names = new List<string>(ps.Length);
|
||||
foreach (var p in ps) names.Add(p.ParameterType.Name);
|
||||
return $"{ps.Length} args: {string.Join(",", names)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
|
||||
|
||||
The shipping ClientMod carries ONLY the patches needed to connect and
|
||||
survive anti-cheat. ALL of the verbose diagnostic/tracing code (HTTP
|
||||
tracing, join/quit/studio/room-load traces, the process-exception sink,
|
||||
auth-token tracing, the registration-dialog trace, the in-game
|
||||
DebugConsole enable path) lives in THIS mod. Drop this dll into the
|
||||
Mods folder next to DorkNet.ClientMod.dll to debug; remove it to ship a
|
||||
quiet, minimal client.
|
||||
|
||||
Fully self-contained: its own Harmony instance + its own copy of the
|
||||
reflection/patch helpers, so it never couples to ClientMod at load time.
|
||||
Debug knobs are read from the same UserData\dorknet-clientmod.json.
|
||||
|
||||
Build: dotnet build DorkNet.DebugMod/DorkNet.DebugMod.csproj -c Release
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.DebugMod</RootNamespace>
|
||||
<AssemblyName>DorkNet.DebugMod</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader the ClientMod build already downloads. -->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader\MelonLoader\net6</MelonLoaderDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,290 +0,0 @@
|
||||
using HarmonyLib;
|
||||
using Il2CppRecRoom.Debugging;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using UnityEngine;
|
||||
using UObject = UnityEngine.Object;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.DevMenu.DevMenuMod), "DorkNet DevMenu", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.DevMenu;
|
||||
|
||||
public class DevMenuMod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static DevMenuMod? Instance;
|
||||
internal HarmonyLib.Harmony PatchHarmony => HarmonyInstance;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
|
||||
public static class Cfg
|
||||
{
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
public static bool DiagnoseDevMenu = false;
|
||||
public static bool EnableDevWatchButton = false;
|
||||
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet DevMenu loading ===");
|
||||
LoadConfig();
|
||||
|
||||
if (Cfg.EnableDebugConsole)
|
||||
{
|
||||
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
|
||||
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
|
||||
Log.Msg($"[debugconsole] enabled; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
|
||||
if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick();
|
||||
if (Cfg.EnableDevWatchButton) DevWatchButton.Tick();
|
||||
}
|
||||
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
|
||||
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
Directory.CreateDirectory(userData);
|
||||
|
||||
var devPath = Path.Combine(userData, "dorknet-devmenu.json");
|
||||
var legacyPath = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
var path = File.Exists(devPath) ? devPath : legacyPath;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no dev config found; all dev menu features disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (r.TryGetProperty("EnableDebugConsole", out var v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
|
||||
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
{
|
||||
var s = e.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
|
||||
}
|
||||
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
|
||||
}
|
||||
|
||||
Log.Msg($"[config] loaded {Path.GetFileName(path)}: debugConsole={Cfg.EnableDebugConsole}, devWatchButton={Cfg.EnableDevWatchButton}, diagnose={Cfg.DiagnoseDevMenu}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using disabled defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryPatchByName(string typeName, string methodName, Type[]? args = null, string? prefix = null, string? postfix = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {typeName}.{methodName}: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var method = args is null
|
||||
? AccessTools.Method(type, methodName)
|
||||
: AccessTools.Method(type, methodName, args);
|
||||
if (method is null)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {typeName}.{methodName}: method not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
HarmonyMethod? pre = prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix));
|
||||
HarmonyMethod? post = postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix));
|
||||
Instance!.PatchHarmony.Patch(method, pre, post);
|
||||
Log.Msg($"[patch-ok] {typeName}.{methodName}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {typeName}.{methodName}: {ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
foreach (var holder in new[] { typeof(DebugConsolePatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found");
|
||||
}
|
||||
|
||||
internal static Type? ResolveType(string requested)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(requested, out var cached)) return cached;
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
Type? found = null;
|
||||
try
|
||||
{
|
||||
found = asm.GetType(requested, throwOnError: false)
|
||||
?? asm.GetType("Il2Cpp" + requested, throwOnError: false);
|
||||
if (found is null)
|
||||
{
|
||||
foreach (var t in asm.GetTypes())
|
||||
{
|
||||
if (t.FullName == requested ||
|
||||
t.Name == requested ||
|
||||
t.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
t.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true)
|
||||
{
|
||||
found = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (found is null) continue;
|
||||
ResolvedTypeCache[requested] = found;
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _warned;
|
||||
private static KeyCode _toggleKey = KeyCode.BackQuote;
|
||||
private static bool _keyParsed;
|
||||
private static MethodInfo? _toggleMethod;
|
||||
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_keyParsed)
|
||||
{
|
||||
_keyParsed = true;
|
||||
if (!Enum.TryParse(DevMenuMod.Cfg.DebugConsoleToggleKey, ignoreCase: true, out _toggleKey))
|
||||
{
|
||||
_toggleKey = KeyCode.BackQuote;
|
||||
DevMenuMod.Log.Warning($"[debugconsole] invalid key '{DevMenuMod.Cfg.DebugConsoleToggleKey}', defaulting to BackQuote");
|
||||
}
|
||||
}
|
||||
|
||||
if (Input.GetKeyDown(_toggleKey))
|
||||
{
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_warned)
|
||||
{
|
||||
_warned = true;
|
||||
DevMenuMod.Log.Warning($"[debugconsole] poll failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Toggle()
|
||||
{
|
||||
try
|
||||
{
|
||||
var console = UObject.FindObjectOfType<DebugConsole>();
|
||||
if (console == null)
|
||||
{
|
||||
DevMenuMod.Log.Warning("[debugconsole] no live DebugConsole in scene");
|
||||
return;
|
||||
}
|
||||
|
||||
_toggleMethod ??= AccessTools.Method(typeof(DebugConsole), "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(typeof(DebugConsole), "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(typeof(DebugConsole));
|
||||
if (_toggleMethod is null)
|
||||
{
|
||||
DevMenuMod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
return;
|
||||
}
|
||||
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(console, new object[] { _consoleVisible });
|
||||
DevMenuMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DevMenuMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null;
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DevMenuProbe
|
||||
{
|
||||
private static int _frame;
|
||||
private static bool _done;
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (_done) return;
|
||||
_frame++;
|
||||
if (_frame < 180) return;
|
||||
_done = true;
|
||||
|
||||
var homeType = DevMenuMod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? DevMenuMod.ResolveType("HomeScreenFlow");
|
||||
var consoleType = DevMenuMod.ResolveType("RecRoom.Debugging.DebugConsole") ?? typeof(DebugConsole);
|
||||
DevMenuMod.Log.Msg("=== [devmenu-probe] runtime dev-menu state ===");
|
||||
DevMenuMod.Log.Msg($"[devmenu-probe] HomeScreenFlow type: {(homeType is null ? "NOT FOUND" : homeType.FullName)}");
|
||||
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole type: {(consoleType is null ? "NOT FOUND" : consoleType.FullName)}");
|
||||
try
|
||||
{
|
||||
var console = UObject.FindObjectOfType<DebugConsole>();
|
||||
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole live instance: {(console == null ? "null" : "FOUND")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DevMenuMod.Log.Warning($"[devmenu-probe] console instance probe failed: {ex.Message}");
|
||||
}
|
||||
DevMenuMod.Log.Msg("=== [devmenu-probe] done ===");
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Native watch "Dev Console" injection — typed against the per-install Il2Cpp
|
||||
// interop assemblies (referenced in the csproj, dev-tools only, never shipped).
|
||||
// Pure reflection couldn't drive this build's watch UI (FindObjectOfType /
|
||||
// property-get / GetComponentsInChildren all failed under Il2CppInterop), so
|
||||
// we use real types here: clone an existing watch Button3D, relabel it, and
|
||||
// wire its OnClick to run console commands via DebugConsole.Execute (which
|
||||
// works headless even though the console UI isn't in the scene). It's the
|
||||
// game's own Button3D, so it's interactable with the VR laser AND the
|
||||
// Screen-Mode mouse. Gated behind Cfg.EnableDevWatchButton.
|
||||
using System;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppAGUI.StackedUI;
|
||||
using Il2CppRecRoom.Debugging;
|
||||
using Il2CppTMPro;
|
||||
using UnityEngine.Events;
|
||||
using UObject = UnityEngine.Object;
|
||||
|
||||
namespace DorkNet.DevMenu;
|
||||
|
||||
internal static class DevWatchButton
|
||||
{
|
||||
private static int _frame;
|
||||
private static bool _done;
|
||||
|
||||
// Polled from Mod.OnUpdate while Cfg.EnableDevWatchButton is set. Waits for
|
||||
// the watch's HomeScreenFlow to exist, then injects once.
|
||||
public static void Tick()
|
||||
{
|
||||
if (_done) return;
|
||||
_frame++;
|
||||
if (_frame < 120 || (_frame % 30) != 0) return; // settle, then ~2/s
|
||||
var home = UObject.FindObjectOfType<HomeScreenFlow>();
|
||||
if (home == null && _frame < 3600) return; // wait for the watch
|
||||
_done = true;
|
||||
if (home == null) { DevMenuMod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; }
|
||||
try { Inject(home); }
|
||||
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] inject failed: {ex}"); }
|
||||
}
|
||||
|
||||
private static void Inject(HomeScreenFlow home)
|
||||
{
|
||||
var buttons = home.GetComponentsInChildren<Button3D>(true);
|
||||
if (buttons == null || buttons.Length == 0)
|
||||
{
|
||||
DevMenuMod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer a source button that HAS a TMP label, so our clones can be
|
||||
// relabeled (HomeButton etc. are icon-only — no text child).
|
||||
Button3D? src = null;
|
||||
for (int i = 0; i < buttons.Length; i++)
|
||||
if (buttons[i].GetComponentInChildren<TextMeshProUGUI>(true) != null) { src = buttons[i]; break; }
|
||||
bool labeled = src != null;
|
||||
if (src == null) src = buttons[0]; // fall back: inject anyway, just no text
|
||||
DevMenuMod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}");
|
||||
|
||||
int n = 0;
|
||||
foreach (var (label, cmd) in ParsePresets())
|
||||
{
|
||||
var clone = UObject.Instantiate(src, src.transform.parent);
|
||||
clone.name = $"DorkNetDev_{label}";
|
||||
clone.gameObject.SetActive(true);
|
||||
clone.transform.localScale = src.transform.localScale;
|
||||
|
||||
var tmp = clone.GetComponentInChildren<TextMeshProUGUI>(true);
|
||||
if (tmp != null) tmp.text = label;
|
||||
|
||||
var command = cmd; // capture per-button
|
||||
clone.OnClick.RemoveAllListeners();
|
||||
clone.OnClick.AddListener(DelegateSupport.ConvertDelegate<UnityAction>((Action)(() => Run(label, command))));
|
||||
_clones.Add(clone);
|
||||
n++;
|
||||
}
|
||||
DevMenuMod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them");
|
||||
DumpCommands();
|
||||
}
|
||||
|
||||
// Log the real console command names from DebugConsoleCommandConfig.Metas
|
||||
// so we can set DevCommands correctly (the asset loads even though the
|
||||
// console component isn't in the scene).
|
||||
private static void DumpCommands()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of<DebugConsoleCommandConfig>());
|
||||
DevMenuMod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded");
|
||||
for (int c = 0; c < cfgs.Length; c++)
|
||||
{
|
||||
var cfg = cfgs[c].TryCast<DebugConsoleCommandConfig>();
|
||||
var metas = cfg?.Metas;
|
||||
if (metas == null) continue;
|
||||
DevMenuMod.Log.Msg($"[devcmds] {metas.Count} command(s):");
|
||||
for (int i = 0; i < metas.Count; i++)
|
||||
{
|
||||
var m = metas[i];
|
||||
DevMenuMod.Log.Msg($" {m.MethodName} ({m.DeclaringType})");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { DevMenuMod.Log.Warning($"[devcmds] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void Run(string label, string command)
|
||||
{
|
||||
DevMenuMod.Log.Msg($"[devbtn] '{label}' -> DebugConsole.Execute(\"{command}\")");
|
||||
try { DebugConsole.Execute(command); }
|
||||
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Presets come from Cfg.DevCommands as "Label=command" strings.
|
||||
private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets()
|
||||
{
|
||||
foreach (var entry in DevMenuMod.Cfg.DevCommands)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
var eq = entry.IndexOf('=');
|
||||
if (eq > 0) yield return (entry.Substring(0, eq).Trim(), entry.Substring(eq + 1).Trim());
|
||||
else yield return (entry.Trim(), entry.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Generic.List<Button3D> _clones = new();
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.DevMenu</RootNamespace>
|
||||
<AssemblyName>DorkNet.DevMenu</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\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>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Il2CppAsmDir Condition="'$(Il2CppAsmDir)' == ''">C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies</Il2CppAsmDir>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="Exists('$(Il2CppAsmDir)')">
|
||||
<Reference Include="Assembly-CSharp"><HintPath>$(Il2CppAsmDir)\Assembly-CSharp.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2Cppmscorlib"><HintPath>$(Il2CppAsmDir)\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.CoreModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.UI"><HintPath>$(Il2CppAsmDir)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.InputLegacyModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Unity.TextMeshPro"><HintPath>$(Il2CppAsmDir)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2CppRecRoom.Promises.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2CppRecRoom.Datastructures.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dll</HintPath><Private>false</Private></Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DiagnoseDevMenu": false,
|
||||
"EnableDevWatchButton": false,
|
||||
"DevCommands": [
|
||||
"Help=help",
|
||||
"Fly=fly",
|
||||
"NoClip=noclip"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -88,6 +88,36 @@ public class RecNetAccount
|
||||
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// 2023-03-21 account reader (RecNet.Runtime CICAKEDCJLA / MJKDDBKGNOP /
|
||||
// AEGCKMKICFG) registers these keys in three casings each, e.g.
|
||||
// "DisplayEmoji"/"displayEmoji"/"displayemoji" (CICAKEDCJLA.txt:1398-1550).
|
||||
// Missing keys default on the client, which is why profile emoji,
|
||||
// banner, pronouns and identity flags never rendered for anyone.
|
||||
private string displayEmoji = string.Empty;
|
||||
private string bannerImage = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayEmoji")]
|
||||
public string DisplayEmoji
|
||||
{
|
||||
get => displayEmoji;
|
||||
set => displayEmoji = value ?? string.Empty;
|
||||
}
|
||||
|
||||
[JsonPropertyName("bannerImage")]
|
||||
public string BannerImage
|
||||
{
|
||||
get => bannerImage;
|
||||
set => bannerImage = value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Pronoun flags enum, serialized as its Int32 value.</summary>
|
||||
[JsonPropertyName("personalPronouns")]
|
||||
public int PersonalPronouns { get; set; }
|
||||
|
||||
/// <summary>Identity flags enum, serialized as its Int32 value.</summary>
|
||||
[JsonPropertyName("identityFlags")]
|
||||
public int IdentityFlags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -112,6 +142,13 @@ public class RecNetSelfAccount : RecNetAccount
|
||||
|
||||
[JsonPropertyName("parentAccountId")]
|
||||
public int? ParentAccountId { get; set; } = null;
|
||||
|
||||
// 2023 self-account reader key (CICAKEDCJLA.txt:1310). An absent key
|
||||
// defaults to 0, which the settings UI renders as "no username changes
|
||||
// left". DorkNet does not rate-limit username changes, so report a
|
||||
// generous allowance.
|
||||
[JsonPropertyName("availableUsernameChanges")]
|
||||
public int AvailableUsernameChanges { get; set; } = 99;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone MelonLoader IL2CPP mod whose ONLY job is to dump every
|
||||
Google.Protobuf FileDescriptor that the running client has loaded.
|
||||
|
||||
It carries no Harmony patches, so it is safe to drop onto ANY Rec Room
|
||||
build (2020, 2023, …) without the version-specific ClientMod patches
|
||||
misfiring. Everything game-side is reached through plain
|
||||
System.Reflection over the Il2CppInterop proxy assemblies, so no game
|
||||
DLLs are referenced at compile time (they would be derivative works).
|
||||
|
||||
Build (desktop net6 MelonLoader, same pin as the ClientMod):
|
||||
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
|
||||
Drop bin\Release\DorkNet.ProtoDumper.dll into <RecRoom>\Mods\ on a
|
||||
MelonLoader-patched build and launch. Output lands in
|
||||
<RecRoom>\DorkNet-ProtoDump\.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ProtoDumper</RootNamespace>
|
||||
<AssemblyName>DorkNet.ProtoDumper</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader DLLs the ClientMod project already downloads. -->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\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)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,378 @@
|
||||
// DorkNet ProtoDumper — runtime Google.Protobuf schema extractor.
|
||||
//
|
||||
// Rec Room compiles its room-save / creation / circuits data model from
|
||||
// .proto files into Google.Protobuf C# classes. In the shipped IL2CPP build
|
||||
// those classes are name-obfuscated and the descriptor base64 is split into
|
||||
// scattered string-literal chunks, so static reconstruction from
|
||||
// global-metadata.dat is unreliable.
|
||||
//
|
||||
// At RUNTIME every FileDescriptor still carries its original, un-obfuscated
|
||||
// FileDescriptorProto (message names, field names, numbers, types intact).
|
||||
// The catch: these descriptors are built LAZILY and in a specific dependency
|
||||
// order by the game. Force-building them ourselves (by reading a message
|
||||
// type's static descriptor property out of order) throws inside the type's
|
||||
// static constructor and the failure is cached — poisoning the type. So we do
|
||||
// NOT enumerate types. Instead we Harmony-postfix the one library method every
|
||||
// generated cctor funnels through — Google.Protobuf.Reflection.FileDescriptor
|
||||
// .BuildFrom / .FromGeneratedCode — and capture each descriptor as the game
|
||||
// builds it. Completely passive: just play the client through the dorm, a
|
||||
// created room, the Maker Pen and a circuits board, and every descriptor that
|
||||
// loads is recorded.
|
||||
//
|
||||
// Output (under <RecRoom>\DorkNet-ProtoDump\):
|
||||
// manifest.tsv — one "<proto path>\t<base64>" line per file
|
||||
// files\<sanitised>.b64 — same base64, one file each
|
||||
// dump.log — capture log
|
||||
//
|
||||
// Decode offline with decode_dump.py (see README.md).
|
||||
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ProtoDumper.ProtoDumper), "DorkNet ProtoDumper", "2.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.ProtoDumper;
|
||||
|
||||
public class ProtoDumper : MelonMod
|
||||
{
|
||||
private static MelonLogger.Instance? _log;
|
||||
|
||||
// proto file name -> descriptor base64 (accumulated as the game builds them)
|
||||
private static readonly Dictionary<string, string> Files = new(StringComparer.Ordinal);
|
||||
private static readonly object Gate = new();
|
||||
private static string _outDir = "";
|
||||
private static string _filesDir = "";
|
||||
private static volatile bool _dirty;
|
||||
private int _frame;
|
||||
|
||||
// Interception heartbeat: how many times any hooked method actually ran.
|
||||
// Lets us distinguish "hook never fires (inlined/wrong target)" from
|
||||
// "content that builds protos hasn't loaded yet".
|
||||
private static long _fireCount;
|
||||
private static bool _firstFireLogged;
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
_log = LoggerInstance;
|
||||
try
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Directory.GetCurrentDirectory();
|
||||
_outDir = Path.Combine(baseDir, "DorkNet-ProtoDump");
|
||||
_filesDir = Path.Combine(_outDir, "files");
|
||||
Directory.CreateDirectory(_filesDir);
|
||||
_log.Msg($"ProtoDumper ready. Output dir: {_outDir}");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper init failed: {e}"); }
|
||||
|
||||
InstallHooks();
|
||||
}
|
||||
|
||||
private void InstallHooks()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Google.Protobuf library type — NOT obfuscated (it's the library,
|
||||
// not game code), reached by its original name via every loaded
|
||||
// assembly so we never reference the interop DLL at compile time.
|
||||
Type? fd = ResolveType("Google.Protobuf.Reflection.FileDescriptor");
|
||||
if (fd == null)
|
||||
{
|
||||
_log?.Error("ProtoDumper: FileDescriptor type not found — cannot hook.");
|
||||
return;
|
||||
}
|
||||
|
||||
var postfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
var ctorPostfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(Ctor_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
|
||||
int patched = 0;
|
||||
|
||||
// PRIMARY hook: the 6-arg instance constructor. Every FileDescriptor
|
||||
// is built via `newobj` through this ctor, which is NOT an inlining
|
||||
// candidate the way the small static BuildFrom/FromGeneratedCode
|
||||
// helpers are (those get AOT-inlined into each generated cctor, so a
|
||||
// Harmony patch on the managed wrapper never fires — the real work
|
||||
// runs as inlined native code). The ctor stores SerializedData /
|
||||
// Proto / Name before it returns, so __instance carries the full
|
||||
// descriptor bytes.
|
||||
foreach (ConstructorInfo c in fd.GetConstructors(
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var ps = c.GetParameters();
|
||||
// Skip the Il2CppInterop IntPtr wrapper ctor (single IntPtr arg).
|
||||
if (ps.Length == 1 && ps[0].ParameterType == typeof(IntPtr)) continue;
|
||||
try { HarmonyInstance.Patch(c, postfix: ctorPostfix); patched++; _log?.Msg($"ProtoDumper: hooked .ctor({ps.Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch .ctor({ps.Length}) failed: {e.Message}"); }
|
||||
}
|
||||
|
||||
// SECONDARY hooks (kept as a belt-and-braces net in case the ctor is
|
||||
// itself inlined on some build): BuildFrom / FromGeneratedCode.
|
||||
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
|
||||
{
|
||||
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != name) continue;
|
||||
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; _log?.Msg($"ProtoDumper: hooked {name}({m.GetParameters().Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// STRONGEST hook: DescriptorPool.AddSymbol(IDescriptor). Called once
|
||||
// per message/enum/service symbol while a descriptor is built. Unlike
|
||||
// the tiny BuildFrom/FromGeneratedCode/ctor helpers, AddSymbol does
|
||||
// real work (name validation + dictionary insert + duplicate-throw),
|
||||
// so the AOT compiler does NOT inline it away — the managed patch
|
||||
// actually fires. From the symbol we walk `.File` to the owning
|
||||
// FileDescriptor and collect its serialized bytes. We also stash the
|
||||
// pool so ScanPools() can later enumerate everything it holds.
|
||||
Type? pool = ResolveType("Google.Protobuf.Reflection.DescriptorPool");
|
||||
if (pool != null)
|
||||
{
|
||||
var symPostfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(AddSymbol_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
foreach (MethodInfo m in pool.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
if (m.Name != "AddSymbol" && m.Name != "AddPackage") continue;
|
||||
try { HarmonyInstance.Patch(m, postfix: symPostfix); patched++; _log?.Msg($"ProtoDumper: hooked DescriptorPool.{m.Name}({m.GetParameters().Length} args)"); }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch DescriptorPool.{m.Name} failed: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
else _log?.Warning("ProtoDumper: DescriptorPool type not found — AddSymbol hook skipped.");
|
||||
|
||||
_log?.Msg($"ProtoDumper: installed {patched} descriptor hooks. Play through your content; descriptors are captured as they load. Watch for 'FIRST DESCRIPTOR CAPTURED'.");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); }
|
||||
}
|
||||
|
||||
// Resolve a type by its ORIGINAL (game-side) name, accounting for the
|
||||
// Il2Cpp prefix Il2CppInterop puts on the namespace (so
|
||||
// "Google.Protobuf.Reflection.FileDescriptor" ->
|
||||
// "Il2CppGoogle.Protobuf.Reflection.FileDescriptor").
|
||||
private static Type? ResolveType(string name)
|
||||
{
|
||||
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp" + name, false);
|
||||
if (t != null) return t;
|
||||
int dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var ns = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), false);
|
||||
if (ns != null) return ns;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Harmony postfix on FileDescriptor.BuildFrom / FromGeneratedCode.
|
||||
// __result is the freshly-built FileDescriptor (Il2Cpp proxy, boxed as
|
||||
// object — same pattern the ClientMod uses for il2cpp reference returns).
|
||||
public static void Captured_Postfix(object? __result)
|
||||
{
|
||||
Fired();
|
||||
if (__result == null) return;
|
||||
try { CollectFile(__result); }
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Postfix on the FileDescriptor instance ctor — __instance is the freshly
|
||||
// constructed descriptor (SerializedData/Proto/Name already set).
|
||||
public static void Ctor_Postfix(object? __instance)
|
||||
{
|
||||
Fired();
|
||||
if (__instance == null) return;
|
||||
try { CollectFile(__instance); }
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Live DescriptorPool references seen via AddSymbol, kept so ScanPools() can
|
||||
// enumerate every descriptor a pool holds (already-built → no force-build).
|
||||
private static readonly List<object> Pools = new();
|
||||
|
||||
// Postfix on DescriptorPool.AddSymbol / AddPackage. __instance is the pool,
|
||||
// __0 is the IDescriptor symbol being registered. Walk symbol.File to the
|
||||
// owning FileDescriptor and collect it.
|
||||
public static void AddSymbol_Postfix(object? __instance, object? __0)
|
||||
{
|
||||
Fired();
|
||||
try
|
||||
{
|
||||
if (__instance != null)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
bool known = false;
|
||||
foreach (var p in Pools) if (ReferenceEquals(p, __instance)) { known = true; break; }
|
||||
if (!known) Pools.Add(__instance);
|
||||
}
|
||||
}
|
||||
if (__0 == null) return;
|
||||
object? file = GetProp(__0, "File");
|
||||
if (file != null) CollectFile(file);
|
||||
}
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
// Interception heartbeat — proves a hooked method actually ran. Logged once
|
||||
// on first fire, so "no output" can be diagnosed as inlining (never fires)
|
||||
// vs. content-not-loaded (fires but no proto-building content yet).
|
||||
private static void Fired()
|
||||
{
|
||||
long n = System.Threading.Interlocked.Increment(ref _fireCount);
|
||||
if (!_firstFireLogged)
|
||||
{
|
||||
_firstFireLogged = true;
|
||||
_log?.Msg("ProtoDumper: FIRST DESCRIPTOR CAPTURED — interception is live.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectFile(object file)
|
||||
{
|
||||
string? name = GetProp(file, "Name") as string;
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (Files.ContainsKey(name!)) return;
|
||||
|
||||
string b64 = DescriptorBase64(file);
|
||||
Files[name!] = b64;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
// Capture import dependencies too (closure of well-known + leaf protos).
|
||||
try
|
||||
{
|
||||
object? deps = GetProp(file, "Dependencies");
|
||||
if (deps != null)
|
||||
{
|
||||
var dt = deps.GetType();
|
||||
var countP = dt.GetProperty("Count");
|
||||
var itemM = dt.GetMethod("get_Item", new[] { typeof(int) });
|
||||
if (countP != null && itemM != null)
|
||||
{
|
||||
int n = (int)(countP.GetValue(deps) ?? 0);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
object? dep = itemM.Invoke(deps, new object[] { i });
|
||||
if (dep != null) CollectFile(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string DescriptorBase64(object file)
|
||||
{
|
||||
try
|
||||
{
|
||||
// FileDescriptor exposes no public SerializedData property — only
|
||||
// the Il2CppInterop backing field (a ByteString with the original
|
||||
// descriptor bytes). Fall back to re-serialising the parsed Proto.
|
||||
object? sd = GetProp(file, "_SerializedData_k__BackingField")
|
||||
?? GetProp(file, "SerializedData");
|
||||
if (sd != null)
|
||||
{
|
||||
string b = ByteStringToBase64(sd);
|
||||
if (b.Length > 0) return b;
|
||||
}
|
||||
object? proto = GetProp(file, "Proto");
|
||||
if (proto != null)
|
||||
{
|
||||
MethodInfo? toBs = proto.GetType().GetMethod("ToByteString", Type.EmptyTypes);
|
||||
object? bs = toBs?.Invoke(proto, null);
|
||||
if (bs != null) return ByteStringToBase64(bs);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string ByteStringToBase64(object byteString)
|
||||
{
|
||||
MethodInfo? toB64 = byteString.GetType().GetMethod("ToBase64", Type.EmptyTypes);
|
||||
return toB64?.Invoke(byteString, null) as string ?? "";
|
||||
}
|
||||
|
||||
private static object? GetProp(object obj, string propName)
|
||||
{
|
||||
var p = obj.GetType().GetProperty(propName,
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
return p?.GetValue(obj);
|
||||
}
|
||||
|
||||
private long _lastReportedFire = -1;
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
// Flush to disk shortly after new descriptors are captured (every ~1s).
|
||||
if (++_frame % 60 == 0 && _dirty)
|
||||
Flush();
|
||||
|
||||
// Every ~5s, if the hooks fired since last report, log the running
|
||||
// fire/capture counts — surfaces "fired but 0 files" (empty base64)
|
||||
// separately from "never fired".
|
||||
if (_frame % 300 == 0)
|
||||
{
|
||||
long f = System.Threading.Interlocked.Read(ref _fireCount);
|
||||
if (f != _lastReportedFire)
|
||||
{
|
||||
_lastReportedFire = f;
|
||||
int files; lock (Gate) files = Files.Count;
|
||||
_log?.Msg($"ProtoDumper heartbeat: hook-fires={f}, unique-descriptors={files}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit() => Flush();
|
||||
|
||||
private static void Flush()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (!_dirty) return;
|
||||
_dirty = false;
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int withData = 0;
|
||||
foreach (var kv in Files)
|
||||
{
|
||||
if (kv.Value.Length == 0) continue;
|
||||
withData++;
|
||||
sb.Append(kv.Key).Append('\t').Append(kv.Value).Append('\n');
|
||||
string fp = Path.Combine(_filesDir, Sanitize(kv.Key) + ".b64");
|
||||
if (!File.Exists(fp)) File.WriteAllText(fp, kv.Value);
|
||||
}
|
||||
File.WriteAllText(Path.Combine(_outDir, "manifest.tsv"), sb.ToString());
|
||||
string line = $"captured files={Files.Count} (with-data={withData})";
|
||||
File.AppendAllText(Path.Combine(_outDir, "dump.log"), line + "\n");
|
||||
_log?.Msg($"ProtoDumper: {line} -> {_outDir}");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper flush error: {e}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string protoPath)
|
||||
{
|
||||
var sb = new StringBuilder(protoPath.Length);
|
||||
foreach (char c in protoPath)
|
||||
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' ? c : '_');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# DorkNet ProtoDumper
|
||||
|
||||
Runtime extractor for Rec Room's Google.Protobuf schema (room save /
|
||||
creation / circuits data model). Produces clean `.proto` files for any
|
||||
client build — including obfuscated ones like 2023.03.21 where static
|
||||
reconstruction from `global-metadata.dat` is unreliable (the descriptor
|
||||
base64 is split into scattered string-literal chunks whose concat order
|
||||
only lives in IL).
|
||||
|
||||
At runtime every `FileDescriptor` still carries its original,
|
||||
un-obfuscated `FileDescriptorProto` (real message names, field names,
|
||||
numbers and types intact). The catch: Rec Room builds these descriptors
|
||||
**lazily and in a specific dependency order** as content loads. Trying to
|
||||
force-build them by reading a message type's static descriptor out of
|
||||
order throws inside the type's static constructor, and the failure is
|
||||
cached — *poisoning* the type (and crashing the client). So this mod does
|
||||
**not** enumerate types.
|
||||
|
||||
Instead it Harmony-postfixes the one library method every generated cctor
|
||||
funnels through —
|
||||
`Google.Protobuf.Reflection.FileDescriptor.BuildFrom` /
|
||||
`.FromGeneratedCode` — and records each descriptor as the game builds it.
|
||||
Completely passive: **play the client through the content you care about**
|
||||
and every descriptor that loads is captured.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. **Build the mod**
|
||||
|
||||
```sh
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
```
|
||||
|
||||
Output: `DorkNet.ProtoDumper/bin/Release/DorkNet.ProtoDumper.dll`.
|
||||
|
||||
2. **Run it on a *runnable* 2023.03.21 client (MelonLoader 0.6.6)**
|
||||
|
||||
> The `GameAssembly.dll` + `il2cpp_data`-only copy under
|
||||
> `Recnet-old/dist/RecRoom-2023.03.21-il2cpp/…/staging/` is an
|
||||
> **offline-analysis payload, not a runnable build**. Use a full build
|
||||
> with the exe + UnityPlayer + MelonLoader (e.g. the devdork package).
|
||||
|
||||
- Drop `DorkNet.ProtoDumper.dll` into `<RecRoom>\Mods\`. On launch it
|
||||
installs the descriptor-build hooks (see `dump.log` / MelonLoader
|
||||
console for "installed N descriptor-build hooks").
|
||||
- **Log in and play through the content whose schema you want:** your
|
||||
dorm, a created room, the Maker Pen, and a circuits (CV2) board. Each
|
||||
subsystem builds its protos the first time it loads. Descriptors are
|
||||
captured the instant they build — no scene/timer scanning, nothing is
|
||||
force-built, so the client stays stable.
|
||||
- Output appears under `<RecRoom>\DorkNet-ProtoDump\` and is flushed to
|
||||
disk ~1 s after new descriptors load, and again on quit:
|
||||
- `manifest.tsv` — `<proto path>\t<base64>` per file
|
||||
- `files\*.b64` — same, one file each
|
||||
- `dump.log` — running capture count
|
||||
|
||||
3. **Decode to .proto**
|
||||
|
||||
```sh
|
||||
pip install protobuf
|
||||
python DorkNet.ProtoDumper/decode_dump.py <path>/manifest.tsv proto_out
|
||||
```
|
||||
|
||||
Emits one `.proto` per descriptor under `proto_out/`, plus a combined
|
||||
`proto_out/recroom_2023.proto` (every rec_room/circuits message in one
|
||||
file) for an easy diff against
|
||||
`DorkNet.Server/Protos/recroom_2020.proto`.
|
||||
|
||||
## Notes
|
||||
|
||||
- No game DLLs are referenced at compile time. `FileDescriptor` is the
|
||||
Google.Protobuf *library* type (not obfuscated); it's reached by its
|
||||
original name with the Il2Cpp namespace-prefix fallback, and its members
|
||||
are read via reflection. The same DLL works across builds and stays clean
|
||||
of derivative game code.
|
||||
- The only Harmony patches are the two read-only descriptor-build
|
||||
postfixes above — safe to run alongside (or without) `DorkNet.ClientMod`.
|
||||
- The descriptor base64 is the same `FileDescriptorProto` wire format
|
||||
Google's own tooling emits, so `decode_dump.py` (or `protoc
|
||||
--decode=google.protobuf.FileDescriptorProto descriptor.proto`) reads it
|
||||
directly.
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode a DorkNet ProtoDumper manifest into .proto text.
|
||||
|
||||
Usage:
|
||||
python decode_dump.py manifest.tsv [out_dir]
|
||||
|
||||
manifest.tsv has one "<proto path>\\t<base64 FileDescriptorProto>" line per
|
||||
file (as written by the ProtoDumper MelonLoader mod). This emits one .proto
|
||||
file per descriptor under out_dir/ (default: ./proto_out), plus a combined
|
||||
recroom_2023.proto with every rec_room/circuits message in one file for an
|
||||
easy diff against DorkNet.Server/Protos/recroom_2020.proto.
|
||||
|
||||
Requires: pip install protobuf
|
||||
"""
|
||||
import sys, os, base64, re
|
||||
from google.protobuf import descriptor_pb2 as D
|
||||
|
||||
FDP = D.FileDescriptorProto
|
||||
|
||||
# proto3 scalar type names keyed by FieldDescriptorProto.Type
|
||||
SCALAR = {
|
||||
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
|
||||
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 12: "bytes",
|
||||
13: "uint32", 15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
|
||||
}
|
||||
LABEL_REPEATED = 3
|
||||
|
||||
|
||||
def type_name(f):
|
||||
if f.type in (11, 14): # message / enum -> use type_name (strip leading dot)
|
||||
return f.type_name.lstrip(".")
|
||||
return SCALAR.get(f.type, f"type{f.type}")
|
||||
|
||||
|
||||
def emit_enum(e, indent):
|
||||
pad = " " * indent
|
||||
out = [f"{pad}enum {e.name} {{"]
|
||||
for v in e.value:
|
||||
out.append(f"{pad} {v.name} = {v.number};")
|
||||
out.append(f"{pad}}}")
|
||||
return out
|
||||
|
||||
|
||||
def emit_message(m, indent=0):
|
||||
pad = " " * indent
|
||||
out = [f"{pad}message {m.name} {{"]
|
||||
|
||||
# map<> detection: synthetic nested *Entry types with map_entry option
|
||||
map_entries = {}
|
||||
for nt in m.nested_type:
|
||||
if nt.options.map_entry:
|
||||
kt = type_name(nt.field[0])
|
||||
vt = type_name(nt.field[1])
|
||||
map_entries[nt.name] = (kt, vt)
|
||||
|
||||
# which oneofs are real (not proto3 optional synthetic)
|
||||
for nt in m.nested_type:
|
||||
if nt.name in map_entries:
|
||||
continue
|
||||
out += emit_message(nt, indent + 1)
|
||||
for en in m.enum_type:
|
||||
out += emit_enum(en, indent + 1)
|
||||
|
||||
# group fields by oneof
|
||||
printed_oneof = set()
|
||||
for f in m.field:
|
||||
# map field?
|
||||
if f.type == 11 and f.type_name.split(".")[-1] in map_entries:
|
||||
kt, vt = map_entries[f.type_name.split(".")[-1]]
|
||||
out.append(f"{pad} map<{kt}, {vt}> {f.name} = {f.number};")
|
||||
continue
|
||||
|
||||
if f.HasField("oneof_index") and not f.proto3_optional:
|
||||
idx = f.oneof_index
|
||||
if idx not in printed_oneof:
|
||||
printed_oneof.add(idx)
|
||||
out.append(f"{pad} oneof {m.oneof_decl[idx].name} {{")
|
||||
for g in m.field:
|
||||
if g.HasField("oneof_index") and g.oneof_index == idx and not g.proto3_optional:
|
||||
out.append(f"{pad} {type_name(g)} {g.name} = {g.number};")
|
||||
out.append(f"{pad} }}")
|
||||
continue
|
||||
|
||||
label = "repeated " if f.label == LABEL_REPEATED else ""
|
||||
opt = "optional " if f.proto3_optional else ""
|
||||
out.append(f"{pad} {label}{opt}{type_name(f)} {f.name} = {f.number};")
|
||||
|
||||
out.append(f"{pad}}}")
|
||||
return out
|
||||
|
||||
|
||||
def render_file(fdp):
|
||||
out = []
|
||||
syntax = fdp.syntax or "proto2"
|
||||
out.append(f'syntax = "{syntax}";')
|
||||
out.append("")
|
||||
if fdp.package:
|
||||
out.append(f"package {fdp.package};")
|
||||
for dep in fdp.dependency:
|
||||
out.append(f'import "{dep}";')
|
||||
if fdp.package or fdp.dependency:
|
||||
out.append("")
|
||||
for en in fdp.enum_type:
|
||||
out += emit_enum(en, 0)
|
||||
out.append("")
|
||||
for m in fdp.message_type:
|
||||
out += emit_message(m, 0)
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip() + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__); sys.exit(1)
|
||||
manifest = sys.argv[1]
|
||||
out_dir = sys.argv[2] if len(sys.argv) > 2 else "proto_out"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
files = []
|
||||
with open(manifest, "r", encoding="utf-8") as fh:
|
||||
for ln in fh:
|
||||
ln = ln.rstrip("\n")
|
||||
if not ln or "\t" not in ln:
|
||||
continue
|
||||
name, b64 = ln.split("\t", 1)
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
fdp = FDP(); fdp.ParseFromString(raw)
|
||||
except Exception as e:
|
||||
print(f" ! failed to parse {name}: {e}")
|
||||
continue
|
||||
files.append((name, fdp))
|
||||
|
||||
print(f"parsed {len(files)} descriptors")
|
||||
|
||||
# per-file .proto
|
||||
for name, fdp in files:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.\-]", "_", name)
|
||||
with open(os.path.join(out_dir, safe), "w", encoding="utf-8") as w:
|
||||
w.write(render_file(fdp))
|
||||
|
||||
# combined rec_room/circuits view (skip google/* well-known imports)
|
||||
rr = [ (n,f) for n,f in files if not n.startswith("google/") ]
|
||||
seen = set(); combo = ['syntax = "proto3";', "", "package recroom;", ""]
|
||||
total_msgs = 0
|
||||
for name, fdp in sorted(rr, key=lambda x: x[0]):
|
||||
combo.append(f"// ===== {name} (package {fdp.package or '-'}) =====")
|
||||
for m in fdp.message_type:
|
||||
if m.name in seen:
|
||||
continue
|
||||
seen.add(m.name)
|
||||
combo += emit_message(m, 0)
|
||||
combo.append("")
|
||||
total_msgs += 1
|
||||
for en in fdp.enum_type:
|
||||
combo += emit_enum(en, 0)
|
||||
combo.append("")
|
||||
with open(os.path.join(out_dir, "recroom_2023.proto"), "w", encoding="utf-8") as w:
|
||||
w.write("\n".join(combo).rstrip() + "\n")
|
||||
print(f"wrote {out_dir}/recroom_2023.proto with {total_msgs} unique messages "
|
||||
f"from {len(rr)} rec_room/circuits descriptors")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
@@ -0,0 +1,9 @@
|
||||
# Operator secrets and build outputs — never commit.
|
||||
dorknet-quest.cfg
|
||||
*.keystore
|
||||
*.so
|
||||
build/
|
||||
*_generated.h
|
||||
|
||||
# The resolved per-build target tables ARE committed (targets/<ver>-<code>.json);
|
||||
# only the generated C header is ignored.
|
||||
@@ -0,0 +1,237 @@
|
||||
# DorkNet.Quest
|
||||
|
||||
Sideload-patches the **Meta Quest** build of Rec Room **2023.03.21**
|
||||
(versionName `20230317`, versionCode `2128210`) to connect to a DorkNet server.
|
||||
|
||||
It is the Quest counterpart of `DorkNet.ClientMod` (the PC MelonLoader mod) plus
|
||||
`tools/patch-client.ps1` (the PC Photon byte-patch). Android/IL2CPP has no
|
||||
MelonLoader, so everything is done in **one injected native library**
|
||||
(`libdorknet.so`): it is added as a `DT_NEEDED` entry on `libmain.so` so the
|
||||
loader maps it before Unity, then it installs arm64 inline hooks at addresses
|
||||
resolved from the build's own `dump.cs`.
|
||||
|
||||
This build reports appVersion **`20230317`** — the *same* version the PC client
|
||||
reports — so a Quest player patched with this tool shares rooms, presence and
|
||||
Photon shards with PC DorkNet players with no server-side version change.
|
||||
|
||||
> This ships **no** Rec Room code or assets. You supply your own extracted store
|
||||
> APK and OBB. It disables the client's certificate pinning and image-signature
|
||||
> checks so a self-hosted server works; do not redistribute game binaries.
|
||||
|
||||
## What it does
|
||||
|
||||
| Hook | Effect | PC equivalent |
|
||||
| --- | --- | --- |
|
||||
| `HTTPManager.SendRequest` | rewrite `<svc>.rec.net` → `<svc>.<ServerHost>`, or into a `{service}` template such as `{service}-dev.example.com` for hyphen-style hosts (the name server supplies every other URL) | `ClientMod` UriPatches |
|
||||
| Photon `AppSettings` factory | inject the operator's Realtime/Voice/Chat app ids and fixed region (`PhotonRegion`, must match the PC clients) | `tools/patch-client.ps1` |
|
||||
| `PlatformSettings.get_AppID` / `get_MobileAppID` | return the operator's Meta app id (only when set) | — |
|
||||
| `UnityTlsContext.VerifyCallback` + BouncyCastle `NotifyServerCertificate` | accept the self-hosted TLS chain | `ClientMod` TlsPatches |
|
||||
| three certificate-pin verifiers | accept the chain (pins never match a self-hosted host) | `ClientMod` DisableTLSPinning |
|
||||
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
|
||||
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
|
||||
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
|
||||
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
|
||||
|
||||
### Two builds, selected by `OculusAppId`
|
||||
|
||||
- **Set** — the *Meta build*: Meta's real entitlement + login run against the
|
||||
app you registered; the platform hooks stay disarmed. Players need a Meta
|
||||
account, and your package + signing cert must be registered with that app.
|
||||
- **Blank** — the *no-Meta-account build*: the platform path is steered past
|
||||
Meta's checks to Rec Room's own username/password screen. It authenticates
|
||||
nothing — every headset sends the placeholder platform user id `1`. DorkNet's
|
||||
server treats platform id `1` (and `0`/blank) as a non-identity and keys
|
||||
accounts by `deviceId` instead (`PlayerService.IsPlaceholderPlatformId`), so
|
||||
players are **not** merged into one account and are never offered each other's
|
||||
"remembered logins". **Never** add a server rule that accepts platform id `1`
|
||||
as an identity.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Android NDK** r27c-class (`ANDROID_NDK`, or `ndk/<ver>` under
|
||||
`ANDROID_SDK_ROOT`) — compiles the hook library against bionic.
|
||||
- **Android SDK build-tools** (`zipalign`, `apksigner`) — `ANDROID_SDK_ROOT`.
|
||||
- **apktool** `apktool.jar` (`APKTOOL_JAR`, or next to `ANDROID_SDK_ROOT`).
|
||||
- **JDK** (`keytool` for the debug keystore; apksigner needs a JRE).
|
||||
- **Python 3** with **LIEF** (`pip install lief`) — the `DT_NEEDED` injection.
|
||||
- **[Il2CppDumper](https://github.com/Perfare/Il2CppDumper)** — to produce
|
||||
`dump.cs` for the target APK (see below).
|
||||
- Your own **store APK** (`com.AgainstGravity.RecRoom`, versionCode `2128210`)
|
||||
and its matching **OBB**.
|
||||
|
||||
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
||||
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
|
||||
> keep the work dir outside protected folders.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
# 1. Dump the target build (Il2CppDumper), once, into a scratch dir:
|
||||
dotnet Il2CppDumper.dll `
|
||||
<extracted>\lib\arm64-v8a\libil2cpp.so `
|
||||
<extracted>\assets\bin\Data\Managed\Metadata\global-metadata.dat `
|
||||
<scratch>\dump
|
||||
|
||||
# 2. Configure:
|
||||
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
||||
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
||||
|
||||
# 3. Build the signed APK (and, with --obb, the release package):
|
||||
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
|
||||
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
|
||||
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
|
||||
py -3 DorkNet.Quest\scripts\build.py build `
|
||||
--apk C:\path\to\RecRoom-store-2128210.apk `
|
||||
--dump-cs <scratch>\dump\dump.cs `
|
||||
--obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
|
||||
--keystore C:\path\to\dorknet-debug.keystore
|
||||
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
||||
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
|
||||
```
|
||||
|
||||
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
|
||||
|
||||
For a build whose target table is already committed under `targets/`, pass
|
||||
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
|
||||
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
|
||||
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
|
||||
Jenkins job does (`ci/jenkins/README.md`).
|
||||
|
||||
With `--obb`, the build also writes a release folder ready to hand to players:
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `DorkNet-20230317.xapk` | standard XAPK: `manifest.json`, the APK, the OBB at its install path, `icon.png` |
|
||||
| `install.cmd` / `install.ps1` | one-command install on Windows (double-click `install.cmd`) |
|
||||
| `install.sh` | one-command install on macOS / Linux |
|
||||
| `README.txt` | player instructions |
|
||||
|
||||
To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
|
||||
The package contains game binaries from your store copy, so only share it with
|
||||
people who own the game.
|
||||
|
||||
`build.py build` chains: resolve targets → generate headers → compile
|
||||
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
|
||||
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
|
||||
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
|
||||
keystore in the work dir on first run. **Keep that keystore** and pass it with
|
||||
`--keystore` from then on — Meta ties tokens to app id + package
|
||||
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
||||
It is never committed.
|
||||
|
||||
### Rebasing onto a different build
|
||||
|
||||
Obfuscated Assembly-CSharp names are re-randomised each release but shared with
|
||||
the PC build of the same release, so the PC decompile is the oracle. For a new
|
||||
build:
|
||||
|
||||
1. Dump it with Il2CppDumper.
|
||||
2. Re-run `scripts/resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so
|
||||
--version-name <v> --version-code <c> --objdump <ndk>/llvm-objdump` and
|
||||
confirm every target resolves, is ≥ 20 bytes, and has a real frame-setup
|
||||
prologue. Adjust `targets/spec.json` signatures if a name/shape changed
|
||||
(cross-check against the PC decompile first).
|
||||
3. Commit the new `targets/<v>-<c>.json`.
|
||||
|
||||
The library refuses to arm any target whose 20-byte fingerprint does not match
|
||||
the running `libil2cpp.so`, so a table can never be applied to the wrong build.
|
||||
|
||||
## Install
|
||||
|
||||
**From the browser (easiest).** Open the **Quest** page of your DorkNet site
|
||||
(`https://<apex>/quest`) in Chrome or Edge on a computer, plug the headset in
|
||||
over USB, pick the `.xapk`, click **Connect**, accept "Allow USB debugging" in
|
||||
the headset, then **Install**. The page talks to the headset over WebUSB
|
||||
(Tango, `@yume-chan/adb`): it installs the APK, copies the OBB to its install
|
||||
path (skipped if an identical one is already there) and launches the app. The
|
||||
`.xapk` never leaves the player's computer; the server hosts no game files.
|
||||
Close SideQuest / Meta Quest Developer Hub, or run `adb kill-server`, first:
|
||||
only one program can hold the headset's USB connection. Firefox, Safari and
|
||||
phones don't support WebUSB.
|
||||
|
||||
**From a release folder**, one command does everything (finds adb and the headset,
|
||||
installs the APK, copies the OBB to the right path, skips the OBB if it is
|
||||
already there, and launches the app):
|
||||
|
||||
```powershell
|
||||
.\install.cmd # Windows (or double-click it)
|
||||
./install.sh # macOS / Linux
|
||||
```
|
||||
|
||||
Both take an optional package path, `-Serial`/`--serial` for a specific headset
|
||||
and `-NoLaunch`/`--no-launch`. SideQuest and other XAPK installers can open the
|
||||
`.xapk` directly. The app shows up as `AppName` (default **DorkNet**) under
|
||||
Library > Unknown Sources.
|
||||
|
||||
By hand:
|
||||
|
||||
```sh
|
||||
PKG=net.dorknet.RecRoom # your PackageName
|
||||
VC=2128210
|
||||
adb uninstall $PKG 2>/dev/null
|
||||
adb install -r dorknet-recroom-20230317.apk
|
||||
adb shell mkdir -p /sdcard/Android/obb/$PKG
|
||||
adb push main.2128210.com.AgainstGravity.RecRoom.obb \
|
||||
/sdcard/Android/obb/$PKG/main.$VC.$PKG.obb
|
||||
```
|
||||
|
||||
The on-device OBB filename **must** be `main.<versionCode>.<package>.obb` —
|
||||
Unity rebuilds that exact path at runtime, so the source file's own name is
|
||||
irrelevant, only the destination is.
|
||||
|
||||
## Check it on the headset
|
||||
|
||||
**Wear the headset** — off-head it sleeps within seconds and never finishes
|
||||
init, which looks like a crash.
|
||||
|
||||
```sh
|
||||
adb logcat -c
|
||||
adb shell am start -n net.dorknet.RecRoom/com.unity3d.player.UnityPlayerActivity
|
||||
adb logcat -d -s DORKNET
|
||||
```
|
||||
|
||||
A healthy boot logs: `DorkNet.Quest hooks loaded`, `init: build 20230317/...`,
|
||||
every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<host>/`,
|
||||
`unitytls verify: forcing 0x8 -> SUCCESS`, `[http<-] 200 ...`, and
|
||||
`photon appsettings: RT=... Voice=... Chat=...`.
|
||||
|
||||
| Symptom | Likely cause |
|
||||
| --- | --- |
|
||||
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
||||
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
|
||||
| boots to an empty world | OBB missing or named for the wrong versionCode |
|
||||
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
|
||||
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
|
||||
| multiplayer never connects | Photon app ids not set in the cfg |
|
||||
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
|
||||
| "Unable to load Oculus user account" | old build (before the GetLoggedInUser bypass), or a Meta build whose app id the account isn't entitled to |
|
||||
| game restarts while "creating a new account" | `AutoCreateAccount = true` on a server with sign-ups off — set it to `false` so the sign-in page opens |
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `src/dorknet_quest.cpp` | all hooks + init |
|
||||
| `src/config.h` / `config.cpp` | compile-time defaults + runtime cfg loader |
|
||||
| `src/third_party/And64InlineHook/` | vendored arm64 inline hooker (MIT, © Rprop) |
|
||||
| `targets/spec.json` | target signatures (class header + method), build-independent |
|
||||
| `targets/<ver>-<code>.json` | resolved RVAs + fingerprints for one build |
|
||||
| `scripts/resolve_targets.py` | dump.cs + libil2cpp.so → target table (+ prologue check) |
|
||||
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
||||
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
||||
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
||||
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
|
||||
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
||||
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
||||
|
||||
## Credits
|
||||
|
||||
- [RecFlare](https://github.com/recflare) by djdevin. Their Quest client
|
||||
showed the way this patcher is built: one native hook library loaded
|
||||
through a `DT_NEEDED` entry on `libmain.so`, and which client checks need
|
||||
hooking. They target a newer build (20230414), so every hook target here
|
||||
was re-derived from the 20230317 build; no RecFlare code is included.
|
||||
- [And64InlineHook](https://github.com/Rprop/And64InlineHook) © Rprop (MIT) —
|
||||
vendored under `src/third_party/`.
|
||||
- [Il2CppDumper](https://github.com/Perfare/Il2CppDumper), [LIEF](https://lief.re).
|
||||
@@ -0,0 +1,80 @@
|
||||
# DorkNet.Quest build configuration.
|
||||
#
|
||||
# Copy to dorknet-quest.cfg (git-ignored) and edit. scripts/build.ps1 bakes
|
||||
# these into libdorknet.so at compile time. Omit a key to keep the built-in
|
||||
# default. This same file can be pushed to
|
||||
# /sdcard/Android/data/<PackageName>/files/dorknet-quest.cfg
|
||||
# to override on-device without a rebuild.
|
||||
|
||||
# ---- deployment ----
|
||||
|
||||
# The apex your DorkNet server is reachable at. The client's service hosts are
|
||||
# rewritten onto it: api.rec.net -> api.<ServerHost>, auth.rec.net ->
|
||||
# auth.<ServerHost>, ns.rec.net -> ns.<ServerHost>, and so on. The ns response
|
||||
# then supplies every other service URL. Your DNS must resolve each subdomain
|
||||
# (ns, api, auth, match, chat, cdn, config, ...) and the TLS cert must cover
|
||||
# them. No default -- required.
|
||||
#
|
||||
# For a server on DorkNet's hyphen host style (Domain:HostStyle = hyphen, e.g.
|
||||
# ns-dev.example.com), give a template instead: ServerHost = {service}-dev.example.com
|
||||
ServerHost = example.com
|
||||
|
||||
# Android package name for the patched build, so it coexists with the store
|
||||
# app and uses its own OBB path. Keep this stable: Meta ties platform tokens to
|
||||
# package + signing cert, and the runtime-config path derives from it.
|
||||
PackageName = net.dorknet.RecRoom
|
||||
|
||||
# Name shown in the headset library (Unknown Sources). Build-time only.
|
||||
AppName = DorkNet
|
||||
|
||||
# ---- Meta (Oculus) platform ----
|
||||
|
||||
# Your registered Meta app id. Set it for the Meta build: real entitlement and
|
||||
# Meta login run against an app your players own. Leave BLANK for the
|
||||
# no-Meta-account build -- the platform entitlement/login path is steered to the
|
||||
# client's own username/password screen. Blank sends platform id 1 from every
|
||||
# headset; DorkNet's server treats that as a placeholder and keys accounts by
|
||||
# device id (see PlayerService.IsPlaceholderPlatformId), so players are NOT
|
||||
# merged. Never add a server rule that accepts platform id 1 as an identity.
|
||||
OculusAppId =
|
||||
|
||||
# ---- Photon ----
|
||||
|
||||
# Your own Photon app ids (dashboard.photonengine.com). Rec Room's originals
|
||||
# reject external clients. Leave an id blank to keep the client's own (dead)
|
||||
# value -- multiplayer will not connect until these are set. No defaults.
|
||||
PhotonAppId =
|
||||
PhotonVoiceAppId =
|
||||
PhotonChatAppId =
|
||||
|
||||
# Optional self-hosted Photon name server (blank / 0 = Photon Cloud).
|
||||
PhotonNameServer =
|
||||
PhotonNameServerPort = 0
|
||||
|
||||
# Photon Cloud region. Must match the PC clients' PhotonCloudRegion (ClientMod
|
||||
# config), or Quest and PC players connect to different masters and never meet.
|
||||
PhotonRegion = us
|
||||
|
||||
# ---- client behaviour ----
|
||||
|
||||
# Accept the self-hosted TLS chain and skip Rec Room's certificate pinning.
|
||||
# Required for a self-hosted server; turn off only if you front it with a cert
|
||||
# chain the client's bundled CA set trusts AND you reproduce Rec Room's pins.
|
||||
EnableTlsTrustBypass = true
|
||||
|
||||
# Accept image Content-Signatures the server signs with its own key.
|
||||
AcceptUnsignedImages = true
|
||||
|
||||
# A headset with no remembered account. false: open the sign-in page so the
|
||||
# player logs into their existing DorkNet account. true: Rec Room's own
|
||||
# behaviour, which creates a new account automatically -- only useful when the
|
||||
# server allows sign-ups (otherwise the game keeps retrying and failing).
|
||||
AutoCreateAccount = false
|
||||
|
||||
# ---- diagnostics (adb logcat -s DORKNET) ----
|
||||
|
||||
LogHttp = true
|
||||
# WARNING: Debug logs request/response BODIES, including auth tokens and
|
||||
# passwords. Leave off except when debugging, and don't share the logs.
|
||||
Debug = false
|
||||
LogHttpBodyMax = 10000
|
||||
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
|
||||
|
||||
From a pristine store APK, end to end:
|
||||
|
||||
1. hook targets resolve them from an Il2CppDumper dump.cs
|
||||
(resolve_targets.py), or use a committed table checked
|
||||
against the APK's libil2cpp.so
|
||||
2. headers targets_generated.h + config_generated.h
|
||||
3. compile libdorknet.so (arm64) with the Android NDK
|
||||
4. apktool decode
|
||||
5. rename package rename, launcher category, app label
|
||||
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
|
||||
7. sign apktool build -> zipalign -> apksigner
|
||||
8. package with --obb: .xapk + one-command installers (or run
|
||||
`package` on an APK you already built)
|
||||
|
||||
Nothing game-derived is written into the repo. You supply the store APK and,
|
||||
for a release package, the matching OBB. The output contains game binaries
|
||||
from your own store copy: share it only with people who own the game.
|
||||
|
||||
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
|
||||
keytool), the Android NDK, Android build-tools and apktool.
|
||||
|
||||
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
|
||||
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
|
||||
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
REPO = SCRIPTS.parent # DorkNet.Quest/
|
||||
OLD_PKG = "com.AgainstGravity.RecRoom"
|
||||
SO_NAME = "libdorknet.so"
|
||||
ON_WINDOWS = os.name == "nt"
|
||||
|
||||
|
||||
def step(msg: str) -> None:
|
||||
print(f"==> {msg}", flush=True)
|
||||
|
||||
|
||||
def fail(msg: str) -> NoReturn:
|
||||
raise SystemExit(f"error: {msg}")
|
||||
|
||||
|
||||
def need(path: Path | str, what: str) -> Path:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
fail(f"{what} not found: {p}")
|
||||
return p
|
||||
|
||||
|
||||
def run(*cmd: object, cwd: Path | None = None) -> None:
|
||||
args = [str(c) for c in cmd]
|
||||
r = subprocess.run(args, cwd=cwd)
|
||||
if r.returncode:
|
||||
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||
|
||||
|
||||
def capture(*cmd: object) -> str:
|
||||
args = [str(c) for c in cmd]
|
||||
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
if r.returncode:
|
||||
sys.stderr.write(r.stderr)
|
||||
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def run_py(script: str, *args: object) -> None:
|
||||
"""Run a sibling helper script with this interpreter (isolated mode)."""
|
||||
run(sys.executable, "-I", SCRIPTS / script, *args)
|
||||
|
||||
|
||||
def exe(name: str) -> str:
|
||||
return name + (".exe" if ON_WINDOWS else "")
|
||||
|
||||
|
||||
# ---- tool resolution ------------------------------------------------------
|
||||
|
||||
def sdk_root() -> Path | None:
|
||||
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
|
||||
return Path(v) if v else None
|
||||
|
||||
|
||||
def newest_subdir(parent: Path) -> Path | None:
|
||||
if not parent.is_dir():
|
||||
return None
|
||||
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
|
||||
return dirs[0] if dirs else None
|
||||
|
||||
|
||||
def resolve_build_tools(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return need(arg, "build-tools dir")
|
||||
root = sdk_root()
|
||||
found = newest_subdir(root / "build-tools") if root else None
|
||||
if not found:
|
||||
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
|
||||
return found
|
||||
|
||||
|
||||
def resolve_ndk(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return need(arg, "NDK")
|
||||
if os.environ.get("ANDROID_NDK"):
|
||||
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
|
||||
root = sdk_root()
|
||||
found = newest_subdir(root / "ndk") if root else None
|
||||
if not found:
|
||||
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
|
||||
return found
|
||||
|
||||
|
||||
def ndk_bin(ndk: Path) -> Path:
|
||||
if ON_WINDOWS:
|
||||
tag = "windows-x86_64"
|
||||
elif sys.platform == "darwin":
|
||||
tag = "darwin-x86_64"
|
||||
else:
|
||||
tag = "linux-x86_64"
|
||||
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
|
||||
|
||||
|
||||
def resolve_apktool(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return need(arg, "apktool.jar")
|
||||
if os.environ.get("APKTOOL_JAR"):
|
||||
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
|
||||
root = sdk_root()
|
||||
if root and (root / "apktool.jar").exists():
|
||||
return root / "apktool.jar"
|
||||
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
|
||||
|
||||
|
||||
def cfg_value(config: Path, key: str) -> str | None:
|
||||
for line in config.read_text(encoding="utf-8").splitlines():
|
||||
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
|
||||
if m:
|
||||
return m.group(1).split("#", 1)[0].strip()
|
||||
return None
|
||||
|
||||
|
||||
# ---- build ----------------------------------------------------------------
|
||||
|
||||
def cmd_build(a: argparse.Namespace) -> None:
|
||||
ndk = resolve_ndk(a.ndk_root)
|
||||
bin_dir = ndk_bin(ndk)
|
||||
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
|
||||
"NDK aarch64 clang++")
|
||||
objdump = bin_dir / exe("llvm-objdump")
|
||||
build_tools = resolve_build_tools(a.build_tools_dir)
|
||||
zipalign = need(build_tools / exe("zipalign"), "zipalign")
|
||||
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
|
||||
apktool = resolve_apktool(a.apktool_jar)
|
||||
apk = need(a.apk, "store APK").resolve()
|
||||
|
||||
config = Path(a.config) if a.config else (
|
||||
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
|
||||
need(config, "config")
|
||||
print(f"Using config: {config}")
|
||||
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
|
||||
app_name = cfg_value(config, "AppName") or "DorkNet"
|
||||
|
||||
work = Path(a.work_dir)
|
||||
gen, project, extract = work / "gen", work / "project", work / "extract"
|
||||
for d in (work, gen, extract):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. extract + targets
|
||||
step("extracting libil2cpp.so + global-metadata.dat")
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
for entry in ("lib/arm64-v8a/libil2cpp.so",
|
||||
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
|
||||
"lib/arm64-v8a/libmain.so"):
|
||||
try:
|
||||
data = z.read(entry)
|
||||
except KeyError:
|
||||
fail(f"APK missing {entry}")
|
||||
(extract / Path(entry).name).write_bytes(data)
|
||||
il2cpp = extract / "libil2cpp.so"
|
||||
|
||||
ver_name, ver_code = "20230317", "2128210"
|
||||
if a.targets_json:
|
||||
# A committed, already-resolved table. It is only valid for the exact
|
||||
# libil2cpp.so it was resolved against, so check that first.
|
||||
table = need(a.targets_json, "target table")
|
||||
step(f"using target table {table}")
|
||||
data = json.loads(table.read_text(encoding="utf-8"))
|
||||
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
|
||||
want = str(data.get("il2cppSha256") or "").lower()
|
||||
if want and sha != want:
|
||||
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
|
||||
f"for ({want}). Resolve a table for it with --dump-cs.")
|
||||
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
|
||||
else:
|
||||
if not a.dump_cs:
|
||||
# Dumping is left to Il2CppDumper (see README) to keep this
|
||||
# script toolchain-light.
|
||||
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
|
||||
"resolved table (see DorkNet.Quest/README.md).")
|
||||
need(a.dump_cs, "dump.cs")
|
||||
step("resolving hook targets")
|
||||
# Written into the work dir (Controlled Folder Access can block writes
|
||||
# under Documents). The repo keeps a committed copy under targets/.
|
||||
table = work / f"{ver_name}-{ver_code}.json"
|
||||
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
|
||||
"--version-name", ver_name, "--version-code", ver_code,
|
||||
"--objdump", objdump, "--out", table)
|
||||
|
||||
# 2. headers
|
||||
step("generating headers")
|
||||
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
|
||||
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
|
||||
|
||||
# 3. compile
|
||||
step("compiling libdorknet.so (arm64)")
|
||||
src = REPO / "src"
|
||||
so = work / SO_NAME
|
||||
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
|
||||
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
|
||||
f"-I{gen}", f"-I{src}",
|
||||
src / "dorknet_quest.cpp", src / "config.cpp",
|
||||
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
|
||||
"-llog", "-o", so)
|
||||
need(so, "libdorknet.so")
|
||||
|
||||
# 4. decode
|
||||
step("apktool decode")
|
||||
if project.exists():
|
||||
shutil.rmtree(project)
|
||||
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
|
||||
|
||||
# 5. package rename + manifest + label
|
||||
step(f"renaming package {OLD_PKG} -> {pkg_name}")
|
||||
manifest = project / "AndroidManifest.xml"
|
||||
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
|
||||
if "android.intent.category.LAUNCHER" not in m:
|
||||
m = m.replace('<category android:name="android.intent.category.INFO"/>',
|
||||
'<category android:name="android.intent.category.INFO"/>'
|
||||
'<category android:name="android.intent.category.LAUNCHER"/>')
|
||||
manifest.write_text(m, encoding="utf-8", newline="")
|
||||
|
||||
# The launcher label comes from @string/app_name; set it in every locale.
|
||||
step(f"naming the app '{app_name}'")
|
||||
# XML entities for & < > " ' (the PowerShell version used
|
||||
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
|
||||
label = xml_escape(app_name, {'"': """, "'": "'"})
|
||||
for sx in (project / "res").rglob("strings.xml"):
|
||||
s = sx.read_text(encoding="utf-8")
|
||||
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
|
||||
if n != s:
|
||||
sx.write_text(n, encoding="utf-8", newline="")
|
||||
|
||||
# 6. inject
|
||||
step(f"injecting {SO_NAME}")
|
||||
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
|
||||
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
|
||||
|
||||
# 7. build + align + sign
|
||||
step("apktool build")
|
||||
unsigned = work / "unsigned.apk"
|
||||
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
|
||||
|
||||
if a.keystore:
|
||||
keystore = need(a.keystore, "keystore")
|
||||
else:
|
||||
keystore = work / "dorknet-debug.keystore"
|
||||
if not keystore.exists():
|
||||
step("generating a fresh debug keystore (kept in the work dir, never committed)")
|
||||
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
|
||||
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
|
||||
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
|
||||
"-dname", "CN=DorkNet Quest, O=DorkNet")
|
||||
|
||||
step("zipalign")
|
||||
aligned = work / "aligned.apk"
|
||||
run(zipalign, "-f", "-p", "4", unsigned, aligned)
|
||||
|
||||
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
|
||||
out_apk.parent.mkdir(parents=True, exist_ok=True)
|
||||
step(f"apksigner -> {out_apk}")
|
||||
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
|
||||
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
|
||||
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
|
||||
"--out", out_apk, aligned)
|
||||
capture(apksigner, "verify", "--print-certs", out_apk)
|
||||
|
||||
print()
|
||||
print(f"DONE. Patched APK: {out_apk}")
|
||||
print(f"Package: {pkg_name} versionCode: {ver_code}")
|
||||
if a.obb:
|
||||
print()
|
||||
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
|
||||
else:
|
||||
print("Install it, then push the matching OBB to:")
|
||||
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
|
||||
print("(or pass --obb to bundle both into an installable .xapk)")
|
||||
|
||||
|
||||
# ---- package --------------------------------------------------------------
|
||||
|
||||
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
|
||||
============================================================
|
||||
|
||||
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
||||
in over USB and accept "Allow USB debugging" inside the headset.
|
||||
2. Install. Easiest: open the Quest page of your DorkNet server's website
|
||||
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
|
||||
steps. Or use the scripts here:
|
||||
Windows: double-click install.cmd
|
||||
macOS / Linux: ./install.sh
|
||||
The installer needs adb (Android platform-tools) on PATH or in
|
||||
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
||||
offers to launch it.
|
||||
3. In the headset: Library > Unknown Sources > {name}.
|
||||
|
||||
The app installs as {pkg} alongside any store copy of Rec Room.
|
||||
SideQuest users can also drop the .xapk onto SideQuest instead.
|
||||
"""
|
||||
|
||||
|
||||
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
|
||||
"""Bundle a patched APK and its OBB into an .xapk plus installers.
|
||||
|
||||
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
|
||||
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
|
||||
The APK and OBB are stored uncompressed: they are already compressed, and
|
||||
storing them keeps packaging and install fast."""
|
||||
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
|
||||
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
|
||||
|
||||
step("reading APK metadata")
|
||||
badging = capture(aapt2, "dump", "badging", apk).splitlines()
|
||||
|
||||
def badge(pattern: str) -> str | None:
|
||||
for line in badging:
|
||||
mm = re.search(pattern, line)
|
||||
if mm:
|
||||
return mm.group(1)
|
||||
return None
|
||||
|
||||
pkg = badge(r"^package: name='([^']+)'")
|
||||
ver_code = badge(r"versionCode='([^']+)'")
|
||||
ver_name = badge(r"versionName='([^']+)'")
|
||||
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
|
||||
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
|
||||
label = badge(r"^application-label:'([^']*)'")
|
||||
if not pkg or not ver_code:
|
||||
fail("could not read package name / versionCode from the APK")
|
||||
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
|
||||
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
|
||||
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
|
||||
|
||||
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
|
||||
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
|
||||
icon_path = None
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
names = set(z.namelist())
|
||||
if icons:
|
||||
ref = icons[0][1]
|
||||
if ref.endswith(".png") and ref in names:
|
||||
icon_path = ref
|
||||
else:
|
||||
base = Path(ref).stem
|
||||
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
|
||||
for kind in ("mipmap", "drawable"):
|
||||
cand = f"res/{kind}-{d}/{base}.png"
|
||||
if icon_path is None and cand in names:
|
||||
icon_path = cand
|
||||
icon_bytes = z.read(icon_path) if icon_path else None
|
||||
print(f" {pkg} version {ver_name} ({ver_code})")
|
||||
|
||||
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
|
||||
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
|
||||
xapk.unlink(missing_ok=True)
|
||||
|
||||
manifest = {
|
||||
"xapk_version": 2,
|
||||
"package_name": pkg,
|
||||
"name": label or "DorkNet",
|
||||
"version_code": ver_code,
|
||||
"version_name": ver_name,
|
||||
"min_sdk_version": min_sdk,
|
||||
"target_sdk_version": tgt_sdk,
|
||||
"permissions": perms,
|
||||
"total_size": apk.stat().st_size + obb.stat().st_size,
|
||||
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
|
||||
}
|
||||
if icon_bytes:
|
||||
manifest["icon"] = "icon.png"
|
||||
|
||||
step(f"writing {xapk} (the OBB is large; this takes a minute)")
|
||||
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
|
||||
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
|
||||
if icon_bytes:
|
||||
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
|
||||
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
|
||||
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
step("adding installers")
|
||||
for f in ("install.ps1", "install.cmd", "install.sh"):
|
||||
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
|
||||
(out_dir / "install.sh").chmod(0o755)
|
||||
(out_dir / "README.txt").write_text(
|
||||
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
|
||||
|
||||
print()
|
||||
print(f"DONE. Package folder: {out_dir}")
|
||||
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
|
||||
print(f"Install with: {out_dir / 'install.cmd'}")
|
||||
return out_dir
|
||||
|
||||
|
||||
def cmd_package(a: argparse.Namespace) -> None:
|
||||
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
|
||||
resolve_build_tools(a.build_tools_dir))
|
||||
|
||||
|
||||
# ---- cli ------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
|
||||
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
|
||||
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
|
||||
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
|
||||
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
|
||||
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
|
||||
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
|
||||
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
|
||||
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
|
||||
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
|
||||
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
|
||||
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
|
||||
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
|
||||
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
|
||||
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
|
||||
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
|
||||
b.set_defaults(func=cmd_build)
|
||||
|
||||
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
|
||||
k.add_argument("--apk", required=True)
|
||||
k.add_argument("--obb", required=True)
|
||||
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||
k.add_argument("--build-tools-dir")
|
||||
k.set_defaults(func=cmd_package)
|
||||
|
||||
a = p.parse_args()
|
||||
a.func(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate config_generated.h from dorknet-quest.cfg.
|
||||
|
||||
Bakes the operator's config into the library at compile time. Keys absent from
|
||||
the cfg are simply not emitted, so the built-in defaults in config.h apply.
|
||||
Secrets (Photon ids, Meta app id) live only in the git-ignored cfg, never in a
|
||||
committed file.
|
||||
|
||||
Usage: gen_config_header.py <dorknet-quest.cfg> <package> <out/config_generated.h>
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# cfg key -> (macro, kind) where kind is "str", "bool" or "int".
|
||||
KEYS = {
|
||||
"ServerHost": ("DN_SERVER_HOST", "str"),
|
||||
"OculusAppId": ("DN_OCULUS_APPID", "str"),
|
||||
"PhotonAppId": ("DN_PHOTON_REALTIME", "str"),
|
||||
"PhotonVoiceAppId": ("DN_PHOTON_VOICE", "str"),
|
||||
"PhotonChatAppId": ("DN_PHOTON_CHAT", "str"),
|
||||
"PhotonNameServer": ("DN_PHOTON_SERVER", "str"),
|
||||
"PhotonNameServerPort": ("DN_PHOTON_PORT", "int"),
|
||||
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
||||
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
||||
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
||||
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
|
||||
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
||||
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
||||
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
||||
}
|
||||
TRUE = {"true", "1", "yes", "on"}
|
||||
FALSE = {"false", "0", "no", "off"}
|
||||
|
||||
|
||||
def c_string(v):
|
||||
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
raise SystemExit("usage: gen_config_header.py <cfg> <package> <out.h>")
|
||||
cfg, package, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
|
||||
lines = ["// Generated by scripts/gen_config_header.py -- do not edit, do not commit.",
|
||||
"#ifndef DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||
"#define DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||
f"#define DN_PACKAGE {c_string(package)}", ""]
|
||||
|
||||
if cfg.exists():
|
||||
for raw in cfg.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line or "=" not in line or line.startswith("["):
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key, val = key.strip(), val.strip()
|
||||
if key not in KEYS or val == "":
|
||||
continue
|
||||
macro, kind = KEYS[key]
|
||||
if kind == "str":
|
||||
lines.append(f"#define {macro} {c_string(val)}")
|
||||
elif kind == "int":
|
||||
lines.append(f"#define {macro} {int(val)}")
|
||||
elif kind == "bool":
|
||||
low = val.lower()
|
||||
if low in TRUE:
|
||||
lines.append(f"#define {macro} true")
|
||||
elif low in FALSE:
|
||||
lines.append(f"#define {macro} false")
|
||||
else:
|
||||
raise SystemExit(f"{key}: not a boolean: {val}")
|
||||
else:
|
||||
print(f"note: {cfg} not found; baking package only, all values default", file=sys.stderr)
|
||||
|
||||
lines += ["", "#endif"]
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"wrote {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate targets_generated.h from a resolved target table.
|
||||
|
||||
The header holds each target's RVA, length and 20-byte FNV-1a fingerprint as a
|
||||
named struct field, plus the build's version. The hook library checks the
|
||||
fingerprint against the running libil2cpp.so before arming, so a table can only
|
||||
ever be applied to the build it was resolved from.
|
||||
|
||||
Usage: gen_targets_header.py <targets/NAME-CODE.json> <out/targets_generated.h>
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: gen_targets_header.py <table.json> <out.h>")
|
||||
table = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
ids = list(table["targets"].keys())
|
||||
|
||||
lines = [
|
||||
"// Generated by scripts/gen_targets_header.py -- do not edit.",
|
||||
f"// Build {table['versionName']} / versionCode {table['versionCode']}",
|
||||
f"// libil2cpp.so sha256 {table['il2cppSha256']}",
|
||||
"#ifndef DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||
"#define DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
f'#define DN_TARGET_VERSION_NAME "{table["versionName"]}"',
|
||||
f'#define DN_TARGET_VERSION_CODE "{table["versionCode"]}"',
|
||||
f"#define DN_HOOK_BYTES {table['hookBytes']}",
|
||||
"",
|
||||
"typedef struct { const char *id; uint32_t rva; uint64_t fingerprint; uint32_t length; } DnTarget;",
|
||||
"",
|
||||
"typedef struct {",
|
||||
]
|
||||
for tid in ids:
|
||||
lines.append(f" DnTarget {tid};")
|
||||
lines.append("} DnTargets;")
|
||||
lines.append("")
|
||||
lines.append("static const DnTargets dn_targets = {")
|
||||
for tid in ids:
|
||||
e = table["targets"][tid]
|
||||
length = e["length"] if e["length"] is not None else 0
|
||||
lines.append(
|
||||
f' .{tid} = {{ "{tid}", {e["rva"]:#x}u, {e["fingerprint"]}ull, {length}u }},')
|
||||
lines.append("};")
|
||||
lines.append("")
|
||||
lines.append("#endif")
|
||||
Path(sys.argv[2]).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"wrote {sys.argv[2]} ({len(ids)} targets)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a DT_NEEDED entry to libmain.so so the Android loader pulls in
|
||||
libdorknet.so before Unity starts.
|
||||
|
||||
libmain.so is tiny (the Unity bootstrap shim) and lists libil2cpp etc. as
|
||||
DT_NEEDED; adding one more makes the loader map our hook library first. This is
|
||||
the same mechanism RecFlare uses on Quest. Idempotent: a second run is a no-op.
|
||||
|
||||
Usage: inject_needed.py <libmain.so> <needed-soname> (e.g. libdorknet.so)
|
||||
"""
|
||||
import sys
|
||||
|
||||
import lief
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: inject_needed.py <libmain.so> <soname>")
|
||||
path, soname = sys.argv[1], sys.argv[2]
|
||||
binary = lief.ELF.parse(path)
|
||||
if binary is None:
|
||||
raise SystemExit(f"could not parse {path}")
|
||||
|
||||
existing = [e.name for e in binary.dynamic_entries
|
||||
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||
if soname in existing:
|
||||
print(f"{soname} already a DT_NEEDED of {path}; nothing to do")
|
||||
return
|
||||
|
||||
binary.add_library(soname)
|
||||
binary.write(path)
|
||||
print(f"added DT_NEEDED {soname} to {path}")
|
||||
|
||||
check = lief.ELF.parse(path)
|
||||
names = [e.name for e in check.dynamic_entries
|
||||
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||
if soname not in names:
|
||||
raise SystemExit("injection did not stick")
|
||||
print("verified:", ", ".join(names))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
rem One-click installer: runs install.ps1 next to this file.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,145 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Install a DorkNet Rec Room .xapk onto a Meta Quest over adb.
|
||||
|
||||
.DESCRIPTION
|
||||
Installs the APK, copies the OBB to the path the game expects, then launches
|
||||
the app. Works in Windows PowerShell 5.1 and PowerShell 7. Run it with no
|
||||
arguments from the package folder, or double-click install.cmd.
|
||||
|
||||
.PARAMETER Xapk
|
||||
The package to install. Default: the only .xapk next to this script.
|
||||
|
||||
.PARAMETER Serial
|
||||
adb serial of the headset when more than one device is connected.
|
||||
|
||||
.PARAMETER NoLaunch
|
||||
Install only; don't start the app afterwards.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Xapk,
|
||||
[string]$Serial,
|
||||
[switch]$NoLaunch
|
||||
)
|
||||
|
||||
# adb writes progress to stderr; under 'Stop' Windows PowerShell would treat
|
||||
# that as a failure, so exit codes are checked by hand instead.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Set-StrictMode -Version 2
|
||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
||||
|
||||
function Say($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Fail($m) { Write-Host "ERROR: $m" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# ---- find the package ----------------------------------------------------
|
||||
if (-not $Xapk) {
|
||||
$found = @(Get-ChildItem -Path $PSScriptRoot -Filter *.xapk -File)
|
||||
if ($found.Count -eq 0) { Fail "no .xapk found next to $PSCommandPath; pass -Xapk <file>." }
|
||||
if ($found.Count -gt 1) { Fail "more than one .xapk here; pass -Xapk <file>." }
|
||||
$Xapk = $found[0].FullName
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Xapk)) { Fail "package not found: $Xapk" }
|
||||
$Xapk = (Resolve-Path -LiteralPath $Xapk).Path
|
||||
|
||||
# ---- find adb -----------------------------------------------------------
|
||||
$adb = $null
|
||||
$cmd = Get-Command adb -ErrorAction SilentlyContinue
|
||||
if ($cmd) { $adb = $cmd.Source }
|
||||
if (-not $adb) {
|
||||
$roots = @($env:ANDROID_SDK_ROOT, $env:ANDROID_HOME, (Join-Path $env:LOCALAPPDATA 'Android\Sdk'), $PSScriptRoot) | Where-Object { $_ }
|
||||
foreach ($r in $roots) {
|
||||
foreach ($c in @((Join-Path $r 'platform-tools\adb.exe'), (Join-Path $r 'adb.exe'))) {
|
||||
if (Test-Path $c) { $adb = $c; break }
|
||||
}
|
||||
if ($adb) { break }
|
||||
}
|
||||
}
|
||||
if (-not $adb) {
|
||||
Fail ("adb not found. Install Android platform-tools from " +
|
||||
"https://developer.android.com/tools/releases/platform-tools and put adb on PATH " +
|
||||
"(or unzip platform-tools next to this script).")
|
||||
}
|
||||
|
||||
# ---- pick the headset ----------------------------------------------------
|
||||
$lines = & $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\S' }
|
||||
$ready = @($lines | Where-Object { $_ -match "^(\S+)\s+device$" } | ForEach-Object { ($_ -split '\s+')[0] })
|
||||
$unauth = @($lines | Where-Object { $_ -match '\sunauthorized$' })
|
||||
if ($Serial) {
|
||||
if ($ready -notcontains $Serial) { Fail "device $Serial is not connected and authorised (adb devices)." }
|
||||
} elseif ($ready.Count -eq 1) {
|
||||
$Serial = $ready[0]
|
||||
} elseif ($ready.Count -gt 1) {
|
||||
Fail ("more than one device connected: " + ($ready -join ', ') + ". Pass -Serial <id>.")
|
||||
} elseif ($unauth.Count -gt 0) {
|
||||
Fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||
} else {
|
||||
Fail 'no headset found. Enable developer mode, connect it over USB, and accept "Allow USB debugging" in the headset.'
|
||||
}
|
||||
function Adb { & $adb -s $Serial @args }
|
||||
$model = (Adb shell getprop ro.product.model | Out-String).Trim()
|
||||
Say "headset: $model ($Serial)"
|
||||
|
||||
# ---- read the package ----------------------------------------------------
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead($Xapk)
|
||||
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dorknet-install-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||
try {
|
||||
$me = $zip.GetEntry('manifest.json')
|
||||
if (-not $me) { Fail 'not an XAPK: manifest.json missing.' }
|
||||
$sr = New-Object System.IO.StreamReader($me.Open())
|
||||
$manifest = $sr.ReadToEnd() | ConvertFrom-Json
|
||||
$sr.Dispose()
|
||||
$pkg = $manifest.package_name
|
||||
Say "package: $($manifest.name) $pkg $($manifest.version_name) ($($manifest.version_code))"
|
||||
|
||||
function Extract($entry) {
|
||||
$out = Join-Path $tmp ([System.IO.Path]::GetFileName($entry.FullName))
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $out, $true)
|
||||
return $out
|
||||
}
|
||||
|
||||
# ---- APK -------------------------------------------------------------
|
||||
$apkEntry = $zip.GetEntry("$pkg.apk")
|
||||
if (-not $apkEntry) { $apkEntry = @($zip.Entries | Where-Object { $_.FullName -like '*.apk' })[0] }
|
||||
if (-not $apkEntry) { Fail 'no APK inside the package.' }
|
||||
Say 'installing the app'
|
||||
$apkFile = Extract $apkEntry
|
||||
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||
if ($res -match 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match') {
|
||||
Write-Host "An older copy of $pkg is installed with a different signing key." -ForegroundColor Yellow
|
||||
Write-Host "It has to be removed first. Your account lives on the server, but local settings on the headset are lost." -ForegroundColor Yellow
|
||||
$ans = Read-Host 'Uninstall it and continue? [y/N]'
|
||||
if ($ans -notmatch '^(y|yes)$') { Fail 'cancelled.' }
|
||||
Adb uninstall $pkg | Out-Null
|
||||
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||
}
|
||||
if ($res -notmatch 'Success') { Fail ("app install failed:`n" + $res.Trim()) }
|
||||
Remove-Item -Force $apkFile
|
||||
|
||||
# ---- OBB (expansion files) ------------------------------------------
|
||||
foreach ($x in @($manifest.expansions)) {
|
||||
if (-not $x) { continue }
|
||||
$entry = $zip.GetEntry($x.file)
|
||||
if (-not $entry) { Fail "package is missing $($x.file)." }
|
||||
$dest = '/sdcard/' + $x.install_path
|
||||
$have = (Adb shell "stat -c %s '$dest' 2>/dev/null" | Out-String).Trim()
|
||||
if ($have -eq [string]$entry.Length) { Say "game data already on the headset, skipping ($([System.IO.Path]::GetFileName($dest)))"; continue }
|
||||
Say ("copying game data ({0:N2} GB) - this takes a few minutes" -f ($entry.Length / 1GB))
|
||||
$f = Extract $entry
|
||||
Adb shell "mkdir -p '$([System.IO.Path]::GetDirectoryName($dest).Replace('\','/'))'" | Out-Null
|
||||
Adb push $f $dest
|
||||
if ($LASTEXITCODE) { Fail 'copying the game data failed (is there enough free space on the headset?).' }
|
||||
Remove-Item -Force $f
|
||||
}
|
||||
} finally {
|
||||
$zip.Dispose()
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Installed. In the headset: Library > Unknown Sources > $($manifest.name)." -ForegroundColor Green
|
||||
if (-not $NoLaunch) {
|
||||
Say 'launching (put the headset on)'
|
||||
Adb shell monkey -p $pkg -c android.intent.category.LAUNCHER 1 2>&1 | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install a DorkNet Rec Room .xapk onto a Meta Quest over adb (macOS / Linux).
|
||||
#
|
||||
# ./install.sh [package.xapk] [--serial <id>] [--no-launch]
|
||||
#
|
||||
# Needs adb (Android platform-tools) and unzip.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
xapk="" serial="" launch=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--serial) serial="$2"; shift 2 ;;
|
||||
--no-launch) launch=0; shift ;;
|
||||
*) xapk="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[36m==> %s\033[0m\n' "$*"; }
|
||||
fail() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
if [ -z "$xapk" ]; then
|
||||
set -- "$here"/*.xapk
|
||||
[ -e "$1" ] || fail "no .xapk found next to this script; pass it as an argument."
|
||||
[ $# -eq 1 ] || fail "more than one .xapk here; pass the one to install."
|
||||
xapk="$1"
|
||||
fi
|
||||
[ -f "$xapk" ] || fail "package not found: $xapk"
|
||||
command -v unzip >/dev/null || fail "unzip is required."
|
||||
|
||||
adb="$(command -v adb || true)"
|
||||
for c in "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "${ANDROID_HOME:-}/platform-tools/adb" \
|
||||
"$HOME/Library/Android/sdk/platform-tools/adb" "$HOME/Android/Sdk/platform-tools/adb" \
|
||||
"$here/platform-tools/adb"; do
|
||||
[ -n "$adb" ] && break
|
||||
[ -x "$c" ] && adb="$c"
|
||||
done
|
||||
[ -n "$adb" ] || fail "adb not found. Install Android platform-tools: https://developer.android.com/tools/releases/platform-tools"
|
||||
|
||||
if [ -z "$serial" ]; then
|
||||
ready=$("$adb" devices | awk 'NR>1 && $2=="device"{print $1}')
|
||||
count=$(printf '%s' "$ready" | grep -c . || true)
|
||||
if [ "$count" -eq 0 ]; then
|
||||
if "$adb" devices | grep -q unauthorized; then
|
||||
fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||
fi
|
||||
fail 'no headset found. Enable developer mode, connect over USB, and accept "Allow USB debugging".'
|
||||
fi
|
||||
[ "$count" -eq 1 ] || fail "more than one device connected: $(echo $ready). Pass --serial <id>."
|
||||
serial="$ready"
|
||||
fi
|
||||
a() { "$adb" -s "$serial" "$@"; }
|
||||
say "headset: $(a shell getprop ro.product.model | tr -d '\r') ($serial)"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
manifest="$(unzip -p "$xapk" manifest.json)" || fail "not an XAPK: manifest.json missing."
|
||||
field() { printf '%s' "$manifest" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"\(.*\)"/\1/'; }
|
||||
pkg="$(field package_name)"
|
||||
[ -n "$pkg" ] || fail "manifest.json has no package_name."
|
||||
say "package: $(field name) $pkg $(field version_name) ($(field version_code))"
|
||||
|
||||
say "installing the app"
|
||||
unzip -q -o "$xapk" "$pkg.apk" -d "$tmp"
|
||||
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||
if printf '%s' "$out" | grep -qE 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match'; then
|
||||
echo "An older copy of $pkg is installed with a different signing key; it has to be removed first."
|
||||
echo "Your account lives on the server, but local settings on the headset are lost."
|
||||
read -r -p "Uninstall it and continue? [y/N] " ans
|
||||
case "$ans" in y|Y|yes) ;; *) fail "cancelled." ;; esac
|
||||
a uninstall "$pkg" >/dev/null || true
|
||||
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||
fi
|
||||
printf '%s' "$out" | grep -q Success || fail "app install failed: $out"
|
||||
rm -f "$tmp/$pkg.apk"
|
||||
|
||||
# Expansion files: every "install_path" in the manifest.
|
||||
printf '%s' "$manifest" | grep -o '"install_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"\(.*\)"/\1/' |
|
||||
while read -r path; do
|
||||
dest="/sdcard/$path"
|
||||
size="$(unzip -l "$xapk" "$path" | awk 'NR==4{print $1}')"
|
||||
have="$(a shell "stat -c %s '$dest' 2>/dev/null" | tr -d '\r' || true)"
|
||||
if [ -n "$size" ] && [ "$have" = "$size" ]; then say "game data already on the headset, skipping"; continue; fi
|
||||
say "copying game data - this takes a few minutes"
|
||||
unzip -q -o "$xapk" "$path" -d "$tmp"
|
||||
a shell "mkdir -p '$(dirname "$dest")'"
|
||||
a push "$tmp/$path" "$dest" || fail "copying the game data failed (is there enough free space on the headset?)."
|
||||
rm -f "$tmp/$path"
|
||||
done
|
||||
|
||||
echo
|
||||
printf '\033[32mInstalled. In the headset: Library > Unknown Sources > %s.\033[0m\n' "$(field name)"
|
||||
if [ "$launch" -eq 1 ]; then
|
||||
say "launching (put the headset on)"
|
||||
a shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve DorkNet.Quest hook targets for one Rec Room Quest build.
|
||||
|
||||
Reads targets/spec.json, finds every target in an Il2CppDumper dump.cs by its
|
||||
class header and method signature, and verifies each against the build's
|
||||
libil2cpp.so:
|
||||
|
||||
* exactly one method matches the signature,
|
||||
* the method body is at least 20 bytes (the inline hook rewrites the first
|
||||
five instructions),
|
||||
* the first instructions are disassembled (llvm-objdump, when provided) so
|
||||
the prologue can be eyeballed before a build.
|
||||
|
||||
Output is a per-build table (targets/<versionName>-<versionCode>.json) holding
|
||||
each target's RVA, body length and an FNV-1a fingerprint of the first 20 code
|
||||
bytes. The hook library refuses to arm a target whose fingerprint does not
|
||||
match the running libil2cpp.so, so a table can never be applied to the wrong
|
||||
build. Only RVAs, lengths and hashes are written -- no game code or metadata.
|
||||
|
||||
Usage:
|
||||
python resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so \
|
||||
--version-name 20230317 --version-code 2128210 \
|
||||
[--objdump path/to/llvm-objdump] [--out targets/20230317-2128210.json]
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HOOK_BYTES = 20
|
||||
RVA_RE = re.compile(r"^\s*// RVA: 0x([0-9A-Fa-f]+) Offset: 0x([0-9A-Fa-f]+)")
|
||||
TYPE_RE = re.compile(r"^(public|internal|private|protected)\b.*\b(class|struct|interface|enum)\b ")
|
||||
|
||||
|
||||
def fnv1a64(data):
|
||||
h = 0xCBF29CE484222325
|
||||
for b in data:
|
||||
h ^= b
|
||||
h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
||||
return h
|
||||
|
||||
|
||||
def sha256(path):
|
||||
d = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
d.update(chunk)
|
||||
return d.hexdigest()
|
||||
|
||||
|
||||
def scan_dump(dump, spec):
|
||||
"""One pass over dump.cs collecting (rva, offset) for each spec match and
|
||||
for the NEXT method after it (to bound the body length)."""
|
||||
wanted = {t["id"]: (t["class"], t["method"]) for t in spec["targets"]}
|
||||
hits = {} # id -> {rva, offset, ...}
|
||||
all_rvas = set() # every method RVA, for next-greater length bounding
|
||||
rva = off = None
|
||||
cur_class = None
|
||||
lines = dump.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
for ln in lines:
|
||||
m = RVA_RE.match(ln)
|
||||
if m:
|
||||
rva, off = int(m.group(1), 16), int(m.group(2), 16)
|
||||
if rva:
|
||||
all_rvas.add(rva)
|
||||
continue
|
||||
if TYPE_RE.match(ln):
|
||||
# dump.cs class headers end with "// TypeDefIndex: N"; drop it.
|
||||
cur_class = ln.split("// TypeDefIndex")[0].rstrip()
|
||||
continue
|
||||
# dump.cs method lines end with " { }" (or "; " for abstract); drop it.
|
||||
body = ln.strip()
|
||||
body = body.split("// RVA")[0].rstrip()
|
||||
if body.endswith("{ }"):
|
||||
body = body[:-3].rstrip()
|
||||
elif body.endswith(";"):
|
||||
body = body[:-1].rstrip()
|
||||
for tid, (cls, meth) in wanted.items():
|
||||
if tid in hits:
|
||||
continue
|
||||
# Class header may carry a base/interface list ("X : Base, IFoo").
|
||||
# Match the exact class, or that class followed by " : <bases>",
|
||||
# but never a nested "X.Y" (which continues with "." not " ").
|
||||
spec_class = cls.rstrip()
|
||||
class_ok = cur_class is not None and (
|
||||
cur_class == spec_class or cur_class.startswith(spec_class + " "))
|
||||
if class_ok and body == meth.rstrip():
|
||||
if rva is None:
|
||||
raise SystemExit(f"{tid}: matched signature with no preceding RVA line")
|
||||
hits[tid] = {"rva": rva, "offset": off, "signature": meth,
|
||||
"class": cls.rstrip()}
|
||||
# Bound each body by the next-greater RVA anywhere in the dump: dump.cs
|
||||
# lists methods in declaration order, so the following *line* is not the
|
||||
# following *function* in memory.
|
||||
ordered = sorted(all_rvas)
|
||||
import bisect
|
||||
for h in hits.values():
|
||||
i = bisect.bisect_right(ordered, h["rva"])
|
||||
h["end_rva"] = ordered[i] if i < len(ordered) else None
|
||||
return hits
|
||||
|
||||
|
||||
def objdump_prologue(objdump, il2cpp, rva, count=6):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[objdump, "-d", f"--start-address={rva:#x}",
|
||||
f"--stop-address={rva + count * 4:#x}", str(il2cpp)],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
return f"(objdump failed: {e})"
|
||||
return "\n".join(l for l in out.splitlines() if ":\t" in l)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dump", required=True, type=Path)
|
||||
ap.add_argument("--il2cpp", required=True, type=Path)
|
||||
ap.add_argument("--version-name", required=True)
|
||||
ap.add_argument("--version-code", required=True)
|
||||
ap.add_argument("--objdump")
|
||||
ap.add_argument("--spec", type=Path,
|
||||
default=Path(__file__).resolve().parent.parent / "targets" / "spec.json")
|
||||
ap.add_argument("--out", type=Path)
|
||||
args = ap.parse_args()
|
||||
|
||||
spec = json.loads(args.spec.read_text(encoding="utf-8"))
|
||||
hits = scan_dump(args.dump, spec)
|
||||
|
||||
code = args.il2cpp.read_bytes()
|
||||
missing = [t["id"] for t in spec["targets"] if t["id"] not in hits]
|
||||
if missing:
|
||||
raise SystemExit("targets not found in dump: " + ", ".join(missing))
|
||||
|
||||
entries = {}
|
||||
ok = True
|
||||
for t in spec["targets"]:
|
||||
tid = t["id"]
|
||||
h = hits[tid]
|
||||
rva = h["rva"]
|
||||
# In this ELF Offset == VA == RVA, so the file offset is the RVA.
|
||||
length = (h["end_rva"] - rva) if h["end_rva"] else None
|
||||
fp = fnv1a64(code[rva:rva + HOOK_BYTES])
|
||||
short = length is not None and length < HOOK_BYTES
|
||||
if short:
|
||||
ok = False
|
||||
entries[tid] = {
|
||||
"rva": rva,
|
||||
"length": length,
|
||||
"fingerprint": f"{fp:#018x}",
|
||||
"why": t["why"],
|
||||
}
|
||||
flag = " TOO SHORT" if short else ""
|
||||
print(f"{tid:28s} rva={rva:#010x} len={length}{flag}")
|
||||
if args.objdump:
|
||||
print(objdump_prologue(args.objdump, args.il2cpp, rva))
|
||||
|
||||
table = {
|
||||
"versionName": args.version_name,
|
||||
"versionCode": args.version_code,
|
||||
"il2cppSha256": sha256(args.il2cpp),
|
||||
"hookBytes": HOOK_BYTES,
|
||||
"targets": entries,
|
||||
}
|
||||
out = args.out or (args.spec.parent / f"{args.version_name}-{args.version_code}.json")
|
||||
out.write_text(json.dumps(table, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"\nwrote {out}")
|
||||
if not ok:
|
||||
raise SystemExit("one or more targets are shorter than the hook patch; refuse to build")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
// Runtime config loader: applies an optional on-device cfg over the baked-in
|
||||
// defaults. The cfg format is "Key = Value", one per line, '#' comments and
|
||||
// [section] headers ignored -- the same keys DorkNet.ClientMod uses on PC, so a
|
||||
// PC config reads here too.
|
||||
#include "config.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
|
||||
#define CLOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
|
||||
|
||||
static void trim(char *s) {
|
||||
char *p = s;
|
||||
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
|
||||
if (p != s) memmove(s, p, strlen(p) + 1);
|
||||
size_t n = strlen(s);
|
||||
while (n && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r' || s[n - 1] == '\n'))
|
||||
s[--n] = '\0';
|
||||
}
|
||||
|
||||
static bool as_bool(const char *v, bool dflt) {
|
||||
if (!v || !*v) return dflt;
|
||||
if (!strcasecmp(v, "true") || !strcasecmp(v, "1") || !strcasecmp(v, "yes") || !strcasecmp(v, "on"))
|
||||
return true;
|
||||
if (!strcasecmp(v, "false") || !strcasecmp(v, "0") || !strcasecmp(v, "no") || !strcasecmp(v, "off"))
|
||||
return false;
|
||||
return dflt;
|
||||
}
|
||||
|
||||
// Accept a bare host or a full URL; store just the host.
|
||||
static void set_host(char *out, size_t cap, const char *val) {
|
||||
const char *scheme = strstr(val, "://");
|
||||
const char *host = scheme ? scheme + 3 : val;
|
||||
size_t i = 0;
|
||||
for (; host[i] && host[i] != '/' && i + 1 < cap; i++) out[i] = host[i];
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||
char *oculus_appid, size_t oculus_appid_cap,
|
||||
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||
char *photon_region, size_t photon_region_cap,
|
||||
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
|
||||
bool *log_http, bool *debug_http, int *body_max) {
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "/sdcard/Android/data/%s/files/%s", DN_PACKAGE, DN_RUNTIME_CFG_NAME);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) { CLOGI("no runtime config at %s; using baked-in defaults", path); return; }
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
char *hash = strchr(line, '#');
|
||||
if (hash) *hash = '\0';
|
||||
char *eq = strchr(line, '=');
|
||||
if (!eq) continue;
|
||||
*eq = '\0';
|
||||
char *key = line, *val = eq + 1;
|
||||
trim(key);
|
||||
trim(val);
|
||||
if (!strcasecmp(key, "ServerHost") && *val) set_host(server_host, server_host_cap, val);
|
||||
else if (!strcasecmp(key, "OculusAppId")) {
|
||||
oculus_appid[0] = '\0';
|
||||
strncpy(oculus_appid, val, oculus_appid_cap - 1);
|
||||
}
|
||||
else if (!strcasecmp(key, "PhotonAppId") && *val) strncpy(photon_realtime, val, 63);
|
||||
else if (!strcasecmp(key, "PhotonVoiceAppId") && *val) strncpy(photon_voice, val, 63);
|
||||
else if (!strcasecmp(key, "PhotonChatAppId") && *val) strncpy(photon_chat, val, 63);
|
||||
else if (!strcasecmp(key, "PhotonNameServer")) set_host(photon_server, photon_server_cap, val);
|
||||
else if (!strcasecmp(key, "PhotonNameServerPort")) *photon_port = atoi(val);
|
||||
else if (!strcasecmp(key, "PhotonRegion") && *val) { photon_region[0] = '\0'; strncpy(photon_region, val, photon_region_cap - 1); }
|
||||
else if (!strcasecmp(key, "EnableTlsTrustBypass")) *tls_bypass = as_bool(val, *tls_bypass);
|
||||
else if (!strcasecmp(key, "AcceptUnsignedImages")) *accept_images = as_bool(val, *accept_images);
|
||||
else if (!strcasecmp(key, "AutoCreateAccount")) *auto_create_account = as_bool(val, *auto_create_account);
|
||||
else if (!strcasecmp(key, "LogHttp")) *log_http = as_bool(val, *log_http);
|
||||
else if (!strcasecmp(key, "Debug")) *debug_http = as_bool(val, *debug_http);
|
||||
else if (!strcasecmp(key, "LogHttpBodyMax") && *val) *body_max = atoi(val);
|
||||
}
|
||||
fclose(f);
|
||||
CLOGI("runtime config applied from %s", path);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Compile-time configuration for libdorknet.so.
|
||||
//
|
||||
// scripts/gen_config_header.py turns dorknet-quest.cfg into config_generated.h
|
||||
// (put on the include path by the build), baking the operator's values into the
|
||||
// APK. Any key left out of the cfg falls back to the built-in default below.
|
||||
// The same cfg can also be pushed to
|
||||
// /sdcard/Android/data/<package>/files/dorknet-quest.cfg
|
||||
// to override at runtime without a rebuild (dn_load_runtime_config).
|
||||
#ifndef DORKNET_QUEST_CONFIG_H
|
||||
#define DORKNET_QUEST_CONFIG_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#if defined(__has_include)
|
||||
# if __has_include("config_generated.h")
|
||||
# include "config_generated.h"
|
||||
# endif
|
||||
#endif
|
||||
|
||||
// The apex the client's *.rec.net hosts are rewritten onto. "api.rec.net"
|
||||
// becomes "api.<this>", "auth.rec.net" -> "auth.<this>", etc. No default: a
|
||||
// build with no server configured would do nothing useful.
|
||||
#ifndef DN_SERVER_HOST
|
||||
# define DN_SERVER_HOST ""
|
||||
#endif
|
||||
// Empty = keep the client's own (Rec Room's) Meta app id, which selects the
|
||||
// no-Meta-account build (platform entitlement/login are steered past).
|
||||
#ifndef DN_OCULUS_APPID
|
||||
# define DN_OCULUS_APPID ""
|
||||
#endif
|
||||
// Photon app ids: per-deployment, no default. Empty leaves the client's own
|
||||
// (dead) value in place, so multiplayer will not connect until set.
|
||||
#ifndef DN_PHOTON_REALTIME
|
||||
# define DN_PHOTON_REALTIME ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_VOICE
|
||||
# define DN_PHOTON_VOICE ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_CHAT
|
||||
# define DN_PHOTON_CHAT ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_SERVER
|
||||
# define DN_PHOTON_SERVER "" // "" = Photon Cloud
|
||||
#endif
|
||||
#ifndef DN_PHOTON_PORT
|
||||
# define DN_PHOTON_PORT 0
|
||||
#endif
|
||||
// Photon Cloud fixed region. Must match the PC clients' region (ClientMod
|
||||
// PhotonCloudRegion), or Quest and PC players land on different masters.
|
||||
#ifndef DN_PHOTON_REGION
|
||||
# define DN_PHOTON_REGION "us"
|
||||
#endif
|
||||
#ifndef DN_TLS_TRUST_BYPASS
|
||||
# define DN_TLS_TRUST_BYPASS true // accept a self-hosted TLS chain + skip pinning
|
||||
#endif
|
||||
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
||||
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
||||
#endif
|
||||
// A headset with no remembered account: false opens the sign-in page, true
|
||||
// keeps Rec Room's behaviour of creating a new account automatically.
|
||||
#ifndef DN_AUTO_CREATE_ACCOUNT
|
||||
# define DN_AUTO_CREATE_ACCOUNT false
|
||||
#endif
|
||||
#ifndef DN_LOG_HTTP
|
||||
# define DN_LOG_HTTP true
|
||||
#endif
|
||||
#ifndef DN_DEBUG_HTTP
|
||||
# define DN_DEBUG_HTTP false // also log request/response bodies (incl. tokens!)
|
||||
#endif
|
||||
#ifndef DN_LOG_BODY_MAX
|
||||
# define DN_LOG_BODY_MAX 10000
|
||||
#endif
|
||||
|
||||
#ifndef DN_RUNTIME_CFG_NAME
|
||||
# define DN_RUNTIME_CFG_NAME "dorknet-quest.cfg"
|
||||
#endif
|
||||
// Package the runtime cfg is read from; the build's gen step overrides this.
|
||||
#ifndef DN_PACKAGE
|
||||
# define DN_PACKAGE "net.dorknet.RecRoom"
|
||||
#endif
|
||||
|
||||
// Apply /sdcard/Android/data/<DN_PACKAGE>/files/dorknet-quest.cfg over the
|
||||
// baked defaults. Each out pointer already holds the compiled-in value.
|
||||
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||
char *oculus_appid, size_t oculus_appid_cap,
|
||||
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||
char *photon_region, size_t photon_region_cap,
|
||||
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
|
||||
bool *log_http, bool *debug_http, int *body_max);
|
||||
|
||||
#endif // DORKNET_QUEST_CONFIG_H
|
||||
@@ -0,0 +1,772 @@
|
||||
// DorkNet.Quest native hook library (libdorknet.so).
|
||||
//
|
||||
// Loaded via a DT_NEEDED entry added to libmain.so, so it runs before Unity.
|
||||
// It waits for libil2cpp.so, then installs arm64 inline hooks (And64InlineHook)
|
||||
// at RVAs resolved from the build's dump.cs by scripts/resolve_targets.py and
|
||||
// baked into targets_generated.h at compile time. It is the Quest counterpart
|
||||
// of DorkNet.ClientMod (the PC MelonLoader mod) and does the same jobs:
|
||||
//
|
||||
// * rewrite <service>.rec.net hosts to <service>.<ServerHost> (the ns
|
||||
// response then supplies every other service URL) -- like ClientMod's
|
||||
// UriPatches,
|
||||
// * inject the operator's Photon app ids -- like the PC byte patch
|
||||
// tools/patch-client.ps1,
|
||||
// * optionally set the operator's Meta (Oculus) app id,
|
||||
// * accept the self-hosted TLS chain and the image signature the operator's
|
||||
// server produces -- like ClientMod's TlsPatches / the PC ImageSigning,
|
||||
// * neutralise the integrity callbacks that would reject a re-signed,
|
||||
// hook-loaded client -- like ClientMod's AntiTamper/ToxMod/FileHash patches,
|
||||
// * when no Meta app id is set, steer the Oculus platform path past its
|
||||
// entitlement/login checks to the client's own credential screen.
|
||||
//
|
||||
// Every RVA is checked against a 20-byte FNV-1a fingerprint from the target
|
||||
// table before it is hooked, so the wrong build is refused rather than
|
||||
// miswritten. Obfuscated Assembly-CSharp names are re-randomised per release
|
||||
// but shared with the PC build of the same release; this build (20230317 /
|
||||
// versionCode 2128210) shares its names with the PC 2023-03-21 decompile.
|
||||
//
|
||||
// No Rec Room code or assets are included. Third-party inline hooker in
|
||||
// src/third_party/And64InlineHook (MIT, (c) Rprop).
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "third_party/And64InlineHook/And64InlineHook.hpp"
|
||||
#include "config.h"
|
||||
#include "targets_generated.h"
|
||||
|
||||
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
|
||||
#define LOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(6, "DORKNET", __VA_ARGS__)
|
||||
|
||||
// --------------------------------------------------------------- config -----
|
||||
|
||||
static const char *kOfficialSuffix = ".rec.net";
|
||||
|
||||
static char g_server_host[160] = DN_SERVER_HOST; // <service>.rec.net -> <service>.<this>
|
||||
static char g_oculus_appid[64] = DN_OCULUS_APPID; // "" = keep the client's own
|
||||
static char g_photon_realtime[64] = DN_PHOTON_REALTIME;
|
||||
static char g_photon_voice[64] = DN_PHOTON_VOICE;
|
||||
static char g_photon_chat[64] = DN_PHOTON_CHAT;
|
||||
static char g_photon_server[128] = DN_PHOTON_SERVER; // "" = Photon Cloud
|
||||
static int g_photon_port = DN_PHOTON_PORT;
|
||||
static char g_photon_region[16] = DN_PHOTON_REGION;
|
||||
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
||||
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
|
||||
static bool g_auto_create_account = DN_AUTO_CREATE_ACCOUNT;
|
||||
static bool g_log_http = DN_LOG_HTTP;
|
||||
static bool g_debug_http = DN_DEBUG_HTTP;
|
||||
static int g_body_max = DN_LOG_BODY_MAX;
|
||||
|
||||
// AppSettings field offsets (dump.cs; unchanged across the 2023 builds).
|
||||
static const size_t kAppIdRealtimeOff = 0x10;
|
||||
static const size_t kAppIdChatOff = 0x18;
|
||||
static const size_t kAppIdVoiceOff = 0x20;
|
||||
static const size_t kUseNameServerOff = 0x30; // bool
|
||||
static const size_t kFixedRegionOff = 0x38;
|
||||
static const size_t kServerOff = 0x48;
|
||||
static const size_t kPortOff = 0x50; // int
|
||||
static const size_t kProtocolOff = 0x60; // int (Udp = 0)
|
||||
|
||||
// HTTPRequest / HTTPResponse field offsets (dump.cs).
|
||||
static const size_t kUriFieldOffset = 0x10; // HTTPRequest.<Uri>
|
||||
static const size_t kReqMethodOffset = 0x18; // HTTPRequest.<MethodType> (byte)
|
||||
static const size_t kReqResponseOffset = 0x70; // HTTPRequest.<Response>
|
||||
static const size_t kReqExceptionOffset = 0x80; // HTTPRequest.<Exception>
|
||||
static const size_t kReqStateOffset = 0xB4; // HTTPRequest.<State> (int)
|
||||
static const size_t kRespStatusOffset = 0x18; // HTTPResponse.<StatusCode> (int)
|
||||
static const size_t kRespDataOffset = 0x48; // HTTPResponse.<Data> (byte[])
|
||||
|
||||
// Il2CppArray data layout on 64-bit.
|
||||
static const size_t kArrayLengthOff = 0x18;
|
||||
static const size_t kArrayDataOff = 0x20;
|
||||
|
||||
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
||||
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
||||
static const unsigned kMsgUserGetUserProof = 578880643u;
|
||||
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
|
||||
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
||||
static const unsigned long long kNoPlatformUserId = 1ULL;
|
||||
// Stand-in ovrUserHandle for a GetLoggedInUser that Meta refused. Only our
|
||||
// hooked ovr_User_* accessors ever see it; they never pass it to the SDK.
|
||||
static void *const kPlaceholderUser = (void *)0x2;
|
||||
|
||||
static const size_t kLogChunk = 1500;
|
||||
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
||||
static const size_t kErrorStateCodeOff = 0x4;
|
||||
|
||||
// --------------------------------------------------------------- il2cpp ------
|
||||
|
||||
typedef void *(*il2cpp_object_get_class_t)(void *obj);
|
||||
typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *name, int argc);
|
||||
typedef void *(*il2cpp_object_new_t)(void *klass);
|
||||
typedef void *(*il2cpp_string_new_t)(const char *str);
|
||||
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
|
||||
typedef void *(*il2cpp_domain_get_t)();
|
||||
typedef void **(*il2cpp_domain_get_assemblies_t)(void *domain, size_t *size);
|
||||
typedef void *(*il2cpp_assembly_get_image_t)(void *assembly);
|
||||
typedef const char *(*il2cpp_image_get_name_t)(void *image);
|
||||
typedef void *(*il2cpp_class_from_name_t)(void *image, const char *ns, const char *name);
|
||||
|
||||
static il2cpp_object_get_class_t il2cpp_object_get_class;
|
||||
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
||||
static il2cpp_object_new_t il2cpp_object_new;
|
||||
static il2cpp_string_new_t il2cpp_string_new;
|
||||
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
|
||||
static il2cpp_domain_get_t il2cpp_domain_get;
|
||||
static il2cpp_domain_get_assemblies_t il2cpp_domain_get_assemblies;
|
||||
static il2cpp_assembly_get_image_t il2cpp_assembly_get_image;
|
||||
static il2cpp_image_get_name_t il2cpp_image_get_name;
|
||||
static il2cpp_class_from_name_t il2cpp_class_from_name;
|
||||
|
||||
static uintptr_t g_il2cpp_base;
|
||||
|
||||
// Il2CppString: [klass][monitor][int32 length][char16 chars...]
|
||||
static void str_to_utf8(void *s, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!s || cap == 0) return;
|
||||
int32_t len = *(int32_t *)((char *)s + 0x10);
|
||||
if (len < 0) return;
|
||||
const uint16_t *chars = (const uint16_t *)((char *)s + 0x14);
|
||||
size_t i = 0;
|
||||
for (; i < (size_t)len && i + 1 < cap; i++)
|
||||
out[i] = chars[i] < 0x80 ? (char)chars[i] : '?';
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *len) {
|
||||
*len = 0;
|
||||
if (!obj) return nullptr;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return nullptr;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return nullptr;
|
||||
void *exc = nullptr;
|
||||
void *arr = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
if (!arr || exc) return nullptr;
|
||||
*len = (size_t) * (uint64_t *)((char *)arr + kArrayLengthOff);
|
||||
return (const unsigned char *)((char *)arr + kArrayDataOff);
|
||||
}
|
||||
|
||||
static bool invoke_void(void *obj, const char *method) {
|
||||
if (!obj) return false;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return false;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return false;
|
||||
void *exc = nullptr;
|
||||
il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
return !exc;
|
||||
}
|
||||
|
||||
static void *find_class(const char *image_name, const char *ns, const char *name) {
|
||||
if (!il2cpp_domain_get || !il2cpp_domain_get_assemblies || !il2cpp_assembly_get_image ||
|
||||
!il2cpp_image_get_name || !il2cpp_class_from_name) return nullptr;
|
||||
size_t n = 0;
|
||||
void **assemblies = il2cpp_domain_get_assemblies(il2cpp_domain_get(), &n);
|
||||
for (size_t i = 0; assemblies && i < n; i++) {
|
||||
void *image = il2cpp_assembly_get_image(assemblies[i]);
|
||||
const char *iname = image ? il2cpp_image_get_name(image) : nullptr;
|
||||
if (iname && !strcmp(iname, image_name)) return il2cpp_class_from_name(image, ns, name);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!obj) return;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return;
|
||||
void *exc = nullptr;
|
||||
void *s = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
if (s && !exc) str_to_utf8(s, out, cap);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- logging ----
|
||||
|
||||
static void log_chunked(const char *prefix, const char *text) {
|
||||
size_t n = strlen(text);
|
||||
if (n <= kLogChunk) { LOGI("%s%s", prefix, text); return; }
|
||||
char buf[kLogChunk + 1];
|
||||
for (size_t off = 0, part = 1; off < n; off += kLogChunk, part++) {
|
||||
size_t take = n - off < kLogChunk ? n - off : kLogChunk;
|
||||
memcpy(buf, text + off, take);
|
||||
buf[take] = '\0';
|
||||
LOGI("%s[%zu] %s", prefix, part, buf);
|
||||
}
|
||||
}
|
||||
|
||||
static bool looks_binary(const unsigned char *d, size_t n) {
|
||||
if (n == 0) return false;
|
||||
size_t sample = n < 4096 ? n : 4096, nontext = 0;
|
||||
for (size_t i = 0; i < sample; i++) {
|
||||
unsigned char b = d[i];
|
||||
if (b == 0) return true;
|
||||
if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nontext++;
|
||||
}
|
||||
return nontext * 100 / sample > 10;
|
||||
}
|
||||
|
||||
static void format_body(const unsigned char *d, size_t n, char *out, size_t cap) {
|
||||
if (!d || n == 0) { snprintf(out, cap, "<empty>"); return; }
|
||||
size_t limit = (size_t)g_body_max;
|
||||
if (limit > cap - 64) limit = cap - 64;
|
||||
if (!looks_binary(d, n)) {
|
||||
size_t take = n < limit ? n : limit;
|
||||
memcpy(out, d, take);
|
||||
out[take] = '\0';
|
||||
if (take < n) snprintf(out + take, cap - take, "... <truncated %zu bytes>", n - take);
|
||||
return;
|
||||
}
|
||||
int w = snprintf(out, cap, "<binary %zu bytes>", n);
|
||||
out[w < 0 ? 0 : (size_t)w] = '\0';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- hooks -------
|
||||
|
||||
static void *(*orig_SendRequest)(void *request);
|
||||
|
||||
static void *hk_SendRequest(void *request) {
|
||||
if (!request) return orig_SendRequest(request);
|
||||
void *uri = *(void **)((char *)request + kUriFieldOffset);
|
||||
if (!uri) return orig_SendRequest(request);
|
||||
void *uri_class = il2cpp_object_get_class(uri);
|
||||
if (!uri_class) return orig_SendRequest(request);
|
||||
|
||||
void *m_abs = il2cpp_class_get_method_from_name(uri_class, "get_AbsoluteUri", 0);
|
||||
if (!m_abs) return orig_SendRequest(request);
|
||||
void *exc = nullptr;
|
||||
void *s = il2cpp_runtime_invoke(m_abs, uri, nullptr, &exc);
|
||||
if (!s || exc) return orig_SendRequest(request);
|
||||
|
||||
char url[2048];
|
||||
str_to_utf8(s, url, sizeof(url));
|
||||
|
||||
if (g_log_http) {
|
||||
int method = *(unsigned char *)((char *)request + kReqMethodOffset);
|
||||
static const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE",
|
||||
"PATCH", "MERGE", "OPTIONS", "CONNECT"};
|
||||
const char *mname = (method >= 0 && method < 9) ? methods[method] : "?";
|
||||
LOGI("[http] %s %s", mname, url);
|
||||
if (g_debug_http) {
|
||||
size_t blen = 0;
|
||||
const unsigned char *body = invoke_bytes(request, "GetEntityBody", &blen);
|
||||
if (body && blen) {
|
||||
size_t cap = (size_t)g_body_max + 128;
|
||||
char *text = (char *)malloc(cap);
|
||||
if (text) { format_body(body, blen, text, cap); log_chunked("[http] body=", text); free(text); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite any "<service>.rec.net" host to "<service>.<ServerHost>".
|
||||
const char *scheme_end = strstr(url, "://");
|
||||
if (!scheme_end) return orig_SendRequest(request);
|
||||
const char *host = scheme_end + 3;
|
||||
const char *host_end = host;
|
||||
while (*host_end && *host_end != '/' && *host_end != ':' && *host_end != '?') host_end++;
|
||||
size_t host_len = (size_t)(host_end - host);
|
||||
size_t suf = strlen(kOfficialSuffix);
|
||||
if (host_len <= suf || strncasecmp(host_end - suf, kOfficialSuffix, suf) != 0)
|
||||
return orig_SendRequest(request);
|
||||
|
||||
char newurl[1024];
|
||||
size_t head = (size_t)(host - url); // "https://"
|
||||
size_t service_len = host_len - suf; // "api", "auth", ...
|
||||
// ServerHost is either a plain apex ("example.com" -> "api.example.com") or a
|
||||
// template with "{service}" for servers on DorkNet's hyphen host style
|
||||
// ("{service}-dev.example.com" -> "ns-dev.example.com").
|
||||
static const char kServiceToken[] = "{service}";
|
||||
const char *token = strstr(g_server_host, kServiceToken);
|
||||
size_t new_host_len = token ? strlen(g_server_host) - (sizeof(kServiceToken) - 1) + service_len
|
||||
: service_len + 1 + strlen(g_server_host);
|
||||
if (head + new_host_len + strlen(host_end) + 1 >= sizeof(newurl))
|
||||
return orig_SendRequest(request);
|
||||
memcpy(newurl, url, head);
|
||||
size_t p = head;
|
||||
if (token) {
|
||||
size_t before = (size_t)(token - g_server_host);
|
||||
memcpy(newurl + p, g_server_host, before);
|
||||
p += before;
|
||||
memcpy(newurl + p, host, service_len);
|
||||
p += service_len;
|
||||
strcpy(newurl + p, token + sizeof(kServiceToken) - 1);
|
||||
} else {
|
||||
memcpy(newurl + p, host, service_len);
|
||||
p += service_len;
|
||||
newurl[p++] = '.';
|
||||
strcpy(newurl + p, g_server_host);
|
||||
}
|
||||
strcat(newurl + head, host_end);
|
||||
|
||||
void *new_str = il2cpp_string_new(newurl);
|
||||
void *new_uri = il2cpp_object_new(uri_class);
|
||||
void *ctor = il2cpp_class_get_method_from_name(uri_class, ".ctor", 1);
|
||||
if (!new_str || !new_uri || !ctor) return orig_SendRequest(request);
|
||||
void *args[1] = {new_str};
|
||||
exc = nullptr;
|
||||
il2cpp_runtime_invoke(ctor, new_uri, args, &exc);
|
||||
if (exc) return orig_SendRequest(request);
|
||||
*(void **)((char *)request + kUriFieldOffset) = new_uri;
|
||||
LOGI("intercepted %s -> %s", url, newurl);
|
||||
return orig_SendRequest(request);
|
||||
}
|
||||
|
||||
static void (*orig_CallCallback)(void *thiz, void *method);
|
||||
static void hk_CallCallback(void *thiz, void *method) {
|
||||
if (!thiz || !g_log_http) { orig_CallCallback(thiz, method); return; }
|
||||
char url[2048];
|
||||
invoke_str(*(void **)((char *)thiz + kUriFieldOffset), "get_AbsoluteUri", url, sizeof(url));
|
||||
int state = *(int *)((char *)thiz + kReqStateOffset);
|
||||
void *resp = *(void **)((char *)thiz + kReqResponseOffset);
|
||||
if (!resp) {
|
||||
void *ex = *(void **)((char *)thiz + kReqExceptionOffset);
|
||||
if (ex) {
|
||||
char msg[900];
|
||||
invoke_str(ex, "ToString", msg, sizeof(msg));
|
||||
LOGI("[http<-] NO-RESPONSE state=%d %s | %s", state, url, msg);
|
||||
} else {
|
||||
LOGI("[http<-] NO-RESPONSE state=%d %s | (no exception)", state, url);
|
||||
}
|
||||
} else {
|
||||
int status = *(int *)((char *)resp + kRespStatusOffset);
|
||||
LOGI("[http<-] %d %s", status, url);
|
||||
if (g_debug_http || status < 200 || status >= 300) {
|
||||
size_t blen = 0;
|
||||
const unsigned char *data = invoke_bytes(resp, "get_Data", &blen);
|
||||
size_t cap = (size_t)g_body_max + 128;
|
||||
char *text = (char *)malloc(cap);
|
||||
if (text) { format_body(data, blen, text, cap); log_chunked("[http<-] body=", text); free(text); }
|
||||
}
|
||||
}
|
||||
orig_CallCallback(thiz, method);
|
||||
}
|
||||
|
||||
static void *hk_get_AppID(void *method) {
|
||||
(void)method;
|
||||
return il2cpp_string_new(g_oculus_appid);
|
||||
}
|
||||
|
||||
static void *(*orig_Photon)(void *thiz, void *method);
|
||||
static void *hk_Photon(void *thiz, void *method) {
|
||||
void *s = orig_Photon(thiz, method);
|
||||
if (s) {
|
||||
if (g_photon_realtime[0]) *(void **)((char *)s + kAppIdRealtimeOff) = il2cpp_string_new(g_photon_realtime);
|
||||
if (g_photon_chat[0]) *(void **)((char *)s + kAppIdChatOff) = il2cpp_string_new(g_photon_chat);
|
||||
if (g_photon_voice[0]) *(void **)((char *)s + kAppIdVoiceOff) = il2cpp_string_new(g_photon_voice);
|
||||
*(unsigned char *)((char *)s + kUseNameServerOff) = 1;
|
||||
*(void **)((char *)s + kFixedRegionOff) = il2cpp_string_new(g_photon_region);
|
||||
*(int *)((char *)s + kProtocolOff) = 0;
|
||||
if (g_photon_server[0]) {
|
||||
*(void **)((char *)s + kServerOff) = il2cpp_string_new(g_photon_server);
|
||||
*(int *)((char *)s + kPortOff) = g_photon_port ? g_photon_port : 4533;
|
||||
}
|
||||
static int logged = 0;
|
||||
if (logged < 2) {
|
||||
logged++;
|
||||
LOGI("photon appsettings: RT=%s Voice=%s Chat=%s region=%s server=%s port=%d",
|
||||
g_photon_realtime[0] ? g_photon_realtime : "(unset)",
|
||||
g_photon_voice[0] ? g_photon_voice : "(unset)",
|
||||
g_photon_chat[0] ? g_photon_chat : "(unset)", g_photon_region,
|
||||
g_photon_server[0] ? g_photon_server : "(cloud)", g_photon_port);
|
||||
if (!g_photon_realtime[0])
|
||||
LOGE("photon app ids are not configured; multiplayer will try Rec Room's dead apps");
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static uint32_t (*orig_UnityTls)(void *thiz, uint64_t chain, void *es, void *method);
|
||||
static uint32_t hk_UnityTls(void *thiz, uint64_t chain, void *es, void *method) {
|
||||
uint32_t r = orig_UnityTls(thiz, chain, es, method);
|
||||
if (r != kUnityTlsSuccess) {
|
||||
static int logged = 0;
|
||||
if (logged < 3) { logged++; LOGI("unitytls verify: forcing 0x%x -> SUCCESS", r); }
|
||||
if (es) *(uint32_t *)((char *)es + kErrorStateCodeOff) = kUnityTlsSuccess;
|
||||
}
|
||||
return kUnityTlsSuccess;
|
||||
}
|
||||
|
||||
// void NotifyServerCertificate(Certificate): a no-op accepts the chain.
|
||||
static void hk_NotifyServerCertificate(void *thiz, void *cert, void *method) {
|
||||
(void)thiz; (void)cert; (void)method;
|
||||
}
|
||||
|
||||
// bool pin verifiers: accept unconditionally (replace-only, so no mismatch
|
||||
// report is ever built and the client never quits).
|
||||
static bool hk_pin_true4(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||
static bool hk_pin_true4b(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||
|
||||
static bool hk_image_true(void *data, void *sig, void *method) {
|
||||
(void)data; (void)sig; (void)method;
|
||||
return true;
|
||||
}
|
||||
|
||||
// void callbacks the client runs on an integrity rejection -- no-op them so a
|
||||
// re-signed, hook-loaded sideload is not warned/quit (as DorkNet.ClientMod does
|
||||
// on PC). The quit funnels and the report are left to run but are logged.
|
||||
static void hk_void0(void *thiz, void *method) { (void)thiz; (void)method; }
|
||||
static void hk_void1(void *thiz, void *arg, void *method) { (void)thiz; (void)arg; (void)method; }
|
||||
|
||||
static bool hk_toxmod_false(void *method) { (void)method; return false; }
|
||||
|
||||
static const char *cheat_reason(int r) {
|
||||
switch (r) {
|
||||
case 0: return "Obscured"; case 1: return "Time"; case 2: return "Inject";
|
||||
case 3: return "GiftCount"; case 4: return "Engine"; case 5: return "UnknownDll";
|
||||
case 6: return "ImageSignature"; case 7: return "AvatarHack";
|
||||
case 100: return "NetworkCertificatePublicKey"; case 101: return "NetworkCertificateIssuer";
|
||||
case 102: return "NetworkCertificateMissing"; case 103: return "NetworkCertificateMismatch";
|
||||
case 150: return "AutosaveChecksumMismatch"; case 151: return "AutosaveSubRoomIdMismatch";
|
||||
case 152: return "AutosaveChecksumException"; case 200: return "Photon_MissingHash";
|
||||
case 201: return "Photon_CorruptHash"; case 203: return "Photon_DifferentHash";
|
||||
case 300: return "AppData_Runtime_LengthMismatch"; case 301: return "AppData_Runtime_LastWriteTimeMismatch";
|
||||
case 302: return "AppData_Runtime_FileModified"; case 310: return "AppData_Boot_InvalidSignature";
|
||||
case 311: return "AppData_Boot_UnableToVerifySignatures"; case 320: return "Config_MissingHash";
|
||||
case 321: return "Config_DifferentHash"; case 400: return "Photon_InstantiateTool";
|
||||
case 500: return "Memory_Hash_Mismatch"; case 600: return "Driver_Invalid_Signature";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static void log_caller(const char *what, void *ret) {
|
||||
uintptr_t a = (uintptr_t)ret;
|
||||
if (g_il2cpp_base && a > g_il2cpp_base)
|
||||
LOGI("[caller] %s <- libil2cpp rva 0x%lx", what, (unsigned long)(a - g_il2cpp_base));
|
||||
else
|
||||
LOGI("[caller] %s <- %p", what, ret);
|
||||
}
|
||||
|
||||
static void *(*orig_CheatReport)(int reason, void *msg, uint64_t code, void *method);
|
||||
static void *hk_CheatReport(int reason, void *msg, uint64_t code, void *method) {
|
||||
char text[512];
|
||||
str_to_utf8(msg, text, sizeof(text));
|
||||
LOGI("[integrity] report reason=%d (%s) msg=\"%s\"", reason, cheat_reason(reason), text);
|
||||
log_caller("report", __builtin_return_address(0));
|
||||
return orig_CheatReport(reason, msg, code, method);
|
||||
}
|
||||
|
||||
static void (*orig_FatalQuit)(int code, void *reason, void *method);
|
||||
static void hk_FatalQuit(int code, void *reason, void *method) {
|
||||
char msg[512];
|
||||
str_to_utf8(reason, msg, sizeof(msg));
|
||||
LOGI("[exit] FatalApplicationQuit(%d, \"%s\")", code, msg);
|
||||
log_caller("fatal quit", __builtin_return_address(0));
|
||||
orig_FatalQuit(code, reason, method);
|
||||
}
|
||||
|
||||
static void (*orig_TryQuit)(int code, void *method);
|
||||
static void hk_TryQuit(int code, void *method) {
|
||||
LOGI("[exit] TryApplicationQuit(%d)", code);
|
||||
log_caller("try quit", __builtin_return_address(0));
|
||||
orig_TryQuit(code, method);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- oculus platform -----
|
||||
|
||||
typedef bool (*ovr_IsError_t)(void *m);
|
||||
typedef unsigned (*ovr_GetType_t)(void *m);
|
||||
static ovr_IsError_t orig_ovr_IsError;
|
||||
static ovr_GetType_t ovr_GetType;
|
||||
|
||||
static bool is_bypassed_type(unsigned t) {
|
||||
return t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof ||
|
||||
t == kMsgUserGetLoggedInUser;
|
||||
}
|
||||
|
||||
// The SDK's Message constructor checks IsError twice: on the message, and --
|
||||
// when that passes -- on ovr_Message_GetNativeMessage(message). The inner
|
||||
// message carries the platform's refusal too but doesn't report the outer
|
||||
// type, so remember the inner handle of a bypassed message (same thread, the
|
||||
// constructor asks about it immediately) and pass it as well.
|
||||
static __thread void *t_bypassed_native;
|
||||
static __thread unsigned t_bypassed_type;
|
||||
|
||||
static bool hk_ovr_IsError(void *m) {
|
||||
bool real = orig_ovr_IsError(m);
|
||||
if (!real) return real;
|
||||
if (m && m == t_bypassed_native) return false;
|
||||
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
|
||||
return real;
|
||||
}
|
||||
|
||||
typedef void *(*ovr_GetNativeMessage_t)(void *m);
|
||||
static ovr_GetNativeMessage_t orig_ovr_GetNativeMessage;
|
||||
static void *hk_ovr_GetNativeMessage(void *m) {
|
||||
void *native = orig_ovr_GetNativeMessage(m);
|
||||
unsigned t = ovr_GetType ? ovr_GetType(m) : 0;
|
||||
t_bypassed_native = is_bypassed_type(t) ? native : nullptr;
|
||||
t_bypassed_type = t;
|
||||
return native;
|
||||
}
|
||||
|
||||
// OculusPlatformManager.LoadUser asks for the logged-in user; on a headset
|
||||
// that reaches Meta, Meta refuses it for this re-packaged app, LoadUser
|
||||
// rejects ("GetLoggedInUser failed") and Initialize shows "Unable to load
|
||||
// Oculus user account". A headset that can't reach Meta gets a cached user
|
||||
// and never hits this. Answer a refused request with a placeholder user whose
|
||||
// ID is kNoPlatformUserId -- the same placeholder the server already treats
|
||||
// as "no platform account" (PlayerService.IsPlaceholderPlatformId).
|
||||
static void *(*orig_ovr_Message_GetUser)(void *m);
|
||||
static void *hk_ovr_Message_GetUser(void *m) {
|
||||
void *u = orig_ovr_Message_GetUser ? orig_ovr_Message_GetUser(m) : nullptr;
|
||||
if (u) return u;
|
||||
// The SDK reads the user from the inner native message
|
||||
// (GetUser(GetNativeMessage(msg))), so match that handle as well.
|
||||
bool logged_in_user = (m && m == t_bypassed_native && t_bypassed_type == kMsgUserGetLoggedInUser) ||
|
||||
(ovr_GetType && ovr_GetType(m) == kMsgUserGetLoggedInUser);
|
||||
if (!logged_in_user) return u;
|
||||
LOGI("GetLoggedInUser refused by the platform; using the no-platform placeholder user");
|
||||
return kPlaceholderUser;
|
||||
}
|
||||
|
||||
// Accessors the SDK's User model reads. Real users pass straight through; the
|
||||
// placeholder gets empty strings and zero values.
|
||||
#define DN_USER_STR_ACCESSOR(name) static const char *(*orig_##name)(void *u); static const char *hk_##name(void *u) { if (u == kPlaceholderUser) return ""; return orig_##name ? orig_##name(u) : ""; }
|
||||
#define DN_USER_VAL_ACCESSOR(name) static uintptr_t (*orig_##name)(void *u); static uintptr_t hk_##name(void *u) { if (u == kPlaceholderUser) return 0; return orig_##name ? orig_##name(u) : 0; }
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetDisplayName)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetOculusID)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetImageUrl)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetSmallImageUrl)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetInviteToken)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresence)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDeeplinkMessage)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDestinationApiName)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceLobbySessionId)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceMatchSessionId)
|
||||
DN_USER_VAL_ACCESSOR(ovr_User_GetPresenceStatus) // ovrUserPresenceStatus: 0 = Unknown
|
||||
|
||||
static void *(*orig_ovr_GetUserProof)(void *m);
|
||||
static void *hk_ovr_GetUserProof(void *m) {
|
||||
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
||||
return p ? p : (void *)0x1;
|
||||
}
|
||||
|
||||
static const char *(*orig_ovr_GetNonce)(void *proof);
|
||||
static const char *hk_ovr_GetNonce(void *proof) {
|
||||
if (proof && proof != (void *)0x1 && orig_ovr_GetNonce) {
|
||||
const char *n = orig_ovr_GetNonce(proof);
|
||||
if (n && n[0]) return n;
|
||||
}
|
||||
return kPlaceholderNonce;
|
||||
}
|
||||
|
||||
static unsigned long long (*orig_ovr_GetLoggedInUserID)();
|
||||
static unsigned long long hk_ovr_GetLoggedInUserID() {
|
||||
unsigned long long real = orig_ovr_GetLoggedInUserID ? orig_ovr_GetLoggedInUserID() : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
||||
static unsigned long long hk_ovr_User_GetID(void *u) {
|
||||
if (u == kPlaceholderUser) return kNoPlatformUserId;
|
||||
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ title screen --
|
||||
|
||||
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time auto-login:
|
||||
// the title screen downloads the headset's remembered accounts and logs into
|
||||
// one; with none (null) it creates a brand-new account instead
|
||||
// (PJMGFADOEEM -> grant_type create_account). A server with sign-ups off
|
||||
// refuses that, the error handler shows the connection-error page and the
|
||||
// flow tries again, forever. Unless AutoCreateAccount is on, open the sign-in
|
||||
// page instead (TitleScreenFlowModel.GoToLogin, what the "Log in" button
|
||||
// does). ManuallyLogin carries the player into the game from there on its own,
|
||||
// so the boot step gets a promise that never settles.
|
||||
static void *(*orig_TitleAutoLogin)(void *thiz, void *cached, void *method);
|
||||
static void *new_pending_promise() {
|
||||
// MAMJDBJDFAF: the game's non-generic promise (RecRoom.Promises.Runtime);
|
||||
// a fresh one stays pending.
|
||||
static void *cls = find_class("RecRoom.Promises.Runtime.dll", "", "MAMJDBJDFAF");
|
||||
if (!cls) return nullptr;
|
||||
void *ctor = il2cpp_class_get_method_from_name(cls, ".ctor", 0);
|
||||
if (!ctor) return nullptr;
|
||||
void *p = il2cpp_object_new(cls);
|
||||
void *exc = nullptr;
|
||||
if (p) il2cpp_runtime_invoke(ctor, p, nullptr, &exc);
|
||||
return exc ? nullptr : p;
|
||||
}
|
||||
static void *hk_TitleAutoLogin(void *thiz, void *cached, void *method) {
|
||||
if (cached || g_auto_create_account) return orig_TitleAutoLogin(thiz, cached, method);
|
||||
void *pending = new_pending_promise();
|
||||
if (!pending || !invoke_void(thiz, "GoToLogin")) {
|
||||
LOGE("no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
|
||||
return orig_TitleAutoLogin(thiz, cached, method);
|
||||
}
|
||||
LOGI("no remembered account on this headset; opened the sign-in page instead of creating a new account");
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- init ------
|
||||
|
||||
static uintptr_t module_base(const char *name) {
|
||||
FILE *f = fopen("/proc/self/maps", "r");
|
||||
if (!f) return 0;
|
||||
char line[512];
|
||||
uintptr_t base = 0;
|
||||
while (fgets(line, sizeof(line), f))
|
||||
if (strstr(line, name)) { base = (uintptr_t)strtoull(line, nullptr, 16); break; }
|
||||
fclose(f);
|
||||
return base;
|
||||
}
|
||||
|
||||
static uint64_t fnv1a(const unsigned char *d, size_t n) {
|
||||
uint64_t h = 0xCBF29CE484222325ULL;
|
||||
for (size_t i = 0; i < n; i++) { h ^= d[i]; h *= 0x100000001B3ULL; }
|
||||
return h;
|
||||
}
|
||||
|
||||
// Arm a target by id: verify the 20-byte fingerprint, then install the hook.
|
||||
static bool arm(const DnTarget *t, void *hook, void **orig) {
|
||||
void *addr = (void *)(g_il2cpp_base + t->rva);
|
||||
uint64_t fp = fnv1a((const unsigned char *)addr, DN_HOOK_BYTES);
|
||||
if (fp != t->fingerprint) {
|
||||
LOGE("REFUSING %s: rva 0x%lx fingerprint 0x%llx != expected 0x%llx (wrong build?)",
|
||||
t->id, (unsigned long)t->rva, (unsigned long long)fp,
|
||||
(unsigned long long)t->fingerprint);
|
||||
return false;
|
||||
}
|
||||
void *dummy = nullptr;
|
||||
A64HookFunction(addr, hook, orig ? orig : &dummy);
|
||||
LOGI("armed %s @ rva 0x%lx", t->id, (unsigned long)t->rva);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool resolve_il2cpp(void *h) {
|
||||
#define SYM(n) n = (n##_t)dlsym(h, #n); if (!n) { LOGE("dlsym " #n " failed"); return false; }
|
||||
SYM(il2cpp_object_get_class)
|
||||
SYM(il2cpp_class_get_method_from_name)
|
||||
SYM(il2cpp_object_new)
|
||||
SYM(il2cpp_string_new)
|
||||
SYM(il2cpp_runtime_invoke)
|
||||
#undef SYM
|
||||
// Optional: only the title-screen sign-in redirect needs these.
|
||||
il2cpp_domain_get = (il2cpp_domain_get_t)dlsym(h, "il2cpp_domain_get");
|
||||
il2cpp_domain_get_assemblies = (il2cpp_domain_get_assemblies_t)dlsym(h, "il2cpp_domain_get_assemblies");
|
||||
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_t)dlsym(h, "il2cpp_assembly_get_image");
|
||||
il2cpp_image_get_name = (il2cpp_image_get_name_t)dlsym(h, "il2cpp_image_get_name");
|
||||
il2cpp_class_from_name = (il2cpp_class_from_name_t)dlsym(h, "il2cpp_class_from_name");
|
||||
return true;
|
||||
}
|
||||
|
||||
static void arm_oculus_platform_bypass() {
|
||||
void *ovr = nullptr;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
ovr = dlopen("libovrplatformloader.so", RTLD_NOLOAD | RTLD_NOW);
|
||||
if (ovr) break;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
if (!ovr) { LOGE("libovrplatformloader.so never appeared; platform bypass not armed"); return; }
|
||||
ovr_GetType = (ovr_GetType_t)dlsym(ovr, "ovr_Message_GetType");
|
||||
void *isErr = dlsym(ovr, "ovr_Message_IsError");
|
||||
if (ovr_GetType && isErr) {
|
||||
A64HookFunction(isErr, (void *)hk_ovr_IsError, (void **)&orig_ovr_IsError);
|
||||
LOGI("platform entitlement/proof bypass armed (no Meta app id configured)");
|
||||
} else {
|
||||
LOGE("ovr symbols missing; platform bypass not armed");
|
||||
return;
|
||||
}
|
||||
void *a;
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetNativeMessage")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNativeMessage, (void **)&orig_ovr_GetNativeMessage);
|
||||
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
||||
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_User_GetID, (void **)&orig_ovr_User_GetID);
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetUserProof")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
||||
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetUser")))
|
||||
A64HookFunction(a, (void *)hk_ovr_Message_GetUser, (void **)&orig_ovr_Message_GetUser);
|
||||
#define DN_HOOK_USER(name) if ((a = dlsym(ovr, #name))) A64HookFunction(a, (void *)hk_##name, (void **)&orig_##name);
|
||||
DN_HOOK_USER(ovr_User_GetDisplayName)
|
||||
DN_HOOK_USER(ovr_User_GetOculusID)
|
||||
DN_HOOK_USER(ovr_User_GetImageUrl)
|
||||
DN_HOOK_USER(ovr_User_GetSmallImageUrl)
|
||||
DN_HOOK_USER(ovr_User_GetInviteToken)
|
||||
DN_HOOK_USER(ovr_User_GetPresence)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceDeeplinkMessage)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceDestinationApiName)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceLobbySessionId)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceMatchSessionId)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceStatus)
|
||||
#undef DN_HOOK_USER
|
||||
}
|
||||
|
||||
static void *init_thread(void *) {
|
||||
void *h = nullptr;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
h = dlopen("libil2cpp.so", RTLD_NOLOAD | RTLD_NOW);
|
||||
if (h) break;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
if (!h) { LOGE("libil2cpp.so never appeared; aborting"); return nullptr; }
|
||||
g_il2cpp_base = module_base("libil2cpp.so");
|
||||
if (!g_il2cpp_base) { LOGE("could not find libil2cpp.so base"); return nullptr; }
|
||||
if (!resolve_il2cpp(h)) return nullptr;
|
||||
dn_load_runtime_config(g_server_host, sizeof(g_server_host), g_oculus_appid, sizeof(g_oculus_appid),
|
||||
g_photon_realtime, g_photon_voice, g_photon_chat,
|
||||
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
||||
g_photon_region, sizeof(g_photon_region),
|
||||
&g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
|
||||
|
||||
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
|
||||
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
|
||||
|
||||
arm(&dn_targets.http_send_request, (void *)hk_SendRequest, (void **)&orig_SendRequest);
|
||||
if (g_log_http)
|
||||
arm(&dn_targets.http_call_callback, (void *)hk_CallCallback, (void **)&orig_CallCallback);
|
||||
arm(&dn_targets.photon_app_settings, (void *)hk_Photon, (void **)&orig_Photon);
|
||||
|
||||
if (g_oculus_appid[0]) {
|
||||
arm(&dn_targets.oculus_get_app_id, (void *)hk_get_AppID, nullptr);
|
||||
arm(&dn_targets.oculus_get_mobile_app_id, (void *)hk_get_AppID, nullptr);
|
||||
LOGI("oculus app id override: %s", g_oculus_appid);
|
||||
}
|
||||
|
||||
if (g_tls_bypass) {
|
||||
arm(&dn_targets.tls_unity_verify, (void *)hk_UnityTls, (void **)&orig_UnityTls);
|
||||
arm(&dn_targets.tls_bc_notify_server_certificate, (void *)hk_NotifyServerCertificate, nullptr);
|
||||
arm(&dn_targets.pin_nameserver, (void *)hk_pin_true4, nullptr);
|
||||
arm(&dn_targets.pin_https, (void *)hk_pin_true4b, nullptr);
|
||||
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
||||
}
|
||||
|
||||
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
|
||||
|
||||
if (g_accept_images)
|
||||
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
||||
|
||||
// Integrity callbacks that would reject a re-signed, hook-loaded client.
|
||||
arm(&dn_targets.hile_warning_callback, (void *)hk_void1, nullptr);
|
||||
arm(&dn_targets.unknown_dll_callback, (void *)hk_void0, nullptr);
|
||||
arm(&dn_targets.file_hash_callback, (void *)hk_void1, nullptr);
|
||||
arm(&dn_targets.toxmod_can_initialize, (void *)hk_toxmod_false, nullptr);
|
||||
|
||||
// Log-only diagnostics.
|
||||
arm(&dn_targets.cheat_report, (void *)hk_CheatReport, (void **)&orig_CheatReport);
|
||||
arm(&dn_targets.fatal_application_quit, (void *)hk_FatalQuit, (void **)&orig_FatalQuit);
|
||||
arm(&dn_targets.try_application_quit, (void *)hk_TryQuit, (void **)&orig_TryQuit);
|
||||
|
||||
if (!g_oculus_appid[0]) arm_oculus_platform_bypass();
|
||||
LOGI("DorkNet.Quest init complete");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void on_load() {
|
||||
LOGI("DorkNet.Quest hooks loaded (build " __DATE__ " " __TIME__ ")");
|
||||
pthread_t t;
|
||||
pthread_create(&t, nullptr, init_thread, nullptr);
|
||||
pthread_detach(t);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
/*
|
||||
* @date : 2018/04/18
|
||||
* @author : Rprop (r_prop@outlook.com)
|
||||
* https://github.com/Rprop/And64InlineHook
|
||||
*/
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#define __STDC_FORMAT_MACROS
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <sys/mman.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#if defined(__aarch64__)
|
||||
|
||||
#include "And64InlineHook.hpp"
|
||||
#define A64_MAX_INSTRUCTIONS 5
|
||||
#define A64_MAX_REFERENCES (A64_MAX_INSTRUCTIONS * 2)
|
||||
#define A64_NOP 0xd503201fu
|
||||
#define A64_JNIEXPORT __attribute__((visibility("default")))
|
||||
#define A64_LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "A64_HOOK", __VA_ARGS__))
|
||||
#ifndef NDEBUG
|
||||
# define A64_LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "A64_HOOK", __VA_ARGS__))
|
||||
#else
|
||||
# define A64_LOGI(...) ((void)0)
|
||||
#endif // NDEBUG
|
||||
typedef uint32_t *__restrict *__restrict instruction;
|
||||
struct context
|
||||
{
|
||||
struct fix_info
|
||||
{
|
||||
uint32_t *bp;
|
||||
uint32_t ls; // left-shift counts
|
||||
uint32_t ad; // & operand
|
||||
};
|
||||
struct insns_info
|
||||
{
|
||||
union
|
||||
{
|
||||
uint64_t insu;
|
||||
int64_t ins;
|
||||
void *insp;
|
||||
};
|
||||
fix_info fmap[A64_MAX_REFERENCES];
|
||||
};
|
||||
int64_t basep;
|
||||
int64_t endp;
|
||||
insns_info dat[A64_MAX_INSTRUCTIONS];
|
||||
|
||||
public:
|
||||
inline bool is_in_fixing_range(const int64_t absolute_addr) {
|
||||
return absolute_addr >= this->basep && absolute_addr < this->endp;
|
||||
}
|
||||
inline intptr_t get_ref_ins_index(const int64_t absolute_addr) {
|
||||
return static_cast<intptr_t>((absolute_addr - this->basep) / sizeof(uint32_t));
|
||||
}
|
||||
inline intptr_t get_and_set_current_index(uint32_t *__restrict inp, uint32_t *__restrict outp) {
|
||||
intptr_t current_idx = this->get_ref_ins_index(reinterpret_cast<int64_t>(inp));
|
||||
this->dat[current_idx].insp = outp;
|
||||
return current_idx;
|
||||
}
|
||||
inline void reset_current_ins(const intptr_t idx, uint32_t *__restrict outp) {
|
||||
this->dat[idx].insp = outp;
|
||||
}
|
||||
void insert_fix_map(const intptr_t idx, uint32_t *bp, uint32_t ls = 0u, uint32_t ad = 0xffffffffu) {
|
||||
for (auto &f : this->dat[idx].fmap) {
|
||||
if (f.bp == NULL) {
|
||||
f.bp = bp;
|
||||
f.ls = ls;
|
||||
f.ad = ad;
|
||||
return;
|
||||
} //if
|
||||
}
|
||||
// What? GGing..
|
||||
}
|
||||
void process_fix_map(const intptr_t idx) {
|
||||
for (auto &f : this->dat[idx].fmap) {
|
||||
if (f.bp == NULL) break;
|
||||
*(f.bp) = *(f.bp) | (((int32_t(this->dat[idx].ins - reinterpret_cast<int64_t>(f.bp)) >> 2) << f.ls) & f.ad);
|
||||
f.bp = NULL;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
#define __intval(p) reinterpret_cast<intptr_t>(p)
|
||||
#define __uintval(p) reinterpret_cast<uintptr_t>(p)
|
||||
#define __ptr(p) reinterpret_cast<void *>(p)
|
||||
#define __page_size 4096
|
||||
#define __page_align(n) __align_up(static_cast<uintptr_t>(n), __page_size)
|
||||
#define __ptr_align(x) __ptr(__align_down(reinterpret_cast<uintptr_t>(x), __page_size))
|
||||
#define __align_up(x, n) (((x) + ((n) - 1)) & ~((n) - 1))
|
||||
#define __align_down(x, n) ((x) & -(n))
|
||||
#define __countof(x) static_cast<intptr_t>(sizeof(x) / sizeof((x)[0])) // must be signed
|
||||
#define __atomic_increase(p) __sync_add_and_fetch(p, 1)
|
||||
#define __sync_cmpswap(p, v, n) __sync_bool_compare_and_swap(p, v, n)
|
||||
#define __predict_true(exp) __builtin_expect((exp) != 0, 1)
|
||||
#define __flush_cache(c, n) __builtin___clear_cache(reinterpret_cast<char *>(c), reinterpret_cast<char *>(c) + n)
|
||||
#define __make_rwx(p, n) ::mprotect(__ptr_align(p), \
|
||||
__page_align(__uintval(p) + n) != __page_align(__uintval(p)) ? __page_align(n) + __page_size : __page_align(n), \
|
||||
PROT_READ | PROT_WRITE | PROT_EXEC)
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_branch_imm(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
static constexpr uint32_t mbits = 6u;
|
||||
static constexpr uint32_t mask = 0xfc000000u; // 0b11111100000000000000000000000000
|
||||
static constexpr uint32_t rmask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
static constexpr uint32_t op_b = 0x14000000u; // "b" ADDR_PCREL26
|
||||
static constexpr uint32_t op_bl = 0x94000000u; // "bl" ADDR_PCREL26
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
const uint32_t opc = ins & mask;
|
||||
switch (opc) {
|
||||
case op_b:
|
||||
case op_bl:
|
||||
{
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>(ins << mbits) >> (mbits - 2u)); // sign-extended
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
// whether the branch should be converted to absolute jump
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (rmask >> 1)) {
|
||||
bool b_aligned = (reinterpret_cast<uint64_t>(*outpp + 2) & 7u) == 0u;
|
||||
if (opc == op_b) {
|
||||
if (b_aligned != true) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = 0x58000051u; // LDR X17, #0x8
|
||||
(*outpp)[1] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 4;
|
||||
} else {
|
||||
if (b_aligned == true) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = 0x58000071u; // LDR X17, #12
|
||||
(*outpp)[1] = 0x1000009eu; // ADR X30, #16
|
||||
(*outpp)[2] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 3, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 5;
|
||||
} //if
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, 0u, rmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = opc | (new_pc_offset & ~mask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_cond_comp_test_branch(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t lmask01 = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t mask0 = 0xff000010u; // 0b11111111000000000000000000010000
|
||||
static constexpr uint32_t op_bc = 0x54000000u; // "b.c" ADDR_PCREL19
|
||||
static constexpr uint32_t mask1 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||
static constexpr uint32_t op_cbz = 0x34000000u; // "cbz" Rt, ADDR_PCREL19
|
||||
static constexpr uint32_t op_cbnz = 0x35000000u; // "cbnz" Rt, ADDR_PCREL19
|
||||
static constexpr uint32_t lmask2 = 0xfff8001fu; // 0b11111111111110000000000000011111
|
||||
static constexpr uint32_t mask2 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||
static constexpr uint32_t op_tbz = 0x36000000u; // 0b00110110000000000000000000000000 "tbz" Rt, BIT_NUM, ADDR_PCREL14
|
||||
static constexpr uint32_t op_tbnz = 0x37000000u; // 0b00110111000000000000000000000000 "tbnz" Rt, BIT_NUM, ADDR_PCREL14
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
uint32_t lmask = lmask01;
|
||||
if ((ins & mask0) != op_bc) {
|
||||
uint32_t opc = ins & mask1;
|
||||
if (opc != op_cbz && opc != op_cbnz) {
|
||||
opc = ins & mask2;
|
||||
if (opc != op_tbz && opc != op_tbnz) {
|
||||
return false;
|
||||
} //if
|
||||
lmask = lmask2;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
const uint32_t msb = __builtin_clz(~lmask);
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>((ins & ~lmask) << msb) >> (lsb - 2u + msb));
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (~lmask >> (lsb + 1))) {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 4) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = (((8u >> 2u) << lsb) & ~lmask) | (ins & lmask); // B.C #0x8
|
||||
(*outpp)[1] = 0x14000005u; // B #0x14
|
||||
(*outpp)[2] = 0x58000051u; // LDR X17, #0x8
|
||||
(*outpp)[3] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 4, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 6;
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, lsb, ~lmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~lmask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_loadlit(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
const uint32_t ins = *(*inpp);
|
||||
|
||||
// memory prefetch("prfm"), just skip it
|
||||
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897420050.html
|
||||
if ((ins & 0xff000000u) == 0xd8000000u) {
|
||||
ctxp->process_fix_map(ctxp->get_and_set_current_index(*inpp, *outpp));
|
||||
++(*inpp);
|
||||
return true;
|
||||
} //if
|
||||
|
||||
static constexpr uint32_t msb = 8u;
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t mask_30 = 0x40000000u; // 0b01000000000000000000000000000000
|
||||
static constexpr uint32_t mask_31 = 0x80000000u; // 0b10000000000000000000000000000000
|
||||
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t mask_ldr = 0xbf000000u; // 0b10111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldr = 0x18000000u; // 0b00011000000000000000000000000000 "LDR Wt/Xt, label" | ADDR_PCREL19
|
||||
static constexpr uint32_t mask_ldrv = 0x3f000000u; // 0b00111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldrv = 0x1c000000u; // 0b00011100000000000000000000000000 "LDR St/Dt/Qt, label" | ADDR_PCREL19
|
||||
static constexpr uint32_t mask_ldrsw = 0xff000000u; // 0b11111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldrsw = 0x98000000u; // "LDRSW Xt, label" | ADDR_PCREL19 | load register signed word
|
||||
// LDR S0, #0 | 0b00011100000000000000000000000000 | 32-bit
|
||||
// LDR D0, #0 | 0b01011100000000000000000000000000 | 64-bit
|
||||
// LDR Q0, #0 | 0b10011100000000000000000000000000 | 128-bit
|
||||
// INVALID | 0b11011100000000000000000000000000 | may be 256-bit
|
||||
|
||||
uint32_t mask = mask_ldr;
|
||||
uintptr_t faligned = (ins & mask_30) ? 7u : 3u;
|
||||
if ((ins & mask_ldr) != op_ldr) {
|
||||
mask = mask_ldrv;
|
||||
if (faligned != 7u)
|
||||
faligned = (ins & mask_31) ? 15u : 3u;
|
||||
if ((ins & mask_ldrv) != op_ldrv) {
|
||||
if ((ins & mask_ldrsw) != op_ldrsw) {
|
||||
return false;
|
||||
} //if
|
||||
mask = mask_ldrsw;
|
||||
faligned = 7u;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + ((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u);
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
// special_fix_type may encounter issue when there are mixed data and code
|
||||
if (special_fix_type || (llabs(new_pc_offset) + (faligned + 1u - 4u) / 4u) >= (~lmask >> (lsb + 1))) { // inaccurate, but it works
|
||||
while ((reinterpret_cast<uint64_t>(*outpp + 2) & faligned) != 0u) {
|
||||
*(*outpp)++ = A64_NOP;
|
||||
}
|
||||
ctxp->reset_current_ins(current_idx, *outpp);
|
||||
|
||||
// Note that if memory at absolute_addr is writeable (non-const), we will fail to fetch it.
|
||||
// And what's worse, we may unexpectedly overwrite something if special_fix_type is true...
|
||||
uint32_t ns = static_cast<uint32_t>((faligned + 1) / sizeof(uint32_t));
|
||||
(*outpp)[0] = (((8u >> 2u) << lsb) & ~mask) | (ins & lmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000001u + ns; // B #0xc
|
||||
memcpy(*outpp + 2, reinterpret_cast<void *>(absolute_addr), faligned + 1);
|
||||
*outpp += 2 + ns;
|
||||
} else {
|
||||
faligned >>= 2; // new_pc_offset is shifted and 4-byte aligned
|
||||
while ((new_pc_offset & faligned) != 0) {
|
||||
*(*outpp)++ = A64_NOP;
|
||||
new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
}
|
||||
ctxp->reset_current_ins(current_idx, *outpp);
|
||||
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~mask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_pcreladdr(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
// Load a PC-relative address into a register
|
||||
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897645644.html
|
||||
static constexpr uint32_t msb = 8u;
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t mask = 0x9f000000u; // 0b10011111000000000000000000000000
|
||||
static constexpr uint32_t rmask = 0x0000001fu; // 0b00000000000000000000000000011111
|
||||
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t fmask = 0x00ffffffu; // 0b00000000111111111111111111111111
|
||||
static constexpr uint32_t max_val = 0x001fffffu; // 0b00000000000111111111111111111111
|
||||
static constexpr uint32_t op_adr = 0x10000000u; // "adr" Rd, ADDR_PCREL21
|
||||
static constexpr uint32_t op_adrp = 0x90000000u; // "adrp" Rd, ADDR_ADRP
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
intptr_t current_idx;
|
||||
switch (ins & mask) {
|
||||
case op_adr:
|
||||
{
|
||||
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes);
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp));
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (max_val >> 1)) {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 4;
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr & ~3ull);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp));
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, lsb, fmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
// the lsb_bytes will never be changed, so we can use lmask to keep it
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << (lsb - 2u)) & fmask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
}
|
||||
break;
|
||||
case op_adrp:
|
||||
{
|
||||
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int32_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||
int64_t absolute_addr = (reinterpret_cast<int64_t>(*inpp) & ~0xfffll) + ((((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes) << 12);
|
||||
A64_LOGI("ins = 0x%.8X, pc = %p, abs_addr = %p",
|
||||
ins, *inpp, reinterpret_cast<int64_t *>(absolute_addr));
|
||||
if (ctxp->is_in_fixing_range(absolute_addr)) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr/* & ~3ull*/);
|
||||
if (ref_idx > current_idx) {
|
||||
// the bottom 12 bits of absolute_addr are masked out,
|
||||
// so ref_idx must be less than or equal to current_idx!
|
||||
A64_LOGE("ref_idx must be less than or equal to current_idx!");
|
||||
} //if
|
||||
|
||||
// *absolute_addr may be changed due to relocation fixing
|
||||
A64_LOGI("What is the correct way to fix this?");
|
||||
*(*outpp)++ = ins; // 0x90000000u;
|
||||
} else {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); // potential overflow?
|
||||
*outpp += 4;
|
||||
} //if
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
ctxp->process_fix_map(current_idx);
|
||||
++(*inpp);
|
||||
return true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static void __fix_instructions(uint32_t *__restrict inp, int32_t count, uint32_t *__restrict outp)
|
||||
{
|
||||
context ctx;
|
||||
ctx.basep = reinterpret_cast<int64_t>(inp);
|
||||
ctx.endp = reinterpret_cast<int64_t>(inp + count);
|
||||
memset(ctx.dat, 0, sizeof(ctx.dat));
|
||||
static_assert(sizeof(ctx.dat) / sizeof(ctx.dat[0]) == A64_MAX_INSTRUCTIONS,
|
||||
"please use A64_MAX_INSTRUCTIONS!");
|
||||
#ifndef NDEBUG
|
||||
if (count > A64_MAX_INSTRUCTIONS) {
|
||||
A64_LOGE("too many fixing instructions!");
|
||||
} //if
|
||||
#endif // NDEBUG
|
||||
|
||||
uint32_t *const outp_base = outp;
|
||||
|
||||
while (--count >= 0) {
|
||||
if (__fix_branch_imm(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_cond_comp_test_branch(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_loadlit(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_pcreladdr(&inp, &outp, &ctx)) continue;
|
||||
|
||||
// without PC-relative offset
|
||||
ctx.process_fix_map(ctx.get_and_set_current_index(inp, outp));
|
||||
*(outp++) = *(inp++);
|
||||
}
|
||||
|
||||
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
auto callback = reinterpret_cast<int64_t>(inp);
|
||||
auto pc_offset = static_cast<int64_t>(callback - reinterpret_cast<int64_t>(outp)) >> 2;
|
||||
if (llabs(pc_offset) >= (mask >> 1)) {
|
||||
if ((reinterpret_cast<uint64_t>(outp + 2) & 7u) != 0u) {
|
||||
outp[0] = A64_NOP;
|
||||
++outp;
|
||||
} //if
|
||||
outp[0] = 0x58000051u; // LDR X17, #0x8
|
||||
outp[1] = 0xd61f0220u; // BR X17
|
||||
*reinterpret_cast<int64_t *>(outp + 2) = callback;
|
||||
outp += 4;
|
||||
} else {
|
||||
outp[0] = 0x14000000u | (pc_offset & mask); // "B" ADDR_PCREL26
|
||||
++outp;
|
||||
} //if
|
||||
|
||||
const uintptr_t total = (outp - outp_base) * sizeof(uint32_t);
|
||||
__flush_cache(outp_base, total); // necessary
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
extern "C" {
|
||||
static __attribute__((__aligned__(__page_size))) uint32_t __insns_pool[A64_MAX_BACKUPS][A64_MAX_INSTRUCTIONS * 10];
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
class A64HookInit
|
||||
{
|
||||
public:
|
||||
A64HookInit()
|
||||
{
|
||||
__make_rwx(__insns_pool, sizeof(__insns_pool));
|
||||
A64_LOGI("insns pool initialized.");
|
||||
}
|
||||
};
|
||||
static A64HookInit __init;
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static uint32_t *FastAllocateTrampoline()
|
||||
{
|
||||
static_assert((A64_MAX_INSTRUCTIONS * 10 * sizeof(uint32_t)) % 8 == 0, "8-byte align");
|
||||
static volatile int32_t __index = -1;
|
||||
|
||||
int32_t i = __atomic_increase(&__index);
|
||||
if (__predict_true(i >= 0 && i < __countof(__insns_pool))) {
|
||||
return __insns_pool[i];
|
||||
} //if
|
||||
|
||||
A64_LOGE("failed to allocate trampoline!");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
A64_JNIEXPORT void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||
void *const rwx, const uintptr_t rwx_size)
|
||||
{
|
||||
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
|
||||
uint32_t *trampoline = static_cast<uint32_t *>(rwx), *original = static_cast<uint32_t *>(symbol);
|
||||
|
||||
static_assert(A64_MAX_INSTRUCTIONS >= 5, "please fix A64_MAX_INSTRUCTIONS!");
|
||||
auto pc_offset = static_cast<int64_t>(__intval(replace) - __intval(symbol)) >> 2;
|
||||
if (llabs(pc_offset) >= (mask >>1)) {
|
||||
int32_t count = (reinterpret_cast<uint64_t>(original + 2) & 7u) != 0u ? 5 : 4;
|
||||
if (trampoline) {
|
||||
if (rwx_size < count * 10u) {
|
||||
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", count * 10u);
|
||||
return NULL;
|
||||
} //if
|
||||
__fix_instructions(original, count, trampoline);
|
||||
} //if
|
||||
|
||||
if (__make_rwx(original, 5 * sizeof(uint32_t)) == 0) {
|
||||
if (count == 5) {
|
||||
original[0] = A64_NOP;
|
||||
++original;
|
||||
} //if
|
||||
original[0] = 0x58000051u; // LDR X17, #0x8
|
||||
original[1] = 0xd61f0220u; // BR X17
|
||||
*reinterpret_cast<int64_t *>(original + 2) = __intval(replace);
|
||||
__flush_cache(symbol, 5 * sizeof(uint32_t));
|
||||
|
||||
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||
symbol, replace, 5 * sizeof(uint32_t));
|
||||
} else {
|
||||
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||
errno, original, 5 * sizeof(uint32_t));
|
||||
trampoline = NULL;
|
||||
} //if
|
||||
} else {
|
||||
if (trampoline) {
|
||||
if (rwx_size < 1u * 10u) {
|
||||
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", 1u * 10u);
|
||||
return NULL;
|
||||
} //if
|
||||
__fix_instructions(original, 1, trampoline);
|
||||
} //if
|
||||
|
||||
if (__make_rwx(original, 1 * sizeof(uint32_t)) == 0) {
|
||||
__sync_cmpswap(original, *original, 0x14000000u | (pc_offset & mask)); // "B" ADDR_PCREL26
|
||||
__flush_cache(symbol, 1 * sizeof(uint32_t));
|
||||
|
||||
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||
symbol, replace, 1 * sizeof(uint32_t));
|
||||
} else {
|
||||
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||
errno, original, 1 * sizeof(uint32_t));
|
||||
trampoline = NULL;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
return trampoline;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
A64_JNIEXPORT void A64HookFunction(void *const symbol, void *const replace, void **result)
|
||||
{
|
||||
void *trampoline = NULL;
|
||||
if (result != NULL) {
|
||||
trampoline = FastAllocateTrampoline();
|
||||
*result = trampoline;
|
||||
if (trampoline == NULL) return;
|
||||
} //if
|
||||
|
||||
// fix Android 10 .text segment is read-only by default
|
||||
__make_rwx(symbol, 5 * sizeof(size_t));
|
||||
|
||||
trampoline = A64HookFunctionV(symbol, replace, trampoline, A64_MAX_INSTRUCTIONS * 10u);
|
||||
if (trampoline == NULL && result != NULL) {
|
||||
*result = NULL;
|
||||
} //if
|
||||
}
|
||||
}
|
||||
|
||||
#endif // defined(__aarch64__)
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* @date : 2018/04/18
|
||||
* @author : Rprop (r_prop@outlook.com)
|
||||
* https://github.com/Rprop/And64InlineHook
|
||||
*/
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#pragma once
|
||||
#define A64_MAX_BACKUPS 256
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void A64HookFunction(void *const symbol, void *const replace, void **result);
|
||||
void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||
void *const rwx, const uintptr_t rwx_size);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 RLib
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"versionName": "20230317",
|
||||
"versionCode": "2128210",
|
||||
"il2cppSha256": "842a496cc59948af03fe435e0abe0a173cbf2a6bf439be48096ad2b2c2dccbb9",
|
||||
"hookBytes": 20,
|
||||
"targets": {
|
||||
"http_send_request": {
|
||||
"rva": 108025384,
|
||||
"length": 456,
|
||||
"fingerprint": "0x8cd03afe41eecdef",
|
||||
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||
},
|
||||
"http_call_callback": {
|
||||
"rva": 108061608,
|
||||
"length": 424,
|
||||
"fingerprint": "0x98ed61ac83527269",
|
||||
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||
},
|
||||
"oculus_get_app_id": {
|
||||
"rva": 166405008,
|
||||
"length": 32,
|
||||
"fingerprint": "0xaa5a50a8d4246c46",
|
||||
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||
},
|
||||
"oculus_get_mobile_app_id": {
|
||||
"rva": 166380836,
|
||||
"length": 32,
|
||||
"fingerprint": "0x5f5d3bc64dedb8de",
|
||||
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||
},
|
||||
"photon_app_settings": {
|
||||
"rva": 69268008,
|
||||
"length": 196,
|
||||
"fingerprint": "0xc8050d166579e43b",
|
||||
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||
},
|
||||
"tls_unity_verify": {
|
||||
"rva": 159789160,
|
||||
"length": 884,
|
||||
"fingerprint": "0xf8d4a696f15e6f0e",
|
||||
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||
},
|
||||
"tls_bc_notify_server_certificate": {
|
||||
"rva": 103301824,
|
||||
"length": 284,
|
||||
"fingerprint": "0x7f061bfc843e881f",
|
||||
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||
},
|
||||
"pin_nameserver": {
|
||||
"rva": 96989180,
|
||||
"length": 332,
|
||||
"fingerprint": "0x659c928fe96e1008",
|
||||
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||
},
|
||||
"pin_https": {
|
||||
"rva": 103249928,
|
||||
"length": 1032,
|
||||
"fingerprint": "0x943a238f04d54362",
|
||||
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||
},
|
||||
"pin_bc_verifier": {
|
||||
"rva": 82659376,
|
||||
"length": 1128,
|
||||
"fingerprint": "0x60415d1d29a7315d",
|
||||
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||
},
|
||||
"image_signature_verify": {
|
||||
"rva": 97302848,
|
||||
"length": 732,
|
||||
"fingerprint": "0xe1549ef6f4691a33",
|
||||
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||
},
|
||||
"hile_warning_callback": {
|
||||
"rva": 75750004,
|
||||
"length": 352,
|
||||
"fingerprint": "0xccf510feeada40d9",
|
||||
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||
},
|
||||
"unknown_dll_callback": {
|
||||
"rva": 57998052,
|
||||
"length": 112,
|
||||
"fingerprint": "0x84e1fed3a70e7560",
|
||||
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||
},
|
||||
"file_hash_callback": {
|
||||
"rva": 52872472,
|
||||
"length": 1052,
|
||||
"fingerprint": "0x03a8b6f1b373e600",
|
||||
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||
},
|
||||
"toxmod_can_initialize": {
|
||||
"rva": 55928080,
|
||||
"length": 1040,
|
||||
"fingerprint": "0xf72dd6f5dbb16216",
|
||||
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||
},
|
||||
"cheat_report": {
|
||||
"rva": 106127656,
|
||||
"length": 2248,
|
||||
"fingerprint": "0x9c1e0ef53e5a3ec8",
|
||||
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||
},
|
||||
"fatal_application_quit": {
|
||||
"rva": 55748824,
|
||||
"length": 348,
|
||||
"fingerprint": "0x8cd03afe41eecdef",
|
||||
"why": "Log-only: prints the quit reason and caller."
|
||||
},
|
||||
"try_application_quit": {
|
||||
"rva": 55748412,
|
||||
"length": 412,
|
||||
"fingerprint": "0xb12b0044ac4f844f",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
},
|
||||
"title_auto_login": {
|
||||
"rva": 58892048,
|
||||
"length": 668,
|
||||
"fingerprint": "0x83bebb9a30983b33",
|
||||
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Hook targets, identified by the class header and method signature exactly as",
|
||||
"Il2CppDumper prints them in dump.cs. scripts/resolve_targets.py turns this into a",
|
||||
"per-build table (RVA, length, code fingerprint). Obfuscated names are fixed per",
|
||||
"release and shared with the PC build of the same release; when rebasing, re-check",
|
||||
"each one against the PC decompile and update the signature here."
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"id": "http_send_request",
|
||||
"class": "public static class HTTPManager ",
|
||||
"method": "public static HTTPRequest SendRequest(HTTPRequest request)",
|
||||
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||
},
|
||||
{
|
||||
"id": "http_call_callback",
|
||||
"class": "public sealed class HTTPRequest ",
|
||||
"method": "internal void CallCallback()",
|
||||
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||
},
|
||||
{
|
||||
"id": "oculus_get_app_id",
|
||||
"class": "public sealed class PlatformSettings ",
|
||||
"method": "public static string get_AppID()",
|
||||
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||
},
|
||||
{
|
||||
"id": "oculus_get_mobile_app_id",
|
||||
"class": "public sealed class PlatformSettings ",
|
||||
"method": "public static string get_MobileAppID()",
|
||||
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||
},
|
||||
{
|
||||
"id": "photon_app_settings",
|
||||
"class": "internal class NFOMFIAOKFO ",
|
||||
"method": "public AppSettings LBKJOHGFHOO()",
|
||||
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||
},
|
||||
{
|
||||
"id": "tls_unity_verify",
|
||||
"class": "internal class UnityTlsContext ",
|
||||
"method": "private UnityTls.unitytls_x509verify_result VerifyCallback(UnityTls.unitytls_x509list_ref chain, UnityTls.unitytls_errorstate* errorState)",
|
||||
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||
},
|
||||
{
|
||||
"id": "tls_bc_notify_server_certificate",
|
||||
"class": "public class LegacyTlsAuthentication ",
|
||||
"method": "public virtual void NotifyServerCertificate(Certificate serverCertificate)",
|
||||
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||
},
|
||||
{
|
||||
"id": "pin_nameserver",
|
||||
"class": "public class PAPLNIPKAMG ",
|
||||
"method": "private static bool GKHFNFDOKDK(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||
},
|
||||
{
|
||||
"id": "pin_https",
|
||||
"class": "public static class LJEHIHEHMCN ",
|
||||
"method": "public static bool JFIODJJKMBF(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||
},
|
||||
{
|
||||
"id": "pin_bc_verifier",
|
||||
"class": "public class LJEHIHEHMCN.NALNEIDKBOC ",
|
||||
"method": "public bool IsValid(Uri HEEAJLIFCHN, X509CertificateStructure[] MFLGMAEAFON)",
|
||||
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||
},
|
||||
{
|
||||
"id": "image_signature_verify",
|
||||
"class": "public class KLJOGJHBONK ",
|
||||
"method": "private static bool PBLLFOKJAMK(byte[] HBMMCJPOJGP, byte[] JJGOGMKDLGI)",
|
||||
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||
},
|
||||
{
|
||||
"id": "hile_warning_callback",
|
||||
"class": "private sealed class FPIBGPIAOBI.KHIIHOIFDMP ",
|
||||
"method": "internal void <CreateHileWarning>b__0(bool shouldQuit)",
|
||||
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "unknown_dll_callback",
|
||||
"class": "private sealed class CheatManager.NOBCKPOGHEB ",
|
||||
"method": "internal void <OnUnknownDLLDetected>b__0()",
|
||||
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||
},
|
||||
{
|
||||
"id": "file_hash_callback",
|
||||
"class": "private sealed class BEPEAHOOLDB.PBENKLNHBHL ",
|
||||
"method": "internal void <InitializeFileHashChecker>b__0(JFDPIJFDHAD.ACCABIOJGNE result)",
|
||||
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "toxmod_can_initialize",
|
||||
"class": "public class ToxModVoiceComponent ",
|
||||
"method": "public static bool CanInitializeToxMod()",
|
||||
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "cheat_report",
|
||||
"class": "public class FPIBGPIAOBI ",
|
||||
"method": "public static LDGADANDBIO IEIHCFLCHBE(FPIBGPIAOBI.HHKINDNBLEE EHPFPOFFCEJ, string EPAIMCEIMPA, Nullable<int> BNCEPENKLDF)",
|
||||
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||
},
|
||||
{
|
||||
"id": "fatal_application_quit",
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void FatalApplicationQuit(int DKOGJICFAEP, string EPAIMCEIMPA = \"\")",
|
||||
"why": "Log-only: prints the quit reason and caller."
|
||||
},
|
||||
{
|
||||
"id": "try_application_quit",
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
},
|
||||
{
|
||||
"id": "title_auto_login",
|
||||
"class": "public class TitleScreenFlowModel ",
|
||||
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
|
||||
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression: admin "pull a player into an instance" broke because the
|
||||
/// instance id (an int64 well past JS's 2^53 safe-integer range) was
|
||||
/// emitted as a JSON number. The admin SPA's JSON.parse rounded it to the
|
||||
/// nearest double, so the pull URL targeted a non-existent instance and
|
||||
/// the server returned <c>instance_not_active</c>. The fix serializes the
|
||||
/// id as a STRING so the exact digits survive the browser round-trip.
|
||||
/// </summary>
|
||||
public sealed class AdminInstancePullTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
// A dorm-style instance id past 2^53 — the value class that a JS
|
||||
// Number cannot represent exactly.
|
||||
private const long BigInstanceId = 7597291079085522944L;
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public AdminInstancePullTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Instances_list_emits_id_as_string_and_pull_with_exact_id_succeeds()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long roomId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
// Promote the caller to admin (AdminOnly reads IsAdmin from DB).
|
||||
var adminRow = await db.Players.FirstAsync(p => p.Id == admin.PlayerId);
|
||||
adminRow.IsAdmin = true;
|
||||
|
||||
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Put the streamer in a live instance carrying the huge id.
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = BigInstanceId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = 0,
|
||||
PhotonRoomId = "^teststreamerinstance",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(streamer.PlayerId);
|
||||
}
|
||||
|
||||
using var adminClient = ApiClient(admin);
|
||||
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
|
||||
|
||||
// The list must emit roomInstanceId as a STRING equal to the exact
|
||||
// digits — never a number (which would already be rounded here).
|
||||
var list = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/instances");
|
||||
var row = list.EnumerateArray()
|
||||
.First(r => r.GetProperty("roomInstanceId").GetString() == BigInstanceId.ToString());
|
||||
Assert.Equal(JsonValueKind.String, row.GetProperty("roomInstanceId").ValueKind);
|
||||
|
||||
// Pull the second player in using the EXACT id from the list. This
|
||||
// is what the SPA does; with the string round-trip the digits are
|
||||
// intact and the instance matches (pre-fix: 404 instance_not_active).
|
||||
var idFromList = row.GetProperty("roomInstanceId").GetString()!;
|
||||
using var pull = await adminClient.PostAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/instances/{idFromList}/pull",
|
||||
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
|
||||
Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, pull.StatusCode);
|
||||
using var body = await JsonDocument.ParseAsync(await pull.Content.ReadAsStreamAsync());
|
||||
Assert.True(body.RootElement.GetProperty("ok").GetBoolean());
|
||||
// The pulled player now shares the streamer's instance.
|
||||
await using var verify = _factory.Services.CreateAsyncScope();
|
||||
var presenceCheck = verify.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
Assert.Equal(BigInstanceId, presenceCheck.GetRoom(pulled.PlayerId)!.RoomInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_with_precision_mangled_id_is_rejected()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long roomId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
|
||||
await db.SaveChangesAsync();
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = BigInstanceId,
|
||||
RoomId = roomId,
|
||||
PhotonRoomId = "^teststreamerinstance2",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(streamer.PlayerId);
|
||||
}
|
||||
|
||||
using var adminClient = ApiClient(admin);
|
||||
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
|
||||
|
||||
// The value the admin SPA's JSON.parse actually produced for this
|
||||
// id (from the bug report log) — the exact failure mode.
|
||||
const long mangled = 7597291079085523000L;
|
||||
Assert.NotEqual(BigInstanceId, mangled);
|
||||
using var pull = await adminClient.PostAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/instances/{mangled}/pull",
|
||||
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
|
||||
Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, pull.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class CanonicalRoomBootstrapTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CanonicalRoomBootstrapTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_seeds_stunt_runner_subroom_and_rec_rally_remote_image_name()
|
||||
{
|
||||
using var _ = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
var stuntRunner = await db.Rooms.SingleAsync(r => r.Name == "StuntRunner");
|
||||
var stuntRunnerScenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == stuntRunner.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Collection(
|
||||
stuntRunnerScenes,
|
||||
scene =>
|
||||
{
|
||||
Assert.Equal(0, scene.OrderIndex);
|
||||
Assert.Equal("StuntRunner", scene.Name);
|
||||
Assert.Equal("b7281665-a715-4051-826b-8e08e69c6172", scene.RoomSceneLocationId);
|
||||
Assert.Equal("", scene.DataBlobName);
|
||||
},
|
||||
scene =>
|
||||
{
|
||||
Assert.Equal(1, scene.OrderIndex);
|
||||
Assert.Equal("TheMainEvent", scene.Name);
|
||||
Assert.Equal("3a636bd2-f896-424c-9225-c184522c0d87", scene.RoomSceneLocationId);
|
||||
Assert.Equal("", scene.DataBlobName);
|
||||
});
|
||||
|
||||
var recRally = await db.Rooms.SingleAsync(r => r.Name == "RecRally");
|
||||
Assert.Equal("image_RecRally.jpg", recRally.ImageName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Hubs;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the notify hub that records every push instead of sending it,
|
||||
/// so tests can assert on the exact frames the game client would receive.
|
||||
/// A frame is the hub method plus its single string argument, parsed back into
|
||||
/// the <c>{Id, Msg}</c> envelope.
|
||||
/// </summary>
|
||||
public sealed class CapturingHubContext : IHubContext<NotifyHub>
|
||||
{
|
||||
private readonly ConcurrentQueue<SentFrame> _sent = new();
|
||||
|
||||
public IHubClients Clients => new CapturingClients(this);
|
||||
public IGroupManager Groups { get; } = new NoopGroups();
|
||||
|
||||
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
|
||||
|
||||
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
|
||||
/// order, optionally filtered by envelope <c>Id</c>.</summary>
|
||||
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
|
||||
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
|
||||
|
||||
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
|
||||
|
||||
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
|
||||
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
|
||||
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
|
||||
|
||||
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
|
||||
{
|
||||
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
|
||||
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
|
||||
public JsonElement Msg => Envelope.GetProperty("Msg");
|
||||
}
|
||||
|
||||
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
|
||||
{
|
||||
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
|
||||
new CapturingProxy(owner, target, excluded);
|
||||
|
||||
public IClientProxy All => Proxy("all");
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
|
||||
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
|
||||
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
|
||||
Proxy($"group:{groupName}", excludedConnectionIds);
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
|
||||
public IClientProxy User(string userId) => Proxy($"user:{userId}");
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
|
||||
}
|
||||
|
||||
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
owner.Record(target, excluded, method, args);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
|
||||
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
|
||||
}
|
||||
|
||||
private sealed class NoopGroups : IGroupManager
|
||||
{
|
||||
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the 3D Charades word-list feature and the
|
||||
/// profanity-filter toggle. Boots the full server (which runs the seeder)
|
||||
/// against SQLite via <see cref="DorkNetServerFactory"/> and exercises the
|
||||
/// public wire endpoints the March 2023 client actually calls.
|
||||
/// </summary>
|
||||
public sealed class CharadesWordListTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CharadesWordListTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Charades")] // enum-name form
|
||||
[InlineData("CharadesAprilFoolsDay")]
|
||||
[InlineData("Icebreakers")]
|
||||
[InlineData("0")] // int form the client may send
|
||||
[InlineData("1")]
|
||||
[InlineData("2")]
|
||||
public async Task Words_endpoint_returns_seeded_deck_in_wire_shape(string source)
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync($"/api/activities/charades/v1/words/{source}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0, $"expected a non-empty deck for '{source}'");
|
||||
|
||||
var first = doc.RootElement[0];
|
||||
// Exact wire key casing the client's LitJson importer reads.
|
||||
Assert.True(first.TryGetProperty("EN_US", out var en) && en.ValueKind == JsonValueKind.String,
|
||||
"each card must expose a string EN_US field");
|
||||
Assert.True(first.TryGetProperty("Difficulty", out var diff) && diff.ValueKind == JsonValueKind.Number,
|
||||
"each card must expose a numeric Difficulty field");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task December_noparam_route_serves_the_charades_slot()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync("/api/activities/charades/v1/words");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_source_falls_back_to_charades_slot()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.GetAsync("/api/activities/charades/v1/words/totally-bogus");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Profanity_filter_is_active_by_default()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.PostAsync(
|
||||
"/api/sanitize/v1/requestIsStringPure",
|
||||
new StringContent("{\"Value\":\"you piece of shit\"}", Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
var body = (await res.Content.ReadAsStringAsync()).Trim();
|
||||
// Default state: filter on → the profane string is NOT pure.
|
||||
Assert.Equal("false", body.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Starting a chat with a player is a two-step handshake, and both steps have to
|
||||
/// work on a thread that has never carried a message:
|
||||
///
|
||||
/// POST thread/withmembers?ids={playerId} -> creates/returns the thread
|
||||
/// GET thread/{id} -> opens it
|
||||
///
|
||||
/// Observed failing in production: the POST returned 200 at 06:38:20.862 and the
|
||||
/// GET 404'd 415ms later, because the handler decided a thread existed by
|
||||
/// counting its messages. A brand-new conversation has none, so the client was
|
||||
/// handed a thread id it was then told did not exist, and no chat could ever be
|
||||
/// opened with someone you had not already messaged.
|
||||
/// </summary>
|
||||
public sealed class ChatStartConversationTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ChatStartConversationTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Brand_new_conversation_can_be_opened_before_any_message_is_sent()
|
||||
{
|
||||
using var starterClient = Client();
|
||||
using var otherClient = Client();
|
||||
|
||||
var starter = await GameClientSessionFactory.CreateAsync(starterClient, _factory.ApexDomain);
|
||||
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
|
||||
|
||||
starterClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
|
||||
|
||||
// Step 1 — the client asks for the thread with that player. It sends the
|
||||
// ids as a form body (observed on the wire as
|
||||
// "ids=9741717&messageCount=50"), which also matters for routing: the
|
||||
// content type is what separates this from POST thread/{chatThreadId},
|
||||
// whose parameter segment otherwise also matches "withmembers".
|
||||
using var form = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
|
||||
new KeyValuePair<string, string>("messageCount", "50"),
|
||||
]);
|
||||
using var createResponse = await starterClient.PostAsync("/thread/withmembers", form);
|
||||
var createBody = await createResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(createResponse.IsSuccessStatusCode,
|
||||
$"POST /thread/withmembers -> {(int)createResponse.StatusCode}: {createBody}");
|
||||
|
||||
var thread = JsonDocument.Parse(createBody).RootElement;
|
||||
Assert.True(thread.TryGetProperty("ChatThreadId", out var idElement)
|
||||
|| thread.TryGetProperty("chatThreadId", out idElement),
|
||||
$"no thread id in the withmembers reply: {createBody}");
|
||||
var threadId = idElement.ValueKind == JsonValueKind.String
|
||||
? idElement.GetString()
|
||||
: idElement.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(threadId));
|
||||
|
||||
// Step 2 — and immediately opens it. No message has been sent yet.
|
||||
using var openResponse = await starterClient.GetAsync($"/thread/{threadId}?maxCount=50");
|
||||
var openBody = await openResponse.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.True(openResponse.StatusCode != HttpStatusCode.NotFound,
|
||||
$"""
|
||||
GET /thread/{threadId} returned 404 for a thread that thread/withmembers
|
||||
had just created. An empty thread is not a missing thread — the chat
|
||||
cannot be opened until someone has already sent a message, which is
|
||||
impossible to do first.
|
||||
""");
|
||||
Console.WriteLine("=== WITHMEMBERS ===");
|
||||
Console.WriteLine(createBody);
|
||||
Console.WriteLine("=== GET THREAD ===");
|
||||
Console.WriteLine(openBody);
|
||||
|
||||
Assert.True(openResponse.IsSuccessStatusCode,
|
||||
$"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
|
||||
}
|
||||
|
||||
private HttpClient Client()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://chat.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using DorkNet.Server.Services;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The March-2023 client budgets every CircuitsV2 network send against
|
||||
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>, which it fills from the
|
||||
/// <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. Retail ships 5 RPCs per
|
||||
/// frame; past that the send is dropped and the room save is flagged
|
||||
/// <c>cv2_rpc_limited</c>. DorkNet serves budgets that lift the cap, and
|
||||
/// these tests pin both the wire shape the client's Newtonsoft reader
|
||||
/// binds and the clamps that keep a hand-edited value from crashing it.
|
||||
/// </summary>
|
||||
public sealed class CircuitsV2LimitsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CircuitsV2LimitsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Gameconfigs_endpoint_serves_the_network_budget()
|
||||
{
|
||||
using var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
|
||||
var rows = await client.GetFromJsonAsync<List<GameConfigurationSetting>>(
|
||||
"api/gameconfigs/v1/all");
|
||||
|
||||
var network = rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Network");
|
||||
var entry = JsonDocument.Parse(network.Value).RootElement.EnumerateArray().Single();
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
// A server that never saved limits (empty column) serves the
|
||||
// current defaults, not the retail values.
|
||||
Assert.Equal("30000", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Chips").Value);
|
||||
Assert.Equal("0", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.InkPerChip").Value);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GameConfigurationSetting> Emit(CircuitsV2LimitSettings limits) =>
|
||||
ServerSettingsService.ToGameConfigurations(
|
||||
ServerSettingsService.DefaultRecCenterDoors(),
|
||||
ServerSettingsService.DefaultDiscoveredGameConfigs(),
|
||||
limits);
|
||||
|
||||
private static string ValueOf(IReadOnlyList<GameConfigurationSetting> rows, string key) =>
|
||||
rows.Single(r => r.Key == key).Value;
|
||||
|
||||
private static JsonElement NetworkOf(CircuitsV2LimitSettings limits) =>
|
||||
JsonDocument.Parse(ValueOf(Emit(limits), "UGC.CircuitsV2.Limit.Network")).RootElement;
|
||||
|
||||
[Fact]
|
||||
public void Defaults_lift_the_rpc_and_byte_budgets()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits());
|
||||
|
||||
Assert.Equal(JsonValueKind.Array, network.ValueKind);
|
||||
var entry = Assert.Single(network.EnumerateArray());
|
||||
// Retail is 5 RPCs / 1024 bytes; anything at those values means the
|
||||
// budget never actually got lifted.
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
Assert.True(entry.GetProperty("Bytes").GetInt32() >= 1_000_000);
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Network_json_uses_the_field_names_the_client_binds()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits())
|
||||
.EnumerateArray().First();
|
||||
|
||||
// LimitConfig.NetworkConfig's il2cpp field names, verbatim.
|
||||
Assert.True(entry.TryGetProperty("NumPlayersForActivation", out _));
|
||||
Assert.True(entry.TryGetProperty("Bytes", out _));
|
||||
Assert.True(entry.TryGetProperty("BytesTimeWindowFrame60s", out _));
|
||||
Assert.True(entry.TryGetProperty("Rpcs", out _));
|
||||
Assert.True(entry.TryGetProperty("RpcTimeWindowFrame60s", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scalar_limits_serialize_invariant_without_separators()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Chips = 123456,
|
||||
CloudValuesAutosaveSeconds = 300f,
|
||||
});
|
||||
|
||||
// The client parses these with int/float.TryParse under
|
||||
// InvariantCulture, so a grouped or comma-decimal value silently
|
||||
// falls back to its baked default.
|
||||
Assert.Equal("123456", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
|
||||
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_raise_the_room_size_limits_past_retail()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits());
|
||||
|
||||
// Retail: Compute 50000, Chips 3000, CloudValues 100,
|
||||
// Unculled/VisibleChips 500, InkPerChip 15.
|
||||
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
Assert.Equal("30000", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("1000", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
|
||||
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.UnculledChips"));
|
||||
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.VisibleChips"));
|
||||
// InkPerChip is a cost, so the lift is downward.
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
// A save cadence, not a capacity limit: left at retail.
|
||||
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_visible_chips_do_not_exceed_the_chip_cap()
|
||||
{
|
||||
var limits = ServerSettingsService.DefaultCircuitsV2Limits();
|
||||
|
||||
// The add-chip path only charges InkPerChip when Chips < VisibleChips
|
||||
// (CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt ISIL 228-259), so the
|
||||
// defaults keep that path off as well as zeroing the cost.
|
||||
Assert.True(limits.VisibleChips <= limits.Chips);
|
||||
Assert.True(limits.UnculledChips <= limits.Chips);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ink_cost_and_cloud_values_are_capped_so_their_product_fits_an_int()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
InkPerChip = int.MaxValue,
|
||||
CloudValues = int.MaxValue,
|
||||
});
|
||||
|
||||
// The client computes InkPerChip * Math.Min(numChips, CloudValues)
|
||||
// in plain int; InkPerChip past the hard-coded 30000-ink room cap
|
||||
// also makes a single chip unaffordable.
|
||||
var ink = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
var cloud = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
|
||||
Assert.Equal(30_000, ink);
|
||||
Assert.Equal(10_000, cloud);
|
||||
Assert.True((long)ink * cloud < int.MaxValue / 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_ink_cost_is_floored_at_zero()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { InkPerChip = -5 });
|
||||
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chips_are_bounded_because_the_culling_manager_preallocates_by_them()
|
||||
{
|
||||
var low = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = 0 });
|
||||
var high = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = int.MaxValue });
|
||||
|
||||
// Four List<Chip>(Chips) at culling-manager construction: 0 or less
|
||||
// throws, and int.MaxValue is an out-of-memory kill.
|
||||
Assert.Equal("1", ValueOf(low, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("1000000", ValueOf(high, "UGC.CircuitsV2.Limit.Chips"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_stays_finite_because_it_is_the_runaway_loop_guard()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Compute = int.MaxValue });
|
||||
|
||||
Assert.Equal("10000000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_rpcs_is_clamped_because_the_debug_subscreen_divides_by_it()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [new(0, 1024, 60, Rpcs: 0, RpcTimeWindowFrame60s: 1)],
|
||||
}).EnumerateArray().First();
|
||||
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Time_windows_are_capped_so_the_ring_buffer_stays_small()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [new(0, 1024, int.MaxValue, 5, int.MaxValue)],
|
||||
}).EnumerateArray().First();
|
||||
|
||||
// Each window frame is one int in a client-side ring buffer, so an
|
||||
// unbounded window is an out-of-memory kill at circuit init.
|
||||
Assert.Equal(3600, entry.GetProperty("RpcTimeWindowFrame60s").GetInt32());
|
||||
Assert.Equal(3600, entry.GetProperty("BytesTimeWindowFrame60s").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lowest_entry_is_forced_to_activate_at_zero_players()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network =
|
||||
[
|
||||
new(20, 4096, 60, 50, 1),
|
||||
new(8, 2048, 60, 25, 1),
|
||||
],
|
||||
});
|
||||
|
||||
// Without an entry at 0 the client rejects the whole array and
|
||||
// falls back to the retail 5-RPC budget.
|
||||
var entries = network.EnumerateArray().ToList();
|
||||
Assert.Equal(0, entries[0].GetProperty("NumPlayersForActivation").GetInt32());
|
||||
// The rewrite keeps that entry's own budget.
|
||||
Assert.Equal(25, entries[0].GetProperty("Rpcs").GetInt32());
|
||||
Assert.Equal(20, entries[1].GetProperty("NumPlayersForActivation").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_thresholds_collapse_and_entries_sort_ascending()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network =
|
||||
[
|
||||
new(16, 4096, 60, 50, 1),
|
||||
new(0, 1024, 60, 10, 1),
|
||||
new(16, 8192, 60, 99, 1),
|
||||
],
|
||||
});
|
||||
|
||||
var thresholds = network.EnumerateArray()
|
||||
.Select(e => e.GetProperty("NumPlayersForActivation").GetInt32())
|
||||
.ToList();
|
||||
Assert.Equal([0, 16], thresholds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_network_list_falls_back_to_the_default_budget()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [],
|
||||
});
|
||||
|
||||
var entry = Assert.Single(network.EnumerateArray());
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts our responses carry EXACTLY the keys the 2023-03-21 client's
|
||||
/// generated readers register — no missing ones, no invented ones.
|
||||
///
|
||||
/// This is stricter than "the client doesn't crash" on purpose. A wrong key set
|
||||
/// does not produce a bad request: the server answers HTTP 200, the reader
|
||||
/// throws while deserialising, and it surfaces later as a bare "Failed to copy
|
||||
/// room" with no URL and no payload logged on either side. By the time it is
|
||||
/// visible there is nothing left to diagnose it with.
|
||||
///
|
||||
/// The expected sets are extracted straight from the binary by
|
||||
/// <c>tools/extract-client-dto-keys.py</c> into
|
||||
/// <c>data/client-dto-keys-2023.json</c>, keyed by the obfuscated reader type.
|
||||
/// Cpp2IL emits each reader's property names as string literals in registration
|
||||
/// order, so that file is the client's own contract rather than our reading of
|
||||
/// it — regenerate it rather than editing it by hand.
|
||||
/// </summary>
|
||||
public sealed class ClientDtoParityTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ClientDtoParityTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
/// <summary>GLEGPFFPDBE — the room-details reader. This is what
|
||||
/// <c>GET rooms/{id}</c> and <c>POST rooms/{id}/clone</c> both deserialise
|
||||
/// into (clone's return type FGCPNAACHIK, NLDBPDCNNCF.txt:4676).</summary>
|
||||
private const string RoomDetailsReader = "GLEGPFFPDBE";
|
||||
|
||||
/// <summary>EKEFFNBIOHJ — the chat-thread reader, shared by
|
||||
/// <c>POST thread/withmembers</c> and <c>GET thread/{id}</c>.</summary>
|
||||
private const string ChatThreadReader = "EKEFFNBIOHJ";
|
||||
|
||||
/// <summary>Keys the reader registers that we deliberately do NOT send.
|
||||
///
|
||||
/// A registered slot does not oblige us to fill it — Newtonsoft leaves an
|
||||
/// absent key at its default. These two were added for parity and broke
|
||||
/// the dorm: which blob a room loads is decided per INSTANCE (the matchmake
|
||||
/// response carries it, and a dorm's is per-player, dorm_p{id}_v*.dat) and
|
||||
/// per SUB-ROOM. A room-scoped answer to the same question is one the
|
||||
/// client preferred, and for a dorm it named a file that was never written,
|
||||
/// so the room came back "corrupt".
|
||||
///
|
||||
/// Every room loaded fine while these were absent. Their absence is the
|
||||
/// fix, not a gap to close later — do not "restore parity" here without a
|
||||
/// dorm join to prove it.</summary>
|
||||
private static readonly HashSet<(string Reader, string Key)> DeliberatelyOmitted =
|
||||
[
|
||||
(RoomDetailsReader, "DataBlob"),
|
||||
(RoomDetailsReader, "DataBlobHash"),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Room_details_carries_exactly_the_keys_the_client_reads()
|
||||
{
|
||||
using var client = RoomsClient();
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Parity{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, Name = "Home", OrderIndex = 0 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync($"/rooms/{roomId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET /rooms/{roomId} -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
AssertKeyParity(RoomDetailsReader, body, $"GET rooms/{roomId}");
|
||||
|
||||
// The reader walks into the nested objects too, and a wrong key set
|
||||
// there fails exactly the same way — a 200 the client throws on.
|
||||
var details = JsonDocument.Parse(body).RootElement;
|
||||
foreach (var (property, reader) in NestedReaders)
|
||||
{
|
||||
if (!details.TryGetProperty(property, out var value)) continue;
|
||||
|
||||
var element = value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array when value.GetArrayLength() > 0 => value[0],
|
||||
JsonValueKind.Object => value,
|
||||
_ => default,
|
||||
};
|
||||
if (element.ValueKind != JsonValueKind.Object) continue;
|
||||
|
||||
AssertKeyParity(reader, element.GetRawText(),
|
||||
$"GET rooms/{roomId} → {property}{(value.ValueKind == JsonValueKind.Array ? "[0]" : "")}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nested objects in the room-details payload and the reader each
|
||||
/// one is deserialised by. Sub-room 0, the first role, the stats object and
|
||||
/// the first tag all have their own generated readers, and a key set that
|
||||
/// disagrees with them breaks the whole room just as hard as a wrong
|
||||
/// top-level one.</summary>
|
||||
private static readonly (string Property, string Reader)[] NestedReaders =
|
||||
[
|
||||
("SubRooms", "JDEKAFDPGBE"),
|
||||
("Roles", "LMEECKCCPHG"),
|
||||
("Stats", "MLHBPDKBABN"),
|
||||
("Tags", "GEGADPCDGBO"),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Chat_thread_carries_exactly_the_keys_the_client_reads()
|
||||
{
|
||||
using var client = ChatClient();
|
||||
using var otherClient = ChatClient();
|
||||
var starter = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
|
||||
|
||||
using var form = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
|
||||
new KeyValuePair<string, string>("messageCount", "50"),
|
||||
]);
|
||||
using var response = await client.PostAsync("/thread/withmembers", form);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"POST /thread/withmembers -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers");
|
||||
}
|
||||
|
||||
/// <summary>Compare one response object's keys against the reader's set.
|
||||
/// Matching is case-insensitive because the readers register three spellings
|
||||
/// per field (Pascal, camel, lower) and accept any of them — casing is the
|
||||
/// one thing they are NOT strict about.</summary>
|
||||
private static void AssertKeyParity(string readerType, string body, string what)
|
||||
{
|
||||
var expected = LoadReaderKeys(readerType)
|
||||
.Where(k => !DeliberatelyOmitted.Contains((readerType, k)))
|
||||
.ToList();
|
||||
var actual = JsonDocument.Parse(body).RootElement
|
||||
.EnumerateObject().Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var missing = expected.Where(k => !actual.Contains(k)).ToList();
|
||||
var extra = actual
|
||||
.Where(k => !expected.Contains(k, StringComparer.OrdinalIgnoreCase))
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
Assert.True(missing.Count == 0 && extra.Count == 0,
|
||||
$"""
|
||||
{what} does not match what the client's {readerType} reader expects.
|
||||
|
||||
MISSING ({missing.Count}) — the reader registers these and we never send them;
|
||||
a non-nullable field left null is what throws mid-deserialise:
|
||||
{string.Join(", ", missing)}
|
||||
|
||||
UNEXPECTED ({extra.Count}) — we send these and the reader has no slot for them:
|
||||
{string.Join(", ", extra)}
|
||||
|
||||
Expected, in the reader's own registration order:
|
||||
{string.Join(", ", expected)}
|
||||
""");
|
||||
}
|
||||
|
||||
private static List<string> LoadReaderKeys(string readerType)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-dto-keys-2023.json");
|
||||
Assert.True(File.Exists(path),
|
||||
$"missing {path} — regenerate with tools/extract-client-dto-keys.py");
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
Assert.True(doc.RootElement.TryGetProperty(readerType, out var keys),
|
||||
$"{readerType} is not in the extracted catalogue");
|
||||
return keys.EnumerateArray().Select(k => k.GetString()!).ToList();
|
||||
}
|
||||
|
||||
private HttpClient RoomsClient() => ClientFor("rooms");
|
||||
|
||||
private HttpClient ChatClient() => ClientFor("chat");
|
||||
|
||||
private HttpClient ClientFor(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that every HTTP request the Rec Room 2023-03-21 client can issue has
|
||||
/// a handler on this server, under the verb the client actually uses.
|
||||
///
|
||||
/// Rec Room is shut down, so the client can't be observed against a live server.
|
||||
/// <c>data/client-routes-2023.tsv</c> is the recovered inventory of what it
|
||||
/// asks for — see <c>docs/recroom-2023-client-api-complete.md</c> for how it was
|
||||
/// extracted. This test is the regression guard on that inventory: adding a
|
||||
/// client route without a handler, or moving a handler to a different verb,
|
||||
/// fails here rather than silently 404/405-ing a real player.
|
||||
///
|
||||
/// Routes not yet implemented live in <see cref="KnownGaps"/>. That list is
|
||||
/// expected to shrink and never grow. Deleting an entry as you implement it is
|
||||
/// the point — it is the project's running score against the client.
|
||||
/// </summary>
|
||||
public sealed class ClientRouteCoverageTests
|
||||
{
|
||||
/// <summary>Client routes with no handler yet. Each entry is
|
||||
/// <c>VERB route</c> exactly as it appears in the inventory file.
|
||||
///
|
||||
/// This is EMPTY, and that is the point: every request the 2023-03-21
|
||||
/// client can issue reaches a handler under the verb it actually uses. Do
|
||||
/// not add an entry to make a red build green — an entry here is a promise
|
||||
/// that the client gets a 404 or 405 in the wild. Implement the route, or
|
||||
/// if the inventory itself is wrong (a literal that is really a cache key,
|
||||
/// a deeplink, or a base the client concatenates onto), fix the inventory
|
||||
/// so the claim stays honest.</summary>
|
||||
private static readonly HashSet<string> KnownGaps = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void Every_client_route_has_a_handler()
|
||||
{
|
||||
var client = LoadClientRoutes();
|
||||
Assert.NotEmpty(client);
|
||||
|
||||
var discovered = EndpointContractDiscovery.Discover()
|
||||
.Select(r => (Verb: r.Method.ToUpperInvariant(), Pattern: Normalise(r.Template)))
|
||||
.ToList();
|
||||
|
||||
var server = discovered.ToHashSet();
|
||||
|
||||
// A bare [Route] with no verb attribute serves every verb.
|
||||
var verbless = discovered
|
||||
.Where(d => d.Verb == "ANY")
|
||||
.Select(d => d.Pattern)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var serverAnyVerb = discovered.Select(s => s.Pattern).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = new List<string>();
|
||||
var wrongVerb = new List<string>();
|
||||
|
||||
foreach (var (verb, route, _) in client)
|
||||
{
|
||||
var pattern = Normalise(route);
|
||||
if (server.Contains((verb, pattern)) || verbless.Contains(pattern)) continue;
|
||||
|
||||
// Distinguish "no handler at all" from "handler exists, wrong verb" —
|
||||
// they have different causes and different fixes.
|
||||
if (serverAnyVerb.Contains(pattern)) wrongVerb.Add($"{verb} {route}");
|
||||
else missing.Add($"{verb} {route}");
|
||||
}
|
||||
|
||||
var gaps = missing.Concat(wrongVerb)
|
||||
.Where(g => !KnownGaps.Contains(g))
|
||||
.OrderBy(g => g, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var covered = client.Count - missing.Count - wrongVerb.Count;
|
||||
var pct = 100.0 * covered / client.Count;
|
||||
|
||||
Assert.True(gaps.Count == 0,
|
||||
$"""
|
||||
Client route coverage: {covered}/{client.Count} ({pct:F1}%).
|
||||
{missing.Count} missing, {wrongVerb.Count} wrong-verb, {KnownGaps.Count} known gaps.
|
||||
|
||||
Unlisted gaps ({gaps.Count}) — implement them, or add to KnownGaps with a reason:
|
||||
{string.Join(Environment.NewLine, gaps)}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Collapse a route to a comparable shape: no host, no leading or
|
||||
/// trailing slash, no query, and every parameter — the client's <c>{0}</c>
|
||||
/// and the server's <c>{id:long}</c> alike — reduced to <c>*</c>.</summary>
|
||||
private static string Normalise(string route)
|
||||
{
|
||||
var r = route.Trim();
|
||||
var q = r.IndexOf('?');
|
||||
if (q >= 0) r = r[..q];
|
||||
r = r.Trim('/', '~');
|
||||
r = Regex.Replace(r, @"\{[^}]*\}", "*");
|
||||
return r.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static List<(string Verb, string Route, string Subsystem)> LoadClientRoutes()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-routes-2023.tsv");
|
||||
Assert.True(File.Exists(path), $"missing route inventory at {path}");
|
||||
|
||||
var rows = new List<(string, string, string)>();
|
||||
foreach (var line in File.ReadLines(path))
|
||||
{
|
||||
if (line.Length == 0 || line[0] == '#') continue;
|
||||
var parts = line.Split('\t');
|
||||
if (parts.Length < 2) continue;
|
||||
rows.Add((parts[0].Trim().ToUpperInvariant(), parts[1].Trim(),
|
||||
parts.Length > 2 ? parts[2].Trim() : string.Empty));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
@@ -17,10 +17,16 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
|
||||
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Recovered client route inventory read by ClientRouteCoverageTests. -->
|
||||
<Content Include="data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -38,8 +38,8 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
|
||||
["S3:Endpoint"] = "",
|
||||
["S3:AccessKey"] = "",
|
||||
["S3:SecretKey"] = "",
|
||||
["DorkNet:DefaultClientVersion"] = "december_2020_12_18",
|
||||
["DorkNet:SupportedVersions:0"] = "december_2020_12_18",
|
||||
["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
|
||||
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using DorkNet.Server.Controllers.Site;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>The site's downloads proxy: off without config, only on the site's
|
||||
/// own host, serves only files the manifest lists, and passes Range through so
|
||||
/// the browser Quest installer can read an .xapk in place.</summary>
|
||||
public sealed class DownloadsControllerTests
|
||||
{
|
||||
private const string Apex = "dorknet.example.com";
|
||||
|
||||
private const string Manifest = """
|
||||
{"releases":[{"id":"2023.03","title":"March 2023","files":[
|
||||
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_base_url_the_manifest_is_404()
|
||||
{
|
||||
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
|
||||
|
||||
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("api.dorknet.example.com")]
|
||||
[InlineData("cdn.dorknet.example.com")]
|
||||
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
|
||||
{
|
||||
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
|
||||
ctx.Request.Host = new HostString(host);
|
||||
|
||||
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
||||
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
|
||||
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_listed_paths_are_found()
|
||||
{
|
||||
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
|
||||
|
||||
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
|
||||
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
|
||||
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlisted_file_is_404_and_never_fetched()
|
||||
{
|
||||
var requested = new List<string>();
|
||||
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
|
||||
{
|
||||
requested.Add(req.RequestUri!.AbsolutePath);
|
||||
return Json(Manifest);
|
||||
});
|
||||
|
||||
await controller.GetFile("index.html", CancellationToken.None);
|
||||
|
||||
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
|
||||
Assert.Equal(["/manifest.json"], requested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Range_is_forwarded_and_the_partial_response_streamed()
|
||||
{
|
||||
HttpRequestMessage? fileRequest = null;
|
||||
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
|
||||
fileRequest = req;
|
||||
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
|
||||
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
|
||||
return res;
|
||||
}, host: "downloads.example.com");
|
||||
ctx.Request.Headers.Range = "bytes=2-5";
|
||||
|
||||
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(fileRequest);
|
||||
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
|
||||
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
|
||||
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
|
||||
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
|
||||
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
|
||||
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
|
||||
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
|
||||
}
|
||||
|
||||
private static (DownloadsController, HttpContext) Create(
|
||||
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["DorkNet:Downloads:BaseUrl"] = baseUrl,
|
||||
["DorkNet:Downloads:Host"] = host,
|
||||
}).Build();
|
||||
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Host = new HostString(Apex);
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
var controller = new DownloadsController(service, new DomainConfig(Apex))
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = ctx },
|
||||
};
|
||||
return (controller, ctx);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string body) =>
|
||||
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||
|
||||
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
=> Task.FromResult(handler(request));
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,20 @@ public static partial class EndpointContractDiscovery
|
||||
{
|
||||
var httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<HttpMethodAttribute>()
|
||||
.Cast<IRouteTemplateProvider>()
|
||||
.ToArray();
|
||||
|
||||
// An action can carry a bare [Route("...")] with no [HttpGet]/[HttpPost],
|
||||
// which routes ALL verbs — the CDN's unity_assets handler is the live
|
||||
// example. Skipping those made the discovered table under-report the
|
||||
// server, so route-coverage checks saw phantom gaps.
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Template))
|
||||
.ToArray();
|
||||
}
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
continue;
|
||||
@@ -44,8 +57,8 @@ public static partial class EndpointContractDiscovery
|
||||
|
||||
foreach (var attr in httpAttrs)
|
||||
{
|
||||
var attrMethods = attr.HttpMethods.Any()
|
||||
? attr.HttpMethods
|
||||
var attrMethods = attr is HttpMethodAttribute { HttpMethods: var verbs } && verbs.Any()
|
||||
? verbs
|
||||
: new[] { "ANY" };
|
||||
|
||||
foreach (var httpMethod in attrMethods)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Website / admin-panel sign-in with an external OpenID Connect provider.
|
||||
/// The provider round-trip can't run in-process, so each test starts where the
|
||||
/// provider callback leaves off: a <c>DorkNet.External</c> cookie holding the
|
||||
/// provider identity, encrypted with the app's own cookie format. Everything
|
||||
/// from there (link, session, admin SSO, unlink, limits) is the real code.
|
||||
/// </summary>
|
||||
public sealed class ExternalSignInTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private const string Issuer = "https://idp.example.com";
|
||||
// Test player ids: sequential, in a range no other test uses.
|
||||
private static long _nextPlayerId = 9_600_000;
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
var subject = Guid.NewGuid().ToString("N");
|
||||
|
||||
using var site = Client();
|
||||
var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(subject, "player@example.com"));
|
||||
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||
var session = SessionCookie(link);
|
||||
Assert.NotNull(session);
|
||||
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.True(me.GetProperty("signedIn").GetBoolean());
|
||||
Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString());
|
||||
Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString());
|
||||
|
||||
// Next time the provider identity alone is enough.
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var signIn = scope.ServiceProvider.GetRequiredService<ExternalSignInService>();
|
||||
var stored = await signIn.FindAsync(Issuer, subject);
|
||||
Assert.Equal(player.Id, stored?.PlayerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_passwords_are_refused_then_rate_limited()
|
||||
{
|
||||
var (player, _) = await PlayerAsync();
|
||||
var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com");
|
||||
using var site = Client();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
using var r = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password = "wrong-" + i }, external);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
|
||||
}
|
||||
using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password = "still-wrong" }, external);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another()
|
||||
{
|
||||
var (first, firstPassword) = await PlayerAsync();
|
||||
var (second, secondPassword) = await PlayerAsync();
|
||||
var subject = Guid.NewGuid().ToString("N");
|
||||
using var site = Client();
|
||||
|
||||
using var ok = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com"));
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
using var refused = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com"));
|
||||
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_sso_issues_tokens_only_to_admins()
|
||||
{
|
||||
var (admin, adminPassword) = await PlayerAsync(isAdmin: true);
|
||||
var (regular, regularPassword) = await PlayerAsync();
|
||||
using var admins = Client("admin");
|
||||
|
||||
var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||
new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession);
|
||||
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
|
||||
var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString()));
|
||||
Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64());
|
||||
|
||||
var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||
new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||
|
||||
using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlinking_ends_the_session()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
|
||||
using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session);
|
||||
Assert.Equal(HttpStatusCode.OK, unlink.StatusCode);
|
||||
|
||||
// The old cookie is still in the browser, but the link behind it is gone.
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_banned_player_cannot_link_and_loses_an_existing_session()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.Players.Where(p => p.Id == player.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1)));
|
||||
}
|
||||
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||
using var relink = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cookie_keys_are_stored_encrypted()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
using var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var stored = await db.DataProtectionKeys.Select(k => k.Xml).ToListAsync();
|
||||
Assert.NotEmpty(stored);
|
||||
Assert.All(stored, xml =>
|
||||
{
|
||||
Assert.Contains("dorknetEncryptedKey", xml);
|
||||
Assert.DoesNotContain("masterKey", xml);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_encryption_round_trips_and_rejects_another_secret()
|
||||
{
|
||||
var element = new System.Xml.Linq.XElement("key", new System.Xml.Linq.XElement("masterKey", "secret-bytes"));
|
||||
var encrypted = new DataProtectionKeyEncryption("first-secret").Encrypt(element).EncryptedElement;
|
||||
Assert.DoesNotContain("secret-bytes", encrypted.ToString());
|
||||
Assert.Equal(element.ToString(), new DataProtectionKeyEncryption("first-secret").DecryptElement(encrypted).ToString());
|
||||
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
|
||||
() => new DataProtectionKeyEncryption("other-secret").DecryptElement(encrypted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Downloads_can_require_sign_in()
|
||||
{
|
||||
await using var factory = _factory.WithWebHostBuilder(b =>
|
||||
{
|
||||
b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9");
|
||||
b.UseSetting("DorkNet:Downloads:RequireSignIn", "true");
|
||||
});
|
||||
using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") });
|
||||
using var manifest = await site.GetAsync("/api/site/v1/downloads");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode);
|
||||
using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false)
|
||||
{
|
||||
var password = "pw-" + Guid.NewGuid().ToString("N")[..12];
|
||||
var player = new PlayerEntity
|
||||
{
|
||||
Id = Interlocked.Increment(ref _nextPlayerId),
|
||||
Username = "sso" + Guid.NewGuid().ToString("N")[..10],
|
||||
DisplayName = "SSO test",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||
IsAdmin = isAdmin,
|
||||
};
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
return (player, password);
|
||||
}
|
||||
|
||||
/// <summary>The cookie the provider callback leaves behind: the provider
|
||||
/// identity in the DorkNet.External scheme, in the app's cookie format.</summary>
|
||||
private string ExternalCookie(string subject, string email)
|
||||
{
|
||||
var options = _factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
|
||||
.Get(ExternalSignIn.ExternalScheme);
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email),
|
||||
}, "test");
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme);
|
||||
return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}";
|
||||
}
|
||||
|
||||
private static string? SessionCookie(HttpResponseMessage response) =>
|
||||
response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length)
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string? subdomain = null)
|
||||
{
|
||||
var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}";
|
||||
return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false });
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, path);
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string path, object body, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, string? cookie)
|
||||
{
|
||||
using var response = await SendAsync(client, HttpMethod.Get, path, cookie);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ public sealed class GameClientEndpointContractTests :
|
||||
[Fact]
|
||||
public async Task Game_client_contract_probe_reaches_every_controller_route()
|
||||
{
|
||||
var routes = EndpointContractDiscovery.Discover();
|
||||
var routes = EndpointContractDiscovery.Discover()
|
||||
.Where(r => !DestroysTheProbeSession(r))
|
||||
.ToList();
|
||||
Assert.NotEmpty(routes);
|
||||
|
||||
using var client = _factory.CreateClient(new()
|
||||
@@ -48,6 +50,31 @@ public sealed class GameClientEndpointContractTests :
|
||||
+ string.Join(Environment.NewLine, failures));
|
||||
}
|
||||
|
||||
/// <summary>Routes this probe must not call, because they destroy the very
|
||||
/// session it authenticates with.
|
||||
///
|
||||
/// The probe walks every route in order using one test player. Account
|
||||
/// deletion is a genuine, correctly-guarded purge — but once it runs, that
|
||||
/// player row is gone, so every later probe fails on a foreign-key
|
||||
/// violation or a 401. Excluding these keeps the failure list meaningful
|
||||
/// instead of one real event cascading into dozens of phantom failures.
|
||||
///
|
||||
/// This is deliberately narrow: only self-destruct routes belong here, not
|
||||
/// anything merely inconvenient.</summary>
|
||||
private static bool DestroysTheProbeSession(EndpointContract route)
|
||||
{
|
||||
if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
// Case-insensitive to match the method comparison above: a route
|
||||
// declared "Account/v1/me" would otherwise slip through and delete
|
||||
// the probe player mid-run.
|
||||
var template = route.Template.Trim('/');
|
||||
return template.Equals("account/me", StringComparison.OrdinalIgnoreCase)
|
||||
|| template.Equals("account/v1/me", StringComparison.OrdinalIgnoreCase)
|
||||
|| template.Equals("account/v2/me", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static async Task<EndpointProbeResult> ProbeAsync(
|
||||
HttpClient client,
|
||||
EndpointContract route,
|
||||
@@ -61,8 +88,8 @@ public sealed class GameClientEndpointContractTests :
|
||||
ToHttpMethod(route.Method),
|
||||
new Uri($"http://{host}{path}"));
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
if (NeedsRequestBody(request.Method))
|
||||
|
||||
@@ -21,8 +21,8 @@ public static class GameClientSessionFactory
|
||||
HttpMethod.Post,
|
||||
new Uri($"http://auth.{apexDomain}/connect/token"));
|
||||
tokenRequest.Headers.Accept.ParseAdd("application/json");
|
||||
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
@@ -49,8 +49,8 @@ public static class GameClientSessionFactory
|
||||
HttpMethod.Get,
|
||||
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
|
||||
userInfoRequest.Headers.Accept.ParseAdd("application/json");
|
||||
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using var userInfoResponse = await client.SendAsync(userInfoRequest);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET api/incentivizedreferrals/referrals?take=&continuationToken=</c> is
|
||||
/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737),
|
||||
/// so it never showed up as a route literal and had no handler. The reply is the
|
||||
/// GPJIMAHMAIO page: <c>{Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
|
||||
/// ContinuationToken}</c> (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
|
||||
/// </summary>
|
||||
public sealed class IncentivizedReferralsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token()
|
||||
{
|
||||
using var setup = Client();
|
||||
var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
// Same row LinkController.CreditReferralAsync writes on the invitee.
|
||||
foreach (var invitee in new[] { inviteeA, inviteeB })
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = invitee.PlayerId,
|
||||
Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}",
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = Client(referrer);
|
||||
var first = JsonDocument.Parse(
|
||||
await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement;
|
||||
var firstPage = Get(first, "Referrals");
|
||||
Assert.Equal(1, firstPage.GetArrayLength());
|
||||
var token = Get(first, "ContinuationToken").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
var second = JsonDocument.Parse(await client.GetStringAsync(
|
||||
$"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement;
|
||||
var secondPage = Get(second, "Referrals");
|
||||
Assert.Equal(1, secondPage.GetArrayLength());
|
||||
Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind);
|
||||
|
||||
var ids = new[] { firstPage[0], secondPage[0] }
|
||||
.Select(e => Get(e, "InviteeAccountId").GetInt64())
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids);
|
||||
// CreatedAt is a non-nullable DateTime on the client: it must be present.
|
||||
Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind);
|
||||
}
|
||||
|
||||
// The client formatter registers PascalCase, camelCase and lowercase.
|
||||
private static JsonElement Get(JsonElement e, string pascal) =>
|
||||
e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]);
|
||||
|
||||
private HttpClient Client(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Net;
|
||||
using DorkNet.Contracts;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the 2023 "pull a player into a private instance"
|
||||
/// flow. The 2023 client renamed the matchmaking surface from <c>goto/*</c>
|
||||
/// to <c>matchmake/*</c> and added <c>matchmake/chatinvite/{account}/{instance}</c>
|
||||
/// for chat party invites; the server had neither the routes nor the gateway
|
||||
/// ownership entry, so those requests 404'd.
|
||||
/// </summary>
|
||||
public sealed class MatchmakePullInviteTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public MatchmakePullInviteTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/matchmake/instance/999")]
|
||||
[InlineData("/matchmake/invite/999")]
|
||||
[InlineData("/matchmake/chatinvite/2/16777216")] // {accountId}/{roomInstanceId}
|
||||
[InlineData("/matchmake/player/2")]
|
||||
[InlineData("/matchmake/event/999")]
|
||||
[InlineData("/matchmake/club/999")]
|
||||
[InlineData("/matchmake/code/5/ABCDEF")] // {roomId}/{code}
|
||||
public async Task Matchmake_pull_routes_exist(string path)
|
||||
{
|
||||
using var client = ApiClient();
|
||||
using var res = await client.PostAsync(path, new StringContent(string.Empty));
|
||||
|
||||
// The bug was a hard 404 (no route). These resolve to a dorm-fallback
|
||||
// matchmaking response when the instance/invite doesn't exist, so any
|
||||
// non-404 status proves the route is now mounted.
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, res.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/matchmake/instance/999")]
|
||||
[InlineData("/matchmake/invite/999")]
|
||||
[InlineData("/matchmake/chatinvite/2/16777216")]
|
||||
[InlineData("/matchmake/room/5")]
|
||||
[InlineData("/matchmake/player/2")]
|
||||
[InlineData("/matchmake/event/999")]
|
||||
[InlineData("/matchmake/club/999")]
|
||||
[InlineData("/matchmake/code/5/ABCDEF")]
|
||||
public void Matchmake_paths_route_to_same_service_as_goto(string path)
|
||||
{
|
||||
const string apex = "dork.test";
|
||||
var gotoOwner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", "/goto/instance/999", apex);
|
||||
var owner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", path, apex);
|
||||
|
||||
// matchmake/* must land on the same service that owns goto/* — before
|
||||
// the fix it fell through to the Monolith default owner.
|
||||
Assert.Equal(gotoOwner, owner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.API.Inventory;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Hubs;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Push frames checked against the 2023-03-21 client's notification readers
|
||||
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
|
||||
/// JSON string <c>{"Id":"<key>","Msg":{…}}</c>; the client looks the Id
|
||||
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
|
||||
/// wrong key or shape drops the push without any error.
|
||||
/// </summary>
|
||||
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
using var setup = ApiClient(factory);
|
||||
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
using var client = ApiClient(factory, sender);
|
||||
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
// RelationshipChanged "1" = the Relationship row from each side's view.
|
||||
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
|
||||
Assert.Equal("Notification", toTarget.Method);
|
||||
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
|
||||
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
|
||||
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
|
||||
|
||||
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
|
||||
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
|
||||
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
|
||||
|
||||
// MessageReceived "2" — the "X sent you a friend request" inbox row.
|
||||
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
|
||||
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
|
||||
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
|
||||
|
||||
using var targetClient = ApiClient(factory, target);
|
||||
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
|
||||
.Msg.GetProperty("RelationshipType").GetInt32());
|
||||
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
|
||||
.Msg.GetProperty("RelationshipType").GetInt32());
|
||||
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
|
||||
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
using var setup = ApiClient(factory);
|
||||
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
using var client = ApiClient(factory, me);
|
||||
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
|
||||
|
||||
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
|
||||
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
|
||||
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
|
||||
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||
|
||||
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
|
||||
|
||||
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
|
||||
var msg = frame.Msg;
|
||||
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
|
||||
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
|
||||
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
|
||||
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
|
||||
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
|
||||
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
|
||||
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
|
||||
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
|
||||
[InlineData(NotificationService.LogoutReason.Banned, 4)]
|
||||
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||
|
||||
await notifications.LogoutAsync(43, reason);
|
||||
|
||||
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
|
||||
var value = frame.Msg.GetProperty("Reason");
|
||||
Assert.Equal(JsonValueKind.Number, value.ValueKind);
|
||||
Assert.Equal(wire, value.GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||
|
||||
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
|
||||
excludeConnectionIds: ["self-conn", "friend-conn"]);
|
||||
|
||||
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
|
||||
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
|
||||
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
|
||||
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
|
||||
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
|
||||
[InlineData("""[6,7]""", new long[] { 6, 7 })]
|
||||
[InlineData("""{"PlayerIds":[]}""", new long[0])]
|
||||
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
|
||||
{
|
||||
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
|
||||
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
|
||||
{
|
||||
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consumable_mapping_push_has_exactly_the_clients_keys()
|
||||
{
|
||||
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
|
||||
{
|
||||
Id = 9,
|
||||
ItemSlug = "some-consumable",
|
||||
Quantity = 3,
|
||||
}));
|
||||
|
||||
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
|
||||
Assert.Equal(
|
||||
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
|
||||
keys);
|
||||
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
|
||||
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
|
||||
}
|
||||
|
||||
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
|
||||
{
|
||||
var hub = new CapturingHubContext();
|
||||
var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHubContext<NotifyHub>>();
|
||||
services.AddSingleton<IHubContext<NotifyHub>>(hub);
|
||||
}));
|
||||
return (factory, hub);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(WebApplicationFactory<Program> factory, GameClientSession? session = null)
|
||||
{
|
||||
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read
|
||||
/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter
|
||||
/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens,
|
||||
/// the locked-item lookup, the owned list's per-entry ids, and the gift
|
||||
/// generate call the menu's gift flow makes.
|
||||
/// </summary>
|
||||
public sealed class OutfitMenu2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Creation_allowed_gate_is_a_success_error_envelope()
|
||||
{
|
||||
// MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error}
|
||||
// (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged
|
||||
// "Failed to query if local account can create Custom Avatar Items".
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount");
|
||||
Assert.Equal(JsonValueKind.Object, body.ValueKind);
|
||||
Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sibling_gates_and_min_price_are_bare_scalars()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind);
|
||||
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind);
|
||||
|
||||
// <GetPricingConfig>b__69_0(Int32 minimumPrice) — a bare integer.
|
||||
var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem");
|
||||
Assert.Equal(JsonValueKind.Number, min.ValueKind);
|
||||
Assert.True(min.GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Saved_outfit_round_trips_every_client_key()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var shirt = Guid.NewGuid();
|
||||
// Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits.
|
||||
var outfit = new Dictionary<string, object?>
|
||||
{
|
||||
["Slot"] = 3,
|
||||
["PreviewImageName"] = "preview-3.jpg",
|
||||
["OutfitSelections"] = "",
|
||||
["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}",
|
||||
["FaceFeatures"] = "{\"ver\":3}",
|
||||
["SkinColor"] = "skin-guid",
|
||||
["HairColor"] = "hair-guid",
|
||||
["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } },
|
||||
};
|
||||
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
||||
|
||||
var list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
||||
var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
||||
Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString());
|
||||
Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
||||
Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString());
|
||||
Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString());
|
||||
Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString());
|
||||
var items = saved.GetProperty("CustomAvatarItems");
|
||||
Assert.Equal(1, items.GetArrayLength());
|
||||
Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid());
|
||||
Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32());
|
||||
|
||||
// Overwriting the slot replaces it rather than adding a second row.
|
||||
outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}";
|
||||
outfit["CustomAvatarItems"] = Array.Empty<object>();
|
||||
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
||||
list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
||||
saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
||||
Assert.Equal("{\"v\":2,\"items\":[\"c\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
||||
Assert.Equal(0, saved.GetProperty("CustomAvatarItems").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gift_generate_accepts_the_clients_form_body()
|
||||
{
|
||||
// Live trace: this exact form body was answered 415 under [FromBody].
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
using var response = await client.PostAsync("/api/avatar/v2/gifts/generate",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["GiftContext"] = "12000",
|
||||
["IsGameGift"] = "True",
|
||||
["Message"] = "Activity completed!",
|
||||
}));
|
||||
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.True(doc.RootElement.GetProperty("Id").GetInt64() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locked_items_carry_the_full_owned_item_shape()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
HashSet<string> owned;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId)
|
||||
.Select(a => a.InventoryJson).FirstAsync();
|
||||
owned = (JsonSerializer.Deserialize<List<string>>(inv) ?? new())
|
||||
.Select(d => d.Replace("wardrobe-", "").Split(',')[0])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
var notOwned = AvatarItemsController.Catalog.Keys
|
||||
.Where(g => Guid.TryParse(g, out _) && !owned.Contains(g)).Take(3).ToList();
|
||||
Assert.Equal(3, notOwned.Count);
|
||||
|
||||
var query = string.Join("&", notOwned.Select(g => "desc=" + Uri.EscapeDataString(g + ",,,")));
|
||||
var rows = await GetJsonAsync(client, "/api/avatar/v1/lockeditems?" + query);
|
||||
Assert.Equal(3, rows.GetArrayLength());
|
||||
var ids = new List<int>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
// Same IFNGBPJGINL keys v4/items carries (CLIANCNHNIE reader).
|
||||
Assert.Equal(JsonValueKind.String, row.GetProperty("TagList").ValueKind);
|
||||
Assert.True(row.GetProperty("IsBaseAvatarItem").ValueKind is JsonValueKind.True or JsonValueKind.False);
|
||||
ids.Add(row.GetProperty("AvatarItemId").GetInt32());
|
||||
}
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owning_a_base_item_and_its_colorways_yields_unique_ids()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
// Two colorways of one base item that the runtime catalog knows.
|
||||
var group = StoreService.ColorVariantCatalog.Values
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.descriptor)
|
||||
&& AvatarItemsController.Catalog.ContainsKey(v.item_guid))
|
||||
.GroupBy(v => v.item_guid, StringComparer.OrdinalIgnoreCase)
|
||||
.First(g => g.Select(v => v.descriptor).Distinct().Count() >= 2);
|
||||
var baseGuid = group.Key;
|
||||
var variants = group.Select(v => v.descriptor).Distinct().Take(2).ToList();
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
inv.Add(baseGuid);
|
||||
inv.AddRange(variants);
|
||||
avatar.InventoryJson = JsonSerializer.Serialize(inv);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var sameBase = items.EnumerateArray()
|
||||
.Where(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]
|
||||
.Equals(baseGuid, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
Assert.Equal(3, sameBase.Count);
|
||||
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task PostJsonAsync(HttpClient client, string path, object body)
|
||||
{
|
||||
using var response = await client.PostAsync(path,
|
||||
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// api/avatar/v4/items — the owned/unlocked wardrobe. The 2023 client's
|
||||
/// owned-item DTO (RecNet IFNGBPJGINL) requires <c>AvatarItemId</c> (Int32),
|
||||
/// <c>TagList</c> (string) and <c>IsBaseAvatarItem</c> (bool). Omitting them
|
||||
/// left every item at AvatarItemId 0; the client keys the wardrobe by that
|
||||
/// id, so the second item collided, the unlocked-items list failed to build
|
||||
/// (empty wardrobe = "no owned items"), and any refetch — including the one
|
||||
/// after a purchase — NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew
|
||||
/// ("can't buy items"). Each item must carry a unique, non-zero id.
|
||||
/// </summary>
|
||||
public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public OwnedAvatarItemsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Owned_items_carry_unique_nonzero_ids_and_2023_keys()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
Assert.True(items.GetArrayLength() > 0, "starter wardrobe was empty");
|
||||
|
||||
var ids = new List<int>();
|
||||
foreach (var it in items.EnumerateArray())
|
||||
{
|
||||
// Every 2023 key present and correctly typed.
|
||||
Assert.Equal(JsonValueKind.Number, it.GetProperty("AvatarItemId").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, it.GetProperty("TagList").ValueKind);
|
||||
var isBase = it.GetProperty("IsBaseAvatarItem").ValueKind;
|
||||
Assert.True(isBase is JsonValueKind.True or JsonValueKind.False);
|
||||
Assert.Equal(JsonValueKind.String, it.GetProperty("AvatarItemDesc").ValueKind);
|
||||
|
||||
var id = it.GetProperty("AvatarItemId").GetInt32();
|
||||
Assert.NotEqual(0, id);
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// No duplicate ids — a collision is what crashed the wardrobe.
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purchased_item_appears_owned_with_valid_shape()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var before = (await GetJsonAsync(client, "/api/avatar/v4/items")).GetArrayLength();
|
||||
|
||||
// Buy the first avatar-backed watch-storefront item.
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
|
||||
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
|
||||
.GetProperty("PurchasableItemId").GetInt32();
|
||||
|
||||
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
|
||||
new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
StorefrontType = 3,
|
||||
PurchasableItemId = purchasableId,
|
||||
CurrencyType = 2,
|
||||
RequestedPrice = 1,
|
||||
Gift = (object?)null,
|
||||
}), Encoding.UTF8, "application/json"));
|
||||
buy.EnsureSuccessStatusCode();
|
||||
|
||||
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
Assert.True(after.GetArrayLength() >= before, "owned count shrank after purchase");
|
||||
// Still no duplicate ids after the new item joins the list.
|
||||
var ids = after.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Buy_gift_desc_matches_v4items_and_consume_grants_once()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
// Buy an avatar-backed item — the server queues a post-purchase gift box.
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
|
||||
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
|
||||
.GetProperty("PurchasableItemId").GetInt32();
|
||||
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
|
||||
new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
StorefrontType = 3, PurchasableItemId = purchasableId,
|
||||
CurrencyType = 2, RequestedPrice = 1, Gift = (object?)null,
|
||||
}), Encoding.UTF8, "application/json"));
|
||||
buy.EnsureSuccessStatusCode();
|
||||
|
||||
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
|
||||
Assert.True(gifts.GetArrayLength() > 0, "buy did not queue a gift box");
|
||||
var gift = gifts[0];
|
||||
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
|
||||
|
||||
// The gift's desc MUST be the same 4-part "{guid},,," form v4/items
|
||||
// emits — the client matches on it verbatim in
|
||||
// OutfitManager.UnlockAvatarItemAndMarkNew after the box is consumed;
|
||||
// a bare guid missed and NRE'd ("items removed on every buy").
|
||||
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"gift desc not 4-part: {giftDesc}");
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()).ToHashSet();
|
||||
Assert.Contains(giftDesc, descs);
|
||||
|
||||
// Consuming the box must not add a SECOND wardrobe row for the item
|
||||
// the direct purchase already granted as a bare guid.
|
||||
using var consume = await client.PostAsync(
|
||||
$"/api/avatar/v2/gifts/consume/{gift.GetProperty("Id").GetInt64()}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
consume.EnsureSuccessStatusCode();
|
||||
|
||||
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var baseGuid = giftDesc.Split(',')[0];
|
||||
var occurrences = after.EnumerateArray()
|
||||
.Count(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0] == baseGuid);
|
||||
Assert.Equal(1, occurrences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Legacy_bare_desc_gift_is_served_as_4part_matching_v4items()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
// Simulate a gift persisted BEFORE the fix: a bare-guid outfit desc
|
||||
// (no ",,," suffix) — the class of row that made the post-consume
|
||||
// UnlockAvatarItemAndMarkNew Find() miss and NRE.
|
||||
string bareGuid;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<Data.DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
bareGuid = item.Slug["wardrobe-".Length..];
|
||||
db.GiftPackages.Add(new Data.Entities.GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0, // outfit
|
||||
AvatarItemDescOrHairDyeDesc = bareGuid, // BARE — the legacy bug
|
||||
IsValid = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
|
||||
var gift = gifts.EnumerateArray()
|
||||
.First(g => g.GetProperty("AvatarItemDesc").GetString()!.StartsWith(bareGuid));
|
||||
|
||||
// Both desc keys are served as the 4-part form regardless of how the
|
||||
// row was stored, so the client's exact-match unlock succeeds. (A
|
||||
// real buy also grants the item, so v4/items contains it — covered by
|
||||
// Purchased_item_appears_owned / Buy_gift_desc_matches_v4items.)
|
||||
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
|
||||
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"legacy gift not normalized: {giftDesc}");
|
||||
Assert.Equal(giftDesc, gift.GetProperty("AvatarItemDescOrHairDyeDesc").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggle_on_wardrobe_includes_color_variants_with_unique_ids()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var settings = scope.ServiceProvider
|
||||
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>();
|
||||
await settings.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = ApiClient(player);
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
|
||||
// Variant descs ("{guid},{combo},{mask},") — the colorways — are
|
||||
// present, not just base "{guid},,," rows; and every id is unique
|
||||
// (base + variants share a base guid, so a base-guid hash would
|
||||
// collide → wardrobe dict crash).
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
var hasVariant = descs.Any(d =>
|
||||
{
|
||||
var p = d.Split(',');
|
||||
return p.Length >= 2 && p[1].Length > 0;
|
||||
});
|
||||
Assert.True(hasVariant, "toggle-on wardrobe has no colored variants");
|
||||
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owned_items_carry_the_same_name_tooltip_and_rarity_as_their_shop_tile()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||
try
|
||||
{
|
||||
using var client = ApiClient(player);
|
||||
var owned = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var shop = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
|
||||
var tiles = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
foreach (var row in shop.GetProperty("StoreItems").EnumerateArray())
|
||||
{
|
||||
var drop = row.GetProperty("GiftDrop");
|
||||
var desc = drop.GetProperty("AvatarItemDesc").GetString();
|
||||
if (!string.IsNullOrEmpty(desc)) tiles.TryAdd(desc, drop);
|
||||
}
|
||||
|
||||
var matched = 0;
|
||||
foreach (var item in owned.EnumerateArray())
|
||||
{
|
||||
if (!tiles.TryGetValue(item.GetProperty("AvatarItemDesc").GetString()!, out var tile)) continue;
|
||||
matched++;
|
||||
Assert.Equal(tile.GetProperty("FriendlyName").GetString(), item.GetProperty("FriendlyName").GetString());
|
||||
Assert.Equal(tile.GetProperty("Tooltip").GetString(), item.GetProperty("Tooltip").GetString());
|
||||
Assert.Equal(tile.GetProperty("Rarity").GetInt32(), item.GetProperty("Rarity").GetInt32());
|
||||
}
|
||||
Assert.True(matched > 0, "no owned item matched a shop tile");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Base_avatar_items_are_only_the_custom_clothing_templates()
|
||||
{
|
||||
// The client flags every defaultbaseavataritems row as a custom-
|
||||
// clothing template and leaves it out of the owned rebuild, so a
|
||||
// normal item listed here showed a price even when owned.
|
||||
using var anonymous = ApiClient();
|
||||
var rows = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultbaseavataritems");
|
||||
var byDesc = rows.EnumerateArray().ToDictionary(
|
||||
r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0],
|
||||
r => r);
|
||||
Assert.Equal(2, byDesc.Count);
|
||||
Assert.Equal(2184, byDesc["c5d70cb4-71dd-4fe4-b719-34fe2073c611"].GetProperty("AvatarItemId").GetInt32());
|
||||
Assert.Equal(2918, byDesc["95a519de-f2cb-429c-b014-508477f20d42"].GetProperty("AvatarItemId").GetInt32());
|
||||
Assert.All(byDesc.Values, r => Assert.True(r.GetProperty("IsBaseAvatarItem").GetBoolean()));
|
||||
|
||||
// Templates aren't sold or listed as owned clothing.
|
||||
var shop = await GetJsonAsync(anonymous, "/api/storefronts/v3/giftdropstore/3");
|
||||
var shopDescs = shop.GetProperty("StoreItems").EnumerateArray()
|
||||
.Select(r => r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString() ?? "");
|
||||
Assert.DoesNotContain(shopDescs, d => byDesc.ContainsKey(d.Split(',')[0]));
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||
try
|
||||
{
|
||||
var owned = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
||||
var ownedDescs = owned.EnumerateArray().Select(r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]).ToList();
|
||||
Assert.DoesNotContain(ownedDescs, byDesc.ContainsKey);
|
||||
Assert.Contains("3c2ed30c-ca2a-4f9f-9bfa-ffe561c4a686", ownedDescs); // 80sHipHopJacket
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Beard_Close: one of the client's defaultUnlockedVariants.
|
||||
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
|
||||
|
||||
[Fact]
|
||||
public async Task Default_unlocked_items_are_owned_by_every_player()
|
||||
{
|
||||
// The client replaces its unlocked set with each list it downloads,
|
||||
// so v4/items must carry the free default items or they show a price.
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
||||
// A default item the starter inventory also holds appears once.
|
||||
Assert.Equal(descs.Count, descs.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
|
||||
var locked = await GetJsonAsync(client, $"/api/avatar/v1/lockeditems?avatarItemDescs={DefaultItemGuid},,,");
|
||||
Assert.Equal(0, locked.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_unlocked_endpoint_serves_defaults_before_login()
|
||||
{
|
||||
using var anonymous = ApiClient();
|
||||
var items = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal(
|
||||
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.DefaultUnlockedGuids.Count,
|
||||
descs.Count);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// While a join is pending, the heartbeat must tell the joining player which
|
||||
/// instance they are joining — never null.
|
||||
///
|
||||
/// Deferring the presence commit is about what everyone ELSE sees: presence
|
||||
/// stays uncommitted until the join is confirmed so a failed join leaves no
|
||||
/// ghost behind. It must not change what the joining player is told about
|
||||
/// themselves, because the client XORs (cachedRoomInstance != null) against
|
||||
/// (serverRoomInstance != null): holding the target while the server says null
|
||||
/// reads as the room vanishing mid-join. The client then logs "presence
|
||||
/// heartbeat response indicates local presence is out-of-sync", "Attempting to
|
||||
/// go to Invalid RoomInstance", and cancels the very join the server was
|
||||
/// waiting on — so it retries until a heartbeat happens to miss the window, and
|
||||
/// entering a dorm takes several attempts while looking like a load failure.
|
||||
/// </summary>
|
||||
public sealed class PendingJoinPresenceTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined()
|
||||
{
|
||||
using var client = Client("match");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
// matchmake/dorm is what arms the deferred-commit pending join.
|
||||
using var dormForm = new FormUrlEncodedContent(
|
||||
[new KeyValuePair<string, string>("MaxPersistenceVersion", "45")]);
|
||||
using var dorm = await client.PostAsync("/matchmake/dorm", dormForm);
|
||||
var dormBody = await dorm.Content.ReadAsStringAsync();
|
||||
Assert.True(dorm.IsSuccessStatusCode,
|
||||
$"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}");
|
||||
|
||||
var expectedInstance = JsonDocument.Parse(dormBody).RootElement;
|
||||
var instanceId = FindInstanceId(expectedInstance);
|
||||
Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}");
|
||||
|
||||
// The join has NOT been reported yet, so the commit is still deferred —
|
||||
// this is exactly the window the client heartbeats in.
|
||||
using var beatForm = new FormUrlEncodedContent(
|
||||
[new KeyValuePair<string, string>("LoginLock", Guid.NewGuid().ToString())]);
|
||||
using var beat = await client.PostAsync("/player/heartbeat", beatForm);
|
||||
var beatBody = await beat.Content.ReadAsStringAsync();
|
||||
Assert.True(beat.IsSuccessStatusCode,
|
||||
$"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}");
|
||||
|
||||
var presence = JsonDocument.Parse(beatBody).RootElement;
|
||||
Assert.True(presence.TryGetProperty("roomInstance", out var reported)
|
||||
|| presence.TryGetProperty("RoomInstance", out reported),
|
||||
$"no roomInstance key in the heartbeat: {beatBody}");
|
||||
|
||||
Assert.True(reported.ValueKind != JsonValueKind.Null,
|
||||
$"""
|
||||
The heartbeat returned a null roomInstance while the player's join was
|
||||
still pending. The client reads that as the room vanishing mid-join and
|
||||
cancels the load it is waiting on:
|
||||
|
||||
{beatBody}
|
||||
""");
|
||||
|
||||
Assert.Equal(instanceId, FindInstanceId(reported));
|
||||
}
|
||||
|
||||
/// <summary>Pull roomInstanceId out of either the matchmake reply (which
|
||||
/// wraps it) or the heartbeat's roomInstance object, whatever the casing.</summary>
|
||||
private static string? FindInstanceId(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.ToString();
|
||||
|
||||
if (property.Value.ValueKind == JsonValueKind.Object
|
||||
&& FindInstanceId(property.Value) is { } nested)
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpClient Client(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Presence must follow the player through every transition, for the player
|
||||
/// themselves (the heartbeat) and for everyone else (bulk /player and the
|
||||
/// PresenceUpdate push).
|
||||
///
|
||||
/// The heartbeat side is decided by the client's compare
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): a cached instance with
|
||||
/// a negative id — the offline room — is in sync ONLY with roomInstance null,
|
||||
/// and anything else makes the client cancel what it is doing and chase the
|
||||
/// server's room. matchmake/none is how the client tells us it went to that
|
||||
/// offline room, so after it the heartbeat must stay null.
|
||||
/// </summary>
|
||||
public sealed class PresenceTransitionTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PresenceTransitionTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_to_the_offline_room_makes_the_heartbeat_report_no_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// Enter the dorm for real: matchmake, then report the join.
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
var before = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(before)));
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode, $"POST /matchmake/none -> {(int)none.StatusCode}");
|
||||
|
||||
// Twice: the first beat must not re-seed a dorm the second one returns.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
var room = RoomInstanceOf(beat);
|
||||
Assert.True(room.ValueKind == JsonValueKind.Null,
|
||||
$"""
|
||||
After matchmake/none the client holds its offline room (instance -1)
|
||||
and is only in sync with a null roomInstance. The heartbeat answered
|
||||
with a room, which makes the client cancel the offline load and
|
||||
chase it:
|
||||
|
||||
{beat.GetRawText()}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_while_a_join_is_pending_abandons_the_join()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// The client gave up on this dorm join (e.g. a Photon timeout) and fell
|
||||
// back to the offline room before reporting a result.
|
||||
await MatchmakeDormAsync(client);
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.True(RoomInstanceOf(beat).ValueKind == JsonValueKind.Null,
|
||||
$"heartbeat still echoed the abandoned join target: {beat.GetRawText()}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Matchmaking_again_after_leaving_restores_the_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
Assert.True(RoomInstanceOf(await HeartbeatAsync(client)).ValueKind == JsonValueKind.Null);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(beat)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_see_a_player_in_the_offline_room_as_online_with_no_room()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
using var none = await subject.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.True(row.GetProperty("isOnline").GetBoolean(),
|
||||
$"a player sitting in the offline room is online: {row.GetRawText()}");
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logging_out_shows_the_player_offline_and_roomless()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
Assert.True((await BulkPresenceAsync(viewer, subjectId)).GetProperty("isOnline").GetBoolean());
|
||||
|
||||
using var logout = await subject.PostAsync("/player/logout", Form());
|
||||
Assert.True(logout.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.False(row.GetProperty("isOnline").GetBoolean(), row.GetRawText());
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bulk_presence_reports_the_stored_status_visibility()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
using var put = await subject.PutAsync("/player/statusvisibility",
|
||||
Form(("statusVisibility", "2")));
|
||||
Assert.True(put.IsSuccessStatusCode, $"PUT statusvisibility -> {(int)put.StatusCode}");
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.Equal(2, row.GetProperty("statusVisibility").GetInt32());
|
||||
|
||||
// And the player's own heartbeat agrees.
|
||||
var beat = await HeartbeatAsync(subject);
|
||||
Assert.Equal(2, beat.GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_push_carries_online_state_independently_of_the_room()
|
||||
{
|
||||
var room = new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 42,
|
||||
RoomId = 7,
|
||||
Name = "DormRoom",
|
||||
PhotonRoomId = "^dormroom_p1",
|
||||
};
|
||||
|
||||
// Online, no room: the offline-room state. Used to be sent as OFFLINE
|
||||
// because isOnline was derived from the room.
|
||||
var onlineNoRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, null, isOnline: true));
|
||||
Assert.True(onlineNoRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, onlineNoRoom.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Offline never leaks a room, even if one is still cached.
|
||||
var offline = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: false));
|
||||
Assert.False(offline.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, offline.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Online in a room: redacted join coordinates, visibility passed through,
|
||||
// appVersion a STRING.
|
||||
var inRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: true, statusVisibility: 1));
|
||||
Assert.True(inRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(42, inRoom.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64());
|
||||
Assert.Equal(string.Empty, inRoom.GetProperty("roomInstance").GetProperty("photonRoomId").GetString());
|
||||
Assert.Equal(1, inRoom.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.Equal(JsonValueKind.String, inRoom.GetProperty("appVersion").ValueKind);
|
||||
|
||||
static JsonElement Msg(string payload)
|
||||
{
|
||||
var root = JsonDocument.Parse(payload).RootElement;
|
||||
// String-keyed channel: the client registers "PresenceUpdate".
|
||||
Assert.Equal("PresenceUpdate", root.GetProperty("Id").GetString());
|
||||
return root.GetProperty("Msg");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pushes_honour_the_players_status_visibility()
|
||||
{
|
||||
// Live PresenceUpdate frames must hide a player exactly as GET /player
|
||||
// does, or "appear offline" leaks every room change to friends.
|
||||
long[] friends = [10, 11, 12];
|
||||
var favorited = new HashSet<long> { 11 };
|
||||
|
||||
var pub = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Public, favorited);
|
||||
Assert.Equal(friends, pub.VisibleTo);
|
||||
Assert.Empty(pub.HiddenFrom);
|
||||
|
||||
// Every recipient is already a friend, so FriendsOnly hides nobody.
|
||||
var friendsOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FriendsOnly, favorited);
|
||||
Assert.Equal(friends, friendsOnly.VisibleTo);
|
||||
Assert.Empty(friendsOnly.HiddenFrom);
|
||||
|
||||
var favoritesOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FavoriteFriendsOnly, favorited);
|
||||
Assert.Equal([11L], favoritesOnly.VisibleTo);
|
||||
Assert.Equal([10L, 12L], favoritesOnly.HiddenFrom);
|
||||
|
||||
var offline = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Offline, favorited);
|
||||
Assert.Empty(offline.VisibleTo);
|
||||
Assert.Equal(friends, offline.HiddenFrom);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<HttpClient> SignedInMatchClientAsync()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://match.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<long> PlayerIdOfAsync(HttpClient client)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
return beat.GetProperty("playerId").GetInt64();
|
||||
}
|
||||
|
||||
private static async Task<string> MatchmakeDormAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/matchmake/dorm", Form(("MaxPersistenceVersion", "45")));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)resp.StatusCode}: {body}");
|
||||
var id = FindInstanceId(JsonDocument.Parse(body).RootElement);
|
||||
Assert.False(string.IsNullOrEmpty(id), $"no roomInstanceId in the dorm reply: {body}");
|
||||
return id!;
|
||||
}
|
||||
|
||||
private static async Task ReportJoinAsync(HttpClient client, string instanceId)
|
||||
{
|
||||
using var resp = await client.PostAsync($"/roominstance/{instanceId}/reportjoinresult", Form(("result", "0")));
|
||||
Assert.True(resp.IsSuccessStatusCode, $"reportjoinresult -> {(int)resp.StatusCode}");
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> HeartbeatAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/player/heartbeat", Form(("LoginLock", Guid.NewGuid().ToString())));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)resp.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> BulkPresenceAsync(HttpClient viewer, long subjectId)
|
||||
{
|
||||
using var resp = await viewer.GetAsync($"/player?{subjectId}");
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"GET /player -> {(int)resp.StatusCode}: {body}");
|
||||
foreach (var row in JsonDocument.Parse(body).RootElement.EnumerateArray())
|
||||
{
|
||||
if (row.GetProperty("playerId").GetInt64() == subjectId) return row.Clone();
|
||||
}
|
||||
Assert.Fail($"no row for {subjectId}: {body}");
|
||||
return default;
|
||||
}
|
||||
|
||||
private static JsonElement RoomInstanceOf(JsonElement presence)
|
||||
{
|
||||
Assert.True(presence.TryGetProperty("roomInstance", out var room),
|
||||
$"no roomInstance key: {presence.GetRawText()}");
|
||||
return room;
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
|
||||
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||
|
||||
private static string? FindInstanceId(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.ToString();
|
||||
if (property.Value.ValueKind == JsonValueKind.Object
|
||||
&& FindInstanceId(property.Value) is { } nested)
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Private-room semantics:
|
||||
/// 1. A freshly cloned room is PRIVATE (Accessibility=0) regardless of
|
||||
/// the source's visibility — the owner publishes explicitly.
|
||||
/// 2. A private room has exactly ONE instance per sub-room: every
|
||||
/// JoinMode (plain matchmake / new public / new private) funnels
|
||||
/// into the same deterministic invite-gated instance instead of
|
||||
/// nonce-forking parallel copies.
|
||||
/// 3. Players who are neither the creator nor role-holders/invitees
|
||||
/// are turned away with ErrorCode=4 (indistinguishable from a
|
||||
/// nonexistent room, so private rooms don't leak).
|
||||
/// </summary>
|
||||
public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PrivateRoomInstanceTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_is_private_by_default()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var sourceId = 9_400_100L;
|
||||
await SeedRoomAsync(sourceId, creatorId: owner.PlayerId, accessibility: 1);
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
using var resp = await roomsClient.PostAsync(
|
||||
$"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"PrivClone_{Guid.NewGuid():N}"[..24] }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var clone = await db.Rooms.SingleAsync(r => r.Id == cloneId);
|
||||
Assert.Equal(0, clone.Accessibility); // private by default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Private_room_gets_one_instance_per_subroom_for_all_join_modes()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_400_200L;
|
||||
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
|
||||
|
||||
using var matchClient = ApiClient(owner, subdomain: "match");
|
||||
|
||||
var joins = new List<(long InstanceId, string Photon, bool IsPrivate)>();
|
||||
foreach (var joinMode in new[] { 0, 1, 2, 0 })
|
||||
{
|
||||
using var resp = await matchClient.PostAsync(
|
||||
$"/matchmake/room/{roomId}",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = joinMode.ToString() }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"JoinMode={joinMode} -> {(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(0, json.RootElement.GetProperty("errorCode").GetInt32());
|
||||
var inst = json.RootElement.GetProperty("roomInstance");
|
||||
joins.Add((
|
||||
inst.GetProperty("roomInstanceId").GetInt64(),
|
||||
inst.GetProperty("photonRoomId").GetString()!,
|
||||
inst.GetProperty("isPrivate").GetBoolean()));
|
||||
}
|
||||
|
||||
// Every join mode must land in the SAME single instance.
|
||||
Assert.Single(joins.Select(j => j.InstanceId).Distinct());
|
||||
Assert.Single(joins.Select(j => j.Photon).Distinct());
|
||||
Assert.All(joins, j => Assert.True(j.IsPrivate, "private room instances must be invite-gated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Private_room_turns_away_uninvited_players()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var stranger = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_400_300L;
|
||||
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
|
||||
|
||||
using var strangerClient = ApiClient(stranger, subdomain: "match");
|
||||
using var resp = await strangerClient.PostAsync(
|
||||
$"/matchmake/room/{roomId}",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = "0" }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
// 4 = RoomDoesNotExist — private rooms are invisible, not "forbidden".
|
||||
Assert.Equal(4, json.RootElement.GetProperty("errorCode").GetInt32());
|
||||
}
|
||||
|
||||
private async Task SeedRoomAsync(long roomId, long creatorId, int accessibility)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (await db.Rooms.AnyAsync(r => r.Id == roomId)) return;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"PrivRoom_{roomId}",
|
||||
Description = "private-room test",
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = accessibility,
|
||||
CloningAllowed = true,
|
||||
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-game profile edits for the 2023-03-21 client. Every edit goes through
|
||||
/// OPMAPIOEIFG.OBAGJBBIFGB → PUT "account/me/" + suffix with a single
|
||||
/// urlencoded field whose name differs from the suffix (displayEmoji,
|
||||
/// pronounFlags, identityFlags, imageName). None of these routes existed, so
|
||||
/// every edit failed with "Failed to modify <suffix>", and no account
|
||||
/// DTO emitted the fields, so nothing showed up for anyone. Online-status
|
||||
/// visibility (PUT player/statusvisibility) was persisted but GET /player
|
||||
/// always reported Public and never hid the player.
|
||||
/// </summary>
|
||||
public sealed class ProfileEdit2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ProfileEdit2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_persists_and_is_visible_to_other_players()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "🤪");
|
||||
|
||||
var me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("🤪", me.GetProperty("displayEmoji").GetString());
|
||||
|
||||
var seen = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}");
|
||||
Assert.Equal("🤪", seen.GetProperty("displayEmoji").GetString());
|
||||
|
||||
// Empty clears it.
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "");
|
||||
me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_rejects_non_whitelisted_and_canonicalizes_variation_selector()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var bad = await PutFormAsync(client, "/account/me/emoji", "displayEmoji", "not-an-emoji"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
// "✌" without U+FE0F resolves to the list's "✌️".
|
||||
await PutFormOkAsync(client, "/account/me/emoji", "displayEmoji", "✌");
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
Assert.Equal("✌️", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pronouns_identity_flags_and_banner_round_trip_on_every_account_read()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/personalpronouns", "pronounFlags", "5");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/identityflags", "identityFlags", "3");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bannerimage", "imageName", "banner-test.jpg");
|
||||
|
||||
void AssertProfile(JsonElement a)
|
||||
{
|
||||
Assert.Equal(5, a.GetProperty("personalPronouns").GetInt32());
|
||||
Assert.Equal(3, a.GetProperty("identityFlags").GetInt32());
|
||||
Assert.Equal("banner-test.jpg", a.GetProperty("bannerImage").GetString());
|
||||
}
|
||||
|
||||
AssertProfile(await GetJsonAsync(ownerClient, "/account/me"));
|
||||
AssertProfile(await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}"));
|
||||
|
||||
var bulk = await GetJsonAsync(viewerClient, $"/account/bulk?id={owner.PlayerId}");
|
||||
AssertProfile(bulk.EnumerateArray().Single(a => a.GetProperty("accountId").GetInt64() == owner.PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_edits_reject_non_numeric_values()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var r = await PutFormAsync(client, "/account/me/personalpronouns", "pronounFlags", "HeHim"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
using (var r = await PutFormAsync(client, "/account/me/identityflags", "identityFlags", "-1"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_account_reports_username_changes_remaining()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
// Absent/0 makes the settings UI say no username changes are left.
|
||||
Assert.True(me.GetProperty("availableUsernameChanges").GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bio_edit_round_trips_through_public_bio_lookup()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bio", "bio", "hello from the profile");
|
||||
var bio = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}/bio");
|
||||
Assert.Equal("hello from the profile", bio.GetProperty("bio").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_visibility_is_reported_to_self_and_hides_appear_offline_player()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
using var viewerMatch = MatchClient(viewer);
|
||||
|
||||
// Baseline: Public → viewer sees owner online.
|
||||
var before = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.True(before.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "3");
|
||||
|
||||
// Self keeps its own choice (the dialog reads the cached self presence).
|
||||
var self = await PresenceOfAsync(ownerMatch, owner.PlayerId);
|
||||
Assert.Equal(3, self.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.True(self.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
// Others see an offline player with no room.
|
||||
var other = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.False(other.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, other.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_only_visibility_shows_friends_and_hides_strangers()
|
||||
{
|
||||
var (owner, friend) = await TwoPlayersAsync();
|
||||
using var setup = AccountsClient();
|
||||
var stranger = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Relationships.Add(new RelationshipEntity
|
||||
{
|
||||
RequesterId = owner.PlayerId,
|
||||
TargetId = friend.PlayerId,
|
||||
Status = RelationshipStatus.Friend,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "1");
|
||||
|
||||
using var friendMatch = MatchClient(friend);
|
||||
using var strangerMatch = MatchClient(stranger);
|
||||
Assert.True((await PresenceOfAsync(friendMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.False((await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(1, (await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pref_key_matches_status_visibility_writer()
|
||||
{
|
||||
// MatchPlayerController writes the pref; PlayerPresenceController reads it.
|
||||
var field = typeof(MatchPlayerController).GetField(
|
||||
"PrefStatusVisibility",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
Assert.Equal(PlayerPresenceController.PrefStatusVisibility, (string)field!.GetRawConstantValue()!);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private void PutOnline(long playerId)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(playerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 900_000 + playerId,
|
||||
RoomId = 1,
|
||||
SubRoomId = 0,
|
||||
PhotonRoomId = $"^profiletest{playerId}",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(playerId);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PresenceOfAsync(HttpClient client, long playerId)
|
||||
{
|
||||
var list = await GetJsonAsync(client, $"/player?{playerId}");
|
||||
return list.EnumerateArray().Single(p => p.GetProperty("playerId").GetInt64() == playerId);
|
||||
}
|
||||
|
||||
private async Task<(GameClientSession, GameClientSession)> TwoPlayersAsync()
|
||||
{
|
||||
using var setup = AccountsClient();
|
||||
var a = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var b = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
return (a, b);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> PutFormAsync(HttpClient client, string path, string field, string value)
|
||||
=> await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary<string, string> { [field] = value }));
|
||||
|
||||
private static async Task PutFormOkAsync(HttpClient client, string path, string field, string value)
|
||||
{
|
||||
using var response = await PutFormAsync(client, path, field, value);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"PUT {path} → {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
private HttpClient AccountsClient(GameClientSession? session = null) => Client("accounts", session);
|
||||
|
||||
private HttpClient MatchClient(GameClientSession session) => Client("match", session);
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} → {(int)response.StatusCode}: {body}");
|
||||
using var document = JsonDocument.Parse(body);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A Quest client patched without its own Meta app has no platform session,
|
||||
/// so every headset reports the same placeholder platform user id ("1").
|
||||
/// Accounts must stay keyed by device id: two headsets get two accounts, a
|
||||
/// headset gets its own account back on the next boot, and the placeholder is
|
||||
/// never stored or matched by the remembered-logins lookup — otherwise every
|
||||
/// Quest player would be offered every other Quest player's account.
|
||||
/// </summary>
|
||||
public sealed class QuestPlaceholderPlatformIdTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
// RecNet platform value the 2023 Quest client sends on /connect/token.
|
||||
// The fix does not depend on it; any platform with a placeholder id is
|
||||
// handled the same way.
|
||||
private const string QuestPlatform = "1";
|
||||
private const string PlaceholderPlatformId = "1";
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public QuestPlaceholderPlatformIdTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(" ", true)]
|
||||
[InlineData("0", true)]
|
||||
[InlineData("1", true)]
|
||||
[InlineData(" 1 ", true)]
|
||||
[InlineData("10", false)]
|
||||
[InlineData("2173678582678296", false)]
|
||||
public void Placeholder_platform_ids_are_recognised(string? platformId, bool expected)
|
||||
=> Assert.Equal(expected, PlayerService.IsPlaceholderPlatformId(platformId));
|
||||
|
||||
[Fact]
|
||||
public async Task Two_headsets_with_the_placeholder_platform_id_get_separate_accounts()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
var headsetA = $"quest-headset-a-{Guid.NewGuid():N}";
|
||||
var headsetB = $"quest-headset-b-{Guid.NewGuid():N}";
|
||||
|
||||
var a = await CreateAccountAsync(client, headsetA);
|
||||
var b = await CreateAccountAsync(client, headsetB);
|
||||
Assert.NotEqual(a, b);
|
||||
|
||||
// Same headset, next boot: its own account comes back.
|
||||
var aAgain = await CreateAccountAsync(client, headsetA);
|
||||
Assert.Equal(a, aAgain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Placeholder_platform_id_never_lists_remembered_logins()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/cachedlogin/forplatformid/{QuestPlatform}/{PlaceholderPlatformId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
||||
Assert.Equal(0, json.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_real_platform_id_still_lists_its_remembered_login()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
var realPlatformId = $"{Random.Shared.NextInt64(10_000_000_000, 99_999_999_999)}";
|
||||
var id = await CreateAccountAsync(client, $"quest-meta-{Guid.NewGuid():N}", realPlatformId);
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/cachedlogin/forplatformid/{QuestPlatform}/{realPlatformId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||
Assert.Contains(id.ToString(), body);
|
||||
}
|
||||
|
||||
// The 2023 client's account-creation request (LBNJFPOLCDL: grant_type,
|
||||
// client_id, platform, platform_id, device_id, ...). No username/password,
|
||||
// so the server takes the device-id path.
|
||||
private async Task<long> CreateAccountAsync(
|
||||
HttpClient client, string deviceId, string platformId = PlaceholderPlatformId)
|
||||
{
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "create_account",
|
||||
["client_id"] = "recroom",
|
||||
["platform"] = QuestPlatform,
|
||||
["platform_id"] = platformId,
|
||||
["device_id"] = deviceId,
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(form),
|
||||
};
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.17");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"POST /connect/token -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var accessToken = json.RootElement.GetProperty("access_token").GetString()!;
|
||||
|
||||
using var userInfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
|
||||
userInfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var userInfoResponse = await client.SendAsync(userInfo);
|
||||
userInfoResponse.EnsureSuccessStatusCode();
|
||||
using var userJson = JsonDocument.Parse(await userInfoResponse.Content.ReadAsStringAsync());
|
||||
return long.Parse(userJson.RootElement.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private HttpClient Client(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The end-of-quest reward chest is the gifts/generate + gifts/consume
|
||||
/// flow. This covers the load-bearing new behaviour: a gift stamped with
|
||||
/// a <c>SourceStoreItemId</c> (how quest chests are built) grants exactly
|
||||
/// that store item to the player's inventory when the box is opened,
|
||||
/// regardless of item kind. (The room-keyed pick itself is data-driven
|
||||
/// via data/quest_rewards.json and exercised once real per-quest lists
|
||||
/// are configured.)
|
||||
/// </summary>
|
||||
public sealed class QuestRewardChestTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public QuestRewardChestTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Consuming_quest_chest_grants_backing_item()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long giftId;
|
||||
string wardrobeGuid;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
wardrobeGuid = item.Slug["wardrobe-".Length..];
|
||||
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0,
|
||||
AvatarItemDescOrHairDyeDesc = wardrobeGuid + ",,,",
|
||||
Message = "You earned it!",
|
||||
SourceStoreItemId = item.Id,
|
||||
IsValid = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
giftId = gift.Id;
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
using var consume = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
consume.EnsureSuccessStatusCode();
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == player.PlayerId);
|
||||
Assert.NotNull(avatar);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar!.InventoryJson) ?? new();
|
||||
Assert.Contains(wardrobeGuid, inv);
|
||||
|
||||
var gift = await db.GiftPackages.FirstAsync(g => g.Id == giftId);
|
||||
Assert.True(gift.Consumed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consuming_is_idempotent_and_does_not_double_grant()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
long giftId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = player.PlayerId,
|
||||
AvatarItemType = 0,
|
||||
AvatarItemDescOrHairDyeDesc = item.Slug["wardrobe-".Length..] + ",,,",
|
||||
SourceStoreItemId = item.Id,
|
||||
IsValid = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
giftId = gift.Id;
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var r = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
|
||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
r.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
// The wardrobe guid appears exactly once despite two consumes.
|
||||
Assert.Equal(inv.Distinct().Count(), inv.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Writes whose client method returns the untyped promise (<c>LDGADANDBIO</c>)
|
||||
/// but whose reply is still parsed — as <c>DJMHAFPGLLN</c>, the
|
||||
/// <c>{Success, Error}</c> RecNetResult — by <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||
/// (ExpandRecNetResult, RecNet.Runtime/BFDGEKKNAPB.txt:353).
|
||||
///
|
||||
/// The failure modes are silent server-side: an empty 200 parses to null and
|
||||
/// null-derefs, so the player sees "Failed to …" after the change was applied;
|
||||
/// a body without <c>Success</c> parses as a failure whose <c>Error</c> is null,
|
||||
/// and <c>Promise.Rejected(null)</c> throws "Error message cannot be null".
|
||||
/// So the assertion that matters is the body, read the way the client's
|
||||
/// formatter reads it (LMCCNLLHLCJ.txt: Success/success, Error/error).
|
||||
/// </summary>
|
||||
public sealed class RecNetResultEnvelopeTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RecNetResultEnvelopeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Club_member_writes_answer_a_success_result_not_an_empty_body()
|
||||
{
|
||||
using var setup = Client("clubs");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var joiner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var clubId = await SeedOpenClubAsync(owner.PlayerId);
|
||||
|
||||
using var joinerClient = Client("clubs", joiner);
|
||||
// requesttojoin is verb 3 (PUT) through IKMMOCKDKAF.JNFEEDLCGHH, form-encoded.
|
||||
using var join = await joinerClient.PutAsync(
|
||||
$"/club/{clubId}/members/requesttojoin", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(join, "requesttojoin");
|
||||
|
||||
using var leave = await joinerClient.PostAsync(
|
||||
$"/club/{clubId}/members/leave", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(leave, "leave");
|
||||
|
||||
using var clearHome = await joinerClient.DeleteAsync("/club/home/me");
|
||||
await AssertRecNetSuccessAsync(clearHome, "DELETE club/home/me");
|
||||
|
||||
using var ownerClient = Client("clubs", owner);
|
||||
using var delete = await ownerClient.DeleteAsync($"/club/{clubId}");
|
||||
await AssertRecNetSuccessAsync(delete, "DELETE club/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_markers_and_privacy_toggle_answer_a_success_result()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var commentRead = await client.PutAsync("/comments/read/1/1", content: null);
|
||||
await AssertRecNetSuccessAsync(commentRead, "PUT comments/read");
|
||||
|
||||
using var accounts = Client("accounts", player);
|
||||
using var privacy = await accounts.PutAsync(
|
||||
"/accountprivacysettings/recenthistoryvisibility",
|
||||
new FormUrlEncodedContent([new("isRecentHistoryVisible", "false")]));
|
||||
await AssertRecNetSuccessAsync(privacy, "PUT recenthistoryvisibility");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_warning_carries_an_error_message()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var response = await client.PostAsync("/api/playerwarnings",
|
||||
new FormUrlEncodedContent([new("WarnedPlayerId", player.PlayerId.ToString())]));
|
||||
var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
// Without Error the client's Promise.Rejected(null) throws instead of
|
||||
// surfacing the refusal.
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_token_is_a_bare_string_redeemed_once_for_its_own_account()
|
||||
{
|
||||
using var setup = Client("auth");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var auth = Client("auth", player);
|
||||
|
||||
using var mint = await auth.PostAsync("/account/me/createlogintoken", content: null);
|
||||
var raw = await mint.Content.ReadAsStringAsync();
|
||||
Assert.True(mint.IsSuccessStatusCode, raw);
|
||||
// FGLDKEJLAKB<String> LGKLFFPACGL(): a JSON string, not an object.
|
||||
var token = JsonSerializer.Deserialize<string>(raw);
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
using var anon = Client("auth");
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "login_token",
|
||||
["account_id"] = player.PlayerId.ToString(),
|
||||
["login_token"] = token!,
|
||||
};
|
||||
using var redeemed = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
var redeemedBody = await redeemed.Content.ReadAsStringAsync();
|
||||
Assert.True(redeemed.IsSuccessStatusCode, redeemedBody);
|
||||
Assert.Equal(player.PlayerId, await SubjectOfAsync(redeemedBody));
|
||||
|
||||
using var again = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_code_poll_waits_for_remoteauth_approval_then_signs_in_the_approver()
|
||||
{
|
||||
using var anon = Client("auth");
|
||||
var approver = await GameClientSessionFactory.CreateAsync(anon, _factory.ApexDomain);
|
||||
|
||||
using var start = await anon.PostAsync("/connect/deviceauthorization", content: null);
|
||||
var started = JsonDocument.Parse(await start.Content.ReadAsStringAsync()).RootElement;
|
||||
var deviceCode = started.GetProperty("device_code").GetString()!;
|
||||
var userCode = started.GetProperty("user_code").GetString()!;
|
||||
|
||||
var poll = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
|
||||
["device_code"] = deviceCode,
|
||||
};
|
||||
|
||||
// Before approval the client must be told to keep polling: a 400
|
||||
// whose error is authorization_pending (JAEKFNKDNCP.txt:97-107).
|
||||
using (var pending = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.BadRequest, pending.StatusCode);
|
||||
var body = JsonDocument.Parse(await pending.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("authorization_pending", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
using var approverClient = Client("auth", approver);
|
||||
using (var bad = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", "NOPE0000")])))
|
||||
{
|
||||
var root = JsonDocument.Parse(await bad.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
using var approve = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", userCode.ToLowerInvariant())]));
|
||||
await AssertRecNetSuccessAsync(approve, "remoteauth");
|
||||
|
||||
using var granted = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
var grantedBody = await granted.Content.ReadAsStringAsync();
|
||||
Assert.True(granted.IsSuccessStatusCode, grantedBody);
|
||||
Assert.Equal(approver.PlayerId, await SubjectOfAsync(grantedBody));
|
||||
|
||||
// Single use.
|
||||
using var reused = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, reused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gameconfig_carries_the_play_menu_carousel_ordering_in_the_parsed_format()
|
||||
{
|
||||
using var client = Client("api");
|
||||
var rows = JsonDocument.Parse(await client.GetStringAsync("/api/gameconfigs/v1/all")).RootElement;
|
||||
var row = rows.EnumerateArray().FirstOrDefault(r =>
|
||||
string.Equals(Prop(r, "Key"), "PlayMenuCarouselOrdering_1", StringComparison.Ordinal));
|
||||
Assert.NotEqual(JsonValueKind.Undefined, row.ValueKind);
|
||||
|
||||
// PlayMenuCarouselModel.TryGetDisplayPriorityMap: Split(';'), then each
|
||||
// entry Split('=') must give exactly 2 parts with an Int32 priority, or
|
||||
// the entry is logged as "Failed to parse priority mapping".
|
||||
var entries = Prop(row, "Value")!.Split(';');
|
||||
Assert.All(entries, e =>
|
||||
{
|
||||
var parts = e.Split('=');
|
||||
Assert.Equal(2, parts.Length);
|
||||
Assert.True(int.TryParse(parts[1].Trim(), out _), e);
|
||||
});
|
||||
Assert.Contains(entries, e => e.StartsWith("hotrooms=", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_strings_download_answers_an_empty_table()
|
||||
{
|
||||
using var client = Client("strings");
|
||||
using var response = await client.GetAsync("/strings?locale=en-US&scopePrefix=client.&eTag=");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, body);
|
||||
Assert.Equal(JsonValueKind.Object, JsonDocument.Parse(body).RootElement.ValueKind);
|
||||
}
|
||||
|
||||
/// <summary>PUT playerevents/v2/{id}/multiinstance sends the two broadcast
|
||||
/// permission fields as boxed enums, i.e. member names (BNDIAONDFFF.txt:947
|
||||
/// falls back to Object.ToString). Parsing them as ints stored 0 every time.</summary>
|
||||
[Theory]
|
||||
[InlineData("RoomOwners", 256)]
|
||||
[InlineData("All", int.MaxValue)]
|
||||
[InlineData("None", 0)]
|
||||
[InlineData("256", 256)]
|
||||
[InlineData("roomowners, None", 256)]
|
||||
public void Broadcast_permission_names_parse_to_their_values(string raw, int expected) =>
|
||||
Assert.Equal(expected,
|
||||
DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController.ParseBroadcastPermissions(raw));
|
||||
|
||||
[Fact]
|
||||
public void Unknown_broadcast_permission_name_keeps_the_stored_value() =>
|
||||
Assert.Null(DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController
|
||||
.ParseBroadcastPermissions("SomethingElse"));
|
||||
|
||||
private async Task<long> SeedOpenClubAsync(long ownerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var club = new ClubEntity
|
||||
{
|
||||
Name = $"Env{Guid.NewGuid():N}"[..16],
|
||||
CreatorPlayerId = ownerId,
|
||||
Joinability = 0, // Open
|
||||
};
|
||||
db.Clubs.Add(club);
|
||||
await db.SaveChangesAsync();
|
||||
return club.Id;
|
||||
}
|
||||
|
||||
private async Task<long> SubjectOfAsync(string tokenBody)
|
||||
{
|
||||
var access = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString();
|
||||
using var client = Client("auth");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access);
|
||||
var info = JsonDocument.Parse(await client.GetStringAsync("/connect/userinfo")).RootElement;
|
||||
return long.Parse(info.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private static async Task AssertRecNetSuccessAsync(HttpResponseMessage response, string what)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{what} -> {(int)response.StatusCode}: {body}");
|
||||
Assert.False(string.IsNullOrWhiteSpace(body), $"{what} answered an empty body");
|
||||
var root = JsonDocument.Parse(body).RootElement;
|
||||
Assert.True(ReadSuccess(root), $"{what} body has no Success=true: {body}");
|
||||
}
|
||||
|
||||
// The client formatter registers exactly these spellings.
|
||||
private static bool ReadSuccess(JsonElement root) =>
|
||||
(root.TryGetProperty("Success", out var s) || root.TryGetProperty("success", out s))
|
||||
&& s.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string? ReadError(JsonElement root) =>
|
||||
root.TryGetProperty("Error", out var e) || root.TryGetProperty("error", out e)
|
||||
? e.ValueKind == JsonValueKind.String ? e.GetString() : null
|
||||
: null;
|
||||
|
||||
private static string? Prop(JsonElement row, string name) =>
|
||||
row.TryGetProperty(name, out var v) || row.TryGetProperty(char.ToLowerInvariant(name[0]) + name[1..], out v)
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using DorkNet.Server.Services;
|
||||
using Google.Protobuf;
|
||||
using RecRoom.Protobuf;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A modern RecNet export's subroom save stamps PersistedRoomData
|
||||
/// versions past what the March-2023 client knows (field 1
|
||||
/// DEPRECATED_RoomPersistenceVersion, max 38; field 30
|
||||
/// PersistedRoomVersion, max 19 = V19February23BetaRelease). The client
|
||||
/// rejects the whole room with its "update Rec Room to visit this room"
|
||||
/// gate — the ShibuyaCrossing import (Sep-2025 save, version=131) is the
|
||||
/// canonical repro. The CDN serve path clamps both varints down; these
|
||||
/// tests pin the rewrite to exactly those two fields.
|
||||
/// </summary>
|
||||
public sealed class RoomBlobVersionClampTests
|
||||
{
|
||||
private static byte[] BuildModernBlob(int deprecatedVersion, int version)
|
||||
{
|
||||
// Emit the wire shape a modern save actually has: version fields
|
||||
// plus payload fields the 2023 schema does and doesn't know about.
|
||||
using var ms = new MemoryStream();
|
||||
var output = new CodedOutputStream(ms);
|
||||
output.WriteTag(1, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(deprecatedVersion);
|
||||
// Field 2: persistence_views payload (opaque here — the clamp
|
||||
// must copy it verbatim).
|
||||
output.WriteTag(2, WireFormat.WireType.LengthDelimited);
|
||||
output.WriteBytes(ByteString.CopyFrom(0x0A, 0x03, 0x01, 0x02, 0x03));
|
||||
// Field 6: sub_room_id.
|
||||
output.WriteTag(6, WireFormat.WireType.Varint);
|
||||
output.WriteInt64(19589779);
|
||||
output.WriteTag(30, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(version);
|
||||
// Field 37: unknown-to-2023 varint field from newer schemas.
|
||||
output.WriteTag(37, WireFormat.WireType.Varint);
|
||||
output.WriteInt32(1);
|
||||
output.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clamps_modern_versions_to_2023_maxima()
|
||||
{
|
||||
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.True(changed);
|
||||
var msg = PersistedRoomData.Parser.ParseFrom(bytes);
|
||||
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)msg.Version);
|
||||
Assert.Equal(DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion, msg.DEPRECATEDVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preserves_every_non_version_byte()
|
||||
{
|
||||
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
|
||||
|
||||
var (bytes, _) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
// version=131 is a two-byte varint, 16 is one byte — the output
|
||||
// shrinks by exactly that and matches the input everywhere else.
|
||||
Assert.Equal(input.Length - 1, bytes.Length);
|
||||
var expected = BuildModernBlob(
|
||||
deprecatedVersion: 38,
|
||||
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
|
||||
Assert.Equal(expected, bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blob_already_at_2023_versions_passes_through_unchanged()
|
||||
{
|
||||
var input = BuildModernBlob(
|
||||
deprecatedVersion: 38,
|
||||
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Same(input, bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_input_is_returned_verbatim()
|
||||
{
|
||||
var input = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01 };
|
||||
|
||||
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Same(input, bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 play-menu browse sources. The client's room-discovery URL
|
||||
/// builder (IBEOONPEELF/GGDPFFDEDKN) requests
|
||||
/// <c>rooms/search_rooms/{query}&skip={skip}&take={take}</c> —
|
||||
/// note the paging pairs live INSIDE the final path segment, and the
|
||||
/// whole family is prefixed with <c>rooms/</c> on the rooms host.
|
||||
/// The room photo gallery sends <c>sort</c>/<c>filter</c> as enum
|
||||
/// names (<c>CheerCount_Desc</c>, <c>PublicOnly</c>), which must not
|
||||
/// trip int model binding.
|
||||
/// </summary>
|
||||
public sealed class RoomBrowse2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomBrowse2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Search_rooms_2023_path_with_embedded_paging_returns_matches()
|
||||
{
|
||||
using var setupClient = Client();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var marker = $"Zz{Guid.NewGuid():N}"[..14];
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = 9_300_000 + Random.Shared.Next(1, 99_999),
|
||||
Name = $"Search{marker}",
|
||||
Description = "2023 search test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var roomsClient = Client(owner, subdomain: "rooms");
|
||||
using var response = await roomsClient.GetAsync($"/rooms/search_rooms/{marker}&skip=0&take=20");
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var names = json.RootElement.EnumerateArray()
|
||||
.Select(e => e.GetProperty("Name").GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
Assert.Contains(names, n => n.Contains(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_images_accept_2023_enum_name_sort_and_filter()
|
||||
{
|
||||
using var setupClient = Client();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
using var apiClient = Client(player);
|
||||
using var response = await apiClient.GetAsync(
|
||||
"/api/images/v4/room/116?sort=CheerCount_Desc&filter=PublicOnly&take=8&skip=0");
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
||||
}
|
||||
|
||||
private HttpClient Client(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
|
||||
/// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
|
||||
/// as "save room"), whose <c>value</c> is <c>FGCPNAACHIK</c> — the SAME type
|
||||
/// <c>GET rooms/{id}</c> returns (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
||||
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
||||
/// with the route literal on :4802). A bare room with no <c>success</c> is
|
||||
/// rejected with an empty error.
|
||||
///
|
||||
/// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
|
||||
/// still answers 200 and the client throws while reading it — and because the
|
||||
/// clone promise's exception is never observed, it surfaces from the finalizer
|
||||
/// thread seconds later as a bare "Failed to copy room: Exception of type
|
||||
/// '...' was thrown", with nothing in the server log to show for it. That is
|
||||
/// exactly how this failed in the wild: the room WAS created, and the client
|
||||
/// simply never registered it.
|
||||
/// </summary>
|
||||
public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomCloneShapeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_response_matches_the_get_by_id_shape()
|
||||
{
|
||||
using var client = Client();
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var sourceId = 9_400_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = sourceId,
|
||||
Name = $"CloneSrc{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
});
|
||||
// Give the source a real sub-room. Without one, BOTH responses fall
|
||||
// back to the synthesised default sub-room and the comparison never
|
||||
// exercises the branch a real room (like the seeded RecCenter the
|
||||
// room-creation picker clones) actually takes.
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = sourceId,
|
||||
Name = "Home",
|
||||
OrderIndex = 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
||||
var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||
// The client reads clone through the {success, value, error} envelope.
|
||||
Assert.True(cloneEnvelope.GetProperty("success").GetBoolean(), cloneEnvelope.ToString());
|
||||
Assert.Equal("", cloneEnvelope.GetProperty("error").GetString());
|
||||
var cloneBody = cloneEnvelope.GetProperty("value");
|
||||
|
||||
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = expected.Except(actual).Order(StringComparer.Ordinal).ToList();
|
||||
|
||||
// The reader walks the nested sub-room list too, so a key missing there
|
||||
// breaks it just as hard as one missing at the top level.
|
||||
foreach (var list in new[] { "SubRooms", "Scenes" })
|
||||
{
|
||||
if (!getBody.TryGetProperty(list, out var expectedList) ||
|
||||
expectedList.ValueKind != JsonValueKind.Array ||
|
||||
expectedList.GetArrayLength() == 0) continue;
|
||||
|
||||
Assert.True(cloneBody.TryGetProperty(list, out var actualList)
|
||||
&& actualList.ValueKind == JsonValueKind.Array,
|
||||
$"clone response has no {list} array while get-by-id does");
|
||||
Assert.True(actualList.GetArrayLength() > 0,
|
||||
$"clone response has an EMPTY {list} array while get-by-id returns "
|
||||
+ $"{expectedList.GetArrayLength()}; the client's reader requires at least one entry");
|
||||
|
||||
var expectedKeys = expectedList[0].EnumerateObject().Select(p => p.Name);
|
||||
var actualKeys = actualList[0].EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
missing.AddRange(expectedKeys.Where(k => !actualKeys.Contains(k)).Select(k => $"{list}[0].{k}"));
|
||||
}
|
||||
Assert.True(missing.Count == 0,
|
||||
$"""
|
||||
The clone response is missing {missing.Count} key(s) that get-by-id
|
||||
returns. Both deserialize into FGCPNAACHIK, so the client throws while
|
||||
reading the clone and silently never registers the new room:
|
||||
|
||||
{string.Join(Environment.NewLine, missing)}
|
||||
|
||||
clone keys: {string.Join(", ", actual.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>A clone must own its save data. Cloning used to be a shallow
|
||||
/// copy — the new room's scenes kept pointing at the SOURCE's blob until
|
||||
/// someone saved over them — so every copy of a room shared one object and
|
||||
/// cloning RecCenter produced rooms whose data blob was literally
|
||||
/// <c>room_100_v1.dat</c>. A clone that references another room's blob is
|
||||
/// the bug, whatever the name.</summary>
|
||||
[Fact]
|
||||
public async Task Clone_does_not_reference_the_source_rooms_blob()
|
||||
{
|
||||
// The suite runs with S3 unconfigured, where there is nothing to copy
|
||||
// and the clone deliberately keeps the shallow reference. Swap in a
|
||||
// real (in-memory) store so this exercises the copy path itself.
|
||||
var store = new InMemoryObjectStorage();
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IObjectStorage>();
|
||||
services.AddSingleton<IObjectStorage>(store);
|
||||
}));
|
||||
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
var sourceId = 9_500_000 + Random.Shared.Next(1, 99_999);
|
||||
var sourceBlob = $"room_{sourceId}_v1.dat";
|
||||
var sourceBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
|
||||
await store.PutAsync(srcBucket, srcKey, sourceBytes, "application/octet-stream");
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = sourceId,
|
||||
Name = $"BlobSrc{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = session.PlayerId,
|
||||
CloningAllowed = true,
|
||||
CurrentDataBlobName = sourceBlob,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = sourceId,
|
||||
Name = "Home",
|
||||
OrderIndex = 0,
|
||||
DataBlobName = sourceBlob,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||
var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.FirstAsync(r => r.Id == cloneId);
|
||||
var scenes = await db.RoomScenes.Where(s => s.RoomId == cloneId).ToListAsync();
|
||||
|
||||
Assert.False(room.CurrentDataBlobName == sourceBlob,
|
||||
$"the clone's room row still points at the source's blob ({sourceBlob})");
|
||||
foreach (var scene in scenes)
|
||||
Assert.False(scene.DataBlobName == sourceBlob,
|
||||
$"cloned sub-room {scene.OrderIndex} still points at the source's blob ({sourceBlob})");
|
||||
|
||||
// It must be a real copy, not just a cleared reference — the point
|
||||
// is that the player's content comes along.
|
||||
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName),
|
||||
"the clone has no blob at all; the source's data should have been copied, not dropped");
|
||||
var (dstBucket, dstKey) = BlobRouter.Route(room.CurrentDataBlobName);
|
||||
Assert.Equal(sourceBytes, await store.GetAsync(dstBucket, dstKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal in-memory <see cref="IObjectStorage"/>. The suite runs
|
||||
/// with S3 unconfigured, so without this the clone's copy path short-circuits
|
||||
/// and never gets exercised.</summary>
|
||||
private sealed class InMemoryObjectStorage : IObjectStorage
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
public bool IsS3Configured => true;
|
||||
|
||||
public Task<long> PutAsync(
|
||||
string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
_objects[$"{bucket}/{key}"] = bytes;
|
||||
return Task.FromResult((long)bytes.Length);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default) =>
|
||||
Task.FromResult(_objects.TryGetValue($"{bucket}/{key}", out var bytes) ? bytes : null);
|
||||
|
||||
public Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default) =>
|
||||
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_refusal_is_a_200_envelope_with_a_message()
|
||||
{
|
||||
using var client = Client();
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
// No room has this id, so the clone is refused; the client shows
|
||||
// `error`, which a bare 404 would leave empty.
|
||||
var envelope = await ReadJsonAsync(client, HttpMethod.Post, "/rooms/987654321987/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", "NoSuchSource")]));
|
||||
|
||||
Assert.False(envelope.GetProperty("success").GetBoolean());
|
||||
Assert.False(string.IsNullOrEmpty(envelope.GetProperty("error").GetString()));
|
||||
Assert.Equal(JsonValueKind.Null, envelope.GetProperty("value").ValueKind);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(
|
||||
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path) { Content = content };
|
||||
using var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"{method} {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage of the 2023 client's in-room shop
|
||||
/// (api/roomconsumables/v1) using the exact request/response wire shapes
|
||||
/// the 2023.03.21 client serialises (verified against the RecNet.Runtime
|
||||
/// formatters in the ISIL dump). The critical regression guard: every
|
||||
/// inventory row returned by <c>/room/{id}/me</c> must carry a non-null
|
||||
/// <c>Consumable</c> desc — RoomConsumablesManager NREs on room join
|
||||
/// otherwise.
|
||||
/// </summary>
|
||||
public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomConsumablesShopTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Full_shop_loop_create_purchase_inventory_consume_delete()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var buyer = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_200_001L;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"ShopRoom_{Guid.NewGuid():N}"[..24],
|
||||
Description = "shop test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
State = 0,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "community",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var level = scope.ServiceProvider.GetRequiredService<LevelService>();
|
||||
await level.GrantCurrencyAsync(buyer.PlayerId, 2, 100, "test-seed");
|
||||
}
|
||||
|
||||
using var ownerClient = ApiClient(owner);
|
||||
using var buyerClient = ApiClient(buyer);
|
||||
|
||||
// ── Create (client body = the desc DTO itself) ───────────────────
|
||||
var created = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{
|
||||
"RoomId": {{roomId}},
|
||||
"Name": "Cola",
|
||||
"Description": "A refreshing test beverage",
|
||||
"ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, created.GetProperty("Status").GetInt32());
|
||||
var consumableId = created.GetProperty("Consumable").GetProperty("RoomConsumableId").GetGuid();
|
||||
Assert.NotEqual(Guid.Empty, consumableId);
|
||||
Assert.Equal(roomId, created.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
|
||||
|
||||
// ── Non-owner cannot create in someone else's room ───────────────
|
||||
var forbidden = await PostJsonAsync(buyerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{ "RoomId": {{roomId}}, "Name": "Hax", "Description": "", "ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 1, "CurrencyId": null } }
|
||||
""");
|
||||
Assert.Equal(7, forbidden.GetProperty("Status").GetInt32()); // PlayerDoesntHavePermission
|
||||
|
||||
// ── Room catalog ─────────────────────────────────────────────────
|
||||
var catalog = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
|
||||
Assert.Equal(1, catalog.GetArrayLength());
|
||||
// Responses carry the price FLAT. The client's consumable formatter
|
||||
// (RecNet.Runtime/FCIBLPCOODP) reads RoomConsumableId/RoomId/Name/
|
||||
// Description/ImageName/Price/PurchaseCurrencyId/ModifiedAt and has no
|
||||
// "PriceAndCurrency" key at all — only REQUEST bodies nest it.
|
||||
Assert.Equal(10, catalog[0].GetProperty("Price").GetInt64());
|
||||
|
||||
// ── Purchase at the wrong price is rejected ──────────────────────
|
||||
var codeA = Guid.NewGuid();
|
||||
var badPrice = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
|
||||
{
|
||||
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
|
||||
"ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
// OperationResult is an OBJECT { Status, InventoryItem } — the base type
|
||||
// OKHNOPLBOFP holds a single BGHBAILNNJJ, which itself exposes the status
|
||||
// enum plus the inventory row. The scalar token-operation enum lives in
|
||||
// BalanceUpdateResult (Nullable<CABBDKFODEC>) on MJKFEHPIDME.
|
||||
Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
|
||||
Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
||||
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
|
||||
|
||||
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
|
||||
var purchase = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
|
||||
{
|
||||
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
|
||||
"ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, purchase.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
||||
var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
|
||||
Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
|
||||
Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
|
||||
|
||||
// ── Inventory on room join: Consumable must NEVER be null ────────
|
||||
var inventory = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}/me");
|
||||
Assert.Equal(1, inventory.GetArrayLength());
|
||||
var row = inventory[0];
|
||||
Assert.Equal(consumableId, row.GetProperty("RoomConsumableId").GetGuid());
|
||||
Assert.Equal((int)buyer.PlayerId, row.GetProperty("AccountId").GetInt32());
|
||||
Assert.Equal(1, row.GetProperty("Count").GetInt32());
|
||||
Assert.Equal(codeA, row.GetProperty("ConcurrencyCode").GetGuid());
|
||||
Assert.Equal(JsonValueKind.Object, row.GetProperty("Consumable").ValueKind);
|
||||
Assert.Equal(roomId, row.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
|
||||
|
||||
// ── isOwned: a non-creator holds stock ───────────────────────────
|
||||
using var isOwnedResponse = await ownerClient.GetAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/isOwned");
|
||||
Assert.Equal("true", await isOwnedResponse.Content.ReadAsStringAsync());
|
||||
|
||||
// ── Consume with a stale concurrency code resyncs the client ─────
|
||||
var codeB = Guid.NewGuid();
|
||||
var staleConsume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{Guid.NewGuid()}}", "NewConcurrencyCode": "{{codeB}}" }
|
||||
""");
|
||||
Assert.Equal(32, staleConsume.GetProperty("Status").GetInt32()); // ConcurrencyCodeMismatch
|
||||
Assert.Equal(1, staleConsume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
|
||||
|
||||
// ── Consume with the right code decrements and rotates the code ──
|
||||
var consume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{codeA}}", "NewConcurrencyCode": "{{codeB}}" }
|
||||
""");
|
||||
Assert.Equal(0, consume.GetProperty("Status").GetInt32());
|
||||
Assert.Equal(0, consume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
|
||||
Assert.Equal(codeB, consume.GetProperty("InventoryItem").GetProperty("ConcurrencyCode").GetGuid());
|
||||
|
||||
// ── Consuming an empty stack is refused ──────────────────────────
|
||||
var emptyConsume = await PostJsonAsync(buyerClient,
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
||||
{ "CurrentConcurrencyCode": "{{codeB}}", "NewConcurrencyCode": "{{Guid.NewGuid()}}" }
|
||||
""");
|
||||
Assert.Equal(33, emptyConsume.GetProperty("Status").GetInt32()); // PlayerDoesNotOwnConsumable
|
||||
|
||||
// ── Update via POST with RoomConsumableId in the body ────────────
|
||||
var updated = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
||||
{
|
||||
"RoomConsumableId": "{{consumableId}}",
|
||||
"RoomId": {{roomId}},
|
||||
"Name": "Diet Cola",
|
||||
"Description": "Now with fewer tokens",
|
||||
"ImageName": "",
|
||||
"PriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, updated.GetProperty("Status").GetInt32());
|
||||
Assert.Equal("Diet Cola", updated.GetProperty("Consumable").GetProperty("Name").GetString());
|
||||
Assert.Equal(5, updated.GetProperty("Consumable").GetProperty("Price").GetInt64());
|
||||
|
||||
// ── Delete: buyer refused, owner succeeds, catalog empties ───────
|
||||
using var buyerDelete = await buyerClient.DeleteAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
|
||||
Assert.Equal("7", await buyerDelete.Content.ReadAsStringAsync()); // PlayerDoesntHavePermission
|
||||
|
||||
using var ownerDelete = await ownerClient.DeleteAsync(
|
||||
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
|
||||
Assert.Equal("0", await ownerDelete.Content.ReadAsStringAsync());
|
||||
|
||||
var emptied = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
|
||||
Assert.Equal(0, emptied.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unprefixed_2023_commerce_probes_respond()
|
||||
{
|
||||
// The 2023 client calls these WITHOUT the api/ prefix on startup;
|
||||
// a 404 here surfaces as "CleanupPendingTransactions failed" +
|
||||
// unobserved-HTTP-404 crash reports in Player.log.
|
||||
using var client = ApiClient();
|
||||
|
||||
using var cleanup = await client.PostAsync("/purchase/v1/cleanuppending",
|
||||
new StringContent("", Encoding.UTF8, "application/x-www-form-urlencoded"));
|
||||
Assert.True(cleanup.IsSuccessStatusCode);
|
||||
|
||||
using var hasSpent = await client.GetAsync("/purchase/v1/hasspentmoney");
|
||||
Assert.Equal("false", await hasSpent.Content.ReadAsStringAsync());
|
||||
|
||||
var bundles = await GetJsonAsync(client, "/reminder/currentTokenBundles/v2");
|
||||
Assert.Equal(JsonValueKind.Array, bundles.ValueKind);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path, string json)
|
||||
{
|
||||
using var response = await client.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
|
||||
/// <c>PUT roomserver/rooms/{id}/accessibility</c> with form
|
||||
/// <c>accessibility=1</c> (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
|
||||
/// typed <c>Task<FGCPNAACHIK></c>. The button only exists when
|
||||
/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, <b>not IsRRO</b>,
|
||||
/// not already public. A clone of a Rec Room Original template used to be
|
||||
/// advertised as IsRRO and so could never be published.
|
||||
/// </summary>
|
||||
public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomPublish2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public void Only_system_owned_originals_are_RRO_on_the_wire()
|
||||
{
|
||||
Assert.True(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = PlayerService.SystemAccountId }));
|
||||
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = 4242 }));
|
||||
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = false, CreatorPlayerId = PlayerService.SystemAccountId }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_of_an_original_is_not_RRO_and_can_be_published_by_owner_and_co_owner()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var coOwner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var moderator = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var templateId = 9_100_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = templateId,
|
||||
Name = $"Tmpl{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = PlayerService.SystemAccountId,
|
||||
IsAGRoom = true,
|
||||
Accessibility = 1,
|
||||
CloningAllowed = false,
|
||||
ImageName = "image_RecCenter.png",
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = templateId, OrderIndex = 0, Name = "Home" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var template = await GetJsonAsync(ownerRooms, $"/roomserver/rooms/{templateId}");
|
||||
Assert.True(template.GetProperty("IsRRO").GetBoolean());
|
||||
|
||||
using var cloneResp = await ownerRooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Pub{Guid.NewGuid():N}"[..20] }));
|
||||
var cloneText = await cloneResp.Content.ReadAsStringAsync();
|
||||
Assert.True(cloneResp.IsSuccessStatusCode, cloneText);
|
||||
var clone = JsonDocument.Parse(cloneText).RootElement.GetProperty("value").Clone();
|
||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||
Assert.False(clone.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(0, clone.GetProperty("Accessibility").GetInt32());
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true });
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = moderator.PlayerId, Role = RoomRoleRules.StoredModerator, Accepted = true });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Publish as the owner: full room details back, now public, still not RRO.
|
||||
var published = await PutAccessibilityAsync(ownerRooms, cloneId, "1");
|
||||
Assert.Equal(1, published.GetProperty("Accessibility").GetInt32());
|
||||
Assert.False(published.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.True(published.TryGetProperty("SubRooms", out _));
|
||||
|
||||
// A moderator may not change accessibility; a co-owner may.
|
||||
using var modRooms = Client("rooms", moderator);
|
||||
using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = "0" })))
|
||||
{
|
||||
// Refusals are 200 {success:false, error} so the client can show them.
|
||||
using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
|
||||
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PutAccessibilityAsync(HttpClient client, long roomId, string value)
|
||||
{
|
||||
using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
|
||||
// "change room accessibility" goes through the {success, value, error}
|
||||
// helper (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>), same as clone.
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
|
||||
return doc.RootElement.GetProperty("value").Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 room roles, the co-owner invite flow and the sub-room role-permission
|
||||
/// matrix, against the client contract:
|
||||
/// <list type="bullet">
|
||||
/// <item>Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30,
|
||||
/// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 —
|
||||
/// the client takes the local role from the Roles list and only lets a
|
||||
/// player edit roles strictly below their own, Creator excepted
|
||||
/// (OMJKHJLFOCO.DIIMLMKEJLC).</item>
|
||||
/// <item>Invite: owner <c>PUT rooms/{id}/roles/{target}/invite</c> role=30;
|
||||
/// invitee gets message Type 62 with RoomId; accepts with
|
||||
/// <c>PUT rooms/{id}/roles/{me}</c> role=30, rejects with
|
||||
/// <c>PUT rooms/{id}/roles/{me}/invite</c> role=0.</item>
|
||||
/// <item>Permissions: <c>PUT rooms/{id}/subrooms/{sub}/permissions</c> raw
|
||||
/// JSON array of {Permission, Role, Override, Type, Value}; read back from
|
||||
/// <c>GET photon_access_token</c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
// ── pure rules ──────────────────────────────────────────────────────
|
||||
|
||||
private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator };
|
||||
|
||||
private static RoomRoleEntity Row(long player, int stored, bool accepted = true) =>
|
||||
new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted };
|
||||
|
||||
[Fact]
|
||||
public void Creator_is_255_and_listed_once()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var wires = RoomRoleRules.BuildWires(room, new[]
|
||||
{
|
||||
Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator
|
||||
Row(8, RoomRoleRules.StoredModerator),
|
||||
Row(9, RoomRoleRules.StoredCoOwner, accepted: false),
|
||||
});
|
||||
|
||||
Assert.Single(wires, w => w.AccountId == 7);
|
||||
Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role);
|
||||
Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role);
|
||||
var invited = wires.Single(w => w.AccountId == 9);
|
||||
Assert.Equal(0, invited.Role);
|
||||
Assert.Equal(30, invited.InvitedRole);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = Array.Empty<RoomRoleEntity>();
|
||||
foreach (var role in new[] { 0, 10, 20, 30, 31, 255 })
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Co_owner_may_edit_only_roles_below_their_own()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) };
|
||||
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20));
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255));
|
||||
// A moderator edits nothing.
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0));
|
||||
// Nor does a stranger.
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Role_changes_follow_the_ladder()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = new List<RoomRoleEntity>
|
||||
{
|
||||
Row(8, RoomRoleRules.StoredCoOwner),
|
||||
Row(9, RoomRoleRules.StoredModerator),
|
||||
Row(10, RoomRoleRules.StoredCoOwner, accepted: false),
|
||||
};
|
||||
|
||||
// Creator: anything on anyone but themselves.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Invite, k);
|
||||
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _));
|
||||
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _));
|
||||
|
||||
// Co-owner: only below 30, on targets below 30.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _));
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner
|
||||
// …and cannot remove a peer co-owner.
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(
|
||||
room, new List<RoomRoleEntity> { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _));
|
||||
|
||||
// Moderator and strangers manage nobody.
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _));
|
||||
|
||||
// Self-service: accept exactly the invited role, decline, leave.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role
|
||||
|
||||
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _));
|
||||
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Permission_matrix_merges_stored_cells_over_defaults()
|
||||
{
|
||||
var merged = RoomPermissionMatrix.Merge(new[]
|
||||
{
|
||||
new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" },
|
||||
new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" },
|
||||
});
|
||||
|
||||
Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value);
|
||||
Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value);
|
||||
Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value);
|
||||
Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Permission_body_accepts_numbers_strings_and_enum_names()
|
||||
{
|
||||
var cells = RoomPermissionMatrix.Parse(
|
||||
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" +
|
||||
"""{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" +
|
||||
"""{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]""");
|
||||
|
||||
Assert.NotNull(cells);
|
||||
Assert.Equal(3, cells!.Count);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]);
|
||||
Assert.Null(RoomPermissionMatrix.Parse("role=30"));
|
||||
}
|
||||
|
||||
// ── over HTTP ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Roles_list_reports_the_creator_as_Creator_255()
|
||||
{
|
||||
var (owner, _, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles");
|
||||
var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
|
||||
Assert.Equal(255, me.GetProperty("Role").GetInt32());
|
||||
|
||||
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
|
||||
var detailsRole = details.GetProperty("Roles").EnumerateArray()
|
||||
.Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
|
||||
Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32());
|
||||
|
||||
var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}");
|
||||
Assert.Equal(255, single.GetProperty("Role").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee()
|
||||
{
|
||||
var (owner, invitee, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
var inviteeRow = RoleOf(invited, invitee.PlayerId);
|
||||
Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32());
|
||||
Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
// The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId.
|
||||
long messageId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var msg = await db.Messages.SingleAsync(m =>
|
||||
m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited);
|
||||
Assert.Equal(owner.PlayerId, msg.SenderPlayerId);
|
||||
Assert.Null(msg.ReadAt);
|
||||
messageId = msg.Id;
|
||||
}
|
||||
|
||||
// Accept: the invitee PUTs their own role to 30.
|
||||
using var inviteeRooms = Client("rooms", invitee);
|
||||
var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
|
||||
var acceptedRow = RoleOf(accepted, invitee.PlayerId);
|
||||
Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32());
|
||||
Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
|
||||
Assert.True(await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
|
||||
{
|
||||
var (owner, invitee, stranger) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
|
||||
using var inviteeRooms = Client("rooms", invitee);
|
||||
var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
|
||||
Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
|
||||
e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
|
||||
|
||||
// With the invite gone, the same accept is refused.
|
||||
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
|
||||
await AssertRefusedAsync(again);
|
||||
|
||||
// A stranger can neither promote themselves nor invite anyone.
|
||||
using var strangerRooms = Client("rooms", stranger);
|
||||
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
|
||||
await AssertRefusedAsync(self);
|
||||
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
await AssertRefusedAsync(other);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Co_owner_manages_roles_below_but_not_co_owners()
|
||||
{
|
||||
var (owner, coOwner, target) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
|
||||
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
|
||||
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
|
||||
|
||||
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
|
||||
await AssertRefusedAsync(inviteCo);
|
||||
|
||||
// The creator can directly add and remove a co-owner.
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
|
||||
Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
|
||||
|
||||
// …and now the co-owner can't touch their peer.
|
||||
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
|
||||
await AssertRefusedAsync(demote);
|
||||
|
||||
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
|
||||
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
|
||||
e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
|
||||
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
|
||||
{
|
||||
var (owner, coOwner, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
|
||||
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
|
||||
{
|
||||
var text = await put.Content.ReadAsStringAsync();
|
||||
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
|
||||
// {success, value: full room details, error}
|
||||
var value = Envelope(text);
|
||||
Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
|
||||
}
|
||||
|
||||
// The owner enters the room, then the matrix comes back with the token.
|
||||
using var match = Client("match", owner);
|
||||
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
|
||||
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
|
||||
var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
|
||||
var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
|
||||
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
|
||||
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
|
||||
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
|
||||
&& c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
|
||||
Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
|
||||
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
|
||||
|
||||
// A co-owner may not edit the Co-Owner row, but may edit Moderator.
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
await AssertRefusedAsync(denied);
|
||||
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
Envelope(await allowed.Content.ReadAsStringAsync());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync();
|
||||
Assert.Equal(3, stored.Count);
|
||||
Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0
|
||||
Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token()
|
||||
{
|
||||
var hub = new CapturingHubContext();
|
||||
var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions
|
||||
.RemoveAll<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(services);
|
||||
services.AddSingleton<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(hub);
|
||||
}));
|
||||
|
||||
HttpClient FactoryClient(string sub, GameClientSession? session = null)
|
||||
{
|
||||
var c = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}");
|
||||
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return c;
|
||||
}
|
||||
|
||||
using var setup = FactoryClient("api");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = FactoryClient("rooms", owner);
|
||||
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
|
||||
var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2"));
|
||||
Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32());
|
||||
Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64());
|
||||
Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32());
|
||||
var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate"));
|
||||
Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
using var match = FactoryClient("match", owner);
|
||||
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
|
||||
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
|
||||
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
Envelope(await put.Content.ReadAsStringAsync());
|
||||
|
||||
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
|
||||
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
|
||||
Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
|
||||
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
|
||||
{
|
||||
static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
|
||||
JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
|
||||
|
||||
var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
|
||||
Assert.True(ok.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
|
||||
Assert.Equal("", ok.GetProperty("error").GetString());
|
||||
|
||||
var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
|
||||
Assert.False(forbid.GetProperty("success").GetBoolean());
|
||||
Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
|
||||
|
||||
var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
|
||||
new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
|
||||
Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
|
||||
|
||||
// An envelope the handler built itself is left alone.
|
||||
var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
|
||||
Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
|
||||
{
|
||||
var (owner, invitee, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
using var rooms = Client("rooms", owner);
|
||||
|
||||
// The exact request the client sent in production (bare path, form role=30).
|
||||
var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
foreach (var (path, key, value) in new[]
|
||||
{
|
||||
($"/rooms/{roomId}/description", "description", "hello"),
|
||||
($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
|
||||
($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
|
||||
($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
|
||||
})
|
||||
{
|
||||
var details = await PutFormAsync(rooms, path, (key, value));
|
||||
Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
return (
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
|
||||
}
|
||||
|
||||
private async Task<long> SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles)
|
||||
{
|
||||
var roomId = 9_300_000 + Random.Shared.Next(1, 99_999);
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Roles{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 0,
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" });
|
||||
foreach (var (pid, role) in roles)
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true });
|
||||
await db.SaveChangesAsync();
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private static JsonElement RoleOf(JsonElement details, long playerId) =>
|
||||
details.GetProperty("Roles").EnumerateArray()
|
||||
.Single(e => e.GetProperty("AccountId").GetInt64() == playerId);
|
||||
|
||||
private static Task<HttpResponseMessage> PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||
=> client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
|
||||
|
||||
private static async Task<JsonElement> PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||
{
|
||||
using var response = await PutFormRawAsync(client, path, fields);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
|
||||
return Envelope(body);
|
||||
}
|
||||
|
||||
/// <summary>Every room mutation answers the {success, value, error}
|
||||
/// envelope (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>): assert success and
|
||||
/// return the room details in <c>value</c>.</summary>
|
||||
private static JsonElement Envelope(string body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.GetProperty("success").GetBoolean(), body);
|
||||
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
|
||||
return root.GetProperty("value").Clone();
|
||||
}
|
||||
|
||||
/// <summary>A refusal is HTTP 200 with success=false and a message: the
|
||||
/// client shows the message, while a 403/404 surfaces only as the
|
||||
/// message-less "Exception of type 'CEMNLBKJABA' was thrown".</summary>
|
||||
private static async Task AssertRefusedAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
|
||||
Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
|
||||
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
|
||||
=> client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 room-save flow (captured from the 2023.03.21 client against dev,
|
||||
/// 2026-07-07). The client uploads two blobs to storage /upload —
|
||||
/// FileType=6 room metadata, then FileType=1 scene save — and commits
|
||||
/// them with a JSON POST to rooms/{id}/subrooms/{id}/data:
|
||||
/// { "UnityAssetId": null,
|
||||
/// "RoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null },
|
||||
/// "SubRoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null } }
|
||||
/// SubRoomData.Filename is the scene blob and must become the room's
|
||||
/// CurrentDataBlobName.
|
||||
/// </summary>
|
||||
public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomSave2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task SubRoomData_accepts_2023_nested_json_and_commits_blob()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_200_001L;
|
||||
var blobName = $"room_{roomId}_v1_{Guid.NewGuid():N}.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Save2023_{Guid.NewGuid():N}"[..24],
|
||||
Description = "2023 save flow test room",
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Rooms.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(r => r.CreatorPlayerId, owner.PlayerId));
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
var body = new
|
||||
{
|
||||
UnityAssetId = (long?)null,
|
||||
RoomData = new { Filename = "roommeta_test.bin", Hash = (string?)null, OwnershipProof = (string?)null },
|
||||
SubRoomData = new { Filename = blobName, Hash = (string?)null, OwnershipProof = (string?)null },
|
||||
};
|
||||
using var response = await roomsClient.PostAsync(
|
||||
$"/rooms/{roomId}/subrooms/0/data",
|
||||
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
|
||||
|
||||
using var json = JsonDocument.Parse(responseText);
|
||||
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText);
|
||||
|
||||
// 2023 commit-response contract (NEOPBOMGIOG): value carries a
|
||||
// Room and a SubRoomDataSave. The Room reuses the exact GET
|
||||
// rooms/{id} shape (BuildRoomServerDetails) — do NOT bolt extra
|
||||
// keys onto it: the room-details mapper is strict about value
|
||||
// shapes and an invented RankingContext object broke every
|
||||
// room-details parse ("Malformed Response"). See befd590 revert.
|
||||
var value = json.RootElement.GetProperty("value");
|
||||
var roomDto = value.GetProperty("Room");
|
||||
Assert.Equal(JsonValueKind.Object, roomDto.ValueKind);
|
||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles", "Tags", "Stats" })
|
||||
Assert.True(roomDto.TryGetProperty(key, out _), $"Room missing '{key}': {responseText}");
|
||||
|
||||
var save = value.GetProperty("SubRoomDataSave");
|
||||
Assert.Equal(blobName, save.GetProperty("DataBlob").GetString());
|
||||
foreach (var key in new[] { "SubRoomDataSaveId", "SubRoomId", "UnityAssetId", "SavedByAccountId", "CreatedAt" })
|
||||
Assert.True(save.TryGetProperty(key, out _), $"SubRoomDataSave missing '{key}': {responseText}");
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.AsNoTracking().SingleAsync(r => r.Id == roomId);
|
||||
Assert.Equal(blobName, room.CurrentDataBlobName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_fileType6_room_metadata_is_stored_not_stubbed()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
using var storageClient = ApiClient(player, subdomain: "storage");
|
||||
using var form = new MultipartFormDataContent("BestHTTP_HTTPMultiPartForm_TEST");
|
||||
var file = new ByteArrayContent(new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
form.Add(file, "File", "file.bin");
|
||||
form.Add(new StringContent("6"), "FileType");
|
||||
|
||||
using var response = await storageClient.PostAsync("/upload", form);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
|
||||
|
||||
using var json = JsonDocument.Parse(responseText);
|
||||
var filename = json.RootElement.GetProperty("filename").GetString();
|
||||
Assert.NotNull(filename);
|
||||
Assert.StartsWith("roommeta_", filename);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.True(
|
||||
await db.RoomDataBlobs.AnyAsync(b => b.BlobName == filename),
|
||||
$"expected a RoomDataBlobs row for {filename}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: cloning a room — and then cloning the CLONE — must both
|
||||
/// succeed and return the full room-details shape (FGCPNAACHIK). Previously
|
||||
/// the handler relied on DB auto-increment for the room id AND the copied
|
||||
/// scene ids, which collide with the seeded id range once the identity
|
||||
/// sequence lags → the clone POST 500s and the client boots the player to
|
||||
/// their dorm ("Failed to copy room: Failed to clone room").
|
||||
///
|
||||
/// The first clone goes through the <c>roomserver/</c>-prefixed route:
|
||||
/// the 2023 in-room "copy room" flow (RecNet.Runtime NLDBPDCNNCF) sends
|
||||
/// that prefix like every other room mutation, and the bare-only route
|
||||
/// 404'd with an empty body ("Failed to copy room: Exception of type
|
||||
/// '…' was thrown").
|
||||
///
|
||||
/// Blob rules: cloning a FIRST-PARTY template (AG + system-owned) must
|
||||
/// give the clone a FRESH empty blob — the template's blob is a MakerPen
|
||||
/// overlay against the shared baked scene, not the clone's content.
|
||||
/// Cloning a USER-owned room (even AG-flagged) must carry its blob so
|
||||
/// "copy room" keeps the player's edits.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Cloning_a_room_and_then_the_clone_both_succeed()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_300_777L;
|
||||
var templateBlob = $"room_{roomId}_v1.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"CloneSrc_{Guid.NewGuid():N}"[..20],
|
||||
Description = "clone source",
|
||||
// A first-party RRO/AG base room (RecCenter-like):
|
||||
// system-owned, CloningAllowed=false, yet the base-room
|
||||
// picker must still be able to clone it. Its blob is a
|
||||
// MakerPen overlay that must NOT leak into clones.
|
||||
CreatorPlayerId = PlayerService.SystemAccountId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
CloningAllowed = false,
|
||||
CurrentDataBlobName = templateBlob,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Main",
|
||||
DataBlobName = templateBlob,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
|
||||
// First clone: template → player copy, via the roomserver/ prefix the
|
||||
// real client uses.
|
||||
var firstCloneId = await CloneAndAssertAsync(
|
||||
roomsClient, roomId, "FirstClone", pathPrefix: "/roomserver");
|
||||
Assert.NotEqual(roomId, firstCloneId);
|
||||
// A clone of a first-party template used to be left with NO blob, on
|
||||
// the reasoning that its content is baked client geometry. That left
|
||||
// the copy with nothing to load: the CDN synthesises a ~1.8 KB stub for
|
||||
// a name it has nothing stored under, and the client fetches exactly
|
||||
// that through GetRoomData ("/room/{blob}") while copying, then rejects
|
||||
// it. Clones that carried real content joined fine.
|
||||
//
|
||||
// The content now comes across. Sharing the SOURCE's object is still
|
||||
// wrong, but that is prevented by copying it under the clone's own name
|
||||
// rather than by having no blob at all — see
|
||||
// RoomCloneShapeTests.Clone_does_not_reference_the_source_rooms_blob,
|
||||
// which runs with a real object store. This suite has none, so the
|
||||
// copy deliberately falls back to keeping the reference and only the
|
||||
// "has content" half is checkable here.
|
||||
await AssertBlobsAsync(firstCloneId, expectEmpty: false,
|
||||
"a clone of a first-party template must carry its content");
|
||||
|
||||
// Simulate the player saving MakerPen edits into their clone.
|
||||
var editedBlob = $"room_{firstCloneId}_edit_{Guid.NewGuid():N}.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.SingleAsync(r => r.Id == firstCloneId);
|
||||
room.CurrentDataBlobName = editedBlob;
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == firstCloneId);
|
||||
scene.DataBlobName = editedBlob;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// The regression: cloning the clone (bare path still works too).
|
||||
// The source is user-owned — even though it inherited IsAGRoom, the
|
||||
// player's edits must carry over.
|
||||
var secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone");
|
||||
Assert.NotEqual(firstCloneId, secondCloneId);
|
||||
await AssertBlobsAsync(secondCloneId, expectEmpty: false,
|
||||
"copying a user-owned room must keep its blob (player edits)");
|
||||
}
|
||||
|
||||
private async Task AssertBlobsAsync(long roomId, bool expectEmpty, string why)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var room = await db.Rooms.SingleAsync(r => r.Id == roomId);
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == roomId);
|
||||
if (expectEmpty)
|
||||
{
|
||||
Assert.True(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was '{room.CurrentDataBlobName}'");
|
||||
Assert.True(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was '{scene.DataBlobName}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was empty");
|
||||
Assert.False(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was empty");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<long> CloneAndAssertAsync(
|
||||
HttpClient client, long sourceId, string name, string pathPrefix = "")
|
||||
{
|
||||
// The real 2023 client posts the name as x-www-form-urlencoded
|
||||
// (`name=...`), NOT JSON — testing with JSON would miss the 415 that
|
||||
// a [FromBody] handler returns for a form POST.
|
||||
using var resp = await client.PostAsync(
|
||||
$"{pathPrefix}/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = name }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
// {success, value, error} envelope around the full room-details
|
||||
// shape (FGCPNAACHIK).
|
||||
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
|
||||
var root = json.RootElement.GetProperty("value");
|
||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
||||
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
||||
return root.GetProperty("RoomId").GetInt64();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: <c>GET rooms/{id}/subrooms/{sub}/saves</c> must return the
|
||||
/// paged wrapper <c>{"Results":[…],"TotalResults":N}</c>
|
||||
/// (PagedResultsDTO<SubRoomDataSaveDTO>), NOT a bare array. The 2023
|
||||
/// client's GetSubRoomDataSaves strict reader throws
|
||||
/// <c>expected:'{', actual:'['</c> on an array, which aborts the whole
|
||||
/// calling flow — room clone fails with the message-less "Failed to copy
|
||||
/// room" and the private-instance button silently does nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubRoom_saves_returns_paged_object_not_bare_array()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_300_888L;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
|
||||
{
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"SavesShape_{Guid.NewGuid():N}"[..20],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 1,
|
||||
CurrentDataBlobName = $"room_{roomId}_v1.dat",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
DataBlobName = $"room_{roomId}_v1.dat",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||
foreach (var path in new[]
|
||||
{
|
||||
$"/roomserver/rooms/{roomId}/subrooms/0/saves?skip=0&take=10",
|
||||
$"/rooms/{roomId}/subrooms/0/saves",
|
||||
$"/roomserver/rooms/{roomId}/subrooms/0/saves/no_unity_assets?skip=0&take=10",
|
||||
})
|
||||
{
|
||||
using var resp = await roomsClient.GetAsync(path);
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{path} -> {(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
Assert.Equal(JsonValueKind.Object, json.RootElement.ValueKind);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.GetProperty("Results").ValueKind);
|
||||
Assert.True(json.RootElement.GetProperty("TotalResults").GetInt64() >= 1, $"TotalResults missing/zero: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class RoomServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToWireRoom_never_emits_empty_image_name()
|
||||
{
|
||||
var room = new RoomEntity
|
||||
{
|
||||
Id = 1001,
|
||||
Name = "CustomRoomWithoutImage",
|
||||
Description = "Custom room",
|
||||
ImageName = "",
|
||||
Accessibility = 1,
|
||||
State = 0,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(RoomService.ToWireRoom(room));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
|
||||
Assert.Equal(RoomService.DefaultRoomImageName, document.RootElement.GetProperty("ImageName").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDisplayImageName_preserves_existing_image_name()
|
||||
{
|
||||
var room = new RoomEntity
|
||||
{
|
||||
Name = "Paintball",
|
||||
ImageName = "custom-thumbnail.png",
|
||||
};
|
||||
|
||||
Assert.Equal("custom-thumbnail.png", RoomService.ResolveDisplayImageName(room));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class RoomsVisitedByTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomsVisitedByTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Continue_rooms_hide_private_rooms_from_non_owners()
|
||||
{
|
||||
using var setupClient = ApiClient();
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
var visitor = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var publicRoomId = 9_100_001L;
|
||||
var privateRoomId = 9_100_002L;
|
||||
var publicRoomName = $"ContinuePublic_{suffix}";
|
||||
var privateRoomName = $"ContinuePrivate_{suffix}";
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.AddRange(
|
||||
BuildRoom(publicRoomId, publicRoomName, owner.PlayerId, accessibility: 1),
|
||||
BuildRoom(privateRoomId, privateRoomName, owner.PlayerId, accessibility: 0));
|
||||
db.RoomVisits.AddRange(
|
||||
BuildVisit(publicRoomId, visitor.PlayerId, now.AddMinutes(-2)),
|
||||
BuildVisit(privateRoomId, visitor.PlayerId, now.AddMinutes(-1)),
|
||||
BuildVisit(privateRoomId, owner.PlayerId, now));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var visitorClient = ApiClient(visitor);
|
||||
var visitorNames = await GetContinueRoomNamesAsync(visitorClient);
|
||||
|
||||
Assert.Contains(publicRoomName, visitorNames);
|
||||
Assert.DoesNotContain(privateRoomName, visitorNames);
|
||||
|
||||
using var ownerClient = ApiClient(owner);
|
||||
var ownerNames = await GetContinueRoomNamesAsync(ownerClient);
|
||||
|
||||
Assert.Contains(privateRoomName, ownerNames);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string[]> GetContinueRoomNamesAsync(HttpClient client)
|
||||
{
|
||||
using var response = await client.GetAsync("/rooms/visitedby/me");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync();
|
||||
using var document = await JsonDocument.ParseAsync(body);
|
||||
return document.RootElement
|
||||
.EnumerateArray()
|
||||
.Select(room => room.GetProperty("Name").GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static RoomEntity BuildRoom(long id, string name, long creatorPlayerId, int accessibility) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = name,
|
||||
CreatorPlayerId = creatorPlayerId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
State = 0,
|
||||
Accessibility = accessibility,
|
||||
IsAGRoom = true,
|
||||
IsDormRoom = false,
|
||||
HiddenFromBrowse = false,
|
||||
TagsCsv = "community",
|
||||
};
|
||||
|
||||
private static RoomVisitEntity BuildVisit(long roomId, long playerId, DateTime visitedAt) => new()
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = playerId,
|
||||
FirstVisitAt = visitedAt,
|
||||
LastVisitAt = visitedAt,
|
||||
VisitCount = 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using RecRoom.Protobuf;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty
|
||||
/// <see cref="RoomSceneEntity.DataBlobName"/>, and every details builder and
|
||||
/// matchmaking resolver turns that into an empty <c>DataBlob</c>. The client
|
||||
/// reads that as "no room data at all": no persistence views, no room-role
|
||||
/// overlay, no Maker Pen, no CV2 chips, no room mood engine. Real Rec Room
|
||||
/// served these rooms a blob — Rec Royale's game-rule chip lives in it, not
|
||||
/// in the scene — so DorkNet needs a switch that gives such a room one.
|
||||
///
|
||||
/// The property pinned here: the admin "enable blob" action stamps the
|
||||
/// synthetic default name, that name flows through the game-client details
|
||||
/// verbatim while <c>IsRRO</c> stays true (the client still loads the baked
|
||||
/// map, and only additionally fetches the blob), the CDN answers that name
|
||||
/// with the RRO-editable blob (permissive room roles, no objects), and
|
||||
/// "reset" puts everything back to scene-only.
|
||||
/// </summary>
|
||||
public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RroRoomBlobTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Baked_original_gets_no_blob_until_an_admin_enables_one()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
|
||||
var roomName = $"Royale{Guid.NewGuid():N}"[..18];
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
|
||||
// Shape of a seeded baked original: system-owned, AG-flagged,
|
||||
// tagged recroomoriginal, no blob anywhere.
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = roomName,
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal,sport",
|
||||
CurrentDataBlobName = string.Empty,
|
||||
LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||
using var gameClient = Client("rooms", player);
|
||||
using var adminClient = Client("admin", admin);
|
||||
using var cdnClient = Client("cdn");
|
||||
|
||||
// ── before: baked scene only ────────────────────────────────────────
|
||||
var before = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.True(before.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(string.Empty, SubRoomZero(before).GetProperty("DataBlob").GetString());
|
||||
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var row = listed.EnumerateArray().Single();
|
||||
Assert.True(row.GetProperty("isBakedOriginal").GetBoolean());
|
||||
Assert.Equal(string.Empty, row.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
// ── enable: the synthetic name lands on the scene AND the room ─────
|
||||
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.True(enabled.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
|
||||
Assert.Equal(synthetic, enabled.GetProperty("dataBlobName").GetString());
|
||||
Assert.Equal(synthetic, enabled.GetProperty("roomCurrentDataBlobName").GetString());
|
||||
|
||||
// Idempotent: a second enable changes nothing and keeps the name.
|
||||
var again = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.False(again.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(synthetic, again.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
// The game client now gets the blob name, and IsRRO is untouched —
|
||||
// that flag is what makes it load the baked map (a false there sends
|
||||
// it looking for geometry inside the blob and the join fails).
|
||||
var after = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.True(after.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(synthetic, SubRoomZero(after).GetProperty("DataBlob").GetString());
|
||||
|
||||
// ── the CDN answers that name with the RRO-editable blob ───────────
|
||||
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
|
||||
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
|
||||
Assert.True(blobResponse.IsSuccessStatusCode,
|
||||
$"GET cdn /room/{synthetic} -> {(int)blobResponse.StatusCode}");
|
||||
var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes);
|
||||
Assert.NotNull(persisted.RoomRoleData);
|
||||
Assert.NotEmpty(persisted.RoomRoleData.RoomRoles);
|
||||
Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true }));
|
||||
|
||||
// The March-2023 client rejects a blob past PersistedRoomVersion 16
|
||||
// (the old stub was stamped 19 and loaded as "no room data"), and a
|
||||
// real save carries the room's location id as activity_id. Our proto
|
||||
// doesn't type activity_id, so look for it on the wire.
|
||||
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version);
|
||||
var blobText = System.Text.Encoding.UTF8.GetString(blobBytes);
|
||||
Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", blobText);
|
||||
|
||||
// Every real room save carries three GameRoleNode chips (default role
|
||||
// named after the room, "In-Game", "Eliminated" — well-known GUIDs).
|
||||
// The 2023 client grants Maker Pen off the player's GameRole, so a
|
||||
// blob with no role objects leaves even a co-owner with nothing.
|
||||
Assert.Equal(3, CountTopLevelPersistenceViews(blobBytes));
|
||||
Assert.Contains("In-Game", blobText);
|
||||
Assert.Contains("Eliminated", blobText);
|
||||
Assert.Contains("2c721342-c7ed-49fe-b8f0-93f8d282a6df", blobText);
|
||||
Assert.Contains("0b9ec39e-2cc9-4825-8321-da11561f1c4f", blobText);
|
||||
Assert.Contains("c48bac02-a743-4797-94c4-e2d6f0749004", blobText);
|
||||
Assert.Contains(roomName, blobText);
|
||||
|
||||
// ── reset: back to scene-only ───────────────────────────────────────
|
||||
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
||||
Assert.True(reset.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(string.Empty, reset.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
var restored = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.True(restored.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString());
|
||||
}
|
||||
|
||||
/// <summary>The seeded Rec Royale rows have NO RoomScenes at all (the admin
|
||||
/// page showed "Scenes 0 / No sub-rooms"). The admin list must synthesise
|
||||
/// the entry scene the way the game-client details do, and enable must
|
||||
/// create the row rather than 404.</summary>
|
||||
[Fact]
|
||||
public async Task Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_670_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"NoScene{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
MaxCapacity = 24,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||
using var gameClient = Client("rooms", player);
|
||||
using var adminClient = Client("admin", admin);
|
||||
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var virtualRow = listed.EnumerateArray().Single();
|
||||
Assert.True(virtualRow.GetProperty("isVirtual").GetBoolean());
|
||||
Assert.Equal(0, virtualRow.GetProperty("subRoomId").GetInt32());
|
||||
Assert.Equal(24, virtualRow.GetProperty("maxPlayers").GetInt32());
|
||||
Assert.Equal(string.Empty, virtualRow.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.True(enabled.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
// The row now exists with the game-side defaults, and the game client
|
||||
// sees the blob on its entry scene.
|
||||
var relisted = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var real = relisted.EnumerateArray().Single();
|
||||
Assert.False(real.TryGetProperty("isVirtual", out _));
|
||||
Assert.Equal("Home", real.GetProperty("name").GetString());
|
||||
Assert.Equal(24, real.GetProperty("maxPlayers").GetInt32());
|
||||
Assert.Equal(synthetic, real.GetProperty("dataBlobName").GetString());
|
||||
|
||||
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.True(details.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(synthetic, SubRoomZero(details).GetProperty("DataBlob").GetString());
|
||||
|
||||
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
||||
Assert.True(reset.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Rec Royale's Frontier scene has no baked game rule; real Rec
|
||||
/// Room delivered the RecRoyaleSolos/Squads_GameRuleWrapperCircuit chip
|
||||
/// in the blob. The stub for those rooms must carry it (a fourth
|
||||
/// persistence view with the palette tag the client generates).</summary>
|
||||
[Theory]
|
||||
[InlineData("RecRoyaleSolos", "recroyalesolosgamerulecir")]
|
||||
[InlineData("RecRoyaleSquads", "recroyalesquadsgameruleci")]
|
||||
public async Task Rec_royale_stub_carries_its_game_rule_chip(string roomNamePrefix, string expectedTag)
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_680_000 + Random.Shared.Next(1, 99_999);
|
||||
var roomName = $"{roomNamePrefix}-{Guid.NewGuid():N}"[..24];
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = roomName,
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
LocationReplicationId = "253fa009-6e65-4c90-91a1-7137a56a267f",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
using var cdnClient = Client("cdn");
|
||||
await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
|
||||
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
|
||||
Assert.True(blobResponse.IsSuccessStatusCode);
|
||||
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(4, CountTopLevelPersistenceViews(blobBytes)); // 3 roles + the rule chip
|
||||
Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes));
|
||||
}
|
||||
|
||||
/// <summary>A real save from any client version can be attached to a baked
|
||||
/// original: activity id rewritten to the target room, output log /
|
||||
/// sub-room id / unknown fields dropped, version clamped, and in
|
||||
/// <c>basics</c> mode only the role + rule objects kept with circuits
|
||||
/// stripped. The room then loads that blob (with the CDN's role overlay).</summary>
|
||||
[Fact]
|
||||
public async Task Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_690_000 + Random.Shared.Next(1, 99_999);
|
||||
const string location = "253fa009-6e65-4c90-91a1-7137a56a267f";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Upload{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
LocationReplicationId = location,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// A "modern" save: version 138, someone else's activity id, an output
|
||||
// log, a sub-room id, a newer-only field 37, circuit payloads, and
|
||||
// three views — a game role (field 50), a game rule (field 41) and a
|
||||
// plain prop (field 15 only).
|
||||
var modern = BuildModernSave();
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new ByteArrayContent(modern), "file", "persisted_room_data.binpb");
|
||||
using var uploaded = await adminClient.PostAsync($"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/upload?mode=basics", form);
|
||||
var uploadBody = await uploaded.Content.ReadAsStringAsync();
|
||||
Assert.True(uploaded.IsSuccessStatusCode, $"upload -> {(int)uploaded.StatusCode}: {uploadBody}");
|
||||
var result = JsonDocument.Parse(uploadBody).RootElement;
|
||||
var blobName = result.GetProperty("dataBlobName").GetString()!;
|
||||
Assert.StartsWith($"room_{roomId}_upload_", blobName);
|
||||
var summary = result.GetProperty("summary");
|
||||
Assert.Equal(3, summary.GetProperty("viewsIn").GetInt32());
|
||||
Assert.Equal(2, summary.GetProperty("viewsKept").GetInt32());
|
||||
Assert.Equal(138, summary.GetProperty("versionIn").GetInt32());
|
||||
Assert.Equal(16, summary.GetProperty("versionOut").GetInt32());
|
||||
|
||||
// The game client is now pointed at the uploaded blob …
|
||||
using var gameClient = Client("rooms", player);
|
||||
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.Equal(blobName, SubRoomZero(details).GetProperty("DataBlob").GetString());
|
||||
|
||||
// … and the CDN serves it rewritten: 2 views, clamped header, our
|
||||
// activity id, the role overlay applied, no field 37 / output log.
|
||||
using var cdnClient = Client("cdn");
|
||||
using var served = await cdnClient.GetAsync($"/room/{blobName}");
|
||||
Assert.True(served.IsSuccessStatusCode, $"cdn -> {(int)served.StatusCode}");
|
||||
var bytes = await served.Content.ReadAsByteArrayAsync();
|
||||
var parsed = PersistedRoomData.Parser.ParseFrom(bytes);
|
||||
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)parsed.Version);
|
||||
Assert.NotEmpty(parsed.RoomRoleData.RoomRoles);
|
||||
Assert.Equal(2, CountTopLevelPersistenceViews(bytes));
|
||||
var text = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
Assert.Contains(location, text);
|
||||
Assert.DoesNotContain("someone-elses-activity", text);
|
||||
Assert.DoesNotContain("Serialized 3 persistence views", text);
|
||||
}
|
||||
|
||||
private static byte[] BuildModernSave()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
void Varint(int field, ulong v) { WriteVarint(ms, ((ulong)field << 3) | 0); WriteVarint(ms, v); }
|
||||
void Bytes(int field, byte[] payload) { WriteVarint(ms, ((ulong)field << 3) | 2); WriteVarint(ms, (ulong)payload.Length); ms.Write(payload); }
|
||||
static byte[] View(int markerField)
|
||||
{
|
||||
var v = new MemoryStream();
|
||||
WriteVarint(v, (1UL << 3) | 2); WriteVarint(v, 16); v.Write(Guid.NewGuid().ToByteArray());
|
||||
WriteVarint(v, ((ulong)markerField << 3) | 2); WriteVarint(v, 0);
|
||||
return v.ToArray();
|
||||
}
|
||||
Varint(1, 38);
|
||||
Bytes(2, View(50)); // game role
|
||||
Bytes(2, View(41)); // game rule wrapper
|
||||
Bytes(2, View(15)); // a prop (tagged tool only)
|
||||
Bytes(4, System.Text.Encoding.UTF8.GetBytes("someone-elses-activity-0000-000000000000"));
|
||||
Bytes(5, System.Text.Encoding.UTF8.GetBytes("Serialized 3 persistence views"));
|
||||
Varint(6, 3991471883933115623UL);
|
||||
Bytes(18, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||
Bytes(28, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||
Varint(30, 138);
|
||||
Varint(31, 1);
|
||||
Varint(37, 1);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteVarint(Stream s, ulong value)
|
||||
{
|
||||
while (value >= 0x80) { s.WriteByte((byte)(value | 0x80)); value >>= 7; }
|
||||
s.WriteByte((byte)value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_650_000 + Random.Shared.Next(1, 99_999);
|
||||
const string saved = "room_saved_by_a_player_v3.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Saved{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
CurrentDataBlobName = saved,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = saved });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.False(enabled.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Count top-level <c>persistence_views</c> (field 2) entries by
|
||||
/// walking the wire format — our server proto doesn't type field 2.</summary>
|
||||
private static int CountTopLevelPersistenceViews(byte[] blob)
|
||||
{
|
||||
var pos = 0;
|
||||
var count = 0;
|
||||
while (pos < blob.Length)
|
||||
{
|
||||
var tag = ReadVarint(blob, ref pos);
|
||||
var field = (int)(tag >> 3);
|
||||
var wireType = (int)(tag & 7);
|
||||
switch (wireType)
|
||||
{
|
||||
case 0: ReadVarint(blob, ref pos); break;
|
||||
case 1: pos += 8; break;
|
||||
case 2:
|
||||
var len = (int)ReadVarint(blob, ref pos);
|
||||
if (field == 2) count++;
|
||||
pos += len;
|
||||
break;
|
||||
case 5: pos += 4; break;
|
||||
default: throw new InvalidDataException($"wire type {wireType}");
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static ulong ReadVarint(byte[] input, ref int pos)
|
||||
{
|
||||
ulong value = 0;
|
||||
var shift = 0;
|
||||
while (true)
|
||||
{
|
||||
var b = input[pos++];
|
||||
value |= (ulong)(b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) return value;
|
||||
shift += 7;
|
||||
}
|
||||
}
|
||||
|
||||
// The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
|
||||
private static JsonElement SubRoomZero(JsonElement details) =>
|
||||
details.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.PostAsync(path, content: null);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the two ways a column reaches a database that already exists, because
|
||||
/// getting either one wrong is invisible until a live server starts returning
|
||||
/// 500s.
|
||||
///
|
||||
/// Every other test in this suite runs against a FRESH database, where
|
||||
/// <c>EnsureCreated</c> builds the whole schema straight from the entity model —
|
||||
/// so every column always exists and none of this is exercised. Only an
|
||||
/// *existing* database can be missing a column, and that is exactly what took
|
||||
/// down club home, matchmake/dorm, the presence heartbeat and the chat inbox at
|
||||
/// once: three columns were added to the entity model with an EF migration, but
|
||||
/// the Postgres path never replays migrations, so a deployed database never got
|
||||
/// them.
|
||||
///
|
||||
/// The two mechanisms, and the invariant each test asserts:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><b>SQLite</b> applies committed EF migrations via <c>Database.Migrate()</c>.
|
||||
/// So the migration history alone must be able to build the current model.</item>
|
||||
/// <item><b>Postgres</b> is <c>EnsureCreated</c>-only and NEVER replays
|
||||
/// migrations. A database created before a column existed only gets it from an
|
||||
/// idempotent <c>Ensure*</c> ALTER in <see cref="DorkNet.Server.Startup"/>'s
|
||||
/// DatabaseBootstrap. So anything added after the Initial migration must appear
|
||||
/// there too.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SchemaBootstrapParityTests
|
||||
{
|
||||
/// <summary>Every table and column in the entity model must be reachable on
|
||||
/// a database that already exists — via a committed migration, an
|
||||
/// idempotent <c>Ensure*</c> patch, or both.
|
||||
///
|
||||
/// That union is exactly what boot does: SQLite runs <c>Database.Migrate()</c>
|
||||
/// and then the <c>Ensure*</c> helpers, and Postgres runs the helpers alone.
|
||||
/// A property with NEITHER reaches only brand-new databases, and every
|
||||
/// query touching it throws at runtime on a deployed server.
|
||||
///
|
||||
/// The union is asserted rather than migrations alone because a large amount
|
||||
/// of the schema legitimately predates this rule and lives only in the
|
||||
/// helpers. Tightening that up is a separate cleanup; what must never happen
|
||||
/// is a column covered by nothing.</summary>
|
||||
[Fact]
|
||||
public async Task Every_model_column_reaches_an_existing_database()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DorkNetDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var db = new DorkNetDbContext(options);
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var actual = await ReadSqliteSchemaAsync(connection);
|
||||
var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: false);
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var entity in db.Model.GetEntityTypes())
|
||||
{
|
||||
var table = entity.GetTableName();
|
||||
if (string.IsNullOrEmpty(table)) continue;
|
||||
|
||||
var migrated = actual.TryGetValue(table, out var columns);
|
||||
if (!migrated && !patchedTables.Contains(table))
|
||||
{
|
||||
missing.Add($"{table} (whole table)");
|
||||
continue;
|
||||
}
|
||||
// A table the helpers create carries its own columns in the same
|
||||
// CREATE, so only migrated tables need a per-column check.
|
||||
if (!migrated) continue;
|
||||
|
||||
foreach (var property in entity.GetProperties())
|
||||
{
|
||||
var column = property.GetColumnName();
|
||||
if (string.IsNullOrEmpty(column)) continue;
|
||||
if (columns!.Contains(column)) continue;
|
||||
if (patchedColumns.Contains($"{table}.{column}")) continue;
|
||||
missing.Add($"{table}.{column}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
$"""
|
||||
{missing.Count} model table(s)/column(s) are covered by NOTHING.
|
||||
|
||||
They exist on a freshly-created database (EnsureCreated builds the
|
||||
whole model) but never appear on one that already exists, so a
|
||||
deployed server throws "no such column" / "column does not exist"
|
||||
on every request that touches them.
|
||||
|
||||
Add a migration AND an idempotent ALTER in an Ensure*Async helper in
|
||||
DorkNet.Server/Startup/DatabaseBootstrap.cs for:
|
||||
|
||||
{string.Join(Environment.NewLine, missing.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Every column added AFTER the Initial migration must also be
|
||||
/// added by an idempotent Postgres <c>ALTER</c> in DatabaseBootstrap.
|
||||
///
|
||||
/// Postgres never replays migrations, so a migration alone reaches only
|
||||
/// brand-new databases. A production database created before the column
|
||||
/// existed gets it from the Ensure* helpers or not at all — the exact hole
|
||||
/// that broke matchmake/dorm (PrivateInstances.RoomCode),
|
||||
/// club/home/me (Clubs.ClubChatEnabled) and the chat inbox
|
||||
/// (ChatMessages.ModerationState).</summary>
|
||||
[Fact]
|
||||
public void Post_initial_columns_are_patched_for_existing_postgres_databases()
|
||||
{
|
||||
var migrations = FindRepoDirectory("DorkNet.Server", "Migrations");
|
||||
var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: true);
|
||||
|
||||
// Anything AddColumn'd by a migration other than Initial post-dates the
|
||||
// baseline, so an existing Postgres database will not have it.
|
||||
var gaps = new List<string>();
|
||||
foreach (var file in Directory.GetFiles(migrations, "*.cs"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
if (name.Contains("Designer", StringComparison.Ordinal)) continue;
|
||||
if (name.Contains("Initial", StringComparison.Ordinal)) continue;
|
||||
if (name.Contains("ModelSnapshot", StringComparison.Ordinal)) continue;
|
||||
|
||||
var text = File.ReadAllText(file);
|
||||
foreach (Match m in Regex.Matches(text,
|
||||
@"AddColumn<[^>]+>\(\s*name:\s*""(?<column>\w+)""\s*,\s*table:\s*""(?<table>\w+)""",
|
||||
RegexOptions.Singleline))
|
||||
{
|
||||
var table = m.Groups["table"].Value;
|
||||
var column = m.Groups["column"].Value;
|
||||
if (patchedTables.Contains(table)) continue; // whole table is created by a patch
|
||||
if (patchedColumns.Contains($"{table}.{column}")) continue;
|
||||
gaps.Add($"{table}.{column} (added by {name})");
|
||||
}
|
||||
|
||||
foreach (Match m in Regex.Matches(text,
|
||||
@"CreateTable\(\s*name:\s*""(?<table>\w+)""",
|
||||
RegexOptions.Singleline))
|
||||
{
|
||||
var table = m.Groups["table"].Value;
|
||||
if (!patchedTables.Contains(table))
|
||||
gaps.Add($"{table} (whole table, created by {name})");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(gaps.Count == 0,
|
||||
$"""
|
||||
{gaps.Count} schema change(s) reach new databases only.
|
||||
|
||||
The Postgres path is EnsureCreated-only and never replays
|
||||
migrations, so a migration alone does NOT add these to a database
|
||||
that already exists — a deployed server will throw
|
||||
"column does not exist" on every request that touches them.
|
||||
|
||||
Add an idempotent ALTER/CREATE to an Ensure*Async helper in
|
||||
DorkNet.Server/Startup/DatabaseBootstrap.cs for:
|
||||
|
||||
{string.Join(Environment.NewLine, gaps.Order(StringComparer.Ordinal))}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Tables and columns the <c>Ensure*</c> helpers add by raw SQL.
|
||||
///
|
||||
/// The helpers are private, so this reads their statements out of the source
|
||||
/// rather than executing them. In that source the SQL sits inside verbatim
|
||||
/// strings, so a quoted identifier appears as two literal quote characters
|
||||
/// (<c>""Playlists""</c>).
|
||||
///
|
||||
/// <paramref name="postgresOnly"/> keys off <c>IF NOT EXISTS</c>: the
|
||||
/// Postgres branch uses it, while the SQLite branch omits it and swallows
|
||||
/// the duplicate-column error instead. So requiring it isolates the branch
|
||||
/// that an existing Postgres database actually depends on.</summary>
|
||||
private static (HashSet<string> Tables, HashSet<string> Columns) ReadBootstrapPatches(
|
||||
bool postgresOnly)
|
||||
{
|
||||
var bootstrap = Path.Combine(
|
||||
FindRepoDirectory("DorkNet.Server", "Startup"), "DatabaseBootstrap.cs");
|
||||
Assert.True(File.Exists(bootstrap), $"missing {bootstrap}");
|
||||
var sql = File.ReadAllText(bootstrap);
|
||||
|
||||
// The source mixes both C# string styles, so a quoted identifier shows
|
||||
// up as either two quote characters (verbatim, @"... ""X"" ...") or one
|
||||
// (raw, """... "X" ..."""). Accept either.
|
||||
const string Q = "\"{1,2}";
|
||||
var ifNotExists = postgresOnly ? @"IF\s+NOT\s+EXISTS\s+" : @"(?:IF\s+NOT\s+EXISTS\s+)?";
|
||||
|
||||
var columns = Regex
|
||||
.Matches(sql,
|
||||
$@"ALTER\s+TABLE\s+{Q}(?<table>\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}(?<column>\w+){Q}",
|
||||
RegexOptions.IgnoreCase)
|
||||
.Select(m => $"{m.Groups["table"].Value}.{m.Groups["column"].Value}")
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tables = Regex
|
||||
.Matches(sql,
|
||||
$@"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+{Q}(?<table>\w+){Q}",
|
||||
RegexOptions.IgnoreCase)
|
||||
.Select(m => m.Groups["table"].Value)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Some helpers loop a column-name array through one interpolated ALTER
|
||||
// (EnsureGameRewardColumnsAsync does). The statement alone only shows
|
||||
// "{col}", so pair each such ALTER with the nearest preceding
|
||||
// `new[] { "A", "B" }` and expand it.
|
||||
foreach (Match alter in Regex.Matches(sql,
|
||||
$@"ALTER\s+TABLE\s+{Q}(?<table>\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}\{{(?<var>\w+)\}}{Q}",
|
||||
RegexOptions.IgnoreCase))
|
||||
{
|
||||
var loop = Regex.Matches(
|
||||
sql[..alter.Index],
|
||||
$@"foreach\s*\(\s*var\s+{Regex.Escape(alter.Groups["var"].Value)}\s+in\s+new\[\]\s*\{{(?<items>[^}}]*)\}}")
|
||||
.Cast<Match>()
|
||||
.LastOrDefault();
|
||||
if (loop is null) continue;
|
||||
|
||||
foreach (Match item in Regex.Matches(loop.Groups["items"].Value, "\"(?<name>\\w+)\""))
|
||||
columns.Add($"{alter.Groups["table"].Value}.{item.Groups["name"].Value}");
|
||||
}
|
||||
|
||||
return (tables, columns);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, HashSet<string>>> ReadSqliteSchemaAsync(
|
||||
SqliteConnection connection)
|
||||
{
|
||||
var schema = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tables = new List<string>();
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync()) tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
foreach (var table in tables)
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"PRAGMA table_info(\"{table}\");";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync()) columns.Add(reader.GetString(1));
|
||||
schema[table] = columns;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// <summary>Walk up from the test binaries to the repo root, then into the
|
||||
/// requested source directory.</summary>
|
||||
private static string FindRepoDirectory(params string[] segments)
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "DorkNet.sln")))
|
||||
dir = dir.Parent;
|
||||
|
||||
Assert.NotNull(dir);
|
||||
var path = Path.Combine(new[] { dir!.FullName }.Concat(segments).ToArray());
|
||||
Assert.True(Directory.Exists(path), $"missing source directory {path}");
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Max players is a PER SUB-ROOM setting, adjustable from two places:
|
||||
///
|
||||
/// in-game PUT rooms/{id}/subrooms/{sub}/maxplayers (owner only — the
|
||||
/// "Max Player Count in This Subroom" slider)
|
||||
/// admin PUT api/admin/v1/rooms/{id}/subrooms/{sub}/maxplayers
|
||||
///
|
||||
/// Both write the same <see cref="RoomSceneEntity.MaxPlayers"/>, so a change
|
||||
/// made in one place has to be visible from the other. That is the property
|
||||
/// worth pinning: two independent write paths onto one column drift silently,
|
||||
/// and the symptom (a slider that snaps back) looks like a client bug.
|
||||
///
|
||||
/// Kept distinct from the room's own <see cref="RoomEntity.MaxCapacity"/>,
|
||||
/// which is what the ROOM advertises and what matchmaking hands to
|
||||
/// RoomInstance.MaxCapacity. Reading the room-level figure off sub-room 0
|
||||
/// would collapse the two settings into one.
|
||||
/// </summary>
|
||||
public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SubRoomMaxPlayersTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_and_admin_edit_the_same_per_subroom_value()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_700_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Caps{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
MaxCapacity = 12,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Arena", OrderIndex = 1, MaxPlayers = 8 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── in-game: the owner's slider on the second sub-room only ─────────
|
||||
// The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
|
||||
// (RoomSceneIds — the client will not load a sub-room whose id is 0).
|
||||
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
|
||||
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
|
||||
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
|
||||
// most of its siblings use. Testing with a form would pass while the
|
||||
// real client's body went unread.
|
||||
using var ownerClient = Client("rooms", owner);
|
||||
using var slider = await ownerClient.PutAsJsonAsync(
|
||||
$"/rooms/{roomId}/subrooms/2/maxplayers", new { maxPlayers = 24 });
|
||||
var sliderBody = await slider.Content.ReadAsStringAsync();
|
||||
Assert.True(slider.IsSuccessStatusCode,
|
||||
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
|
||||
|
||||
// The client reads the reply through the {success, value, error}
|
||||
// helper with value = FGCPNAACHIK, so the slider's own response has to
|
||||
// carry the new value inside the envelope.
|
||||
var envelope = JsonDocument.Parse(sliderBody).RootElement;
|
||||
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
|
||||
var echoed = envelope.GetProperty("value")
|
||||
.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
|
||||
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
|
||||
// (the admin API addresses sub-rooms by OrderIndex)
|
||||
using var adminClient = Client("admin", admin);
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var subRooms = listed.EnumerateArray()
|
||||
.ToDictionary(e => e.GetProperty("subRoomId").GetInt32(),
|
||||
e => e.GetProperty("maxPlayers").GetInt32());
|
||||
|
||||
Assert.Equal(24, subRooms[1]);
|
||||
Assert.Equal(8, subRooms[0]);
|
||||
|
||||
using var adminSet = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 40 });
|
||||
Assert.True(adminSet.IsSuccessStatusCode,
|
||||
$"admin PUT maxplayers -> {(int)adminSet.StatusCode}: {await adminSet.Content.ReadAsStringAsync()}");
|
||||
|
||||
// ── the room's own details carry each sub-room's own figure ─────────
|
||||
var details = await GetJsonAsync(ownerClient, $"/rooms/{roomId}");
|
||||
var wire = details.GetProperty("SubRooms").EnumerateArray()
|
||||
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
|
||||
e => e.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
// Wire ids are OrderIndex + 1.
|
||||
Assert.Equal(40, wire[1]);
|
||||
Assert.Equal(24, wire[2]);
|
||||
|
||||
// The room-level figure is the room's advertised capacity, NOT a copy
|
||||
// of sub-room 0 — otherwise the admin edit above would have moved it.
|
||||
Assert.Equal(12, details.GetProperty("MaxPlayers").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_cap_is_clamped_to_a_joinable_range()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_800_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Clamp{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = owner.PlayerId,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
|
||||
// 0 would make the sub-room unjoinable.
|
||||
using var tooLow = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 0 });
|
||||
var low = JsonDocument.Parse(await tooLow.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(1, low.GetProperty("maxPlayers").GetInt32());
|
||||
|
||||
using var tooHigh = await adminClient.PutAsJsonAsync(
|
||||
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 9999 });
|
||||
var high = JsonDocument.Parse(await tooHigh.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(80, high.GetProperty("maxPlayers").GetInt32());
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Sub-room ids and the room instance browser for the 2023 client.
|
||||
/// <list type="bullet">
|
||||
/// <item>The client will not load a sub-room whose id is not positive
|
||||
/// (SubroomModel.DOKCPLJOKIE: <c>RoomId > 0 && SubroomId > 0</c>),
|
||||
/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
|
||||
/// room details, sub-room routes, matchmaking and the instance browser.</item>
|
||||
/// <item>A new room always owns a real, named entry sub-room, and a startup
|
||||
/// pass repairs rooms that lack one.</item>
|
||||
/// <item><c>GET room/{id}/instances</c> lists live instances with their real
|
||||
/// players, including the private instance of a private room for the people
|
||||
/// allowed into it.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SubRoomsAndInstances2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SubRoomsAndInstances2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public void Wire_sub_room_ids_are_positive_and_round_trip()
|
||||
{
|
||||
Assert.Equal(1, RoomSceneIds.ToWire(0));
|
||||
Assert.Equal(3, RoomSceneIds.ToWire(2));
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(1));
|
||||
Assert.Equal(2, RoomSceneIds.ToOrderIndex(3));
|
||||
// 0 (cached client state, bare matchmake) is the entry sub-room.
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(0));
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_matchmaking_and_sub_room_routes_share_the_wire_id()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 1, scenes: new[] { "Home", "Arena" });
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
|
||||
var subRooms = details.GetProperty("SubRooms").EnumerateArray().ToList();
|
||||
Assert.Equal(new long[] { 1, 2 }, subRooms.Select(s => s.GetProperty("SubRoomId").GetInt64()));
|
||||
Assert.Equal("Home", subRooms[0].GetProperty("Name").GetString());
|
||||
|
||||
// matchmake/room/{id}/{sub} takes the wire id and echoes it.
|
||||
using var match = Client("match", owner);
|
||||
var arena = await PostFormAsync(match, $"/matchmake/room/{roomId}/2");
|
||||
Assert.Equal(2, arena.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
|
||||
var lobby = await PostFormAsync(match, $"/matchmake/room/{roomId}");
|
||||
Assert.Equal(1, lobby.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
|
||||
|
||||
// A sub-room route addressed by wire id 2 edits OrderIndex 1.
|
||||
using (var rename = await rooms.PutAsync($"/roomserver/rooms/{roomId}/subrooms/2/name",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Basement" })))
|
||||
Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.Equal("Basement", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 1)).Name);
|
||||
Assert.Equal("Home", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 0)).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cloning_a_template_without_scene_rows_creates_a_named_home_sub_room()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var templateId = await SeedRoomAsync(1, accessibility: 1, scenes: Array.Empty<string>(), isAg: true);
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Fresh{Guid.NewGuid():N}"[..20] }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, text);
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var value = json.RootElement.GetProperty("value");
|
||||
var cloneId = value.GetProperty("RoomId").GetInt64();
|
||||
var sub = value.GetProperty("SubRooms").EnumerateArray().Single();
|
||||
Assert.Equal(1, sub.GetProperty("SubRoomId").GetInt64());
|
||||
Assert.Equal("Home", sub.GetProperty("Name").GetString());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == cloneId);
|
||||
Assert.Equal(0, scene.OrderIndex);
|
||||
Assert.Equal("Home", scene.Name);
|
||||
Assert.Equal("a75f7547-79eb-47c6-8986-6767abcb4f92", scene.RoomSceneLocationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_repair_gives_scene_less_rooms_a_home_and_names_blank_rows()
|
||||
{
|
||||
var bare = await SeedRoomAsync(1, accessibility: 0, scenes: Array.Empty<string>());
|
||||
var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
var (added, renamed) = await service.RepairRoomScenesAsync();
|
||||
Assert.True(added >= 1);
|
||||
Assert.True(renamed >= 2);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
|
||||
Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
|
||||
var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
|
||||
Assert.Equal(new[] { "Home", "Subroom 2" }, names);
|
||||
|
||||
// Idempotent.
|
||||
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_level_restore_also_restores_the_entry_sub_room()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home", "Back" });
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.RoomScenes.Where(s => s.RoomId == roomId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DataBlobName, "blob-old"));
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = roomId, BlobName = "blob-new", UploadedByPlayerId = owner.PlayerId });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var api = Client("api", owner);
|
||||
using var response = await api.PostAsync("/api/rooms/v1/restore", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["roomId"] = roomId.ToString(), ["blobName"] = "blob-new",
|
||||
}));
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
||||
|
||||
await using var check = _factory.Services.CreateAsyncScope();
|
||||
var checkDb = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var blobs = await checkDb.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex).Select(s => s.DataBlobName).ToListAsync();
|
||||
// The entry sub-room follows the restore; other sub-rooms keep theirs.
|
||||
Assert.Equal(new[] { "blob-new", "blob-old" }, blobs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_repair_restores_a_missing_entry_sub_room_next_to_others()
|
||||
{
|
||||
var roomId = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "Home", "Back" });
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync();
|
||||
|
||||
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
var (added, _) = await service.RepairRoomScenesAsync();
|
||||
Assert.True(added >= 1);
|
||||
|
||||
var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync();
|
||||
Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name)));
|
||||
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var coOwner = await PlayerAsync();
|
||||
var stranger = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerMatch = Client("match", owner);
|
||||
var joined = await PostFormAsync(ownerMatch, $"/matchmake/room/{roomId}");
|
||||
var instanceId = joined.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64();
|
||||
|
||||
foreach (var viewer in new[] { owner, coOwner })
|
||||
{
|
||||
using var match = Client("match", viewer);
|
||||
var list = await GetJsonAsync(match, $"/room/{roomId}/instances");
|
||||
var row = list.EnumerateArray().Single();
|
||||
Assert.Equal(instanceId, row.GetProperty("roomInstanceId").GetInt64());
|
||||
Assert.Equal(roomId, row.GetProperty("roomId").GetInt64());
|
||||
Assert.Equal(1, row.GetProperty("subRoomId").GetInt64());
|
||||
Assert.False(row.GetProperty("isFull").GetBoolean());
|
||||
Assert.Equal(new[] { (int)owner.PlayerId },
|
||||
row.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt32()));
|
||||
}
|
||||
|
||||
// Someone with no way into the private room is not shown it.
|
||||
using var strangerMatch = Client("match", stranger);
|
||||
var hidden = await GetJsonAsync(strangerMatch, $"/room/{roomId}/instances");
|
||||
Assert.Empty(hidden.EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Private_instance_visibility_rule()
|
||||
{
|
||||
var row = new PrivateInstanceEntity { Id = 5, OwnerPlayerId = 3 };
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, true, Array.Empty<long>(), row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, new long[] { 9 }, row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(3, false, Array.Empty<long>(), row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, true));
|
||||
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, false));
|
||||
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty<long>(), row, true));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<GameClientSession> PlayerAsync()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
}
|
||||
|
||||
private async Task<long> SeedRoomAsync(long creatorId, int accessibility, string[] scenes, bool isAg = false)
|
||||
{
|
||||
var roomId = 9_200_000 + Random.Shared.Next(1, 99_999);
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Subs{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = accessibility,
|
||||
CloningAllowed = true,
|
||||
IsAGRoom = isAg,
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
for (var i = 0; i < scenes.Length; i++)
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = i, Name = scenes[i] });
|
||||
await db.SaveChangesAsync();
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostFormAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary<string, string>()));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Event notifications (cheers) now go through the REAL Message channel
|
||||
/// (persisted MessageEntity + MessageReceived push with the client's
|
||||
/// Message DTO shape), instead of the old blank-account misuse of
|
||||
/// SubscriptionUpdateProfile. A cheer must land in the recipient's message
|
||||
/// feed as a PlayerCheer (Type 50) from the cheerer.
|
||||
/// </summary>
|
||||
public sealed class SystemNotificationTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private const int PlayerCheerType = 50;
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SystemNotificationTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Cheering_a_player_creates_a_PlayerCheer_message_notification()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var cheerer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
using var client = ApiClient(cheerer);
|
||||
// Real client field names (RecNet.Runtime KFJKDMGHKHE.EMKBFCLIOGN):
|
||||
// PlayerIdTo + CheerCategory (+ RoomId, Anonymous). The old test used
|
||||
// TargetAccountId/Type, which matched the buggy handler binding but
|
||||
// NOT the game client — so it passed while cheers were broken live.
|
||||
using var resp = await client.PostAsync("/api/PlayerCheer/v1/create",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["PlayerIdTo"] = target.PlayerId.ToString(),
|
||||
["CheerCategory"] = "0",
|
||||
["Anonymous"] = "false",
|
||||
}));
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var msg = await db.Messages.FirstOrDefaultAsync(m =>
|
||||
m.RecipientPlayerId == target.PlayerId && m.Type == PlayerCheerType);
|
||||
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal(cheerer.PlayerId, msg!.SenderPlayerId);
|
||||
// No orphan/blank-account row was written anywhere in the flow.
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// QA test cases linked to GitHub issues.
|
||||
///
|
||||
/// The link lives in <see cref="TestCaseEntity.JiraBugUrl"/> — the field Rec
|
||||
/// Room's own tooling used for the bug filed against a failing case — so no
|
||||
/// column is added and the Postgres "migrations never replay" trap is avoided
|
||||
/// entirely.
|
||||
///
|
||||
/// GitHub itself is faked. These tests are about our behaviour around it:
|
||||
/// filing once rather than per sweep, following issue state in the one
|
||||
/// direction that is safe, and degrading rather than 500ing when no token is
|
||||
/// configured.
|
||||
/// </summary>
|
||||
public sealed class TestCaseGitHubIssueTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public TestCaseGitHubIssueTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
private const int StatusClaimed = 1;
|
||||
private const int StatusFailed = 2;
|
||||
private const int StatusPassed = 3;
|
||||
|
||||
[Fact]
|
||||
public async Task Filing_is_idempotent_and_stores_the_issue_url()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var first = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
var firstBody = await first.Content.ReadAsStringAsync();
|
||||
Assert.True(first.IsSuccessStatusCode, $"first file -> {(int)first.StatusCode}: {firstBody}");
|
||||
Assert.True(JsonDocument.Parse(firstBody).RootElement.GetProperty("created").GetBoolean());
|
||||
|
||||
// The link must be persisted, and in the field the admin UI reads.
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
Assert.Contains("/issues/", tc.JiraBugUrl);
|
||||
}
|
||||
|
||||
// Re-running a sweep over a failing pass must not file the bug again.
|
||||
// Measured as a DELTA rather than an absolute count: the fake is shared
|
||||
// with whatever else the server does during the test, and the property
|
||||
// under test is "this call filed nothing", not "nothing was ever filed".
|
||||
var filedBefore = github.Created;
|
||||
using var second = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
var secondBody = await second.Content.ReadAsStringAsync();
|
||||
Assert.True(second.IsSuccessStatusCode, $"second file -> {(int)second.StatusCode}: {secondBody}");
|
||||
Assert.False(JsonDocument.Parse(secondBody).RootElement.GetProperty("created").GetBoolean(),
|
||||
"filing twice created a duplicate issue");
|
||||
Assert.Equal(filedBefore, github.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_closed_issue_marks_the_failing_case_passed()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
github.CloseAll();
|
||||
using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(sync.IsSuccessStatusCode, $"sync -> {(int)sync.StatusCode}");
|
||||
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
|
||||
// A closed issue means the bug is fixed, so the case that was failing
|
||||
// on it passes.
|
||||
Assert.Equal(StatusPassed, tc.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reopened_issue_marks_the_case_failed_again()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
github.CloseAll();
|
||||
using var closeSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(closeSync.IsSuccessStatusCode);
|
||||
|
||||
github.ReopenAll();
|
||||
using var reopenSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(reopenSync.IsSuccessStatusCode);
|
||||
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.Equal(StatusFailed, (await db.TestCases.SingleAsync(c => c.Id == caseId)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_sweep_does_not_stomp_a_tester_mid_claim()
|
||||
{
|
||||
var github = new FakeGitHub();
|
||||
using var factory = WithGitHub(github);
|
||||
using var client = await AdminClientAsync(factory);
|
||||
var caseId = await SeedCaseAsync(factory, StatusFailed);
|
||||
|
||||
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
Assert.True(filed.IsSuccessStatusCode);
|
||||
|
||||
// Someone picks the case up while the issue is still open.
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.TestCases.SingleAsync(c => c.Id == caseId)).Status = StatusClaimed;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
|
||||
Assert.True(sync.IsSuccessStatusCode);
|
||||
|
||||
await using var check = factory.Services.CreateAsyncScope();
|
||||
var db2 = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var tc = await db2.TestCases.SingleAsync(c => c.Id == caseId);
|
||||
|
||||
// An open issue against a claimed case says nothing new; flipping it to
|
||||
// Failed would yank it out from under whoever is testing it. Only
|
||||
// Failed<->Passed are the reconciler's to move.
|
||||
Assert.Equal(StatusClaimed, tc.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_token_the_endpoint_says_so_instead_of_failing()
|
||||
{
|
||||
// The default fixture has no GitHub configuration, which is what an
|
||||
// ordinary deployment looks like.
|
||||
using var client = await AdminClientAsync(_factory);
|
||||
var caseId = await SeedCaseAsync(_factory, StatusFailed);
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
$"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> WithGitHub(IGitHubIssues github) =>
|
||||
_factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IGitHubIssues>();
|
||||
services.AddSingleton(github);
|
||||
}));
|
||||
|
||||
private async Task<HttpClient> AdminClientAsync(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == session.PlayerId)).IsAdmin = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string> SeedCaseAsync(WebApplicationFactory<Program> factory, int status)
|
||||
{
|
||||
var id = $"tc-{Guid.NewGuid():N}";
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.TestCases.Add(new TestCaseEntity
|
||||
{
|
||||
Id = id,
|
||||
Key = $"QA-{Random.Shared.Next(1000, 9999)}",
|
||||
Title = "Dorm fails to load after cloning a room",
|
||||
Description = "Clone RecCenter, then return to the dorm.",
|
||||
RoomName = "Dorm",
|
||||
Status = status,
|
||||
TagsJson = """["dorm","regression"]""",
|
||||
CommentsJson = """["1811750|2026-07-28T08:00:00Z|Reproduced twice"]""",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>An in-memory GitHub. Records what was filed so the tests can
|
||||
/// assert we did not file twice, and lets them flip issue state.</summary>
|
||||
private sealed class FakeGitHub : IGitHubIssues
|
||||
{
|
||||
private readonly Dictionary<int, GitHubIssue> _issues = [];
|
||||
private int _next = 1;
|
||||
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public string Repository => "DorkSquadRR/DorkNet";
|
||||
|
||||
public int Created { get; private set; }
|
||||
|
||||
public Task<GitHubIssue?> CreateAsync(
|
||||
string title, string body, IEnumerable<string> labels, CancellationToken ct = default)
|
||||
{
|
||||
var number = _next++;
|
||||
var issue = new GitHubIssue(
|
||||
number, $"https://github.com/{Repository}/issues/{number}", "open", title);
|
||||
_issues[number] = issue;
|
||||
Created++;
|
||||
return Task.FromResult<GitHubIssue?>(issue);
|
||||
}
|
||||
|
||||
public Task<GitHubIssue?> GetAsync(int number, CancellationToken ct = default) =>
|
||||
Task.FromResult(_issues.TryGetValue(number, out var issue) ? issue : null);
|
||||
|
||||
public Task<bool> CommentAsync(int number, string body, CancellationToken ct = default) =>
|
||||
Task.FromResult(_issues.ContainsKey(number));
|
||||
|
||||
public void CloseAll() => SetState("closed");
|
||||
|
||||
public void ReopenAll() => SetState("open");
|
||||
|
||||
private void SetState(string state)
|
||||
{
|
||||
foreach (var number in _issues.Keys.ToList())
|
||||
_issues[number] = _issues[number] with { State = state };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ namespace DorkNet.Server.Tests;
|
||||
public sealed class VersionCheckControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void December_2020_build_is_valid_with_minimal_service_config()
|
||||
public void March_2023_build_is_valid_with_minimal_service_config()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "0"));
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
|
||||
|
||||
Assert.Equal(0, ReadVersionStatus(result.Value));
|
||||
}
|
||||
@@ -37,9 +37,9 @@ public sealed class VersionCheckControllerTests
|
||||
[new Late2020VersionPlugin()],
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"december_2020_12_18",
|
||||
"march_2023_03_21",
|
||||
},
|
||||
"december_2020_12_18");
|
||||
"march_2023_03_21");
|
||||
|
||||
return new VersionCheckController(
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 client's watch Shop tab pipeline: rows come from
|
||||
/// <c>/api/storefronts/v3/giftdropstore/{id}</c> (each row MUST carry a
|
||||
/// singular <c>GiftDrop</c> object — the 2023 POGBAGAHGIA formatter
|
||||
/// ignores the 2020 <c>GiftDrops</c> array, and a null inner desc makes
|
||||
/// StoreItemListModel's filter predicate NRE so the Shop shows nothing),
|
||||
/// and the featured strip ids come from <c>/v1/toptoday</c> as a BARE
|
||||
/// int list that must resolve against those rows.
|
||||
/// </summary>
|
||||
public sealed class WatchShop2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public WatchShop2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Giftdropstore_rows_carry_singular_giftdrop_and_toptoday_ids_resolve()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var rows = storefront.GetProperty("StoreItems");
|
||||
Assert.True(rows.GetArrayLength() > 0, "watch storefront seeded no rows");
|
||||
|
||||
var rowIds = new HashSet<int>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
rowIds.Add(row.GetProperty("PurchasableItemId").GetInt32());
|
||||
Assert.Equal(0, row.GetProperty("Type").GetInt32());
|
||||
Assert.True(row.GetProperty("Prices").GetArrayLength() > 0);
|
||||
|
||||
var giftDrop = row.GetProperty("GiftDrop");
|
||||
Assert.Equal(JsonValueKind.Object, giftDrop.ValueKind);
|
||||
Assert.Equal(row.GetProperty("PurchasableItemId").GetInt32(),
|
||||
giftDrop.GetProperty("GiftDropId").GetInt32());
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("FriendlyName").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("TagList").ValueKind);
|
||||
var rarity = giftDrop.GetProperty("Rarity").GetInt32();
|
||||
Assert.Contains(rarity, new[] { 0, 10, 20, 30, 50 });
|
||||
|
||||
// Int32 on the 2023 wire — a descriptor STRING here makes the
|
||||
// strict Utf8Json reader throw "Malformed Response" for the
|
||||
// whole storefront payload (expected:'Number Token').
|
||||
Assert.Equal(JsonValueKind.Number, giftDrop.GetProperty("AvatarItemId").ValueKind);
|
||||
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("AvatarItemDesc").ValueKind);
|
||||
}
|
||||
|
||||
// Featured strip: bare int ids, every one resolvable in the rows.
|
||||
var topToday = await GetJsonAsync(client, "/api/storefronts/v1/toptoday");
|
||||
Assert.Equal(JsonValueKind.Array, topToday.ValueKind);
|
||||
Assert.True(topToday.GetArrayLength() > 0, "toptoday returned no featured ids");
|
||||
foreach (var id in topToday.EnumerateArray())
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Number, id.ValueKind);
|
||||
Assert.Contains(id.GetInt32(), rowIds);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Storefront_includes_food_consumables_with_literal_desc()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
var rows = storefront.GetProperty("StoreItems");
|
||||
|
||||
// Food / KO / camera consumables surface with the client's literal
|
||||
// ConsumableItemDesc and no avatar item — that's what routes them to
|
||||
// the Shop's Consumables tab and binds the baked prefab. (Hair dyes
|
||||
// are a hybrid: they also carry an AvatarItemDesc colour guid, so
|
||||
// they're excluded from the no-avatar-item check.)
|
||||
var byDesc = new Dictionary<string, JsonElement>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
var gd = row.GetProperty("GiftDrop");
|
||||
var desc = gd.GetProperty("ConsumableItemDesc").GetString() ?? "";
|
||||
if (desc.Length > 0) byDesc[desc] = gd;
|
||||
}
|
||||
|
||||
foreach (var key in new[] { "[FoodConsumable_RootBeer]", "[KOConsumable_Cola]" })
|
||||
{
|
||||
Assert.True(byDesc.TryGetValue(key, out var gd), $"missing consumable {key}");
|
||||
Assert.Equal("", gd.GetProperty("AvatarItemDesc").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, gd.GetProperty("AvatarItemType").ValueKind);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ApiClient()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2815 @@
|
||||
{
|
||||
"AALDCAANEMM": [
|
||||
"CurrencyPurchaseOfferId",
|
||||
"CurrencyId",
|
||||
"Order",
|
||||
"Name",
|
||||
"CurrencyAmount",
|
||||
"Price",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"ABJLKAFOPMP": [
|
||||
"GiftDropIds",
|
||||
"Message",
|
||||
"SubscriptionPurchase"
|
||||
],
|
||||
"ACKNGEEHHAE": [
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Tags",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions"
|
||||
],
|
||||
"ADAFNGNADFH": [
|
||||
"InviteeAccountId",
|
||||
"CreatedAt",
|
||||
"VerifiedAt"
|
||||
],
|
||||
"ADDGMGGOLHM": [
|
||||
"BlobName",
|
||||
"Title",
|
||||
"Description",
|
||||
"ThumbnailBlobName",
|
||||
"SourceUrl"
|
||||
],
|
||||
"ADMGGPDGKCE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"AECJMONHBHM": [
|
||||
"ClubId",
|
||||
"LastReadAnnouncementId",
|
||||
"Announcements"
|
||||
],
|
||||
"AEGADMDIKNE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"AEGCKMKICFG": [
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"AEHFGHPLNDO": [
|
||||
"AccountId",
|
||||
"LastVisitedAt"
|
||||
],
|
||||
"AFHCKGCJNFM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"AFJDLEFKNFP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"AHJKILCAGML": [
|
||||
"ChallengeId",
|
||||
"Name",
|
||||
"Config",
|
||||
"Description",
|
||||
"Tooltip",
|
||||
"Complete"
|
||||
],
|
||||
"AIEJFLGKIGN": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"AJEDJLBJLFA": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"MembershipType",
|
||||
"CreatedAt"
|
||||
],
|
||||
"AKDFPANIAFL": [
|
||||
"AnnouncementId",
|
||||
"AnnouncementType",
|
||||
"Title",
|
||||
"Body",
|
||||
"ImageName",
|
||||
"LinkType",
|
||||
"LinkName",
|
||||
"LinkButtonLabel",
|
||||
"LinkUri",
|
||||
"Platform",
|
||||
"CreatedAt"
|
||||
],
|
||||
"AKKPACEJJFK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"ALGKCAKCFAJ": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"ALJPHDEAHBK": [
|
||||
"Result",
|
||||
"Tags"
|
||||
],
|
||||
"AMPFGJPKHOD": [
|
||||
"Device_code",
|
||||
"User_code",
|
||||
"Verification_uri",
|
||||
"Verification_uri_complete",
|
||||
"Expires_in",
|
||||
"Interval"
|
||||
],
|
||||
"ANHGLIFGACE": [
|
||||
"RoomKeyId",
|
||||
"ReplicationId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"CreatedAt",
|
||||
"ImageName"
|
||||
],
|
||||
"ANNOJCNMHIG": [
|
||||
"CustomAvatarItemId",
|
||||
"CreatorAccountId",
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"Accessibility",
|
||||
"IsFeatured",
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor",
|
||||
"DesignFilename",
|
||||
"ThumbnailImageFilename",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"ANPJKOOEEBB": [
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"AOAACMFGAGK": [
|
||||
"ChallengeMapId",
|
||||
"ChallengeThemeString",
|
||||
"Challenges",
|
||||
"Gift",
|
||||
"FallbackGiftName",
|
||||
"StartAt",
|
||||
"EndAt",
|
||||
"ServerTime",
|
||||
"CompletedRequired"
|
||||
],
|
||||
"APOJHADGCNA": [
|
||||
"Ids",
|
||||
"CreatedAts",
|
||||
"ConsumableItemDesc",
|
||||
"Count",
|
||||
"InitialCount",
|
||||
"IsActive",
|
||||
"ActiveDurationMinutes",
|
||||
"IsTransferable"
|
||||
],
|
||||
"BAEBDMPPKKE": [
|
||||
"Take",
|
||||
"ContinuationToken",
|
||||
"InjectedFeedItems",
|
||||
"FeedInstanceId"
|
||||
],
|
||||
"BBACJMOPFPE": [
|
||||
"ProgressionEventRewardId",
|
||||
"GiftDropId",
|
||||
"ImageName",
|
||||
"Xp",
|
||||
"RewardIndex",
|
||||
"IsBonus"
|
||||
],
|
||||
"BBEFAELEANL": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"BBFGGPEMLLE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"BBMJLKMGFGA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"BCEJDGGBBHC": [
|
||||
"Price",
|
||||
"CurrencyId"
|
||||
],
|
||||
"BCMOCNHAJCL": [
|
||||
"Status",
|
||||
"Consumable"
|
||||
],
|
||||
"BGFBJCIOIFP": [
|
||||
"SkuId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"Price",
|
||||
"OculusSkuId",
|
||||
"AppleProductId",
|
||||
"PsnProductLabel",
|
||||
"XboxProductId",
|
||||
"XboxStoreId",
|
||||
"GooglePlaySkuId",
|
||||
"PicoSkuId",
|
||||
"IsSingleUse",
|
||||
"ShouldAppearInTokenStore",
|
||||
"Data"
|
||||
],
|
||||
"BIMHOENEGII": [
|
||||
"Group",
|
||||
"IsCompleted",
|
||||
"ClearedAt"
|
||||
],
|
||||
"BJHOKILBIML": [
|
||||
"FeedItemType",
|
||||
"FeedItemId"
|
||||
],
|
||||
"BKCOBJILFEI": [
|
||||
"KeepsakeFeatureEnabled",
|
||||
"KeepsakeRoomLimit",
|
||||
"SocialXpBoostEnabled"
|
||||
],
|
||||
"BKFOGBGPLFP": [
|
||||
"SavedImageId",
|
||||
"ImageName",
|
||||
"Username",
|
||||
"PlayerId",
|
||||
"RoomName",
|
||||
"RoomId"
|
||||
],
|
||||
"BLADEIPEPOH": [
|
||||
"SavedImageId",
|
||||
"ImageName",
|
||||
"PlayerId",
|
||||
"RoomId",
|
||||
"PlayerEventId",
|
||||
"ClubId",
|
||||
"Description",
|
||||
"Accessibility",
|
||||
"AccessibilityLocked",
|
||||
"SavedImageType",
|
||||
"CreatedAt",
|
||||
"CheerCount",
|
||||
"CommentCount"
|
||||
],
|
||||
"BLIBJIHOENF": [
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"MainImageName",
|
||||
"State",
|
||||
"CreatorAccountId",
|
||||
"Category",
|
||||
"Visibility",
|
||||
"Joinability",
|
||||
"AllowJuniors",
|
||||
"MinLevel",
|
||||
"MemberCount",
|
||||
"IsRRO",
|
||||
"ClubChatEnabled",
|
||||
"ClubhouseRoomId",
|
||||
"ClubType"
|
||||
],
|
||||
"BLPIIBBPOJM": [
|
||||
"Action",
|
||||
"Reason",
|
||||
"Error"
|
||||
],
|
||||
"BLPNLMALDAC": [
|
||||
"AccountId",
|
||||
"Bio"
|
||||
],
|
||||
"BPAPIKPFDAH": [
|
||||
"Difficulty",
|
||||
"EN_US"
|
||||
],
|
||||
"BPHCOIBNCDP": [
|
||||
"ClubId",
|
||||
"Type",
|
||||
"EditDetails",
|
||||
"ApproveMember",
|
||||
"CreateEvent",
|
||||
"PostAnnouncement",
|
||||
"EditPermissionSettings",
|
||||
"BanUnban"
|
||||
],
|
||||
"CAAJPHFCIPK": [
|
||||
"ReportCategory",
|
||||
"Reason"
|
||||
],
|
||||
"CAEHDIMCGLD": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CAKCKAMAAPP": [
|
||||
"ClubId",
|
||||
"LastAnnouncementId",
|
||||
"LastReadAnnouncementId",
|
||||
"Announcements"
|
||||
],
|
||||
"CBKDKACJDOF": [
|
||||
"ChatMessageId",
|
||||
"ChatThreadId",
|
||||
"SenderPlayerId",
|
||||
"TimeSent",
|
||||
"Contents",
|
||||
"ModerationState"
|
||||
],
|
||||
"CBNEDELBFAJ": [
|
||||
"Clubs",
|
||||
"TotalClubs",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"CCAGOJFGLLO": [
|
||||
"FeedItemType",
|
||||
"FeedItemId",
|
||||
"FeedAlgorithmVersion"
|
||||
],
|
||||
"CCKEEAMMHPJ": [
|
||||
"UnityAssetId",
|
||||
"CreatedByAccountId",
|
||||
"BakedUnityAssets"
|
||||
],
|
||||
"CDEDNEPPPND": [
|
||||
"LevelProgressionMaps",
|
||||
"DailyObjectives",
|
||||
"ServerMaintenance",
|
||||
"AutoMicMutingConfig",
|
||||
"StorefrontConfig",
|
||||
"RoomKeyConfig",
|
||||
"RoomCurrencyConfig",
|
||||
"ShareBaseUrl"
|
||||
],
|
||||
"CEAMINNJFGO": [
|
||||
"Left",
|
||||
"Right",
|
||||
"Spine",
|
||||
"Pelvis",
|
||||
"Root",
|
||||
"Torso",
|
||||
"Body",
|
||||
"Hips",
|
||||
"Neck",
|
||||
"Chest",
|
||||
"Head",
|
||||
"Arm",
|
||||
"Hand",
|
||||
"Wrist",
|
||||
"Elbow",
|
||||
"Palm",
|
||||
"Leg",
|
||||
"Thigh",
|
||||
"Calf",
|
||||
"Femur",
|
||||
"Knee",
|
||||
"Foot",
|
||||
"Ankle",
|
||||
"Hip",
|
||||
"Tail",
|
||||
"Eye",
|
||||
"Dummy",
|
||||
"Collar",
|
||||
"Clavicle",
|
||||
"Finger",
|
||||
"Index",
|
||||
"Mid",
|
||||
"Pinky",
|
||||
"Thumb",
|
||||
"Adjust",
|
||||
"Twist",
|
||||
"Toe",
|
||||
"Lid",
|
||||
"Brow",
|
||||
"Lash"
|
||||
],
|
||||
"CENEMCMGDKG": [
|
||||
"Tags",
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"CGACOKHBJCB": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CGCKIMIEHNM": [
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"CHEMHPGCJBM": [
|
||||
"PlayerId",
|
||||
"Score",
|
||||
"Rank"
|
||||
],
|
||||
"CHKOLCGMCKJ": [
|
||||
"ReportBudget",
|
||||
"FilterType",
|
||||
"SampleRate",
|
||||
"LogLineCount",
|
||||
"CaptureNativeCrashes",
|
||||
"ANRThresholdMs",
|
||||
"MessageCount",
|
||||
"MessageRegex",
|
||||
"VersionRegex"
|
||||
],
|
||||
"CICAKEDCJLA": [
|
||||
"Email",
|
||||
"Phone",
|
||||
"Birthday",
|
||||
"JuniorState",
|
||||
"ParentAccountId",
|
||||
"AvailableUsernameChanges",
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"CICEHLNDLDE": [
|
||||
"RoomConsumableId",
|
||||
"AccountId",
|
||||
"Count",
|
||||
"ConcurrencyCode",
|
||||
"ModifiedAt",
|
||||
"Consumable"
|
||||
],
|
||||
"CIKAJCJMOFJ": [
|
||||
"MembershipRequestId",
|
||||
"AccountId",
|
||||
"InviterAccountId",
|
||||
"ClubId",
|
||||
"MembershipType",
|
||||
"MembershipStatus",
|
||||
"CreatedAt"
|
||||
],
|
||||
"CJPKAAJBLAJ": [
|
||||
"Id",
|
||||
"Name",
|
||||
"Description",
|
||||
"StartDate",
|
||||
"EndDate",
|
||||
"WasManuallyClosed",
|
||||
"TestCases",
|
||||
"Tags",
|
||||
"NumTestCases",
|
||||
"NumPassedTestCases",
|
||||
"NumFailedTestCases"
|
||||
],
|
||||
"CLHBPPNOLHP": [
|
||||
"Version",
|
||||
"ButtonNumber",
|
||||
"Override",
|
||||
"CustomRoomName",
|
||||
"CustomTitle",
|
||||
"CustomDescription",
|
||||
"DefaultRoomName",
|
||||
"DefaultTitle",
|
||||
"DefaultDescription"
|
||||
],
|
||||
"CLIANCNHNIE": [
|
||||
"AvatarItemType",
|
||||
"AvatarItemDesc",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"Rarity",
|
||||
"TagList",
|
||||
"AvatarItemId",
|
||||
"IsBaseAvatarItem"
|
||||
],
|
||||
"CMBHBADOOAF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"CMHEJKENPBH": [
|
||||
"TriggerType",
|
||||
"TriggerId",
|
||||
"TriggeredAt"
|
||||
],
|
||||
"CMLNDGCIPIH": [
|
||||
"Index",
|
||||
"Group",
|
||||
"Progress",
|
||||
"VisualProgress",
|
||||
"IsCompleted",
|
||||
"HasClaimedReward"
|
||||
],
|
||||
"CNFFPECMOHE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"CNNAFLNKDCL": [
|
||||
"AccountId",
|
||||
"KeepsakeInstanceId",
|
||||
"CollectedAt"
|
||||
],
|
||||
"CNNEAFEIMPP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"COIELAHKALG": [
|
||||
"Enabled",
|
||||
"Key",
|
||||
"Region"
|
||||
],
|
||||
"DCECPNPOEFG": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DDDLCGDEPNH": [
|
||||
"PlayerEventResponseId",
|
||||
"PlayerEventId",
|
||||
"PlayerId",
|
||||
"CreatedAt",
|
||||
"Type"
|
||||
],
|
||||
"DDFBPFOBIAO": [
|
||||
"ImageName",
|
||||
"Accessibility"
|
||||
],
|
||||
"DDFNNBDLBKF": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"SubscriberCount"
|
||||
],
|
||||
"DEIDIGKKLHE": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"DFKDBJMNKCO": [
|
||||
"Event",
|
||||
"NewSubscription"
|
||||
],
|
||||
"DHAPGKPJDKH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DHMKNFOLCGM": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DICIOFKDIEE": [
|
||||
"Season",
|
||||
"Name",
|
||||
"StartAt",
|
||||
"EndAt",
|
||||
"CurrencyType",
|
||||
"EliteUpgrade",
|
||||
"Tiers",
|
||||
"PersonalDetails",
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil"
|
||||
],
|
||||
"DIEEOOKELHG": [
|
||||
"AccountId",
|
||||
"IsRecentHistoryVisible"
|
||||
],
|
||||
"DILNAKMDJOF": [
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"KeepsakeCategory"
|
||||
],
|
||||
"DJCLNEEBAJP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DJFEOAGKKAK": [
|
||||
"FreshnessSeconds",
|
||||
"Url"
|
||||
],
|
||||
"DJFIAOIFLDI": [
|
||||
"Memberships",
|
||||
"TotalMemberships",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"DJHIABKJLBD": [
|
||||
"RoomInstanceId",
|
||||
"PhotonAccessToken",
|
||||
"Permissions"
|
||||
],
|
||||
"DKHBLLDMDHB": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DKMBKPLABAG": [
|
||||
"Type",
|
||||
"Score"
|
||||
],
|
||||
"DKNKIEOODHK": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DLJCLHBGADL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DNBAAJILMPE": [
|
||||
"RoomId",
|
||||
"ClubId",
|
||||
"PlayerCount"
|
||||
],
|
||||
"DOINHLNFOCP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"DOJEJNECAAI": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"DOMOBFDIAJA": [
|
||||
"AccountId",
|
||||
"CheeredAt"
|
||||
],
|
||||
"DPHFOPBAAPN": [
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"DPIECEMDKPL": [
|
||||
"CreatorPlayerId",
|
||||
"Data",
|
||||
"IsValid"
|
||||
],
|
||||
"DPKHIGPMCIP": [
|
||||
"InviteId",
|
||||
"Name"
|
||||
],
|
||||
"DPNLANKOHON": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Balance",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"DatatypeImplementation": [
|
||||
"ENTITIES",
|
||||
"ENTITY",
|
||||
"ID",
|
||||
"IDREF",
|
||||
"IDREFS",
|
||||
"NMTOKEN",
|
||||
"NMTOKENS",
|
||||
"NOTATION"
|
||||
],
|
||||
"EACKMEGHIGM": [
|
||||
"AccountId",
|
||||
"ClubId",
|
||||
"CreatedAt"
|
||||
],
|
||||
"ECEBBLBCFKO": [
|
||||
"AccountId",
|
||||
"BannedByAccountId",
|
||||
"BanStartTime"
|
||||
],
|
||||
"ECHJAKNMEEC": [
|
||||
"KeepsakeRoomListId",
|
||||
"ProgressionEventId",
|
||||
"UnlockItemAvatarItemId",
|
||||
"UnlockItemGiftDropId",
|
||||
"UnlockItemLockDurationTicks",
|
||||
"RoomUnlockStartOffsetTicks",
|
||||
"RoomUnlockIntervalTicks",
|
||||
"RoomUnlockBatchSize",
|
||||
"RoomType",
|
||||
"KeepsakeRooms"
|
||||
],
|
||||
"ECIINMENOGP": [
|
||||
"BalanceAddType",
|
||||
"Delta",
|
||||
"Balance",
|
||||
"Platform",
|
||||
"CurrencyType"
|
||||
],
|
||||
"EDMKBBPGJAB": [
|
||||
"Balance",
|
||||
"RoomKeyResponse"
|
||||
],
|
||||
"EEAAGIJHLOJ": [
|
||||
"PlayerEvent",
|
||||
"Result",
|
||||
"TagModifyResult"
|
||||
],
|
||||
"EHFKGCIDOFL": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"EHKEOCKBCDH": [
|
||||
"Items",
|
||||
"FeedInstanceId",
|
||||
"FeedContext",
|
||||
"FeedAlgorithmVersion"
|
||||
],
|
||||
"EIFIAMHKFAG": [
|
||||
"PurchaseReminderId",
|
||||
"SkuId",
|
||||
"TokenPrice",
|
||||
"Title",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"EndDate",
|
||||
"BonusGiftDrops"
|
||||
],
|
||||
"EJEJHJPCBOE": [
|
||||
"KeepsakeRoomId",
|
||||
"RoomId",
|
||||
"KeepsakeRoomListId",
|
||||
"Type",
|
||||
"Order"
|
||||
],
|
||||
"EJIHDJDKKEC": [
|
||||
"AccountId",
|
||||
"RoomKeyId"
|
||||
],
|
||||
"EJNFJPNOFFO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"EKEFFNBIOHJ": [
|
||||
"ChatThreadId",
|
||||
"LastReadMessageId",
|
||||
"Messages",
|
||||
"LatestMessage",
|
||||
"PlayerIds",
|
||||
"ChatThreadName",
|
||||
"SnoozedUntil",
|
||||
"IsFavorited",
|
||||
"ClubId"
|
||||
],
|
||||
"EKHJKDNHOPB": [
|
||||
"OutfitSelections",
|
||||
"OutfitSelectionsV2",
|
||||
"FaceFeatures",
|
||||
"SkinColor",
|
||||
"HairColor",
|
||||
"CustomAvatarItems"
|
||||
],
|
||||
"EKMLNNNABAD": [
|
||||
"UnityAssetId",
|
||||
"RoomData",
|
||||
"SubRoomData",
|
||||
"InventionUsage",
|
||||
"PersistenceVersion",
|
||||
"Description",
|
||||
"AutoPublish"
|
||||
],
|
||||
"EMBCEDNHFLB": [
|
||||
"GiftDropId",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"TagList",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemDesc",
|
||||
"AvatarItemType",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"IsQuery",
|
||||
"Unique",
|
||||
"SubscribersOnly",
|
||||
"Rarity",
|
||||
"AvatarItemId",
|
||||
"CurrencyType",
|
||||
"Currency",
|
||||
"Context",
|
||||
"ItemSetId",
|
||||
"ItemSetFriendlyName"
|
||||
],
|
||||
"EMJAJEHCIFN": [
|
||||
"MarketingEmails",
|
||||
"InviteEmails",
|
||||
"GameClientTextInvites",
|
||||
"FriendRequestEmails",
|
||||
"CreatorPublishedNewRoomEmails",
|
||||
"MyRoomSummaryEmails",
|
||||
"NewPhotoTagEmails",
|
||||
"PlayerEventInviteEmails",
|
||||
"PlayerEventReminderEmails"
|
||||
],
|
||||
"ENJDEONHNMO": [
|
||||
"Created",
|
||||
"Responses"
|
||||
],
|
||||
"EOFEKNIHNNC": [
|
||||
"PlayerEventId",
|
||||
"BroadcastRoomInstanceId"
|
||||
],
|
||||
"EOILOIMIJLJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"EOIMIBBJBCB": [
|
||||
"ImageName",
|
||||
"Title",
|
||||
"Subtitle"
|
||||
],
|
||||
"EPPPACFECMH": [
|
||||
"Type",
|
||||
"Reference"
|
||||
],
|
||||
"EncodingTable": [
|
||||
"Big5",
|
||||
"GB2312",
|
||||
"IBM850",
|
||||
"IBM852",
|
||||
"IBM857",
|
||||
"IBM861",
|
||||
"IBM869"
|
||||
],
|
||||
"FAMGNALGHPN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FBNOJCDDBJK": [
|
||||
"XPAwardStrings",
|
||||
"TotalXPAwarded",
|
||||
"NewProgress"
|
||||
],
|
||||
"FCIBLPCOODP": [
|
||||
"RoomConsumableId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"FCJNIBGKHDM": [
|
||||
"ClubId",
|
||||
"MembershipType"
|
||||
],
|
||||
"FDPFICCCJNM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FEJOMKJBOOE": [
|
||||
"RoomId",
|
||||
"EarningsDistributionMapping",
|
||||
"EarningsDistributionMethod"
|
||||
],
|
||||
"FIFFMMLKKCJ": [
|
||||
"PlayerId",
|
||||
"Level",
|
||||
"XP"
|
||||
],
|
||||
"FJJIJOGNMCP": [
|
||||
"ReferralRewardTierId",
|
||||
"RequiredReferralCount",
|
||||
"RewardSelectionCount"
|
||||
],
|
||||
"FJNBDGFCLOF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FKANCKLCCDI": [
|
||||
"Id",
|
||||
"FromPlayerId",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemType",
|
||||
"AvatarItemDesc",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"CurrencyType",
|
||||
"Currency",
|
||||
"Xp",
|
||||
"GiftContext",
|
||||
"GiftRarity",
|
||||
"Message",
|
||||
"Platform",
|
||||
"PlatformsToSpawnOn",
|
||||
"BalanceType"
|
||||
],
|
||||
"FKPPDBLFKHJ": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FLEDNKHKIND": [
|
||||
"InfluencerIds",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"FLELPHJDLNG": [
|
||||
"Filename",
|
||||
"Hash",
|
||||
"OwnershipProof"
|
||||
],
|
||||
"FLMCOHEGJEI": [
|
||||
"RequiresEliteUpgrade",
|
||||
"GiftDrop",
|
||||
"SeasonXpBoost"
|
||||
],
|
||||
"FMIDLABJCLM": [
|
||||
"Strings",
|
||||
"StringPackageVersion"
|
||||
],
|
||||
"FMKALNIKMKF": [
|
||||
"AccountId",
|
||||
"MembershipType"
|
||||
],
|
||||
"FMNDPHMOIIH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FNDBGOGAIFL": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FNIDAIDJBBO": [
|
||||
"PlayerID",
|
||||
"RelationshipType",
|
||||
"Muted",
|
||||
"Ignored",
|
||||
"Favorited"
|
||||
],
|
||||
"FNNAJDFFICN": [
|
||||
"ReportId",
|
||||
"Client"
|
||||
],
|
||||
"FOIMFMEEGDI": [
|
||||
"SupportsJoinInProgress",
|
||||
"UseLevelBasedMatchmaking",
|
||||
"UseAgeBasedMatchmaking",
|
||||
"UseRecRoyaleMatchmaking",
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"FOKKDBJEFOO": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"FOOOKJCPBKC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"FPGBBCLENDA": [
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil"
|
||||
],
|
||||
"FPLGJGDJIJG": [
|
||||
"StorefrontType",
|
||||
"PurchasableItemId",
|
||||
"CurrencyType",
|
||||
"RequestedPrice",
|
||||
"CouponConsumablePlayerMappingId",
|
||||
"Gift"
|
||||
],
|
||||
"FPNMHECOPPF": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"FPNOOOFJBJF": [
|
||||
"RankStart",
|
||||
"RankEnd",
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"FastDualShock4GamepadHID": [
|
||||
"Dpad",
|
||||
"Square",
|
||||
"Cross",
|
||||
"Circle",
|
||||
"Triangle"
|
||||
],
|
||||
"FastKeyboard": [
|
||||
"AnyKey",
|
||||
"Escape",
|
||||
"Space",
|
||||
"Enter",
|
||||
"Tab",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"Shift",
|
||||
"Alt",
|
||||
"Backspace",
|
||||
"Home",
|
||||
"End",
|
||||
"Insert",
|
||||
"Delete",
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12"
|
||||
],
|
||||
"FastMouse": [
|
||||
"Pressure",
|
||||
"Radius",
|
||||
"Position",
|
||||
"Delta",
|
||||
"Scroll",
|
||||
"Press"
|
||||
],
|
||||
"FastTouchscreen": [
|
||||
"Pressure",
|
||||
"Radius",
|
||||
"Position",
|
||||
"Delta",
|
||||
"Press"
|
||||
],
|
||||
"GACOLGJKHBI": [
|
||||
"VersionStatus",
|
||||
"UpdateNotificationStage"
|
||||
],
|
||||
"GAGIJHJGFJD": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Balance",
|
||||
"AmountAwarded",
|
||||
"AwardedAt"
|
||||
],
|
||||
"GAODOHODBDE": [
|
||||
"FailedInvites",
|
||||
"Result"
|
||||
],
|
||||
"GBPDOLJBABB": [
|
||||
"Success",
|
||||
"Message"
|
||||
],
|
||||
"GBPHFPMCGHH": [
|
||||
"Subscription",
|
||||
"PlatformAccountSubscribedPlayerId"
|
||||
],
|
||||
"GCKGIDLDBNH": [
|
||||
"Error",
|
||||
"Error_description",
|
||||
"Access_token",
|
||||
"Refresh_token",
|
||||
"Key"
|
||||
],
|
||||
"GDGCOAMMCIH": [
|
||||
"MicSpamVolumeThreshold",
|
||||
"MicVolumeSampleInterval",
|
||||
"MicVolumeSampleRollingWindowLength",
|
||||
"MicSpamSamplePercentageForWarning",
|
||||
"MicSpamSamplePercentageForWarningToEnd",
|
||||
"MicSpamSamplePercentageForForceMute",
|
||||
"MicSpamSamplePercentageForForceMuteToEnd",
|
||||
"MicSpamWarningStateVolumeMultiplier"
|
||||
],
|
||||
"GEEKOKDMAHG": [
|
||||
"AccountId",
|
||||
"ApiKey"
|
||||
],
|
||||
"GEGADPCDGBO": [
|
||||
"Tag",
|
||||
"Type"
|
||||
],
|
||||
"GEMBOCLPFGE": [
|
||||
"AccountId",
|
||||
"FavoritedAt"
|
||||
],
|
||||
"GFCIGEIEPGH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"GFGBAAADKEL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"GFGKDCENJMB": [
|
||||
"AccountId",
|
||||
"Xp",
|
||||
"GameMinutesToday",
|
||||
"RewardsCollected",
|
||||
"BonusRewardsCollected",
|
||||
"XpBoostLastPurchasedAt"
|
||||
],
|
||||
"GFHNJMMBHFD": [
|
||||
"PlayerEventId",
|
||||
"Type"
|
||||
],
|
||||
"GFIHPOOFHLC": [
|
||||
"AnnouncementId",
|
||||
"CreatorAccountId",
|
||||
"ClubId",
|
||||
"Title",
|
||||
"Body",
|
||||
"ImageName",
|
||||
"CreatedAt",
|
||||
"Meta"
|
||||
],
|
||||
"GFKKOADFJKC": [
|
||||
"CurrencyId",
|
||||
"RecipientId",
|
||||
"Amount",
|
||||
"TransactionId"
|
||||
],
|
||||
"GFMOBMCIOJI": [
|
||||
"AccountId",
|
||||
"IsCheerful",
|
||||
"SelectedCheer",
|
||||
"CheerCredit",
|
||||
"CheerGeneral",
|
||||
"CheerHelpful",
|
||||
"CheerCreative",
|
||||
"CheerGreatHost",
|
||||
"CheerSportsman"
|
||||
],
|
||||
"GGFHKCDELJH": [
|
||||
"Id",
|
||||
"Link"
|
||||
],
|
||||
"GHBIGGJKPFI": [
|
||||
"Events",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"GIFNHCGDNEM": [
|
||||
"Platform",
|
||||
"PlatformId",
|
||||
"AccountId",
|
||||
"LastLoginTime",
|
||||
"RequirePassword",
|
||||
"RefreshToken"
|
||||
],
|
||||
"GIJCKEDKGLH": [
|
||||
"ReportCategory",
|
||||
"PlayerEventId",
|
||||
"Details"
|
||||
],
|
||||
"GJGLBNABMNL": [
|
||||
"CreatorAccountId",
|
||||
"ClubId",
|
||||
"MembershipType"
|
||||
],
|
||||
"GKFBDCHILOD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"GLEGPFFPDBE": [
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"SubRooms",
|
||||
"Roles",
|
||||
"Tags",
|
||||
"LoadScreens",
|
||||
"PromoImages",
|
||||
"PromoExternalContent",
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"GLENKGBDMAO": [
|
||||
"Result",
|
||||
"Code",
|
||||
"Url",
|
||||
"RedemptionUrl"
|
||||
],
|
||||
"GMBGBPNMGBA": [
|
||||
"Status",
|
||||
"Invention",
|
||||
"InventionVersion"
|
||||
],
|
||||
"GMDIDJMOEBB": [
|
||||
"Objectives",
|
||||
"ObjectiveGroups"
|
||||
],
|
||||
"GMFJIAMOIEO": [
|
||||
"SubscriptionId",
|
||||
"RecNetPlayerId",
|
||||
"PlatformType",
|
||||
"PlatformId",
|
||||
"PlatformPurchaseId",
|
||||
"Level",
|
||||
"Period",
|
||||
"ExpirationDate",
|
||||
"IsAutoRenewing",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"GMNMHECGEGD": [
|
||||
"CurrencyType",
|
||||
"BalanceAdds"
|
||||
],
|
||||
"GNIPAKCCOOE": [
|
||||
"ProgressionEventId",
|
||||
"Name",
|
||||
"Rewards",
|
||||
"KeepsakeRoomLists",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"CollectionEndTime",
|
||||
"UsesBoost",
|
||||
"BoostDailyGameplayMinutesLimit",
|
||||
"BoostXpMultiplier",
|
||||
"PurchasableXpBoostId",
|
||||
"ActiveExperiment",
|
||||
"ChallengesIconImageName",
|
||||
"RewardsPipImageName",
|
||||
"EventInfoImageName"
|
||||
],
|
||||
"GNMDEJJAHCJ": [
|
||||
"SubscriberDiscountPercent",
|
||||
"StorefrontType",
|
||||
"NextUpdate",
|
||||
"NewUntil",
|
||||
"StoreItems"
|
||||
],
|
||||
"GOHGJADGDJF": [
|
||||
"ReplicationId",
|
||||
"DataBlob",
|
||||
"SubRooms",
|
||||
"Tags",
|
||||
"UnityAssets",
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"GUISkin": [
|
||||
"HorizontalSliderThumbExtent",
|
||||
"VerticalSliderThumbExtent"
|
||||
],
|
||||
"HADOHMCCLEO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HBKAFCAEFCC": [
|
||||
"CreatedAt",
|
||||
"Comment"
|
||||
],
|
||||
"HDLOMAGLILM": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HDOJMEBCAGP": [
|
||||
"Permission",
|
||||
"Role",
|
||||
"Override",
|
||||
"Type",
|
||||
"Value"
|
||||
],
|
||||
"HECILPKCKMK": [
|
||||
"FeaturedRoomGroupId",
|
||||
"Name",
|
||||
"Rooms"
|
||||
],
|
||||
"HEIPMIFEFEE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HEKMOMJFFGJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HFHNOHAODOF": [
|
||||
"ValidTill",
|
||||
"Images"
|
||||
],
|
||||
"HFODJDLIJPG": [
|
||||
"TargetPlayerId",
|
||||
"RoomName",
|
||||
"ActionCode"
|
||||
],
|
||||
"HGJDGKFIOCK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HHJLCKPJOIA": [
|
||||
"PlayerId",
|
||||
"StatusVisibility",
|
||||
"DeviceClass",
|
||||
"VrMovementMode",
|
||||
"RoomInstance",
|
||||
"IsOnline",
|
||||
"AppVersion",
|
||||
"ClientJoinData"
|
||||
],
|
||||
"HHOJLJGGNKN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HIKOJENMDKE": [
|
||||
"KeepsakeCategoryId",
|
||||
"VisualId",
|
||||
"LimitPerRoom",
|
||||
"XpValue",
|
||||
"IconOutlineImageName",
|
||||
"IconFilledImageName"
|
||||
],
|
||||
"HIOJNFKGAOK": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HIOPBEPNFOO": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HJKLIIPPPEM": [
|
||||
"PlayerEvent",
|
||||
"PlayerEventResponse"
|
||||
],
|
||||
"HJOIJBMAIPO": [
|
||||
"PlaylistId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"HJOKFJKKEJM": [
|
||||
"BalanceUpdateResult",
|
||||
"TokenBalanceResponse",
|
||||
"OperationResult"
|
||||
],
|
||||
"HJPEABLEEKL": [
|
||||
"RoomConsumableId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"PriceAndCurrency"
|
||||
],
|
||||
"HKAGNIGNBOJ": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HKIJINJGBGD": [
|
||||
"Type",
|
||||
"User_id",
|
||||
"Identifiers",
|
||||
"Ip",
|
||||
"Gameplay_session_id",
|
||||
"Transaction_id"
|
||||
],
|
||||
"HKPKGOPHLHE": [
|
||||
"RoomInstanceCount",
|
||||
"PlayerCount"
|
||||
],
|
||||
"HLDLKOPNDPF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"HMDONHHKKGA": [
|
||||
"PinnedFilters",
|
||||
"PopularFilters",
|
||||
"TrendingFilters"
|
||||
],
|
||||
"HMGHGBMGNJG": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HMKMGPNMHBM": [
|
||||
"Instances",
|
||||
"CollectionRecords",
|
||||
"KeepsakeProgressionEventIds"
|
||||
],
|
||||
"HMMFNEEAFGP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"HNLGHDMNMDB": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"HOGIMIPDDGP": [
|
||||
"ImageName",
|
||||
"ImageUrl"
|
||||
],
|
||||
"HPAKKJFOFDF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"HPEGLGGMNMC": [
|
||||
"ChatThread",
|
||||
"ChatResult"
|
||||
],
|
||||
"HPMMKFGDAEC": [
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"IACBFJEMJMJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"IAPMDDFENDD": [
|
||||
"PurchasableItemId",
|
||||
"CouponConsumablePlayerMappingId",
|
||||
"Count",
|
||||
"CreatorPlayerId",
|
||||
"RequestedPrice"
|
||||
],
|
||||
"IBAPHBCOBHL": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"IBILPLGNAJE": [
|
||||
"Id",
|
||||
"ImageName",
|
||||
"PlayerId",
|
||||
"RoomId",
|
||||
"PlayerEventId",
|
||||
"Accessibility",
|
||||
"AccessibilityLocked",
|
||||
"Type",
|
||||
"CreatedAt",
|
||||
"TaggedPlayerIds",
|
||||
"CheerCount",
|
||||
"CommentCount"
|
||||
],
|
||||
"ICMBGNCJMJG": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"ICNLODNGFJJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"IDBFKCFEAHM": [
|
||||
"BalanceUpdateResult",
|
||||
"CurrencyBalanceResponse",
|
||||
"OperationResult"
|
||||
],
|
||||
"IDBLMMAGACO": [
|
||||
"Key",
|
||||
"Value"
|
||||
],
|
||||
"IDFEIMCEMCC": [
|
||||
"Level",
|
||||
"Period",
|
||||
"IsAutoRenewing"
|
||||
],
|
||||
"IEIKHFIPDMD": [
|
||||
"CurrencyType",
|
||||
"Price",
|
||||
"StorefrontSaleData"
|
||||
],
|
||||
"IFFEHJEPKOH": [
|
||||
"ReferralsVerifiedCount",
|
||||
"PlayerReferralRewards"
|
||||
],
|
||||
"IFLDEEGLKFG": [
|
||||
"Level",
|
||||
"RequiredXp",
|
||||
"GiftDropId"
|
||||
],
|
||||
"IGMCJEFBJCF": [
|
||||
"ServerTime",
|
||||
"PlayerId",
|
||||
"StatusVisibility",
|
||||
"DeviceClass",
|
||||
"VrMovementMode",
|
||||
"RoomInstance",
|
||||
"IsOnline",
|
||||
"AppVersion",
|
||||
"ClientJoinData"
|
||||
],
|
||||
"IGNECOJDECH": [
|
||||
"Info",
|
||||
"ReferrerId"
|
||||
],
|
||||
"IHGDKHPLIGH": [
|
||||
"RoomId",
|
||||
"RoomName",
|
||||
"ImageName"
|
||||
],
|
||||
"IJFKOMJBJMI": [
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"StatValue",
|
||||
"CurrentStatValue"
|
||||
],
|
||||
"ILAHMLLJDLE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"ILEHDPADJJF": [
|
||||
"Nouns",
|
||||
"Adjectives"
|
||||
],
|
||||
"ILJOBJDPBNJ": [
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor",
|
||||
"Accessibility"
|
||||
],
|
||||
"IMMOJHBENFI": [
|
||||
"Slot",
|
||||
"PreviewImageName",
|
||||
"OutfitSelections",
|
||||
"OutfitSelectionsV2",
|
||||
"FaceFeatures",
|
||||
"SkinColor",
|
||||
"HairColor",
|
||||
"CustomAvatarItems"
|
||||
],
|
||||
"INLPIKBODME": [
|
||||
"CreatorAccountId",
|
||||
"BaseAvatarItemId",
|
||||
"DesignFilename",
|
||||
"BaseAvatarItemColor"
|
||||
],
|
||||
"IOEPPCKGBFL": [
|
||||
"InventionId",
|
||||
"ReplicationId",
|
||||
"CreatorPlayerId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"CurrentVersionNumber",
|
||||
"Accessibility",
|
||||
"ModifiedAt",
|
||||
"CreatedAt",
|
||||
"FirstPublishedAt",
|
||||
"CreationRoomId",
|
||||
"NumPlayersHaveUsedInRoom",
|
||||
"NumDownloads",
|
||||
"CheerCount",
|
||||
"CreatorPermission",
|
||||
"GeneralPermission",
|
||||
"IsAGInvention",
|
||||
"IsCertifiedInvention",
|
||||
"AllowTrial",
|
||||
"Price",
|
||||
"HideFromPlayer"
|
||||
],
|
||||
"IPMGNJMIONC": [
|
||||
"Revenue_currency",
|
||||
"Revenue_amount",
|
||||
"Revenue_type",
|
||||
"Type",
|
||||
"User_id",
|
||||
"Identifiers",
|
||||
"Ip",
|
||||
"Gameplay_session_id",
|
||||
"Transaction_id"
|
||||
],
|
||||
"JAFDEPHGOAF": [
|
||||
"Title",
|
||||
"Message",
|
||||
"ImageName",
|
||||
"ActiveExperiments",
|
||||
"Context",
|
||||
"InputType",
|
||||
"Visibility",
|
||||
"AllowCycling",
|
||||
"PlatformMask",
|
||||
"RestrictToNewUsers",
|
||||
"RoomNames"
|
||||
],
|
||||
"JAHJGFHFKIB": [
|
||||
"Club",
|
||||
"CustomTags",
|
||||
"AdditionalImages",
|
||||
"CoownerPermissions",
|
||||
"ModeratorPermissions",
|
||||
"MemberPermissions",
|
||||
"MyMembershipType"
|
||||
],
|
||||
"JBGKGCLPCHP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"JBIEJIKHBMG": [
|
||||
"SeedRoom",
|
||||
"Rooms"
|
||||
],
|
||||
"JCPFCOEEELK": [
|
||||
"AccountId",
|
||||
"CurrencyId",
|
||||
"Success",
|
||||
"Error",
|
||||
"Response"
|
||||
],
|
||||
"JDEKAFDPGBE": [
|
||||
"ReplicationId",
|
||||
"PersistenceVersion",
|
||||
"SupportsJoinInProgress",
|
||||
"UseLevelBasedMatchmaking",
|
||||
"UseAgeBasedMatchmaking",
|
||||
"UseRecRoyaleMatchmaking",
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"JEBCHNDADKK": [
|
||||
"ConcurrencyCodes",
|
||||
"ExpectedPriceAndCurrency"
|
||||
],
|
||||
"JFEKPNOKFBP": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JGJFCFAFGCM": [
|
||||
"TotalXP",
|
||||
"Level",
|
||||
"RankIdx",
|
||||
"RankName",
|
||||
"CurrentLevelXPThreshold",
|
||||
"NextLevelXPThreshold",
|
||||
"NextLevelAcornReward"
|
||||
],
|
||||
"JHNAJMDHFKH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JICIOMDBPDO": [
|
||||
"Id",
|
||||
"ConsumableItemDesc",
|
||||
"Count",
|
||||
"InitialCount",
|
||||
"CreatedAt",
|
||||
"ActiveDurationMinutes",
|
||||
"IsActive",
|
||||
"IsTransferable"
|
||||
],
|
||||
"JIOEIEENGNO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JJEKBGHDCEG": [
|
||||
"Tier",
|
||||
"Rewards",
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"JJGEJHCFEJH": [
|
||||
"GiftDropId",
|
||||
"ConsumableItemDesc",
|
||||
"AvatarItemDesc",
|
||||
"AvatarItemType",
|
||||
"EquipmentPrefabName",
|
||||
"EquipmentModificationGuid",
|
||||
"StorefrontType",
|
||||
"Xp",
|
||||
"Level",
|
||||
"GiftContext",
|
||||
"GiftRarity"
|
||||
],
|
||||
"JKJHOAKNOCF": [
|
||||
"CurrentConcurrencyCode",
|
||||
"NewConcurrencyCode"
|
||||
],
|
||||
"JLIBBFMMLFF": [
|
||||
"CurrencyType",
|
||||
"BalanceAddType",
|
||||
"BaseAward",
|
||||
"BonusAwardMin",
|
||||
"BonusAwardMax",
|
||||
"RateLimitType",
|
||||
"IgnorePartialMultiplier",
|
||||
"MaxPartialMultiplier",
|
||||
"RateLimit",
|
||||
"BalanceInGiftBox"
|
||||
],
|
||||
"JLNFEFHNFBP": [
|
||||
"Title",
|
||||
"Description"
|
||||
],
|
||||
"JLNKMODEKKM": [
|
||||
"BaseAvatarItemId",
|
||||
"BaseAvatarItemColor"
|
||||
],
|
||||
"JMJLMOJCBCK": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"JNFDPPCIPHG": [
|
||||
"CategoryId",
|
||||
"Importance",
|
||||
"Name",
|
||||
"Description",
|
||||
"IsMuteable"
|
||||
],
|
||||
"JOMMALIPDGI": [
|
||||
"RoomId",
|
||||
"IsDorm",
|
||||
"MaxPlayerCalculationMode",
|
||||
"MaxPlayers",
|
||||
"PersistenceVersion",
|
||||
"CloningAllowed",
|
||||
"DisableMicAutoMute",
|
||||
"DisableRoomComments",
|
||||
"EncryptVoiceChat",
|
||||
"ToxmodEnabled",
|
||||
"LoadScreenLocked",
|
||||
"IsDeveloperOwned",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"JPKBLKGJNCA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"JPLIIMKJAEL": [
|
||||
"AccountId",
|
||||
"DisallowInAppPurchases"
|
||||
],
|
||||
"JPNMBICAMEE": [
|
||||
"RoomInstanceId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"IsFull",
|
||||
"CreatedAt",
|
||||
"PlayerIds"
|
||||
],
|
||||
"JsonParser_NestedType_JsonValue": [
|
||||
"False",
|
||||
"True"
|
||||
],
|
||||
"KAONMCEONPF": [
|
||||
"Name",
|
||||
"Price",
|
||||
"ProductId"
|
||||
],
|
||||
"KBHFIPFPCMN": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KBLOCDGCGFA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KBMAAKAIPLE": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"KCMDEFMLDJF": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"BalanceType",
|
||||
"Data"
|
||||
],
|
||||
"KDAHIOHGPBN": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KDJILJAIDFG": [
|
||||
"RoomId",
|
||||
"Count"
|
||||
],
|
||||
"KEOLHAONMIF": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KFAOOJLFCGC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KFPHOKPEMOF": [
|
||||
"ItemType",
|
||||
"ItemId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"RoomId",
|
||||
"Price",
|
||||
"PurchaseCurrencyId",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"KGNFNBHMAFL": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"KHBGNAMFAHK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KHFKIJEGPDG": [
|
||||
"AccountId",
|
||||
"RoomKeyId",
|
||||
"DoesPlayerOwnRoomKey"
|
||||
],
|
||||
"KIANAKFJGCP": [
|
||||
"FeaturedPlayer",
|
||||
"FeaturedRoomGroup",
|
||||
"CurrentAnnouncement",
|
||||
"InstagramImages",
|
||||
"Videos"
|
||||
],
|
||||
"KILGEFOENFN": [
|
||||
"BalanceUpdates",
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"KJEIGGBIIHP": [
|
||||
"SavedImageId",
|
||||
"IsCheered"
|
||||
],
|
||||
"KKOHONLGIHL": [
|
||||
"Id",
|
||||
"FromPlayerId",
|
||||
"SentTime",
|
||||
"Type",
|
||||
"Data",
|
||||
"RoomId",
|
||||
"PlayerEventId"
|
||||
],
|
||||
"KLHCKBPJKPF_NestedType_DHKPMMHMIJF_NestedType_KKNKAJBPNPC": [
|
||||
"SerializeUnsafe",
|
||||
"ToJsonString"
|
||||
],
|
||||
"KMBHAKAHNGH": [
|
||||
"Room",
|
||||
"SubRoomDataSave"
|
||||
],
|
||||
"KMHNOGNHDLO": [
|
||||
"SubscriptionSeasonMilestoneId",
|
||||
"SeasonId",
|
||||
"StartAt",
|
||||
"GiftDrops"
|
||||
],
|
||||
"KNGMIABFJHK": [
|
||||
"CurrencyBalanceResponse",
|
||||
"TokenBalanceResponse"
|
||||
],
|
||||
"KOEEACLMAFE": [
|
||||
"Referrals",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"KPEIHOCIGGE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"KPMMGJELBPP": [
|
||||
"WindowSize",
|
||||
"PlayerId",
|
||||
"StatChannel",
|
||||
"RoomId",
|
||||
"FilterType",
|
||||
"SortAscending"
|
||||
],
|
||||
"KPNBKKKCOCA": [
|
||||
"UnlockedAt",
|
||||
"ClaimedAt",
|
||||
"Status",
|
||||
"ReferralRewardTier",
|
||||
"IsCurrentTier"
|
||||
],
|
||||
"KPPINBHEKHE": [
|
||||
"Products",
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"LAIJIAIHGCO": [
|
||||
"ReportCategory",
|
||||
"Duration",
|
||||
"GameSessionId",
|
||||
"IsHostKick",
|
||||
"Message",
|
||||
"PlayerIdReporter",
|
||||
"IsBan",
|
||||
"IsVoiceModAutoban",
|
||||
"IsWarning",
|
||||
"VoteKickReason",
|
||||
"TimeoutStartedAt"
|
||||
],
|
||||
"LBOGFNBPIKJ": [
|
||||
"SubRoomId",
|
||||
"RoomId",
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"UnityAssetId",
|
||||
"UnityAsset",
|
||||
"UnityAssetHash",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"DataSavedAt",
|
||||
"IsSandbox",
|
||||
"MaxPlayers",
|
||||
"Accessibility"
|
||||
],
|
||||
"LDGHHCCJAMC": [
|
||||
"Status",
|
||||
"RoomKey"
|
||||
],
|
||||
"LEEAJGDIOHI": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LEINHCHGFON": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"LEMIBENHABD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LIJHBNDJKNP": [
|
||||
"RewardSelectionId",
|
||||
"RewardType",
|
||||
"Message",
|
||||
"GiftContext",
|
||||
"GiftDrop1",
|
||||
"GiftDrop2",
|
||||
"GiftDrop3",
|
||||
"Subscriber_GiftDrop3",
|
||||
"CreatedAt"
|
||||
],
|
||||
"LIODMDILCAJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LKBGCOELLIN": [
|
||||
"CurrencyId",
|
||||
"PurchaseOffers"
|
||||
],
|
||||
"LKJLPNENINM": [
|
||||
"RoomKeyResponse",
|
||||
"BalanceUpdateResponse"
|
||||
],
|
||||
"LKJPKMBEPCG": [
|
||||
"PrefabName",
|
||||
"ModificationGuid",
|
||||
"FriendlyName",
|
||||
"Tooltip",
|
||||
"Rarity",
|
||||
"Favorited"
|
||||
],
|
||||
"LKLKHEEHEBC": [
|
||||
"UnityAssetId",
|
||||
"Target",
|
||||
"Version",
|
||||
"Filename",
|
||||
"Hash"
|
||||
],
|
||||
"LKMEPPOFHLK": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"LMCCNLLHLCJ": [
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"LMEECKCCPHG": [
|
||||
"AccountId",
|
||||
"Role",
|
||||
"LastChangedByAccountId",
|
||||
"InvitedRole"
|
||||
],
|
||||
"LMHKHIJIDEO": [
|
||||
"SavedImageId",
|
||||
"Cheer"
|
||||
],
|
||||
"LNMAMOCCBAK": [
|
||||
"Filename",
|
||||
"UnityAssetId",
|
||||
"CreatedByAccountId",
|
||||
"BakedUnityAssets"
|
||||
],
|
||||
"LOEHGGMCKAM": [
|
||||
"ChatResult",
|
||||
"SnoozedUntil"
|
||||
],
|
||||
"LOOLOADLJPG": [
|
||||
"SubscriptionSeasonId",
|
||||
"Name",
|
||||
"ImageName",
|
||||
"StartAt",
|
||||
"NextSeasonStart",
|
||||
"Milestones"
|
||||
],
|
||||
"LPAEOGFPGBD": [
|
||||
"UnitySceneId",
|
||||
"Name",
|
||||
"MaxPlayers",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile"
|
||||
],
|
||||
"LPFAFIGAIOE": [
|
||||
"Status",
|
||||
"InventoryItem"
|
||||
],
|
||||
"MACBLLENLFD": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MADLMAFINGF": [
|
||||
"KeepsakeInstanceId",
|
||||
"KeepsakeCategoryConfigId",
|
||||
"PlacedByAccountId",
|
||||
"RoomId",
|
||||
"SubRoomId"
|
||||
],
|
||||
"MAHHAAEGEEP": [
|
||||
"InventionResponse",
|
||||
"BalanceUpdateResponse"
|
||||
],
|
||||
"MBFADJPOGDI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MCDADMNCDEI": [
|
||||
"WishlistItemId",
|
||||
"AccountId",
|
||||
"PurchasableItemId",
|
||||
"CreatedAt"
|
||||
],
|
||||
"MCMPJPNBJBI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MEBAFOGDHBB": [
|
||||
"PurchaseCampaignId",
|
||||
"PurchaseReminder",
|
||||
"Name",
|
||||
"TriggerFlags",
|
||||
"LastShown",
|
||||
"ShowCount",
|
||||
"DaysBetweenPopups",
|
||||
"MaxPopups"
|
||||
],
|
||||
"MEEAEDNAPGI": [
|
||||
"CommentId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"AccountId",
|
||||
"CreatedAt",
|
||||
"Message",
|
||||
"Style",
|
||||
"Unread",
|
||||
"PositionX",
|
||||
"PositionY",
|
||||
"PositionZ"
|
||||
],
|
||||
"MFNBIPCHKHC": [
|
||||
"MembershipRequests",
|
||||
"TotalMembershipRequests",
|
||||
"ContinuationToken"
|
||||
],
|
||||
"MGOLBEMMNDA": [
|
||||
"BalanceAddType",
|
||||
"BaseAward",
|
||||
"BonusAward",
|
||||
"RateLimit",
|
||||
"CurrentCount",
|
||||
"Total",
|
||||
"Platform",
|
||||
"BalanceInGiftBox"
|
||||
],
|
||||
"MIFGPMGEMEK": [
|
||||
"RefreshesAt",
|
||||
"RefreshIntervalMinutes",
|
||||
"Room"
|
||||
],
|
||||
"MIHDILLIAFE": [
|
||||
"Instances",
|
||||
"CollectionRecords"
|
||||
],
|
||||
"MIHJPKMLADF": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MIHKAJFMIPE": [
|
||||
"RoomId",
|
||||
"CurrencyId"
|
||||
],
|
||||
"MJGHCJDOJLH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MJJDCIAIDKI": [
|
||||
"HasEliteUpgrade",
|
||||
"HasEliteUpgradePlatformMask",
|
||||
"CurrentSeasonTier",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"MJKDDBKGNOP": [
|
||||
"ContactDetail",
|
||||
"AccountId",
|
||||
"UserName",
|
||||
"DisplayName",
|
||||
"DisplayEmoji",
|
||||
"ProfileImage",
|
||||
"BannerImage",
|
||||
"TreatAsJunior",
|
||||
"HasBirthday",
|
||||
"PersonalPronouns",
|
||||
"IdentityFlags",
|
||||
"CreatedAt",
|
||||
"IsJunior"
|
||||
],
|
||||
"MJNABIBJDMO": [
|
||||
"Order",
|
||||
"Objective",
|
||||
"Count"
|
||||
],
|
||||
"MKAIABAJIAJ": [
|
||||
"CustomAvatarItemId",
|
||||
"BodyPart"
|
||||
],
|
||||
"MKBLHNGJOBA": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MLHBPDKBABN": [
|
||||
"CheerCount",
|
||||
"FavoriteCount",
|
||||
"VisitorCount",
|
||||
"VisitCount"
|
||||
],
|
||||
"MMDMDIJLIEH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MMFCMCPCAJP": [
|
||||
"ChatMessage",
|
||||
"ChatResult"
|
||||
],
|
||||
"MMPLMIAEBLE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"MOJLGDKFECO": [
|
||||
"InvitedPlayerId",
|
||||
"Result"
|
||||
],
|
||||
"MOLNFPGNEGP": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"MOMDMDMGEOA": [
|
||||
"ItemType",
|
||||
"ItemId"
|
||||
],
|
||||
"Matchmaking": [
|
||||
"PlayerId",
|
||||
"RoomInstanceId"
|
||||
],
|
||||
"NAGNGCMPGOP": [
|
||||
"RoomId",
|
||||
"KeepsakeInstanceIds"
|
||||
],
|
||||
"NAPOKFHELLC": [
|
||||
"Multiplier",
|
||||
"BalanceAddType"
|
||||
],
|
||||
"NBGPICODFEE": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NCBBKAMCPBC": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"NCHBANKEJMP": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"NDIIFIGNAAM": [
|
||||
"Error",
|
||||
"Identifier",
|
||||
"Genuine",
|
||||
"GenuineCheckAvailable"
|
||||
],
|
||||
"NEJOPOKDPKE": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"NEKEOJFNCCJ": [
|
||||
"UpdateResponse",
|
||||
"Data"
|
||||
],
|
||||
"NFBJOAGJHME": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NGCPIKDINEI": [
|
||||
"Provider",
|
||||
"Data"
|
||||
],
|
||||
"NHGOFGABJIN": [
|
||||
"RoomInstanceId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"Location",
|
||||
"EventId",
|
||||
"ClubId",
|
||||
"RoomCode",
|
||||
"PhotonRegionId",
|
||||
"PhotonRoomId",
|
||||
"Name",
|
||||
"MaxCapacity",
|
||||
"IsFull",
|
||||
"IsPrivate",
|
||||
"IsInProgress",
|
||||
"EncryptVoiceChat",
|
||||
"RoomInstanceType"
|
||||
],
|
||||
"NIJPNDPJICF": [
|
||||
"Id",
|
||||
"Key",
|
||||
"Title",
|
||||
"Description",
|
||||
"RoomName",
|
||||
"Status",
|
||||
"AssignedPlayerNames",
|
||||
"Tags",
|
||||
"JiraUrl",
|
||||
"Comments"
|
||||
],
|
||||
"NKMEHOHCKOH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"NLIAAGLJBNN": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"NMGHOIMEJIK": [
|
||||
"Name",
|
||||
"Description",
|
||||
"Price",
|
||||
"Accessibility"
|
||||
],
|
||||
"NNAGOPNGJFE": [
|
||||
"AmplitudeKey",
|
||||
"RudderStackKey",
|
||||
"UseRudderStack",
|
||||
"StatSigKey"
|
||||
],
|
||||
"OANOOFEPJCI": [
|
||||
"Rooms",
|
||||
"Tags",
|
||||
"PlaylistId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"WarningMask",
|
||||
"CustomWarning",
|
||||
"CreatorAccountId",
|
||||
"State",
|
||||
"Accessibility",
|
||||
"SupportsLevelVoting",
|
||||
"IsRRO",
|
||||
"SupportsScreens",
|
||||
"SupportsWalkVR",
|
||||
"SupportsTeleportVR",
|
||||
"SupportsVRLow",
|
||||
"SupportsQuest2",
|
||||
"SupportsMobile",
|
||||
"SupportsJuniors",
|
||||
"MinLevel",
|
||||
"CreatedAt",
|
||||
"Stats",
|
||||
"RankingContext"
|
||||
],
|
||||
"OBACBEGPKAK": [
|
||||
"ReportCategory",
|
||||
"Details",
|
||||
"ReportedPlayerId"
|
||||
],
|
||||
"OBGIGMCOMDE": [
|
||||
"AdCarouselItemId",
|
||||
"ImageName",
|
||||
"Title",
|
||||
"Description",
|
||||
"PurchasableItemIds",
|
||||
"PurchaseReminderId"
|
||||
],
|
||||
"OBIBPCLDPGA": [
|
||||
"Message",
|
||||
"MoreInfoUrl"
|
||||
],
|
||||
"OCFNOBAONAP": [
|
||||
"Cheered",
|
||||
"Favorited",
|
||||
"LastVisitedAt"
|
||||
],
|
||||
"OCGICPIMNEB": [
|
||||
"ErrorCode",
|
||||
"RoomInstance"
|
||||
],
|
||||
"ODNKLIIJIOH": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OEHMNIAOAOH": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"OFGIPBHIAGF": [
|
||||
"ProgressionEventPurchasableXpBoostId",
|
||||
"Cost",
|
||||
"XpMultiplier",
|
||||
"XpCap",
|
||||
"LookbackDurationTicks",
|
||||
"CooldownDurationTicks"
|
||||
],
|
||||
"OFMNNCMPEPA": [
|
||||
"SessionId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"RoomData",
|
||||
"SubRoomData",
|
||||
"SavedByAccountId"
|
||||
],
|
||||
"OGCMIDEFOEE": [
|
||||
"ChatResult",
|
||||
"IsFavorited"
|
||||
],
|
||||
"OGEDLCFDDIO": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OHNEMFHCBIJ": [
|
||||
"Value",
|
||||
"Success",
|
||||
"Error"
|
||||
],
|
||||
"OKHGCKDIILD": [
|
||||
"ImageName",
|
||||
"Index"
|
||||
],
|
||||
"OLACHOEDKFB": [
|
||||
"ReportCategory",
|
||||
"YesVotes",
|
||||
"NoVotes",
|
||||
"PlayerIdReported"
|
||||
],
|
||||
"OLIHBGPDPHF": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"BalanceType",
|
||||
"Data"
|
||||
],
|
||||
"OLKCALBDDOI": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"OLPHKLCPFEF": [
|
||||
"InventionId",
|
||||
"ReplicationId",
|
||||
"VersionNumber",
|
||||
"InstantiationCost",
|
||||
"LightsCost",
|
||||
"ChipsCost",
|
||||
"CloudVariablesCost",
|
||||
"BlobName",
|
||||
"BlobHash"
|
||||
],
|
||||
"OMAPLLOIDNA": [
|
||||
"ToPlayerIds",
|
||||
"Message",
|
||||
"GiftContext"
|
||||
],
|
||||
"ONGJBLFBHGC": [
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"OOGKPMKKJBE": [
|
||||
"SalePercent",
|
||||
"SaleStartDate",
|
||||
"SaleEndDate"
|
||||
],
|
||||
"PAKGKIKFGAE": [
|
||||
"Id",
|
||||
"TitleOverride",
|
||||
"UrlOverride"
|
||||
],
|
||||
"PCBAACNNDBH": [
|
||||
"FriendlyImageName",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime"
|
||||
],
|
||||
"PCMLHLIBLNJ": [
|
||||
"Tag",
|
||||
"Type"
|
||||
],
|
||||
"PCODFCJECKL": [
|
||||
"Msg",
|
||||
"Id"
|
||||
],
|
||||
"PCPOMPIAFMA": [
|
||||
"Key",
|
||||
"Value",
|
||||
"StartTime",
|
||||
"EndTime"
|
||||
],
|
||||
"PDGENCNGCJI": [
|
||||
"RoomId",
|
||||
"Ids"
|
||||
],
|
||||
"PDIFGFKAMBG": [
|
||||
"PlayerCount",
|
||||
"IsFull",
|
||||
"PlayerEventId",
|
||||
"CreatorPlayerId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"ClubId",
|
||||
"Name",
|
||||
"Description",
|
||||
"ImageName",
|
||||
"StartTime",
|
||||
"EndTime",
|
||||
"AttendeeCount",
|
||||
"Accessibility",
|
||||
"IsMultiInstance",
|
||||
"SupportMultiInstanceRoomChat",
|
||||
"DefaultBroadcastPermissions",
|
||||
"CanRequestBroadcastPermissions",
|
||||
"BroadcastingRoomInstanceId"
|
||||
],
|
||||
"PECGEJAAMHB": [
|
||||
"Balance",
|
||||
"CurrencyType",
|
||||
"Platform"
|
||||
],
|
||||
"PELEHJLMKJO": [
|
||||
"SubRoomDataSaveId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"DataBlob",
|
||||
"DataBlobHash",
|
||||
"SavedByAccountId",
|
||||
"SavedOnPlatform",
|
||||
"SavedOnDeviceClass",
|
||||
"Description",
|
||||
"CreatedAt"
|
||||
],
|
||||
"PEOAOMGOMGC": [
|
||||
"CurrencyId",
|
||||
"RoomId",
|
||||
"Name",
|
||||
"Description",
|
||||
"CurrencyType",
|
||||
"Limit",
|
||||
"ImageName",
|
||||
"CreatedAt",
|
||||
"ModifiedAt"
|
||||
],
|
||||
"PGACEMBEGJF": [
|
||||
"ToPlayerId",
|
||||
"Message",
|
||||
"Anonymous",
|
||||
"GiftContext"
|
||||
],
|
||||
"PJPBJKLDMNA": [
|
||||
"Results",
|
||||
"TotalResults"
|
||||
],
|
||||
"PKCMBJFBHBO": [
|
||||
"TotalXp",
|
||||
"SocialBoostXp"
|
||||
],
|
||||
"PLLNLPADHCN": [
|
||||
"Resolution",
|
||||
"Language",
|
||||
"Timezone",
|
||||
"Platform"
|
||||
],
|
||||
"POGBAGAHGIA": [
|
||||
"GiftDrop",
|
||||
"PurchasableItemId",
|
||||
"Type",
|
||||
"Prices",
|
||||
"SubscriberPrices",
|
||||
"IsFeatured",
|
||||
"AvailableAt",
|
||||
"AvailableUntil",
|
||||
"NewUntil"
|
||||
],
|
||||
"PPHLKNGCGOE": [
|
||||
"SessionId",
|
||||
"RoomId",
|
||||
"SubRoomId",
|
||||
"UnityAssetId",
|
||||
"RoomDataFilename",
|
||||
"RoomDataHash",
|
||||
"SubRoomDataFilename",
|
||||
"SubRoomDataHash"
|
||||
],
|
||||
"PlayerInput": [
|
||||
"OnPlayerLeft",
|
||||
"OnDeviceLost",
|
||||
"OnDeviceRegained",
|
||||
"OnControlsChanged"
|
||||
],
|
||||
"PlayerInputManager": [
|
||||
"OnPlayerJoined",
|
||||
"OnPlayerLeft"
|
||||
],
|
||||
"StringHelpers": [
|
||||
"Mouse",
|
||||
"Mice",
|
||||
"Axis",
|
||||
"Axes"
|
||||
],
|
||||
"X509Name": [
|
||||
"C",
|
||||
"O",
|
||||
"T",
|
||||
"OU",
|
||||
"CN",
|
||||
"L",
|
||||
"ST",
|
||||
"SERIALNUMBER",
|
||||
"E",
|
||||
"DC",
|
||||
"UID",
|
||||
"STREET",
|
||||
"SURNAME",
|
||||
"GIVENNAME",
|
||||
"INITIALS",
|
||||
"GENERATION",
|
||||
"UniqueIdentifier",
|
||||
"DN",
|
||||
"Pseudonym",
|
||||
"PostalAddress",
|
||||
"CountryOfCitizenship",
|
||||
"CountryOfResidence",
|
||||
"Gender",
|
||||
"PlaceOfBirth",
|
||||
"DateOfBirth",
|
||||
"PostalCode",
|
||||
"BusinessCategory",
|
||||
"TelephoneNumber"
|
||||
],
|
||||
"XSDSchema": [
|
||||
"True",
|
||||
"False",
|
||||
"Name"
|
||||
],
|
||||
"XmlTreeGen": [
|
||||
"Namespace",
|
||||
"Unique",
|
||||
"Attribute"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
# Every HTTP request the Rec Room 2023-03-21 client can issue.
|
||||
# Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
|
||||
# verb<TAB>route<TAB>subsystem
|
||||
GET account/bulk identity-account
|
||||
POST account/bulk identity-account
|
||||
GET account/isactivecreator/me identity-account
|
||||
DELETE account/me identity-account
|
||||
GET account/me identity-account
|
||||
POST account/me/changepassword identity-account
|
||||
POST account/me/createlogintoken identity-account
|
||||
GET account/me/haspassword identity-account
|
||||
POST account/me/remoteauth identity-account
|
||||
POST account/recoverpassword identity-account
|
||||
GET account/search identity-account
|
||||
GET account/{0}/bio identity-account
|
||||
GET account/{0}/clubs identity-account
|
||||
PUT accountprivacysettings/recenthistoryvisibility identity-account
|
||||
GET accountprivacysettings/{0} identity-account
|
||||
GET accounts/{0}/receives/{1} identity-account
|
||||
POST actionlink config-telemetry
|
||||
GET actionlink/{code} config-telemetry
|
||||
POST actionlink/{code}/consume config-telemetry
|
||||
GET announcements/club/{clubId} config-telemetry
|
||||
POST announcements/club/{clubId} config-telemetry
|
||||
DELETE announcements/club/{clubId}/{announcementId} config-telemetry
|
||||
PUT announcements/club/{clubId}/{announcementId} config-telemetry
|
||||
POST announcements/club/{clubId}/{announcementId}/read config-telemetry
|
||||
GET announcements/mine config-telemetry
|
||||
GET announcements/subscription/mine config-telemetry
|
||||
GET announcements/v2/mine/unread config-telemetry
|
||||
GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
|
||||
POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
|
||||
POST api/AppIntegrity/v1/iosproducts commerce-subscription
|
||||
GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
|
||||
POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
|
||||
POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
|
||||
POST api/PlayerCheer/v1/create progression-misc
|
||||
POST api/PlayerReporting/v1/deviceId moderation-safety
|
||||
POST api/PlayerReporting/v1/hile moderation-safety
|
||||
POST api/PlayerReporting/v1/instantKick moderation-safety
|
||||
GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
|
||||
GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
|
||||
POST api/PlayerReporting/v3/create moderation-safety
|
||||
POST api/PlayerReporting/v3/voteToKick moderation-safety
|
||||
GET api/activities/charades/v1/words/{0} progression-misc
|
||||
POST api/ageverification/generateCode moderation-safety
|
||||
GET api/announcement/v1/get config-telemetry
|
||||
GET api/apple/musicpromotion/active commerce-subscription
|
||||
GET api/apple/musicpromotion/code commerce-subscription
|
||||
GET api/avatar/v1/defaultbaseavataritems avatar-inventory
|
||||
GET api/avatar/v1/defaultunlocked avatar-inventory
|
||||
GET api/avatar/v1/lockeditems avatar-inventory
|
||||
GET api/avatar/v2 avatar-inventory
|
||||
GET api/avatar/v2/gifts avatar-inventory
|
||||
POST api/avatar/v2/gifts/generate avatar-inventory
|
||||
POST api/avatar/v2/set avatar-inventory
|
||||
GET api/avatar/v2/{playerId} avatar-inventory
|
||||
POST api/avatar/v3/gifts/generate avatar-inventory
|
||||
GET api/avatar/v3/saved avatar-inventory
|
||||
POST api/avatar/v3/saved/set avatar-inventory
|
||||
GET api/avatar/v4/items avatar-inventory
|
||||
GET api/banappeal/generateCode moderation-safety
|
||||
GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
|
||||
GET api/challenge/v2/getCurrent progression-misc
|
||||
POST api/challenge/v2/updateProgress progression-misc
|
||||
POST api/checklist/v1/complete progression-misc
|
||||
GET api/checklist/v1/current progression-misc
|
||||
POST api/clubreporting/v1/report moderation-safety
|
||||
GET api/communityboard/v2/current progression-misc
|
||||
GET api/config/v1/amplitude config-telemetry
|
||||
GET api/config/v1/azurespeech config-telemetry
|
||||
GET api/config/v1/backtrace config-telemetry
|
||||
GET api/config/v1/cohortnux/{cohortId} config-telemetry
|
||||
GET api/config/v1/freegiftbutton config-telemetry
|
||||
GET api/config/v2 config-telemetry
|
||||
POST api/consumables/v1/consume avatar-inventory
|
||||
GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
|
||||
POST api/consumables/v1/transfer avatar-inventory
|
||||
POST api/consumables/v1/updateActive avatar-inventory
|
||||
GET api/consumables/v2/getUnlocked avatar-inventory
|
||||
POST api/customAvatarItems/v1 avatar-inventory
|
||||
POST api/customAvatarItems/v1/bulk avatar-inventory
|
||||
DELETE api/customAvatarItems/v1/design avatar-inventory
|
||||
GET api/customAvatarItems/v1/design avatar-inventory
|
||||
PUT api/customAvatarItems/v1/design avatar-inventory
|
||||
GET api/customAvatarItems/v1/featured avatar-inventory
|
||||
GET api/customAvatarItems/v1/hot avatar-inventory
|
||||
GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
|
||||
GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
|
||||
GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
|
||||
GET api/customAvatarItems/v1/me avatar-inventory
|
||||
GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
|
||||
DELETE api/customAvatarItems/v1/{id} avatar-inventory
|
||||
PUT api/customAvatarItems/v1/{id} avatar-inventory
|
||||
POST api/customAvatarItems/v1/{id}/report avatar-inventory
|
||||
GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
|
||||
POST api/equipment/v1/update avatar-inventory
|
||||
GET api/equipment/v2/getUnlocked avatar-inventory
|
||||
POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
|
||||
POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
|
||||
POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
|
||||
POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
|
||||
POST api/freegifts/v1/sendmultiple progression-misc
|
||||
GET api/gameconfigs/v1/all config-telemetry
|
||||
GET api/gamerewards/v1/pending progression-misc
|
||||
POST api/gamerewards/v1/request progression-misc
|
||||
POST api/gamerewards/v1/select progression-misc
|
||||
POST api/gamesight/event events-playlists
|
||||
POST api/images/v1/cheer inventions-media
|
||||
POST api/images/v1/deletesaved inventions-media
|
||||
GET api/images/v1/listsaved inventions-media
|
||||
POST api/images/v1/modifyaccessibility inventions-media
|
||||
POST api/images/v1/sendlink inventions-media
|
||||
GET api/images/v1/slideshow inventions-media
|
||||
POST api/images/v1/{id}/report inventions-media
|
||||
GET api/images/v2/named inventions-media
|
||||
GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
|
||||
POST api/images/v4/uploadsaved inventions-media
|
||||
GET api/images/v5/bulk inventions-media
|
||||
GET api/images/v5/cheered/bulk inventions-media
|
||||
GET api/images/v5/player/{playerId}?sort={int} inventions-media
|
||||
GET api/images/v6/{id} inventions-media
|
||||
GET api/images/v6?name={imageName} inventions-media
|
||||
POST api/incentivizedreferrals/claim progression-misc
|
||||
GET api/incentivizedreferrals/progress progression-misc
|
||||
GET api/influencerpartnerprogram/influencer progression-misc
|
||||
GET api/influencerpartnerprogram/influencers progression-misc
|
||||
GET api/influencerpartnerprogram/myinfluencer progression-misc
|
||||
POST api/influencerpartnerprogram/remove progression-misc
|
||||
POST api/influencerpartnerprogram/support progression-misc
|
||||
POST api/inventions/v1/cheer inventions-media
|
||||
GET api/inventions/v1/delete?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/details?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/dormskinsfromids inventions-media
|
||||
GET api/inventions/v1/featured inventions-media
|
||||
GET api/inventions/v1/featureddormskins inventions-media
|
||||
GET api/inventions/v1/fromcreators inventions-media
|
||||
GET api/inventions/v1/fulllineageowner inventions-media
|
||||
GET api/inventions/v1/personaldetails/{id} inventions-media
|
||||
POST api/inventions/v1/report inventions-media
|
||||
GET api/inventions/v1/room?id={roomId} inventions-media
|
||||
POST api/inventions/v1/settags inventions-media
|
||||
GET api/inventions/v1/tagfilters inventions-media
|
||||
GET api/inventions/v1/toptoday inventions-media
|
||||
GET api/inventions/v1/unpublish?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/update inventions-media
|
||||
POST api/inventions/v1/updateprice inventions-media
|
||||
GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
|
||||
GET api/inventions/v1/versions?inventionId={id} inventions-media
|
||||
GET api/inventions/v1?inventionId={id} inventions-media
|
||||
GET api/inventions/v2/batch inventions-media
|
||||
GET api/inventions/v2/mine inventions-media
|
||||
GET api/inventions/v2/search?value&skip&take inventions-media
|
||||
GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
|
||||
POST api/inventions/v4/addversion inventions-media
|
||||
POST api/inventions/v6/save inventions-media
|
||||
POST api/keepsakes events-playlists
|
||||
GET api/keepsakes/categories events-playlists
|
||||
GET api/keepsakes/events/{progressionEventId} events-playlists
|
||||
GET api/keepsakes/globalconfig events-playlists
|
||||
GET api/keepsakes/rooms/{roomId} events-playlists
|
||||
DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
|
||||
POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
|
||||
GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
|
||||
POST api/messages/v1/sendMultiple relationships-messaging
|
||||
GET api/messages/v2/get relationships-messaging
|
||||
POST api/messages/v2/send relationships-messaging
|
||||
POST api/messages/v3/delete relationships-messaging
|
||||
POST api/objectives/v1/cleargroup progression-misc
|
||||
POST api/objectives/v1/completegroup progression-misc
|
||||
GET api/objectives/v1/myprogress progression-misc
|
||||
POST api/objectives/v1/updateobjective progression-misc
|
||||
POST api/offlineinvite/v1/send relationships-messaging
|
||||
GET api/playerevents/v1 events-playlists
|
||||
GET api/playerevents/v1/all events-playlists
|
||||
GET api/playerevents/v1/all/{0} events-playlists
|
||||
POST api/playerevents/v1/broadcast events-playlists
|
||||
POST api/playerevents/v1/bulk events-playlists
|
||||
POST api/playerevents/v1/bulkInvite events-playlists
|
||||
GET api/playerevents/v1/club/{0} events-playlists
|
||||
GET api/playerevents/v1/clubs events-playlists
|
||||
POST api/playerevents/v1/deleteResponse events-playlists
|
||||
POST api/playerevents/v1/report events-playlists
|
||||
POST api/playerevents/v1/respond events-playlists
|
||||
GET api/playerevents/v1/room/{0} events-playlists
|
||||
GET api/playerevents/v1/search events-playlists
|
||||
GET api/playerevents/v1/searchlive events-playlists
|
||||
GET api/playerevents/v1/tagfilters events-playlists
|
||||
GET api/playerevents/v1/{0} events-playlists
|
||||
GET api/playerevents/v1/{0}/responses events-playlists
|
||||
POST api/playerevents/v2 events-playlists
|
||||
POST api/playerevents/v2/delete/{0} events-playlists
|
||||
POST api/playerevents/v2/{0} events-playlists
|
||||
PUT api/playerevents/v2/{0}/accessibility events-playlists
|
||||
PUT api/playerevents/v2/{0}/club events-playlists
|
||||
PUT api/playerevents/v2/{0}/description events-playlists
|
||||
PUT api/playerevents/v2/{0}/image events-playlists
|
||||
PUT api/playerevents/v2/{0}/multiinstance events-playlists
|
||||
PUT api/playerevents/v2/{0}/name events-playlists
|
||||
PUT api/playerevents/v2/{0}/room events-playlists
|
||||
PUT api/playerevents/v2/{0}/tags events-playlists
|
||||
PUT api/playerevents/v2/{0}/time events-playlists
|
||||
PUT api/players/v4/current/contact identity-account
|
||||
POST api/playerwarnings moderation-safety
|
||||
POST api/playerwarnings/acknowledge moderation-safety
|
||||
POST api/playstationplus/expire commerce-subscription
|
||||
GET api/progressionEvents/active events-playlists
|
||||
POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
|
||||
GET api/progressionEvents/event/{eventId} events-playlists
|
||||
GET api/progressionEvents/record/{progressionEventId} events-playlists
|
||||
GET api/progressionEvents/{eventId}/xpboosts events-playlists
|
||||
GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
|
||||
GET api/quickPlay/v1/getandclear progression-misc
|
||||
GET api/relationships/mutualfriends relationships-messaging
|
||||
POST api/relationships/sendfriendintroductions relationships-messaging
|
||||
GET api/relationships/v1/addfriendwithcode relationships-messaging
|
||||
POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
|
||||
GET api/relationships/v1/favorite relationships-messaging
|
||||
POST api/relationships/v1/ignore moderation-safety
|
||||
POST api/relationships/v1/mute moderation-safety
|
||||
GET api/relationships/v1/unfavorite relationships-messaging
|
||||
POST api/relationships/v1/unignore moderation-safety
|
||||
POST api/relationships/v1/unmute moderation-safety
|
||||
GET api/relationships/v2/acceptfriendrequest relationships-messaging
|
||||
GET api/relationships/v2/addfriend relationships-messaging
|
||||
GET api/relationships/v2/get relationships-messaging
|
||||
GET api/relationships/v2/removefriend relationships-messaging
|
||||
GET api/relationships/v2/sendfriendrequest relationships-messaging
|
||||
POST api/roomCurrencies/v2/purchase room-economy
|
||||
POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
|
||||
PUT api/roomconsumables/v1/roomConsumable room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
|
||||
DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
|
||||
PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
|
||||
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
|
||||
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
|
||||
POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
|
||||
POST api/roomcurrencies/v1/createCurrency room-economy
|
||||
POST api/roomcurrencies/v1/createPurchaseOffer room-economy
|
||||
GET api/roomcurrencies/v1/currencies room-economy
|
||||
POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
|
||||
GET api/roomcurrencies/v1/getAllBalances room-economy
|
||||
GET api/roomcurrencies/v1/getBalance room-economy
|
||||
GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
|
||||
POST api/roomcurrencies/v1/updateCurrency room-economy
|
||||
POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
|
||||
POST api/roomkeys/v1/create room-economy
|
||||
DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
|
||||
GET api/roomkeys/v1/mine room-economy
|
||||
GET api/roomkeys/v1/owns room-economy
|
||||
POST api/roomkeys/v1/owns/bulk room-economy
|
||||
GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
|
||||
GET api/roomkeys/v1/room?roomId={id} room-economy
|
||||
GET api/rooms/v1/filters rooms
|
||||
POST api/rooms/v1/verifyRole rooms
|
||||
POST api/rooms/v2/report rooms
|
||||
GET api/royale/v1/current progression-misc
|
||||
POST api/royale/v2/matchcomplete progression-misc
|
||||
POST api/sanitize/v1 identity-account
|
||||
POST api/sanitize/v1/isPure identity-account
|
||||
POST api/screensharereports/v1/report moderation-safety
|
||||
POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
|
||||
GET api/storefronts/v1/adcarouselitems commerce-subscription
|
||||
GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
|
||||
POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
|
||||
POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
|
||||
POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
|
||||
GET api/storefronts/v1/buyRoomKey commerce-subscription
|
||||
POST api/storefronts/v1/objectives commerce-subscription
|
||||
GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
|
||||
GET api/storefronts/v1/toptoday commerce-subscription
|
||||
POST api/storefronts/v1/trialInvention commerce-subscription
|
||||
GET api/storefronts/v1/trialInvention/duration commerce-subscription
|
||||
POST api/storefronts/v2/balance commerce-subscription
|
||||
POST api/storefronts/v2/buyElite commerce-subscription
|
||||
GET api/storefronts/v2/buyInvention commerce-subscription
|
||||
POST api/storefronts/v2/buyItem commerce-subscription
|
||||
POST api/storefronts/v2/buyTier commerce-subscription
|
||||
GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
|
||||
GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
|
||||
GET api/subscriptionseasons/v1/seasons/current progression-misc
|
||||
GET api/testcasemanagement/v1/testcase/{id} config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
|
||||
GET api/testcasemanagement/v1/testpass/{id} config-telemetry
|
||||
GET api/testcasemanagement/v1/testpasssummary config-telemetry
|
||||
POST api/ugcPurchasables/v1/items/bulk avatar-inventory
|
||||
GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
|
||||
GET api/versioncheck/v4 identity-account
|
||||
DELETE cachedlogin/current identity-account
|
||||
GET cachedlogin/forplatformid/{0}/{1} identity-account
|
||||
POST cachedlogin/forplatformids identity-account
|
||||
POST cachedlogin/migrate identity-account
|
||||
GET club/account/{0}/created social-clubs-chat
|
||||
GET club/categoryTags social-clubs-chat
|
||||
POST club/create social-clubs-chat
|
||||
DELETE club/home/me social-clubs-chat
|
||||
GET club/home/me social-clubs-chat
|
||||
PUT club/home/me social-clubs-chat
|
||||
GET club/mine/created social-clubs-chat
|
||||
GET club/mine/member social-clubs-chat
|
||||
GET club/mostactivetoday social-clubs-chat
|
||||
GET club/search social-clubs-chat
|
||||
DELETE club/{0} social-clubs-chat
|
||||
GET club/{0} social-clubs-chat
|
||||
DELETE club/{0}/additionalimage/{1} social-clubs-chat
|
||||
PUT club/{0}/additionalimage/{1} social-clubs-chat
|
||||
PUT club/{0}/clubChatEnabled social-clubs-chat
|
||||
DELETE club/{0}/clubhouse social-clubs-chat
|
||||
PUT club/{0}/clubhouse social-clubs-chat
|
||||
GET club/{0}/details social-clubs-chat
|
||||
GET club/{0}/hasDisabledClubChat social-clubs-chat
|
||||
DELETE club/{0}/mainimage social-clubs-chat
|
||||
PUT club/{0}/mainimage social-clubs-chat
|
||||
GET club/{0}/members social-clubs-chat
|
||||
POST club/{0}/members/acceptinvite social-clubs-chat
|
||||
POST club/{0}/members/acceptrequest social-clubs-chat
|
||||
POST club/{0}/members/acceptrequests social-clubs-chat
|
||||
POST club/{0}/members/ban social-clubs-chat
|
||||
GET club/{0}/members/banned social-clubs-chat
|
||||
PUT club/{0}/members/changetype social-clubs-chat
|
||||
POST club/{0}/members/declineinvite social-clubs-chat
|
||||
POST club/{0}/members/denyrequest social-clubs-chat
|
||||
POST club/{0}/members/denyrequests social-clubs-chat
|
||||
POST club/{0}/members/directJoin social-clubs-chat
|
||||
PUT club/{0}/members/invite social-clubs-chat
|
||||
POST club/{0}/members/invitemembers social-clubs-chat
|
||||
POST club/{0}/members/leave social-clubs-chat
|
||||
POST club/{0}/members/remove social-clubs-chat
|
||||
GET club/{0}/members/requests social-clubs-chat
|
||||
GET club/{0}/members/requests/search social-clubs-chat
|
||||
PUT club/{0}/members/requesttojoin social-clubs-chat
|
||||
GET club/{0}/members/search social-clubs-chat
|
||||
POST club/{0}/members/unban social-clubs-chat
|
||||
GET club/{0}/members/{1} social-clubs-chat
|
||||
PUT club/{0}/minlevel social-clubs-chat
|
||||
PUT club/{0}/modify social-clubs-chat
|
||||
PUT club/{0}/modifydetails social-clubs-chat
|
||||
PUT club/{0}/permissions/{1} social-clubs-chat
|
||||
GET clubhousesearch/mostactivenow social-clubs-chat
|
||||
POST comments/create/{0} social-clubs-chat
|
||||
DELETE comments/delete/{0} social-clubs-chat
|
||||
GET comments/get/{0} social-clubs-chat
|
||||
PUT comments/read/{0}/{1} social-clubs-chat
|
||||
GET comments/unreadcounts social-clubs-chat
|
||||
GET config/categories config-telemetry
|
||||
POST connect/deviceauthorization identity-account
|
||||
POST connect/token identity-account
|
||||
POST data/event events-playlists
|
||||
PUT datalink config-telemetry
|
||||
GET datalink/{code} config-telemetry
|
||||
GET eac/challenge identity-account
|
||||
GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
|
||||
GET econ/customAvatarItems/v1/owned avatar-inventory
|
||||
POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
|
||||
GET emojiConfig/whitelistedEmojis config-telemetry
|
||||
GET event/{0}/instances events-playlists
|
||||
GET featuredrooms/current rooms
|
||||
POST feed/query config-telemetry
|
||||
DELETE invite/{0} progression-misc
|
||||
POST leaderboard/CheckAndSetStat progression-misc
|
||||
POST leaderboard/GetNearbyScores progression-misc
|
||||
POST leaderboard/GetPlayerRank progression-misc
|
||||
POST leaderboard/GetRanks progression-misc
|
||||
POST matchmake/chatinvite/{0}/{1} matchmaking-session
|
||||
POST matchmake/club/{0} matchmaking-session
|
||||
POST matchmake/code/{0}/{1} matchmaking-session
|
||||
POST matchmake/dorm matchmaking-session
|
||||
POST matchmake/event/{0} matchmaking-session
|
||||
POST matchmake/instance/{0} matchmaking-session
|
||||
POST matchmake/invite/{0} matchmaking-session
|
||||
POST matchmake/none matchmaking-session
|
||||
POST matchmake/player/{0} matchmaking-session
|
||||
POST matchmake/room/{0} matchmaking-session
|
||||
POST matchmake/room/{0}/{1} matchmaking-session
|
||||
GET members/bulk social-clubs-chat
|
||||
GET namegen/options identity-account
|
||||
POST pageview/consume config-telemetry
|
||||
GET parentalcontrol/me identity-account
|
||||
GET platformid/{0} identity-account
|
||||
GET player/avoidjuniors matchmaking-session
|
||||
POST player/exclusivelogin matchmaking-session
|
||||
POST player/heartbeat matchmaking-session
|
||||
POST player/login matchmaking-session
|
||||
POST player/logout matchmaking-session
|
||||
POST player/notifydisconnect matchmaking-session
|
||||
PUT player/photonregionpings matchmaking-session
|
||||
PUT player/statusvisibility matchmaking-session
|
||||
PUT player/vrmovementmode matchmaking-session
|
||||
POST playlists/bulk events-playlists
|
||||
GET playlists/cheeredby/me events-playlists
|
||||
GET playlists/createdby/me events-playlists
|
||||
GET playlists/favoritedby/me events-playlists
|
||||
GET playlists/visitedby/me events-playlists
|
||||
DELETE playlists/{0} events-playlists
|
||||
GET playlists/{0} events-playlists
|
||||
PUT playlists/{0}/accessibility events-playlists
|
||||
PUT playlists/{0}/description events-playlists
|
||||
PUT playlists/{0}/image events-playlists
|
||||
GET playlists/{0}/interactionby/me events-playlists
|
||||
PUT playlists/{0}/interactionby/me/cheer events-playlists
|
||||
PUT playlists/{0}/interactionby/me/favorite events-playlists
|
||||
PUT playlists/{0}/levelvoting events-playlists
|
||||
PUT playlists/{0}/name events-playlists
|
||||
PUT playlists/{0}/restrictions events-playlists
|
||||
PUT playlists/{0}/rooms/{1} events-playlists
|
||||
PUT playlists/{0}/tags events-playlists
|
||||
PUT playlists/{0}/warning events-playlists
|
||||
POST purchase/v1/cancelpurchase commerce-subscription
|
||||
POST purchase/v1/cleanuppending commerce-subscription
|
||||
POST purchase/v1/completepurchase commerce-subscription
|
||||
GET purchase/v1/hasspentmoney commerce-subscription
|
||||
POST purchase/v1/initiatepurchase commerce-subscription
|
||||
POST purchase/v1/processpurchase commerce-subscription
|
||||
GET purchasecampaign/allcurrent/v2 commerce-subscription
|
||||
POST purchasecampaign/shown commerce-subscription
|
||||
GET reminder/currentTokenBundles/v2 commerce-subscription
|
||||
POST remote-run/push-to-studio inventions-media
|
||||
GET role/developer/{0} progression-misc
|
||||
GET role/moderator/{0} progression-misc
|
||||
GET room/{0}/instances rooms
|
||||
PUT roominstance/{0}/inprogress rooms
|
||||
PUT roominstance/{0}/markprivate rooms
|
||||
POST roominstance/{0}/reportjoinresult rooms
|
||||
PUT roominstance/{0}/roomCode rooms
|
||||
GET rooms/base rooms
|
||||
GET rooms/bulk rooms
|
||||
GET rooms/cheeredby/me rooms
|
||||
GET rooms/contestwinners rooms
|
||||
GET rooms/createdby/me rooms
|
||||
GET rooms/createdby/{0} rooms
|
||||
GET rooms/curated_playlists rooms
|
||||
GET rooms/favoritedby/me rooms
|
||||
GET rooms/fromcreators rooms
|
||||
GET rooms/hot rooms
|
||||
GET rooms/magic_door rooms
|
||||
GET rooms/moderatedby/me rooms
|
||||
GET rooms/ownedby/me rooms
|
||||
GET rooms/ownedby/{0} rooms
|
||||
GET rooms/recommendations rooms
|
||||
GET rooms/rro_ids rooms
|
||||
GET rooms/search rooms
|
||||
GET rooms/topcreators rooms
|
||||
GET rooms/visitedby/me rooms
|
||||
GET rooms/visitedby/{0} rooms
|
||||
DELETE rooms/{0} rooms
|
||||
GET rooms/{0} rooms
|
||||
PUT rooms/{0}/accessibility rooms
|
||||
PUT rooms/{0}/allow_new_users rooms
|
||||
PUT rooms/{0}/automute rooms
|
||||
GET rooms/{0}/bans rooms
|
||||
POST rooms/{0}/bans rooms
|
||||
POST rooms/{0}/bans/import rooms
|
||||
DELETE rooms/{0}/bans/{1} rooms
|
||||
POST rooms/{0}/clone rooms
|
||||
PUT rooms/{0}/cloning rooms
|
||||
PUT rooms/{0}/comments rooms
|
||||
PUT rooms/{0}/creator rooms
|
||||
PUT rooms/{0}/description rooms
|
||||
PUT rooms/{0}/image rooms
|
||||
GET rooms/{0}/interactionby/me rooms
|
||||
DELETE rooms/{0}/interactionby/me/cheer rooms
|
||||
PUT rooms/{0}/interactionby/me/cheer rooms
|
||||
DELETE rooms/{0}/interactionby/me/favorite rooms
|
||||
PUT rooms/{0}/interactionby/me/favorite rooms
|
||||
DELETE rooms/{0}/loadscreen rooms
|
||||
PUT rooms/{0}/loadscreen rooms
|
||||
PUT rooms/{0}/max_player_calculation_mode rooms
|
||||
PUT rooms/{0}/min_level rooms
|
||||
PUT rooms/{0}/modify rooms
|
||||
PUT rooms/{0}/name rooms
|
||||
GET rooms/{0}/playerdata/me rooms
|
||||
PUT rooms/{0}/playerdata/me rooms
|
||||
DELETE rooms/{0}/promo_external rooms
|
||||
PUT rooms/{0}/promo_external rooms
|
||||
DELETE rooms/{0}/promo_images rooms
|
||||
PUT rooms/{0}/promo_images rooms
|
||||
PUT rooms/{0}/restrictions rooms
|
||||
GET rooms/{0}/roles rooms
|
||||
GET rooms/{0}/roles/{1} rooms
|
||||
PUT rooms/{0}/roles/{1} rooms
|
||||
PUT rooms/{0}/roles/{1}/invite rooms
|
||||
GET rooms/{0}/similar rooms
|
||||
POST rooms/{0}/subrooms rooms
|
||||
DELETE rooms/{0}/subrooms/{1} rooms
|
||||
PUT rooms/{0}/subrooms/{1}/accessibility rooms
|
||||
POST rooms/{0}/subrooms/{1}/clone rooms
|
||||
POST rooms/{0}/subrooms/{1}/data rooms
|
||||
PUT rooms/{0}/subrooms/{1}/maxplayers rooms
|
||||
PUT rooms/{0}/subrooms/{1}/modify rooms
|
||||
POST rooms/{0}/subrooms/{1}/move rooms
|
||||
PUT rooms/{0}/subrooms/{1}/name rooms
|
||||
PUT rooms/{0}/subrooms/{1}/permissions rooms
|
||||
POST rooms/{0}/subrooms/{1}/publish_save rooms
|
||||
GET rooms/{0}/subrooms/{1}/saves rooms
|
||||
PUT rooms/{0}/tags rooms
|
||||
PUT rooms/{0}/voice_chat_encryption rooms
|
||||
PUT rooms/{0}/warning rooms
|
||||
GET settings/partyinvite config-telemetry
|
||||
GET showcase/{0} inventions-media
|
||||
GET subscription/details/{accountId:int} commerce-subscription
|
||||
GET subscription/mine/member commerce-subscription
|
||||
GET subscription/subscriberCount/{accountId:int} commerce-subscription
|
||||
GET subscription/top/creators/today commerce-subscription
|
||||
POST subscription/v1/cancel commerce-subscription
|
||||
POST subscription/{accountId:int} commerce-subscription
|
||||
GET thread/club/{0} social-clubs-chat
|
||||
PUT thread/message/{0}/moderate social-clubs-chat
|
||||
POST thread/withmembers social-clubs-chat
|
||||
GET thread/{0} social-clubs-chat
|
||||
POST thread/{0} social-clubs-chat
|
||||
PUT thread/{0}/favorite social-clubs-chat
|
||||
POST thread/{0}/leave social-clubs-chat
|
||||
POST thread/{0}/member/{1} social-clubs-chat
|
||||
GET thread/{0}/message social-clubs-chat
|
||||
POST thread/{0}/message/{1}/read social-clubs-chat
|
||||
POST thread/{0}/rename social-clubs-chat
|
||||
POST thread/{0}/snooze social-clubs-chat
|
||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||
GET voice/config moderation-safety
|
||||
GET voice/requiresModeration moderation-safety
|
||||
# Requests the plain route-literal sweep missed. Each verified in the ISIL:
|
||||
# - leading-slash literals (RecNet.Runtime): LCOCDKINDMN_NestedType_CIPKPLFLIPC.txt:218,
|
||||
# PBOGNAPEBAI_NestedType_JKNCNOHGPHK.txt:251 (GET under 100 ids, POST at 100+),
|
||||
# NHILNDLLLHH.txt:329/801, OKOOJMAOKFE.txt:561, MNANGNGKKDA_NestedType_JAMKPNKPJPD.txt:179
|
||||
# - Assembly-CSharp CDN fetch: JFDPIJFDHAD.txt:1432 (/sigs/<id>, boot file-signature check)
|
||||
# - DELETE subscription/{0}: IKMMOCKDKAF.txt LPFOPPHGDHG (verb 4)
|
||||
# - account/me/{field}: composed at runtime by OPMAPIOEIFG.OBAGJBBIFGB("account/me/" + field);
|
||||
# verbs from each caller (username/displayname/bio/birthday/emoji/personalpronouns/identityflags/bannerimage PUT=3,
|
||||
# email/phone/confirmphone POST=2).
|
||||
GET api/playerReputation/v2/bulk identity-account
|
||||
POST api/playerReputation/v2/bulk identity-account
|
||||
GET api/players/v2/progression/bulk identity-account
|
||||
POST api/players/v2/progression/bulk identity-account
|
||||
GET strings config-telemetry
|
||||
GET config/{0} config-telemetry
|
||||
GET data/{0} config-telemetry
|
||||
GET sigs/{0} config-telemetry
|
||||
DELETE subscription/{0} commerce-subscription
|
||||
PUT account/me/username identity-account
|
||||
PUT account/me/displayname identity-account
|
||||
PUT account/me/bio identity-account
|
||||
PUT account/me/birthday identity-account
|
||||
POST account/me/email identity-account
|
||||
POST account/me/phone identity-account
|
||||
PUT account/me/emoji identity-account
|
||||
PUT account/me/personalpronouns identity-account
|
||||
PUT account/me/identityflags identity-account
|
||||
PUT account/me/bannerimage identity-account
|
||||
POST account/me/confirmphone identity-account
|
||||
# Routes composed from a base literal plus a format/segment (BPAIBFBKKCN.txt:51-53, HPEBLAHNBLC.txt:38-40,
|
||||
# and the LPPEFJMCJFN URL builder in LINKIECPDML.txt:677-737).
|
||||
GET api/itemWishlists/v1/wishlist/me avatar-inventory
|
||||
GET api/itemWishlists/v1/wishlist/{0} avatar-inventory
|
||||
PUT api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
DELETE api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
GET api/roomEarningsDistributions/v1/earningsDistribution/{0} progression-misc
|
||||
GET api/incentivizedreferrals/referrals progression-misc
|
||||
# Contact lookup (OPMAPIOEIFG.txt:11590-11620) and composed paths (FFFIMAGLKEG.txt:137, NOGJJIIENIL.txt:29).
|
||||
POST account/bulk/email identity-account
|
||||
POST account/bulk/phone identity-account
|
||||
POST api/avatar/v2/gifts/consume avatar-inventory
|
||||
GET video/{0} config-telemetry
|
||||
|
Can't render this file because it contains an unexpected character in line 532 and column 73.
|
@@ -0,0 +1,142 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace DorkNet.Server.Binding;
|
||||
|
||||
/// <summary>
|
||||
/// Binds an action's request DTO from either a form-urlencoded body or a JSON
|
||||
/// body, whichever the caller actually sent.
|
||||
///
|
||||
/// The Rec Room client's HTTP layer (<c>BNDIAONDFFF</c> in RecNet.Runtime)
|
||||
/// sends nearly every write as <c>application/x-www-form-urlencoded</c>, adding
|
||||
/// fields one at a time. Handlers written against <c>[FromBody]</c> only accept
|
||||
/// JSON, so with <c>[ApiController]</c> those requests were rejected with 415
|
||||
/// Unsupported Media Type before the action body ever ran — this is why every
|
||||
/// club member-management action (invite, ban, kick, promote, approve, modify)
|
||||
/// failed on the 2023 client.
|
||||
///
|
||||
/// Rather than hand-rolling a form reader per handler, apply this binder to the
|
||||
/// DTO parameter:
|
||||
///
|
||||
/// <code>
|
||||
/// public Task<IActionResult> MemberBan(
|
||||
/// long clubId,
|
||||
/// [ModelBinder(typeof(FormOrJsonModelBinder))] MemberTargetRequest req)
|
||||
/// </code>
|
||||
///
|
||||
/// The explicit <c>[ModelBinder]</c> is required: without it <c>[ApiController]</c>
|
||||
/// infers <c>[FromBody]</c> for complex types and this binder never runs.
|
||||
///
|
||||
/// Matching is case-insensitive on both paths, and a scalar property also
|
||||
/// accepts the singular/plural spellings the client uses interchangeably. A
|
||||
/// missing or unparseable body yields a default-constructed DTO rather than a
|
||||
/// 400, matching how the client treats these calls as fire-and-forget.
|
||||
/// </summary>
|
||||
public sealed class FormOrJsonModelBinder : IModelBinder
|
||||
{
|
||||
public async Task BindModelAsync(ModelBindingContext bindingContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindingContext);
|
||||
|
||||
var type = bindingContext.ModelType;
|
||||
var request = bindingContext.HttpContext.Request;
|
||||
var model = Activator.CreateInstance(type)!;
|
||||
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync(bindingContext.HttpContext.RequestAborted);
|
||||
foreach (var prop in Writable(type))
|
||||
{
|
||||
var values = Lookup(form, prop.Name);
|
||||
if (values.Count == 0) continue;
|
||||
var converted = Convert(values, prop.PropertyType);
|
||||
if (converted is not null) prop.SetValue(model, converted);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
request.EnableBuffering();
|
||||
request.Body.Position = 0;
|
||||
var parsed = await JsonSerializer.DeserializeAsync(
|
||||
request.Body, type,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
|
||||
bindingContext.HttpContext.RequestAborted);
|
||||
request.Body.Position = 0;
|
||||
if (parsed is not null) model = parsed;
|
||||
}
|
||||
catch (JsonException) { /* empty or non-JSON body → defaults */ }
|
||||
catch (NotSupportedException) { /* unreadable body → defaults */ }
|
||||
}
|
||||
|
||||
bindingContext.Result = ModelBindingResult.Success(model);
|
||||
}
|
||||
|
||||
private static IEnumerable<PropertyInfo> Writable(Type type) =>
|
||||
type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanWrite);
|
||||
|
||||
/// <summary>Form keys, case-insensitively, also accepting the other
|
||||
/// number of the name — the client says <c>accountId</c> where the DTO says
|
||||
/// <c>AccountIds</c> and vice versa depending on the endpoint.</summary>
|
||||
private static List<string> Lookup(IFormCollection form, string propertyName)
|
||||
{
|
||||
var candidates = new List<string> { propertyName };
|
||||
if (propertyName.EndsWith('s')) candidates.Add(propertyName[..^1]);
|
||||
else candidates.Add(propertyName + "s");
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
foreach (var key in form.Keys)
|
||||
{
|
||||
if (!string.Equals(key, candidate, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var values = form[key]
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v!)
|
||||
.ToList();
|
||||
if (values.Count > 0) return values;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static object? Convert(List<string> values, Type target)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(target) ?? target;
|
||||
|
||||
if (underlying.IsGenericType && underlying.GetGenericTypeDefinition() == typeof(List<>))
|
||||
{
|
||||
var element = underlying.GetGenericArguments()[0];
|
||||
var list = (System.Collections.IList)Activator.CreateInstance(underlying)!;
|
||||
// A repeated field arrives as multiple values; a single field may
|
||||
// still carry a comma-separated list.
|
||||
foreach (var raw in values.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)))
|
||||
{
|
||||
var item = ConvertScalar(raw, element);
|
||||
if (item is not null) list.Add(item);
|
||||
}
|
||||
return list.Count > 0 ? list : null;
|
||||
}
|
||||
|
||||
return ConvertScalar(values[0], underlying);
|
||||
}
|
||||
|
||||
private static object? ConvertScalar(string raw, Type target)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (target == typeof(string)) return raw;
|
||||
if (target == typeof(bool)) return bool.TryParse(raw, out var b) ? b : raw == "1";
|
||||
if (target.IsEnum) return Enum.TryParse(target, raw, ignoreCase: true, out var e) ? e : null;
|
||||
if (target == typeof(Guid)) return Guid.TryParse(raw, out var g) ? g : null;
|
||||
if (target == typeof(DateTime)) return DateTime.TryParse(raw, out var d) ? d : null;
|
||||
return System.Convert.ChangeType(raw, target, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,17 +57,7 @@ public class AccountApiController(
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
},
|
||||
Account = AccountDtos.Public(player),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,22 +67,7 @@ public class AccountApiController(
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new RecNetSelfAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
});
|
||||
return Ok(AccountDtos.Self(player));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -106,9 +81,11 @@ public class AccountApiController(
|
||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Activities;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/activities/* — per-activity content
|
||||
/// (charades words, party-game prompts, etc.). The 2020 watch
|
||||
/// fetches these as a static list at room-join time; we ship an
|
||||
/// empty list so the activity's bundled defaults kick in.
|
||||
/// (charades words, party-game prompts, etc.).
|
||||
///
|
||||
/// The March 2023 watch fetches a 3D Charades deck at card-box spawn from
|
||||
/// <c>api/activities/charades/v1/words/{source}</c>, where <c>{source}</c>
|
||||
/// is one of three baked <c>CardBox.cardSource</c> slots — <c>Charades</c>,
|
||||
/// <c>CharadesAprilFoolsDay</c>, or <c>Icebreakers</c> (verified in the
|
||||
/// 2023.03.21 il2cpp dump). We resolve the admin-bound word list for the
|
||||
/// requested slot via <see cref="CharadesWordListService"/> and return the
|
||||
/// wire array of <c>{ EN_US, Difficulty }</c>. The older December route
|
||||
/// (no <c>{source}</c>) maps to the default Charades slot.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ActivitiesController : ControllerBase
|
||||
public class ActivitiesController(CharadesWordListService charades) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/activities/charades/v1/words/{source}")]
|
||||
public async Task<IActionResult> CharadesWords(string source)
|
||||
=> Ok(await charades.ResolveWireWordsAsync(source));
|
||||
|
||||
/// <summary>December 2020 shape — no source segment. Serves the default
|
||||
/// Charades slot so the older client still gets a populated deck.</summary>
|
||||
[HttpGet("api/activities/charades/v1/words")]
|
||||
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
|
||||
public async Task<IActionResult> CharadesWordsDefault()
|
||||
=> Ok(await charades.ResolveWireWordsAsync(
|
||||
nameof(CharadesWordListService.CharadesSlot.Charades)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AgeVerification;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ageverification")]
|
||||
[Authorize]
|
||||
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>The 2023-03-21 client POSTs this (verb register <c>rdx=2</c>
|
||||
/// at RecNet.Runtime/ALLHCFFLMLD.txt:216-222) and deserialises the body as
|
||||
/// a bare <c>String</c>: the issuing method is
|
||||
/// <c>FGLDKEJLAKB<IPCJLCNIBEG<System.String>> GKEBPOPAEJC()</c>
|
||||
/// (:150). Returning a JSON object here made Json.NET throw, so the
|
||||
/// promise rejected and the code was never shown in the watch.
|
||||
/// GET stays registered because it costs nothing.</summary>
|
||||
[HttpPost("generateCode")]
|
||||
[HttpGet("generateCode")]
|
||||
public async Task<IActionResult> GenerateCode()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Content(JsonSerializer.Serialize(code), "application/json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AppIntegrity;
|
||||
|
||||
[ApiController]
|
||||
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/AppIntegrity/v1/iosproducts")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> IosProducts()
|
||||
{
|
||||
var rows = await db.StoreItems
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Id)
|
||||
.Take(100)
|
||||
.Select(i => new
|
||||
{
|
||||
ProductId = i.Slug,
|
||||
i.Price,
|
||||
i.CurrencyType,
|
||||
Name = i.DisplayName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
|
||||
{
|
||||
db.BugReports.Add(new BugReportEntity
|
||||
{
|
||||
ReporterPlayerId = this.RequireCurrentPlayerId(),
|
||||
Title = "iOS payment queue failed",
|
||||
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
|
||||
Category = "appintegrity",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Apple;
|
||||
|
||||
[ApiController]
|
||||
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/apple/musicpromotion/active")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active()
|
||||
{
|
||||
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = start.AddYears(1);
|
||||
return Ok(new { Active = true, StartAt = start, EndAt = end });
|
||||
}
|
||||
|
||||
[HttpPost("api/apple/musicpromotion/code")]
|
||||
[HttpGet("api/apple/musicpromotion/code")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Code()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
|
||||
if (row is null)
|
||||
{
|
||||
row = new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "applemusic:promotion_code",
|
||||
Value = $"RR-{Guid.NewGuid():N}"[..16],
|
||||
};
|
||||
db.PlayerSettings.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { Code = row.Value, Redeemed = false });
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,10 @@ namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
public class AvatarGiftsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level) : ControllerBase
|
||||
LevelService level,
|
||||
StoreService store,
|
||||
QuestRewardService questRewards,
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2/gifts")]
|
||||
[HttpGet("api/avatar/v3/gifts")]
|
||||
@@ -47,26 +50,62 @@ public class AvatarGiftsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record GenerateGiftRequest(int? GiftContext);
|
||||
/// <summary>Body keys are the client's AFGEDDANEKP param literals
|
||||
/// (GiftContext / IsGameGift / AlternateGiftContext / Message). A class
|
||||
/// with settable properties, not a positional record, because
|
||||
/// <see cref="Binding.FormOrJsonModelBinder"/> constructs it empty and
|
||||
/// fills it.</summary>
|
||||
public sealed class GenerateGiftRequest
|
||||
{
|
||||
public int? GiftContext { get; set; }
|
||||
public bool? IsGameGift { get; set; }
|
||||
public int? AlternateGiftContext { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
|
||||
/// a random unlocked-avatar item and queues a gift package. The
|
||||
/// 2020 client posts to this when the user clicks a free-gift
|
||||
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
|
||||
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
|
||||
/// server first tries the quest's configured item pool, keyed by the
|
||||
/// RRO room the player is in (the request carries no quest id). If
|
||||
/// that room has no pool — or for non-game free-gift tiles — it falls
|
||||
/// back to a random wardrobe item. The rolled item is stamped as a
|
||||
/// gift package; opening the box (<c>gifts/consume</c>) grants it.</summary>
|
||||
// The 2023 client posts the gift-chest generate to v3 (GET gifts is
|
||||
// already fanned across v2/v3/v4, but generate only routed v2 — the v3
|
||||
// POST 404'd, breaking reward chests on that path). Same request body.
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
[HttpPost("api/avatar/v3/gifts/generate")]
|
||||
// The client adds each field with AFGEDDANEKP (FFFIMAGLKEG.txt:1930-2013,
|
||||
// POST = ordinal 2), which goes out form-urlencoded: a live trace showed
|
||||
// `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while this
|
||||
// was [FromBody]. The form-or-JSON binder accepts either encoding.
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Generate(
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
// Pick a random wardrobe StoreItem (Slug starts with
|
||||
// "wardrobe-" — that's the convention StoreService uses for
|
||||
// catalogue rows derived from outfits_assets bundles).
|
||||
var isGameGift = req?.IsGameGift ?? false;
|
||||
var rng = new Random();
|
||||
|
||||
// Quest chest: pick from this room's configured reward pool.
|
||||
if (isGameGift && presence.GetRoom(pid) is { RoomId: > 0 } room
|
||||
&& await questRewards.PickForRoomAsync(room.RoomId, rng.Next()) is { } questItem)
|
||||
{
|
||||
var gift = BuildGiftFromItem(pid, questItem, req?.GiftContext ?? 0, rng);
|
||||
gift.SourceStoreItemId = questItem.Id;
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.NotifyAsync(pid, PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
// Fallback: random wardrobe StoreItem (Slug "wardrobe-{guid}").
|
||||
var pool = await db.StoreItems
|
||||
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
|
||||
.Select(s => new { s.Slug, s.DisplayName })
|
||||
.ToListAsync();
|
||||
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
|
||||
|
||||
var rng = new Random();
|
||||
var pick = pool[rng.Next(pool.Count)];
|
||||
var guid = pick.Slug["wardrobe-".Length..];
|
||||
|
||||
@@ -74,7 +113,7 @@ public class AvatarGiftsController(
|
||||
// so the watch's parse-and-iterate doesn't blow up in
|
||||
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
|
||||
// for the canonical wire-format note.
|
||||
var gift = new GiftPackageEntity
|
||||
var randomGift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
@@ -90,15 +129,57 @@ public class AvatarGiftsController(
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
db.GiftPackages.Add(randomGift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push so the watch shows the new-gift toast.
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
PushNotificationId.GiftPackageReceived, ToWire(randomGift));
|
||||
return Ok(ToWire(randomGift));
|
||||
}
|
||||
|
||||
/// <summary>Build a gift package for a specific store item, filling
|
||||
/// the wire payload that matches its kind (avatar item vs equipment
|
||||
/// vs consumable) so <c>gifts/consume</c> grants the right thing.</summary>
|
||||
private static GiftPackageEntity BuildGiftFromItem(long pid, GiftPackageItem item, int giftContext, Random rng)
|
||||
{
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
CurrencyType = 0,
|
||||
GiftContext = giftContext,
|
||||
GiftRarity = RollRarity(rng),
|
||||
Message = $"You earned {item.DisplayName}!",
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
|
||||
if (StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc))
|
||||
{
|
||||
gift.AvatarItemType = avatarItemType;
|
||||
gift.AvatarItemDescOrHairDyeDesc = avatarItemDesc;
|
||||
}
|
||||
else if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var modGuid, out _))
|
||||
{
|
||||
gift.EquipmentPrefabName = prefab;
|
||||
gift.EquipmentModificationGuid = modGuid;
|
||||
}
|
||||
else if (StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc))
|
||||
{
|
||||
gift.ConsumableItemDesc = consumableDesc;
|
||||
}
|
||||
return gift;
|
||||
}
|
||||
|
||||
private static GiftPackageEntity BuildGiftFromItem(long pid, StoreItemEntity item, int giftContext, Random rng) =>
|
||||
BuildGiftFromItem(pid, new GiftPackageItem(item.Slug, item.DisplayName), giftContext, rng);
|
||||
|
||||
private readonly record struct GiftPackageItem(string Slug, string DisplayName);
|
||||
|
||||
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
|
||||
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
|
||||
|
||||
@@ -168,11 +249,30 @@ public class AvatarGiftsController(
|
||||
if (gift.RecipientPlayerId != pid) return Forbid();
|
||||
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
|
||||
|
||||
// Quest-reward chest: grant the exact backing store item (handles
|
||||
// avatar / equipment / consumable uniformly, idempotently). The
|
||||
// per-kind wire-derivation below is skipped for these — the wire
|
||||
// fields are still populated for the client to render the box.
|
||||
if (gift.SourceStoreItemId > 0)
|
||||
{
|
||||
await store.GrantItemAsync(pid, gift.SourceStoreItemId);
|
||||
gift.Consumed = true;
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
// Append avatar item to player's inventory list (JSON list of
|
||||
// GUID strings). Defensive parse — corrupted JSON resets to
|
||||
// empty list rather than 500.
|
||||
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
// Inventory stores the BARE guid ("guid"), but a gift now carries
|
||||
// the 4-part wire desc ("guid,,,") so its post-consume unlock
|
||||
// matches api/avatar/v4/items. Normalise to the inventory form
|
||||
// here or we'd store "guid,,," alongside the bare "guid" a direct
|
||||
// purchase already added → a duplicate wardrobe row.
|
||||
var invDesc = StoreService.InventoryAvatarItemDesc(gift.AvatarItemDescOrHairDyeDesc);
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
@@ -183,17 +283,16 @@ public class AvatarGiftsController(
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
|
||||
if (!list.Contains(invDesc))
|
||||
{
|
||||
list.Add(gift.AvatarItemDescOrHairDyeDesc);
|
||||
list.Add(invDesc);
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new[] { gift.AvatarItemDescOrHairDyeDesc });
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { invDesc });
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -253,17 +352,13 @@ public class AvatarGiftsController(
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Profile-update push so the watch's wallet readout refreshes
|
||||
// without waiting for the next /balance poll. Mirrors what
|
||||
// /api/admin/v1/players/{id}/gift does after the inbox push so
|
||||
// the toast number animates up the moment the box closes.
|
||||
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
|
||||
{
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
|
||||
}
|
||||
|
||||
// The wallet readout refreshes off the StorefrontBalanceUpdate that
|
||||
// GrantCurrencyAsync (above) already pushes for currency gifts. Do
|
||||
// NOT also push SubscriptionUpdateProfile ("AccountUpdate") with a
|
||||
// {Reason,Currency,…} blob: the 2023 client deserialises that channel
|
||||
// as its Account DTO, so a non-account payload decodes to a blank
|
||||
// account (accountId 0) → "Orphan player account (0) found" spam
|
||||
// (audited 2026-07-12).
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
@@ -288,14 +383,35 @@ public class AvatarGiftsController(
|
||||
// GiftRarity, GiftContext. Missing any one → KeyNotFoundException
|
||||
// surfaces as "Failed to download gifts: Malformed Response" from
|
||||
// BootSequence and BLOCKS LOGIN.
|
||||
public static object ToWire(GiftPackageEntity g) => new
|
||||
/// <summary>The desc the client matches against api/avatar/v4/items in
|
||||
/// OutfitManager.UnlockAvatarItemAndMarkNew after the gift box is
|
||||
/// consumed. That endpoint emits outfit items as the 4-part "{guid},,,"
|
||||
/// form (a bare guid crashes the client's AvatarItem parser), and the
|
||||
/// match is an EXACT string compare — so an outfit gift whose desc has
|
||||
/// fewer than 4 comma-parts (legacy rows stored the bare guid) makes
|
||||
/// Find() miss and NRE, which strips the item on consume. Pad outfit
|
||||
/// descs to 4 parts here so BOTH new and already-persisted gifts match.
|
||||
/// Hair dyes (AvatarItemType 1) match by bare colour guid — leave them;
|
||||
/// currency-only gifts have an empty desc.</summary>
|
||||
private static string NormalizeAvatarWireDesc(GiftPackageEntity g)
|
||||
{
|
||||
var d = g.AvatarItemDescOrHairDyeDesc ?? string.Empty;
|
||||
if (d.Length == 0 || g.AvatarItemType == 1) return d;
|
||||
var parts = d.Split(',').Length;
|
||||
return parts >= 4 ? d : d + new string(',', 4 - parts);
|
||||
}
|
||||
|
||||
public static object ToWire(GiftPackageEntity g)
|
||||
{
|
||||
var avatarDesc = NormalizeAvatarWireDesc(g);
|
||||
return new
|
||||
{
|
||||
g.Id,
|
||||
g.FromPlayerId,
|
||||
g.ConsumableItemDesc,
|
||||
g.AvatarItemType,
|
||||
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
|
||||
g.AvatarItemDescOrHairDyeDesc,
|
||||
AvatarItemDesc = avatarDesc,
|
||||
AvatarItemDescOrHairDyeDesc = avatarDesc,
|
||||
g.EquipmentPrefabName,
|
||||
g.EquipmentModificationGuid,
|
||||
g.CurrencyType,
|
||||
@@ -330,5 +446,6 @@ public class AvatarGiftsController(
|
||||
g.ErrorMessage,
|
||||
g.SupportsCurrentPlatform,
|
||||
g.IsGifted,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
string? hairColor = null;
|
||||
string? skinColor = null;
|
||||
string? faceFeatures = null;
|
||||
string? outfitSelectionsV2 = null;
|
||||
string? customAvatarItems = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
@@ -91,6 +93,11 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
hairColor = ReadStringField(root, "HairColor");
|
||||
skinColor = ReadStringField(root, "SkinColor");
|
||||
faceFeatures = ReadStringField(root, "FaceFeatures");
|
||||
// 2023 client (HHDLNAPEMGP writer EKHJKDNHOPB) also
|
||||
// uploads OutfitSelectionsV2 + CustomAvatarItems —
|
||||
// persist them so remote-player fetches round-trip.
|
||||
outfitSelectionsV2 = ReadStringField(root, "OutfitSelectionsV2");
|
||||
customAvatarItems = ReadStringField(root, "CustomAvatarItems");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
@@ -116,6 +123,16 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
if (hairColor is not null) a.HairColor = hairColor;
|
||||
if (skinColor is not null) a.SkinColor = skinColor;
|
||||
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
|
||||
if (outfitSelectionsV2 is not null) a.OutfitSelectionsV2 = outfitSelectionsV2;
|
||||
if (customAvatarItems is not null)
|
||||
{
|
||||
// CustomAvatarItems arrives as a JSON ARRAY of
|
||||
// {"CustomAvatarItemId":guid,"BodyPart":byte}; ReadStringField
|
||||
// re-encodes arrays as raw JSON. Guard against non-array junk.
|
||||
a.CustomAvatarItemsJson = customAvatarItems.TrimStart().StartsWith('[')
|
||||
? customAvatarItems
|
||||
: "[]";
|
||||
}
|
||||
a.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(a));
|
||||
@@ -140,6 +157,25 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/avatar/v2/{accountId}</c> — the 2023 client's
|
||||
/// remote-player avatar settings fetch. Decompile evidence
|
||||
/// (2023.03.21 ISIL, RecNet.Runtime FFFIMAGLKEG.txt:4845-5006,
|
||||
/// method KAFJNMDBKDB): URL is String.Format("{0}v2/{1}",
|
||||
/// "api/avatar/", accountId) — the account id is a bare path segment
|
||||
/// directly under v2 (NOT under /equipped/). The response is parsed
|
||||
/// as object HHDLNAPEMGP whose reader (EKHJKDNHOPB.txt:471-766)
|
||||
/// registers OutfitSelections / OutfitSelectionsV2 / FaceFeatures /
|
||||
/// SkinColor / HairColor / CustomAvatarItems. Without this route the
|
||||
/// request 404'd with an empty body → "Failed to load Rec Room
|
||||
/// remote player avatar settings" (Player.log:2597).</summary>
|
||||
[HttpGet("api/avatar/v2/{playerId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetSettingsFor(long playerId)
|
||||
{
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Public lookup of another player's equipped avatar — the
|
||||
/// client uses this to render remote players' avatars in shared
|
||||
/// rooms. No auth requirement on read so anonymous calls in the
|
||||
@@ -174,7 +210,27 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
FaceFeatures = a?.FaceFeatures ?? string.Empty,
|
||||
HairColor = a?.HairColor ?? string.Empty,
|
||||
SkinColor = a?.SkinColor ?? string.Empty,
|
||||
OutfitSelectionsV2 = a?.OutfitSelectionsV2 ?? string.Empty,
|
||||
CustomAvatarItems = ParseCustomAvatarItems(a?.CustomAvatarItemsJson),
|
||||
};
|
||||
|
||||
/// <summary>Re-hydrate the stored CustomAvatarItems JSON into typed
|
||||
/// wire items so the response always carries a well-formed array
|
||||
/// (never a raw string). Bad/legacy blobs degrade to [].</summary>
|
||||
private static List<CustomAvatarItemRefDto> ParseCustomAvatarItems(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<CustomAvatarItemRefDto>>(
|
||||
json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AvatarV2Dto
|
||||
@@ -190,4 +246,28 @@ public class AvatarV2Dto
|
||||
|
||||
[JsonPropertyName("FaceFeatures")]
|
||||
public string FaceFeatures { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>2023 client field — HHDLNAPEMGP reader key
|
||||
/// "OutfitSelectionsV2" (EKHJKDNHOPB.txt:498-514). Extra keys are
|
||||
/// skipped by the 2020 reader, so this is safe cross-version.</summary>
|
||||
[JsonPropertyName("OutfitSelectionsV2")]
|
||||
public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>2023 client field — HHDLNAPEMGP reader key
|
||||
/// "CustomAvatarItems" (EKHJKDNHOPB.txt:594-610), a list of
|
||||
/// BGNNOMBFMLH items.</summary>
|
||||
[JsonPropertyName("CustomAvatarItems")]
|
||||
public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>BGNNOMBFMLH — { Guid CustomAvatarItemId, Byte BodyPart }
|
||||
/// (property types BGNNOMBFMLH.txt:3-39; JSON keys registered by reader
|
||||
/// MKAIABAJIAJ.txt:215-258).</summary>
|
||||
public class CustomAvatarItemRefDto
|
||||
{
|
||||
[JsonPropertyName("CustomAvatarItemId")]
|
||||
public Guid CustomAvatarItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("BodyPart")]
|
||||
public byte BodyPart { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,16 +4,21 @@ using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V3;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
|
||||
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
|
||||
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
|
||||
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Client wire
|
||||
/// type BEPMJICDNDD, whose converter IMMOJHBENFI registers exactly
|
||||
/// <c>Slot, PreviewImageName, OutfitSelections, OutfitSelectionsV2,
|
||||
/// FaceFeatures, SkinColor, HairColor, CustomAvatarItems</c>
|
||||
/// (RecNet.Runtime/IMMOJHBENFI.txt:587-766 reader, :781-910 writer).
|
||||
/// CustomAvatarItems is a list of BGNNOMBFMLH
|
||||
/// <c>{CustomAvatarItemId, BodyPart}</c>, the same element the avatar
|
||||
/// settings (<c>api/avatar/v2</c>) use.
|
||||
///
|
||||
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
||||
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
||||
@@ -38,12 +43,27 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
/// Verb ordinal 2 = POST with a raw JSON body of one serialised
|
||||
/// BEPMJICDNDD (RecNet.Runtime/FFFIMAGLKEG.txt:4071-4095, NCILAADFJMM).
|
||||
///
|
||||
/// Parsed by hand rather than <c>[FromBody] SavedOutfitDto</c>: the old
|
||||
/// DTO silently dropped OutfitSelectionsV2 and CustomAvatarItems, and the
|
||||
/// 2023 client wears OutfitSelectionsV2 whenever it is non-empty
|
||||
/// (BEPMJICDNDD.NLGCNDMLACA, BEPMJICDNDD.txt:237-266, falls back to
|
||||
/// OutfitSelections only when V2 is empty). Loading a saved slot
|
||||
/// therefore restored a stale V1 selection (or nothing) and lost every
|
||||
/// custom shirt in it.</summary>
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
public async Task<IActionResult> Set()
|
||||
{
|
||||
string raw;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
raw = await reader.ReadToEndAsync();
|
||||
|
||||
var body = ParseOutfit(raw);
|
||||
if (body is null) return BadRequest("missing body");
|
||||
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
@@ -59,6 +79,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
||||
list.Add(body);
|
||||
}
|
||||
list.Sort((a, b) => a.Slot.CompareTo(b.Slot));
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
@@ -81,12 +102,91 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Read one outfit object, matching keys case-insensitively
|
||||
/// (the client reader accepts Pascal/camel/lower spellings, so any of
|
||||
/// them is a valid write). String fields tolerate null; a nested
|
||||
/// object/array is kept as its raw JSON text, mirroring how
|
||||
/// <c>avatar/v2/set</c> treats the same fields. Returns null for a body
|
||||
/// that is not a JSON object.</summary>
|
||||
internal static SavedOutfitDto? ParseOutfit(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
var dto = new SavedOutfitDto();
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "slot":
|
||||
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var slot)) dto.Slot = slot;
|
||||
else if (prop.Value.ValueKind == JsonValueKind.String && int.TryParse(prop.Value.GetString(), out slot)) dto.Slot = slot;
|
||||
break;
|
||||
case "previewimagename": dto.PreviewImageName = AsString(prop.Value); break;
|
||||
case "outfitselections": dto.OutfitSelections = AsString(prop.Value); break;
|
||||
case "outfitselectionsv2": dto.OutfitSelectionsV2 = AsString(prop.Value); break;
|
||||
case "facefeatures": dto.FaceFeatures = AsString(prop.Value); break;
|
||||
case "skincolor": dto.SkinColor = AsString(prop.Value); break;
|
||||
case "haircolor": dto.HairColor = AsString(prop.Value); break;
|
||||
case "customavataritems": dto.CustomAvatarItems = AsCustomItems(prop.Value); break;
|
||||
}
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string AsString(JsonElement v) => v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
|
||||
_ => v.GetRawText(),
|
||||
};
|
||||
|
||||
/// <summary>BGNNOMBFMLH list. Elements without a parseable guid are
|
||||
/// dropped instead of failing the whole save.</summary>
|
||||
private static List<CustomAvatarItemRefDto> AsCustomItems(JsonElement v)
|
||||
{
|
||||
var result = new List<CustomAvatarItemRefDto>();
|
||||
if (v.ValueKind != JsonValueKind.Array) return result;
|
||||
foreach (var el in v.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
Guid? id = null;
|
||||
byte bodyPart = 0;
|
||||
foreach (var p in el.EnumerateObject())
|
||||
{
|
||||
if (p.Name.Equals("CustomAvatarItemId", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Value.ValueKind == JsonValueKind.String
|
||||
&& Guid.TryParse(p.Value.GetString(), out var g))
|
||||
id = g;
|
||||
else if (p.Name.Equals("BodyPart", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Value.ValueKind == JsonValueKind.Number
|
||||
&& p.Value.TryGetByte(out var b))
|
||||
bodyPart = b;
|
||||
}
|
||||
if (id is { } guid) result.Add(new CustomAvatarItemRefDto { CustomAvatarItemId = guid, BodyPart = bodyPart });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<SavedOutfitDto> ParseList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
var list = JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
// Rows written before CustomAvatarItems existed carry no key;
|
||||
// keep the wire array non-null.
|
||||
foreach (var o in list) o.CustomAvatarItems ??= new();
|
||||
return list;
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
@@ -97,7 +197,14 @@ public sealed class SavedOutfitDto
|
||||
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
||||
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
||||
/// <summary>The selection the 2023 client actually wears when non-empty
|
||||
/// (BEPMJICDNDD.NLGCNDMLACA). Rows saved before this field existed
|
||||
/// deserialize to "" and the client falls back to OutfitSelections.</summary>
|
||||
[JsonPropertyName("OutfitSelectionsV2")] public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
||||
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
||||
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
||||
/// <summary>Custom shirts worn in this slot. Always an array on the wire
|
||||
/// (the client's ctor allocates an empty list, BEPMJICDNDD.txt:321-360).</summary>
|
||||
[JsonPropertyName("CustomAvatarItems")] public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user