feat(bridge): protocol 5 — decay schedule, vendor fee state, and a login result #18

Merged
whitlocktech merged 1 commits from feature/protocol-v5-enrichments into edge 2026-09-01 00:27:49 +00:00
Member

The shard half of the protocol-5 bump. Engagement Phase 10, whose scope was widened by the org lead on 2026-08-31 (docs#192). Design of record: docs#193 → link/v5.md.

Companion PRs: link#…, Module-uo#…, docs#193. overlay.toml is bumped in this PR, per CLAUDE.md's "The bridge is a contract" — otherwise the next bundle silently fails to compose.

Three enrichments, one bump

A protocol bump costs a sidecar release, a republished bundle and an operator update on every shard. A field left out costs a second round of that, not a follow-up commit — so everything the engagement workstream is known to need from the wire rides v5.

house.decayownerName + a nested schedule

{dynamicDecay, nextStage, decayPeriodSec, estimatedCollapse}.

estimatedCollapse is emitted only where ServUO can actually know it. Dynamic decay (Core.ML) draws each stage's duration at random when the stage is entered, so NextDecayStage is exact for the next transition and nothing beyond it is known — collapse becomes exact only at IDOC, where the next transition is the collapse. Static decay is a pure function of LastRefreshed/DecayPeriod and is exact at every stage. Emitting it anywhere else publishes a guess as a fact, and downstream that becomes a dated promise in someone's mail.

vendor.listingownerAcct + a nested fees

ownerAcct is the structural one: the frame has carried ownerName since v3, but a character name joins to nothing — only the game account is the website's link key.

fees resolves PlayerVendor.PayTimer's dismissal rule (pay > totalGold => Destroy) on the shard, because both halves of that comparison differ between ServUO's two vendor systems and re-deriving them downstream would be a second implementation of a rule that lives in core.

There is no daysRemaining. Under the old vendor system a pay period is a UO day (Clock.MinutesPerUODay, ≈2 real hours), so the obvious name is wrong by a factor of twelve on exactly the shards least likely to notice. periodsRemaining plus the interval, and dismissalAt as an instant. A commission vendor has no pay timer at all and reports exempt with no schedule — "never dismissed" is not "dismissed in 400 days".

account.login.result — a new kind

EventSink.AccountLogin is a veto hook that fires before the auth decision, and AccountLoginEventArgs constructs with Accepted = true — so the existing account.login.attempt fires on successful logins too and cannot carry a verdict. A security rule built on it would have mailed "someone tried to get into your account" every time the player logged in themselves.

The verdict is read one Core slice later via DelayCall(Zero). That needs no core patch and does not depend on handler subscription order, which ServUO does not define and a shard's own scripts can change. reason is omitted on an accept (ALRReason's zero value is Invalid and would read as a failure reason). The address is resolved inside the handler, since AccountLogin_ReplyRej disposes the NetState before the deferred read runs. The password is never read, logged or emitted.

Rig verification

tools/scaffolding/BridgeProtocol5Probe.cs (new) drives all three on a live shard — real ServUO 57.4, seeded world (43k mobiles / 209k items), the release Rust sidecar.

Claim Evidence
estimatedCollapse appears only at IDOC One house walked Fairly → Greatly → IDOC; the first two frames carry nextStage and no collapse, the third carries both, equal
the fee arithmetic 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, dismissalAt == nextPayAt
the login verdict is the FINAL one Real socket login: wrong password → accepted:false reason:BadPass; right one → accepted:true, no reason. Both saying true is the bug this kind exists to prevent

Compiles clean against real ServUO 57.4 reference assemblies (0 warnings, 0 errors).

Two rig traps, both recorded in the scaffolding README

Both produce silence rather than an error, so each looks exactly like a broken emitter:

  • An in-process login probe can never produce accepted:true. AccountHandler calls acct.HasAccess(e.State) before it checks the password, and a null NetState fails that — so firing EventSink.InvokeAccountLogin directly logs Access denied even for a correct password. Only a real socket proves the accepted half (and it is the better test anyway — it produces the real ip).
  • Forcing a decay stage on a house that cannot decay emits nothing at all. Only Condemned/ManualRefresh houses decay; an AutoRefresh one — and the owner's newest house is always AutoRefresh — has a DecayLevel getter that calls ResetDynamicDecay() and reports Ageless, wiping the forced stage before the sweep reads it. On the seeded world exactly one house qualified, and it was already at IDOC, so the walk had to put it back down first.

  • AI-assisted — implemented with Claude Code (Claude Opus 5), verified on the local rig before opening.

🤖 Generated with Claude Code

The shard half of the protocol-5 bump. **Engagement Phase 10**, whose scope was widened by the org lead on 2026-08-31 (docs#192). Design of record: **docs#193 → `link/v5.md`**. Companion PRs: **link#…**, **Module-uo#…**, **docs#193**. `overlay.toml` is bumped **in this PR**, per CLAUDE.md's "The bridge is a contract" — otherwise the next bundle silently fails to compose. ## Three enrichments, one bump A protocol bump costs a sidecar release, a republished bundle and an operator update on every shard. A field left out costs a *second* round of that, not a follow-up commit — so everything the engagement workstream is known to need from the wire rides v5. ### `house.decay` — `ownerName` + a nested `schedule` `{dynamicDecay, nextStage, decayPeriodSec, estimatedCollapse}`. **`estimatedCollapse` is emitted only where ServUO can actually know it.** Dynamic decay (`Core.ML`) draws each stage's duration at *random* when the stage is entered, so `NextDecayStage` is exact for the next transition and nothing beyond it is known — collapse becomes exact only at IDOC, where the next transition *is* the collapse. Static decay is a pure function of `LastRefreshed`/`DecayPeriod` and is exact at every stage. Emitting it anywhere else publishes a guess as a fact, and downstream that becomes a dated promise in someone's mail. ### `vendor.listing` — `ownerAcct` + a nested `fees` `ownerAcct` is the structural one: the frame has carried `ownerName` since v3, but a character name joins to nothing — only the game account is the website's link key. `fees` resolves `PlayerVendor.PayTimer`'s dismissal rule (`pay > totalGold => Destroy`) **on the shard**, because both halves of that comparison differ between ServUO's two vendor systems and re-deriving them downstream would be a second implementation of a rule that lives in core. **There is no `daysRemaining`.** Under the old vendor system a pay period is a UO day (`Clock.MinutesPerUODay`, ≈2 real hours), so the obvious name is wrong by a factor of twelve on exactly the shards least likely to notice. `periodsRemaining` plus the interval, and `dismissalAt` as an instant. A commission vendor has no pay timer at all and reports `exempt` with no schedule — *"never dismissed"* is not *"dismissed in 400 days"*. ### `account.login.result` — a new kind `EventSink.AccountLogin` is a veto hook that fires **before** the auth decision, and `AccountLoginEventArgs` constructs with `Accepted = true` — so the existing `account.login.attempt` fires on successful logins too and cannot carry a verdict. A security rule built on it would have mailed *"someone tried to get into your account"* every time the player logged in themselves. The verdict is read one Core slice later via `DelayCall(Zero)`. That needs no core patch **and** does not depend on handler subscription order, which ServUO does not define and a shard's own scripts can change. `reason` is omitted on an accept (`ALRReason`'s zero value is `Invalid` and would read as a failure reason). The address is resolved *inside* the handler, since `AccountLogin_ReplyRej` disposes the `NetState` before the deferred read runs. The password is never read, logged or emitted. ## Rig verification `tools/scaffolding/BridgeProtocol5Probe.cs` (new) drives all three on a live shard — real ServUO 57.4, seeded world (43k mobiles / 209k items), the **release** Rust sidecar. | Claim | Evidence | |---|---| | `estimatedCollapse` appears **only** at IDOC | One house walked Fairly → Greatly → IDOC; the first two frames carry `nextStage` and no collapse, the third carries both, equal | | the fee arithmetic 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`, `dismissalAt == nextPayAt` | | the login verdict is the FINAL one | Real socket login: wrong password → `accepted:false reason:BadPass`; right one → `accepted:true`, no `reason`. **Both saying `true` is the bug this kind exists to prevent** | Compiles clean against real ServUO 57.4 reference assemblies (0 warnings, 0 errors). ## Two rig traps, both recorded in the scaffolding README Both produce **silence** rather than an error, so each looks exactly like a broken emitter: - **An in-process login probe can never produce `accepted:true`.** `AccountHandler` calls `acct.HasAccess(e.State)` *before* it checks the password, and a null `NetState` fails that — so firing `EventSink.InvokeAccountLogin` directly logs `Access denied` even for a correct password. Only a real socket proves the accepted half (and it is the better test anyway — it produces the real `ip`). - **Forcing a decay stage on a house that cannot decay emits nothing at all.** Only `Condemned`/`ManualRefresh` houses decay; an `AutoRefresh` one — and *the owner's newest house is always `AutoRefresh`* — has a `DecayLevel` getter that calls `ResetDynamicDecay()` and reports `Ageless`, wiping the forced stage before the sweep reads it. On the seeded world exactly **one** house qualified, and it was already at IDOC, so the walk had to put it back down first. --- - [x] AI-assisted — implemented with Claude Code (Claude Opus 5), verified on the local rig before opening. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-01 00:21:07 +00:00
Three emitter changes and the overlay's protocol declaration, in one PR because
"The bridge is a contract": overlay.toml must be bumped in the same change as the
emitters or the next bundle silently fails to compose.

BridgeSweeps — house.decay gains ownerName and a nested `schedule`
{dynamicDecay, nextStage, decayPeriodSec, estimatedCollapse}.

estimatedCollapse is emitted only where ServUO can actually know it. Dynamic decay
(Core.ML) draws each stage's duration at RANDOM when the stage is entered, so
NextDecayStage is exact for the next transition and nothing beyond it is known —
collapse becomes exact only at IDOC, where the next transition IS the collapse.
Static decay is a pure function of LastRefreshed and DecayPeriod, so it is exact at
every stage. Emitting it anywhere else would publish a guess as a fact, and on the
website's side that becomes a dated promise in someone's mail.

BridgeMarket — vendor.listing gains ownerAcct and a nested `fees` block.

ownerAcct is the one that matters structurally: the frame has carried ownerName
since v3, but a character name joins to nothing — only the game account is the
website's link key. The fees block resolves PlayerVendor.PayTimer's dismissal rule
(pay > totalGold => Destroy) on the shard, because both halves of that comparison
differ between ServUO's two vendor systems and re-deriving them downstream would be
a second implementation of a rule that lives in core.

No daysRemaining: under the old vendor system a pay period is a UO day
(Clock.MinutesPerUODay, about two real hours), so the obvious name would be wrong
by a factor of twelve on exactly the shards least likely to notice. periodsRemaining
plus the interval, and dismissalAt as an instant. A commission vendor has no pay
timer at all and reports exempt with no schedule — "never dismissed" is not the same
as "dismissed in 400 days".

BridgeEvents — a new account.login.result kind.

EventSink.AccountLogin is a veto hook that fires BEFORE the auth decision, and
AccountLoginEventArgs constructs with Accepted = true, so the existing
account.login.attempt fires on successful logins too and cannot carry a verdict. A
security rule built on it would have mailed "someone tried to get into your account"
every time the player logged in.

The verdict is read one Core slice later via DelayCall(Zero). That needs no core
patch AND does not depend on handler subscription order, which ServUO does not define
and a shard's own scripts can change. reason is omitted on an accept, because
ALRReason's zero value is Invalid and would read as a failure reason. The address is
resolved inside the handler, since AccountLogin_ReplyRej disposes the NetState before
the deferred read runs. The password is never read, logged or emitted.

tools/scaffolding/BridgeProtocol5Probe.cs drives all three on a live shard, and the
README records the two traps it took to get there — both of which produce SILENCE
rather than an error, so each looks exactly like a broken emitter:

  * An in-process login probe can never produce accepted:true. AccountHandler calls
    acct.HasAccess(e.State) BEFORE it checks the password, and a null NetState fails
    that. Only a real socket proves the accepted half — and it is the better test
    anyway, since it also produces the real ip.
  * Forcing a decay stage on a house that cannot decay emits nothing at all. Only
    Condemned and ManualRefresh houses decay; an AutoRefresh one — and the owner's
    NEWEST house is always AutoRefresh — has a DecayLevel getter that calls
    ResetDynamicDecay() and reports Ageless, wiping the forced stage before the sweep
    reads it.

Verified on the local rig against the release sidecar: a house walked
Fairly -> Greatly -> IDOC carried estimatedCollapse on the IDOC frame and only there;
every vendor's periodsRemaining matched funds/chargePerPeriod, including one at 0
whose dismissalAt equals its next tick; a real socket login gave
accepted:false reason:BadPass and then accepted:true. Compiles clean against ServUO
57.4 reference assemblies.

Docs: RunicGateway/docs link/v5.md.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit a144c12c46 into edge 2026-09-01 00:27:49 +00:00
whitlocktech deleted branch feature/protocol-v5-enrichments 2026-09-01 00:27:49 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#18
No description provided.