using Newtonsoft.Json.Linq; namespace Campus_live_rewrite.api.server { /// /// Imports UGC custom-avatar-item packs (the shape RecRoomStudio/archive exports use): /// /// <Pack>/<Item Name>/avatarItem.json /// <Pack>/<Item Name>/<n>/<n>_save.json + <hash>.assetbundle + <hash>.png /// (some items keep the .png at the item-folder level and have no .assetbundle - shirts /// ride a base mesh + a design texture served from /img.) /// /// For every avatarItem.json found: hosts its assets on THIS server so the item can render - /// .assetbundle files -> Data/CDN/AvatarBlobs (served at /cdn/avatar/<name>), .png files -> /// Data/Images (served at /img/<name>) - and upserts the item's metadata into the UGC catalog /// (CustomItemsCache), marking it IMPORTED so it appears in the dev locker. /// /// See [[campus-avatar-item-architecture]]: the ONLY reason the cosmetic catalog didn't render /// was that these .assetbundle bytes were never hosted. This is the path that hosts them. /// public static class AvatarItemImporter { public class Result { public int Items; public int BundlesHosted; public int ThumbsHosted; public int Skipped; public readonly List ImportedNames = new(); public readonly List MissingAssets = new(); public readonly List Errors = new(); /// Unity editor version -> bundle count, read from each hosted bundle's UnityFS header. public readonly Dictionary BundleUnityVersions = new(); } // Unity versions the clients this server serves can load bundles from. A player can only // load bundles built with its own editor line or OLDER - never newer - so anything above // these is dead weight that the client downloads, fails to load (NullReference in its // bundle loader, "OutfitPrefab ... is null") and retries forever. Checked at import so a // wrong-era pack is flagged instead of discovered in the wardrobe. // PC = Unity 2021.3.41f1 (StandaloneWindows) // Quest = Unity 2020.3.33f1 (Android) public const string MaxLoadableUnityMajor = "2021"; /// /// The editor version stamped in a UnityFS header ("UnityFS", u32 format, "5.x.x", /// "<editor>"), or null if the file isn't a UnityFS bundle. /// public static string? BundleUnityVersion(string path) { try { using var fs = File.OpenRead(path); var b = new byte[160]; int n = fs.Read(b, 0, b.Length); if (n < 16 || b[0] != (byte)'U' || b[1] != (byte)'n' || b[2] != (byte)'i' || b[3] != (byte)'t' || b[4] != (byte)'y') return null; int i = Array.IndexOf(b, (byte)0, 0, n); if (i < 0) return null; i += 1 + 4; // skip NUL + format version int j = Array.IndexOf(b, (byte)0, i, n - i); if (j < 0) return null; i = j + 1; // skip "5.x.x" j = Array.IndexOf(b, (byte)0, i, n - i); if (j < 0) return null; return System.Text.Encoding.ASCII.GetString(b, i, j - i); } catch { return null; } } /// True when a bundle from this editor version can't load in the clients (newer major than MaxLoadableUnityMajor). public static bool IsUnloadableUnityVersion(string? unityVersion) { if (string.IsNullOrEmpty(unityVersion)) return false; string major = unityVersion.Split('.')[0]; return int.TryParse(major, out int m) && int.TryParse(MaxLoadableUnityMajor, out int max) && m > max; } /// True if the directory tree contains at least one avatarItem.json (= it's a UGC pack). public static bool LooksLikeAvatarPack(string dir) { try { return Directory.EnumerateFiles(dir, "avatarItem.json", SearchOption.AllDirectories).Any(); } catch { return false; } } public static Result ImportDir(string dir, bool moveAssets = false) { var r = new Result(); string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "AvatarBlobs"); string imgDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images"); Directory.CreateDirectory(blobDir); Directory.CreateDirectory(imgDir); List manifests; try { manifests = Directory.EnumerateFiles(dir, "avatarItem.json", SearchOption.AllDirectories).ToList(); } catch (Exception ex) { r.Errors.Add($"scan: {ex.Message}"); return r; } foreach (var manifest in manifests) { try { var item = JObject.Parse(File.ReadAllText(manifest)); string? id = (string?)item["CustomAvatarItemId"]; if (string.IsNullOrWhiteSpace(id)) { r.Skipped++; r.Errors.Add($"{Rel(dir, manifest)}: no CustomAvatarItemId"); continue; } string itemFolder = Path.GetDirectoryName(manifest)!; // Copy EVERY .assetbundle / .png anywhere under the item folder by basename. // (Robust across both layouts: version-subfolder assets and item-level pngs.) foreach (var f in Directory.EnumerateFiles(itemFolder, "*", SearchOption.AllDirectories)) { string ext = Path.GetExtension(f).ToLowerInvariant(); string bn = Path.GetFileName(f); try { // moveAssets: the staging copy is consumed rather than duplicated - a 25 GB // pack would otherwise need 50 GB of disk on the box while it imports. if (ext == ".assetbundle") { string uv = BundleUnityVersion(f) ?? "not-unityfs"; lock (r.BundleUnityVersions) r.BundleUnityVersions[uv] = r.BundleUnityVersions.GetValueOrDefault(uv) + 1; string dest = Path.Combine(blobDir, bn); if (!File.Exists(dest) || new FileInfo(dest).Length != new FileInfo(f).Length) { if (moveAssets) File.Move(f, dest, true); else File.Copy(f, dest, true); } else if (moveAssets) File.Delete(f); r.BundlesHosted++; } else if (ext is ".png" or ".jpg" or ".jpeg") { string dest = Path.Combine(imgDir, bn); if (!File.Exists(dest) || new FileInfo(dest).Length != new FileInfo(f).Length) { if (moveAssets) File.Move(f, dest, true); else File.Copy(f, dest, true); } else if (moveAssets) File.Delete(f); r.ThumbsHosted++; } } catch (Exception cx) { r.Errors.Add($"{bn}: {cx.Message}"); } } // Packs often ship the DESIGN texture (the print that actually renders on the // base mesh) but not the separate wardrobe-TILE thumbnail. Alias an available // image to the tile filename so the wardrobe isn't blank - the item renders // regardless, this is purely the preview tile. string? thumb = (string?)item["ThumbnailImageFilename"]; if (!string.IsNullOrWhiteSpace(thumb) && !File.Exists(Path.Combine(imgDir, thumb))) { foreach (var cand in ThumbnailFallbacks(item)) { string src = Path.Combine(imgDir, cand); if (File.Exists(src)) { try { File.Copy(src, Path.Combine(imgDir, thumb), true); r.ThumbsHosted++; } catch { } break; } } } // Verify the assets the manifest actually REFERENCES are now hosted, so a broken // pack (missing bundle) is reported instead of silently rendering nothing. The // tile thumbnail is excluded - it's aliased above and never render-blocking. foreach (var refName in ReferencedAssets(item)) { string ext = Path.GetExtension(refName).ToLowerInvariant(); string dest = ext == ".assetbundle" ? Path.Combine(blobDir, refName) : Path.Combine(imgDir, refName); var fi = new FileInfo(dest); if (!fi.Exists || fi.Length == 0) r.MissingAssets.Add(refName); } CustomItemsCache.AddOrUpdate(item, persist: false); // batch-persist once at the end r.Items++; r.ImportedNames.Add((string?)item["Name"] ?? id!); } catch (Exception ex) { r.Errors.Add($"{Rel(dir, manifest)}: {ex.Message}"); r.Skipped++; } } if (r.Items > 0) CustomItemsCache.FlushImported(); return r; } /// /// RENDER-CRITICAL filenames the manifest points at - the mesh bundles (UnityAsset/2) and the /// shirt design texture. Tile thumbnails are deliberately NOT here: they're aliased from an /// available image and never block rendering, so flagging them as "missing" is just noise. /// private static IEnumerable ReferencedAssets(JObject item) { var names = new List(); void Add(string? s) { if (!string.IsNullOrWhiteSpace(s)) names.Add(s!); } Add((string?)item["DesignFilename"]); if (item["CurrentSaves"] is JArray saves) foreach (var s in saves) { Add((string?)s["UnityAsset"]); Add((string?)s["UnityAsset2"]); Add((string?)s["DesignFilename"]); } return names.Distinct(StringComparer.OrdinalIgnoreCase); } /// Images that can stand in for a missing wardrobe tile: the design print, then any save thumb. private static IEnumerable ThumbnailFallbacks(JObject item) { var names = new List(); void Add(string? s) { if (!string.IsNullOrWhiteSpace(s)) names.Add(s!); } Add((string?)item["DesignFilename"]); if (item["CurrentSaves"] is JArray saves) foreach (var s in saves) { Add((string?)s["ThumbnailFileName"]); Add((string?)s["DesignFilename"]); } return names.Distinct(StringComparer.OrdinalIgnoreCase); } private static string Rel(string root, string path) { try { return Path.GetRelativePath(root, path); } catch { return path; } } } }