From d8acde2b0af4fb081d882f60f5b782d04a7ab03f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:35:33 -0700 Subject: [PATCH 01/13] feat(objects): cache DTOs + chunk-key/friendly-type naming helpers Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/Objects/ObjectNamingTests.cs | 29 ++++++ .../Commands/Object Creation/ObjectDocs.cs | 93 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs create mode 100644 Projects/UOContent/Commands/Object Creation/ObjectDocs.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs new file mode 100644 index 000000000..f24216df8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs @@ -0,0 +1,29 @@ +using System; +using Server.Commands; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectNamingTests +{ + [Theory] + [InlineData("Items.Skill Items.Magical", "items.skill-items.magical")] + [InlineData("Items.Weapons.Swords", "items.weapons.swords")] + [InlineData("Mobiles.Uncategorized", "mobiles.uncategorized")] + public void ChunkKey_lowercases_and_replaces_spaces(string category, string expected) + { + Assert.Equal(expected, ObjectNaming.ChunkKey(category)); + } + + [Theory] + [InlineData(typeof(int), "int")] + [InlineData(typeof(bool), "bool")] + [InlineData(typeof(string), "string")] + [InlineData(typeof(double), "double")] + [InlineData(typeof(int?), "int?")] + [InlineData(typeof(Server.Items.WeaponQuality), "WeaponQuality")] + public void FriendlyTypeName_maps_primitives_and_keeps_enum_names(Type t, string expected) + { + Assert.Equal(expected, ObjectNaming.FriendlyTypeName(t)); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs new file mode 100644 index 000000000..8f18dd8fc --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Server.Commands; + +public static class ObjectNaming +{ + public static string ChunkKey(string category) => category.ToLowerInvariant().Replace(' ', '-'); + + public static string FriendlyTypeName(Type t) + { + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return $"{FriendlyTypeName(Nullable.GetUnderlyingType(t))}?"; + } + + return t switch + { + _ when t == typeof(int) => "int", + _ when t == typeof(uint) => "uint", + _ when t == typeof(bool) => "bool", + _ when t == typeof(string) => "string", + _ when t == typeof(double) => "double", + _ when t == typeof(float) => "float", + _ when t == typeof(long) => "long", + _ when t == typeof(ulong) => "ulong", + _ when t == typeof(short) => "short", + _ when t == typeof(ushort) => "ushort", + _ when t == typeof(byte) => "byte", + _ when t == typeof(sbyte) => "sbyte", + _ when t == typeof(char) => "char", + _ when t == typeof(decimal) => "decimal", + _ => t.Name + }; + } +} + +public sealed class ObjectIndexEntry +{ + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("entity")] public string Entity { get; set; } + [JsonPropertyName("category")] public string Category { get; set; } + [JsonPropertyName("chunk")] public string Chunk { get; set; } + [JsonPropertyName("gfx")] public int ItemID { get; set; } + [JsonPropertyName("hue")] public int Hue { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("cliloc")] public int? Cliloc { get; set; } +} + +public sealed class ObjectIndexFile +{ + [JsonPropertyName("generatedUtc")] public string GeneratedUtc { get; set; } + [JsonPropertyName("objects")] public List Objects { get; set; } = []; +} + +public sealed class ParamDoc +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("default")] public string Default { get; set; } + [JsonPropertyName("isParams")] public bool IsParams { get; set; } +} + +public sealed class CtorDoc +{ + [JsonPropertyName("parameters")] public List Parameters { get; set; } = []; +} + +public sealed class PropertyDoc +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("readLevel")] public string ReadLevel { get; set; } + [JsonPropertyName("writeLevel")] public string WriteLevel { get; set; } + [JsonPropertyName("readOnly")] public bool ReadOnly { get; set; } + [JsonPropertyName("enumValues")] public string[] EnumValues { get; set; } +} + +public sealed class OplLine +{ + [JsonPropertyName("cliloc")] public int Cliloc { get; set; } + [JsonPropertyName("args")] public string Args { get; set; } + [JsonPropertyName("text")] public string Text { get; set; } +} + +public sealed class ObjectDetail +{ + [JsonPropertyName("baseType")] public string BaseType { get; set; } + [JsonPropertyName("ctors")] public List Ctors { get; set; } = []; + [JsonPropertyName("properties")] public List Properties { get; set; } = []; + [JsonPropertyName("opl")] public List Opl { get; set; } = []; +} From d02cb5687141d2349a3178e1119750b51c6b3e3e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:44:34 -0700 Subject: [PATCH 02/13] =?UTF-8?q?feat(objects):=20ExtractLean=20=E2=80=94?= =?UTF-8?q?=20itemID/hue/name/cliloc=20from=20a=20live=20instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectIntrospectionLeanTests.cs | 26 ++++++++ .../Object Creation/ObjectIntrospection.cs | 60 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs create mode 100644 Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs new file mode 100644 index 000000000..935ea3cdb --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs @@ -0,0 +1,26 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionLeanTests +{ + [Fact] + public void ExtractLean_reads_item_id_from_a_weapon() + { + // Katana ctor is base(0x13FF) — era-independent. + var lean = ObjectIntrospection.ExtractLean(typeof(Katana)); + Assert.Equal(0x13FF, lean.ItemID); + } + + [Fact] + public void ExtractLean_reads_hue_and_cliloc_from_a_runebook() + { + // Runebook sets Hue = 0x461 and LabelNumber 1041267 regardless of era. + var lean = ObjectIntrospection.ExtractLean(typeof(Runebook)); + Assert.Equal(0x461, lean.Hue); + Assert.Equal(1041267, lean.Cliloc); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs new file mode 100644 index 000000000..4d7a0222e --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -0,0 +1,60 @@ +using System; +using Server.Items; + +namespace Server.Commands; + +public readonly record struct LeanMetadata(int ItemID, int Hue, string Name, int? Cliloc); + +public static class ObjectIntrospection +{ + public static LeanMetadata ExtractLean(Type type) + { + if (type.IsAssignableTo(typeof(Item))) + { + var item = type.CreateInstance(); + try + { + var itemID = item.ItemID; + if (item is BaseAddon addon && addon.Components.Count == 1) + { + itemID = addon.Components[0].ItemID; + } + + if (itemID > TileData.MaxItemValue) + { + itemID = 1; + } + + var hue = item.Hue & 0x7FFF; + hue = (hue & 0x4000) != 0 ? 0 : hue; + + var cliloc = item.LabelNumber > 0 ? item.LabelNumber : (int?)null; + var name = item.Name ?? (cliloc.HasValue ? Server.Localization.GetText(cliloc.Value) : null); + + return new LeanMetadata(itemID, hue, name, cliloc); + } + finally + { + item.Delete(); + } + } + + if (type.IsAssignableTo(typeof(Mobile))) + { + var m = type.CreateInstance(); + try + { + var itemID = ShrinkTable.Lookup(m, 1); + var hue = m.Hue & 0x7FFF; + hue = (hue & 0x4000) != 0 ? 0 : hue; + return new LeanMetadata(itemID, hue, m.Name, null); + } + finally + { + m.Delete(); + } + } + + throw new ArgumentException($"{type} is neither Item nor Mobile.", nameof(type)); + } +} From 95d57afdec3104c02068a29a96033f522c4e8760 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:49:48 -0700 Subject: [PATCH 03/13] =?UTF-8?q?feat(objects):=20ExtractCtors=20=E2=80=94?= =?UTF-8?q?=20constructible=20ctor=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectIntrospectionCtorsTests.cs | 24 ++++++++++++++ .../Object Creation/ObjectIntrospection.cs | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs new file mode 100644 index 000000000..fcdaed07c --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs @@ -0,0 +1,24 @@ +using System.Linq; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionCtorsTests +{ + [Fact] + public void ExtractCtors_lists_both_constructible_runebook_overloads() + { + // Runebook has [Constructible] Runebook() and [Constructible] Runebook(int maxCharges). + var ctors = ObjectIntrospection.ExtractCtors(typeof(Runebook)); + + Assert.Equal(2, ctors.Count); + Assert.Contains(ctors, c => c.Parameters.Count == 0); + + var parameterized = Assert.Single(ctors, c => c.Parameters.Count == 1); + Assert.Equal("maxCharges", parameterized.Parameters[0].Name); + Assert.Equal("int", parameterized.Parameters[0].Type); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs index 4d7a0222e..357909c77 100644 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Reflection; using Server.Items; namespace Server.Commands; @@ -57,4 +59,35 @@ public static class ObjectIntrospection throw new ArgumentException($"{type} is neither Item nor Mobile.", nameof(type)); } + + public static List ExtractCtors(Type type) + { + var docs = new List(); + + foreach (var ctor in type.GetConstructors()) + { + if (!Attributes.IsConstructible(ctor, AccessLevel.Developer)) + { + continue; + } + + var doc = new CtorDoc(); + foreach (var p in ctor.GetParameters()) + { + doc.Parameters.Add( + new ParamDoc + { + Name = p.Name, + Type = ObjectNaming.FriendlyTypeName(p.ParameterType), + Default = p.HasDefaultValue ? p.DefaultValue?.ToString() ?? "null" : null, + IsParams = p.IsDefined(typeof(ParamArrayAttribute), false) + } + ); + } + + docs.Add(doc); + } + + return docs; + } } From 685fbc146edecd6dbdf19686d9684f167310236f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:54:40 -0700 Subject: [PATCH 04/13] =?UTF-8?q?feat(objects):=20ExtractProperties=20?= =?UTF-8?q?=E2=80=94=20[CommandProperty]=20props=20with=20enum=20expansion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ObjectIntrospection.ExtractProperties(Type) to extract public instance properties carrying [CommandProperty] attribute, including inherited ones. Properties include type via ObjectNaming.FriendlyTypeName, read/write access levels, readOnly flag, and enum values expanded via Enum.GetNames. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ObjectIntrospectionPropertiesTests.cs | 23 ++++++++++++++ .../Object Creation/ObjectIntrospection.cs | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs new file mode 100644 index 000000000..efda9a929 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs @@ -0,0 +1,23 @@ +using System.Linq; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionPropertiesTests +{ + [Fact] + public void ExtractProperties_includes_inherited_item_command_properties() + { + var props = ObjectIntrospection.ExtractProperties(typeof(Runebook)); + + var hue = Assert.Single(props, p => p.Name == "Hue"); + Assert.Equal("int", hue.Type); + + var lootType = Assert.Single(props, p => p.Name == "LootType"); + Assert.NotNull(lootType.EnumValues); + Assert.Contains("Blessed", lootType.EnumValues); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs index 357909c77..f264a76f0 100644 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -90,4 +90,34 @@ public static class ObjectIntrospection return docs; } + + public static List ExtractProperties(Type type) + { + var docs = new List(); + + var props = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + foreach (var p in props) + { + var attr = p.GetCustomAttribute(true); + if (attr == null) + { + continue; + } + + var pt = p.PropertyType; + docs.Add( + new PropertyDoc + { + Name = p.Name, + Type = ObjectNaming.FriendlyTypeName(pt), + ReadLevel = attr.ReadLevel.ToString(), + WriteLevel = attr.WriteLevel.ToString(), + ReadOnly = attr.ReadOnly || !p.CanWrite, + EnumValues = pt.IsEnum ? Enum.GetNames(pt) : null + } + ); + } + + return docs; + } } From c71bc4bcd5848b9a7936d358362969510ae8d356 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:59:37 -0700 Subject: [PATCH 05/13] =?UTF-8?q?feat(objects):=20ExtractOpl=20=E2=80=94?= =?UTF-8?q?=20decode=20OPL=20buffer=20into=20cliloc/args/text=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectIntrospectionOplTests.cs | 17 +++++ .../Object Creation/ObjectIntrospection.cs | 64 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs new file mode 100644 index 000000000..b360a2726 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs @@ -0,0 +1,17 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionOplTests +{ + [Fact] + public void ExtractOpl_captures_the_runebook_name_cliloc() + { + // Runebook.LabelNumber is 1041267 ("runebook") — it appears as an OPL line. + var opl = ObjectIntrospection.ExtractOpl(typeof(Runebook)); + Assert.Contains(opl, line => line.Cliloc == 1041267); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs index f264a76f0..f4d076f97 100644 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -1,6 +1,8 @@ using System; +using System.Buffers.Binary; using System.Collections.Generic; using System.Reflection; +using System.Text; using Server.Items; namespace Server.Commands; @@ -120,4 +122,66 @@ public static class ObjectIntrospection return docs; } + + public static List ExtractOpl(Type type) + { + if (type.IsAssignableTo(typeof(Item))) + { + var item = type.CreateInstance(); + try + { + var opl = new ObjectPropertyList(item); + item.GetProperties(opl); + return DecodeOpl(opl); + } + finally + { + item.Delete(); + } + } + + if (type.IsAssignableTo(typeof(Mobile))) + { + var m = type.CreateInstance(); + try + { + var opl = new ObjectPropertyList(m); + m.GetProperties(opl); + return DecodeOpl(opl); + } + finally + { + m.Delete(); + } + } + + return []; + } + + private static List DecodeOpl(ObjectPropertyList opl) + { + opl.Terminate(); + var buffer = opl.Buffer; + var lines = new List(); + var pos = 15; // fixed OPL header length + + while (pos + 4 <= buffer.Length) + { + var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos)); + pos += 4; + if (cliloc == 0) + { + break; + } + + var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos)); + pos += 2; + var args = byteLen > 0 ? Encoding.Unicode.GetString(buffer, pos, byteLen) : null; + pos += byteLen; + + lines.Add(new OplLine { Cliloc = cliloc, Args = args, Text = Localization.GetText(cliloc) }); + } + + return lines; + } } From ee0c8c256db4887fabe654aa4a1de7394ba47f59 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:05:15 -0700 Subject: [PATCH 06/13] =?UTF-8?q?feat(objects):=20DiscoverConstructibleTyp?= =?UTF-8?q?es=20=E2=80=94=20all=20constructible=20Item/Mobile=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement DiscoverConstructibleTypes() method with HasConstructibleCtor helper to enumerate every non-abstract Item/Mobile subclass in AssemblyHandler.Assemblies that has at least one [Constructible] ctor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/Objects/ObjectDiscoveryTests.cs | 19 +++++++++ .../Object Creation/ObjectIntrospection.cs | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs new file mode 100644 index 000000000..d36f8bb8a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs @@ -0,0 +1,19 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectDiscoveryTests +{ + [Fact] + public void Discover_includes_concrete_constructibles_and_excludes_abstract() + { + var types = ObjectIntrospection.DiscoverConstructibleTypes(); + + Assert.Contains(typeof(Katana), types); + Assert.Contains(typeof(Runebook), types); + Assert.DoesNotContain(typeof(BaseWeapon), types); // abstract + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs index f4d076f97..f0a11b31c 100644 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -184,4 +184,45 @@ public static class ObjectIntrospection return lines; } + + public static List DiscoverConstructibleTypes() + { + var results = new List(); + + foreach (var asm in AssemblyHandler.Assemblies) + { + foreach (var type in AssemblyHandler.GetTypeCache(asm).Types) + { + if (type.IsAbstract) + { + continue; + } + + if (!typeof(Item).IsAssignableFrom(type) && !typeof(Mobile).IsAssignableFrom(type)) + { + continue; + } + + if (HasConstructibleCtor(type)) + { + results.Add(type); + } + } + } + + return results; + } + + private static bool HasConstructibleCtor(Type type) + { + foreach (var ctor in type.GetConstructors()) + { + if (Attributes.IsConstructible(ctor, AccessLevel.Developer)) + { + return true; + } + } + + return false; + } } From 8f20106aecdd1df4679b67b3d962de94fdb626c0 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:08:57 -0700 Subject: [PATCH 07/13] =?UTF-8?q?feat(objects):=20CategorizationSync.Recon?= =?UTF-8?q?cile=20=E2=80=94=20append=20Uncategorized=20+=20report=20orphan?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/CategorizationSyncTests.cs | 44 +++++++++++ .../Object Creation/CategorizationSync.cs | 77 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs create mode 100644 Projects/UOContent/Commands/Object Creation/CategorizationSync.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs new file mode 100644 index 000000000..8fb4a225d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class CategorizationSyncTests +{ + private static CAGJson Cat(string category, params System.Type[] types) => + new() + { + Category = category, + Objects = types.Select(t => new CAGObject { Type = t }).ToArray() + }; + + [Fact] + public void Reconcile_appends_missing_types_to_uncategorized() + { + var categorization = new List { Cat("Items.Weapons.Swords", typeof(Katana)) }; + var discovered = new List { typeof(Katana), typeof(Runebook) }; + + var (updated, report) = CategorizationSync.Reconcile(categorization, discovered); + + Assert.Contains("Runebook", report.Appended); + Assert.Empty(report.Orphaned); + + var uncategorized = Assert.Single(updated, c => c.Category == "Items.Uncategorized"); + Assert.Contains(uncategorized.Objects, o => o.Type == typeof(Runebook)); + } + + [Fact] + public void Reconcile_reports_orphans_not_in_discovered() + { + var categorization = new List { Cat("Items.Weapons.Swords", typeof(Katana)) }; + var discovered = new List { typeof(Runebook) }; + + var (_, report) = CategorizationSync.Reconcile(categorization, discovered); + + Assert.Contains("Katana", report.Orphaned); + Assert.Contains("Runebook", report.Appended); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs new file mode 100644 index 000000000..90b8f0169 --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server.Items; + +namespace Server.Commands; + +public sealed record SyncReport(List Appended, List Orphaned); + +public static class CategorizationSync +{ + public static (List updated, SyncReport report) Reconcile( + List categorization, IReadOnlyList discovered + ) + { + var categorizedTypes = new HashSet(); + foreach (var cag in categorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type != null) + { + categorizedTypes.Add(obj.Type); + } + } + } + + var discoveredSet = new HashSet(discovered); + + var orphaned = categorizedTypes + .Where(t => !discoveredSet.Contains(t)) + .Select(t => t.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + var updated = new List(categorization); + var appended = new List(); + var itemAppend = new List(); + var mobileAppend = new List(); + + foreach (var type in discovered) + { + if (categorizedTypes.Contains(type)) + { + continue; + } + + appended.Add(type.Name); + var target = typeof(Mobile).IsAssignableFrom(type) ? mobileAppend : itemAppend; + target.Add(new CAGObject { Type = type }); + } + + AppendUncategorized(updated, "Items.Uncategorized", itemAppend); + AppendUncategorized(updated, "Mobiles.Uncategorized", mobileAppend); + + return (updated, new SyncReport(appended, orphaned)); + } + + private static void AppendUncategorized(List updated, string category, List toAdd) + { + if (toAdd.Count == 0) + { + return; + } + + var existing = updated.FirstOrDefault(c => c.Category == category); + if (existing == null) + { + updated.Add(new CAGJson { Category = category, Objects = toAdd.ToArray() }); + return; + } + + var merged = new List(existing.Objects ?? []); + merged.AddRange(toAdd); + updated[updated.IndexOf(existing)] = existing with { Objects = merged.ToArray() }; + } +} From a55d83fc215352386d126ca45aa0bbd3be7ce819 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:13:36 -0700 Subject: [PATCH 08/13] =?UTF-8?q?feat(objects):=20ObjectCacheBuilder=20?= =?UTF-8?q?=E2=80=94=20assemble=20index=20+=20per-category=20detail=20chun?= =?UTF-8?q?ks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectCacheBuilderTests.cs | 35 +++++++++++ .../Object Creation/ObjectCacheBuilder.cs | 60 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs create mode 100644 Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs new file mode 100644 index 000000000..860822597 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectCacheBuilderTests +{ + [Fact] + public void Build_produces_index_row_and_detail_chunk() + { + var extracted = new ExtractedObject( + typeof(Runebook), + "item", + "Items.Skill Items.Magical", + new LeanMetadata(8901, 0x461, "runebook", 1041267), + [new CtorDoc()], + [new PropertyDoc { Name = "Hue", Type = "int" }], + [new OplLine { Cliloc = 1041267 }], + "Item" + ); + + var (index, chunks) = ObjectCacheBuilder.Build([extracted], "2026-07-19T00:00:00Z"); + + var row = Assert.Single(index.Objects); + Assert.Equal("Runebook", row.Type); + Assert.Equal("items.skill-items.magical", row.Chunk); + Assert.Equal(8901, row.ItemID); + + var chunk = Assert.Contains("items.skill-items.magical", chunks); + Assert.Contains("Runebook", chunk.Keys); + Assert.Equal("Item", chunk["Runebook"].BaseType); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs new file mode 100644 index 000000000..0ce79145f --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Server.Commands; + +public sealed record ExtractedObject( + Type Type, + string Entity, + string Category, + LeanMetadata Lean, + List Ctors, + List Properties, + List Opl, + string BaseType +); + +public static class ObjectCacheBuilder +{ + public static (ObjectIndexFile index, Dictionary> chunks) Build( + IReadOnlyList objects, string generatedUtc + ) + { + var index = new ObjectIndexFile { GeneratedUtc = generatedUtc }; + var chunks = new Dictionary>(); + + foreach (var obj in objects) + { + var chunkKey = ObjectNaming.ChunkKey(obj.Category); + + index.Objects.Add( + new ObjectIndexEntry + { + Type = obj.Type.Name, + Entity = obj.Entity, + Category = obj.Category, + Chunk = chunkKey, + ItemID = obj.Lean.ItemID, + Hue = obj.Lean.Hue, + Name = obj.Lean.Name, + Cliloc = obj.Lean.Cliloc + } + ); + + if (!chunks.TryGetValue(chunkKey, out var chunk)) + { + chunks[chunkKey] = chunk = new Dictionary(); + } + + chunk[obj.Type.Name] = new ObjectDetail + { + BaseType = obj.BaseType, + Ctors = obj.Ctors, + Properties = obj.Properties, + Opl = obj.Opl + }; + } + + return (index, chunks); + } +} From 0ac505d7b68677256431d05bbf27b241e73501d9 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:21:12 -0700 Subject: [PATCH 09/13] feat(objects): ObjectCacheGenerator core + GenObjects command Wraps introspection, categorization sync, and cache-building into a single file-I/O-free Generate() so the full pipeline is testable without a shard; GenObjects is a thin command that adds file I/O and operator messaging on top. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectCacheGeneratorTests.cs | 32 +++++++++ Projects/UOContent/Commands/GenObjects.cs | 56 ++++++++++++++++ .../Object Creation/ObjectCacheGenerator.cs | 67 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs create mode 100644 Projects/UOContent/Commands/GenObjects.cs create mode 100644 Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs new file mode 100644 index 000000000..96521bdfd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectCacheGeneratorTests +{ + [Fact] + public void Generate_builds_index_and_chunks_from_extracted_objects() + { + var discovered = new List { typeof(Runebook), typeof(Katana) }; + var categorization = new List(); // empty -> both land in Items.Uncategorized + + var result = ObjectCacheGenerator.Generate(categorization, discovered); + + Assert.Equal(2, result.Index.Objects.Count); + Assert.NotEmpty(result.Chunks); + + var runebook = Assert.Single(result.Index.Objects, o => o.Type == "Runebook"); + Assert.True(runebook.ItemID > 0); + Assert.Equal(1041267, runebook.Cliloc); + Assert.Equal("items.uncategorized", runebook.Chunk); + + Assert.Contains("Runebook", result.Report.Appended); + Assert.Contains("items.uncategorized", result.Chunks.Keys); + Assert.Contains("Runebook", result.Chunks["items.uncategorized"].Keys); + } +} diff --git a/Projects/UOContent/Commands/GenObjects.cs b/Projects/UOContent/Commands/GenObjects.cs new file mode 100644 index 000000000..dfd6903a1 --- /dev/null +++ b/Projects/UOContent/Commands/GenObjects.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.IO; +using Server.Json; +using Server.Logging; + +namespace Server.Commands; + +public static class GenObjects +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenObjects)); + + public static void Configure() + { + CommandSystem.Register("GenObjects", AccessLevel.Developer, GenObjects_OnCommand); + } + + [Usage("GenObjects")] + [Aliases("GenObjWeb")] + [Description("Generates the objects cache (index + detail chunks) and syncs categorization.json.")] + private static void GenObjects_OnCommand(CommandEventArgs e) + { + var baseDir = Core.BaseDirectory; + var categorizationPath = Path.Combine(baseDir, "Data", "categorization.json"); + var categorization = JsonConfig.Deserialize>(categorizationPath) ?? []; + var discovered = ObjectIntrospection.DiscoverConstructibleTypes(); + + var result = ObjectCacheGenerator.Generate(categorization, discovered); + + var objectsDir = Path.Combine(baseDir, "Data", "objects"); + var detailDir = Path.Combine(objectsDir, "detail"); + Directory.CreateDirectory(detailDir); + + JsonConfig.Serialize(Path.Combine(objectsDir, "index.json"), result.Index); + foreach (var (chunkKey, map) in result.Chunks) + { + JsonConfig.Serialize(Path.Combine(detailDir, $"{chunkKey}.json"), map); + } + + JsonConfig.Serialize(categorizationPath, result.UpdatedCategorization); + + e.Mobile.SendMessage( + $"Objects cache written: {result.Index.Objects.Count} objects, {result.Chunks.Count} chunks. " + + $"Appended {result.Report.Appended.Count} to Uncategorized, {result.Report.Orphaned.Count} orphaned." + ); + + if (result.Report.Appended.Count > 0) + { + logger.Information("Appended to Uncategorized: {Types}", string.Join(", ", result.Report.Appended)); + } + + if (result.Report.Orphaned.Count > 0) + { + logger.Warning("Orphaned categorization entries: {Types}", string.Join(", ", result.Report.Orphaned)); + } + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs new file mode 100644 index 000000000..1f7db92b6 --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using Server.Logging; + +namespace Server.Commands; + +public sealed record ObjectCacheResult( + ObjectIndexFile Index, + Dictionary> Chunks, + List UpdatedCategorization, + SyncReport Report +); + +public static class ObjectCacheGenerator +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ObjectCacheGenerator)); + + public static ObjectCacheResult Generate(List categorization, IReadOnlyList discovered) + { + var (updatedCategorization, report) = CategorizationSync.Reconcile(categorization, discovered); + + var categoryByType = new Dictionary(); + foreach (var cag in updatedCategorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type != null) + { + categoryByType[obj.Type] = cag.Category; + } + } + } + + var generatedUtc = DateTime.UtcNow.ToString("O"); + var extracted = new List(); + foreach (var type in discovered) + { + try + { + var entity = typeof(Mobile).IsAssignableFrom(type) ? "mobile" : "item"; + var category = categoryByType.TryGetValue(type, out var c) + ? c + : entity == "mobile" ? "Mobiles.Uncategorized" : "Items.Uncategorized"; + + extracted.Add( + new ExtractedObject( + type, + entity, + category, + ObjectIntrospection.ExtractLean(type), + ObjectIntrospection.ExtractCtors(type), + ObjectIntrospection.ExtractProperties(type), + ObjectIntrospection.ExtractOpl(type), + type.BaseType?.Name ?? "object" + ) + ); + } + catch (Exception ex) + { + logger.Warning(ex, "Failed to introspect {Type}; skipping.", type); + } + } + + var (index, chunks) = ObjectCacheBuilder.Build(extracted, generatedUtc); + return new ObjectCacheResult(index, chunks, updatedCategorization, report); + } +} From 06001a6ea57ff05bd2f7b6c43bd347a48049bb5b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:27:46 -0700 Subject: [PATCH 10/13] refac(objects): CAGLoader consumes index.json cache with live fallback CAGLoader.Load() now reads Data/objects/index.json (BuildTree) and only falls back to live type instantiation for entries missing from the cache (stale-cache warning) or when the index file itself is absent (LoadLegacy, the original categorization.json live-load, moved verbatim). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/Objects/CagTreeBuilderTests.cs | 48 ++++++ .../Commands/Object Creation/CAGLoader.cs | 145 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs new file mode 100644 index 000000000..0d61e34d8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs @@ -0,0 +1,48 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class CagTreeBuilderTests +{ + [Fact] + public void BuildTree_creates_nested_categories_and_resolves_types() + { + var index = new ObjectIndexFile + { + Objects = + [ + new ObjectIndexEntry + { + Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", + Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 + } + ] + }; + + var root = CAGLoader.BuildTree(index); + + var items = Assert.IsType(FindChild(root, "Items")); + var weapons = Assert.IsType(FindChild(items, "Weapons")); + var swords = Assert.IsType(FindChild(weapons, "Swords")); + + var leaf = Assert.IsType(swords.Nodes[0]); + Assert.Equal(typeof(Katana), leaf.Type); + Assert.Equal(0x13FF, leaf.ItemID); + } + + private static CAGNode FindChild(CAGCategory parent, string title) + { + foreach (var node in parent.Nodes) + { + if (node.Title == title) + { + return node; + } + } + + throw new Xunit.Sdk.XunitException($"No child '{title}'."); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs index cbded692c..f214ee063 100644 --- a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs +++ b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs @@ -29,6 +29,151 @@ 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(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"); + + 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); + AppendObject( + category, + new CAGObject + { + Type = type, + ItemID = entry.ItemID, + Hue = entry.Hue == 0 ? null : entry.Hue, + Parent = category + } + ); + } + + 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 AddFallbackForStaleCache(CAGCategory root, ObjectIndexFile index) + { + var cached = new HashSet(); + foreach (var entry in index.Objects) + { + cached.Add(entry.Type); + } + + var categorizationPath = Path.Combine(Core.BaseDirectory, "Data/categorization.json"); + var categorization = JsonConfig.Deserialize>(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"); From d72313b76bb47488ba1ec1ce0992fcbcba83eba7 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:33:19 -0700 Subject: [PATCH 11/13] style(objects): drop unused System.Linq imports in extraction tests Assert.Single(collection, predicate) is an xUnit overload, not LINQ. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs | 1 - .../Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs index fcdaed07c..6c35df2f8 100644 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Server.Commands; using Server.Items; using Xunit; diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs index efda9a929..1eba2a5bb 100644 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Server.Commands; using Server.Items; using Xunit; From bff08c69d212f29c317d41c5c2226972b1764de2 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:44:30 -0700 Subject: [PATCH 12/13] 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) --- .../Commands/Objects/CagTreeBuilderTests.cs | 32 ++++++++++++ .../Objects/ObjectIndexSerializationTests.cs | 52 +++++++++++++++++++ .../Commands/Object Creation/CAGLoader.cs | 28 +++++++++- 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs index 0d61e34d8..4716dd239 100644 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using Server.Commands; using Server.Items; using Xunit; @@ -33,6 +34,26 @@ public class CagTreeBuilderTests Assert.Equal(0x13FF, leaf.ItemID); } + [Fact] + public void BuildTree_keeps_multiple_objects_in_one_category() + { + var index = new ObjectIndexFile + { + Objects = + [ + new ObjectIndexEntry { Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 }, + new ObjectIndexEntry { Type = "Longsword", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x0F5E, Hue = 0 } + ] + }; + + var root = CAGLoader.BuildTree(index); + var swords = (CAGCategory)FindNestedCategory(root, "Items", "Weapons", "Swords"); + + var leafTypes = swords.Nodes.OfType().Select(o => o.Type).ToList(); + Assert.Contains(typeof(Katana), leafTypes); + Assert.Contains(typeof(Server.Items.Longsword), leafTypes); + } + private static CAGNode FindChild(CAGCategory parent, string title) { foreach (var node in parent.Nodes) @@ -45,4 +66,15 @@ public class CagTreeBuilderTests throw new Xunit.Sdk.XunitException($"No child '{title}'."); } + + private static CAGNode FindNestedCategory(CAGCategory root, params string[] titles) + { + CAGNode current = root; + foreach (var title in titles) + { + current = FindChild((CAGCategory)current, title); + } + + return current; + } } diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs new file mode 100644 index 000000000..268ee13e0 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs @@ -0,0 +1,52 @@ +using System.IO; +using Server.Commands; +using Server.Json; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectIndexSerializationTests +{ + [Fact] + public void ObjectIndexFile_round_trips_through_json() + { + var index = new ObjectIndexFile + { + GeneratedUtc = "2026-07-19T00:00:00Z", + Objects = + [ + new ObjectIndexEntry + { + Type = "Katana", + Entity = "item", + Category = "Items.Weapons.Swords", + Chunk = "items.weapons.swords", + ItemID = 8901, + Hue = 0x461, + Name = "katana", + Cliloc = 1041267 + } + ] + }; + + var tempPath = Path.GetTempFileName(); + try + { + JsonConfig.Serialize(tempPath, index); + var roundTripped = JsonConfig.Deserialize(tempPath); + + Assert.NotNull(roundTripped); + var entry = Assert.Single(roundTripped.Objects); + + Assert.Equal("Katana", entry.Type); + Assert.Equal("items.weapons.swords", entry.Chunk); + Assert.Equal(8901, entry.ItemID); + Assert.Equal(0x461, entry.Hue); + Assert.Equal(1041267, entry.Cliloc); + } + finally + { + File.Delete(tempPath); + } + } +} diff --git a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs index f214ee063..6b9c37c8b 100644 --- a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs +++ b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs @@ -52,6 +52,7 @@ public static class CAGLoader public static CAGCategory BuildTree(ObjectIndexFile index) { var root = new CAGCategory("Add Menu"); + var pending = new Dictionary>(); foreach (var entry in index.Objects) { @@ -63,8 +64,13 @@ public static class CAGLoader } var category = NavigateToCategory(root, entry.Category); - AppendObject( - category, + if (!pending.TryGetValue(category, out var objects)) + { + objects = new List(); + pending[category] = objects; + } + + objects.Add( new CAGObject { Type = type, @@ -75,6 +81,11 @@ public static class CAGLoader ); } + foreach (var (category, objects) in pending) + { + AppendNodes(category, objects); + } + return root; } @@ -125,6 +136,19 @@ public static class CAGLoader private static void AppendObject(CAGCategory category, CAGObject obj) => AppendNode(category, obj); + private static void AppendNodes(CAGCategory parent, IReadOnlyList 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(); From 1b8aff6b719c66c2f1695069c4ee246044ea82d2 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:15:38 -0700 Subject: [PATCH 13/13] test(objects): skip weapon-itemID assertion when TileData is absent ExtractLean clamps itemID > TileData.MaxItemValue to 1; MaxItemValue is 0 when tiledata.mul is missing (CI), so Katana's 0x13FF only survives with client data. Guard with [SkippableFact] + TileDataRequirement.SkipIfMissing() like other data-dependent tests. The hue/cliloc assertion needs no data and stays a [Fact]. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs index 935ea3cdb..5ddb65278 100644 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs @@ -1,5 +1,6 @@ using Server.Commands; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands.Objects; @@ -7,9 +8,13 @@ namespace UOContent.Tests.Commands.Objects; [Collection("Sequential UOContent Tests")] public class ObjectIntrospectionLeanTests { - [Fact] + [SkippableFact] public void ExtractLean_reads_item_id_from_a_weapon() { + // Requires client TileData: ExtractLean clamps itemID > TileData.MaxItemValue to 1, and + // MaxItemValue is 0 when tiledata.mul is absent (CI), so the real 0x13FF only survives with data. + TileDataRequirement.SkipIfMissing(); + // Katana ctor is base(0x13FF) — era-independent. var lean = ObjectIntrospection.ExtractLean(typeof(Katana)); Assert.Equal(0x13FF, lean.ItemID);