Two corrections from the org lead on the transport rewrite. It is an OXIDE plugin, not "a mod loaded by the server's mod framework". Oxide is what modded Rust servers run, and naming it is the difference between a design a reader can start from and one they have to go and pick a framework for. And the architecture is ONE SERVER, ONE SIDECAR - not one sidecar fronting a community's several servers, which is the arrangement a UO-shaped reading reaches for and which this document had. Rust servers in practice sit on separate VMs, so a shared sidecar would have to be reached across a network by plugins that are supposed to talk to it over loopback: it trades the invariant that makes the design safe for a saving in process count. The cost lands on the module, which is the right place for it - it holds one client per configured server rather than one client to an aggregator - and it makes the Team provider's `complete` answerable rather than vague, since "every team there is" now means every team on THIS server. Five of six sidecars reachable is `complete` left off, and core adds and updates without archiving. Recorded as a reevaluable assumption rather than a principle, because that is what it is. Nothing in the contract objects either way: core is not in this conversation at all, which finding 2 now says. Co-Authored-By: Claude <noreply@anthropic.com>
406 lines
24 KiB
Markdown
406 lines
24 KiB
Markdown
# `module-rust` — a dry run
|
|
|
|
**Phase 3's fourth acceptance criterion** ([`../website/MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md)
|
|
§2.7). A written design for a module serving a **Rust** (Facepunch) community, taken far enough to
|
|
find out whether the contract generalises past the game it was extracted from — **and deliberately
|
|
not implemented.** A contract validated only against the module it was carved out of has not been
|
|
validated.
|
|
|
|
The exercise is honest if it finds something. It found four things, one of which is a gap in the
|
|
contract that a real second module would hit on its first day. They are in *Findings* at the end; the
|
|
design comes first, because a finding is only worth anything with the design that produced it.
|
|
|
|
Rust was chosen because it is unlike Ultima Online in the ways most likely to break assumptions:
|
|
it **wipes** every month, a community runs **several servers** rather than one shard, its identity
|
|
is **Steam**, and its server is **not source you can compile** — ServUO's overlay is C# a shard owner
|
|
builds into their own server, and a Rust server is a binary nobody outside Facepunch patches. The way
|
|
in is a **mod** — specifically an **Oxide** plugin, since Oxide is what modded Rust servers run —
|
|
hooking the game's own events. If the contract survives that, "game-agnostic" means something.
|
|
|
|
> Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is
|
|
> normative; this document only *uses* it.
|
|
>
|
|
> **Revisited 2026-08-19, for Teams** (`MODULE_API_VERSION` 1.6.0, Teams phase 11). A Rust team is a
|
|
> Team, so the design grew a provider and an inverted slot — the two places the contract changed since
|
|
> this was written. Everything else stands, including all four findings: the identity gap is still
|
|
> open and still the one a real second module hits first.
|
|
|
|
---
|
|
|
|
## 1. The manifest
|
|
|
|
```json
|
|
{
|
|
"id": "rust",
|
|
"name": "Rust",
|
|
"version": "0.1.0",
|
|
"coreApi": "^1.5.0",
|
|
"server": "server/index.js",
|
|
"client": { "entry": "client/dist/entry.js" },
|
|
"schema": "server/db/schema.sql",
|
|
"purge": "server/db/purge.sql",
|
|
"mounts": {
|
|
"public": ["/rust"],
|
|
"admin": ["/rust"],
|
|
"player": ["/rust"]
|
|
},
|
|
"extensions": ["admin.users.detail"],
|
|
"capabilities": ["servers", "wipes", "leaderboards", "map", "killfeed", "teams"]
|
|
}
|
|
```
|
|
|
|
> **Correction, 2026-08-12.** `coreApi` read `^1.3.0` here until the Integration Kit's acceptance run
|
|
> ([`kit-acceptance.md`](kit-acceptance.md)) found it. The contract was at 1.3.0 when this design was
|
|
> written and has moved twice since; the number is now `^1.5.0`. Left as a correction rather than a
|
|
> silent edit because *why* it went stale is the reusable part: this is the only complete `module.json`
|
|
> in the kit's reading path, so it is what a newcomer copies — and unlike the template, which CI holds
|
|
> against core's `MODULE_API_VERSION` on every pull request
|
|
> ([`../website/MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md) §2.11.1 d2), **a JSON block inside a
|
|
> Markdown document has nothing checking it.** A range is also the shape least likely to be noticed
|
|
> when it rots: `^1.3.0` is *satisfied* by a 1.5.0 core, so a module copied from here would have loaded
|
|
> fine and simply been wrong about what it was written against.
|
|
|
|
One prefix per tier, named for the module rather than for a feature — the opposite of module-uo's
|
|
`/shard` + `/atlas` + `/uo-link`, and the better choice for anything new. Module-uo's prefixes are
|
|
what they are because §1.2 froze the URLs core already served; a module written today has no such
|
|
debt and should claim one obvious segment. It also sidesteps the collision surface entirely:
|
|
`/shard` is a word a second game might want, `/rust` is not.
|
|
|
|
## 2. The server half
|
|
|
|
```js
|
|
module.exports = function register(ctx, api) {
|
|
core.init(ctx)
|
|
|
|
const publicRouter = require('./router/public/rust.router')
|
|
const adminRouter = require('./router/admin/rust.router')
|
|
const playerRouter = require('./router/player/rust.router')
|
|
const userExt = require('./router/admin/usersRust.router')
|
|
|
|
api.registerRoutes({
|
|
public: { '/rust': publicRouter },
|
|
admin: { '/rust': adminRouter },
|
|
player: { '/rust': playerRouter },
|
|
})
|
|
|
|
api.registerExtension('admin.users.detail', userExt)
|
|
|
|
api.registerNotificationStreams([
|
|
{ id: 'rust.wipe', label: 'Server wipes',
|
|
description: 'A server wiped — new map, new seed, everything reset.',
|
|
personal: false, requiresLinkedAccount: false },
|
|
{ id: 'rust.raid', label: 'Base raided',
|
|
description: 'Your base took damage while you were offline.',
|
|
personal: true, requiresLinkedAccount: true },
|
|
])
|
|
|
|
api.registerAnnounceLeg({
|
|
leg: 'rust.ingame',
|
|
label: 'In-game chat',
|
|
dispatch: (post) => links.broadcast('chat.say', { text: `[NEWS] ${post.title} — ${ctx.site.baseUrl}/news/${post.slug}` }),
|
|
classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }),
|
|
})
|
|
|
|
// Teams (1.6.0). A Rust "team" is a Team: core owns the tables, the membership
|
|
// sync, the access rules, the forum and the activity feed; this module owns the
|
|
// word and the roster behind it.
|
|
api.registerTeamProvider(teamProvider)
|
|
|
|
api.onBoot(async () => { await links.connectAll() })
|
|
api.onShutdown(async () => { await links.closeAll() })
|
|
}
|
|
```
|
|
|
|
Everything above is a call the contract already has, used the way module-uo uses it. Three details
|
|
are worth pointing at:
|
|
|
|
- **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven
|
|
bare stream ids are allowlisted because they were in `notification_subs` before the rule existed
|
|
(§6.5); a new module gets the rule, and the rule is exactly right.
|
|
- **The announce leg goes to in-game chat**, by asking each server's sidecar to send a command down
|
|
the socket its plugin already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and
|
|
not `registerPostHook`. Note the plural: there is one sidecar per server, so a news post reaches
|
|
every configured server and the leg's `classify` answers for the set. The distinction §2.4 draws holds up on a game that has nothing in common with
|
|
the one it was drawn for. Note the direction: the module never speaks to a game server, and the
|
|
*sidecar* never dials one either — it answers on a connection the plugin opened.
|
|
- **The Team provider is the one registration core calls back into**, and Rust makes two of its rules
|
|
bite harder than UO does. `externalId` must survive a rename, and a Rust team has no name at all —
|
|
it is a numeric team id in the server's save, which is the right answer and the one a designer is
|
|
least likely to reach for. And **`complete` is per SERVER, not per community**: a community running
|
|
six servers has six team spaces, so a provider that can reach five of them must leave `complete`
|
|
off or core archives every Team on the sixth. Wipes make the same point once a month, on purpose —
|
|
a wipe empties every team, and `{ ok: true, complete: true, teams: [] }` is then *true* and core
|
|
archiving all of them is *correct*. Which is exactly why a sidecar with no plugin connected must
|
|
answer `{ ok: false }` instead: the two states are one API call apart and only the module can tell them
|
|
apart.
|
|
|
|
### Tables
|
|
|
|
`rust_servers`, `rust_wipes`, `rust_players`, `rust_player_stats`, `rust_teams`, `rust_events`,
|
|
`rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment.
|
|
|
|
**`rust_teams` stays this module's table, and core's `teams` stays core's.** They hold the same teams
|
|
and neither reads the other: the module ingests from the sidecar into `rust_teams`, core reconciles by
|
|
*asking* the provider, and §2.6's prefix rule forbids the module touching core's table even though
|
|
the module is what populates it. A module that wrote `team_members` directly would be racing core's
|
|
reconciler for rows it does not own.
|
|
|
|
**Every table that holds gameplay data carries a `wipe_id`.** That is the whole shape of the game in
|
|
one column: a leaderboard means "since the last wipe", a base means "on this map", and a player's
|
|
stats are per-wipe with an all-time rollup kept separately. It has no bearing on the contract —
|
|
core replays the fragment and never looks inside — but it is the first thing a UO-shaped mental
|
|
model gets wrong, and it is worth writing down for whoever builds this.
|
|
|
|
### Talking to the game
|
|
|
|
**An Oxide plugin, a sidecar, and the same three-part shape UO has.** Rust's server is a binary, so
|
|
there is no overlay to compile into it and no source to patch — but a modded server runs **Oxide**,
|
|
and an Oxide plugin is C# with a hook for everything this design needs. So `rust-link` is a real
|
|
sidecar rather than a wrapper around an admin channel, and it is fed the way `uo-link` is fed:
|
|
|
|
```
|
|
ONE Rust server + the rust-bridge Oxide plugin (C#, hooks)
|
|
│ loopback TCP, newline-delimited JSON, bidirectional
|
|
│ the PLUGIN dials out to the sidecar — the game opens no listening port
|
|
▼
|
|
ONE rust-link sidecar, on that same host
|
|
│ bearer-authed HTTP + WebSocket, versioned
|
|
▼
|
|
module-rust, inside the website — one client per server it is configured with
|
|
```
|
|
|
|
**One server, one sidecar** (org lead, 2026-08-19). Not one sidecar fronting a community's several
|
|
servers, which is the arrangement a UO-shaped reading reaches for. Rust servers in practice sit on
|
|
**separate VMs**, so a shared sidecar would have to be reached across a network by plugins that are
|
|
supposed to talk to it over loopback — trading the invariant that makes this design safe for a saving
|
|
in process count. The pairing stays local: a server, its plugin, and its own sidecar on the same host.
|
|
|
|
The cost lands on the module, and it is the right place for it: `module-rust` holds one client per
|
|
configured server rather than one client to one aggregator, and every board it reads is that server's.
|
|
A community with six servers runs six pairs and the module knows about six endpoints. **That is a
|
|
reevaluable assumption rather than a principle** — if a deployment ever wants one sidecar for several
|
|
servers, nothing in the contract objects, because core is not in this conversation at all.
|
|
|
|
**The plugin is the interesting half, and it is where a UO-shaped model has the least to unlearn.** The
|
|
threading contract transfers whole — emit enqueues onto a bounded drop-oldest queue and returns, one
|
|
writer thread owns the socket, the world is read only on the game's own thread — because it is a
|
|
property of *game servers* and not of ServUO. What changes is that the hooks are handed to you rather
|
|
than found: Oxide publishes them, so the plugin is small and the guesswork is in deciding what to
|
|
emit rather than in finding somewhere to hang it.
|
|
|
|
Two of them answer questions UO had to work for:
|
|
|
|
- **The wipe arrives as an event.** The plugin is told a new save has begun; nothing has to detect a
|
|
wipe by noticing the world looks different.
|
|
- **Membership is real-time** — created, joined, left, disbanded, leader changed. UO has no
|
|
`guild.leave` at all and needed a 60-second sweep plus a set diff to synthesise one (protocol 4,
|
|
[`../link/v4.md`](../link/v4.md)); here every transition is delivered as it happens. So this
|
|
module's Team provider is **event-driven with a baseline on connect** rather than sweep-driven —
|
|
and the provider contract does not change by one line, because core asks the same three questions
|
|
and gets the same envelope. That is the result worth keeping: the contract never needed to know how
|
|
the data arrives.
|
|
|
|
**The sidecar still earns its place, and the store is why.** A hook fires once, and what it says while
|
|
nobody is listening is gone. So the sidecar appends every kill, wipe and chat line, keeps the latest
|
|
snapshot of its server's state, and answers the website's reads from disk — a website that is down,
|
|
restarting or mid-deploy loses nothing, and a leaderboard renders the last thing the server said
|
|
rather than an error. It also keeps the auth token and the reconnect loop out of an Express process,
|
|
where a stalled socket is a stalled request handler.
|
|
|
|
**A sidecar answers for exactly one server**, which is what makes the Team provider's `complete`
|
|
(§2) answerable at all: "every team there is" means every team on *this* server, and the module can
|
|
only claim it for the servers whose sidecar answered. Five of six reachable is `complete` left off,
|
|
and core then adds and updates without archiving anything.
|
|
|
|
**`wipe_id` makes the durable copy load-bearing rather than a nicety.** A wipe is the moment the
|
|
game forgets; the sidecar is the only thing that remembers the shape of the map that just ended.
|
|
|
|
**Commands go back down the same socket.** The announce leg's in-game chat line is a command the
|
|
sidecar sends to its plugin — the same direction UO's bridge already carries. The connection belongs
|
|
to the plugin, and nothing outside the game ever dials into it.
|
|
|
|
> **Correction, 2026-08-12.** This section originally concluded **"No sidecar"** — the module dialling
|
|
> RCON directly — and offered it as evidence that core has no opinion about how a module reaches its
|
|
> game. That was overruled by the org lead when Phase 5 (§2.11.1 d3/d4) settled the kit's stance, and
|
|
> the prohibition is now contract: [`../website/MODULE_API.md`](../website/MODULE_API.md) §2.7, as of
|
|
> `MODULE_API_VERSION` 1.4.0, a module does not open a connection to a game server from the website
|
|
> process. The original reasoning was not wrong about *ServUO* — the shard-dials-out invariant in
|
|
> [`../link/PLAN.md`](../link/PLAN.md) really is a property of an engine with no remote-control
|
|
> surface — but it mistook that for the whole reason a sidecar exists. The other reason is durability:
|
|
> the website is not the right place to hold a game connection, because it is the process most likely
|
|
> to be restarted and the one facing the internet. The finding is left in view rather than edited out;
|
|
> what a dry run concluded is worth more than a tidy document.
|
|
|
|
> **Correction, 2026-08-19 (org lead).** This document originally reached the game over **RCON**, and
|
|
> this section concluded "a thin sidecar" on that basis — no wire protocol to invent and no game-side
|
|
> plugin to write. Overruled: **the transport is an Oxide plugin, exactly as UO's is a ServUO
|
|
> overlay, and RCON is not used.** Hooks inside the plugin expose the data and the plugin dials the
|
|
> sidecar. **One server, one sidecar**, since Rust servers usually sit on separate VMs — a reevaluable
|
|
> assumption, recorded as one.
|
|
>
|
|
> Two things that costs the document, worth stating because both were used as evidence elsewhere.
|
|
> Rust is no longer an example of *"a game that already speaks a remote-control protocol, so its
|
|
> sidecar is thin"* — that example now has none in this project. And the reason Rust is a good second
|
|
> game is no longer that its protocol comes free. It is that its server is a **binary**, the opposite
|
|
> of ServUO: the way in is a published mod API rather than source you compile, and the
|
|
> shard-dials-out invariant has to survive that change of footing. It does, unchanged, which is a
|
|
> stronger result than the one this document originally claimed.
|
|
|
|
## 3. The client half
|
|
|
|
```js
|
|
registry.registerRoutes('rust', {
|
|
public: [
|
|
{ path: 'servers', element: <Servers /> },
|
|
{ path: 'servers/:id', element: <ServerDetail /> },
|
|
{ path: 'servers/:id/map', element: <MapView /> },
|
|
{ path: 'leaderboards', element: <Leaderboards /> },
|
|
{ path: 'wipes', element: <WipeSchedule /> },
|
|
],
|
|
player: [
|
|
{ path: 'account', element: <LinkedAccount /> },
|
|
{ path: 'stats', element: <MyStats /> },
|
|
],
|
|
admin: [
|
|
{ path: 'servers', element: <AdminServers />, gate: { roles: ['admin'] } },
|
|
{ path: 'ops', element: <AdminOps />, gate: { roles: ['admin', 'moderator'] } },
|
|
],
|
|
})
|
|
|
|
registry.registerNav('rust', {
|
|
area: 'public',
|
|
items: [
|
|
{ label: 'Servers', to: '/rust/servers', group: 'Play', order: 10, icon: ServerIcon },
|
|
{ label: 'Leaderboards', to: '/rust/leaderboards', group: 'Play', order: 20, icon: TrophyIcon,
|
|
feature: 'leaderboards' },
|
|
{ label: 'Wipe schedule', to: '/rust/wipes', group: 'Play', order: 30, icon: CalendarIcon },
|
|
],
|
|
})
|
|
|
|
registry.registerFeatureProvider('rust', 'rust', useRustFeatures)
|
|
registry.registerExtension('rust', 'admin.users.detail', LinkedSteamAccounts)
|
|
|
|
// The INVERTED direction (1.6.0): this module declares places on its OWN team
|
|
// page and core fills them. Core publishes no team page — it does not own the
|
|
// word — so `/rust/servers/:id/teams/:teamId` is this module's, and core's feed
|
|
// and forum are contributed into it.
|
|
registry.declareModuleSlot('rust', 'rust.team.detail', { core: 'team.activity' })
|
|
registry.declareModuleSlot('rust', 'rust.team.forum', { core: 'team.forum' })
|
|
```
|
|
|
|
`Play` is a group core does not have; §3.3 appends an unknown group rather than dropping the items,
|
|
so this works and lands at the end of the nav — where an operator can move it, because a module row
|
|
is an ordinary row once it is interleaved.
|
|
|
|
The pages need `PublicLayout`, `PageHeader`, the three `PageState` components, `useAsync` and
|
|
`useAuth`: **six of the kit's nine members**, plus `useSite` on the wipe-schedule page for the site's
|
|
timezone and `Slot` on the team page. A second game, unrelated to the first, wanting exactly what the
|
|
kit contains is the strongest evidence available that §3.4 was curated at the right altitude.
|
|
|
|
**The team route carries a server id as well as a team id**, which is the Rust-shaped consequence of
|
|
the finding two sections down: team `4` on one server and team `4` on another are different teams,
|
|
so the module's `externalId` has to be `<serverId>:<teamId>` and its page needs both. Core stores
|
|
that string and never parses it — an external id is opaque to core by design, and this is the case
|
|
that shows why.
|
|
|
|
The map view is the one page that wants something the kit does not have — a pan/zoom canvas. It
|
|
bundles one, which is the answer §3.4 already gives ("everything else a module bundles itself"), and
|
|
it costs the chunk about 40 KB.
|
|
|
|
---
|
|
|
|
## Findings
|
|
|
|
### 1. A module cannot register an identity provider — and Rust's identity is Steam
|
|
|
|
The gap. Module-uo proves account ownership with an in-game `[link` command that issues a one-time
|
|
code; the website confirms it with the sidecar. Nothing about that needs core's auth layer, so
|
|
nothing in `api` ever needed to touch it.
|
|
|
|
**Rust's answer is Steam OpenID**, and every Rust community expects "Sign in with Steam". Core has an
|
|
SSO layer — `authProviders`, `userIdentities`, OAuth2/OIDC providers behind a registry — and
|
|
[`MODULE_API.md`](../website/MODULE_API.md) §2.4 offers a module no way in. There is no
|
|
`registerAuthProvider`, and §2.7 forbids reaching for one.
|
|
|
|
A Rust module can still ship: it can copy the UO shape, issuing a code in-game and matching it on
|
|
the website. That works, it is one screen worse, and it leaves the community's obvious expectation
|
|
unmet.
|
|
|
|
**This is not a defect in what was built** — it is the boundary of what was specified, found exactly
|
|
where a dry run is supposed to find it. It is worth stating precisely, because whoever adds it has to
|
|
answer a question the current policy already has an opinion about: **SSO is link-only by design**, an
|
|
external identity must already be linked to an existing account, and identities are never
|
|
auto-provisioned. A Steam provider a module registers must inherit that, not route around it. It is
|
|
also *not* a small addition: an identity provider participates in session creation, which is the one
|
|
part of core a module must never be able to weaken.
|
|
|
|
Recommendation: leave it out of v1 of the contract and record it here as the first candidate for
|
|
`MODULE_API_VERSION` 1.4 or 2.0, specified deliberately rather than bolted on when someone needs it.
|
|
|
|
### 2. "One module, one game" is not the same as "one module, one server"
|
|
|
|
A UO community runs one shard. A Rust community runs four or five, wipes them on different
|
|
schedules, and every page is a per-server view.
|
|
|
|
The contract is silent on this, and silence turns out to be right: multiplicity lives entirely in the
|
|
module's own tables and route parameters (`/rust/servers/:id`). Core's mount prefixes, capabilities
|
|
and state machine are per-**module**, and none of them wanted to be per-server.
|
|
|
|
The one-server-one-sidecar decision above pushes that further and it still holds: the module holds
|
|
*several* sidecar clients, and core never learns there is more than one. What core has an opinion
|
|
about is the module; how many things the module talks to is the module's business.
|
|
|
|
Worth recording only because it looks like a problem until you try it — and because it is the shape
|
|
that would have broken a contract designed around "the shard" as a singular noun. The phase-2
|
|
inversions that removed core's opinions about game content (the push catalog, the announce legs,
|
|
`mapEvent` dropped) are why it does not.
|
|
|
|
### 3. The event catalog generalises; the *retention* assumption does not
|
|
|
|
`rust_events` is the twin of `shard_events`, and the ingest shape carries over unchanged.
|
|
|
|
What does not carry over is that a UO event log grows forever while a Rust one is **truncated every
|
|
wipe**. That is module-internal — but it lands on something core does own: the schema fragment
|
|
**must not** be where that truncation happens. §2.6's leading-verb allowlist (`CREATE`, `ALTER`,
|
|
`INSERT`, `UPDATE`) already forbids `DELETE` and `TRUNCATE` in a fragment, precisely because the
|
|
fragment is replayed on **every boot** and would empty the table each restart.
|
|
|
|
So a wipe is a runtime operation on a module route, not a schema one. The allowlist was written for a
|
|
different reason — the phase-2 note says "the file replays every boot, so TRUNCATE/DELETE would empty
|
|
a table each restart" — and it correctly forbids the first mistake this module's author would make.
|
|
A rule that catches a case it was not written for is a rule at the right altitude.
|
|
|
|
### 4. `capabilities` earns its keep the moment there are two modules
|
|
|
|
With one module, `capabilities` reads like decoration — core never interprets one, and the SPA knows
|
|
what it registered. With two, it is the only thing a *client* can ask.
|
|
|
|
The Android app is the case: it feature-detects against `GET /api/v1/public/modules` and must render
|
|
a site whose module it has never heard of. `["servers", "wipes", "leaderboards"]` tells it there is
|
|
nothing shard-shaped here without it having to know what `rust` means, and §2.9's rule — treat an
|
|
unknown capability as absent, never infer a route from one — is what keeps that from becoming a
|
|
second, worse route table.
|
|
|
|
No change needed. Recorded because the design decision looked over-engineered with one module and is
|
|
load-bearing with two.
|
|
|
|
---
|
|
|
|
## Verdict
|
|
|
|
**The contract generalises.** A second game, chosen for how little it shares with the first, is
|
|
served by the same `module.json`, the same registration calls, the same schema-fragment rules,
|
|
the same client registry and the same UI kit — with one genuine gap (identity providers), one
|
|
non-issue that looks like a gap (multiple servers), and two places where a rule written for one
|
|
reason turns out to cover another.
|
|
|
|
**And the three-part shape generalises with it**, which the 2026-08-19 correction is what actually
|
|
established: a game whose server is a binary, reached through a published mod API, still ends up with
|
|
a plugin that dials out, a sidecar that persists before it forwards, and a module that talks only to
|
|
the sidecar. Nothing about that arrangement was a property of ServUO being source you can compile.
|
|
|
|
The gap is worth having found before something was built on top of it, which is what a dry run is
|
|
for. What it does **not** establish is that someone outside this org could build this module from the
|
|
documentation alone — that is the Integration Kit's acceptance test (§2.11), and it stays untested
|
|
until a person who did not write any of this does it.
|