Files
servuo-plugins/overlay/Config/Bridge.cfg
Claude 13b6fc02a4 feat(asset-bridge): the shard's own files stop needing a shared filesystem (Phase 7)
The spawn atlas was the one place the platform's rule -- only the sidecar
bridges the shard -- was broken, and it was broken by the component that faces
the internet: SPAWN_ATLAS.md required the website to read the ServUO tree off a
bind mount or a shared volume. This serves those files over the loopback link
instead (docs/link/v8.md 10).

The measurement came first and changed the shape. 10 said the shard would serve
`tree/<label>` -> bytes; against a stock 57.4 tree it cannot. Spawns/trammel.xml
is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as
one base64 row is 5.4 MiB -- it would be dropped, time out, and be re-requested
forever with no error anywhere. Two files on a STOCK tree are in that state.

So a file crosses as 512 KiB chunks, each gzipped: tree/Spawns/trammel.xml/c0
and so on, which is 5's depth scheme doing the same job it does for
body/400/a0/f0 and needing no protocol change to do it. The chunk is the bound
and the compression is only the saving -- nothing guarantees an operator's files
compress, so the ceiling has to hold when they do not, and a 512 KiB chunk that
refuses to compress is still ~683 KiB of base64, inside the wire cap that
AssetBatchBytes' deliberate factor of two leaves room for.

It is a `tree` FAMILY on assets.fetch rather than 14's separate tree.* commands:
phase 5 had already learned that the command is the transport and the family is
a property of the key, and assets.manifest is generalised here the same way.
That reuses the single slot, the paging envelope, the key ceiling and the
mid-import guard -- and leaves `link` with nothing to do for the third phase
running.

But it gets its OWN consent, Bridge.TreeEnabled. AssetsEnabled is an operator
agreeing the website may read their EA-licensed UO client; this is the shard's
own configuration, which they wrote, and which the public bestiary is built
from. One switch could not express both, and the thing that would silently
disappear for an operator who declined the first is their spawn atlas. So the
consent check moved into the family lookup, and assets.sources answers whenever
either plane is on, reporting `families` filtered to what is actually enabled --
which is how a tree-only shard's website discovers there is anything to ask for.

Two defects found, and which harness found which is the part worth keeping:

  - An empty `catalog` is not an absent one. `expected != null` refused every
    fetch from a caller that sent "", with a sentence naming no catalog at all.
    Found by an offline probe that passed one by accident.
  - GZipStream writes NOTHING for zero bytes of input -- the header is emitted
    lazily, so a stream opened and closed without a write yields a zero-length
    buffer rather than the 20-byte empty member. Stock ServUO ships two empty
    decoration files, so this broke every import off an untouched tree. The
    offline probe reassembled all 141 files and reported success, because .NET's
    own decompressor reads an empty stream as empty data and the chunk's
    declared length (0) and hash (of nothing) both agreed. Only the live walk,
    through a reader on another runtime, disagreed.

Measured end to end against a live shard, the real sidecar and the website's own
reader: 141 files, 11,895,427 bytes, 158 chunks, 3 pages, 1.33 MB on the wire,
512 ms; every file byte-identical to disk; the atlas built over the bridge
identical to the one built off it. A drift check is the manifest alone -- 32 KB,
~70 ms, no file bytes.

The label set is this shard's, never the caller's: a fetch resolves against the
set the shard itself enumerated, and tree/../../Scripts/..., Config/Bridge.cfg
and Saves/Accounts/accounts.xml are all answered `absent` before a path is built
out of them.

Protocol stays 8 and EXTRACTOR_VERSION stays 3 -- this family derives nothing,
it forwards an operator's own file unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 02:00:22 -05:00

371 lines
20 KiB
INI

# uo-link bridge settings.
#
# Key scope is the filename: Bridge.cfg + StatSweepSeconds => "Bridge.StatSweepSeconds".
# Read in Configure(), which runs before World.Load.
# Loopback only. The socket being local is the trust boundary for inbound commands;
# if the sidecar ever moves off-host, add a shared secret first.
Host=127.0.0.1
Port=7788
# Outbound queue cap. On overflow the plugin drops oldest and counts the drops,
# because a stalled sidecar must never OOM the shard.
QueueCap=10000
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
# this interval to emit champ.update on any status/level/kills/boss change. The world holds
# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
ChampSweepSeconds=10
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
# support queue; the full open queue is also available on demand via pages.snapshot.
PageSweepSeconds=5
# Guild roster poll (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
GuildSweepSeconds=60
# Members per guild.roster frame (Protocol 4). A roster is the only fat frame the bridge emits
# (~69 bytes per member) and the sidecar reads a line with no length bound, so this caps it; a
# guild over the cap is split across continuation frames carrying seq/more. 500 members is ~35 KB,
# past any realistic guild, so the split path is an edge case rather than the norm.
GuildRosterMembersPerLine=500
# Guilds that may emit a roster in one sweep. Every guild looks changed right after a sidecar
# reconnect, and building hundreds of fat frames in a single Core-thread pass is exactly the stall
# the bridge exists to avoid. The sweep re-arms itself every 2s while a baseline is draining, so
# lowering this slows the catch-up without making the site wait a full sweep interval per batch.
GuildRosterGuildsPerTick=25
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
CitySweepSeconds=300
# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
# interval and emitted as presence.online only when it changes. Region transitions come
# through separately in real time as region.enter (EventSink.OnEnterRegion).
PresenceSweepSeconds=30
# Housing registry poll. Every house is diffed on this interval to emit house.update /
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
HousingSweepSeconds=300
# Points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7). ServUO carries ~25 point
# currencies (Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city loyalties,
# the Doom/Khaldun/Kotl treasure systems, …). Each is diffed on this interval and emitted as
# one points.board frame per system when its top N moves.
#
# Slow on purpose: these are month-scale standings, and ten of the systems keep a row for
# every character ever created, so the pass is the widest read in the bridge. It is still
# cheap — a single bounded pass, never a sort — but there is nothing to gain by hurrying it.
PointsSweepSeconds=300
# Master switch for the boards. Off leaves char.profile points alone (see below).
PointsLeaderboardEnabled=true
# How many players per board. Clamped to 1..100 — the frame is emitted PER SYSTEM, so a big
# N is multiplied by ~25.
PointsTopN=10
# Which systems to publish, as a comma-separated list of PointsType names, e.g.
# PointsSystems=QueensLoyalty,CleanUpBritannia,VoidPool
# Blank (the default) publishes whatever the shard itself shows on the in-game loyalty gump
# (ShowOnLoyaltyGump), so a subsystem you add later gets a board without an edit here.
# An unrecognized name is logged and ignored, never silently dropped.
PointsSystems=
# Include a per-character "points" block in char.profile (the website character sheet). This
# is a lookup across every published system's table, so it is the dominant cost of building a
# profile; turn it off on a very large shard that does not want the sheet paying for it.
PointsProfileEnabled=true
# Also compute each system's rank in that block. OFF by default and worth leaving off: a
# points lookup stops at the character's own row, but a rank must count every row that beats
# them, in every system, on every profile build. The website already derives rank from the
# board for anyone in the top N.
PointsProfileRank=false
# Player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8). Every player vendor's shop name,
# owner, location and priced inventory, published as one vendor.listing frame per vendor so the
# website can offer the search the in-game Vendor Search gump offers. Honours each player's own
# in-game opt-out (the vendor's VendorSearch flag) — hide your vendor in game and it is hidden
# on the site too.
MarketEnabled=true
# Sweep interval. UNLIKE every other sweep here, a tick does NOT walk the whole world: it
# inventories at most MarketSweepBatch vendors and a persistent cursor round-robins through the
# rest, so the per-tick cost is bounded by the batch rather than by how many vendors exist. Full
# coverage takes ceil(vendors / batch) x MarketSweepSeconds — 500 vendors at the defaults is one
# complete pass every 20 minutes, and the site labels the data with how stale it may be.
#
# Lower this (or raise the batch) for faster coverage; both trade directly against per-tick cost,
# and the expensive part is the item walk, which recurses into every container a vendor is selling.
MarketSweepSeconds=60
MarketSweepBatch=25
# Per-vendor listing cap, after which the frame carries "truncated": true. A commodity reseller
# with thousands of stacked resources is a real thing, and an uncapped frame for one is measured
# in megabytes. Clamped to 1..5000.
MarketMaxListings=250
# Shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5). One world.ruleset frame — expansion, which
# systems are on, skill/stat caps, account and house limits, champion scroll rules —
# emitted on every sidecar connect (and on [bridge reload), so the website's rules page
# cannot drift from the server. Not a sweep: it changes only when you edit a .cfg.
#
# The frame is built from an explicit allowlist of keys in BridgeRuleset.cs. Server.cfg,
# Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg, Compiler.cfg, Reports.cfg and Client.cfg
# are never read.
RulesetEnabled=true
# The one connection detail the bridge will publish, e.g. play.myshard.com,2593. Blank
# (the default) omits it entirely. Server.cfg's Address/Listen/Port are NEVER published —
# if you want a connect string on the site, put it here deliberately.
PublicConnectAddress=
# Include the save/restart schedule (AutoSave frequency, AutoRestart hour) in the frame.
# Turn off if you would rather not advertise a predictable restart window.
RulesetIncludeSchedule=true
# Shown to a player when they run [link. The website page where they enter the code.
LinkUrl=https://yoursite/link
# Town-crier news pushed from the website. Caps are defense in depth on top of the
# loopback trust boundary: a buggy or compromised sidecar still cannot flood the criers.
TownCrierMaxLines=6
TownCrierMaxLineLength=200
TownCrierMaxActive=20
TownCrierMaxDurationSec=86400
# Town Cryer news gump. Website articles (news.add) become entries in the modern Town
# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines
# above. The article title is also proclaimed by the criers (announce defaults on). Caps
# are defense in depth on top of the loopback trust boundary.
NewsMaxTitleLength=100
NewsMaxBodyLength=2000
NewsMaxExternal=20
NewsAnnounceDurationSec=300
# Admin write plane (staff moderation from the website). OFF by default: the whole
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
# loopback socket and applies a hard floor below.
AdminWriteEnabled=false
# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
AdminAccessFloor=CoOwner
# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
AdminBroadcastMaxLength=300
AdminReasonMaxLength=400
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
AdminBanMaxDurationSec=31536000
# Account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A). Which side may mint game accounts:
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
# (else an in-game login of any new name still mints an account).
# game — the game server is the authority; website account.create is refused.
# hybrid — either side may create (the default).
# The bridge governs only the account.create verb; the in-game first-login auto-create is
# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
# (the safest — no website creation).
SignupMode=hybrid
# Master switch for the account.create verb. Absent, it follows the mode (on unless
# SignupMode=game). Set explicitly to force it on or off regardless of mode.
AccountCreateEnabled=true
# Fail closed if account.create omits a usable browser IP. The per-IP cap
# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
# than waved through. Turn off only for a deployment that deliberately does not cap website
# signups by IP (MaxAccountsPerIP still applies in-game either way).
RequireIpForCreate=true
# Length caps on a website-supplied username / password, checked before the account is made.
AccountNameMaxLength=16
AccountPasswordMaxLength=30
# ── The event plane (docs/link/v6.md 8) ──────────────────────────────────────
#
# Leases and the participation ledger: the website holding a live config value for a bounded
# time, and this shard counting who took part in a run. Both are driven on a SCHEDULE, by an
# event the website starts unattended.
#
# This is deliberately NOT AdminWriteEnabled. Turning the admin plane on is consenting to
# staff moderation driven from a screen a human is looking at; turning this on is consenting
# to the website changing and watching your world at four in the morning. One switch could
# not honestly express both.
#
# A lease always carries its own deadline and this shard restores the baseline when it
# passes, whether or not the website is ever heard from again -- and a lease is never written
# to disk, so a restart puts every leased value back too.
EventsEnabled=false
# The longest this shard will hold a lease, whatever the website asks for. Thirty days.
# A longer request is REFUSED rather than shortened: a silently-clamped lease would leave the
# two halves disagreeing about when the world comes back.
LeaseMaxDurationSec=2592000
# How long a finished lease stays listed after its deadline restored it, so a teardown that
# arrives late still gets a definite verdict instead of finding nothing.
LeaseGraceSec=86400
# How often the participation sweep credits everyone standing in a run's area, and what one
# kill inside it is worth against one minute of being there.
ParticipationSweepSeconds=30
ParticipationKillWeight=5.0
# Bounds. Runs counted at once, members per run, and the widest area an event may declare.
ParticipationMaxRuns=8
ParticipationMaxMembers=2000
ParticipationMaxRadius=300
# How long a closed run's tally stays readable before this shard forgets it, and how many
# members one snapshot resolves before yielding the Core thread.
ParticipationGraceSec=86400
ParticipationSnapshotChunk=100
# ---- The world verbs (protocol 7) ----------------------------------------------------
# What an event may PLACE in the world, all of it owned by the run that placed it and
# deleted when the run tears down. Every ceiling here REFUSES rather than clamps: this
# shard's bound exists for the case where the website is wrong, and a quiet clamp would
# leave the two halves disagreeing about what was actually placed.
#
# The defaults are the EM Program's published quotas, because they are the only numbers
# anyone has defended in public.
# Per CALL: creatures, enhanced "boss" variants, oracle NPCs and decoration items.
EventsMaxCreatures=30
EventsMaxBosses=4
EventsMaxNpcs=5
EventsMaxDecor=60
# The longest a temporary gate may stand. The shard closes it on its own when the time
# passes, whether or not the website is ever heard from again.
EventsMaxGateMinutes=240
# Per RUN, across every verb above. The per-call ceilings bound one request; this bounds
# a run that calls a verb in a loop, which is the shape a runaway schedule takes.
EventsMaxOwnedPerRun=200
# How far from the chosen spot things may be scattered.
EventsMaxSpread=40
# How much harder than normal a "boss" may be made. EVENTS.md calls it an enhanced
# regular mob, so this is low enough that the result is still the creature that was
# picked.
EventsMaxBossMultiplier=10.0
# The oracle NPC: how many keyword lines it answers to, how close a player must be to be
# greeted and to be heard, and how often it will speak to the same player.
EventsOracleMaxLines=5
EventsOracleGreetRange=4
EventsOracleSpeechRange=8
EventsOracleGreetCooldownSec=60
EventsOracleAnswerCooldownSec=5
# How often expired gates are collected and rows for objects the world has already lost
# are pruned.
EventsSweepSeconds=30
# Item grants (Phase 12b). The first bounds how many characters one grant may reach --
# the run's participation ledger is the recipient list, so this is a bound on the size of
# an event rather than on a number somebody typed. The second bounds one hand.
# Both REFUSE rather than clamp: the website records what was handed out.
EventsMaxGrantPerRun=200
EventsMaxGrantStack=1000
# The shortest gap between world saves, counted from the last save by anybody --
# ServUO's own autosave included. A save stops the world, so this is a rate limit rather
# than a cap, and a save asked for too soon is refused rather than queued: a queued save
# would land at a moment nobody chose. Set to 0 to allow a save at any time.
EventsMinSaveIntervalSec=300
# The asset plane (docs/link/v8.md, protocol 8). Its own switch, deliberately: turning
# this on is consenting to the website reading this host's UO CLIENT FILES -- art,
# animations, the string table -- over the link. Nothing on this plane writes anything.
AssetsEnabled=true
# The largest reply the asset plane will build, in encoded bytes. Not an item count:
# the ceiling it lives inside is the sidecar's 1 MiB inbound line cap, and base64 adds
# 33% to every payload. Clamped to [64 KiB, 512 KiB] -- half the wire cap, so that a
# single oversized item (always admitted, or its family could never make progress)
# still fits.
AssetBatchBytes=524288
# How many ServUO class names one `assets.bodies` request may carry (phase 3). The only
# bound on this plane counted in items rather than bytes, because what it bounds is not
# reply size -- it is constructing and deleting that many real mobiles ON THE CORE
# THREAD, between two ticks of the world. A larger request is refused, never truncated.
# Clamped to [1, 500].
AssetBodyBatch=100
# How many keys one `assets.fetch` request may name. The byte budget above still decides
# where a page is cut; this only bounds how large a request the shard will parse at all.
# Clamped to [1, 10000].
AssetFetchKeys=2000
# The wall-clock budget for one catalogue page, in milliseconds. The catalogue's manifest
# rows are ~90 bytes so the byte budget never stops it -- but building them means
# decoding hundreds of animations, and the sidecar waits 10 s for a reply. Kept well
# under that, because the page still has to be serialised and written afterwards.
# Clamped to [250, 5000].
AssetScanMs=3000
# Which direction the catalogue renders. NOT part of the asset key: five directions
# would five-fold every count in the working set to express a choice nobody varies.
#
# The split was found by RENDERING all five, not from a table. 0 is head-on, facing the
# viewer -- what a character portrait wants, and the least legible view there is of a
# four-legged creature (a wolf seen from the front is a dark blob). 1 is the front
# three-quarter, where the same wolf is unmistakably a wolf.
#
# Which bodies count as player bodies is asked of the shard (every registered race's
# male/female/ghost ids), never hardcoded. Clamped to [0, 4]: 5-7 are the client
# mirroring 1-3 through a decode branch this overlay has not verified.
AssetPlayerDirection=0
AssetCreatureDirection=1
# The tree plane (docs/link/v8.md §10, phase 7). A THIRD switch, for a third consent:
# the asset switch above is about this host's UO client, which came from EA. This one is
# about the shard's own configuration -- Spawns/*.xml, Data/Regions.xml,
# Data/Locations/*.xml, Config/ChampionSpawns.xml and Data/Decoration/**.cfg -- which is
# the operator's own work and is what the website's spawn atlas is built from. Before
# protocol 8 the website read those files off a shared filesystem; that was the one place
# the platform's own rule (only the sidecar bridges the shard) was broken, and broken by
# the component that faces the internet. Turning this off closes the bridge route and
# leaves that shared-filesystem path as the only way an atlas can be built.
#
# Reads only, and only those five groups. Nothing here joins a path the website sent: a
# request names a label this shard itself enumerated, or it is refused.
TreeEnabled=true
# How much of a tree file one chunk carries, BEFORE compression. Chunking is not an
# optimisation here, it is what makes a spawn file transferable: a stock trammel.xml is
# 4.03 MB, the sidecar discards any inbound line over 1 MiB, and the whole file as one
# base64 row would time out and be re-requested forever with no error anywhere. Each
# chunk is gzipped (a spawn file compresses ~18x, so a chunk is typically 40 KB on the
# wire), but the BOUND comes from the chunk rather than the compression, because nothing
# guarantees input compresses at all. Clamped to [64 KiB, 512 KiB]: at the ceiling a
# worst-case incompressible chunk is ~683 KiB of base64, which still fits the wire.
TreeChunkBytes=524288
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed
# server never runs the scaffolding even if its .cs files are present.