using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Campus_live_rewrite.api.server { /// /// In-memory index of the UGC custom-avatar-item catalog (Data/APIS/items/UGC.json, ~24k items, /// ~87MB), the imported overlay (ImportedUGC.json - the dev locker) and the items players made /// here (CreatedUGC.json). Loaded once at startup. /// /// MEMORY. Each record is kept as its compact UTF-8 JSON (the whole catalog is ~45 MB that /// way) plus a small typed header - - with the handful of fields the routes /// filter and sort on. A is only built when a record is actually being /// served (a search page, a /bulk lookup, a locker page), and thrown away after. Holding the /// catalog as JObjects cost ~640 MB for the raw file alone (measured), and loading it as one /// parsed tree put the peak well over a gigabyte; the server sat at 2.3 GB RSS. The file is /// also read item by item (JsonTextReader), never as one parsed tree, so the peak is one item. /// /// Dates are kept as the strings they arrived as (DateParseHandling.None) - the catalog's own /// spelling is what the client got before, and ISO strings sort correctly as text. /// public static class CustomItemsCache { /// One catalog record: its JSON bytes and the fields the server reasons about. public sealed class Rec { public string Id = ""; public byte[] Json = Array.Empty(); public ulong Creator; public int Accessibility; public int OutfitType = -1; public int? BaseAvatarItemId; public bool Featured; public string Created = ""; public int Price; public string Name = ""; public string Description = ""; /// Has asset-bundle saves (a modelled item) as opposed to a PNG shirt design. public bool BundleBacked; /// /// Exclusive: tagged "adminonly" (Tags[].Value). Never listed in any store / profile and /// never sold - only the admin panel's gift gives it. Owners still see and wear it normally. /// public bool AdminOnly; /// The record as the client receives it. A fresh object every call. public JObject ToJObject() => BundleHashes.Stamp(ParseRecord(Json)); public static Rec From(JObject o) { // Clothing floor (owner rule): a custom item that is for sale costs at least // StoreGenerator.DefaultPrice. Applied to the record itself so the price the client // displays and the price the buy charges are the same number. int rawPrice = (int?)o["Price"] ?? 0; if (rawPrice > 0 && rawPrice < StoreGenerator.DefaultPrice) o["Price"] = StoreGenerator.DefaultPrice; var r = new Rec { Id = (string?)o["CustomAvatarItemId"] ?? "", Creator = (ulong?)o["CreatorAccountId"] ?? 0, Accessibility = (int?)o["Accessibility"] ?? 0, OutfitType = (int?)o["OutfitType"] ?? -1, BaseAvatarItemId = (int?)o["BaseAvatarItemId"], Featured = (bool?)o["IsFeatured"] == true, Created = (string?)o["CreatedAt"] ?? "", Price = (int?)o["Price"] ?? 0, Name = (string?)o["Name"] ?? "", Description = (string?)o["Description"] ?? "", BundleBacked = o["CurrentSaves"] is JArray saves && saves.Count > 0, AdminOnly = o["Tags"] is JArray tags && tags.Any(t => string.Equals((string?)t?["Value"], "adminonly", StringComparison.OrdinalIgnoreCase)), }; r.Json = Encoding.UTF8.GetBytes(o.ToString(Formatting.None)); return r; } } private static readonly JsonSerializerSettings NoDateParsing = new() { DateParseHandling = DateParseHandling.None }; public static JObject ParseRecord(byte[] json) { using var sr = new StreamReader(new MemoryStream(json, writable: false), Encoding.UTF8); using var jr = new JsonTextReader(sr) { DateParseHandling = DateParseHandling.None }; return JObject.Load(jr); } private static Dictionary _byId = new(StringComparer.OrdinalIgnoreCase); private static List _all = new(); private static Dictionary> _byCreator = new(); // IMPORTED UGC = items whose real .assetbundle/.png assets have been hosted on THIS server // (via the admin importer). These are what a dev "owns" in the UGC dev-locker. Kept as a // distinct set so devs don't get the whole catalog dumped into their wardrobe. private static readonly List _imported = new(); private static readonly HashSet _importedIds = new(StringComparer.OrdinalIgnoreCase); // The official first-party items (FirstPartyUGC.json) - the Coach's stock, account 1. private static readonly HashSet _firstPartyIds = new(StringComparer.OrdinalIgnoreCase); private static readonly object _lock = new(); public static string ImportedPath => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "ImportedUGC.json"); // FIRST-PARTY (Coach) custom items from the reference export, priced from a real Watch // storefront - ImportRealStore.py writes it. Loaded over UGC.json like part of the base // catalog (its record wins for an id both hold, which is how the prices land); NOT the // dev locker, so nobody's wardrobe grows by 2700 items. public static string FirstPartyPath => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "FirstPartyUGC.json"); // PLAYER-CREATED items (the in-game costume maker, POST /api/customAvatarItems/v1): their own // file so a deploy can never clobber them, indexed like the rest but NOT marked imported. public static string CreatedPath => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "CreatedUGC.json"); private static readonly List _created = new(); // CAMPUSSTUDIO items (recolours published from the CampusStudio app through // StudioController): their own file, loaded after FirstPartyUGC.json and treated as // first-party (Coach) stock. Written only by the server, so a deploy never clobbers it. public static string StudioPath => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "StudioUGC.json"); private static readonly List _studio = new(); public static int Count => _all.Count; public static int ImportedCount { get { lock (_lock) return _imported.Count; } } /// Every record (catalog + imported + created), as compact records. public static IReadOnlyList AllRecs => _all; /// The imported (dev locker) records. public static List ImportedRecs { get { lock (_lock) return new List(_imported); } } public static HashSet ImportedIds { get { lock (_lock) return new HashSet(_importedIds, StringComparer.OrdinalIgnoreCase); } } /// Walk a {"Results":[...]} file one object at a time - never the whole tree. private static IEnumerable ReadResults(string path) { using var sr = new StreamReader(path, Encoding.UTF8); using var jr = new JsonTextReader(sr) { DateParseHandling = DateParseHandling.None }; // Find the Results array, then yield each object in it. while (jr.Read()) { if (jr.TokenType == JsonToken.PropertyName && string.Equals((string?)jr.Value, "Results", StringComparison.OrdinalIgnoreCase)) { if (!jr.Read() || jr.TokenType != JsonToken.StartArray) yield break; while (jr.Read() && jr.TokenType != JsonToken.EndArray) { if (jr.TokenType == JsonToken.StartObject) yield return JObject.Load(jr); } yield break; } } } public static void Load() { _droppedItems = 0; _droppedSaves = 0; try { string ugc = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "UGC.json"); string legacy = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "customitems.json"); string path = File.Exists(ugc) ? ugc : legacy; var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); var all = new List(); if (File.Exists(path)) { foreach (var o in ReadResults(path)) { string? id = (string?)o["CustomAvatarItemId"]; if (string.IsNullOrEmpty(id)) continue; if (!PassesCutoff(o)) continue; SanitizeForClient(o); var rec = Rec.From(o); if (byId.TryGetValue(id, out var existing)) all.Remove(existing); byId[id] = rec; all.Add(rec); // NOT indexed by creator: the catalog's CreatorAccountId values are the ORIGINAL // RecNet creators' ids, which are not accounts here. Only CreatedUGC (made on // this server) maps to local accounts; see AddOrUpdate. } Console.WriteLine($"[ugc] loaded {all.Count} custom avatar items from {Path.GetFileName(path)}"); } else Console.WriteLine("[ugc] no UGC.json / customitems.json found - base custom items empty"); _byId = byId; _all = all; _byCreator = new Dictionary>(); lock (_lock) { _imported.Clear(); _importedIds.Clear(); } // Merge the imported overlay LAST so its records win (they carry hosted-asset refs). if (File.Exists(ImportedPath)) { try { int n = 0; foreach (var o in ReadResults(ImportedPath)) { AddOrUpdate(o, persist: false); n++; } Console.WriteLine($"[ugc] merged {n} imported UGC item(s) from ImportedUGC.json (dev locker)"); } catch (Exception iex) { Console.WriteLine($"[ugc] ImportedUGC.json load failed: {iex.Message}"); } } // First-party items LAST: the same 2830 Coach items the dev locker was imported // from, now carrying the real store's price (ImportRealStore.py). A record that is // already in the locker stays in it - only the record is swapped. if (File.Exists(FirstPartyPath)) { try { int added = 0, replaced = 0; lock (_lock) _firstPartyIds.Clear(); foreach (var o in ReadResults(FirstPartyPath)) { string? id = (string?)o["CustomAvatarItemId"]; if (string.IsNullOrEmpty(id)) continue; if (!PassesCutoff(o)) continue; SanitizeForClient(o); var rec = Rec.From(o); lock (_lock) { if (_byId.TryGetValue(id, out var existing)) { _all.Remove(existing); if (_imported.Remove(existing)) _imported.Add(rec); replaced++; } else added++; _byId[id] = rec; _all.Add(rec); _firstPartyIds.Add(id); } } Console.WriteLine($"[ugc] FirstPartyUGC.json: {added} first-party item(s) added, {replaced} replaced (priced)"); } catch (Exception fex) { Console.WriteLine($"[ugc] FirstPartyUGC.json load failed: {fex.Message}"); } } lock (_lock) _studio.Clear(); if (File.Exists(StudioPath)) { try { int n = 0; foreach (var o in ReadResults(StudioPath)) if (AddStudio(o, persist: false) != null) n++; Console.WriteLine($"[ugc] StudioUGC.json: {n} CampusStudio item(s)"); } catch (Exception sex) { Console.WriteLine($"[ugc] StudioUGC.json load failed: {sex.Message}"); } } lock (_lock) _created.Clear(); if (File.Exists(CreatedPath)) { try { int n = 0; foreach (var o in ReadResults(CreatedPath)) { var rec = AddOrUpdate(o, persist: false, markImported: false); if (rec != null) { lock (_lock) _created.Add(rec); n++; } } Console.WriteLine($"[ugc] loaded {n} player-created item(s) from CreatedUGC.json"); } catch (Exception cex) { Console.WriteLine($"[ugc] CreatedUGC.json load failed: {cex.Message}"); } } if (ConfigCampus.CustomItemCreatedCutoffUtc != null) Console.WriteLine($"[ugc] cutoff {ConfigCampus.CustomItemCreatedCutoffUtc:u}: dropped {_droppedItems} item(s) and {_droppedSaves} newer save(s) the clients can't read; {_all.Count} item(s) served"); long bytes = 0; foreach (var r in _all) bytes += r.Json.Length; Console.WriteLine($"[ugc] catalog held as {bytes / (1024 * 1024)} MB of compact JSON ({_all.Count} record(s))"); GC.Collect(); } catch (Exception ex) { Console.WriteLine($"[ugc] load failed: {ex.Message}"); } } /// /// Upsert one custom item into every index and mark it IMPORTED (dev-locker eligible). /// Thread-safe; used both at Load (persist:false) and live from the admin importer /// (persist:true appends it to ImportedUGC.json so it survives a restart). Returns the /// stored record, or null when the item was refused by the cutoff. /// public static Rec? AddOrUpdate(JObject item, bool persist = true, bool markImported = true) { string? id = (string?)item["CustomAvatarItemId"]; if (string.IsNullOrEmpty(id)) return null; if (markImported && !PassesCutoff(item)) return null; // a player's own creation is always current SanitizeForClient(item); var rec = Rec.From(item); lock (_lock) { if (_byId.TryGetValue(id, out var existing)) { _all.Remove(existing); if (_byCreator.TryGetValue(existing.Creator, out var oldList)) oldList.Remove(existing); _imported.Remove(existing); _created.Remove(existing); } _byId[id] = rec; _all.Add(rec); if (rec.Creator != 0 && !markImported) // imported catalog records keep their RecNet creator id; only local creations are attributed { if (!_byCreator.TryGetValue(rec.Creator, out var list)) _byCreator[rec.Creator] = list = new List(); list.Add(rec); } if (markImported) { _importedIds.Add(id); _imported.Add(rec); } } if (persist && markImported) PersistImported(); return rec; } /// /// Upsert a CampusStudio item: indexed exactly like a first-party item (so the store, the /// dev locker and gifting all see it) and persisted to StudioUGC.json. Live - no restart. /// public static Rec? AddStudio(JObject item, bool persist = true) { string? id = (string?)item["CustomAvatarItemId"]; if (string.IsNullOrEmpty(id)) return null; if (!PassesCutoff(item)) return null; SanitizeForClient(item); var rec = Rec.From(item); lock (_lock) { if (_byId.TryGetValue(id, out var existing)) { _all.Remove(existing); if (_imported.Remove(existing)) _imported.Add(rec); } _byId[id] = rec; _all.Add(rec); _firstPartyIds.Add(id); _studio.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); _studio.Add(rec); } if (persist) PersistStudio(); return rec; } /// /// Delete a CampusStudio item from every index and from StudioUGC.json (live - no restart). Only items that /// were published through Studio are in that list; anything else answers null. Returns the removed record. /// public static JObject? RemoveStudio(string? id) { if (string.IsNullOrEmpty(id)) return null; Rec? rec; lock (_lock) { if (!_studio.Any(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))) return null; if (!_byId.TryGetValue(id!, out rec)) return null; _byId.Remove(id!); _all.Remove(rec); _firstPartyIds.Remove(id!); _studio.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); if (_byCreator.TryGetValue(rec.Creator, out var list)) list.Remove(rec); _importedIds.Remove(id!); _imported.Remove(rec); _created.Remove(rec); } PersistStudio(); return rec.ToJObject(); } /// Does any catalog record's JSON still contain this text (a bundle or thumbnail file name)? public static bool AnyRecordMentions(string needle) { var nb = Encoding.UTF8.GetBytes(needle); lock (_lock) foreach (var r in _all) if (r.Json.AsSpan().IndexOf(nb) >= 0) return true; return false; } public static bool IsStudio(string? id) { if (string.IsNullOrEmpty(id)) return false; lock (_lock) return _studio.Any(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); } private static void PersistStudio() { try { List list; lock (_lock) list = new List(_studio); WriteResults(StudioPath, list); } catch (Exception ex) { Console.WriteLine($"[ugc] persist studio failed: {ex.Message}"); } } /// First-party (Coach + CampusStudio) modelled items, admin-only included - the CampusStudio library. public static List StudioLibraryRecs() { List all; HashSet ids; lock (_lock) { all = new List(_all); ids = new HashSet(_firstPartyIds, StringComparer.OrdinalIgnoreCase); } return all.Where(r => ids.Contains(r.Id) && r.BundleBacked).OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase).ToList(); } /// Register a player-created item (costume maker) and persist it to CreatedUGC.json. public static void AddCreated(JObject item) { var rec = AddOrUpdate(item, persist: false, markImported: false); if (rec == null) return; lock (_lock) { _created.RemoveAll(x => string.Equals(x.Id, rec.Id, StringComparison.OrdinalIgnoreCase)); _created.Add(rec); } PersistCreated(); } /// Store an edited created item (PUT) and re-persist. The record is replaced, not patched in place. public static void CreatedChanged(JObject item) => AddCreated(item); /// Remove an item from every index (DELETE). Returns the record, or null. public static JObject? Remove(string? id) { if (string.IsNullOrEmpty(id)) return null; Rec? rec; lock (_lock) { if (!_byId.TryGetValue(id!, out rec)) return null; _byId.Remove(id!); _all.Remove(rec); if (_byCreator.TryGetValue(rec.Creator, out var list)) list.Remove(rec); _importedIds.Remove(id!); _imported.Remove(rec); _created.Remove(rec); } PersistCreated(); return rec.ToJObject(); } // {"Results":[,...],"TotalResults":N}, streamed - no tree is built to write it. private static void WriteResults(string path, List recs) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); string tmp = path + ".tmp"; using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None)) { var head = Encoding.UTF8.GetBytes("{\"Results\":["); fs.Write(head, 0, head.Length); for (int i = 0; i < recs.Count; i++) { if (i > 0) fs.WriteByte((byte)','); fs.Write(recs[i].Json, 0, recs[i].Json.Length); } var tail = Encoding.UTF8.GetBytes($"],\"TotalResults\":{recs.Count}}}"); fs.Write(tail, 0, tail.Length); } File.Move(tmp, path, overwrite: true); } private static void PersistCreated() { try { List list; lock (_lock) list = new List(_created); WriteResults(CreatedPath, list); } catch (Exception ex) { Console.WriteLine($"[ugc] persist created failed: {ex.Message}"); } } /// Write the imported overlay to disk once (use after a batch of AddOrUpdate(persist:false)). public static void FlushImported() => PersistImported(); private static void PersistImported() { try { List list; lock (_lock) list = new List(_imported); WriteResults(ImportedPath, list); } catch (Exception ex) { Console.WriteLine($"[ugc] persist imported failed: {ex.Message}"); } } // Keys of a save's AdditionalConfiguration envelope the 2021.3 client (PC + Quest // 20250718.01) can read: {"v":1,"d":{...}}. Newer exports add top-level keys ("ls":1 on // every 2024+ save); the client's SerializedAvatarItemData converter reads v and d and then // expects the object to END, so an extra key throws "The converter read too much or not // enough. Path: $ ... BytePositionInLine: 53" inside ApplyDownloadableAvatarItem and the // item never gets as far as its bundle. Unknown keys INSIDE d are only warned about // ("unhandled property ... (et)"), so d is passed through untouched. private static readonly HashSet ConfigEnvelopeKeys = new(StringComparer.Ordinal) { "v", "d" }; private static int _droppedItems, _droppedSaves; /// /// Apply ConfigCampus.CustomItemCreatedCutoffUtc: false when the ITEM was created after the /// cutoff (caller skips it). Saves created after the cutoff are removed from the record; an /// item left with no saves at all is dropped too (nothing renderable would remain). /// private static bool PassesCutoff(JObject item) { var cutoff = ConfigCampus.CustomItemCreatedCutoffUtc; if (cutoff == null) return true; if (After(item["CreatedAt"], cutoff.Value)) { _droppedItems++; return false; } if (item["CurrentSaves"] is JArray saves && saves.Count > 0) { var late = saves.Where(s => After(s["CreatedAt"], cutoff.Value)).ToList(); foreach (var s in late) { s.Remove(); _droppedSaves++; } if (saves.Count == 0) { _droppedItems++; return false; } } return true; } private static bool After(JToken? createdAt, DateTime cutoffUtc) { string? s = (string?)createdAt; return !string.IsNullOrEmpty(s) && DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt) && dt > cutoffUtc; } // Keys the client's converter accepts INSIDE d.d (the per-slot attach/render block). This is // every key the 2024-era catalogs use; a key it doesn't know is first warned about ("Found // an unhandled property when parsing SerializedAvatarItemData | propertyName(et)") and then // the SAME "read too much or not enough" exception follows - and because the client applies // the whole owned list in one pass, one bad item (a sword exported in 2025-08 carrying // "et") aborts registration of every item after it, which surfaces as bundles that download // but "can't load". Unknown nested keys are stripped and logged. private static readonly HashSet ConfigNestedKnownKeys = new(StringComparer.Ordinal) { "ap", "fp", "af", "uu", "hh", "fr", "uv", "c", "r", "ho", "hp", "l", "dc", "p", "hr", "ha", "hs", "t" }; private static int _strippedNested; /// Strip the parts of an item record the clients' converters can't parse (see ConfigEnvelopeKeys). public static void SanitizeForClient(JObject item) { if (item["CurrentSaves"] is not JArray saves) return; foreach (var t in saves) { if (t is not JObject save) continue; string? ac = (string?)save["AdditionalConfiguration"]; if (string.IsNullOrWhiteSpace(ac)) continue; try { var cfg = JObject.Parse(ac); bool changed = false; var extra = cfg.Properties().Where(pr => !ConfigEnvelopeKeys.Contains(pr.Name)).ToList(); foreach (var pr in extra) { pr.Remove(); changed = true; } if (cfg["d"] is JObject d && d["d"] is JObject nested) { var unknown = nested.Properties().Where(pr => !ConfigNestedKnownKeys.Contains(pr.Name)).ToList(); foreach (var pr in unknown) { if (_strippedNested++ < 20) Console.WriteLine($"[ugc] '{(string?)item["Name"]}' ({(string?)item["CustomAvatarItemId"]}): stripped unknown config key d.d.{pr.Name} (client can't parse it)"); pr.Remove(); changed = true; } } if (!changed) continue; save["AdditionalConfiguration"] = cfg.ToString(Formatting.None); } catch { /* not JSON - leave it for the client to reject */ } } } /// /// A custom item the client renders from an ASSET BUNDLE (CurrentSaves carry UnityAsset /// files), as opposed to a custom SHIRT - a PNG design (DesignFilename) drawn onto a stock /// base item, which the client paints itself and needs no bundle for. /// /// The two are what the store search's tabs are: the CLOTHING tab asks for the modelled /// outfit types (0, 2, 3, 10, 20, 100-103, 200, 300, 301) and the CUSTOM CLOTHING tab asks /// for 105, the shirt designs. Every bundle-backed item in the catalog was built for a /// newer Unity than this client and comes up "Can't load item", so while /// ConfigCampus.HiddenItemsStorefronts is on they are kept out of the store (see /// StoreListable) - the Clothing tab goes empty, and the Custom Clothing tab keeps the /// shirts, which do render. /// public static bool IsBundleBacked(Rec r) => r.BundleBacked; /// Whether the store (search, featured, hot) may list this item right now. public static bool StoreListable(Rec r) => !r.AdminOnly && (!ConfigCampus.HiddenItemsStorefronts || !r.BundleBacked) || (!r.AdminOnly && ConfigCampus.ServeFirstPartyClothing && IsFirstParty(r)); /// An official first-party item (FirstPartyUGC.json) - the Coach's stock. public static bool IsFirstParty(Rec r) { lock (_lock) return _firstPartyIds.Contains(r.Id); } /// The Coach's stock (account 1): every published first-party item, newest first. /// Published, modelled custom items whose name contains q (admin gift search). public static List SearchRecs(string q, int cap) { if (string.IsNullOrWhiteSpace(q)) return new List(); List all; lock (_lock) all = _all.ToList(); return all.Where(r => r.Accessibility != 0 && r.BundleBacked && r.Name.Contains(q, StringComparison.OrdinalIgnoreCase)) .OrderBy(r => r.Name.Length).ThenBy(r => r.Name).Take(cap).ToList(); } public static List FirstPartyRecs() { HashSet ids; lock (_lock) ids = new HashSet(_firstPartyIds, StringComparer.OrdinalIgnoreCase); return _all.Where(r => ids.Contains(r.Id) && r.Accessibility != 0 && !r.AdminOnly) .OrderByDescending(r => r.Created).ThenBy(r => r.Id).ToList(); } // ---- the developer locker (UGC half) -------------------------------------------------- private static List? _devLocker; private static DateTime _devLockerAt; private static int _devLockerSource = -1; private static readonly object _devLockerLock = new(); /// /// Every custom item a developer owns: every first-party (Coach) item and every published /// player-made / imported item - modelled ones whose bundles we don't host included (they /// show but can't load). Rebuilt every 10 minutes (or when the item count changes). /// public static List DevLockerRecs() { lock (_devLockerLock) { int count; lock (_lock) count = _all.Count; if (_devLocker != null && count == _devLockerSource && DateTime.UtcNow - _devLockerAt < TimeSpan.FromMinutes(10)) return new List(_devLocker); List all; HashSet firstParty; lock (_lock) { all = new List(_all); firstParty = new HashSet(_firstPartyIds, StringComparer.OrdinalIgnoreCase); } var sw = System.Diagnostics.Stopwatch.StartNew(); var locker = new List(); int skipped = 0; // EVERY published item (owner's call 2026-09-26: "every single item") - including // imported modelled items whose bundles we don't host; `skipped` only counts those // for the log. Other players' unpublished drafts stay private. foreach (var r in all) { if (firstParty.Contains(r.Id)) { locker.Add(r); continue; } if (r.Accessibility == 0) continue; // someone's unpublished draft if (r.BundleBacked && !BundlesHeld(r)) skipped++; locker.Add(r); } // First-party first (the Coach's stock), then the rest newest first. locker = locker.OrderByDescending(r => firstParty.Contains(r.Id)).ThenByDescending(r => r.Created).ToList(); Console.WriteLine($"[ugc] dev locker: {locker.Count} custom item(s) ({skipped} of them modelled items without hosted bundles) in {sw.ElapsedMilliseconds} ms"); _devLocker = locker; _devLockerAt = DateTime.UtcNow; _devLockerSource = count; return new List(locker); } } /// Does this server hold every bundle this item's saves name? private static bool BundlesHeld(Rec r) { try { var saves = ParseRecord(r.Json)["CurrentSaves"] as JArray; if (saves == null || saves.Count == 0) return true; foreach (var s in saves) foreach (var k in new[] { "UnityAsset", "UnityAsset2" }) { var n = (string?)s[k]; if (!string.IsNullOrWhiteSpace(n) && BundleHashes.Resolve(n) == null) return false; } return true; } catch { return false; } } /// The Coach's account - who first-party items are filed under (reference COACH_ACCOUNT_ID). public const ulong CoachAccountId = 1; /// All imported UGC (the dev-locker set), materialised. Prefer ImportedRecs + paging. public static List Imported => ImportedRecs.Select(r => r.ToJObject()).ToList(); public static bool IsImported(string? id) { if (string.IsNullOrEmpty(id)) return false; lock (_lock) return _importedIds.Contains(id!); } /// Resolve specific ids (the outfit-build /bulk lookup). Order/duplicates follow input. public static List GetByIds(IEnumerable ids) { // Every id resolves that exists - bundle-backed ones included. Withholding those here // (tried 2026-09-22, to keep the client off the CAI bundle path) emptied the locker // and the store: the client re-resolves its whole cached custom-item set through this // call at login (~5,000 ids in batches of 500) and does not cope with misses. var res = new List(); foreach (var id in ids) if (!string.IsNullOrEmpty(id) && _byId.TryGetValue(id!, out var r)) res.Add(r.ToJObject()); return res; } public static JObject? Get(string? id) => !string.IsNullOrEmpty(id) && _byId.TryGetValue(id!, out var r) ? r.ToJObject() : null; public static Rec? GetRec(string? id) => !string.IsNullOrEmpty(id) && _byId.TryGetValue(id!, out var r) ? r : null; public static List ByCreatorRecs(ulong creatorAccountId) { lock (_lock) return _byCreator.TryGetValue(creatorAccountId, out var l) ? new List(l) : new List(); } public static List ByCreator(ulong creatorAccountId) => ByCreatorRecs(creatorAccountId).Where(r => !r.AdminOnly).Select(r => r.ToJObject()).ToList(); // profile lists: exclusives stay hidden } }