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; 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"); } } 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; 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, }; } /// 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, }; } 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; }