Files
2026-10-14 06:59:51 +02:00

212 lines
12 KiB
C#

using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Imports UGC custom-avatar-item packs (the shape RecRoomStudio/archive exports use):
///
/// &lt;Pack&gt;/&lt;Item Name&gt;/avatarItem.json
/// &lt;Pack&gt;/&lt;Item Name&gt;/&lt;n&gt;/&lt;n&gt;_save.json + &lt;hash&gt;.assetbundle + &lt;hash&gt;.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 -&gt; Data/CDN/AvatarBlobs (served at /cdn/avatar/&lt;name&gt;), .png files -&gt;
/// Data/Images (served at /img/&lt;name&gt;) - 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.
/// </summary>
public static class AvatarItemImporter
{
public class Result
{
public int Items;
public int BundlesHosted;
public int ThumbsHosted;
public int Skipped;
public readonly List<string> ImportedNames = new();
public readonly List<string> MissingAssets = new();
public readonly List<string> Errors = new();
/// <summary>Unity editor version -> bundle count, read from each hosted bundle's UnityFS header.</summary>
public readonly Dictionary<string, int> 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";
/// <summary>
/// The editor version stamped in a UnityFS header ("UnityFS", u32 format, "5.x.x",
/// "&lt;editor&gt;"), or null if the file isn't a UnityFS bundle.
/// </summary>
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; }
}
/// <summary>True when a bundle from this editor version can't load in the clients (newer major than MaxLoadableUnityMajor).</summary>
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;
}
/// <summary>True if the directory tree contains at least one avatarItem.json (= it's a UGC pack).</summary>
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<string> 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;
}
/// <summary>
/// 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.
/// </summary>
private static IEnumerable<string> ReferencedAssets(JObject item)
{
var names = new List<string>();
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);
}
/// <summary>Images that can stand in for a missing wardrobe tile: the design print, then any save thumb.</summary>
private static IEnumerable<string> ThumbnailFallbacks(JObject item)
{
var names = new List<string>();
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; }
}
}
}