using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using System.Text.Json; using System.Text.Json.Serialization; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Avatar.V4; /// /// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the /// caller has UNLOCKED. The watch's /// RecNet.Avatars+<DowloadUnlockedAvatarItems> coroutine /// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149 /// hits "{api/avatar/}v4/items") parses the result as /// List<UnlockedAvatarItem>; the wardrobe UI then /// renders a "buy" button for any item NOT in this list and an /// "equip" affordance for items that are. /// /// Wire shape per /// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212: /// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based), /// "AvatarItemDesc" (string — the item GUID with three empty /// customizations appended, see below), /// "PlatformMask" (int), "FriendlyName" (string), /// "Tooltip" (string), "Rarity" (int). /// /// IMPORTANT — AvatarItemDesc format: the client passes this /// string to AvatarItem.FromRecNetString(string) /// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257). /// The desc is String.Split(',') into guid + customization[0..3] /// — the inner overload then indexes customization[0], /// customization[1], customization[2] and throws /// ArgumentException at ArraySegment.get_Current if the /// segment is shorter than 3. Every desc we ship MUST therefore have /// at least 4 comma-separated parts, so we suffix bare GUIDs with /// ",,," (three empty customizations). /// /// CATALOG SOURCE — data/avatar_item_lookup.json. Generated by /// the manual/CI-only BepInEx patch under /// tools/AvatarCatalogDumpPatch. That patch reads the watch's bundled /// AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup /// SerializedDictionary at boot and writes every (guid, AvatarItemData) /// pair to JSON. THIS is the authoritative safe list — any GUID NOT /// in this file throws InvalidOperationException("Missing data /// for guid …") inside the watch's wardrobe parser, which aborts /// the entire coroutine on the first bad item and leaves the drawer /// empty. Items extracted from outfits_assets_*.bundle /// MonoBehaviours (the older data/wardrobe_items.json) are /// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file /// can't be used as a safelist. /// /// PER-PLAYER ownership: the watch intersects each store tile's /// AvatarItemDesc against THIS list to flip the buy-button /// to "Already Owned." Returning the global wardrobe here therefore /// makes every store item appear owned. We instead intersect the /// caller's with the master /// catalog so the watch sees only items the player actually owns. /// Anonymous calls return []. /// [ApiController] public class AvatarItemsController( DorkNetDbContext db, IConfiguration config, ServerSettingsService serverSettings) : ControllerBase { /// Master catalog keyed by GUID. Loaded once on first /// request from data/avatar_item_lookup.json. Same /// lazy-load idiom as before but the catalog itself is now a /// dump of the watch's bundled /// avatarItemDataLookup instead of the asset-bundle /// MonoBehaviour scan in the previous wardrobe_items.json. private static readonly Lazy _catalog = new(LoadCatalog); [HttpGet("api/avatar/v2/items")] [HttpGet("api/avatar/v3/items")] [HttpGet("api/avatar/v4/items")] [Authorize] public async Task>> Get() { // Keep this switch as a fast kill-switch, but default it on now that // the response is bounded below. The risky path is not the parser // itself; it is feeding the 2020 watch too many BrowsableAvatarItem / // DynamicAvatarItemImposter rows at once. if (!config.GetValue("Avatar:EnableUnlockedItems", true)) return Ok(new List()); // 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); // "Everyone owns all avatar items" admin toggle (mirrors the // "everyone is friends" one): report the ENTIRE master catalog plus // all permanent hair dyes as owned, for every caller, without reading // or writing any inventory row. Flipping the toggle off instantly // reverts to the real per-player wardrobe below. Bounded by the same // maxItems ceiling so a future watch build can't be over-fed. if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync()) return Ok(BuildEntireCatalogOwned(maxItems)); var pid = this.CurrentPlayerId(); if (pid is not long me) return Ok(new List()); var avatar = await db.Avatars .Where(a => a.PlayerId == me) .Select(a => new { a.InventoryJson, a.OutfitSelections }) .FirstOrDefaultAsync(); var owned = ParseInventoryEntries(avatar?.InventoryJson); var ownedHairDyeSlugs = await db.PlayerInventory .Where(i => i.PlayerId == me && i.Quantity > 0) .Select(i => i.ItemSlug) .ToListAsync(); var ownedHairDyes = ownedHairDyeSlugs .Where(slug => StoreService.TryGetHairDyePayload(slug, out _)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List()); var catalog = _catalog.Value; var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); // Recency tiebreaker: GrantWardrobeAsync / gift-consume APPEND new // GUIDs to the end of InventoryJson, so the original index is also // the purchase order. When per-slot truncation has to drop entries, // we want it to drop the OLDEST (starter seed) items rather than the // one the user just bought. var ordered = owned .Select((entry, idx) => (Entry: entry, OriginalIndex: idx)) .OrderByDescending(x => equipped.Contains(x.Entry.BaseGuid)) .ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Entry.BaseGuid, out var slot) ? slot : int.MaxValue) .ThenByDescending(x => x.OriginalIndex) .Select(x => x.Entry); var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count); var perSlot = new Dictionary(); var result = new List(Math.Min(owned.Count, maxItems)); foreach (var entry in ordered) { if (result.Count >= maxOutfitItems) break; // Intersect against the safelist. Items not in the bundled // lookup would crash the watch's wardrobe parser, so they're // silently dropped here. The catalog is keyed by the BARE GUID, // so colored variants (stored as "{guid},{swatch},{mask},") must // look up by BaseGuid — keying by the full comma string would // miss and silently drop every owned colored item (the contest // hat "I bought it but it never shows in my wardrobe" bug). if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue; var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1; perSlot.TryGetValue(slot, out var count); if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue; perSlot[slot] = count + 1; // Preserve the bought color: a bare entry (FullDesc == BaseGuid) // emits the catalog DTO as-is ("{guid},,,"); a colored entry // overrides AvatarItemDesc with the stored swatch/mask desc so // the watch renders the variant the player paid for instead of // falling back to the default swatch. result.Add(string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase) ? item : new UnlockedAvatarItemDto { AvatarItemType = item.AvatarItemType, AvatarItemDesc = ToWatchDesc(entry.FullDesc), // Hash the FULL desc, as the toggle-on path does: a // player can own the base item and several colorways of // it at once (entries dedupe by FullDesc), and reusing the // base id made them collide in the client's id-keyed // wardrobe. AvatarItemId = StableAvatarItemIdFull(ToWatchDesc(entry.FullDesc)), PlatformMask = item.PlatformMask, FriendlyName = item.FriendlyName, Tooltip = item.Tooltip, Rarity = item.Rarity, TagList = item.TagList, IsBaseAvatarItem = item.IsBaseAvatarItem, }); if (result.Count >= maxItems) break; } foreach (var slug in ownedHairDyes) { if (result.Count >= maxItems) break; if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue; StoreService.TryGetHairDyeName(slug, out var friendlyName); result.Add(new UnlockedAvatarItemDto { AvatarItemType = AvatarItemType.HairDye, AvatarItemDesc = hairColorGuid, AvatarItemId = StableAvatarItemId(hairColorGuid), PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = friendlyName, Tooltip = string.Empty, Rarity = 0, TagList = string.Empty, IsBaseAvatarItem = false, }); } return Ok(result); } /// The full owned set when the "everyone owns all avatar /// items" toggle is on: every catalog item (already carrying the /// "{guid},,," desc) plus every permanent hair dye, capped at /// . Hair dyes are emitted in the same /// shape the per-player path uses (AvatarItemType.HairDye, desc = /// hair-color GUID) so the wardrobe renders them identically. private static List BuildEntireCatalogOwned(int maxItems) { var catalog = _catalog.Value; var result = new List(Math.Min(maxItems, catalog.Items.Count + 32)); foreach (var item in catalog.Items.Values) { if (result.Count >= maxItems) return result; result.Add(item); } // Colored variants aren't in the base avatar_item_lookup catalog, so // without this the "everyone owns everything" wardrobe was missing all // ~1.8k colorways — the player didn't own them AND buying one gifted a // variant desc ("{guid},{combo},{mask},") that wasn't in this list, so // the client's post-consume UnlockAvatarItemAndMarkNew Find() missed // and NRE'd (the item vanished on buy). Emit each variant with its // verbatim equip descriptor and a UNIQUE id hashed from the FULL // descriptor (the base-guid hash would collide with the base item and // every sibling colorway). foreach (var v in StoreService.ColorVariantCatalog.Values) { if (result.Count >= maxItems) return result; if (string.IsNullOrWhiteSpace(v.descriptor)) continue; result.Add(new UnlockedAvatarItemDto { AvatarItemType = AvatarItemType.Outfit, AvatarItemDesc = v.descriptor, AvatarItemId = StableAvatarItemIdFull(v.descriptor), PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = v.item_name ?? string.Empty, Tooltip = string.Empty, Rarity = 0, TagList = string.Empty, IsBaseAvatarItem = false, }); } foreach (var slug in StoreService.AllHairDyeSlugs) { if (result.Count >= maxItems) break; if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue; StoreService.TryGetHairDyeName(slug, out var friendlyName); result.Add(new UnlockedAvatarItemDto { AvatarItemType = AvatarItemType.HairDye, AvatarItemDesc = hairColorGuid, AvatarItemId = StableAvatarItemId(hairColorGuid), PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = friendlyName, Tooltip = string.Empty, Rarity = 0, TagList = string.Empty, IsBaseAvatarItem = false, }); } return 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>> DefaultUnlocked() => Get(); /// The set of GUIDs the bundled /// AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup /// contains. Exposed so other controllers (StoreService seed, /// PlayerService starter inventory) can validate their seed /// data against the same safe list. public static IReadOnlySet SafeGuids => _catalog.Value.SafeGuids; /// Master catalog of all renderable items, keyed by /// GUID. Used by callers that need item metadata (slot type, /// name, hair-color flags) outside of the per-player ownership /// check. public static IReadOnlyDictionary Catalog => _catalog.Value.Items; /// GET api/avatar/v1/lockeditems — metadata for /// specific avatar item descriptions the caller does not own. The /// store UI calls this with item descs from purchasable rows and /// expects the same item-detail shape used by unlocked items, /// minus ownership. We resolve against the safe runtime catalog so /// unknown GUIDs never reach the client parser. [HttpGet("api/avatar/v1/lockeditems")] [AllowAnonymous] public async Task LockedItems() { var requested = RequestedAvatarItemGuids().ToList(); if (requested.Count == 0) return Ok(Array.Empty()); // When everyone owns everything, nothing is "locked" — the store's // owned/locked diff must see an empty locked set so every tile reads // as owned, consistent with the full wardrobe returned by Get(). if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync()) return Ok(Array.Empty()); var owned = new HashSet(StringComparer.OrdinalIgnoreCase); var pid = this.CurrentPlayerId(); if (pid is long me) { var invJson = await db.Avatars .Where(a => a.PlayerId == me) .Select(a => a.InventoryJson) .FirstOrDefaultAsync(); foreach (var guid in ParseInventory(invJson)) owned.Add(guid); } // The client parses this as List — the SAME element as // v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by // CLIANCNHNIE which registers TagList / AvatarItemId / // IsBaseAvatarItem too (CLIANCNHNIE.txt). The old 5-key subset left // every locked row at AvatarItemId 0 with a null TagList, so ship // the full catalog DTO. var catalog = _catalog.Value.Items; var rows = requested .Where(guid => !owned.Contains(guid)) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(guid => catalog.TryGetValue(guid, out var item) ? item : null) .Where(item => item is not null) .Select(item => item!) .ToArray(); return Ok(rows); } /// Per-OutfitType slot index → list of GUIDs in that slot. /// Used by to /// build a starter loadout (one per slot) and a starter inventory /// (a few per slot). public static IReadOnlyDictionary> BySlot => _catalog.Value.BySlot; public static bool TryGetOutfitType(string guid, out int outfitType) => _catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType); private IEnumerable RequestedAvatarItemGuids() { foreach (var pair in Request.Query) foreach (var raw in pair.Value) foreach (var part in (raw ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var value = part.Trim(); if (string.IsNullOrWhiteSpace(value)) continue; var comma = value.IndexOf(','); if (comma >= 0) value = value[..comma]; if (Guid.TryParse(value, out _)) yield return value; } } /// An owned wardrobe entry: the bare AvatarItem GUID used as /// the catalog / slot / equipped key, plus the full RecNet desc exactly /// as stored in InventoryJson. For a plain item the two are identical; /// for a colored variant the desc is "{guid},{swatch},{mask}," so the /// swatch the player paid for survives the round-trip to the wardrobe. private readonly record struct OwnedEntry(string BaseGuid, string FullDesc); /// Parse InventoryJson into normalized owned entries. Accepts /// both the plain JSON string array of descs (the shape /// GrantWardrobeAsync / PurchaseAsync write) and the legacy /// [{itemId,...}] object shape. Strips a leading "wardrobe-" slug /// prefix, derives the bare GUID (substring before the first comma) for /// catalog lookups, and keeps the full desc so a colored variant keeps /// its swatch. Dedupes by full desc, so multiple colors of the same /// base item remain distinct wardrobe tiles while exact duplicates /// collapse. private static List ParseInventoryEntries(string? json) { if (string.IsNullOrWhiteSpace(json) || json == "[]") return new(); var raw = new List(); try { var strings = JsonSerializer.Deserialize>(json); if (strings is not null) raw.AddRange(strings); } catch { } if (raw.Count == 0) { try { using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); foreach (var element in doc.RootElement.EnumerateArray()) { if (element.ValueKind == JsonValueKind.String) raw.Add(element.GetString() ?? string.Empty); else if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("itemId", out var itemId) && itemId.ValueKind == JsonValueKind.String) raw.Add(itemId.GetString() ?? string.Empty); } } catch { return new(); } } const string wardrobePrefix = "wardrobe-"; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var result = new List(); foreach (var entry in raw) { var value = entry; if (string.IsNullOrWhiteSpace(value)) continue; if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal)) value = value[wardrobePrefix.Length..]; var comma = value.IndexOf(','); var baseGuid = comma >= 0 ? value[..comma] : value; if (string.IsNullOrWhiteSpace(baseGuid)) continue; if (!seen.Add(value)) continue; result.Add(new OwnedEntry(baseGuid, value)); } return result; } /// Bare-GUID view of the owned set, for callers that only need /// ownership membership (e.g. the locked-items diff), not the swatch. private static List ParseInventory(string? json) => ParseInventoryEntries(json) .Select(e => e.BaseGuid) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); /// Ensure a stored desc carries the ≥4 comma-separated parts /// (guid + 3 customizations) that the watch's /// AvatarItem.FromRecNetString requires — anything shorter throws /// inside the wardrobe parser. Stored colored descs are already /// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy /// value. // A stored colored descriptor is the EXACT RecNet equip string // ({itemGuid},{combinationGuid…}) — the client feeds everything after the // item GUID into combinationLookup.TryGetValue as an opaque, case-sensitive // key, so it must be returned byte-for-byte. Do NOT pad to 3 commas: the // 2023 combination model has short-form descriptors ({item},{combo}) whose // trailing commas are significant — padding "{item},{combo}" to // "{item},{combo},," corrupts the key and the watch fails to resolve the // combination (renders a broken/default mesh). Only genuinely bare entries // (FullDesc == BaseGuid) get the "{guid},,," base form, and those never // reach here (the caller emits the catalog DTO directly for them). private static string ToWatchDesc(string storedDesc) => storedDesc; private static HashSet ParseEquippedItemGuids(string? outfitSelections) { var result = new HashSet(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(outfitSelections)) return result; foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var comma = selection.IndexOf(','); var guid = comma >= 0 ? selection[..comma] : selection; if (!string.IsNullOrWhiteSpace(guid)) result.Add(guid.Trim()); } return result; } private sealed record CatalogData( IReadOnlyDictionary Items, IReadOnlySet SafeGuids, IReadOnlyDictionary> BySlot, IReadOnlyDictionary SlotByGuid); private static CatalogData LoadCatalog() { // Try both lowercase 'data/' (csproj None+Link target) and // uppercase 'Data/' (git-tracked source folder) so case-sensitivity // mismatches between Windows (where the dev wrote the path) and // Linux (where Coolify builds the container) can't lose the lookup // file. Without this fallback, a single missed file → empty // SafeGuids → StoreService.SeedAsync skips its stale-row cleanup // → 418 swatch-GUID rows ship live → every store tile renders as // Hat_Angler. var candidates = new[] { Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"), Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"), Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"), Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"), }; var path = candidates.FirstOrDefault(System.IO.File.Exists); Console.WriteLine(path is null ? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}" : $"[avatar-catalog] loading from {path}"); if (path is null) return new CatalogData( new Dictionary(), new HashSet(), new Dictionary>(), new Dictionary()); using var fs = System.IO.File.OpenRead(path); var dump = JsonSerializer.Deserialize(fs, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new LookupFile(); var items = new Dictionary(dump.Items.Count, StringComparer.OrdinalIgnoreCase); var safe = new HashSet(StringComparer.OrdinalIgnoreCase); var bySlot = new Dictionary>(); var slotByGuid = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var row in dump.Items) { if (string.IsNullOrEmpty(row.Guid)) continue; safe.Add(row.Guid); // Prefer the baked display_name from the JSON if present // (tools/add-display-names.py writes it once per refresh). // Falls back to the raw-name cleanup if the field is missing // — e.g. partial dumps or older fixture files. var pretty = !string.IsNullOrEmpty(row.DisplayName) ? row.DisplayName : PrettyName(row.Name); items[row.Guid] = new UnlockedAvatarItemDto { // AvatarItemType is the wire enum the watch parses // (Outfit=0 / HairDye=1), not the slot. Rows in // avatarItemDataLookup are wearable avatar items, including // hair meshes that *use* hair color. They must still be sent // as Outfit; sending HairDye makes the client treat the item // GUID as a color GUID and log "Can not find hair dye color". AvatarItemType = AvatarItemType.Outfit, AvatarItemDesc = row.Guid + ",,,", AvatarItemId = StableAvatarItemId(row.Guid), PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = pretty, Tooltip = string.Empty, Rarity = 0, TagList = string.Empty, IsBaseAvatarItem = false, }; if (!bySlot.TryGetValue(row.OutfitType, out var lst)) bySlot[row.OutfitType] = lst = new List(); lst.Add(row.Guid); slotByGuid[row.Guid] = row.OutfitType; } return new CatalogData(items, safe, bySlot, slotByGuid); } /// Stable, deterministic, non-zero 31-bit id for an owned /// avatar item, derived from its base GUID (FNV-1a). The 2023 client /// keys the wardrobe by AvatarItemId, so it must be unique per /// item and stable across restarts. Colored variants must NOT use this — a player can own the base and /// several colorways together; see . public static int StableAvatarItemId(string? avatarItemDescOrGuid) { var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0]; uint h = 2166136261u; foreach (var c in guid) h = (h ^ c) * 16777619u; var id = (int)(h & 0x7FFFFFFF); return id == 0 ? 1 : id; } /// Stable id from the FULL descriptor string (not just the base /// guid). Colored variants share a base guid but are distinct wardrobe /// entries, so they must hash the whole "{guid},{combo},{mask}," string /// to get ids distinct from the base item and each other. public static int StableAvatarItemIdFull(string? descriptor) { uint h = 2166136261u; foreach (var c in descriptor ?? string.Empty) h = (h ^ c) * 16777619u; var id = (int)(h & 0x7FFFFFFF); return id == 0 ? 1 : id; } private static string PrettyName(string raw) { if (string.IsNullOrEmpty(raw)) return string.Empty; // Strip leading/trailing parens that the bundled config wraps // names in, then turn underscores into spaces. Keep it simple — // the watch shows whatever string we send. var trimmed = raw.Trim().TrimStart('(').TrimEnd(')'); // Drop common asset-name suffixes. Items in the runtime config // use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock"); // the watch never normalises these, so we do it server-side. var s = trimmed.Replace('_', ' ').Trim(); return s; } /// Shape of data/avatar_item_lookup.json as written /// by tools/AvatarCatalogDumpPatch. private sealed class LookupFile { [JsonPropertyName("items")] public List Items { get; set; } = new(); // Auxiliary GUID lists (prefab/mask/swatch/decal). Not used // server-side yet — they'd matter for resolving asset // references the watch might subsequently request, but the // wire protocol only ever ships the AvatarItem GUID. [JsonPropertyName("prefab_guids")] public List PrefabGuids { get; set; } = new(); [JsonPropertyName("mask_guids")] public List MaskGuids { get; set; } = new(); [JsonPropertyName("swatch_guids")] public List SwatchGuids { get; set; } = new(); [JsonPropertyName("decal_guids")] public List DecalGuids { get; set; } = new(); } private sealed class LookupItem { [JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty; [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; // Algorithmically derived from `name` by // tools/add-display-names.py — Rec Room's official UI strings // live in Unity asset bundles we can't read from a code dump, // so this is a best-effort cleanup. Optional — server falls // back to PrettyName() if missing. [JsonPropertyName("display_name")] public string? DisplayName { get; set; } [JsonPropertyName("outfit_type")] public int OutfitType { get; set; } [JsonPropertyName("outfit_set")] public int OutfitSet { get; set; } [JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; } [JsonPropertyName("hides_hair")] public bool HidesHair { get; set; } [JsonPropertyName("hides_hand")] public bool HidesHand { get; set; } // hides_left_ear / hides_right_ear were added to AvatarItemData // in the 2020.12.18 client build. Optional in the JSON so older // dumps still deserialize. [JsonPropertyName("hides_left_ear")] public bool HidesLeftEar { get; set; } [JsonPropertyName("hides_right_ear")] public bool HidesRightEar { get; set; } [JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; } [JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; } [JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; } } /// Mirrors RecNet.Avatars+AvatarItemType /// (Avatars.cs:186-190). private static class AvatarItemType { public const int Outfit = 0; public const int HairDye = 1; } /// OutfitType slot enum mirrored from /// RecRoom.Avatar.Data.Shared.OutfitType. Used by other /// callers (PlayerService, StoreService) for slot-aware seeding. public static class Slot { public const int Hat = 0; public const int BackHead = 1; public const int Hair = 2; public const int Eye = 10; public const int Mouth = 20; public const int Neck = 100; public const int Shirt = 101; public const int Belt = 102; public const int Pocket = 103; public const int TeamShirt = 104; public const int Wrist = 200; public const int Glove = 201; public const int Watch = 202; public const int TeamWrist = 203; } } public class UnlockedAvatarItemDto { [JsonPropertyName("AvatarItemType")] public int AvatarItemType { get; set; } [JsonPropertyName("AvatarItemDesc")] public string AvatarItemDesc { get; set; } = string.Empty; /// The 2023 client's owned-item DTO (RecNet IFNGBPJGINL) reads /// AvatarItemId as an Int32. Omitting it left every owned /// item at id 0; the client keys its wardrobe by AvatarItemId, so a /// second item collided and the whole unlocked-items list failed to /// build — the wardrobe showed nothing owned and buying (which refetches /// this list) NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew. Emit a /// stable, unique, non-zero id. [JsonPropertyName("AvatarItemId")] public int AvatarItemId { get; set; } [JsonPropertyName("PlatformMask")] public int PlatformMask { get; set; } [JsonPropertyName("FriendlyName")] public string FriendlyName { get; set; } = string.Empty; [JsonPropertyName("Tooltip")] public string Tooltip { get; set; } = string.Empty; [JsonPropertyName("Rarity")] public int Rarity { get; set; } /// CSV tag string (IFNGBPJGINL.TagList). Empty is fine — /// AvatarItemMetadata.ParseTagList tolerates it — but the key must be /// present and a string, never null. [JsonPropertyName("TagList")] public string TagList { get; set; } = string.Empty; /// Whether this is a default base-body item vs a cosmetic /// (IFNGBPJGINL.IsBaseAvatarItem). False for wardrobe/catalog items. [JsonPropertyName("IsBaseAvatarItem")] public bool IsBaseAvatarItem { get; set; } }