diff --git a/core/src/raster.rs b/core/src/raster.rs index ee8105ac..83b5c9d1 100644 --- a/core/src/raster.rs +++ b/core/src/raster.rs @@ -24,8 +24,13 @@ //! which is exactly a little-endian ABGR u32 (0xAABBGGRR), the spec color //! format, so channel bytes map 1:1. The buffer is treated as opaque: the //! destination alpha is always written back as 255. +//! +//! [`render_scaled_argb`] emits the same pixels as A,R,G,B bytes instead — +//! the byte order the ESP32-P4 PPA SRM consumes as ARGB8888. That lets the +//! ESP firmware present the framebuffer without a per-frame reorder pass: +//! byte placement is fused into the pixel writes, so it costs nothing extra. -use crate::spec::{self, draw_op, SCREEN_H, SCREEN_W}; +use crate::spec::{self, draw_op}; use crate::{TexView, Ui}; pub const MAX_RENDER_SCALE: u32 = 4; @@ -80,14 +85,27 @@ fn channels(color: u32) -> (u32, u32, u32, u32) { /// src-over blend one pixel (integer, round-to-nearest). Caller guarantees /// (x, y) inside the framebuffer. Destination treated as opaque. +/// ARGB=false writes bytes [R,G,B,A] (spec layout, golden-pinned); +/// ARGB=true writes [A,R,G,B] for the ESP32-P4 PPA. Only byte placement +/// differs — the blend math and the opaque destination are identical. #[inline] -fn blend_px(fb: &mut [u8], stride: i32, x: i32, y: i32, r: u32, g: u32, b: u32, a: u32) { +fn blend_px( + fb: &mut [u8], + stride: i32, + x: i32, + y: i32, + r: u32, + g: u32, + b: u32, + a: u32, +) { let o = ((y * stride + x) * 4) as usize; + let (ri, gi, bi, ai) = if ARGB { (1, 2, 3, 0) } else { (0, 1, 2, 3) }; if a >= 255 { - fb[o] = r as u8; - fb[o + 1] = g as u8; - fb[o + 2] = b as u8; - fb[o + 3] = 255; + fb[o + ri] = r as u8; + fb[o + gi] = g as u8; + fb[o + bi] = b as u8; + fb[o + ai] = 255; return; } if a == 0 { @@ -95,21 +113,21 @@ fn blend_px(fb: &mut [u8], stride: i32, x: i32, y: i32, r: u32, g: u32, b: u32, } let ia = 255 - a; let mix = |s: u32, d: u8| ((s * a + d as u32 * ia + 127) / 255) as u8; - fb[o] = mix(r, fb[o]); - fb[o + 1] = mix(g, fb[o + 1]); - fb[o + 2] = mix(b, fb[o + 2]); - fb[o + 3] = 255; + fb[o + ri] = mix(r, fb[o + ri]); + fb[o + gi] = mix(g, fb[o + gi]); + fb[o + bi] = mix(b, fb[o + bi]); + fb[o + ai] = 255; } /// Fill an already-clipped span rect with one flat color. -fn fill_rect(fb: &mut [u8], stride: i32, c: Clip, color: u32) { +fn fill_rect(fb: &mut [u8], stride: i32, c: Clip, color: u32) { let (r, g, b, a) = channels(color); if a == 0 { return; } for y in c.y0..c.y1 { for x in c.x0..c.x1 { - blend_px(fb, stride, x, y, r, g, b, a); + blend_px::(fb, stride, x, y, r, g, b, a); } } } @@ -129,26 +147,43 @@ fn lerp_color(from: u32, to: u32, f: f32) -> u32 { // ---- the interpreter -------------------------------------------------------------- -/// Execute `words` into the byte-exact legacy 480x272 surface. +/// Execute `words` into the UI's logical viewport at one sample per pixel. +/// The stock viewport remains 480x272, preserving the legacy golden output. pub fn render(ui: &Ui, words: &[u32], fb: &mut [u8]) { render_scaled(ui, words, fb, 1); } /// Execute `words` (a full DrawList) directly into an integer-scaled physical -/// surface. `fb` must contain exactly `SCREEN_W*scale × SCREEN_H*scale` RGBA8 -/// pixels. Geometry, clips and gradient/triangle sample points are evaluated -/// at physical resolution; textures are sampled over that destination and -/// font coverage accounts for the atlas's own raster density. +/// surface. `fb` must contain exactly `viewport_width*scale × +/// viewport_height*scale` RGBA8 pixels. Geometry, clips and gradient/triangle +/// sample points are evaluated at physical resolution; textures are sampled +/// over that destination and font coverage accounts for the atlas's own raster +/// density. /// `ui` supplies font atlases and textures. The framebuffer is cleared to /// opaque black first (the PSP host clears the draw buffer the same way). pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { + render_scaled_impl::(ui, words, fb, scale); +} + +/// Same as [`render_scaled`] but emits A,R,G,B bytes per pixel — the in-memory +/// order the ESP32-P4 PPA SRM expects for ARGB8888 input. Byte-identical to +/// shuffling the RGBA output, but fused into the rasterizer so the firmware +/// skips its per-frame reorder copy. Output determinism matches the RGBA path +/// pixel-for-pixel; only the byte placement differs. +pub fn render_scaled_argb(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { + render_scaled_impl::(ui, words, fb, scale); +} + +fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { assert!( (1..=MAX_RENDER_SCALE).contains(&scale), "render scale must be 1 through 4" ); let scale = scale as i32; - let width = SCREEN_W as i32 * scale; - let height = SCREEN_H as i32 * scale; + let (viewport_w, viewport_h) = ui.viewport(); + let width = viewport_w as i32 * scale; + let height = viewport_h as i32 * scale; + assert!(width > 0 && height > 0, "viewport must have positive dimensions"); let expected = width as usize * height as usize * 4; assert_eq!( fb.len(), @@ -161,12 +196,19 @@ pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { x1: width, y1: height, }; - // Clear: opaque black. + // Clear: opaque black (alpha first in ARGB mode). for px in fb.chunks_exact_mut(4) { - px[0] = 0; - px[1] = 0; - px[2] = 0; - px[3] = 255; + if ARGB { + px[0] = 255; + px[1] = 0; + px[2] = 0; + px[3] = 0; + } else { + px[0] = 0; + px[1] = 0; + px[2] = 0; + px[3] = 255; + } } let mut stack: [Clip; 32] = [screen; 32]; @@ -189,7 +231,7 @@ pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { y1: y + h, }); if c.x0 < c.x1 && c.y0 < c.y1 { - fill_rect(fb, width, c, words[i + 3]); + fill_rect::(fb, width, c, words[i + 3]); } i += 4; } @@ -199,7 +241,7 @@ pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { } let (x, y) = xy(words[i + 1], scale); let (w, h) = wh(words[i + 2], scale); - grad_rect( + grad_rect::( fb, width, clip, @@ -223,7 +265,7 @@ pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { if i + 3 + 2 * n > words.len() { return; } - glyph_run( + glyph_run::( ui, fb, width, @@ -239,7 +281,7 @@ pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { if i + 9 > words.len() { return; } - tex_quad(ui, fb, width, scale, clip, &words[i + 1..i + 9]); + tex_quad::(ui, fb, width, scale, clip, &words[i + 1..i + 9]); i += 9; } draw_op::SCISSOR => { @@ -273,14 +315,14 @@ pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { if i + 7 > words.len() { return; } - tri(fb, width, scale, clip, &words[i + 1..i + 7]); + tri::(fb, width, scale, clip, &words[i + 1..i + 7]); i += 7; } draw_op::TEX_TRI => { if i + 12 > words.len() { return; } - tex_tri(ui, fb, width, scale, clip, &words[i + 1..i + 12]); + tex_tri::(ui, fb, width, scale, clip, &words[i + 1..i + 12]); i += 12; } // The op set is closed per DrawList version; anything else means @@ -293,7 +335,7 @@ pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { // ---- GRAD_RECT: per-axis gouraud lerp --------------------------------------------- #[allow(clippy::too_many_arguments)] -fn grad_rect( +fn grad_rect( fb: &mut [u8], stride: i32, clip: Clip, @@ -332,7 +374,7 @@ fn grad_rect( continue; } for py in c.y0..c.y1 { - blend_px(fb, stride, px, py, r, g, bb, al); + blend_px::(fb, stride, px, py, r, g, bb, al); } } } else { @@ -345,7 +387,7 @@ fn grad_rect( continue; } for px in c.x0..c.x1 { - blend_px(fb, stride, px, py, r, g, bb, al); + blend_px::(fb, stride, px, py, r, g, bb, al); } } } @@ -360,7 +402,7 @@ fn orient(ax: i64, ay: i64, bx: i64, by: i64, px: i64, py: i64) -> i64 { (bx - ax) * (py - ay) - (by - ay) * (px - ax) } -fn tri(fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u32]) { +fn tri(fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u32]) { let (x0, y0) = xy(p[0], scale); let (x1, y1) = xy(p[1], scale); let (x2, y2) = xy(p[2], scale); @@ -402,13 +444,13 @@ fn tri(fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u32]) { continue; } if flat { - blend_px(fb, stride, px, py, r0, g0, b0, a0); + blend_px::(fb, stride, px, py, r0, g0, b0, a0); } else { // Integer barycentric interpolation, round-to-nearest. let mix = |v0: u32, v1: u32, v2: u32| { ((v0 as i64 * w0 + v1 as i64 * w1 + v2 as i64 * w2 + half) / area) as u32 }; - blend_px( + blend_px::( fb, stride, px, @@ -435,7 +477,7 @@ fn coverage_index(destination_px: i32, output_scale: i32, atlas_density: i32, li } #[allow(clippy::too_many_arguments)] -fn glyph_run( +fn glyph_run( ui: &Ui, fb: &mut [u8], stride: i32, @@ -480,7 +522,7 @@ fn glyph_run( let cx = coverage_index(px - gx, output_scale, atlas_density, coverage_w); let cov = row[cx] as u32; if cov != 0 { - blend_px(fb, stride, px, py, r, g, b, (a * cov + 127) / 255); + blend_px::(fb, stride, px, py, r, g, b, (a * cov + 127) / 255); } } } @@ -565,7 +607,14 @@ fn sample_linear(view: &TexView, u: f32, v: f32) -> Option<(u32, u32, u32, u32)> // ---- TEX_TRI: barycentric textured triangle (affine UV; nearest, or integer // bilinear when the texture carries the linear flag) -------------------- -fn tex_tri(ui: &Ui, fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u32]) { +fn tex_tri( + ui: &Ui, + fb: &mut [u8], + stride: i32, + scale: i32, + clip: Clip, + p: &[u32], +) { let handle = p[0] as i32; let (x0, y0) = xy(p[1], scale); let (u0, v0) = (f32::from_bits(p[2]), f32::from_bits(p[3])); @@ -635,7 +684,7 @@ fn tex_tri(ui: &Ui, fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u32 b = (b * mb + 127) / 255; a = (a * ma + 127) / 255; } - blend_px(fb, stride, px, py, r, g, b, a); + blend_px::(fb, stride, px, py, r, g, b, a); } } } @@ -643,7 +692,14 @@ fn tex_tri(ui: &Ui, fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u32 // ---- TEX_QUAD: textured rect (nearest, or integer bilinear when the texture // carries the linear flag) -------------------------------------------------------- -fn tex_quad(ui: &Ui, fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u32]) { +fn tex_quad( + ui: &Ui, + fb: &mut [u8], + stride: i32, + scale: i32, + clip: Clip, + p: &[u32], +) { let handle = p[0] as i32; let (x, y) = xy(p[1], scale); let (w, h) = wh(p[2], scale); @@ -696,7 +752,7 @@ fn tex_quad(ui: &Ui, fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u3 b = (b * mb + 127) / 255; a = (a * ma + 127) / 255; } - blend_px(fb, stride, px, py, r, g, b, a); + blend_px::(fb, stride, px, py, r, g, b, a); } } } @@ -714,11 +770,11 @@ mod tests { } fn framebuffer(scale: u32) -> Vec { - vec![0; SCREEN_W as usize * scale as usize * SCREEN_H as usize * scale as usize * 4] + vec![0; spec::SCREEN_W as usize * scale as usize * spec::SCREEN_H as usize * scale as usize * 4] } fn rgba(fb: &[u8], scale: u32, x: usize, y: usize) -> [u8; 4] { - let width = SCREEN_W as usize * scale as usize; + let width = spec::SCREEN_W as usize * scale as usize; let offset = (y * width + x) * 4; fb[offset..offset + 4].try_into().unwrap() } @@ -752,6 +808,56 @@ mod tests { assert_eq!(legacy, scaled); } + #[test] + fn argb_output_is_rgba_with_channels_reordered() { + let ui = Ui::new(); + let words = vec![ + draw_op::RECT, + xy_word(3, 4), + wh_word(7, 5), + 0xff33_2211, + // Semi-transparent rect exercises the dst-read blend path, which + // must read A,R,G,B offsets in ARGB mode. + draw_op::RECT, + xy_word(4, 5), + wh_word(5, 3), + 0x8033_2211, + draw_op::GRAD_RECT, + xy_word(12, 4), + wh_word(8, 6), + 0xff00_0000, + 0xffff_ffff, + spec::GradDir::ToRight as u32, + draw_op::TRI, + xy_word(24, 4), + xy_word(30, 10), + xy_word(20, 10), + 0xff00_00ff, + 0xff00_ff00, + 0xffff_0000, + ]; + let mut rgba_fb = framebuffer(1); + let mut argb_fb = framebuffer(1); + render_scaled(&ui, &words, &mut rgba_fb, 1); + render_scaled_argb(&ui, &words, &mut argb_fb, 1); + for px in 0..rgba_fb.len() / 4 { + let o = px * 4; + assert_eq!(argb_fb[o], rgba_fb[o + 3], "alpha at pixel {px}"); + assert_eq!(argb_fb[o + 1], rgba_fb[o], "red at pixel {px}"); + assert_eq!(argb_fb[o + 2], rgba_fb[o + 1], "green at pixel {px}"); + assert_eq!(argb_fb[o + 3], rgba_fb[o + 2], "blue at pixel {px}"); + } + } + + #[test] + fn custom_viewport_controls_framebuffer_dimensions() { + let mut ui = Ui::new(); + ui.set_viewport(7.0, 5.0); + let mut fb = vec![0; 7 * 2 * 5 * 2 * 4]; + render_scaled(&ui, &[], &mut fb, 2); + assert!(fb.chunks_exact(4).all(|pixel| pixel == [0, 0, 0, 255])); + } + #[test] fn geometry_gradients_triangles_and_scissors_use_physical_samples() { let ui = Ui::new(); diff --git a/host-sim/sim.ts b/host-sim/sim.ts index 38683fc9..765fd06a 100644 --- a/host-sim/sim.ts +++ b/host-sim/sim.ts @@ -151,6 +151,13 @@ export interface SimWorld { getTree: () => unknown; } +export interface SimViewportOptions { + width?: number; + height?: number; + rasterDensity?: number; + renderScale?: number; +} + /** * Boot a fresh world: fresh wasm core, fresh bundle eval, host globals * (ui/__pak/__simHz/effect trace/DevTools transport) installed before eval — @@ -163,11 +170,13 @@ export async function bootWorld( hz: number, extraGlobals?: Record, mutateOps?: (ops: Record) => void, + viewport: SimViewportOptions = {}, ): Promise { ensureBuilt(WASM_PATH, [process.execPath, "scripts/wasm.ts"]); ensureBuilt(DIST + app + ".js", [process.execPath, "scripts/build.ts", app]); if (!wasmBytes) wasmBytes = await Bun.file(WASM_PATH).arrayBuffer(); - const wasm = await createWasmUi(wasmBytes); + const wasm = await createWasmUi(wasmBytes, viewport); + const renderScale = viewport.renderScale ?? 1; const g = globalThis as Record; const effects: EffectEvent[] = []; const inbox: string[] = []; @@ -200,7 +209,7 @@ export async function bootWorld( return { frame, tick: wasm.tick, - render: () => wasm.render(), + render: () => wasm.renderScaled(renderScale), ticksPerFrame: TICKS_PER_SECOND / hz, hz, effects, diff --git a/host-web/engine.js b/host-web/engine.js index 7dd794b1..d9a2df54 100644 --- a/host-web/engine.js +++ b/host-web/engine.js @@ -5,7 +5,7 @@ // installs the demo's .pak as globalThis.__pak, evals the demo bundle // (which mounts the app and installs globalThis.frame), then drives a // fixed-timestep 60 Hz loop: frame(buttons) -> ui_tick -> ui_render -> -// putImageData onto a 480x272 canvas (CSS-scaled, pixelated). +// putImageData onto a viewport-sized canvas (CSS-scaled, pixelated). // // Loop shape (dt clamp 250 ms, max 4 catch-up steps) is copied from the // proven dreamcart web/engine.js driver. @@ -14,9 +14,23 @@ // arrows = d-pad Enter / Z = CIRCLE X = CROSS // A = SQUARE S = TRIANGLE Shift = SELECT Space = START -import { createWasmUi, FB_W, FB_H } from "./wasm-ops.js"; +import { createWasmUi, FB_W as DEFAULT_FB_W, FB_H as DEFAULT_FB_H } from "./wasm-ops.js"; import { drawHud, wasmMemoryBytes } from "./hud.js"; +const query = new URLSearchParams(location.search); +function positiveIntParam(name, fallback, max = 32000) { + const value = Number(query.get(name)); + return Number.isInteger(value) && value > 0 && value <= max ? value : fallback; +} +const LOGICAL_W = positiveIntParam("width", DEFAULT_FB_W); +const LOGICAL_H = positiveIntParam("height", DEFAULT_FB_H); +const RENDER_SCALE = positiveIntParam("scale", 1, 4); +const RASTER_DENSITY = positiveIntParam("density", RENDER_SCALE, 255); +const FB_W = LOGICAL_W * RENDER_SCALE; +const FB_H = LOGICAL_H * RENDER_SCALE; +const CUSTOM_VIEWPORT = + LOGICAL_W !== DEFAULT_FB_W || LOGICAL_H !== DEFAULT_FB_H || RENDER_SCALE !== 1; + // spec/spec.ts BTN (plain module — keep the literal in sync with the spec). export const BTN = { SELECT: 0x0001, @@ -167,7 +181,7 @@ function devtoolsScreenshot() { shot.height = FB_H; const sctx = shot.getContext("2d"); const img = sctx.createImageData(FB_W, FB_H); - img.data.set(wasm.render()); + img.data.set(wasm.renderScaled(RENDER_SCALE)); sctx.putImageData(img, 0, 0); const frame = globalThis.__pocketDevtools ? globalThis.__pocketDevtools.frame : 0; dtSend(JSON.stringify({ t: "screenshot", frame, data: shot.toDataURL("image/png") })); @@ -209,7 +223,8 @@ function safeFrame() { function blit() { if (!wasm || !ctx) return; - imageData.data.set(wasm.render()); + const pixels = wasm.renderScaled(RENDER_SCALE); + imageData.data.set(pixels); ctx.putImageData(imageData, 0, 0); drawHud(ctx, FB_W, FB_H, hudFps, hudMem); // built-in on-canvas overlay } @@ -287,6 +302,15 @@ export async function mount(theCanvas, opts = {}) { canvas = theCanvas; canvas.width = FB_W; canvas.height = FB_H; + if (CUSTOM_VIEWPORT) { + // Custom panels present at native framebuffer size; the stylesheet keeps + // its 2x-scaled default for the stock 480x272 host. height:auto preserves + // the framebuffer aspect ratio when max-width makes the preview + // responsive inside a narrower browser window. + canvas.style.width = `${FB_W}px`; + canvas.style.height = "auto"; + canvas.style.aspectRatio = `${FB_W} / ${FB_H}`; + } ctx = canvas.getContext("2d"); ctx.imageSmoothingEnabled = false; imageData = ctx.createImageData(FB_W, FB_H); @@ -296,11 +320,15 @@ export async function mount(theCanvas, opts = {}) { held = 0; nubHeld.clear(); }); - const hzParam = Number(new URLSearchParams(location.search).get("hz")); + const hzParam = Number(query.get("hz")); if (VALID_HZ.includes(hzParam)) simHz = hzParam; const res = await fetch("pocketjs.wasm"); if (!res.ok) throw new Error("pocketjs.wasm not found — run: bun scripts/wasm.ts"); - wasm = await createWasmUi(await res.arrayBuffer()); + wasm = await createWasmUi(await res.arrayBuffer(), { + width: LOGICAL_W, + height: LOGICAL_H, + rasterDensity: RASTER_DENSITY, + }); connectDevtools(); logSink("PocketJS wasm ready"); } @@ -314,7 +342,7 @@ export async function load(name, opts = {}) { if (!wasm) throw new Error("mount() first"); stop(); frameCb = null; - wasm.init(); // fresh Ui: tree/styles/atlases/textures all reset + wasm.init(RASTER_DENSITY); // fresh Ui: tree/styles/atlases/textures all reset // Host contract (see demos/hero/main.tsx): both globals BEFORE eval, reset // EVERY load so nothing stale leaks across reloads. globalThis.ui = wasm.ops; diff --git a/host-web/index.html b/host-web/index.html index 4a26a16f..3f8bc6b2 100644 --- a/host-web/index.html +++ b/host-web/index.html @@ -1,5 +1,5 @@ - @@ -15,7 +15,7 @@ } h1 { font-size: 16px; margin: 0; color: #e2e8f0; } #screen { - width: 960px; height: 544px; /* 2x logical 480x272 */ + width: 960px; height: auto; /* engine sets the native framebuffer width */ max-width: 100%; image-rendering: pixelated; background: #000; border: 1px solid #1e293b; border-radius: 6px; diff --git a/host-web/wasm-ops.d.ts b/host-web/wasm-ops.d.ts index 1ef0bcef..92f4ecea 100644 --- a/host-web/wasm-ops.d.ts +++ b/host-web/wasm-ops.d.ts @@ -15,7 +15,7 @@ export interface WasmUi { tick(): void; /** Hash the current DrawList without rasterizing it; null for an older wasm. */ drawHash: (() => bigint) | null; - /** Rasterize the byte-exact RGBA8 480x272 framebuffer as a fresh view. */ + /** Rasterize the byte-exact RGBA8 framebuffer at the logical viewport size. */ render(): Uint8Array; /** Rasterize directly at an integer physical scale from 1 through 4. */ renderScaled(scale: number): Uint8Array; @@ -23,6 +23,7 @@ export interface WasmUi { export declare function createWasmUi( wasm: ArrayBuffer | Uint8Array | WebAssembly.Module, + options?: { width?: number; height?: number; rasterDensity?: number }, ): Promise; export declare function uploadPackImages( diff --git a/host-web/wasm-ops.js b/host-web/wasm-ops.js index 3ffa2c1d..e0ce5030 100644 --- a/host-web/wasm-ops.js +++ b/host-web/wasm-ops.js @@ -7,8 +7,8 @@ // // ABI (see wasm/src/lib.rs): one export per ui.* op; strings/buffers cross // via linear memory — ui_alloc(len) -> ptr, write bytes, call, ui_free. -// ui_render() returns the byte-exact RGBA8 480x272 framebuffer pointer; -// ui_render_scaled(n) renders the logical DrawList directly at n×. +// ui_render() returns the byte-exact RGBA8 framebuffer pointer at the +// logical viewport size; ui_render_scaled(n) renders the DrawList at n×. export const FB_W = 480; export const FB_H = 272; @@ -23,7 +23,7 @@ const encoder = new TextEncoder(); * * @param {ArrayBuffer | Uint8Array | WebAssembly.Module} wasm */ -export async function createWasmUi(wasm) { +export async function createWasmUi(wasm, options = {}) { const source = wasm instanceof WebAssembly.Module ? wasm : await WebAssembly.compile(wasm); const instance = await WebAssembly.instantiate(source, {}); const ex = instance.exports; @@ -35,10 +35,20 @@ export async function createWasmUi(wasm) { return value; } - const init = (rasterDensity = 1) => { + const viewportWidth = integerInRange(options.width ?? FB_W, "viewport width", 1, 32000); + const viewportHeight = integerInRange(options.height ?? FB_H, "viewport height", 1, 32000); + const initialDensity = integerInRange(options.rasterDensity ?? 1, "rasterDensity", 1, 255); + + const init = (rasterDensity = initialDensity) => { ex.ui_init(integerInRange(rasterDensity, "rasterDensity", 1, 255)); + // Older wasm binaries predate ui_set_viewport (same convention as + // drawHash): tolerate them at the stock size, fail loud otherwise. + if (ex.ui_set_viewport) ex.ui_set_viewport(viewportWidth, viewportHeight); + else if (viewportWidth !== FB_W || viewportHeight !== FB_H) { + throw new Error("this pocketjs.wasm predates ui_set_viewport — rebuild it: bun scripts/wasm.ts"); + } }; - init(); + init(initialDensity); // Copy bytes into wasm scratch, run fn(ptr, len), free. Views are rebuilt // per call: memory.buffer is detached whenever linear memory grows. @@ -56,6 +66,7 @@ export async function createWasmUi(wasm) { /** @type {import("../src/host.ts").HostOps} */ const ops = { + __viewport: { w: viewportWidth, h: viewportHeight }, createNode: (type) => ex.ui_create_node(type), destroyNode: (id) => ex.ui_destroy_node(id), insertBefore: (parent, child, anchor) => ex.ui_insert_before(parent, child, anchor), @@ -109,7 +120,7 @@ export async function createWasmUi(wasm) { return new Uint8Array( ex.memory.buffer, ptr, - FB_W * scale * FB_H * scale * 4, + viewportWidth * scale * viewportHeight * scale * 4, ); } @@ -122,7 +133,7 @@ export async function createWasmUi(wasm) { tick: () => ex.ui_tick(), /** Hash the current DrawList without rasterizing it (BigInt, wasm i64). */ drawHash: ex.ui_draw_hash ? () => ex.ui_draw_hash() : null, - /** Rasterize the byte-exact legacy 480x272 framebuffer. */ + /** Rasterize the byte-exact framebuffer at the logical viewport size. */ render() { return framebufferView(ex.ui_render(), 1); }, diff --git a/package.json b/package.json index 0404d759..0ffe009b 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "e2e": "bun test/e2e-ppsspp.ts", "launcher": "bun scripts/launcher.ts", "e2e:launcher": "bun test/e2e-launcher-ppsspp.ts", - "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test test/release-check.test.ts test/release-notes.test.ts test/platform-contracts.test.ts test/widget-args.test.ts test/ipod-nano.test.ts test/note.test.ts test/site-stage.test.ts test/host-build-inputs.test.ts test/platform-runtime.test.ts test/app-check.test.ts test/font-bake.test.ts test/touch.test.ts test/vita-package.test.ts test/psp-toolchain.test.ts test/cli.test.ts test/npm-package.test.ts test/video-outro.test.ts test/osk-layout.test.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/cursor.test.ts test/action-handler-vue-vapor.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts && bun scripts/build.ts im-main >/dev/null && bun test --conditions=browser test/im-sim.test.ts && bun scripts/launcher.ts covers >/dev/null && bun test --conditions=browser test/launcher-sim.test.ts", + "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test test/release-check.test.ts test/release-notes.test.ts test/platform-contracts.test.ts test/widget-args.test.ts test/ipod-nano.test.ts test/note.test.ts test/site-stage.test.ts test/host-build-inputs.test.ts test/platform-runtime.test.ts test/app-check.test.ts test/font-bake.test.ts test/touch.test.ts test/vita-package.test.ts test/psp-toolchain.test.ts test/cli.test.ts test/npm-package.test.ts test/video-outro.test.ts test/osk-layout.test.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/cursor.test.ts test/action-handler-vue-vapor.test.ts test/vue-vapor-dom.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts && bun scripts/build.ts im-main >/dev/null && bun test --conditions=browser test/im-sim.test.ts && bun scripts/launcher.ts covers >/dev/null && bun test --conditions=browser test/launcher-sim.test.ts", "tape": "bun scripts/tape.ts", "tape:check": "bun scripts/tape.ts replay hero-main test/tapes/hero-main.tape.json --assert test/tapes/hero-main.hashes.json", "devtools": "bun scripts/devtools.ts", diff --git a/scripts/build.ts b/scripts/build.ts index aa7ceb7d..b4acd950 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -455,6 +455,9 @@ const result = await Bun.build({ __POCKET_HOST_ABI__: String(buildPlan?.target.hostAbi ?? 0), __POCKET_FEATURES__: JSON.stringify(buildPlan?.features ?? {}), __POCKET_PIXEL_RATIO__: String(rasterDensity), + ...(framework === "vue-vapor" + ? { document: "globalThis.__pocketDocument" } + : {}), }, minify: false, sourcemap: "none", diff --git a/src/components-vue-vapor.ts b/src/components-vue-vapor.ts index 6edac511..a84596ab 100644 --- a/src/components-vue-vapor.ts +++ b/src/components-vue-vapor.ts @@ -118,7 +118,11 @@ function slotDefault(slots: SlotBag, ...args: unknown[]): unknown { } function withNativeTextDocument(fn: () => T): T { - const doc = (globalThis as { document?: unknown }).document as + // Patch the guest's document, never the embedding page's: vue-vapor builds + // alias the guest `document` to globalThis.__pocketDocument (see + // installVueVaporDom), and the real browser document must stay untouched. + const g = globalThis as { __pocketDocument?: unknown; document?: unknown }; + const doc = (g.__pocketDocument ?? g.document) as | { createTextNode?: (value?: string) => unknown; createComment?: (value?: string) => unknown; diff --git a/src/devtools.ts b/src/devtools.ts index 92b0aedf..fa6b2293 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -469,6 +469,39 @@ interface TreeNodeJson { k?: TreeNodeJson[]; } +function isTreeMirror(value: unknown): value is NodeMirror { + if (value == null || typeof value !== "object") return false; + const candidate = value as Partial; + return typeof candidate.id === "number" && typeof candidate.type === "number"; +} + +/** + * Vue Vapor may place an internal fragment wrapper in a native node's + * `children` while swapping a dynamic branch. The wrapper is not a + * NodeMirror: it owns a possibly nested `nodes` array instead. DevTools is + * observational and must never stop the host frame loop because of that + * renderer-private shape, so flatten wrappers and ignore non-node metadata. + */ +function forEachTreeMirror(value: unknown, visit: (node: NodeMirror) => void): void { + if (Array.isArray(value)) { + for (const entry of value) forEachTreeMirror(entry, visit); + return; + } + if (isTreeMirror(value)) { + visit(value); + return; + } + if (value != null && typeof value === "object") { + const nodes = (value as { nodes?: unknown }).nodes; + if (nodes !== undefined) forEachTreeMirror(nodes, visit); + } +} + +function forEachTreeChild(node: NodeMirror, visit: (child: NodeMirror) => void): void { + const children = Array.isArray(node.children) ? node.children : []; + for (const child of children) forEachTreeMirror(child, visit); +} + function serializeNode(node: NodeMirror): TreeNodeJson { const out: TreeNodeJson = { i: node.id, t: node.domTag ?? String(node.type) }; if (node.debugName) out.n = node.debugName; @@ -476,21 +509,19 @@ function serializeNode(node: NodeMirror): TreeNodeJson { if (typeof cls === "string" && cls) out.c = cls; if (node.text) out.x = node.text.length > 80 ? node.text.slice(0, 79) + "…" : node.text; const kids: TreeNodeJson[] = []; - if (node.children) { - for (const child of node.children) { - if (child.domNodeType === 8) continue; // comment anchors: invisible noise - kids.push(serializeNode(child)); - } - } + forEachTreeChild(node, (child) => { + if (child.domNodeType === 8) return; // comment anchors: invisible noise + kids.push(serializeNode(child)); + }); if (kids.length) out.k = kids; return out; } function countNodes(node: NodeMirror): number { let n = 1; - if (node.children) { - for (const child of node.children) n += countNodes(child); - } + forEachTreeChild(node, (child) => { + n += countNodes(child); + }); return n; } diff --git a/src/index-vue-vapor.ts b/src/index-vue-vapor.ts index b72905b2..21b15752 100644 --- a/src/index-vue-vapor.ts +++ b/src/index-vue-vapor.ts @@ -2,7 +2,7 @@ import "./prelude.ts"; -import { detectHost, installFrameHandler, installHost, type HostOps } from "./host.ts"; +import { detectHost, hostViewport, installFrameHandler, installHost, type HostOps } from "./host.ts"; import { initDevtools, wrapFrameHandler } from "./devtools.ts"; import { createElement, @@ -123,14 +123,17 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi } } + const viewport = hostViewport(host.ops); + const layerW = viewport?.w ?? SCREEN_W; + const layerH = viewport?.h ?? SCREEN_H; const appRoot = createLayer({ - width: SCREEN_W, - height: SCREEN_H, + width: layerW, + height: layerH, overflow: ENUMS.Overflow.Hidden, }); const overlayRoot = createLayer({ - width: SCREEN_W, - height: SCREEN_H, + width: layerW, + height: layerH, posType: ENUMS.PosType.Absolute, insetT: 0, insetR: 0, diff --git a/src/vue-vapor-dom.ts b/src/vue-vapor-dom.ts index 0cfad5a3..c72beb7b 100644 --- a/src/vue-vapor-dom.ts +++ b/src/vue-vapor-dom.ts @@ -18,6 +18,16 @@ interface TemplateLike { innerHTML: string; } +interface PocketDocumentLike { + createElement(tag: string): TemplateLike | NodeMirror; + createElementNS(namespace: string, tag: string): NodeMirror; + createTextNode(value?: string): NodeMirror; + createComment(value?: string): NodeMirror; + querySelector(): null; + addEventListener(): void; + removeEventListener(): void; +} + function parseAttrs(raw: string, node: NodeMirror): void { const re = /([A-Za-z_:][-A-Za-z0-9_:]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; let match: RegExpExecArray | null; @@ -60,6 +70,28 @@ function createTemplate(): TemplateLike { }; } +function createPocketDocument(): PocketDocumentLike { + return { + createElement(tag: string) { + return tag === "template" ? createTemplate() : createElement(tag.toLowerCase()); + }, + createElementNS(_namespace: string, tag: string) { + return createElement(tag.toLowerCase()); + }, + createTextNode(value = "") { + return createTextNode(value); + }, + createComment(value = "") { + return createCommentNode(value); + }, + querySelector() { + return null; + }, + addEventListener() {}, + removeEventListener() {}, + }; +} + function makeDomClass(predicate: (value: unknown) => boolean): unknown { return class { static [Symbol.hasInstance](value: unknown): boolean { @@ -102,6 +134,7 @@ export function installVueVaporDom(): void { Text?: unknown; Comment?: unknown; window?: unknown; + __pocketDocument?: PocketDocumentLike; }; installDomClass(g as Record, "Node", isNativeNode); @@ -112,25 +145,10 @@ export function installVueVaporDom(): void { installDomClass(g as Record, "Comment", (value) => isNativeNode(value) && value.domNodeType === 8); if (!g.window) g.window = globalThis; - if (!g.document) { - g.document = { - createElement(tag: string) { - return tag === "template" ? createTemplate() : createElement(tag.toLowerCase()); - }, - createElementNS(_ns: string, tag: string) { - return createElement(tag.toLowerCase()); - }, - createTextNode(value = "") { - return createTextNode(value); - }, - createComment(value = "") { - return createCommentNode(value); - }, - querySelector() { - return null; - }, - addEventListener() {}, - removeEventListener() {}, - }; - } + // The browser HostWeb and the PocketJS guest share one JavaScript global, + // but they must not share a DOM. Vue Vapor's compiled guest references this + // facade through a build-time `document` alias; the real browser document + // remains available to engine.js for its canvas and controls. + g.__pocketDocument = createPocketDocument(); + if (!g.document) g.document = g.__pocketDocument; } diff --git a/test/devtools.test.ts b/test/devtools.test.ts index 4799258c..848c26d1 100644 --- a/test/devtools.test.ts +++ b/test/devtools.test.ts @@ -17,7 +17,14 @@ import { installHost, type Host, type HostOps } from "../src/host.ts"; import { render as publicRender } from "../src/index.ts"; import { expandTape, fmt, type Tape } from "../src/devtools.ts"; import { onFrame } from "../src/lifecycle.ts"; -import { createComponent, createTextNode, insertNode, resetRendererState, type NodeMirror } from "../src/renderer.ts"; +import { + createComponent, + createTextNode, + insertNode, + resetRendererState, + rootMirror, + type NodeMirror, +} from "../src/renderer.ts"; import { resetStyles } from "../src/styles.ts"; import { resetInput } from "../src/input.ts"; import { resetPack } from "../src/pak.ts"; @@ -170,6 +177,28 @@ describe("tree + semantic names", () => { expect(card.n).toBe("Card"); expect(card.k![0].x).toBe("hello world"); }); + + test("tree traversal tolerates Vue Vapor fragment wrappers", () => { + mountApp(() => View({ debugName: "PlaybackStatus" })); + const playbackStatus = rootMirror.children[0].children[0]; + const paused = createTextNode("PAUSED"); + + // Vue Vapor's dynamic conditional temporarily represents the swapped + // branch as a fragment wrapper. It is not a native node and therefore + // has `nodes`, not `children`. + (playbackStatus.children as unknown[]).push({ nodes: [[paused]] }); + + push({ t: "getTree" }); + expect(() => frame()).not.toThrow(); + const trees = sent("tree"); + expect(trees.length).toBeGreaterThan(0); + expect(JSON.stringify(trees[trees.length - 1])).toContain("PAUSED"); + + // Periodic stats use a separate traversal and must stay safe too. + expect(() => { + for (let index = 0; index < 30; index++) frame(); + }).not.toThrow(); + }); }); describe("pause / step / resume", () => { diff --git a/test/vue-vapor-dom.test.ts b/test/vue-vapor-dom.test.ts new file mode 100644 index 00000000..3d3a90c0 --- /dev/null +++ b/test/vue-vapor-dom.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { installHost, type HostOps } from "../src/host.ts"; +import { isNativeNode } from "../src/native-tree.ts"; +import { installVueVaporDom } from "../src/vue-vapor-dom.ts"; + +const g = globalThis as Record; +const domGlobals = [ + "document", + "window", + "Node", + "Element", + "HTMLElement", + "Text", + "Comment", + "__pocketDocument", +] as const; +const originals = new Map(domGlobals.map((name) => [name, g[name]])); + +afterEach(() => { + for (const name of domGlobals) { + const original = originals.get(name); + if (original === undefined) delete g[name]; + else g[name] = original; + } +}); + +describe("Vue Vapor guest DOM", () => { + test("uses a Pocket document without replacing an existing browser document", () => { + let nextId = 2; + installHost({ + kind: "injected", + target: "test", + strict: true, + ops: { + createNode: () => nextId++, + setText() {}, + } as unknown as HostOps, + }); + + const browserDocument = { kind: "browser-document" }; + g.document = browserDocument; + installVueVaporDom(); + + expect(g.document).toBe(browserDocument); + expect(g.__pocketDocument).not.toBe(browserDocument); + + const pocketDocument = g.__pocketDocument as { + createTextNode(value: string): unknown; + }; + const text = pocketDocument.createTextNode("PAUSED") as { text?: string; children?: unknown[] }; + expect(isNativeNode(text)).toBe(true); + expect(text.text).toBe("PAUSED"); + expect(text.children).toEqual([]); + }); +}); diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 0d054d28..a8226970 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -22,7 +22,6 @@ // safety IS the ABI contract (ui_alloc/ui_free above), not a Rust signature. #![allow(clippy::not_unsafe_ptr_arg_deref)] -use pocketjs_core::spec::{SCREEN_H, SCREEN_W}; use pocketjs_core::Ui; use pocketjs_core::raster; @@ -64,6 +63,13 @@ pub extern "C" fn ui_init(raster_density: u32) { } } +/// Resize the logical layout/draw viewport. Existing stock hosts never call +/// this and retain the legacy 480x272 contract. +#[no_mangle] +pub extern "C" fn ui_set_viewport(width: f32, height: f32) { + ui().set_viewport(width, height); +} + /// Allocate `len` bytes of scratch in linear memory for host -> wasm buffers. #[no_mangle] pub extern "C" fn ui_alloc(len: usize) -> *mut u8 { @@ -273,10 +279,14 @@ fn render_at_scale(scale: u32) -> *const u8 { if !(1..=raster::MAX_RENDER_SCALE).contains(&scale) { return core::ptr::null(); } - let Some(width) = (SCREEN_W as usize).checked_mul(scale as usize) else { + let u = ui(); + let (viewport_w, viewport_h) = u.viewport(); + let logical_width = viewport_w as usize; + let logical_height = viewport_h as usize; + let Some(width) = logical_width.checked_mul(scale as usize) else { return core::ptr::null(); }; - let Some(height) = (SCREEN_H as usize).checked_mul(scale as usize) else { + let Some(height) = logical_height.checked_mul(scale as usize) else { return core::ptr::null(); }; let Some(bytes) = width @@ -285,7 +295,6 @@ fn render_at_scale(scale: u32) -> *const u8 { else { return core::ptr::null(); }; - let u = ui(); // draw() borrows `u` mutably for the returned &DrawList; the rasterizer // then needs a shared &Ui for atlases/textures. Both live in the single // static; nothing mutates during rasterization, and this module is @@ -303,8 +312,9 @@ fn render_at_scale(scale: u32) -> *const u8 { } } -/// Rasterize the current tree and return the byte-exact RGBA8 480x272 -/// framebuffer pointer. Kept as a dedicated ABI entry for existing hosts. +/// Rasterize the current tree and return the byte-exact RGBA8 framebuffer +/// pointer at the logical viewport size. Kept as a dedicated ABI entry for +/// existing hosts. #[no_mangle] pub extern "C" fn ui_render() -> *const u8 { render_at_scale(1)