ModernUO/Projects/UOContent/Commands/Object Creation/CAGLoader.cs
Kamron Batman bff08c69d2
perf(objects): batch CAGLoader.BuildTree leaf insertion to O(N)
BuildTree previously grew each category's Nodes array by one element per
object via AppendObject/AppendNode, causing O(N^2) array copies across large
categories (e.g. Items.Uncategorized) on every server startup. Leaves are
now accumulated per-category in a List and flushed with a single array
allocation per category via a new AppendNodes helper. AddFallbackForStaleCache
and LoadLegacy are untouched.

Also adds a JSON round-trip test for ObjectIndexFile/ObjectIndexEntry to
guard the gfx/hue property mapping and the Objects = [] initializer against
double-appending on deserialize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:38:05 -07:00

337 lines
11 KiB
C#

/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: CAGLoader.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using Server.Items;
using Server.Json;
using Server.Logging;
namespace Server.Commands;
public static class CAGLoader
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(CAGLoader));
public static CAGCategory Load()
{
var indexPath = Path.Combine(Core.BaseDirectory, "Data/objects/index.json");
if (!File.Exists(indexPath))
{
logger.Warning("objects/index.json missing — run [GenObjects. Falling back to live categorization load.");
return LoadLegacy();
}
var index = JsonConfig.Deserialize<ObjectIndexFile>(indexPath);
if (index?.Objects == null)
{
throw new JsonException($"Failed to deserialize {indexPath}.");
}
var root = BuildTree(index);
AddFallbackForStaleCache(root, index);
return root;
}
public static CAGCategory BuildTree(ObjectIndexFile index)
{
var root = new CAGCategory("Add Menu");
var pending = new Dictionary<CAGCategory, List<CAGObject>>();
foreach (var entry in index.Objects)
{
var type = AssemblyHandler.FindTypeByName(entry.Type);
if (type == null)
{
logger.Warning("Cached type {Type} no longer resolves; skipping.", entry.Type);
continue;
}
var category = NavigateToCategory(root, entry.Category);
if (!pending.TryGetValue(category, out var objects))
{
objects = new List<CAGObject>();
pending[category] = objects;
}
objects.Add(
new CAGObject
{
Type = type,
ItemID = entry.ItemID,
Hue = entry.Hue == 0 ? null : entry.Hue,
Parent = category
}
);
}
foreach (var (category, objects) in pending)
{
AppendNodes(category, objects);
}
return root;
}
private static CAGCategory NavigateToCategory(CAGCategory root, string dotted)
{
var parent = root;
foreach (var name in dotted.Split('.'))
{
var child = FindCategory(parent, name);
if (child == null)
{
child = new CAGCategory(name, parent);
AppendNode(parent, child);
}
parent = child;
}
return parent;
}
private static CAGCategory FindCategory(CAGCategory parent, string title)
{
if (parent.Nodes == null)
{
return null;
}
foreach (var node in parent.Nodes)
{
if (node is CAGCategory cat && cat.Title == title)
{
return cat;
}
}
return null;
}
private static void AppendNode(CAGCategory parent, CAGNode node)
{
var nodes = parent.Nodes ?? [];
var grown = new CAGNode[nodes.Length + 1];
Array.Copy(nodes, grown, nodes.Length);
grown[^1] = node;
parent.Nodes = grown;
}
private static void AppendObject(CAGCategory category, CAGObject obj) => AppendNode(category, obj);
private static void AppendNodes(CAGCategory parent, IReadOnlyList<CAGNode> nodes)
{
var existing = parent.Nodes ?? [];
var grown = new CAGNode[existing.Length + nodes.Count];
Array.Copy(existing, grown, existing.Length);
for (var i = 0; i < nodes.Count; i++)
{
grown[existing.Length + i] = nodes[i];
}
parent.Nodes = grown;
}
private static void AddFallbackForStaleCache(CAGCategory root, ObjectIndexFile index)
{
var cached = new HashSet<string>();
foreach (var entry in index.Objects)
{
cached.Add(entry.Type);
}
var categorizationPath = Path.Combine(Core.BaseDirectory, "Data/categorization.json");
var categorization = JsonConfig.Deserialize<List<CAGJson>>(categorizationPath);
if (categorization == null)
{
return;
}
foreach (var cag in categorization)
{
foreach (var obj in cag.Objects ?? [])
{
if (obj.Type == null || cached.Contains(obj.Type.Name))
{
continue;
}
logger.Warning("objects cache stale for {Type} — run [GenObjects.", obj.Type.Name);
try
{
var lean = ObjectIntrospection.ExtractLean(obj.Type);
var category = NavigateToCategory(root, cag.Category);
AppendObject(
category,
new CAGObject
{
Type = obj.Type,
ItemID = lean.ItemID,
Hue = lean.Hue == 0 ? null : lean.Hue,
Parent = category
}
);
}
catch (Exception ex)
{
logger.Warning(ex, "Failed live fallback for {Type}.", obj.Type.Name);
}
}
}
}
private static CAGCategory LoadLegacy()
{
var root = new CAGCategory("Add Menu");
var path = Path.Combine(Core.BaseDirectory, "Data/categorization.json");
var list = JsonConfig.Deserialize<List<CAGJson>>(path);
if (list == null)
{
throw new JsonException($"Failed to deserialize {path}.");
}
// Not an optimized solution
foreach (var cag in list)
{
var parent = root;
// Navigate through the dot notation categories until we find the last one
var categories = cag.Category.Split(".");
for (var i = 0; i < categories.Length; i++)
{
var category = categories[i];
// No children, so let's make one
if (parent.Nodes == null)
{
var cat = new CAGCategory(category, parent);
parent.Nodes = new CAGNode[] { cat };
parent = cat;
continue;
}
var oldParent = parent;
for (var j = 0; j < parent.Nodes.Length; j++)
{
var node = parent.Nodes[j];
if (category == node.Title && node is CAGCategory cat)
{
parent = cat;
break;
}
}
// Didn't find the child, let's add it
if (oldParent == parent)
{
var nodes = parent.Nodes;
parent.Nodes = new CAGNode[nodes.Length + 1];
Array.Copy(nodes, parent.Nodes, nodes.Length);
var cat = new CAGCategory(category, parent);
parent.Nodes[^1] = cat;
parent = cat;
}
}
// Set the objects associated with the child most node
var pooledList = new List<CAGNode>(cag.Objects.Length);
for (var i = 0; i < cag.Objects.Length; i++)
{
var cagObj = cag.Objects[i];
cagObj.Parent = parent;
// Set ItemID and Hue
if (cagObj.Hue == null || cagObj.ItemID == null)
{
var type = cagObj.Type;
try
{
if (type.IsAssignableTo(typeof(Item)))
{
var item = cagObj.Type.CreateInstance<Item>();
if (cagObj.ItemID == null)
{
var itemID = item.ItemID;
if (item is BaseAddon addon && addon.Components.Count == 1)
{
itemID = addon.Components[0].ItemID;
}
if (itemID > TileData.MaxItemValue)
{
itemID = 1;
}
cagObj.ItemID = itemID;
}
if (cagObj.Hue == null)
{
var hue = item.Hue & 0x7FFF;
hue = (hue & 0x4000) != 0 ? 0 : hue;
cagObj.Hue = hue == 0 ? null : hue;
}
item.Delete();
}
if (type.IsAssignableTo(typeof(Mobile)))
{
var m = cagObj.Type.CreateInstance<Mobile>();
cagObj.ItemID ??= ShrinkTable.Lookup(m, 1);
if (cagObj.Hue == null)
{
var hue = m.Hue & 0x7FFF;
hue = (hue & 0x4000) != 0 ? 0 : hue;
cagObj.Hue = hue == 0 ? null : hue;
}
m.Delete();
}
}
catch (Exception e)
{
logger.Warning(e, "Failed to instantiate type {Type}.", type);
continue;
}
}
pooledList.Add(cagObj);
}
parent.Nodes = pooledList.ToArray();
}
return root;
}
}
public record CAGJson
{
[JsonPropertyName("category")]
public string Category { get; init; }
[JsonPropertyName("objects")]
public CAGObject[] Objects { get; init; }
}