Files
DorkNet/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs
T
Alexa 4edf25b2d5 Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
  Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
  on v1-december-* tag push.

Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
2026-05-27 01:04:12 +03:00

483 lines
22 KiB
C#

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;
/// <summary>
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
/// caller has UNLOCKED. The watch's
/// <c>RecNet.Avatars+&lt;DowloadUnlockedAvatarItems&gt;</c> coroutine
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
/// <c>List&lt;UnlockedAvatarItem&gt;</c>; 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 — <c>AvatarItemDesc</c> format: the client passes this
/// string to <c>AvatarItem.FromRecNetString(string)</c>
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
/// The desc is <c>String.Split(',')</c> into <c>guid + customization[0..3]</c>
/// — the inner overload then indexes <c>customization[0]</c>,
/// <c>customization[1]</c>, <c>customization[2]</c> and throws
/// <c>ArgumentException</c> at <c>ArraySegment.get_Current</c> 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
/// <c>",,,"</c> (three empty customizations).
///
/// CATALOG SOURCE — <c>data/avatar_item_lookup.json</c>. Generated by
/// the manual/CI-only BepInEx patch under
/// <c>tools/AvatarCatalogDumpPatch</c>. That patch reads the watch's bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
/// in this file throws <c>InvalidOperationException("Missing data
/// for guid …")</c> inside the watch's wardrobe parser, which aborts
/// the entire coroutine on the first bad item and leaves the drawer
/// empty. Items extracted from <c>outfits_assets_*.bundle</c>
/// MonoBehaviours (the older <c>data/wardrobe_items.json</c>) 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
/// <c>AvatarItemDesc</c> 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 <see cref="AvatarEntity.InventoryJson"/> with the master
/// catalog so the watch sees only items the player actually owns.
/// Anonymous calls return [].
/// </summary>
[ApiController]
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
{
/// <summary>Master catalog keyed by GUID. Loaded once on first
/// request from <c>data/avatar_item_lookup.json</c>. Same
/// lazy-load idiom as before but the catalog itself is now a
/// dump of the watch's bundled
/// <c>avatarItemDataLookup</c> instead of the asset-bundle
/// MonoBehaviour scan in the previous wardrobe_items.json.</summary>
private static readonly Lazy<CatalogData> _catalog = new(LoadCatalog);
[HttpGet("api/avatar/v2/items")]
[HttpGet("api/avatar/v3/items")]
[HttpGet("api/avatar/v4/items")]
[Authorize]
public async Task<ActionResult<List<UnlockedAvatarItemDto>>> 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<UnlockedAvatarItemDto>());
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<UnlockedAvatarItemDto>());
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<UnlockedAvatarItemDto>());
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<int, int>();
var result = new List<UnlockedAvatarItemDto>(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);
}
/// <summary>The set of GUIDs the bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// contains. Exposed so other controllers (StoreService seed,
/// PlayerService starter inventory) can validate their seed
/// data against the same safe list.</summary>
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
/// <summary>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.</summary>
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
=> _catalog.Value.Items;
/// <summary>GET <c>api/avatar/v1/lockeditems</c> — 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.</summary>
[HttpGet("api/avatar/v1/lockeditems")]
[AllowAnonymous]
public async Task<IActionResult> LockedItems()
{
var requested = RequestedAvatarItemGuids().ToList();
if (requested.Count == 0) return Ok(Array.Empty<object>());
var owned = new HashSet<string>(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);
}
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 => new
{
item!.AvatarItemType,
item.AvatarItemDesc,
item.FriendlyName,
item.Tooltip,
item.Rarity,
})
.ToArray();
return Ok(rows);
}
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
/// build a starter loadout (one per slot) and a starter inventory
/// (a few per slot).</summary>
public static IReadOnlyDictionary<int, List<string>> BySlot
=> _catalog.Value.BySlot;
public static bool TryGetOutfitType(string guid, out int outfitType) =>
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
private IEnumerable<string> 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;
}
}
private static List<string> ParseInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
var strings = JsonSerializer.Deserialize<List<string>>(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<string>();
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<string> ParseEquippedItemGuids(string? outfitSelections)
{
var result = new HashSet<string>(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<string, UnlockedAvatarItemDto> Items,
IReadOnlySet<string> SafeGuids,
IReadOnlyDictionary<int, List<string>> BySlot,
IReadOnlyDictionary<string, int> 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<string, UnlockedAvatarItemDto>(),
new HashSet<string>(),
new Dictionary<int, List<string>>(),
new Dictionary<string, int>());
using var fs = System.IO.File.OpenRead(path);
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new LookupFile();
var items = new Dictionary<string, UnlockedAvatarItemDto>(dump.Items.Count, StringComparer.OrdinalIgnoreCase);
var safe = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var bySlot = new Dictionary<int, List<string>>();
var slotByGuid = new Dictionary<string, int>(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 + ",,,",
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<string>();
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;
}
/// <summary>Shape of <c>data/avatar_item_lookup.json</c> as written
/// by <c>tools/AvatarCatalogDumpPatch</c>.</summary>
private sealed class LookupFile
{
[JsonPropertyName("items")]
public List<LookupItem> 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<string> PrefabGuids { get; set; } = new();
[JsonPropertyName("mask_guids")]
public List<string> MaskGuids { get; set; } = new();
[JsonPropertyName("swatch_guids")]
public List<string> SwatchGuids { get; set; } = new();
[JsonPropertyName("decal_guids")]
public List<string> 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; }
}
/// <summary>Mirrors <c>RecNet.Avatars+AvatarItemType</c>
/// (Avatars.cs:186-190).</summary>
private static class AvatarItemType
{
public const int Outfit = 0;
public const int HairDye = 1;
}
/// <summary>OutfitType slot enum mirrored from
/// <c>RecRoom.Avatar.Data.Shared.OutfitType</c>. Used by other
/// callers (PlayerService, StoreService) for slot-aware seeding.</summary>
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; }
}