wtclaude 48d57e6278 feat(bridge): publish the player-vendor market index as vendor.listing
Protocol 3.0 §8. Every player vendor's shop name, owner, location and priced
inventory, so the website can offer the search the in-game Vendor Search gump
offers — from outside the game, and honouring the same per-player opt-out.

It cannot be an RPC. rpc.rs correlates a reply on the FIRST frame carrying a
matching reqId, so a chunked reply sharing one reqId would deliver chunk 1 to the
HTTP caller and leak chunks 2..N onto the broadcast feed; a whole-world snapshot
would not fit in one frame inside the 10 s timeout either. So it is a diff sweep
on the broadcast stream, one authoritative frame per vendor.

The one genuinely new pattern here is an amortized round-robin: every other sweep
walks its whole collection per tick, which is fine for tens of houses and is not
fine for a world of shops whose inventories recurse into containers.
MarketSweepBatch (25) vendors are inventoried per tick from a persistent cursor,
so per-tick cost is bounded by the batch rather than by world size.

VendorSearch.GetItemName is never called: it builds an ObjectPropertyList,
serialises it and byte-parses the packet per item. The frame carries itemId, hue,
amount, price, the plain item.Name field and item.LabelNumber; the website
resolves names against its own cliloc table. (It would not work anyway — every
current client ships its cliloc files compressed and ServUO's Ultima.StringList
cannot read them, so the in-game gump has the same gap.)

Measured on the live shard (27 vendors x 40 listings, 209k items / 43k mobiles):
15.4 ms for the first cold tick of 25 vendors, 3.4 ms for the next, 0.3 ms in
steady state. `[bridge status` now reports lastMs/maxMs and a tick over 50 ms
warns, naming the knob — the batch cap is a claim about that number and an
operator tuning it was otherwise tuning blind.

- location is ONE nested object, not flat map/x/y/region, so the website's single
  market.location visibility rule can hide a vendor's whereabouts on both the
  live frame and the stored read model. Flat keys would need five rules.
- Owner is flat ownerSerial/ownerName, never BridgeJson.Actor, which would add
  acct and webId. Same argument points.board makes.
- pv.VendorSearch is honoured, so a shop hidden in game is hidden on the site;
  the seen-set removal then emits vendor.listing.remove.
- Container-priced items carry child:true, exactly as DoSearch reports them.
- Over MarketMaxListings (250) the frame says truncated and carries the real
  total, so the site shows "250 of 3,104" rather than a partial shop as complete.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:00 -05:00

Runic Gateway — ServUO Plugin

The C# ServUO side of the Runic Gateway bridge. The shard emits newline-delimited JSON over a loopback TCP socket to the Rust sidecar (RunicGateway/link), which owns the WebSocket the website consumes.

ServUO plugin (C#, net48)  ──loopback TCP, newline-JSON──►  Rust sidecar  ──WebSocket/JSON──►  website
      (Core-thread reads)  ◄──inbound commands─────────────┘  (owns WS, auth, buffering, fan-out)
   >>> THIS REPO <<<                                          (RunicGateway/link)

The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.

Documentation

All project documentation lives in the central RunicGateway/docs repo, under link/ (design docs, integration guide, protocol spec, research — with full history preserved).

Layout

Path What
overlay/ Mirrors the ServUO server root. Everything here — and only this — copies over an install.
patches/ Unified diffs against stock ServUO for files we must modify rather than add.
tools/ Never deployed. Test scaffolding (C# probes + PowerShell stub sidecars) and anything else that must not reach a server.
deploy.ps1 Copies overlay/ into a server root. -Verify diffs instead of writing.
INTEGRATION.md Website integration guide — the WebSocket feed, REST endpoints, auth, event catalog, and examples.
PLAN.md Implementation plan, measured performance budget, and the full data catalog.
RESEARCH.md Original source-level research. Partly superseded — see the corrections table in PLAN.md §8.
SHARD_PREREQS.md Repairs the target shard needed before any of this could load.

Anything under overlay/ is authoritative. Do not edit files in the server tree directly — edit here and deploy.

Sidecar & deployment

The Rust sidecar is the other half of the bridge and lives in RunicGateway/link. The two are deployed together but built independently:

  • This plugin is deployed as sourcedeploy.ps1 copies overlay/ into the ServUO server root, and ServUO compiles it at boot (Scripts.csproj; see Phase 0). There is no separate build artifact and no CI build — it cannot be compiled standalone without the ServUO reference assemblies.
  • The sidecar is a standalone Rust binary, released from its own repo.

The only coupling is the loopback JSON protocol (the shard dials out to the sidecar on 127.0.0.1). Compatibility is a protocol concern, not a build-order one: keep the event/command catalog in sync across the two repos (canonical spec: PLAN.md §5/§7 and INTEGRATION.md). A wedged or absent sidecar cannot stall the shard, so the plugin can be deployed before, after, or without the sidecar running.

Deploy

.\deploy.ps1 -ServerPath <servuo> -Verify   # show what would change
.\deploy.ps1 -ServerPath <servuo>           # write

Status

Phase State
0 — build fix (Scripts.csproj) done, verified end-to-end
1 — transport (BridgeLink) done, acceptance in PLAN.md §11
2 — event streams (BridgeEvents) done, acceptance in PLAN.md §12
3 — sweeps (BridgeSweeps) done, acceptance in PLAN.md §13
4 — request/response (BridgeRequests) done, acceptance in PLAN.md §14
5 — [link account linking (BridgeAccountLink) done, acceptance in PLAN.md §15
6 — town-crier inbound (BridgeTownCrier) done, acceptance in PLAN.md §16
7 — PlayerVendorSale core event (patches/ + BridgeVendorSale) done, acceptance in PLAN.md §17

Every phase on the ServUO side is complete. Phases 06 are drop-in (overlay/); Phase 7 is the one core change, shipped as patches/.

Cheat-detection signals are not a separate phase — they are folded into the streams above: cheat.fastwalk, audit.set, audit.command, and vendor.sale (buyer + owner for laundering detection).

Phase 0 — what it fixes

ScriptCompiler.Compile() runs dotnet build Scripts/Scripts.csproj -c Release, prints the output, and never checks the exit code, then Assembly.LoadFrom("Scripts.dll") and returns true. Because that build passed no Platform, MSBuild defaulted to AnyCPU, and Scripts.csproj gated both OutputPath and DefineConstants on Configuration|Platform == Release|x64. So:

  • the DLL landed in Scripts/bin/Release/ while the core loads Scripts.dll from the base directory, and
  • TRACE;NEWTIMERS;ServUO went undefined, so XmlSpawner compiled its non-ServUO branches.

Runtime script compilation therefore had no effect, silently. overlay/Scripts/Scripts.csproj conditions both property groups on Configuration alone.

Server.csproj is deliberately 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.

The plugin (Phase 1)

overlay/Scripts/Custom/Bridge/:

File Responsibility
BridgeConfig.cs Reads Config/Bridge.cfg in Configure(), before World.Load.
BridgeJson.cs Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via JavaScriptSerializer.
BridgeLink.cs The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread.
BridgeBoot.cs Lifecycle, inbound dispatch, [bridge status|reload|ping].
BridgeEvents.cs EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets.
BridgeSweeps.cs Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers.
BridgeProfile.cs Read-model builders (Phase 4): full character profile, account roster. Core-thread reads.
BridgeRequests.cs Inbound request handlers (Phase 4): char.request, account.roster, vendor.snapshot, with bridge.error replies.
BridgeAccountLink.cs [link account linking (Phase 5): one-time code, link.confirm, WebsiteUserId account tag.
BridgeTownCrier.cs Town-crier news (Phase 6): inbound towncrier.add / remove into the global crier list, with abuse caps.

Emit() is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. A wedged or absent sidecar cannot stall the shard, and that is the property everything else depends on.

Testing

tools/stub_sidecar.ps1 is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch server.hello arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error.

.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log

tools/stub_sidecar_request.ps1 additionally sends inbound requests (char.request, account.roster, vendor.snapshot, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4.

Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar (RunicGateway/link) replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).

tools/scaffolding/ holds the world seeder and the performance probe. Neither is deployed — deploy.ps1 only copies overlay/. They produced the budget in PLAN.md §1. See tools/scaffolding/README.md.


License

Runic Gateway is free software, licensed under the GNU General Public License v3.0 or later — see LICENSE.md.

Copyright (C) 2026 Runic Gateway

This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version. It is distributed WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.

Contributions are welcome — please read CONTRIBUTING.md (note the AI-usage disclosure requirement) and our Code of Conduct. Report vulnerabilities privately per SECURITY.md.

Description
No description provided
Readme 262 KiB
Languages
C# 96.1%
PowerShell 3.9%