# llimphi-wire-escena The **scene** IR that crosses the WASM boundary: a flat list of shapes, paints and **unshaped text**, serialized with postcard, `no_std + alloc`. Phase W4 of [`03_ukupacha/wawa/PLAN-LLIMPHI.md`](../../../03_ukupacha/wawa/PLAN-LLIMPHI.md). ## The problem A wawa app is a `.wasm` module in a sandbox, and the interpreter penalty is measured: **30.3x**. W4 asked where to put the boundary between sandboxed and native. The three possible answers, at 500 widgets: | | fps | widget catalogue | per-app size | |---|---|---|---| | Kernel-side (`llimphi-wire-view`) | 685 | ~15-18 of 41 entries | 12 KB | | **Scene IR** (this crate) | **77** | **all 69 widgets** | ~12 KB + geometry | | Sandbox-side rasterizing | 18 | all 69 widgets | 476 KB | The scene IR doesn't reach native speed, but it delivers 77 fps **with the full catalogue** — comfortably above 60, and 4x better than rasterizing inside the sandbox. The reason: the expensive work (flattening curves, generating strips, rasterizing) is 79-93% of the frame and lands entirely on the native side. ## Text is the one thing that isn't a stroke Deliberately — it is what W4.1 measured: | 500 widgets, live text | app shapes (glyphs in IR) | **kernel shapes (text in IR)** | |---|---|---| | total | 45.92 ms → **21.8 fps** | 14.06 ms → **71 fps** | If the app shaped and shipped glyphs, `parley` would pay the 30.3x penalty and eat 72% of the frame. Shipping **the string**, the kernel shapes natively and text costs 1.09 ms instead of 32.94. Hence `OrdenWire::Texto` carries string + style + rect, and there is **no glyph variant**. The accepted cost: `parley` and `swash` live on the native side — meaning Ring 0 when that side is the kernel. Same bargain already struck for `vello_cpu`. ## Which IR to use ``` llimphi-wire-view tree of BOXES host runs layout AND paints llimphi-wire-escena list of STROKES host only rasterizes ``` `wire-view` is smaller on the wire and lets the host decide the look, but only expresses what its vocabulary names: no gradients, shadows, clipping, z-index or custom paint. Nodegraph, table, terminal and text-editor are out **by construction**. `wire-escena` has no such ceiling because it doesn't talk about widgets: it talks about strokes, and every widget ends in strokes. ## Shape `EscenaWire` is simultaneously the wire format and a **recorder carrying the eight `vello::Scene` signatures** — the same ones `llimphi_cpu::Escena` mirrors. All three matching is what allows picking a backend with a type alias instead of three parallel code paths. ```rust let mut escena = EscenaWire::new(); escena.fill(Fill::NonZero, Affine::IDENTITY, Color::BLACK, None, &rect); escena.texto(Texto { cadena: "hello".into(), estilo: EstiloTextoWire { tamano_px: 14.0, ..Default::default() }, color: [255, 255, 255, 255], ancho_max: None, xf: UbicacionWire::Punto([10.0, 4.0]), hint: true, }); let bytes = postcard::to_allocvec(&escena)?; // this is what crosses ``` On the other side, `llimphi_wawa::reproducir` shapes and rasterizes it natively. ## Two decisions the measurement corrected **Styles are interned.** The first version put all 17 style fields in every text order; at 500 labels text took **half the wire** (31,890 of 64,393 B) — not because of the strings (~8 B each) but from repeating the style. In a real UI style varies by *role*, not per label, so 500 labels yield **one** table entry and a `u16` per order. The price: `append` must remap the child's indices, or a cached subtree repaints with someone else's typeface. **Text location picks its representation.** `UbicacionWire` stores a point when the affine is a pure translation — the normal case, 8 B — and the full matrix when there is rotation or scale — 24 B, exact. Always storing the point would have painted a rotated subtree's text upright *with nothing to warn you*; always storing the matrix costs +17% of the frame for a case that almost never happens. **Images don't travel.** `OrdenWire::Imagen` carries a 64-bit FNV-1a fingerprint of the blob, not the pixels — shipping pixels would be exactly the 768 KB/frame of the "pure sandbox" option W4 rejects. A 4 MB image weighs under 64 B on the wire. Transferring the blob once is a separate channel. ## Measured size `cargo test -p llimphi-wire-escena --release -- --nocapture`, at 480x400: | widgets | orders | bytes | KB | vs shipping pixels | |---|---|---|---|---| | 50 | 100 | 4,729 | 4.6 | **162x** smaller | | 200 | 400 | 18,930 | 18.5 | **41x** smaller | | 500 | 1,000 | 47,430 | 46.3 | **16x** smaller | ## Bare metal ```bash cargo +nightly check -p llimphi-wire-escena --target x86_64-unknown-none \ --no-default-features --features libm -Z build-std=core,alloc ``` Same feature deal as `llimphi-cpu`, for the same reason: this crate's profile must match `kurbo`'s in the same build. Inside the workspace `vello` forces `std`; on wawa there is no vello to unify anything. ## The two ends of the cable This is the cable; the plugs live apart, each on the side it belongs to: | | where | what it does | |---|---|---| | **encoder** | [`llimphi-jaula`](../llimphi-jaula/README.md) | runs the compositor in the sandbox and records here | | **replayer** | [`llimphi-wawa`](../llimphi-wawa/README.md) | deserializes, shapes natively, rasterizes | Neither living in this crate isn't tidiness: **they cannot be compiled together**. `llimphi-text` carries a `compile_error!` preventing it, because they are two processes on two sides of a boundary, and pretending otherwise would hide the one mistake that matters — shaping on the wrong side.