Design of record for the bump, plus the reference updates it obliges.
Engagement Phase 10, as built.
link/v5.md is new. Three enrichments in one bump, because a bump costs a sidecar
release, a republished bundle and an operator update on every shard — a field left
out costs a whole second round of that, not a follow-up commit.
a. house.decay gains ownerName and a nested `schedule`. estimatedCollapse is
present ONLY where it is exact: under dynamic decay each stage's duration is
drawn at random on entry, so collapse is knowable only once the house is
already at IDOC; under static decay it is exact at every stage. Omitted
rather than approximated — an absent field is honest where a wrong date
becomes a dated promise in a player's mail.
b. vendor.listing gains ownerAcct — a character name identifies nobody, only the
game account joins to shard_account_links — and a `fees` block resolving
PlayerVendor.PayTimer's dismissal rule into a dismissalAt instant. There is
deliberately no daysRemaining: a pay period is a UO day on an old-system
shard, so the name would be wrong by a factor of twelve.
c. account.login.result is a NEW kind. account.login.attempt fires from a veto
hook that runs before the auth decision and constructs with Accepted = true,
so it fires on successful logins too and cannot carry a verdict.
INTEGRATION.md: the version banner to 5, the Housing and Market field tables and
examples, and the new kind beside the attempt — with the attempt's own row
corrected to say why it is not a failure signal.
PLAN.md: 5.1's "failed-login signals" row was wrong about what AccountLogin can
tell you; 5.3 and 5.4 gain the two new signals; 5.4's dynamic-decay model gets the
consequence for publishing a collapse time, and the CanDecay rule gets its testing
trap. 7's frames are marked as 1.0's design sketch rather than the shipped wire,
which they have not been for four versions.
ENGAGEMENT.md: Phase 10 as built. Two decisions taken during the build (both field
groups nested, so one admin rule governs each and a future field inherits the gate;
no daysRemaining), the three visibility classifications, and the pin improvement —
the schema test now asserts the declarations AGREE rather than that they all say a
literal, which is the protocol-4 bug seen from the other side.
Co-Authored-By: Claude <noreply@anthropic.com>
16 KiB
Protocol 5 — Three enrichments, one bump
Status: Built, on edge. Not yet cut over to main, so not yet released.
Date: 2026-08-31
Codebase: ServUO 57.4, <servuo>, net48 / x64, Expansion EJ.
Companion to PLAN.md (1.0 read/event plane), PROTOCOL_2.md (2.0 provisioning + world-state streams), v3.md (3.0 shard content + the visibility framework), v4.md (4.0 guild membership), INTEGRATION.md (website API).
Driven by ../website/ENGAGEMENT.md Phase 10.
1. Why three things at once
Every previous bump did one thing. This one does three, and the reason is a property of the bump itself rather than of the features:
A protocol bump costs a sidecar release, a republished bundle, and an operator update on every shard. A field left out does not cost a follow-up commit — it costs a second bump with the same three-part lead time, and an operator population split across two protocol versions until the second one finishes propagating. So everything the engagement workstream is known to need from the wire was decided before any of it was written, and all of it rides v5.
The three were not picked from a wish list. Each is a field that a specific Phase 11 trigger cannot be built without, and in two of the three cases that was discovered by reading the emitters rather than by reading the plan, which had recorded both as already possible.
| # | Enrichment | The trigger that needs it | What was actually there |
|---|---|---|---|
| a | house.decay gains ownerName + a schedule |
uo.house.idoc_warning — the flagship |
The frame could say a house had become IDOC and nothing about when it ends |
| b | vendor.listing gains ownerAcct + fees |
uo.vendor.expiring |
ownerSerial and ownerName only, and no fee data anywhere on the wire — the plan had this as "needs a mapper" |
| c | account.login.result, a new kind |
uo.account.login_failed |
account.login.attempt fires before the auth decision, so it fires on successful logins too |
1.1 What was deliberately left out
vendor.sale stays in the opt-in patch tier. It is real, and it already carries ownerAcct —
but it lives in servuo-plugins/patches/, verified only against ServUO 57.4, because it needs a
PlayerVendorSale EventSink that the patches add to core. Moving it into overlay/ was considered
and declined: the emitter cannot exist without the core event, so "moving" it means shipping a core
patch as though it were an overlay file. The consequence is a documentation obligation, not a
protocol one — uo.vendor.sale is a trigger that is silently dormant on a shard that declined the
tier, and the rule that names it has to say so.
2. The shard side
2.1 house.decay — a schedule, and only where it is knowable
BridgeSweeps.WriteDecay gains ownerName and one nested object:
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
"name":"Millrace","ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
"schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z",
"decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"},
"map":"Felucca","x":1480,"y":1600,"z":0,"lastRefreshed":"2026-08-25T17:21:14.5725317Z"}
estimatedCollapse is present only when it is exact, and that is the whole design of this field.
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.SetDynamicDecaycallsDynamicDecay.GetRandomDuration. SoNextDecayStageis 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 ofLastRefreshedandDecayPeriod, so collapse is exact at every stage — there is no randomness to wait out.
Emitting a dynamic-decay house's collapse time at, say, Fairly would publish a guess as a fact, and
on the website's side that becomes a dated promise in a player's mail. So it is omitted rather than
approximated. An absent field is honest where a wrong date is not, and the consumer's
required: false declaration already permits the absence.
dynamicDecay is emitted unconditionally so a reader can tell the two regimes apart, rather than
having to infer them from which fields arrived.
The nesting is load-bearing, not cosmetic. The website's visibility projection matches literal
JSON keys, so a nested group is one admin rule that hides the whole schedule where four sibling keys
would be four rules that drift apart — the same argument that made vendor.listing's location
nested in v3.
2.2 vendor.listing — an owner who can be found, and a deadline
Two additions, and the first matters more than it looks:
ownerAcct. The frame has carried ownerName since v3, but a character name joins to nothing:
the website's shard_account_links is keyed by the game account. Until now a vendor row named an
owner the site could not resolve to a person, which is why a per-owner rule was impossible however
much fee data existed.
fees, a nested object describing PlayerVendor.PayTimer's dismissal rule. That rule is
if (pay > totalGold) Destroy() at each tick — and both halves of the comparison differ between
ServUO's two vendor systems:
| charge | funds | interval | |
|---|---|---|---|
NewVendorSystem |
ChargePerRealWorldDay |
HoldGold |
1 real day |
| old system | ChargePerDay |
BankAccount + HoldGold |
1 UO day (Clock.MinutesPerUODay, ≈2 real hours) |
Both are resolved on the shard rather than left for the sidecar or the website to reconstruct,
because reconstructing them anywhere else is a second implementation of a rule that lives in
PlayerVendor.
"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,"funds":82504,
"holdGold":82504,"bankAccount":0,"payIntervalSec":86400,
"nextPayAt":"2026-09-01T21:01:21.6883320Z","periodsRemaining":7,
"dismissalAt":"2026-09-08T21:01:21.6883320Z"}
Two naming decisions worth recording, because the obvious spellings are both wrong:
- There is no
daysRemaining. On an old-system shard a pay period is a UO day, so a field with "days" in its name would be off by a factor of twelve on exactly the shards least likely to notice. The wire carriesperiodsRemainingplus the interval that gives it meaning, and resolves the arithmetic intodismissalAt— an instant, which needs no units at all. exemptis not "a very long time left". A commission vendor (IsCommission) has noPayTimerand is never dismissed for fees. It reports{"exempt": true}and no schedule, so a surface rendering "never" can tell it apart from one rendering "in 400 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.
2.3 account.login.result — a new kind, because the old one cannot be fixed
account.login.attempt (protocol 1) is emitted from EventSink.AccountLogin, which
PacketHandlers.AccountLogin invokes before it branches on e.Accepted. The emitter's own
comment has said so since it was written: "Fires before the auth decision, so this is an attempt,
not a result."
The verdict is set by the handlers themselves — Server.Misc.AccountHandler is the one that checks
the password and sets Accepted/RejectReason — so inside our handler the verdict does not exist
yet. AccountLoginEventArgs constructs with Accepted = true, which means a naive read there
reports every login as accepted, including the ones about to be rejected.
That is why "someone tried to log into your game account" could not be built on the attempt: a rule on it would have mailed a security alert every time the player logged in successfully — the exact inversion that makes people stop trusting security mail.
{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":false,"reason":"BadPass"}
{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":true}
How it reads the verdict. Timer.DelayCall(TimeSpan.Zero, …) over the args object: one Core
slice later, InvokeAccountLogin has returned and the verdict is final. This needs no core patch,
and — the reason it is preferred over simply subscribing late — it does not depend on handler
subscription order, which ServUO does not define and which a shard's own scripts can change.
Three details that are deliberate:
reasonis omitted on an accept.ALRReason's zero value isInvalid, so emitting it unconditionally would put a plausible-looking failure reason on every successful login.- The IP is resolved inside the handler, not in the deferred call:
AccountLogin_ReplyRejdisposes theNetStatebefore the deferred read runs. - The password is never read, never logged, never emitted. The args object carries it, so it is held for one extra Core slice and no longer, and exactly two properties are read off it.
3. The sidecar side
PROTOCOL_VERSION: u32 = 4 → 5 in sidecar/src/main.rs, and nothing else.
There is no store migration this time, unlike v4. Every frame is persisted whole and the board
tables index only the columns they already had, so the new fields ride inside the stored JSON and the
new kind lands in events like any other. There is no kind allowlist to extend, either.
That is the dumb-forwarder property doing its job (v3 §3): the sidecar defines no schema for a frame's contents, so it needs no change when they grow. A bump that touches one constant is the expected cost of an additive protocol version here; v4 needing a migration was the exception, because it added a column to a board table.
4. Visibility
Three classifications, made on the website in module-uo's shardVisibility.js, never in the
sidecar. Each had to be chosen: a v5 field nobody classified would either leak, or be silently
invisible with nobody noticing.
| Field / kind | Audience | Why |
|---|---|---|
house.decay → schedule |
anonymous, configurable |
The countdown is the public IDOC page's content, and a house at IDOC is already announced in game. Listed rather than left unconfigurable so a shard that considers a precise collapse time an unfair advantage can raise it |
vendor.listing → fees |
admin, configurable |
The only default in the market feature that does not reproduce prior behaviour, because there is no prior behaviour to reproduce |
vendor.listing → ownerAcct |
admin, locked | Rule 1, automatically: the key ends in acct |
account.login.result |
admin, by omission | Rule 2 — a kind absent from KIND_FEATURE reaches nobody below admin |
Why fees breaks the market pattern. A shop's name, owner and location are already visible to
any player through the stock in-game Vendor Search gump, which is the entire argument for publishing
them. A vendor's held gold, daily charge and dismissal date are not: in game they are visible to the
owner, on that vendor's own gump. Publishing them anonymously would be a genuinely new disclosure
and a targeting aid — it says which shops are about to be abandoned and how much coin is sitting in
each.
Why account.login.result is admin-only by omission rather than by mapping. KIND_FEATURE is
the map of kinds that may leave the admin channel, and an admin can widen anything on it. There is
no rung below admin that a frame carrying an IP address and an auth verdict belongs on, so mapping it
at all would create a door that should not exist. The omission is the decision, and there is a test
that says so by name.
The REST reads are unchanged. shard_vendors' new columns are not in any read model's column
list: they exist for Phase 11's server-side trigger and reach no client at all. The house schedule
is on the read model, re-nested under schedule on the way out so the stored shape and the live
wire frame spell it identically — otherwise one admin rule would cover only one of the two paths,
which is the §3.6.1 failure mode v3 already recorded once.
5. Cross-repo obligations
| Repo | Change |
|---|---|
servuo-plugins |
BridgeSweeps.AppendDecaySchedule, BridgeMarket.AppendFees, BridgeEvents.EmitLoginResult · overlay.toml protocol = 5, in the same PR as the emitters |
link |
PROTOCOL_VERSION → 5 |
module-uo |
ingest + schema (shard_houses ×4, shard_vendors ×8) · shardVisibility.js ×3 · the uo_link_config protocol pin, both declaration sites |
docs |
this file · INTEGRATION.md §Housing, §Market, §Account · PLAN.md §5/§7 |
runicgateway.com |
platform.json.protocol → 5 — deferred to ENGAGEMENT.md Phase 12, because checkFacts.mjs fetches from main and setting it during the edge period turns that repo red immediately |
The pin has three declaration sites and they are checked against each other, not against a
literal. module-uo's schema test used to assert DEFAULT 4 at each site, which is how protocol 4
shipped with the emitters moved and one declaration left behind: every site agreed with itself. It
now reads DEFAULT_PROTOCOL from the model, so the assertion is "the declarations agree" and a
bump that misses one fails in CI instead of on an operator's install.
6. Verification
Unit tests: 470 in module-uo/server (16 new), 39 in the sidecar, all passing; the C# compiles
against the real ServUO 57.4 reference assemblies.
Everything below was proved on the local rig — a real ServUO with a seeded world (43k mobiles,
209k items), the release Rust sidecar, and tools/scaffolding/BridgeProtocol5Probe.cs.
| Claim | Evidence |
|---|---|
estimatedCollapse appears only at IDOC |
One house walked Fairly → Greatly → IDOC. The Fairly and Greatly frames carry nextStage and no estimatedCollapse; the IDOC frame carries both, equal |
house.decay carries the owner's character name |
"ownerName":"Zara Crowe" on all three frames |
| the fee block is right | funds/chargePerPeriod floors to periodsRemaining on every observed vendor (82504/10548→7, 63252/6834→9, 115531/10035→11, 43/60→0) |
| a vendor with one tick left says so | periodsRemaining: 0 with dismissalAt == nextPayAt — dismissed at the very next tick, which is the case uo.vendor.expiring exists for |
| the login verdict is the FINAL one | A real socket login with a wrong password → accepted:false reason:BadPass; with the right one → accepted:true, no reason. Both saying true is the bug this kind exists to prevent |
| the sidecar needed no change beyond the constant | GET /health → "protocol":5; every v5 field arrived through the generic forward path |
| a mismatched pair is refused, not mis-parsed | Unchanged and already tested in installer: bundle.rs::a_protocol_disagreement_inside_one_bundle_is_refused and doctor.rs::a_protocol_mismatch_fails_the_row. The check is version-agnostic, so v5 needs no installer change |
6.1 Two rig traps, both of which fake a broken emitter
Recorded because each cost a rebuild and each produces silence rather than an error.
- An in-process login probe can never produce
accepted:true.AccountHandlercallsacct.HasAccess(e.State)before it checks the password, and a nullNetStatefails that — so firingEventSink.InvokeAccountLogindirectly logsAccess deniedeven for a correct password. Only a real socket proves the accepted half (and it is the better test anyway: it also produces the realip). - Forcing a decay stage on a house that cannot decay emits nothing at all. Only
CondemnedandManualRefreshhouses decay; anAutoRefreshone — and the owner's newest house is alwaysAutoRefresh— has aDecayLevelgetter that callsResetDynamicDecay()and reportsAgeless, wiping the forced stage on the very next read. The sweep then sees no change. On the seeded world exactly one house qualified, and it was already at IDOC, so the walk had to put it back down first.