Commit Graph

599 Commits

Author SHA1 Message Date
98bed8ff3d docs(modules): re-install Oxide after an update is routine, not a finding
Org lead correction: every Rust host already updates the server and
re-installs Oxide together. Presenting that as something an operator
would be caught by talks down to the audience.

Keeps the one narrow consequence that is actually ours: app_update
leaves Oxide.Core.dll and the rest in place, so a phase-18 doctor check
that tests for oxide/ or for Oxide's assemblies passes on a server that
is mid-routine. Compare Assembly-CSharp.dll against the Oxide build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 16:04:23 -05:00
947e1c1c67 docs(modules): phase 0 as built — the rig is current, and four plan claims were wrong
Brings D:\rust to build 25230300 with Oxide 2.0.7716 and the four base
plugins loaded, and records what the rig disproved.

- §4: start.bat never updated anything. +force_install_dir must precede
  +login; the 2026-09-15 fix corrected the path and left the order, so
  every update run in the rig's history errored without updating. Also:
  app_update reverts Oxide's patched Assembly-CSharp.dll while leaving
  Oxide's own assemblies, producing a server that looks Oxided and loads
  no plugins. Phase 18's doctor should check for this.
- R2: permission.GrantUserPermission silently no-ops for an unregistered
  permission — void, no throw, no log. Since R2 recovers by re-pushing
  the full set on connect, a re-push into a server missing one plugin is
  a silent partial. Plus: grants and groups have different reach for a
  player who has never connected.
- R5: the Clans plugin raises nine hooks, not three, and three carry full
  member lists. The "exactly three, none a membership transition" claim
  was a grep artefact — the hook name is a const at the call site. The
  decision stands on a different reason; phase 17 gains event-driven
  leadership as a sharpening rather than a replacement.
- R17: EraseTemporaryZone's owner scoping refuses only a *different*
  owner. An unowned zone — every zone an operator made by hand — is
  erased by anyone and returns true. Phase 12 must gate on its own id map.
- §12.5: the zone-occupancy criterion stays open. A console command has no
  BasePlayer, so every plugin's permission check short-circuits to allowed
  and no console session can observe a gate closing; NPCs are excluded
  from zone player lists by construction. This is a standing property of
  the rig, relevant at phases 7 and 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 15:07:59 -05:00
cbf322b4d6 Merge pull request 'docs(modules): second contract pass over MODULE_API.md — restores a dropped reconcile, reprices R9' (#250) from docs/rust-plan-contract-pass2 into main
Reviewed-on: #250
2026-09-15 17:49:23 +00:00
dbc0455490 docs(modules): a second contract pass over MODULE_API.md, member by member
Section 7 audited the plan against the Integration Kit and the template. This
pass reads MODULE_API.md itself and enumerates every member rather than grepping
for registration names. It found one regression, one mispriced decision, one
missing declaration, and a set of ctx members the plan had never mentioned.

THE REGRESSION. ctx.events.reconcile() and an action's reconcile() appear
nowhere in the document. The twelve-phase first draft had them - phase 6,
reconcile and the boot-id watch - and the rewrite to twenty phases lost them.
That is a regression in the plan, not a decision. It matters MORE for Rust than
for the game the contract was written against: the kit rates reconcile the one
omission that is merely a lower standard rather than a broken promise, but that
assumes a world which persists. Rust wipes monthly and a wipe invalidates every
ledgered resource for that server at once, and core cannot tell a wedged sidecar
from a game that rebooted and lost everything - it sees ok:false retry:true
either way. It asks once at its own boot and otherwise waits to be told.
ctx.events.reconcile() is being told, and the trigger is a watch on the game's
boot id changing, which is also the only way to tell a game restart from a
sidecar reconnect. Restored to phase 13.

R9 WAS MISPRICED. It says the map's per-layer switches work through the existing
visibility framework, which reads as reuse. It is not reuse: section 6.3 records
shardVisibility as module-owned, and the tree confirms it - the util, both
models, the admin controller and its tests all live under module-uo/server/ and
there is nothing by that name left in website/server. Section 2.7 forbids a
module requiring outside its own directory, so module-rust cannot import a line
of it and builds its own. Not a reason to change the decision; phase 14 now
carries a visibility layer of its own rather than a configuration of somebody
else's.

A MISSING DECLARATION. R13 claims two extension slots and never says where they
are declared. module.json has an extensions array and the dry run's own manifest
carried it. Like mounts it is a statement of surface the loader holds against
reality, so phase 1 adds it.

THE CTX MEMBERS. ctx has 29 members and the plan named a handful. The ones that
change work: secretBox, because each configured server's sidecar token is a
secret at rest and core already has the posture for it; middleware.rateLimit,
which is the mechanism R1 asked for without naming; uploads, which is where R9's
map image actually lands - the plan described fetching it and never said where
it goes; activity.log, which both R2 and R18 owe an audit trail to; the three
ctx.teams calls, since Teams is more than the provider; ctx.posts behind the
announce leg and post hook; and the three send paths section 10's catalogue
implies.

Also records section 6.8, a constraint on phase 10 and on purge: a trigger, a
rule and an audience outlive the module that declared them.
engagement_rules.trigger_id is a plain VARCHAR with no foreign key and no
cascade, so a module can be removed and reinstalled without destroying an
operator's rules - and a rule whose trigger or audience is unregistered shows
DORMANT, never an error and never auto-deleted. An audience resolving to the
empty set is not the same answer as resolved to nobody and must not be rendered
as if it were. The failure that prevents is exact: an id that stops resolving
must never silently become a send to a different set of people.

And the two client lists in full, since section 7 said UI kit discipline without
saying what is in it; plus one confirmation for R10, that
/api/v1/public/modules returns only started modules with no state and no failure
reason, so a Rust module that failed to boot makes the app render a site without
those screens rather than one advertising screens that 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 12:46:36 -05:00
5bc038adf8 Merge pull request 'docs(modules): the module-rust plan — 18 decisions of record and a 21-phase schedule' (#249) from docs/rust-module-plan into main
Reviewed-on: #249
2026-09-15 17:41:15 +00:00
6392f39512 docs(modules): R18 discovery is a recursive walk, and it stops at oxide/config
Configuration is not one flat oxide/config/<Plugin>.json per plugin. Plugins
nest - oxide/config/<Mod>/whatever.json and deeper - and one plugin may own
several files. So discovery is a recursive walk and the UI groups by plugin
rather than assuming one file each. Four things follow, and the first is a
boundary rather than a detail.

oxide/data/ is NOT the settings surface and must not be walked into.
DataFileSystem writes there and that is live state, not configuration. The base
set makes the point by itself: Kits keeps Kits/kits_data.json and
Kits/player_data.json, ZoneManager keeps ZoneManager/zone_data.json, and Clans
keeps clan_data.json with a legacy clans_data.json beside it - which is also a
reminder that these names are not stable. Editing those from a web form edits
players' kit cooldowns and the live zone definitions, a running plugin
overwrites the change on its next save, and oxide.reload does not make most
plugins safely re-read them. Different problem, different answer, deliberately
out of scope.

The reload target cannot be inferred from the path. oxide/config/Foo/bar.json
may belong to plugin Foo or to something else; the folder name is convention,
not contract. So the target is an explicit field with the folder name as its
default guess. Infer it silently and the failure is the nastiest kind available
here: we reload the wrong plugin, observe OnPluginLoaded for IT, and report
success while the plugin that was actually edited never re-read anything.

A relative path from a web form is a path-traversal surface. Canonicalise the
resolved path, assert it is under the config root, reject absolute paths, reject
symlinks resolving outside. Before this amendment the feature addressed files by
plugin name; addressing them by path is exactly the change that introduces the
bug class.

And bound it: depth limit, file-count limit, per-file size cap - a pathological
tree must not be enumerated and a multi-megabyte JSON must not be loaded into a
form. Because one plugin can own several files, the backup and rollback operate
on the whole set a save touches rather than one file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 12:34:38 -05:00
82f2b0d18d docs(modules): R18 edits plugin configuration from the site, with a generated form and an auto-reload
An admin edits any loaded plugin's configuration from the website and it reloads
automatically. Base tier generates a form from the config VALUES themselves -
boolean to toggle, number to numeric field, string to text, array to list,
nested object to group - so it works for whatever plugins happen to be
installed, including ones added after we ship. Advanced tier is raw JSON.

Same posture as R2, the site as authority over the game host, but a different
SHAPE: R2 is continuously reconciled state pushed on connect, this is
request/reply on demand. It must not be built on the permission mirror.

The mechanics, all verified: configs at oxide/config/<Plugin>.json, oxide.reload
rereads one, and OnPluginLoaded / OnPluginUnloaded are real hooks in the Server
category - so whether a reload actually SUCCEEDED is observable rather than
assumed. That is what makes the feature safe.

The trap that would silently corrupt every float: JavaScript cannot tell 1 from
1.0, and Oxide configs deserialize into typed C# classes. JSON.parse of
{"Rate":1.0} yields the number 1 and JSON.stringify writes it back as 1, so a
naive read-modify-write rewrites every whole-numbered float as an integer, on
fields nobody touched. Newtonsoft may coerce it or may throw, and a throw at
load means the plugin does not come back. So never parse the whole document,
mutate and re-serialize - edit textually, or use a parser that preserves number
literals. The fields at risk are exactly the ones a Rust server tunes: gather
rates, multipliers, scales.

Five more limits of inferring a schema from values are recorded, since the
feature's whole promise is that it works without knowing the plugin: empty
arrays and null carry no type; enum-like strings are indistinguishable from free
text; there are no descriptions, minimums or maximums, so the key name is the
entire label; nested objects need recursion with a depth limit and a raw-JSON
fallback; and the file after a reload may not be what we wrote, because Oxide
merges missing defaults and saves.

Safety needs more than usual here, because a bad config does not fail the write,
it fails the next LOAD and the plugin stays down - and R6/R17 make four plugins
required, so a broken ZoneManager config takes event participation with it. The
write path is: read with a version and require it back on write so a concurrent
on-disk edit conflicts rather than being clobbered; validate it parses; back up,
write, reload; then watch for OnPluginLoaded within a window and, if it does not
arrive, restore the backup and reload again AUTOMATICALLY. That rollback is the
feature's real content - without it this is a web form that can take the shard's
plugins down one typo at a time.

Two more obligations. Plugin configs routinely hold API keys and Discord
webhooks, so a config reader hands those to anyone who can open the page: mask
values whose key matches key/token/secret/password/webhook and treat them
write-only, as the platform already treats the uo-link token. And gate it on its
own site permission with an audit trail of who changed which key from what to
what and whether the reload succeeded - it is an admin writing to the game
host's filesystem, the most powerful thing the site can do to a server.

One distinction kept explicit: editing a config FILE is not a lease. A lease
borrows a convar for a while and the game restores it on a deadline; this writes
a file and is permanent until someone changes it back. They look similar from a
web form and an event should never reach for this one.

Lands as phase 7b, beside permissions, since it shares the admin surface and the
gating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 12:29:53 -05:00
1138fd36dd docs(modules): R17 makes ZoneManager a base plugin, and it settles two loose ends
ZoneManager (k1lly0u, 3.1.14, MIT, ~218k downloads) is a fourth REQUIRED plugin
rather than an optional one. It is what makes an event able to answer where a
player is, and reading its source changed two things this plan had been vague
about while promoting one action out of the optional tier.

Its API is private methods reached through Oxide's reflection Call() - no
HookMethod, no API_ prefix. That makes THREE conventions among the four base
plugins: Kits declares HookMethod, BetterChat uses API_ prefixed methods, and
ZoneManager uses plain private methods resolved by name. All are reached the
same way from our side, but only the first is greppable as a declared API, which
is worth knowing before someone goes hunting for one that is not there.

Participation stops being the hard part. EVENTS.md section H rates participation
the hard part for UO and substantially easier for Rust because hooks carry
attacker and victim. ZoneManager makes it exact rather than merely easier:
OnEnterZone and OnExitZone are presence transitions delivered as events, so the
participation ledger is fed from what happened rather than reconstructed from a
sweep. That is where an action's participants envelope member gets its content.

Advance conditions become expressible. A phase that waits until ten players are
at the monument is a real gate that reads zone membership, rather than distance
arithmetic against a point recomputed on a timer.

And rust.zone.open moves from the optional tier into the base catalogue with an
honest reversible: ledger. CreateOrUpdateTemporaryZone takes a Plugin owner and
EraseTemporaryZone is SCOPED to that owner, so ZoneManager already has a
first-class notion of a zone belonging to the plugin that made it. That is most
of the persisted ownership registry chapter 4 demands - we still keep our own
map from core's resource reference to the zone id, but we are borrowing a
concept rather than inventing one. Erasing a zone that is gone is a success,
which is what revert needs.

One trap recorded, chapter 4's rule meeting a chatty hook: OnEnterZone and
OnExitZone fire on the game thread and a large zone on a busy server produces a
great many. The emit path already enqueues and returns so the game cannot stall,
but the bridge should subscribe SELECTIVELY rather than forwarding every
transition in every zone - a zone no event cares about should cost nothing on
the wire. Decide the filter at phase 12 with the hooks in front of you, and
measure it: a sweep over GetPlayerZoneIDsNoAlloc is cheap and a flood of wire
traffic is not.

Also adds rust.options.zones, makes the rust.zone.minutes budget dimension real,
and takes phase 0's install step to four curls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 12:23:13 -05:00
601ee4e05f docs(modules): the event catalogue and the engagement catalogue
Phases 12-13 described the event MECHANISM and never the CATALOGUE - one budget
and one lease as a proof of life, which is a skeleton rather than a product.
Same omission for engagement: R7 settled that the set ships and nothing said
what goes in it. Two new sections fix both.

EVENTS.md section H is a Rust/Oxide compatibility section that already sketched
the event half, and it should have been read before the phase list was written.
Its thesis is the one to design around: the lease is the primitive that travels,
not the spawn. Double gather rate for the weekend is the canonical Rust
community event and it is exactly lease-with-expiry. It also rates Rust the
EASIER case than UO, because Oxide's convars are live by default where ServUO's
are mostly cached at boot - an argument for expecting them to work, never a
substitute for verifying each key live.

Section 9 declares four budget dimensions, five option sources, seven leases and
four actions, taking section H's ids rather than inventing a parallel set. Two
things in it are load-bearing.

Caps are PER RUN, and R8 makes that matter: run.scope is part of a run's unique
key, so one definition fanning out to six servers is six separate budgets rather
than one shared pool. An operator setting a cap of 30 is setting it per server.

And rust.group.membership is the lease section H names that R16 did not - the
pair is the whole reward design. A permanent earned entitlement is an ACTION
with reversible: ledger (R16). A time-limited group is genuinely core.lease,
held with a deadline the game enforces on its own. Same permission mirror,
two shapes, and choosing wrong is the mistake: a weekend VIP implemented as a
grant is a VIP for ever if the website goes away.

Section 10 is the engagement catalogue - eleven triggers with their ceilings,
three audiences, and the seed grouping. The ceiling lattice is containment and
not size, so every ceiling is chosen against that rather than against a ladder.

rust.base.destroyed is the one that matters most and is most likely to be got
wrong. The offline raid alert is the single most-wanted notification in Rust and
its ceiling is OWNER - the player whose base it was. Ceilinged staff it is
useless to the person who needs it; ceilinged everyone it broadcasts base
locations to the server. Exactly the case the lattice exists for.

Three hooks carry data that must never widen: CanUserLogin and OnUserApproved
carry IP addresses, OnPlayerReported carries player reports. README.md section 5
already flags these as admin-channel-only on the live feed and the same
judgement binds their triggers.

One design note flagged rather than decided: PopupNotifications gives the module
an IN-GAME alert surface, which is not one of core's channels. An in-game popup
is the module publishing to its own surface off its own trigger, not a fourth
channel core learns about. A raid alert that reaches a phone and pops on screen
next login is two mechanisms and only one of them is core's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 12:17:39 -05:00
43bf73bae5 docs(modules): R15 the optional tier with BetterChat, R16 rewards grant the right to redeem
R16 changes a design rather than adding to it. The reward action no longer calls
GiveKit; it grants the permission that GATES a kit, and the player redeems it
themselves in game. Kits already has exactly this model built in - every kit
carries a RequiredPermission, GiveKit's own path checks it, the in-game menu
renders an ungated kit as locked rather than hiding it, and GetKitInfo returns
the permission so the module can read which kits are gated.

What that removes is most of the hard part.

The offline-grant problem disappears. GiveKit needed a connected BasePlayer, so
an event firing at 2am rewarded only whoever was online; an entitlement waits.
That CLOSES the open question section 3 carried - no pending-grant queue, no
second at-most-once store.

It is the same machinery as R2 rather than a second mechanism: a reward becomes
a permission grant authored by the site and mirrored into Oxide, which phase 7
already builds. One permission authority, one drift story, one audit trail.

reversible: 'ledger' becomes honest where a direct grant could only ever be
'none'. revert revokes the permission, removing one that is not there is a
success, and it is idempotent by construction. A player who redeemed before the
revert keeps the items, and that is correct: the ledgered resource is the GRANT,
so reverting withdraws the entitlement rather than the consumption.

cost() counts grants and is exactly knowable before dispatch, which removes the
whole declare-the-maximum-because-you-cannot-know class of problem from chapter
5 section 4. And the idempotency key largely stops mattering: chapter 5 draws
the line itself - a key is for a write whose repetition would be a second
EFFECT, and a permission grant is a SET.

The earlier section arguing a kit grant can only be reversible: none is
rewritten rather than deleted, with the correction stated: it was right about a
direct grant, and the reusable part is that the action had been declared around
the wrong noun. What the event makes is not loot, it is an entitlement.

One design note carried into the option source: a kit with an EMPTY
RequiredPermission is open to everybody, so granting a permission for it rewards
nobody. The dropdown must surface which kits are gated and refuse or warn on one
that is not.

R15 opens an optional-integration tier, with BetterChat (LaserHydra, 5.2.15,
MIT, Universal) as the first member, for leaderboard-earned chat titles. Its
integration point is a PULL - API_RegisterThirdPartyTitle registers a callback
BetterChat invokes per player - so a title is a pure function of state we
already hold, with nothing written into it and no drift to reconcile. The trap
is chapter 4's rule applied to somebody else's callback: that getter runs
synchronously on the chat path and must be a cheap in-memory lookup, never a
socket call. Its API_AddGroup and API_SetGroupField pair naturally with R2's
site-authored groups, but that direction is a push and would need R2's drift
posture, so it is a phase 17 decision rather than a given.

Section 3 is now empty. Clans-in-the-base-set was confirmed complementary, and
the offline-grant question was dissolved rather than answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 12:11:11 -05:00
5dcb8088ad docs(modules): R7-R14 close the audit, and the phase list roughly doubles
Every question the contract audit raised was answered the same day. Eight new
decisions of record, and the schedule goes from twelve phases to twenty.

R7 the notifications and engagement set ships in v1 - streams, triggers,
audiences, seeds, announce leg, post hook - as ONE phase because they are a
matched set in the template rather than three independent gaps. The three rules
it lives on: ceiling is required and is not a ladder (a staff ceiling does not
permit owner, because one person for a cheat-detection event is the player it
was detected on); subjectKey must name a declared variable or every subject
shares undefined; and a rule group is offered once, so a rule appended to an
existing group reaches fresh installs only.

R8 multi-server from the start - the dry run's finding 2 taken at face value.
R12 per-wipe detail plus all-time rollups, with the truncation as a runtime
route and never a schema statement, since the fragment replays every boot.
R14 /rust on all three tiers, chosen deliberately because prefixes share one
namespace with core's and the collision probe cannot see core's root-mounted
endpoints. R13 two extension slots. R11 a small read-only slash command set,
with ephemerality fixed at the deferral so a refusal must defer ephemeral.

R9 is the one that split in two. The map IMAGE is static content on the game
host regenerated only on a wipe - the ch.3 2b case, so request/reply, one in
flight, two stages, its own derivation version, and no import on boot - while
everything moving on it is live state down the ordinary read path. Every layer
is an operator switch, and that is a security boundary rather than a preference:
public player positions in Rust locate players and let anyone infer base
positions. Default is monuments and world events public, players and bases
admin-only. This is the ONLY asset-bridge work in scope; item icons and the
2,590 skin ids stay out of v1.

R10 the Android app is in this workstream, deciding its screens from
/api/v1/public/modules capabilities, with each leg trailing the website surface
it consumes by one phase. Records the two endpoints that are not this and are
easy to confuse with it: /api/v1/public/status is site mode plus a version
block, and /api/health is an internal liveness probe.

Section 7 now carries the phase each previously-unplanned element lands in, and
the honest cost: twelve phases, from reading the chapters that describe the game
bridge and treating the module as the thin part when the kit says in its first
paragraph that the module is most of the work.
2026-09-15 12:01:44 -05:00
b467156a1f Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#246) from chore/sync-website-tree into main
Reviewed-on: #246
2026-09-15 17:00:49 +00:00
a4544ba5f2 docs(modules): audit the plan against the whole contract, not just the game-facing chapters
The first draft of the phase list was built from chapters 3-5 and under-planned
the website module by a wide margin. The template registers eight of the ten
non-event registrations; the plan covered three.

Unplanned and now recorded: registerExtension, registerNotificationStreams,
registerEventTriggers, registerAudiences, registerEngagementSeeds,
registerAnnounceLeg, registerPostHook, registerSlashCommands - with triggers,
audiences and seeds being a matched set rather than three independent gaps.
Also unplanned: purge.sql, mount prefix choice, the swagger fragment and its
staleness check, the vite alias/shim mechanism the kit calls the highest-risk
mechanical detail in the system, checkImports, and the entire packaging and
release path - there was no packaging phase at all.

On the client: declareModuleSlot, which the kit says is needed the moment a game
has anything like a guild, so it belongs in the Teams phase; registerFeatureProvider;
and the UI kit discipline that has caught this project twice already (PublicLayout
needs shell, PageHeader silently drops unknown props).

Beyond the module: the asset bridge, which Rust's item icons and 2,590 skin ids
fit exactly; the .profile landing page, owed whenever repos are added and three
just were; runicgateway.com, for which a second game is a headline change; and
the Android app, which feature-detects capabilities and must render a site whose
module it has never heard of.

Section 8 carries the eight questions the plan cannot answer for itself.
2026-09-15 11:45:04 -05:00
839e5d05a8 docs(modules): R6 names the base mod set, and reading Clans settles R5 the other way
The three repos exist and are named: Module-Rust, Rust-Link, Rust-Plugins. Notes
that a repository name is not a module id - Module-uo ships id `uo`, this ships
`rust`, and 2.1 requires id to equal the directory core loads it from.

R6 - the required base set is Kits 4.4.9, Clans 0.2.10 and PopupNotifications
0.2.1, all k1lly0u, all MIT, each with a direct .cs download, so phase 0's
install step is three curls rather than manual retrieval. Clans is listed as a
Universal plugin, written against Covalence, which is why its API takes IPlayer
rather than BasePlayer.

Kits is a good dependency: 23 HookMethods, including GiveKit for the reward
action, GetKitNames/GetAllKits for the authoring form's OPTION SOURCE so an
operator picks a kit from the live server instead of typing an identifier, and
per-player uses and cooldown for eligibility. It also raises OnKitRedeemed,
which the bridge can report as an ordinary event whoever triggered it.

Two traps in GiveKit, both found by reading it rather than reasoning about it.
It returns null on a failure path - `if (!player) return null` - and null is
Oxide's idiom for "no opinion", so the reflex of treating null as fine reports a
reward as delivered when there was no player to deliver it to. Success is the
literal true and a refusal is a message string, which drops straight into
chapter 5's `error`. And GiveKit takes a BasePlayer, so there is no offline
grant: an event rewarding participants at 2am rewards only whoever is online.
Phase 8 chooses between accepting online-only and keeping a persisted
pending-grant queue, which is a second at-most-once store and is not free.

R5 is settled far more firmly, and the other way round from how it was argued.
For Teams the Clans plugin is WORSE than first-party, not richer: it publishes
fifteen HookMethods and every one is a mutation, with no read API at all - no
GetClan, no GetClanMembers, no GetAllClans - and it raises exactly three hooks,
none of them a membership transition. It cannot answer any of core's three
provider questions from its published surface, while first-party clans answer
all three. So first-party is PERMANENT for the provider, not a first step. What
the plugin actually adds is alliances and clan/alliance chat - richer in
features, not in roster data - which is what phase 9 surfaces beside the
provider rather than under it. Reading its own data files is recorded as
deliberately not taken: that is another plugin's private storage, not an API.

Two questions left open in 3, neither blocking: whether Clans being in the base
set while the provider reads first-party is the intended reading (an
interpretation, not something stated), and the offline-grant choice, deferred to
phase 8 on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 11:40:10 -05:00
7d589ff5f2 docs(modules): R4 the installer grows a game dimension, R5 Teams come from first-party clans
R4 - Rust reaches an operator through the shipped installer behind
--game servuo|rust, not a second binary and not a shared-core refactor. The
shape of the work is set by where the coupling already is: service.rs and
main.rs mention ServUO zero times, while install.rs, doctor.rs, overlay.rs and
tier.rs carry nearly all of it, plus the bundle manifest where OverlayComponent
and ServUoCompat name the game in the schema itself. So the game payload becomes
a variant and ServUoCompat a per-game compat block - a schema change on the
published bundles branch, which is the part to design before touching code. The
Rust payload is much simpler than the UO one (no source tree, no patches, no
patch tier - one .cs into oxide/plugins) and what it gains instead is an Oxide
prerequisite check, which is doctor's shape rather than a new concept.

R5 - Teams come from Rust's FIRST-PARTY clans; the uMod Clans plugin is the
richer optional tier in phase 9. Three distinctions that are easy to collapse
and expensive to get wrong: the seven first-party clan hooks are all "no return
behavior", which is exactly what a read-only bridge wants; Rust's first-party
TEAMS are a different system entirely (twelve mostly-vetoable hooks, the
transient squad rather than the persistent organisation) and are not what core's
Team provider should be fed; and the uMod Clans plugin's API is not in our
mirror at all, since HOOKS.md is the game's 477 hooks and a plugin's API is its
own documentation.

The gap R5 has to design around: the seven hooks carry created, disbanded,
added, kicked and left, but no promote or leader-changed event. So
getTeamLeaders reads leadership off LocalClan at snapshot time, which makes
phase 6 partly snapshot-driven where the dry run predicted fully event-driven.
Recorded as a correction to that document rather than silently.

Also records the start.bat fix on the rig: it now updates D:\rust rather than
C:\rustserver, the original is kept at start.bat.bak, and the appmanifest
already present at the same buildid means the first corrected run is a delta
rather than a 5.9 GB re-download.

Phases are now 0-11; the installer is phase 10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 11:32:43 -05:00
a5881d5a55 docs(modules): the module-rust plan, its decisions of record and its rig
The dry run designed this module on paper and deliberately did not build it.
This is the document that builds it: eleven phases, three new repos, and the
three decisions the org lead settled on 2026-09-15.

R1 identity is an in-game link code for v1. There is still no
registerAuthProvider at MODULE_API_VERSION 1.10.0, so "Sign in with Steam" is
not reachable from a module. What changed since the dry run is the stakes, not
the options: the dry run rated this survivable because the module only read,
and R2/R3 make the site the author of who may do what and the thing that hands
out loot. A weak link is now a privilege-escalation path.

R2 site-authored permissions mirror into Oxide's own permission store, so every
third-party plugin honours them with no adapter and a wipe stops being a
data-loss event for permissions. This is a direction the Integration Kit has no
chapter for - not the read path, not a ledgered one-shot, but continuously
reconciled state where the website is authoritative. Its nearest relative is the
Team provider inverted. Whether that deserves a sixth chapter is phase 10's
question.

R3 the Kits reward action always registers and refuses with a reason in `error`,
rather than vanishing from the form or refusing to boot. Phase 8 must declare
reversible: 'none' honestly - there is no way to un-grant loot a player has
spent - and count cost() per kit actually granted.

The rig is D:\rust, which has been booted and carries a matched Oxide 2.0.7585.
Two traps recorded: its start.bat updates C:\rustserver and launches D:\rust, so
the server that boots has never been updated by its own script; and
C:\oxide_files is a 2025-04-23 Oxide whose bundled Assembly-CSharp.dll would
downgrade a real install.

One question left open: which plugins besides Kits are in the required base set.
oxide/plugins/ is empty, so all of it is a fresh install either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 11:12:20 -05:00
c487b8fd8c Merge pull request 'docs(modules): mirror the uMod/Oxide ecosystem for module-rust' (#248) from docs/rust-oxide-api into main
Reviewed-on: #248
2026-09-15 15:20:49 +00:00
4e2c2dc439 docs(modules): mirror the rest of the Oxide ecosystem, and add a machine-readable set
Completes the uMod mirror beyond the Rust hook table, and adds agent/ — the
same facts in TSV/JSONL at ~46% of the tokens.

New prose:
- OXIDE_API.md: the 19 developer pages under umod.org/documentation/api/ —
  plugin structure, hooks, commands, IPlayer, permissions, config, data files,
  database, localization, timers, web requests, dependencies, integration,
  preprocessor directives, security, style guide, CI, review. This is the
  framework our plugin is a guest in, where HOOKS.md is what the game says.
- OPERATING.md: the 6 operator pages — installing Oxide on a server, then
  installing, configuring and permissioning plugins.

New machine-readable set (agent/):
- hooks.tsv    477 rows, ~31% of HOOKS.md
- items.tsv    678 rows, ~80% of DEFINITIONS.md's item table
- skins.tsv    104 rows covering 2,590 skins, ~77%
- api.jsonl    150 code examples, ~47% of the two prose docs
Generated in the same pass as the markdown, so the two cannot drift.

HOOKS.md gains the universal-hook split: 34 of the 477 are uMod's own
Covalence hooks, raised identically on every game uMod supports. Verified
against /documentation/games/universal in the same capture - all 34 are in
the Rust set and the Rust page adds none of its own, so the overlap is exact.
The distinction is architectural: a universal hook is the portable part of
the surface.

Two things worth recording from building it:
- The first skins.tsv was one row per skin and came out 11% LARGER than the
  markdown it replaces. Grouping it one row per item is what made it a
  saving. The token win is real for prose (3.2x on hooks) and small for
  tables that were already dense - agent/README.md says so plainly rather
  than claiming a flat number.
- Signature extraction by brace depth silently captured body lines (a nested
  '}' in OnUserConnected's example ended the block early). It now matches on
  the hook's own name; all 477 rows verified to carry a real signature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 10:01:11 -05:00
8d31e497d2 docs(modules): add the Rust Oxide/uMod API reference for module-rust
Mirrors uMod's Rust game API into docs/modules/rust/ so the upcoming
module-rust can be designed against it without a round trip upstream.

- HOOKS.md: all 477 hooks in 20 categories, scraped verbatim from
  umod.org/documentation/games/rust. Each entry keeps its description,
  its return contract, its tags and every C# overload uMod publishes.
  Opens with a category table and a full alphabetical index
  (name -> category -> return contract).
- DEFINITIONS.md: 678 items (short name, item id, display name) and
  2,590 workshop skin ids across 104 items.
- README.md: why the reference exists, how an Oxide hook actually binds
  (by name and arity, by reflection - a typo is silently never called),
  the four return contracts, and how the bridge invariants restate
  against Oxide. Records provenance and the exact refresh procedure:
  the hooks JSON endpoint is not Cloudflare-gated even though the HTML
  page is, and the definitions page has to come out of a browser.

The mirror is reference only - uMod stays upstream and normative, and
nothing here is a Runic Gateway contract. Rust wipes monthly, so both
files carry their capture date (2026-09-15) and a re-capture note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 09:38:43 -05:00
e1f26de369 Merge pull request 'docs(link): the site leg, and the nine keys no phase row could catch (Phase 9c, the last)' (#247) from docs/asset-bridge-p9c into main
Reviewed-on: #247
2026-09-15 12:10:34 +00:00
bc5b43e1a4 docs(link): the site leg, and the nine keys no phase row could catch (Phase 9c, the last)
Phase 9c is done, and with it the Asset Bridge. §16's row 9c records what the leg
actually was, and §17.17 what it found.

The site is the only repo whose checks read the platform over the API instead of
describing it, so its starting state is a measurement rather than an opinion:

- `checkFacts` red in **ten** places — the protocol in all three independent reads,
  the bundle triple, and the three releases the cutover cut. `moduleApi` and the
  capability list did not move, which is §17.16's finding restated by a different
  mechanism.
- `checkReference` red in **nine nobody planned for** — every `Bridge.cfg` key phases
  5, 6 and 7 added, none of them ever mentioned on the public reference page. No phase
  row in this plan could have caught that: the rows name the repos a change edits, and
  this is a repo that only describes them.

Two corrections only prose could make: §17.15's "five repositories" had become a page
on the site that named `website` among them (protocol 8 touched four, and core's
absence is the interesting half), and the site said the bridge has two ways in when the
asset plane is a third — a paged bulk read on the reply path, which must not ride the
event stream for a structural reason rather than a throughput one.

The defect the phase found is a version pinned in a rig: `seedDemo.mjs` pinned protocol
**4** under a comment explaining a debt since paid. It reads `platform.json` now.

Screenshots retaken against the real shard (D42 holds): 1,095 portraits in 2.1 s,
67,496 names in 1.4 s, 313 item pictures in 0.8 s.

Site: RunicGateway/runicgateway.com#31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 05:47:37 -05:00
runic-docs-bot
608f9824cd docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@efa9db7 [skip ci] 2026-09-15 10:20:56 +00:00
22a40ab0b0 Merge pull request 'docs(link): the cutover, and the counter two branches bumped at once (Phase 9b, 5 of 5)' (#245) from docs/asset-bridge-p9b into main
Reviewed-on: #245
2026-09-15 10:05:48 +00:00
e53ee15f15 docs(link): the cutover published, and the bundle that did not recompose (Phase 9b)
Adds §17.16 — what the five-repo cutover published, and the one step that
needed a hand.

sidecar v2.3.0, overlay v1.3.0, module-uo v1.3.0, installer v0.2.0, bundle
2026.09.15, all at protocol 8. `edge` stays standing everywhere.

## The bundle did not recompose on its own

Two compose runs failed for two unrelated reasons and only their logs separate
them. Run 111 was **Gate 1 working**, refusing `sidecar v2.2.0 speaks 7,
overlay v1.3.0 declares 8` in the nine-minute window between the two releases --
the concrete case for landing the pair together. Run 114 was the **runner**:
`Set up job` 14m33s, every later step failing at 0s, no log blob stored.

A failed compose is silent from outside. The bundle keeps its previous value,
so operators keep installing the previous protocol while `main` carries the new
one, and nothing says so. Dispatching bundle.yml recovered it within a minute.

The lesson recorded for the next cutover: publishing the releases is not the
last step, verifying the bundle moved is -- and read `current.json` through
`/contents/`, never `/raw/`, which is CDN-cached for six hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 19:14:00 -05:00
04ae207fe0 docs(link): the cutover, and the counter two branches bumped at once (Phase 9b)
Records the Asset Bridge `edge -> main` cutover: §16's 9b row, §17.15's four
org-lead decisions, and the `PARSER_VERSION` correction those forced through
§7, §10.4 and `SPAWN_ATLAS.md`.

## The number had to move, and the reason generalises

§10.4 wrote phase 7's canonical-read-order bump as `PARSER_VERSION` 4 -> 5.
`main` had meanwhile bumped 4 -> 5 for the `UniqueId` fix and RELEASED it as
v1.2.2. Same number, different derivation. An install that imported under
v1.2.2 stores 5, so a phase-7 build also declaring 5 is read as current by
`currentParser` and never re-reads -- the exact failure the constant exists to
prevent, reached through a merge instead of through forgetting to bump.

Two long-lived branches bumping one counter for different reasons is a defect
the counter cannot see; only the merge can. The docs now carry both meanings --
5 as main's released one, 6 as phase 7's -- rather than collapsing them.

## The cutover is five repos

`installer`'s `edge` carried 9a's `libgdiplus` `doctor` check, and
`SHARD_PREREQS.md` on `main` had been describing that check as existing since
phase 1. §16's 9b row named four repos and would have left an operator-facing
doc naming a check in no released binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 18:00:47 -05:00
157e8034b1 Merge pull request 'docs(link): the guide an integrator reads stopped at protocol 6 (Phase 9a)' (#244) from docs/asset-bridge-p9 into main
Reviewed-on: #244
2026-09-14 22:24:40 +00:00
72a379f84c docs(link): 9a touched servuo-plugins too — the plugin README's file table
Its file table stopped at Phase 6, ten rows for a directory of 38. The nine files
this workstream added are documented there now (servuo-plugins#35).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 13:15:05 -05:00
57ecdc8c29 docs(link): the guide an integrator reads stopped at protocol 6 (Phase 9a)
The Asset Bridge's docs pass, and the acceptance walk that shaped it (v8.md §16
row 9a, §17.13-14). Phase 9 is three legs now: this one, the edge->main cutover,
and the site.

## INTEGRATION.md had stopped at 6 and contradicted itself

Its §2 said "the current version is 6" above examples already carrying
`X-UOLink-Version: 8`, there was no protocol-7 paragraph, and `assets.` appeared
zero times in 1,306 lines. It is the only document an integrator outside this org
has, so it is carried the whole way: the version block corrected, v7 (the event
plane's command half) and v8 (the asset plane) written, a §5 section for the five
routes, 425/422 in the status table, and a caveat that the asset plane is a
working set rather than a stream.

Protocol 7's absence is the Events workstream's debt rather than this one's, but
it cannot be stepped over on the way to 8.

## The operator-facing half

`UPGRADE_NOTES.md` gains the entry an operator reads when this ships: what
changed, the one required action on a Linux host, and the thing that will not
announce itself -- nothing here happens on a restart, so a patched client keeps
serving the old pictures until somebody presses a button.

`installer/INSTALL.md` gains libgdiplus as a prerequisite row and the `doctor`
row that checks it. The index rows for SPAWN_ATLAS and CLILOCS described the
workflows this protocol deleted; v8.md now has an index row of its own, and v7 is
marked as the released protocol.

## The walk

Wiped every asset row and every imported sprite, then walked it as a new
operator: 1,095 portraits in 3.18 s, 67,496 names in 1.42 s, 313 item pictures in
1.38 s, the atlas over the bridge in ~2.0 s, an Update with no drift answered in
0.99 s. Bestiary portraits are the right animals by eye; the marketplace shows
hued item art with cliloc names.

It found two defects (§17.14) and one cutover hazard: module-uo's `edge` is
behind its `main`, missing #35, so the walk measured 0 of 6,455 spawners carrying
a UniqueId. 9b's row says to sync before merging or the cutover ships a
regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 13:06:26 -05:00
1f3c728053 Merge pull request 'docs(link): the panel, and the two deletions it could see and no test could (Phase 8)' (#243) from docs/asset-bridge-p8 into main
Reviewed-on: #243
2026-09-14 16:03:28 +00:00
fc2d4872f6 docs(link): the panel, and the two deletions it could see and no test could (Phase 8)
v8.md section 12.2 records the admin surface as built: one page, Admin -> Client
Files, over the creature catalogue, item and land art, and the cliloc table --
one page because they come off one client install and change on one event. The
four decisions are written down, including the one that kept the phase to a
single repo: section 14's "activity view" is the last import's own summary
rather than a filtered feed, which would have needed a core activity filter, a
`ctx.activity.list` helper and a MODULE_API bump to re-read rows the panel can
keep itself.

The part worth reading twice is what the walk found. Two of the three defects
were DELETIONS, both predate phase 8, and neither was reachable by a test that
did not already suspect them:

  * the body import diffing its manifest against every family's rows, so 313
    item pictures were staged for deletion under a sentence that was wrong about
    what had happened;
  * an approved vanish unlinking the sprite and keeping the row, so the key came
    back for review forever and the count never moved.

The rule they share is now stated: after phase 5, `shard_assets` is three
collections in one table, and every read of it that feeds a decision has to say
which one it means. Two of the three that did not were deletions.

CLILOCS.md and SPAWN_ATLAS.md now name the button that exists (Admin -> Client
Files) instead of the one they promised, and modules/uo/API.md records that a
`needsReview` reply carries each vanished key's picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 08:10:49 -05:00
ae0efe26b3 Merge pull request 'docs(link): what a 4 MB spawn file does to a 1 MiB wire (Phase 7)' (#242) from docs/asset-bridge-p7 into main
Reviewed-on: #242
2026-09-14 07:36:29 +00:00
047ecb6771 docs(link): the empty-catalog fix is plane-wide, and the stock-install walk that proves it
10.3 recorded the empty-`catalog` defect as a tree-family bug. It was inline in
three places -- the body catalogue, statics and land, and the tree -- and all
three now share one comparison. Records why BridgeLeases keeps the other shape,
and names the two defects as one kind of thing: a value meaning "I am not
asserting anything" has two spellings on a wire, and a stock install finds
whichever one you did not think about.

Adds the whole-plane check against a stock ServUO install: every family asked
with a real catalog, an absent one and an empty one, plus the cross-family and
stale-catalog refusals and both of stock ServUO's empty decoration files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 02:33:25 -05:00
dcf7be4975 docs(link): what a 4 MB spawn file does to a 1 MiB wire (Phase 7)
10 said the shard would serve `tree/<label>` -> bytes. Measured 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 in it anywhere --
and two files on a STOCK tree are in that state.

10.1 records the three carriages measured before anything was written, and why
the winner is not the smallest one: whole-file gzip is 1.21 MB against chunked
gzip's 1.26 MB and is bounded by nothing, so it works on every tree anyone would
test and fails on the first one nobody did. The chunk is the guarantee; the
compression is only the saving.

10.2: it is a `tree` family on assets.fetch, not 14's separate tree.* commands
-- phase 5's registry already owns the single slot, the envelope, the ceiling
and the mid-import guard, so reusing it left `link` with nothing to do for the
third phase running. Its CONSENT is its own, though: Bridge.TreeEnabled, because
declining to serve an EA-licensed client is not the same as declining to serve
the spawn files an operator wrote, and the atlas would have been the casualty.

10.3 records the two defects and which harness found which. An empty `catalog`
is not an absent one. And GZipStream writes nothing at all for zero bytes of
input, which stock ServUO's two empty decoration files walk straight into -- an
offline probe called that a success, because .NET's own decompressor reads an
empty stream as empty data and the declared length and hash both agree with it.
Only a live walk through a reader on another runtime disagreed.

10.4: one canonical read order, because the decoration index keeps the first
item id it sees and the two readers agreed by coincidence rather than by
construction. PARSER_VERSION 4 -> 5.

10.5 has the end-to-end numbers against a live shard. 17 gains the phase's three
decisions, including the one that departed from the recommendation: boot never
calls the shard, so an install on the bridge has no automatic refresh at all.

SPAWN_ATLAS.md is rewritten around the two sources and stops requiring a shared
filesystem. Protocol stays 8; EXTRACTOR_VERSION stays 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 02:00:58 -05:00
48caedd3e0 Merge pull request 'docs(link): the phase the measurement cancelled, and the 452 wrong pictures it found on the way (Phase 6)' (#241) from docs/asset-bridge-p6 into main
Reviewed-on: #241
2026-09-14 06:15:29 +00:00
b84a73d0e7 docs(link): the phase the measurement cancelled, and the 452 wrong pictures it found on the way (Phase 6)
§16 planned deep animation keys and a bulk-fill switch. Measuring first changed
the phase: a complete one-direction animation set is 174,453 frames / 281.5 MB
(not the ~119,000 / ~117 MB §11 estimated), every frame carries its own centre
and 23,818 of 26,274 actions have frames of differing size — and the site
displays still pictures. So none of it was built (§11.2, org lead 2026-09-11);
`body/<id>/a<n>/f<k>` stays named in §5 and refused in practice.

§4.10 is what the walk found instead. A body's actions are a contiguous band and
the next body's begin immediately after, so one action past the ceiling is a real
record of another creature: 643 of 795 legacy bodies pass every check there and
452 are byte-identical to body+1's action 0. `Animations.GetAnimLength` is not
the ceiling — it disagrees with the index arithmetic on one body of this client,
by nine actions.

What shipped: the 73 bodies with no art at action 0 (a horse at `body/820/a23`),
the catalogue key carrying its action, the atlas join that reads it instead of
hardcoding `a0`, and the ceiling that makes the fallback walk safe. Catalogue
1,022 -> 1,095; EXTRACTOR_VERSION 2 -> 3; protocol stays 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 01:10:01 -05:00
e1cefa5be2 Merge pull request 'docs(link): the hue belongs where the files are, and the cache poisons it (Phase 5)' (#240) from docs/asset-bridge-p5 into main
Reviewed-on: #240
2026-09-11 11:26:22 +00:00
a4b63d87d2 docs(link): the hue belongs where the files are, and the cache poisons it (Phase 5)
§11.1 is new and carries what phase 5 measured: 49,152 addressable static ids
(not the 81,884 `artidx.mul` declares -- `FileIndex` sizes its table from its
length ARGUMENT), 39,189 with art, 4,244 land tiles, 9,963 + 12,140 empty index
slots, and the whole set at 81 MB decoding in 34 s. That last number reopens the
bulk question and the answer is still no: 108 MB of base64 through a 512 KB
single-slot channel to store 43,433 pictures a shard displays a few hundred of.

Two traps, both §4.5's failure mode -- a confident, plausible, wrong picture:

- `Art.GetStatic` hands back the SAME cached Bitmap and `Hue.ApplyTo` repaints in
  place, so hueing edits the library's own copy: the plain key comes back hued
  from then on, and the next hue stacks. `Files.CacheData` off process-wide fixes
  it and also stops a game server retaining 74 MB of Bitmap. Copying instead does
  not solve the retention, and `new Bitmap(src)` throws on ARGB1555 anyway.

- `PartialHue` (13,259 of 65,536 ids) decides whether a hue repaints every pixel
  or only the grey ones, from a file only the shard has. Item 597 is a wooden
  screen with painted flowers; one mode reddens the flowers, the other the whole
  screen, and both decode. Hence land takes no hue segment and `h0` is not a key.

Plus the namespace trap that compiled: unqualified `TileData` binds to ServUO's
own `Server.TileData`, because the enclosing namespace beats `using Ultima;`.

§14 records what the wire gained -- the `static` and `land` families, `families`
on `assets.sources`, and `assets.fetch` becoming shared plumbing whose family is
DERIVED from the keys (§5 made the key the address; a request naming its family
too would have two places to be wrong and one of them silent). Additive, so the
protocol stays 8 and EXTRACTOR_VERSION stays 2. §15 records that `link` needed
nothing in phases 4 or 5: it forwards verbatim in both directions.

§17.10 is the four org-lead decisions. §12 and modules/uo/SCHEMA.md carry the
website side: `uploads/items/`, per-row `catalog` staleness, and why a key with
no art writes no row at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 06:16:15 -05:00
2590d4cc58 Merge pull request 'docs(link): two of the eight player bodies existed, and 233 nobody asked about (Phase 4)' (#239) from docs/asset-bridge-p4 into main
Reviewed-on: #239
2026-09-11 10:09:39 +00:00
6ef4b06c76 docs(link): two of the eight player bodies existed, and 233 nobody asked about (Phase 4)
Phase 4 built 4.3's UOP animation reader. What it found first changed what the phase
was worth, so the plan is corrected rather than merely annotated.

  - New 4.9: what phase 4 measured. Of the EIGHT player bodies 4.8 assigned this
    phase, two are in the client at all -- gargoyles 666/667, in AnimationFrame3.uop.
    The six ghost bodies are in no package, and that is established rather than
    unfound: the five packages hold 10,724 entries and the
    build/animationlegacyframe/%06d/%02d.bin scheme claims every one, leaving no room
    for another naming. Also the format as read (AMOU, a per-frame ARGB1555 palette,
    direction as a slice of the frame table), the nine bodies whose frame count is not
    a multiple of five, the validate-as-we-go bounds and the measurement that says
    they refuse nothing real, and the live rig.
  - 5.2 rewritten: the player-body set is the LIVING pair per race, six ids not
    twelve. Ghost ids left it because no client has art for any of them. Still asked
    of the shard, never hardcoded -- only the question changed. And with phase 4 in,
    all six have art for the first time.
  - 4.3 rewritten against what was measured, including why searching five UOP packages
    for one body is NOT the never-sweep rule being broken: a legacy index is addressed
    by position, a UOP entry by the hash of a name carrying the body id, which the
    payload then declares again.
  - 11 sizing: the catalogue is 1,022, not 787. The mix is recorded because "add every
    body" sounds like it changes what a catalogue is, and it does not -- the legacy
    787 was already 366 equipment bodies.
  - 14: manifest and fetch rows carry `source` (legacy/uop). Additive, so protocol
    stays 8; EXTRACTOR_VERSION 1 -> 2 is the change consumers actually see.
  - 16 phase 4 marked DONE; 17.9 records the four org-lead decisions (fallback applies
    to every body; ghost ids leave the set; own PNG encoder; NO_IMAGING stays flat).
  - 4.8 and 8.1 keep their numbers as the record of what those phases measured, with a
    pointer to where the answer landed.

Two consumer docs repeated the ghost claim as fact and are corrected:
website/SPAWN_ATLAS.md (787 -> 1,022, and "two thirds of the playable ghost and
gargoyle bodies have no art" -> about half the addressable body range) and
modules/uo/API.md (same sentence).

Code: servuo-plugins#31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 04:59:55 -05:00
afd0772289 Merge pull request 'docs(link): the catalogue is real, and UOFiddler's last job is gone (Phase 3)' (#238) from docs/asset-bridge-p3 into main
Reviewed-on: #238
2026-09-10 23:57:18 +00:00
d07772a3d8 docs(modules): the cliloc conversion step IS avoidable now (phase 2 debt)
SCHEMA.md's cliloc section still described the pre-protocol-8 world, two
phases after phase 2 changed it. Three sentences said the same false thing,
so fixing only the flagged one would have left the section arguing with
itself:

- "Sourced from files the operator supplies" -- the BASE comes from the shard
  on any install with uo-link configured; only the overlays are the
  filesystem's, and that asymmetry has a reason worth stating (ServUO has no
  server-side notion of a custom cliloc, so there is nothing to ask for).
- "A base (the converted client table)" -- not converted any more.
- "The conversion step is not avoidable ... so the shard cannot supply names
  on our behalf" -- it does supply them. Phase 2 ported UOFiddler's Mythic
  decompressor into the overlay precisely so nobody converts anything.

Rewritten to say what is true and why the file path still exists (deprecated,
not removed, so an existing install keeps working), plus the two things a
reader of this table actually needs: `shard_cliloc_meta.payload` keeps the
base's fingerprint under `base` SEPARATELY from the overlay hashes -- because
on the bridge the old `clilocs.plain` label is supposed to disappear and one
flat hash map would read that upgrade as a vanished source -- and boot does
not import on the bridge path at all.

Each claim checked against the code rather than from memory:
shardClilocs.model.js:307 (`base: fingerprint`), :438-440 (refreshOnBoot
returns `skipped` on the bridge), clilocSource.js:333 (`missingOverlays`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 18:50:45 -05:00
1a7481e9f4 docs(link): the catalogue is real, and UOFiddler's last job is gone (Phase 3)
Phase 3 is built and walked on a live shard. What the walk measured, and the
two places the design of record needed correcting:

§8.1, new: the catalogue is 787 exactly as §4.8 predicted, and the whole scan
of bodies 1-2047 takes 734 ms cold -- so the wall-clock paging §11 designed
never fires on this client. Every §4.8/§5.2 prediction held when the bytes were
rendered and LOOKED at: 320, 607, 608 and 666 come back absent rather than as
another creature's picture, and the direction split is 783 at index 1 against 4
at index 0 -- four player bodies, not six.

44 of the 787 hashes are shared by two or three bodies, which is the exact
signature of the wrong-picture bug, so it was chased rather than assumed. It is
the client's own Body.def aliasing (83 {1}, 84 {1}, 106 {12, 59}), and the check
that settles it is at the source: Translate(ref body, ref hue) rewrites `body`
only when bit 31 is set, unlike the one-argument overload -- and ResolveAnimation
calls that same two-argument overload, so validator and decoder resolve the
identical record.

§12.1, new: **§12 is right about the outcome and wrong about the mechanism.**
`shard_spawn_creatures` is emptied and refilled by every atlas refresh, and a
refresh runs on every boot -- so an imported filename written to that row is
destroyed by an ordinary re-parse of the ServUO tree, and the next Update finds
the client files unchanged and never restores it. Three tables outside that
blast radius, and the atlas import re-derives `art` on the way past.

§14: **§16 listed phase 3 as servuo-plugins + module-uo and that was wrong.**
web.rs routes every command explicitly, so `link` is in the phase. Corrected in
both places.

UOFIDDLER.md is DELETED, two phases earlier than §9.1 predicted -- creature art
was the only thing still on it. SPAWN_ATLAS.md §Artwork is rewritten around the
import, keeping the operator's own map as the thing that wins; the module's
SCHEMA.md gains the three tables and API.md the two admin routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 18:41:08 -05:00
4c6b0c566a Merge pull request 'docs(link): clilocs come over the bridge now, and UOFiddler's first job is gone' (#237) from docs/asset-bridge-p2 into main
Reviewed-on: #237
2026-09-10 16:19:35 +00:00
bbd69a8e2e docs(link): clilocs come over the bridge now, and UOFiddler's first job is gone
Phase 2 of the Asset Bridge is built, so the documentation stops telling an
operator to install a GUI tool.

`v8.md` gains §9.1 and §9.2 — what the port cost, what it measured, and where the
base table comes from now. The measurement worth keeping: **67,496 rows in
290 ms**, which is exactly what UOFiddler's own `Ultima.dll` produced from this
same client through the converter this phase deletes. An independent
implementation agreeing to the row is the strongest check available that a format
decoder is correct, and it is not something a subtly-wrong one produces.

§17 records the four shapes the org lead settled before any of it was written.
Two departed from the recommendation: **the bridge always wins** (no source
setting — there is no version of that question an operator benefits from
answering) and **import is admin-triggered** (boot does not call the shard at
all).

`CLILOCS.md` is rewritten around that: where the table comes from, what arrives
and in how many pieces, the refusals — including the two the file pipeline had no
equivalent of (a client patched mid-import, and the base's exemption from the
vanished-source rule, which exists so an upgraded install is not asked to approve
a change the upgrade itself made).

`UOFIDDLER.md` loses Part 1 entirely rather than having it rewritten. What is
left is creature art, which phase 5 takes, after which the page goes away. `v3.md`
§8.6 keeps its reasoning with a note saying what superseded it, because the
argument for why the manual step existed is still the argument for why this was
worth building.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 11:13:56 -05:00
df9b3fd990 Merge pull request 'docs(link): phase 1 built the transport, and re-measured the catalogue' (#236) from docs/asset-bridge-p1 into main
Reviewed-on: #236
2026-09-10 15:04:39 +00:00
1a048ae1be docs(link): phase 1 built the transport, and re-measured the catalogue
Asset Bridge phase 1, docs half. Code: RunicGateway/servuo-plugins#28,
RunicGateway/link#41.

## The correction, which is most of this

**New §4.8.** The animation path has §4.5's shared-buffer defect too, and 357 of the
1,144 bodies §1.1 counted are **wrong pictures on a stock client** — ids with `length 0`
that return whichever body was decoded before them. Proved by decoding body 320 after a
dragon (a dragon), a wolf (a wolf) and a human (a human).

So the catalogue is **787 bodies**, and the numbers that were derived from 1,144 move
with it: §11's working set, its ~173,000-frame full set (now ~119,000), phase 3's scope.

**§5.2's table was wrong in the direction that matters.** The elf ghosts were listed as
decoding; their index entry has no record, and what came back was the elf female. Four
of twelve player bodies have art, not six — which takes phase 4's UOP decoder from six
ids to eight.

§1.1 now says outright that every "decoded" count in it is an upper bound. It is not a
table to size anything from any more.

## What phase 1 settled

- **§3.3** — the two numbers: a 512 KiB batch budget under a 1 MiB inbound line cap, with
  the factor of two load-bearing rather than cautious.
- **§3.2** — flow control is enforced **on the shard**, as a single slot answering
  `bridge.busy`, not serialised in the sidecar and not left to the website as a
  convention. Records what it costs: a status poll shares the slot.
- **§3.4, new** — one paging envelope (`more`/`cursor`/`cut`) for all five families that
  will page, defined before the first one needs it. `cut` because "short page" has three
  meanings and only one of them means finished.
- **§6** — hashing had to come off the request path entirely. The gate is unchanged; what
  changed is that "the normal case must cost nothing" now also means "and the abnormal
  case must not time out", because the first hash of 1.06 GB does not fit in 10 s.
- **§14** — which commands exist now, and which phase brings the rest.
- **§16, §17** — phase 1 done; decisions 6 and 7.

## Elsewhere

- **`SHARD_PREREQS.md`** gains the libgdiplus requirement (§4.4) — Linux hosts only, with
  the archived-upstream caveat and the `NO_IMAGING` status the shard now reports on the
  source gate.
- **`INTEGRATION.md`** advertised `X-UOLink-Version: 6`. It was already two versions stale
  before this change; now 8.

- [x] AI-assisted — Claude Code (Opus 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 08:32:58 -05:00
f6ea5c0484 Merge pull request 'docs(link): phase 0 ran, and the fault it found is not a crash' (#235) from docs/asset-bridge-p0 into main
Reviewed-on: #235
2026-09-10 08:04:13 +00:00
018f1af5ff docs(link): phase 0 ran, and the fault it found is not a crash
Records the Asset Bridge phase 0 spike (§16) against v8.md, and closes the
last open decision.

§4's choice to call ServUO's vendored `Ultima` STANDS: nothing faulted on a
path this protocol calls, and §9's cliloc reader reproduced UOFiddler's
123,490-entry table byte for byte in 218 ms from inside the shard.

But the spike was looking for the wrong kind of failure. `LoadStatic` and
`LoadLand` decode out of a buffer that is reused, only ever grown, and
filled by a `Read` whose return value is discarded — so a short, absent or
out-of-bounds record does not throw, it renders the PREVIOUS asset. On the
stock, unmodified client on this machine that is 22,102 ids whose index
entry reads `lookup 0, length 0`, all of which the library returns a picture
for. §1.1's "32,766 decoded" was counting these.

New §4.5 states the rule that answers it — validate before calling — with
the six checks phase 0 implemented, the eight deliberate defects they caught
(seven of which the library rendered silently, including a verdata lookup
past verdata.mul's own end, which `Verdata.Seek` bounds-checks nowhere), and
the number that makes the boundary defensible: zero false refusals across
49,151 statics and 16,384 land tiles on a clean client.

New §4.6: `FileIndex`'s UOP constructor ends `MulPath = uopPath`, so
`artLegacyMUL.uop` wins outright and `art.mul` is never opened on a current
client. Bounding an offset against the wrong file is not approximate, it is
meaningless — the spike's first run refused 34,299 good statics that way,
and every refusal read like a real finding.

New §4.7: `Ultima.Gumps.GetGump(2)`, called once from inside a running
shard, made the ServUO process disappear — no catch reached, no console
line, the probe's checkpoint file the only record. §4.1's rule is earned.

§17 now has nothing open:

  * item 4 — the default audience — SETTLED: an asset inherits the audience
    of the page that uses it.
  * item 5 is new: validate-before-calling, chosen ahead of the spike over
    a child-process extractor and over reversing §4, and confirmed by it.
    The dangerous failure turns out to be a wrong picture, which no
    containment strategy would have caught.

§16 marks phase 0 done and adds the half it deliberately left unbuilt to
phase 1: the animation path has no validator, and the patched client's wolf
decoded something else in silence to prove it.

Full measurements and the rig recipe live in servuo-plugins
`tools/scaffolding/README.md`; the code is RunicGateway/servuo-plugins#27.

- [x] AI-assisted — Claude Code (Opus 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 02:59:56 -05:00
f2e074c3f7 Merge pull request 'docs(link): the Asset Bridge (Protocol 8) — client assets without UOFiddler' (#234) from docs/client-assets-v8 into main
Reviewed-on: #234
2026-09-10 07:03:55 +00:00
041e1f4069 docs(link): name it the Asset Bridge, and make libgdiplus a stated requirement
The work has a name now — the Asset Bridge — for commits, PR titles, branches
(feat/asset-bridge-p<n>) and conversation. Protocol number stays 8 and the file
stays docs/link/v8.md.

§4.4 closes the last real open question rather than deferring it to phase 0, and
takes all three answers instead of choosing one. ServUO targets net48, so a Linux
host runs it under Mono, and Mono's System.Drawing is a thin layer over
libgdiplus — which §4.2 put in the decode path, not just the encode. So on Linux
it is a hard prerequisite for art. Cliloc and atlas import are unaffected; neither
touches pixels. Windows hosts need nothing at all.

It is now written down as: a SHARD_PREREQS.md entry, a doctor check in the
installer, and a named NO_IMAGING status when it is missing, in the same family
as the cliloc reader's COMPRESSED — never a stack trace, never a 500. Install
routes per distro are in the section, apt-get install libgdiplus being the
normal one.

One fact recorded because depending on something unmaintained should be a
conscious act: github.com/mono/libgdiplus was ARCHIVED in March 2025 and is
read-only. Distributions still package and patch it, so installing it today is
supported and ordinary — but it is the strongest long-term argument for moving
extraction off System.Drawing eventually, and phase 4's UOP reader is written
without it so that door stays open.

§17 restructured: three settled items kept because each changes numbers
elsewhere, and one genuinely open question (the default audience) that does not
block starting.

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