Files
link/overlay/Scripts/Custom/BridgeProbe.cs
Claude 710dd17745 Phase 0: fix runtime script compilation
ScriptCompiler.Compile() runs `dotnet build Scripts/Scripts.csproj -c Release`
with no Platform, so MSBuild defaults to AnyCPU. Scripts.csproj gated both
OutputPath and DefineConstants on Configuration|Platform == Release|x64, so
under the server's own build the DLL landed in Scripts/bin/Release/ (while the
core loads Scripts.dll from the base directory) and TRACE;NEWTIMERS;ServUO went
undefined (XmlSpawner compiled its non-ServUO branches).

Compile() also never checks the build's exit code before Assembly.LoadFrom, so
the failure was silent and the stale DLL reloaded. Runtime script compilation
had had no effect since 2026-05-30.

Condition both property groups on Configuration alone. Server.csproj is left
alone: nothing under Server/ uses those symbols, and giving it OutputPath=..\
would make the boot-time build try to overwrite the running ServUO.exe.

Verified end-to-end: a plain boot now logs "Core: Compiling scripts... / Build
succeeded." and loads 206208 items, 42771 mobiles.

Also adds the implementation plan, the measured performance budget, the test
scaffolding used to produce it (seeder + probe, both default-off), and the
record of shard repairs that had to precede any of this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 04:34:18 -05:00

406 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using Server.Accounting;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Measures the main-thread cost of every read the bridge plugin would perform, against
/// whatever world is currently loaded. Read-only. Test scaffolding, not part of the bridge.
///
/// Everything here runs on the Core thread, which is exactly where the real plugin's
/// reads must run, so these timings are the ones that matter for frame budget.
/// </summary>
public static class BridgeProbe
{
private const int Iterations = 20;
private static readonly AosAttribute[] AllAttrs =
(AosAttribute[])Enum.GetValues(typeof(AosAttribute));
private static readonly AosWeaponAttribute[] AllWeaponAttrs =
(AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
private static readonly AosArmorAttribute[] AllArmorAttrs =
(AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
public static void Initialize()
{
if (Config.Get("Bridge.ProbeOnStart", false))
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(2.0), Run);
}
private static void Log(string fmt, params object[] args)
{
Console.WriteLine("[BridgeProbe] " + String.Format(fmt, args));
}
private static void Run()
{
try
{
var players = CollectSeededChars();
var houses = BaseHouse.AllHouses;
var vendors = PlayerVendor.PlayerVendors ?? new List<PlayerVendor>();
Log("world: {0} seeded chars, {1} houses, {2} vendors, {3} accounts",
players.Count, houses.Count, vendors.Count, Accounting.Accounts.Count);
Log("thread: {0} (id {1})",
System.Threading.Thread.CurrentThread.Name,
System.Threading.Thread.CurrentThread.ManagedThreadId);
Log("");
// ---- one full character profile, the heaviest single read ----
var sb = new StringBuilder(8192);
double perProfile = Time(() =>
{
for (int i = 0; i < players.Count; i++)
{
sb.Clear();
WriteProfile(sb, players[i]);
}
}) / Math.Max(1, players.Count);
sb.Clear();
if (players.Count > 0)
WriteProfile(sb, players[0]);
int profileBytes = sb.Length;
Log("char.profile {0,8:F3} ms/char {1,6} bytes json -> {2:F1} ms for all {3}",
perProfile, profileBytes, perProfile * players.Count, players.Count);
// ---- vitals: the 30s sweep the doc proposes ----
double vitals = Time(() =>
{
var b = new StringBuilder(256);
for (int i = 0; i < players.Count; i++)
{
b.Clear();
WriteVitals(b, players[i]);
}
});
Log("vitals sweep {0,8:F3} ms for {1} chars ({2:F4} ms/char)",
vitals, players.Count, vitals / Math.Max(1, players.Count));
// ---- house decay sweep (III.3) ----
double decay = Time(() =>
{
for (int i = 0; i < houses.Count; i++)
{
var lvl = houses[i].DecayLevel;
GC.KeepAlive(lvl);
}
});
Log("decay sweep {0,8:F3} ms for {1} houses ({2:F4} ms/house)",
decay, houses.Count, decay / Math.Max(1, houses.Count));
// ---- economy: money supply snapshot ----
double econ = 0;
double total = 0;
econ = Time(() =>
{
total = 0;
foreach (Account a in Accounting.Accounts.GetAccounts())
total += a.TotalCurrency;
});
Log("economy sweep {0,8:F3} ms for {1} accounts (supply {2:N0} gold)",
econ, Accounting.Accounts.Count, total * Account.CurrencyThreshold);
// ---- player vendor snapshot ----
int listings = 0;
double vend = Time(() =>
{
listings = 0;
var b = new StringBuilder(4096);
for (int i = 0; i < vendors.Count; i++)
{
b.Clear();
listings += WriteVendor(b, vendors[i]);
}
});
Log("vendor snap {0,8:F3} ms for {1} vendors ({2} listings)",
vend, vendors.Count, listings);
Log("");
Log("--- extrapolation (linear, same gear complexity) ---");
Log(" vitals sweep @ 200 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 200);
Log(" vitals sweep @ 1000 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 1000);
Log(" profiles for 1000 chars : {0,7:F1} ms <-- never do this in a sweep",
perProfile * 1000);
Log(" decay sweep @ 2000 houses: {0,7:F2} ms", decay / Math.Max(1, houses.Count) * 2000);
Log(" economy @ 5000 accts : {0,7:F2} ms", econ / Math.Max(1, Accounting.Accounts.Count) * 5000);
}
catch (Exception ex)
{
Log("FAILED: " + ex);
}
}
/// <summary>Best-of-N: the minimum is the least noisy estimate of true cost.</summary>
private static double Time(Action action)
{
action(); // warm up JIT and caches
double best = double.MaxValue;
var sw = new Stopwatch();
for (int i = 0; i < Iterations; i++)
{
sw.Restart();
action();
sw.Stop();
double ms = sw.Elapsed.TotalMilliseconds;
if (ms < best)
best = ms;
}
return best;
}
private static List<PlayerMobile> CollectSeededChars()
{
var list = new List<PlayerMobile>();
foreach (Account a in Accounting.Accounts.GetAccounts())
{
if (!a.Username.StartsWith("seed_", StringComparison.Ordinal))
continue;
for (int i = 0; i < a.Length; i++)
{
var pm = a[i] as PlayerMobile;
if (pm != null)
list.Add(pm);
}
}
return list;
}
private static void WriteVitals(StringBuilder sb, PlayerMobile m)
{
sb.Append("{\"kind\":\"char.vitals\",\"serial\":\"0x");
sb.Append(m.Serial.Value.ToString("X"));
sb.Append("\",\"hits\":").Append(m.Hits);
sb.Append(",\"hitsMax\":").Append(m.HitsMax);
sb.Append(",\"mana\":").Append(m.Mana);
sb.Append(",\"stam\":").Append(m.Stam);
sb.Append(",\"str\":").Append(m.Str);
sb.Append(",\"dex\":").Append(m.Dex);
sb.Append(",\"int\":").Append(m.Int);
sb.Append(",\"x\":").Append(m.X);
sb.Append(",\"y\":").Append(m.Y);
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
sb.Append('}');
}
private static void WriteProfile(StringBuilder sb, PlayerMobile m)
{
sb.Append("{\"kind\":\"char.profile\",\"serial\":\"0x");
sb.Append(m.Serial.Value.ToString("X"));
sb.Append("\",\"name\":\"").Append(m.Name).Append('"');
sb.Append(",\"body\":").Append(m.Body.BodyID);
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
sb.Append(",\"stats\":{\"str\":").Append(m.Str);
sb.Append(",\"dex\":").Append(m.Dex);
sb.Append(",\"int\":").Append(m.Int);
sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
sb.Append(",\"luck\":").Append(m.Luck);
sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
sb.Append(",\"fire\":").Append(m.FireResistance);
sb.Append(",\"cold\":").Append(m.ColdResistance);
sb.Append(",\"pois\":").Append(m.PoisonResistance);
sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
sb.Append(",\"skills\":[");
bool first = true;
for (int i = 0; i < m.Skills.Length; i++)
{
var s = m.Skills[i];
if (s.Base <= 0.0)
continue; // untrained: the bridge should not ship ~50 zeroes per char
if (!first)
sb.Append(',');
first = false;
sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
}
sb.Append(']');
sb.Append(",\"equipment\":[");
first = true;
foreach (var item in m.Items)
{
if (item.Layer == Layer.Backpack || item.Layer == Layer.Bank ||
item.Layer == Layer.Hair || item.Layer == Layer.FacialHair ||
item.Layer == Layer.Mount)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
sb.Append("\",\"layer\":\"").Append(item.Layer).Append('"');
sb.Append(",\"itemId\":").Append(item.ItemID);
sb.Append(",\"hue\":").Append(item.Hue);
sb.Append(",\"cliloc\":").Append(item.LabelNumber);
if (item.Name != null)
sb.Append(",\"name\":\"").Append(item.Name).Append('"');
sb.Append(",\"mods\":{");
bool m1 = true;
var weapon = item as BaseWeapon;
var armor = item as BaseArmor;
if (weapon != null)
{
sb.Append("\"minDamage\":").Append(weapon.MinDamage);
sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage);
m1 = false;
WriteAttrs(sb, weapon.Attributes, ref m1);
WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref m1);
}
else if (armor != null)
{
sb.Append("\"baseRating\":").Append(armor.BaseArmorRating);
m1 = false;
WriteAttrs(sb, armor.Attributes, ref m1);
WriteArmorAttrs(sb, armor.ArmorAttributes, ref m1);
}
sb.Append("}}");
}
sb.Append(']');
sb.Append('}');
}
private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllAttrs.Length; i++)
{
int v = a[AllAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
}
}
private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllWeaponAttrs.Length; i++)
{
int v = a[AllWeaponAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
}
}
private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
{
if (a == null)
return;
for (int i = 0; i < AllArmorAttrs.Length; i++)
{
int v = a[AllArmorAttrs[i]];
if (v == 0)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
}
}
private static int WriteVendor(StringBuilder sb, PlayerVendor v)
{
int count = 0;
sb.Append("{\"kind\":\"vendor.snapshot\",\"serial\":\"0x");
sb.Append(v.Serial.Value.ToString("X"));
sb.Append("\",\"holdGold\":").Append(v.HoldGold);
sb.Append(",\"listings\":[");
var pack = v.Backpack;
if (pack != null)
{
bool first = true;
foreach (var item in pack.Items)
{
var vi = v.GetVendorItem(item);
if (vi == null)
continue;
if (!first)
sb.Append(',');
first = false;
count++;
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
sb.Append("\",\"itemId\":").Append(item.ItemID);
sb.Append(",\"price\":").Append(vi.Price);
sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false");
sb.Append('}');
}
}
sb.Append("]}");
return count;
}
}
}