docs(installer): record Phase 3 — the patch tier as built

PLAN.md gains a "Phase 3 as built" section covering the decisions the plan had
left open, and §2.2, §2.2.1 and §7.0 are brought in line with what shipped:

* The engine is fully native. §2.2.1 wrote rung 1 as "apply verbatim with
  git apply", but §1 chose the release tarball so there would be no git on the
  shard host, and rung 2 needs a native applier anyway. Rung 1 keeps its
  stronger verdict and shares rung 2's write path. On the real files this is
  not academic — the shipped patches are CRLF and two of their three targets
  are LF, so git apply refuses patches the installer places correctly.

* §7.0 documents `patch_tier` in the overlay manifest. Which patches form one
  unit, which companion follows which, whether a core rebuild is needed and
  what declining costs are not derivable from a diff, so the release declares
  them and adding a patch regenerates metadata rather than an installer.

* §2.2 gains the pre-image cache and the widened patch cache, and §2.2.1 gains
  the second, per-feature level of the all-or-nothing rule.

INSTALL.md's illustrated tier output is replaced with the real thing, the
status banner now says `install` is complete, §3's path tables list
patches/originals/, and Appendix A2 names the line-ending trap that makes
git apply refuse a patch whose region is visibly untouched.

Refs: RunicGateway/installer#6, RunicGateway/servuo-plugins#10

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:57:42 -05:00
parent 5ae53d287f
commit 42e6f3a0cb
2 changed files with 182 additions and 39 deletions

View File

@@ -1,15 +1,15 @@
# Runic Gateway Installer — plan
Status: **Phases 1 and 2 built, on `edge`.** Phase 0's prerequisites all landed, the installer repo
publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) specified the operator-facing run
Status: **Phases 1, 2 and 3 built, on `edge`.** Phase 0's prerequisites all landed, the installer
repo publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) specified the operator-facing run
before the binary existed. The crate now implements the installer core (bundle resolution, ServUO
detection and validation, the overlay sync, `install.json` — [Phase 1 as
built](#phase-1--installer-core)) and the sidecar half (binary, config, service, token handoff —
[Phase 2 as built](#phase-2--uo-link-install-and-service)). Both are on the `edge` branch, not
`main`, so no half-capable binary is released. **Phase 3 (the patch tier) is next, and the
`edge → main` cutover follows it** rather than Phase 2: `INSTALL.md` §4 describes the tier as part
of the run, and a first release whose every patch-tier answer is "not implemented" is the same
half-capable binary that kept Phase 1 off `main`.
built](#phase-1--installer-core)), the sidecar half (binary, config, service, token handoff —
[Phase 2 as built](#phase-2--uo-link-install-and-service)), and the patch tier (the rung ladder, the
unsupported-version path, the cached patch set — [Phase 3 as built](#phase-3--patch-tier-opt-in)).
All three are on the `edge` branch, not `main`, so no half-capable binary is released. **The
`edge → main` cutover is next**, and it now cuts a binary that does everything `INSTALL.md`
describes except `doctor`/`update`/`uninstall`, each of which says which phase it arrives in.
This document is the design of record; it supersedes the informal overview it grew out of, which
described a ServUO integration that does not match how `servuo-plugins` actually ships (see
[Corrections](#corrections-to-the-original-overview)).
@@ -102,10 +102,19 @@ most real shards are hand-modified. Therefore:
audit forwarding**.
- The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a
shard restart.
- Record applied patches in `install.json`, **and cache the applied `.patch` files** next to it
- Record applied patches in `install.json`, **and cache the `.patch` files** next to it
(`/etc/runicgateway/patches/`, `%ProgramData%\RunicGateway\patches\`). Re-runs stay idempotent,
and uninstall can print the exact hunks offline long after the release tarball is gone (§5,
Phase 4).
Phase 4). As built this caches every patch the tier *evaluated*, not only those that applied,
because the refusal message names that path as the file to apply by hand.
- **Cache the pre-image of every file the tier edits**, under `patches/originals/`, mirroring its
path in the ServUO tree. It is written before the first edit and never overwritten, so a revert
can be verified byte-for-byte rather than reconstructed from a printed diff — which matters most
after a `region-match` apply, where the surrounding file was already the operator's. It stays out
of the ServUO tree, since uninstall has promised never to clean up in there.
- **A `.patch` does not carry everything the tier needs.** Which patches form one unit, which
companion `.cs` follows which, whether a core rebuild is required and what declining costs are
declared by the overlay release and read from its manifest — see §7.0.
#### 2.2.1 A whole-file hash mismatch is not a verdict — check the region
@@ -137,9 +146,13 @@ rules:
- **Line numbers are advisory.** The hunk header's offsets are used only to prefer the nearest
candidate when reporting; the match itself is by content, since insertions above the region shift
every number below it.
- **All-or-nothing per patch file.** If one hunk of a patch reaches rung 3, none of that patch's
hunks are applied. A half-patched `EventSink.cs` compiles against a companion `.cs` that expects
the whole thing, and a partial apply is harder for an operator to unpick than an untouched file.
- **All-or-nothing per patch file, and again per feature.** If one hunk of a patch reaches rung 3,
none of that patch's hunks are applied — a half-patched `EventSink.cs` compiles against a
companion `.cs` that expects the whole thing, and a partial apply is harder for an operator to
unpick than an untouched file. The same rule then applies across the patches of one feature: the
two vendor-sale patches are a unit (the event, the call site, and the subscriber that needs both),
so a patch that *could* have been placed is held back when a sibling cannot be — and the run says
that rather than reporting it as applied.
- **Rung 0 is checked first and is also all-or-nothing.** A file where some hunks are already
present and others are not is a hand-merge in progress, not an idempotent re-run — that is
rung 3.
@@ -631,6 +644,74 @@ pick the first). Fixtures are cheap: the three stock 57.4 files, each with a han
patched region (must reach rung 2), an edit inside it (must reach rung 3), and an already-patched
copy (must reach rung 0).
**As built** ([installer#6](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/6), with the
metadata half in [servuo-plugins#10](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/10))
`src/diff.rs` (the parser), `src/patch.rs` (the ladder and the applier) and `src/tier.rs` (consent,
writing, reporting, recording), wired into the same `install` run between the overlay sync and the
sidecar. The decisions that were not already settled above:
- **The engine is fully native; `git` is never invoked.** §2.2.1 wrote rung 1 as "apply verbatim
with `git apply`", but §1 chose the release tarball precisely so there would be **no git on the
shard host**, and rung 2 needs a native applier regardless. One engine now serves both: rung 1
keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image,
computed as a git blob SHA1 in process — while the write goes through rung 2's code path. That
leaves one set of CRLF and whitespace behaviours to reason about instead of two, and a bug report
never has to say which engine ran. It is also not academic: the shipped `.patch` files are CRLF in
a Windows checkout while two of their three targets are LF, so `git apply` **refuses** patches
this places correctly.
- **What a `.patch` cannot say is declared by the release, with a built-in fallback.** Which patches
form one all-or-nothing unit, which companion `.cs` follows which, whether a **core** rebuild is
needed, and what declining costs are all things a diff does not carry. `servuo-plugins/patches/tier.json`
declares them and the release workflow folds them into `manifest.json` as `patch_tier` (§7.0), so
adding a patch regenerates release metadata rather than requiring an installer release — the same
rule §7.1 applies to the bundle. Overlay `v0.1.1` is in the current bundle and declares nothing,
so the installer carries a built-in description of exactly that release; a declared tier always
wins. A checked-in fixture of the release workflow's **own jq output** asserts the two descriptions
are identical, so the two repos cannot drift apart quietly — the failure mode otherwise is a tier
that is silently never offered.
- **All-or-nothing gained a second level.** §2.2.1 makes it per *patch file*; the tier is also
all-or-nothing per **feature**, because the two vendor-sale patches are one unit — `EventSink.cs`
grows the event, `PlayerVendorGumps.cs` raises it, and the companion subscribes to it. Applying
either alone yields a tree that does not compile or silently never emits. A patch that could have
been placed but was held back by a sibling says so in as many words; reporting it as applied is
the exact misreading this tier exists to prevent.
- **The pre-image of every patched file is cached**, under `<state>/patches/originals/`, mirroring
its path in the ServUO tree. The tier is the only part of the installer that edits a file the
operator owns, and this is what turns "here are the hunks we added" into a revert anyone can
verify — which matters most for a `region-match` apply, where the surrounding file was already
theirs. It lives in the state directory rather than beside the file it copies, because an
installer-owned file inside the ServUO tree is one `uninstall` has promised never to clean up. It
is written before the first edit and never overwritten, so it stays pre-tier however many times
`install` runs.
- **Every patch the tier *evaluated* is cached, not only the ones that applied** — a refinement of
§2.2's "cache the applied `.patch` files". The refusal message names that path as the file to
apply by hand (as §4 of `INSTALL.md` already illustrated), so caching only successes would point
an operator at a file the run had decided not to write.
- **Rung 0 reuses the previous record whole rather than re-deriving it.** The rung is the
support-relevant fact — how did this land? — and a later run re-deriving it answers
`already-present` for something that first landed as `region-match`. That flip rewrites
`install.json` on the second run of an identical install, which is the same class of bug as the
`Bridge.cfg` comparison in Phase 1: a record describing the run instead of the state. A tree
patched by hand per `INSTALL.md` Appendix A2 has no prior record, so there `already-present` is
correctly what gets minted.
- **Declining never erases what an earlier run applied**, and no longer claims a loss that is not
real. `--no-patches` and an unselected prompt both carry the previous `patches` section through
untouched, as `--verify` does — and the "Without it:" line now names only the features the record
does not already show as applied.
- **Withholding `--patches-unsupported-servuo` skips the tier loudly rather than failing the run.**
By that point the overlay is deployed and the sidecar is about to be installed; turning a completed
base install into exit 1 over a tier documented as optional would cost the operator more than the
tier is worth. Saying nothing would be the real failure, so it is reported where it happens.
Verified on this machine against the ServUO 57.4 tree at `C:\Users\colby\Desktop\ServUO`, across
four scratch roots built from its real files: a hand-patched tree (rung 0 on both vendor-sale
patches), a reverse-applied stock one (**rung 1 on the real `EventSink.cs`**, whose blob hash
reproduces the patch's declared `index d30788f` pre-image), a feature resolving at mixed rungs, a
tree with edits inside two patched regions (rung 3 — nothing written, the placeable sibling held
back, no companions copied, and all three patches cached anyway), and a non-57.4 tree both with and
without the extra consent flag. Three consecutive runs left `install.json` byte-identical, the
patched files unchanged, and the cached pre-image still pre-patch.
### Phase 4 — diagnostics and updates
`runicgateway doctor` — the command that makes the whole thing supportable:
@@ -745,14 +826,30 @@ Shipped inside every `runicgateway-overlay-<ver>.tar.gz`, generated by that repo
"repo": "RunicGateway/servuo-plugins",
"protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"patch_tier": {
"features": [{
"name": "vendor-sale",
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
"lost": "no vendor.sale events",
"rebuild": "core",
"patches": [
{ "name": "playervendor-sale-eventsink", "file": "patches/playervendor-sale-eventsink.patch", "target": "Server/EventSink.cs" },
{ "name": "playervendor-sale-gump", "file": "patches/playervendor-sale-gump.patch", "target": "Scripts/Gumps/PlayerVendorGumps.cs" }
],
"companions": [
{ "file": "patches/BridgeVendorSale.cs", "install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs" }
]
}]
},
"files": { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" }
}
```
`version` and `commit` come from the release engine; `protocol` and the `servuo` block are read from
`servuo-plugins/overlay.toml`; `files` is a SHA256 per shipped file.
`servuo-plugins/overlay.toml`; `patch_tier` is folded in from `servuo-plugins/patches/tier.json`;
`files` is a SHA256 per shipped file.
Two of these carry weight beyond documentation:
Three of these carry weight beyond documentation:
- **`protocol` is a hand-maintained declaration, and has to be.** The plugin announces no version on
the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes
@@ -763,6 +860,18 @@ Two of these carry weight beyond documentation:
overlay moved on"** (§5, Phase 4). The installer copies these hashes into `install.json` at deploy
time; a later mismatch against *both* the manifest and `install.json` means upstream changed, a
mismatch against `install.json` alone means local edits.
- **`patch_tier` is everything a `.patch` cannot say about itself**, and is the reason the tier is
data rather than code. Which patches form one all-or-nothing unit, which companion `.cs` may only
be copied once that unit lands, whether the change needs a **core** solution rebuild or just the
dynamic script build, and what the operator loses by declining are none of them derivable from a
diff. Declaring them here means adding a patch regenerates release metadata rather than requiring
an installer release — the rule §7.1 already applies to the bundle. The maintainer-facing source
is `servuo-plugins/patches/tier.json`; the release workflow folds it in and removes the staged
copy, so the tarball carries exactly one statement of the table, and gates that every `.patch` is
described by exactly one feature, that every named patch and companion exists, and that each
declared `target` is the file its diff actually edits. Installers older than this key ignore it;
an installer newer than the overlay it is deploying falls back to a built-in description of the
release that predates it (see [Phase 3 as built](#phase-3--patch-tier-opt-in)).
`min_version` and `patches_verified_against` are separate on purpose. The base overlay only *adds*
files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified