diff --git a/overlay.toml b/overlay.toml
index 0799583..3392ee0 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -23,8 +23,8 @@
# manual duty: when the protocol changes, bump it here in the same PR that
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
#
-# Current: 4 — see docs/link/v4.md (guild.roster, guild.leave).
-protocol = 4
+# Current: 5 — see docs/link/v5.md (house.decay scheduling, vendor.listing fees, account.login.result).
+protocol = 5
# ── ServUO compatibility ─────────────────────────────────────────────────────
#
diff --git a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
index a05fbea..a7a117e 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
@@ -170,9 +170,53 @@ namespace Server.Custom.Bridge
.Str("acct", e.Username)
.Str("ip", address)
.End());
+
+ EmitLoginResult(e, address);
});
}
+ ///
+ /// Protocol 5. The RESULT of the login above, which the attempt itself cannot carry.
+ ///
+ /// Why a second kind rather than two more fields: PacketHandlers.AccountLogin invokes this
+ /// sink and only THEN branches on e.Accepted, and the decision is made by the handlers
+ /// themselves -- Server.Misc.AccountHandler is the one that validates the password and
+ /// sets Accepted/RejectReason. Inside our own handler the verdict therefore does not exist
+ /// yet: Accepted is still its constructor default of `true` for a password that is about
+ /// to be rejected. Anything built on the attempt alone fires on every SUCCESSFUL login
+ /// too, which is the wrong way round for a security notice -- it would tell a player
+ /// "someone tried to get into your account" every time they logged in themselves.
+ ///
+ /// Reading it one Core slice later, via DelayCall(Zero), is what makes the verdict final
+ /// without a core patch and without depending on handler subscription ORDER, which
+ /// ServUO does not define and which a shard's own scripts can change.
+ ///
+ /// On holding the args object: it carries the plaintext Password, so it is deliberately
+ /// alive for one extra slice and no longer, and exactly two properties are read off it.
+ /// The password is never read, never logged and never emitted -- the same rule the
+ /// attempt emitter above states.
+ ///
+ private static void EmitLoginResult(AccountLoginEventArgs e, string address)
+ {
+ // The NetState is disposed by AccountLogin_ReplyRej before this runs, which is why the
+ // address is passed in already resolved rather than re-read from e.State.
+ Timer.DelayCall(TimeSpan.Zero, () =>
+ Guard("account.login.result", () =>
+ {
+ var sb = BridgeJson.Begin("account.login.result")
+ .Str("acct", e.Username)
+ .Str("ip", address)
+ .Bool("accepted", e.Accepted);
+
+ // ALRReason is only meaningful on a rejection; on an accept it is still the
+ // enum's zero value (Invalid), which would read as a failure reason if emitted.
+ if (!e.Accepted)
+ sb.Str("reason", e.RejectReason.ToString());
+
+ BridgeLink.Emit(sb.End());
+ }));
+ }
+
// ---- economy ----
private static void OnGoldChange(AccountGoldChangeEventArgs e)
diff --git a/overlay/Scripts/Custom/Bridge/BridgeMarket.cs b/overlay/Scripts/Custom/Bridge/BridgeMarket.cs
index 9c2fe80..47be9ac 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeMarket.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeMarket.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Text;
+using Server.Accounting;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
@@ -506,8 +507,16 @@ namespace Server.Custom.Bridge
{
sb.Ser("ownerSerial", owner.Serial);
sb.Str("ownerName", owner.Name);
+
+ // Protocol 5. Without this the listing names an owner the website cannot resolve to
+ // a person: ownerName is a character name, and only the account is the link key.
+ var acct = owner.Account as Account;
+ if (acct != null)
+ sb.Str("ownerAcct", acct.Username);
}
+ AppendFees(sb, vendor);
+
sb.Append(",\"location\":{\"map\":");
Text(sb, vendor.Map == null ? null : vendor.Map.Name);
sb.Append(",\"x\":").Append(vendor.X);
@@ -587,5 +596,75 @@ namespace Server.Custom.Bridge
return sb.End();
}
+
+ ///
+ /// Protocol 5. The vendor's fee state, which is what makes "your vendor is about to be
+ /// dismissed" a thing the website can say BEFORE it happens instead of after.
+ ///
+ /// The dismissal rule is PlayerVendor.PayTimer.OnTick: at every tick the charge is
+ /// compared with the funds, and `if (pay > totalGold) Destroy()`. Both halves of that
+ /// comparison differ between ServUO's two vendor systems, so both are resolved here
+ /// rather than left for the sidecar or the website to guess at:
+ ///
+ /// | charge | funds | interval
+ /// NewVendorSystem | ChargePerRealWorldDay | HoldGold | 1 real day
+ /// old system | ChargePerDay | BankAccount + HoldGold | 1 UO day
+ ///
+ /// Two consequences worth stating, because both are easy to get wrong downstream:
+ ///
+ /// * A field called `daysRemaining` would be WRONG on an old-system shard, where a pay
+ /// period is a UO day (Clock.MinutesPerUODay, roughly two real hours) rather than a
+ /// real one. So this emits `periodsRemaining` plus the interval that gives it meaning,
+ /// and resolves the arithmetic into `dismissalAt` -- an instant, which needs no units.
+ /// * A commission vendor (IsCommission) has no PayTimer at all and is never dismissed
+ /// for fees. It reports exempt:true and no schedule, rather than a misleading
+ /// "infinite days".
+ ///
+ /// `dismissalAt` assumes no further sales or deposits, exactly as a bank balance
+ /// projection does. Unlike a dynamic-decay house, though, there is no randomness in it:
+ /// given the current funds it is the exact tick the vendor is destroyed on.
+ ///
+ private static void AppendFees(StringBuilder sb, PlayerVendor vendor)
+ {
+ sb.Append(",\"fees\":{");
+
+ if (vendor.IsCommission)
+ {
+ sb.Append("\"exempt\":true}");
+ return;
+ }
+
+ bool newSystem = BaseHouse.NewVendorSystem;
+
+ int charge = newSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
+ int funds = newSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
+
+ sb.Append("\"exempt\":false");
+ sb.Append(",\"newVendorSystem\":").Append(newSystem ? "true" : "false");
+ sb.Append(",\"chargePerPeriod\":").Append(charge);
+ sb.Append(",\"funds\":").Append(funds);
+ sb.Append(",\"holdGold\":").Append(vendor.HoldGold);
+ sb.Append(",\"bankAccount\":").Append(vendor.BankAccount);
+
+ var interval = newSystem ? TimeSpan.FromDays(1.0) : TimeSpan.FromMinutes(Clock.MinutesPerUODay);
+ sb.Append(",\"payIntervalSec\":").Append((long)interval.TotalSeconds);
+
+ var nextPay = vendor.NextPayTime.ToUniversalTime();
+ sb.Append(",\"nextPayAt\":");
+ Text(sb, nextPay.ToString("o"));
+
+ // A free vendor (no priced stock under the old system can reach charge 0) never runs out.
+ if (charge > 0)
+ {
+ // Ticks it survives before the one that finds pay > totalGold.
+ long periods = funds / charge;
+ sb.Append(",\"periodsRemaining\":").Append(periods);
+
+ sb.Append(",\"dismissalAt\":");
+ Text(sb, nextPay.AddSeconds(periods * interval.TotalSeconds).ToString("o"));
+ }
+
+ sb.Append('}');
+ }
}
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs b/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs
index 0de3fb0..b0786c9 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs
@@ -215,11 +215,14 @@ namespace Server.Custom.Bridge
if (owner != null)
{
sb.Ser("ownerSerial", owner.Serial);
+ sb.Str("ownerName", owner.Name);
var acct = owner.Account as Account;
if (acct != null)
sb.Str("ownerAcct", acct.Username);
}
+ AppendDecaySchedule(sb, house, to);
+
// Where a player would physically stand to see it.
var ban = house.BanLocation;
sb.Append(",\"ban\":{\"x\":").Append(ban.X)
@@ -232,6 +235,67 @@ namespace Server.Custom.Bridge
return sb.End();
}
+ ///
+ /// Protocol 5. The three scheduling fields, and the reason they are not all always present.
+ ///
+ /// ServUO has two decay implementations and they differ in how KNOWABLE the future is:
+ ///
+ /// * Dynamic decay (DynamicDecay.Enabled, i.e. Core.ML) draws each stage's duration at
+ /// RANDOM when the stage is entered (BaseHouse.SetDynamicDecay ->
+ /// DynamicDecay.GetRandomDuration). So NextDecayStage is exact for the NEXT transition
+ /// and nothing beyond it is known at all. Collapse becomes exact only once the house is
+ /// already at IDOC, because then the next transition IS the collapse.
+ /// * Static decay (GetOldDecayLevel) is a pure function of LastRefreshed and DecayPeriod,
+ /// so collapse is exact at EVERY stage -- there is no randomness to wait out.
+ ///
+ /// Emitting estimatedCollapse from a dynamic-decay house at, say, Fairly would therefore be
+ /// publishing a guess as a fact, which on the website's side becomes a dated promise in a
+ /// player's mail. It is omitted rather than approximated: the website's `required: false`
+ /// declaration already permits its absence, and an absent field is honest where a wrong
+ /// date is not.
+ ///
+ private static void AppendDecaySchedule(StringBuilder sb, BaseHouse house, DecayLevel to)
+ {
+ // ONE nested object rather than four sibling keys, for the same reason vendor.listing
+ // nests `location`: the website's visibility projection matches literal JSON keys, so a
+ // nested group is one admin rule that can hide the whole schedule, where four flat keys
+ // would be four rules that drift apart.
+ sb.Append(",\"schedule\":{");
+
+ // The stage clock. Only dynamic decay keeps one; static decay leaves it at MinValue.
+ bool dynamic = DynamicDecay.Enabled;
+ var next = house.NextDecayStage;
+
+ sb.Append("\"dynamicDecay\":").Append(dynamic ? "true" : "false");
+
+ if (dynamic && next > DateTime.MinValue)
+ sb.Str("nextStage", next.ToUniversalTime().ToString("o"));
+
+ // Total seconds from a full refresh to collapse. Constant per house type, but it is what
+ // lets a reader turn lastRefreshed into a percentage without knowing ServUO's tables.
+ var period = house.DecayPeriod;
+ if (period > TimeSpan.Zero)
+ sb.Num("decayPeriodSec", (long)period.TotalSeconds);
+
+ DateTime collapse;
+ bool knowable = true;
+
+ if (!dynamic)
+ collapse = house.LastRefreshed.ToUniversalTime() + period;
+ else if (to == DecayLevel.IDOC && next > DateTime.MinValue)
+ collapse = next.ToUniversalTime();
+ else
+ {
+ collapse = DateTime.MinValue;
+ knowable = false;
+ }
+
+ if (knowable)
+ sb.Str("estimatedCollapse", collapse.ToString("o"));
+
+ sb.Append('}');
+ }
+
// ---- economy supply ----
///
diff --git a/tools/scaffolding/BridgeProtocol5Probe.cs b/tools/scaffolding/BridgeProtocol5Probe.cs
new file mode 100644
index 0000000..a070179
--- /dev/null
+++ b/tools/scaffolding/BridgeProtocol5Probe.cs
@@ -0,0 +1,275 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Accounting;
+using Server.Commands;
+using Server.Mobiles;
+using Server.Multis;
+using Server.Network;
+
+namespace Server.Custom
+{
+ ///
+ /// Exercises all three Protocol 5 enrichments on a live shard, without a game client.
+ ///
+ /// Each of the three needs something a unit test cannot produce, and each needs it for a
+ /// different reason:
+ ///
+ /// * house.decay's `schedule` is only interesting ACROSS a transition, and the interesting
+ /// pair is Greatly -> IDOC: the first must carry no estimatedCollapse (under dynamic
+ /// decay the remaining stages have not been drawn yet) and the second must carry one.
+ /// A fixture can assert the mapping; only a real BaseHouse walking a real
+ /// SetDynamicDecay proves the emitter reads ServUO the way the comment claims.
+ /// * vendor.listing's `fees` are computed from PlayerVendor state that differs between
+ /// ServUO's two vendor systems. This reports what the shard actually holds so the
+ /// emitted frame can be checked against it rather than against an assumption.
+ /// * account.login.result is the one that could not be built at all before v5, because
+ /// EventSink.AccountLogin fires BEFORE the verdict exists. Invoking the real sink with a
+ /// real password (right and wrong) runs the shard's own AccountHandler, which is what
+ /// sets Accepted/RejectReason -- so this proves the deferred read sees the FINAL verdict
+ /// and not the constructor's default of true.
+ ///
+ /// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
+ /// In game / at the console: `[p5probe`.
+ ///
+ public static class BridgeProtocol5Probe
+ {
+ public static void Initialize()
+ {
+ CommandSystem.Register("p5probe", AccessLevel.Administrator, Probe_OnCommand);
+
+ if (Config.Get("Bridge.Protocol5ProbeOnStart", false))
+ EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(8.0), () => Run(null));
+ }
+
+ [Usage("p5probe")]
+ [Description("Drives the three Protocol 5 enrichments so their frames can be observed.")]
+ private static void Probe_OnCommand(CommandEventArgs e)
+ {
+ Run(e.Mobile);
+ }
+
+ private static void Report(Mobile to, string line)
+ {
+ Console.WriteLine("[P5Probe] " + line);
+
+ if (to != null)
+ to.SendMessage(line);
+ }
+
+ private static void Run(Mobile to)
+ {
+ try
+ {
+ ReportVendorFees(to);
+ DriveLogins(to);
+ WalkHouseToIdoc(to);
+ }
+ catch (Exception ex)
+ {
+ Report(to, "threw: " + ex);
+ }
+ }
+
+ // ---- (a) house.decay schedule ----
+
+ ///
+ /// Walks one house Greatly, then (after a pause long enough for a decay sweep to run)
+ /// IDOC. Two frames, and the PAIR is the assertion: no estimatedCollapse on the first,
+ /// one on the second.
+ ///
+ private static void WalkHouseToIdoc(Mobile to)
+ {
+ BaseHouse target = null;
+ var byType = new Dictionary();
+
+ foreach (var h in BaseHouse.AllHouses)
+ {
+ if (h == null || h.Deleted || h.Owner == null)
+ continue;
+
+ var type = h.DecayType.ToString();
+ byType[type] = (byType.ContainsKey(type) ? byType[type] : 0) + 1;
+
+ // CanDecay is the filter that matters, and getting it wrong is silent. A house
+ // whose DecayType is AutoRefresh or Ageless -- and the owner's NEWEST house is
+ // always AutoRefresh -- has a DecayLevel getter that calls ResetDynamicDecay() and
+ // reports Ageless, so a forced SetDynamicDecay is wiped on the very next read. The
+ // sweep then sees no change and emits nothing at all, which looks exactly like a
+ // broken emitter.
+ if (!h.CanDecay)
+ continue;
+
+ // The current level does NOT disqualify a house. On this rig every decaying house
+ // is already at IDOC (a seeded world has only a couple of Condemned houses and they
+ // have long since bottomed out), so the walk starts by putting one BACK to Fairly.
+ // BridgeDemoDress.PrimeIdoc does the same thing for the same reason.
+ target = h;
+ break;
+ }
+
+ foreach (var kv in byType)
+ Report(to, "houses by DecayType: " + kv.Key + "=" + kv.Value);
+
+ if (target == null)
+ {
+ Report(to, "no walkable house found (none with CanDecay below IDOC)");
+ return;
+ }
+
+ Report(to, string.Format(
+ "walking house 0x{0:X} owner={1} decayType={2} from {3}; dynamicDecay={4}",
+ target.Serial.Value,
+ target.Owner == null ? "?" : target.Owner.Name,
+ target.DecayType,
+ target.DecayLevel,
+ DynamicDecay.Enabled));
+
+ // Each step needs its own sweep to land, or the sweep sees one net change and emits a
+ // single frame -- which would collapse the whole point, since the assertion is the
+ // DIFFERENCE between the Greatly frame and the IDOC one.
+ var step = TimeSpan.FromSeconds(Math.Max(4, BridgeConfigSeconds()) * 2 + 4);
+
+ Step(to, target, DecayLevel.Fairly, TimeSpan.Zero, "reset (no estimatedCollapse expected)");
+ Step(to, target, DecayLevel.Greatly, step, "expect schedule WITHOUT estimatedCollapse");
+ Step(to, target, DecayLevel.IDOC, TimeSpan.FromTicks(step.Ticks * 2), "expect schedule WITH estimatedCollapse");
+ }
+
+ private static void Step(Mobile to, BaseHouse house, DecayLevel level, TimeSpan after, string note)
+ {
+ Action go = () =>
+ {
+ if (house.Deleted)
+ return;
+
+ Report(to, string.Format("house 0x{0:X} -> {1} ({2})", house.Serial.Value, level, note));
+ house.SetDynamicDecay(level);
+ };
+
+ if (after <= TimeSpan.Zero)
+ go();
+ else
+ Timer.DelayCall(after, () => go());
+ }
+
+ /// The decay sweep interval, read the same way the bridge reads it.
+ private static int BridgeConfigSeconds()
+ {
+ return Config.Get("Bridge.DecaySweepSeconds", 60);
+ }
+
+ // ---- (b) vendor.listing fees ----
+
+ ///
+ /// Prints the fee state of the first few player vendors straight off the PlayerVendor
+ /// objects, so the emitted `fees` block can be compared against the shard's own numbers
+ /// rather than against what the emitter believes them to be.
+ ///
+ private static void ReportVendorFees(Mobile to)
+ {
+ bool newSystem = BaseHouse.NewVendorSystem;
+ int shown = 0;
+
+ Report(to, "NewVendorSystem=" + newSystem);
+
+ foreach (var m in World.Mobiles.Values)
+ {
+ var v = m as PlayerVendor;
+
+ if (v == null || v.Deleted)
+ continue;
+
+ int charge = newSystem ? v.ChargePerRealWorldDay : v.ChargePerDay;
+ int funds = newSystem ? v.HoldGold : v.BankAccount + v.HoldGold;
+ var acct = v.Owner == null ? null : v.Owner.Account as Account;
+
+ Report(to, string.Format(
+ "vendor 0x{0:X} owner={1} acct={2} commission={3} charge={4} funds={5} periods={6} nextPay={7:o}",
+ v.Serial.Value,
+ v.Owner == null ? "?" : v.Owner.Name,
+ acct == null ? "" : acct.Username,
+ v.IsCommission,
+ charge,
+ funds,
+ charge > 0 ? (funds / charge).ToString() : "n/a",
+ v.NextPayTime.ToUniversalTime()));
+
+ if (++shown >= 3)
+ break;
+ }
+
+ if (shown == 0)
+ Report(to, "no player vendors in the world");
+ }
+
+ // ---- (c) account.login.result ----
+
+ ///
+ /// Fires the real EventSink.AccountLogin twice against a real account: once with a
+ /// deliberately wrong password and once with the right one.
+ ///
+ /// The shard's own AccountHandler is what decides, and it decides AFTER our handler has
+ /// returned. So a correct implementation emits `accepted:false reason:BadPass` for the
+ /// first and `accepted:true` for the second. An implementation that read the verdict
+ /// inside the handler would emit `accepted:true` for BOTH -- which is precisely the bug
+ /// this kind exists to make impossible, and precisely what this probe would show.
+ ///
+ /// The password is read from config, never compiled in. `Bridge.Protocol5ProbeAccount`
+ /// and `Bridge.Protocol5ProbePassword`; with no password configured only the failing
+ /// half runs, which is still the half that matters.
+ ///
+ private static void DriveLogins(Mobile to)
+ {
+ var username = Config.Get("Bridge.Protocol5ProbeAccount", (string)null);
+
+ if (String.IsNullOrEmpty(username))
+ {
+ Report(to, "no Bridge.Protocol5ProbeAccount configured; skipping the login probe");
+ return;
+ }
+
+ var password = Config.Get("Bridge.Protocol5ProbePassword", (string)null);
+
+ // Accounts store a hash, so the rig cannot READ a password to log in with -- it has to
+ // set one. Same posture as BridgeDemoDress, which does this for the same reason: the
+ // value comes from config and is never compiled in or logged.
+ if (!String.IsNullOrEmpty(password))
+ {
+ var acct = Accounts.GetAccount(username) as Account;
+
+ if (acct == null)
+ {
+ Report(to, "account '" + username + "' does not exist; skipping the login probe");
+ return;
+ }
+
+ acct.SetPassword(password);
+ Report(to, "set a known password on '" + username + "' for the accepted half");
+ }
+
+ Report(to, "login probe: '" + username + "' with a WRONG password (expect accepted:false)");
+ Fire(username, "definitely-not-the-password-" + Guid.NewGuid().ToString("N"));
+
+ if (String.IsNullOrEmpty(password))
+ {
+ Report(to, "no Bridge.Protocol5ProbePassword configured; skipping the accepted half");
+ return;
+ }
+
+ // Spaced out so the two results are unambiguous in the sidecar's history.
+ Timer.DelayCall(TimeSpan.FromSeconds(3.0), () =>
+ {
+ Report(to, "login probe: '" + username + "' with the RIGHT password (expect accepted:true)");
+ Fire(username, password);
+ });
+ }
+
+ private static void Fire(string username, string password)
+ {
+ // A null NetState is deliberate and is itself part of the test: the real emitter reads
+ // the address defensively because AccountLogin_ReplyRej disposes the state before the
+ // deferred read runs, so it must already survive not having one.
+ EventSink.InvokeAccountLogin(new AccountLoginEventArgs(null, username, password));
+ }
+ }
+}
diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md
index 7eb50ff..e2291ad 100644
--- a/tools/scaffolding/README.md
+++ b/tools/scaffolding/README.md
@@ -14,6 +14,7 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
+| `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** |
## Deploy overwrites Bridge.cfg
@@ -106,3 +107,35 @@ Probe, best-of-20 on the Core thread:
```
Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 2–4× for a fully-kitted character.
+
+## The login half needs a socket, not the sink
+
+`BridgeProtocol5Probe` fires `EventSink.InvokeAccountLogin` directly, which proves the REJECTED
+half of `account.login.result` and nothing more. ServUO's own `AccountHandler` calls
+`acct.HasAccess(e.State)` *before* it ever checks the password, and a null `NetState` fails that --
+so an in-process probe logs `Access denied` for a correct password too, and never produces an
+`accepted:true`.
+
+To prove the accepted half, speak the wire. A real socket also gives the frame a real `ip`, which
+is one of the fields being tested:
+
+```python
+# 4-byte seed, then 0x80 = [0x80][30b username][30b password][1b]
+s = socket.create_connection(('127.0.0.1', 2593))
+s.sendall(b'\x7f\x00\x00\x01')
+s.sendall(b'\x80' + pad(user) + pad(password) + b'\x5d')
+```
+
+The shard logs `Invalid password for ''` or `Valid credentials for ''`, and the sidecar's
+`/history?kind=account.login.result` should show `accepted:false reason:BadPass` and `accepted:true`
+respectively. **Both saying `accepted:true` is the bug the kind exists to prevent** -- it means the
+verdict was read inside the handler, before it existed.
+
+## Walking a house into IDOC needs a house that can decay
+
+Only a `Condemned` or `ManualRefresh` house decays. An `AutoRefresh` one -- and the owner's NEWEST
+house is always `AutoRefresh` -- has a `DecayLevel` getter that calls `ResetDynamicDecay()` and
+reports `Ageless`, so a forced `SetDynamicDecay` is wiped on the very next read, the sweep sees no
+change, and **nothing is emitted at all**. That looks exactly like a broken emitter. Filter on
+`house.CanDecay`, and expect a seeded world to have only one or two houses that qualify -- both
+probably already at IDOC, so the walk has to put one back down first.