Compare commits

..

3 Commits

Author SHA1 Message Date
Alexa a142625f3d Add DorkNet.Contracts reference to monolith Server build
DorkNet.Server uses DorkNetRouteOwnership from DorkNet.Contracts but the
project never referenced it and the Dockerfile never copied it, so the
monolith image failed to compile (CS0234). Add the project reference and
copy the project into the build stage.
2026-06-25 12:42:46 +03:00
Alexa 52e1d4f61c Route microservices through Traefik behind Cloudflare Tunnel
Add scoped Traefik host labels to the gateway and put cloudflared on the
Traefik network so the tunnel hands off to Traefik instead of forwarding
every host straight to the app. Keep backend services internal-only.
2026-06-25 12:04:06 +03:00
Alexa 12b1a7f1e2 Restrict gateway to configured public hosts 2026-06-23 00:27:21 +03:00
145 changed files with 5936 additions and 24901 deletions
-2
View File
@@ -86,5 +86,3 @@ Recovered(*)/
local-releases/**
DorkNet.Models/bin-verify/**
DorkNet.Models/bin-verify2/**
+6 -4
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target.
# ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:10.0 with only the published output.
# slimmer aspnet:9.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=10.0
ARG DOTNET_VERSION=9.0
ARG NODE_VERSION=20
# ── admin SPA build stage ──────────────────────────────────────────
@@ -53,12 +53,14 @@ WORKDIR /src
# DorkNet.Models project ref, so its csproj needs to be present too.
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
COPY DorkNet.Contracts/DorkNet.Contracts.csproj DorkNet.Contracts/
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
# Now copy the rest of the .NET source, then drop the prebuilt admin
# SPA into wwwroot/admin so the publish step ships it as static content.
COPY DorkNet.Server/ DorkNet.Server/
COPY DorkNet.Models/ DorkNet.Models/
COPY DorkNet.Contracts/ DorkNet.Contracts/
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
@@ -76,13 +78,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:10.0 base image strips both by
# both have to be present. The aspnet:9.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:10.0 ships with the `app` UID 1654.
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID.
USER app
+1 -1
View File
@@ -1,4 +1,4 @@
ARG DOTNET_VERSION=10.0
ARG DOTNET_VERSION=9.0
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS admin-ui
+615 -2378
View File
@@ -3,7 +3,9 @@
// server:
//
// 1. URI rewrite — `.rec.net` → user-configured host
// 2. TLS trust bypass — make BouncyCastle never reject server certs
// 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
//
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
// install-melon.ps1 wrapper writes that file on install; you can also
@@ -20,21 +22,13 @@
// 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.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)]
@@ -49,19 +43,11 @@ public class Mod : MelonMod
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered;
private bool _httpDiagnosticsRegistered;
private bool _joinTracePatchesRegistered;
private bool _quitTraceCoreRegistered;
private bool _quitTraceGameComplete;
private int _quitTraceRetryFrame;
private bool _antiTamperCallbackPatchesComplete;
private int _antiTamperCallbackRetryFrame;
private bool _toxModPatchesComplete;
private int _toxModRetryFrame;
private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete;
private int _diagnosticRetryFrame;
private bool _firstUpdateLogged;
private bool _processExceptionHandlersRegistered;
// Config backed by a plain JSON file under MelonLoader/UserData.
// The defaults below match the recommended install, so dropping this
@@ -69,6 +55,20 @@ 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
@@ -87,18 +87,6 @@ public class Mod : MelonMod
// 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>();
public static bool DiagnoseDevMenu = false;
public static bool EnableBuiltInDevDebugTools = false;
// Master switch for the verbose, non-essential DIAGNOSTIC patches
// (HTTP header tracing, join/quit traces, studio traces, the deep
// RegisterDiagnostics path). OFF by default so the shipping client
// mod installs only the patches needed to connect + survive
// anti-cheat — the safest possible footprint. Turned ON automatically
// when DorkNet.DebugMod.dll is present in the Mods folder (drop it in
// to debug, remove it to ship), or by "EnableDiagnostics": true in
// the config. Core connectivity/anti-cheat patches are NEVER gated.
public static bool EnableDiagnostics = false;
// ── Desktop Screen Sharing gadget FPS ──
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
@@ -123,6 +111,29 @@ 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
@@ -146,19 +157,46 @@ public class Mod : MelonMod
Log.Msg("=== DorkNet ClientMod loading ===");
LoadConfig();
RegisterNetworkPatches();
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
}
public override void OnLateInitializeMelon()
{
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
Log.Msg("=== Registering client patches ===");
RegisterNetworkPatches();
TryPatchByName("RecRoom.AntiCheat.EACManager",
"GenerateChallengeResponse",
args: new[] { typeof(string) },
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix));
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
args: new[] { typeof(string), typeof(string), typeof(string) },
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
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));
// 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
@@ -176,14 +214,145 @@ public class Mod : MelonMod
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
}
// Anti-tamper / ToxMod / file-hash-checker are anti-cheat BYPASS
// patches — core, always on, or the modded client gets kicked.
// Quit-trace is pure diagnostics, so it's gated.
if (Cfg.EnableDiagnostics)
RegisterQuitTracePatches(logMisses: true);
RegisterAntiTamperCallbackPatches(logMisses: true);
RegisterToxModPatches(logMisses: true);
PatchFileHashCheckerCallback();
// 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();
// 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
@@ -197,6 +366,11 @@ public class Mod : MelonMod
$"(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
@@ -219,33 +393,10 @@ public class Mod : MelonMod
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
}
if (Cfg.EnableDiagnostics)
RegisterStudioTracePatches();
if (Cfg.EnableBuiltInDevDebugTools)
RegisterBuiltInDevDebugTools();
RegisterDiagnostics();
Log.Msg("=== Client patches registered ===");
}
private void RegisterBuiltInDevDebugTools()
{
Log.Msg("=== Registering built-in Dev Debug Tools hooks ===");
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperAndNotDebugBuildImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDebugBuildImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.DevDebugBuildInformationModel",
"get_BuildInfoFormatted", args: Type.EmptyTypes,
postfix: nameof(BuiltInDevDebugToolsPatches.BuildInfoFormatted_Postfix));
Log.Msg("=== Built-in Dev Debug Tools hooks 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)
@@ -380,146 +531,6 @@ public class Mod : MelonMod
return patched > 0;
}
private bool PatchFileHashCheckerCallback()
{
var prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix));
var patched = 0;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in GetLoadableTypes(asm))
{
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++;
Log.Msg($"[patch-ok] file hash checker callback {type.FullName}.{method.Name}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
}
}
}
}
if (patched == 0)
{
Log.Warning("[patch-miss] file hash checker callback");
return false;
}
return true;
}
private void RegisterAntiTamperCallbackPatches(bool logMisses)
{
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 asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in GetLoadableTypes(asm))
{
if (!typePredicate(type)) continue;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (!methodPredicate(method)) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
Log.Msg($"[patch-ok] {label} {type.FullName}.{method.Name}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}");
}
}
}
}
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;
@@ -531,7 +542,6 @@ public class Mod : MelonMod
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
new[] { typeof(string), typeof(UriKind) },
prefix: nameof(UriPatches.UriStringCtor_Prefix));
complete &= TryPatchHttpRequestUriConstructors();
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
complete &= TryPatchHttpManagerStringSendRequestOverloads();
@@ -539,130 +549,9 @@ public class Mod : MelonMod
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
// HTTP header/callback tracing is pure diagnostics (and the noisiest
// thing in the log) — only install it when diagnostics are enabled.
if (Cfg.EnableDiagnostics)
RegisterHttpDiagnostics();
_networkPatchesRegistered = complete;
}
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 bool TryPatchHttpRequestUriConstructors()
{
var type = ResolveType("BestHTTP.HTTPRequest");
if (type is null)
{
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri): type not found");
return false;
}
var prefix = GetPatchMethod(nameof(UriPatches.HttpRequestCtorUri_Prefix));
var patched = 0;
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
var parameters = ctor.GetParameters();
if (parameters.Length == 0 || !IsUriType(parameters[0].ParameterType)) continue;
try
{
HarmonyInstance.Patch(ctor, prefix: new HarmonyMethod(prefix));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] BestHTTP.HTTPRequest..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
}
}
if (patched == 0)
{
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri)");
return false;
}
Log.Msg($"[patch-ok] BestHTTP.HTTPRequest..ctor(Uri first) overloads x{patched}");
return true;
}
private bool TryPatchHttpManagerStringSendRequestOverloads()
{
var type = ResolveType("BestHTTP.HTTPManager");
@@ -693,19 +582,6 @@ public class Mod : MelonMod
return true;
}
private static bool IsUriType(Type type)
{
return type == typeof(Uri) || type.FullName == typeof(Uri).FullName || type.Name == nameof(Uri);
}
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);
}
public override void OnUpdate()
{
if (!_firstUpdateLogged)
@@ -714,33 +590,19 @@ public class Mod : MelonMod
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
}
if (Cfg.EnableDiagnostics && !_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
{
_quitTraceRetryFrame = 0;
RegisterQuitTracePatches(logMisses: false);
}
if (_diagnosticGameComplete) return;
if (_diagnosticRetryFrame++ > 3600) return;
if ((_diagnosticRetryFrame % 60) != 0) return;
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
{
_antiTamperCallbackRetryFrame = 0;
RegisterAntiTamperCallbackPatches(logMisses: false);
}
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
{
_toxModRetryFrame = 0;
RegisterToxModPatches(logMisses: false);
}
if (Cfg.EnableDebugConsole)
DebugConsolePatches.PollToggleKey();
if (Cfg.DiagnoseDevMenu)
DevMenuProbe.Tick();
var logMisses = (_diagnosticRetryFrame % 300) == 0;
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
if (_diagnosticGameComplete)
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
}
public override void OnApplicationQuit()
{
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
}
// ── Config ────────────────────────────────────────────────────────
@@ -751,22 +613,6 @@ public class Mod : MelonMod
var userData = ResolveUserDataDirectory();
Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
// Auto-enable diagnostics when the separate DorkNet.DebugMod.dll is
// dropped in alongside this mod. Done before the config read (and
// before the no-config-file early return) so presence alone is
// enough — no config edit required to debug.
try
{
var modsDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
if (modsDir != null && File.Exists(Path.Combine(modsDir, "DorkNet.DebugMod.dll")))
{
Cfg.EnableDiagnostics = true;
Log.Msg("[config] DorkNet.DebugMod.dll present → diagnostics ENABLED");
}
}
catch { /* presence detection is best-effort */ }
var path = Path.Combine(userData, "dorknet-clientmod.json");
if (!File.Exists(path))
{
@@ -776,27 +622,18 @@ public class Mod : MelonMod
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, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (TryGetConfigValue(r, "EnableBuiltInDevDebugTools", out v)) Cfg.EnableBuiltInDevDebugTools = v.GetBoolean();
// Config can also enable diagnostics; OR with the debug-mod
// presence check above so either path turns them on.
if (TryGetConfigValue(r, "EnableDiagnostics", out v)) Cfg.EnableDiagnostics |= 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();
}
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, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{
@@ -806,7 +643,20 @@ public class Mod : MelonMod
rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
}
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " +
if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (TryGetConfigValue(r, "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();
}
// "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)
@@ -970,8 +820,6 @@ 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
@@ -1027,22 +875,18 @@ public class Mod : MelonMod
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.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 ||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
}
private static bool ReturnTypeMatches(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.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 ||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
}
@@ -1059,7 +903,7 @@ public class Mod : MelonMod
{
// 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(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(BuiltInDevDebugToolsPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
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) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
@@ -1067,30 +911,6 @@ public class Mod : MelonMod
throw new MissingMethodException($"patch method '{name}' not found on any holder");
}
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 ===");
@@ -1109,6 +929,8 @@ public class Mod : MelonMod
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.
@@ -1125,12 +947,14 @@ public class Mod : MelonMod
"OnAdvancedMovementCheatDetected",
})
{
TryPatchDiagnosticByName("CheatManager", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("CheatManager", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
}
RegisterDeepRoomDiagnostics(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
@@ -1139,7 +963,7 @@ public class Mod : MelonMod
// 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;
return complete;
}
private bool TryPatchDiagnosticByName(string typeName, string methodName,
@@ -1156,211 +980,6 @@ public class Mod : MelonMod
_diagnosticPatchLabels.Add(label);
return true;
}
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;
}
/// <summary>Studio baked-asset load trace. Registered directly from the
/// live client-patch flow (the deep diagnostics path
/// <see cref="RegisterDiagnostics"/> is not wired into init, so probes
/// placed there never run). Pinpoints where the baked-Studio-scene chain
/// breaks: the unity-asset getter (does the client ever request a
/// bundle), prefab registration (does a bundle ever load), and the prefab
/// lookups that fail with "Expected a prefab, but found none".
/// RecRoomObjectPrefabManager is name-preserved; the getter is the
/// obfuscated NLDBPDCNNCF.FKLDMPMFLBD. Each is a single overload.</summary>
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 { }
};
}
}
// ── Patch holders ─────────────────────────────────────────────────────
@@ -1368,369 +987,7 @@ public class Mod : MelonMod
// Harmony invokes. Kept separate from Mod for readability and so the
// reflection lookup in GetPatchMethod stays simple.
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>";
}
}
// ── Debug console access ───────────────────────────────────────────────
// Built-in watch Dev Debug Tools hooks. The 2023 RRUI page already exists,
// but retail rows are hidden by GlobalModelController hide gates. We keep
// those rows visible and append the debug-console command table to the
// built-in Build Information text so the watch page reflects the real client
// command config.
internal static class BuiltInDevDebugToolsPatches
{
public static bool KeepVisible_Prefix() => false;
public static void BuildInfoFormatted_Postfix(ref string __result)
{
try
{
var commands = DebugConsolePatches.BuildCommandReferenceText("Debug Console Commands", maxCommands: 60);
__result = string.IsNullOrWhiteSpace(__result)
? commands
: __result.TrimEnd() + "\n\n" + commands;
}
catch (Exception ex)
{
Mod.Log.Warning($"[devtools] failed to append command list: {ex.Message}");
}
}
}
// 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,
@@ -1741,13 +998,12 @@ internal static class BuiltInDevDebugToolsPatches
//
// 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 private instance void(bool) on
// DebugConsole — ShowInputField on 2020.03, MCNBJFECHLJ on 2020.12,
// KIAMDEDKFBC on 2023.03. We try the known names, then fall back to
// a structural match.
// * The live component is found via Resources.FindObjectsOfTypeAll
// using an Il2CppSystem.Type argument; this interop build does not
// expose UnityEngine.Object.FindObjectOfType(System.Type).
// * 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
@@ -1764,24 +1020,12 @@ internal static class DebugConsolePatches
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;
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;
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 Mod.OnUpdate while EnableDebugConsole is set.
public static void PollToggleKey()
{
@@ -1817,7 +1061,7 @@ internal static class DebugConsolePatches
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
try
{
var active = FindConsoleInstance();
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}"); }
@@ -1870,9 +1114,13 @@ internal static class DebugConsolePatches
// 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) 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
@@ -1898,10 +1146,10 @@ internal static class DebugConsolePatches
public static void Toggle()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null || _toggleMethod is null) return;
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
try
{
var instance = FindConsoleInstance();
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");
@@ -1914,236 +1162,10 @@ internal static class DebugConsolePatches
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
if (_consoleVisible)
ShowConfiguredCommands(instance);
}
catch (Exception ex) { Mod.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 = Mod.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 = Mod.ResolveType("UnityEngine.Object");
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
return findOne?.Invoke(null, new[] { il2cppType });
}
catch (Exception ex)
{
Mod.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)
{
Mod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
return;
}
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
Mod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
}
catch (Exception ex)
{
Mod.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.";
}
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;
Mod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
return commands;
}
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
foreach (var entry in Mod.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;
AddCommand(commands, seen, label, command);
}
}
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
try
{
var cfgType = Mod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
if (cfgType is null)
{
Mod.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 = Mod.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)
{
Mod.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 label = string.IsNullOrWhiteSpace(declaringType)
? methodName
: $"{ShortTypeName(declaringType)}.{methodName}";
AddCommand(commands, seen, label, methodName);
}
}
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command)
{
label = CleanLabel(label);
command = (command ?? string.Empty).Trim();
if (command.Length == 0 || !seen.Add(command)) return;
commands.Add(new ConfiguredCommand(label, command));
}
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)
{
Label = label;
Command = command;
}
public string Label { get; }
public string Command { get; }
}
private static void EnableShowButton()
{
if (_consoleType is null) return;
@@ -2159,52 +1181,6 @@ internal static class DebugConsolePatches
}
catch { /* best-effort — the toggle still works without it */ }
}
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; }
}
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 });
}
}
@@ -2744,929 +1720,40 @@ internal static class MakerPenGiftPreviewPatches
}
}
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;
else if (string.IsNullOrWhiteSpace(__0?.ToString()) && string.IsNullOrWhiteSpace(__1?.ToString()))
_latestAccessToken = null;
if (_tokenLogCount++ < 3)
{
DiagnosticPatches.Write(
"[auth-trace] token setter " +
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
}
}
catch (Exception ex)
{
Mod.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";
public static void ApplyBearerTokenHeader(object? request)
{
try
{
if (request is null || string.IsNullOrWhiteSpace(_latestAccessToken)) return;
var uri = TryReadUri(request);
if (uri is null || !IsDorkNetServiceHost(uri.Host)) return;
var type = request.GetType();
var setHeader = AccessTools.Method(type, "SetHeader", new[] { typeof(string), typeof(string) })
?? AccessTools.Method(type, "AddHeader", new[] { typeof(string), typeof(string) });
setHeader?.Invoke(request, new object[] { "Authorization", "Bearer " + _latestAccessToken });
}
catch (Exception ex)
{
if (_tokenLogCount++ < 6)
Mod.Log.Warning($"[auth-trace] bearer propagation failed: {ex.Message}");
}
}
private static Uri? TryReadUri(object request)
{
var type = request.GetType();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
if (TryGetUri(type.GetProperty("Uri", flags), request, out var uri)) return uri;
if (TryGetUri(type.GetField("<Uri>k__BackingField", flags), request, out uri)) return uri;
foreach (var prop in type.GetProperties(flags))
{
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryGetUri(prop, request, out uri)) return uri;
}
foreach (var field in type.GetFields(flags))
{
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryGetUri(field, request, out uri)) return uri;
}
return null;
}
private static bool TryGetUri(PropertyInfo? prop, object request, out Uri? uri)
{
uri = null;
if (prop is null || !prop.CanRead || prop.GetIndexParameters().Length != 0) return false;
uri = prop.GetValue(request) as Uri;
return uri is not null;
}
private static bool TryGetUri(FieldInfo? field, object request, out Uri? uri)
{
uri = null;
if (field is null) return false;
uri = field.GetValue(request) as Uri;
return uri is not null;
}
private static bool IsDorkNetServiceHost(string host)
{
var configuredHost = Mod.Cfg.ServerHost?.Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(configuredHost)) return false;
return host.Equals(configuredHost, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("." + configuredHost, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("-" + configuredHost, StringComparison.OrdinalIgnoreCase);
}
}
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)
Mod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
}
}
internal static class UriPatches
{
public static void HttpRequestCtorUri_Prefix(object __0)
{
HttpTracePatches.TraceUrl(__0?.ToString(), "BestHTTP.HTTPRequest..ctor(Uri)");
}
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");
HttpTracePatches.TraceUrl(url, "BestHTTP.HTTPManager.SendRequest(string)");
}
public static void HttpManagerSendRequestObject_Prefix(object request)
{
RewriteRequestUri(request, "httpmanager-request-rewrite");
AuthPatches.ApplyBearerTokenHeader(request);
}
public static void UriStringCtor_Prefix(ref string uriString)
{
RewriteString(ref uriString, "uri-rewrite");
HttpTracePatches.TraceUrl(uriString, "System.Uri..ctor(string)");
}
public static void HttpRequestSend_Prefix(object __instance)
{
RewriteRequestUri(__instance, "http-rewrite");
AuthPatches.ApplyBearerTokenHeader(__instance);
}
private static void RewriteRequestUri(object request, string label)
{
try
{
if (request is null) return;
var type = request.GetType();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
if (uriProp?.GetValue(request) is not Uri uri) return;
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
var rewritten = RewriteUri(uri);
if (rewritten is null) return;
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;
}
uriProp.SetValue(request, rewritten);
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
}
catch (Exception ex)
{
@@ -3674,53 +1761,13 @@ 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;
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
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);
Mod.Log.Msg($"[{label}] {uri} -> {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);
Mod.Log.Msg($"[{label}] {uri} -> {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;
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
var rewrittenUri = RewriteUri(uri);
if (rewrittenUri is null) return;
var rewritten = rewrittenUri.ToString();
var rewritten = uriString.Replace(".rec.net", "." + host);
if (rewritten == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
uriString = rewritten;
@@ -3742,6 +1789,333 @@ internal static class UriPatches
}
}
internal static class PhotonPatches
{
public static void PhotonAppIdOverride_Prefix()
{
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}"); }
}
/// <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
{
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
@@ -3990,50 +2364,6 @@ internal static class SavePatches
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
internal static class AntiCheatPatches
{
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
{
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
Mod.Log.Msg("[eac] using local challenge response for copied test client");
return false;
}
}
internal static class AntiTamperPatches
{
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
{
Mod.Log.Msg($"[hile] suppressed warning callback shouldQuit={shouldQuit}");
return false;
}
public static bool UnknownDllDetectedCallback_Prefix()
{
Mod.Log.Msg("[cheatmanager] suppressed unknown DLL fatal callback");
return false;
}
}
internal static class FileHashCheckerPatches
{
public static bool InitializeCallback_Prefix(object? result)
{
Mod.Log.Msg($"[filecheck] suppressed verifier callback result={result?.GetType().FullName ?? "null"}");
return false;
}
}
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
@@ -4047,83 +2377,6 @@ internal static class TlsPatches
}
}
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 { Mod.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)
@@ -4138,18 +2391,16 @@ internal static class DiagnosticPatches
catch { }
}
public static bool ApplicationQuit_Prefix()
public static void ApplicationQuit_Prefix()
{
Write("[quit] UnityEngine.Application.Quit()");
WriteStack();
return true;
}
public static bool ApplicationQuitInt_Prefix(int exitCode)
public static void ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
WriteStack();
return true;
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
@@ -4166,23 +2417,9 @@ internal static class DiagnosticPatches
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
Write($"[quit-blocked] {__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;
return false;
}
private static void WriteStack()
@@ -0,0 +1,132 @@
// ─────────────────────────────────────────────────────────────────────
// 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,5 +1,8 @@
{
"ServerHost": "{HOST}",
"PhotonAppId": "{PHOTON_APPID}",
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
"PhotonCloudRegion": "{PHOTON_REGION}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"QuestMaxTeamSize": 0,
"QuestMaxTeamSizeRooms": [
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -164,6 +164,15 @@ public static class DorkNetRouteOwnership
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
public static IReadOnlyList<string> PublicSubdomains =>
ServiceRouteGroups
.SelectMany(group => group.HostSubdomains)
.Append(AdminHost)
.Append("feed")
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
public static bool IsInfrastructurePath(string path)
{
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
-31
View File
@@ -1,31 +0,0 @@
// DorkNet.DebugMod — opt-in debugging switch for DorkNet.ClientMod.
//
// This mod intentionally does almost nothing on its own. Its job is to
// EXIST: when its DLL sits in the Mods folder next to DorkNet.ClientMod.dll,
// the ClientMod detects it at startup (Cfg.EnableDiagnostics) and installs
// its verbose diagnostic patches — HTTP header/callback tracing, join/quit
// /studio traces, and the deep RegisterDiagnostics probes. Remove this DLL
// to ship a quiet, minimal client that only carries the connect + anti-cheat
// patches.
//
// Keeping the switch as a separate assembly (rather than a config flag baked
// into the shipping mod) means the diagnostics literally cannot turn
// themselves on in a normal install — you have to deliberately drop this
// file in. That is the "as safe as possible" default the main mod wants.
using MelonLoader;
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.DebugMod;
public class DebugMod : MelonMod
{
public override void OnInitializeMelon()
{
LoggerInstance.Msg(
"=== DorkNet DebugMod present — DorkNet.ClientMod diagnostics are ENABLED. " +
"Remove DorkNet.DebugMod.dll from Mods to ship a quiet client. ===");
}
}
-40
View File
@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
The shipping ClientMod installs ONLY the patches needed to connect and
survive anti-cheat; all of its verbose diagnostic patches (HTTP header
tracing, join/quit/studio traces, the deep RegisterDiagnostics path) are
gated OFF by default. Dropping THIS dll into the Mods folder next to
DorkNet.ClientMod.dll flips Cfg.EnableDiagnostics on (the ClientMod
detects this dll's presence at startup), turning all of that tracing
back on. Remove it to ship a quiet, minimal client.
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>
</ItemGroup>
</Project>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+41
View File
@@ -26,6 +26,20 @@ var app = builder.Build();
app.MapDorkNetServiceDefaults();
var publicHosts = BuildPublicHosts(apexDomain);
app.Use(async (ctx, next) =>
{
if (DorkNetRouteOwnership.IsInfrastructurePath(ctx.Request.Path.Value ?? "/")
|| IsAllowedPublicHost(ctx.Request.Host.Host, publicHosts))
{
await next();
return;
}
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
await ctx.Response.WriteAsync("Unknown DorkNet host.");
});
app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> options) =>
Results.Ok(options.Value.Endpoints()));
app.MapGet("/internal/routes", () =>
@@ -190,6 +204,33 @@ static string[] WebBrowserHosts(string apexDomain)
return [apexDomain, SubdomainHost("www", apexDomain)];
}
static HashSet<string> BuildPublicHosts(string apexDomain)
{
var hosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
apexDomain,
"localhost",
"127.0.0.1",
};
foreach (var subdomain in DorkNetRouteOwnership.PublicSubdomains)
{
hosts.Add(SubdomainHost(subdomain, apexDomain));
}
return hosts;
}
static bool IsAllowedPublicHost(string? host, HashSet<string> publicHosts)
{
if (string.IsNullOrWhiteSpace(host))
{
return true;
}
return publicHosts.Contains(host.Split(':', 2)[0].Trim());
}
static void AddHostRoute(
List<RouteConfig> routes,
ref int order,
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -1,76 +0,0 @@
<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>
-259
View File
@@ -1,259 +0,0 @@
// 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;
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));
int patched = 0;
// BuildFrom is the lower-level builder every descriptor passes
// through; FromGeneratedCode is the generated-code entry point.
// Patch both (deduped downstream) so nothing is missed.
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
{
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != name) continue;
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; }
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
}
}
_log?.Msg($"ProtoDumper: installed {patched} descriptor-build hooks. Play through your content; descriptors are captured as they load.");
}
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)
{
if (__result == null) return;
try { CollectFile(__result); }
catch { /* never let our hook disturb the game */ }
}
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);
}
public override void OnUpdate()
{
// Flush to disk shortly after new descriptors are captured (every ~1s).
if (++_frame % 60 == 0 && _dirty)
Flush();
}
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();
}
}
-81
View File
@@ -1,81 +0,0 @@
# 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.
-165
View File
@@ -1,165 +0,0 @@
#!/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()
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.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="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
<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 -2
View File
@@ -1,8 +1,11 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace DorkNet.Server.Tests;
@@ -35,8 +38,8 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
["S3:Endpoint"] = "",
["S3:AccessKey"] = "",
["S3:SecretKey"] = "",
["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
["DorkNet:DefaultClientVersion"] = "december_2020_12_18",
["DorkNet:SupportedVersions:0"] = "december_2020_12_18",
});
});
builder.ConfigureServices(services =>
@@ -53,6 +56,18 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
options.AllowEmptyHosts = true;
options.IncludeFailureMessage = true;
});
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
});
});
}
@@ -61,8 +61,8 @@ public sealed class GameClientEndpointContractTests :
ToHttpMethod(route.Method),
new Uri($"http://{host}{path}"));
request.Headers.Accept.ParseAdd("application/json");
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
if (NeedsRequestBody(request.Method))
+4 -6
View File
@@ -21,13 +21,11 @@ public static class GameClientSessionFactory
HttpMethod.Post,
new Uri($"http://auth.{apexDomain}/connect/token"));
tokenRequest.Headers.Accept.ParseAdd("application/json");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["client_id"] = "recroom",
["client_secret"] = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2",
["username"] = "EndpointContract",
["device_id"] = deviceId,
["platform"] = "0",
@@ -51,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/2023.03.21");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var userInfoResponse = await client.SendAsync(userInfoRequest);
@@ -11,11 +11,11 @@ namespace DorkNet.Server.Tests;
public sealed class VersionCheckControllerTests
{
[Fact]
public void March_2023_build_is_valid_with_minimal_service_config()
public void December_2020_build_is_valid_with_minimal_service_config()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "0"));
Assert.Equal(0, ReadVersionStatus(result.Value));
}
@@ -37,9 +37,9 @@ public sealed class VersionCheckControllerTests
[new Late2020VersionPlugin()],
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"march_2023_03_21",
"december_2020_12_18",
},
"march_2023_03_21");
"december_2020_12_18");
return new VersionCheckController(
config,
@@ -1,785 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.ResponseHandling;
using Open.IdentityServer.Services;
using Open.IdentityServer.Stores;
using Open.IdentityServer.Validation;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Auth;
public sealed class IdentityServerSigningKeyProvider
{
private readonly string _legacySecret;
public IdentityServerSigningKeyProvider(IConfiguration config)
{
_legacySecret =
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
CertificatePath = ResolveCertificatePath(config);
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
if (!string.IsNullOrWhiteSpace(certificateBase64))
{
LoadedFromBase64 = true;
Certificate = LoadCertificateFromBase64(certificateBase64, password);
}
else
{
Certificate = LoadOrCreateCertificate(CertificatePath, password);
}
SigningKey = new X509SecurityKey(Certificate);
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
}
public string CertificatePath { get; }
public bool LoadedFromBase64 { get; }
public string CertificateSource => LoadedFromBase64
? "IdentityServer:SigningCertificateBase64"
: CertificatePath;
public X509Certificate2 Certificate { get; }
public SecurityKey SigningKey { get; }
public SecurityKey LegacyJwtKey { get; }
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
private static string ResolveCertificatePath(IConfiguration config)
{
var path = config["IdentityServer:SigningCertificatePath"];
if (string.IsNullOrWhiteSpace(path))
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
return path;
}
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
var compact = NormalizeCertificateBase64(certificateBase64);
byte[] bytes;
try
{
bytes = Convert.FromBase64String(compact);
}
catch (FormatException ex)
{
throw new InvalidOperationException(
"IdentityServer signing certificate base64 is invalid. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
"without a file path or shell assignment text.",
ex);
}
try
{
return new X509Certificate2(bytes, password, flags);
}
catch (CryptographicException ex)
{
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
return certificate;
throw new InvalidOperationException(
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
"IdentityServer__SigningCertificatePassword matches the PFX password.",
ex);
}
}
private static bool TryLoadPemCertificate(
byte[] bytes,
string password,
X509KeyStorageFlags flags,
out X509Certificate2 certificate)
{
certificate = null!;
string pem;
try
{
pem = Encoding.UTF8.GetString(bytes);
}
catch (DecoderFallbackException)
{
return false;
}
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
return false;
try
{
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
if (!pemCertificate.HasPrivateKey)
return false;
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
certificate = new X509Certificate2(pfxBytes, password, flags);
return true;
}
catch (CryptographicException)
{
return false;
}
}
private static string NormalizeCertificateBase64(string value)
{
var text = value.Trim().Trim('"', '\'');
var equals = text.IndexOf('=');
if (equals > 0)
{
var key = text[..equals].Trim();
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
text = text[(equals + 1)..].Trim().Trim('"', '\'');
}
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = text.IndexOf(',');
if (comma >= 0)
text = text[(comma + 1)..];
}
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
text = text["base64:".Length..];
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
.Replace('-', '+')
.Replace('_', '/');
return (compact.Length % 4) switch
{
2 => compact + "==",
3 => compact + "=",
_ => compact,
};
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path))
return new X509Certificate2(path, password, flags);
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=DorkNet IdentityServer Signing",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
using var created = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(10));
var bytes = created.Export(X509ContentType.Pfx, password);
File.WriteAllBytes(path, bytes);
return new X509Certificate2(bytes, password, flags);
}
}
public static class RecRoomIdentityServerRegistration
{
public static void AddRecRoomIdentityServer(
this IServiceCollection services,
IConfiguration config,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeyProvider)
{
services
.AddIdentityServer(options =>
{
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
})
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
.AddProfileService<RecRoomProfileService>();
services.AddTransient<ITokenResponseGenerator, RecRoomTokenResponseGenerator>();
}
}
internal static class RecRoomIdentityServerConfig
{
public const string ClientId = "recroom";
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
public const string GameClientScope = "gameClient";
public const string CachedLoginGrantType = "cached_login";
public static readonly IdentityResource[] IdentityResources =
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
public static readonly ApiScope[] ApiScopes =
{
new(GameClientScope, "Game client")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
new("api", "DorkNet API")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
};
public static readonly Client[] Clients =
{
new()
{
ClientId = ClientId,
ClientName = "Rec Room game client",
RequireClientSecret = true,
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
AllowedScopes = RecRoomScopes(),
AllowOfflineAccess = true,
AccessTokenLifetime = 60 * 60 * 12,
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Absolute,
UpdateAccessTokenClaimsOnRefresh = true,
AlwaysIncludeUserClaimsInIdToken = true,
},
};
private static ICollection<string> RecRoomScopes() =>
[
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
GameClientScope,
"api",
];
}
public sealed class IdentityServerGameTokenRequestMiddleware(
RequestDelegate next,
DomainConfig domain,
IConfiguration config,
ILogger<IdentityServerGameTokenRequestMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsTokenRequest(context))
{
await next(context);
return;
}
var form = await context.Request.ReadFormAsync();
var values = form.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value,
StringComparer.OrdinalIgnoreCase);
var changed = false;
if (IsDevicePasswordGrant(values))
{
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
{
values["username"] = First(
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
"anonymous");
changed = true;
}
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
{
values["password"] = "__dorknet_device_login__";
values["dorknet_device_login"] = "true";
changed = true;
}
}
var scopes = values.TryGetValue("scope", out var rawScope)
? rawScope.ToString()
: string.Empty;
var mergedScopes = MergeScopes(scopes);
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
{
values["scope"] = mergedScopes;
changed = true;
}
if (changed)
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
await InvokeAndPersistAccessTokenCookieAsync(context);
}
private async Task InvokeAndPersistAccessTokenCookieAsync(HttpContext context)
{
var originalBody = context.Response.Body;
await using var bufferedBody = new MemoryStream();
context.Response.Body = bufferedBody;
try
{
await next(context);
bufferedBody.Position = 0;
if (IsSuccessfulJsonResponse(context.Response))
TryPersistAccessTokenCookie(context, bufferedBody);
bufferedBody.Position = 0;
await bufferedBody.CopyToAsync(originalBody);
}
finally
{
context.Response.Body = originalBody;
}
}
private static bool IsSuccessfulJsonResponse(HttpResponse response) =>
response.StatusCode is >= 200 and < 300
&& response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true;
private void TryPersistAccessTokenCookie(HttpContext context, Stream responseBody)
{
try
{
using var document = JsonDocument.Parse(responseBody);
if (!document.RootElement.TryGetProperty("access_token", out var accessTokenElement))
return;
var accessToken = accessTokenElement.GetString();
if (string.IsNullOrWhiteSpace(accessToken))
return;
var expires = DateTimeOffset.UtcNow.AddHours(12);
if (document.RootElement.TryGetProperty("expires_in", out var expiresInElement) &&
expiresInElement.TryGetInt32(out var expiresIn) &&
expiresIn > 0)
{
expires = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
}
var options = new CookieOptions
{
HttpOnly = true,
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
SameSite = SameSiteMode.None,
Expires = expires,
IsEssential = true,
};
var cookieDomain = ResolveCookieDomain(context.Request.Host.Host);
if (cookieDomain is not null)
options.Domain = cookieDomain;
context.Response.Cookies.Append(AuthService.AccessCookieName, accessToken, options);
logger.LogInformation(
"[auth-cookie] issued access cookie host={Host} domain={Domain} secure={Secure} sameSite={SameSite} essential={IsEssential}",
context.Request.Host.Host,
cookieDomain ?? "<host-only>",
options.Secure,
options.SameSite,
options.IsEssential);
}
catch (JsonException)
{
// Leave the token response untouched if the upstream payload is not JSON.
}
}
private string? ResolveCookieDomain(string host)
{
var configuredCookieDomain =
Environment.GetEnvironmentVariable("DORKNET_AUTH_COOKIE_DOMAIN")
?? config["Auth:CookieDomain"]
?? config["IdentityServer:CookieDomain"];
if (!string.IsNullOrWhiteSpace(configuredCookieDomain))
return configuredCookieDomain.Trim().TrimStart('.') is { Length: > 0 } value ? "." + value : null;
var apex = domain.Apex.Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(apex) ||
apex.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
apex.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) ||
apex.Equals("::1", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (host.Equals(apex, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith("." + apex, StringComparison.OrdinalIgnoreCase))
{
return "." + apex;
}
if (domain.UsesHyphenSubdomains)
{
var parent = HyphenCookieParent(apex, host);
if (parent is not null)
return "." + parent;
}
return null;
}
private static string? HyphenCookieParent(string apex, string host)
{
var parts = apex.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 3)
return null;
var environmentLabel = parts[0];
var parent = string.Join('.', parts.Skip(1));
if (!host.EndsWith("." + parent, StringComparison.OrdinalIgnoreCase))
return null;
var hostLabel = host[..^(parent.Length + 1)];
return hostLabel.EndsWith("-" + environmentLabel, StringComparison.OrdinalIgnoreCase)
? parent
: null;
}
private static bool IsTokenRequest(HttpContext context) =>
HttpMethods.IsPost(context.Request.Method)
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
&& context.Request.HasFormContentType;
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
{
if (!values.TryGetValue("grant_type", out var grantType) ||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
return false;
return !values.TryGetValue("password", out var password) ||
string.IsNullOrWhiteSpace(password.ToString());
}
private static string First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
private static string MergeScopes(string existing)
{
var scopes = existing
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
return string.Join(' ', scopes);
}
}
public sealed class RecRoomTokenResponseGenerator(
TimeProvider clock,
ITokenService tokenService,
IRefreshTokenService refreshTokenService,
IScopeParser scopeParser,
IResourceStore resources,
IClientStore clients,
ILogger<TokenResponseGenerator> logger) : ITokenResponseGenerator
{
private readonly TokenResponseGenerator _inner = new(
clock,
tokenService,
refreshTokenService,
scopeParser,
resources,
clients,
logger);
public async Task<TokenResponse> ProcessAsync(TokenRequestValidationResult request)
{
var response = await _inner.ProcessAsync(request);
AddLegacyGameFields(response);
return response;
}
private static void AddLegacyGameFields(TokenResponse response)
{
if (string.IsNullOrWhiteSpace(response.AccessToken))
return;
response.Custom ??= new Dictionary<string, object>(StringComparer.Ordinal);
AddIfMissing(response.Custom, "Access_token", response.AccessToken);
AddIfMissing(response.Custom, "accessToken", response.AccessToken);
AddIfMissing(response.Custom, "AccessToken", response.AccessToken);
AddIfMissing(response.Custom, "token", response.AccessToken);
AddIfMissing(response.Custom, "Token", response.AccessToken);
AddIfMissing(response.Custom, "tokenType", "Bearer");
AddIfMissing(response.Custom, "TokenType", "Bearer");
AddIfMissing(response.Custom, "expiresIn", response.AccessTokenLifetime);
AddIfMissing(response.Custom, "ExpiresIn", response.AccessTokenLifetime);
if (!string.IsNullOrWhiteSpace(response.RefreshToken))
{
AddIfMissing(response.Custom, "Refresh_token", response.RefreshToken);
AddIfMissing(response.Custom, "refreshToken", response.RefreshToken);
AddIfMissing(response.Custom, "RefreshToken", response.RefreshToken);
}
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
AddIfMissing(response.Custom, "key", signingKey);
AddIfMissing(response.Custom, "Key", signingKey);
}
private static void AddIfMissing(IDictionary<string, object> values, string key, object value)
{
if (!values.ContainsKey(key))
values[key] = value;
}
}
public sealed class RecRoomPasswordValidator(
PlayerService playerService,
LevelService level,
ServerSettingsService settings,
DorkNetDbContext db,
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
{
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
var raw = context.Request.Raw;
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
{
var byName = await playerService.GetByUsernameAsync(context.UserName);
if (byName is null || byName.PasswordHash is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown_user_or_no_password");
return;
}
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"password_mismatch");
return;
}
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
await TryAwardLoginXpAsync(byName.Id);
context.Result = await CreateSuccessAsync(byName, "password");
return;
}
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
? deviceId
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
effectiveDeviceId);
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"signups_disabled");
return;
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: platformId,
displayName: context.UserName);
await TryAwardLoginXpAsync(player.Id);
context.Result = await CreateSuccessAsync(player, "device_password");
}
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
new(
subject: player.Id.ToString(),
authenticationMethod: authMethod,
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class CachedLoginGrantValidator(
PlayerService playerService,
LevelService level,
OrphanAccountTracker orphans,
DorkNetDbContext db,
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
{
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var raw = context.Request.Raw;
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"missing account_id");
return;
}
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown account_id");
return;
}
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var pendingId = orphans.PeekPending(deviceId, platformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId,
orphanId);
}
orphans.Clear(deviceId, platformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
context.Result = new GrantValidationResult(
subject: picked.Id.ToString(),
authenticationMethod: GrantType,
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
}
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
{
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
if (!TryGetSubjectId(context.Subject, out var playerId))
return;
var player = await playerService.GetByIdAsync(playerId);
if (player is null)
return;
context.IssuedClaims.AddRange(
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
}
public async Task IsActiveAsync(IsActiveContext context)
{
context.IsActive =
TryGetSubjectId(context.Subject, out var playerId)
&& await playerService.GetByIdAsync(playerId) is not null;
}
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
{
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(value, out playerId);
}
}
public static class RecRoomIdentityClaims
{
public static readonly List<string> UserClaimTypes =
[
JwtRegisteredClaimNames.Sub,
ClaimTypes.NameIdentifier,
"name",
"role",
ClaimTypes.Role,
"roles",
"accountId",
"account_id",
"accountid",
];
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
{
var playerInfo = await db.Players
.Where(p => p.Id == playerId)
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
.FirstOrDefaultAsync();
var name = username ?? playerInfo?.Username ?? playerId.ToString();
var isDev = playerInfo?.IsDev ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("name", name),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("role", "gameClient"),
new("roles", "gameClient"),
};
if (isDev)
{
claims.Add(new Claim(ClaimTypes.Role, "developer"));
claims.Add(new Claim("role", "developer"));
claims.Add(new Claim("roles", "developer"));
}
return claims;
}
}
@@ -106,11 +106,9 @@ public class AccountApiController(
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? auth["Bearer ".Length..].Trim()
: Request.Cookies[AuthService.AccessCookieName];
if (string.IsNullOrWhiteSpace(token))
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
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,31 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
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
{
[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 Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
}
}
@@ -1,47 +0,0 @@
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 });
}
}
@@ -1,44 +0,0 @@
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 });
}
}
@@ -85,17 +85,16 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
return Ok(new List<UnlockedAvatarItemDto>());
// The old [1,120] / [1,20] ceilings reflected the 2020 *watch's*
// render-budget headroom — feeding it too many BrowsableAvatarItem /
// DynamicAvatarItemImposter rows could crash its wardrobe drawer.
// This branch (march-2023-03-21) serves the 2023 DESKTOP client, which
// has no such limit, and the watch-era cap was silently truncating the
// owned wardrobe to 100 ("most of my items don't show"). Raise the
// ceilings well above the full catalog (882 base items + variants) so
// nothing the player owns is dropped; the config keys still let an
// operator dial it back down for a watch build.
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000);
// Defaults raised (30 → 100, 4 → 20) — the old 4-per-slot silently
// dropped any newly-purchased item once a slot held 4+ entries (the
// "I bought it but it's gone after relog" symptom). The clamp
// ranges stay at [1,120] / [1,20] — those reflect empirical watch
// testing on render-budget headroom (per the prior maintainer note
// about BrowsableAvatarItem / DynamicAvatarItemImposter thrash
// above this count); raising the clamp ceiling without retesting
// could crash the watch's wardrobe drawer.
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 100), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 20), 1, 20);
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
@@ -187,22 +186,6 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
return Ok(result);
}
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
[AllowAnonymous]
public IActionResult DefaultBaseAvatarItems()
{
var rows = _catalog.Value.Items.Values
.OrderBy(i => i.AvatarItemType)
.ThenBy(i => i.FriendlyName)
.Take(100)
.ToList();
return Ok(rows);
}
[HttpGet("api/avatar/v1/defaultunlocked")]
[AllowAnonymous]
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
/// <summary>The set of GUIDs the bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// contains. Exposed so other controllers (StoreService seed,
@@ -60,42 +60,19 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
.ExecuteDeleteAsync();
}
var active = targetId == pid || await db.Subscriptions.AnyAsync(s =>
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
var now = DateTime.UtcNow;
var subscriptionId = unchecked((pid * 397L) ^ targetId);
var subscription = new Dictionary<string, object?>
// 2020.12 deserializer for this response expects `subscription` to be a
// nested OBJECT (Subscription DTO with subscriptionId/recNetPlayerId/...
// fields), NOT a boolean. Sending a bool throws InvalidCastException
// on the client ("Unable to cast object of type 'Boolean' to type
// 'Dictionary`2'"). Send null when there's no active subscription —
// the client's generic-cast helper returns default(T) which is fine.
return Ok(new
{
["subscriptionId"] = subscriptionId,
["recNetPlayerId"] = pid,
["platformAccountSubscribedPlayerId"] = targetId,
["isActive"] = active,
["startedAt"] = now,
["currentPeriodEnd"] = active ? now.AddYears(10) : now,
["renewalDate"] = active ? now.AddYears(10) : now,
["SubscriptionId"] = subscriptionId,
["RecNetPlayerId"] = pid,
["PlatformAccountSubscribedPlayerId"] = targetId,
["IsActive"] = active,
["StartedAt"] = now,
["CurrentPeriodEnd"] = active ? now.AddYears(10) : now,
["RenewalDate"] = active ? now.AddYears(10) : now,
};
return Ok(new Dictionary<string, object?>
{
["subscription"] = subscription,
["platformAccountSubscribedPlayerId"] = targetId,
["Subscription"] = subscription,
["PlatformAccountSubscribedPlayerId"] = targetId,
subscription = (object?)null,
platformAccountSubscribedPlayerId = targetId,
});
}
[HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
[AllowAnonymous]
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
=> Content("true", "application/json");
[HttpPost("api/clubreporting/v1/report")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
@@ -21,102 +21,6 @@ public class ConfigController(ConfigService configService) : ControllerBase
[HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
/// audio/core initialization. Returning 404 leaves the static backtrace
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
/// crash upload/deobfuscation disabled locally while preserving the
/// object shape and readable config keys the client inspects.
/// </summary>
[HttpGet("backtrace")]
public ActionResult<object> GetBacktrace(
[FromQuery(Name = "platformType")] string? platformType,
[FromQuery(Name = "allocate")] bool? allocate)
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var submissionUrl = $"{baseUrl}/backtrace/submit";
var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump";
var config = new Dictionary<string, object>
{
["Backtrace.SampleRate"] = 0.0,
["Backtrace.ReportBudget"] = 0,
["Backtrace.deobfuscationRate"] = 0.0,
["Backtrace.Deobfuscate"] = false,
["Backtrace.DeobfuscationUrl"] = string.Empty,
["Backtrace.Enabled"] = false,
["Backtrace.PlatformType"] = platformType ?? string.Empty,
["Backtrace.Allocate"] = allocate ?? false,
};
return Ok(new Dictionary<string, object>
{
["MaxMessagesPerMinute"] = 0,
["MaxReportsPerMinute"] = 0,
["SampleRate"] = 0.0,
["ReportBudget"] = 0,
["ErrorReportBudget"] = 0,
["WarningReportBudget"] = 0,
["LogReportBudget"] = 0,
["SubmissionUrl"] = submissionUrl,
["MinidumpSubmissionUrl"] = minidumpSubmissionUrl,
["ServerUrl"] = submissionUrl,
["DatabasePath"] = "backtrace/database",
["Config"] = config,
["maxMessagesPerMinute"] = 0,
["maxReportsPerMinute"] = 0,
["sampleRate"] = 0.0,
["reportBudget"] = 0,
["errorReportBudget"] = 0,
["warningReportBudget"] = 0,
["logReportBudget"] = 0,
["submissionUrl"] = submissionUrl,
["minidumpSubmissionUrl"] = minidumpSubmissionUrl,
["serverUrl"] = submissionUrl,
["databasePath"] = "backtrace/database",
["config"] = config,
});
}
[HttpGet("azurespeech")]
public IActionResult GetAzureSpeech()
{
return Ok(new Dictionary<string, object>
{
["Enabled"] = false,
["SpeechRecognitionEnabled"] = false,
["SpeechSynthesisEnabled"] = false,
["Region"] = string.Empty,
["SubscriptionKey"] = string.Empty,
["TokenEndpoint"] = string.Empty,
["SpeechRecognitionEndpoint"] = string.Empty,
["SpeechSynthesisEndpoint"] = string.Empty,
["enabled"] = false,
["speechRecognitionEnabled"] = false,
["speechSynthesisEnabled"] = false,
["region"] = string.Empty,
["subscriptionKey"] = string.Empty,
["tokenEndpoint"] = string.Empty,
["speechRecognitionEndpoint"] = string.Empty,
["speechSynthesisEndpoint"] = string.Empty,
});
}
[HttpGet("/voice/config")]
public IActionResult GetVoiceConfig()
{
return Ok(new Dictionary<string, object>
{
["ToxMod.Enabled"] = false,
["ToxMod.SampleRate"] = 0.0,
["ToxMod.SessionRecordingEnabled"] = false,
["ToxMod.Endpoint"] = string.Empty,
["ToxMod.ApiKey"] = string.Empty,
["Enabled"] = false,
["enabled"] = false,
["toxModEnabled"] = false,
});
}
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the
@@ -5,7 +5,6 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V2;
[ApiController]
[Route("api/config")]
[Route("api/[controller]/v2")]
public class ConfigController(ConfigService configService) : ControllerBase
{
@@ -1,410 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
[ApiController]
[Route("api/customAvatarItems")]
[Route("api/customAvatarItems/v1")]
[Route("api/customAvatarItems/v2")]
public class CustomAvatarItemsController(
DorkNetDbContext db,
LevelService level,
DomainConfig domain) : ControllerBase
{
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> List(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
.Where(i => i.IsPublic);
if (needle.Length > 0)
{
rows = rows.Where(i =>
i.Name.ToLower().Contains(needle) ||
i.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[HttpGet("{id:guid}")]
[AllowAnonymous]
public async Task<IActionResult> Get(Guid id)
{
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
if (item is null) return NotFound();
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
return Ok(ToWire(item));
}
[HttpGet("bulk")]
[HttpPost("bulk")]
[AllowAnonymous]
public async Task<IActionResult> Bulk([FromBody] BulkRequest? body)
{
var ids = await ReadIdsAsync(body);
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.CustomAvatarItems
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("featured")]
[AllowAnonymous]
public async Task<IActionResult> Featured([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic && i.IsFeatured)
.OrderByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("hot")]
[AllowAnonymous]
public async Task<IActionResult> Hot([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic)
.OrderByDescending(i => i.CheerCount)
.ThenByDescending(i => i.PurchaseCount)
.ThenByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("new")]
[AllowAnonymous]
public async Task<IActionResult> New([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic)
.OrderByDescending(i => i.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("fromCreator/{creatorId:long}")]
[AllowAnonymous]
public async Task<IActionResult> FromCreator(
long creatorId,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var currentPlayerId = this.CurrentPlayerId();
var rows = await db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == creatorId
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var pid = this.RequireCurrentPlayerId();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var createdQuery = db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt);
var total = await createdQuery.CountAsync();
var created = await createdQuery
.Skip(skip)
.Take(take)
.ToListAsync();
var ownedIds = await db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Select(o => o.CustomAvatarItemId)
.ToListAsync();
var owned = ownedIds.Count == 0
? new List<CustomAvatarItemEntity>()
: await db.CustomAvatarItems
.Where(i => ownedIds.Contains(i.Id))
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
return Ok(new
{
Results = created.Select(ToWire),
TotalResults = total,
Created = created.Select(ToWire),
Owned = owned.Select(ToWire),
});
}
[HttpGet("owned")]
[Authorize]
public async Task<IActionResult> Owned([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var pid = this.RequireCurrentPlayerId();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var query = db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
.OrderByDescending(i => i.UpdatedAt);
var total = await query.CountAsync();
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(new
{
Results = rows.Select(ToWire),
TotalResults = total,
});
}
[HttpGet("isCreationAllowedForAccount")]
[Authorize]
public async Task<IActionResult> IsCreationAllowedForAccount()
{
var pid = this.RequireCurrentPlayerId();
var player = await db.Players
.Where(p => p.Id == pid)
.Select(p => new { p.IsJunior, p.BannedUntil })
.FirstOrDefaultAsync();
var allowed = player is not null
&& !player.IsJunior
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
return Ok(new { IsCreationAllowed = allowed, Allowed = allowed });
}
[HttpGet("isCreationEnabled")]
[AllowAnonymous]
public IActionResult IsCreationEnabled() => Content("true", "application/json");
[HttpGet("isRenderingEnabled")]
[AllowAnonymous]
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
[HttpGet("minPriceForPublicItem")]
[AllowAnonymous]
public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 });
[HttpPost("design")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Design()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadDesignRequestAsync();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
if (name.Length > 128) name = name[..128];
var description = (req.Description ?? string.Empty).Trim();
if (description.Length > 1024) description = description[..1024];
CustomAvatarItemEntity item;
if (req.Id is Guid id)
{
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
if (item.Id == 0) db.CustomAvatarItems.Add(item);
}
else
{
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
db.CustomAvatarItems.Add(item);
}
item.Name = name;
item.Description = description;
item.Price = Math.Max(100, req.Price ?? item.Price);
item.ItemType = req.ItemType ?? item.ItemType;
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
item.Color = Trim(req.Color, 64);
item.ImageName = Trim(req.ImageName, 256);
item.AssetName = Trim(req.AssetName, 256);
item.IsPublic = req.IsPublic ?? item.IsPublic;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await EnsureOwnershipAsync(pid, item.Id);
return Ok(ToWire(item));
}
[HttpPost("{id:guid}/purchase")]
[Authorize]
public async Task<IActionResult> Purchase(Guid id)
{
var pid = this.RequireCurrentPlayerId();
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
if (item is null) return NotFound();
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
var balance = await level.GetBalanceAsync(pid, 2);
if (balance < item.Price)
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
await EnsureOwnershipAsync(pid, item.Id);
item.PurchaseCount += 1;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
}
private async Task EnsureOwnershipAsync(long playerId, long itemId)
{
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
{
return;
}
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
{
PlayerId = playerId,
CustomAvatarItemId = itemId,
});
await db.SaveChangesAsync();
}
private async Task<List<Guid>> ReadIdsAsync(BulkRequest? body)
{
var result = new List<Guid>();
if (body?.Ids is { Count: > 0 })
result.AddRange(body.Ids);
if (body?.ItemIds is { Count: > 0 })
result.AddRange(body.ItemIds);
foreach (var pair in Request.Query)
foreach (var value in pair.Value)
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (Guid.TryParse(part, out var id)) result.Add(id);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" })
foreach (var value in form[key])
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (Guid.TryParse(part, out var id)) result.Add(id);
}
return result.Distinct().Take(200).ToList();
}
private async Task<DesignRequest> ReadDesignRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new DesignRequest
{
Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null,
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null,
BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null,
Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(),
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(),
IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<DesignRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new DesignRequest();
}
catch (JsonException)
{
return new DesignRequest();
}
}
private object ToWire(CustomAvatarItemEntity item) => new
{
Id = item.PublicId,
CustomAvatarItemId = item.PublicId,
CreatorId = (int)item.CreatorPlayerId,
CreatorPlayerId = (int)item.CreatorPlayerId,
item.Name,
item.Description,
item.Price,
item.ItemType,
item.BaseAvatarItemId,
item.Color,
item.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
item.AssetName,
item.IsPublic,
item.IsFeatured,
item.CheerCount,
item.PurchaseCount,
item.CreatedAt,
item.UpdatedAt,
Status = item.IsPublic ? 1 : 0,
};
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
public sealed class BulkRequest
{
public List<Guid>? Ids { get; set; }
public List<Guid>? ItemIds { get; set; }
}
public sealed class DesignRequest
{
public Guid? Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public int? Price { get; set; }
public int? ItemType { get; set; }
public int? BaseAvatarItemId { get; set; }
public string? Color { get; set; }
public string? ImageName { get; set; }
public string? AssetName { get; set; }
public bool? IsPublic { get; set; }
}
}
@@ -1,34 +1,11 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Events.V3;
[ApiController]
[Route("api/[controller]/v3")]
public class EventsController(DorkNetDbContext db) : ControllerBase
public class EventsController : ControllerBase
{
[HttpGet("list")]
public async Task<ActionResult<List<object>>> GetActiveEvents([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.Select(e => new
{
EventId = e.Id,
PlayerEventId = e.Id,
Name = e.Title,
e.Description,
StartTime = e.StartsAt,
EndTime = e.EndsAt,
CreatorPlayerId = (int)e.CreatorPlayerId,
e.RoomId,
e.Capacity,
})
.ToListAsync();
return Ok(rows.Cast<object>().ToList());
}
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
}
@@ -1,79 +0,0 @@
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.ExternalFriendInvite;
[ApiController]
[Authorize]
public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
{
var code = Guid.NewGuid().ToString("N")[..10];
var key = $"externalinvite:platform:{code}";
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = key,
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
return Ok(new { InviteCode = code, InviteUrl = url, Platform = platform, PlatformId = platformId ?? string.Empty });
}
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
public async Task<IActionResult> SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message)
{
var code = Guid.NewGuid().ToString("N")[..10];
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = $"externalinvite:text:{code}",
Value = $"{phoneNumber ?? string.Empty}|{message ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty });
}
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
public async Task<IActionResult> GetPlatformReferrers()
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows.Select(ToInviteWire));
}
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
public async Task<IActionResult> GetTextMessageReferrers()
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows.Select(ToInviteWire));
}
private static object ToInviteWire(PlayerSettingEntity row)
{
var parts = row.Value.Split('|');
return new
{
InviteCode = row.Key.Split(':').Last(),
Kind = row.Key.Contains(":text:") ? "text" : "platform",
Value = parts.FirstOrDefault() ?? string.Empty,
CreatedAt = parts.LastOrDefault() ?? string.Empty,
};
}
}
@@ -1,124 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.FreeGifts;
[ApiController]
[Authorize]
public class FreeGiftsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
{
[HttpPost("api/freegifts/v1/sendmultiple")]
public async Task<IActionResult> SendMultiple()
{
var fields = await ReadFieldsAsync();
var sender = this.RequireCurrentPlayerId();
var recipients = ReadLongList(fields, "recipientPlayerIds", "RecipientPlayerIds", "playerIds", "PlayerIds");
if (recipients.Count == 0) return BadRequest("missing_recipients");
var currencyType = ReadInt(fields, "currencyType", "CurrencyType") ?? 2;
var currency = Math.Max(0, ReadInt(fields, "currency", "Currency") ?? 0);
var xp = Math.Max(0, ReadInt(fields, "xp", "Xp") ?? 0);
var message = ReadString(fields, "message", "Message") ?? string.Empty;
var created = new List<(long RecipientPlayerId, GiftPackageEntity Gift)>();
foreach (var recipient in recipients.Distinct().Take(100))
{
if (!await db.Players.AnyAsync(p => p.Id == recipient)) continue;
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = (int)sender,
CurrencyType = currency > 0 ? currencyType : 0,
Currency = currency,
Xp = xp,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
Message = message,
Platform = -1,
PackageVariant = "FreeGift",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
created.Add((recipient, gift));
}
await db.SaveChangesAsync();
foreach (var row in created)
{
await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived,
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
}
return Ok(new { Success = true, Sent = created.Count });
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value;
return null;
}
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.Take(100)
.ToList();
}
@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.GameSight;
[ApiController]
[AllowAnonymous]
public class GameSightController : ControllerBase
{
[HttpPost("/api/gamesight/event")]
[HttpPost("/data/event")]
[HttpPost("/data/heartbeat")]
public IActionResult Event() => Ok(new { Success = true });
}
@@ -274,48 +274,6 @@ public class ImagesController(
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
}
public sealed class ImageBulkRequest
{
public List<long>? ImageIds { get; set; }
public List<long>? SavedImageIds { get; set; }
}
[HttpGet("api/images/v5/bulk")]
[HttpPost("api/images/v5/bulk")]
[Authorize]
public async Task<IActionResult> BulkImages([FromBody] ImageBulkRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List<long>())
.Concat(Request.Query.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0))
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Photos
.Where(p => ids.Contains(p.Id) && p.DeletedAt == null
&& (p.IsPublic || p.UploaderPlayerId == pid))
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
[HttpGet("api/images/v6")]
[AllowAnonymous]
public async Task<IActionResult> ImagesV6([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>GET <c>api/images/v2/named</c> — the watch's
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
@@ -396,63 +354,19 @@ public class ImagesController(
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
[HttpGet("api/images/v2/player/{accountId:long}")]
[HttpGet("api/images/v3/player/{accountId:long}")]
[HttpGet("api/images/v4/player/{accountId:long}")]
[HttpGet("api/images/v5/player/{accountId:long}")]
public async Task<IActionResult> GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] int sort = 0)
[FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var query = db.Photos
var rows = await db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = sort switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
[HttpGet("api/images/v2/room/{roomId:long}")]
[HttpGet("api/images/v3/room/{roomId:long}")]
[HttpGet("api/images/v4/room/{roomId:long}")]
[HttpGet("api/images/v5/room/{roomId:long}")]
public async Task<IActionResult> GetByRoom(
long roomId,
[FromQuery] int take = 50,
[FromQuery] int skip = 0,
[FromQuery] int sort = 0,
[FromQuery] int filter = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var query = db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = sort switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll
@@ -510,7 +424,6 @@ public class ImagesController(
}
[HttpPost("api/images/v4/cheered/bulk")]
[HttpPost("api/images/v5/cheered/bulk")]
[Authorize]
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
{
@@ -1,57 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.IncentivizedReferrals;
[ApiController]
[Authorize]
[Route("api/incentivizedreferrals")]
public class IncentivizedReferralsController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("")]
[HttpGet("progress")]
public async Task<IActionResult> Progress()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
var claimed = await db.PlayerSettings
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
return Ok(new
{
ReferralCount = referralCount,
RequiredReferralCount = 1,
CanClaim = referralCount >= 1 && !claimed,
Claimed = claimed,
RewardCurrencyType = 2,
RewardCurrency = 250,
});
}
[HttpPost("claim")]
public async Task<IActionResult> Claim()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
if (referralCount < 1)
return Ok(new { Success = false, Error = "not_enough_referrals" });
if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"))
return Ok(new { Success = false, Error = "already_claimed" });
var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = "referral:reward_claimed",
Value = DateTime.UtcNow.ToString("O"),
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Balance = balance, CurrencyType = 2, Currency = 250 });
}
}
@@ -1,115 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
[ApiController]
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/influencerpartnerprogram/influencers")]
[AllowAnonymous]
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
.Distinct()
.Take(take)
.ToListAsync();
var players = await db.Players
.Where(p => creatorIds.Contains(p.Id))
.OrderBy(p => p.Username)
.ToListAsync();
var influencers = players.Select(ToInfluencerWire).ToList();
return Ok(new Dictionary<string, object>
{
["Influencers"] = influencers,
["influencers"] = influencers,
["Accounts"] = influencers,
["accounts"] = influencers,
});
}
[HttpGet("api/influencerpartnerprogram/influencer")]
[AllowAnonymous]
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
{
var player = accountId is long id
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (player is null) return NotFound();
return Ok(ToInfluencerWire(player));
}
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
[Authorize]
public async Task<IActionResult> MyInfluencer()
{
var me = this.RequireCurrentPlayerId();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
if (setting is null || !long.TryParse(setting.Value, out var influencerId))
return Ok(new { Influencer = (object?)null });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == influencerId);
return Ok(new { Influencer = player is null ? null : ToInfluencerWire(player) });
}
[HttpPost("api/influencerpartnerprogram/support")]
[Authorize]
public async Task<IActionResult> Support([FromForm] long? influencerAccountId, [FromForm] string? username)
{
var me = this.RequireCurrentPlayerId();
var influencer = influencerAccountId is long id
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (influencer is null) return NotFound();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
if (setting is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = "influencer:supported",
Value = influencer.Id.ToString(),
});
}
else
{
setting.Value = influencer.Id.ToString();
}
await db.SaveChangesAsync();
return Ok(new { Success = true, Influencer = ToInfluencerWire(influencer) });
}
[HttpPost("api/influencerpartnerprogram/remove")]
[Authorize]
public async Task<IActionResult> Remove()
{
var me = this.RequireCurrentPlayerId();
await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key == "influencer:supported")
.ExecuteDeleteAsync();
return Ok(new { Success = true });
}
private static RecNetAccount ToInfluencerWire(PlayerEntity player) => new()
{
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,
CreatedAt = player.CreatedAt == default ? DateTime.UtcNow : player.CreatedAt,
};
}
@@ -40,9 +40,6 @@ public class InventionsController(
// ── Browse ───────────────────────────────────────────────────────────
[HttpGet("api/inventions/v1/featured")]
[HttpGet("api/inventions/v1/featureddormskins")]
[HttpGet("api/inventions/v1/toptoday")]
[HttpGet("api/inventions/v3/popular")]
public async Task<ActionResult> Popular([FromQuery] int take = 50)
{
@@ -81,7 +78,6 @@ public class InventionsController(
public Task<ActionResult> Mine() => Saved(100);
[HttpGet("api/inventions/v1/search")]
[HttpGet("api/inventions/v2/search")]
public async Task<ActionResult> Search([FromQuery] string value = "")
{
if (string.IsNullOrWhiteSpace(value))
@@ -131,62 +127,17 @@ public class InventionsController(
public sealed record InventionBatchRequest(List<long> InventionIds);
[HttpPost("api/inventions/v1/batch")]
[HttpPost("api/inventions/v2/batch")]
[HttpPost("api/inventions/v1/dormskinsfromids")]
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
{
return await BatchIdsAsync(req?.InventionIds ?? []);
}
[HttpGet("api/inventions/v1/batch")]
[HttpGet("api/inventions/v2/batch")]
[HttpGet("api/inventions/v1/dormskinsfromids")]
public async Task<ActionResult> BatchFromQuery()
{
var ids = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
return await BatchIdsAsync(ids);
}
private async Task<ActionResult> BatchIdsAsync(IReadOnlyCollection<long> requestedIds)
{
if (requestedIds.Count == 0)
if (req?.InventionIds is null || req.InventionIds.Count == 0)
return Ok(Array.Empty<object>());
var ids = requestedIds.Take(200).ToList();
var ids = req.InventionIds.Take(200).ToList();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v1/fromcreators")]
public async Task<ActionResult> FromCreators()
{
var creatorIds = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (creatorIds.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
/// response as <c>RecNet.GetFiltersResponse</c> (an
@@ -405,7 +356,6 @@ public class InventionsController(
/// save flow. v4 wraps the new invention plus its first version in
/// a Status/Invention/InventionVersion object.</summary>
[HttpPost("api/inventions/v4/save")]
[HttpPost("api/inventions/v6/save")]
[Authorize]
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
{
@@ -476,7 +426,6 @@ public class InventionsController(
int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/addversion")]
[HttpPost("api/inventions/v4/addversion")]
[Authorize]
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
{
@@ -542,22 +491,6 @@ public class InventionsController(
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/updateprice")]
[Authorize]
public async Task<ActionResult> UpdatePrice(
[FromQuery] long inventionId,
[FromQuery] int price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.Price = Math.Clamp(price, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/delete")]
[Authorize]
public async Task<ActionResult> Delete([FromQuery] long inventionId)
@@ -752,7 +685,7 @@ public class InventionsController(
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
i.Price,
Price = 0,
HideFromPlayer = false,
};
@@ -66,13 +66,6 @@ public class InventoryController(
public string PrefabName { get; set; } = string.Empty;
public string ModificationGuid { get; set; } = string.Empty;
public bool Favorited { get; set; }
// The 2023 client sends Equipped/IsEquipped when a weapon skin is
// equipped (distinct from Favorited). We only have one IsActive flag,
// and for skins "active" == "equipped", so honor whichever the client
// sent. Without this the equip was dropped (only Favorited was read)
// and the skin never stuck.
public bool? Equipped { get; set; }
public bool? IsEquipped { get; set; }
}
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
@@ -110,27 +103,7 @@ public class InventoryController(
// to themselves).
return Forbid();
}
// "equipped" is the meaningful state for weapon skins; prefer it,
// fall back to Favorited for plain tool-pin updates.
var active = req.Equipped ?? req.IsEquipped ?? req.Favorited;
// One skin equipped per weapon: when equipping a catalog-backed skin,
// clear the equipped flag on the player's OTHER skins for the SAME
// prefab (slugs share the "{prefix}{prefab}:" head) so the weapon
// doesn't end up with two "equipped" skins.
if (active && !string.IsNullOrWhiteSpace(req.ModificationGuid)
&& !string.IsNullOrWhiteSpace(req.PrefabName))
{
var prefabPrefix = StoreService.EquipmentSkinSlug(req.PrefabName, string.Empty);
var siblings = await db.PlayerInventory
.Where(inv => inv.PlayerId == pid && inv.IsActive
&& inv.ItemSlug != slug
&& inv.ItemSlug.StartsWith(prefabPrefix))
.ToListAsync();
foreach (var sib in siblings) sib.IsActive = false;
}
row.IsActive = active;
row.IsActive = req.Favorited;
await db.SaveChangesAsync();
return Ok(new { Updated = true });
}
@@ -209,44 +182,6 @@ public class InventoryController(
return Ok(new { row.Id, Count = row.Quantity });
}
[HttpPost("api/consumables/v1/transfer")]
public async Task<IActionResult> TransferConsumable(
[FromForm] long? id,
[FromForm] long? recipientPlayerId,
[FromForm] int quantity = 1)
{
var pid = Me;
var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
var amount = Math.Max(1, quantity);
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
return BadRequest("invalid_transfer");
var source = await db.PlayerInventory.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == inventoryId);
if (source is null || source.Quantity < amount) return NotFound();
var target = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == recipient && p.ItemSlug == source.ItemSlug);
if (target is null)
{
target = new PlayerInventoryEntity
{
PlayerId = recipient,
ItemSlug = source.ItemSlug,
Quantity = 0,
};
db.PlayerInventory.Add(target);
}
source.Quantity -= amount;
target.Quantity += amount;
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded,
new { target.Id, target.ItemSlug, target.Quantity });
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
}
public sealed class ActivateConsumableRequest
{
public long Id { get; set; }
@@ -288,11 +223,7 @@ public class InventoryController(
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = rarity,
// IsActive is the equipped/active flag; surface it under every key
// the 2023 client reads so the equipped skin shows on reload.
Favorited = inv.IsActive,
Equipped = inv.IsActive,
IsEquipped = inv.IsActive,
};
}
@@ -1,134 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ItemWishlists;
[ApiController]
[Route("api/itemWishlists")]
[Authorize]
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet]
[HttpGet("v1/wishlist/me")]
public async Task<IActionResult> Mine()
{
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == Me)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("v1/wishlist")]
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId)
{
var pid = playerId is > 0 ? playerId.Value : Me;
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == pid)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost]
[HttpPost("v1/wishlist")]
public async Task<IActionResult> Add()
{
var req = await ReadRequestAsync();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
if (key.Length > 128) key = key[..128];
var itemType = req.ItemType ?? 0;
var exists = await db.ItemWishlists.AnyAsync(w =>
w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType);
if (!exists)
{
db.ItemWishlists.Add(new ItemWishlistEntity
{
PlayerId = Me,
ItemKey = key,
ItemType = itemType,
});
await db.SaveChangesAsync();
}
return await Mine();
}
[HttpDelete("v1/wishlist")]
[HttpPost("v1/wishlist/remove")]
public async Task<IActionResult> Remove()
{
var req = await ReadRequestAsync();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
var itemType = req.ItemType ?? 0;
await db.ItemWishlists
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType)
.ExecuteDeleteAsync();
return await Mine();
}
private async Task<WishlistRequest> ReadRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new WishlistRequest
{
ItemKey = form["itemKey"].FirstOrDefault() ?? form["ItemKey"].FirstOrDefault(),
ItemId = form["itemId"].FirstOrDefault() ?? form["ItemId"].FirstOrDefault(),
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var type) ? type : null,
};
}
var queryKey = Request.Query["itemKey"].FirstOrDefault() ?? Request.Query["ItemKey"].FirstOrDefault();
var queryId = Request.Query["itemId"].FirstOrDefault() ?? Request.Query["ItemId"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(queryKey) || !string.IsNullOrWhiteSpace(queryId))
{
return new WishlistRequest
{
ItemKey = queryKey,
ItemId = queryId,
ItemType = int.TryParse(Request.Query["itemType"].FirstOrDefault() ?? Request.Query["ItemType"].FirstOrDefault(), out var type) ? type : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<WishlistRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new WishlistRequest();
}
catch (JsonException)
{
return new WishlistRequest();
}
}
private static object ToWire(ItemWishlistEntity row) => new
{
row.Id,
row.PlayerId,
row.ItemKey,
ItemId = row.ItemKey,
row.ItemType,
row.CreatedAt,
};
public sealed class WishlistRequest
{
public string? ItemKey { get; set; }
public string? ItemId { get; set; }
public int? ItemType { get; set; }
}
}
@@ -1,246 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Keepsakes;
[ApiController]
[Route("api/keepsakes")]
[Authorize]
public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet]
public async Task<IActionResult> Mine()
{
await EnsureLoginKeepsakeAsync(Me);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me)
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("categories")]
[AllowAnonymous]
public IActionResult Categories()
{
var results = new[]
{
new { KeepsakeCategoryId = 0, VisualId = "account", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
new { KeepsakeCategoryId = 1, VisualId = "event", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
new { KeepsakeCategoryId = 2, VisualId = "room", LimitPerRoom = 64, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
};
return Ok(new
{
Results = results,
TotalResults = results.Length,
});
}
[HttpGet("events")]
public async Task<IActionResult> Events()
{
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me && k.Category == "event")
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("events/{eventId:long}")]
public async Task<IActionResult> EventInstances(long eventId)
{
var eventKey = eventId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me
&& k.Category == "event"
&& (k.EventKey == eventKey
|| k.EventKey == $"event:{eventKey}"
|| k.EventKey == $"event/{eventKey}"
|| EF.Functions.Like(k.EventKey, $"event:{eventKey}:%")
|| EF.Functions.Like(k.EventKey, $"event/{eventKey}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(new
{
KeepsakeProgressionEventId = eventId,
Instances = rows.Select(ToWire),
CollectionRecords = Array.Empty<object>(),
KeepsakeProgressionEventIds = new[] { eventId },
});
}
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomKeepsakes(long roomId)
{
var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId);
if (!roomExists) return NotFound();
var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.Category == "room"
&& (k.EventKey == $"room:{roomIdText}"
|| k.EventKey == $"room/{roomIdText}"
|| k.EventKey == roomIdText
|| EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%")
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList();
return Ok(new
{
Instances = instances,
CollectionRecords = Array.Empty<object>(),
KeepsakeProgressionEventIds = Array.Empty<long>(),
});
}
[HttpGet("globalconfig")]
[AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new
{
KeepsakeFeatureEnabled = true,
KeepsakeRoomLimit = 64,
SocialXpBoostEnabled = false,
});
[HttpPost]
public async Task<IActionResult> Create()
{
var req = await ReadRequestAsync();
var key = Trim(req.EventKey, 128);
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
var category = Trim(req.Category, 64);
if (category.Length == 0) category = "event";
var existing = await db.Keepsakes.FirstOrDefaultAsync(k =>
k.PlayerId == Me && k.EventKey == key);
if (existing is null)
{
existing = new KeepsakeEntity
{
PlayerId = Me,
EventKey = key,
};
db.Keepsakes.Add(existing);
}
existing.Category = category;
existing.Title = Trim(req.Title, 128);
existing.Description = Trim(req.Description, 1024);
existing.ImageName = Trim(req.ImageName, 256);
existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(existing));
}
private async Task EnsureLoginKeepsakeAsync(long playerId)
{
const string key = "first-login";
if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key))
return;
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = playerId,
Category = "account",
EventKey = key,
Title = "First Login",
Description = "Joined this server.",
});
await db.SaveChangesAsync();
}
private async Task<KeepsakeRequest> ReadRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new KeepsakeRequest
{
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<KeepsakeRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new KeepsakeRequest();
}
catch (JsonException)
{
return new KeepsakeRequest();
}
}
private object ToWire(KeepsakeEntity row) => new
{
row.Id,
row.PlayerId,
row.Category,
row.EventKey,
row.Title,
row.Description,
row.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}",
row.EarnedAt,
};
private static object ToRoomInstanceWire(KeepsakeEntity row, long roomId)
{
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
? (int)row.PlayerId
: 0;
return new
{
KeepsakeInstanceId = StableKeepsakeInstanceId(row.Id).ToString("D"),
KeepsakeCategoryConfigId = CategoryId(row.Category),
PlacedByAccountId = placedByAccountId,
RoomId = roomId,
SubRoomId = (long?)null,
};
}
private static int CategoryId(string? category) =>
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
0;
private static Guid StableKeepsakeInstanceId(long id)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"keepsakeInstance:{id}"));
return new Guid(hash);
}
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
public sealed class KeepsakeRequest
{
public string? Category { get; set; }
public string? EventKey { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public DateTime? EarnedAt { get; set; }
}
}
@@ -114,18 +114,7 @@ public class PlatformLoginController(
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
[HttpDelete("cached")]
[HttpDelete("cachedlogins")]
public async Task<IActionResult> RemoveCachedLogin([FromQuery] long? accountId = null)
{
var targetId = accountId ?? this.CurrentPlayerId();
if (targetId is not long playerId) return Unauthorized();
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return NotFound();
player.LastPlatform = 0;
player.LastPlatformId = string.Empty;
await db.SaveChangesAsync();
return Ok(new { Success = true, AccountId = (int)player.Id });
}
public IActionResult RemoveCachedLogin() => Ok(new { });
// Login.RefreshLogin → POST api/platformlogin/refresh
// Response is Login.RefreshLoginResponse { Token } — single field, the
@@ -1,54 +0,0 @@
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.PlayStationPlus;
[ApiController]
[Authorize]
public class PlayStationPlusController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/playstationplus/membership")]
public async Task<IActionResult> Membership()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
var expires = row is not null && DateTime.TryParse(row.Value, out var parsed)
? parsed
: DateTime.MinValue;
var active = expires > DateTime.UtcNow;
return Ok(new
{
IsMember = active,
MembershipActive = active,
ExpiresAt = active ? expires : (DateTime?)null,
});
}
[HttpPost("api/playstationplus/expire")]
public async Task<IActionResult> Expire()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = "playstationplus:expires",
Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"),
});
}
else
{
row.Value = DateTime.UtcNow.AddSeconds(-1).ToString("O");
}
await db.SaveChangesAsync();
return Ok(new { Success = true, IsMember = false });
}
}
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -46,7 +45,6 @@ public class PlayerEventsController(
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
[HttpGet("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> GetOne(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
@@ -54,121 +52,6 @@ public class PlayerEventsController(
return Ok(ToWire(ev));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/search")]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task<ActionResult> Search(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow);
if (needle.Length > 0)
{
rows = rows.Where(e =>
e.Title.ToLower().Contains(needle) ||
e.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderBy(e => e.StartsAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/room/{roomId:long}")]
public async Task<ActionResult> ForRoom(long roomId)
{
var rows = await db.PlayerEvents
.Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
public async Task<ActionResult> ForClub(long clubId)
{
var memberIds = await db.ClubMemberships
.Where(m => m.ClubId == clubId)
.Select(m => m.PlayerId)
.ToListAsync();
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
var roomIds = await db.Rooms
.Where(r => memberIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/clubs")]
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var clubPlayerIds = await db.ClubMemberships
.Select(m => m.PlayerId)
.Distinct()
.ToListAsync();
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
var roomIds = await db.Rooms
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed class EventBulkRequest
{
public List<long>? PlayerEventIds { get; set; }
public List<long>? EventIds { get; set; }
public List<long>? Ids { get; set; }
}
[AllowAnonymous]
[HttpPost("api/playerevents/v1/bulk")]
[HttpGet("api/playerevents/v1/bulk")]
public async Task<ActionResult> Bulk()
{
var ids = await ReadEventIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.PlayerEvents
.Where(e => ids.Contains(e.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/tagfilters")]
public IActionResult TagFilters() => Ok(new
{
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
});
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
@@ -210,39 +93,6 @@ public class PlayerEventsController(
return Ok(new { Created = created, Responses = responses });
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
{
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == accountId)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == accountId)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
@@ -383,123 +233,6 @@ public class PlayerEventsController(
return Ok(new { success = true, error = "" });
}
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
public async Task<IActionResult> UpdateName(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("name", "Name", "value");
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
[HttpPut("api/playerevents/v2/{eventId:long}/description")]
public async Task<IActionResult> UpdateDescription(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
public async Task<IActionResult> UpdateRoom(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value");
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
evt.RoomId = id;
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
[HttpPut("api/playerevents/v2/{eventId:long}/time")]
public async Task<IActionResult> UpdateTime(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt");
var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt");
if (start is DateTime s) evt.StartsAt = s;
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
[HttpPut("api/playerevents/v2/{eventId:long}/image")]
public async Task<IActionResult> UpdateImage(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "image", value.Trim());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
[HttpPut("api/playerevents/v2/{eventId:long}/accessibility")]
public async Task<IActionResult> UpdateAccessibility(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
if (value is not int accessibility) return BadRequest("missing_accessibility");
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
public async Task<IActionResult> UpdateTags(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "tags", value.Trim());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
public async Task<IActionResult> UpdateMultiInstance(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
public async Task<IActionResult> UpdateClub(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
return Forbid();
await SetEventSettingAsync(evt, "club", id.ToString());
return Ok(ToWire(evt));
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> Delete(long eventId)
{
@@ -564,44 +297,6 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = failed, Result = 0 });
}
public sealed class BroadcastRequest
{
public long PlayerEventId { get; set; }
public string? Message { get; set; }
}
[HttpPost("api/playerevents/v1/broadcast")]
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var recipients = await db.PlayerEventResponses
.Where(r => r.EventId == ev.Id && r.Response != 2)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
var body = string.IsNullOrWhiteSpace(req.Message)
? $"Update for '{ev.Title}'"
: req.Message.Trim();
foreach (var playerId in recipients.Where(id => id != Me))
{
db.Messages.Add(new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = playerId,
Body = body,
});
await notifications.NotifyAsync(playerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, ev.Title, Message = body });
}
await db.SaveChangesAsync();
return Ok(new { Success = true, Sent = recipients.Count });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
@@ -611,174 +306,6 @@ public class PlayerEventsController(
public string? Details { get; set; }
}
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
AddDelimitedIds(ids, value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
foreach (var value in form[key])
AddDelimitedIds(ids, value);
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
{
if (doc.RootElement.TryGetProperty(key, out var prop))
AddJsonIds(ids, prop);
}
}
else
{
AddJsonIds(ids, doc.RootElement);
}
}
catch (JsonException)
{
}
}
return ids.Distinct().Take(200).ToList();
}
private static void AddDelimitedIds(List<long> ids, string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
private static void AddJsonIds(List<long> ids, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
AddJsonIds(ids, item);
break;
case JsonValueKind.Number:
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
break;
case JsonValueKind.String:
AddDelimitedIds(ids, element.GetString());
break;
}
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var fields = await ReadRequestFieldsAsync();
foreach (var name in names)
if (fields.TryGetValue(name, out var value))
return value;
return null;
}
private async Task<long?> ReadLongFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return long.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return int.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return bool.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return DateTime.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
{
const string itemKey = "__playerevent_fields";
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
&& cached is Dictionary<string, string> existing)
{
return existing;
}
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? string.Empty
: prop.Value.GetRawText();
}
}
catch (JsonException)
{
}
}
HttpContext.Items[itemKey] = fields;
return fields;
}
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
{
var settingKey = $"playerevent:{evt.Id}:{key}";
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = evt.CreatorPlayerId,
Key = settingKey,
Value = value,
});
}
else
{
row.Value = value;
}
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
/// moderation report against an event. Wire response is
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
@@ -23,7 +23,7 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
/// </summary>
[ApiController]
[Authorize]
public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> logger) : ControllerBase
public class ReportsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
[AllowAnonymous]
@@ -202,7 +202,7 @@ public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> l
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return HileResult();
if (pid is not long me) return Ok(new { success = true });
var detail = (rawDetail ?? string.Empty).Trim();
if (type is int hileType)
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
@@ -215,22 +215,7 @@ public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> l
GameSessionId = gameSessionId ?? 0,
Category = "hile",
});
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
{
db.ChangeTracker.Clear();
logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy");
}
return HileResult();
await db.SaveChangesAsync();
return Ok(new { success = true });
}
private static IActionResult HileResult() => new ContentResult
{
Content = "true",
ContentType = "application/json",
StatusCode = StatusCodes.Status200OK,
};
}
@@ -36,7 +36,6 @@ public class PlayerStateController(
// values from PlayerEntity.Level / .XP. Default 1/0 when the
// account hasn't been seen yet (synthetic ids passed in queries).
[HttpGet("api/players/v1/progression/{accountId:long}")]
[HttpGet("api/players/v2/progression/{accountId:long}")]
public async Task<ActionResult<Progression>> GetProgression(long accountId)
{
var p = await db.Players
@@ -52,25 +51,12 @@ public class PlayerStateController(
}
[HttpPost("api/players/v1/progression/bulk")]
[HttpPost("api/players/v2/progression/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
return Ok(await BuildProgressionBulkAsync(idList));
}
[HttpGet("api/players/v2/progression/bulk")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulkV2Get()
{
var idList = ParseQueryIds().ToList();
return Ok(await BuildProgressionBulkAsync(idList));
}
private async Task<List<Progression>> BuildProgressionBulkAsync(IReadOnlyCollection<long> idList)
{
if (idList.Count == 0) return new List<Progression>();
if (idList.Count == 0) return Ok(new List<Progression>());
var rows = await db.Players
.Where(p => idList.Contains(p.Id))
.Select(p => new Progression
@@ -86,40 +72,26 @@ public class PlayerStateController(
rows.AddRange(idList
.Where(id => !found.Contains((int)id))
.Select(id => new Progression { PlayerId = (int)id }));
return rows;
return Ok(rows);
}
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
// by category. Each Cheer* count is the sum of distinct cheers
// received from other players in that category.
[HttpGet("api/playerReputation/v1/{accountId:long}")]
[HttpGet("api/playerReputation/v2/{accountId:long}")]
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
Ok(await BuildReputationAsync(accountId));
[HttpPost("api/playerReputation/v1/bulk")]
[HttpPost("api/playerReputation/v2/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
return Ok(await BuildReputationBulkAsync(idList));
}
[HttpGet("api/playerReputation/v2/bulk")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulkV2Get()
{
var idList = ParseQueryIds().ToList();
return Ok(await BuildReputationBulkAsync(idList));
}
private async Task<List<Reputation>> BuildReputationBulkAsync(IReadOnlyCollection<long> idList)
{
var result = new List<Reputation>(idList.Count);
foreach (var id in idList)
result.Add(await BuildReputationAsync(id));
return result;
return Ok(result);
}
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
@@ -244,23 +216,6 @@ public class PlayerStateController(
.Select(s => int.TryParse(s, out var v) ? v : 0)
.Where(v => v != 0);
private IEnumerable<long> ParseQueryIds()
{
foreach (var (key, values) in Request.Query)
{
if (long.TryParse(key, out var keyId) && keyId > 0)
yield return keyId;
foreach (var value in values)
{
if (long.TryParse(value, out var valueId) && valueId > 0)
yield return valueId;
foreach (var part in value.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var partId) && partId > 0)
yield return partId;
}
}
}
// RecNet.PlayerReporting.GetModerationBlockDetails
// IMPORTANT: returns a single object, not an array. Defaults indicate
// "user is not currently blocked / kicked".
@@ -1,63 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.PlayerWarnings;
[ApiController]
[Authorize]
public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/playerwarnings")]
public async Task<IActionResult> Warnings()
{
var me = this.RequireCurrentPlayerId();
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows
.Select(ToWarningWire)
.Where(w => !w.Acknowledged)
.ToList());
}
[HttpPost("api/playerwarnings/acknowledge")]
public async Task<IActionResult> Acknowledge([FromForm] string? warningId)
{
var me = this.RequireCurrentPlayerId();
var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(id)) return BadRequest("missing_warning");
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
if (row is null) return NotFound();
var parts = row.Value.Split('|');
row.Value = string.Join('|', parts.Take(3).Append("true"));
await db.SaveChangesAsync();
return Ok(new { Success = true, WarningId = id });
}
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
{
var parts = row.Value.Split('|');
return new WarningWire
{
WarningId = row.Key["warning:".Length..],
Message = parts.ElementAtOrDefault(0) ?? string.Empty,
Category = int.TryParse(parts.ElementAtOrDefault(1), out var category) ? category : 0,
CreatedAt = parts.ElementAtOrDefault(2) ?? string.Empty,
Acknowledged = bool.TryParse(parts.ElementAtOrDefault(3), out var acknowledged) && acknowledged,
};
}
private sealed class WarningWire
{
public string WarningId { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public int Category { get; set; }
public string CreatedAt { get; set; } = string.Empty;
public bool Acknowledged { get; set; }
}
}
@@ -21,20 +21,6 @@ public class PlayersController(PlayerService playerService, NotificationService
return Ok(PlayerService.ToProfile(player));
}
[HttpGet("/api/players/v4/current/contact")]
public async Task<IActionResult> CurrentContact()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(new
{
AccountId = (int)player.Id,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
CanReceiveInvites = player.CanReceiveInvites,
});
}
[HttpGet("{id:long}")]
public async Task<ActionResult<PlayerProfile>> GetById(long id)
{
@@ -1,120 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.ProgressionEvents;
[ApiController]
public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsService serverSettings) : ControllerBase
{
[HttpGet("api/progressionEvents")]
[Authorize]
public async Task<IActionResult> Progress()
{
var me = this.RequireCurrentPlayerId();
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key.StartsWith("progressionEvent:"))
.OrderByDescending(o => o.ClearedAt)
.Take(100)
.Select(o => new
{
EventKey = o.Key,
o.IsCompleted,
o.ClearedAt,
})
.ToListAsync();
return Ok(rows);
}
[HttpGet("api/progressionEvents/event/{eventId:long}")]
[AllowAnonymous]
public async Task<IActionResult> Event(long eventId)
{
var activeId = CurrentProgressionEventId();
if (eventId != activeId)
return NotFound();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var (start, end) = CurrentWeekWindow();
return Ok(new
{
ProgressionEventId = activeId,
Name = "Weekly Progression",
Rewards = new[]
{
new
{
ProgressionEventRewardId = activeId * 1000L,
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : activeId,
ImageName = string.Empty,
Xp = weekly.Reward.Xp,
RewardIndex = 0,
IsBonus = false,
},
},
KeepsakeRoomLists = Array.Empty<object>(),
StartTime = start,
EndTime = end,
CollectionEndTime = end,
UsesBoost = false,
BoostDailyGameplayMinutesLimit = 0,
BoostXpMultiplier = 1.0,
PurchasableXpBoostId = (Guid?)null,
ActiveExperiment = string.Empty,
ChallengesIconImageName = string.Empty,
RewardsPipImageName = string.Empty,
EventInfoImageName = string.Empty,
});
}
[HttpGet("api/progressionEvents/active")]
[AllowAnonymous]
public IActionResult Active() => Ok(CurrentProgressionEventId());
/// <summary>
/// The current player's progress record for a given progression event.
/// The 2023 client fetches this during InitialRoomLoad
/// (<c>api/progressionEvents/record/{id}</c>, where <c>{id}</c> is the
/// <c>yyyyMMdd</c> event id). A 404 here faults the room-load coroutine
/// with a NullReferenceException and traps the client in a dorm
/// matchmaking loop, so we always return a non-null record.
/// </summary>
[HttpGet("api/progressionEvents/record/{progressionEventId:long}")]
[Authorize]
public async Task<IActionResult> Record(long progressionEventId)
{
var me = this.RequireCurrentPlayerId();
// Count this player's completed progression-event objectives so the
// record reflects real progress rather than a flat zero.
var completed = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == me
&& o.IsCompleted
&& o.Key.StartsWith("progressionEvent:"));
return Ok(new
{
ProgressionEventId = progressionEventId,
Xp = 0,
ClaimedRewardIndex = completed > 0 ? completed - 1 : -1,
PurchasedXpBoostCount = 0,
DailyBoostGameplayMinutes = 0,
XpBoostExpiresAt = (DateTime?)null,
});
}
private static long CurrentProgressionEventId()
{
var (start, _) = CurrentWeekWindow();
return long.Parse(start.ToString("yyyyMMdd"));
}
private static (DateTime Start, DateTime End) CurrentWeekWindow()
{
var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek);
return (start, start.AddDays(7));
}
}
@@ -25,29 +25,15 @@ public class QuickPlayController(DorkNetDbContext db) : ControllerBase
/// immediately teleports — that's WHY users were randomly
/// spawning in Paddleball etc.
///
/// Correct behaviour: return the full no-target launch DTO unless
/// we actually have a queued match for the caller. The /set endpoint
/// is what queues; getandclear is the consume.
/// Correct behaviour: return empty {} unless we actually have a
/// queued match for the caller. The /set endpoint is what
/// queues; getandclear is the consume.
///
/// We don't persist queue state on a single-region private
/// server (no real matchmaking pool), so always return empty.</summary>
[HttpGet("api/quickPlay/v1/getandclear")]
[HttpGet("api/quickPlay/v2/getandclear")]
public IActionResult GetAndClear() => Ok(QuickPlayLaunchTarget.Empty);
private sealed class QuickPlayLaunchTarget
{
public static readonly QuickPlayLaunchTarget Empty = new();
public bool OKCGKFJELAC { get; init; }
public string INMKAFBAOPC { get; init; } = string.Empty;
public int? FIOPJIIOCGA { get; init; }
public string ICKOEFDNOPM { get; init; } = string.Empty;
public int? KLDCJBLLDKN { get; init; }
public bool OFBMKDAONNE { get; init; }
public object? FMKHKNCEBCN { get; init; }
public long? EEFNKAFLPLG { get; init; }
}
public IActionResult GetAndClear() => Ok(new { });
public sealed class SetQuickPlayRequest
{
@@ -451,76 +451,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
return Ok(new { Imported = added });
}
[HttpGet("api/relationships/mutualfriends")]
public async Task<IActionResult> MutualFriends([FromQuery] long playerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(Array.Empty<object>());
var mine = await FriendIdsAsync(me);
var theirs = await FriendIdsAsync(playerId);
var mutualIds = mine.Intersect(theirs).Take(100).ToList();
var players = await db.Players
.Where(p => mutualIds.Contains(p.Id))
.Select(p => new
{
AccountId = (int)p.Id,
p.Username,
p.DisplayName,
ProfileImage = p.ProfileImageName ?? string.Empty,
})
.ToListAsync();
return Ok(players);
}
[HttpPost("api/relationships/sendfriendintroductions")]
public async Task<IActionResult> SendFriendIntroductions(
[FromForm] long playerId,
[FromForm] long introducedPlayerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || introducedPlayerId <= 0)
return BadRequest("missing_player");
db.Messages.Add(new MessageEntity
{
SenderPlayerId = me,
RecipientPlayerId = playerId,
Type = 30,
Body = $"Friend introduction:{introducedPlayerId}",
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
[HttpPost("api/relationships/v1/addfriendwithcode")]
[HttpGet("api/relationships/v1/addfriendwithcode")]
public async Task<ActionResult> AddFriendWithCode([FromQuery] string? code, [FromForm] string? formCode)
{
var raw = (code ?? formCode ?? string.Empty).Trim();
if (raw.Length == 0) return Ok(EmptyRelationship(0));
long? id = long.TryParse(raw, out var parsed) ? parsed : null;
if (id is null)
{
id = await db.Players
.Where(p => p.Username == raw)
.Select(p => (long?)p.Id)
.FirstOrDefaultAsync();
}
if (id is not long playerId) return Ok(EmptyRelationship(0));
return await AddFriend(playerId);
}
// ── helpers ──────────────────────────────────────────────────────
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{
var rows = await db.Relationships
.Where(r => r.Status == EntityStatus.Friend
&& (r.RequesterId == playerId || r.TargetId == playerId))
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId)
.ToListAsync();
return rows.ToHashSet();
}
/// <summary>Map a storage row to the watch's wire RelationshipType
/// from <paramref name="me"/>'s perspective. PendingSent flips
/// direction depending on which side originated.</summary>
@@ -1,139 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.RoomConsumables;
[ApiController]
public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase
{
[HttpGet("api/roomconsumables")]
[HttpGet("api/roomconsumables/v1/roomConsumable")]
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}")]
[AllowAnonymous]
public async Task<IActionResult> List([FromRoute] long? roomId = null, [FromQuery] long? id = null)
{
var query = db.StoreItems
.Where(i => i.IsActive && i.Category.ToLower() == "consumable");
if (id is long itemId && itemId > 0)
query = query.Where(i => i.Id == itemId);
var rows = await query
.OrderBy(i => i.DisplayName)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}/me")]
[Authorize]
public async Task<IActionResult> MineForRoom(long roomId)
{
var me = this.RequireCurrentPlayerId();
var rows = await (from inventory in db.PlayerInventory
join item in db.StoreItems on inventory.ItemSlug equals item.Slug
where inventory.PlayerId == me
&& inventory.Quantity > 0
&& item.IsActive
&& item.Category.ToLower() == "consumable"
orderby item.DisplayName
select new { Inventory = inventory, Item = item })
.Take(200)
.ToListAsync();
return Ok(rows.Select(row => ToInventoryWire(row.Item, row.Inventory)));
}
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/currency")]
[Authorize]
public Task<IActionResult> PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) =>
Purchase(itemId, currencyType);
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/tokens")]
[Authorize]
public Task<IActionResult> PurchaseTokens(long itemId) => Purchase(itemId, 2);
private async Task<IActionResult> Purchase(long itemId, int? currencyType)
{
var me = this.RequireCurrentPlayerId();
var item = await db.StoreItems
.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive && i.Category.ToLower() == "consumable");
if (item is null) return NotFound();
var spendCurrency = currencyType ?? item.CurrencyType;
var balance = await level.GetBalanceAsync(me, spendCurrency);
if (balance < item.Price)
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, CurrencyType = spendCurrency });
var newBalance = await level.GrantCurrencyAsync(me, spendCurrency, -item.Price, $"roomConsumable:{item.Slug}");
var inventory = await db.PlayerInventory
.FirstOrDefaultAsync(i => i.PlayerId == me && i.ItemSlug == item.Slug);
if (inventory is null)
{
inventory = new PlayerInventoryEntity
{
PlayerId = me,
ItemSlug = item.Slug,
Quantity = 0,
};
db.PlayerInventory.Add(inventory);
}
inventory.Quantity += 1;
await db.SaveChangesAsync();
return Ok(new
{
Success = true,
Balance = newBalance,
CurrencyType = spendCurrency,
Item = ToWire(item),
Quantity = inventory.Quantity,
});
}
private static object ToWire(StoreItemEntity item)
{
var roomConsumableId = StableRoomConsumableId(item.Id).ToString("D");
return new
{
RoomConsumableId = roomConsumableId,
Name = item.DisplayName,
item.ImageName,
item.Description,
item.Price,
CurrencyId = (Guid?)null,
CreatedAt = DateTimeOffset.UnixEpoch,
ItemId = item.Id,
item.Slug,
item.CurrencyType,
item.Category,
};
}
private static object ToInventoryWire(StoreItemEntity item, PlayerInventoryEntity inventory)
{
var createdAt = inventory.AcquiredAt == default
? DateTimeOffset.UnixEpoch
: new DateTimeOffset(DateTime.SpecifyKind(inventory.AcquiredAt, DateTimeKind.Utc));
return new
{
Id = StableRoomConsumableId(item.Id).ToString("D"),
ConsumableItemDesc = ToWire(item),
Count = Math.Max(0, inventory.Quantity),
InitialCount = Math.Max(0, inventory.Quantity),
CreatedAt = createdAt,
ActiveDurationMinutes = 0,
IsActive = inventory.IsActive,
IsTransferable = false,
};
}
private static Guid StableRoomConsumableId(long itemId)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"roomConsumable:{itemId}"));
return new Guid(hash);
}
}
@@ -1,405 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.RoomCurrencies;
[ApiController]
[Authorize]
public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/roomcurrencies/v1/currencies")]
[AllowAnonymous]
public async Task<IActionResult> Currencies([FromQuery] long roomId)
{
var rows = await db.RoomCurrencies
.Where(c => !c.IsDeleted && (roomId <= 0 || c.RoomId == roomId))
.OrderBy(c => c.Name)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/roomcurrencies/v1/createCurrency")]
public async Task<IActionResult> CreateCurrency()
{
var fields = await ReadFieldsAsync();
var roomId = ReadLong(fields, "roomId", "RoomId");
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
if (room is null) return NotFound("room_not_found");
if (!await CanManageRoomAsync(room, Me)) return Forbid();
var name = Trim(ReadString(fields, "name", "Name") ?? "Room Currency", 64);
var currency = new RoomCurrencyEntity
{
RoomId = room.Id,
CreatorPlayerId = Me,
Name = name,
Description = Trim(ReadString(fields, "description", "Description"), 512),
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
DailyLimit = Math.Max(0, ReadInt(fields, "dailyLimit", "DailyLimit") ?? 0),
};
db.RoomCurrencies.Add(currency);
await db.SaveChangesAsync();
return Ok(ToWire(currency));
}
[HttpPost("api/roomcurrencies/v1/updateCurrency")]
[HttpPut("api/roomcurrencies/v1/updateCurrency")]
public async Task<IActionResult> UpdateCurrency()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64);
if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512);
if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256);
if (ReadInt(fields, "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit);
currency.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(currency));
}
[HttpGet("api/roomcurrencies/v1/getBalance")]
public async Task<IActionResult> GetBalance()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var playerId = ReadLong(fields, "playerId", "PlayerId") ?? Me;
var balance = await GetBalanceAsync(playerId, currency.Id);
return Ok(new
{
PlayerId = (int)playerId,
RoomCurrencyId = currency.PublicId,
Balance = balance,
});
}
[HttpGet("api/roomcurrencies/v1/getAllBalances")]
public async Task<IActionResult> GetAllBalances([FromQuery] long? roomId = null)
{
var playerId = Me;
var query = db.RoomCurrencyBalances
.Where(b => b.PlayerId == playerId)
.Join(db.RoomCurrencies, b => b.RoomCurrencyId, c => c.Id, (b, c) => new { b, c })
.Where(x => !x.c.IsDeleted);
if (roomId is long rid && rid > 0)
query = query.Where(x => x.c.RoomId == rid);
var rows = await query
.OrderBy(x => x.c.RoomId)
.ThenBy(x => x.c.Name)
.ToListAsync();
return Ok(rows.Select(x => new
{
PlayerId = (int)playerId,
RoomCurrencyId = x.c.PublicId,
x.b.Balance,
}));
}
[HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")]
public async Task<IActionResult> AwardCurrencyBulk()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
var playerIds = ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds");
var amount = ReadLong(fields, "amount", "Amount") ?? 0;
if (playerIds.Count == 0 || amount == 0) return BadRequest("missing_awards");
var results = new List<object>();
foreach (var playerId in playerIds)
{
var balance = await AddBalanceAsync(playerId, currency.Id, amount);
results.Add(new { PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, Balance = balance });
}
await db.SaveChangesAsync();
return Ok(results);
}
[HttpPost("api/roomcurrencies/v1/createPurchaseOffer")]
public async Task<IActionResult> CreatePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
var offer = new RoomCurrencyPurchaseOfferEntity
{
RoomCurrencyId = currency.Id,
Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64),
Amount = Math.Max(1, ReadInt(fields, "amount", "Amount") ?? 1),
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
};
db.RoomCurrencyPurchaseOffers.Add(offer);
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
[HttpPost("api/roomcurrencies/v1/updatePurchaseOffer")]
[HttpPut("api/roomcurrencies/v1/updatePurchaseOffer")]
public async Task<IActionResult> UpdatePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64);
if (ReadInt(fields, "amount", "Amount") is int amount) offer.Amount = Math.Max(1, amount);
if (ReadInt(fields, "price", "Price") is int price) offer.Price = Math.Max(0, price);
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType);
offer.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
[HttpPost("api/roomcurrencies/v1/deletePurchaseOffer")]
[HttpDelete("api/roomcurrencies/v1/deletePurchaseOffer")]
public async Task<IActionResult> DeletePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
offer.IsDeleted = true;
offer.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
[HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")]
[HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")]
[AllowAnonymous]
public async Task<IActionResult> GetPurchaseOffersBatch()
{
var fields = await ReadFieldsAsync();
var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds");
var currencyIds = ReadGuidList(fields, "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds");
var query = db.RoomCurrencyPurchaseOffers
.Where(o => !o.IsDeleted)
.Join(db.RoomCurrencies, o => o.RoomCurrencyId, c => c.Id, (o, c) => new { o, c })
.Where(x => !x.c.IsDeleted);
if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId));
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
var rows = await query.Take(200).ToListAsync();
return Ok(rows.Select(x => ToOfferWire(x.o, x.c)));
}
[HttpPost("api/roomCurrencies/v2/purchase")]
[HttpGet("api/roomCurrencies/v2/purchase")]
public async Task<IActionResult> Purchase()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var pid = Me;
var balance = await level.GetBalanceAsync(pid, offer.CurrencyType);
if (balance < offer.Price)
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, offer.CurrencyType });
var newBalance = offer.Price == 0
? balance
: await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}");
var customBalance = await AddBalanceAsync(pid, currency.Id, offer.Amount);
await db.SaveChangesAsync();
return Ok(new
{
Success = true,
Balance = newBalance,
offer.CurrencyType,
RoomCurrencyId = currency.PublicId,
RoomCurrencyBalance = customBalance,
});
}
private async Task<RoomCurrencyEntity?> FindCurrencyAsync(Dictionary<string, string> fields)
{
var guid = ReadGuid(fields, "roomCurrencyId", "RoomCurrencyId", "currencyId", "CurrencyId", "id", "Id");
if (guid is Guid publicId)
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
var localId = ReadLong(fields, "roomCurrencyInternalId", "internalId");
if (localId is long id)
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted);
return null;
}
private async Task<RoomCurrencyPurchaseOfferEntity?> FindOfferAsync(Dictionary<string, string> fields)
{
var guid = ReadGuid(fields, "purchaseOfferId", "PurchaseOfferId", "offerId", "OfferId", "id", "Id");
if (guid is Guid publicId)
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.PublicId == publicId && !o.IsDeleted);
var localId = ReadLong(fields, "purchaseOfferInternalId", "internalId");
if (localId is long id)
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.Id == id && !o.IsDeleted);
return null;
}
private async Task<long> GetBalanceAsync(long playerId, long currencyId) =>
await db.RoomCurrencyBalances
.Where(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId)
.Select(b => b.Balance)
.FirstOrDefaultAsync();
private async Task<long> AddBalanceAsync(long playerId, long currencyId, long amount)
{
var row = await db.RoomCurrencyBalances
.FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId);
if (row is null)
{
row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId };
db.RoomCurrencyBalances.Add(row);
}
row.Balance = Math.Max(0, row.Balance + amount);
row.UpdatedAt = DateTime.UtcNow;
return row.Balance;
}
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
return true;
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value;
return null;
}
private static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
long.TryParse(ReadString(fields, names), out var value) ? value : null;
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.Take(200)
.ToList();
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
private static object ToWire(RoomCurrencyEntity currency) => new
{
RoomCurrencyId = currency.PublicId,
Id = currency.PublicId,
InternalId = currency.Id,
currency.RoomId,
CreatorPlayerId = (int)currency.CreatorPlayerId,
currency.Name,
currency.Description,
currency.ImageName,
currency.DailyLimit,
currency.CreatedAt,
currency.UpdatedAt,
};
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
{
PurchaseOfferId = offer.PublicId,
RoomCurrencyPackageId = offer.PublicId,
Id = offer.PublicId,
InternalId = offer.Id,
RoomCurrencyId = currency.PublicId,
offer.Name,
offer.Amount,
offer.Price,
offer.CurrencyType,
offer.CreatedAt,
offer.UpdatedAt,
};
}
@@ -1,55 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.RoomEarningsDistributions;
[ApiController]
[Authorize]
public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/roomEarningsDistributions")]
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")]
public async Task<IActionResult> EarningsDistribution([FromQuery] long? roomId = null)
{
var me = this.RequireCurrentPlayerId();
var rooms = db.Rooms.Where(r => r.CreatorPlayerId == me);
if (roomId is long rid && rid > 0)
rooms = rooms.Where(r => r.Id == rid);
var roomRows = await rooms
.Select(r => new { r.Id, r.Name })
.ToListAsync();
var roomIds = roomRows.Select(r => r.Id).ToList();
var earnings = await db.RoomKeyPurchases
.Join(db.RoomKeys, p => p.RoomKeyId, k => k.Id, (p, k) => new { p, k })
.Where(x => roomIds.Contains(x.k.RoomId))
.GroupBy(x => x.k.RoomId)
.Select(g => new
{
RoomId = g.Key,
Gross = g.Sum(x => x.p.PaidPrice),
PurchaseCount = g.Count(),
})
.ToListAsync();
var byRoom = earnings.ToDictionary(e => e.RoomId);
return Ok(roomRows.Select(room =>
{
byRoom.TryGetValue(room.Id, out var value);
var gross = value?.Gross ?? 0;
return new
{
RoomId = room.Id,
room.Name,
GrossEarnings = gross,
NetEarnings = gross,
PurchaseCount = value?.PurchaseCount ?? 0,
CurrencyType = 2,
UpdatedAt = DateTime.UtcNow,
};
}).ToList());
}
}
@@ -42,22 +42,6 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Ok(rows.Select(ToWire));
}
[HttpGet("api/roomkeys")]
[HttpGet("api/roomkeys/v1")]
public async Task<IActionResult> List([FromQuery] long? roomId = null)
{
var rows = db.RoomKeys.Where(k => !k.IsDeleted);
if (roomId is long id && id > 0)
rows = rows.Where(k => k.RoomId == id);
var result = await rows
.OrderBy(k => k.RoomId)
.ThenBy(k => k.Price)
.Take(200)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[HttpPost("api/roomkeys/v1/create")]
public async Task<IActionResult> Create()
{
@@ -148,29 +132,6 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Content(owns ? "true" : "false", "application/json");
}
[HttpGet("api/roomkeys/v1/owns")]
public async Task<IActionResult> Owns([FromQuery] long roomKeyId)
{
var pid = Me;
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
return Content(owns ? "true" : "false", "application/json");
}
[HttpGet("api/roomkeys/v1/owns/bulk")]
[HttpPost("api/roomkeys/v1/owns/bulk")]
public async Task<IActionResult> OwnsBulk()
{
var ids = await ReadRoomKeyIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var pid = Me;
var owned = await db.RoomKeyPurchases
.Where(p => p.PlayerId == pid && ids.Contains(p.RoomKeyId))
.Select(p => p.RoomKeyId)
.ToListAsync();
var ownedSet = owned.ToHashSet();
return Ok(ids.Select(id => new { RoomKeyId = id, Owns = ownedSet.Contains(id) }).ToList());
}
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
{
Status = (int)status,
@@ -212,25 +173,6 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
.FirstOrDefaultAsync();
}
private async Task<List<long>> ReadRoomKeyIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "roomKeyIds", "RoomKeyIds", "ids", "Ids" })
foreach (var value in form[key])
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
return ids.Distinct().Take(200).ToList();
}
private async Task<T> ReadBodyAsync<T>() where T : new()
{
if (Request.HasFormContentType)
@@ -48,121 +48,12 @@ public class RoomsController(
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
/// </summary>
[HttpGet("api/rooms/v1/hot")]
public async Task<IActionResult> HotV1(
[HttpGet("rooms/hot")]
public async Task<IActionResult> Hot(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
[HttpGet("rooms/hot")]
public async Task<IActionResult> HotRoomServer(
[FromQuery] string? tag,
[FromQuery] string? tags,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
var all = await rooms.HotAsync(tag ?? tags, take: 200);
var page = all
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.Select(RoomService.ToWireRoom)
.ToList();
return Ok(new
{
Results = page,
TotalResults = all.Count,
});
}
[HttpGet("rooms/topcreators")]
[HttpGet("roomserver/rooms/topcreators")]
public async Task<IActionResult> TopCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.VisitCount)
.Take(250)
.ToListAsync();
var page = rows
.GroupBy(r => r.CreatorPlayerId)
.Select(g => g.First())
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToList();
return Ok(await BuildRoomServerListAsync(page));
}
[HttpGet("rooms/contestwinners")]
[HttpGet("roomserver/rooms/contestwinners")]
public async Task<IActionResult> ContestWinners([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.Where(r =>
EF.Functions.Like(r.TagsCsv, "%contest%") ||
EF.Functions.Like(r.TagsCsv, "%winner%") ||
EF.Functions.Like(r.TagsCsv, "%featured%"))
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
if (rows.Count == 0)
{
rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
}
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/fromcreators")]
[HttpPost("rooms/fromcreators")]
[HttpGet("roomserver/rooms/fromcreators")]
[HttpPost("roomserver/rooms/fromcreators")]
public async Task<IActionResult> FromCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var creatorIds = Request.Query["creatorId"]
.Concat(Request.Query["creatorIds"])
.Concat(Request.Query["accountId"])
.Concat(Request.Query["accountIds"])
.Concat(Request.Query["id"])
.SelectMany(v => (v ?? string.Empty).Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToList();
var query = PublicRoomQuery();
if (creatorIds.Count > 0)
query = query.Where(r => creatorIds.Contains(r.CreatorPlayerId));
var rows = await query
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.UpdatedAt)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/magic_door")]
[HttpGet("roomserver/rooms/magic_door")]
public async Task<IActionResult> MagicDoor()
{
var room = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Id)
.FirstOrDefaultAsync();
if (room is null) return NotFound();
return Ok((await BuildRoomServerListAsync(new[] { room })).First());
}
/// <summary>
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
/// "Recommended" carousel. <b>Wire shape is NOT a flat
@@ -216,38 +107,6 @@ public class RoomsController(
return Ok(result);
}
[HttpGet("rooms/{roomId:long}/similar")]
[HttpGet("roomserver/rooms/{roomId:long}/similar")]
public async Task<IActionResult> SimilarRooms(long roomId, [FromQuery] int take = 12)
{
var seed = await rooms.GetByIdAsync(roomId);
if (seed is null) return NotFound();
var tags = (seed.TagsCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
var query = PublicRoomQuery().Where(r => r.Id != roomId);
if (tags.Length > 0)
{
var primaryTag = tags[0];
query = query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{primaryTag}%"));
}
var similar = await query
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
var seedWire = (await BuildRoomServerListAsync(new[] { seed })).First();
var similarWire = await BuildRoomServerListAsync(similar);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["SeedRoom"] = seedWire,
["Rooms"] = similarWire,
});
}
/// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary>
@@ -293,7 +152,6 @@ public class RoomsController(
{
PinnedFilters = tags.PinnedTags,
PopularFilters = tags.PopularTags,
TrendingFilters = tags.TrendingTags,
});
}
@@ -301,11 +159,7 @@ public class RoomsController(
public async Task<IActionResult> Tags()
{
var tags = await serverSettings.GetPlayMenuTagsAsync();
return Ok(tags.PinnedTags
.Concat(tags.PopularTags)
.Concat(tags.TrendingTags)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray());
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
}
[HttpGet("api/rooms/v1/pinnedtags")]
@@ -316,10 +170,6 @@ public class RoomsController(
public async Task<IActionResult> PopularTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
[HttpGet("api/rooms/v1/trendingtags")]
public async Task<IActionResult> TrendingTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).TrendingTags);
// ── Single room lookups ──────────────────────────────────────────────
/// <summary>
@@ -337,39 +187,17 @@ public class RoomsController(
}
/// <summary>
/// `Rooms.GetById(roomId)` — same flat shape as ByName.
/// `Rooms.GetById(roomId)` — same shape as ByName.
/// </summary>
[HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")]
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> ById(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId)));
}
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomServerById(
long roomId,
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
var room = await rooms.GetByIdAsync(roomId) ?? Synthetic($"Room_{roomId}", roomId);
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return Ok(BuildRoomServerDetails(
room,
sceneRows,
roles: roles,
unityAssetTarget: unityAssetTarget,
unityAssetVersion: unityAssetVersion));
}
// ── RoomDetails (the boot-critical one) ─────────────────────────────
//
// /v4/details/{roomId} is hit *during* the matchmaking goto flow.
@@ -450,270 +278,6 @@ public class RoomsController(
return Ok(blobs);
}
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}</c>
/// — Studio room save metadata. The 2023 client can load baked Studio
/// scenes by first asking this endpoint which Unity asset bundle file
/// belongs to the save, then downloading it from
/// <c>cdn.../room/{Filename}.assetbundle</c>. The importer persists
/// the exact bundle filenames from the Studio dump on RoomScenes, so
/// this response is backed by the imported archive bytes rather than a
/// placeholder.</summary>
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")]
[Authorize]
public async Task<IActionResult> StudioSubRoomSave(
long roomId,
long subRoomId,
long saveId,
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scene = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId)
?? sceneRows.FirstOrDefault(s => s.StudioSubRoomDataSaveId == saveId);
if (scene is null) return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
}
/// <summary>GET <c>rooms/{roomId}/subrooms</c> — the subroom list the
/// 2023 client / Studio editor fetches
/// (<c>String.Format("rooms/{0}/subrooms", roomId)</c>). Emits the same
/// per-subroom shape as the <c>SubRooms[]</c> entries inside
/// <c>/roomserver/rooms/{id}</c>, so a single-scene room synthesises one
/// "Home" entry and a multi-scene imported room enumerates its
/// RoomScenes.</summary>
[HttpGet("rooms/{roomId:long}/subrooms")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> SubRooms(long roomId)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var wire = sceneRows.Count > 0
? sceneRows.Select(s => BuildSubRoomWire(room, s)).ToArray()
: new[] { BuildSubRoomWire(room, null) };
return Ok(wire);
}
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}</c> — one subroom
/// (<c>String.Format("rooms/{0}/subrooms/{1}", roomId, subRoomId)</c>).
/// <paramref name="subRoomId"/> is the RoomScene OrderIndex.</summary>
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var scene = await db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
.FirstOrDefaultAsync();
// A single-scene room has no RoomScene row; subRoom 0 is the
// synthesised "Home" scene.
if (scene is null && subRoomId != 0)
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
return Ok(BuildSubRoomWire(room, scene));
}
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/data</c> — the
/// current persisted save for the subroom
/// (<c>String.Format("rooms/{0}/subrooms/{1}/data", roomId, subRoomId)</c>).
/// Returns the same <c>SubRoomDataSave</c> shape as
/// <c>/saves/{saveId}</c> resolved to the scene's current save id.</summary>
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
public async Task<IActionResult> SubRoomData(
long roomId,
long subRoomId,
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var scene = await db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
.FirstOrDefaultAsync();
if (scene is null && subRoomId != 0)
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
var saveId = scene?.StudioSubRoomDataSaveId ?? 0L;
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
}
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/saves</c> — the
/// list of saves for a subroom
/// (<c>String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)</c>).
/// Returns the current save plus any historical blobs recorded for the
/// subroom, newest first, each in the <c>SubRoomDataSave</c> shape.</summary>
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
[Authorize]
public async Task<IActionResult> SubRoomSaves(long roomId, long subRoomId)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var scene = await db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
.FirstOrDefaultAsync();
if (scene is null && subRoomId != 0)
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
var currentSaveId = scene?.StudioSubRoomDataSaveId ?? 0L;
var saves = new List<object>
{
BuildStudioSaveWire(room, scene, subRoomId, currentSaveId, null, null),
};
// Historical blobs for this subroom (the same rows the datahistory
// endpoint surfaces), surfaced as prior saves so the Studio editor's
// version picker has the full set. Distinct by blob name, current
// save excluded (already first in the list).
var history = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId && b.SubRoomId == subRoomId)
.OrderByDescending(b => b.UploadedAt)
.Take(50)
.ToListAsync();
foreach (var h in history)
{
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName) &&
string.Equals(h.BlobName, scene!.DataBlobName, StringComparison.OrdinalIgnoreCase))
continue;
saves.Add(new
{
SubRoomDataSaveId = currentSaveId,
SubRoomId = subRoomId,
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
DataBlob = h.BlobName,
DataBlobName = h.BlobName,
PersistenceVersion = 41,
OMVersion = 0,
SavedByAccountId = h.UploadedByPlayerId,
SavedOnPlatform = 7,
SavedOnDeviceClass = 2,
SavedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
}
return Ok(saves);
}
/// <summary>Build one subroom entry in the wire shape the watch's
/// <c>SubRooms[]</c> array uses (see <c>BuildRoomServerDetails</c>). A
/// null <paramref name="scene"/> yields the synthesised "Home" scene for
/// single-scene rooms.</summary>
private object BuildSubRoomWire(RoomEntity room, RoomSceneEntity? scene)
{
var fallback = CurrentOrSyntheticDataBlobName(room);
var orderIndex = scene?.OrderIndex ?? 0;
var dataBlob = scene is null
? fallback
: SceneOrSyntheticDataBlobName(room, scene.DataBlobName, fallback);
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
return new
{
SubRoomId = (long)orderIndex,
RoomId = room.Id,
Name = scene?.Name ?? "Home",
DataBlob = dataBlob,
DataSavedAt = (modified == default ? DateTime.UtcNow : modified)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
IsSandbox = scene?.IsSandbox ?? false,
MaxPlayers = scene?.MaxPlayers ?? 8,
Accessibility = room.Accessibility,
UnitySceneId = scene?.RoomSceneLocationId ?? room.LocationReplicationId,
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
CurrentSave = new
{
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = (long)orderIndex,
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
DataBlob = dataBlob,
},
};
}
/// <summary>Build the <c>SubRoomDataSave</c> wire object for a Studio
/// save — shared by <c>/saves/{saveId}</c>, <c>/data</c>, and the first
/// entry of <c>/saves</c>. A null <paramref name="scene"/> (single-scene
/// room) returns a save with no baked bundles, pointing at the room's
/// current data blob.</summary>
private object BuildStudioSaveWire(
RoomEntity room,
RoomSceneEntity? scene,
long subRoomId,
long saveId,
int? unityAssetTarget,
int? unityAssetVersion)
{
var bundles = StudioBundlesForScene(scene, saveId);
if (bundles.Count == 0 && scene is not null)
{
logger.LogWarning(
"[rooms-studio-save] no asset bundles room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} csvLen={CsvLen}",
room.Id, subRoomId, saveId, scene.Name, scene.StudioAssetBundleNamesCsv?.Length ?? 0);
}
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
var bakedAssets = BuildStudioBakedAssets(room, scene, saveId, unityAssetTarget, unityAssetVersion);
var primary = bakedAssets.FirstOrDefault();
var unityAsset = BuildStudioUnityAsset(room, scene, bakedAssets);
var dataBlob = !string.IsNullOrWhiteSpace(scene?.DataBlobName)
? scene!.DataBlobName
: CurrentOrSyntheticDataBlobName(room);
logger.LogInformation(
"[rooms-studio-save] room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} target={Target} version={Version} primary={Primary} bundles={BundleCount}",
room.Id, subRoomId, saveId, scene?.Name ?? "Home", unityAssetTarget, unityAssetVersion,
primary?.Filename ?? string.Empty, bundles.Count);
return new
{
SubRoomDataSaveId = saveId,
SubRoomId = subRoomId,
UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty<string>(),
DataBlob = dataBlob,
DataBlobName = dataBlob,
PersistenceVersion = 41,
OMVersion = 0,
SavedByAccountId = room.CreatorPlayerId,
SavedOnPlatform = 7,
SavedOnDeviceClass = 2,
CreatedByAccountId = room.CreatorPlayerId,
UnityAssetHash = string.Empty,
UnityAsset = unityAsset,
UnityAssetFilename = primary?.Filename ?? string.Empty,
BakedUnityAssets = bakedAssets,
UnitySubAssets = Array.Empty<object>(),
};
}
/// <summary>POST <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata</c>
/// — the watch's "Go" button in the Restore Room dialog. Body is
/// form-urlencoded with a single <c>filename</c> field carrying the
@@ -1051,11 +615,7 @@ public class RoomsController(
EncryptVoiceChat = currentPresence.EncryptVoiceChat,
},
isOnline = true,
// 2023.03.21 client build version (baked "20230317"). MUST match
// the joining client's own version or it reports a version
// mismatch and refuses to join. Was a stale December-2020 value
// (20201210) carried over when this branch forked.
appVersion = 20230317,
appVersion = 20201210,
};
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
}
@@ -1522,19 +1082,10 @@ public class RoomsController(
SupportsWalkVR = src.SupportsWalkVR,
SupportsTeleportVR = src.SupportsTeleportVR,
AllowsJuniors = src.AllowsJuniors,
AllowNewUsers = src.AllowNewUsers,
MinLevel = src.MinLevel,
MaxPlayerCalculationMode = src.MaxPlayerCalculationMode,
LoadScreensJson = src.LoadScreensJson,
PromoImagesJson = src.PromoImagesJson,
PromoExternalContentJson = src.PromoExternalContentJson,
RoomWarningMask = src.RoomWarningMask,
CustomRoomWarning = src.CustomRoomWarning,
DisableMicAutoMute = src.DisableMicAutoMute,
LocationReplicationId = src.LocationReplicationId,
IsStudioRoom = src.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = src.IsRoomLinkedToRecRoomStudio,
StudioSessionId = src.StudioSessionId,
TagsCsv = src.TagsCsv,
CheerCount = src.CheerCount,
FavoriteCount = src.FavoriteCount,
@@ -1568,12 +1119,7 @@ public class RoomsController(
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.Select(t => (object)new { Type = 0, Tag = t })
.ToArray();
// Scenes population: every room must produce a Scenes[0] entry
@@ -1604,14 +1150,32 @@ public class RoomsController(
// no DeserializeRoomDataBlobCoroutine, the master init
// proceeds with default-zero permissions.
//
// If a room has a saved blob, use it. Otherwise point at
// a synthetic CDN key, which misses S3 and is served by
// RoomDataBlobService.GetDefaultBlob. That default is a captured
// PersistedRoomData (v38); the older synthetic role-only blob and
// empty DataBlobName path both leave the 2023 room-permissions
// runtime without data it dereferences during spawn.
// • Saved dorm / customisable rooms: DataBlobName is the
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
// written by StorageController on /upload). Unsaved dorms
// deliberately use "" so the baked DormRoom scene loads
// without deserializing the synthetic all-perms blob. The
// role-only default blob made December wait forever on
// StandaloneShapeContainer big-data during first-dorm entry.
// • AG-Original rooms: DataBlobName="" → completed-promise
// short-circuit → no download → no leaked stale-blob
// behaviour → master flow proceeds → spawn fires. Maker
// Pen stays locked, which matches public-server semantics
// (you can't edit the rec center).
//
// Heuristic: "customisable" iff it's the dorm OR a user-created
// room (CreatorPlayerId != 1, the seeded system account that
// owns AG-Originals like the rec center). User clones inherit
// the all-perms default blob until their first save replaces
// it via StorageController; the rec center / paintball / etc.
// serve "" so the persistence flow short-circuits.
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
var dataBlobName = CurrentOrSyntheticDataBlobName(room);
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: room.IsDormRoom ? ""
: customisable ? $"room_{room.Id}_v1.dat" : "";
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
@@ -1633,7 +1197,8 @@ public class RoomsController(
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
? overrideDataBlobName
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName),
: room.IsDormRoom ? dataBlobName
: s.DataBlobName,
MaxPlayers = s.MaxPlayers,
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
@@ -1720,150 +1285,78 @@ public class RoomsController(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = null,
int? unityAssetTarget = null,
int? unityAssetVersion = null)
IReadOnlyList<RoomRoleEntity>? roles = null)
{
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
// 2023's room-permissions runtime needs a real PersistedRoomData
// source during spawn. If no saved blob exists, use the captured
// default via the synthetic CDN path.
var dataBlobName = CurrentOrSyntheticDataBlobName(room);
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: room.IsDormRoom ? ""
: customisable ? $"room_{room.Id}_v1.dat" : "";
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ");
object[] subRooms = sceneRows is { Count: > 0 }
? sceneRows.Select(s =>
? sceneRows.Select(s => (object)new
{
var dataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
SubRoomId = (long)s.OrderIndex,
RoomId = room.Id,
Name = s.Name,
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
? overrideDataBlobName
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName);
// A scene with a baked Studio asset (non-empty UnityAssetId)
// must advertise a Studio-era PersistenceVersion on its
// CurrentSave. The 2023 client gates the baked-Addressables
// load path on this: a version of 0 (the old default) makes
// it treat the save as a pre-baked legacy blob and it never
// fetches unity_assets/{id}/{target}/{version}, so the
// persistence views reference prefabs that were never loaded
// ("Expected a prefab, but found none"). 41 matches the real
// RecRocks export (room.json PersistenceVersion=41).
var baked = !string.IsNullOrWhiteSpace(s.StudioUnityAssetId);
var saveVersion = baked ? StudioPersistenceVersion : 0;
var bakedAssets = BuildStudioBakedAssets(
room,
s,
s.StudioSubRoomDataSaveId ?? 0L,
unityAssetTarget,
unityAssetVersion);
var unityAsset = bakedAssets.FirstOrDefault();
var unityAssetParent = BuildStudioUnityAsset(room, s, bakedAssets);
return (object)new
{
ReplicationId = s.RoomSceneLocationId,
PersistenceVersion = saveVersion,
SupportsJoinInProgress = true,
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = (long)s.OrderIndex,
RoomId = room.Id,
Name = s.Name,
UnityAssetId = s.StudioUnityAssetId,
SubRoomUnityAssetId = s.StudioUnityAssetId,
UnityAsset = unityAsset?.Filename ?? string.Empty,
UnityAssetHash = string.Empty,
DataBlob = dataBlob,
DataBlobHash = (string?)null,
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
IsSandbox = s.IsSandbox,
MaxPlayers = s.MaxPlayers,
Accessibility = room.Accessibility,
UnitySceneId = s.RoomSceneLocationId,
CurrentSave = new
{
SubRoomDataSaveId = s.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = (long)s.OrderIndex,
UnityAssetId = s.StudioUnityAssetId,
SubRoomUnityAssetId = s.StudioUnityAssetId,
CreatedByAccountId = room.CreatorPlayerId,
ReferencedUnityAssetIds = Array.Empty<string>(),
DataBlob = dataBlob,
DataBlobHash = (string?)null,
PersistenceVersion = saveVersion,
OMVersion = 0,
SavedByAccountId = room.CreatorPlayerId,
SavedOnPlatform = 0,
SavedOnDeviceClass = 2,
Description = string.Empty,
ModerationState = 0,
CreatedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
UgcSubVersion = saveVersion,
UnityAssetHash = string.Empty,
UnityAsset = unityAssetParent,
UnityAssetFilename = unityAsset?.Filename ?? string.Empty,
BakedUnityAssets = bakedAssets,
UnitySubAssets = Array.Empty<object>(),
},
LastModeratedSaveModerationState = 0,
DefaultMatchmakingPolicy = 0,
ShouldAutoStageSaves = true,
StagedSubRoomDataSaveId = (long?)null,
};
: room.IsDormRoom ? dataBlobName
: s.DataBlobName,
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
IsSandbox = s.IsSandbox,
MaxPlayers = s.MaxPlayers,
Accessibility = room.Accessibility,
UnitySceneId = s.RoomSceneLocationId,
}).ToArray()
: new object[]
{
new
{
ReplicationId = room.LocationReplicationId,
PersistenceVersion = 0,
SupportsJoinInProgress = true,
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = 0L,
RoomId = room.Id,
Name = "Home",
UnityAssetId = string.Empty,
SubRoomUnityAssetId = string.Empty,
UnityAsset = string.Empty,
UnityAssetHash = string.Empty,
DataBlob = dataBlobName,
DataBlobHash = (string?)null,
DataSavedAt = updatedAt,
IsSandbox = false,
MaxPlayers = 8,
Accessibility = room.Accessibility,
UnitySceneId = room.LocationReplicationId,
CurrentSave = new
{
SubRoomDataSaveId = 0L,
SubRoomId = 0L,
UnityAssetId = string.Empty,
SubRoomUnityAssetId = string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
DataBlob = dataBlobName,
},
},
};
static int WireRole(int role) => role switch
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
var wireRoles = new List<object>
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 },
};
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
wireRoles.AddRange(roleList.Select(r =>
{
var role = WireRole(r.Role);
return (object)new
{
AccountId = r.PlayerId,
Role = r.Accepted ? role : 0,
InvitedRole = r.Accepted ? 0 : role,
};
}));
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.Select(t => (object)new { Type = 0, Tag = t })
.ToArray();
return new
@@ -1886,9 +1379,6 @@ public class RoomsController(
SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors,
AllowNewUsers = room.AllowNewUsers,
AllowsNewUsers = room.AllowNewUsers,
MinLevel = room.MinLevel,
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Stats = new
@@ -1899,105 +1389,19 @@ public class RoomsController(
VisitCount = room.VisitCount,
},
IsDorm = room.IsDormRoom,
IsStudioRoom = room.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = room.IsRoomLinkedToRecRoomStudio,
StudioSessionId = room.StudioSessionId,
CloningAllowed = room.CloningAllowed,
DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = false,
EncryptVoiceChat = false,
LoadScreenLocked = false,
// Studio/UGC version family. The 2023 client reads UgcVersion to
// decide the asset-bundle version it requests
// (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion
// to recognise a baked Studio room at all. Matches the real
// RecRocks room.json (UgcVersion=1, PersistenceVersion=41). For a
// non-Studio room these stay at the legacy 0 so behaviour is
// unchanged.
PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0,
UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"),
IsJuniorCreated = false,
IsRecRoomApproved = false,
ExcludeFromLists = false,
BoostCount = 0,
RestrictedCircuitsAllowListNames = Array.Empty<string>(),
PublishStateAvailability = new
{
CanSaveAsBeta = false,
CanSaveAsUpdate = true,
AvailableUpdateTokenCount = 3,
NextAvailableUpdateDateTimeUtc = (string?)null,
},
PublishState = 0,
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
SubRooms = subRooms,
Roles = wireRoles,
LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson),
PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson),
PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson),
LoadScreens = Array.Empty<object>(),
PromoImages = Array.Empty<string>(),
PromoExternalContent = Array.Empty<object>(),
Tags = tags,
};
}
private static int RoomTagTypeForWire(string tag)
{
if (string.Equals(tag, "beta", StringComparison.OrdinalIgnoreCase)) return 1;
return tag.ToLowerInvariant() switch
{
"rrstudio" or "community" or "screen" or "walkvr" or "teleportvr" or "junior" or "pickup" => 2,
_ => 0,
};
}
private static bool HasRoomTag(RoomEntity room, string tag)
{
return !string.IsNullOrWhiteSpace(room.TagsCsv) &&
room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase));
}
private static JsonElement[] JsonArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<JsonElement>();
try
{
return JsonSerializer.Deserialize<JsonElement[]>(json) ?? Array.Empty<JsonElement>();
}
catch (JsonException)
{
return Array.Empty<JsonElement>();
}
}
private static string[] JsonStringArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
}
catch (JsonException)
{
return Array.Empty<string>();
}
}
/// <summary>PersistenceVersion / UgcSubVersion stamped on a baked Studio
/// scene's CurrentSave. The 2023 client's room-load gate treats anything
/// below the Studio-asset-bundle era as a legacy non-baked save and skips
/// the Addressables bundle fetch. 41 matches the real RecRocks export
/// (room.json + the per-save sidecars).</summary>
public const int StudioPersistenceVersion = 41;
/// <summary>Room-level UgcVersion for a Studio room. The client uses this
/// as the asset-bundle version in unity_assets/{id}/{target}/{version}
/// (RecRocks bundles bake at v1, room.json UgcVersion=1).</summary>
public const int StudioUgcVersion = 1;
// ── Clone / create ───────────────────────────────────────────────────
@@ -2062,9 +1466,7 @@ public class RoomsController(
[HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/ownedby/{otherPlayerId:long}")]
[HttpGet("rooms/createdby/{otherPlayerId:long}")]
[HttpGet("rooms/ownedby/{otherPlayerId:long}")]
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
@@ -2074,36 +1476,7 @@ public class RoomsController(
[HttpGet("roomserver/rooms/visitedby/me")]
[HttpGet("rooms/visitedby/me")]
[Authorize]
public async Task<IActionResult> VisitedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return await VisitedBy(pid.Value);
}
[HttpGet("rooms/visitedby/{playerId:long}")]
[HttpGet("roomserver/rooms/visitedby/{playerId:long}")]
public async Task<IActionResult> VisitedBy(long playerId)
{
var ids = await db.RoomVisits.AsNoTracking()
.Where(v => v.PlayerId == playerId)
.OrderByDescending(v => v.LastVisitAt)
.Select(v => v.RoomId)
.Take(100)
.ToListAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.ToListAsync();
var byId = rows.ToDictionary(r => r.Id);
var ordered = ids
.Select(id => byId.TryGetValue(id, out var room) ? room : null)
.Where(r => r is not null)
.Select(r => r!)
.ToList();
return Ok(await BuildRoomServerListAsync(ordered));
}
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/moderatedby/me")]
[HttpGet("api/rooms/v2/moderatedby/me")]
@@ -2111,23 +1484,7 @@ public class RoomsController(
[HttpGet("roomserver/rooms/moderatedby/me")]
[HttpGet("rooms/moderatedby/me")]
[Authorize]
public async Task<IActionResult> ModeratedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var ids = await db.RoomRoles.AsNoTracking()
.Where(r => r.PlayerId == pid.Value && r.Accepted)
.OrderByDescending(r => r.GrantedAt)
.Select(r => r.RoomId)
.Distinct()
.Take(100)
.ToListAsync();
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.UpdatedAt)
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
public IActionResult ModeratedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/favoritedby/me")]
[HttpGet("api/rooms/v2/favoritedby/me")]
@@ -2135,13 +1492,7 @@ public class RoomsController(
[HttpGet("roomserver/rooms/favoritedby/me")]
[HttpGet("rooms/favoritedby/me")]
[Authorize]
public async Task<IActionResult> FavoritedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var rows = await rooms.BookmarkedByAsync(pid.Value);
return Ok(await BuildRoomServerListAsync(rows));
}
public IActionResult FavoritedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/cheeredby/me")]
[HttpGet("api/rooms/v2/cheeredby/me")]
@@ -2149,23 +1500,7 @@ public class RoomsController(
[HttpGet("roomserver/rooms/cheeredby/me")]
[HttpGet("rooms/cheeredby/me")]
[Authorize]
public async Task<IActionResult> CheeredByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var ids = await db.Cheers.AsNoTracking()
.Where(c => c.FromPlayerId == pid.Value && c.TargetRoomId != 0)
.OrderByDescending(c => c.CheeredAt)
.Select(c => c.TargetRoomId)
.Distinct()
.Take(100)
.ToListAsync();
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.HotScore)
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
public IActionResult CheeredByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
@@ -2183,146 +1518,6 @@ public class RoomsController(
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
[HttpGet("player_room_data/{roomId:long}")]
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task<IActionResult> PlayerDataForMe(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
var room = await db.Rooms.AsNoTracking()
.Where(r => r.Id == roomId)
.Select(r => new { r.Id, r.VisitCount, r.CheerCount, r.FavoriteCount })
.FirstOrDefaultAsync();
var favorited = await db.RoomBookmarks
.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid.Value);
var cheered = await db.Cheers
.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid.Value);
var visit = await db.RoomVisits.AsNoTracking()
.Where(v => v.RoomId == roomId && v.PlayerId == pid.Value)
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
.FirstOrDefaultAsync();
return Ok(new
{
Data = "CAE=",
RoomId = room?.Id ?? roomId,
PlayerId = pid.Value,
Favorite = favorited,
Favorited = favorited,
IsFavorite = favorited,
Cheer = cheered,
Cheered = cheered,
IsCheered = cheered,
IsBookmarked = favorited,
IsCheering = cheered,
VisitCount = visit?.VisitCount ?? 0,
FirstVisitedAt = visit?.FirstVisitAt,
LastVisitedAt = visit?.LastVisitAt,
RoomVisitCount = room?.VisitCount ?? 0,
RoomCheerCount = room?.CheerCount ?? 0,
RoomFavoriteCount = room?.FavoriteCount ?? 0,
});
}
[HttpGet("rooms/requiring/{restriction}")]
[HttpGet("roomserver/rooms/requiring/{restriction}")]
[Authorize]
public async Task<IActionResult> RoomsRequiring(string restriction)
{
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
if (key.Length == 0) return Ok(new List<string>());
IQueryable<RoomEntity> query = db.Rooms.AsNoTracking()
.Where(r => !r.HiddenFromBrowse && r.State == 0);
query = key switch
{
"developer" or "studio" or "rrstudio" => query.Where(r =>
r.IsStudioRoom
|| r.IsRoomLinkedToRecRoomStudio
|| EF.Functions.Like(r.TagsCsv, "%developer%")
|| EF.Functions.Like(r.TagsCsv, "%studio%")),
"rrplus" or "recroomplus" => query.Where(r =>
EF.Functions.Like(r.TagsCsv, "%rrplus%")
|| EF.Functions.Like(r.TagsCsv, "%recroomplus%")),
_ => query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{key}%")),
};
var rows = await query
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Select(r => r.Name)
.Take(100)
.ToListAsync();
return Ok(rows);
}
[HttpGet("rooms/curated_playlists")]
[HttpGet("roomserver/rooms/curated_playlists")]
[Authorize]
public async Task<IActionResult> CuratedPlaylistsCompat()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(p => p.Id).ToList());
}
[HttpGet("clubhousesearch/mostactivenow")]
[HttpGet("roomserver/clubhousesearch/mostactivenow")]
[Authorize]
public async Task<IActionResult> MostActiveClubhouses()
{
var rows = await db.Clubs.AsNoTracking()
.Where(c => c.State == 0 && c.ClubhouseRoomId != null)
.GroupJoin(
db.ClubMemberships.AsNoTracking(),
c => c.Id,
m => m.ClubId,
(club, memberships) => new
{
Club = club,
MemberCount = memberships.Count(),
})
.Join(
db.Rooms.AsNoTracking(),
c => c.Club.ClubhouseRoomId!.Value,
r => r.Id,
(c, room) => new
{
c.Club,
c.MemberCount,
Room = room,
})
.OrderByDescending(x => x.MemberCount)
.ThenByDescending(x => x.Room.HotScore)
.Take(50)
.ToListAsync();
return Ok(rows.Select(x => new
{
ClubId = x.Club.Id,
x.Club.Name,
x.Club.Description,
MainImageName = x.Club.ImageName,
ImageName = x.Club.ImageName,
State = x.Club.State,
CreatorAccountId = x.Club.CreatorPlayerId,
Category = x.Club.Category,
Visibility = x.Club.Visibility,
Joinability = x.Club.Joinability,
x.Club.AllowJuniors,
MemberCount = x.MemberCount,
x.Club.IsRRO,
x.Club.ClubhouseRoomId,
x.Club.ClubType,
Room = RoomService.ToWireRoom(x.Room),
RoomId = x.Room.Id,
RoomName = x.Room.Name,
}).ToList());
}
// ── Bookmark mutations ───────────────────────────────────────────────
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
@@ -2347,40 +1542,6 @@ public class RoomsController(
return Ok(new { success = true, error = "" });
}
private IQueryable<RoomEntity> PublicRoomQuery() =>
db.Rooms.AsNoTracking().Where(r =>
r.State == 0 &&
r.Accessibility == 1 &&
!r.IsDormRoom &&
!r.HiddenFromBrowse);
private async Task<List<object>> BuildRoomServerListAsync(IReadOnlyCollection<RoomEntity> roomRows)
{
if (roomRows.Count == 0) return new List<object>();
var ids = roomRows.Select(r => r.Id).Distinct().ToList();
var sceneRows = await db.RoomScenes.AsNoTracking()
.Where(s => ids.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var roleRows = await db.RoomRoles.AsNoTracking()
.Where(r => ids.Contains(r.RoomId))
.ToListAsync();
var rolesByRoom = roleRows
.GroupBy(r => r.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomRoleEntity>)g.ToList());
return roomRows
.Select(r => BuildRoomServerDetails(
r,
scenesByRoom.GetValueOrDefault(r.Id),
roles: rolesByRoom.GetValueOrDefault(r.Id)))
.ToList();
}
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
@@ -2412,25 +1573,17 @@ public class RoomsController(
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
/// the watch's group-id lookup.</summary>
[HttpGet("featuredrooms/current")]
public async Task<IActionResult> FeaturedRoomsCurrent()
public async Task<IActionResult> FeaturedRoomsCurrent() => Ok(new
{
var ids = await rooms.FeaturedAgRoomIdsAsync(12);
var featuredRooms = await db.Rooms
.Where(r => ids.Contains(r.Id))
.ToListAsync();
var byId = featuredRooms.ToDictionary(r => r.Id);
var wireRooms = ids
.Select(id => byId.TryGetValue(id, out var room) ? RoomService.ToWireRoom(room) : null)
.Where(room => room is not null)
.Cast<object>()
.ToList();
return Ok(new
{
FeaturedRoomGroupId = 1L,
Name = "Featured",
Rooms = wireRooms,
});
}
FeaturedRoomGroupId = 1L,
Name = "Featured",
// NMPFCIJPODA.PPGFHEDFBEA (NMPFCIJPODA.txt:100-125) reads
// 3 strict keys, not 2 — FeaturedRoomGroupId, Name, AND
// Rooms (List<PPKJFAAAGDO>). Returning without the third
// key throws KeyNotFoundException on the watch and the
// Featured carousel stays empty.
Rooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
@@ -2534,8 +1687,6 @@ public class RoomsController(
/// verifying ownership and intra-room uniqueness.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameSubroom(
long roomId,
@@ -2907,12 +2058,7 @@ public class RoomsController(
? new List<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.Select(t => (object)new { Type = 0, Tag = t })
.ToList();
// Start from BuildPlaylistUnionEntry for base + PlaylistId, then
@@ -3192,128 +2338,19 @@ public class RoomsController(
public string? RoomSceneLocationId { get; set; }
}
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
public sealed class PublishSubRoomSaveRequest
{
public long? SubRoomDataSaveId { get; set; }
public long? SaveId { get; set; }
public long? StagedSubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
public string? DataBlobName { get; set; }
public string? Filename { get; set; }
}
// The "Max Player Count in This Subroom" slider (SubroomModel.
// ChangeSubRoomMaxPlayers) POSTs here on the roomserver host. Like its
// siblings (accessibility/permissions/modify) the client prefixes the
// path with "roomserver/", so the bare-only routes 404'd and the slider's
// change silently never persisted — i.e. the limit couldn't be changed.
// The value flows back to the host via the subroom details MaxPlayers,
// which sets the Photon room cap.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) =>
MutateScene(roomId, subRoomId, s =>
{
if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto;
if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers);
if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox;
});
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
var request = await ReadPublishSubRoomSaveRequestAsync();
var saveId = request.SubRoomDataSaveId ?? request.SaveId ?? request.StagedSubRoomDataSaveId;
var dataBlob = request.DataBlobName ?? request.DataBlob ?? request.Filename;
if (!string.IsNullOrWhiteSpace(dataBlob))
{
var blobExists = await db.RoomDataBlobs.AnyAsync(b => b.RoomId == roomId && b.BlobName == dataBlob);
if (!blobExists) return NotFound(new { error = "blob_not_found", dataBlob });
scene.DataBlobName = dataBlob;
if (scene.OrderIndex == 0) room.CurrentDataBlobName = dataBlob;
}
if (saveId is > 0)
scene.StudioSubRoomDataSaveId = saveId.Value;
scene.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = scene.DataModifiedAt;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
private async Task<PublishSubRoomSaveRequest> ReadPublishSubRoomSaveRequestAsync()
{
var request = new PublishSubRoomSaveRequest();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (long.TryParse(FirstFormValue(form, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "SaveId", "stagedSubRoomDataSaveId", "StagedSubRoomDataSaveId"), out var saveId))
request.SubRoomDataSaveId = saveId;
request.DataBlobName = FirstFormValue(form, "dataBlobName", "DataBlobName", "dataBlob", "DataBlob", "filename", "Filename");
return request;
}
if (long.TryParse(Request.Query["subRoomDataSaveId"].FirstOrDefault()
?? Request.Query["SubRoomDataSaveId"].FirstOrDefault()
?? Request.Query["saveId"].FirstOrDefault()
?? Request.Query["SaveId"].FirstOrDefault()
?? Request.Query["stagedSubRoomDataSaveId"].FirstOrDefault()
?? Request.Query["StagedSubRoomDataSaveId"].FirstOrDefault(), out var querySaveId))
request.SubRoomDataSaveId = querySaveId;
request.DataBlobName = Request.Query["dataBlobName"].FirstOrDefault()
?? Request.Query["DataBlobName"].FirstOrDefault()
?? Request.Query["dataBlob"].FirstOrDefault()
?? Request.Query["DataBlob"].FirstOrDefault()
?? Request.Query["filename"].FirstOrDefault()
?? Request.Query["Filename"].FirstOrDefault();
if ((Request.ContentLength ?? 0) <= 0) return request;
try
{
var body = await JsonSerializer.DeserializeAsync<PublishSubRoomSaveRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return body ?? request;
}
catch (JsonException)
{
return request;
}
}
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate
/// a scene (DataBlobName + location pointer). New scene goes at the
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
@@ -3355,13 +2392,8 @@ public class RoomsController(
return Ok(SceneWire(clone));
}
// Subroom settings save POSTs here on the roomserver host ("failed to
// modify room" = the bare-only routes 404'd). Same roomserver/ prefix as
// the sibling subroom mutations.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
MutateScene(roomId, subRoomId, s =>
@@ -3377,7 +2409,6 @@ public class RoomsController(
/// scene. Body NewIndex is the target OrderIndex; the in-between
/// rows shift to fill the gap.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
{
@@ -3475,7 +2506,8 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
@@ -3483,28 +2515,6 @@ public class RoomsController(
return Ok(SceneWire(scene));
}
private async Task<RoomSceneEntity?> GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
{
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
if (scene is not null || subRoomId != 0) return scene;
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
DataBlobName = CurrentOrSyntheticDataBlobName(room),
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
};
db.RoomScenes.Add(scene);
return scene;
}
private static object SceneWire(RoomSceneEntity s) => new
{
Result = 0,
@@ -3521,8 +2531,9 @@ public class RoomsController(
// ── Bare-path roles ────────────────────────────────────────────────
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
/// 2023's EFHPLDPNGIM deserializer.</summary>
/// <summary>GET <c>rooms/{id}/roles</c> — accepted role grants on
/// the room. Owner is implicit (CreatorPlayerId) and surfaces under
/// Role=30; co-owner/mod/host rows come from RoomRoles.</summary>
[HttpGet("rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
@@ -3531,132 +2542,29 @@ public class RoomsController(
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1)
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true },
};
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
list.AddRange(rows.Select(r => (object)new
{
RoomId = roomId,
PlayerId = (int)r.PlayerId,
Role = r.Role,
Accepted = r.Accepted,
}));
return Ok(list);
}
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
{
if (room.IsDormRoom)
return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName);
if (RoomService.IsBakedOriginalRoom(room) && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
return string.Empty;
return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
private static string SceneOrSyntheticDataBlobName(RoomEntity room, string? sceneBlobName, string fallbackBlobName)
{
if (!string.IsNullOrWhiteSpace(sceneBlobName) &&
!(room.IsDormRoom && RoomService.IsLegacySyntheticDefaultRoomDataBlobName(room.Id, sceneBlobName)))
return sceneBlobName;
return fallbackBlobName;
}
private sealed record StudioBundleInfo(long SaveId, int Target, int Version, string Filename);
private sealed record BakedUnityAssetWire(
string UnityAssetId,
long CreatedByAccountId,
string Filename,
int Target,
int Version,
string Hash);
private sealed record UnityAssetWire(
string UnityAssetId,
long CreatedByAccountId,
string Filename,
BakedUnityAssetWire[] BakedUnityAssets);
private static BakedUnityAssetWire[] BuildStudioBakedAssets(
RoomEntity room,
RoomSceneEntity? scene,
long saveId,
int? unityAssetTarget,
int? unityAssetVersion)
{
var bundles = StudioBundlesForScene(scene, saveId);
if (unityAssetTarget is int target)
bundles = bundles.Where(b => b.Target == target).ToList();
if (unityAssetVersion is int requestedVersion && bundles.Any(b => b.Version == requestedVersion))
bundles = bundles.Where(b => b.Version == requestedVersion).ToList();
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
return bundles
.OrderBy(b => b.Target)
.ThenByDescending(b => b.Version)
.Select(b => new BakedUnityAssetWire(
unityAssetId,
room.CreatorPlayerId,
b.Filename,
b.Target,
b.Version,
string.Empty))
.ToArray();
}
private static UnityAssetWire? BuildStudioUnityAsset(
RoomEntity room,
RoomSceneEntity? scene,
BakedUnityAssetWire[] bakedAssets)
{
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
if (string.IsNullOrWhiteSpace(unityAssetId))
return null;
var filename = bakedAssets.FirstOrDefault()?.Filename ?? string.Empty;
return new UnityAssetWire(unityAssetId, room.CreatorPlayerId, filename, bakedAssets);
}
private static List<StudioBundleInfo> StudioBundlesForScene(RoomSceneEntity? scene, long saveId)
{
return (scene?.StudioAssetBundleNamesCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(ParseStudioBundleName)
.Where(b => b is not null)
.Cast<StudioBundleInfo>()
.Where(b => b.SaveId == saveId || scene?.StudioSubRoomDataSaveId == saveId)
.OrderBy(b => b.Target)
.ThenByDescending(b => b.Version)
.ToList();
}
private static StudioBundleInfo? ParseStudioBundleName(string fileName)
{
var name = Path.GetFileName(fileName);
var match = System.Text.RegularExpressions.Regex.Match(
name,
@"^(?<save>\d+)__bundle_t(?<target>\d+)(?:_v(?<version>\d+))?\.assetbundle$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant);
if (!match.Success) return null;
if (!long.TryParse(match.Groups["save"].Value, out var saveId)) return null;
if (!int.TryParse(match.Groups["target"].Value, out var target)) return null;
var version = 0;
if (match.Groups["version"].Success &&
!int.TryParse(match.Groups["version"].Value, out version))
return null;
return new StudioBundleInfo(saveId, target, version, name);
}
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId)
return Ok(BuildRoomAccountRoleWire(playerId, 30));
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true });
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
return Ok(BuildRoomRoleGrantWire(row));
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted });
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
@@ -3824,44 +2732,6 @@ public class RoomsController(
return roomRole >= 0;
}
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
=> BuildRoomAccountRoleWire(
role.PlayerId,
ToClientRoomRole(role.Role),
role.Accepted,
role.GrantedByPlayerId);
private static object BuildRoomAccountRoleWire(
long accountId,
int role,
bool accepted = true,
long? lastChangedByAccountId = null)
=> new
{
AccountId = ToClientAccountId(accountId),
Role = accepted ? role : 0,
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
InvitedRole = accepted ? 0 : role,
};
private static int ToClientRoomRole(int roomRole) => roomRole switch
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
private static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
private static int? ToClientAccountId(long? accountId)
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
@@ -527,204 +527,6 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return null;
}
private async Task<long?> ReadBareLongAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
if (long.TryParse(form[key].FirstOrDefault(), out var value)) return value;
return null;
}
foreach (var key in keys)
if (long.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value;
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt64(out var n)) return n;
if (prop.ValueKind == JsonValueKind.String && long.TryParse(prop.GetString(), out n)) return n;
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<bool?> ReadBareBoolAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
if (TryParseBool(form[key].FirstOrDefault(), out var value)) return value;
return null;
}
foreach (var key in keys)
if (TryParseBool(Request.Query[key].FirstOrDefault(), out var value)) return value;
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return doc.RootElement.ValueKind == JsonValueKind.True
? true
: doc.RootElement.ValueKind == JsonValueKind.False
? false
: null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.True) return true;
if (prop.ValueKind == JsonValueKind.False) return false;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n != 0;
if (prop.ValueKind == JsonValueKind.String && TryParseBool(prop.GetString(), out var parsed)) return parsed;
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<string?> ReadStringValueAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
foreach (var key in keys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.String)
return doc.RootElement.GetString();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.String) return prop.GetString();
if (prop.ValueKind == JsonValueKind.Number || prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
return prop.ToString();
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<JsonElement?> ReadJsonElementAsync(params string[] stringKeys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var key in form.Keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) dict[key] = value;
}
if (dict.Count == 0) return null;
return JsonSerializer.SerializeToElement(dict);
}
if ((Request.ContentLength ?? 0) > 0)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
return doc.RootElement.Clone();
}
catch (JsonException)
{
return null;
}
}
foreach (var key in stringKeys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value);
}
return null;
}
private static bool TryParseBool(string? raw, out bool value)
{
if (bool.TryParse(raw, out value)) return true;
if (int.TryParse(raw, out var n))
{
value = n != 0;
return true;
}
value = false;
return false;
}
private static List<JsonElement> ReadJsonElementList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<JsonElement>();
try
{
return JsonSerializer.Deserialize<JsonElement[]>(json)?.ToList() ?? new List<JsonElement>();
}
catch (JsonException)
{
return new List<JsonElement>();
}
}
private static List<string> ReadStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
try
{
return JsonSerializer.Deserialize<string[]>(json)?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList()
?? new List<string>();
}
catch (JsonException)
{
return new List<string>();
}
}
private static string SerializeLimited<T>(IEnumerable<T> values, int maxChars = 4096)
{
var json = JsonSerializer.Serialize(values);
return json.Length <= maxChars ? json : "[]";
}
[HttpPost("rooms/{roomId:long}/cloning")]
[HttpPut("rooms/{roomId:long}/cloning")]
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
@@ -767,171 +569,6 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
});
[HttpPost("rooms/{roomId:long}/creator")]
[HttpPut("rooms/{roomId:long}/creator")]
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareCreator(long roomId)
{
var creatorId = await ReadBareLongAsync(
"creatorAccountId", "CreatorAccountId",
"creatorPlayerId", "CreatorPlayerId",
"accountId", "AccountId",
"value", "Value");
if (creatorId is null or <= 0) return BadRequest(new { error = "missing_creator" });
var exists = await db.Players.AnyAsync(p => p.Id == creatorId.Value);
if (!exists) return NotFound(new { error = "creator_not_found", creatorId });
return await ApplyAndReturn(roomId, r => r.CreatorPlayerId = creatorId.Value);
}
[HttpPost("rooms/{roomId:long}/allow_new_users")]
[HttpPut("rooms/{roomId:long}/allow_new_users")]
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareAllowNewUsers(long roomId)
{
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is bool v) r.AllowNewUsers = v;
});
}
[HttpPost("rooms/{roomId:long}/min_level")]
[HttpPut("rooms/{roomId:long}/min_level")]
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareMinLevel(long roomId)
{
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is int v) r.MinLevel = Math.Max(0, v);
});
}
[HttpPost("rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
{
var value = await ReadBareIntAsync(
"maxPlayerCalculationMode", "MaxPlayerCalculationMode",
"mode", "Mode",
"value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is int v) r.MaxPlayerCalculationMode = Math.Max(0, v);
});
}
[HttpPost("rooms/{roomId:long}/loadscreen")]
[HttpPut("rooms/{roomId:long}/loadscreen")]
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareLoadScreen(long roomId)
{
var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (payload is JsonElement p)
r.LoadScreensJson = p.ValueKind == JsonValueKind.Array
? p.GetRawText()
: SerializeLimited(new[] { p });
});
}
[HttpPost("rooms/{roomId:long}/promo_images")]
[HttpPut("rooms/{roomId:long}/promo_images")]
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
return await ApplyAndReturn(roomId, r =>
{
var images = ReadStringList(r.PromoImagesJson);
if (!images.Contains(image, StringComparer.OrdinalIgnoreCase))
images.Add(image);
r.PromoImagesJson = SerializeLimited(images);
});
}
[HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
return await ApplyAndReturn(roomId, r =>
{
var images = ReadStringList(r.PromoImagesJson)
.Where(v => !string.Equals(v, image, StringComparison.OrdinalIgnoreCase))
.ToList();
r.PromoImagesJson = SerializeLimited(images);
});
}
[HttpPost("rooms/{roomId:long}/promo_external")]
[HttpPut("rooms/{roomId:long}/promo_external")]
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadJsonElementAsync("url", "Url", "value", "Value");
if (payload is null) return BadRequest(new { error = "missing_promo_external" });
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson);
var raw = payload.Value.GetRawText();
if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal)))
items.Add(payload.Value);
r.PromoExternalContentJson = SerializeLimited(items);
});
}
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" });
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson)
.Where(i => !PromoExternalMatches(i, match))
.ToList();
r.PromoExternalContentJson = SerializeLimited(items);
});
}
private static bool PromoExternalMatches(JsonElement item, string match)
{
if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true;
if (item.ValueKind == JsonValueKind.String)
return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase);
if (item.ValueKind != JsonValueKind.Object) return false;
foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" })
{
if (item.TryGetProperty(key, out var prop) &&
prop.ValueKind == JsonValueKind.String &&
string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
/// acknowledgement so the watch's settings panel doesn't surface
@@ -1002,19 +639,10 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
SupportsWalkVR = source.SupportsWalkVR,
SupportsTeleportVR = source.SupportsTeleportVR,
AllowsJuniors = source.AllowsJuniors,
AllowNewUsers = source.AllowNewUsers,
MinLevel = source.MinLevel,
MaxPlayerCalculationMode = source.MaxPlayerCalculationMode,
LoadScreensJson = source.LoadScreensJson,
PromoImagesJson = source.PromoImagesJson,
PromoExternalContentJson = source.PromoExternalContentJson,
RoomWarningMask = source.RoomWarningMask,
CustomRoomWarning = source.CustomRoomWarning,
DisableMicAutoMute = source.DisableMicAutoMute,
LocationReplicationId = source.LocationReplicationId,
IsStudioRoom = source.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
StudioSessionId = source.StudioSessionId,
TagsCsv = source.TagsCsv,
CurrentDataBlobName = source.CurrentDataBlobName,
};
@@ -84,11 +84,6 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
public string? Text { get; set; }
public string? Input { get; set; }
public string? Value { get; set; }
public string? Context { get; set; }
public int? Intent { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("ruleset")]
public int? Ruleset { get; set; }
}
/// <summary>POST <c>api/sanitize/v1/purifyString</c> — returns
@@ -121,32 +116,6 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
return Ok(ProfanityFilter.IsClean(input));
}
/// <summary>POST <c>api/sanitize/v1/isPure</c> — 2023 route used
/// by CV2 maker pen text such as port names. It imports an object
/// field named <c>IsPure</c>.</summary>
[HttpPost("api/sanitize/v1/isPure")]
public IActionResult IsPureV1(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
[FromForm(Name = "Value")] string? valueForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
var isPure = ProfanityFilter.IsClean(input);
logger.LogInformation(
"[sanitize] isPure context={Context} intent={Intent} ruleset={Ruleset} valueLength={ValueLength} isPure={IsPure}",
body?.Context ?? string.Empty,
body?.Intent,
body?.Ruleset,
input.Length,
isPure);
var json = System.Text.Json.JsonSerializer.Serialize(new { IsPure = isPure });
return Content(json, "application/json");
}
/// <summary>POST <c>api/sanitize/isPure</c> — December 2020 route
/// used by room/settings forms. Its importer reads an <c>IsPure</c>
/// object field, unlike the newer bare-bool route above.</summary>
@@ -1,40 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ScreenShareReports;
[ApiController]
[Authorize]
public class ScreenShareReportsController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/screensharereports/v1/report")]
public async Task<IActionResult> Report(
[FromForm] long? targetPlayerId,
[FromForm] long? roomId,
[FromForm] int? reportCategory,
[FromForm] string? details)
{
var reporter = this.RequireCurrentPlayerId();
var target = targetPlayerId
?? (long.TryParse(Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0);
var room = roomId
?? (long.TryParse(Request.Query["roomId"].FirstOrDefault(), out var qRoom) ? qRoom : 0);
var category = reportCategory
?? (int.TryParse(Request.Query["reportCategory"].FirstOrDefault(), out var qCategory) ? qCategory : 5);
var message = details ?? Request.Query["details"].FirstOrDefault() ?? string.Empty;
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = target,
RoomId = room,
Category = category,
Message = message[..Math.Min(1000, message.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
}
@@ -205,7 +205,6 @@ public class StorefrontsBuyController(
/// persisted room key. The 2020.12 client sends roomKeyId and the
/// price it displayed so we reject stale-price purchases.</summary>
[HttpGet("api/storefronts/v1/buyRoomKey")]
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
public async Task<IActionResult> BuyRoomKey(
[FromQuery] long roomKeyId,
[FromQuery] int requestedPrice)
@@ -243,95 +242,6 @@ public class StorefrontsBuyController(
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
}
[HttpPost("api/storefronts/v2/buyInvention")]
[HttpGet("api/storefronts/v2/buyInvention")]
public async Task<IActionResult> BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2)
{
var pid = Me;
var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
if (inv is null) return NotFound();
var price = Math.Max(0, inv.Price);
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}");
var key = $"invention_purchase:{inv.Id}";
if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key))
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null));
}
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
public async Task<IActionResult> BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton");
db.GiftPackages.Add(new GiftPackageEntity
{
RecipientPlayerId = pid,
CurrencyType = 2,
Currency = 25,
Level = 1,
PackageVariant = "FreeGiftButton",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
});
await db.SaveChangesAsync();
return Ok(BalanceUpdateResponse(newBalance, currencyType));
}
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
public async Task<IActionResult> BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost");
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(BalanceUpdateResponse(newBalance, currencyType));
}
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
public async Task<IActionResult> BuyPurchaseReminder()
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = Me,
Key = $"purchaseReminder:{Guid.NewGuid():N}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
// ── Helpers ──────────────────────────────────────────────────────────
private async Task GrantWardrobeAsync(long pid, string guid)
@@ -32,7 +32,6 @@ public class StorefrontsController(
[HttpGet("api/storefronts/v1/all")]
[HttpGet("api/storefronts/v2/all")]
[HttpGet("api/storefronts/v3/all")]
[HttpGet("api/storefronts/v1/toptoday")]
[AllowAnonymous]
public async Task<IActionResult> All()
{
@@ -40,20 +39,6 @@ public class StorefrontsController(
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET <c>api/storefronts/v1/adcarouselitems</c> — the rotating
/// promo banner at the top of the Shop, NOT the main catalog. The client
/// (AdCarouselItemListModel) keys these by <c>AdCarouselItemId</c> in a
/// Dictionary; our generic store DTOs don't carry that field, so every
/// row deserialised to id 0 and the SECOND item threw
/// "An item with the same key has already been added. Key: 0",
/// which surfaced as an unobserved task exception that broke the Shop.
/// We don't run promo carousels, so return an empty banner — the real
/// catalog still comes from /all + /skus. (To add a real carousel later,
/// emit one row per item with a UNIQUE AdCarouselItemId.)</summary>
[HttpGet("api/storefronts/v1/adcarouselitems")]
[AllowAnonymous]
public IActionResult AdCarouselItems() => Ok(Array.Empty<object>());
/// <summary>GET api/storefronts/v3/skus — alias for the full
/// catalog. Some 2020 client paths call this instead of /all
/// during the watch's "browse all SKUs" flow.</summary>
@@ -1,40 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.SubscriptionSeasons;
[ApiController]
public class SubscriptionSeasonsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/subscriptionseasons/v1/seasons/current")]
public async Task<IActionResult> Current()
{
var now = DateTime.UtcNow;
var start = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var end = start.AddMonths(1);
var activeSubscribers = await db.Subscriptions.CountAsync();
var rewards = await db.StoreItems
.Where(i => i.IsActive)
.OrderBy(i => i.Id)
.Take(10)
.Select(i => new
{
RewardId = i.Id,
i.Slug,
Name = i.DisplayName,
i.Category,
i.ImageName,
})
.ToListAsync();
return Ok(new
{
SeasonId = $"{start:yyyyMM}",
Name = $"Season {start:yyyy.MM}",
StartAt = start,
EndAt = end,
ActiveSubscriberCount = activeSubscribers,
Rewards = rewards,
});
}
}
@@ -1,342 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.UgcPurchasables;
[ApiController]
public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/ugcPurchasables")]
[AllowAnonymous]
public async Task<IActionResult> List([FromQuery] long roomId = 0)
{
var rows = await db.UgcPurchasables
.Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId))
.OrderBy(i => i.SortOrder)
.ThenBy(i => i.Name)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/ugcPurchasables")]
[Authorize]
public async Task<IActionResult> Create()
{
var fields = await ReadFieldsAsync();
var roomId = ReadLong(fields, "roomId", "RoomId");
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
if (room is null) return NotFound("room_not_found");
var me = this.RequireCurrentPlayerId();
if (!await CanManageRoomAsync(room, me)) return Forbid();
var item = new UgcPurchasableEntity
{
RoomId = room.Id,
CreatorPlayerId = me,
Name = Trim(ReadString(fields, "name", "Name") ?? "Purchasable", 128),
Description = Trim(ReadString(fields, "description", "Description"), 1024),
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
ItemType = Math.Max(0, ReadInt(fields, "itemType", "ItemType") ?? 0),
SortOrder = ReadInt(fields, "sortOrder", "SortOrder") ?? 0,
IsFeatured = ReadBool(fields, "isFeatured", "IsFeatured") ?? false,
};
db.UgcPurchasables.Add(item);
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpPut("api/ugcPurchasables")]
[HttpPost("api/ugcPurchasables/update")]
[Authorize]
public async Task<IActionResult> Update()
{
var fields = await ReadFieldsAsync();
var item = await FindItemAsync(fields);
if (item is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) item.Name = Trim(name, 128);
if (ReadString(fields, "description", "Description") is { } description) item.Description = Trim(description, 1024);
if (ReadString(fields, "imageName", "ImageName") is { } imageName) item.ImageName = Trim(imageName, 256);
if (ReadInt(fields, "price", "Price") is int price) item.Price = Math.Max(0, price);
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) item.CurrencyType = Math.Max(0, currencyType);
if (ReadInt(fields, "itemType", "ItemType") is int itemType) item.ItemType = Math.Max(0, itemType);
if (ReadInt(fields, "sortOrder", "SortOrder") is int sortOrder) item.SortOrder = sortOrder;
if (ReadBool(fields, "isFeatured", "IsFeatured") is bool featured) item.IsFeatured = featured;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpDelete("api/ugcPurchasables")]
[HttpPost("api/ugcPurchasables/delete")]
[Authorize]
public async Task<IActionResult> Delete()
{
var fields = await ReadFieldsAsync();
var item = await FindItemAsync(fields);
if (item is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
item.IsDeleted = true;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpGet("api/ugcPurchasables/v1/items/bulk")]
[HttpPost("api/ugcPurchasables/v1/items/bulk")]
[AllowAnonymous]
public async Task<IActionResult> Bulk()
{
// The 2023 client posts {"RoomId":N,"Ids":[{"itemType":1,
// "itemId":"<guid>"}]} — Ids is an array of OBJECTS, not flat guid
// strings. The generic field-flattener collapses each object to its
// JSON text, so the old ReadGuidList parsed ZERO guids and this
// endpoint always returned [] — the client's UGC resolver then
// re-queried the same ids forever (the growing-list flood in the
// server log), the referenced items never resolved, and the online
// player/avatar spawn null-reffed on an unresolved custom item
// (reportjoinresult FailedToSpawnPlayer → infinite dorm bounce).
var requested = await ReadBulkIdsAsync();
if (requested.Count == 0) return Ok(Array.Empty<object>());
var ids = requested.Select(r => r.Id).ToList();
var rows = await db.UgcPurchasables
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
.ToListAsync();
var byId = rows.ToDictionary(r => r.PublicId);
// Return an entry for EVERY requested id, in request order. Items not
// in our DB (custom UGC the player owns from real Rec Room that we
// never ingested) get a synthetic placeholder so the client treats
// the reference as resolved instead of spinning.
var result = requested
.Select(r => byId.TryGetValue(r.Id, out var row) ? ToWire(row) : SyntheticWire(r.Id, r.ItemType))
.ToList();
return Ok(result);
}
/// <summary>Parse the bulk request's item ids. Handles the 2023 client's
/// JSON object-array shape (<c>Ids:[{itemType,itemId}]</c>) as well as
/// flat guid lists in the body/form/query for older callers.</summary>
private async Task<List<(Guid Id, int ItemType)>> ReadBulkIdsAsync()
{
var list = new List<(Guid, int)>();
var seen = new HashSet<Guid>();
void Add(Guid id, int itemType)
{
if (id != Guid.Empty && seen.Add(id)) list.Add((id, itemType));
}
if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& (TryGetPropertyCI(root, "Ids", out var idsEl)
|| TryGetPropertyCI(root, "itemIds", out idsEl)
|| TryGetPropertyCI(root, "ugcPurchasableIds", out idsEl))
&& idsEl.ValueKind == JsonValueKind.Array)
{
foreach (var el in idsEl.EnumerateArray())
{
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var flat))
{
Add(flat, 0);
}
else if (el.ValueKind == JsonValueKind.Object)
{
var itemType = TryGetPropertyCI(el, "itemType", out var it) && it.TryGetInt32(out var n) ? n : 0;
if ((TryGetPropertyCI(el, "itemId", out var idEl)
|| TryGetPropertyCI(el, "id", out idEl)
|| TryGetPropertyCI(el, "ugcPurchasableId", out idEl))
&& Guid.TryParse(idEl.GetString(), out var guid))
{
Add(guid, itemType);
}
}
}
}
}
catch (JsonException)
{
}
}
// Fallback: flat guid lists in form/query (older callers).
if (list.Count == 0)
{
var fields = await ReadFieldsAsync();
foreach (var id in ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds"))
Add(id, 0);
}
return list.Take(200).ToList();
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
/// <summary>Minimal placeholder for a requested ugc item we don't have a
/// row for. Same wire shape as <see cref="ToWire"/> so the client's
/// deserializer resolves the reference; zero price / empty metadata.</summary>
private static object SyntheticWire(Guid id, int itemType) => new
{
UgcPurchasableId = id,
PurchasableItemId = id,
Id = id,
InternalId = 0L,
RoomId = 0L,
CreatorPlayerId = 0,
Name = string.Empty,
Description = string.Empty,
ImageName = string.Empty,
Price = 0,
CurrencyType = 2,
ItemType = itemType,
IsFeatured = false,
SortOrder = 0,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> fields)
{
var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id");
if (publicId is Guid guid)
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.PublicId == guid && !i.IsDeleted);
var localId = ReadLong(fields, "internalId", "ugcPurchasableInternalId");
if (localId is long id)
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted);
return null;
}
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
return true;
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value;
return null;
}
private static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
long.TryParse(ReadString(fields, names), out var value) ? value : null;
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static bool? ReadBool(Dictionary<string, string> fields, params string[] names) =>
bool.TryParse(ReadString(fields, names), out var value) ? value : null;
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.Take(200)
.ToList();
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
private static object ToWire(UgcPurchasableEntity item) => new
{
UgcPurchasableId = item.PublicId,
PurchasableItemId = item.PublicId,
Id = item.PublicId,
InternalId = item.Id,
item.RoomId,
CreatorPlayerId = (int)item.CreatorPlayerId,
item.Name,
item.Description,
item.ImageName,
item.Price,
item.CurrencyType,
item.ItemType,
item.IsFeatured,
item.SortOrder,
item.CreatedAt,
item.UpdatedAt,
};
}
@@ -73,11 +73,9 @@ public class AccountsController(
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? auth["Bearer ".Length..].Trim()
: Request.Cookies[AuthService.AccessCookieName];
if (string.IsNullOrWhiteSpace(token))
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
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);
@@ -172,7 +170,6 @@ public class AccountsController(
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
// localhost public site's account search. Anonymous-safe (no JWT
// required) so the front-facing site can call it directly.
[HttpGet("/account/search")]
[HttpGet("/account/v1/search")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
@@ -408,26 +405,6 @@ public class AccountsController(
return age;
}
private async Task SetPlayerSettingAsync(long playerId, string key, string value)
{
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = key,
Value = value,
});
}
else
{
row.Value = value;
}
await db.SaveChangesAsync();
}
// Everyone is a dev for now — exposes the in-game debug console + dev
// tools. Per user request: easier debugging while iterating on the
// private server. Flip back to false later if you want shipping behavior.
@@ -532,120 +509,6 @@ public class AccountsController(
[HttpGet("/account/v1/me/disallowiap")]
public IActionResult DisallowIap() => Ok(new { Disallow = false });
[HttpGet("/account/isactivecreator/me")]
public async Task<IActionResult> IsActiveCreator()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Content("false", "application/json");
var active = await db.Rooms.AnyAsync(r => r.CreatorPlayerId == me)
|| await db.Inventions.AnyAsync(i => i.CreatorPlayerId == me && !i.IsDeleted)
|| await db.CustomAvatarItems.AnyAsync(i => i.CreatorPlayerId == me);
return Content(active ? "true" : "false", "application/json");
}
[HttpPost("/account/me/createlogintoken")]
[HttpGet("/account/me/createlogintoken")]
public async Task<IActionResult> CreateLoginToken()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var token = Guid.NewGuid().ToString("N");
var expiresAt = DateTime.UtcNow.AddMinutes(10);
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}");
return Ok(new
{
Success = true,
Error = string.Empty,
Token = token,
LoginToken = token,
ExpiresAt = expiresAt,
});
}
[HttpPost("/account/me/remoteauth")]
[HttpGet("/account/me/remoteauth")]
public async Task<IActionResult> RemoteAuth()
{
var token = Request.Query["token"].FirstOrDefault()
?? Request.Query["loginToken"].FirstOrDefault()
?? Request.Query["code"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
token = form["token"].FirstOrDefault()
?? form["loginToken"].FirstOrDefault()
?? form["code"].FirstOrDefault();
}
if (string.IsNullOrWhiteSpace(token))
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var pair = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = pair.AccessToken,
RefreshToken = pair.RefreshToken,
});
}
var prefix = token.Trim() + "|";
var row = await db.PlayerSettings
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
.OrderByDescending(s => s.Id)
.FirstOrDefaultAsync();
if (row is null) return Unauthorized();
var rawExpiry = row.Value[prefix.Length..];
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
return Unauthorized();
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
}
[HttpPost("/account/recoverpassword")]
[HttpGet("/account/recoverpassword")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> RecoverPassword(
[FromForm] string? username,
[FromForm] string? email)
{
var nameOrEmail = username
?? email
?? Request.Query["username"].FirstOrDefault()
?? Request.Query["email"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(nameOrEmail))
return Ok(new RecNetResult { Success = false, Error = "missing_account" });
var needle = nameOrEmail.Trim().ToLowerInvariant();
var player = await db.Players
.FirstOrDefaultAsync(p => p.Username.ToLower() == needle
|| (p.Email != null && p.Email.ToLower() == needle));
if (player is null)
return Ok(new RecNetResult { Success = false, Error = "unknown_account" });
var code = Random.Shared.Next(100000, 999999).ToString();
var expiresAt = DateTime.UtcNow.AddMinutes(30);
await SetPlayerSettingAsync(player.Id, "password_recovery_code", $"{code}|{expiresAt:O}");
return Ok(new
{
Success = true,
Error = string.Empty,
Code = code,
ExpiresAt = expiresAt,
});
}
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
@@ -667,31 +530,6 @@ public class AccountsController(
[HttpGet("/account/v1/{accountId:long}/platformid")]
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L });
[HttpGet("/accountprivacysettings/{accountId:long}")]
[HttpGet("/account/v1/privacysettings/{accountId:long}")]
[HttpGet("/account/{accountId:long}/privacysettings")]
public IActionResult GetAccountPrivacySettings(long accountId) => Ok(new
{
AccountId = (int)accountId,
IsProfileVisible = true,
IsOnlineStatusVisible = true,
IsActivityVisible = true,
ShowOnlineStatus = true,
ShowActivity = true,
ShowCurrentRoom = true,
ReceiveFriendRequests = true,
ReceiveInvites = true,
ReceiveMessages = true,
WhoCanSeeOnlineStatus = 0,
WhoCanSeeActivity = 0,
WhoCanSeeCurrentRoom = 0,
WhoCanSendFriendRequests = 0,
WhoCanInviteMe = 0,
WhoCanMessageMe = 0,
VoicePrivacy = 0,
TextChatPrivacy = 0,
});
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
// unknown accounts URLs now 404 so we notice them in logs and wire
// them properly rather than silently masking missing endpoints.
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -40,7 +39,6 @@ public class AdminController(
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -406,7 +404,6 @@ public class AdminController(
r.IsAGRoom,
r.IsDormRoom,
r.CreatorPlayerId,
r.State,
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
})
.ToListAsync();
@@ -451,21 +448,6 @@ public class AdminController(
return Ok(new { room.Id, room.State });
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/restore</c> — the inverse of
/// the soft-archive above: clears State back to 0 (Active) so the room
/// shows up in browse/search again. Idempotent; safe on a non-archived
/// room (just re-asserts State=0).</summary>
[HttpPost("rooms/{id:long}/restore")]
public async Task<ActionResult> RestoreRoom(long id)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
room.State = 0; // Active
await LogAsync("restore_room", "room", id, "");
await db.SaveChangesAsync();
return Ok(new { room.Id, room.State });
}
// The bulk "purge every custom room" endpoint was removed: a single
// wrong click could wipe every imported map in one shot. Per-room
// purge (below) is the only hard-delete path, gated by two
@@ -505,11 +487,8 @@ public class AdminController(
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
// Dorms are per-player state — purging one wipes that player's dorm
// contents, but it's recoverable: EnsurePersonalDormAsync regenerates
// a fresh dorm on their next join. An admin sometimes needs to nuke a
// corrupted/test dorm, so we ALLOW it; the two typed-name
// confirmations below are the guard against an accidental wipe.
if (room.IsDormRoom)
return BadRequest(new { error = "refuses_to_hard_delete_dorm", note = "Dorms are per-player state; never purge." });
if (room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId)
return BadRequest(new { error = "refuses_to_hard_delete_seeded_room", note = "This row is owned by the system seed account — soft-archive via DELETE /rooms/{id} instead." });
@@ -1481,53 +1460,6 @@ public class AdminController(
// ── Server settings (runtime toggles) ────────────────────────────────
[HttpGet("identityserver")]
public ActionResult GetIdentityServerSettings()
{
var issuer = domain.AuthIssuer.TrimEnd('/');
var client = RecRoomIdentityServerConfig.Clients[0];
var certPath = signingKeys.CertificatePath;
var fullCertPath = Path.GetFullPath(certPath);
var certificateExists = signingKeys.LoadedFromBase64 || System.IO.File.Exists(fullCertPath);
return Ok(new
{
Issuer = issuer,
DiscoveryUrl = $"{issuer}/.well-known/openid-configuration",
TokenEndpoint = $"{issuer}/connect/token",
UserInfoEndpoint = $"{issuer}/connect/userinfo",
Client = new
{
client.ClientId,
client.ClientName,
AllowedGrantTypes = client.AllowedGrantTypes.ToArray(),
AllowedScopes = client.AllowedScopes.ToArray(),
client.AllowOfflineAccess,
client.AccessTokenLifetime,
client.AbsoluteRefreshTokenLifetime,
client.SlidingRefreshTokenLifetime,
RefreshTokenUsage = client.RefreshTokenUsage.ToString(),
RefreshTokenExpiration = client.RefreshTokenExpiration.ToString(),
},
Signing = new
{
signingKeys.CertificateSource,
signingKeys.LoadedFromBase64,
CertificatePath = fullCertPath,
CertificateExists = certificateExists,
signingKeys.Certificate.Thumbprint,
NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
Algorithm = SecurityAlgorithms.RsaSha256,
},
PersistedGrants = new
{
Store = "in-memory",
Warning = "Refresh tokens are process-local until an operational store is configured.",
},
});
}
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
/// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")]
@@ -1677,18 +1609,16 @@ public class AdminController(
public sealed record PlayMenuTagsRequest(
List<string>? PinnedTags,
List<string>? PopularTags,
List<string>? TrendingTags);
List<string>? PopularTags);
[HttpPost("settings/play-menu-tags")]
public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body)
{
var tags = await serverSettings.SetPlayMenuTagsAsync(
body.PinnedTags ?? [],
body.PopularTags ?? [],
body.TrendingTags ?? []);
body.PopularTags ?? []);
await LogAsync("play_menu_tags_updated", "system", 0,
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)} trending={string.Join(",", tags.TrendingTags)}");
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}");
await db.SaveChangesAsync();
return Ok(tags);
}
@@ -59,7 +59,6 @@ public class RoomZipImportController(
ILogger<RoomZipImportController> logger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
private const long MaxArchiveBytes = 25_000_000_000L;
// ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser does two things now (see RoomBlobNormalizerService):
@@ -129,7 +128,6 @@ public class RoomZipImportController(
".mp4" => "video/mp4",
".htr" => "application/octet-stream",
".room" => "application/octet-stream",
".assetbundle" => "application/octet-stream",
".meta" => "application/octet-stream",
".inv" => "application/octet-stream",
".dat" => "application/octet-stream",
@@ -140,14 +138,6 @@ public class RoomZipImportController(
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// RecNet serializes large snowflake-style IDs (SubRoomId, save IDs)
// as JSON strings to avoid JS 2^53 precision loss, while smaller/older
// IDs stay as raw numbers — and a single SubRooms[] array can mix both
// forms (Showdown's export is numeric for entries 0-17, then string
// from "SD_Archive_120822_Catalyst" on). Allow numeric properties to
// read from either token so the import doesn't crash on the first
// string-encoded ID.
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
// ── RoomDetails.json DTOs ──────────────────────────────────────────
@@ -230,7 +220,6 @@ public class RoomZipImportController(
/// missing, the importer derives it from the SubRooms/&lt;scene&gt;
/// folder name suffix (<c>SubRoomName__&lt;subRoomId&gt;</c>).</summary>
public long? SubRoomId { get; set; }
public string? UnityAssetId { get; set; }
public string? DataBlob { get; set; }
public DateTime? CreatedAt { get; set; }
public string? Description { get; set; }
@@ -279,7 +268,7 @@ public class RoomZipImportController(
[HttpPost("zip-bulk-import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = MaxArchiveBytes)]
[RequestFormLimits(MultipartBodyLengthLimit = 5_000_000_000)] // 5 GB
public async Task<IActionResult> ZipImport(
[FromForm(Name = "archive")] IFormFile? archive,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
@@ -331,8 +320,8 @@ public class RoomZipImportController(
[HttpPost("zip-upload-init")]
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
{
if (body.TotalBytes <= 0 || body.TotalBytes > MaxArchiveBytes)
return BadRequest(new { error = "invalid_size", maxBytes = MaxArchiveBytes, gotBytes = body.TotalBytes });
if (body.TotalBytes <= 0 || body.TotalBytes > 5_000_000_000)
return BadRequest(new { error = "invalid_size", maxBytes = 5_000_000_000L, gotBytes = body.TotalBytes });
// Prune stale sessions before allocating a new one. Anything
// older than 2 hours with no activity is abandoned.
@@ -925,11 +914,9 @@ public class RoomZipImportController(
// it reflects whether the diagnostic parse succeeded, not
// whether we actually applied the normalised output.
NormalizedOk: norm.Normalized,
StudioUnityAssetId: null,
HtrAssets: htrAssets,
PvImages: pvImages,
Polaroids: polaroids,
AssetBundles: new List<(string Name, ZipArchiveEntry Entry)>(),
History: history));
logger.LogInformation(
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
@@ -1057,11 +1044,9 @@ public class RoomZipImportController(
var htrAssetCount = 0;
var pvImageCount = 0;
var polaroidCount = 0;
var assetBundleCount = 0;
var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList();
var allPvImages = ordered.SelectMany(s => s.PvImages).ToList();
var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList();
var allAssetBundles = ordered.SelectMany(s => s.AssetBundles).ToList();
// De-dupe within the archive first (same .htr can appear under
// multiple scenes), then de-dupe against the DB. Both layers
@@ -1069,7 +1054,6 @@ public class RoomZipImportController(
var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniqueAssetBundleNames = allAssetBundles.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var existingHtrNames = new HashSet<string>(
await db.RoomDataBlobs
@@ -1086,15 +1070,9 @@ public class RoomZipImportController(
.Where(b => uniquePolaroidNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var existingAssetBundleNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniqueAssetBundleNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var htrAlreadyInDbAtStart = existingHtrNames.Count;
var pvAlreadyInDbAtStart = existingPvNames.Count;
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
var assetBundleAlreadyInDbAtStart = existingAssetBundleNames.Count;
// History (SubRooms/<scene>/History/<DataBlob>.room) — pre-load
// existing names so the de-dup HashSet covers prior imports + same
@@ -1195,17 +1173,6 @@ public class RoomZipImportController(
await WriteBlobAsync(0, polName, bytes, creator);
polaroidCount++;
}
// Studio baked Unity asset bundles. Keep original filenames;
// Studio save metadata points the 2023 client at
// cdn.../room/<filename>.assetbundle.
foreach (var (bundleName, bundleEntry) in allAssetBundles)
{
if (existingAssetBundleNames.Contains(bundleName)) continue;
existingAssetBundleNames.Add(bundleName);
var bytes = await ReadAllAsync(bundleEntry);
await WriteBlobAsync(0, bundleName, bytes, creator);
assetBundleCount++;
}
// Room-root opaque marker files (e.g. <hash>.meta — 6-byte
// sentinel the exporter drops, purpose unclear but watching
// for fetches just in case). Rename to room_<id>_<hash>.meta
@@ -1267,9 +1234,6 @@ public class RoomZipImportController(
Accessibility = details.Accessibility ?? 1,
IsAGRoom = false,
IsDormRoom = details.IsDorm ?? false,
IsStudioRoom = entries.ContainsKey(studioDetailsKey),
IsRoomLinkedToRecRoomStudio = entries.ContainsKey(studioDetailsKey),
StudioSessionId = details.RoomId?.ToString() ?? TryParseStudioId(ZipFileName(roomFolder))?.ToString() ?? string.Empty,
CloningAllowed = details.CloningAllowed ?? true,
SupportsLevelVoting = details.SupportsLevelVoting ?? false,
SupportsVRLow = details.SupportsVRLow ?? true,
@@ -1282,7 +1246,7 @@ public class RoomZipImportController(
CustomRoomWarning = details.CustomWarning ?? string.Empty,
DisableMicAutoMute = details.DisableMicAutoMute ?? false,
LocationReplicationId = entryLocation,
TagsCsv = EnsureStudioTags(JoinTags(details.Tags), entries.ContainsKey(studioDetailsKey)),
TagsCsv = JoinTags(details.Tags),
CheerCount = details.Stats?.CheerCount ?? 0,
FavoriteCount = details.Stats?.FavoriteCount ?? 0,
VisitCount = details.Stats?.VisitCount ?? 0,
@@ -1302,11 +1266,6 @@ public class RoomZipImportController(
Name = s.SceneName,
RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation,
DataBlobName = MakeBlobName(s.SceneName),
StudioSubRoomDataSaveId = s.Manifest.CurrentSave?.SubRoomDataSaveId,
StudioUnityAssetId = s.StudioUnityAssetId ?? string.Empty,
StudioAssetBundleNamesCsv = string.Join(',', s.AssetBundles
.Select(a => a.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)),
MaxPlayers = s.Manifest.MaxPlayers ?? 8,
IsSandbox = s.Manifest.IsSandbox ?? false,
CanMatchmakeInto = true,
@@ -1323,8 +1282,7 @@ public class RoomZipImportController(
Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " +
$"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " +
$"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " +
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed " +
$"bundles={assetBundleCount}new+{assetBundleAlreadyInDbAtStart}existed",
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed",
});
await db.SaveChangesAsync();
await tx.CommitAsync();
@@ -1360,12 +1318,6 @@ public class RoomZipImportController(
newlyImported = polaroidCount,
alreadyInDb = polaroidAlreadyInDbAtStart,
},
assetBundles = new
{
referenced = uniqueAssetBundleNames.Count,
newlyImported = assetBundleCount,
alreadyInDb = assetBundleAlreadyInDbAtStart,
},
history = new
{
referenced = allHistoryNames.Count,
@@ -1398,7 +1350,6 @@ public class RoomZipImportController(
maxPlayers = s.Manifest.MaxPlayers,
htrAssets = s.HtrAssets.Count,
pvImages = s.PvImages.Count,
assetBundles = s.AssetBundles.Count,
}),
};
}
@@ -1479,19 +1430,9 @@ public class RoomZipImportController(
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
var assetBundles = new List<(string Name, ZipArchiveEntry Entry)>();
foreach (var save in saves)
{
var bundlePrefix = $"{savesPrefix}{save.SaveId}__bundle_";
foreach (var (path, assetEntry) in entries.Where(kv =>
kv.Key.StartsWith(bundlePrefix, StringComparison.OrdinalIgnoreCase)
&& kv.Key.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase)
&& !kv.Key[bundlePrefix.Length..].Contains('/')))
{
assetBundles.Add((Path.GetFileName(path), assetEntry));
}
var refPrefix = $"{savesPrefix}{save.SaveId}__ref_";
foreach (var (path, assetEntry) in entries.Where(kv =>
kv.Key.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase)
@@ -1540,18 +1481,16 @@ public class RoomZipImportController(
Bytes: persistedBytes,
RawBytes: blobBytes.Length,
NormalizedOk: norm.Normalized,
StudioUnityAssetId: current.Sidecar.UnityAssetId,
HtrAssets: htrAssets,
PvImages: pvImages,
Polaroids: polaroids,
AssetBundles: assetBundles,
History: history));
logger.LogInformation(
"[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} bundles={Bundles} history={History}",
"[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} history={History}",
roomName, sceneName, current.SaveId, blobBytes.Length, norm.Bytes.Length,
normalizeBlobs ? "normaliser" : "raw",
htrAssets.Count, pvImages.Count, polaroids.Count, assetBundles.Count, history.Count);
htrAssets.Count, pvImages.Count, polaroids.Count, history.Count);
}
return (resolvedScenes, missingScenes);
@@ -1852,32 +1791,15 @@ public class RoomZipImportController(
return values.Count > 0 ? string.Join(',', values!) : "community";
}
private static string EnsureStudioTags(string tagsCsv, bool isStudioDump)
{
if (!isStudioDump) return tagsCsv;
var values = (tagsCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
if (!values.Contains("community", StringComparer.OrdinalIgnoreCase))
values.Add("community");
if (!values.Contains("studio", StringComparer.OrdinalIgnoreCase))
values.Add("studio");
if (!values.Contains("developer", StringComparer.OrdinalIgnoreCase))
values.Add("developer");
return string.Join(',', values);
}
private sealed record ResolvedSubroom(
SubRoomDto Manifest,
string SceneName,
byte[] Bytes,
int RawBytes,
bool NormalizedOk,
string? StudioUnityAssetId,
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
List<(string Name, ZipArchiveEntry Entry)> PvImages,
List<(string Name, ZipArchiveEntry Entry)> Polaroids,
List<(string Name, ZipArchiveEntry Entry)> AssetBundles,
List<HistorySave> History);
/// <summary>One historical save under SubRooms/&lt;scene&gt;/History/.
+240 -47
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
@@ -6,18 +7,241 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>
/// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by IdentityServer.
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
/// </summary>
[ApiController]
public class AuthController(
PlayerService playerService,
AuthService authService) : ControllerBase
AuthService authService,
LevelService level,
OrphanAccountTracker orphans,
ServerSettingsService settings,
DorkNet.Server.Data.DorkNetDbContext db,
DomainConfig domain,
ILogger<AuthController> logger) : ControllerBase
{
/// <summary>Award the per-day first-login XP if the calling
/// player hasn't been seen yet today (UTC).</summary>
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
// OAuth token endpoint. The 2020 client uses three grant types here:
// - grant_type=cached_login → user clicked a remembered account on the
// account-selection screen. Body includes `account_id` (and platform
// creds). MUST issue a token for THAT specific account; otherwise the
// client receives a JWT for a different account and prompts the user
// to create a new one (because the JWT identity doesn't match what
// they picked).
// - grant_type=refresh_token → re-issue access token from the existing
// refresh JWT.
// - grant_type=password → first-time sign-in, resolve via deviceId
// (preferred) or fall back to a stable synthetic id derived from
// username so legacy callers don't share accounts.
[HttpPost("/connect/token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token(
[FromForm] string? grant_type,
[FromForm] string? username,
[FromForm] string? password,
[FromForm] string? refresh_token,
[FromForm] string? deviceId,
[FromForm(Name = "device_id")] string? device_id_snake,
[FromForm] int platform = 0,
[FromForm] string? platformId = null,
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
[FromForm] long? account_id = null)
{
// The client mixes camelCase and snake_case parameter naming
// depending on grant type — accept both and merge.
var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
{
if (account_id is not long pickedId)
return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
// Orphan cleanup: if the watch just created a brand-new
// account on account/create as part of its boot probe, but
// the user actually picked a different cached account here,
// the just-created row is unused. Delete it so the DB
// doesn't accumulate one orphan per game launch. When the
// user is genuinely creating a new account, the picked id
// matches the just-created id and we leave it.
var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId, orphanId);
}
orphans.Clear(effDeviceId, effPlatformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
return Ok(new
{
access_token = acc1,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr1,
scope = "openid profile",
});
}
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(refresh_token))
return BadRequest(new { error = "invalid_request" });
var resolved = authService.ValidateToken(refresh_token);
if (resolved is not long refreshedId)
return Unauthorized(new { error = "invalid_grant" });
var existing = await playerService.GetByIdAsync(refreshedId);
if (existing is null)
return Unauthorized(new { error = "invalid_grant" });
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
return Ok(new
{
access_token = acc,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr,
scope = "openid profile",
});
}
// password grant: two paths.
// • Username + password supplied: real password verify against
// PlayerEntity.PasswordHash. Wrong password → 401.
// • No password: legacy device-id synth path for the 2020
// client's first-launch flow where the player hasn't set a
// password yet (HasPassword returns false in the watch UI
// and prompts them to create one). Resolves the existing
// account (or creates a fresh one) by deviceId.
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
{
var byName = await playerService.GetByUsernameAsync(username);
if (byName is null || byName.PasswordHash is null)
return Unauthorized(new
{
error = "invalid_grant",
error_description = "unknown_user_or_no_password",
});
if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
return Unauthorized(new
{
error = "invalid_grant",
error_description = "password_mismatch",
});
// Tag the platform so the next boot's cached-login lookup
// finds this account — without this the watch's "remembered
// accounts" prompt would stay empty after a manual sign-in.
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
await TryAwardLoginXpAsync(byName.Id);
var pair = authService.GenerateTokenPair(byName.Id);
return Ok(new
{
access_token = pair.AccessToken,
token_type = "Bearer",
expires_in = 43200,
refresh_token = pair.RefreshToken,
scope = "openid profile",
});
}
// Device-id fallback path. Prefer the real deviceId; fall back
// to a stable legacy synthesis from username so older callers
// don't accidentally share an account.
var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
? effDeviceId
: $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
// Honour the admin "signups disabled" toggle. If the device
// hasn't logged in before, GetOrCreateByDeviceAsync would mint
// a fresh account here — same outcome as hitting
// /account/create directly, which the toggle blocks. Refuse
// BEFORE creation so the flag isn't bypassable.
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] /connect/token device fallback refused — signups disabled (device={Device})",
effectiveDeviceId);
return Unauthorized(new
{
error = "signups_disabled",
error_description = "Account creation is currently disabled by the server admin.",
});
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: effPlatformId,
displayName: username);
await TryAwardLoginXpAsync(player.Id);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refresh,
scope = "openid profile",
});
}
[HttpGet("/connect/userinfo")]
public async Task<IActionResult> UserInfo()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(new { sub = player.Id.ToString(), name = player.Username });
}
// Discovery document — issuer + endpoints derived from
// DomainConfig so a single DORKNET_DOMAIN env var keeps the
// OpenID document pointed at the live deployment.
[HttpGet("/.well-known/openid-configuration")]
public IActionResult OpenIdConfig()
{
var base_ = $"https://{domain.Sub("auth")}";
return Ok(new
{
issuer = base_,
token_endpoint = $"{base_}/connect/token",
userinfo_endpoint = $"{base_}/connect/userinfo",
});
}
// 2020.12 added an EAC challenge step in the login flow. The client GETs
// a challenge string and passes it to EACManager.GenerateChallengeResponse,
// which calls System.Convert.FromBase64CharPtr on the string. So the
// returned challenge MUST be valid base64 — sending arbitrary text throws
// FormatException client-side and kicks the login.
// returned challenge MUST be valid base64 — sending arbitrary text like
// "dorknet-eac-disabled" throws FormatException client-side and kicks
// the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches
// the rough size of a real EAC handshake nonce.
[HttpGet("/eac/challenge")]
[HttpPost("/eac/challenge")]
public IActionResult EacChallenge()
@@ -26,6 +250,11 @@ public class AuthController(
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
//
// Real lookup: any account whose last platformlogin matched
// (platform, platformId) shows up here, sorted most-recent-first. On a
// single-Steam-emu setup that's one row; on a multi-account dev box
// it's the full list the user has cycled through.
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
{
@@ -35,8 +264,10 @@ public class AuthController(
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
/// the watch uses when hydrating multiple platform-id mappings
/// at once. Body is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c>
/// or JSON with the same shape.</summary>
/// at once (e.g. populating a friend list from Steam ids). Body
/// is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c> or
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</c> across every matched id.</summary>
[HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")]
public async Task<IActionResult> CachedLoginsForPlatformIds()
@@ -54,7 +285,6 @@ public class AuthController(
}
}
catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds))
{
int.TryParse(Request.Query["platform"], out platform);
@@ -77,50 +307,13 @@ public class AuthController(
return Ok(results);
}
[HttpGet("/cachedlogin/current")]
public async Task<IActionResult> CurrentCachedLogin()
{
var token = GetBearerToken();
if (token is null) return Unauthorized();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
}
[HttpPost("/cachedlogin/migrate")]
public async Task<IActionResult> MigrateCachedLogin(
[FromForm] int platform = 0,
[FromForm] string? platformId = null)
{
var token = GetBearerToken();
if (token is null) return Unauthorized();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
await playerService.TagPlatformAsync(playerId, platform, platformId);
var player = await playerService.GetByIdAsync(playerId);
return player is null
? Unauthorized()
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
}
private string? GetBearerToken()
{
var auth = Request.Headers.Authorization.ToString();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return auth["Bearer ".Length..].Trim();
return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
? cookieToken
: null;
}
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
{
Platform = platform,
PlatformId = platformId ?? string.Empty,
PlatformId = platformId,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
};
}
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>The boot-time client-version gate the 2023.03.21 client hits
/// <summary>The boot-time client-version gate the 2020.12 watch hits
/// at startup as <c>GET api/versioncheck/v4?v={build}&amp;p={platform}</c>.
///
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
@@ -20,9 +20,10 @@ namespace DorkNet.Server.Controllers.Auth;
/// front door — the watch has dedicated UX for it, no custom client
/// mod work needed.</para>
///
/// <para>The <c>v</c> query param is the raw build identifier supplied
/// by the client. We map raw build IDs to our internal
/// <see cref="ClientVersion.Key"/>s via the
/// <para>The <c>v</c> query param is the raw build identifier the watch
/// reads from <c>KMJJGELKNAP.MCFMPCNNEBH</c> — for the December build
/// that's the literal string <c>"20201210"</c>. We map raw build IDs
/// to our internal <see cref="ClientVersion.Key"/>s via the
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
/// don't recognise OR that maps to a key not in
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
@@ -48,8 +49,7 @@ public class VersionCheckController(
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["20230317"] = "march_2023_03_21",
["20230321"] = "march_2023_03_21",
["20201210"] = "december_2020_12_18",
};
[HttpGet("/api/versioncheck/v4")]
@@ -66,7 +66,8 @@ public class VersionCheckController(
// Map raw build ID → ClientVersion key via config. Format:
// "DorkNet": {
// "BuildIdToVersionKey": {
// "20230321": "march_2023_03_21"
// "20201210": "december_2020_12_18",
// "20200310": "march_2020_03_10"
// }
// }
// Mapping ABSENT → unknown build → UpdateRequired.
+6 -192
View File
@@ -60,8 +60,6 @@ public class CdnController(
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
private static readonly Lazy<Dictionary<string, string>> RoomImageAliases =
new(LoadRoomImageAliases);
// Specific route prefixes only — NO global catch-all wildcard.
// A previous version had [Route("/{*path:minlength(1)}")] which
@@ -102,22 +100,16 @@ public class CdnController(
// String.Concat("/invention/", filename). Same byte-fetch flow as
// /room/, routed to S3 via BlobRouter on the filename.
[Route("/invention/{*path:minlength(1)}")]
// 2023 builds a CDN URL as `/sigs/<signature-set-id>` during boot
// file-hash verification. The original Rec Room CDN no longer
// serves the embedded 2023 id, so keep this on a dedicated path and
// fail soft instead of letting the request 404.
[Route("/sigs/{*path:minlength(1)}")]
// ASP.NET route templates treat `[` / `]` as token markers
// (e.g. `[controller]`). To use them as literals inside a regex
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
//
// Extension list: image formats + room/.htr/.inv blobs that the
// watch fetches directly, Studio .assetbundle files, plus video
// formats (mp4/webm/mov/m4v) so
// watch fetches directly, plus video formats (mp4/webm/mov/m4v) so
// community-board video blobs uploaded via /admin/v1/communityboard/
// video/upload (which writes `cb_video_<hash>.mp4` etc.) resolve
// through the same bare-filename catch-all.
[Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|assetbundle|mp4|webm|mov|m4v)$)}")]
[Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|mp4|webm|mov|m4v)$)}")]
[AcceptVerbs("GET", "HEAD")]
public async Task<IActionResult> Serve(string? path)
{
@@ -143,113 +135,6 @@ public class CdnController(
return await ServeCdn(fullPath);
}
// ── Studio baked Unity asset-bundle download ───────────────────────
/// <summary>GET <c>unity_assets/{assetId}/{target}/{version}</c> — the
/// 2023 client's asset-bundle download for baked Studio scenes. The
/// client builds this URL itself in RecNet.Runtime:
/// <c>String.Format("unity_assets/{0}/{1}/{2}",
/// WebUtility.UrlEncode(unityAssetId), (byte)target, (int)version)</c>
/// after reading <c>UnityAssetId</c> / <c>Target</c> / <c>Version</c>
/// from <c>rooms/{id}/subrooms/{sid}/saves/{saveId}</c>. It does NOT
/// fetch the bare <c>{filename}.assetbundle</c> the save metadata names
/// — so the bundle bytes only resolve through this id-addressed route.
///
/// <para>Resolution: the importer stores each baked bundle in S3 under
/// its original filename (<c>{saveId}__bundle_t{target}_v{version}.assetbundle</c>)
/// and stamps the owning scene's <see cref="Data.Entities.RoomSceneEntity.StudioUnityAssetId"/>
/// + <see cref="Data.Entities.RoomSceneEntity.StudioAssetBundleNamesCsv"/>.
/// We look the scene up by <c>StudioUnityAssetId == assetId</c>, pick the
/// bundle whose embedded target+version match, and stream its bytes.</para>
///
/// <para>A genuine miss 404s — handing back a default room blob here
/// (as the room-blob path does) would feed the Unity AssetBundle loader
/// garbage and fail the scene load far less legibly than an honest
/// 404.</para></summary>
[Route("/unity_assets/{assetId}/{target:int}/{version:int}")]
[AcceptVerbs("GET", "HEAD")]
public async Task<IActionResult> ServeUnityAsset(string assetId, int target, int version)
{
assetId = (assetId ?? string.Empty).Trim();
if (string.IsNullOrEmpty(assetId)) return NotFound();
// The Studio UnityAssetId is the per-save asset id from the dump
// sidecar, unique per baked save, so this maps to one scene in
// practice; tolerate >1 by scanning each candidate's bundle list.
var scenes = await db.RoomScenes
.Where(s => s.StudioUnityAssetId == assetId)
.ToListAsync();
var candidates = scenes
.SelectMany(s => (s.StudioAssetBundleNamesCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(ParseBundleName)
.Where(b => b is not null)
.Cast<(int Target, int Version, string Filename)>()
.Where(b => b.Target == target)
.ToList();
// Exact match first; otherwise serve the highest available version
// for the requested target (a Studio re-bake bumps the version, but
// a cached client manifest may still ask for a superseded one).
var pick = candidates.FirstOrDefault(b => b.Version == version);
if (pick.Filename is null && candidates.Count > 0)
pick = candidates.OrderByDescending(b => b.Version).First();
if (pick.Filename is null)
{
logger.LogWarning("[unity_assets] no bundle assetId={AssetId} target={Target} version={Version} scenes={Scenes}",
assetId, target, version, scenes.Count);
return NotFound();
}
var (bucket, key) = BlobRouter.Route(pick.Filename);
byte[]? bytes = null;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
bytes = await storage.GetAsync(bucket, key, cts.Token);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[unity_assets] S3 GetAsync threw {Bucket}/{Key}", bucket, key);
}
if (bytes is not { Length: > 0 })
{
logger.LogWarning("[unity_assets] MISS assetId={AssetId} file={File} bucket={Bucket} key={Key}",
assetId, pick.Filename, bucket, key);
return NotFound();
}
logger.LogInformation("[unity_assets] hit assetId={AssetId} target={Target} version={Version} file={File} bytes={Bytes}",
assetId, target, version, pick.Filename, bytes.Length);
Response.Headers.CacheControl = "public, max-age=3600";
signatures.AddContentSignature(Response, bytes);
return new FileContentResult(bytes, "application/octet-stream");
}
/// <summary>Parse a Studio baked-bundle filename
/// (<c>{saveId}__bundle_t{target}_v{version}.assetbundle</c>, or older
/// <c>{saveId}__bundle_t{target}.assetbundle</c>) into its
/// target/version/filename. Mirrors the parser in RoomsController so the
/// id-addressed CDN route and the save-metadata endpoint agree on the
/// same naming contract.</summary>
private static (int Target, int Version, string Filename)? ParseBundleName(string fileName)
{
var name = Path.GetFileName(fileName);
var match = System.Text.RegularExpressions.Regex.Match(
name,
@"^(?<save>\d+)__bundle_t(?<target>\d+)(?:_v(?<version>\d+))?\.assetbundle$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant);
if (!match.Success) return null;
if (!int.TryParse(match.Groups["target"].Value, out var target)) return null;
var version = 0;
if (match.Groups["version"].Success &&
!int.TryParse(match.Groups["version"].Value, out version))
return null;
return (target, version, name);
}
// ── Image pipeline (img.{apex}) ────────────────────────────────────
private async Task<IActionResult> ServeImage(string? path)
{
@@ -342,8 +227,8 @@ public class CdnController(
return RespondWithTransforms(s3Bytes, contentType);
}
var full = ResolveLocalImagePath(safe);
if (full is not null)
var full = Path.Combine(ImageDir, safe);
if (System.IO.File.Exists(full))
{
logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}",
Request.Host.Host, path, full);
@@ -381,17 +266,6 @@ public class CdnController(
// ── CDN serve path (cdn.*, storage.*, data.*, apex) ────────────────
private async Task<IActionResult> ServeCdn(string? path)
{
if (!string.IsNullOrEmpty(path) &&
path.StartsWith("sigs/", StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("[cdn] signature set MISS host={Host} path={Path} -> empty signature payload",
Request.Host.Host, path);
var payload = Array.Empty<byte>();
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, payload);
return new FileContentResult(payload, "application/octet-stream");
}
if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase))
{
// Tips live in the LoadingScreenTips DB table; admins
@@ -508,21 +382,10 @@ public class CdnController(
// S3 is the only canonical store — DB carries text-only
// metadata (BlobName, owner, timestamps) and never holds
// bytes. RRO thumbnail images are the exception for local dev:
// tools/fetch-room-images.py stores them under data/images/, and
// the 2023 client requests them through cdn.* rather than img.*.
// bytes. If S3 misses, the bytes don't exist on this server.
if (IsImageName(fileName))
{
var imagePath = ResolveLocalImagePath(fileName);
if (imagePath is not null)
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(imagePath);
logger.LogInformation("[cdn] disk image hit host={Host} file={File} -> {Path} bytes={Bytes}",
Request.Host.Host, fileName, imagePath, imageBytes.Length);
Response.Headers.CacheControl = "public, max-age=300";
return RespondWithTransforms(imageBytes, MimeFromName(fileName));
}
logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png",
Request.Host.Host, fileName, Request.QueryString);
Response.Headers.CacheControl = "public, max-age=60";
@@ -633,7 +496,6 @@ public class CdnController(
".mp4" or ".m4v" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
".assetbundle" => "application/octet-stream",
_ => "application/octet-stream",
};
}
@@ -643,52 +505,4 @@ public class CdnController(
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp";
}
private static string? ResolveLocalImagePath(string fileName)
{
var direct = Path.Combine(ImageDir, fileName);
if (System.IO.File.Exists(direct)) return direct;
var baseName = Path.GetFileNameWithoutExtension(fileName);
if (!baseName.StartsWith("image_", StringComparison.OrdinalIgnoreCase))
return null;
var roomName = baseName["image_".Length..];
if (!RoomImageAliases.Value.TryGetValue(roomName, out var mappedName))
return null;
var mapped = Path.Combine(ImageDir, mappedName);
return System.IO.File.Exists(mapped) ? mapped : null;
}
private static Dictionary<string, string> LoadRoomImageAliases()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "room_images.json"),
};
foreach (var path in candidates)
{
if (!System.IO.File.Exists(path)) continue;
try
{
using var fs = System.IO.File.OpenRead(path);
var map = JsonSerializer.Deserialize<Dictionary<string, string>>(fs);
if (map is null) continue;
foreach (var (roomName, imageName) in map)
if (!string.IsNullOrWhiteSpace(roomName) && !string.IsNullOrWhiteSpace(imageName))
result[roomName] = imageName;
break;
}
catch
{
// Fall back to direct filename lookup.
}
}
return result;
}
}
@@ -191,23 +191,6 @@ public class ClubsController(ClubService clubs) : ControllerBase
return Ok(playerSubs + clubSubs);
}
[HttpGet("/subscription/details/{accountId:long}")]
public async Task<IActionResult> SubscriptionDetails(long accountId)
{
var viewerId = this.CurrentPlayerId();
var subscriberCount = await clubs.PlayerSubscriberCountAsync(accountId);
var subscribedCount = await clubs.PlayerSubscribedCountAsync(accountId);
var isSubscribed = viewerId is long viewer
&& await clubs.IsSubscribedToPlayerAsync(viewer, accountId);
return Ok(new
{
accountId,
subscriberCount,
subscribedCount,
isSubscribed,
});
}
/// <summary>
/// POST endpoint the watch hits when the player taps "mark read".
/// Idempotent — the upsert in the service quietly returns if a
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
using DorkNet.Server.Controllers.API.Avatar.V4;
namespace DorkNet.Server.Controllers.Commerce;
@@ -86,31 +85,6 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain)
{
var available = i.IsActive && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow);
var imageUrl = string.IsNullOrEmpty(i.ImageName) ? "" : $"https://cdn.{apex}/{i.ImageName}";
// Avatar-item linkage — THE thing that makes wardrobe items appear in
// the Shop's Clothing tab. The 2023 client does NOT categorize a SKU by
// any field on the SKU itself; instead StoreItemListModel resolves the
// SKU to the client's baked AvatarItem (by GUID) and reads that item's
// OutfitType to place it in a tab. With no AvatarItemId the resolve
// returns OutfitType = -1, the item matches no Clothing/Consumables
// filter, and the whole Shop renders empty even though all 1531 SKUs
// parsed fine. The GUID is the tail of the "wardrobe-{guid}" slug; ship
// it (plus the OutfitType) under the keys the client reads
// (AvatarItemId / AvatarItemType — verified vs 2023 ISIL store-item
// context types EMBCEDNHFLB / CLIANCNHNIE). Coloured variants
// (wardrobe-colored-*) resolve through a different descriptor path and
// are left alone here.
string? avatarItemId = null;
int? avatarItemType = null;
if (i.Slug.StartsWith("wardrobe-", StringComparison.Ordinal)
&& !i.Slug.StartsWith("wardrobe-colored-", StringComparison.Ordinal))
{
var guid = i.Slug["wardrobe-".Length..];
avatarItemId = guid;
if (AvatarItemsController.TryGetOutfitType(guid, out var ot))
avatarItemType = ot;
}
return new Dictionary<string, object?>
{
// Identity / naming — every alias the watch's strict
@@ -142,14 +116,6 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain)
["Slug"] = i.Slug,
["slug"] = i.Slug,
// Avatar-item join keys (see resolve block above). Emitted both at
// top level and inside Data so whichever the client reads, the
// SKU→AvatarItem resolve succeeds and the item lands in a tab.
["AvatarItemId"] = avatarItemId,
["avatarItemId"] = avatarItemId,
["AvatarItemType"] = avatarItemType,
["avatarItemType"] = avatarItemType,
["Name"] = i.DisplayName,
["name"] = i.DisplayName,
["DisplayName"] = i.DisplayName,
@@ -239,21 +205,13 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain)
// string / null sub-purchase are valid.
["Data"] = new Dictionary<string, object?>
{
["AvatarItemId"] = avatarItemId,
["AvatarItemType"] = avatarItemType,
// Link this SKU to its gift-drop store card (the EFFIEFEFHHB
// record `storefronts/v3/giftdropstore` builds, GiftDropId =
// this same id) so the client can resolve the card that carries
// the AvatarItemId join key. Empty here = SKU points at no card.
["GiftDropIds"] = new[] { (int)(i.Id & 0x7fffffff) },
["GiftDropIds"] = Array.Empty<int>(),
["Message"] = string.Empty,
["SubscriptionPurchase"] = (object?)null,
},
["data"] = new Dictionary<string, object?>
{
["avatarItemId"] = avatarItemId,
["avatarItemType"] = avatarItemType,
["giftDropIds"] = new[] { (int)(i.Id & 0x7fffffff) },
["giftDropIds"] = Array.Empty<int>(),
["message"] = string.Empty,
["subscriptionPurchase"] = (object?)null,
},
@@ -1,13 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Commerce;
[ApiController]
public class PurchaseCampaignController : ControllerBase
{
[HttpGet("/purchasecampaign/allcurrent/v2")]
[HttpGet("/purchasecampaign/allcurrent")]
[AllowAnonymous]
public IActionResult AllCurrent() => Ok(Array.Empty<object>());
}
@@ -71,53 +71,6 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
.ToListAsync();
return Ok(rows);
}
[HttpGet("/econ/customAvatarItems")]
[HttpGet("/econ/customAvatarItems/v1/owned")]
public async Task<IActionResult> GetOwnedCustomAvatarItems([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var pid = this.RequireCurrentPlayerId();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var query = db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
.OrderByDescending(i => i.UpdatedAt);
var total = await query.CountAsync();
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(new
{
Results = rows.Select(ToCustomAvatarItemWire),
TotalResults = total,
});
}
[HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")]
public IActionResult CustomAvatarItemOwnershipLimit() => Ok(new
{
Limit = 1000,
ItemOwnershipLimit = 1000,
});
private static object ToCustomAvatarItemWire(DorkNet.Server.Data.Entities.CustomAvatarItemEntity i) => new
{
CustomAvatarItemId = i.PublicId,
Id = i.PublicId,
CreatorPlayerId = (int)i.CreatorPlayerId,
i.Name,
i.Description,
i.Price,
i.ItemType,
i.ImageName,
i.AssetName,
i.Color,
};
}
public class TokenBalance
@@ -215,7 +215,19 @@ public class GoToController(
// (roomInstanceId differs → reset state machine) plus "new
// photon match" (photonRoomId differs → leave/rejoin →
// SceneLoaded events that ultimately dismiss the dialog).
ApplySubRoomMutation(resp, subId, subRoomName, subLocation, subDataBlob);
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
resp.RoomInstance.Name = subRoomName;
resp.RoomInstance.SubRoomId = subId;
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
// dataBlob on the matchmaking response tells the client which
// PersistedRoomData blob to download for the entered scene.
// RoomInstance.Deserialize at RVA 0x114DAB0 reads it via the
// string-with-default reader (call 0x180EE6210), so empty is
// technically legal — but if it's empty the watch falls back
// to the parent room's RoomDataBlobName, which is the lobby's
// blob, so the sub-scene visually never changes.
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
// Private/new-instance mutation goes AFTER sub-room mutation so the
// PhotonRoomId we register includes the _sub{N} suffix as its
@@ -228,142 +240,6 @@ public class GoToController(
return Ok(resp);
}
/// <summary>Mutate <paramref name="resp"/> in place so it represents
/// the given sub-scene of its parent room. Both PhotonRoomId AND
/// RoomInstanceId must differ from the lobby's or the watch's
/// transition state machine never fires:
/// • Same photon + same roomInstanceId → the new dataBlob downloads
/// but the watch never transitions; "Going to X" hangs forever.
/// • Different photon + same roomInstanceId → the watch sees the id
/// unchanged, treats the response as a no-op presence update and
/// skips the join flow entirely.
/// Folding subId into RoomInstanceId AND forking PhotonRoomId gives
/// the watch both signals it needs ("new instance" + "new photon
/// match"). dataBlob tells the client which PersistedRoomData to
/// download for the scene (RoomInstance.Deserialize, RVA 0x114DAB0,
/// reads it with a string-with-default reader so empty is legal) —
/// but an empty blob makes the watch fall back to the parent room's
/// lobby blob, so the sub-scene never changes visually. Only override
/// location/blob when we actually resolved one.</summary>
private static void ApplySubRoomMutation(
MatchmakingResponseDto resp, long subId, string subRoomName,
string? subLocation, string? subDataBlob, string? subRoomUnityAssetId = null)
{
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
resp.RoomInstance.Name = subRoomName;
resp.RoomInstance.SubRoomId = subId;
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
resp.RoomInstance.SubRoomUnityAssetId = subRoomUnityAssetId ?? string.Empty;
}
/// <summary>POST <c>match.*/matchmake/room/{roomId}</c> — the 2023
/// client's primary "join this public room" call
/// (<c>RecNet.Matchmaking</c>, format string <c>"matchmake/room/{0}"</c>,
/// same sender <c>HHJFFJEHLOB</c> as the working <c>matchmake/dorm</c>,
/// so it's a POST returning <see cref="MatchmakingResponseDto"/>).
/// It navigates by NUMERIC roomId straight from the room tile, NOT by
/// name like the older <c>/goto/room/{name}</c> path. With no route
/// here every public-room join 404s and the watch bounces the player
/// back to their dorm ("Matchmaking request failed
/// (matchmake/room/N): HTTP Error 404"). We resolve the id to its
/// room and reuse <see cref="BuildResponseAsync"/> so dorm/private/
/// dataBlob/JoinMode handling stays identical to <see cref="GoToRoom"/>.
/// GET is accepted too as cheap insurance against a verb mismatch.</summary>
[HttpPost("/matchmake/room/{roomId:long}")]
[HttpGet("/matchmake/room/{roomId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeRoom(
long roomId,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
logger.LogWarning("[matchmake-room] unknown roomId={RoomId} — returning RoomDoesNotExist", roomId);
// 4 = RoomDoesNotExist (CreateModifyRoomStatus); watch shows
// its standard "room not found" toast instead of softlocking.
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
}
var resp = await BuildResponseAsync(room.Name);
await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode);
await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
logger.LogInformation(
"[matchmake-room] player={Player} roomId={RoomId} name={Name} instance={Instance} photon={Photon}",
this.CurrentPlayerId(), roomId, room.Name,
resp.RoomInstance?.RoomInstanceId, resp.RoomInstance?.PhotonRoomId);
return Ok(resp);
}
/// <summary>POST <c>match.*/matchmake/room/{roomId}/{subRoomId}</c> —
/// sub-room form (<c>RecNet.Matchmaking</c> "matchmake/room/{0}/{1}").
/// Unlike <see cref="GoToSubRoom"/> (which takes a sub-room NAME and
/// fuzzy-matches it) the sub-room arrives here as a numeric
/// <see cref="RoomSceneEntity.OrderIndex"/>, so we resolve the scene
/// row by OrderIndex directly. subRoomId 0 is the lobby/entry scene —
/// the base response already represents it, so we skip the
/// mutation.</summary>
[HttpPost("/matchmake/room/{roomId:long}/{subRoomId:long}")]
[HttpGet("/matchmake/room/{roomId:long}/{subRoomId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeSubRoom(
long roomId, long subRoomId,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
logger.LogWarning(
"[matchmake-room] unknown roomId={RoomId} (sub={Sub}) — returning RoomDoesNotExist",
roomId, subRoomId);
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
}
var resp = await BuildResponseAsync(room.Name);
if (subRoomId != 0)
{
var scene = await db.RoomScenes.AsNoTracking()
.Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId)
.Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName, s.StudioUnityAssetId })
.FirstOrDefaultAsync();
if (scene is not null)
{
logger.LogInformation(
"[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob} unityAsset={UnityAsset}",
roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName, scene.StudioUnityAssetId);
ApplySubRoomMutation(
resp,
subRoomId,
scene.Name,
scene.RoomSceneLocationId,
scene.DataBlobName,
scene.StudioUnityAssetId);
}
else
{
logger.LogWarning(
"[matchmake-subroom] {Room}/{Sub} — no RoomScenes row with that OrderIndex; " +
"forking ids onto the lobby scene so the watch still transitions",
roomId, subRoomId);
// Reuse the lobby location/blob (sub-scene won't differ
// visually) but still fold subId in so the watch leaves
// the current match instead of softlocking.
ApplySubRoomMutation(resp, subRoomId, resp.RoomInstance.Name, null, null);
}
}
await ApplyNewInstanceAsync(resp, subRoomId: subRoomId, createPrivateInstance, joinMode);
await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
return Ok(resp);
}
/// <summary>Per-sub-room scene location overrides for baked
/// AG-Original rooms with multiple scenes. Each entry maps a
/// <c>"{room}/{subRoom}"</c> key (lowercase) to the
@@ -691,7 +567,6 @@ public class GoToController(
IsPrivate = true,
IsInProgress = true,
DataBlob = inst.DataBlob,
SubRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(inst.RoomId, inst.SubRoomId),
},
};
}
@@ -826,7 +701,6 @@ public class GoToController(
IsPrivate = room.IsPrivate,
IsInProgress = room.IsInProgress,
DataBlob = room.DataBlob,
SubRoomUnityAssetId = room.SubRoomUnityAssetId,
EventId = room.EventId,
Name = room.Name,
RoomInstanceType = room.RoomInstanceType,
@@ -1209,7 +1083,6 @@ public class GoToController(
? $"^dormroom_p{ownerForName}"
: $"^{roomName.ToLowerInvariant()}_{roomId}";
var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId);
var subRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(roomId, 0);
var resp = new MatchmakingResponseDto
{
@@ -1238,7 +1111,6 @@ public class GoToController(
IsPrivate = false,
IsInProgress = true,
DataBlob = dataBlob,
SubRoomUnityAssetId = subRoomUnityAssetId,
},
};
logger.LogInformation(
@@ -1300,17 +1172,10 @@ public class GoToController(
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
if (room.IsDormRoom) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
private async Task<string> ResolveSubRoomUnityAssetIdAsync(long roomId, long subRoomId)
{
return await db.RoomScenes.AsNoTracking()
.Where(s => s.RoomId == roomId && s.OrderIndex == subRoomId)
.Select(s => s.StudioUnityAssetId)
.FirstOrDefaultAsync() ?? string.Empty;
var customisable = room.CreatorPlayerId != 1;
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
}
/// <summary>
@@ -1360,7 +1225,6 @@ public class GoToController(
"cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038",
"recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f",
"recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11",
"recrally" => "56193568-9ae0-498c-8a77-4df79dec91f5",
_ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom
};
}
@@ -1421,13 +1285,6 @@ public class RoomInstanceDto
[JsonPropertyName("dataBlob")]
public string DataBlob { get; set; } = string.Empty;
// 2023 RoomLoading.LoadRoomCommand carries this separately from
// dataBlob. If it is empty, the loader can deserialize room view data
// before the Studio asset bundle has registered its prefabs, producing
// "Bad View Data detected. Expected a prefab, but found none".
[JsonPropertyName("subRoomUnityAssetId")]
public string SubRoomUnityAssetId { get; set; } = string.Empty;
[JsonPropertyName("eventId")]
public long EventId { get; set; } = 0;
@@ -1501,7 +1358,6 @@ public class RoomInstanceDto
IsPrivate = room.IsPrivate,
IsInProgress = room.IsInProgress,
DataBlob = string.Empty,
SubRoomUnityAssetId = room.SubRoomUnityAssetId,
EventId = room.EventId,
Name = room.Name,
};
@@ -17,12 +17,10 @@ namespace DorkNet.Server.Controllers.Match;
[ApiController]
[Authorize]
public class MatchController(
IConfiguration config,
GameSessionService sessionService,
PlayerPresenceService presence,
PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts,
RoomService rooms,
DorkNetDbContext db,
ILogger<MatchController> logger) : ControllerBase
{
@@ -37,8 +35,8 @@ public class MatchController(
/// we mostly use this for visibility into Photon-side issues —
/// without it, all we see in the catch-all is an opaque "200, empty
/// reply" and no clue why a player crashed back to dorm. Failure
/// reports also clear the pending /goto room so the next matchmaking
/// attempt starts from a clean presence state.</summary>
/// reports also clear the pending /goto room and push a ModerationKick
/// so the watch leaves the "Joining room" overlay instead of softlocking.</summary>
[HttpPost("/roominstance/{instanceId:long}/reportjoinresult")]
public async Task<IActionResult> ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result)
{
@@ -59,7 +57,6 @@ public class MatchController(
{
presence.SetRoom(pid, completed.Value.TargetRoom);
presence.MarkActive(pid);
presence.EndBootDormGrace(pid);
logger.LogInformation(
"[joinresult] committed presence player={Player} room={RoomId}/{RoomName} instance={Instance} deferred={Deferred}",
pid,
@@ -72,7 +69,6 @@ public class MatchController(
{
presence.MarkActive(pid);
presence.TouchRoom(pid);
presence.EndBootDormGrace(pid);
}
logger.LogInformation(
"[joinresult] player={Player} instance={Instance} OK ({Label})",
@@ -82,220 +78,12 @@ public class MatchController(
{
await joinTimeouts.MarkFailedAsync(pid, instanceId, label);
logger.LogWarning(
"[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label})",
"[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label}) — Photon-side error, check Photon AppId/region/auth",
pid, instanceId, code, label);
}
return Ok();
}
[HttpPost("/matchmake/none")]
public async Task<IActionResult> MatchmakeNone()
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized();
var pending = joinTimeouts.GetPending(pid);
if (pending is { DeferPresenceCommit: true })
{
presence.MarkActive(pid);
logger.LogInformation(
"[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence",
pid,
pending.Value.TargetRoom.RoomId,
pending.Value.TargetRoom.RoomInstanceId);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = pending.Value.TargetRoom,
});
}
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
presence.SetRoom(pid, current);
presence.MarkActive(pid);
logger.LogInformation("[matchmake-none] player={Player}", pid);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = current,
});
}
[HttpGet("/photon_access_token")]
public IActionResult PhotonAccessToken()
{
var pid = this.RequireCurrentPlayerId();
var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var currentRoom = presence.GetRoom(pid);
var permissions = BuildPhotonPermissionSeed();
logger.LogInformation(
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
return Ok(new Dictionary<string, object>
{
// 2023 deserializes this endpoint as ALIIIAGAPND before room
// permissions initialization. If Permissions is missing/empty,
// OMJKHJLFOCO falls into the persisted-role migration path that
// currently NREs during first spawn.
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
["PhotonAccessToken"] = string.Empty,
["Permissions"] = permissions,
// Existing custom-auth/app-id shape kept for Photon auth callers.
["ResultCode"] = 1,
["Message"] = string.Empty,
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
["PhotonAppId"] = appId,
["PhotonVoiceAppId"] = voiceAppId,
["AppId"] = appId,
["VoiceAppId"] = voiceAppId,
["CloudRegion"] = region,
["Region"] = region,
["Token"] = string.Empty,
});
}
private static object[] BuildPhotonPermissionSeed()
=>
[
BuildPhotonPermission(13, 30), // Can Use Maker Pen
BuildPhotonPermission(20, 30), // Can Spawn Inventions
BuildPhotonPermission(21, 30), // Can Spawn Consumables
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
BuildPhotonPermission(13, 255), // Creator alias
BuildPhotonPermission(20, 255),
BuildPhotonPermission(21, 255),
BuildPhotonPermission(22, 255),
BuildPhotonPermission(23, 255),
];
private static object BuildPhotonPermission(int permission, int role) => new
{
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
Role = role,
Override = true,
Type = 0,
Value = "true",
};
[HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm()
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized();
var room = await BuildDormRoomInstanceAsync(pid);
joinTimeouts.MarkPending(pid, room, deferPresenceCommit: true);
presence.MarkActive(pid);
await RecordVisitAsync(pid, room.RoomId);
logger.LogInformation(
"[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon} deferredPresence=True",
pid, room.RoomId, room.RoomInstanceId, room.PhotonRoomId);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = room,
});
}
private async Task<RoomInstanceDto> BuildDormRoomInstanceAsync(long playerId)
{
var personal = await rooms.EnsurePersonalDormAsync(playerId);
var dataBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, personal.Id);
if (string.IsNullOrWhiteSpace(dataBlob))
dataBlob = personal.CurrentDataBlobName;
var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var photonRoomId = $"^dormroom_p{playerId}";
var dormInstanceName = await BuildDormInstanceNameAsync(playerId);
var dorm = await privateInstances.EnsureForDormAsync(
ownerPlayerId: playerId,
roomId: personal.Id,
subRoomId: 0,
baseName: dormInstanceName,
location: personal.LocationReplicationId,
dataBlob: dataBlob ?? string.Empty,
photonRegion: photonRegion,
maxCapacity: personal.MaxCapacity,
photonRoomId: photonRoomId);
return new RoomInstanceDto
{
RoomInstanceId = dorm.InstanceId,
RoomId = personal.Id,
SubRoomId = 0,
Location = personal.LocationReplicationId,
PhotonRegionId = photonRegion,
PhotonRoomId = photonRoomId,
Name = dormInstanceName,
MaxCapacity = personal.MaxCapacity,
IsFull = false,
IsPrivate = true,
IsInProgress = true,
DataBlob = dataBlob ?? string.Empty,
EventId = 0,
};
}
private async Task<string> BuildDormInstanceNameAsync(long ownerPlayerId)
{
var player = await db.Players.AsNoTracking()
.Where(p => p.Id == ownerPlayerId)
.Select(p => new { p.DisplayName, p.Username })
.FirstOrDefaultAsync();
var name = !string.IsNullOrWhiteSpace(player?.DisplayName)
? player!.DisplayName
: !string.IsNullOrWhiteSpace(player?.Username)
? player!.Username
: $"Player{ownerPlayerId}";
var cleaned = new string(name
.Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_')
.ToArray()).Trim('_');
return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm";
}
private async Task RecordVisitAsync(long playerId, long roomId)
{
var visit = await db.RoomVisits
.FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId);
var isFirst = visit is null;
if (isFirst)
{
visit = new RoomVisitEntity
{
RoomId = roomId,
PlayerId = playerId,
FirstVisitAt = DateTime.UtcNow,
};
db.RoomVisits.Add(visit);
}
visit!.LastVisitAt = DateTime.UtcNow;
visit.VisitCount += 1;
if (isFirst)
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
.SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 2.0));
}
else
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 0.25));
}
await db.SaveChangesAsync();
}
/// <summary>PUT <c>/roominstance/{id}/inprogress</c> — fired by
/// <c>RecNet.Matchmaking.SetGameInProgress(bool inProgress)</c>
@@ -23,7 +23,6 @@ public class MatchPlayerController(
NotificationService notifications,
RoomService rooms,
PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts,
IConfiguration config,
DorkNetDbContext db,
ILogger<MatchPlayerController> log) : ControllerBase
@@ -39,37 +38,16 @@ public class MatchPlayerController(
[HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
=> await RecordLoginLockAsync(loginLock, "player-login");
/// <summary>
/// POST <c>/player/exclusivelogin</c> — newer clients use this instead
/// of <c>/player/login</c> and include a
/// <c>TakeOverExclusiveSession</c> form value. The lock semantics are
/// otherwise the same: the latest LoginLock wins for this account.
/// </summary>
[HttpPost("/player/exclusivelogin")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerExclusiveLogin(
[FromForm(Name = "LoginLock")] string? loginLock,
[FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession)
=> await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession);
private async Task<IActionResult> RecordLoginLockAsync(
string? loginLock,
string logScope,
bool? takeOverExclusiveSession = null)
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
{
presence.Clear(playerId);
presence.BeginBootDormGrace(playerId);
var replaced = presence.SetLock(playerId, loginLock);
if (replaced)
{
log.LogWarning(
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
logScope, playerId, takeOverExclusiveSession);
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
playerId);
// The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the
@@ -158,7 +136,7 @@ public class MatchPlayerController(
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = 0, // Everyone (was 1 = FriendsOnly)
StatusVisibility = 1,
DeviceClass = 0,
VrMovementMode = 0,
RoomInstance = room,
@@ -174,7 +152,7 @@ public class MatchPlayerController(
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = value ?? 0, // default Everyone; honor explicit client choice
StatusVisibility = value ?? 1,
DeviceClass = 0,
VrMovementMode = 0,
RoomInstance = room,
@@ -190,7 +168,7 @@ public class MatchPlayerController(
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = 0, // Everyone (was 1 = FriendsOnly)
StatusVisibility = 1,
DeviceClass = 0,
VrMovementMode = value ?? 0,
RoomInstance = room,
@@ -279,27 +257,8 @@ public class MatchPlayerController(
if (playerId == 0) return null;
var room = presence.GetRoom(playerId);
var pending = joinTimeouts.GetPending(playerId);
if (pending is { DeferPresenceCommit: true })
{
log.LogInformation(
"[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; returning null until join result",
playerId,
pending.Value.TargetRoom.RoomId,
pending.Value.TargetRoom.RoomInstanceId);
return null;
}
if (room is null)
{
if (presence.IsBootDormGraceActive(playerId))
{
log.LogInformation(
"[player-heartbeat] player={PlayerId} in boot dorm grace; returning null roomInstance",
playerId);
return null;
}
// Boot-time race: the watch hits /player/heartbeat before
// /goto/room/DormRoom has finished recording presence. The
// watch's OnPresenceHeartbeatResponse compares
@@ -329,12 +288,7 @@ public class MatchPlayerController(
return null;
}
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(
room.RoomId,
playerId,
room.RoomInstanceId,
room.SubRoomId,
room.IsPrivate);
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(room.RoomId, playerId, room.RoomInstanceId);
if (!string.IsNullOrWhiteSpace(resolvedDataBlob)
&& !string.Equals(room.DataBlob, resolvedDataBlob, StringComparison.Ordinal))
{
@@ -439,9 +393,7 @@ public class MatchPlayerController(
private async Task<string> ResolveDataBlobForPresenceAsync(
long roomId,
int playerId,
long? roomInstanceId = null,
long? subRoomId = null,
bool isPrivateInstance = false)
long? roomInstanceId = null)
{
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return string.Empty;
@@ -452,7 +404,7 @@ public class MatchPlayerController(
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
}
if (isPrivateInstance && roomInstanceId is > 0)
if (roomInstanceId is > 0)
{
var instanceBlob = await db.PrivateInstances.AsNoTracking()
.Where(instance => instance.Id == roomInstanceId && instance.RoomId == roomId)
@@ -461,20 +413,12 @@ public class MatchPlayerController(
if (!string.IsNullOrWhiteSpace(instanceBlob)) return instanceBlob;
}
if (subRoomId is >= 0)
{
var sceneBlob = await db.RoomScenes.AsNoTracking()
.Where(scene => scene.RoomId == roomId && scene.OrderIndex == subRoomId)
.Select(scene => scene.DataBlobName)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(sceneBlob)) return sceneBlob;
}
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
if (room.IsDormRoom) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
var customisable = room.CreatorPlayerId != 1;
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
}
}
@@ -504,8 +448,5 @@ public class PresenceDto
public bool IsOnline { get; set; } = true;
[JsonPropertyName("appVersion")]
// 2023.03.21 client build version ("20230317"); must match the joining
// client's own version or it reports a version mismatch. Was a stale 2020
// value (20201210) left over from the December branch this forked from.
public string AppVersion { get; set; } = "20230317";
public int AppVersion { get; set; } = 20201210;
}
@@ -98,14 +98,12 @@ public class PlayerPresenceController(
return new PlayerPresenceDto
{
PlayerId = (int)id,
// StatusVisibility is the player's *preference* for who may
// see them online. The enum is Everyone=0 / FriendsOnly=1 /
// Nobody=2 — so the previous value 1 (mislabeled "Visible")
// actually meant FriendsOnly, and anyone who wasn't your
// friend saw you as OFFLINE. On a private server everyone
// should see everyone, so report Everyone (0). IsOnline=false
// still gates the "Online" badge for offline players.
StatusVisibility = 0, // Everyone
// StatusVisibility is the player's *preference* (would
// they like to appear online if they were online?). For
// offline players we still report Visible so when they
// come back the friend's cached preference is correct,
// and IsOnline=false is what gates the "Online" badge.
StatusVisibility = 1, // Visible
DeviceClass = 0, // Desktop / unknown — not VR
VrMovementMode = 0,
// Full instance details are safe, and necessary, when the
@@ -181,8 +179,5 @@ public class PlayerPresenceDto
public bool IsOnline { get; set; } = true;
[JsonPropertyName("appVersion")]
// 2023.03.21 client build version ("20230317"); must match the joining
// client's own version or it reports a version mismatch. Stale 2020 value
// (20201210) carried over from the December branch.
public string AppVersion { get; set; } = "20230317";
public int AppVersion { get; set; } = 20201210;
}
@@ -1,122 +0,0 @@
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.PlatformNotifications;
[ApiController]
[Authorize]
public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("/preferences")]
[HttpGet("/platformnotifications/preferences")]
public async Task<IActionResult> Preferences()
{
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
return Ok(ToPreferencesWire(row));
}
[HttpPut("/preferences")]
[HttpPost("/preferences")]
[HttpPut("/platformnotifications/preferences")]
[HttpPost("/platformnotifications/preferences")]
public async Task<IActionResult> SetPreferences([FromBody] PlatformNotificationPrefsRequest? body)
{
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
if (row is null)
{
row = new NotificationPrefsEntity
{
PlayerId = Me,
Platform = "platform",
};
db.NotificationPrefs.Add(row);
}
if (body is not null)
{
row.AllowAll = body.AllowAll ?? body.Enabled ?? row.AllowAll;
row.AllowMessage = body.AllowMessages ?? body.Messages ?? row.AllowMessage;
row.AllowFriendRequest = body.AllowFriendRequests ?? body.FriendRequests ?? row.AllowFriendRequest;
row.AllowEventInvite = body.AllowEventInvites ?? body.EventInvites ?? row.AllowEventInvite;
row.AllowAnnouncements = body.AllowAnnouncements ?? body.Announcements ?? row.AllowAnnouncements;
row.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(ToPreferencesWire(row));
}
[HttpGet("/config/categories")]
[HttpGet("/platformnotifications/config/categories")]
[AllowAnonymous]
public IActionResult Categories()
{
var categories = new[]
{
Category(1, "messages", "Messages"),
Category(2, "friendRequests", "Friend Requests"),
Category(3, "eventInvites", "Event Invites"),
Category(4, "announcements", "Announcements"),
};
return Ok(new
{
Categories = categories,
Results = categories,
TotalResults = categories.Length,
});
}
private static object Category(int id, string key, string name) => new
{
Id = id,
CategoryId = id,
Key = key,
Name = name,
DisplayName = name,
IsEnabled = true,
Enabled = true,
DefaultEnabled = true,
};
private static object ToPreferencesWire(NotificationPrefsEntity? row) => new
{
AllowAll = row?.AllowAll ?? true,
Enabled = row?.AllowAll ?? true,
AllowMessages = row?.AllowMessage ?? true,
Messages = row?.AllowMessage ?? true,
AllowFriendRequests = row?.AllowFriendRequest ?? true,
FriendRequests = row?.AllowFriendRequest ?? true,
AllowEventInvites = row?.AllowEventInvite ?? true,
EventInvites = row?.AllowEventInvite ?? true,
AllowAnnouncements = row?.AllowAnnouncements ?? true,
Announcements = row?.AllowAnnouncements ?? true,
Categories = new[]
{
new { CategoryId = 1, Key = "messages", Enabled = row?.AllowMessage ?? true },
new { CategoryId = 2, Key = "friendRequests", Enabled = row?.AllowFriendRequest ?? true },
new { CategoryId = 3, Key = "eventInvites", Enabled = row?.AllowEventInvite ?? true },
new { CategoryId = 4, Key = "announcements", Enabled = row?.AllowAnnouncements ?? true },
},
};
public sealed class PlatformNotificationPrefsRequest
{
public bool? AllowAll { get; set; }
public bool? Enabled { get; set; }
public bool? AllowMessages { get; set; }
public bool? Messages { get; set; }
public bool? AllowFriendRequests { get; set; }
public bool? FriendRequests { get; set; }
public bool? AllowEventInvites { get; set; }
public bool? EventInvites { get; set; }
public bool? AllowAnnouncements { get; set; }
public bool? Announcements { get; set; }
}
}
@@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.PlayerSettings;
@@ -14,35 +13,10 @@ namespace DorkNet.Server.Controllers.PlayerSettings;
/// </summary>
[ApiController]
[Authorize]
public class PlayerSettingsController(DorkNetDbContext db, ILogger<PlayerSettingsController> logger) : ControllerBase
public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet("/playersettings")]
public async Task<ActionResult<List<SettingDto>>> GetMine()
{
var accountId = CurrentPlayerId;
var settings = await db.PlayerSettings
.Where(s => s.PlayerId == accountId)
.Select(s => new SettingDto { Key = s.Key, Value = s.Value })
.ToListAsync();
return Ok(settings);
}
[HttpPut("/playersettings")]
public async Task<IActionResult> SetMine()
{
var (key, value) = await ReadSettingKeyValueAsync();
if (string.IsNullOrWhiteSpace(key))
return BadRequest(new PlayerSettingsResult { Success = false, Error = "missing_key" });
key = key.Trim();
value ??= string.Empty;
await UpsertSettingAsync(CurrentPlayerId, key, value);
return Ok(new SettingDto { Key = key, Value = value });
}
[HttpGet("/settings/v1/{accountId:long}")]
public async Task<ActionResult<Dictionary<string, string>>> Get(long accountId)
{
@@ -62,9 +36,16 @@ public class PlayerSettingsController(DorkNetDbContext db, ILogger<PlayerSetting
if (accountId != CurrentPlayerId) return Forbid();
foreach (var (key, value) in updates)
await UpsertSettingAsync(accountId, key, value, save: false);
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key);
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = accountId, Key = key, Value = value });
else
existing.Value = value;
}
await SaveSettingsChangesAsync();
await db.SaveChangesAsync();
return Ok();
}
@@ -78,106 +59,7 @@ public class PlayerSettingsController(DorkNetDbContext db, ILogger<PlayerSetting
if (setting is null) return NotFound();
db.PlayerSettings.Remove(setting);
await SaveSettingsChangesAsync();
await db.SaveChangesAsync();
return Ok();
}
private async Task UpsertSettingAsync(long accountId, string key, string value, bool save = true)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key);
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = accountId, Key = key, Value = value });
else
existing.Value = value;
if (save) await SaveSettingsChangesAsync();
}
private async Task SaveSettingsChangesAsync()
{
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
{
db.ChangeTracker.Clear();
logger.LogWarning(ex, "[playersettings] dropping low-priority setting write because sqlite is busy");
}
}
private async Task<(string? Key, string? Value)> ReadSettingKeyValueAsync()
{
string? key = FirstValue(Request.Query, "key", "Key");
string? value = FirstValue(Request.Query, "value", "Value");
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
key ??= FirstValue(form, "key", "Key");
value ??= FirstValue(form, "value", "Value");
}
if (key is null && Request.ContentLength.GetValueOrDefault() > 0)
{
try
{
using var document = await JsonDocument.ParseAsync(Request.Body);
if (document.RootElement.ValueKind == JsonValueKind.Object)
{
key = FirstJsonString(document.RootElement, "key", "Key");
value = FirstJsonString(document.RootElement, "value", "Value");
}
}
catch (JsonException)
{
// Non-JSON bodies are handled by form/query parsing above.
}
}
return (key, value);
}
private static string? FirstValue(IQueryCollection values, params string[] names)
{
foreach (var name in names)
if (values.TryGetValue(name, out var value))
return value.ToString();
return null;
}
private static string? FirstValue(IFormCollection values, params string[] names)
{
foreach (var name in names)
if (values.TryGetValue(name, out var value))
return value.ToString();
return null;
}
private static string? FirstJsonString(JsonElement obj, params string[] names)
{
foreach (var name in names)
if (obj.TryGetProperty(name, out var prop))
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : prop.ToString();
return null;
}
}
public class SettingDto
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
public class PlayerSettingsResult
{
[JsonPropertyName("Success")]
public bool Success { get; set; }
[JsonPropertyName("Error")]
public string Error { get; set; } = string.Empty;
}
-67
View File
@@ -74,14 +74,6 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
public DbSet<SignupCodeEntity> SignupCodes => Set<SignupCodeEntity>();
public DbSet<PendingDeviceEntity> PendingDevices => Set<PendingDeviceEntity>();
public DbSet<CustomAvatarItemEntity> CustomAvatarItems => Set<CustomAvatarItemEntity>();
public DbSet<CustomAvatarItemOwnershipEntity> CustomAvatarItemOwnership => Set<CustomAvatarItemOwnershipEntity>();
public DbSet<ItemWishlistEntity> ItemWishlists => Set<ItemWishlistEntity>();
public DbSet<KeepsakeEntity> Keepsakes => Set<KeepsakeEntity>();
public DbSet<RoomCurrencyEntity> RoomCurrencies => Set<RoomCurrencyEntity>();
public DbSet<RoomCurrencyBalanceEntity> RoomCurrencyBalances => Set<RoomCurrencyBalanceEntity>();
public DbSet<RoomCurrencyPurchaseOfferEntity> RoomCurrencyPurchaseOffers => Set<RoomCurrencyPurchaseOfferEntity>();
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -687,64 +679,5 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
e.HasKey(c => new { c.RoomId, c.Channel });
e.HasIndex(c => c.RoomId);
});
modelBuilder.Entity<CustomAvatarItemEntity>(e =>
{
e.HasKey(i => i.Id);
e.HasIndex(i => i.PublicId).IsUnique();
e.HasIndex(i => i.CreatorPlayerId);
e.HasIndex(i => i.IsPublic);
e.HasIndex(i => i.IsFeatured);
});
modelBuilder.Entity<CustomAvatarItemOwnershipEntity>(e =>
{
e.HasKey(o => o.Id);
e.HasIndex(o => new { o.PlayerId, o.CustomAvatarItemId }).IsUnique();
e.HasIndex(o => o.CustomAvatarItemId);
});
modelBuilder.Entity<ItemWishlistEntity>(e =>
{
e.HasKey(w => w.Id);
e.HasIndex(w => new { w.PlayerId, w.ItemKey, w.ItemType }).IsUnique();
});
modelBuilder.Entity<KeepsakeEntity>(e =>
{
e.HasKey(k => k.Id);
e.HasIndex(k => k.PlayerId);
e.HasIndex(k => new { k.PlayerId, k.EventKey });
});
modelBuilder.Entity<RoomCurrencyEntity>(e =>
{
e.HasKey(c => c.Id);
e.HasIndex(c => c.PublicId).IsUnique();
e.HasIndex(c => c.RoomId);
e.HasIndex(c => c.CreatorPlayerId);
});
modelBuilder.Entity<RoomCurrencyBalanceEntity>(e =>
{
e.HasKey(b => b.Id);
e.HasIndex(b => new { b.PlayerId, b.RoomCurrencyId }).IsUnique();
e.HasIndex(b => b.RoomCurrencyId);
});
modelBuilder.Entity<RoomCurrencyPurchaseOfferEntity>(e =>
{
e.HasKey(o => o.Id);
e.HasIndex(o => o.PublicId).IsUnique();
e.HasIndex(o => o.RoomCurrencyId);
});
modelBuilder.Entity<UgcPurchasableEntity>(e =>
{
e.HasKey(p => p.Id);
e.HasIndex(p => p.PublicId).IsUnique();
e.HasIndex(p => p.RoomId);
e.HasIndex(p => p.CreatorPlayerId);
});
}
}
@@ -1,94 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace DorkNet.Server.Data.Entities;
public class CustomAvatarItemEntity
{
public long Id { get; set; }
public Guid PublicId { get; set; } = Guid.NewGuid();
public long CreatorPlayerId { get; set; }
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
[MaxLength(1024)]
public string Description { get; set; } = string.Empty;
public int Price { get; set; } = 100;
public int ItemType { get; set; } = 0;
public int BaseAvatarItemId { get; set; }
[MaxLength(64)]
public string Color { get; set; } = string.Empty;
[MaxLength(256)]
public string ImageName { get; set; } = string.Empty;
[MaxLength(256)]
public string AssetName { get; set; } = string.Empty;
public bool IsPublic { get; set; }
public bool IsFeatured { get; set; }
public long CheerCount { get; set; }
public long PurchaseCount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class CustomAvatarItemOwnershipEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
public long CustomAvatarItemId { get; set; }
public DateTime AcquiredAt { get; set; } = DateTime.UtcNow;
}
public class ItemWishlistEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
[MaxLength(128)]
public string ItemKey { get; set; } = string.Empty;
public int ItemType { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class KeepsakeEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
[MaxLength(64)]
public string Category { get; set; } = "general";
[MaxLength(128)]
public string EventKey { get; set; } = string.Empty;
[MaxLength(128)]
public string Title { get; set; } = string.Empty;
[MaxLength(1024)]
public string Description { get; set; } = string.Empty;
[MaxLength(256)]
public string ImageName { get; set; } = string.Empty;
public DateTime EarnedAt { get; set; } = DateTime.UtcNow;
}
@@ -85,7 +85,6 @@ public class InventionEntity
public int CheerCount { get; set; } = 0;
public int SpawnCount { get; set; } = 0;
public int Price { get; set; } = 0;
/// <summary>Soft-delete flag. When set, the row is hidden from
/// browse / search / detail endpoints but stays in the table so
@@ -1,60 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace DorkNet.Server.Data.Entities;
public class RoomCurrencyEntity
{
public long Id { get; set; }
public Guid PublicId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public long CreatorPlayerId { get; set; }
[MaxLength(64)] public string Name { get; set; } = string.Empty;
[MaxLength(512)] public string Description { get; set; } = string.Empty;
[MaxLength(256)] public string ImageName { get; set; } = string.Empty;
public int DailyLimit { get; set; } = 0;
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class RoomCurrencyBalanceEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
public long RoomCurrencyId { get; set; }
public long Balance { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class RoomCurrencyPurchaseOfferEntity
{
public long Id { get; set; }
public Guid PublicId { get; set; } = Guid.NewGuid();
public long RoomCurrencyId { get; set; }
[MaxLength(64)] public string Name { get; set; } = string.Empty;
public int Amount { get; set; }
public int Price { get; set; }
public int CurrencyType { get; set; } = 2;
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class UgcPurchasableEntity
{
public long Id { get; set; }
public Guid PublicId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public long CreatorPlayerId { get; set; }
[MaxLength(128)] public string Name { get; set; } = string.Empty;
[MaxLength(1024)] public string Description { get; set; } = string.Empty;
[MaxLength(256)] public string ImageName { get; set; } = string.Empty;
public int Price { get; set; }
public int CurrencyType { get; set; } = 2;
public int ItemType { get; set; }
public bool IsFeatured { get; set; }
public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -47,12 +47,6 @@ public class RoomEntity
public bool IsAGRoom { get; set; } = true;
public bool IsDormRoom { get; set; } = false;
public bool IsStudioRoom { get; set; } = false;
public bool IsRoomLinkedToRecRoomStudio { get; set; } = false;
[MaxLength(128)]
public string StudioSessionId { get; set; } = string.Empty;
public bool CloningAllowed { get; set; } = false;
public bool SupportsVRLow { get; set; } = true;
public bool SupportsMobile { get; set; } = false;
@@ -60,9 +54,6 @@ public class RoomEntity
public bool SupportsWalkVR { get; set; } = true;
public bool SupportsTeleportVR { get; set; } = true;
public bool AllowsJuniors { get; set; } = true;
public bool AllowNewUsers { get; set; } = true;
public int MinLevel { get; set; } = 0;
public int MaxPlayerCalculationMode { get; set; } = 0;
/// <summary>Max players the room advertises per instance — flows into the
/// matchmaking RoomInstance.MaxCapacity and the v4/details synthesized
@@ -92,15 +83,6 @@ public class RoomEntity
[MaxLength(1024)]
public string TagsCsv { get; set; } = string.Empty;
[MaxLength(4096)]
public string LoadScreensJson { get; set; } = string.Empty;
[MaxLength(4096)]
public string PromoImagesJson { get; set; } = string.Empty;
[MaxLength(4096)]
public string PromoExternalContentJson { get; set; } = string.Empty;
public int CheerCount { get; set; } = 0;
public int FavoriteCount { get; set; } = 0;
@@ -52,18 +52,6 @@ public class RoomSceneEntity
[MaxLength(128)]
public string DataBlobName { get; set; } = string.Empty;
/// <summary>Original RecNet Studio save id for this sub-room, when
/// the scene was imported from a Studio dump. Used by
/// rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId} to return the
/// baked Unity asset bundle filenames the 2023 client downloads.</summary>
public long? StudioSubRoomDataSaveId { get; set; }
[MaxLength(64)]
public string StudioUnityAssetId { get; set; } = string.Empty;
[MaxLength(4096)]
public string StudioAssetBundleNamesCsv { get; set; } = string.Empty;
public int MaxPlayers { get; set; } = 8;
public bool IsSandbox { get; set; } = false;
public bool CanMatchmakeInto { get; set; } = true;
-19
View File
@@ -1,19 +0,0 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Data;
internal static class SqliteErrors
{
public static bool IsBusy(DbUpdateException ex)
{
for (Exception? current = ex; current is not null; current = current.InnerException)
{
if (current is SqliteException sqlite &&
(sqlite.SqliteErrorCode == 5 || sqlite.SqliteErrorCode == 6))
return true;
}
return false;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3667 -3784
View File
@@ -1,1988 +1,17 @@
{
"matches": [
{
"swatch_name": "Jersey_Shirt_Outline_Yellow_Swatch",
"swatch_guid": "d2a692e6-e1a9-4cfe-8154-10b52be7f8c8",
"color": "Outline_Yellow",
"item_name": "(Shirt_Jersey)",
"item_guid": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WackyShorts_Shirt_Watermelon_Swatch",
"swatch_guid": "pgBFpOtl00K2zsTJcfrefQ",
"color": "Watermelon",
"item_name": "(Shirt_WackyShorts)",
"item_guid": "ae180fb1-3e74-4232-9a9d-388fd488eba1",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "AviatorGoggles_Eye_Gray_Swatch",
"swatch_guid": "556f876a-70da-4104-b792-fca8ad67be0c",
"color": "Gray",
"item_name": "(Eye_AviatorGoggles)",
"item_guid": "47629ed1-5378-427c-ad61-4b44d2fe90cc",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "BunnyEars_Hat_Brown_Swatch",
"swatch_guid": "knKB2ETYmUupbvBwlleZRw",
"color": "Brown",
"item_name": "(Hat_BunnyEars)",
"item_guid": "71fd5353-b439-4a23-b471-bfb99b8154d7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Hat_Blue_Swatch",
"swatch_guid": "ddf429fd-a883-48a5-869b-bc12e44d30bf",
"color": "Blue",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Cowboy_Shirt_Black_Swatch",
"swatch_guid": "98553538-cafd-4df9-8f37-bb0a4bb478a5",
"color": "Black",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Hat_LogoRamen_Swatch",
"swatch_guid": "Mrf17aidXEeCF-fFURUUcQ",
"color": "LogoRamen",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Unicorn_Hat_Unicorn_Glowing_Swatch",
"swatch_guid": "nW_fBsuZA0OfIjTMXm6ZCA",
"color": "Unicorn_Glowing",
"item_name": "(Hat_Unicorn)",
"item_guid": "2caHvVQ1vECrPv-YcdxdyA",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TennisSkirt_Shirt_Red_Swatch",
"swatch_guid": "b75ef67d-00c3-4ac1-9b72-212032460294",
"swatch_name": "Windbreaker_Red_Swatch",
"swatch_guid": "grutFwJD_0Cb3t6fddNg6w",
"color": "Red",
"item_name": "(TennisSkirt)",
"item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Headphones_Hat_Blue_Swatch",
"swatch_guid": "_Yq00H-NWUyQNvGxRunHbg",
"color": "Blue",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "ScoutNeckerchief_Shirt_Blue_Swatch",
"swatch_guid": "5814a331-6155-4df4-b921-3ffc6059e44c",
"color": "Blue",
"item_name": "(Shirt_ScoutNeckerchief)",
"item_guid": "a96e168c-0c4d-4c70-9f8a-c8f07164ce99",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scallywag_Hat_Red_Swatch",
"swatch_guid": "a4903da3-249d-4f0a-a746-b547fda371ff",
"color": "Red",
"item_name": "(Hat_Scallywag)",
"item_guid": "8d17031a-c0a5-4145-92b7-1f0c2d8f0ce6",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PocketTee_Shirt_Red_Swatch",
"swatch_guid": "_o3mI1mjE02vekPVyJg8Aw",
"color": "Red",
"item_name": "(Shirt_PocketTee)",
"item_guid": "b3b59410-d3df-464b-a75b-500a9e5ea154",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WackyShorts_Shirt_Flamingo_Swatch",
"swatch_guid": "o__JORNtYEiJEyyoBIimPA",
"color": "Flamingo",
"item_name": "(Shirt_WackyShorts)",
"item_guid": "ae180fb1-3e74-4232-9a9d-388fd488eba1",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BabydollDress_Shirt_Pink_Swatch",
"swatch_guid": "cb9e9e05-c481-4397-86e7-c2111bc0e817",
"color": "Pink",
"item_name": "(BabydollDress)",
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BountyHunterBelt_Belt_Green_Swatch",
"swatch_guid": "3v_zUVDTUku_iEf-olDk1w",
"color": "Green",
"item_name": "(Neck_BountyHunterBelt)",
"item_guid": "f2c1c5e3-b201-4d34-9ce6-3082ff7e5d42",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "BeanieSlouch_Hat_Blue_Swatch",
"swatch_guid": "uWXtz0WaO0qJ1W4TfWnLqg",
"color": "Blue",
"item_name": "(Hat_Beanie_Slouch)",
"item_guid": "41bece0e-9b5d-4151-bf5c-d0e786a803b4",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "DeepSeaDiver_Wrist_Beige_Swatch",
"swatch_guid": "0p5N9U5qokKdQhW07yYLGQ",
"color": "Beige",
"item_name": "(Wrist_DeepSeaDiver)",
"item_guid": "b1c3ccc8-91d7-434e-b9cc-bd632fffc01b",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "SpaceMarineGlove_Wrist_Red_Swatch",
"swatch_guid": "7634b7ef-9607-497a-b41f-c0191dc75998",
"color": "Red",
"item_name": "(Wrist_SpaceMarineGlove)",
"item_guid": "4763436c-2dcc-482a-bf37-a6b16d7aae6e",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "SpaceMarine_Shirt_Light_Swatch",
"swatch_guid": "7ac81d60-05b8-4a85-982c-f69213a93818",
"color": "Light",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FootballShoulderPads_Shirt_BlackGold_Swatch",
"swatch_guid": "e17481fc-b2b6-47a5-91e5-7205f45a3247",
"color": "BlackGold",
"item_name": "(FootBallShoulderPads)",
"item_guid": "34d45669-b319-4c7d-bd05-078961d70d0b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SeaCaptain_Shirt_BlackDark_Swatch",
"swatch_guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc",
"color": "BlackDark",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Beekeeper_Hat_Teal_Swatch",
"swatch_guid": "e4b53a9a-ac95-49ae-bf68-615bb23fa075",
"color": "Teal",
"item_name": "(Hat_Beekeeper)",
"item_guid": "96bc2f9f-ce3a-4c4c-b92c-cfed4ed4594f",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "50sDress_Shirt_CandyCane_Swatch",
"swatch_guid": "I_OF1c0YWESMwxJr9sXYOA",
"color": "CandyCane",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "50sDress_Shirt_Pink_Swatch",
"swatch_guid": "Rk18_7icF0Srgh7M1_G-hA",
"color": "Pink",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "LuchadorCape_Shoulder_GreenGold_Swatch",
"swatch_guid": "iqGv6iRJ-UGbofv8-aHKVA",
"color": "GreenGold",
"item_name": "(LuchadorCape)",
"item_guid": "67117b3d-0669-4cf3-8a4f-066cabd4d8b1",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Beret_Hat_Purple_Swatch",
"swatch_guid": "FWL8ZeMe9EerdB0_Fya3AA",
"color": "Purple",
"item_name": "(Hat_Beret)",
"item_guid": "ef84cb35-5e25-4aa6-82c9-17f01a5040c7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Countess_Shirt_Green_Swatch",
"swatch_guid": "xnxDimLvVUCI9tEJqWDDcA",
"color": "Green",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "RacingGloves_Wrist_ClassicRed_Swatch",
"swatch_guid": "XD9dH8LF_ECmL0TwXJW1zg",
"color": "ClassicRed",
"item_name": "(Wrist_RacingGloves)",
"item_guid": "45695fba-5abc-4c5b-b9eb-949d5174d1f5",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArcherGlove_Wrist_Red_Swatch",
"swatch_guid": "7b187c06-7ba2-4f3c-bca9-fdf98146f385",
"color": "Red",
"item_name": "(Wrist_Archer_Glove)",
"item_guid": "5b01eaa3-0cac-40c0-b72a-b3f6a868ae0c",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Electrician_Shirt_Green_Swatch",
"swatch_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a",
"color": "Green",
"item_name": "(Shirt_Electrician)",
"item_guid": "3dc003c5-428b-47d6-80fb-6520b63119e8",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Tourist_Hat_Green_Swatch",
"swatch_guid": "Cjy-bE0PikWLCRrL4U2NGQ",
"color": "Green",
"item_name": "(Hat_Tourist)",
"item_guid": "0QOlT9c5xUuinpfZaoEe8Q",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "ReclympicsMedal_Neck_Bronze_Swatch",
"swatch_guid": "OqC4ny2rk0yJJOHemspV7A",
"color": "Bronze",
"item_name": "(Neck_ReclympicsMedal)",
"item_guid": "9fd1be12-1577-4800-b79f-6da29f299826",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "VampireHunter_Neck_Amethyst_Swatch",
"swatch_guid": "StxMBEprVkqMa_IoDG-Q9A",
"color": "Amethyst",
"item_name": "(Neck_VampireHunter)",
"item_guid": "jiBzrzDPkkuL6DqIFKpcCw",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Hat_Holographic_Swatch",
"swatch_guid": "LQheTa-EvUaAonSyoWNmXg",
"color": "Holographic",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BikeGlove_Wrist_Premium_Swatch",
"swatch_guid": "RVraJOPsOUqZjd8bEiPduQ",
"color": "Premium",
"item_name": "(Wrist_BikeGlove)",
"item_guid": "7a6fe0e6-f930-4fb8-9b2f-4c857a273dd9",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Dodgeball_Shirt_Black_Swatch",
"swatch_guid": "944d934d-5e21-42d7-8196-fff9a81ee469",
"color": "Black",
"item_name": "(Shirt_Dodgeball)",
"item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "MaleTShirt_Shirt_VideoContest_GreenSilver_Swatch",
"swatch_guid": "0440f08f-ef1d-49d8-942b-523056e8bb45",
"color": "VideoContest_GreenSilver",
"item_name": "(MaleTShirt)",
"item_guid": "d0a9262f-5504-46a7-bb10-7507503db58e",
"item_outfit_type": 101,
"mask_guid": "t2do8WSejE-Y0aiEirKw9g"
},
{
"swatch_name": "Tennis_Shirt_Pink_Swatch",
"swatch_guid": "90e0da8b-1941-4ca8-b7e5-b4e31921873c",
"color": "Pink",
"item_name": "(Shirt_Tennis)",
"item_guid": "74b91f2b-d4d3-4e41-8946-b1296ab7a773",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WitchHunter_Shirt_Purple_Swatch",
"swatch_guid": "0f49ac81-63bd-4845-8bdc-1944181a8aa3",
"color": "Purple",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_Wrist_Black_Swatch",
"swatch_guid": "yBNsYcZXzEiuR0u3tWnnWQ",
"color": "Black",
"item_name": "(Wrist_TruckerJacket)",
"item_guid": "973d84ee-5b88-475c-b579-a76c34a7c533",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Steampunk_Wrist_BlackandSilver_Swatch",
"swatch_guid": "rPpYm5fIzky4vqVB26guAw",
"color": "BlackandSilver",
"item_name": "(Wrist_SteamPunk)",
"item_guid": "qxovV6Vym0ufL2cTCyKjOQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Winter_Shirt_Trees_Swatch",
"swatch_guid": "Yn1uv3Y8JE6VSxsf0LOBvw",
"color": "Trees",
"item_name": "(Shirt_Winter)",
"item_guid": "3757149b-f478-4975-81e5-d808a1bed8d5",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Punk_Wrist_Pink_Swatch",
"swatch_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675",
"color": "Pink",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "EquestrianJacket_Shirt_Maroon_Swatch",
"swatch_guid": "d344b8cc-85a8-4ace-9f92-38c84f396e99",
"color": "Maroon",
"item_name": "(EquestrianJacket)",
"item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Raincoat_Shirt_Contest_Swatch",
"swatch_guid": "dMDMDExSmEKOF_kQIHaowg",
"color": "Contest",
"item_name": "(Shirt_Raincoat)",
"item_guid": "9e6397dd-b7b0-4d32-8c86-1910106a8478",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ScoutGarrison_Hat_Blue_Swatch",
"swatch_guid": "464fcf20-361f-4dc9-b074-150c3bc27523",
"color": "Blue",
"item_name": "(Hat_ScoutGarrison)",
"item_guid": "f60e093f-33be-4527-b827-060adc77b1f4",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "QuiltedCoat_Shirt_Plum_Swatch",
"swatch_guid": "ACrBLiAIgEur9V6l_B0CvA",
"color": "Plum",
"item_name": "(Shirt_Quiltedcoat)",
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "GrillMaster_Shoulder_Black_Swatch",
"swatch_guid": "t-ZEWJCgFEyGP2PjNblQMA",
"color": "Black",
"item_name": "(Wrist_Grillmaster)",
"item_guid": "q7pSsRMNHkqEJmYHuhhG6g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "MatrixJacket_Shirt_White_Swatch",
"swatch_guid": "8SzXFoWD8k20cekFe6cLKg",
"color": "White",
"item_name": "(Shirt_MatrixJacket)",
"item_guid": "c4622dca-80dc-423b-ae22-2065174e817b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Barbershop_Shirt_Green_Swatch",
"swatch_guid": "15edb8d7-2ff6-4170-a7e6-acd9e965cab5",
"color": "Green",
"item_name": "(Shirt_Barbershop)",
"item_guid": "18cd3470-2dd9-4a0a-98bd-eae2d5f5d522",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PaintballGlove_Wrist_Blue_Swatch",
"swatch_guid": "FsirX90YM0y5lk7mnqDnEQ",
"color": "Blue",
"item_name": "(Wrist_PaintballGlove)",
"item_guid": "64ad8789-1645-45f7-819a-a413ff7c9622",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Luchador_Shirt_GreenGold_Swatch",
"swatch_guid": "-IgxYllxkEyr7V1gosWXUA",
"color": "GreenGold",
"item_name": "(Hat_Luchador)",
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "FancyCape_Shoulder_GoldBlack_Swatch",
"swatch_guid": "EckOEU0mJkGIVDvYoKBN9g",
"color": "GoldBlack",
"item_name": "(Neck_FancyCape)",
"item_guid": "46cde502-86d6-4061-a80c-67458ffd4d60",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "PinkStollDress_Shirt_Green_Swatch",
"swatch_guid": "91a451c1-b285-4c48-b14d-59ded8cc006f",
"color": "Green",
"item_name": "(PinkStollDress)",
"item_guid": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BikeGlove_Wrist_Yellow_Swatch",
"swatch_guid": "0XPd5RzNJEepHztqwAznHQ",
"color": "Yellow",
"item_name": "(Wrist_BikeGlove)",
"item_guid": "7a6fe0e6-f930-4fb8-9b2f-4c857a273dd9",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "SpaceMarineHelmet_Hat_Pink_Swatch",
"swatch_guid": "b94411f6-5b71-48d0-ace1-ac6f63167065",
"color": "Pink",
"item_name": "(Hat_SpaceMarineHelmet)",
"item_guid": "59d9560d-e5af-4205-b860-3afea1cc31a9",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "ArtistBeret_Hat_Blue_Swatch",
"swatch_guid": "3485a30d-27f8-450b-b5aa-1ff3cd227d19",
"color": "Blue",
"item_name": "(Hat_ArtistBeret)",
"item_guid": "17aed0a0-70ed-49da-ad09-5bc4746f7718",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Jazzercise_Shirt_Black_Swatch",
"swatch_guid": "f6f26e2b-657b-454a-8663-55d6b9b6e50c",
"color": "Black",
"item_name": "(Jazzercise)",
"item_guid": "5f492f8f-ab61-484a-9587-747132b54309",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CowboyGlove_Wrist_Black_Swatch",
"swatch_guid": "8eef8837-9141-4088-835a-b2f47f4126f8",
"color": "Black",
"item_name": "(Wrist_CowboyGlove)",
"item_guid": "9eb7f505-5afd-4b28-9886-7996d38c59c6",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Wrist_SRank_Swatch",
"swatch_guid": "7a7bc314-4ffd-4ac8-9ed6-6ca73589c132",
"color": "SRank",
"item_name": "(Wrist_PirateCaptain)",
"item_guid": "eceb7d49-be41-4228-86a9-2973d62e7135",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Barbershop_Wrist_Formal_Swatch",
"swatch_guid": "f7c7d05f-5454-445a-af38-25f110bd204a",
"color": "Formal",
"item_name": "(Wrist_Barbershop)",
"item_guid": "ce81210f-8eb8-4cd2-95d5-046d4da229c8",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Scientist_Wrist_Blue_Swatch",
"swatch_guid": "VSZX99mSt0e7gPRM2_XzMw",
"color": "Blue",
"item_name": "(Glasses_Scientist)",
"item_guid": "11e508a7-fe7d-470e-b270-2dba33e2f7a1",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "VampireHunter_Shirt_Leather_Swatch",
"swatch_guid": "x10FeRBsQU2DIIpG8XIlwg",
"color": "Leather",
"item_name": "(Shirt_VampireHunter)",
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "RoyalCrown_Hat_Contest_Swatch",
"swatch_guid": "hCeaYHy1TE2s6rTPxAQP9A",
"color": "Contest",
"item_name": "(hat_royalCrown)",
"item_guid": "40734c4d-e1fe-426c-8921-930f7463d8e8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PeaCoat_Shirt_Tan_Swatch",
"swatch_guid": "AZnPWCN41kmNwFwDqWGFWQ",
"color": "Tan",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Paintball_Shirt_Red_Swatch",
"swatch_guid": "0189ec4c-0389-4ebc-b9ba-32303a6341c2",
"color": "Red",
"item_name": "(Glasses_Paintball)",
"item_guid": "34144f4c-f6ef-49e5-a286-1781e82e6782",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "Smuggler_Wrist_Grey_Swatch",
"swatch_guid": "8ffce0d6-d03e-4097-bc46-09b8894bc1d3",
"color": "Grey",
"item_name": "(Wrist_Smuggler)",
"item_guid": "3dec4865-cf23-4c19-a460-dd2db80ee851",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Winter_Hat_UglySweater_Swatch",
"swatch_guid": "0zHD3DaMAUOogqOqyd59SQ",
"color": "UglySweater",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "SeaCaptain_Hat_Contest_Swatch",
"swatch_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9",
"color": "Contest",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Turtleneck_Shirt_Orange_Swatch",
"swatch_guid": "rzoH8P44a0esN-HnYd3aQg",
"color": "Orange",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FieldHockey_Shirt_Blue_Swatch",
"swatch_guid": "1fede91a-434e-49a5-882e-e6db107112a8",
"color": "Blue",
"item_name": "(FieldHockey)",
"item_guid": "a986ff33-9e16-4219-8ebb-2e7b4c772ca7",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WristBand_Wrist_ArmyGreen_Swatch",
"swatch_guid": "0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd",
"color": "ArmyGreen",
"item_name": "(Wrist_WristBand)",
"item_guid": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "FlapperGlove_Wrist_Black_Swatch",
"swatch_guid": "0e42108e-3b08-49b4-86c0-7984719e8380",
"color": "Black",
"item_name": "(Wrist_FlapperGlove)",
"item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Punk_Shirt_Green_Swatch",
"swatch_guid": "d738ad4c-c9b5-41e6-8979-d586c964fb6c",
"color": "Green",
"item_name": "(Shirt_Punk)",
"item_guid": "e4ba395e-f291-4e7b-8193-45f4422f41ce",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_Wrist_Tan_Swatch",
"swatch_guid": "sHyNs-dpc0-_Ah-HJVczcw",
"color": "Tan",
"item_name": "(Wrist_TruckerJacket)",
"item_guid": "973d84ee-5b88-475c-b579-a76c34a7c533",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Archer_Shirt_SRank_Swatch",
"swatch_guid": "f88178b8-b204-4fa5-9dfd-6e438bf1e247",
"color": "SRank",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CatHat_Hat_LunarTiger_Swatch",
"swatch_guid": "ao-eeUnb8ECcW2IFGuk76w",
"color": "LunarTiger",
"item_name": "(Hat_CatHat)",
"item_guid": "2c7f90ab-53f7-4579-bf5b-a1c0a6d49e37",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Wizard_Hat_Purple_Swatch",
"swatch_guid": "abdf525b-b871-41be-a1c8-810f49740ad4",
"color": "Purple",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "WaitressApron_Belt_Green_Swatch",
"swatch_guid": "f5z_jhdcqEGjo-1WUmldaw",
"color": "Green",
"item_name": "(Belt_WaitressApron)",
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "GoldMedal_Neck_Silver_Swatch",
"swatch_guid": "wzt-5C_LjEmIQWUVPcYWhA",
"color": "Silver",
"item_name": "(GoldMedal)",
"item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "NinetiesOutfit_Shirt_Tan_Swatch",
"swatch_guid": "u9yHtj_tfkuowGp0h_YpFg",
"color": "Tan",
"item_name": "(Shirt_NinetiesOutfit)",
"item_guid": "ca218759-7eed-440e-b5dc-dba15f81e598",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FighterPilot_Hat_Black_Swatch",
"swatch_guid": "9d6ac0b7-bbef-40bc-9173-effe65d7c0ff",
"color": "Black",
"item_name": "(Hat_FighterPilot)",
"item_guid": "ec836d2a-b1b8-4566-b8eb-123ae2845956",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "IHeartRRShirt_Shirt_LunarTiger_Swatch",
"swatch_guid": "xnZj7ipl60WO8e_BXK-mlw",
"color": "LunarTiger",
"item_name": "(Shirt_IHeartRRShirt)",
"item_guid": "97253092-90ba-4ff2-b137-6324ab244d11",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TutorialCap_Hat_Purple_Swatch",
"swatch_guid": "GV8z2dlqGEqIAYaGrF1ZYQ",
"color": "Purple",
"item_name": "(Hat_TutorialCap)",
"item_guid": "M9weaekvIUimiJSVJqZl-w",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "ScoutSash_Shoulder_Brown_Swatch",
"swatch_guid": "66cc536d-8cb5-4262-b7f0-49a33b97ea10",
"color": "Brown",
"item_name": "(Neck_ScoutSash)",
"item_guid": "bf9dc196-5727-4752-8126-7ad5fcd96d55",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "SmallBrim_Hat_Maroon_Swatch",
"swatch_guid": "65ed43ff-dfc4-4cab-b0b9-77dc94165e24",
"color": "Maroon",
"item_name": "(Hat_SmallBrim)",
"item_guid": "9cdb4965-6f00-4304-afed-e301e73a2b3a",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Hibiscus_Hat_Yellow_Swatch",
"swatch_guid": "65b89eae-347e-4eff-9ff3-7f25cace0125",
"color": "Yellow",
"item_name": "(Hat_Hibiscus)",
"item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "JackfrostHair_Hat_Lilac_Swatch",
"swatch_guid": "byvLynH8x06OQ7GNCXOVYA",
"color": "Lilac",
"item_name": "(Hat_JackfrostHair)",
"item_guid": "e6216087-fb37-46f1-bda4-a88c4640f339",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "WitchHunter_Wrist_Blue_Swatch",
"swatch_guid": "5b8d9490-e5bc-480e-91bc-65d1797b52ad",
"color": "Blue",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "RoyalLady_Shirt_Burgandy_Swatch",
"swatch_guid": "PoMG28QAskagun1LSd4C_A",
"color": "Burgandy",
"item_name": "(shirt_royalLady)",
"item_guid": "0ab6c95c-e157-48bd-a172-2642896b4ffc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BeanieBrim_Hat_Blue_Swatch",
"swatch_guid": "7nBHNMZhAE-t9rLCYO_STA",
"color": "Blue",
"item_name": "(Hat_Beanie_Brim)",
"item_guid": "48ec7a6a-0c31-40d4-9179-66e3ba6505a1",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "GraduationCap_Hat_Red_Swatch",
"swatch_guid": "yzL0M_N5Q0eAa8IxACHPAQ",
"color": "Red",
"item_name": "(Hat_GraduationCap)",
"item_guid": "KPmC_wyX_ECe7fdKwoLMGg",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Samurai_Shirt_Red_Swatch",
"swatch_guid": "5fVeR6S6R0yo_NcT-TB1lQ",
"color": "Red",
"item_name": "(Shirt_Samurai)",
"item_guid": "dbecb1a5-f342-4017-b5c8-9cdfcd1254ec",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "RacingSuit_Shirt_ClassicRed_Swatch",
"swatch_guid": "uFERkSfSl0i9BPFBVOICeg",
"color": "ClassicRed",
"item_name": "(Shirt_RacingSuit)",
"item_guid": "7adae61e-cd62-46c3-ae42-83ef0bced5c7",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TrackSuit_Shirt_Black_Swatch",
"swatch_guid": "adae82eb-a4b9-4541-acf2-76cea839a593",
"color": "Black",
"item_name": "(TrackSuit)",
"item_guid": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "EveningCoat_Shirt_Pink_Swatch",
"swatch_guid": "KOCzRE4a6EKb3_NVOI_lcg",
"color": "Pink",
"item_name": "(Shirt_EveningCoat)",
"item_guid": "64fddd4e-3d59-43df-bb39-671e18953c0f",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "YellowBlueDress_Shirt_Fatigue_Swatch",
"swatch_guid": "cfabdefe-0890-436e-b2a3-b5c712e22955",
"color": "Fatigue",
"item_name": "(YellowBlueDress)",
"item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Aviator_Hat_Red_Swatch",
"swatch_guid": "ff5ae41e-e446-4a96-8c11-49d85556fa81",
"color": "Red",
"item_name": "(Hat_Aviator)",
"item_guid": "7a7d8a06-7982-4bcb-b7bd-4d03b48889b4",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "FancyCape_Shoulder_Premium_Swatch",
"swatch_guid": "XHJ0lW4zYkGcL5UyI1HfEg",
"color": "Premium",
"item_name": "(Neck_FancyCape)",
"item_guid": "46cde502-86d6-4061-a80c-67458ffd4d60",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_Shirt_PlaidOrange_Swatch",
"swatch_guid": "uZjxYOMa10GzJ9wQkRge2w",
"color": "PlaidOrange",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ButterflyWings_Shoulder_Agrias_Swatch",
"swatch_guid": "yViVKOLHc0qLiPw9Oy1Obg",
"color": "Agrias",
"item_name": "(Neck_ButterflyWings)",
"item_guid": "fc96ea5d-b49b-479b-a963-92f1eab44b2c",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "KnightCape_Shoulder_Red_Swatch",
"swatch_guid": "15099be7-38b7-4b0b-ac13-1bacedbf5fb2",
"color": "Red",
"item_name": "(KnightCape)",
"item_guid": "2e5afc40-7e25-418c-8b1b-316b8ef479a7",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "WinterCloak_Shoulder_White_Swatch",
"swatch_guid": "Hj8WnsdidE6rJeNyOmWUfg",
"color": "White",
"item_name": "(Neck_WinterCloak)",
"item_guid": "b9d08f9a-b23d-4d7e-978b-a0cd8adbfe9c",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Harness_Wrist_Teal_Swatch",
"swatch_guid": "rgzG7sfrlkqd9EO13LNmpQ",
"color": "Teal",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Jersey_Shirt_ZebraWhite_Swatch",
"swatch_guid": "b78008e8-abbd-4ece-be34-9a911f721fcc",
"color": "ZebraWhite",
"item_name": "(Shirt_Jersey)",
"item_guid": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Hat_Red_Swatch",
"swatch_guid": "71fd06e3-cac8-4923-a09c-ef91183712d2",
"color": "Red",
"item_name": "(Hat_PirateCaptain)",
"item_guid": "3df394de-9c3e-4141-aba4-12eac4742102",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "LaserTag_Hat_Green_Swatch",
"swatch_guid": "4e378419-d043-4e86-ad0e-66288ac21cf4",
"color": "Green",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "DrumMajor_Hat_Orange_Swatch",
"swatch_guid": "a8a91216-0fcc-461e-a682-95ee82509cce",
"color": "Orange",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "HeadBand_Hat_TrimRed_Swatch",
"swatch_guid": "018a5c07-e956-457d-a540-a5e2cd68da09",
"color": "TrimRed",
"item_name": "(Hat_HeadBand)",
"item_guid": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Shirt_Red_Swatch",
"swatch_guid": "2UNd6eViFUO359xjSiuu2A",
"color": "Red",
"item_name": "(Shirt_MagicalGirl)",
"item_guid": "MJkDYDH510izAab13ZfXQw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_Bi_Swatch",
"swatch_guid": "o3Q2gADhh06dgaipkkdMsA",
"color": "Bi",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "JackFrostWings_Shoulder_Contest_Swatch",
"swatch_guid": "gOt7BYXvw0uUS-U53KThAA",
"color": "Contest",
"item_name": "(Neck_JackFrostWings)",
"item_guid": "022ce375-ed02-479d-b87d-9d72d126bbd4",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "KnightHelmet_Hat_Black_Swatch",
"swatch_guid": "0dd6596c-b4ea-4d55-a219-e1f44e4a376d",
"color": "Black",
"item_name": "(Hat_Knight_Helmet)",
"item_guid": "e614df5d-3cfe-4bca-b9c2-30c267b9c94e",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TruckerOveralls_Shirt_Green_Swatch",
"swatch_guid": "H3OQDBef1UaacSD52p8Hdw",
"color": "Green",
"item_name": "(Shirt_TruckerOveralls)",
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Ruby_Swatch",
"swatch_guid": "0Nca5nBUpUquFhExd5dxcQ",
"color": "Ruby",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
},
{
"swatch_name": "SpaghettiCowboy_Hat_Grey_Swatch",
"swatch_guid": "VZyHWGKNJEGSxDT2GO3ebA",
"color": "Grey",
"item_name": "(Hat_SpaghettiCowboy)",
"item_guid": "a471995f-1fe7-4b77-a05f-8eea4e0970ab",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "NewYearsHat_Hat_Blue_Swatch",
"swatch_guid": "MrrsmY0J0EKduH-f5wUj6g",
"color": "Blue",
"item_name": "(Hat_NewYearsHat)",
"item_guid": "8df990d9-141a-4757-b4f2-138d5e68ab74",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Chef_Hat_Red_Swatch",
"swatch_guid": "-lYK7LhYyEy6wqL1tIE9cg",
"color": "Red",
"item_name": "(Hat_Chef)",
"item_guid": "af33bdb1-b7fa-4fe6-b37a-b1fe6a420879",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Witch_Wrist_Contest_Swatch",
"swatch_guid": "IQvHmZ8ei02hVuXZiXb6TA",
"color": "Contest",
"item_name": "(Wrist_Witch)",
"item_guid": "wy2cfgb8mUqjMrIOwHH2ew",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Thriller_Wrist_Blue_Swatch",
"swatch_guid": "FHB5XHwNWka7aUt5km3LNg",
"color": "Blue",
"item_name": "(Wrist_Thriller)",
"item_guid": "BR0mZKsKaE-t4R8CzEwuyg",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "SodaJerk_Hat_Green_Swatch",
"swatch_guid": "sV_ByCug30G1Gw-77KUe1Q",
"color": "Green",
"item_name": "(Hat_SodaJerk)",
"item_guid": "Wua43ED71UqL71ATtyS0iQ",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BasicBelt_Belt_Blue_Swatch",
"swatch_guid": "-UFHTNvJs0CGDStb0PFkbQ",
"color": "Blue",
"item_name": "(Neck_BasicBelt)",
"item_guid": "12ec292f-0ccd-4c8a-a49c-3598f827659d",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "SkeletonFingerlessGlove_Wrist_PinkGlow_Swatch",
"swatch_guid": "GXS9LQgPU0mJIEuXo_Oocw",
"color": "PinkGlow",
"item_name": "(Wrist_Skeleton_FingerlessGlove)",
"item_guid": "6a98bbb6-bbe0-4dc3-97a9-e253dc506a2b",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "PurpleJacket_Shirt_Green_Swatch",
"swatch_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c",
"color": "Green",
"item_name": "(PurpleJacket)",
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Wizard_Shirt_SRank_Swatch",
"swatch_guid": "8768aa33-b014-4062-b323-b146dd7d78d2",
"color": "SRank",
"item_name": "(Shirt_Wizard)",
"item_guid": "97eae086-cfea-45ff-9b77-1d184256d493",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CatHat_Hat_Orange_Swatch",
"swatch_guid": "5KeLrnvaKEWnEiap1xzmjw",
"color": "Orange",
"item_name": "(Hat_CatHat)",
"item_guid": "2c7f90ab-53f7-4579-bf5b-a1c0a6d49e37",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Boxer_Hat_Red_Swatch",
"swatch_guid": "92714eac-cc07-4f74-a6f5-cfbbadd07e06",
"color": "Red",
"item_name": "(Shirt_Boxer)",
"item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Shirt_Yellow_Swatch",
"swatch_guid": "ovy_3j_qwE-7mmks1Z47ig",
"color": "Yellow",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Smuggler_Shirt_Dark_Swatch",
"swatch_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68",
"color": "Dark",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "IceQueenGown_Shirt_Lilac_Swatch",
"swatch_guid": "qrXO8rdEaUqUCXC1QVkhsw",
"color": "Lilac",
"item_name": "(Shirt_IceQueenGown)",
"item_guid": "3R_zf8xliEiurVU8GwaItg",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Aviator_Shirt_Red_Swatch",
"swatch_guid": "9009bcdf-b6f7-4dae-b2ec-895511d118ff",
"color": "Red",
"item_name": "(Shirt_Aviator)",
"item_guid": "42aa7888-f0b9-4584-9cc1-966585c6b36e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SteamPunk_Shoulder_BlackandSilver_Swatch",
"swatch_guid": "ISycihytUUyoypVb-kXN_Q",
"color": "BlackandSilver",
"item_name": "(Shirt_SteamPunk)",
"item_guid": "E9VTiIVFPEa-OuQRto0e_Q",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Hat_ThreeColorBlackOrange_Swatch",
"swatch_guid": "r_GLekt6mkWxXw8zPPR8Mg",
"color": "ThreeColorBlackOrange",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BeachTowelCape_Shoulder_Amazings_Swatch",
"swatch_guid": "Vat8nL4ZBEK9mLABJiXS6A",
"color": "Amazings",
"item_name": "(Shoulder_BeachTowelCape)",
"item_guid": "281937b2-f994-45e9-a6d3-03d097247ce6",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Hat_Brown_Swatch",
"swatch_guid": "d759c88d-a159-4c26-86dd-b054bb05b3ed",
"color": "Brown",
"item_name": "(Hat_Mobster)",
"item_guid": "76369aef-aeda-46d2-996a-cd00594d0543",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "QueenCrown_Hat_White_Swatch",
"swatch_guid": "XFuSiGupZ02Fut1Dxum_Iw",
"color": "White",
"item_name": "(Hat_QueenCrown)",
"item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Raincoat_Shirt_Red_Swatch",
"swatch_guid": "T7zwcIIc8Ee3ud8a2JCuwQ",
"color": "Red",
"item_name": "(Shirt_Raincoat)",
"item_guid": "9e6397dd-b7b0-4d32-8c86-1910106a8478",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TreasureHunterBelt_Belt_Black_Swatch",
"swatch_guid": "LFB_YqTym0-tQmUIj-YcYA",
"color": "Black",
"item_name": "(Neck_TreasureHunterBelt)",
"item_guid": "ImwG0snB9ECc_gqRRBoz6A",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Luchador_Shirt_Red_Swatch",
"swatch_guid": "7ef7d0cf-99f1-4872-9d3d-8230cc9fa368",
"color": "Red",
"item_name": "(Hat_Luchador)",
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Bow_Hat_Green_Swatch",
"swatch_guid": "e0186718-4a18-434b-b96e-0c9c912f0d1f",
"color": "Green",
"item_name": "(Hat_Bow)",
"item_guid": "1e504fee-b593-4037-bd8f-b88025147991",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Hat_Orange_Swatch",
"swatch_guid": "mG60EPIQIkaJNgOkonVmZg",
"color": "Orange",
"item_name": "(Hat_MagicalGirl)",
"item_guid": "hT7nSD7Ts06F_1SNlOMLwg",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Wrist_Blue_Swatch",
"swatch_guid": "ZtCqMnFTQUWv2h0MfZ4m6A",
"color": "Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "MaleTShirt_Shirt_LaserTag_Alpha_Swatch",
"swatch_guid": "3259238f-4a9e-4e06-99de-6e7324fa0894",
"color": "LaserTag_Alpha",
"item_name": "(MaleTShirt)",
"item_guid": "d0a9262f-5504-46a7-bb10-7507503db58e",
"item_outfit_type": 101,
"mask_guid": "e38294cf-6279-4863-9aea-1a19aeaf8476"
},
{
"swatch_name": "RedBeltDress_Shirt_Yellow_Swatch",
"swatch_guid": "3d971439-41d1-43a2-8434-704f9ec8d906",
"color": "Yellow",
"item_name": "(RedBeltDress)",
"item_guid": "f51a7ed5-52ea-485f-a430-303697f6fec9",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Shirt_Valentine_Swatch",
"swatch_guid": "HTjlzAlWZ0ezY6w3ngSQSg",
"color": "Valentine",
"item_name": "(Shirt_Mobster)",
"item_guid": "4308da04-af84-40bb-845a-7a9e1a2726ec",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "RoyalLord_Shirt_Black_Swatch",
"swatch_guid": "50NUFrDxbUKmXbyAHBenvQ",
"color": "Black",
"item_name": "(shirt_royalLord)",
"item_guid": "e13f6558-d49c-4314-820c-962ef48eb0c6",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "80sWindbreaker_Wrist_Yellow_Swatch",
"swatch_guid": "MV1HW2x43kG4rrhyoC6mmw",
"color": "Yellow",
"item_name": "(Wrist_80sWindbreaker)",
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "TopHat_Hat_ScifiCarpet_Swatch",
"swatch_guid": "ccf1ccc1-e229-4157-bb74-f2cdef01e547",
"color": "ScifiCarpet",
"item_name": "(Hat_TopHat)",
"item_guid": "102c625b-b988-4bf8-a2aa-a31ad7029cdc",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "HarnessHelmet_Hat_Black_Swatch",
"swatch_guid": "PZHohDVX0U2frX13N4KYDQ",
"color": "Black",
"item_name": "(Hat_HarnessHelmet)",
"item_guid": "459ec62c-67db-4b72-9e29-0ad3ecd0b737",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "HeadBackBow_Hat_Purple_Swatch",
"swatch_guid": "3DdSzSqV1UG5AtPr8ro3Kg",
"color": "Purple",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "FemaleCheerleader_Shirt_Red_Swatch",
"swatch_guid": "KFq8d1VsO0KDqBWUj4qRFg",
"color": "Red",
"item_name": "(Hair_FemaleCheerleader)",
"item_guid": "BJ2XrGvl40iIl28IbTfbKg",
"item_outfit_type": 2,
"mask_guid": ""
},
{
"swatch_name": "FlatCap_Hat_Green_Swatch",
"swatch_guid": "73fc2e2a-a036-410b-bafa-3a07658245b5",
"color": "Green",
"item_name": "(Hat_FlatCap)",
"item_guid": "5e3929d3-d291-4e91-a0ba-2ef203e407d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Countess_Wrist_Green_Swatch",
"swatch_guid": "8kdJkmwNM0WQmMVrb8fK8A",
"color": "Green",
"item_name": "(Wrist_Countess)",
"item_guid": "t9co2516nkuJO4LIJA1bJA",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Winter_Shirt_Snowman_Swatch",
"swatch_guid": "3fQj_7meDEeQqm_7RfEfxQ",
"color": "Snowman",
"item_name": "(Shirt_Winter)",
"item_guid": "3757149b-f478-4975-81e5-d808a1bed8d5",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SteampunkFemme_Shirt_Contest_Swatch",
"swatch_guid": "GJRG8sCM7kSGfUd9gLwpgQ",
"color": "Contest",
"item_name": "(Shirt_SteampunkFemme)",
"item_guid": "LK8jt9zCuUGwLRccgOOvjA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SkiBuddy_Hat_White_Swatch",
"swatch_guid": "c_t9XDbn-Eqml1O-wC5zJQ",
"color": "White",
"item_name": "(Hat_SkiBuddy)",
"item_guid": "6QT99hx6DE6t9cKAs61i3w",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Cowboy_Hat_Cherry_Swatch",
"swatch_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8",
"color": "Cherry",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Belt_Black_Swatch",
"swatch_guid": "9b2102f0-e16b-464f-9f1c-f068de52bb89",
"color": "Black",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Soccer_Shirt_Blue_Swatch",
"swatch_guid": "i8bvbGAKREmAEbjKO-geOQ",
"color": "Blue",
"item_name": "(Shirt_Soccer)",
"item_guid": "b6610b7b-3978-46fd-9af6-41b4568c9b4d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WingedHeadphones_Hat_Orange_Swatch",
"swatch_guid": "Dac47T16Wk6LmmDd2cs1LA",
"color": "Orange",
"item_name": "(Hat_WingedHeadphones)",
"item_guid": "cbf478e1-a9d7-4b3f-9f52-c59271a924f6",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TieSequin_Neck_Gold_Red_Swatch",
"swatch_guid": "tfiSfpRez0y_R4QjAhO0bQ",
"color": "Gold_Red",
"item_name": "(Neck_TieSequin)",
"item_guid": "0d4e7ebe-efd9-4e68-9900-5645916e7596",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "SnowyOwlCape_Shoulder_RavenBlue_Swatch",
"swatch_guid": "wQxO4xmXCUGeC9ZHfDNGTw",
"color": "RavenBlue",
"item_name": "(Neck_SnowyOwlCape)",
"item_guid": "9441142e-981c-4f35-8f9d-b4b3604da9a4",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "LacrosseHelmet_Hat_Purple_Swatch",
"swatch_guid": "7e688b30-ed13-4790-9ad8-c49885d1b7e8",
"color": "Purple",
"item_name": "(Hat_LacrosseHelmet)",
"item_guid": "7134c60b-0640-463a-8d97-caa9184bef93",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "KnightGauntlet_Wrist_Red_Swatch",
"swatch_guid": "fd67ec40-f898-4df6-b846-8bf350f362b8",
"color": "Red",
"item_name": "(Wrist_Knight_Gauntlet)",
"item_guid": "9bf9d502-8a3c-4b24-9565-0a2c2d9864fd",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_Shirt_Green_Swatch",
"swatch_guid": "B-sIpWNOfEigMeQF-WVR1g",
"color": "Green",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Artist_Shirt_Blue_Swatch",
"swatch_guid": "5a6db6ba-2a0f-4399-b537-dd78db94bf90",
"color": "Blue",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "YellowPoofyDress_Shirt_Orange_Swatch",
"swatch_guid": "d30e3afe-414c-4b60-8be9-6fa4156a4b65",
"color": "Orange",
"item_name": "(YellowPoofyDress)",
"item_guid": "a302741d-313b-4682-82e5-ac76764e662c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Tourist_Shirt_Green_Swatch",
"swatch_guid": "M-mi8QUHwEeIewXE8uAlrQ",
"color": "Green",
"item_name": "(Shirt_Tourist)",
"item_guid": "vDSlMnayzEqWXwT-lkcKSA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Shirt_FashionGold_Swatch",
"swatch_guid": "c2052d8c-fbcd-4462-8f08-bcf5b3dfde2f",
"color": "FashionGold",
"item_name": "(Shirt_Mobster)",
"item_guid": "4308da04-af84-40bb-845a-7a9e1a2726ec",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BlueJacket_Shirt_Mint_Swatch",
"swatch_guid": "0f36bb97-c61b-4281-929f-ff1d0d11be86",
"color": "Mint",
"item_name": "(BlueJacket)",
"item_guid": "62ce4109-8dee-4895-bf1b-bfa143db4c7e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Prankster_Shirt_Green_Swatch",
"swatch_guid": "icHpJch4uUeGdhoLkawyyg",
"color": "Green",
"item_name": "(Shirt_Prankster)",
"item_guid": "5b3cae00-88bd-4179-b75a-9444c5a52fa5",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Aviator_Wrist_Tan_Swatch",
"swatch_guid": "5659ce2c-9cb0-49da-8c39-4b7df0f8deec",
"color": "Tan",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Tennis_Shirt_Floral_Green_Swatch",
"swatch_guid": "iT7UGMahoEu7BZZqeKXKww",
"color": "Floral_Green",
"item_name": "(Shirt_Tennis)",
"item_guid": "74b91f2b-d4d3-4e41-8946-b1296ab7a773",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Steampunk_Shirt_BlackandSilver_Swatch",
"swatch_guid": "C8gCmBZ8RUG83vvDw_UYew",
"color": "BlackandSilver",
"item_name": "(Shirt_SteamPunk)",
"item_guid": "E9VTiIVFPEa-OuQRto0e_Q",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Wrist_FashionGold_Swatch",
"swatch_guid": "05cff8b8-d437-485a-b4b8-41d35becd022",
"color": "FashionGold",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "SkeletonHoodie_Shirt_PinkGlow_Swatch",
"swatch_guid": "eZGiYEx6sk2PsN39RzQwlQ",
"color": "PinkGlow",
"item_name": "(Shirt_Skeleton_Hoodie)",
"item_guid": "df0e06c4-8ba6-4d7a-bfb2-3759d8fe88c7",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Boxer_Wrist_Red_Swatch",
"swatch_guid": "f79f8a46-ef1d-4d8c-a4fc-383e3a9eb10b",
"color": "Red",
"item_name": "(Shirt_Boxer)",
"item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FighterPilot_Shirt_Black_Swatch",
"swatch_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7",
"color": "Black",
"item_name": "(Shirt_FighterPilot)",
"item_guid": "ff7e6d41-5c27-4516-930c-ad0e9a23cce2",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SnowyOwlCape_Shoulder_Red_Swatch",
"swatch_guid": "Ydd6tdb9dUGooM6WAczNjQ",
"color": "Red",
"item_name": "(Neck_SnowyOwlCape)",
"item_guid": "9441142e-981c-4f35-8f9d-b4b3604da9a4",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Flowers_Hat_Red_Swatch",
"swatch_guid": "7d040391-03f7-4b3d-bcfd-a1278ff459c9",
"color": "Red",
"item_name": "(Hat_Flowers)",
"item_guid": "2b2eb9e5-d52e-4004-bdb2-0bcf6910992d",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "80sWindbreaker_Shirt_Red_Swatch",
"swatch_guid": "oSaMpc5rTE-U6yhjtPWQgg",
"color": "Red",
"item_name": "(Shirt_80sWindbreaker)",
"item_guid": "AIpF1QUeMUWytpHYBDBAkg",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_Shirt_RebelRose_Swatch",
"swatch_guid": "WeyKw_WS70Cx3NZdOaxGeA",
"color": "RebelRose",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scallywag_Shirt_Red_Swatch",
"swatch_guid": "005699fe-2c69-4bec-831f-031fe8ad7f0a",
"color": "Red",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Shirt_SolidWhite_Swatch",
"swatch_guid": "PioQ0o3yP0a6szPZ4EKs2A",
"color": "SolidWhite",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SkeletonMask_Eye_NoGlow_Swatch",
"swatch_guid": "DfyODsHfIESXxVjcfZmBOA",
"color": "NoGlow",
"item_name": "(Eye_Skeleton_Mask)",
"item_guid": "28e5dd74-7963-42cf-aa68-f8005aaf312e",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "TopHat_Hat_Gold_Swatch",
"swatch_guid": "DG83ruIdI0Wu-GSRg4icYQ",
"color": "Gold",
"item_name": "(Hat_TopHat)",
"item_guid": "102c625b-b988-4bf8-a2aa-a31ad7029cdc",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PoolFloatie_Belt_BirdHead_Parrot_Swatch",
"swatch_guid": "qZzkrGXpPkGq_nnGLZorwA",
"color": "BirdHead_Parrot",
"item_name": "(Belt_PoolFloatie)",
"item_guid": "75-HXZBOAEiDBqclnOn8YQ",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Wrist_PinStripePurple_Swatch",
"swatch_guid": "QBzMp7MdAUOLIofvddui1A",
"color": "PinStripePurple",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "LuchadorCape_Shoulder_Blue_Swatch",
"swatch_guid": "OE-PtL0-0kWcJPwn1wvuVg",
"color": "Blue",
"item_name": "(LuchadorCape)",
"item_guid": "67117b3d-0669-4cf3-8a4f-066cabd4d8b1",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "DeepSeaDiver_Shoulder_Black_Swatch",
"swatch_guid": "ZpyoWbO6GkGglXlMuYUARw",
"color": "Black",
"item_name": "(Hat_DeepSeaDiver)",
"item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_Shirt_Black_Swatch",
"swatch_guid": "PMGQ8fLc2UuJXMy42Ati-w",
"color": "Black",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BusinessTie_Neck_ScifiCarpet_Swatch",
"swatch_guid": "8212812b-e7e7-4f6d-aa27-d1112af45473",
"color": "ScifiCarpet",
"item_name": "(BusinessTie)",
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_Pan_Swatch",
"swatch_guid": "4bI7QRvdnEansPkwx8Di9A",
"color": "Pan",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Wizard_Wrist_White_Swatch",
"swatch_guid": "47514b9b-0d17-4727-97ed-dab67e7fe031",
"color": "White",
"item_name": "(Wrist_Wizard)",
"item_guid": "22ef1089-d073-4fe8-b038-6f34adf0b85f",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Shirt_Striped_Black_Swatch",
"swatch_guid": "00ac4e37-6eac-4ed5-b46c-2c075038bc3f",
"color": "Striped_Black",
"item_name": "(Shirt_PirateCaptain)",
"item_guid": "3b00c71f-79c2-4a04-9fc0-a23b4c85562f",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Sherlock_Shirt_Black_Swatch",
"swatch_guid": "9ZP617DQk0mZ10J3d5q9oA",
"color": "Black",
"item_name": "(shirt_sherlock)",
"item_guid": "033e328f-d9d2-4e53-af20-44f18e3ea208",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TrackSuit_Shirt_Plain_Red_Swatch",
"swatch_guid": "ynPJe7asTk-x_GtcEBQbgA",
"color": "Plain_Red",
"item_name": "(TrackSuit)",
"item_guid": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PoolFloatie_Belt_BirdHead_RubberDucky_Swatch",
"swatch_guid": "Uyn3QOl1zkGR2_OUOhNWjQ",
"color": "BirdHead_RubberDucky",
"item_name": "(Belt_PoolFloatie)",
"item_guid": "75-HXZBOAEiDBqclnOn8YQ",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "TreasureHunterTank_Shirt_White_Swatch",
"swatch_guid": "K5NXVecrqk6Nb6SkBu08ww",
"color": "White",
"item_name": "(Shirt_TreasureHunterTank)",
"item_guid": "TjrFi6TFhUC5tQXWkZiK9A",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BasicBelt_Belt_Gold_Cherry_Swatch",
"swatch_guid": "rL9p5JY83Eyxj00Dieg7oA",
"color": "Gold_Cherry",
"item_name": "(Neck_BasicBelt)",
"item_guid": "12ec292f-0ccd-4c8a-a49c-3598f827659d",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Hat_HackerDNS_Swatch",
"swatch_guid": "P12CUkIg8EuFihGQsvPlxg",
"color": "HackerDNS",
"item_name": "(Hat_Mobster)",
"item_guid": "76369aef-aeda-46d2-996a-cd00594d0543",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Tourist_Shirt_Tropical_Navy_Swatch",
"swatch_guid": "ytfIq0TiYU6EUdd0ruaGAQ",
"color": "Tropical_Navy",
"item_name": "(Shirt_Tourist)",
"item_guid": "vDSlMnayzEqWXwT-lkcKSA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CowboyScarf_Neck_RoyalGreen_Swatch",
"swatch_guid": "27a5df72-4095-4837-bf24-cdd3d95a43e9",
"color": "RoyalGreen",
"item_name": "(CowboyScarf)",
"item_guid": "5accb7dc-90a3-46ee-bad2-8fd1861d50f1",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "MirroredGlasses_Eye_Silver_Swatch",
"swatch_guid": "9c55b609-b7dc-4070-a3ad-6351a0054a06",
"color": "Silver",
"item_name": "(Eye_MirroredGlasses)",
"item_guid": "7db6b49f-3e2a-4da8-bdfa-e9ad9ebc5ba6",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "LongScarf_Neck_DirectorsScarf_Swatch",
"swatch_guid": "FRUp0aYZTUmAXsybauOs7g",
"color": "DirectorsScarf",
"item_name": "(LongScarf)",
"item_guid": "6b9e022c-0b68-48fd-8eca-da8573c18900",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "BearHat_Hat_Panda_Swatch",
"swatch_guid": "_zrnGn1ErEWR8WqrZmKZsA",
"color": "Panda",
"item_name": "(Hat_Bearhat)",
"item_guid": "ef7771eb-c5f3-44fd-8c27-f581b035ef10",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Safari_Shirt_Green_Swatch",
"swatch_guid": "84c016ad-133f-45e3-a412-cac35612540b",
"color": "Green",
"item_name": "(Shirt_Safari)",
"item_guid": "1041d745-c5e9-4218-a3e1-198f5c9c418c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "LaserTag_Wrist_Orange_Swatch",
"swatch_guid": "3b825377-d2eb-41e4-a736-b107f88f62d6",
"color": "Orange",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_Purple_Swatch",
"swatch_guid": "oHKhT-GTx0iFWrrlz9s6Tw",
"color": "Purple",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Beekeeper_Shirt_Gold_Swatch",
"swatch_guid": "afd88a79-7f03-40f6-bcb4-c247dd86edc4",
"color": "Gold",
"item_name": "(Shirt_Beekeeper)",
"item_guid": "1ebbfdfc-f72b-4d70-99d4-4d527f896aa7",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Peridot_Swatch",
"swatch_guid": "ByIfwCtDwU-p9rEfqpjfGg",
"color": "Peridot",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
},
{
"swatch_name": "Winter_Hat_Trees_Swatch",
"swatch_guid": "NH0L99bd00OVKMyltuSnHQ",
"color": "Trees",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "HockeyShirt_Shirt_Teal_Swatch",
"swatch_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b",
"color": "Teal",
"item_name": "(HockeyShirt)",
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Hat_BotFace_Swatch",
"swatch_guid": "-apO3Y7om0GKhC4V_hVYdQ",
"color": "BotFace",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scallywag_Shirt_Striped_SRank_Swatch",
"swatch_guid": "9f1dd474-34a3-47eb-bb72-dbfc8d2d6ecf",
"color": "Striped_SRank",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Tourist_Hat_Tropical_Red_Swatch",
"swatch_guid": "sIP83lqv3kG31TH5Rw4gqg",
"color": "Tropical_Red",
"item_name": "(Hat_Tourist)",
"item_guid": "0QOlT9c5xUuinpfZaoEe8Q",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Tourist_Shirt_Tropical_LightBase_Cream_Swatch",
"swatch_guid": "0WOBGm34H0ySBC10VToa9g",
"color": "Tropical_LightBase_Cream",
"item_name": "(Shirt_Tourist)",
"item_guid": "vDSlMnayzEqWXwT-lkcKSA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WingedTruckerHat_Hat_Black_Swatch",
"swatch_guid": "njEWOjNEQEWCEqmytodpow",
"color": "Black",
"item_name": "(Hat_WingedTruckerHat)",
"item_guid": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Rainhat_Hat_Red_Swatch",
"swatch_guid": "1hVOI7IuY0mKvtr2PZXLww",
"color": "Red",
"item_name": "(Hat_Rainhat)",
"item_guid": "2bd53190-46f8-4956-9388-87c5a04efc4f",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "LumberJack_Shirt_Blue_Swatch",
"swatch_guid": "CUCKsNVnIkavHjcspWAv_A",
"swatch_name": "LumberJack_Blue_Swatch",
"swatch_guid": "cKHAetNcgUOxFfDkAPPF9w",
"color": "Blue",
"item_name": "(Shirt_Lumberjack)",
"item_guid": "350c5c1e-318b-4233-a7e5-1f49909d8e65",
@@ -1990,1223 +19,44 @@
"mask_guid": ""
},
{
"swatch_name": "Blazer_Shirt_MidnightBlue_Swatch",
"swatch_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54",
"color": "MidnightBlue",
"swatch_name": "SpaceMarine_Red_Swatch",
"swatch_guid": "d78643e4-c0d2-4836-bf22-b5a0e2d65b28",
"color": "Red",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "Hibiscus_Yellow_Swatch",
"swatch_guid": "596097a8-d545-4256-959d-1d3cd96fc64c",
"color": "Yellow",
"item_name": "(Hat_Hibiscus)",
"item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Blazer_CandyCane_Swatch",
"swatch_guid": "_ycDQ6RcyEGAntVlSDpPeA",
"color": "CandyCane",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": ""
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "Soccer_Wrist_Blue_Swatch",
"swatch_guid": "av4PwLz6YkmpBRImQZgn4Q",
"color": "Blue",
"item_name": "(Wrist_Soccer)",
"item_guid": "94eb7ef3-528f-4f59-92a0-48b0ec287527",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "IHeartRRShirt_Shirt_PumpkinSweater_Swatch",
"swatch_guid": "Ez3OIIGl0Eqys2JGnSnQHw",
"color": "PumpkinSweater",
"item_name": "(Shirt_IHeartRRShirt)",
"item_guid": "97253092-90ba-4ff2-b137-6324ab244d11",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Conductor_Hat_Green_Swatch",
"swatch_guid": "YJGsYfwkik2lp7qWqXyXTw",
"color": "Green",
"item_name": "(Hat_Conductor)",
"item_guid": "07205057-0804-41de-abe4-bd4027fee1df",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Flapper_Shirt_Gold_Swatch",
"swatch_guid": "a240d44f-963d-4d35-b22f-980938c7788d",
"color": "Gold",
"item_name": "(Shirt_Flapper)",
"item_guid": "5f600892-aa67-484a-afc7-14c219d89c52",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Sapphire_Swatch",
"swatch_guid": "aSeusgYlPE6vOBu9Ua_dWA",
"color": "Sapphire",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
},
{
"swatch_name": "Bowtie_Neck_SodaJerk_Swatch",
"swatch_guid": "qCIoeLQw-Uq-SKIetIgIpA",
"color": "SodaJerk",
"item_name": "(Bowtie)",
"item_guid": "83be5ba4-525a-4781-a5f6-839c22e4d1d3",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "DeepSeaDiver_Hat_Black_Swatch",
"swatch_guid": "a3Er_BCApUiPtBaGkSH9LA",
"color": "Black",
"item_name": "(Hat_DeepSeaDiver)",
"item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scuba_Shoulder_Blue_Swatch",
"swatch_guid": "Uvarm_CwTUWWQYZvzI8OaQ",
"color": "Blue",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FannyPack_Belt_Premium_Swatch",
"swatch_guid": "N-cAmfOgv0eJZMTo18zJHQ",
"color": "Premium",
"item_name": "(Neck_FannyPack)",
"item_guid": "oYvOo47DJE-DEPlpn5xNUw",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "80sShutterShades_Eye_Gold_Swatch",
"swatch_guid": "a3OlD7iI_EqurYq_RIQCvw",
"color": "Gold",
"item_name": "(Hat_80sShutterShades)",
"item_guid": "bu9tb-K7NEiocvZY3o7ukw",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "DrumMajor_Shirt_Green_Swatch",
"swatch_guid": "b0298c45-0dd1-453e-bad4-41e4656bb38e",
"color": "Green",
"item_name": "(Shirt_DrumMajor)",
"item_guid": "a6f1825e-1647-4d06-85d7-ac2d139612c0",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scallywag_Eye_SRank_Swatch",
"swatch_guid": "DE1C-RJkuEWIvpR0q1i42g",
"color": "SRank",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PoolFloatie_Belt_BirdHead_Dino_Swatch",
"swatch_guid": "GtoHkTpyA0iGIHsxphwivg",
"color": "BirdHead_Dino",
"item_name": "(Belt_PoolFloatie)",
"item_guid": "75-HXZBOAEiDBqclnOn8YQ",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Scientist_Shirt_Blue_Swatch",
"swatch_guid": "w07bkcUoQEiNJTVDeSywzQ",
"color": "Blue",
"item_name": "(Shirt_Scientist)",
"item_guid": "fe34adaf-47d3-435b-b2fd-b2fee05fceae",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Barbershop_Hat_Formal_Swatch",
"swatch_guid": "83158223-e268-4036-8a6a-218a8430eb2c",
"color": "Formal",
"item_name": "(Hat_Barbershop)",
"item_guid": "dcf9012d-f9e9-4f15-872e-f130af3b2efa",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Shirt_Blue_Swatch",
"swatch_guid": "6ce21c65-f4e9-41ac-be94-8eb1a08d5c76",
"color": "Blue",
"item_name": "(Shirt_Golfer)",
"item_guid": "f76b709c-b173-426e-81f2-279a67d2fde1",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Jersey_Shirt_Fatigue_Swatch",
"swatch_guid": "a0271cd0-e172-4d3f-aa2f-9806f21a82d2",
"color": "Fatigue",
"item_name": "(Shirt_Jersey)",
"item_guid": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Shirt_ArgyleRed_Swatch",
"swatch_guid": "d015cae7-a905-49e4-8823-6dec069689a6",
"color": "ArgyleRed",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WitchHunter_Hat_White_Swatch",
"swatch_guid": "32d91d32-b102-4093-927a-6dc3ac2e9e86",
"color": "White",
"item_name": "(Hat_WitchHunter)",
"item_guid": "6d36091e-7083-4e3d-8729-94612602c8cb",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "SuperheroHeadband_Hat_Green_Swatch",
"swatch_guid": "ItJCR1utoU6i-syjJpxQqw",
"color": "Green",
"item_name": "(Hat_SuperheroHeadBand)",
"item_guid": "b4e48239-a3b0-49ed-b78a-86f4e3aaca1e",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TreasureHunter_Shirt_Black_Swatch",
"swatch_guid": "9HVoTIf90Ea8_czICVg9AA",
"color": "Black",
"item_name": "(Shirt_TreasureHunter)",
"item_guid": "QK6lBlX_wUCiSbJ9RkIwdA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CatEars_Hat_Gold_Swatch",
"swatch_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69",
"color": "Gold",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "SpaghettiCowboy_Shoulder_Turquoise_Swatch",
"swatch_guid": "kxkqq5UA_kCM6lFsFe3dvw",
"color": "Turquoise",
"item_name": "(Neck_SpaghettiCowboy)",
"item_guid": "62a28dea-adcc-40b3-9045-917ae4f38c63",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "FighterPilot_Wrist_Blue_Swatch",
"swatch_guid": "7869b2a5-cd89-4033-b9f7-4f1451ecb47b",
"color": "Blue",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_Lesbian_Swatch",
"swatch_guid": "Fw79pqDPP0C8H07AnTY-Vg",
"color": "Lesbian",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Cloche_Hat_White_Swatch",
"swatch_guid": "WWmx0nBo3k2nXUPPRJVJLQ",
"color": "White",
"item_name": "(Hat_Cloche)",
"item_guid": "a16d59da-caa5-4388-af67-315bfcb16f64",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Angler_Shirt_Blue_Swatch",
"swatch_guid": "cdbe2cc8-4fd7-451f-9b66-0df0e0e7a85e",
"color": "Blue",
"item_name": "(Shirt_Angler)",
"item_guid": "b1bfe0b4-1ff6-420f-acfc-2331d34246dc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ScallywagBracelet_Wrist_SRank_Swatch",
"swatch_guid": "ce3782aa-56cc-40d7-9019-8ce714cb7748",
"color": "SRank",
"item_name": "(Wrist_Scallywag_Bracelet)",
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "SciFiVisor_Eye_Peach_Swatch",
"swatch_guid": "uB4gFxfrh0W5OtkmZdFN1Q",
"color": "Peach",
"item_name": "(Glasses_SciFiVisor)",
"item_guid": "b89be768-a169-4b24-8022-751e7b817865",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "ArcherQuiver_Shoulder_Gray_Swatch",
"swatch_guid": "6094370d-5e28-473a-b422-f3f1c75d5db8",
"color": "Gray",
"item_name": "(ArcherQuiver)",
"item_guid": "5b6535f0-86cd-417a-bcce-a1ace9a5f260",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "ButtonTop_Shirt_Tan_Swatch",
"swatch_guid": "dafa658e-753b-46cb-bd85-85c1de5e6ea7",
"color": "Tan",
"item_name": "(ButtonTop)",
"item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Raincoat_Hat_Contest_Swatch",
"swatch_guid": "qC66Hu6yKEu2Res2o2z1Kw",
"color": "Contest",
"swatch_name": "Raincoat_Shirt_Blue_Swatch",
"swatch_guid": "GH9q8_0O9EqjGdz7lGWD2g",
"color": "Shirt_Blue",
"item_name": "(Shirt_Raincoat)",
"item_guid": "9e6397dd-b7b0-4d32-8c86-1910106a8478",
"item_outfit_type": 101,
"mask_guid": ""
"mask_guid": "T7zwcIIc8Ee3ud8a2JCuwQ"
},
{
"swatch_name": "LumberJack_Hat_Blue_Swatch",
"swatch_guid": "lb5tuJOqnUKp9prjjlgHmA",
"color": "Blue",
"item_name": "(Hat_Lumberjack)",
"item_guid": "b537c30c-faef-4649-9a1f-be3985936f93",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_Shirt_Tan_Swatch",
"swatch_guid": "sxWOOK1RwUOp5YqZ7a36xg",
"color": "Tan",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SkiBuddy_Eye_Blue_Swatch",
"swatch_guid": "SSY8ZYMgaE2Bl4oFmMjKQA",
"color": "Blue",
"item_name": "(Eye_SkiBuddy)",
"item_guid": "5fwjFQoC_Uu9ChvF6uNGZw",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "WristBand_Wrist_MoonAndStars_Swatch",
"swatch_guid": "6Y43EyTpiEejGp0ODWTSjA",
"color": "MoonAndStars",
"item_name": "(Wrist_WristBand)",
"item_guid": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Letterman_Wrist_Green_Swatch",
"swatch_guid": "4EHIyZkOOEqnM-PuD_a5xw",
"color": "Green",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "LettermanShades_Eye_White_Swatch",
"swatch_guid": "S21TW8NwMEWLV9zqHxWfFw",
"color": "White",
"item_name": "(Hat_LettermanShades)",
"item_guid": "exQz9Zr3HEyOR9nDDrimlQ",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "BackSword_Shoulder_Silver_Swatch",
"swatch_guid": "7mLb5jMJMUug7Nzwxv6D4A",
"color": "Silver",
"item_name": "(Neck_BackSword)",
"item_guid": "23b97189-b49f-477b-8a22-0903b3d236f4",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Witch_Shirt_Green_Swatch",
"swatch_guid": "iDxHI0T4Gkifs3_GY5gdjQ",
"color": "Green",
"item_name": "(Shirt_Witch)",
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "RedFlowerDress_Shirt_Gray_Swatch",
"swatch_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2",
"color": "Gray",
"item_name": "(RedFlowerDress)",
"item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "HeadBand_Hat_Royal_Swatch",
"swatch_guid": "56a92c8d-af53-413e-929e-4a9a3cfad780",
"color": "Royal",
"item_name": "(Hat_HeadBand)",
"item_guid": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "IHeartRRShirt_Shirt_Navy_Swatch",
"swatch_guid": "RmjxL1O9bEGOuYxY9kzHEA",
"color": "Navy",
"item_name": "(Shirt_IHeartRRShirt)",
"item_guid": "97253092-90ba-4ff2-b137-6324ab244d11",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Turquoise_Swatch",
"swatch_guid": "0eZVIsv7wUyZ0ohLmF1jGQ",
"color": "Turquoise",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
},
{
"swatch_name": "BasketballTee_Shirt_Red_Swatch",
"swatch_guid": "54a5c055-f757-4c00-a1e1-8b0b6552c608",
"color": "Red",
"item_name": "(Basketball_Tee)",
"item_guid": "348ac48b-986e-4d42-a0f7-1aea16b88271",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FancyCape_Shoulder_HolidayRed_Swatch",
"swatch_guid": "8Y2F-cVStkSn9YB47VCoKA",
"color": "HolidayRed",
"item_name": "(Neck_FancyCape)",
"item_guid": "46cde502-86d6-4061-a80c-67458ffd4d60",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "FurCoat_Shirt_White_Swatch",
"swatch_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6",
"color": "White",
"item_name": "(FurCoat)",
"item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WristBand_Wrist_Plaid_Swatch",
"swatch_guid": "FS7kgc4DpkKuar24J9dW8g",
"color": "Plaid",
"item_name": "(Wrist_WristBand)",
"item_guid": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "BaseballHelmet_Hat_Green_Swatch",
"swatch_guid": "FVdDQFOMQ06teQm2ANDPng",
"color": "Green",
"item_name": "(Hat_BaseballHelmet)",
"item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "LuchadorCape_Shoulder_Shamrock_Swatch",
"swatch_guid": "eYGWiwqSGUG7Hx1R3I-11A",
"color": "Shamrock",
"item_name": "(LuchadorCape)",
"item_guid": "67117b3d-0669-4cf3-8a4f-066cabd4d8b1",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "BusinessTie_Neck_MrRobbie_Swatch",
"swatch_guid": "iLo1YhFY-06HNWgM6gkB7g",
"color": "MrRobbie",
"item_name": "(BusinessTie)",
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "Winter_Shirt_UglySweater_Swatch",
"swatch_guid": "31nK13dsTkaIf3tGFrZlJQ",
"color": "UglySweater",
"item_name": "(Shirt_Winter)",
"item_guid": "3757149b-f478-4975-81e5-d808a1bed8d5",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Safari_Wrist_Green_Swatch",
"swatch_guid": "fa9a3d8e-16c9-4a7e-9166-3226e0d13c39",
"color": "Green",
"item_name": "(Shirt_Safari)",
"item_guid": "1041d745-c5e9-4218-a3e1-198f5c9c418c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "AstronautGlove_Wrist_Orange_Swatch",
"swatch_guid": "hjk9OO8CW0KNGR7A6_p84A",
"color": "Orange",
"item_name": "(Wrist_AstronautGlove)",
"item_guid": "88739d79-aeb9-471a-b25b-776f46bba9f6",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "BeachTowelCape_Shoulder_Valor_Swatch",
"swatch_guid": "5o-249dUdUOhW28lrJY82A",
"color": "Valor",
"item_name": "(Shoulder_BeachTowelCape)",
"item_guid": "281937b2-f994-45e9-a6d3-03d097247ce6",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "GraduationGown_Shirt_Green_Swatch",
"swatch_guid": "ica0LZyM_kOox8gxW7CaMw",
"color": "Green",
"item_name": "(Shirt_GraduationGown)",
"item_guid": "U_H976qYRUmg9e2clsAflg",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SteampunkFemme_Hat_Contest_Swatch",
"swatch_guid": "Uhw5NBQy-EOIiCWiZFiL7g",
"color": "Contest",
"item_name": "(Hat_SteampunkFemme)",
"item_guid": "JWanxvOHyESg9F_HlmTOfA",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "ScoutRaccoon_Hat_Gray_Swatch",
"swatch_guid": "f1cd10fb-3d0b-436f-a173-65bc0d88d8bf",
"color": "Gray",
"item_name": "(Hat_ScoutRaccoon)",
"item_guid": "7c1552a6-bc36-46e6-884c-1de6b8111a92",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_Rainbow_Swatch",
"swatch_guid": "tlpa7195x0CkmSjpR1RArQ",
"color": "Rainbow",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SteampunkFemmeSpex_Eye_BlackandSilver_Swatch",
"swatch_guid": "dGzmSibucEe7i1HD7lPcNQ",
"color": "BlackandSilver",
"item_name": "(Eye_SteampunkFemmeSpex)",
"item_guid": "CdeM6RJmPUumXtdmdM3RXA",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "AnimalSweater_Shirt_Axolotl_Swatch",
"swatch_guid": "cHZ4lDnSoUypIXCiRRowzg",
"color": "Axolotl",
"item_name": "(Shirt_AnimalSweater)",
"item_guid": "20eb23e8-ab3b-44fc-8038-3714946c1a09",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Neck_Green_Swatch",
"swatch_guid": "M5z2wk-GDkOAjvZzqsfcNQ",
"color": "Green",
"item_name": "(Neck_MagicalGirl)",
"item_guid": "WuShlrJnHUKuscn8e6Rt2g",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "Ranger_Shirt_Green_Swatch",
"swatch_guid": "d21e1b6f-9c9e-42b4-bad5-201ec43161e9",
"color": "Green",
"item_name": "(Shirt_Ranger)",
"item_guid": "fb907eb2-91c8-45ff-84a5-462bb86ea7de",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Hat_SRank_Swatch",
"swatch_guid": "4cc0ea9f-65af-486f-8319-02b85ab5197f",
"color": "SRank",
"item_name": "(Hat_PirateCaptain)",
"item_guid": "3df394de-9c3e-4141-aba4-12eac4742102",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BasicNecklace_Neck_Brown_Swatch",
"swatch_guid": "FwjsTo0nb0uNU6l8XvllNA",
"color": "Brown",
"item_name": "(Neck_BasicNecklace)",
"item_guid": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "Beekeeper_Wrist_Teal_Swatch",
"swatch_guid": "1f18670d-df52-4d2b-8009-a32cf7c67b40",
"color": "Teal",
"item_name": "(Wrist_Beekeeper)",
"item_guid": "4025e4c4-abaa-4729-a2a3-a7175313e5ed",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Wrist_HerringBoneTan_Swatch",
"swatch_guid": "c2a9c007-2748-4fa2-ab9b-0081eef792f1",
"color": "HerringBoneTan",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Steampunk_Hat_Contest_Swatch",
"swatch_guid": "fXnd_COSzUeznwe7P4ZYRg",
"color": "Contest",
"item_name": "(Hat_SteamPunk)",
"item_guid": "gwLiwmqBIk6glFxvaTzxeA",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Grillmaster_Wrist_Black_Swatch",
"swatch_guid": "ojCeJ1VwtUelyVqFnUwK5A",
"color": "Black",
"item_name": "(Wrist_Grillmaster)",
"item_guid": "q7pSsRMNHkqEJmYHuhhG6g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "DeepSeaDiver_Shirt_Beige_Swatch",
"swatch_guid": "EiIow5mS80qpoS10r1ZdvQ",
"color": "Beige",
"item_name": "(Shirt_DeepSeaDiver)",
"item_guid": "c3745cee-d87e-4a23-b6e2-64ebf05c8b11",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SuperheroSuit_Shirt_Green_Swatch",
"swatch_guid": "Y-XQzHOjIkC_-it1j9m9jA",
"color": "Green",
"item_name": "(Shirt_SuperheroSuit)",
"item_guid": "ffad1160-8383-4b4a-a5b9-223476b49b92",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Blazer_Shirt_Shamrock_Swatch",
"swatch_guid": "9skD_xxLq0mpfP13f9n8hQ",
"color": "Shamrock",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Shirt_GoldStripe_Swatch",
"swatch_guid": "YEMLBlOWsEO4tELHseZ7LA",
"color": "GoldStripe",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WackyShorts_Shirt_Popsicle_Swatch",
"swatch_guid": "BAjMoTHbD0mSCjoG9jmzjA",
"color": "Popsicle",
"item_name": "(Shirt_WackyShorts)",
"item_guid": "ae180fb1-3e74-4232-9a9d-388fd488eba1",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_Ace_Swatch",
"swatch_guid": "nbLr3c3lOkiWhNg9IYbt6Q",
"color": "Ace",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Shirt_Denim_Swatch",
"swatch_guid": "f600037d-c9c0-43fa-b45b-02f456f9dd5f",
"color": "Denim",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SodaJerk_Shirt_Mustard_Swatch",
"swatch_guid": "MBuxBAIkpkWI26d-arAKQg",
"color": "Mustard",
"item_name": "(Shirt_SodaJerk)",
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Conductor_Shirt_Green_Swatch",
"swatch_guid": "cQUquXMk5EGt0VUCuPKBgg",
"color": "Green",
"item_name": "(Shirt_Conductor)",
"item_guid": "e60fccda-8711-463d-b8a3-7cd5e76903b2",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PoolFloatie_Belt_Turtle_Penguin_Swatch",
"swatch_guid": "QppxPdf550-jT7dK7XrTzw",
"color": "Turtle_Penguin",
"item_name": "(Belt_PoolFloatie)",
"item_guid": "75-HXZBOAEiDBqclnOn8YQ",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_Trans_Swatch",
"swatch_guid": "tnCJp2eDI0SwjVfJMhk3LQ",
"color": "Trans",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WingedTruckerHat_Hat_Trans_Swatch",
"swatch_guid": "1yMyo6oTjU-VAygoeWaohQ",
"color": "Trans",
"item_name": "(Hat_WingedTruckerHat)",
"item_guid": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "ScoutSkirt_Shirt_Red_Swatch",
"swatch_guid": "e65530f7-543c-43e9-bf12-b6cda2bc755c",
"color": "Red",
"item_name": "(Shirt_ScoutSkirt)",
"item_guid": "35f60d27-22cf-4e17-8f39-c9e3a9fa2291",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Letterman_Shirt_Black_Swatch",
"swatch_guid": "fz_0gZ6RXEC78NpYsx_eqA",
"color": "Black",
"item_name": "(Shirt_Letterman)",
"item_guid": "C4uuztTiYUe20PACMARksA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Garnet_Swatch",
"swatch_guid": "iM-04HpG6k2N8n-P3XS22w",
"color": "Garnet",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
},
{
"swatch_name": "80sShutterShades_Eye_Red_Swatch",
"swatch_guid": "tqyzKPdDwkWq3jfZ8jZdaA",
"color": "Red",
"item_name": "(Hat_80sShutterShades)",
"item_guid": "bu9tb-K7NEiocvZY3o7ukw",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "FlapperHeadband_Hat_Gold_Swatch",
"swatch_guid": "d9be3a98-9da9-419e-9dce-fe58a3ad9c4e",
"color": "Gold",
"item_name": "(Hat_FlapperHeadband)",
"item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Shirt_PlaidRed_Swatch",
"swatch_guid": "10fYSIBytk-yUjveDa407w",
"color": "PlaidRed",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Shirt_Red_Swatch",
"swatch_guid": "Too5jpb1w0iQenkjRRS-QA",
"color": "Red",
"item_name": "(Shirt_Winter)",
"item_guid": "3757149b-f478-4975-81e5-d808a1bed8d5",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "AnimalSweater_Shirt_Toucan_Swatch",
"swatch_guid": "sWgRczvpUU6ZHeoMrMD_yw",
"color": "Toucan",
"item_name": "(Shirt_AnimalSweater)",
"item_guid": "20eb23e8-ab3b-44fc-8038-3714946c1a09",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CatHat_Hat_Gray_Swatch",
"swatch_guid": "V1M2-FXZSEG782d6D0AhUg",
"color": "Gray",
"item_name": "(Hat_CatHat)",
"item_guid": "2c7f90ab-53f7-4579-bf5b-a1c0a6d49e37",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "RoundGlasses_Eye_Black_Swatch",
"swatch_guid": "hGtyDIIWckGoRgCaSG6GBw",
"color": "Black",
"item_name": "(Eye_RoundGlasses)",
"item_guid": "bc7299ac-be2b-404f-b8e6-402e2c0660f0",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "Vest_Shirt_Premium_Swatch",
"swatch_guid": "Qzklnrvw0EOiZ1mrj_nAYw",
"color": "Premium",
"item_name": "(Shirt_Vest)",
"item_guid": "3d9d52cb-6a3d-436b-90e7-e76f238329ee",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WackyShorts_Shirt_Island_Swatch",
"swatch_guid": "rAzhaLh1nEWMd9v1D-HWcQ",
"color": "Island",
"item_name": "(Shirt_WackyShorts)",
"item_guid": "ae180fb1-3e74-4232-9a9d-388fd488eba1",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Ranger_Hat_Green_Swatch",
"swatch_guid": "0272eb19-fc0e-4ad6-8db9-cc0cda2528b0",
"color": "Green",
"item_name": "(Hat_Ranger)",
"item_guid": "f277af6a-0807-4716-8948-1bee236ffb74",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Emerald_Swatch",
"swatch_guid": "KSqfmVTiGUKOKijwGDSwgQ",
"color": "Emerald",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
},
{
"swatch_name": "Blazer_Shirt_Snowflake_Swatch",
"swatch_guid": "jhQ23U8Pmk2K2wfBdrEWNA",
"color": "Snowflake",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "DrumMajor_Wrist_Orange_Swatch",
"swatch_guid": "6b38ab79-142b-446e-978c-ad829ade8823",
"color": "Orange",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Winter_Hat_Snowman_Swatch",
"swatch_guid": "YFZimVHKuk--lXS8RCrZTA",
"color": "Snowman",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "WristBand_Wrist_PolkaDot_Swatch",
"swatch_guid": "gSb8iLTEZ0mi3g5-jZXifQ",
"color": "PolkaDot",
"item_name": "(Wrist_WristBand)",
"item_guid": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "MaleTShirt_Shirt_2016Member_Swatch",
"swatch_guid": "2ee3944d-e9fb-4959-973e-570a7fc6cc57",
"color": "2016Member",
"item_name": "(MaleTShirt)",
"item_guid": "d0a9262f-5504-46a7-bb10-7507503db58e",
"item_outfit_type": 101,
"mask_guid": "167ba0d4-4fac-4143-b5ae-1dbd8e02dc76"
},
{
"swatch_name": "Tourist_Hat_Tropical_LightBase_Orange_Swatch",
"swatch_guid": "znNiHfNdTE-szOIAnIWFQg",
"color": "Tropical_LightBase_Orange",
"item_name": "(Hat_Tourist)",
"item_guid": "0QOlT9c5xUuinpfZaoEe8Q",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "RoyalLady_Shirt_Contest_White_Swatch",
"swatch_guid": "m5Vw_MMCWUiSOOCL94QO0w",
"color": "Contest_White",
"item_name": "(shirt_royalLady)",
"item_guid": "0ab6c95c-e157-48bd-a172-2642896b4ffc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Conductor_Wrist_Green_Swatch",
"swatch_guid": "awWjleyNP0akWNYJoehCLA",
"color": "Green",
"item_name": "(Wrist_Conductor)",
"item_guid": "8fc7aaef-adec-4534-8f91-445e4e7095d5",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "HeadBackBow_Hat_Hearts_Swatch",
"swatch_guid": "mkDVc0Mz4kmuLZ7powBHpQ",
"color": "Hearts",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PoolFloatie_Belt_Watermelon_Swatch",
"swatch_guid": "v1ZOE9G9gEWAqNJX1A9f2A",
"color": "Watermelon",
"item_name": "(Belt_PoolFloatie)",
"item_guid": "75-HXZBOAEiDBqclnOn8YQ",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Hat_LogoGoblins_Swatch",
"swatch_guid": "_yznwUwL80ejrS3vki0IKg",
"color": "LogoGoblins",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "FlowerSwimsuit_Shirt_Pink_Swatch",
"swatch_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622",
"color": "Pink",
"item_name": "(FlowerSwimsuit)",
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "VampireHunter_Wrist_Leather_Swatch",
"swatch_guid": "N5wN1l0tYEau5np-9PoIiA",
"color": "Leather",
"item_name": "(Wrist_VampireHunter)",
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "BearHat_Hat_Teddy_Swatch",
"swatch_guid": "TFOAk7kwh0uUgtsyDNxMuQ",
"color": "Teddy",
"item_name": "(Hat_Bearhat)",
"item_guid": "ef7771eb-c5f3-44fd-8c27-f581b035ef10",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PocketTee_Shirt_Scrubs_Blue_Swatch",
"swatch_guid": "nbgwLqKBnUyvieWX0i47dg",
"color": "Scrubs_Blue",
"item_name": "(Shirt_PocketTee)",
"item_guid": "b3b59410-d3df-464b-a75b-500a9e5ea154",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "LaserTag_Shirt_Green_Swatch",
"swatch_guid": "959ed05b-0215-4bbe-bb3d-64e4448d676b",
"color": "Green",
"item_name": "(Shirt_LaserTag)",
"item_guid": "24d94673-f394-42e6-a3c8-276f8b8aa722",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Witch_Wrist_Green_Swatch",
"swatch_guid": "5aX524Lfok-0dC3UFv-OkQ",
"color": "Green",
"item_name": "(Wrist_Witch)",
"item_guid": "wy2cfgb8mUqjMrIOwHH2ew",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Vest_Shirt_HoundsTooth_Black_Swatch",
"swatch_guid": "WF_fg80f30qvHit0vGBwCA",
"color": "HoundsTooth_Black",
"item_name": "(Shirt_Vest)",
"item_guid": "3d9d52cb-6a3d-436b-90e7-e76f238329ee",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_Gold_Swatch",
"swatch_guid": "Ht0WRvpguUaLZ1tigCzCMA",
"color": "Gold",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PocketTee_Shirt_Reclympics_Swatch",
"swatch_guid": "74J7skzs9EaIq09bZArjiQ",
"color": "Reclympics",
"item_name": "(Shirt_PocketTee)",
"item_guid": "b3b59410-d3df-464b-a75b-500a9e5ea154",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Opal_Swatch",
"swatch_guid": "0YF5bACMsk2sN4tixbJbKw",
"color": "Opal",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
},
{
"swatch_name": "TopHat_Hat_Patchwork_Swatch",
"swatch_guid": "HwhHShudGkGjXagJxCN2uA",
"color": "Patchwork",
"item_name": "(Hat_TopHat)",
"item_guid": "102c625b-b988-4bf8-a2aa-a31ad7029cdc",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Sherlock_Hat_Gray_Swatch",
"swatch_guid": "axPBGoqorEe77Oqqjfzb5w",
"color": "Gray",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scarecrow_Shirt_Contest_Swatch",
"swatch_guid": "uTkbswCedUGyuZcaX0W6Ww",
"color": "Contest",
"item_name": "(Shirt_Scarecrow)",
"item_guid": "1fcaf4fd-9012-407e-b691-fbc3ccf22421",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "QueenCrown_Hat_Diamond_Swatch",
"swatch_guid": "x8nPf0xEwUiRdBQU00Bu1g",
"color": "Diamond",
"item_name": "(Hat_QueenCrown)",
"item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "MaleTShirt_Shirt_EarlyAccess_Swatch",
"swatch_guid": "gW2zFv0sFEmiodfw8p9P-Q",
"color": "EarlyAccess",
"item_name": "(MaleTShirt)",
"item_guid": "d0a9262f-5504-46a7-bb10-7507503db58e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Shirt_GreenFlower_Swatch",
"swatch_guid": "DRJcNhkqvkKFEaZpOguR6w",
"color": "GreenFlower",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Shirt_NonBinary_Swatch",
"swatch_guid": "za8Mz14Ww0KtC6wavUPXdg",
"color": "NonBinary",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "50sDress_Shirt_Shamrock_Swatch",
"swatch_guid": "3drRcfyKkku0qGwCy16m9Q",
"color": "Shamrock",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Witch_Hat_Contest_Swatch",
"swatch_guid": "dpBx3DxB7kKJphxCvfmKKw",
"color": "Contest",
"item_name": "(Hat_Witch)",
"item_guid": "s6mUIqiPCk6e57UYCx3SZA",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TreasureHunter_Hat_Black_Swatch",
"swatch_guid": "JO9lJOQSREWbPyDBZH_cyg",
"color": "Black",
"item_name": "(Hat_TreasureHunter)",
"item_guid": "-SQLtL4rdEWTJdw2IroQCQ",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Jazzercise_Shirt_Contest_Swatch",
"swatch_guid": "YTd0lIQLcU2Y2trwuLhJTg",
"color": "Contest",
"item_name": "(Jazzercise)",
"item_guid": "5f492f8f-ab61-484a-9587-747132b54309",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scuba_Eye_Contest_Swatch",
"swatch_guid": "A7Y52vGIN0ijEKsWR_GQLg",
"color": "Contest",
"item_name": "(Eye_Scuba)",
"item_guid": "9c28e450-8e12-455c-a4c8-c0a2160d4190",
"item_outfit_type": 10,
"mask_guid": ""
},
{
"swatch_name": "WingedHeadphones_Hat_Emissive_Red_Swatch",
"swatch_guid": "oEsjaKTnKUq-Zgg4vm-EfA",
"color": "Emissive_Red",
"item_name": "(Hat_WingedHeadphones)",
"item_guid": "cbf478e1-a9d7-4b3f-9f52-c59271a924f6",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Hat_LogoBucketeers_Swatch",
"swatch_guid": "SroEVC6vBkG42ILwCtKB9w",
"color": "LogoBucketeers",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "RacingHelmet_Hat_ClassicRed_Swatch",
"swatch_guid": "VzyoPsQyCUaW6RSusgVUGQ",
"color": "ClassicRed",
"item_name": "(Hat_RacingHelmet)",
"item_guid": "9fb62d0c-67ff-4675-85d3-a2869c2330aa",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Citrine_Swatch",
"swatch_guid": "aNOhXS4nuUeqs0CVw6K7Hg",
"color": "Citrine",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
},
{
"swatch_name": "Jackfrost_Shirt_Lilac_Swatch",
"swatch_guid": "q0nBwoIx_UWPe93wHDWTYA",
"color": "Lilac",
"item_name": "(Shirt_JackFrost)",
"item_guid": "105a5100-cb57-410f-a26b-1ee21002f067",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scuba_Shirt_Contest_Swatch",
"swatch_guid": "m0R4Jtf4HES8krxMnwIWFg",
"color": "Contest",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Boxer_Shirt_Green_Swatch",
"swatch_guid": "0ec6628c-a106-4452-af82-105a3e3a9f2e",
"swatch_name": "Boxer_Green_Swatch",
"swatch_guid": "rzUd1IMTKUmW0HeMPSIOBQ",
"color": "Green",
"item_name": "(Shirt_Boxer)",
"item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c",
@@ -3214,116 +64,557 @@
"mask_guid": ""
},
{
"swatch_name": "PoolFloatie_Belt_Donut_Swatch",
"swatch_guid": "sx10-Hsfl0OS62pIFvHH8A",
"color": "Donut",
"item_name": "(Belt_PoolFloatie)",
"item_guid": "75-HXZBOAEiDBqclnOn8YQ",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Witch_Shirt_Contest_Swatch",
"swatch_guid": "AK4CoAB2IUGkwqECNaO75g",
"color": "Contest",
"item_name": "(Shirt_Witch)",
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
"swatch_name": "Boxer_Red_Swatch",
"swatch_guid": "8c7b09f2-6213-4ef9-87ab-a2df72d07a22",
"color": "Red",
"item_name": "(Shirt_Boxer)",
"item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ButterflyWings_Shoulder_CairnsBirdwing_Swatch",
"swatch_guid": "GlB_scM-5kuc4A5TmGe6Yw",
"color": "CairnsBirdwing",
"item_name": "(Neck_ButterflyWings)",
"item_guid": "fc96ea5d-b49b-479b-a963-92f1eab44b2c",
"item_outfit_type": 100,
"swatch_name": "Winter_Gloves_Tree_Green_Swatch",
"swatch_guid": "tyzdtSkR9k-YCnjNvPAT6w",
"color": "Gloves_Tree_Green",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Shirt_HerringBoneTan_Swatch",
"swatch_guid": "33ee376a-586e-4aff-a7e4-4213445a3e92",
"color": "HerringBoneTan",
"item_name": "(Shirt_Mobster)",
"item_guid": "4308da04-af84-40bb-845a-7a9e1a2726ec",
"swatch_name": "Headphones_Red_Swatch",
"swatch_guid": "viL4CPch2ECwG-RNaWcyZw",
"color": "Red",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "JackfrostHair_Hat_Lilac_Swatch",
"swatch_guid": "KwhdQ1deNE6Y6jzO9vaAnQ",
"color": "Hat_Lilac",
"item_name": "(Hat_JackfrostHair)",
"item_guid": "e6216087-fb37-46f1-bda4-a88c4640f339",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Red_Swatch",
"swatch_guid": "de0a5a3a-8aff-4799-8269-9efa3aeb383a",
"color": "Red",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "ButtonTop_Orange_Swatch",
"swatch_guid": "e0397982-c2c2-4733-9a40-46e18675b5af",
"color": "Orange",
"item_name": "(ButtonTop)",
"item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WackyShorts_Shirt_Pineapple_Swatch",
"swatch_guid": "bjXO36Nu6EWD2dU1UC4Uwg",
"color": "Pineapple",
"item_name": "(Shirt_WackyShorts)",
"item_guid": "ae180fb1-3e74-4232-9a9d-388fd488eba1",
"item_outfit_type": 101,
"swatch_name": "CowboyCherry_Swatch",
"swatch_guid": "624c85e5-095a-48b6-85fb-c9bbade19a8d",
"color": "Cherry",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "Mobster_HerringBone_Tan_Swatch",
"swatch_guid": "531b4297-b38b-4362-9854-4f2139ea43be",
"color": "HerringBone_Tan",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Scuba_Shirt_Blue_Swatch",
"swatch_guid": "xTqtLPQVyEKjAQZYl6HGow",
"swatch_name": "MagicalGirl_Blue_Swatch",
"swatch_guid": "VQjkyb5aoEKHKo_eZsq9ug",
"color": "Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Wizard_SRank_Swatch",
"swatch_guid": "RKmRHk2N_U2gd91nybo6vg",
"color": "SRank",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Punk_Pink_Swatch",
"swatch_guid": "4169bfed-8403-4590-a29d-4941275ac0b6",
"color": "Pink",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "Countess_Wrist_Purple_Swatch",
"swatch_guid": "jwEB6LyEaEekqtPNRc6TVA",
"color": "Wrist_Purple",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": "8kdJkmwNM0WQmMVrb8fK8A"
},
{
"swatch_name": "Hat_GraduationCap_Green_Swatch",
"swatch_guid": "FaWMLkJJgEKFIO493fzGxg",
"color": "Green",
"item_name": "(Hat_GraduationCap)",
"item_guid": "KPmC_wyX_ECe7fdKwoLMGg",
"item_outfit_type": 0,
"mask_guid": "yzL0M_N5Q0eAa8IxACHPAQ"
},
{
"swatch_name": "EquestrianJacket_Maroon_Swatch",
"swatch_guid": "d6823e01-69f0-4f85-b94a-74894356a2cf",
"color": "Maroon",
"item_name": "(EquestrianJacket)",
"item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5",
"item_outfit_type": 101,
"mask_guid": "d344b8cc-85a8-4ace-9f92-38c84f396e99"
},
{
"swatch_name": "Headphones_EmissiveBlack_Swatch",
"swatch_guid": "Uz2hrptCa0erjqyPoiZxew",
"color": "EmissiveBlack",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Shirt_VampireHunter_White_Swatch",
"swatch_guid": "9QG6qp5FBU-u0-JK-yMMqA",
"color": "White",
"item_name": "(Shirt_VampireHunter)",
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
"item_outfit_type": 101,
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
},
{
"swatch_name": "FighterPilot_Gloves_Orange_Swatch",
"swatch_guid": "18b52db5-0261-46a0-9214-ff510d455ed9",
"color": "Gloves_Orange",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Aviator_Gloves_Tan_Swatch",
"swatch_guid": "7fb8496f-0e6c-4e48-b8fa-5709ebbf4820",
"color": "Gloves_Tan",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": "5659ce2c-9cb0-49da-8c39-4b7df0f8deec"
},
{
"swatch_name": "Astronaut_Glove_Orange_Swatch",
"swatch_guid": "y_gTbnuYS0GKJmnCs2retw",
"color": "e_Orange",
"item_name": "(Wrist_AstronautGlove)",
"item_guid": "88739d79-aeb9-471a-b25b-776f46bba9f6",
"item_outfit_type": 200,
"mask_guid": "hjk9OO8CW0KNGR7A6_p84A"
},
{
"swatch_name": "Winter_Hat_Tree_Swatch",
"swatch_guid": "xVwzV9-m6E-8PVpkg4D-AQ",
"color": "Hat_Tree",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "nalaCGqtL0SFA4Itwqmp8g"
},
{
"swatch_name": "Harness_Hat_Teal_Swatch",
"swatch_guid": "lsyYlxY9o0a3TbUrHkxeyQ",
"color": "Hat_Teal",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "Sherlock_Torso_Gray_Swatch",
"swatch_guid": "msOFHoFN9UORkeXXtdqBsw",
"color": "Torso_Gray",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": "9ZP617DQk0mZ10J3d5q9oA"
},
{
"swatch_name": "BowlingShirt_CenterStripe_Burgandy_Swatch",
"swatch_guid": "ghM-uFq5-0Ci38AOHa9zZA",
"color": "Shirt_CenterStripe_Burgandy",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "IWg9wuda4kaCilJk8JQpTQ"
},
{
"swatch_name": "Scuba_Neck_Blue_Swatch",
"swatch_guid": "V_duN2oWaUmmao1xHhzCJA",
"color": "Neck_Blue",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Shirt_VerticalStripe_SolidCollar_Swatch",
"swatch_guid": "RNN0ejjlm0yqGDcEuUkLjw",
"color": "VerticalStripe_SolidCollar",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"swatch_name": "Scallywag_Bracelet_SRank_Swatch",
"swatch_guid": "X3MkhL55Rk-0UK6h4dw0Eg",
"color": "SRank",
"item_name": "(Wrist_Scallywag_Bracelet)",
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArtistHat_Red_Swatch",
"swatch_guid": "5c8f36c5-07da-49f4-af02-d23207a43216",
"color": "Hat_Red",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": "3485a30d-27f8-450b-b5aa-1ff3cd227d19"
},
{
"swatch_name": "ArcherBlack_Quiver_Swatch",
"swatch_guid": "74b009f4-e128-4c59-8a6a-4c0ba6119bde",
"color": "Black_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "LuchadorCape_Shoulder_Tiger_Swatch",
"swatch_guid": "qSsEcadcm0-twR1TvVcgYQ",
"color": "Tiger",
"item_name": "(LuchadorCape)",
"item_guid": "67117b3d-0669-4cf3-8a4f-066cabd4d8b1",
"item_outfit_type": 100,
"swatch_name": "ArcherYellow_Swatch",
"swatch_guid": "6e152cc7-e93a-4408-8092-236db420b680",
"color": "Yellow",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Aquamarine_Swatch",
"swatch_guid": "YKaJjmMHo02FWwem8nw1yw",
"color": "Aquamarine",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
"swatch_name": "PurpleJacket_Red_Swatch",
"swatch_guid": "657f28d5-672a-4f27-86c6-3cb00f25bb84",
"color": "Red",
"item_name": "(PurpleJacket)",
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
"item_outfit_type": 101,
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
},
{
"swatch_name": "AstronautHelmet_Hat_Orange_Swatch",
"swatch_guid": "iQjkS1xYP0S63ltvKpkz5g",
"swatch_name": "FighterPilot_Orange_Swatch",
"swatch_guid": "cea53c7f-24a9-4e3b-80b9-fbf95738f973",
"color": "Orange",
"item_name": "(Hat_AstronautHelmet)",
"item_guid": "f218e2de-46fd-45b9-9e0e-670b6bb905e7",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
},
{
"swatch_name": "Neck_Grillmaster_Denim_Swatch",
"swatch_guid": "eV3Nv6SWS0yl3f-fgjPb-w",
"color": "Denim",
"item_name": "(Neck_Grillmaster)",
"item_guid": "tTKv7eRODka2KC_hsY2O3Q",
"item_outfit_type": 100,
"mask_guid": "t-ZEWJCgFEyGP2PjNblQMA"
},
{
"swatch_name": "BabydollDress_Green_Swatch",
"swatch_guid": "c9589974-c261-485e-b571-cb2f34fb178f",
"color": "Green",
"item_name": "(BabydollDress)",
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hat_SeaCaptainDarkDarkGold_Swatch",
"swatch_guid": "70112887-dc8d-49d5-96ed-0845204e0b58",
"color": "DarkDarkGold",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "Letterman_Wrist_Red_Swatch",
"swatch_guid": "HWc25-d8dUmNN2F3n8mV5A",
"color": "Wrist_Red",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": "4EHIyZkOOEqnM-PuD_a5xw"
},
{
"swatch_name": "WitchHunter_Purple_Swatch",
"swatch_guid": "71e5a0fe-fc8d-420b-af3b-e74160e0d1f3",
"color": "Purple",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Beekeeper_Shirt_Teal_Swatch",
"swatch_guid": "oZ8k0Qv3SkG-DkY0_dP7UA",
"color": "Shirt_Teal",
"item_name": "(Shirt_Beekeeper)",
"item_guid": "1ebbfdfc-f72b-4d70-99d4-4d527f896aa7",
"item_outfit_type": 101,
"mask_guid": "afd88a79-7f03-40f6-bcb4-c247dd86edc4"
},
{
"swatch_name": "Smuggler_Green_Swatch",
"swatch_guid": "076c96ae-9f9b-45bf-9dc7-ce73d5ddf422",
"color": "Green",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "SodaJerk_Green_Swatch",
"swatch_guid": "oKxmH-1s2EyIDkZoOQSQQw",
"color": "Green",
"item_name": "(Shirt_SodaJerk)",
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scarecrow_Hat_Contest_Swatch",
"swatch_guid": "TZ3oQBp_3kWY4Vzscgv5Wg",
"color": "Hat_Contest",
"item_name": "(Shirt_Scarecrow)",
"item_guid": "1fcaf4fd-9012-407e-b691-fbc3ccf22421",
"item_outfit_type": 101,
"mask_guid": "xCpdHx1iM06Gg0tYoGv0DA"
},
{
"swatch_name": "Hat_TutorialCap_2_Swatch",
"swatch_guid": "gDH1WmW5rk2RdY4wJYYZow",
"color": "2",
"item_name": "(Hat_TutorialCap)",
"item_guid": "M9weaekvIUimiJSVJqZl-w",
"item_outfit_type": 0,
"mask_guid": "GV8z2dlqGEqIAYaGrF1ZYQ"
},
{
"swatch_name": "Hat_Baseball_ThreeColor_BlueRed_Swatch",
"swatch_guid": "nZor-clxlkm8EO9Gco0Otg",
"color": "ThreeColor_BlueRed",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": "r_GLekt6mkWxXw8zPPR8Mg"
},
{
"swatch_name": "PirateCaptain_Hat_Black_Swatch",
"swatch_guid": "f9aaa7c5-59bc-486c-9a6e-27a8ac618f84",
"color": "Hat_Black",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Wizard_Purple_Swatch",
"swatch_guid": "d3KmqVuMQkCYK6sVTMOTOA",
"color": "Purple",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "MartyRecFly_Shirt_Hipster_Swatch",
"swatch_guid": "nbhzQy_Hdkeohh4S1IezwA",
"color": "Hipster",
"item_name": "(Shirt_MartyRecFly)",
"item_guid": "74c65554-0d15-409b-b9a8-0b2620a7d7e2",
"swatch_name": "BaseballHelmet_Red_Swatch",
"swatch_guid": "a1c51d1e-1d5f-4f8a-bc0f-6a20eddfe58a",
"color": "Red",
"item_name": "(Hat_BaseballHelmet)",
"item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12",
"item_outfit_type": 0,
"mask_guid": "FVdDQFOMQ06teQm2ANDPng"
},
{
"swatch_name": "TruckerOveralls_Mustard_Swatch",
"swatch_guid": "nKauYAyWhkm2AMHFyC-fLg",
"color": "Mustard",
"item_name": "(Shirt_TruckerOveralls)",
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BrideOfFrank_Shirt_GlitterRed_Swatch",
"swatch_guid": "H8LXGRJj8UOuH5dil8L9qw",
"color": "GlitterRed",
"item_name": "(Shirt_BrideofFrank)",
"item_guid": "7VQvz9sbzUWvGalxd9rISg",
"swatch_name": "PirateCaptain_Black_Swatch",
"swatch_guid": "d81d0b62-e052-46c2-9252-4bc5fb219f53",
"color": "Black",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "BlazerGreyDark_Swatch",
"swatch_guid": "9374bf66-2ee5-493b-8439-efce4b201904",
"color": "GreyDark",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "MagicalGirl_Wrist_Red_Swatch",
"swatch_guid": "N9CBuYaMtUOrCks5UcOycg",
"color": "Wrist_Red",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "ZtCqMnFTQUWv2h0MfZ4m6A"
},
{
"swatch_name": "PirateCaptain_Belt_Black_Swatch",
"swatch_guid": "f4ad4b9e-dd42-4711-9437-5beae51966f6",
"color": "Belt_Black",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": "9b2102f0-e16b-464f-9f1c-f068de52bb89"
},
{
"swatch_name": "BowlingShirt_Flames_Red_Swatch",
"swatch_guid": "8xjU4GQ4OkaqGyad-ypwtw",
"color": "Shirt_Flames_Red",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "5RUG6i2Mx0-55IO-IlhUdQ"
},
{
"swatch_name": "BabydollDress_Black_Swatch",
"swatch_guid": "e13cf33d-7588-4e2e-955e-f0a29d6380b7",
"color": "Black",
"item_name": "(BabydollDress)",
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Vest_Shirt_Red_Swatch",
"swatch_guid": "bVrkIyADCUiGNYWdmP_HPw",
"swatch_name": "Bowtie_SodaJerk_Swatch",
"swatch_guid": "e4IDo-fuH0C9YByzcX5k-A",
"color": "SodaJerk",
"item_name": "(Bowtie)",
"item_guid": "83be5ba4-525a-4781-a5f6-839c22e4d1d3",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "FlapperGlove_Black_Swatch",
"swatch_guid": "30c61e63-5e09-4a6f-8915-1e1fe4d55cf0",
"color": "Black",
"item_name": "(Wrist_FlapperGlove)",
"item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde",
"item_outfit_type": 200,
"mask_guid": "0e42108e-3b08-49b4-86c0-7984719e8380"
},
{
"swatch_name": "ButtonTop_Tan_Swatch",
"swatch_guid": "49f5864f-9d40-497c-88c8-e87f64d41d74",
"color": "Tan",
"item_name": "(ButtonTop)",
"item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Windbreaker_Blue_Swatch",
"swatch_guid": "EbVDqEJQOk6j0UvX3t-8rQ",
"color": "Blue",
"item_name": "(Wrist_80sWindbreaker)",
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Aviator_Goggles_Gray_Swatch",
"swatch_guid": "23428ca9-be75-4146-b400-df01d1dadac9",
"color": "s_Gray",
"item_name": "(Eye_AviatorGoggles)",
"item_guid": "47629ed1-5378-427c-ad61-4b44d2fe90cc",
"item_outfit_type": 10,
"mask_guid": "556f876a-70da-4104-b792-fca8ad67be0c"
},
{
"swatch_name": "SafariTorso_Green_Swatch",
"swatch_guid": "49bbd11a-e5c9-47ff-b04d-72eb321e8a57",
"color": "Torso_Green",
"item_name": "(Shirt_Safari)",
"item_guid": "1041d745-c5e9-4218-a3e1-198f5c9c418c",
"item_outfit_type": 101,
"mask_guid": "84c016ad-133f-45e3-a412-cac35612540b"
},
{
"swatch_name": "Scallywag_Hat_Blue_Swatch",
"swatch_guid": "53dd9e44-7096-438e-a7c3-11d7c307426f",
"color": "Hat_Blue",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": "a4903da3-249d-4f0a-a746-b547fda371ff"
},
{
"swatch_name": "BusinessTie_Valentine_Swatch",
"swatch_guid": "31jelNWFyk6FlWt4HyPAlw",
"color": "Valentine",
"item_name": "(BusinessTie)",
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Astronaut_Helmet_Orange_Swatch",
"swatch_guid": "JFalLDUvm0aMm3wh1UVfVg",
"color": "t_Orange",
"item_name": "(Hat_AstronautHelmet)",
"item_guid": "f218e2de-46fd-45b9-9e0e-670b6bb905e7",
"item_outfit_type": 0,
"mask_guid": "iQjkS1xYP0S63ltvKpkz5g"
},
{
"swatch_name": "ArcherGray_Quiver_Swatch",
"swatch_guid": "imDGL6dhrka-KliYlfpdgw",
"color": "Gray_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Vest_Red_Swatch",
"swatch_guid": "ffHKr5EcIU24PbAtWSnalw",
"color": "Red",
"item_name": "(Shirt_Vest)",
"item_guid": "3d9d52cb-6a3d-436b-90e7-e76f238329ee",
@@ -3331,152 +622,1934 @@
"mask_guid": ""
},
{
"swatch_name": "BeachTowelCape_Shoulder_Defenders_Swatch",
"swatch_guid": "4T-xj-xtSEiv1PDYv30URg",
"color": "Defenders",
"item_name": "(Shoulder_BeachTowelCape)",
"item_guid": "281937b2-f994-45e9-a6d3-03d097247ce6",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Safari_Hat_Green_Swatch",
"swatch_guid": "0dabda0d-842c-4d39-a6b9-a8ca3ec20825",
"color": "Green",
"item_name": "(Hat_Safari)",
"item_guid": "418ed21e-766f-4c69-9d57-6f866dcc7feb",
"swatch_name": "HeadBackBow_Blue_Swatch",
"swatch_guid": "GmS0wKwzVEqdf3NoTMHG5A",
"color": "Blue",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": ""
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "Samurai_Wrist_Red_Swatch",
"swatch_guid": "4Ooi0h3SrkObClvS2cGk9Q",
"color": "Red",
"item_name": "(Wrist_Samurai)",
"item_guid": "dfcc5a47-5f73-496c-84b4-d44702231f7e",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "BeachTowelCape_Shoulder_MasterMinds_Swatch",
"swatch_guid": "gOw6NOX-QUegt4mxjtfpfA",
"color": "MasterMinds",
"item_name": "(Shoulder_BeachTowelCape)",
"item_guid": "281937b2-f994-45e9-a6d3-03d097247ce6",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "TopHat_Hat_Plain_White_Swatch",
"swatch_guid": "vm1ESK9ls0SaxSwHplNALQ",
"color": "Plain_White",
"item_name": "(Hat_TopHat)",
"item_guid": "102c625b-b988-4bf8-a2aa-a31ad7029cdc",
"swatch_name": "Headphones_EmissiveRed_Swatch",
"swatch_guid": "I1J-xY4YNU2dB_od4P_7Dg",
"color": "EmissiveRed",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": ""
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "ReclympicsMedal_Neck_Silver_Swatch",
"swatch_guid": "kJ_pNxSWKE6HZX4Sc7Dv0g",
"color": "Silver",
"item_name": "(Neck_ReclympicsMedal)",
"item_guid": "9fd1be12-1577-4800-b79f-6da29f299826",
"item_outfit_type": 103,
"mask_guid": ""
"swatch_name": "EquestrianJacket_Gray_Swatch",
"swatch_guid": "4828b50c-95b6-466a-bb25-514891d78202",
"color": "Gray",
"item_name": "(EquestrianJacket)",
"item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5",
"item_outfit_type": 101,
"mask_guid": "d344b8cc-85a8-4ace-9f92-38c84f396e99"
},
{
"swatch_name": "MaleTShirt_Shirt_RecCon_ForbiddenOne_Swatch",
"swatch_guid": "UwQiLub0-kqf4dNvQc-R0A",
"color": "RecCon_ForbiddenOne",
"item_name": "(MaleTShirt)",
"item_guid": "d0a9262f-5504-46a7-bb10-7507503db58e",
"swatch_name": "Hoodie_Skeleton_NoGlow_Swatch",
"swatch_guid": "s6DQxJ3gY0CG8L7JmcmRTQ",
"color": "Skeleton_NoGlow",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "JackFrostWings_Shoulder_Lilac_Swatch",
"swatch_guid": "oGrNKZLpMU2bLwNEe4MPnw",
"color": "Lilac",
"item_name": "(Neck_JackFrostWings)",
"item_guid": "022ce375-ed02-479d-b87d-9d72d126bbd4",
"item_outfit_type": 100,
"swatch_name": "AnglerHat_Blue_Swatch",
"swatch_guid": "d7f0ffd3-ccf9-465e-8afe-4d7efb78dd2c",
"color": "Hat_Blue",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": "efeb07ab-6253-436d-bb66-991ddad72e58"
},
{
"swatch_name": "FurCoat_Black_Swatch",
"swatch_guid": "43501b0c-eaaa-4ab3-9d03-2ca61d2e8fc9",
"color": "Black",
"item_name": "(FurCoat)",
"item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5",
"item_outfit_type": 101,
"mask_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6"
},
{
"swatch_name": "Scuba_eye_Contest_Swatch",
"swatch_guid": "ZSOuWuKVCkqaIKc5d23G8Q",
"color": "eye_Contest",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Wrist_Grey_Swatch",
"swatch_guid": "f4cea991-fc3d-4715-bf82-8a8fb60e8799",
"swatch_name": "BowlingShirt_OneStripe_Green_Swatch",
"swatch_guid": "kLyM-6otSUy2ql5SEmnF9Q",
"color": "Shirt_OneStripe_Green",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "b-IM3eYMJUa40395O5XeLg"
},
{
"swatch_name": "FlapperHeadband_Gold_Swatch",
"swatch_guid": "b6c25a3b-125b-4aab-bed0-e6e5ced2c21c",
"color": "Gold",
"item_name": "(Hat_FlapperHeadband)",
"item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c",
"item_outfit_type": 0,
"mask_guid": "d9be3a98-9da9-419e-9dce-fe58a3ad9c4e"
},
{
"swatch_name": "Golfer_Shirt_Blue_Swatch",
"swatch_guid": "96e2b29e-e553-40f1-9eb6-8a9f7e64da39",
"color": "Shirt_Blue",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "6ce21c65-f4e9-41ac-be94-8eb1a08d5c76"
},
{
"swatch_name": "Wrist_VampireHunter_Red_Swatch",
"swatch_guid": "iJW8XGJfU0G12iXarjxhdQ",
"color": "Red",
"item_name": "(Wrist_VampireHunter)",
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
"item_outfit_type": 200,
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
},
{
"swatch_name": "Hibiscus_White_Swatch",
"swatch_guid": "ce465d53-3807-423f-baa0-bdba2a75ceb3",
"color": "White",
"item_name": "(Hat_Hibiscus)",
"item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "FlapperHeadband_Black_Swatch",
"swatch_guid": "565c17e0-496d-45bd-a2c3-564e118c06cc",
"color": "Black",
"item_name": "(Hat_FlapperHeadband)",
"item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c",
"item_outfit_type": 0,
"mask_guid": "d9be3a98-9da9-419e-9dce-fe58a3ad9c4e"
},
{
"swatch_name": "Shirt_TutorialGown3_Black_Swatch",
"swatch_guid": "GK3yolCNlEm3B_5DtHkE8w",
"color": "Black",
"item_name": "(Shirt_TutorialGown_3)",
"item_guid": "0yFzgVESokC3gFd3hrS8bg",
"item_outfit_type": 101,
"mask_guid": "yKb2A8M_m0GQDhFWCRUHTQ"
},
{
"swatch_name": "PeaCoat_Purple_Swatch",
"swatch_guid": "uiBklotTUEqRI2F67vyJyA",
"color": "Purple",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": "AZnPWCN41kmNwFwDqWGFWQ"
},
{
"swatch_name": "Winter_Scarf_Tree_Green_Swatch",
"swatch_guid": "-tDd0gdxQkuvL2id10AweQ",
"color": "Scarf_Tree_Green",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Headphones_TwoRed_Swatch",
"swatch_guid": "hf0gkFVS8ESvceRsC3LAXQ",
"color": "TwoRed",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Vest_HoundsTooth_Black_Swatch",
"swatch_guid": "m4W5D0Oa9kaWa-8I96YYzA",
"color": "HoundsTooth_Black",
"item_name": "(Shirt_Vest)",
"item_guid": "3d9d52cb-6a3d-436b-90e7-e76f238329ee",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Headphones_EmissiveBlue_Swatch",
"swatch_guid": "2sf-FU4WckGBDWUkVz4z1g",
"color": "EmissiveBlue",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Headphones_Purple_Swatch",
"swatch_guid": "a5nUvJZgTEGBUvdHx0Y6Ng",
"color": "Purple",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Winter_Scarf_Red_Swatch",
"swatch_guid": "2lD-mjl3pk2wWv24RY2tbA",
"color": "Scarf_Red",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "dUu7b-JFOky9c2lIVCNmcg"
},
{
"swatch_name": "Headphones_EmissiveOrange_Swatch",
"swatch_guid": "d9mwowRelE2lDKEA0UQMZQ",
"color": "EmissiveOrange",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Scuba_eye_Blue_Swatch",
"swatch_guid": "DRS8xu3AqUOy2L0RKF3f7g",
"color": "eye_Blue",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherGray_Swatch",
"swatch_guid": "61VZkFjB402brzWm4UsftA",
"color": "Gray",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hat_CatEars_Gray_Swatch",
"swatch_guid": "96e42c7a-2881-4299-a2fe-80c0d4d5ca26",
"color": "Gray",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
},
{
"swatch_name": "Hat_CatEars_Brown_Swatch",
"swatch_guid": "713781e5-ce47-41c6-a24f-94d42a565e30",
"color": "Brown",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
},
{
"swatch_name": "Rainhat_Hat_Red_Swatch",
"swatch_guid": "v3af60p1Q02NpdCqIRO9lw",
"color": "Hat_Red",
"item_name": "(Hat_Rainhat)",
"item_guid": "2bd53190-46f8-4956-9388-87c5a04efc4f",
"item_outfit_type": 0,
"mask_guid": "1hVOI7IuY0mKvtr2PZXLww"
},
{
"swatch_name": "WitchHunter_Green_Swatch",
"swatch_guid": "64545dcc-a8eb-4c69-a539-78ee73a2d327",
"color": "Green",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Gloves_Red_Swatch",
"swatch_guid": "SWvJWvMDpE-it5PFoPgERg",
"color": "Gloves_Red",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_White_Swatch",
"swatch_guid": "yA0KhVg19kWqG1UkyxQogQ",
"color": "White",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_White_Swatch",
"swatch_guid": "zu0Jk24WTkan6XWSwGU1Uw",
"color": "White",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "Scout_Sash_Blue_Swatch",
"swatch_guid": "67490e3b-0f9d-4bf8-84fb-54957b981c8c",
"color": "h_Blue",
"item_name": "(Neck_ScoutSash)",
"item_guid": "bf9dc196-5727-4752-8126-7ad5fcd96d55",
"item_outfit_type": 100,
"mask_guid": "66cc536d-8cb5-4262-b7f0-49a33b97ea10"
},
{
"swatch_name": "WaitressApron_Red_Swatch",
"swatch_guid": "HdwZhjrqxE6wuf68MnDhDw",
"color": "Red",
"item_name": "(Belt_WaitressApron)",
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "SpaceMarine_Pink_Swatch",
"swatch_guid": "a36d252b-16ac-4a82-bece-de145147e7d9",
"color": "Pink",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "Mobster_Hat_Brown_Swatch",
"swatch_guid": "1421caa9-9a0d-42c2-b847-1500ba2e483a",
"color": "Hat_Brown",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": "d759c88d-a159-4c26-86dd-b054bb05b3ed"
},
{
"swatch_name": "ElectricianTorso_Gray_Swatch",
"swatch_guid": "b995b419-1897-4c1d-98be-83d551f7ba71",
"color": "Torso_Gray",
"item_name": "(Hat_Electrician)",
"item_guid": "34654303-9760-447f-8614-1c26506db994",
"item_outfit_type": 0,
"mask_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a"
},
{
"swatch_name": "Shirt_MartyRecFly_Hipster_Swatch",
"swatch_guid": "kkvNbVRjcEKtChpy3W1bqQ",
"color": "Hipster",
"item_name": "(Shirt_MartyRecFly)",
"item_guid": "74c65554-0d15-409b-b9a8-0b2620a7d7e2",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Smuggler_SRank_Swatch",
"swatch_guid": "bzYdPb8GWUS-CgT4wrziIA",
"color": "SRank",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "Hat_SkiBuddy_White_Swatch",
"swatch_guid": "KwCEye0nsESFs-yj4h9sjQ",
"color": "White",
"item_name": "(Hat_SkiBuddy)",
"item_guid": "6QT99hx6DE6t9cKAs61i3w",
"item_outfit_type": 0,
"mask_guid": "c_t9XDbn-Eqml1O-wC5zJQ"
},
{
"swatch_name": "PeaCoat_Navy_Swatch",
"swatch_guid": "fvoaMN4M10WS6jiA6XlOJQ",
"color": "Navy",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": "AZnPWCN41kmNwFwDqWGFWQ"
},
{
"swatch_name": "Wrist_Grillmaster_Blue_Swatch",
"swatch_guid": "90qQe0AR3kOou0MsuoTmPA",
"color": "Blue",
"item_name": "(Wrist_Grillmaster)",
"item_guid": "q7pSsRMNHkqEJmYHuhhG6g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "FootballHelmet_Orange_Swatch",
"swatch_guid": "d8d490aa-7088-4163-9305-1441367f490c",
"color": "Helmet_Orange",
"item_name": "(Hat_FootBall)",
"item_guid": "5a38f864-7d1c-4989-8558-40b9668f78ef",
"item_outfit_type": 0,
"mask_guid": "7660e11a-7ef0-43d3-bc02-76b3a4aab064"
},
{
"swatch_name": "HeadBackBow_Red_Swatch",
"swatch_guid": "ZNKGkou_gU-1cxBFy7pDKw",
"color": "Red",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "Scientist_Blue_Swatch",
"swatch_guid": "KTidYfH0ckWxJGs5qzUBTQ",
"color": "Blue",
"item_name": "(Hair_Scientist)",
"item_guid": "f17a2f9e-1eab-44e8-b10a-1f53c5d0778c",
"item_outfit_type": 2,
"mask_guid": ""
},
{
"swatch_name": "RedFlowerDress_Gray_Swatch",
"swatch_guid": "1acb1f56-4509-449f-9c6a-065bdeaa9ee3",
"color": "Gray",
"item_name": "(RedFlowerDress)",
"item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6",
"item_outfit_type": 101,
"mask_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2"
},
{
"swatch_name": "Aviator_Jacket_Tan_Swatch",
"swatch_guid": "bea795cc-aabd-4229-84dd-49ab4f91a445",
"color": "Jacket_Tan",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Hat_Purple_Swatch",
"swatch_guid": "PZx7Iae0pkiDaxT9t3-vgg",
"color": "Hat_Purple",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": "d759c88d-a159-4c26-86dd-b054bb05b3ed"
},
{
"swatch_name": "LumberJack_Green_Swatch",
"swatch_guid": "7amHTgEv-UOJSGl0ofo4mw",
"color": "Green",
"item_name": "(Shirt_Lumberjack)",
"item_guid": "350c5c1e-318b-4233-a7e5-1f49909d8e65",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Hat_Yellow_Swatch",
"swatch_guid": "L2iFQcL170OajqfW7t6sVw",
"color": "Hat_Yellow",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "BowlingShirt_CenterStripe_Black_Swatch",
"swatch_guid": "Gtq8yRlt1UeFW8U-sc7FiA",
"color": "Shirt_CenterStripe_Black",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "IWg9wuda4kaCilJk8JQpTQ"
},
{
"swatch_name": "Punk_WristPink_Swatch",
"swatch_guid": "f3b2330a-b981-4c7e-902f-1f57382f2388",
"color": "WristPink",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "MagicalGirl_Wrist_Orange_Swatch",
"swatch_guid": "xpkEQ6yjhU-WfFPSjvPdHg",
"color": "Wrist_Orange",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "ZtCqMnFTQUWv2h0MfZ4m6A"
},
{
"swatch_name": "FighterPilot_Black_Swatch",
"swatch_guid": "8c74ee27-3d56-401b-8a8c-7868a80770e8",
"color": "Black",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
},
{
"swatch_name": "MagicalGirl_Hat_Blue_Swatch",
"swatch_guid": "lhfOKGDAgEKWucerCmH7Fg",
"color": "Hat_Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "mG60EPIQIkaJNgOkonVmZg"
},
{
"swatch_name": "Windbreaker_Yellow_Swatch",
"swatch_guid": "C3ll7uPfUkeZ1QLX80_BpQ",
"color": "Yellow",
"item_name": "(Wrist_80sWindbreaker)",
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_Red_Swatch",
"swatch_guid": "6t2rCb6WWEW6pJbcjsAFCw",
"color": "Red",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "Hat_Baseball_Logo_Ramen_Swatch",
"swatch_guid": "cpTKHXjj0kulivZCKVQqiA",
"color": "Logo_Ramen",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "YellowBlueDress_White_Swatch",
"swatch_guid": "bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a",
"color": "White",
"item_name": "(YellowBlueDress)",
"item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Headphones_Orange_Swatch",
"swatch_guid": "JWBK5v78Y0uRVEXlD-Qk6Q",
"color": "Orange",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "TennisSkirt_Yellow_Swatch",
"swatch_guid": "c5deba2a-6e35-4b13-8e94-8ba5457f39df",
"color": "Yellow",
"item_name": "(TennisSkirt)",
"item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60",
"item_outfit_type": 101,
"mask_guid": "b75ef67d-00c3-4ac1-9b72-212032460294"
},
{
"swatch_name": "MagicalGirl_Red_Swatch",
"swatch_guid": "FE7_gQNBt0CXzMN6di5HWA",
"color": "Red",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "BowlingShirt_Flames_Green_Swatch",
"swatch_guid": "M4765UHzsUeNTwH-Pcxp1g",
"color": "Shirt_Flames_Green",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "5RUG6i2Mx0-55IO-IlhUdQ"
},
{
"swatch_name": "Smuggler_Grey_Swatch",
"swatch_guid": "90869e86-cbc5-4024-9d08-fcdc41c64eb5",
"color": "Grey",
"item_name": "(Wrist_Golfer)",
"item_guid": "eada710d-ea0c-473f-b9c6-23e210607b24",
"item_outfit_type": 200,
"mask_guid": ""
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "Luchador_Shirt_Tiger_Swatch",
"swatch_guid": "-_ViYCHHRUip7oN_AnH0Og",
"color": "Tiger",
"item_name": "(Hat_Luchador)",
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BountyHunterCape_Shoulder_Green_Swatch",
"swatch_guid": "DobTmYhC7EigHmvYnCTm3w",
"color": "Green",
"item_name": "(Neck_BountyHunterCape)",
"item_guid": "e773fddc-ad9f-408c-82c5-2637f75b3214",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Flowers_Hat_Rainbow_Swatch",
"swatch_guid": "eVl7mLA1VkmnR81YLF-ZNg",
"color": "Rainbow",
"item_name": "(Hat_Flowers)",
"item_guid": "2b2eb9e5-d52e-4004-bdb2-0bcf6910992d",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Samurai_Hat_Red_Swatch",
"swatch_guid": "PIg3ywG2J0CiGUPpm1A6Kw",
"color": "Red",
"item_name": "(Hat_Samurai)",
"item_guid": "5fb2ea11-cd9c-45cb-be1e-e7ff3e0fd3c3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scarecrow_Hat_Contest_Swatch",
"swatch_guid": "xCpdHx1iM06Gg0tYoGv0DA",
"color": "Contest",
"item_name": "(Hat_Scarecrow)",
"item_guid": "d960b47d-f8bd-4c2e-813c-a6a09876ea18",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Chef_Shirt_Red_Swatch",
"swatch_guid": "VDn1hO6ahky0GDJt5dRmyg",
"color": "Red",
"item_name": "(Shirt_Chef)",
"item_guid": "ff5c62b3-4f41-4acd-bfd8-8db00de4f36c",
"swatch_name": "ArcherBlack_Swatch",
"swatch_guid": "9ad37173-e640-4b9e-a474-716d482b73d2",
"color": "Black",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Shirt_VerticalStripe_Swatch",
"swatch_guid": "c2a91a10-2f5b-4ab0-a79f-51408cf4b047",
"color": "VerticalStripe",
"swatch_name": "LaserTag_Cyan_Swatch",
"swatch_guid": "08b7c12d-3317-4d27-9b40-34737a709454",
"color": "Cyan",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BlazerDark_Swatch",
"swatch_guid": "05ac07e1-67f0-486c-abf5-a62866475abb",
"color": "Dark",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "Letterman_Wrist_Purple_Swatch",
"swatch_guid": "MS7EPNLJEEyMLC6_yHQYtA",
"color": "Wrist_Purple",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": "4EHIyZkOOEqnM-PuD_a5xw"
},
{
"swatch_name": "Letterman_Red_Swatch",
"swatch_guid": "VpqHNpPiY0CfRJEjRRM-mw",
"color": "Red",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "TrackSuit_Pink_Swatch",
"swatch_guid": "026ca50e-3a81-43cf-87f5-950687b7bbdd",
"color": "Pink",
"item_name": "(TrackSuit)",
"item_guid": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba",
"item_outfit_type": 101,
"mask_guid": "adae82eb-a4b9-4541-acf2-76cea839a593"
},
{
"swatch_name": "Witch_Contest_Hat_Swatch",
"swatch_guid": "PcUZrdKXhk2XHa5Gy0YnnA",
"color": "Contest_Hat",
"item_name": "(Shirt_Witch)",
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "QuiltedCoat_Orange_Swatch",
"swatch_guid": "-HYrY2A5Tk2UWMi4_hmYbg",
"color": "Orange",
"item_name": "(Shirt_Quiltedcoat)",
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CowboyWhite_Swatch",
"swatch_guid": "teVKVbVEsEyqOSuRdqHQzA",
"color": "White",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "Aviator_Hat_Tan_Swatch",
"swatch_guid": "c6cf096c-f0cb-4500-80a0-bc2916b5a426",
"color": "Hat_Tan",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": "ff5ae41e-e446-4a96-8c11-49d85556fa81"
},
{
"swatch_name": "Beekeeper_Shirt_Lavender_Swatch",
"swatch_guid": "lBe3yxuE40eBmTrnEHBqQQ",
"color": "Shirt_Lavender",
"item_name": "(Shirt_Beekeeper)",
"item_guid": "1ebbfdfc-f72b-4d70-99d4-4d527f896aa7",
"item_outfit_type": 101,
"mask_guid": "afd88a79-7f03-40f6-bcb4-c247dd86edc4"
},
{
"swatch_name": "PeaCoat_Tan_Swatch",
"swatch_guid": "PwsLxCnzOkSW-RGz3tNb1w",
"color": "Tan",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": "AZnPWCN41kmNwFwDqWGFWQ"
},
{
"swatch_name": "Baseball_Green_Swatch",
"swatch_guid": "e5de2e45-207c-46af-9a5e-eb01a0c621fe",
"color": "Green",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Shirt_SkiBuddy_Blue_Swatch",
"swatch_guid": "fXNkMWFMV0aXalK_y_8SHw",
"color": "Blue",
"item_name": "(Shirt_SkiBuddy)",
"item_guid": "6RhwWExXekaYpK0NGHSXGQ",
"item_outfit_type": 101,
"mask_guid": "HQQWccdyakaEmntPhvC6CQ"
},
{
"swatch_name": "BowlingShirt_Diamonds_Purple_Swatch",
"swatch_guid": "yrQCLXiqRUSwnuQRYX2Rsw",
"color": "Shirt_Diamonds_Purple",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "VO_3cgqQqUOTz30TVUxrFA"
},
{
"swatch_name": "Dodgeball_Gray_Swatch",
"swatch_guid": "0ba92db2-690a-44ce-92f4-130e9503b6c1",
"color": "Gray",
"item_name": "(Shirt_Dodgeball)",
"item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59",
"item_outfit_type": 101,
"mask_guid": "944d934d-5e21-42d7-8196-fff9a81ee469"
},
{
"swatch_name": "Aviator_Gloves_Black_Swatch",
"swatch_guid": "97bbb384-b37e-4ea1-902c-88fbd6854ab5",
"color": "Gloves_Black",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": "5659ce2c-9cb0-49da-8c39-4b7df0f8deec"
},
{
"swatch_name": "Hoodie_Gray_Swatch",
"swatch_guid": "JTgXKeT7w0K2Mw4Jk2HCqg",
"color": "Gray",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Headphones_Blue_Swatch",
"swatch_guid": "8ANbOhNnO0yLQOCIBsDwfg",
"color": "Blue",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Scallywag_Eye_SRank_Swatch",
"swatch_guid": "clFrpYWFQEmDkE14f3o_Ig",
"color": "Eye_SRank",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": "DE1C-RJkuEWIvpR0q1i42g"
},
{
"swatch_name": "Winter_Hat_Red_Swatch",
"swatch_guid": "6vjA_vHq70Gclypll_GS_A",
"color": "Hat_Red",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "nalaCGqtL0SFA4Itwqmp8g"
},
{
"swatch_name": "DeepSeaDiver_Black_Swatch",
"swatch_guid": "CahBzgN3D0y46AQsvGC14A",
"color": "Black",
"item_name": "(Hat_DeepSeaDiver)",
"item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Wizard_White_Swatch",
"swatch_guid": "d3a9b576-5433-4305-bd96-b08820c09212",
"color": "White",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Wrist_Grillmaster_Black_Swatch",
"swatch_guid": "hZKPr4sg10SRj9DLuEgOrw",
"color": "Black",
"item_name": "(Wrist_Grillmaster)",
"item_guid": "q7pSsRMNHkqEJmYHuhhG6g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Green_Swatch",
"swatch_guid": "ytmSzjhCr0Sm58MSNbwJUA",
"color": "Green",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Hat_White_Swatch",
"swatch_guid": "zlqLrGEa8E2WW9ey_szREA",
"color": "Hat_White",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "Hoodie_Blue_Swatch",
"swatch_guid": "u_Gzj9Nro0WS48xKnH9few",
"color": "Blue",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Ranger_Shirt_Green_Swatch",
"swatch_guid": "b415fe60-3e9d-4f4d-a53a-ca15eac6af7d",
"color": "Shirt_Green",
"item_name": "(Hat_Ranger)",
"item_guid": "f277af6a-0807-4716-8948-1bee236ffb74",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Shirt_TutorialGown2_Orange_Swatch",
"swatch_guid": "L333w3dfIEWDbtt1Yn1gBg",
"color": "Orange",
"item_name": "(Shirt_TutorialGown_2)",
"item_guid": "MJQiQ3LATEOymxCC8wfSjw",
"item_outfit_type": 101,
"mask_guid": "lSDrKR69rkSFULy9GA__1w"
},
{
"swatch_name": "ElectricianTorso_Yellow_Swatch",
"swatch_guid": "b539777f-48b2-44a2-9d63-17ac703e4d57",
"color": "Torso_Yellow",
"item_name": "(Hat_Electrician)",
"item_guid": "34654303-9760-447f-8614-1c26506db994",
"item_outfit_type": 0,
"mask_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a"
},
{
"swatch_name": "QueenCrown_Black_Swatch",
"swatch_guid": "rmq1AOQvtUGOHvbPUximwg",
"color": "Black",
"item_name": "(Hat_QueenCrown)",
"item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9",
"item_outfit_type": 0,
"mask_guid": "XFuSiGupZ02Fut1Dxum_Iw"
},
{
"swatch_name": "Shirt_VampireHunter_Midnight_Swatch",
"swatch_guid": "CavRDqIlmUeTZBtCLn1hZQ",
"color": "Midnight",
"item_name": "(Shirt_VampireHunter)",
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
"item_outfit_type": 101,
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
},
{
"swatch_name": "Jazzercise_Black_Swatch",
"swatch_guid": "3b2cfc85-4e2a-4e0d-b738-c296ae8692f8",
"color": "Black",
"item_name": "(Hair_Jazzercise)",
"item_guid": "49397e16-1027-4286-9bc4-b59eae565ff0",
"item_outfit_type": 2,
"mask_guid": ""
},
{
"swatch_name": "FieldHockey_Blue_Swatch",
"swatch_guid": "7b0d6661-e856-4ec1-ab90-d89c28a70826",
"color": "Blue",
"item_name": "(FieldHockey)",
"item_guid": "a986ff33-9e16-4219-8ebb-2e7b4c772ca7",
"item_outfit_type": 101,
"mask_guid": "1fede91a-434e-49a5-882e-e6db107112a8"
},
{
"swatch_name": "ArcherTan_Glove_Swatch",
"swatch_guid": "d5987fee-e3cc-4336-853a-5df57f73df05",
"color": "Tan_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FlowerSwimsuit_Green_Swatch",
"swatch_guid": "cf515c87-3c0a-4443-9f63-5ea1bdbaa61a",
"color": "Green",
"item_name": "(FlowerSwimsuit)",
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
"item_outfit_type": 101,
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
},
{
"swatch_name": "Letterman_Purple_Swatch",
"swatch_guid": "iufpnteVCEiiZu_m-yPovQ",
"color": "Purple",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Beekeeper_Shirt_Gold_Swatch",
"swatch_guid": "1919d319-6cce-4500-8766-15fed6a13fa8",
"color": "Shirt_Gold",
"item_name": "(Shirt_Beekeeper)",
"item_guid": "1ebbfdfc-f72b-4d70-99d4-4d527f896aa7",
"item_outfit_type": 101,
"mask_guid": "afd88a79-7f03-40f6-bcb4-c247dd86edc4"
},
{
"swatch_name": "Angler_Blue_Swatch",
"swatch_guid": "6e0f3af8-297f-4ea7-8f9e-4a1bc57d3e35",
"color": "Blue",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Hat_CatEars_White_Swatch",
"swatch_guid": "c1e430c5-fd8d-4032-9e3f-8d87cc8784da",
"color": "White",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
},
{
"swatch_name": "Smuggler_Dark_Swatch",
"swatch_guid": "e821aa74-2d47-4388-bcfb-1893b8a83a52",
"color": "Dark",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "ScoutSkirt_Shirt_Red_Swatch",
"swatch_guid": "2bb65868-ee12-4871-b50d-64bbc9066f0b",
"color": "Shirt_Red",
"item_name": "(Shirt_ScoutSkirt)",
"item_guid": "35f60d27-22cf-4e17-8f39-c9e3a9fa2291",
"item_outfit_type": 101,
"mask_guid": "e65530f7-543c-43e9-bf12-b6cda2bc755c"
},
{
"swatch_name": "Artist_Blue_Swatch",
"swatch_guid": "8c94e45c-6605-4b4f-9ba4-2c6c6e557228",
"color": "Blue",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hat_CatEars_Gold_Swatch",
"swatch_guid": "37f8378b-b98c-477a-b8c9-9340772800b5",
"color": "Gold",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
},
{
"swatch_name": "LaserTagVisor_Green_Swatch",
"swatch_guid": "-e6bmXnwZU2EVwZ8RupKbg",
"color": "Visor_Green",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Headphones_TwoBlue_Swatch",
"swatch_guid": "4scG_UBlzE-5DWRhSrtm6g",
"color": "TwoBlue",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "CollarShirt_Torso_Red_Swatch",
"swatch_guid": "FAviMCQ_EE2Mpt6QPo5OEw",
"color": "Torso_Red",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Lacrosse_Shirt_Stripe_Purple_Swatch",
"swatch_guid": "da36bbcc-2a9a-4ba2-9e4a-6f52c9b1ef74",
"swatch_name": "Pearls_Neck_Black_Swatch",
"swatch_guid": "sqPMH0LhLkeG-4XkxpU-sw",
"color": "Neck_Black",
"item_name": "(Neck_Pearls)",
"item_guid": "7be2eafc-21ea-4266-ae64-8888c06798d6",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "ArcherYellow_Glove_Swatch",
"swatch_guid": "47da86b9-4e6e-423c-b8c4-ea6bf8472509",
"color": "Yellow_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Neck_Orange_Swatch",
"swatch_guid": "S9uymuvraUCOM8gmB7bnEA",
"color": "Neck_Orange",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "M5z2wk-GDkOAjvZzqsfcNQ"
},
{
"swatch_name": "TruckerJacket_Tan_Swatch",
"swatch_guid": "o2WfRyjJy0CKoUg8YIxNjQ",
"color": "Tan",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "HockeyShirt_Red_Swatch",
"swatch_guid": "b1ea158d-1663-4db1-bb09-9847f6e9d1e6",
"color": "Red",
"item_name": "(HockeyShirt)",
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
"item_outfit_type": 101,
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
},
{
"swatch_name": "Hat_SeaCaptain_Contest_Swatch",
"swatch_guid": "hhs9GY3XZkWb-8TXODvo6A",
"color": "Contest",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "YellowBlueDress_Purple_Swatch",
"swatch_guid": "830be2fa-60a5-48cc-931f-34b670eae4bd",
"color": "Purple",
"item_name": "(YellowBlueDress)",
"item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_Green_Swatch",
"swatch_guid": "3frH6KxZ9kGUYkjeCDpylQ",
"color": "Green",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "Hat_Baseball_Logo_Bucketeers_Swatch",
"swatch_guid": "5iCrYb8MBkqN6IBEe3IipQ",
"color": "Logo_Bucketeers",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BowlingShirt_OneStripe_Pink_Swatch",
"swatch_guid": "-k9_-2onrEG407or8t1RFw",
"color": "Shirt_OneStripe_Pink",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "b-IM3eYMJUa40395O5XeLg"
},
{
"swatch_name": "LaserTagGlove_Orange_Swatch",
"swatch_guid": "EC0ttvk6eU2HEMacLAx2Iw",
"color": "Orange",
"item_name": "(Wrist_LaserTagGlove)",
"item_guid": "3044adce-90c0-4f66-81f5-39c4fca86fdf",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "50sDress_Red_Swatch",
"swatch_guid": "Yr0mF1EWzEaIyRMOWFiY6w",
"color": "Red",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "Firefighter_Hat_Yellow_Swatch",
"swatch_guid": "4c26d1e7-2382-4478-b9c4-a068ed01a607",
"color": "Hat_Yellow",
"item_name": "(Hat_Firefighter)",
"item_guid": "bb54e0e9-5d90-41f3-94c0-8e82f1791af1",
"item_outfit_type": 0,
"mask_guid": "c8ccb85e-52cd-4b3b-aadf-289819067125"
},
{
"swatch_name": "TopHat_White_Swatch",
"swatch_guid": "DzgN67HoikexE2rTlZLdYQ",
"color": "White",
"item_name": "(Hat_TopHat)",
"item_guid": "102c625b-b988-4bf8-a2aa-a31ad7029cdc",
"item_outfit_type": 0,
"mask_guid": "ccf1ccc1-e229-4157-bb74-f2cdef01e547"
},
{
"swatch_name": "DeepSeaDiver_Beige_Swatch",
"swatch_guid": "Su7kiW24d0KapNns96JoFQ",
"color": "Beige",
"item_name": "(Hat_DeepSeaDiver)",
"item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scallywag_Bracelet_Red_Swatch",
"swatch_guid": "62bd5dda-08da-4528-83c1-f0ca3812dc6e",
"color": "Red",
"item_name": "(Wrist_Scallywag_Bracelet)",
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "QuiltedCoat_Green_Swatch",
"swatch_guid": "QBglgj80zkuvUkk55Y5Feg",
"color": "Green",
"item_name": "(Shirt_Quiltedcoat)",
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scout_RaccoonHat_Gray_Swatch",
"swatch_guid": "926ae943-76cb-46dd-9c66-a6ee44ea9194",
"color": "nHat_Gray",
"item_name": "(Hat_ScoutRaccoon)",
"item_guid": "7c1552a6-bc36-46e6-884c-1de6b8111a92",
"item_outfit_type": 0,
"mask_guid": "f1cd10fb-3d0b-436f-a173-65bc0d88d8bf"
},
{
"swatch_name": "Shirt_GraduationGown_Green_Swatch",
"swatch_guid": "iZy7RmfAz0603oMO-jD-WA",
"color": "Green",
"item_name": "(Shirt_GraduationGown)",
"item_guid": "U_H976qYRUmg9e2clsAflg",
"item_outfit_type": 101,
"mask_guid": "ica0LZyM_kOox8gxW7CaMw"
},
{
"swatch_name": "WaitressApron_Mustard_Swatch",
"swatch_guid": "b7oobDcDtE-69fhM-5g5AQ",
"color": "Mustard",
"item_name": "(Belt_WaitressApron)",
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "DrumMajor_Orange_Swatch",
"swatch_guid": "82de1ca3-67d6-454d-84fb-0eeece71fcbc",
"color": "Orange",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "JackfrostWings_Neck_Lilac_Swatch",
"swatch_guid": "H6fcuLxeBUSY3oERFc-plA",
"color": "Neck_Lilac",
"item_name": "(Neck_JackFrostWings)",
"item_guid": "022ce375-ed02-479d-b87d-9d72d126bbd4",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "PurpleJacket_Blue_Swatch",
"swatch_guid": "b42d903b-b393-4f49-af5e-38d0fb6c42b0",
"color": "Blue",
"item_name": "(PurpleJacket)",
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
"item_outfit_type": 101,
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
},
{
"swatch_name": "CowboyBlack_Swatch",
"swatch_guid": "K5vPqD3BrEuODRDzFdC6Hw",
"color": "Black",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "Harness_Teal_Swatch",
"swatch_guid": "TnEDe1QoMUCUdqPL_7CxTA",
"color": "Teal",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Angler_Tan_Swatch",
"swatch_guid": "cac27853-e2df-4d77-b3fd-af3bc0813f01",
"color": "Tan",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "50sDress_Pink_Swatch",
"swatch_guid": "eb3SmJTIh0628LrZUkI4KQ",
"color": "Pink",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "Scout_Sash_Brown_Swatch",
"swatch_guid": "639f5e2c-6fbd-4ab4-aa10-7772e0932798",
"color": "h_Brown",
"item_name": "(Neck_ScoutSash)",
"item_guid": "bf9dc196-5727-4752-8126-7ad5fcd96d55",
"item_outfit_type": 100,
"mask_guid": "66cc536d-8cb5-4262-b7f0-49a33b97ea10"
},
{
"swatch_name": "Unicorn_Pastel_Swatch",
"swatch_guid": "FNe9LkWJMUeC3frYJMyEaQ",
"color": "Pastel",
"item_name": "(Hat_Unicorn)",
"item_guid": "2caHvVQ1vECrPv-YcdxdyA",
"item_outfit_type": 0,
"mask_guid": "nW_fBsuZA0OfIjTMXm6ZCA"
},
{
"swatch_name": "Countess_Black_Swatch",
"swatch_guid": "i5RKLo-RbEqisnGII1wpaQ",
"color": "Black",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Smuggler_Cherry_Swatch",
"swatch_guid": "fed760b2-15e2-46b2-b1e3-cc3c8d780d5d",
"color": "Cherry",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "Astronaut_Torso_Orange_Swatch",
"swatch_guid": "9GhnX4Pj4E-wxbPlD8GB-g",
"color": "o_Orange",
"item_name": "(AstronautTorso)",
"item_guid": "ec5255d5-edaf-4888-9fe1-e89ac0e0d300",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "AnglerHat_Gray_Swatch",
"swatch_guid": "225209dc-3afc-40cf-858e-89f7afa00c0d",
"color": "Hat_Gray",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": "efeb07ab-6253-436d-bb66-991ddad72e58"
},
{
"swatch_name": "BaseballHelmet_Green_Swatch",
"swatch_guid": "9967ce54-c2b7-437a-946e-11f8ee6d6fdd",
"color": "Green",
"item_name": "(Hat_BaseballHelmet)",
"item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12",
"item_outfit_type": 0,
"mask_guid": "FVdDQFOMQ06teQm2ANDPng"
},
{
"swatch_name": "Neck_Grillmaster_White_Swatch",
"swatch_guid": "Lxx3q6JRnUW_cPG_kGG4ag",
"color": "White",
"item_name": "(Neck_Grillmaster)",
"item_guid": "tTKv7eRODka2KC_hsY2O3Q",
"item_outfit_type": 100,
"mask_guid": "t-ZEWJCgFEyGP2PjNblQMA"
},
{
"swatch_name": "50sDress_Teal_Swatch",
"swatch_guid": "QzjakcO8tUaPeiI5KBLKtg",
"color": "Teal",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "BowlingShirt_Diamonds_Teal_Swatch",
"swatch_guid": "yZR5aX3gQ0WpRUT6cfK1cA",
"color": "Shirt_Diamonds_Teal",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "VO_3cgqQqUOTz30TVUxrFA"
},
{
"swatch_name": "Scallywag_Bracelet_Blue_Swatch",
"swatch_guid": "cdca593a-8840-4140-9145-fe962732040c",
"color": "Blue",
"item_name": "(Wrist_Scallywag_Bracelet)",
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Harness_Black_Swatch",
"swatch_guid": "AC3zfm38JEyBZWuXDh4Ukg",
"color": "Black",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CowboyDark_Swatch",
"swatch_guid": "192ba73d-6990-42da-8fe0-914c2a7181eb",
"color": "Dark",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "SodaJerk_Mustard_Swatch",
"swatch_guid": "MxnAq_XOw0uTzQ8IIxqb_Q",
"color": "Mustard",
"item_name": "(Shirt_SodaJerk)",
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FlowerSwimsuit_Pink_Swatch",
"swatch_guid": "27680277-aec1-41be-a476-9f51cf775580",
"color": "Pink",
"item_name": "(FlowerSwimsuit)",
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
"item_outfit_type": 101,
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
},
{
"swatch_name": "Artist_Red_Swatch",
"swatch_guid": "17b16982-871c-476a-860f-f6953c89ee8c",
"color": "Red",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Pink_Swatch",
"swatch_guid": "TEgugZRs80iJbhL87RdYwg",
"color": "Pink",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "50sDress_White_Swatch",
"swatch_guid": "BLgkgLeS-EyazgPQxGcavg",
"color": "White",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "Hat_TutorialCap_3_Swatch",
"swatch_guid": "wHJnbDOo8ESmUbQDr0k31w",
"color": "3",
"item_name": "(Hat_TutorialCap)",
"item_guid": "M9weaekvIUimiJSVJqZl-w",
"item_outfit_type": 0,
"mask_guid": "GV8z2dlqGEqIAYaGrF1ZYQ"
},
{
"swatch_name": "Scout_Sash_Red_Swatch",
"swatch_guid": "b520f3bc-599a-498c-8a4f-97cc25aa741f",
"color": "h_Red",
"item_name": "(Neck_ScoutSash)",
"item_guid": "bf9dc196-5727-4752-8126-7ad5fcd96d55",
"item_outfit_type": 100,
"mask_guid": "66cc536d-8cb5-4262-b7f0-49a33b97ea10"
},
{
"swatch_name": "DrumMajor_Green_Swatch",
"swatch_guid": "fe5288bc-0e02-4ea3-b17d-dee0e576979d",
"color": "Green",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Hat_SRank_Swatch",
"swatch_guid": "jQwNBXyoU0Gk4FyNw6jFxg",
"color": "Hat_SRank",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "50sDress_CandyCane_Swatch",
"swatch_guid": "PEGqjoxP6E2yRx_A-HphrQ",
"color": "CandyCane",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "SpaceMarine_Cyan_Swatch",
"swatch_guid": "c8cc9589-4831-4e73-a865-ef8c139a2ba7",
"color": "Cyan",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "MagicalGirl_Green_Swatch",
"swatch_guid": "kTP3xKhzBkuQF3SqvmYFKA",
"color": "Green",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "FootballPads_Orange_Swatch",
"swatch_guid": "32dde6dc-e3dc-43ee-9b7c-987bc6778c17",
"color": "Pads_Orange",
"item_name": "(Hat_FootBall)",
"item_guid": "5a38f864-7d1c-4989-8558-40b9668f78ef",
"item_outfit_type": 0,
"mask_guid": "e17481fc-b2b6-47a5-91e5-7205f45a3247"
},
{
"swatch_name": "HeadBackBow_White_Swatch",
"swatch_guid": "-Iqmonry4EKoqAULCKzRPA",
"color": "White",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "Hoodie_Orange_Swatch",
"swatch_guid": "rNhOQoP3XkOBHV28ze2NpA",
"color": "Orange",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Yellow_Swatch",
"swatch_guid": "dyEaPloE8kazWC7Zblv8uQ",
"color": "Yellow",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Hat_Red_Swatch",
"swatch_guid": "17040a88-87f1-4613-b439-3316e33dbd48",
"color": "Hat_Red",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_Blue_Swatch",
"swatch_guid": "Lf4FjROSAEqXedFxpXFQfg",
"color": "Blue",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "PurpleJacket_Black_Swatch",
"swatch_guid": "8cab8cc6-9a2d-434c-8b72-e66edbdd223c",
"color": "Black",
"item_name": "(PurpleJacket)",
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
"item_outfit_type": 101,
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
},
{
"swatch_name": "Mobster_Valentine_Swatch",
"swatch_guid": "4VkPVbBVLE-eJHilwGMAbQ",
"color": "Valentine",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Hat_SeaCaptainFushia_Swatch",
"swatch_guid": "2738ab5f-8c94-46b3-bac6-4a8111c41c21",
"color": "Fushia",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "Luchador_Red_Swatch",
"swatch_guid": "916384ca-e209-42a0-bd64-c64f5a40b56f",
"color": "Red",
"item_name": "(Hat_Luchador)",
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scarecrow_Wrist_Contest_Swatch",
"swatch_guid": "dro7VLV8BkGjqpPAbJheYA",
"color": "Wrist_Contest",
"item_name": "(Shirt_Scarecrow)",
"item_guid": "1fcaf4fd-9012-407e-b691-fbc3ccf22421",
"item_outfit_type": 101,
"mask_guid": "bNA44H2wzUy-FkWgyOq1Kw"
},
{
"swatch_name": "FlapperGlove_Gold_Swatch",
"swatch_guid": "2cf2b644-3fa6-449c-9fe8-d0ab917f3106",
"color": "Gold",
"item_name": "(Wrist_FlapperGlove)",
"item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde",
"item_outfit_type": 200,
"mask_guid": "0e42108e-3b08-49b4-86c0-7984719e8380"
},
{
"swatch_name": "BlueJacket_Mint_Swatch",
"swatch_guid": "cd5d7285-202d-42d0-b93f-04245875793e",
"color": "Mint",
"item_name": "(BlueJacket)",
"item_guid": "62ce4109-8dee-4895-bf1b-bfa143db4c7e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "HeadBackBow_SolidBlack_Swatch",
"swatch_guid": "f_c50saStkaGJuhLjJ9lfA",
"color": "SolidBlack",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "FootballPads_Dark_Swatch",
"swatch_guid": "88c3ed05-ce41-4a9d-a4a7-8a428d240b3f",
"color": "Pads_Dark",
"item_name": "(Hat_FootBall)",
"item_guid": "5a38f864-7d1c-4989-8558-40b9668f78ef",
"item_outfit_type": 0,
"mask_guid": "e17481fc-b2b6-47a5-91e5-7205f45a3247"
},
{
"swatch_name": "MagicalGirl_Neck_Red_Swatch",
"swatch_guid": "_uh3kj6hKUyBKOSR3R8dog",
"color": "Neck_Red",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "M5z2wk-GDkOAjvZzqsfcNQ"
},
{
"swatch_name": "Aviator_Hat_Black_Swatch",
"swatch_guid": "a8de80d3-286a-4fdc-bed2-75d52f8c0964",
"color": "Hat_Black",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": "ff5ae41e-e446-4a96-8c11-49d85556fa81"
},
{
"swatch_name": "Aviator_Goggles_Black_Swatch",
"swatch_guid": "7b82c3c5-fd1f-45ec-b83f-e32668aa8dce",
"color": "s_Black",
"item_name": "(Eye_AviatorGoggles)",
"item_guid": "47629ed1-5378-427c-ad61-4b44d2fe90cc",
"item_outfit_type": 10,
"mask_guid": "556f876a-70da-4104-b792-fca8ad67be0c"
},
{
"swatch_name": "Mobster_Fashion_Gold_Swatch",
"swatch_guid": "46970903-9799-4d25-b90a-7162c1f5dbef",
"color": "Fashion_Gold",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArcherSRank_Glove_Swatch",
"swatch_guid": "1VGZ1QCvcU6rsKVkUDBNjQ",
"color": "SRank_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Shirt_Grey_Swatch",
"swatch_guid": "4c845dbb-26c3-48a1-b562-e28a7416b154",
"color": "Shirt_Grey",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "6ce21c65-f4e9-41ac-be94-8eb1a08d5c76"
},
{
"swatch_name": "Hat_SeaCaptainDarkGold_Swatch",
"swatch_guid": "f5403c89-9769-479b-8a05-c71607f9b189",
"color": "DarkGold",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "CollarShirt_Torso_Blue_Swatch",
"swatch_guid": "0iSsaY-HgkmLaRHCn5vEdw",
"color": "Torso_Blue",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Letterman_Black_Swatch",
"swatch_guid": "Z1F9rjKqCE-Hd2-Ip5g3gA",
"color": "Black",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "DrumMajor_Purple_Swatch",
"swatch_guid": "54d3c589-30ba-44c1-a77c-8f9e16eb0b20",
"color": "Purple",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Belt_Red_Swatch",
"swatch_guid": "0f71b9a2-dd88-435c-a571-4503d12dcf2d",
"color": "Belt_Red",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": "9b2102f0-e16b-464f-9f1c-f068de52bb89"
},
{
"swatch_name": "ArcherBlack_Glove_Swatch",
"swatch_guid": "2be2f040-68e4-44dd-9099-29f11110f3a4",
"color": "Black_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WitchHunter_Blue_Swatch",
"swatch_guid": "aBQ3R9boek-mYeSYSNvDbQ",
"color": "Blue",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Sherlock_Hat_Gray_Swatch",
"swatch_guid": "MQOxmUk-mUG-Ee9m9nX31Q",
"color": "Hat_Gray",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": "axPBGoqorEe77Oqqjfzb5w"
},
{
"swatch_name": "Steampunk_Contest_Swatch",
"swatch_guid": "fD5vtKxBWECH54Jj4FbnUg",
"color": "Contest",
"item_name": "(Shirt_SteamPunk)",
"item_guid": "E9VTiIVFPEa-OuQRto0e_Q",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Purple_Swatch",
"swatch_guid": "mvMeyStxGUGlNLSYNeSubQ",
"color": "Purple",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FighterPilot_Blue_Swatch",
"swatch_guid": "e37b07d1-6b9d-49b9-94ca-1c728e228cfe",
"color": "Blue",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
},
{
"swatch_name": "AnglerHat_Tan_Swatch",
"swatch_guid": "3f6c8bd2-2c1d-4c8c-a9de-16600fee9645",
"color": "Hat_Tan",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": "efeb07ab-6253-436d-bb66-991ddad72e58"
},
{
"swatch_name": "Golfer_Glove_Blue_Swatch",
"swatch_guid": "32a5e42d-2b60-40b1-a145-ed96fc91af8e",
"color": "Glove_Blue",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "f4cea991-fc3d-4715-bf82-8a8fb60e8799"
},
{
"swatch_name": "Steampunk_Femme_Contest_Hat_Swatch",
"swatch_guid": "vuSwk04zykCe3ANKN838OA",
"color": "e_Contest_Hat",
"item_name": "(Hat_SteampunkFemme)",
"item_guid": "JWanxvOHyESg9F_HlmTOfA",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "GoldMedal_Bronze_Swatch",
"swatch_guid": "iaDL2im4lkCXUo0NWM-F3g",
"color": "Bronze",
"item_name": "(GoldMedal)",
"item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "DrumMajorGlove_Purple_Swatch",
"swatch_guid": "8053b5e5-0f67-4329-a45d-1ee9fc830820",
"color": "Purple",
"item_name": "(Wrist_DrumMajor_Glove)",
"item_guid": "4d533b3a-b5fa-446a-842d-92798ad5b493",
"item_outfit_type": 200,
"mask_guid": "6b38ab79-142b-446e-978c-ad829ade8823"
},
{
"swatch_name": "50sDress_Halloween_Swatch",
"swatch_guid": "ZvPidQ57rUS50Qz89ERmLw",
"color": "Halloween",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "TruckerOveralls_Red_Swatch",
"swatch_guid": "iokdlBfjuEuAwqIMOQwxdA",
"color": "Red",
"item_name": "(Shirt_TruckerOveralls)",
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ElectricianTorso_Green_Swatch",
"swatch_guid": "67fc9269-8dd8-4d6f-b594-c77e716f2482",
"color": "Torso_Green",
"item_name": "(Hat_Electrician)",
"item_guid": "34654303-9760-447f-8614-1c26506db994",
"item_outfit_type": 0,
"mask_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a"
},
{
"swatch_name": "QuiltedCoat_Plum_Swatch",
"swatch_guid": "IjRMopsYlUqSE6327I9TIQ",
"color": "Plum",
"item_name": "(Shirt_Quiltedcoat)",
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WitchHunter_SRank_Swatch",
"swatch_guid": "Tbrc2MlSlE6mKua8qSoluQ",
"color": "SRank",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_RebelRose_Swatch",
"swatch_guid": "4Eck_dNL1UKlIZWsA1YppQ",
"color": "RebelRose",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "GoldMedal_Silver_Swatch",
"swatch_guid": "ERnUPsyT6kuEnIRZF8zPwg",
"color": "Silver",
"item_name": "(GoldMedal)",
"item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Turtleneck_White_Swatch",
"swatch_guid": "Zz4XatKfUEapReRAQHxKKA",
"color": "White",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Sherlock_Torso_Black_Swatch",
"swatch_guid": "Z1Ib9r_fkUqgoZk0uid_Xw",
"color": "Torso_Black",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": "9ZP617DQk0mZ10J3d5q9oA"
},
{
"swatch_name": "DrumMajorGlove_Orange_Swatch",
"swatch_guid": "b444ed0a-eab5-4bd6-af34-d080de74a149",
"color": "Orange",
"item_name": "(Wrist_DrumMajor_Glove)",
"item_guid": "4d533b3a-b5fa-446a-842d-92798ad5b493",
"item_outfit_type": 200,
"mask_guid": "6b38ab79-142b-446e-978c-ad829ade8823"
},
{
"swatch_name": "LaserTagVisor_Orange_Swatch",
"swatch_guid": "oT1vLrHW6UC2hWLIT3f_SQ",
"color": "Visor_Orange",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Punk_WristRed_Swatch",
"swatch_guid": "baffdefe-de21-4551-9330-b50873965bdd",
"color": "WristRed",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "Winter_Gloves_Tree_Swatch",
"swatch_guid": "afWY5VGiF0isGn6qpo4KeQ",
"color": "Gloves_Tree",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Punk_Green_Swatch",
"swatch_guid": "0724b166-9ace-46ff-a191-b13f135b00a8",
"color": "Green",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "ArcherRed_Glove_Swatch",
"swatch_guid": "0c433086-9501-48c4-8d0e-4474eb420c61",
"color": "Red_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Lacrosse_Stripe_Purple_Swatch",
"swatch_guid": "e3454bc7-c88d-4958-8b81-db3fe5472a1e",
"color": "Stripe_Purple",
"item_name": "(Shirt_Lacrosse)",
"item_guid": "39278302-b52a-48a8-a566-ed756a5fa1a9",
@@ -3484,527 +2557,1337 @@
"mask_guid": ""
},
{
"swatch_name": "SpaghettiCowboy_Shirt_Grey_Swatch",
"swatch_guid": "Ru4HN3stVUutQ9rUh7ro1g",
"color": "Grey",
"item_name": "(Shirt_SpaghettiCowboy)",
"item_guid": "b0c8d3fe-d3de-4883-b63f-8cc8890537cb",
"swatch_name": "Hat_Baseball_Logo_Goblins_Swatch",
"swatch_guid": "pqLkLbri9Eu48eryQIFGMg",
"color": "Logo_Goblins",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Letterman_Wrist_Green_Swatch",
"swatch_guid": "UUyjVU4mvECPhI9xTMYR1Q",
"color": "Wrist_Green",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": "4EHIyZkOOEqnM-PuD_a5xw"
},
{
"swatch_name": "Winter_Torso_Tree_Swatch",
"swatch_guid": "XOBIPrKCr02xa4tKj8wN9g",
"color": "Torso_Tree",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "Too5jpb1w0iQenkjRRS-QA"
},
{
"swatch_name": "ArcherSRank_Quiver_Swatch",
"swatch_guid": "29EZ6r2SsUakWjzuevxeoA",
"color": "SRank_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "MartyRecFly_Shirt_Blue_Swatch",
"swatch_guid": "w9qG5gHlAUqh8f9ZgnezsQ",
"color": "Blue",
"item_name": "(Shirt_MartyRecFly)",
"item_guid": "74c65554-0d15-409b-b9a8-0b2620a7d7e2",
"item_outfit_type": 101,
"swatch_name": "Headphones_TwoOrange_Swatch",
"swatch_guid": "nX7krD8e2km_ZlOLWcoxdQ",
"color": "TwoOrange",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Headphones_TwoPurple_Swatch",
"swatch_guid": "OqsbZ8Xls0i8uA6ha7H3qQ",
"color": "TwoPurple",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Wizard_Green_Swatch",
"swatch_guid": "357eb160-360b-4a63-840d-65d11cc8ffc2",
"color": "Green",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scuba_Shoulder_Contest_Swatch",
"swatch_guid": "4zPm9miF5UOouEWFpOxYZg",
"color": "Contest",
"swatch_name": "MagicalGirl_Wrist_Green_Swatch",
"swatch_guid": "1FcyrVGyGEC9dmOq_51nFQ",
"color": "Wrist_Green",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "ZtCqMnFTQUWv2h0MfZ4m6A"
},
{
"swatch_name": "Scuba_Shirt_Contest_Swatch",
"swatch_guid": "J-K34UX_B0yObjQI9tn0vQ",
"color": "Shirt_Contest",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TreasureHunterBandolier_Shoulder_Black_Swatch",
"swatch_guid": "IcR9Z_MxKkS9uh4uFYuFzQ",
"color": "Black",
"item_name": "(Neck_TreasureHunterBandolier)",
"item_guid": "t_66r_j5oUSh9awIPmdUKg",
"item_outfit_type": 100,
"swatch_name": "PeaCoat_Red_Swatch",
"swatch_guid": "jXBi28cRV0CEQgKIrpD0_A",
"color": "Red",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": "AZnPWCN41kmNwFwDqWGFWQ"
},
{
"swatch_name": "Angler_Gray_Swatch",
"swatch_guid": "97938f4c-52b8-4c85-8575-856654666316",
"color": "Gray",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Winter_Hat_Red_Swatch",
"swatch_guid": "nalaCGqtL0SFA4Itwqmp8g",
"color": "Red",
"swatch_name": "80sSpandex_Yellow_Swatch",
"swatch_guid": "i1XbjBqTu0CILIuJClWimA",
"color": "Yellow",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "FootballHelmet_Dark_Swatch",
"swatch_guid": "61ff7a02-50ff-49ea-b95e-c31b95a30c8e",
"color": "Helmet_Dark",
"item_name": "(Hat_FootBall)",
"item_guid": "5a38f864-7d1c-4989-8558-40b9668f78ef",
"item_outfit_type": 0,
"mask_guid": "7660e11a-7ef0-43d3-bc02-76b3a4aab064"
},
{
"swatch_name": "LaserTagVisor_Cyan_Swatch",
"swatch_guid": "426d0592-f8e4-4bc6-9fe1-a62b91fd57d5",
"color": "Visor_Cyan",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TruckerOveralls_Green_Swatch",
"swatch_guid": "EiGgN7uVukqoRE7UJcMLTw",
"color": "Green",
"item_name": "(Shirt_TruckerOveralls)",
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Hat_Tree_Green_Swatch",
"swatch_guid": "mraJrTA4V0eARb_Kq7yYVw",
"color": "Hat_Tree_Green",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Amethyst_Swatch",
"swatch_guid": "gUxl7WNXlUqFYHZ9eGq1cg",
"color": "Amethyst",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"mask_guid": ""
"swatch_name": "Shirt_VampireHunter_Leather_Swatch",
"swatch_guid": "Z8Bfk0MTb0evZehvvgzuBA",
"color": "Leather",
"item_name": "(Shirt_VampireHunter)",
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
"item_outfit_type": 101,
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
},
{
"swatch_name": "SteampunkSatchel_Belt_BlackandSilver_Swatch",
"swatch_guid": "erPtGSgq_kmqfeSlZWw4VA",
"color": "BlackandSilver",
"item_name": "(Neck_SteampunkSatchel)",
"item_guid": "iBQ-v6xaqkipUlfzkSHEeQ",
"item_outfit_type": 102,
"mask_guid": ""
"swatch_name": "CowboyLight_Swatch",
"swatch_guid": "5e00a832-3f25-4dd3-84d2-b6ed6a4b4c37",
"color": "Light",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "FemaleTShirt_Shirt_Orange_Swatch",
"swatch_guid": "05f0ee6e-c824-470e-9178-5ed576c6fe0c",
"color": "Orange",
"item_name": "(FemaleTShirt)",
"item_guid": "8aa79563-ace1-4ba7-ad0c-f3210a78142f",
"swatch_name": "Hoodie_Red_Swatch",
"swatch_guid": "yM5xRrd9OkmDGyQddwOP6g",
"color": "Red",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BirthstoneEarring_Ear_Diamond_Swatch",
"swatch_guid": "8IK1U23XlUOV-2ibVznkDQ",
"color": "Diamond",
"item_name": "(Ear_BirthstoneEarring)",
"item_guid": "308171c3-cfd1-4ff2-8593-856e8de7071a",
"item_outfit_type": 3,
"swatch_name": "Winter_Torso_Tree_Green_Swatch",
"swatch_guid": "i33RmJJCwUem-ADkOwMNQQ",
"color": "Torso_Tree_Green",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scarecrow_Wrist_Contest_Swatch",
"swatch_guid": "bNA44H2wzUy-FkWgyOq1Kw",
"color": "Contest",
"item_name": "(Wrist_Scarecrow)",
"item_guid": "3c9acb9e-3353-4359-aa5a-ae9512b086b4",
"swatch_name": "Smuggler_Yellow_Swatch",
"swatch_guid": "002b07bb-4256-44ce-8ca4-25510e70b6b5",
"color": "Yellow",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "BlazerMidnightBlue_Swatch",
"swatch_guid": "6f2e74bf-1e95-463d-97db-d5d1a53b2c28",
"color": "MidnightBlue",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "MagicalGirl_Wrist_Blue_Swatch",
"swatch_guid": "zaSTWOUy_k6cnoVVYzypWg",
"color": "Wrist_Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "ZtCqMnFTQUWv2h0MfZ4m6A"
},
{
"swatch_name": "Hoodie_White_Swatch",
"swatch_guid": "-Nf7WM2TeEKujB637h3tRA",
"color": "White",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Turtleneck_Yellow_Swatch",
"swatch_guid": "G4HIPUjekUigiU0-Kr9mdA",
"color": "Yellow",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Mobster_WhiteHatHacker_Swatch",
"swatch_guid": "40283127-2ffc-4989-82f6-29c3381bf9e2",
"color": "WhiteHatHacker",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Witch_Hat_Green_Swatch",
"swatch_guid": "smmfK-N2R0uoDiuIHrhImg",
"color": "Green",
"item_name": "(Hat_Witch)",
"item_guid": "s6mUIqiPCk6e57UYCx3SZA",
"swatch_name": "QueenCrown_White_Swatch",
"swatch_guid": "Zljmtj5WqUaRvB3XQTgJXg",
"color": "White",
"item_name": "(Hat_QueenCrown)",
"item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9",
"item_outfit_type": 0,
"mask_guid": "XFuSiGupZ02Fut1Dxum_Iw"
},
{
"swatch_name": "Mobster_Hat_Gray_Swatch",
"swatch_guid": "390a476a-5545-48eb-955c-1bf1a16012bf",
"color": "Hat_Gray",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": "d759c88d-a159-4c26-86dd-b054bb05b3ed"
},
{
"swatch_name": "SpaceMarine_Light_Swatch",
"swatch_guid": "68273c9e-96d6-4ddb-aaab-d99229e864d5",
"color": "Light",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "50sDress_Shamrock_Swatch",
"swatch_guid": "K17dIz_TRUq5D50NBfLjyw",
"color": "Shamrock",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "MagicalGirl_Hat_Green_Swatch",
"swatch_guid": "kLv_wsbbGUmlfIUO1QLYLw",
"color": "Hat_Green",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "mG60EPIQIkaJNgOkonVmZg"
},
{
"swatch_name": "Aviator_Jacket_Black_Swatch",
"swatch_guid": "da185fbb-ad2b-4f30-87a2-491f24b78ebb",
"color": "Jacket_Black",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Bowtie_Neck_CandyCane_Swatch",
"swatch_guid": "rcuK1wZcXkCe0_LGX7E-Tw",
"color": "CandyCane",
"item_name": "(Bowtie)",
"item_guid": "83be5ba4-525a-4781-a5f6-839c22e4d1d3",
"item_outfit_type": 103,
"mask_guid": ""
"swatch_name": "RedFlowerDress_Yellow_Swatch",
"swatch_guid": "e98c3f98-7844-4d6e-a21c-05027033c18c",
"color": "Yellow",
"item_name": "(RedFlowerDress)",
"item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6",
"item_outfit_type": 101,
"mask_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2"
},
{
"swatch_name": "Baseball_Hat_Jumbotron_Swatch",
"swatch_guid": "lxmw7EbzsUuoy0roRy5McQ",
"color": "Jumbotron",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BusinessTie_Neck_Valentine_Swatch",
"swatch_guid": "9zGwmle_yk25V-KnC_PJEg",
"color": "Valentine",
"item_name": "(BusinessTie)",
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "Samurai_Belt_Gold_Swatch",
"swatch_guid": "9sSCSLRkYUCs4CCHK8FfAA",
"color": "Gold",
"item_name": "(Belt_Samurai)",
"item_guid": "76f8f9b8-0793-4d98-962c-9e6a71979540",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Thriller_Shirt_Blue_Swatch",
"swatch_guid": "6GfSjqIb4ECCvIPC8vO8_A",
"color": "Blue",
"item_name": "(Shirt_Thriller)",
"item_guid": "hTHM3Kjqy0ykqBD8UCRQJw",
"swatch_name": "CollarShirt_Torso_Pink_Swatch",
"swatch_guid": "kmj5zOjcwku_WWKroCeiVQ",
"color": "Torso_Pink",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "RoundGlasses_Eye_Tortoiseshell_Swatch",
"swatch_guid": "4J9GPTKjjUOjyLVPmJyxWg",
"color": "Tortoiseshell",
"item_name": "(Eye_RoundGlasses)",
"item_guid": "bc7299ac-be2b-404f-b8e6-402e2c0660f0",
"item_outfit_type": 10,
"mask_guid": ""
"swatch_name": "MagicalGirl_Neck_Green_Swatch",
"swatch_guid": "i3rD_hnQG0a-5f6Ve4AfWg",
"color": "Neck_Green",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "M5z2wk-GDkOAjvZzqsfcNQ"
},
{
"swatch_name": "BusinessTie_Neck_CandyCane_Swatch",
"swatch_guid": "pHw_MghIYUazSFve6L237w",
"color": "CandyCane",
"item_name": "(BusinessTie)",
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
"item_outfit_type": 103,
"mask_guid": ""
"swatch_name": "Winter_Torso_Red_Swatch",
"swatch_guid": "LDqcPkvNXE2gFXxbpNkfhA",
"color": "Torso_Red",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "Too5jpb1w0iQenkjRRS-QA"
},
{
"swatch_name": "Suspenders_Shirt_Black_Swatch",
"swatch_guid": "olyS074RHU-ojE0GzBBYJw",
"swatch_name": "Wizard_Black_Swatch",
"swatch_guid": "7afe1bb0-5ca9-4263-8f85-49d3a18353c7",
"color": "Black",
"item_name": "(Shirt_Suspenders)",
"item_guid": "pQqNvrhDJ0uuipKHA2qUOA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Fannypack_Belt_Rainbow_Swatch",
"swatch_guid": "y6aASPUwDkas0W5Yl27ydQ",
"color": "Rainbow",
"item_name": "(Neck_FannyPack)",
"item_guid": "oYvOo47DJE-DEPlpn5xNUw",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Hat_Heart_Swatch",
"swatch_guid": "Qx7nrZH-K06CgIoJYfFCCQ",
"color": "Heart",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "SkiBuddy_Shirt_Blue_Swatch",
"swatch_guid": "HQQWccdyakaEmntPhvC6CQ",
"color": "Blue",
"item_name": "(Shirt_SkiBuddy)",
"item_guid": "6RhwWExXekaYpK0NGHSXGQ",
"swatch_name": "TruckerJacket_Black_Swatch",
"swatch_guid": "P9bBSL0YLEyjN8PxMfgbKQ",
"color": "Black",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Blazer_Shirt_Pinstripe_Swatch",
"swatch_guid": "NvlKJFz4IkKDliVOJKJYhA",
"color": "Pinstripe",
"swatch_name": "FlowerSwimsuit_Gray_Swatch",
"swatch_guid": "5a96ac4d-03c3-4214-9dcb-c2044ff28f2e",
"color": "Gray",
"item_name": "(FlowerSwimsuit)",
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
"item_outfit_type": 101,
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
},
{
"swatch_name": "PirateCaptain_SRank_Swatch",
"swatch_guid": "xGmnUGF-Ck-jP43r8tVVjg",
"color": "SRank",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Mobster_PinStripe_Purple_Swatch",
"swatch_guid": "newGzX_zA0eGUX3Iayq5Ag",
"color": "PinStripe_Purple",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "BabydollDress_Pink_Swatch",
"swatch_guid": "da6d0ced-3d43-4878-a868-925f2ed6fd5b",
"color": "Pink",
"item_name": "(BabydollDress)",
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Soccer_Shirt_Blue_Swatch",
"swatch_guid": "OnQeVxdstkS2zGxLBAuM3Q",
"color": "Shirt_Blue",
"item_name": "(Wrist_Soccer)",
"item_guid": "94eb7ef3-528f-4f59-92a0-48b0ec287527",
"item_outfit_type": 200,
"mask_guid": "i8bvbGAKREmAEbjKO-geOQ"
},
{
"swatch_name": "CollarShirt_Torso_GoldYellow_Swatch",
"swatch_guid": "jGj28vhq8EGwP2RuM074aQ",
"color": "Torso_GoldYellow",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Turtleneck_Green_Swatch",
"swatch_guid": "Jt0QqNrAp0qzUR_RQoivVg",
"color": "Green",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SodaJerk_Blue_Swatch",
"swatch_guid": "XFCpIdeDXUqeYCIgh4fGtg",
"color": "Blue",
"item_name": "(Shirt_SodaJerk)",
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Red_Swatch",
"swatch_guid": "556141f7-6fbc-4065-9faa-8d8240a93d7f",
"color": "Red",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Steampunk_Femme_Contest_Swatch",
"swatch_guid": "A6VnGtpY9Ey0atf3zaLo3g",
"color": "e_Contest",
"item_name": "(Hat_SteampunkFemme)",
"item_guid": "JWanxvOHyESg9F_HlmTOfA",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BowlingShirt_Flames_Pink_Swatch",
"swatch_guid": "FC8ZFBPt4EyTe7IYbv2Tpg",
"color": "Shirt_Flames_Pink",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "5RUG6i2Mx0-55IO-IlhUdQ"
},
{
"swatch_name": "Luchador_Blue_Swatch",
"swatch_guid": "0217fd2e-ff9d-49f7-9bc8-8d0d6b8bf250",
"color": "Blue",
"item_name": "(Hat_Luchador)",
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Jazzercise_Yellow_Swatch",
"swatch_guid": "78eb0969-f246-4cac-acd6-295df7c092e4",
"color": "Yellow",
"item_name": "(Hair_Jazzercise)",
"item_guid": "49397e16-1027-4286-9bc4-b59eae565ff0",
"item_outfit_type": 2,
"mask_guid": ""
},
{
"swatch_name": "Countess_Purple_Swatch",
"swatch_guid": "IzkFARlv6EenmrfcNkcOuw",
"color": "Purple",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Hat_Orange_Swatch",
"swatch_guid": "Euf1h1qsZ02evDsyoHYbPQ",
"color": "Hat_Orange",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "LaserTag_Green_Swatch",
"swatch_guid": "2Z_FxSPiyUODtlFZWF9DFQ",
"color": "Green",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Glove_Grey_Swatch",
"swatch_guid": "72a010c0-bddd-4e5d-bca6-9821d82de9a6",
"color": "Glove_Grey",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "f4cea991-fc3d-4715-bf82-8a8fb60e8799"
},
{
"swatch_name": "HeadBackBow_Hearts_Swatch",
"swatch_guid": "D-SngK7hhkOpoZ2aMWv9EA",
"color": "Hearts",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "TruckerJacket_Olive_Swatch",
"swatch_guid": "bgRKH1wfqkWVHcuSnFYnlQ",
"color": "Olive",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BlazerSeaDark_Swatch",
"swatch_guid": "0ffad843-d6c9-425a-8686-7217009c867e",
"color": "SeaDark",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "Turtleneck_Red_Swatch",
"swatch_guid": "lBePHNL9LkyaAjEFyFE8Sw",
"color": "Red",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PoolFloatie_Belt_Hamburger_Swatch",
"swatch_guid": "CylZksC7KUGXF2VeJhlLSw",
"color": "Hamburger",
"item_name": "(Belt_PoolFloatie)",
"item_guid": "75-HXZBOAEiDBqclnOn8YQ",
"item_outfit_type": 102,
"swatch_name": "HockeyShirt_Teal_Swatch",
"swatch_guid": "fcbdfbfd-254a-4538-b119-43e23404b8b5",
"color": "Teal",
"item_name": "(HockeyShirt)",
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
"item_outfit_type": 101,
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
},
{
"swatch_name": "Unicorn_Glowing_Swatch",
"swatch_guid": "mudt-Z5-5USdnIq-xaDgOw",
"color": "Glowing",
"item_name": "(Hat_Unicorn)",
"item_guid": "2caHvVQ1vECrPv-YcdxdyA",
"item_outfit_type": 0,
"mask_guid": "nW_fBsuZA0OfIjTMXm6ZCA"
},
{
"swatch_name": "MagicalGirl_Orange_Swatch",
"swatch_guid": "hYu9itGe1Ee5Neanhj9VtQ",
"color": "Orange",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ButterflyWings_Shoulder_Midnight_Swatch",
"swatch_guid": "Do__oD53x0-dNtVeHsr4yA",
"color": "Midnight",
"item_name": "(Neck_ButterflyWings)",
"item_guid": "fc96ea5d-b49b-479b-a963-92f1eab44b2c",
"swatch_name": "ArcherTan_Quiver_Swatch",
"swatch_guid": "d1eb3573-672c-4bb4-9ec5-7c81e6a38d3d",
"color": "Tan_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Punk_WristGreen_Swatch",
"swatch_guid": "a6796264-9c7e-4605-830c-06db8d0dc780",
"color": "WristGreen",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "Headphones_EmissivePurple_Swatch",
"swatch_guid": "3PfP661Fg0CruzRxis9EiA",
"color": "EmissivePurple",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Harness_Hat_Black_Swatch",
"swatch_guid": "JVZHZm6nGEqE2zEEPoHbwA",
"color": "Hat_Black",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "HeadBackBow_SolidBlue_Swatch",
"swatch_guid": "8iZO162KNUiMC8l_z3OC_A",
"color": "SolidBlue",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "Firefighter_Hat_White_Swatch",
"swatch_guid": "891d902e-3257-4a8c-9001-858046007997",
"color": "Hat_White",
"item_name": "(Hat_Firefighter)",
"item_guid": "bb54e0e9-5d90-41f3-94c0-8e82f1791af1",
"item_outfit_type": 0,
"mask_guid": "c8ccb85e-52cd-4b3b-aadf-289819067125"
},
{
"swatch_name": "Scallywag_Hat_Red_Swatch",
"swatch_guid": "c3c9f89c-580c-43c4-b38b-07394044e0e1",
"color": "Hat_Red",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": "a4903da3-249d-4f0a-a746-b547fda371ff"
},
{
"swatch_name": "HeadBackBow_SolidPink_Swatch",
"swatch_guid": "FUP4iRnoc0ikNmjrFD06lg",
"color": "SolidPink",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "SpaceMarine_Dark_Swatch",
"swatch_guid": "a089bcc6-798d-4777-98e2-7afd290dc6b6",
"color": "Dark",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "Headphones_TwoBlack_Swatch",
"swatch_guid": "ns5tIRishUOp-nYqLedcwA",
"color": "TwoBlack",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Wrist_VampireHunter_Black_Swatch",
"swatch_guid": "Vxq6v55a_kGPODzwB5MC1g",
"color": "Black",
"item_name": "(Wrist_VampireHunter)",
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
"item_outfit_type": 200,
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
},
{
"swatch_name": "Blazer_Shamrock_Swatch",
"swatch_guid": "7WrSbK4Ml0K2z-ig5HIUCw",
"color": "Shamrock",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "MagicalGirl_Hat_Red_Swatch",
"swatch_guid": "EDLxuGjEVEGdOPXDqyN-xA",
"color": "Hat_Red",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "mG60EPIQIkaJNgOkonVmZg"
},
{
"swatch_name": "DrumMajorHat_Green_Swatch",
"swatch_guid": "5a614f97-ef7f-4b70-afff-9ed837a86407",
"color": "Hat_Green",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": "a8a91216-0fcc-461e-a682-95ee82509cce"
},
{
"swatch_name": "Golfer_Hat_Blue_Swatch",
"swatch_guid": "6584dada-499e-4d7c-958d-466955b20640",
"color": "Hat_Blue",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "ddf429fd-a883-48a5-869b-bc12e44d30bf"
},
{
"swatch_name": "Harness_Blue_Swatch",
"swatch_guid": "RfZNDPUok0WyQEa3Ko08ow",
"color": "Blue",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherSRank_Swatch",
"swatch_guid": "lgqLGi01o02ZFC9Xs5Ofdg",
"color": "SRank",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherRed_Swatch",
"swatch_guid": "5b5281c9-b795-4bb2-ba7b-ba52047ebe3f",
"color": "Red",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Wrist_VampireHunter_Leather_Swatch",
"swatch_guid": "D0tP58wC30mJ00vNxw8hqA",
"color": "Leather",
"item_name": "(Wrist_VampireHunter)",
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
"item_outfit_type": 200,
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
},
{
"swatch_name": "SpaceMarine_SRank_Swatch",
"swatch_guid": "4ElTihrMA0ipcgeb9UG01Q",
"color": "SRank",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "Jackfrost_Shirt_Lilac_Swatch",
"swatch_guid": "prEZHWvOM0ysaNfAe06ywA",
"color": "Shirt_Lilac",
"item_name": "(Shirt_JackFrost)",
"item_guid": "105a5100-cb57-410f-a26b-1ee21002f067",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BusinessTie_CandyCane_Swatch",
"swatch_guid": "W4a144so90eRzCojUnDowA",
"color": "CandyCane",
"item_name": "(BusinessTie)",
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "AnimalSweater_Shirt_Penguin_Swatch",
"swatch_guid": "2cSC290cPkCOrW5vtlVUxw",
"color": "Penguin",
"item_name": "(Shirt_AnimalSweater)",
"item_guid": "20eb23e8-ab3b-44fc-8038-3714946c1a09",
"swatch_name": "MagicalGirl_Hat_Orange_Swatch",
"swatch_guid": "f0pjsV0XMUWoE_z2kqOlSA",
"color": "Hat_Orange",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "mG60EPIQIkaJNgOkonVmZg"
},
{
"swatch_name": "BowlingShirt_CenterStripe_Yellow_Swatch",
"swatch_guid": "XrSL-v2MbkK0md-FMOweOA",
"color": "Shirt_CenterStripe_Yellow",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "IWg9wuda4kaCilJk8JQpTQ"
},
{
"swatch_name": "CollarShirt_Torso_Plaid_Red_Swatch",
"swatch_guid": "ojvIFCTpJkeegestF065wA",
"color": "Torso_Plaid_Red",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": "10fYSIBytk-yUjveDa407w"
},
{
"swatch_name": "Witch_Contest_Wrist_Swatch",
"swatch_guid": "Hk6svQ5Sr0GDKLxOyktk9w",
"color": "Contest_Wrist",
"item_name": "(Shirt_Witch)",
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SuperheroWrist_Wrist_Green_Swatch",
"swatch_guid": "r94RX2106E2TRK2wQ-u8-w",
"swatch_name": "WitchHunter_White_Swatch",
"swatch_guid": "e02838f8-4029-455a-b35c-dd40f972b05b",
"color": "White",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Witch_Contest_Swatch",
"swatch_guid": "uTHwo8qd6Ei3Oy4gz_j9Ow",
"color": "Contest",
"item_name": "(Shirt_Witch)",
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Countess_Green_Wrist_Swatch",
"swatch_guid": "Wj7I2YiVoE6zp8J_HYs8tw",
"color": "Green_Wrist",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Torso_GreenFlower_Swatch",
"swatch_guid": "EfdMcnfHt0mr0PQ_maaYOg",
"color": "Torso_GreenFlower",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherRed_Quiver_Swatch",
"swatch_guid": "7221132e-053a-4c58-b719-ed3c8558907d",
"color": "Red_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Scarf_Tree_Swatch",
"swatch_guid": "OrqqGMhK1UKGowFxpJi2mw",
"color": "Scarf_Tree",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "dUu7b-JFOky9c2lIVCNmcg"
},
{
"swatch_name": "ArcherGray_Glove_Swatch",
"swatch_guid": "8hUBfmq28EixYjv92Xcrgg",
"color": "Gray_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Tennis_Pink_Swatch",
"swatch_guid": "304e5cf1-5c94-481f-8e49-e6a943e6b300",
"color": "Pink",
"item_name": "(Shirt_Tennis)",
"item_guid": "74b91f2b-d4d3-4e41-8946-b1296ab7a773",
"item_outfit_type": 101,
"mask_guid": "90e0da8b-1941-4ca8-b7e5-b4e31921873c"
},
{
"swatch_name": "BowlingShirt_Diamonds_Blue_Swatch",
"swatch_guid": "KqjQ1xRpokGLKp9GVN1EFA",
"color": "Shirt_Diamonds_Blue",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "VO_3cgqQqUOTz30TVUxrFA"
},
{
"swatch_name": "Scarecrow_Shirt_Contest_Swatch",
"swatch_guid": "S0AKRB_m2k-I7snHnUigtw",
"color": "Shirt_Contest",
"item_name": "(Shirt_Scarecrow)",
"item_guid": "1fcaf4fd-9012-407e-b691-fbc3ccf22421",
"item_outfit_type": 101,
"mask_guid": "uTkbswCedUGyuZcaX0W6Ww"
},
{
"swatch_name": "Sherlock_Hat_Black_Swatch",
"swatch_guid": "fEPwvuJCWkGBe4IekXpmLw",
"color": "Hat_Black",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": "axPBGoqorEe77Oqqjfzb5w"
},
{
"swatch_name": "ElectricianTorso_Beige_Swatch",
"swatch_guid": "5fb2e235-8c67-4335-b432-edb10db2b139",
"color": "Torso_Beige",
"item_name": "(Hat_Electrician)",
"item_guid": "34654303-9760-447f-8614-1c26506db994",
"item_outfit_type": 0,
"mask_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a"
},
{
"swatch_name": "Letterman_Wrist_Black_Swatch",
"swatch_guid": "Y0e7o0_BKUm7XJSLediN5w",
"color": "Wrist_Black",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": "4EHIyZkOOEqnM-PuD_a5xw"
},
{
"swatch_name": "LaserTag_Orange_Swatch",
"swatch_guid": "jkuw9EhCpEKn-2lKJXWd3Q",
"color": "Orange",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Hat_Grey_Swatch",
"swatch_guid": "48c18418-01dd-40ff-9dfe-c4cf5f4e1245",
"color": "Hat_Grey",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "ddf429fd-a883-48a5-869b-bc12e44d30bf"
},
{
"swatch_name": "Soccer_Glove_Blue_Swatch",
"swatch_guid": "whEHo6Rhr0CxsDBsBD32WA",
"color": "Glove_Blue",
"item_name": "(Wrist_Soccer)",
"item_guid": "94eb7ef3-528f-4f59-92a0-48b0ec287527",
"item_outfit_type": 200,
"mask_guid": "av4PwLz6YkmpBRImQZgn4Q"
},
{
"swatch_name": "FurCoat_White_Swatch",
"swatch_guid": "643b5f2d-0666-4869-b699-03742de34d16",
"color": "White",
"item_name": "(FurCoat)",
"item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5",
"item_outfit_type": 101,
"mask_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6"
},
{
"swatch_name": "Hat_SeaCaptainDarkSilver_Swatch",
"swatch_guid": "36e63797-5a42-4779-a5cc-ef5c2e7d17d4",
"color": "DarkSilver",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "HockeyShirt_Purple_Swatch",
"swatch_guid": "d5efaf80-7f34-4c9a-bc3a-b80b1ac70ace",
"color": "Purple",
"item_name": "(HockeyShirt)",
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
"item_outfit_type": 101,
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
},
{
"swatch_name": "Steampunk_Contest_Hat_Swatch",
"swatch_guid": "AAbGHOOitUu9enweOXbrkw",
"color": "Contest_Hat",
"item_name": "(Shirt_SteamPunk)",
"item_guid": "E9VTiIVFPEa-OuQRto0e_Q",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherYellow_Quiver_Swatch",
"swatch_guid": "e548124c-d7f3-45de-aaa0-f66130eca576",
"color": "Yellow_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "LaserTagGlove_Green_Swatch",
"swatch_guid": "mhsglVEtREmElF5wuLw3NQ",
"color": "Green",
"item_name": "(Wrist_SuperheroWrist)",
"item_guid": "fa0e701c-5e80-46f4-8817-6e2dbf904734",
"item_name": "(Wrist_LaserTagGlove)",
"item_guid": "3044adce-90c0-4f66-81f5-39c4fca86fdf",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Pearls_Neck_Black_Swatch",
"swatch_guid": "URWHffwjGUKgXeV01upePA",
"color": "Black",
"item_name": "(Neck_Pearls)",
"item_guid": "7be2eafc-21ea-4266-ae64-8888c06798d6",
"item_outfit_type": 103,
"swatch_name": "ArcherTan_Swatch",
"swatch_guid": "9b35ae52-febe-486a-8557-ec7190a9756e",
"color": "Tan",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Blazer_Shirt_CandyCane_Swatch",
"swatch_guid": "6UBZuZQBG0eZtNmty2IGdA",
"color": "CandyCane",
"swatch_name": "BowlingShirt_Flames_Blue_Swatch",
"swatch_guid": "032pE8s9Ik-2CImFVDrSqg",
"color": "Shirt_Flames_Blue",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "5RUG6i2Mx0-55IO-IlhUdQ"
},
{
"swatch_name": "Turtleneck_Orange_Swatch",
"swatch_guid": "9b9t8_aKH0-gQXMhZycaOA",
"color": "Orange",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Neck_Blue_Swatch",
"swatch_guid": "Ot9EG-FfKEauT0u6pSZuWg",
"color": "Neck_Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "M5z2wk-GDkOAjvZzqsfcNQ"
},
{
"swatch_name": "WitchHunter_Red_Swatch",
"swatch_guid": "909ed249-ce09-4304-8b02-56794fa7567d",
"color": "Red",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Countess_Green_Swatch",
"swatch_guid": "LyVf9lnzdku1F4iPBGqk-g",
"color": "Green",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Shirt_TutorialGown1_Purple_Swatch",
"swatch_guid": "U2ZbHn8WJEySUXVpzAY6ig",
"color": "Purple",
"item_name": "(Shirt_TutorialGown_1)",
"item_guid": "ASHnWS0h9kOJhbOUwxWKFQ",
"item_outfit_type": 101,
"mask_guid": "6nmHu9zj8k-tEz0tBHblnQ"
},
{
"swatch_name": "BowlingShirt_OneStripe_Blue_Swatch",
"swatch_guid": "GDvaUU10-EKh3pKUQ9tnag",
"color": "Shirt_OneStripe_Blue",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "b-IM3eYMJUa40395O5XeLg"
},
{
"swatch_name": "Scuba_Neck_Contest_Swatch",
"swatch_guid": "GWi1rEebqkCV_TS2WzQhcA",
"color": "Neck_Contest",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Dodgeball_Black_Swatch",
"swatch_guid": "4a85c181-92fe-47a5-925e-05f0ccb25006",
"color": "Black",
"item_name": "(Shirt_Dodgeball)",
"item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59",
"item_outfit_type": 101,
"mask_guid": "944d934d-5e21-42d7-8196-fff9a81ee469"
},
{
"swatch_name": "BlazerGrey_Swatch",
"swatch_guid": "272fe8eb-5061-4729-a7a8-414ff667a82f",
"color": "Grey",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "TennisSkirt_Red_Swatch",
"swatch_guid": "758752bd-db2f-43d2-b580-55b3e1efffd5",
"color": "Red",
"item_name": "(TennisSkirt)",
"item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60",
"item_outfit_type": 101,
"mask_guid": "b75ef67d-00c3-4ac1-9b72-212032460294"
},
{
"swatch_name": "Windbreaker_Salmon_Swatch",
"swatch_guid": "JS7aa4QSUUOpUYPqRZEb5g",
"color": "Salmon",
"item_name": "(Wrist_80sWindbreaker)",
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "QuiltedVest_Shirt_Flannel_Swatch",
"swatch_guid": "TUZ21v0C9UK9GPsfbRwpsw",
"color": "Flannel",
"item_name": "(Shirt_QuiltedVest)",
"item_guid": "be75b46c-ce22-4758-8810-c409c5aa1c50",
"swatch_name": "Wizard_Red_Swatch",
"swatch_guid": "a38254c0-00e3-429b-9d5d-b9e6a9812e69",
"color": "Red",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "FighterPilot_Gloves_Blue_Swatch",
"swatch_guid": "55b4e661-43e1-4708-82d5-2dd2bebd4f45",
"color": "Gloves_Blue",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Letterman_Green_Swatch",
"swatch_guid": "apSQiQ1eO0mEzRsbugeu9w",
"color": "Green",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArtistHat_Blue_Swatch",
"swatch_guid": "14b43bf9-5ba0-484e-ae5d-55cfeb09a70d",
"color": "Hat_Blue",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": "3485a30d-27f8-450b-b5aa-1ff3cd227d19"
},
{
"swatch_name": "WaitressApron_Green_Swatch",
"swatch_guid": "Blev_VFjRUysY_nymGWAVg",
"color": "Green",
"item_name": "(Belt_WaitressApron)",
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Torso_White_Swatch",
"swatch_guid": "MFrcSQ1DYUm8imvy4ypgvw",
"color": "Torso_White",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Shirt_PinStripePurple_Swatch",
"swatch_guid": "6Jv4AJGBYUGP_n4sDcz2Kw",
"color": "PinStripePurple",
"item_name": "(Shirt_Mobster)",
"item_guid": "4308da04-af84-40bb-845a-7a9e1a2726ec",
"swatch_name": "Ranger_Hat_Green_Swatch",
"swatch_guid": "d0bb468c-b78f-4e80-916d-65fec3ec0010",
"color": "Hat_Green",
"item_name": "(Hat_Ranger)",
"item_guid": "f277af6a-0807-4716-8948-1bee236ffb74",
"item_outfit_type": 0,
"mask_guid": "0272eb19-fc0e-4ad6-8db9-cc0cda2528b0"
},
{
"swatch_name": "Scuba_Shirt_Blue_Swatch",
"swatch_guid": "VqtOYBiKRkaSW4xAlBxQgg",
"color": "Shirt_Blue",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Shirt_SRank_Swatch",
"swatch_guid": "52053afd-9f12-4fec-a358-7eb3fe8c19bb",
"color": "SRank",
"item_name": "(Shirt_PirateCaptain)",
"item_guid": "3b00c71f-79c2-4a04-9fc0-a23b4c85562f",
"swatch_name": "Harness_Orange_Swatch",
"swatch_guid": "sX0ZZDcCUkyFSJISfvq0vg",
"color": "Orange",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
}
],
"unmatched": [
"Nightcap_Hat_Lilac_Combination",
"ArcherHat_Hat_Yellow_Combination",
"RecTrooper_Wrist_Pink_Combination",
"MaleCheerleader_Shirt_Green_Combination",
"Polkadot_PolkaBlue_Combination",
"JacketDress_Shirt_Blue_Combination",
"RoyalCape_Shoulder_Black_Combination",
"SmugglerGlasses_Eye_Cherry_Combination",
"FemaleCheerleaderSash_Shoulder_Green_Combination",
"HockeyHelmet_Hat_Green_Combination",
"BowlingShirt_Shirt_Flames_Green_Combination",
"RecTrooper_Shirt_Black_Combination",
"BogMonster_Shirt_Contest_Combination",
"MaleCheerleaderSash_Shoulder_Yellow_Combination",
"DryadDress_Shirt_Fall_Combination",
"Sundress_Shirt_Flowers_Black_Combination",
"Knight_Shirt_White_Combination",
"RecTrooper_Hat_Pink_Combination",
"LuauFlowers_Hat_Maroon_Combination",
"SolidColor_Green_Combination",
"FormalDress_Shirt_Black_Combination",
"MaleCheerleader_Shirt_Red_Combination",
"ChunkyScarf_Neck_Green_Combination",
"Recpods_Ear_White_Combination",
"BowlingShirt_Shirt_OneStripe_Pink_Combination",
"Glasses_Eye_Purple_Combination",
"AnglerHat_Hat_Gray_Combination",
"SolidColor_White_Combination",
"SunGlasses_Eye_Orange_Combination",
"TutorialGown_Shoulder_2_Orange_Combination",
"Scifi_Carpet_Combination",
"RecTrooper_Hat_Yellow_Combination",
"Polkadot_Plaid_Purple_Combination",
"Astronaut_Shirt_Orange_Combination",
"SolidColor_Black_Combination",
"LuauFlowers_Hat_BlueMonochromatic_Combination",
"Plaid_PolkaBlack_Combination",
"CheerDress_Shirt_FloralOrange_Combination",
"Sundress_Shirt_Striped_Orange_Combination",
"TuckedTee_Shirt_Latinity_Combination",
"WinterGlove_Wrist_Snowman_Combination",
"CheerDress_Shirt_Purple_Combination",
"ArcherHat_Hat_Black_Combination",
"BowlingShirt_Shirt_CenterStripe_Black_Combination",
"PointyGlasses_Eye_Teal_Combination",
"PaintballTruckerCap_Hat_Red_Combination",
"ArcherHat_Hat_Gray_Combination",
"FootballHelmet_Hat_RedWhite_Combination",
"RoyalCape_Shoulder_Purple_Combination",
"BowlingShirt_Shirt_OneStripe_Green_Combination",
"SantaHat_Hat_Red_Combination",
"InternationalThief_Shirt_Black_Combination",
"SmugglerGlasses_Eye_Dark_Combination",
"BowlingShirt_Shirt_Flames_Pink_Combination",
"FirefighterHelmet_Hat_Black_Combination",
"LuchadorMask_Hat_Blue_Combination",
"FootballHelmet_Hat_BlackGold_Combination",
"PointyGlasses_Eye_Black_Combination",
"PaintballTruckerCap_Hat_Mustard_Combination",
"WinterScarf_Neck_Trees_Combination",
"FemaleCheerleaderSash_Shoulder_Red_Combination",
"LuchadorMask_Hat_GreenGold_Combination",
"FootballHelmet_Hat_RedGray_Combination",
"SmugglerGlasses_Eye_Yellow_Combination",
"SnowyOwlMask_Eye_RavenBlue_Combination",
"OrangeDress_Shirt_LightBlue_Combination",
"SolidColor_LightBlue_Combination",
"LuauFlowers_Hat_Orange_Combination",
"TuckedTee_Shirt_ClassOf2021_Combination",
"IceQueenCrown_Hat_Lilac_Combination",
"Plaid_RoyalGreen_Combination",
"DryadDress_Shirt_Winter_Combination",
"RecTrooper_Wrist_Yellow_Combination",
"RecTrooper_Shirt_Green_Combination",
"RecTrooper_Hat_Black_Combination",
"LuauFlowers_Hat_Red_Combination",
"Knight_Shirt_Black_Combination",
"RecTrooper_Shirt_Yellow_Combination",
"FirefighterHelmet_Hat_Yellow_Combination",
"RecTrooper_Wrist_Green_Combination",
"InternationalThief_Shirt_Brown_Combination",
"Knight_Shirt_SRank_Combination",
"CheerDress_Shirt_White_Combination",
"RecTrooper_Shirt_Blue_Combination",
"WinterScarf_Neck_Trees_Green_Combination",
"WinterScarf_Neck_UglySweater_Combination",
"OrangeDress_Shirt_GreenFlower_Combination",
"InternationalThief_Hat_Brown_Combination",
"SmugglerGlasses_Eye_Green_Combination",
"FootballHelmet_Hat_Orange_Combination",
"DryadHorns_Hat_LightBrown_Combination",
"FootballHelmet_Hat_BlueGreen_Combination",
"BowtieSequin_Neck_Tileable_Red_Combination",
"CheerDress_Shirt_Black_Combination",
"DryadDress_Shirt_Spring_Combination",
"EquestrianHelmet_Hat_Denim_Combination",
"BowlingShirt_Shirt_CenterStripe_Yellow_Combination",
"RoyalCape_Shoulder_Gold_Combination",
"Polkadot_Royal_Combination",
"RecTrooper_Hat_Green_Combination",
"ChunkyScarf_Neck_Beige_Combination",
"PulloverHoodie_Shirt_RecCon_Combination",
"BowlingShirt_Shirt_Diamonds_Purple_Combination",
"BogMonster_Hat_Contest_Combination",
"Glasses_Eye_Blue_Combination",
"LuauDress_Shirt_Red_Combination",
"FootballHelmet_Hat_BlackYellow_Combination",
"ArcherHat_Hat_SRank_Combination",
"InternationalThief_Hat_Monochromatic_Combination",
"RecTrooper_Hat_Blue_Combination",
"Knight_Shirt_Blue_Combination",
"Knight_Shirt_Green_Combination",
"EquestrianHelmet_Hat_Maroon_Combination",
"WinterScarf_Neck_Snowman_Combination",
"TutorialGown_Shirt_3_Black_Combination",
"WinterGlove_Wrist_Trees_Combination",
"CheerDress_Shirt_FloralBlue_Combination",
"Sundress_Shirt_Ombre_Sunset_Combination",
"FormalDress_Shirt_Patchwork_Combination",
"LuauDress_Shirt_LightBase_Orange_Combination",
"Polkadot_Plaid_Red_Combination",
"SolidColor_Red_Combination",
"PointyGlasses_Eye_Pink_Combination",
"LuauDress_Shirt_LightBase_BlueMonochromatic_Combination",
"Nightcap_Hat_Navy_Combination",
"InternationalThief_Shirt_Monochromatic_Combination",
"Plaid_Stripe3_Combination",
"MaleCheerleaderSash_Shoulder_Black_Combination",
"Sundress_Shirt_Ombre_Purple_Combination",
"InternationalThief_Wrist_Monochromatic_Combination",
"RoyalCape_Shoulder_Contest_White_Combination",
"Sundress_Shirt_Ombre_Seafoam_Combination",
"BikeHelmet_Hat_Yellow_Combination",
"BikeHelmet_Hat_Black_Combination",
"InternationalThief_Wrist_Brown_Combination",
"Polkadot_PolkaBlack_Combination",
"WinterGlove_Wrist_Trees_Green_Combination",
"WaitressDress_Shirt_Mustard_Combination",
"CheerDress_Shirt_FloralGreen_Combination",
"WaitressDress_Shirt_Green_Combination",
"Plaid_Royal_Combination",
"FootballHelmet_Hat_Dark_Combination",
"LuchadorMask_Hat_Tiger_Combination",
"IceQueenGlove_Wrist_Lilac_Combination",
"BowlingShirt_Shirt_OneStripe_Blue_Combination",
"PointyGlasses_Eye_Red_Combination",
"EquestrianHelmet_Hat_Gray_Combination",
"SuperheroDress_Shirt_Green_Combination",
"WinterGlove_Wrist_Red_Combination",
"InternationalThief_Wrist_Black_Combination",
"MardiGras2_Eye_Teal_Combination",
"SmugglerGlasses_Eye_Grey_Combination",
"BowlingShirt_Shirt_Flames_Blue_Combination",
"PaintballGoggles_Eye_Red_Combination",
"Plaid_Purple_Combination",
"CheerDress_Shirt_Blue_Combination",
"RoyalCape_Shoulder_Green_Combination",
"ArcherHat_Hat_Red_Combination",
"PaintballGoggles_Eye_Blue_Combination",
"BowlingShirt_Shirt_Diamonds_Teal_Combination",
"SolidColor_Orange_Combination",
"Polkadot_RoyalGreen_Combination",
"WinterGlove_Wrist_Vermont_Combination",
"LuauDress_Shirt_Maroon_Combination",
"ChunkyScarf_Neck_Rust_Combination",
"BikeJersey_Shirt_Blue_Combination",
"SmugglerGlasses_Eye_SRank_Combination",
"Sundress_Shirt_Striped_Blue_Combination",
"HorizontalStripe3_Stripe3_Combination",
"LuchadorMask_Hat_Red_Combination",
"BaseballShirt_Shirt_Red_Combination",
"Knight_Shirt_Red_Combination",
"ArcherHat_Hat_Tan_Combination",
"BikeJersey_Shirt_DarkBlue_Combination",
"CheerDress_Shirt_Green_Combination",
"SunGlasses_Eye_White_Combination",
"FirefighterShirt_Shirt_Tan_Combination",
"WinterGlove_Wrist_UglySweater_Combination",
"SolidColor_GoldYellow_Combination",
"WinterScarf_Neck_Trees_Vermont_Combination",
"Sundress_Shirt_Flowers_White_Combination",
"RoyalCape_Shoulder_Pink_Combination",
"JacketDress_Shirt_Green_Combination",
"BowlingShirt_Shirt_Diamonds_Blue_Combination",
"HockeyHelmet_Hat_Purple_Combination",
"BowlingShirt_Shirt_CenterStripe_Burgandy_Combination",
"Sundress_Shirt_Striped_Green_Combination",
"SolidColor_Charcoal_Combination",
"BowlingShirt_Shirt_Flames_Red_Combination",
"TutorialGown_Shirt_2_Orange_Combination",
"RecTrooper_Wrist_Blue_Combination",
"SolidColor_Cream_Combination",
"InternationalThief_Hat_Black_Combination",
"Nightcap_Hat_Red_Combination",
"BikeHelmet_Hat_DarkBlue_Combination",
"TutorialGown_Shoulder_3_Black_Combination",
"HockeyHelmet_Hat_Blue_Combination",
"AnglerHat_Hat_Blue_Combination",
"WaitressDress_Shirt_Red_Combination",
"BaseballShirt_Shirt_SolidRed_Combination",
"SuspiciousGoblin_Shirt_Combination",
"WinterScarf_Neck_Red_Combination",
"FirefighterHelmet_Hat_White_Combination",
"Sundress_Shirt_Flowers_Red_Combination",
"Plaid_PolkaBlue_Combination",
"Glasses_Eye_Yellow_Combination",
"DryadHorns_Hat_Dark_Combination",
"OrangeDress_Shirt_Pink_Combination",
"PaintballTruckerCap_Hat_Green_Combination",
"DryadHorns_Hat_Light_Combination",
"TutorialGown_Shirt_1_Purple_Combination",
"MoonandStarsDress_Shirt_Snowflakes_Combination",
"LuauFlowers_Hat_Cream_Combination",
"SnowyOwlMask_Eye_Red_Combination",
"Plaid_Red_Combination",
"BaseballShirt_Shirt_Green_Combination",
"BaseballShirt_Shirt_SolidGreen_Combination",
"PulloverHoodie_Shirt_RecCon_Orange_Combination",
"RecTrooper_Wrist_Black_Combination",
"MardiGras_Eye_DarkBlue_Combination",
"ChunkyScarf_Neck_Purple_Combination",
"RoyalCape_Shoulder_Blue_Combination",
"CheerDress_Shirt_Red_Combination",
"BikeJersey_Shirt_Black_Combination",
"AnglerHat_Hat_Tan_Combination",
"LuauDress_Shirt_LightBase_Cream_Combination",
"SolidColor_Pink_Combination",
"SolidColor_Blue_Combination",
"TruckerCap_Hat_Red_Combination",
"RecTrooper_Shirt_Pink_Combination",
"SolidColor_Grey_Combination",
"Plaid_LightBlue_Combination"
"Knight_SRank_Swatch",
"TShirt_SquadGoals_Swatch",
"ZebraWhite_Swatch",
"Cyber_Trash_Yellow_Swatch",
"PoofyDress_Purple_Swatch",
"Laser_Graffiti_Swatch",
"Shuttershades_Salmon_Swatch",
"Class_Of_2018_Swatch",
"Plaid_LightBlue_Swatch",
"Fatigue_Swatch",
"Wrist_CowboyWhite_Swatch",
"Cream_Swatch",
"Shuttershades_Red_Swatch",
"PolkaBlue_Swatch",
"Wrist_CowboyCherry_Swatch",
"Yellow_Swatch",
"Windbreaker_Wrist_Yellow_Swatch",
"2D_Contest_Winner_Swatch",
"Red_Swatch",
"RoyalCape_Green_Swatch",
"Grey_Swatch",
"RoyalCape_Contest_White_Swatch",
"Barber_Torso_Formal_Swatch",
"Knight_Blue_Swatch",
"Neck_TutorialGown3_Black_Swatch",
"TShirt_Laser_Unicorn_Purple_Swatch",
"PaintBallHat_Red_Swatch",
"Blue_Swatch",
"Barber_Hat_Formal_Swatch",
"RoyalCape_Blue_Swatch",
"Neck_VampiteHunter_Opal_Swatch",
"TShirt_HangInThere_Swatch",
"Knight_Red_Swatch",
"Floatie_Donut_Swatch",
"RoyalLady_Green_Swatch",
"TShirt_Orange_Swatch",
"White_Swatch",
"RoyalCape_Purple_Swatch",
"BeltDress_Green_Swatch",
"Shuttershades_Blue_Swatch",
"Neck_VampiteHunter_Ruby_Swatch",
"RoyalCrown_Contest_Swatch",
"BeltDress_Black_Swatch",
"Wrist_CowboyBlack_Swatch",
"TShirt_KO_Skull_Swatch",
"RoyalLord_Black_Swatch",
"TShirt_Orange_Video_Contest_Generic_Swatch",
"TShirt_Scifi_Face_BLue_Swatch",
"Green_Swatch",
"GreenFlower_Swatch",
"FormalDress_Black_Swatch",
"Birthday01_Swatch",
"Neck_VampiteHunter_Amethyst_Swatch",
"Cyber_Trash_Black_Swatch",
"SciFiVisor_Peach_Swatch",
"Scifi_Carpet_Swatch",
"Teal_Swatch",
"Charcoal_Swatch",
"Paintball_Red_Swatch",
"BicycleJacket_Black_Swatch",
"TShirt_Scifi_Face_Pink_Swatch",
"Argyle_Brown_Swatch",
"Paintball_Blue_Swatch",
"BasketballShirt_Purple_Swatch",
"Purple_Swatch",
"Knight_Black_Swatch",
"TShirt_KO_Skull_Pink_Swatch",
"BeltDress_Yellow_Swatch",
"Shuttershades_Yellow_Swatch",
"PaintBallHat_Green_Swatch",
"Cyber_Junk_City_Swatch",
"BicycleJacket_DarkBlue_Swatch",
"Torso_SeaCaptainBlueWhite_Swatch",
"CheerDress_Blue_Swatch",
"Denim_Swatch",
"RoyalLady_Contest_White_Swatch",
"Neck_VampiteHunter_Diamond_Swatch",
"Floatie_Dinosaur_Swatch",
"Torso_SeaCaptainBlueDark2_Swatch",
"WaitressDress_Mustard_Swatch",
"CollarDress_Pink_Swatch",
"Torso_SeaCaptainDark_Swatch",
"SciFiVisor_Blue_Swatch",
"RoyalCrown_White_Swatch",
"Pink_Swatch",
"Royal_Swatch",
"GoldYellow_Swatch",
"Happy_Tree_Purple_Swatch",
"Barber_Torso_Green_Swatch",
"CheerDress_Red_Swatch",
"TShirt_RECS_Logo_Blue_Swatch",
"Scout_Shirt_Blue_Swatch",
"Windbreaker_Wrist_Red_Swatch",
"Neck_VampiteHunter_Emerald_Swatch",
"TShirt_RECS_Logo_Green_Swatch",
"Neck_VampiteHunter_Amber_Swatch",
"RoyalCrown_Black_Swatch",
"RoyalLord_Burgandy_Swatch",
"Knight_Green_Swatch",
"Torso_SeaCaptainWhiteDark_Swatch",
"LightBlue_Swatch",
"Happy_Tree_Swatch",
"BicycleJacket_Blue_Swatch",
"Torso_SeaCaptainBlackDark_Swatch",
"PaintBallHat_Mustard_Swatch",
"Scifi_Carpet_TopHat_Swatch",
"Windbreaker_Wrist_Blue_Swatch",
"Neck_VampiteHunter_Topaz_Swatch",
"Plaid_Red_Swatch",
"Laser_Ham_Swatch",
"Rebel_Rose_Swatch",
"Wrist_CowboyLight_Swatch",
"Barber_Wrist_Formal_Swatch",
"SciFiVisor_Orange_Swatch",
"RoyalLady_Black_Swatch",
"WaitressDress_Green_Swatch",
"StollDress_Green_Swatch",
"Torso_SeaCaptainBlueDark_Swatch",
"Torso_SeaCaptainBlue_Swatch",
"Orange_Swatch",
"PB_Trucker_Red_Swatch",
"Barber_Hat_Green_Swatch",
"RoyalLord_Contest_White_Swatch",
"Black_Swatch",
"RoyalLord_Green_Swatch",
"CollarDress_LightBlue_Swatch",
"StollDress_Blue_Swatch",
"Argyle_Red_Swatch",
"RoyalLady_Burgandy_Swatch",
"TShirt_LaserTag_Alpha_Swatch",
"Hyper_Noodle_Ramen_Swatch",
"PoofyDress_Orange_Swatch",
"Knight_White_Swatch",
"BicycleHelmet_Yellow_Swatch",
"BasketballShirt_Blue_Swatch",
"Wrist_CowboyDark_Swatch",
"Windbreaker_Wrist_Salmon_Swatch",
"Emerald_Syndicate_Swatch",
"Neck_TutorialGown2_Orange_Swatch",
"2D_Contest_Winner_Yellow_Swatch",
"WaitressDress_Red_Swatch",
"Stripe3_Swatch"
],
"ambiguous": []
}
@@ -1,3893 +0,0 @@
{
"matches": [
{
"swatch_name": "Windbreaker_Red_Swatch",
"swatch_guid": "grutFwJD_0Cb3t6fddNg6w",
"color": "Red",
"item_name": "(Wrist_80sWindbreaker)",
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "LumberJack_Blue_Swatch",
"swatch_guid": "cKHAetNcgUOxFfDkAPPF9w",
"color": "Blue",
"item_name": "(Shirt_Lumberjack)",
"item_guid": "350c5c1e-318b-4233-a7e5-1f49909d8e65",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SpaceMarine_Red_Swatch",
"swatch_guid": "d78643e4-c0d2-4836-bf22-b5a0e2d65b28",
"color": "Red",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "Hibiscus_Yellow_Swatch",
"swatch_guid": "596097a8-d545-4256-959d-1d3cd96fc64c",
"color": "Yellow",
"item_name": "(Hat_Hibiscus)",
"item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Blazer_CandyCane_Swatch",
"swatch_guid": "_ycDQ6RcyEGAntVlSDpPeA",
"color": "CandyCane",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "Raincoat_Shirt_Blue_Swatch",
"swatch_guid": "GH9q8_0O9EqjGdz7lGWD2g",
"color": "Shirt_Blue",
"item_name": "(Shirt_Raincoat)",
"item_guid": "9e6397dd-b7b0-4d32-8c86-1910106a8478",
"item_outfit_type": 101,
"mask_guid": "T7zwcIIc8Ee3ud8a2JCuwQ"
},
{
"swatch_name": "Boxer_Green_Swatch",
"swatch_guid": "rzUd1IMTKUmW0HeMPSIOBQ",
"color": "Green",
"item_name": "(Shirt_Boxer)",
"item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Boxer_Red_Swatch",
"swatch_guid": "8c7b09f2-6213-4ef9-87ab-a2df72d07a22",
"color": "Red",
"item_name": "(Shirt_Boxer)",
"item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Gloves_Tree_Green_Swatch",
"swatch_guid": "tyzdtSkR9k-YCnjNvPAT6w",
"color": "Gloves_Tree_Green",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Headphones_Red_Swatch",
"swatch_guid": "viL4CPch2ECwG-RNaWcyZw",
"color": "Red",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "JackfrostHair_Hat_Lilac_Swatch",
"swatch_guid": "KwhdQ1deNE6Y6jzO9vaAnQ",
"color": "Hat_Lilac",
"item_name": "(Hat_JackfrostHair)",
"item_guid": "e6216087-fb37-46f1-bda4-a88c4640f339",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Baseball_Red_Swatch",
"swatch_guid": "de0a5a3a-8aff-4799-8269-9efa3aeb383a",
"color": "Red",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "ButtonTop_Orange_Swatch",
"swatch_guid": "e0397982-c2c2-4733-9a40-46e18675b5af",
"color": "Orange",
"item_name": "(ButtonTop)",
"item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CowboyCherry_Swatch",
"swatch_guid": "624c85e5-095a-48b6-85fb-c9bbade19a8d",
"color": "Cherry",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "Mobster_HerringBone_Tan_Swatch",
"swatch_guid": "531b4297-b38b-4362-9854-4f2139ea43be",
"color": "HerringBone_Tan",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Blue_Swatch",
"swatch_guid": "VQjkyb5aoEKHKo_eZsq9ug",
"color": "Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Wizard_SRank_Swatch",
"swatch_guid": "RKmRHk2N_U2gd91nybo6vg",
"color": "SRank",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Punk_Pink_Swatch",
"swatch_guid": "4169bfed-8403-4590-a29d-4941275ac0b6",
"color": "Pink",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "Countess_Wrist_Purple_Swatch",
"swatch_guid": "jwEB6LyEaEekqtPNRc6TVA",
"color": "Wrist_Purple",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": "8kdJkmwNM0WQmMVrb8fK8A"
},
{
"swatch_name": "Hat_GraduationCap_Green_Swatch",
"swatch_guid": "FaWMLkJJgEKFIO493fzGxg",
"color": "Green",
"item_name": "(Hat_GraduationCap)",
"item_guid": "KPmC_wyX_ECe7fdKwoLMGg",
"item_outfit_type": 0,
"mask_guid": "yzL0M_N5Q0eAa8IxACHPAQ"
},
{
"swatch_name": "EquestrianJacket_Maroon_Swatch",
"swatch_guid": "d6823e01-69f0-4f85-b94a-74894356a2cf",
"color": "Maroon",
"item_name": "(EquestrianJacket)",
"item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5",
"item_outfit_type": 101,
"mask_guid": "d344b8cc-85a8-4ace-9f92-38c84f396e99"
},
{
"swatch_name": "Headphones_EmissiveBlack_Swatch",
"swatch_guid": "Uz2hrptCa0erjqyPoiZxew",
"color": "EmissiveBlack",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Shirt_VampireHunter_White_Swatch",
"swatch_guid": "9QG6qp5FBU-u0-JK-yMMqA",
"color": "White",
"item_name": "(Shirt_VampireHunter)",
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
"item_outfit_type": 101,
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
},
{
"swatch_name": "FighterPilot_Gloves_Orange_Swatch",
"swatch_guid": "18b52db5-0261-46a0-9214-ff510d455ed9",
"color": "Gloves_Orange",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Aviator_Gloves_Tan_Swatch",
"swatch_guid": "7fb8496f-0e6c-4e48-b8fa-5709ebbf4820",
"color": "Gloves_Tan",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": "5659ce2c-9cb0-49da-8c39-4b7df0f8deec"
},
{
"swatch_name": "Astronaut_Glove_Orange_Swatch",
"swatch_guid": "y_gTbnuYS0GKJmnCs2retw",
"color": "e_Orange",
"item_name": "(Wrist_AstronautGlove)",
"item_guid": "88739d79-aeb9-471a-b25b-776f46bba9f6",
"item_outfit_type": 200,
"mask_guid": "hjk9OO8CW0KNGR7A6_p84A"
},
{
"swatch_name": "Winter_Hat_Tree_Swatch",
"swatch_guid": "xVwzV9-m6E-8PVpkg4D-AQ",
"color": "Hat_Tree",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "nalaCGqtL0SFA4Itwqmp8g"
},
{
"swatch_name": "Harness_Hat_Teal_Swatch",
"swatch_guid": "lsyYlxY9o0a3TbUrHkxeyQ",
"color": "Hat_Teal",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "Sherlock_Torso_Gray_Swatch",
"swatch_guid": "msOFHoFN9UORkeXXtdqBsw",
"color": "Torso_Gray",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": "9ZP617DQk0mZ10J3d5q9oA"
},
{
"swatch_name": "BowlingShirt_CenterStripe_Burgandy_Swatch",
"swatch_guid": "ghM-uFq5-0Ci38AOHa9zZA",
"color": "Shirt_CenterStripe_Burgandy",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "IWg9wuda4kaCilJk8JQpTQ"
},
{
"swatch_name": "Scuba_Neck_Blue_Swatch",
"swatch_guid": "V_duN2oWaUmmao1xHhzCJA",
"color": "Neck_Blue",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scallywag_Bracelet_SRank_Swatch",
"swatch_guid": "X3MkhL55Rk-0UK6h4dw0Eg",
"color": "SRank",
"item_name": "(Wrist_Scallywag_Bracelet)",
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArtistHat_Red_Swatch",
"swatch_guid": "5c8f36c5-07da-49f4-af02-d23207a43216",
"color": "Hat_Red",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": "3485a30d-27f8-450b-b5aa-1ff3cd227d19"
},
{
"swatch_name": "ArcherBlack_Quiver_Swatch",
"swatch_guid": "74b009f4-e128-4c59-8a6a-4c0ba6119bde",
"color": "Black_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherYellow_Swatch",
"swatch_guid": "6e152cc7-e93a-4408-8092-236db420b680",
"color": "Yellow",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PurpleJacket_Red_Swatch",
"swatch_guid": "657f28d5-672a-4f27-86c6-3cb00f25bb84",
"color": "Red",
"item_name": "(PurpleJacket)",
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
"item_outfit_type": 101,
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
},
{
"swatch_name": "FighterPilot_Orange_Swatch",
"swatch_guid": "cea53c7f-24a9-4e3b-80b9-fbf95738f973",
"color": "Orange",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
},
{
"swatch_name": "Neck_Grillmaster_Denim_Swatch",
"swatch_guid": "eV3Nv6SWS0yl3f-fgjPb-w",
"color": "Denim",
"item_name": "(Neck_Grillmaster)",
"item_guid": "tTKv7eRODka2KC_hsY2O3Q",
"item_outfit_type": 100,
"mask_guid": "t-ZEWJCgFEyGP2PjNblQMA"
},
{
"swatch_name": "BabydollDress_Green_Swatch",
"swatch_guid": "c9589974-c261-485e-b571-cb2f34fb178f",
"color": "Green",
"item_name": "(BabydollDress)",
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hat_SeaCaptainDarkDarkGold_Swatch",
"swatch_guid": "70112887-dc8d-49d5-96ed-0845204e0b58",
"color": "DarkDarkGold",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "Letterman_Wrist_Red_Swatch",
"swatch_guid": "HWc25-d8dUmNN2F3n8mV5A",
"color": "Wrist_Red",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": "4EHIyZkOOEqnM-PuD_a5xw"
},
{
"swatch_name": "WitchHunter_Purple_Swatch",
"swatch_guid": "71e5a0fe-fc8d-420b-af3b-e74160e0d1f3",
"color": "Purple",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Beekeeper_Shirt_Teal_Swatch",
"swatch_guid": "oZ8k0Qv3SkG-DkY0_dP7UA",
"color": "Shirt_Teal",
"item_name": "(Shirt_Beekeeper)",
"item_guid": "1ebbfdfc-f72b-4d70-99d4-4d527f896aa7",
"item_outfit_type": 101,
"mask_guid": "afd88a79-7f03-40f6-bcb4-c247dd86edc4"
},
{
"swatch_name": "Smuggler_Green_Swatch",
"swatch_guid": "076c96ae-9f9b-45bf-9dc7-ce73d5ddf422",
"color": "Green",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "SodaJerk_Green_Swatch",
"swatch_guid": "oKxmH-1s2EyIDkZoOQSQQw",
"color": "Green",
"item_name": "(Shirt_SodaJerk)",
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scarecrow_Hat_Contest_Swatch",
"swatch_guid": "TZ3oQBp_3kWY4Vzscgv5Wg",
"color": "Hat_Contest",
"item_name": "(Shirt_Scarecrow)",
"item_guid": "1fcaf4fd-9012-407e-b691-fbc3ccf22421",
"item_outfit_type": 101,
"mask_guid": "xCpdHx1iM06Gg0tYoGv0DA"
},
{
"swatch_name": "Hat_TutorialCap_2_Swatch",
"swatch_guid": "gDH1WmW5rk2RdY4wJYYZow",
"color": "2",
"item_name": "(Hat_TutorialCap)",
"item_guid": "M9weaekvIUimiJSVJqZl-w",
"item_outfit_type": 0,
"mask_guid": "GV8z2dlqGEqIAYaGrF1ZYQ"
},
{
"swatch_name": "Hat_Baseball_ThreeColor_BlueRed_Swatch",
"swatch_guid": "nZor-clxlkm8EO9Gco0Otg",
"color": "ThreeColor_BlueRed",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": "r_GLekt6mkWxXw8zPPR8Mg"
},
{
"swatch_name": "PirateCaptain_Hat_Black_Swatch",
"swatch_guid": "f9aaa7c5-59bc-486c-9a6e-27a8ac618f84",
"color": "Hat_Black",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Wizard_Purple_Swatch",
"swatch_guid": "d3KmqVuMQkCYK6sVTMOTOA",
"color": "Purple",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BaseballHelmet_Red_Swatch",
"swatch_guid": "a1c51d1e-1d5f-4f8a-bc0f-6a20eddfe58a",
"color": "Red",
"item_name": "(Hat_BaseballHelmet)",
"item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12",
"item_outfit_type": 0,
"mask_guid": "FVdDQFOMQ06teQm2ANDPng"
},
{
"swatch_name": "TruckerOveralls_Mustard_Swatch",
"swatch_guid": "nKauYAyWhkm2AMHFyC-fLg",
"color": "Mustard",
"item_name": "(Shirt_TruckerOveralls)",
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Black_Swatch",
"swatch_guid": "d81d0b62-e052-46c2-9252-4bc5fb219f53",
"color": "Black",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "BlazerGreyDark_Swatch",
"swatch_guid": "9374bf66-2ee5-493b-8439-efce4b201904",
"color": "GreyDark",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "MagicalGirl_Wrist_Red_Swatch",
"swatch_guid": "N9CBuYaMtUOrCks5UcOycg",
"color": "Wrist_Red",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "ZtCqMnFTQUWv2h0MfZ4m6A"
},
{
"swatch_name": "PirateCaptain_Belt_Black_Swatch",
"swatch_guid": "f4ad4b9e-dd42-4711-9437-5beae51966f6",
"color": "Belt_Black",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": "9b2102f0-e16b-464f-9f1c-f068de52bb89"
},
{
"swatch_name": "BowlingShirt_Flames_Red_Swatch",
"swatch_guid": "8xjU4GQ4OkaqGyad-ypwtw",
"color": "Shirt_Flames_Red",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "5RUG6i2Mx0-55IO-IlhUdQ"
},
{
"swatch_name": "BabydollDress_Black_Swatch",
"swatch_guid": "e13cf33d-7588-4e2e-955e-f0a29d6380b7",
"color": "Black",
"item_name": "(BabydollDress)",
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Bowtie_SodaJerk_Swatch",
"swatch_guid": "e4IDo-fuH0C9YByzcX5k-A",
"color": "SodaJerk",
"item_name": "(Bowtie)",
"item_guid": "83be5ba4-525a-4781-a5f6-839c22e4d1d3",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "FlapperGlove_Black_Swatch",
"swatch_guid": "30c61e63-5e09-4a6f-8915-1e1fe4d55cf0",
"color": "Black",
"item_name": "(Wrist_FlapperGlove)",
"item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde",
"item_outfit_type": 200,
"mask_guid": "0e42108e-3b08-49b4-86c0-7984719e8380"
},
{
"swatch_name": "ButtonTop_Tan_Swatch",
"swatch_guid": "49f5864f-9d40-497c-88c8-e87f64d41d74",
"color": "Tan",
"item_name": "(ButtonTop)",
"item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Windbreaker_Blue_Swatch",
"swatch_guid": "EbVDqEJQOk6j0UvX3t-8rQ",
"color": "Blue",
"item_name": "(Wrist_80sWindbreaker)",
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Aviator_Goggles_Gray_Swatch",
"swatch_guid": "23428ca9-be75-4146-b400-df01d1dadac9",
"color": "s_Gray",
"item_name": "(Eye_AviatorGoggles)",
"item_guid": "47629ed1-5378-427c-ad61-4b44d2fe90cc",
"item_outfit_type": 10,
"mask_guid": "556f876a-70da-4104-b792-fca8ad67be0c"
},
{
"swatch_name": "SafariTorso_Green_Swatch",
"swatch_guid": "49bbd11a-e5c9-47ff-b04d-72eb321e8a57",
"color": "Torso_Green",
"item_name": "(Shirt_Safari)",
"item_guid": "1041d745-c5e9-4218-a3e1-198f5c9c418c",
"item_outfit_type": 101,
"mask_guid": "84c016ad-133f-45e3-a412-cac35612540b"
},
{
"swatch_name": "Scallywag_Hat_Blue_Swatch",
"swatch_guid": "53dd9e44-7096-438e-a7c3-11d7c307426f",
"color": "Hat_Blue",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": "a4903da3-249d-4f0a-a746-b547fda371ff"
},
{
"swatch_name": "BusinessTie_Valentine_Swatch",
"swatch_guid": "31jelNWFyk6FlWt4HyPAlw",
"color": "Valentine",
"item_name": "(BusinessTie)",
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Astronaut_Helmet_Orange_Swatch",
"swatch_guid": "JFalLDUvm0aMm3wh1UVfVg",
"color": "t_Orange",
"item_name": "(Hat_AstronautHelmet)",
"item_guid": "f218e2de-46fd-45b9-9e0e-670b6bb905e7",
"item_outfit_type": 0,
"mask_guid": "iQjkS1xYP0S63ltvKpkz5g"
},
{
"swatch_name": "ArcherGray_Quiver_Swatch",
"swatch_guid": "imDGL6dhrka-KliYlfpdgw",
"color": "Gray_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Vest_Red_Swatch",
"swatch_guid": "ffHKr5EcIU24PbAtWSnalw",
"color": "Red",
"item_name": "(Shirt_Vest)",
"item_guid": "3d9d52cb-6a3d-436b-90e7-e76f238329ee",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "HeadBackBow_Blue_Swatch",
"swatch_guid": "GmS0wKwzVEqdf3NoTMHG5A",
"color": "Blue",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "Headphones_EmissiveRed_Swatch",
"swatch_guid": "I1J-xY4YNU2dB_od4P_7Dg",
"color": "EmissiveRed",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "EquestrianJacket_Gray_Swatch",
"swatch_guid": "4828b50c-95b6-466a-bb25-514891d78202",
"color": "Gray",
"item_name": "(EquestrianJacket)",
"item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5",
"item_outfit_type": 101,
"mask_guid": "d344b8cc-85a8-4ace-9f92-38c84f396e99"
},
{
"swatch_name": "Hoodie_Skeleton_NoGlow_Swatch",
"swatch_guid": "s6DQxJ3gY0CG8L7JmcmRTQ",
"color": "Skeleton_NoGlow",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "AnglerHat_Blue_Swatch",
"swatch_guid": "d7f0ffd3-ccf9-465e-8afe-4d7efb78dd2c",
"color": "Hat_Blue",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": "efeb07ab-6253-436d-bb66-991ddad72e58"
},
{
"swatch_name": "FurCoat_Black_Swatch",
"swatch_guid": "43501b0c-eaaa-4ab3-9d03-2ca61d2e8fc9",
"color": "Black",
"item_name": "(FurCoat)",
"item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5",
"item_outfit_type": 101,
"mask_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6"
},
{
"swatch_name": "Scuba_eye_Contest_Swatch",
"swatch_guid": "ZSOuWuKVCkqaIKc5d23G8Q",
"color": "eye_Contest",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BowlingShirt_OneStripe_Green_Swatch",
"swatch_guid": "kLyM-6otSUy2ql5SEmnF9Q",
"color": "Shirt_OneStripe_Green",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "b-IM3eYMJUa40395O5XeLg"
},
{
"swatch_name": "FlapperHeadband_Gold_Swatch",
"swatch_guid": "b6c25a3b-125b-4aab-bed0-e6e5ced2c21c",
"color": "Gold",
"item_name": "(Hat_FlapperHeadband)",
"item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c",
"item_outfit_type": 0,
"mask_guid": "d9be3a98-9da9-419e-9dce-fe58a3ad9c4e"
},
{
"swatch_name": "Golfer_Shirt_Blue_Swatch",
"swatch_guid": "96e2b29e-e553-40f1-9eb6-8a9f7e64da39",
"color": "Shirt_Blue",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "6ce21c65-f4e9-41ac-be94-8eb1a08d5c76"
},
{
"swatch_name": "Wrist_VampireHunter_Red_Swatch",
"swatch_guid": "iJW8XGJfU0G12iXarjxhdQ",
"color": "Red",
"item_name": "(Wrist_VampireHunter)",
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
"item_outfit_type": 200,
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
},
{
"swatch_name": "Hibiscus_White_Swatch",
"swatch_guid": "ce465d53-3807-423f-baa0-bdba2a75ceb3",
"color": "White",
"item_name": "(Hat_Hibiscus)",
"item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "FlapperHeadband_Black_Swatch",
"swatch_guid": "565c17e0-496d-45bd-a2c3-564e118c06cc",
"color": "Black",
"item_name": "(Hat_FlapperHeadband)",
"item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c",
"item_outfit_type": 0,
"mask_guid": "d9be3a98-9da9-419e-9dce-fe58a3ad9c4e"
},
{
"swatch_name": "Shirt_TutorialGown3_Black_Swatch",
"swatch_guid": "GK3yolCNlEm3B_5DtHkE8w",
"color": "Black",
"item_name": "(Shirt_TutorialGown_3)",
"item_guid": "0yFzgVESokC3gFd3hrS8bg",
"item_outfit_type": 101,
"mask_guid": "yKb2A8M_m0GQDhFWCRUHTQ"
},
{
"swatch_name": "PeaCoat_Purple_Swatch",
"swatch_guid": "uiBklotTUEqRI2F67vyJyA",
"color": "Purple",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": "AZnPWCN41kmNwFwDqWGFWQ"
},
{
"swatch_name": "Winter_Scarf_Tree_Green_Swatch",
"swatch_guid": "-tDd0gdxQkuvL2id10AweQ",
"color": "Scarf_Tree_Green",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Headphones_TwoRed_Swatch",
"swatch_guid": "hf0gkFVS8ESvceRsC3LAXQ",
"color": "TwoRed",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Vest_HoundsTooth_Black_Swatch",
"swatch_guid": "m4W5D0Oa9kaWa-8I96YYzA",
"color": "HoundsTooth_Black",
"item_name": "(Shirt_Vest)",
"item_guid": "3d9d52cb-6a3d-436b-90e7-e76f238329ee",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Headphones_EmissiveBlue_Swatch",
"swatch_guid": "2sf-FU4WckGBDWUkVz4z1g",
"color": "EmissiveBlue",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Headphones_Purple_Swatch",
"swatch_guid": "a5nUvJZgTEGBUvdHx0Y6Ng",
"color": "Purple",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Winter_Scarf_Red_Swatch",
"swatch_guid": "2lD-mjl3pk2wWv24RY2tbA",
"color": "Scarf_Red",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "dUu7b-JFOky9c2lIVCNmcg"
},
{
"swatch_name": "Headphones_EmissiveOrange_Swatch",
"swatch_guid": "d9mwowRelE2lDKEA0UQMZQ",
"color": "EmissiveOrange",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Scuba_eye_Blue_Swatch",
"swatch_guid": "DRS8xu3AqUOy2L0RKF3f7g",
"color": "eye_Blue",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherGray_Swatch",
"swatch_guid": "61VZkFjB402brzWm4UsftA",
"color": "Gray",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hat_CatEars_Gray_Swatch",
"swatch_guid": "96e42c7a-2881-4299-a2fe-80c0d4d5ca26",
"color": "Gray",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
},
{
"swatch_name": "Hat_CatEars_Brown_Swatch",
"swatch_guid": "713781e5-ce47-41c6-a24f-94d42a565e30",
"color": "Brown",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
},
{
"swatch_name": "Rainhat_Hat_Red_Swatch",
"swatch_guid": "v3af60p1Q02NpdCqIRO9lw",
"color": "Hat_Red",
"item_name": "(Hat_Rainhat)",
"item_guid": "2bd53190-46f8-4956-9388-87c5a04efc4f",
"item_outfit_type": 0,
"mask_guid": "1hVOI7IuY0mKvtr2PZXLww"
},
{
"swatch_name": "WitchHunter_Green_Swatch",
"swatch_guid": "64545dcc-a8eb-4c69-a539-78ee73a2d327",
"color": "Green",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Gloves_Red_Swatch",
"swatch_guid": "SWvJWvMDpE-it5PFoPgERg",
"color": "Gloves_Red",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_White_Swatch",
"swatch_guid": "yA0KhVg19kWqG1UkyxQogQ",
"color": "White",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_White_Swatch",
"swatch_guid": "zu0Jk24WTkan6XWSwGU1Uw",
"color": "White",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "Scout_Sash_Blue_Swatch",
"swatch_guid": "67490e3b-0f9d-4bf8-84fb-54957b981c8c",
"color": "h_Blue",
"item_name": "(Neck_ScoutSash)",
"item_guid": "bf9dc196-5727-4752-8126-7ad5fcd96d55",
"item_outfit_type": 100,
"mask_guid": "66cc536d-8cb5-4262-b7f0-49a33b97ea10"
},
{
"swatch_name": "WaitressApron_Red_Swatch",
"swatch_guid": "HdwZhjrqxE6wuf68MnDhDw",
"color": "Red",
"item_name": "(Belt_WaitressApron)",
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "SpaceMarine_Pink_Swatch",
"swatch_guid": "a36d252b-16ac-4a82-bece-de145147e7d9",
"color": "Pink",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "Mobster_Hat_Brown_Swatch",
"swatch_guid": "1421caa9-9a0d-42c2-b847-1500ba2e483a",
"color": "Hat_Brown",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": "d759c88d-a159-4c26-86dd-b054bb05b3ed"
},
{
"swatch_name": "ElectricianTorso_Gray_Swatch",
"swatch_guid": "b995b419-1897-4c1d-98be-83d551f7ba71",
"color": "Torso_Gray",
"item_name": "(Hat_Electrician)",
"item_guid": "34654303-9760-447f-8614-1c26506db994",
"item_outfit_type": 0,
"mask_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a"
},
{
"swatch_name": "Shirt_MartyRecFly_Hipster_Swatch",
"swatch_guid": "kkvNbVRjcEKtChpy3W1bqQ",
"color": "Hipster",
"item_name": "(Shirt_MartyRecFly)",
"item_guid": "74c65554-0d15-409b-b9a8-0b2620a7d7e2",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Smuggler_SRank_Swatch",
"swatch_guid": "bzYdPb8GWUS-CgT4wrziIA",
"color": "SRank",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "Hat_SkiBuddy_White_Swatch",
"swatch_guid": "KwCEye0nsESFs-yj4h9sjQ",
"color": "White",
"item_name": "(Hat_SkiBuddy)",
"item_guid": "6QT99hx6DE6t9cKAs61i3w",
"item_outfit_type": 0,
"mask_guid": "c_t9XDbn-Eqml1O-wC5zJQ"
},
{
"swatch_name": "PeaCoat_Navy_Swatch",
"swatch_guid": "fvoaMN4M10WS6jiA6XlOJQ",
"color": "Navy",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": "AZnPWCN41kmNwFwDqWGFWQ"
},
{
"swatch_name": "Wrist_Grillmaster_Blue_Swatch",
"swatch_guid": "90qQe0AR3kOou0MsuoTmPA",
"color": "Blue",
"item_name": "(Wrist_Grillmaster)",
"item_guid": "q7pSsRMNHkqEJmYHuhhG6g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "FootballHelmet_Orange_Swatch",
"swatch_guid": "d8d490aa-7088-4163-9305-1441367f490c",
"color": "Helmet_Orange",
"item_name": "(Hat_FootBall)",
"item_guid": "5a38f864-7d1c-4989-8558-40b9668f78ef",
"item_outfit_type": 0,
"mask_guid": "7660e11a-7ef0-43d3-bc02-76b3a4aab064"
},
{
"swatch_name": "HeadBackBow_Red_Swatch",
"swatch_guid": "ZNKGkou_gU-1cxBFy7pDKw",
"color": "Red",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "Scientist_Blue_Swatch",
"swatch_guid": "KTidYfH0ckWxJGs5qzUBTQ",
"color": "Blue",
"item_name": "(Hair_Scientist)",
"item_guid": "f17a2f9e-1eab-44e8-b10a-1f53c5d0778c",
"item_outfit_type": 2,
"mask_guid": ""
},
{
"swatch_name": "RedFlowerDress_Gray_Swatch",
"swatch_guid": "1acb1f56-4509-449f-9c6a-065bdeaa9ee3",
"color": "Gray",
"item_name": "(RedFlowerDress)",
"item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6",
"item_outfit_type": 101,
"mask_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2"
},
{
"swatch_name": "Aviator_Jacket_Tan_Swatch",
"swatch_guid": "bea795cc-aabd-4229-84dd-49ab4f91a445",
"color": "Jacket_Tan",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Mobster_Hat_Purple_Swatch",
"swatch_guid": "PZx7Iae0pkiDaxT9t3-vgg",
"color": "Hat_Purple",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": "d759c88d-a159-4c26-86dd-b054bb05b3ed"
},
{
"swatch_name": "LumberJack_Green_Swatch",
"swatch_guid": "7amHTgEv-UOJSGl0ofo4mw",
"color": "Green",
"item_name": "(Shirt_Lumberjack)",
"item_guid": "350c5c1e-318b-4233-a7e5-1f49909d8e65",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Hat_Yellow_Swatch",
"swatch_guid": "L2iFQcL170OajqfW7t6sVw",
"color": "Hat_Yellow",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "BowlingShirt_CenterStripe_Black_Swatch",
"swatch_guid": "Gtq8yRlt1UeFW8U-sc7FiA",
"color": "Shirt_CenterStripe_Black",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "IWg9wuda4kaCilJk8JQpTQ"
},
{
"swatch_name": "Punk_WristPink_Swatch",
"swatch_guid": "f3b2330a-b981-4c7e-902f-1f57382f2388",
"color": "WristPink",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "MagicalGirl_Wrist_Orange_Swatch",
"swatch_guid": "xpkEQ6yjhU-WfFPSjvPdHg",
"color": "Wrist_Orange",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "ZtCqMnFTQUWv2h0MfZ4m6A"
},
{
"swatch_name": "FighterPilot_Black_Swatch",
"swatch_guid": "8c74ee27-3d56-401b-8a8c-7868a80770e8",
"color": "Black",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
},
{
"swatch_name": "MagicalGirl_Hat_Blue_Swatch",
"swatch_guid": "lhfOKGDAgEKWucerCmH7Fg",
"color": "Hat_Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "mG60EPIQIkaJNgOkonVmZg"
},
{
"swatch_name": "Windbreaker_Yellow_Swatch",
"swatch_guid": "C3ll7uPfUkeZ1QLX80_BpQ",
"color": "Yellow",
"item_name": "(Wrist_80sWindbreaker)",
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_Red_Swatch",
"swatch_guid": "6t2rCb6WWEW6pJbcjsAFCw",
"color": "Red",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "Hat_Baseball_Logo_Ramen_Swatch",
"swatch_guid": "cpTKHXjj0kulivZCKVQqiA",
"color": "Logo_Ramen",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "YellowBlueDress_White_Swatch",
"swatch_guid": "bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a",
"color": "White",
"item_name": "(YellowBlueDress)",
"item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Headphones_Orange_Swatch",
"swatch_guid": "JWBK5v78Y0uRVEXlD-Qk6Q",
"color": "Orange",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "TennisSkirt_Yellow_Swatch",
"swatch_guid": "c5deba2a-6e35-4b13-8e94-8ba5457f39df",
"color": "Yellow",
"item_name": "(TennisSkirt)",
"item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60",
"item_outfit_type": 101,
"mask_guid": "b75ef67d-00c3-4ac1-9b72-212032460294"
},
{
"swatch_name": "MagicalGirl_Red_Swatch",
"swatch_guid": "FE7_gQNBt0CXzMN6di5HWA",
"color": "Red",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "BowlingShirt_Flames_Green_Swatch",
"swatch_guid": "M4765UHzsUeNTwH-Pcxp1g",
"color": "Shirt_Flames_Green",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "5RUG6i2Mx0-55IO-IlhUdQ"
},
{
"swatch_name": "Smuggler_Grey_Swatch",
"swatch_guid": "90869e86-cbc5-4024-9d08-fcdc41c64eb5",
"color": "Grey",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "ArcherBlack_Swatch",
"swatch_guid": "9ad37173-e640-4b9e-a474-716d482b73d2",
"color": "Black",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "LaserTag_Cyan_Swatch",
"swatch_guid": "08b7c12d-3317-4d27-9b40-34737a709454",
"color": "Cyan",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BlazerDark_Swatch",
"swatch_guid": "05ac07e1-67f0-486c-abf5-a62866475abb",
"color": "Dark",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "Letterman_Wrist_Purple_Swatch",
"swatch_guid": "MS7EPNLJEEyMLC6_yHQYtA",
"color": "Wrist_Purple",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": "4EHIyZkOOEqnM-PuD_a5xw"
},
{
"swatch_name": "Letterman_Red_Swatch",
"swatch_guid": "VpqHNpPiY0CfRJEjRRM-mw",
"color": "Red",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "TrackSuit_Pink_Swatch",
"swatch_guid": "026ca50e-3a81-43cf-87f5-950687b7bbdd",
"color": "Pink",
"item_name": "(TrackSuit)",
"item_guid": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba",
"item_outfit_type": 101,
"mask_guid": "adae82eb-a4b9-4541-acf2-76cea839a593"
},
{
"swatch_name": "Witch_Contest_Hat_Swatch",
"swatch_guid": "PcUZrdKXhk2XHa5Gy0YnnA",
"color": "Contest_Hat",
"item_name": "(Shirt_Witch)",
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "QuiltedCoat_Orange_Swatch",
"swatch_guid": "-HYrY2A5Tk2UWMi4_hmYbg",
"color": "Orange",
"item_name": "(Shirt_Quiltedcoat)",
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CowboyWhite_Swatch",
"swatch_guid": "teVKVbVEsEyqOSuRdqHQzA",
"color": "White",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "Aviator_Hat_Tan_Swatch",
"swatch_guid": "c6cf096c-f0cb-4500-80a0-bc2916b5a426",
"color": "Hat_Tan",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": "ff5ae41e-e446-4a96-8c11-49d85556fa81"
},
{
"swatch_name": "Beekeeper_Shirt_Lavender_Swatch",
"swatch_guid": "lBe3yxuE40eBmTrnEHBqQQ",
"color": "Shirt_Lavender",
"item_name": "(Shirt_Beekeeper)",
"item_guid": "1ebbfdfc-f72b-4d70-99d4-4d527f896aa7",
"item_outfit_type": 101,
"mask_guid": "afd88a79-7f03-40f6-bcb4-c247dd86edc4"
},
{
"swatch_name": "PeaCoat_Tan_Swatch",
"swatch_guid": "PwsLxCnzOkSW-RGz3tNb1w",
"color": "Tan",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": "AZnPWCN41kmNwFwDqWGFWQ"
},
{
"swatch_name": "Baseball_Green_Swatch",
"swatch_guid": "e5de2e45-207c-46af-9a5e-eb01a0c621fe",
"color": "Green",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Shirt_SkiBuddy_Blue_Swatch",
"swatch_guid": "fXNkMWFMV0aXalK_y_8SHw",
"color": "Blue",
"item_name": "(Shirt_SkiBuddy)",
"item_guid": "6RhwWExXekaYpK0NGHSXGQ",
"item_outfit_type": 101,
"mask_guid": "HQQWccdyakaEmntPhvC6CQ"
},
{
"swatch_name": "BowlingShirt_Diamonds_Purple_Swatch",
"swatch_guid": "yrQCLXiqRUSwnuQRYX2Rsw",
"color": "Shirt_Diamonds_Purple",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "VO_3cgqQqUOTz30TVUxrFA"
},
{
"swatch_name": "Dodgeball_Gray_Swatch",
"swatch_guid": "0ba92db2-690a-44ce-92f4-130e9503b6c1",
"color": "Gray",
"item_name": "(Shirt_Dodgeball)",
"item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59",
"item_outfit_type": 101,
"mask_guid": "944d934d-5e21-42d7-8196-fff9a81ee469"
},
{
"swatch_name": "Aviator_Gloves_Black_Swatch",
"swatch_guid": "97bbb384-b37e-4ea1-902c-88fbd6854ab5",
"color": "Gloves_Black",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": "5659ce2c-9cb0-49da-8c39-4b7df0f8deec"
},
{
"swatch_name": "Hoodie_Gray_Swatch",
"swatch_guid": "JTgXKeT7w0K2Mw4Jk2HCqg",
"color": "Gray",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Headphones_Blue_Swatch",
"swatch_guid": "8ANbOhNnO0yLQOCIBsDwfg",
"color": "Blue",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Scallywag_Eye_SRank_Swatch",
"swatch_guid": "clFrpYWFQEmDkE14f3o_Ig",
"color": "Eye_SRank",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": "DE1C-RJkuEWIvpR0q1i42g"
},
{
"swatch_name": "Winter_Hat_Red_Swatch",
"swatch_guid": "6vjA_vHq70Gclypll_GS_A",
"color": "Hat_Red",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "nalaCGqtL0SFA4Itwqmp8g"
},
{
"swatch_name": "DeepSeaDiver_Black_Swatch",
"swatch_guid": "CahBzgN3D0y46AQsvGC14A",
"color": "Black",
"item_name": "(Hat_DeepSeaDiver)",
"item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Wizard_White_Swatch",
"swatch_guid": "d3a9b576-5433-4305-bd96-b08820c09212",
"color": "White",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Wrist_Grillmaster_Black_Swatch",
"swatch_guid": "hZKPr4sg10SRj9DLuEgOrw",
"color": "Black",
"item_name": "(Wrist_Grillmaster)",
"item_guid": "q7pSsRMNHkqEJmYHuhhG6g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Green_Swatch",
"swatch_guid": "ytmSzjhCr0Sm58MSNbwJUA",
"color": "Green",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Hat_White_Swatch",
"swatch_guid": "zlqLrGEa8E2WW9ey_szREA",
"color": "Hat_White",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "Hoodie_Blue_Swatch",
"swatch_guid": "u_Gzj9Nro0WS48xKnH9few",
"color": "Blue",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Ranger_Shirt_Green_Swatch",
"swatch_guid": "b415fe60-3e9d-4f4d-a53a-ca15eac6af7d",
"color": "Shirt_Green",
"item_name": "(Hat_Ranger)",
"item_guid": "f277af6a-0807-4716-8948-1bee236ffb74",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Shirt_TutorialGown2_Orange_Swatch",
"swatch_guid": "L333w3dfIEWDbtt1Yn1gBg",
"color": "Orange",
"item_name": "(Shirt_TutorialGown_2)",
"item_guid": "MJQiQ3LATEOymxCC8wfSjw",
"item_outfit_type": 101,
"mask_guid": "lSDrKR69rkSFULy9GA__1w"
},
{
"swatch_name": "ElectricianTorso_Yellow_Swatch",
"swatch_guid": "b539777f-48b2-44a2-9d63-17ac703e4d57",
"color": "Torso_Yellow",
"item_name": "(Hat_Electrician)",
"item_guid": "34654303-9760-447f-8614-1c26506db994",
"item_outfit_type": 0,
"mask_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a"
},
{
"swatch_name": "QueenCrown_Black_Swatch",
"swatch_guid": "rmq1AOQvtUGOHvbPUximwg",
"color": "Black",
"item_name": "(Hat_QueenCrown)",
"item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9",
"item_outfit_type": 0,
"mask_guid": "XFuSiGupZ02Fut1Dxum_Iw"
},
{
"swatch_name": "Shirt_VampireHunter_Midnight_Swatch",
"swatch_guid": "CavRDqIlmUeTZBtCLn1hZQ",
"color": "Midnight",
"item_name": "(Shirt_VampireHunter)",
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
"item_outfit_type": 101,
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
},
{
"swatch_name": "Jazzercise_Black_Swatch",
"swatch_guid": "3b2cfc85-4e2a-4e0d-b738-c296ae8692f8",
"color": "Black",
"item_name": "(Hair_Jazzercise)",
"item_guid": "49397e16-1027-4286-9bc4-b59eae565ff0",
"item_outfit_type": 2,
"mask_guid": ""
},
{
"swatch_name": "FieldHockey_Blue_Swatch",
"swatch_guid": "7b0d6661-e856-4ec1-ab90-d89c28a70826",
"color": "Blue",
"item_name": "(FieldHockey)",
"item_guid": "a986ff33-9e16-4219-8ebb-2e7b4c772ca7",
"item_outfit_type": 101,
"mask_guid": "1fede91a-434e-49a5-882e-e6db107112a8"
},
{
"swatch_name": "ArcherTan_Glove_Swatch",
"swatch_guid": "d5987fee-e3cc-4336-853a-5df57f73df05",
"color": "Tan_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FlowerSwimsuit_Green_Swatch",
"swatch_guid": "cf515c87-3c0a-4443-9f63-5ea1bdbaa61a",
"color": "Green",
"item_name": "(FlowerSwimsuit)",
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
"item_outfit_type": 101,
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
},
{
"swatch_name": "Letterman_Purple_Swatch",
"swatch_guid": "iufpnteVCEiiZu_m-yPovQ",
"color": "Purple",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Beekeeper_Shirt_Gold_Swatch",
"swatch_guid": "1919d319-6cce-4500-8766-15fed6a13fa8",
"color": "Shirt_Gold",
"item_name": "(Shirt_Beekeeper)",
"item_guid": "1ebbfdfc-f72b-4d70-99d4-4d527f896aa7",
"item_outfit_type": 101,
"mask_guid": "afd88a79-7f03-40f6-bcb4-c247dd86edc4"
},
{
"swatch_name": "Angler_Blue_Swatch",
"swatch_guid": "6e0f3af8-297f-4ea7-8f9e-4a1bc57d3e35",
"color": "Blue",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Hat_CatEars_White_Swatch",
"swatch_guid": "c1e430c5-fd8d-4032-9e3f-8d87cc8784da",
"color": "White",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
},
{
"swatch_name": "Smuggler_Dark_Swatch",
"swatch_guid": "e821aa74-2d47-4388-bcfb-1893b8a83a52",
"color": "Dark",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "ScoutSkirt_Shirt_Red_Swatch",
"swatch_guid": "2bb65868-ee12-4871-b50d-64bbc9066f0b",
"color": "Shirt_Red",
"item_name": "(Shirt_ScoutSkirt)",
"item_guid": "35f60d27-22cf-4e17-8f39-c9e3a9fa2291",
"item_outfit_type": 101,
"mask_guid": "e65530f7-543c-43e9-bf12-b6cda2bc755c"
},
{
"swatch_name": "Artist_Blue_Swatch",
"swatch_guid": "8c94e45c-6605-4b4f-9ba4-2c6c6e557228",
"color": "Blue",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hat_CatEars_Gold_Swatch",
"swatch_guid": "37f8378b-b98c-477a-b8c9-9340772800b5",
"color": "Gold",
"item_name": "(Hat_CatEars)",
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
"item_outfit_type": 0,
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
},
{
"swatch_name": "LaserTagVisor_Green_Swatch",
"swatch_guid": "-e6bmXnwZU2EVwZ8RupKbg",
"color": "Visor_Green",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Headphones_TwoBlue_Swatch",
"swatch_guid": "4scG_UBlzE-5DWRhSrtm6g",
"color": "TwoBlue",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "CollarShirt_Torso_Red_Swatch",
"swatch_guid": "FAviMCQ_EE2Mpt6QPo5OEw",
"color": "Torso_Red",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Pearls_Neck_Black_Swatch",
"swatch_guid": "sqPMH0LhLkeG-4XkxpU-sw",
"color": "Neck_Black",
"item_name": "(Neck_Pearls)",
"item_guid": "7be2eafc-21ea-4266-ae64-8888c06798d6",
"item_outfit_type": 103,
"mask_guid": ""
},
{
"swatch_name": "ArcherYellow_Glove_Swatch",
"swatch_guid": "47da86b9-4e6e-423c-b8c4-ea6bf8472509",
"color": "Yellow_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Neck_Orange_Swatch",
"swatch_guid": "S9uymuvraUCOM8gmB7bnEA",
"color": "Neck_Orange",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "M5z2wk-GDkOAjvZzqsfcNQ"
},
{
"swatch_name": "TruckerJacket_Tan_Swatch",
"swatch_guid": "o2WfRyjJy0CKoUg8YIxNjQ",
"color": "Tan",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "HockeyShirt_Red_Swatch",
"swatch_guid": "b1ea158d-1663-4db1-bb09-9847f6e9d1e6",
"color": "Red",
"item_name": "(HockeyShirt)",
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
"item_outfit_type": 101,
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
},
{
"swatch_name": "Hat_SeaCaptain_Contest_Swatch",
"swatch_guid": "hhs9GY3XZkWb-8TXODvo6A",
"color": "Contest",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "YellowBlueDress_Purple_Swatch",
"swatch_guid": "830be2fa-60a5-48cc-931f-34b670eae4bd",
"color": "Purple",
"item_name": "(YellowBlueDress)",
"item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_Green_Swatch",
"swatch_guid": "3frH6KxZ9kGUYkjeCDpylQ",
"color": "Green",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "Hat_Baseball_Logo_Bucketeers_Swatch",
"swatch_guid": "5iCrYb8MBkqN6IBEe3IipQ",
"color": "Logo_Bucketeers",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BowlingShirt_OneStripe_Pink_Swatch",
"swatch_guid": "-k9_-2onrEG407or8t1RFw",
"color": "Shirt_OneStripe_Pink",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "b-IM3eYMJUa40395O5XeLg"
},
{
"swatch_name": "LaserTagGlove_Orange_Swatch",
"swatch_guid": "EC0ttvk6eU2HEMacLAx2Iw",
"color": "Orange",
"item_name": "(Wrist_LaserTagGlove)",
"item_guid": "3044adce-90c0-4f66-81f5-39c4fca86fdf",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "50sDress_Red_Swatch",
"swatch_guid": "Yr0mF1EWzEaIyRMOWFiY6w",
"color": "Red",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "Firefighter_Hat_Yellow_Swatch",
"swatch_guid": "4c26d1e7-2382-4478-b9c4-a068ed01a607",
"color": "Hat_Yellow",
"item_name": "(Hat_Firefighter)",
"item_guid": "bb54e0e9-5d90-41f3-94c0-8e82f1791af1",
"item_outfit_type": 0,
"mask_guid": "c8ccb85e-52cd-4b3b-aadf-289819067125"
},
{
"swatch_name": "TopHat_White_Swatch",
"swatch_guid": "DzgN67HoikexE2rTlZLdYQ",
"color": "White",
"item_name": "(Hat_TopHat)",
"item_guid": "102c625b-b988-4bf8-a2aa-a31ad7029cdc",
"item_outfit_type": 0,
"mask_guid": "ccf1ccc1-e229-4157-bb74-f2cdef01e547"
},
{
"swatch_name": "DeepSeaDiver_Beige_Swatch",
"swatch_guid": "Su7kiW24d0KapNns96JoFQ",
"color": "Beige",
"item_name": "(Hat_DeepSeaDiver)",
"item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scallywag_Bracelet_Red_Swatch",
"swatch_guid": "62bd5dda-08da-4528-83c1-f0ca3812dc6e",
"color": "Red",
"item_name": "(Wrist_Scallywag_Bracelet)",
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "QuiltedCoat_Green_Swatch",
"swatch_guid": "QBglgj80zkuvUkk55Y5Feg",
"color": "Green",
"item_name": "(Shirt_Quiltedcoat)",
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Scout_RaccoonHat_Gray_Swatch",
"swatch_guid": "926ae943-76cb-46dd-9c66-a6ee44ea9194",
"color": "nHat_Gray",
"item_name": "(Hat_ScoutRaccoon)",
"item_guid": "7c1552a6-bc36-46e6-884c-1de6b8111a92",
"item_outfit_type": 0,
"mask_guid": "f1cd10fb-3d0b-436f-a173-65bc0d88d8bf"
},
{
"swatch_name": "Shirt_GraduationGown_Green_Swatch",
"swatch_guid": "iZy7RmfAz0603oMO-jD-WA",
"color": "Green",
"item_name": "(Shirt_GraduationGown)",
"item_guid": "U_H976qYRUmg9e2clsAflg",
"item_outfit_type": 101,
"mask_guid": "ica0LZyM_kOox8gxW7CaMw"
},
{
"swatch_name": "WaitressApron_Mustard_Swatch",
"swatch_guid": "b7oobDcDtE-69fhM-5g5AQ",
"color": "Mustard",
"item_name": "(Belt_WaitressApron)",
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "DrumMajor_Orange_Swatch",
"swatch_guid": "82de1ca3-67d6-454d-84fb-0eeece71fcbc",
"color": "Orange",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "JackfrostWings_Neck_Lilac_Swatch",
"swatch_guid": "H6fcuLxeBUSY3oERFc-plA",
"color": "Neck_Lilac",
"item_name": "(Neck_JackFrostWings)",
"item_guid": "022ce375-ed02-479d-b87d-9d72d126bbd4",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "PurpleJacket_Blue_Swatch",
"swatch_guid": "b42d903b-b393-4f49-af5e-38d0fb6c42b0",
"color": "Blue",
"item_name": "(PurpleJacket)",
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
"item_outfit_type": 101,
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
},
{
"swatch_name": "CowboyBlack_Swatch",
"swatch_guid": "K5vPqD3BrEuODRDzFdC6Hw",
"color": "Black",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "Harness_Teal_Swatch",
"swatch_guid": "TnEDe1QoMUCUdqPL_7CxTA",
"color": "Teal",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Angler_Tan_Swatch",
"swatch_guid": "cac27853-e2df-4d77-b3fd-af3bc0813f01",
"color": "Tan",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "50sDress_Pink_Swatch",
"swatch_guid": "eb3SmJTIh0628LrZUkI4KQ",
"color": "Pink",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "Scout_Sash_Brown_Swatch",
"swatch_guid": "639f5e2c-6fbd-4ab4-aa10-7772e0932798",
"color": "h_Brown",
"item_name": "(Neck_ScoutSash)",
"item_guid": "bf9dc196-5727-4752-8126-7ad5fcd96d55",
"item_outfit_type": 100,
"mask_guid": "66cc536d-8cb5-4262-b7f0-49a33b97ea10"
},
{
"swatch_name": "Unicorn_Pastel_Swatch",
"swatch_guid": "FNe9LkWJMUeC3frYJMyEaQ",
"color": "Pastel",
"item_name": "(Hat_Unicorn)",
"item_guid": "2caHvVQ1vECrPv-YcdxdyA",
"item_outfit_type": 0,
"mask_guid": "nW_fBsuZA0OfIjTMXm6ZCA"
},
{
"swatch_name": "Countess_Black_Swatch",
"swatch_guid": "i5RKLo-RbEqisnGII1wpaQ",
"color": "Black",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Smuggler_Cherry_Swatch",
"swatch_guid": "fed760b2-15e2-46b2-b1e3-cc3c8d780d5d",
"color": "Cherry",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "Astronaut_Torso_Orange_Swatch",
"swatch_guid": "9GhnX4Pj4E-wxbPlD8GB-g",
"color": "o_Orange",
"item_name": "(AstronautTorso)",
"item_guid": "ec5255d5-edaf-4888-9fe1-e89ac0e0d300",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "AnglerHat_Gray_Swatch",
"swatch_guid": "225209dc-3afc-40cf-858e-89f7afa00c0d",
"color": "Hat_Gray",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": "efeb07ab-6253-436d-bb66-991ddad72e58"
},
{
"swatch_name": "BaseballHelmet_Green_Swatch",
"swatch_guid": "9967ce54-c2b7-437a-946e-11f8ee6d6fdd",
"color": "Green",
"item_name": "(Hat_BaseballHelmet)",
"item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12",
"item_outfit_type": 0,
"mask_guid": "FVdDQFOMQ06teQm2ANDPng"
},
{
"swatch_name": "Neck_Grillmaster_White_Swatch",
"swatch_guid": "Lxx3q6JRnUW_cPG_kGG4ag",
"color": "White",
"item_name": "(Neck_Grillmaster)",
"item_guid": "tTKv7eRODka2KC_hsY2O3Q",
"item_outfit_type": 100,
"mask_guid": "t-ZEWJCgFEyGP2PjNblQMA"
},
{
"swatch_name": "50sDress_Teal_Swatch",
"swatch_guid": "QzjakcO8tUaPeiI5KBLKtg",
"color": "Teal",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "BowlingShirt_Diamonds_Teal_Swatch",
"swatch_guid": "yZR5aX3gQ0WpRUT6cfK1cA",
"color": "Shirt_Diamonds_Teal",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "VO_3cgqQqUOTz30TVUxrFA"
},
{
"swatch_name": "Scallywag_Bracelet_Blue_Swatch",
"swatch_guid": "cdca593a-8840-4140-9145-fe962732040c",
"color": "Blue",
"item_name": "(Wrist_Scallywag_Bracelet)",
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Harness_Black_Swatch",
"swatch_guid": "AC3zfm38JEyBZWuXDh4Ukg",
"color": "Black",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CowboyDark_Swatch",
"swatch_guid": "192ba73d-6990-42da-8fe0-914c2a7181eb",
"color": "Dark",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "SodaJerk_Mustard_Swatch",
"swatch_guid": "MxnAq_XOw0uTzQ8IIxqb_Q",
"color": "Mustard",
"item_name": "(Shirt_SodaJerk)",
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FlowerSwimsuit_Pink_Swatch",
"swatch_guid": "27680277-aec1-41be-a476-9f51cf775580",
"color": "Pink",
"item_name": "(FlowerSwimsuit)",
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
"item_outfit_type": 101,
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
},
{
"swatch_name": "Artist_Red_Swatch",
"swatch_guid": "17b16982-871c-476a-860f-f6953c89ee8c",
"color": "Red",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Pink_Swatch",
"swatch_guid": "TEgugZRs80iJbhL87RdYwg",
"color": "Pink",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "50sDress_White_Swatch",
"swatch_guid": "BLgkgLeS-EyazgPQxGcavg",
"color": "White",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "Hat_TutorialCap_3_Swatch",
"swatch_guid": "wHJnbDOo8ESmUbQDr0k31w",
"color": "3",
"item_name": "(Hat_TutorialCap)",
"item_guid": "M9weaekvIUimiJSVJqZl-w",
"item_outfit_type": 0,
"mask_guid": "GV8z2dlqGEqIAYaGrF1ZYQ"
},
{
"swatch_name": "Scout_Sash_Red_Swatch",
"swatch_guid": "b520f3bc-599a-498c-8a4f-97cc25aa741f",
"color": "h_Red",
"item_name": "(Neck_ScoutSash)",
"item_guid": "bf9dc196-5727-4752-8126-7ad5fcd96d55",
"item_outfit_type": 100,
"mask_guid": "66cc536d-8cb5-4262-b7f0-49a33b97ea10"
},
{
"swatch_name": "DrumMajor_Green_Swatch",
"swatch_guid": "fe5288bc-0e02-4ea3-b17d-dee0e576979d",
"color": "Green",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Hat_SRank_Swatch",
"swatch_guid": "jQwNBXyoU0Gk4FyNw6jFxg",
"color": "Hat_SRank",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "50sDress_CandyCane_Swatch",
"swatch_guid": "PEGqjoxP6E2yRx_A-HphrQ",
"color": "CandyCane",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "SpaceMarine_Cyan_Swatch",
"swatch_guid": "c8cc9589-4831-4e73-a865-ef8c139a2ba7",
"color": "Cyan",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "MagicalGirl_Green_Swatch",
"swatch_guid": "kTP3xKhzBkuQF3SqvmYFKA",
"color": "Green",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "FootballPads_Orange_Swatch",
"swatch_guid": "32dde6dc-e3dc-43ee-9b7c-987bc6778c17",
"color": "Pads_Orange",
"item_name": "(Hat_FootBall)",
"item_guid": "5a38f864-7d1c-4989-8558-40b9668f78ef",
"item_outfit_type": 0,
"mask_guid": "e17481fc-b2b6-47a5-91e5-7205f45a3247"
},
{
"swatch_name": "HeadBackBow_White_Swatch",
"swatch_guid": "-Iqmonry4EKoqAULCKzRPA",
"color": "White",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "Hoodie_Orange_Swatch",
"swatch_guid": "rNhOQoP3XkOBHV28ze2NpA",
"color": "Orange",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Yellow_Swatch",
"swatch_guid": "dyEaPloE8kazWC7Zblv8uQ",
"color": "Yellow",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Hat_Red_Swatch",
"swatch_guid": "17040a88-87f1-4613-b439-3316e33dbd48",
"color": "Hat_Red",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_Blue_Swatch",
"swatch_guid": "Lf4FjROSAEqXedFxpXFQfg",
"color": "Blue",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "PurpleJacket_Black_Swatch",
"swatch_guid": "8cab8cc6-9a2d-434c-8b72-e66edbdd223c",
"color": "Black",
"item_name": "(PurpleJacket)",
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
"item_outfit_type": 101,
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
},
{
"swatch_name": "Mobster_Valentine_Swatch",
"swatch_guid": "4VkPVbBVLE-eJHilwGMAbQ",
"color": "Valentine",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Hat_SeaCaptainFushia_Swatch",
"swatch_guid": "2738ab5f-8c94-46b3-bac6-4a8111c41c21",
"color": "Fushia",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "Luchador_Red_Swatch",
"swatch_guid": "916384ca-e209-42a0-bd64-c64f5a40b56f",
"color": "Red",
"item_name": "(Hat_Luchador)",
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Scarecrow_Wrist_Contest_Swatch",
"swatch_guid": "dro7VLV8BkGjqpPAbJheYA",
"color": "Wrist_Contest",
"item_name": "(Shirt_Scarecrow)",
"item_guid": "1fcaf4fd-9012-407e-b691-fbc3ccf22421",
"item_outfit_type": 101,
"mask_guid": "bNA44H2wzUy-FkWgyOq1Kw"
},
{
"swatch_name": "FlapperGlove_Gold_Swatch",
"swatch_guid": "2cf2b644-3fa6-449c-9fe8-d0ab917f3106",
"color": "Gold",
"item_name": "(Wrist_FlapperGlove)",
"item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde",
"item_outfit_type": 200,
"mask_guid": "0e42108e-3b08-49b4-86c0-7984719e8380"
},
{
"swatch_name": "BlueJacket_Mint_Swatch",
"swatch_guid": "cd5d7285-202d-42d0-b93f-04245875793e",
"color": "Mint",
"item_name": "(BlueJacket)",
"item_guid": "62ce4109-8dee-4895-bf1b-bfa143db4c7e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "HeadBackBow_SolidBlack_Swatch",
"swatch_guid": "f_c50saStkaGJuhLjJ9lfA",
"color": "SolidBlack",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "FootballPads_Dark_Swatch",
"swatch_guid": "88c3ed05-ce41-4a9d-a4a7-8a428d240b3f",
"color": "Pads_Dark",
"item_name": "(Hat_FootBall)",
"item_guid": "5a38f864-7d1c-4989-8558-40b9668f78ef",
"item_outfit_type": 0,
"mask_guid": "e17481fc-b2b6-47a5-91e5-7205f45a3247"
},
{
"swatch_name": "MagicalGirl_Neck_Red_Swatch",
"swatch_guid": "_uh3kj6hKUyBKOSR3R8dog",
"color": "Neck_Red",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "M5z2wk-GDkOAjvZzqsfcNQ"
},
{
"swatch_name": "Aviator_Hat_Black_Swatch",
"swatch_guid": "a8de80d3-286a-4fdc-bed2-75d52f8c0964",
"color": "Hat_Black",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": "ff5ae41e-e446-4a96-8c11-49d85556fa81"
},
{
"swatch_name": "Aviator_Goggles_Black_Swatch",
"swatch_guid": "7b82c3c5-fd1f-45ec-b83f-e32668aa8dce",
"color": "s_Black",
"item_name": "(Eye_AviatorGoggles)",
"item_guid": "47629ed1-5378-427c-ad61-4b44d2fe90cc",
"item_outfit_type": 10,
"mask_guid": "556f876a-70da-4104-b792-fca8ad67be0c"
},
{
"swatch_name": "Mobster_Fashion_Gold_Swatch",
"swatch_guid": "46970903-9799-4d25-b90a-7162c1f5dbef",
"color": "Fashion_Gold",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArcherSRank_Glove_Swatch",
"swatch_guid": "1VGZ1QCvcU6rsKVkUDBNjQ",
"color": "SRank_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Shirt_Grey_Swatch",
"swatch_guid": "4c845dbb-26c3-48a1-b562-e28a7416b154",
"color": "Shirt_Grey",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "6ce21c65-f4e9-41ac-be94-8eb1a08d5c76"
},
{
"swatch_name": "Hat_SeaCaptainDarkGold_Swatch",
"swatch_guid": "f5403c89-9769-479b-8a05-c71607f9b189",
"color": "DarkGold",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "CollarShirt_Torso_Blue_Swatch",
"swatch_guid": "0iSsaY-HgkmLaRHCn5vEdw",
"color": "Torso_Blue",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Letterman_Black_Swatch",
"swatch_guid": "Z1F9rjKqCE-Hd2-Ip5g3gA",
"color": "Black",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "DrumMajor_Purple_Swatch",
"swatch_guid": "54d3c589-30ba-44c1-a77c-8f9e16eb0b20",
"color": "Purple",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Belt_Red_Swatch",
"swatch_guid": "0f71b9a2-dd88-435c-a571-4503d12dcf2d",
"color": "Belt_Red",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": "9b2102f0-e16b-464f-9f1c-f068de52bb89"
},
{
"swatch_name": "ArcherBlack_Glove_Swatch",
"swatch_guid": "2be2f040-68e4-44dd-9099-29f11110f3a4",
"color": "Black_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WitchHunter_Blue_Swatch",
"swatch_guid": "aBQ3R9boek-mYeSYSNvDbQ",
"color": "Blue",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Sherlock_Hat_Gray_Swatch",
"swatch_guid": "MQOxmUk-mUG-Ee9m9nX31Q",
"color": "Hat_Gray",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": "axPBGoqorEe77Oqqjfzb5w"
},
{
"swatch_name": "Steampunk_Contest_Swatch",
"swatch_guid": "fD5vtKxBWECH54Jj4FbnUg",
"color": "Contest",
"item_name": "(Shirt_SteamPunk)",
"item_guid": "E9VTiIVFPEa-OuQRto0e_Q",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hoodie_Purple_Swatch",
"swatch_guid": "mvMeyStxGUGlNLSYNeSubQ",
"color": "Purple",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FighterPilot_Blue_Swatch",
"swatch_guid": "e37b07d1-6b9d-49b9-94ca-1c728e228cfe",
"color": "Blue",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
},
{
"swatch_name": "AnglerHat_Tan_Swatch",
"swatch_guid": "3f6c8bd2-2c1d-4c8c-a9de-16600fee9645",
"color": "Hat_Tan",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": "efeb07ab-6253-436d-bb66-991ddad72e58"
},
{
"swatch_name": "Golfer_Glove_Blue_Swatch",
"swatch_guid": "32a5e42d-2b60-40b1-a145-ed96fc91af8e",
"color": "Glove_Blue",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "f4cea991-fc3d-4715-bf82-8a8fb60e8799"
},
{
"swatch_name": "Steampunk_Femme_Contest_Hat_Swatch",
"swatch_guid": "vuSwk04zykCe3ANKN838OA",
"color": "e_Contest_Hat",
"item_name": "(Hat_SteampunkFemme)",
"item_guid": "JWanxvOHyESg9F_HlmTOfA",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "GoldMedal_Bronze_Swatch",
"swatch_guid": "iaDL2im4lkCXUo0NWM-F3g",
"color": "Bronze",
"item_name": "(GoldMedal)",
"item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "DrumMajorGlove_Purple_Swatch",
"swatch_guid": "8053b5e5-0f67-4329-a45d-1ee9fc830820",
"color": "Purple",
"item_name": "(Wrist_DrumMajor_Glove)",
"item_guid": "4d533b3a-b5fa-446a-842d-92798ad5b493",
"item_outfit_type": 200,
"mask_guid": "6b38ab79-142b-446e-978c-ad829ade8823"
},
{
"swatch_name": "50sDress_Halloween_Swatch",
"swatch_guid": "ZvPidQ57rUS50Qz89ERmLw",
"color": "Halloween",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "TruckerOveralls_Red_Swatch",
"swatch_guid": "iokdlBfjuEuAwqIMOQwxdA",
"color": "Red",
"item_name": "(Shirt_TruckerOveralls)",
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ElectricianTorso_Green_Swatch",
"swatch_guid": "67fc9269-8dd8-4d6f-b594-c77e716f2482",
"color": "Torso_Green",
"item_name": "(Hat_Electrician)",
"item_guid": "34654303-9760-447f-8614-1c26506db994",
"item_outfit_type": 0,
"mask_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a"
},
{
"swatch_name": "QuiltedCoat_Plum_Swatch",
"swatch_guid": "IjRMopsYlUqSE6327I9TIQ",
"color": "Plum",
"item_name": "(Shirt_Quiltedcoat)",
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WitchHunter_SRank_Swatch",
"swatch_guid": "Tbrc2MlSlE6mKua8qSoluQ",
"color": "SRank",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_RebelRose_Swatch",
"swatch_guid": "4Eck_dNL1UKlIZWsA1YppQ",
"color": "RebelRose",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "GoldMedal_Silver_Swatch",
"swatch_guid": "ERnUPsyT6kuEnIRZF8zPwg",
"color": "Silver",
"item_name": "(GoldMedal)",
"item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "Turtleneck_White_Swatch",
"swatch_guid": "Zz4XatKfUEapReRAQHxKKA",
"color": "White",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Sherlock_Torso_Black_Swatch",
"swatch_guid": "Z1Ib9r_fkUqgoZk0uid_Xw",
"color": "Torso_Black",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": "9ZP617DQk0mZ10J3d5q9oA"
},
{
"swatch_name": "DrumMajorGlove_Orange_Swatch",
"swatch_guid": "b444ed0a-eab5-4bd6-af34-d080de74a149",
"color": "Orange",
"item_name": "(Wrist_DrumMajor_Glove)",
"item_guid": "4d533b3a-b5fa-446a-842d-92798ad5b493",
"item_outfit_type": 200,
"mask_guid": "6b38ab79-142b-446e-978c-ad829ade8823"
},
{
"swatch_name": "LaserTagVisor_Orange_Swatch",
"swatch_guid": "oT1vLrHW6UC2hWLIT3f_SQ",
"color": "Visor_Orange",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Punk_WristRed_Swatch",
"swatch_guid": "baffdefe-de21-4551-9330-b50873965bdd",
"color": "WristRed",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "Winter_Gloves_Tree_Swatch",
"swatch_guid": "afWY5VGiF0isGn6qpo4KeQ",
"color": "Gloves_Tree",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Punk_Green_Swatch",
"swatch_guid": "0724b166-9ace-46ff-a191-b13f135b00a8",
"color": "Green",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "ArcherRed_Glove_Swatch",
"swatch_guid": "0c433086-9501-48c4-8d0e-4474eb420c61",
"color": "Red_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Lacrosse_Stripe_Purple_Swatch",
"swatch_guid": "e3454bc7-c88d-4958-8b81-db3fe5472a1e",
"color": "Stripe_Purple",
"item_name": "(Shirt_Lacrosse)",
"item_guid": "39278302-b52a-48a8-a566-ed756a5fa1a9",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Hat_Baseball_Logo_Goblins_Swatch",
"swatch_guid": "pqLkLbri9Eu48eryQIFGMg",
"color": "Logo_Goblins",
"item_name": "(Hat_Baseball)",
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Letterman_Wrist_Green_Swatch",
"swatch_guid": "UUyjVU4mvECPhI9xTMYR1Q",
"color": "Wrist_Green",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": "4EHIyZkOOEqnM-PuD_a5xw"
},
{
"swatch_name": "Winter_Torso_Tree_Swatch",
"swatch_guid": "XOBIPrKCr02xa4tKj8wN9g",
"color": "Torso_Tree",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "Too5jpb1w0iQenkjRRS-QA"
},
{
"swatch_name": "ArcherSRank_Quiver_Swatch",
"swatch_guid": "29EZ6r2SsUakWjzuevxeoA",
"color": "SRank_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Headphones_TwoOrange_Swatch",
"swatch_guid": "nX7krD8e2km_ZlOLWcoxdQ",
"color": "TwoOrange",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Headphones_TwoPurple_Swatch",
"swatch_guid": "OqsbZ8Xls0i8uA6ha7H3qQ",
"color": "TwoPurple",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Wizard_Green_Swatch",
"swatch_guid": "357eb160-360b-4a63-840d-65d11cc8ffc2",
"color": "Green",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Wrist_Green_Swatch",
"swatch_guid": "1FcyrVGyGEC9dmOq_51nFQ",
"color": "Wrist_Green",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "ZtCqMnFTQUWv2h0MfZ4m6A"
},
{
"swatch_name": "Scuba_Shirt_Contest_Swatch",
"swatch_guid": "J-K34UX_B0yObjQI9tn0vQ",
"color": "Shirt_Contest",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PeaCoat_Red_Swatch",
"swatch_guid": "jXBi28cRV0CEQgKIrpD0_A",
"color": "Red",
"item_name": "(Shirt_Peacoat)",
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
"item_outfit_type": 101,
"mask_guid": "AZnPWCN41kmNwFwDqWGFWQ"
},
{
"swatch_name": "Angler_Gray_Swatch",
"swatch_guid": "97938f4c-52b8-4c85-8575-856654666316",
"color": "Gray",
"item_name": "(Hat_Angler)",
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "80sSpandex_Yellow_Swatch",
"swatch_guid": "i1XbjBqTu0CILIuJClWimA",
"color": "Yellow",
"item_name": "(Shirt_80sSpandex)",
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
"item_outfit_type": 101,
"mask_guid": "B-sIpWNOfEigMeQF-WVR1g"
},
{
"swatch_name": "FootballHelmet_Dark_Swatch",
"swatch_guid": "61ff7a02-50ff-49ea-b95e-c31b95a30c8e",
"color": "Helmet_Dark",
"item_name": "(Hat_FootBall)",
"item_guid": "5a38f864-7d1c-4989-8558-40b9668f78ef",
"item_outfit_type": 0,
"mask_guid": "7660e11a-7ef0-43d3-bc02-76b3a4aab064"
},
{
"swatch_name": "LaserTagVisor_Cyan_Swatch",
"swatch_guid": "426d0592-f8e4-4bc6-9fe1-a62b91fd57d5",
"color": "Visor_Cyan",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TruckerOveralls_Green_Swatch",
"swatch_guid": "EiGgN7uVukqoRE7UJcMLTw",
"color": "Green",
"item_name": "(Shirt_TruckerOveralls)",
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Hat_Tree_Green_Swatch",
"swatch_guid": "mraJrTA4V0eARb_Kq7yYVw",
"color": "Hat_Tree_Green",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Shirt_VampireHunter_Leather_Swatch",
"swatch_guid": "Z8Bfk0MTb0evZehvvgzuBA",
"color": "Leather",
"item_name": "(Shirt_VampireHunter)",
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
"item_outfit_type": 101,
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
},
{
"swatch_name": "CowboyLight_Swatch",
"swatch_guid": "5e00a832-3f25-4dd3-84d2-b6ed6a4b4c37",
"color": "Light",
"item_name": "(Hat_Cowboy)",
"item_guid": "ad0bdec5-f767-42e7-99e0-221473464a85",
"item_outfit_type": 0,
"mask_guid": "54778d93-2cff-48db-b25b-65c08b0e5be8"
},
{
"swatch_name": "Hoodie_Red_Swatch",
"swatch_guid": "yM5xRrd9OkmDGyQddwOP6g",
"color": "Red",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Torso_Tree_Green_Swatch",
"swatch_guid": "i33RmJJCwUem-ADkOwMNQQ",
"color": "Torso_Tree_Green",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Smuggler_Yellow_Swatch",
"swatch_guid": "002b07bb-4256-44ce-8ca4-25510e70b6b5",
"color": "Yellow",
"item_name": "(Shirt_Smuggler)",
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
"item_outfit_type": 101,
"mask_guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68"
},
{
"swatch_name": "BlazerMidnightBlue_Swatch",
"swatch_guid": "6f2e74bf-1e95-463d-97db-d5d1a53b2c28",
"color": "MidnightBlue",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "MagicalGirl_Wrist_Blue_Swatch",
"swatch_guid": "zaSTWOUy_k6cnoVVYzypWg",
"color": "Wrist_Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "ZtCqMnFTQUWv2h0MfZ4m6A"
},
{
"swatch_name": "Hoodie_White_Swatch",
"swatch_guid": "-Nf7WM2TeEKujB637h3tRA",
"color": "White",
"item_name": "(Shirt_Hoodie)",
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Turtleneck_Yellow_Swatch",
"swatch_guid": "G4HIPUjekUigiU0-Kr9mdA",
"color": "Yellow",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Mobster_WhiteHatHacker_Swatch",
"swatch_guid": "40283127-2ffc-4989-82f6-29c3381bf9e2",
"color": "WhiteHatHacker",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "QueenCrown_White_Swatch",
"swatch_guid": "Zljmtj5WqUaRvB3XQTgJXg",
"color": "White",
"item_name": "(Hat_QueenCrown)",
"item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9",
"item_outfit_type": 0,
"mask_guid": "XFuSiGupZ02Fut1Dxum_Iw"
},
{
"swatch_name": "Mobster_Hat_Gray_Swatch",
"swatch_guid": "390a476a-5545-48eb-955c-1bf1a16012bf",
"color": "Hat_Gray",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": "d759c88d-a159-4c26-86dd-b054bb05b3ed"
},
{
"swatch_name": "SpaceMarine_Light_Swatch",
"swatch_guid": "68273c9e-96d6-4ddb-aaab-d99229e864d5",
"color": "Light",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "50sDress_Shamrock_Swatch",
"swatch_guid": "K17dIz_TRUq5D50NBfLjyw",
"color": "Shamrock",
"item_name": "(Shirt_50sDress)",
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
"item_outfit_type": 101,
"mask_guid": "Rk18_7icF0Srgh7M1_G-hA"
},
{
"swatch_name": "MagicalGirl_Hat_Green_Swatch",
"swatch_guid": "kLv_wsbbGUmlfIUO1QLYLw",
"color": "Hat_Green",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "mG60EPIQIkaJNgOkonVmZg"
},
{
"swatch_name": "Aviator_Jacket_Black_Swatch",
"swatch_guid": "da185fbb-ad2b-4f30-87a2-491f24b78ebb",
"color": "Jacket_Black",
"item_name": "(Wrist_Aviator)",
"item_guid": "125a3a62-1005-47dc-9fd8-ee09ea803e9f",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "RedFlowerDress_Yellow_Swatch",
"swatch_guid": "e98c3f98-7844-4d6e-a21c-05027033c18c",
"color": "Yellow",
"item_name": "(RedFlowerDress)",
"item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6",
"item_outfit_type": 101,
"mask_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2"
},
{
"swatch_name": "CollarShirt_Torso_Pink_Swatch",
"swatch_guid": "kmj5zOjcwku_WWKroCeiVQ",
"color": "Torso_Pink",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Neck_Green_Swatch",
"swatch_guid": "i3rD_hnQG0a-5f6Ve4AfWg",
"color": "Neck_Green",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "M5z2wk-GDkOAjvZzqsfcNQ"
},
{
"swatch_name": "Winter_Torso_Red_Swatch",
"swatch_guid": "LDqcPkvNXE2gFXxbpNkfhA",
"color": "Torso_Red",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "Too5jpb1w0iQenkjRRS-QA"
},
{
"swatch_name": "Wizard_Black_Swatch",
"swatch_guid": "7afe1bb0-5ca9-4263-8f85-49d3a18353c7",
"color": "Black",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "TruckerJacket_Black_Swatch",
"swatch_guid": "P9bBSL0YLEyjN8PxMfgbKQ",
"color": "Black",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "FlowerSwimsuit_Gray_Swatch",
"swatch_guid": "5a96ac4d-03c3-4214-9dcb-c2044ff28f2e",
"color": "Gray",
"item_name": "(FlowerSwimsuit)",
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
"item_outfit_type": 101,
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
},
{
"swatch_name": "PirateCaptain_SRank_Swatch",
"swatch_guid": "xGmnUGF-Ck-jP43r8tVVjg",
"color": "SRank",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Mobster_PinStripe_Purple_Swatch",
"swatch_guid": "newGzX_zA0eGUX3Iayq5Ag",
"color": "PinStripe_Purple",
"item_name": "(Wrist_Mobster)",
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "BabydollDress_Pink_Swatch",
"swatch_guid": "da6d0ced-3d43-4878-a868-925f2ed6fd5b",
"color": "Pink",
"item_name": "(BabydollDress)",
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Soccer_Shirt_Blue_Swatch",
"swatch_guid": "OnQeVxdstkS2zGxLBAuM3Q",
"color": "Shirt_Blue",
"item_name": "(Wrist_Soccer)",
"item_guid": "94eb7ef3-528f-4f59-92a0-48b0ec287527",
"item_outfit_type": 200,
"mask_guid": "i8bvbGAKREmAEbjKO-geOQ"
},
{
"swatch_name": "CollarShirt_Torso_GoldYellow_Swatch",
"swatch_guid": "jGj28vhq8EGwP2RuM074aQ",
"color": "Torso_GoldYellow",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Turtleneck_Green_Swatch",
"swatch_guid": "Jt0QqNrAp0qzUR_RQoivVg",
"color": "Green",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "SodaJerk_Blue_Swatch",
"swatch_guid": "XFCpIdeDXUqeYCIgh4fGtg",
"color": "Blue",
"item_name": "(Shirt_SodaJerk)",
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "PirateCaptain_Red_Swatch",
"swatch_guid": "556141f7-6fbc-4065-9faa-8d8240a93d7f",
"color": "Red",
"item_name": "(Neck_PirateCaptain)",
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "Steampunk_Femme_Contest_Swatch",
"swatch_guid": "A6VnGtpY9Ey0atf3zaLo3g",
"color": "e_Contest",
"item_name": "(Hat_SteampunkFemme)",
"item_guid": "JWanxvOHyESg9F_HlmTOfA",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "BowlingShirt_Flames_Pink_Swatch",
"swatch_guid": "FC8ZFBPt4EyTe7IYbv2Tpg",
"color": "Shirt_Flames_Pink",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "5RUG6i2Mx0-55IO-IlhUdQ"
},
{
"swatch_name": "Luchador_Blue_Swatch",
"swatch_guid": "0217fd2e-ff9d-49f7-9bc8-8d0d6b8bf250",
"color": "Blue",
"item_name": "(Hat_Luchador)",
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Jazzercise_Yellow_Swatch",
"swatch_guid": "78eb0969-f246-4cac-acd6-295df7c092e4",
"color": "Yellow",
"item_name": "(Hair_Jazzercise)",
"item_guid": "49397e16-1027-4286-9bc4-b59eae565ff0",
"item_outfit_type": 2,
"mask_guid": ""
},
{
"swatch_name": "Countess_Purple_Swatch",
"swatch_guid": "IzkFARlv6EenmrfcNkcOuw",
"color": "Purple",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Hat_Orange_Swatch",
"swatch_guid": "Euf1h1qsZ02evDsyoHYbPQ",
"color": "Hat_Orange",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "LaserTag_Green_Swatch",
"swatch_guid": "2Z_FxSPiyUODtlFZWF9DFQ",
"color": "Green",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Glove_Grey_Swatch",
"swatch_guid": "72a010c0-bddd-4e5d-bca6-9821d82de9a6",
"color": "Glove_Grey",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "f4cea991-fc3d-4715-bf82-8a8fb60e8799"
},
{
"swatch_name": "HeadBackBow_Hearts_Swatch",
"swatch_guid": "D-SngK7hhkOpoZ2aMWv9EA",
"color": "Hearts",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "TruckerJacket_Olive_Swatch",
"swatch_guid": "bgRKH1wfqkWVHcuSnFYnlQ",
"color": "Olive",
"item_name": "(Shirt_TruckerJacket)",
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BlazerSeaDark_Swatch",
"swatch_guid": "0ffad843-d6c9-425a-8686-7217009c867e",
"color": "SeaDark",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "Turtleneck_Red_Swatch",
"swatch_guid": "lBePHNL9LkyaAjEFyFE8Sw",
"color": "Red",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "HockeyShirt_Teal_Swatch",
"swatch_guid": "fcbdfbfd-254a-4538-b119-43e23404b8b5",
"color": "Teal",
"item_name": "(HockeyShirt)",
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
"item_outfit_type": 101,
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
},
{
"swatch_name": "Unicorn_Glowing_Swatch",
"swatch_guid": "mudt-Z5-5USdnIq-xaDgOw",
"color": "Glowing",
"item_name": "(Hat_Unicorn)",
"item_guid": "2caHvVQ1vECrPv-YcdxdyA",
"item_outfit_type": 0,
"mask_guid": "nW_fBsuZA0OfIjTMXm6ZCA"
},
{
"swatch_name": "MagicalGirl_Orange_Swatch",
"swatch_guid": "hYu9itGe1Ee5Neanhj9VtQ",
"color": "Orange",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArcherTan_Quiver_Swatch",
"swatch_guid": "d1eb3573-672c-4bb4-9ec5-7c81e6a38d3d",
"color": "Tan_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Punk_WristGreen_Swatch",
"swatch_guid": "a6796264-9c7e-4605-830c-06db8d0dc780",
"color": "WristGreen",
"item_name": "(Wrist_Punk)",
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
"item_outfit_type": 200,
"mask_guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675"
},
{
"swatch_name": "Headphones_EmissivePurple_Swatch",
"swatch_guid": "3PfP661Fg0CruzRxis9EiA",
"color": "EmissivePurple",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Harness_Hat_Black_Swatch",
"swatch_guid": "JVZHZm6nGEqE2zEEPoHbwA",
"color": "Hat_Black",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": "PZHohDVX0U2frX13N4KYDQ"
},
{
"swatch_name": "HeadBackBow_SolidBlue_Swatch",
"swatch_guid": "8iZO162KNUiMC8l_z3OC_A",
"color": "SolidBlue",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "Firefighter_Hat_White_Swatch",
"swatch_guid": "891d902e-3257-4a8c-9001-858046007997",
"color": "Hat_White",
"item_name": "(Hat_Firefighter)",
"item_guid": "bb54e0e9-5d90-41f3-94c0-8e82f1791af1",
"item_outfit_type": 0,
"mask_guid": "c8ccb85e-52cd-4b3b-aadf-289819067125"
},
{
"swatch_name": "Scallywag_Hat_Red_Swatch",
"swatch_guid": "c3c9f89c-580c-43c4-b38b-07394044e0e1",
"color": "Hat_Red",
"item_name": "(Shirt_Scallywag)",
"item_guid": "664485e9-a159-4fcb-b32c-2b114b4a1218",
"item_outfit_type": 101,
"mask_guid": "a4903da3-249d-4f0a-a746-b547fda371ff"
},
{
"swatch_name": "HeadBackBow_SolidPink_Swatch",
"swatch_guid": "FUP4iRnoc0ikNmjrFD06lg",
"color": "SolidPink",
"item_name": "(Hat_HeadBackBow)",
"item_guid": "JTrfBXhIT02o2-ssP94zew",
"item_outfit_type": 0,
"mask_guid": "3DdSzSqV1UG5AtPr8ro3Kg"
},
{
"swatch_name": "SpaceMarine_Dark_Swatch",
"swatch_guid": "a089bcc6-798d-4777-98e2-7afd290dc6b6",
"color": "Dark",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "Headphones_TwoBlack_Swatch",
"swatch_guid": "ns5tIRishUOp-nYqLedcwA",
"color": "TwoBlack",
"item_name": "(Hat_Headphones)",
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
"item_outfit_type": 0,
"mask_guid": "_Yq00H-NWUyQNvGxRunHbg"
},
{
"swatch_name": "Wrist_VampireHunter_Black_Swatch",
"swatch_guid": "Vxq6v55a_kGPODzwB5MC1g",
"color": "Black",
"item_name": "(Wrist_VampireHunter)",
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
"item_outfit_type": 200,
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
},
{
"swatch_name": "Blazer_Shamrock_Swatch",
"swatch_guid": "7WrSbK4Ml0K2z-ig5HIUCw",
"color": "Shamrock",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "MagicalGirl_Hat_Red_Swatch",
"swatch_guid": "EDLxuGjEVEGdOPXDqyN-xA",
"color": "Hat_Red",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "mG60EPIQIkaJNgOkonVmZg"
},
{
"swatch_name": "DrumMajorHat_Green_Swatch",
"swatch_guid": "5a614f97-ef7f-4b70-afff-9ed837a86407",
"color": "Hat_Green",
"item_name": "(Hat_DrumMajor)",
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
"item_outfit_type": 0,
"mask_guid": "a8a91216-0fcc-461e-a682-95ee82509cce"
},
{
"swatch_name": "Golfer_Hat_Blue_Swatch",
"swatch_guid": "6584dada-499e-4d7c-958d-466955b20640",
"color": "Hat_Blue",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "ddf429fd-a883-48a5-869b-bc12e44d30bf"
},
{
"swatch_name": "Harness_Blue_Swatch",
"swatch_guid": "RfZNDPUok0WyQEa3Ko08ow",
"color": "Blue",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherSRank_Swatch",
"swatch_guid": "lgqLGi01o02ZFC9Xs5Ofdg",
"color": "SRank",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherRed_Swatch",
"swatch_guid": "5b5281c9-b795-4bb2-ba7b-ba52047ebe3f",
"color": "Red",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Wrist_VampireHunter_Leather_Swatch",
"swatch_guid": "D0tP58wC30mJ00vNxw8hqA",
"color": "Leather",
"item_name": "(Wrist_VampireHunter)",
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
"item_outfit_type": 200,
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
},
{
"swatch_name": "SpaceMarine_SRank_Swatch",
"swatch_guid": "4ElTihrMA0ipcgeb9UG01Q",
"color": "SRank",
"item_name": "(Shirt_SpaceMarine)",
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
"item_outfit_type": 101,
"mask_guid": "7ac81d60-05b8-4a85-982c-f69213a93818"
},
{
"swatch_name": "Jackfrost_Shirt_Lilac_Swatch",
"swatch_guid": "prEZHWvOM0ysaNfAe06ywA",
"color": "Shirt_Lilac",
"item_name": "(Shirt_JackFrost)",
"item_guid": "105a5100-cb57-410f-a26b-1ee21002f067",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BusinessTie_CandyCane_Swatch",
"swatch_guid": "W4a144so90eRzCojUnDowA",
"color": "CandyCane",
"item_name": "(BusinessTie)",
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
"item_outfit_type": 100,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Hat_Orange_Swatch",
"swatch_guid": "f0pjsV0XMUWoE_z2kqOlSA",
"color": "Hat_Orange",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "mG60EPIQIkaJNgOkonVmZg"
},
{
"swatch_name": "BowlingShirt_CenterStripe_Yellow_Swatch",
"swatch_guid": "XrSL-v2MbkK0md-FMOweOA",
"color": "Shirt_CenterStripe_Yellow",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "IWg9wuda4kaCilJk8JQpTQ"
},
{
"swatch_name": "CollarShirt_Torso_Plaid_Red_Swatch",
"swatch_guid": "ojvIFCTpJkeegestF065wA",
"color": "Torso_Plaid_Red",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": "10fYSIBytk-yUjveDa407w"
},
{
"swatch_name": "Witch_Contest_Wrist_Swatch",
"swatch_guid": "Hk6svQ5Sr0GDKLxOyktk9w",
"color": "Contest_Wrist",
"item_name": "(Shirt_Witch)",
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "WitchHunter_White_Swatch",
"swatch_guid": "e02838f8-4029-455a-b35c-dd40f972b05b",
"color": "White",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Witch_Contest_Swatch",
"swatch_guid": "uTHwo8qd6Ei3Oy4gz_j9Ow",
"color": "Contest",
"item_name": "(Shirt_Witch)",
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Countess_Green_Wrist_Swatch",
"swatch_guid": "Wj7I2YiVoE6zp8J_HYs8tw",
"color": "Green_Wrist",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Torso_GreenFlower_Swatch",
"swatch_guid": "EfdMcnfHt0mr0PQ_maaYOg",
"color": "Torso_GreenFlower",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherRed_Quiver_Swatch",
"swatch_guid": "7221132e-053a-4c58-b719-ed3c8558907d",
"color": "Red_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Winter_Scarf_Tree_Swatch",
"swatch_guid": "OrqqGMhK1UKGowFxpJi2mw",
"color": "Scarf_Tree",
"item_name": "(Hat_Winter)",
"item_guid": "24c4404a-839f-46bd-98b6-6848d747a98b",
"item_outfit_type": 0,
"mask_guid": "dUu7b-JFOky9c2lIVCNmcg"
},
{
"swatch_name": "ArcherGray_Glove_Swatch",
"swatch_guid": "8hUBfmq28EixYjv92Xcrgg",
"color": "Gray_Glove",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Tennis_Pink_Swatch",
"swatch_guid": "304e5cf1-5c94-481f-8e49-e6a943e6b300",
"color": "Pink",
"item_name": "(Shirt_Tennis)",
"item_guid": "74b91f2b-d4d3-4e41-8946-b1296ab7a773",
"item_outfit_type": 101,
"mask_guid": "90e0da8b-1941-4ca8-b7e5-b4e31921873c"
},
{
"swatch_name": "BowlingShirt_Diamonds_Blue_Swatch",
"swatch_guid": "KqjQ1xRpokGLKp9GVN1EFA",
"color": "Shirt_Diamonds_Blue",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "VO_3cgqQqUOTz30TVUxrFA"
},
{
"swatch_name": "Scarecrow_Shirt_Contest_Swatch",
"swatch_guid": "S0AKRB_m2k-I7snHnUigtw",
"color": "Shirt_Contest",
"item_name": "(Shirt_Scarecrow)",
"item_guid": "1fcaf4fd-9012-407e-b691-fbc3ccf22421",
"item_outfit_type": 101,
"mask_guid": "uTkbswCedUGyuZcaX0W6Ww"
},
{
"swatch_name": "Sherlock_Hat_Black_Swatch",
"swatch_guid": "fEPwvuJCWkGBe4IekXpmLw",
"color": "Hat_Black",
"item_name": "(Hat_Sherlock)",
"item_guid": "5bde836f-9343-4934-b01a-606f707020ef",
"item_outfit_type": 0,
"mask_guid": "axPBGoqorEe77Oqqjfzb5w"
},
{
"swatch_name": "ElectricianTorso_Beige_Swatch",
"swatch_guid": "5fb2e235-8c67-4335-b432-edb10db2b139",
"color": "Torso_Beige",
"item_name": "(Hat_Electrician)",
"item_guid": "34654303-9760-447f-8614-1c26506db994",
"item_outfit_type": 0,
"mask_guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a"
},
{
"swatch_name": "Letterman_Wrist_Black_Swatch",
"swatch_guid": "Y0e7o0_BKUm7XJSLediN5w",
"color": "Wrist_Black",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": "4EHIyZkOOEqnM-PuD_a5xw"
},
{
"swatch_name": "LaserTag_Orange_Swatch",
"swatch_guid": "jkuw9EhCpEKn-2lKJXWd3Q",
"color": "Orange",
"item_name": "(Hat_LaserTag)",
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "Golfer_Hat_Grey_Swatch",
"swatch_guid": "48c18418-01dd-40ff-9dfe-c4cf5f4e1245",
"color": "Hat_Grey",
"item_name": "(Hat_Golfer)",
"item_guid": "2faa26b6-f7c3-4b0d-8f70-1f9454e64a54",
"item_outfit_type": 0,
"mask_guid": "ddf429fd-a883-48a5-869b-bc12e44d30bf"
},
{
"swatch_name": "Soccer_Glove_Blue_Swatch",
"swatch_guid": "whEHo6Rhr0CxsDBsBD32WA",
"color": "Glove_Blue",
"item_name": "(Wrist_Soccer)",
"item_guid": "94eb7ef3-528f-4f59-92a0-48b0ec287527",
"item_outfit_type": 200,
"mask_guid": "av4PwLz6YkmpBRImQZgn4Q"
},
{
"swatch_name": "FurCoat_White_Swatch",
"swatch_guid": "643b5f2d-0666-4869-b699-03742de34d16",
"color": "White",
"item_name": "(FurCoat)",
"item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5",
"item_outfit_type": 101,
"mask_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6"
},
{
"swatch_name": "Hat_SeaCaptainDarkSilver_Swatch",
"swatch_guid": "36e63797-5a42-4779-a5cc-ef5c2e7d17d4",
"color": "DarkSilver",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "HockeyShirt_Purple_Swatch",
"swatch_guid": "d5efaf80-7f34-4c9a-bc3a-b80b1ac70ace",
"color": "Purple",
"item_name": "(HockeyShirt)",
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
"item_outfit_type": 101,
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
},
{
"swatch_name": "Steampunk_Contest_Hat_Swatch",
"swatch_guid": "AAbGHOOitUu9enweOXbrkw",
"color": "Contest_Hat",
"item_name": "(Shirt_SteamPunk)",
"item_guid": "E9VTiIVFPEa-OuQRto0e_Q",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "ArcherYellow_Quiver_Swatch",
"swatch_guid": "e548124c-d7f3-45de-aaa0-f66130eca576",
"color": "Yellow_Quiver",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "LaserTagGlove_Green_Swatch",
"swatch_guid": "mhsglVEtREmElF5wuLw3NQ",
"color": "Green",
"item_name": "(Wrist_LaserTagGlove)",
"item_guid": "3044adce-90c0-4f66-81f5-39c4fca86fdf",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArcherTan_Swatch",
"swatch_guid": "9b35ae52-febe-486a-8557-ec7190a9756e",
"color": "Tan",
"item_name": "(Shirt_Archer)",
"item_guid": "c0e59f27-a13d-4bd9-b3b8-a38a360f649b",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "BowlingShirt_Flames_Blue_Swatch",
"swatch_guid": "032pE8s9Ik-2CImFVDrSqg",
"color": "Shirt_Flames_Blue",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "5RUG6i2Mx0-55IO-IlhUdQ"
},
{
"swatch_name": "Turtleneck_Orange_Swatch",
"swatch_guid": "9b9t8_aKH0-gQXMhZycaOA",
"color": "Orange",
"item_name": "(Shirt_Turtleneck)",
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "MagicalGirl_Neck_Blue_Swatch",
"swatch_guid": "Ot9EG-FfKEauT0u6pSZuWg",
"color": "Neck_Blue",
"item_name": "(Wrist_MagicalGirl)",
"item_guid": "eS_or4AnMka9M_bD7YN28g",
"item_outfit_type": 200,
"mask_guid": "M5z2wk-GDkOAjvZzqsfcNQ"
},
{
"swatch_name": "WitchHunter_Red_Swatch",
"swatch_guid": "909ed249-ce09-4304-8b02-56794fa7567d",
"color": "Red",
"item_name": "(Shirt_WitchHunter)",
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Countess_Green_Swatch",
"swatch_guid": "LyVf9lnzdku1F4iPBGqk-g",
"color": "Green",
"item_name": "(Shirt_Countess)",
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Shirt_TutorialGown1_Purple_Swatch",
"swatch_guid": "U2ZbHn8WJEySUXVpzAY6ig",
"color": "Purple",
"item_name": "(Shirt_TutorialGown_1)",
"item_guid": "ASHnWS0h9kOJhbOUwxWKFQ",
"item_outfit_type": 101,
"mask_guid": "6nmHu9zj8k-tEz0tBHblnQ"
},
{
"swatch_name": "BowlingShirt_OneStripe_Blue_Swatch",
"swatch_guid": "GDvaUU10-EKh3pKUQ9tnag",
"color": "Shirt_OneStripe_Blue",
"item_name": "(Shirt_Bowling)",
"item_guid": "CQuKKNpan0O7rhR1fr14-Q",
"item_outfit_type": 101,
"mask_guid": "b-IM3eYMJUa40395O5XeLg"
},
{
"swatch_name": "Scuba_Neck_Contest_Swatch",
"swatch_guid": "GWi1rEebqkCV_TS2WzQhcA",
"color": "Neck_Contest",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Dodgeball_Black_Swatch",
"swatch_guid": "4a85c181-92fe-47a5-925e-05f0ccb25006",
"color": "Black",
"item_name": "(Shirt_Dodgeball)",
"item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59",
"item_outfit_type": 101,
"mask_guid": "944d934d-5e21-42d7-8196-fff9a81ee469"
},
{
"swatch_name": "BlazerGrey_Swatch",
"swatch_guid": "272fe8eb-5061-4729-a7a8-414ff667a82f",
"color": "Grey",
"item_name": "(Blazer)",
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
"item_outfit_type": 101,
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
},
{
"swatch_name": "TennisSkirt_Red_Swatch",
"swatch_guid": "758752bd-db2f-43d2-b580-55b3e1efffd5",
"color": "Red",
"item_name": "(TennisSkirt)",
"item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60",
"item_outfit_type": 101,
"mask_guid": "b75ef67d-00c3-4ac1-9b72-212032460294"
},
{
"swatch_name": "Windbreaker_Salmon_Swatch",
"swatch_guid": "JS7aa4QSUUOpUYPqRZEb5g",
"color": "Salmon",
"item_name": "(Wrist_80sWindbreaker)",
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Wizard_Red_Swatch",
"swatch_guid": "a38254c0-00e3-429b-9d5d-b9e6a9812e69",
"color": "Red",
"item_name": "(Hat_Wizard)",
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
"item_outfit_type": 0,
"mask_guid": ""
},
{
"swatch_name": "FighterPilot_Gloves_Blue_Swatch",
"swatch_guid": "55b4e661-43e1-4708-82d5-2dd2bebd4f45",
"color": "Gloves_Blue",
"item_name": "(Wrist_FighterPilot)",
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "Letterman_Green_Swatch",
"swatch_guid": "apSQiQ1eO0mEzRsbugeu9w",
"color": "Green",
"item_name": "(Wrist_Letterman)",
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
"item_outfit_type": 200,
"mask_guid": ""
},
{
"swatch_name": "ArtistHat_Blue_Swatch",
"swatch_guid": "14b43bf9-5ba0-484e-ae5d-55cfeb09a70d",
"color": "Hat_Blue",
"item_name": "(Shirt_Artist)",
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
"item_outfit_type": 101,
"mask_guid": "3485a30d-27f8-450b-b5aa-1ff3cd227d19"
},
{
"swatch_name": "WaitressApron_Green_Swatch",
"swatch_guid": "Blev_VFjRUysY_nymGWAVg",
"color": "Green",
"item_name": "(Belt_WaitressApron)",
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
"item_outfit_type": 102,
"mask_guid": ""
},
{
"swatch_name": "CollarShirt_Torso_White_Swatch",
"swatch_guid": "MFrcSQ1DYUm8imvy4ypgvw",
"color": "Torso_White",
"item_name": "(CollarShirt)",
"item_guid": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Ranger_Hat_Green_Swatch",
"swatch_guid": "d0bb468c-b78f-4e80-916d-65fec3ec0010",
"color": "Hat_Green",
"item_name": "(Hat_Ranger)",
"item_guid": "f277af6a-0807-4716-8948-1bee236ffb74",
"item_outfit_type": 0,
"mask_guid": "0272eb19-fc0e-4ad6-8db9-cc0cda2528b0"
},
{
"swatch_name": "Scuba_Shirt_Blue_Swatch",
"swatch_guid": "VqtOYBiKRkaSW4xAlBxQgg",
"color": "Shirt_Blue",
"item_name": "(Shirt_Scuba)",
"item_guid": "57552652-d902-43b7-a68e-5c7da87582bc",
"item_outfit_type": 101,
"mask_guid": ""
},
{
"swatch_name": "Harness_Orange_Swatch",
"swatch_guid": "sX0ZZDcCUkyFSJISfvq0vg",
"color": "Orange",
"item_name": "(Shirt_Harness)",
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
"item_outfit_type": 101,
"mask_guid": ""
}
],
"unmatched": [
"Knight_SRank_Swatch",
"TShirt_SquadGoals_Swatch",
"ZebraWhite_Swatch",
"Cyber_Trash_Yellow_Swatch",
"PoofyDress_Purple_Swatch",
"Laser_Graffiti_Swatch",
"Shuttershades_Salmon_Swatch",
"Class_Of_2018_Swatch",
"Plaid_LightBlue_Swatch",
"Fatigue_Swatch",
"Wrist_CowboyWhite_Swatch",
"Cream_Swatch",
"Shuttershades_Red_Swatch",
"PolkaBlue_Swatch",
"Wrist_CowboyCherry_Swatch",
"Yellow_Swatch",
"Windbreaker_Wrist_Yellow_Swatch",
"2D_Contest_Winner_Swatch",
"Red_Swatch",
"RoyalCape_Green_Swatch",
"Grey_Swatch",
"RoyalCape_Contest_White_Swatch",
"Barber_Torso_Formal_Swatch",
"Knight_Blue_Swatch",
"Neck_TutorialGown3_Black_Swatch",
"TShirt_Laser_Unicorn_Purple_Swatch",
"PaintBallHat_Red_Swatch",
"Blue_Swatch",
"Barber_Hat_Formal_Swatch",
"RoyalCape_Blue_Swatch",
"Neck_VampiteHunter_Opal_Swatch",
"TShirt_HangInThere_Swatch",
"Knight_Red_Swatch",
"Floatie_Donut_Swatch",
"RoyalLady_Green_Swatch",
"TShirt_Orange_Swatch",
"White_Swatch",
"RoyalCape_Purple_Swatch",
"BeltDress_Green_Swatch",
"Shuttershades_Blue_Swatch",
"Neck_VampiteHunter_Ruby_Swatch",
"RoyalCrown_Contest_Swatch",
"BeltDress_Black_Swatch",
"Wrist_CowboyBlack_Swatch",
"TShirt_KO_Skull_Swatch",
"RoyalLord_Black_Swatch",
"TShirt_Orange_Video_Contest_Generic_Swatch",
"TShirt_Scifi_Face_BLue_Swatch",
"Green_Swatch",
"GreenFlower_Swatch",
"FormalDress_Black_Swatch",
"Birthday01_Swatch",
"Neck_VampiteHunter_Amethyst_Swatch",
"Cyber_Trash_Black_Swatch",
"SciFiVisor_Peach_Swatch",
"Scifi_Carpet_Swatch",
"Teal_Swatch",
"Charcoal_Swatch",
"Paintball_Red_Swatch",
"BicycleJacket_Black_Swatch",
"TShirt_Scifi_Face_Pink_Swatch",
"Argyle_Brown_Swatch",
"Paintball_Blue_Swatch",
"BasketballShirt_Purple_Swatch",
"Purple_Swatch",
"Knight_Black_Swatch",
"TShirt_KO_Skull_Pink_Swatch",
"BeltDress_Yellow_Swatch",
"Shuttershades_Yellow_Swatch",
"PaintBallHat_Green_Swatch",
"Cyber_Junk_City_Swatch",
"BicycleJacket_DarkBlue_Swatch",
"Torso_SeaCaptainBlueWhite_Swatch",
"CheerDress_Blue_Swatch",
"Denim_Swatch",
"RoyalLady_Contest_White_Swatch",
"Neck_VampiteHunter_Diamond_Swatch",
"Floatie_Dinosaur_Swatch",
"Torso_SeaCaptainBlueDark2_Swatch",
"WaitressDress_Mustard_Swatch",
"CollarDress_Pink_Swatch",
"Torso_SeaCaptainDark_Swatch",
"SciFiVisor_Blue_Swatch",
"RoyalCrown_White_Swatch",
"Pink_Swatch",
"Royal_Swatch",
"GoldYellow_Swatch",
"Happy_Tree_Purple_Swatch",
"Barber_Torso_Green_Swatch",
"CheerDress_Red_Swatch",
"TShirt_RECS_Logo_Blue_Swatch",
"Scout_Shirt_Blue_Swatch",
"Windbreaker_Wrist_Red_Swatch",
"Neck_VampiteHunter_Emerald_Swatch",
"TShirt_RECS_Logo_Green_Swatch",
"Neck_VampiteHunter_Amber_Swatch",
"RoyalCrown_Black_Swatch",
"RoyalLord_Burgandy_Swatch",
"Knight_Green_Swatch",
"Torso_SeaCaptainWhiteDark_Swatch",
"LightBlue_Swatch",
"Happy_Tree_Swatch",
"BicycleJacket_Blue_Swatch",
"Torso_SeaCaptainBlackDark_Swatch",
"PaintBallHat_Mustard_Swatch",
"Scifi_Carpet_TopHat_Swatch",
"Windbreaker_Wrist_Blue_Swatch",
"Neck_VampiteHunter_Topaz_Swatch",
"Plaid_Red_Swatch",
"Laser_Ham_Swatch",
"Rebel_Rose_Swatch",
"Wrist_CowboyLight_Swatch",
"Barber_Wrist_Formal_Swatch",
"SciFiVisor_Orange_Swatch",
"RoyalLady_Black_Swatch",
"WaitressDress_Green_Swatch",
"StollDress_Green_Swatch",
"Torso_SeaCaptainBlueDark_Swatch",
"Torso_SeaCaptainBlue_Swatch",
"Orange_Swatch",
"PB_Trucker_Red_Swatch",
"Barber_Hat_Green_Swatch",
"RoyalLord_Contest_White_Swatch",
"Black_Swatch",
"RoyalLord_Green_Swatch",
"CollarDress_LightBlue_Swatch",
"StollDress_Blue_Swatch",
"Argyle_Red_Swatch",
"RoyalLady_Burgandy_Swatch",
"TShirt_LaserTag_Alpha_Swatch",
"Hyper_Noodle_Ramen_Swatch",
"PoofyDress_Orange_Swatch",
"Knight_White_Swatch",
"BicycleHelmet_Yellow_Swatch",
"BasketballShirt_Blue_Swatch",
"Wrist_CowboyDark_Swatch",
"Windbreaker_Wrist_Salmon_Swatch",
"Emerald_Syndicate_Swatch",
"Neck_TutorialGown2_Orange_Swatch",
"2D_Contest_Winner_Yellow_Swatch",
"WaitressDress_Red_Swatch",
"Stripe3_Swatch"
],
"ambiguous": []
}
Binary file not shown.
-17
View File
@@ -303,11 +303,6 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`,
| POST | `api/rooms/v1/bookmark` | REAL | `MissingEndpointsController.BookmarkRoom` — upserts/removes a `RoomBookmarkEntity` based on the `Bookmark` form field |
| GET | `api/rooms/v1/datahistory/{roomId}` | REAL | `Rooms.V2.RoomsController.DataHistory` — list of historical scene snapshots |
| POST | `api/rooms/v1/datahistory/restore` | REAL | `Rooms.V2.RoomsController.RestoreDataHistory` — rolls a room's scenes back to a snapshot |
| GET | `rooms/{roomId}/subrooms` | REAL | `Rooms.V2.RoomsController.SubRooms` — Studio/2023 subroom list (one synthesised "Home" for single-scene rooms; `RoomScenes` rows otherwise) |
| GET | `rooms/{roomId}/subrooms/{subRoomId}` | REAL | `Rooms.V2.RoomsController.SubRoom` — one subroom by `RoomScene.OrderIndex` |
| GET | `rooms/{roomId}/subrooms/{subRoomId}/data` | REAL | `Rooms.V2.RoomsController.SubRoomData` — current `SubRoomDataSave` for the subroom |
| GET | `rooms/{roomId}/subrooms/{subRoomId}/saves` | REAL | `Rooms.V2.RoomsController.SubRoomSaves` — current save + historical blobs, newest first |
| GET | `rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}` | REAL | `Rooms.V2.RoomsController.StudioSubRoomSave` — Studio save metadata + baked `unity_assets` bundle list the 2023 client downloads |
| POST | `api/rooms/v2/report` | REAL | `RoomsModerationController.Report` — files a room report |
| GET | `api/rooms/v1/modrooms` | REAL | `Rooms.V2.RoomsController.MyModerated` (route alias `api/rooms/v1/modrooms`) — rooms the caller moderates |
| GET | `api/rooms/v2/myrooms` | REAL | `Rooms.V2.RoomsController.MyRooms` |
@@ -382,20 +377,8 @@ These all live on `match.rec.net` (locally: `match.localhost`).
| POST | `goto/player/{playerId}` | REAL | `Match.GoToController.GoToPlayer` — uses `PlayerPresenceService` to find where the target is and routes the caller there |
| POST | `goto/instance/{instanceId}` | REAL | `Match.GoToController.GoToInstance` — direct join to a specific room instance |
| POST | `goto/invite/{inviteId}` | REAL | `Match.GoToController.GoToInvite` — resolves an invite to its target room/instance |
| POST/GET | `matchmake/room/{roomId}` | REAL | `Match.GoToController.MatchmakeRoom` — the 2023 client's "join this public room" call (navigates by numeric id, not name). Reuses `BuildResponseAsync`; without it every public-room join 404s and the player bounces to dorm |
| POST/GET | `matchmake/room/{roomId}/{subRoomId}` | REAL | `Match.GoToController.MatchmakeSubRoom` — sub-room form; resolves the `RoomScenes` row by `OrderIndex` |
| POST | `matchmake/dorm` | REAL | `Match.MatchController.MatchmakeDorm` — builds the caller's personal-dorm `RoomInstance` |
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
### CDN host (`cdn.rec.net` / `storage` / `data`)
Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host).
| Verb | URL | Status | Handler |
|---|---|---|---|
| GET/HEAD | `unity_assets/{assetId}/{target}/{version}` | REAL | `Cdn.CdnController.ServeUnityAsset` — Studio baked asset-bundle download. The 2023 client builds this URL itself from a save's `UnityAssetId`/`Target`/`Version`; resolves the owning `RoomScene` by `StudioUnityAssetId`, picks the matching `{saveId}__bundle_t{target}_v{version}.assetbundle` from `StudioAssetBundleNamesCsv`, streams it from S3. Without it imported Studio geometry never renders |
## Catch-all wildcards (informational)
Routes registered as `[Route("api/<ns>/{*path}")]` in `ApiNamespaceStubsController` (`Controllers/API/Stubs/ApiNamespaceStubsController.cs`):
+10 -19
View File
@@ -1,23 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Open.IdentityServer" Version="1.0.0" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
(Coolify-friendly). The host pipeline reads stdout line-by-line,
and structured JSON lets a future Loki/Datadog ingest parse fields
@@ -28,8 +25,8 @@
scale: SignalR group fanout, OrphanAccountTracker (sticky-session
account-creation flow), and PlayerPresenceService heartbeats all
need shared state across replicas. -->
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.9" />
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" />
<!-- S3-compatible object storage. Production target: Garage in
Coolify (one-click). Dev fallback: local MinIO for parity
testing. The SDK speaks plain S3, so swapping endpoints
@@ -41,7 +38,7 @@
cost+salt in the output string so we only need one column. -->
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
on profile thumbnails). The 2020 client treats this as part of the
image CDN contract — it uploads a full-frame capture and expects the
@@ -76,6 +73,7 @@
<ItemGroup>
<ProjectReference Include="..\DorkNet.Models\DorkNet.Models.csproj" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup>
<!-- Service-slice containers reference DorkNet.Server for shared controllers
@@ -153,13 +151,6 @@
<Link>data\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Real captured PersistedRoomData served as the default room blob
(RoomDataBlobService.GetDefaultBlob). The hand-built synthetic blob
is too thin for the 2023 client's room-load apply path. -->
<None Include="Data\*.room">
<Link>data\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="Data\images\*.png" Exclude="Data\images\image_*.png">
<Link>data\images\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+13 -104
View File
@@ -35,29 +35,10 @@ message OverridableListStringData {
repeated string inner_value = 2;
}
// ── Game role collection (rec_room/player_roles/game_role_collection_data.proto)
message PlayerGameRoleData {
}
message GameRoleCollectionData {
repeated PlayerGameRoleData DEPRECATED_game_roles = 1;
}
// ── Tool tag settings (rec_room/tools/tool_tag_settings_data.proto)
message TagData {
repeated string tags = 1;
}
message ToolTagSettingsData {
TagData runtime_tag_data = 1;
}
// ── Per-role permission grants (rec_room/player_roles/player_room_role_data.proto)
//
// Field numbers verified against the 2023 WriteTo disassembly in
// C:\tmp\recroom-2023-03-21-isil\IsilDump\RecRoom.Protobuf.Runtime.
// Field numbers verified against the dump's `*FieldNumber` constants
// (TypeDefIndex 15953-ish in the 2020 build) and against the 2026 .proto.
message PlayerRoomRoleData {
reserved 15;
@@ -89,100 +70,28 @@ message PlayerRoomRoleData {
OverridableBoolData disable_mic_auto_mute = 25;
OverridableBoolData can_use_share_cam = 26;
string role_guid = 27;
OverridableBoolData can_spawn_inventions = 28;
OverridableBoolData can_spawn_consumables = 29;
OverridableBoolData can_use_room_reset_button = 30;
OverridableBoolData can_use_play_gizmos_toggle = 31;
}
// ── Role collection (rec_room/player_roles/room_role_collection_data.proto)
enum MigratedToRecNetVersion {
NotYetMigrated = 0;
MigratedToRecNet = 1;
}
message RoomRoleCollectionData {
repeated PlayerRoomRoleData room_roles = 1;
MigratedToRecNetVersion rec_net_migration_version = 2;
// field 2 (rec_net_migration_version) intentionally omitted — its enum
// type doesn't exist in the 2020 build, and proto3 default 0 is fine.
}
// ── PersistedRoomData top-level (rec_room/persistence/persisted_room_data.proto)
//
enum DEPRECATED_RoomPersistenceVersion {
option allow_alias = true;
V0NewRoomPersistenceVersion = 0;
V1Initial = 1;
V2ExtrusionsCenterOfMass = 2;
V3PersistentHostSettings = 3;
V4RadioPersistence = 4;
V5ExtrusionsPhysicsMode = 5;
V6CustomGameSettings = 6;
V7NewSandboxButton = 7;
V8CircuitChipOrientation = 8;
V9NewTriggerVolume = 9;
V10RoomRoles = 10;
V11SubextrusionsPersistedIds = 11;
V12EverythingARoom = 12;
V13CreationObjectAdded = 13;
V14InventionPieceAdded = 14;
V15GameConfigurationAdded = 15;
V16EraserMarkersFixed = 16;
V17GrabbableAdded = 17;
V18ManualSpawnPointsAdded = 18;
V19StuntRunnerButtonNodeChange = 19;
V20GameRolesInChips = 20;
V21PlayerAttractorConfigurable = 21;
V22AutosaveValidation = 22;
V23TriggerVolumeNoShapeContainers = 23;
V24GadgetVisibilityMovedToSubroomSetting = 24;
V25RoomKeysBeta = 25;
V26DirectionalVoiceChat = 26;
V27JuniorEnabledObjectFlag = 27;
V28SfxPlayForAll = 28;
V29UVManipulationAdded = 29;
V30HomeValuesAdded = 30;
V31SeatCollisionToggleAdded = 31;
V32StoringRotationAsVector4 = 32;
V33LightValuesFloat = 33;
V34OptimizeCollidersToggle = 34;
V35AudioPlayerProperties = 35;
V36BackpackToolsForbiddenInSpawner = 36;
V37ToolHandednessAdded = 37;
V38ProjectileSpreadFloat = 38;
LatestRoomPersistenceVersion = 38;
}
enum PersistedRoomVersion {
option allow_alias = true;
V0New = 0;
V1SerializeDefaultTags = 1;
V2ProgressionEventPopupShop = 2;
V3SandboxGunHandleCanReload = 3;
V4StudioAssetBundleChanges = 4;
V5ParticleEmitterSettings = 5;
V6HolotarProjectorCV2 = 6;
V7LaserPointer = 7;
V8TextScreenRelease = 8;
V9AudioPlayerRolloff = 9;
V7SunDirection = 10;
V11StudioSingleAudioMixer = 11;
V12StudioExposedProperties = 12;
V13AuthorizeRestrictedTools = 13;
V14LaserDirection = 14;
V15StudioAudioClipRegistry = 15;
V16NewUGCShapes = 16;
V17TriggerHandleControlLabel = 17;
V18StudioObjectCanvasInteraction = 18;
V19February23BetaRelease = 19;
LatestVersion = 19;
}
// The full schema has many more fields — we only set the fields the
// December 2020 client needs for an editable minimal blob. Its
// RoomPersistenceManager checks field 1 against a minimum editable
// persistence version of 20, so generated blobs must carry a current
// version or room editors see the native "save data is out-of-date" prompt.
message PersistedRoomData {
DEPRECATED_RoomPersistenceVersion DEPRECATED_version = 1;
ToolTagSettingsData tool_tag_settings_data = 20;
GameRoleCollectionData game_role_data = 22;
int32 DEPRECATED_version = 1;
// field 2 (persistence_views) — repeated, default empty list.
// fields 3..22 — left at defaults.
RoomRoleCollectionData room_role_data = 23;
PersistedRoomVersion version = 30;
bool creative_roles_enabled = 31;
// field 24+ — left at defaults.
}
+33 -19
View File
@@ -3,16 +3,12 @@ using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Services;
public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfig domain, IdentityServerSigningKeyProvider signingKeyProvider)
public class AuthService(IConfiguration config, DorkNetDbContext db)
{
public const string AccessCookieName = "dorknet_access_token";
public const string LegacyIssuer = "https://api.rec.net/";
// Resolution order MUST match Program.cs's JwtBearer validator setup —
// env var first, config key second — otherwise tokens are signed with
// one secret and validated against another and every authenticated
@@ -42,17 +38,38 @@ public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfi
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
{
var handler = new JwtSecurityTokenHandler();
var claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
.GetAwaiter()
.GetResult();
claims.Add(new Claim("token_type", tokenType));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
// Look up dev/admin flags so the JWT carries a "developer" role
// claim for accounts that should see the in-game debug button.
// The watch's Login.SetAccessToken parses the access_token's
// <c>role</c> claim — either a single string or a list — and
// calls <c>HasRole("developer", token)</c>; that result drives
// <c>Accounts.SetLocalAccountIsDev</c> and ultimately
// <c>SessionManager.IsDeveloper</c>, which the watch UI checks
// when deciding whether to show the dev console toggle.
var isDev = db.Players
.Where(p => p.Id == playerId)
.Select(p => (bool?)(p.IsDeveloper || p.IsAdmin))
.FirstOrDefault() ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("token_type", tokenType),
};
// Multiple Role claims become a JSON array on the wire — the
// watch's HasRole helper handles both string and List<string>
// forms.
if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer"));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.Add(lifetime),
Issuer = domain.AuthIssuer,
SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
Issuer = "https://api.rec.net/",
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
};
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
@@ -63,22 +80,19 @@ public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfi
try
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
IssuerSigningKey = key,
ValidateIssuer = true,
ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
}, out _);
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
?? principal.FindFirst("sub")?.Value
?? principal.FindFirst("accountId")?.Value
?? principal.FindFirst("account_id")?.Value
?? principal.FindFirst("accountid")?.Value;
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return idClaim is not null ? long.Parse(idClaim) : null;
}
catch
-9
View File
@@ -251,15 +251,6 @@ public class ClubService(DorkNetDbContext db)
public Task<int> PlayerSubscriberCountAsync(long playerId) =>
db.Subscriptions.CountAsync(s => s.TargetPlayerId == playerId);
/// <summary>How many player/profile subscriptions this account has
/// made.</summary>
public Task<int> PlayerSubscribedCountAsync(long playerId) =>
db.Subscriptions.CountAsync(s => s.SubscriberPlayerId == playerId);
public Task<bool> IsSubscribedToPlayerAsync(long subscriberId, long targetPlayerId) =>
db.Subscriptions.AnyAsync(s => s.SubscriberPlayerId == subscriberId
&& s.TargetPlayerId == targetPlayerId);
/// <summary>
/// Bulk member-count lookup so the controller can project a list
/// of clubs in one round-trip. Returns a dict keyed by ClubId; any
+3 -20
View File
@@ -12,24 +12,14 @@ public sealed class DomainConfig
{
public string Apex { get; }
public string Scheme { get; }
public string HostStyle { get; }
public bool UsesHyphenSubdomains => HostStyle == "hyphen";
public DomainConfig(string apex, string scheme = "https", string hostStyle = "dot")
public DomainConfig(string apex, string scheme = "https")
{
Apex = apex;
Scheme = NormalizeScheme(scheme);
HostStyle = NormalizeHostStyle(hostStyle);
}
public string Sub(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix)) return Apex;
return UsesHyphenSubdomains ? $"{prefix}-{Apex}" : $"{prefix}.{Apex}";
}
public string AuthIssuer => Url("auth");
public string Sub(string prefix) => $"{prefix}.{Apex}";
public string Url(string prefix, string path = "")
{
var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix);
@@ -38,18 +28,11 @@ public sealed class DomainConfig
}
public static bool MatchesSubdomain(string host, string prefix)
=> host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)
|| host.StartsWith(prefix + "-", StringComparison.OrdinalIgnoreCase);
=> host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase);
private static string NormalizeScheme(string? value)
{
var scheme = string.IsNullOrWhiteSpace(value) ? "https" : value.Trim().TrimEnd(':', '/', '\\').ToLowerInvariant();
return scheme is "http" or "https" ? scheme : "https";
}
private static string NormalizeHostStyle(string? value)
{
var style = string.IsNullOrWhiteSpace(value) ? "dot" : value.Trim().ToLowerInvariant();
return style is "hyphen" or "dash" or "flat" ? "hyphen" : "dot";
}
}
+1 -28
View File
@@ -64,12 +64,7 @@ public class GameSessionService(DorkNetDbContext db)
// in, so we save once with a placeholder, then patch and save
// again. Two SaveChangesAsync calls is cheap at our QPS.
PhotonRoomName = "pending",
// Track the room's "Max Player Count in This Subroom" slider
// instead of a hardcoded 8, so matchmade joiners fill the room to
// the host's chosen limit rather than fragmenting into a new
// instance every 8 players. (Existing sessions keep whatever cap
// they were created with.)
MaxCapacity = await ResolveRoomMaxCapacityAsync(effectiveRoom),
MaxCapacity = 8,
PlayerCount = 1,
CreatedAt = DateTime.UtcNow,
};
@@ -80,28 +75,6 @@ public class GameSessionService(DorkNetDbContext db)
return ToDto(session);
}
// Default session cap when we can't resolve a room-specific value (system
// rooms like the dorm use a GUID roomId that doesn't map to a RoomEntity).
private const int DefaultMaxCapacity = 8;
/// <summary>Resolve a new session's capacity from the room's entry
/// subroom (lowest <c>OrderIndex</c>) <see cref="RoomSceneEntity.MaxPlayers"/>
/// — the value the in-game "Max Player Count in This Subroom" slider
/// writes. Matchmade sessions land in the entry subroom, so its cap is the
/// right ceiling. GameSession roomIds are the room's numeric Id as a
/// string for user rooms; non-numeric (dorm GUID, etc.) → default. Clamped
/// to a sane Photon ceiling.</summary>
private async Task<int> ResolveRoomMaxCapacityAsync(string roomId)
{
if (!long.TryParse(roomId, out var roomLongId)) return DefaultMaxCapacity;
var max = await db.RoomScenes
.Where(s => s.RoomId == roomLongId)
.OrderBy(s => s.OrderIndex)
.Select(s => (int?)s.MaxPlayers)
.FirstOrDefaultAsync();
return max is int v && v > 0 ? Math.Clamp(v, 1, 80) : DefaultMaxCapacity;
}
public async Task<GameSession?> GetByIdAsync(long id)
{
var s = await db.GameSessions.AsNoTracking()

Some files were not shown because too many files have changed in this diff Show More