diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 5c7d384..825ea4f 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -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, } +/// 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, + Query(q): Query, +) -> 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, + cursor: Option, +} + +/// The bytes, for keys the caller names: `{"keys":[…],"catalog":,"cursor":}`. +/// +/// 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, Json(body): Json) -> 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, Json(body): Json) -> 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) { + 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) -> impl IntoResponse {