Two request families, and they run on opposite threads on purpose.
`assets.bodies` (BridgeBodies) answers the question only code inside ServUO
can: the atlas knows a creature by the class name in Spawns/*.xml, the client
knows it by a body id, and nothing in the tree declares the mapping. Construct
the type, read Body.BodyID, Delete(). That is world mutation, so it answers on
the CORE thread and is the one family here that does not take the asset
worker's slot -- and the batch is capped at 100 names, REFUSED rather than
truncated, because a truncated answer is indistinguishable from a complete one
from the website's side.
`assets.manifest` / `assets.fetch` (BridgeCatalog) are the catalogue, on the
worker. The manifest carries { key, sha256, bytes, width, height } and no
pixels, so an Update fetches only what moved; the fetch carries base64 PNG.
The scan keeps the bytes it hashed rather than decoding all 787 sprites twice.
Three things worth stating about the shapes:
- It pages on the WALL CLOCK as well as on bytes. The rows are ~90 bytes and
the whole catalogue is one page by the byte budget, but building it means
decoding hundreds of sprites against a 10 s reply timeout.
- `catalog` is derived from the client files (sizes, mtimes, both direction
settings, EXTRACTOR_VERSION), not minted per build -- the cache is released
when idle, and a fresh id per build would force a restart mid-import although
nothing about the client moved.
- ARGB1555 is expanded to 32bpp here rather than handed to GDI+, because what
it does with a one-bit alpha channel varies by platform and a black rectangle
behind every sprite would pass any test that only checked the bytes decoded.
Nothing trusts the library's success. Every body goes through CheckEntry and
AnimationRecordSane before it is decoded, which is what keeps the 357 bodies
whose index entry reads `length 0` -- and which the decoder hands back the
PREVIOUS creature's bitmap for -- out of the catalogue.
Walked on a live shard: 787 rows in one 734 ms page; bodies 320, 607, 666 all
absent rather than wrong; 783 at direction 1 and 4 at direction 0; all 455 stock
creature classes resolved at ~190 ms per 100 with zero mobiles leaked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
347 lines
18 KiB
INI
347 lines
18 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 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.
|