using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System.Text.Json; using System.Text.Json.Serialization; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Controllers.API.Avatar.V2; /// /// api.rec.net/api/avatar/v2 — older Avatar settings endpoint used by /// the 2020 client (the V3 endpoint is for newer builds and uses a /// different shape entirely). /// /// Wire shape verified by disassembling RecNet.Avatar.Deserialize at /// RVA 0xFA3C30. Required keys (Util.GetKey<string>): OutfitSelections, /// HairColor, SkinColor, FaceFeatures. Empty strings satisfy the /// deserializer; the client interprets them as "use defaults" and falls /// back to its built-in starter avatar. /// /// This implementation persists each set/get to /// instead of stubbing — round-trip across sessions, multi-user-safe /// (one row per player keyed by PlayerId). /// [ApiController] [Authorize] public class AvatarV2Controller(DorkNetDbContext db, ILogger logger) : ControllerBase { [HttpGet("api/avatar/v2")] public async Task> GetAvatar() { var pid = this.RequireCurrentPlayerId(); var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid); return Ok(ToDto(a)); } /// /// POST /api/avatar/v2/set — replace the player's avatar settings. /// /// CRITICAL: do NOT use [FromBody] AvatarV2Dto here. The /// watch's RecNet.Avatar.Serialize() emits each of /// OutfitSelections / HairColor / SkinColor / FaceFeatures as /// dictionary values whose runtime types are determined by the /// IRecNetObject serializer it uses — FaceFeatures in particular /// sometimes ships as a nested JSON OBJECT instead of a JSON /// string, and the watch may also omit fields entirely when /// they're empty. /// /// System.Text.Json's strict type binder responds to either of those /// with a 400 (model state invalid → ApiController auto-400) and /// the watch then retries every frame, accumulating "Failed to /// upload Rec Room player avatar changes: HTTP Error 400" errors /// (output_log.txt:852+, 1000+ repeats) until the player object /// gets destroyed. /// /// Parse the body manually as JsonElement so we accept any shape /// the watch sends: string-or-object for FaceFeatures (re-encode /// the object as a string before persisting), missing fields (keep /// existing values), null values (treat as empty). /// [HttpPost("api/avatar/v2")] [HttpPost("api/avatar/v2/set")] [Consumes("application/json", "application/x-www-form-urlencoded", "text/plain")] public async Task> SetAvatar() { var pid = this.RequireCurrentPlayerId(); // Read the raw body so we control parsing — bypasses the // model binder's strict type matching that was causing 400s. string raw; using (var reader = new System.IO.StreamReader(Request.Body)) raw = await reader.ReadToEndAsync(); string? outfitSelections = null; string? hairColor = null; string? skinColor = null; string? faceFeatures = null; string? outfitSelectionsV2 = null; string? customAvatarItems = null; if (!string.IsNullOrWhiteSpace(raw)) { try { using var doc = JsonDocument.Parse(raw); var root = doc.RootElement; if (root.ValueKind == JsonValueKind.Object) { outfitSelections = ReadStringField(root, "OutfitSelections"); hairColor = ReadStringField(root, "HairColor"); skinColor = ReadStringField(root, "SkinColor"); faceFeatures = ReadStringField(root, "FaceFeatures"); // 2023 client (HHDLNAPEMGP writer EKHJKDNHOPB) also // uploads OutfitSelectionsV2 + CustomAvatarItems — // persist them so remote-player fetches round-trip. outfitSelectionsV2 = ReadStringField(root, "OutfitSelectionsV2"); customAvatarItems = ReadStringField(root, "CustomAvatarItems"); } } catch (JsonException ex) { // Fall through with all-null updates if the body isn't // JSON. Log so we can see what the watch actually sends. logger.LogWarning( "[avatar-set] could not parse body as JSON ({Msg}); raw={Raw}", ex.Message, raw.Length > 500 ? raw.Substring(0, 500) + "…" : raw); } } var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid); if (a is null) { a = new AvatarEntity { PlayerId = pid }; db.Avatars.Add(a); } // Only overwrite fields the watch actually sent; missing keys // keep the previously-stored value so a partial PATCH-style // update doesn't accidentally wipe HairColor/SkinColor/etc. if (outfitSelections is not null) a.OutfitSelections = outfitSelections; if (hairColor is not null) a.HairColor = hairColor; if (skinColor is not null) a.SkinColor = skinColor; if (faceFeatures is not null) a.FaceFeatures = faceFeatures; if (outfitSelectionsV2 is not null) a.OutfitSelectionsV2 = outfitSelectionsV2; if (customAvatarItems is not null) { // CustomAvatarItems arrives as a JSON ARRAY of // {"CustomAvatarItemId":guid,"BodyPart":byte}; ReadStringField // re-encodes arrays as raw JSON. Guard against non-array junk. a.CustomAvatarItemsJson = customAvatarItems.TrimStart().StartsWith('[') ? customAvatarItems : "[]"; } a.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToDto(a)); } /// Read a field that should be a string but may arrive /// as null, a JSON object/array (re-encoded back to a JSON /// string), or be missing entirely (return null → caller keeps /// the existing DB value). private static string? ReadStringField(JsonElement root, string name) { if (!root.TryGetProperty(name, out var v)) return null; return v.ValueKind switch { JsonValueKind.String => v.GetString() ?? string.Empty, JsonValueKind.Null => string.Empty, JsonValueKind.Object => v.GetRawText(), JsonValueKind.Array => v.GetRawText(), JsonValueKind.Number => v.GetRawText(), JsonValueKind.True or JsonValueKind.False => v.GetRawText(), _ => null, }; } /// GET api/avatar/v2/{accountId} — the 2023 client's /// remote-player avatar settings fetch. Decompile evidence /// (2023.03.21 ISIL, RecNet.Runtime FFFIMAGLKEG.txt:4845-5006, /// method KAFJNMDBKDB): URL is String.Format("{0}v2/{1}", /// "api/avatar/", accountId) — the account id is a bare path segment /// directly under v2 (NOT under /equipped/). The response is parsed /// as object HHDLNAPEMGP whose reader (EKHJKDNHOPB.txt:471-766) /// registers OutfitSelections / OutfitSelectionsV2 / FaceFeatures / /// SkinColor / HairColor / CustomAvatarItems. Without this route the /// request 404'd with an empty body → "Failed to load Rec Room /// remote player avatar settings" (Player.log:2597). [HttpGet("api/avatar/v2/{playerId:long}")] [AllowAnonymous] public async Task> GetSettingsFor(long playerId) { var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId); return Ok(ToDto(a)); } /// Public lookup of another player's equipped avatar — the /// client uses this to render remote players' avatars in shared /// rooms. No auth requirement on read so anonymous calls in the /// boot path don't 401. [HttpGet("api/avatar/v2/equipped/{playerId:long}")] [HttpGet("api/avatar/v3/equipped/{playerId:long}")] [HttpGet("api/avatar/v4/equipped/{playerId:long}")] [AllowAnonymous] public async Task> GetEquippedFor(long playerId) { var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId); return Ok(ToDto(a)); } /// Bare api/avatar/v{2,3}/equipped returns the /// caller's own equipped avatar. Without the bare path the watch's /// "what am I wearing" query falls through to the catch-all and /// gets []. [HttpGet("api/avatar/v2/equipped")] [HttpGet("api/avatar/v3/equipped")] [HttpGet("api/avatar/v4/equipped")] public async Task> GetEquippedSelf() { var pid = this.RequireCurrentPlayerId(); var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid); return Ok(ToDto(a)); } private static AvatarV2Dto ToDto(AvatarEntity? a) => new() { OutfitSelections = a?.OutfitSelections ?? string.Empty, FaceFeatures = a?.FaceFeatures ?? string.Empty, HairColor = a?.HairColor ?? string.Empty, SkinColor = a?.SkinColor ?? string.Empty, OutfitSelectionsV2 = a?.OutfitSelectionsV2 ?? string.Empty, CustomAvatarItems = ParseCustomAvatarItems(a?.CustomAvatarItemsJson), }; /// Re-hydrate the stored CustomAvatarItems JSON into typed /// wire items so the response always carries a well-formed array /// (never a raw string). Bad/legacy blobs degrade to []. private static List ParseCustomAvatarItems(string? json) { if (string.IsNullOrWhiteSpace(json)) return new(); try { return JsonSerializer.Deserialize>( json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new(); } catch (JsonException) { return new(); } } } public class AvatarV2Dto { [JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty; [JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty; [JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty; [JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty; /// 2023 client field — HHDLNAPEMGP reader key /// "OutfitSelectionsV2" (EKHJKDNHOPB.txt:498-514). Extra keys are /// skipped by the 2020 reader, so this is safe cross-version. [JsonPropertyName("OutfitSelectionsV2")] public string OutfitSelectionsV2 { get; set; } = string.Empty; /// 2023 client field — HHDLNAPEMGP reader key /// "CustomAvatarItems" (EKHJKDNHOPB.txt:594-610), a list of /// BGNNOMBFMLH items. [JsonPropertyName("CustomAvatarItems")] public List CustomAvatarItems { get; set; } = new(); } /// BGNNOMBFMLH — { Guid CustomAvatarItemId, Byte BodyPart } /// (property types BGNNOMBFMLH.txt:3-39; JSON keys registered by reader /// MKAIABAJIAJ.txt:215-258). public class CustomAvatarItemRefDto { [JsonPropertyName("CustomAvatarItemId")] public Guid CustomAvatarItemId { get; set; } [JsonPropertyName("BodyPart")] public byte BodyPart { get; set; } }