feat(sidecar): forward the asset manifest, the pixels and the body pass (Phase 3)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m24s

Three routes, forwarded verbatim like everything else on this link:

  GET  /assets/manifest?family=&cursor=
  POST /assets/fetch
  POST /assets/bodies

**The two POSTs are reads.** The method is the request body, not a side
effect -- a few hundred asset keys do not belong in a query string, and these
are the only reads on this link that take one. `assets_call` is `event_call`'s
shape with one difference that matters: it responds through `respond_assets`,
so `bridge.busy` is a 425 rather than an idempotency collision. On this plane
busy is the ORDINARY answer during an import, and a caller that read it as an
error would abandon a healthy transfer.

422 gains a second meaning here alongside "the shard cannot decode that file":
the mid-import guard. A manifest reply carries a `catalog` id the shard derives
from its own client files, and passing it back on a fetch makes the shard refuse
if those files moved in between -- without which an operator patching their
client halfway through an import gets one asset set stitched out of two, with no
error anywhere.

v8.md §16 listed phase 3 as servuo-plugins + module-uo. That was wrong: web.rs
routes every command explicitly and has no generic /assets/* forwarder, so this
repo is in the phase. The doc now says so.

64 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-10 18:40:26 -05:00
parent 60e6de55f3
commit 143f424867

View File

@@ -141,6 +141,17 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// the files on that host changed since the last import", and a cached answer to that is
// worse than no answer.
.route("/assets/sources", get(assets_sources))
// Stage 2 (phase 3): what the shard could serve, hashed but without the pixels, so the
// website can ask only for what changed. Paged.
.route("/assets/manifest", get(assets_manifest))
// The pixels, for an explicit list of keys. POST rather than GET because the list is the
// request -- a few hundred keys do not belong in a query string, and this is the one place
// on this link where a read takes a body.
.route("/assets/fetch", post(assets_fetch))
// Slug -> body id. The one asset-plane call the shard answers ON ITS CORE THREAD, because
// it resolves a class name by constructing the creature and reading its body; the sidecar
// neither knows nor cares, which is the point of forwarding verbatim.
.route("/assets/bodies", post(assets_bodies))
// The cliloc table (Protocol 8, phase 2): UO's id -> display-string map, read out of the
// shard's own client and paged. RPC for the same reason as the source gate, and one more:
// it is five megabytes of somebody else's copyrighted strings, which this process has no
@@ -1445,6 +1456,105 @@ struct ClilocQuery {
cursor: Option<String>,
}
/// Stage 2 of the import gate (docs/link/v8.md §6, phase 3): every asset the shard could serve,
/// with a hash and a size and **no pixels**.
///
/// That separation is the whole difference between an Update and a re-download. The website holds
/// the hashes from last time, diffs this against them and asks `/assets/fetch` only for the keys
/// that moved — which, on the normal restart-that-changed-nothing, is none of them.
///
/// **Paged, and the caller drives the paging**, same envelope as `/cliloc`: echo the previous
/// reply's `cursor` until one says `more: false`, and read `cut` to learn *why* a page was the
/// last — only `end` means the manifest is complete. This family pages on the shard's wall clock
/// rather than on bytes, because its rows are tiny and building them means decoding hundreds of
/// sprites, so expect several pages of a few hundred rows each.
///
/// `?family=` selects which asset family; phase 3 serves `body` and the shard refuses anything
/// else by name rather than substituting a default.
async fn assets_manifest(
State(st): State<AppState>,
Query(q): Query<ManifestQuery>,
) -> impl IntoResponse {
let req_id = st.rpc.next_req_id();
let mut cmd = json!({"kind": "assets.manifest", "reqId": req_id});
if let Some(family) = q.family.as_deref().filter(|s| !s.is_empty()) {
cmd["family"] = json!(family);
}
if let Some(cursor) = q.cursor.as_deref().filter(|s| !s.is_empty()) {
cmd["cursor"] = json!(cursor);
}
respond_assets(st.rpc.call(&st.shard, cmd, &req_id).await)
}
#[derive(Deserialize)]
struct ManifestQuery {
family: Option<String>,
cursor: Option<String>,
}
/// The bytes, for keys the caller names: `{"keys":[…],"catalog":<opt>,"cursor":<opt>}`.
///
/// Each row carries the sprite as base64 PNG. The shard encodes it, and that is deliberate rather
/// than incidental: `System.Drawing` is already in its *decode* path (docs/link/v8.md §4.2), so
/// PNG costs it no new dependency, while asking the website to encode would put an image encoder
/// in Node and make the manifest's hash cover bytes nobody ever stores.
///
/// **`catalog` is the mid-import guard.** A manifest reply carries a `catalog` id derived from the
/// client files themselves; passing it back here makes the shard refuse (422) if those files moved
/// in between. Without it an operator who patched their client halfway through an import would get
/// one asset set stitched out of two, with no error anywhere.
///
/// A key the shard cannot serve comes back as a **row** with a `status`, not as a failed request —
/// a body this client has no art for is the expected answer for two thirds of the player bodies,
/// and failing the whole page over one would make an import impossible on a stock client.
async fn assets_fetch(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
assets_call(&st, "assets.fetch", body).await
}
/// Slug → body id: `{"types":["GiantSpider", …]}` (docs/link/v8.md §8, phase 3).
///
/// The spawn atlas knows a creature by the class name in `Spawns/*.xml`; the client knows it by a
/// body id; nothing in the ServUO tree declares the mapping as data. Only code running *inside*
/// ServUO can answer it — construct the type, read `Body.BodyID`, delete it — which is why this is
/// a request kind of its own rather than a step inside asset extraction: it runs on the shard's
/// Core thread, while every decode on this plane runs off it.
///
/// **The shard caps the batch and refuses rather than truncates** a longer list, because every
/// name in it costs a real constructor between two ticks of the world. Chunk the list; a 400 here
/// names the cap.
async fn assets_bodies(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
assets_call(&st, "assets.bodies", body).await
}
/// `event_call`'s shape for the asset plane: take the caller's object verbatim, stamp `kind` and
/// `reqId` on it, and map the reply through `respond_assets` rather than `respond_event`.
///
/// The two differ in exactly one way that matters, and it is the reason this is not `event_call`
/// with a different string: `bridge.busy` is a **425** here, and it is the ordinary answer during
/// an import rather than a rare collision. The shard serves one asset request at a time on purpose,
/// so a caller that read busy as an error would abandon a healthy transfer.
async fn assets_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json<Value>) {
let mut obj = match body {
Value::Object(m) => m,
Value::Null => serde_json::Map::new(),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "body must be a JSON object"})),
)
}
};
let req_id = st.rpc.next_req_id();
obj.insert("kind".to_string(), json!(kind));
obj.insert("reqId".to_string(), json!(req_id));
respond_assets(st.rpc.call(&st.shard, Value::Object(obj), &req_id).await)
}
// ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {