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) : 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()); var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120); var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20); 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 = ParseInventory(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); var ordered = owned .OrderByDescending(guid => equipped.Contains(guid)) .ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue) .ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase); var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count); var perSlot = new Dictionary(); var result = new List(Math.Min(owned.Count, maxItems)); foreach (var guid 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. if (!catalog.Items.TryGetValue(guid, out var item)) continue; var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1; perSlot.TryGetValue(slot, out var count); if (count >= maxPerSlot && !equipped.Contains(guid)) continue; perSlot[slot] = count + 1; result.Add(item); 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, PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = friendlyName, Tooltip = string.Empty, Rarity = 0, }); } return Ok(result); } /// 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; /// 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 static List ParseInventory(string? json) { if (string.IsNullOrWhiteSpace(json)) return new(); try { var strings = JsonSerializer.Deserialize>(json); if (strings is not null) return strings; } catch { } try { using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); var result = new List(); foreach (var element in doc.RootElement.EnumerateArray()) { string? value = null; if (element.ValueKind == JsonValueKind.String) value = element.GetString(); else if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("itemId", out var itemId) && itemId.ValueKind == JsonValueKind.String) value = itemId.GetString(); if (string.IsNullOrWhiteSpace(value)) continue; const string wardrobePrefix = "wardrobe-"; if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal)) value = value[wardrobePrefix.Length..]; var comma = value.IndexOf(','); if (comma >= 0) value = value[..comma]; result.Add(value); } return result .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } catch { return new(); } } 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); // Rec Room's bundled item names are wrapped in parens like // "(Hat_Wizard)" — strip those and the underscores for a // friendlier UI label. var pretty = 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 + ",,,", PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = pretty, Tooltip = string.Empty, Rarity = 0, }; 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); } 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; [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; } [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; [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; } }