diff --git a/.github/workflows/esp32p4.yml b/.github/workflows/esp32p4.yml index e8028542..0a84a8e8 100644 --- a/.github/workflows/esp32p4.yml +++ b/.github/workflows/esp32p4.yml @@ -6,14 +6,36 @@ on: - ".github/workflows/esp32p4.yml" - "engine/core/**" - "engine/backends/esp32p4-ppa/**" + - "engine/crates/pocket-mod/**" + - "engine/crates/pocket-ui-surface/**" + - "contracts/**" + - "framework/**" - "hosts/esp32p4/**" + - "tools/build.ts" + - "tools/esp32p4*.ts" + - "tests/esp32p4*.test.ts" + - "vapor/boards/**" + - "vapor/compiler/**" + - "package.json" + - "bun.lock" push: branches: [main] paths: - ".github/workflows/esp32p4.yml" - "engine/core/**" - "engine/backends/esp32p4-ppa/**" + - "engine/crates/pocket-mod/**" + - "engine/crates/pocket-ui-surface/**" + - "contracts/**" + - "framework/**" - "hosts/esp32p4/**" + - "tools/build.ts" + - "tools/esp32p4*.ts" + - "tests/esp32p4*.test.ts" + - "vapor/boards/**" + - "vapor/compiler/**" + - "package.json" + - "bun.lock" workflow_dispatch: {} permissions: @@ -29,6 +51,11 @@ jobs: - uses: dtolnay/rust-toolchain@stable + - uses: oven-sh/setup-bun@v2 + + - name: Install JavaScript dependencies + run: bun install --frozen-lockfile + - name: Test RGB565 core rasterizer run: cargo test --locked --manifest-path engine/core/Cargo.toml @@ -38,16 +65,47 @@ jobs: - name: Check ESP-IDF Rust adapter run: cargo check --locked --manifest-path engine/backends/esp32p4-ppa/Cargo.toml --features esp-idf + - name: Test complete QuickJS host runtime + run: cargo test --locked --manifest-path hosts/esp32p4/runtime/Cargo.toml + + - name: Test ESP32-P4 build and device tooling + run: bun test tests/esp32p4-profile.test.ts tests/esp32p4-device.test.ts + + - name: Generate framework style module + run: bun tools/build.ts hero >/dev/null + + - name: Check TypeScript contracts + run: bunx tsc --noEmit + + full-host: + name: Full PocketJS host final link + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Cross-build QuickJS and link the Waveshare firmware + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v5.5.4 + target: esp32p4 + extra_docker_args: --user root + command: bash hosts/esp32p4/waveshare-7b/ci-build.sh + esp-idf: - name: ESP-IDF release/v6.0 + name: ESP-IDF ${{ matrix.idf }} runs-on: ubuntu-latest timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + idf: [v5.5.4, release-v6.0] steps: - uses: actions/checkout@v4 - name: Build ESP32-P4 component smoke app uses: espressif/esp-idf-ci-action@v1 with: - esp_idf_version: release-v6.0 + esp_idf_version: ${{ matrix.idf }} target: esp32p4 path: hosts/esp32p4/examples/ppa-smoke diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6bbae072..e790f759 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -66,11 +66,18 @@ artifacts: `$JOB_TMP/map-*.json`). region and replay the complete DrawList under a root clip; framebuffer format/scale signatures and `Ui::raster_revision()` prevent stale reuse. Region limits and full-redraw thresholds remain backend policy. +- **Rounded gradients are small retained layers**: the core bakes the exact + analytic-span pixels into bounded PSM 8888 `[R,G,B,A]` textures and emits + stable `TEX_QUAD` tiles. The cache is capped at 256 KiB, reuses + generation-tagged texture slots under phase churn, and falls back to the same + spans when a layer cannot fit. It never creates a full-frame 32-bit + intermediate. - **ESP32-P4 stays 16-bit**: `engine/backends/esp32p4-ppa/` consumes the same DrawList into an opaque RGB565 target. It maps flat fills, A8 coverage - blending, and compatible PSM 5650 texture transforms to the PPA, then - preserves ordering with the core RGB565 rasterizer for unsupported ops. - It never allocates a full-frame RGB888/ARGB8888 intermediate. + blending, straight-alpha PSM 8888 texture blending, and compatible PSM 5650 + texture transforms to the PPA, then preserves ordering with the core RGB565 + rasterizer for unsupported ops. It never allocates a full-frame + RGB888/ARGB8888 intermediate. - **Native animation**: tweens/springs tick in Rust per vblank with **fixed dt = 1/60 s** (frame content is a pure function of frame index — this is what makes byte-exact goldens possible **[R]**). JS only declares motion. diff --git a/engine/backends/esp32p4-ppa/README.md b/engine/backends/esp32p4-ppa/README.md index 1eca8a27..8a031509 100644 --- a/engine/backends/esp32p4-ppa/README.md +++ b/engine/backends/esp32p4-ppa/README.md @@ -18,10 +18,13 @@ Accelerated paths: - antialiased font runs (`A8` coverage composed once, then `BLEND`); - single-color alpha textures such as PocketJS rounded-corner masks (`A8` `BLEND`); +- straight-alpha PocketJS PSM 8888 `[R,G,B,A]` texture quads (`RGBA8` + `BLEND`), including bounded retained rounded-gradient layers; - opaque PSM 5650 texture quads (`SRM`) when scaling semantics are compatible. -Gradients, arbitrary triangles, textured triangles, and unsupported texture -formats fall back to `pocketjs_core::raster::render_scaled_rgb565_over`. +Uncached gradients, arbitrary triangles, textured triangles, and unsupported +texture formats fall back to +`pocketjs_core::raster::render_scaled_rgb565_over`. No full-frame RGB888 or ARGB8888 surface is allocated. ## Incremental rendering @@ -36,10 +39,12 @@ composition without touching unchanged pixels. Keep one `RenderTargetState` per framebuffer. This is required for double-buffered hosts because each target contains a different older frame. -The first render and structural DrawList changes use a conservative full -redraw. This backend additionally promotes damage covering at least 75 -percent of the viewport; that transaction-cost policy is deliberately kept -outside the common damage planner. +The first render uses a conservative full redraw. Bounded structural edits +are localized by retaining identical DrawList prefixes and suffixes; malformed +lists and changes that cannot be localized fail closed. This backend also +promotes damage covering at least 75 percent of the viewport; that +transaction-cost policy is deliberately kept outside the common damage +planner. Core-managed texture, font, and style mutations bump `Ui::raster_revision()`, so every `RenderTargetState` automatically forces a complete repaint and the diff --git a/engine/backends/esp32p4-ppa/src/esp_idf.rs b/engine/backends/esp32p4-ppa/src/esp_idf.rs index f00b92c9..91d7b0e1 100644 --- a/engine/backends/esp32p4-ppa/src/esp_idf.rs +++ b/engine/backends/esp32p4-ppa/src/esp_idf.rs @@ -41,6 +41,25 @@ unsafe extern "C" { blue: u8, global_alpha: u8, ) -> i32; + fn pocketjs_ppa_blend_rgba8888_rgb565( + handle: *mut c_void, + destination: *mut u16, + destination_pixels: usize, + width: u32, + height: u32, + source: *const u8, + source_len: usize, + source_width: u32, + source_height: u32, + source_x: u32, + source_y: u32, + source_rect_width: u32, + source_rect_height: u32, + destination_x: u32, + destination_y: u32, + destination_rect_width: u32, + destination_rect_height: u32, + ) -> i32; fn pocketjs_ppa_srm_psm5650_rgb565( handle: *mut c_void, destination: *mut u16, @@ -155,6 +174,40 @@ impl PpaOps for EspIdfPpaOps { } } + fn blend_rgba8888_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + source_rect: Rect, + destination_rect: Rect, + ) -> bool { + unsafe { + pocketjs_ppa_blend_rgba8888_rgb565( + self.handle, + destination.as_mut_ptr(), + destination.len(), + width, + height, + source.as_ptr(), + source.len(), + source_width, + source_height, + source_rect.x, + source_rect.y, + source_rect.w, + source_rect.h, + destination_rect.x, + destination_rect.y, + destination_rect.w, + destination_rect.h, + ) != 0 + } + } + fn srm_psm5650_to_rgb565( &mut self, destination: &mut [u16], diff --git a/engine/backends/esp32p4-ppa/src/lib.rs b/engine/backends/esp32p4-ppa/src/lib.rs index 53b7bac5..63605b37 100644 --- a/engine/backends/esp32p4-ppa/src/lib.rs +++ b/engine/backends/esp32p4-ppa/src/lib.rs @@ -80,6 +80,15 @@ pub struct SrmTransform { /// subsequent CPU or PPA operations. Returning `false` requests the ordered /// software fallback. pub trait PpaOps { + /// Optional monotonic profiling clock used by [`Renderer`] receipts. + /// + /// Portable and `no_std` implementations can keep the default. Product + /// hosts that return microseconds get per-frame CPU-phase and blocking PPA + /// call timings without putting a platform timer in this backend. + fn profile_clock_us(&self) -> Option { + None + } + /// Fill `rect` in the full RGB565 destination. fn fill_rgb565( &mut self, @@ -103,6 +112,31 @@ pub trait PpaOps { global_alpha: u8, ) -> bool; + /// Blend a straight-alpha PSM8888 source over the RGB565 destination. + /// + /// `source` uses PocketJS' canonical row-major `[R, G, B, A]` byte + /// layout. ESP-IDF implementations expose it to the PPA as ARGB8888 with + /// the foreground RGB-swap bit enabled; callers must not reorder or + /// premultiply the texture. Source and destination rectangles have equal + /// dimensions because the PPA blend engine does not scale. + /// + /// The default declines the operation so existing portable `PpaOps` + /// implementations retain the ordered software fallback. + #[allow(clippy::too_many_arguments)] + fn blend_rgba8888_rgb565( + &mut self, + _destination: &mut [u16], + _width: u32, + _height: u32, + _source: &[u8], + _source_width: u32, + _source_height: u32, + _source_rect: Rect, + _destination_rect: Rect, + ) -> bool { + false + } + /// Copy an opaque PSP PSM 5650 texture into the RGB565 destination with /// PPA SRM. PSM 5650 stores R and B opposite to ESP RGB565, so the host /// must enable the PPA input RGB swap. @@ -156,6 +190,41 @@ pub struct RenderStats { pub damage_bounds: Rect, /// True for an initial, invalidated, or heuristically promoted full frame. pub full_redraw: bool, + /// Wall time spent clearing damaged regions, including a PPA FILL when it + /// serviced the clear. This intentionally overlaps `ppa_fill_us`. + pub damage_clear_us: u32, + /// CPU time spent allocating, clearing, and constructing A8 masks. + pub mask_build_us: u32, + /// CPU time spent in ordered RGB565 software raster fallbacks. + pub software_us: u32, + /// Cumulative wall time inside every attempted blocking PPA FILL call. + pub ppa_fill_us: u32, + /// Cumulative wall time inside every attempted blocking PPA BLEND call. + pub ppa_blend_us: u32, + /// Cumulative wall time inside every attempted blocking PPA SRM call. + pub ppa_srm_us: u32, +} + +#[inline] +fn profile_elapsed(ppa: &O, started_us: Option) -> u32 { + let Some(started_us) = started_us else { + return 0; + }; + ppa.profile_clock_us() + .map(|ended_us| ended_us.saturating_sub(started_us).min(u32::MAX as u64) as u32) + .unwrap_or(0) +} + +#[inline] +fn add_profile_time(total_us: &mut u32, elapsed_us: u32) { + *total_us = total_us.saturating_add(elapsed_us); +} + +#[inline] +fn timed_ppa_call(ppa: &mut O, operation: impl FnOnce(&mut O) -> bool) -> (bool, u32) { + let started_us = ppa.profile_clock_us(); + let accepted = operation(ppa); + (accepted, profile_elapsed(ppa, started_us)) } /// Core damage snapshot describing the pixels stored in one framebuffer. @@ -270,8 +339,9 @@ impl Renderer { /// /// The destination must retain the pixels produced by the same /// `RenderTargetState`; double-buffered hosts therefore keep one state per - /// buffer. Structural DrawList changes, invalidated resources, and damage - /// covering most of the screen conservatively fall back to a full redraw. + /// buffer. Bounded structural edits are localized; invalidated resources, + /// target changes, and damage covering most of the screen conservatively + /// fall back to a full redraw. #[allow(clippy::too_many_arguments)] pub fn render_incremental( &mut self, @@ -364,14 +434,32 @@ impl Renderer { ..RenderStats::default() }; + let mask_started_us = ppa.profile_clock_us(); self.ensure_mask(destination.len()); - if local.area() >= self.config.min_fill_pixels - && ppa.fill_rgb565(destination, width, height, local, 0) - { - stats.ppa_fills += 1; + add_profile_time( + &mut stats.mask_build_us, + profile_elapsed(ppa, mask_started_us), + ); + let clear_started_us = ppa.profile_clock_us(); + let cleared_by_ppa = if local.area() >= self.config.min_fill_pixels { + let (accepted, elapsed_us) = timed_ppa_call(ppa, |ppa| { + ppa.fill_rgb565(destination, width, height, local, 0) + }); + add_profile_time(&mut stats.ppa_fill_us, elapsed_us); + if accepted { + stats.ppa_fills += 1; + } + accepted } else { + false + }; + if !cleared_by_ppa { fill_rgb565_rect(destination, width, local, 0); } + add_profile_time( + &mut stats.damage_clear_us, + profile_elapsed(ppa, clear_started_us), + ); self.render_region( ui, words, @@ -453,23 +541,59 @@ impl Renderer { return Some(stats); } - self.ensure_mask(destination.len()); for ®ion in damage.regions() { + // PPA cache maintenance and its A8 input scale with the declared + // surface, not merely the operation rectangle. A logical damage + // region occupies contiguous full-width rows in the persistent + // target, so expose only those rows as a compact surface. Pixels + // outside `region.x0..region.x1` remain untouched, while every op + // keeps its global DrawList coordinates through `strip_surface`. let physical = local_physical_rect(region, surface, scale); - if physical.area() >= self.config.min_fill_pixels - && ppa.fill_rgb565(destination, width, height, physical, 0) - { - stats.ppa_fills += 1; + let strip_start = physical.y as usize * width as usize; + let strip_end = (physical.y + physical.h) as usize * width as usize; + let strip = destination.get_mut(strip_start..strip_end)?; + let strip_height = physical.h; + let strip_surface = Clip { + x0: surface.x0, + y0: region.y0, + x1: surface.x1, + y1: region.y1, + }; + let local = local_physical_rect(region, strip_surface, scale); + + let mask_started_us = ppa.profile_clock_us(); + self.ensure_mask(strip.len()); + add_profile_time( + &mut stats.mask_build_us, + profile_elapsed(ppa, mask_started_us), + ); + let clear_started_us = ppa.profile_clock_us(); + let cleared_by_ppa = if local.area() >= self.config.min_fill_pixels { + let (accepted, elapsed_us) = timed_ppa_call(ppa, |ppa| { + ppa.fill_rgb565(strip, width, strip_height, local, 0) + }); + add_profile_time(&mut stats.ppa_fill_us, elapsed_us); + if accepted { + stats.ppa_fills += 1; + } + accepted } else { - fill_rgb565_rect(destination, width, physical, 0); + false + }; + if !cleared_by_ppa { + fill_rgb565_rect(strip, width, local, 0); } + add_profile_time( + &mut stats.damage_clear_us, + profile_elapsed(ppa, clear_started_us), + ); self.render_region( ui, words, - destination, + strip, width, - height, - surface, + strip_height, + strip_surface, region, ppa, &mut stats, @@ -512,7 +636,7 @@ impl Renderer { stats, ) { - self.software_op(ui, destination, surface, clip, op, stats); + self.software_op(ui, destination, surface, clip, op, ppa, stats); } i += 4; } @@ -521,7 +645,15 @@ impl Renderer { .intersect(clip) .is_empty() { - self.software_op(ui, destination, surface, clip, &words[i..i + 6], stats); + self.software_op( + ui, + destination, + surface, + clip, + &words[i..i + 6], + ppa, + stats, + ); } i += 6; } @@ -545,7 +677,7 @@ impl Renderer { stats, ) { - self.software_op(ui, destination, surface, clip, op, stats); + self.software_op(ui, destination, surface, clip, op, ppa, stats); } i = next; } @@ -572,7 +704,15 @@ impl Renderer { if let Some(next) = next { i = next; } else { - self.software_op(ui, destination, surface, clip, &words[i..i + 9], stats); + self.software_op( + ui, + destination, + surface, + clip, + &words[i..i + 9], + ppa, + stats, + ); i += 9; } } @@ -597,14 +737,30 @@ impl Renderer { spec::draw_op::TRI if i + 7 <= words.len() => { if !triangle_bounds([words[i + 1], words[i + 2], words[i + 3]], clip).is_empty() { - self.software_op(ui, destination, surface, clip, &words[i..i + 7], stats); + self.software_op( + ui, + destination, + surface, + clip, + &words[i..i + 7], + ppa, + stats, + ); } i += 7; } spec::draw_op::TEX_TRI if i + 12 <= words.len() => { if !triangle_bounds([words[i + 2], words[i + 5], words[i + 8]], clip).is_empty() { - self.software_op(ui, destination, surface, clip, &words[i..i + 12], stats); + self.software_op( + ui, + destination, + surface, + clip, + &words[i..i + 12], + ppa, + stats, + ); } i += 12; } @@ -631,22 +787,35 @@ impl Renderer { } let rect = local_physical_rect(logical, surface, self.config.scale); if a == 255 && rect.area() >= self.config.min_fill_pixels { - if ppa.fill_rgb565(destination, width, height, rect, pack_rgb565(r, g, b)) { + let (accepted, elapsed_us) = timed_ppa_call(ppa, |ppa| { + ppa.fill_rgb565(destination, width, height, rect, pack_rgb565(r, g, b)) + }); + add_profile_time(&mut stats.ppa_fill_us, elapsed_us); + if accepted { stats.ppa_fills += 1; return true; } } else if rect.area() >= self.config.min_blend_pixels { + let mask_started_us = ppa.profile_clock_us(); let mask = self.mask_mut(); fill_mask_rect(mask, width, rect, a as u8); - if ppa.blend_a8_rgb565( - destination, - width, - height, - mask, - rect, - [r as u8, g as u8, b as u8], - 255, - ) { + add_profile_time( + &mut stats.mask_build_us, + profile_elapsed(ppa, mask_started_us), + ); + let (accepted, elapsed_us) = timed_ppa_call(ppa, |ppa| { + ppa.blend_a8_rgb565( + destination, + width, + height, + mask, + rect, + [r as u8, g as u8, b as u8], + 255, + ) + }); + add_profile_time(&mut stats.ppa_blend_us, elapsed_us); + if accepted { stats.ppa_blends += 1; return true; } @@ -698,6 +867,7 @@ impl Renderer { if rect.is_empty() || rect.area() < self.config.min_blend_pixels { return false; } + let mask_started_us = ppa.profile_clock_us(); let mask = self.mask_mut(); fill_mask_rect(mask, width, rect, 0); let density = atlas.raster_density as i32; @@ -729,15 +899,23 @@ impl Renderer { } } } - if ppa.blend_a8_rgb565( - destination, - width, - height, - mask, - rect, - [r as u8, g as u8, b as u8], - 255, - ) { + add_profile_time( + &mut stats.mask_build_us, + profile_elapsed(ppa, mask_started_us), + ); + let (accepted, elapsed_us) = timed_ppa_call(ppa, |ppa| { + ppa.blend_a8_rgb565( + destination, + width, + height, + mask, + rect, + [r as u8, g as u8, b as u8], + 255, + ) + }); + add_profile_time(&mut stats.ppa_blend_us, elapsed_us); + if accepted { stats.ppa_blends += 1; true } else { @@ -776,17 +954,21 @@ impl Renderer { mirror_y, ..SrmTransform::default() }; - if ppa.srm_psm5650_to_rgb565( - destination, - width, - height, - view.pixels, - view.w, - view.h, - source_rect, - destination_rect, - transform, - ) { + let (accepted, elapsed_us) = timed_ppa_call(ppa, |ppa| { + ppa.srm_psm5650_to_rgb565( + destination, + width, + height, + view.pixels, + view.w, + view.h, + source_rect, + destination_rect, + transform, + ) + }); + add_profile_time(&mut stats.ppa_srm_us, elapsed_us); + if accepted { stats.ppa_srm += 1; return Some(start + 9); } @@ -796,6 +978,40 @@ impl Renderer { } if !self.is_white_alpha_texture(handle, &view) { + if view.psm == spec::psm::PSM_8888 + && ui.raster_density() == self.config.scale + && op[8] == 0xffff_ffff + { + let logical = logical_rect(op[2], op[3]).intersect(clip); + let destination_rect = local_physical_rect(logical, surface, self.config.scale); + if destination_rect.area() >= self.config.min_blend_pixels { + let (source_rect, mirror_x, mirror_y) = + texture_source_rect(&view, op, logical)?; + if !mirror_x + && !mirror_y + && source_rect.w == destination_rect.w + && source_rect.h == destination_rect.h + { + let (accepted, elapsed_us) = timed_ppa_call(ppa, |ppa| { + ppa.blend_rgba8888_rgb565( + destination, + width, + height, + view.pixels, + view.w, + view.h, + source_rect, + destination_rect, + ) + }); + add_profile_time(&mut stats.ppa_blend_us, elapsed_us); + if accepted { + stats.ppa_blends += 1; + return Some(start + 9); + } + } + } + } return None; } let modulate = op[8]; @@ -809,6 +1025,46 @@ impl Renderer { bounds = bounds.union(logical_rect(words[end + 2], words[end + 3]).intersect(clip)); end += 9; } + let quad_end = end; + + // Core flat rounded boxes are emitted as four white-alpha corner + // quads followed by three same-color rectangular bands. Fold those + // disjoint pieces into the same A8 mask so one rounded layer costs one + // blocking PPA transaction. Requiring the complete packed color and + // pairwise-disjoint bounds is intentional: merging overlapping shadow + // layers with merely equal RGB would remove their intermediate RGB565 + // quantization and change pixels. + let mut rect_count = 0usize; + while rect_count < 3 + && end + 4 <= words.len() + && words[end] == spec::draw_op::RECT + && words[end + 3] == modulate + { + let candidate = logical_rect(words[end + 1], words[end + 2]).intersect(clip); + let mut cursor = start; + let mut overlaps = false; + while cursor < end { + let existing = if cursor < quad_end { + let rect = logical_rect(words[cursor + 2], words[cursor + 3]).intersect(clip); + cursor += 9; + rect + } else { + let rect = logical_rect(words[cursor + 1], words[cursor + 2]).intersect(clip); + cursor += 4; + rect + }; + if clips_overlap(candidate, existing) { + overlaps = true; + break; + } + } + if overlaps { + break; + } + bounds = bounds.union(candidate); + end += 4; + rect_count += 1; + } let (r, g, b, a) = channels(modulate); if a == 0 { return Some(end); @@ -818,10 +1074,11 @@ impl Renderer { return None; } let scale = self.config.scale; + let mask_started_us = ppa.profile_clock_us(); let mask = self.mask_mut(); fill_mask_rect(mask, width, rect, 0); let mut cursor = start; - while cursor < end { + while cursor < quad_end { alpha_quad_into_mask( &view, &words[cursor..cursor + 9], @@ -834,15 +1091,29 @@ impl Renderer { ); cursor += 9; } - if ppa.blend_a8_rgb565( - destination, - width, - height, - mask, - rect, - [r as u8, g as u8, b as u8], - 255, - ) { + while cursor < end { + let logical = logical_rect(words[cursor + 1], words[cursor + 2]).intersect(clip); + let physical = local_physical_rect(logical, surface, scale); + fill_mask_rect(mask, width, physical, a as u8); + cursor += 4; + } + add_profile_time( + &mut stats.mask_build_us, + profile_elapsed(ppa, mask_started_us), + ); + let (accepted, elapsed_us) = timed_ppa_call(ppa, |ppa| { + ppa.blend_a8_rgb565( + destination, + width, + height, + mask, + rect, + [r as u8, g as u8, b as u8], + 255, + ) + }); + add_profile_time(&mut stats.ppa_blend_us, elapsed_us); + if accepted { stats.ppa_blends += 1; Some(end) } else { @@ -850,18 +1121,20 @@ impl Renderer { } } - fn software_op( + fn software_op( &mut self, ui: &Ui, destination: &mut [u16], surface: Clip, clip: Clip, op: &[u32], + ppa: &O, stats: &mut RenderStats, ) { if clip.is_empty() { return; } + let started_us = ppa.profile_clock_us(); self.fallback_words.clear(); self.fallback_words.push(spec::draw_op::SCISSOR); self.fallback_words.push(pack_xy(clip.x0, clip.y0)); @@ -890,6 +1163,7 @@ impl Renderer { } stats.software_ops += 1; stats.software_words += op.len() as u32; + add_profile_time(&mut stats.software_us, profile_elapsed(ppa, started_us)); } fn ensure_mask(&mut self, len: usize) { @@ -1064,6 +1338,16 @@ fn composite_mask(destination: &mut u8, source: u8) { *destination = (s + (d * (255 - s) + 127) / 255) as u8; } +#[inline] +fn clips_overlap(first: Clip, second: Clip) -> bool { + !first.is_empty() + && !second.is_empty() + && first.x0 < second.x1 + && second.x0 < first.x1 + && first.y0 < second.y1 + && second.y0 < first.y1 +} + fn texture_source_rect( view: &TexView<'_>, op: &[u32], @@ -1232,12 +1516,17 @@ fn alpha_quad_into_mask( mod tests { use super::*; use alloc::vec; + use core::cell::Cell; #[derive(Default)] struct MockPpa { fills: u32, blends: u32, + rgba_blends: u32, srm: u32, + decline_rgba: bool, + profiling: bool, + profile_clock_us: Cell, last_mask_max: u8, last_mask: Vec, last_global_alpha: u8, @@ -1249,9 +1538,19 @@ mod tests { last_source_rect: Rect, last_destination_rect: Rect, last_transform: SrmTransform, + last_rgba_source: Vec, } impl PpaOps for MockPpa { + fn profile_clock_us(&self) -> Option { + if !self.profiling { + return None; + } + let now = self.profile_clock_us.get(); + self.profile_clock_us.set(now + 1); + Some(now) + } + fn fill_rgb565( &mut self, destination: &mut [u16], @@ -1300,6 +1599,45 @@ mod tests { true } + fn blend_rgba8888_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + _height: u32, + source: &[u8], + source_width: u32, + _source_height: u32, + source_rect: Rect, + destination_rect: Rect, + ) -> bool { + self.rgba_blends += 1; + self.last_source_rect = source_rect; + self.last_destination_rect = destination_rect; + self.last_rgba_source.clear(); + self.last_rgba_source.extend_from_slice(source); + if self.decline_rgba + || source_rect.w != destination_rect.w + || source_rect.h != destination_rect.h + { + return false; + } + for dy in 0..destination_rect.h { + for dx in 0..destination_rect.w { + let source_index = + ((source_rect.y + dy) * source_width + source_rect.x + dx) as usize * 4; + let rgba: [u8; 4] = source[source_index..source_index + 4].try_into().unwrap(); + let destination_index = (destination_rect.y + dy) as usize * width as usize + + (destination_rect.x + dx) as usize; + blend_rgb565( + &mut destination[destination_index], + [rgba[0], rgba[1], rgba[2]], + rgba[3] as u32, + ); + } + } + true + } + fn srm_psm5650_to_rgb565( &mut self, destination: &mut [u16], @@ -1655,21 +1993,17 @@ mod tests { ) .unwrap(); + let mut ppa = MockPpa::default(); let stats = renderer - .render_incremental( - &mut state, - &ui, - ¤t, - &mut output, - 32, - 16, - &mut MockPpa::default(), - ) + .render_incremental(&mut state, &ui, ¤t, &mut output, 32, 16, &mut ppa) .unwrap(); assert!(!stats.full_redraw); assert_eq!(stats.damage_regions, 2); assert_eq!(stats.damage_pixels, 32); assert_eq!(stats.software_ops, 0, "unchanged off-damage gradient"); + assert_eq!(ppa.last_surface_width, 32); + assert_eq!(ppa.last_surface_height, 4); + assert_eq!(renderer.mask_len, 32 * 4); assert_eq!(output, full_reference(&ui, ¤t, 32, 16)); } @@ -1825,7 +2159,7 @@ mod tests { } #[test] - fn incremental_render_falls_back_for_structural_changes_and_invalidation() { + fn incremental_render_localizes_structural_changes_and_honors_invalidation() { let mut ui = Ui::new(); ui.set_viewport(16.0, 8.0); let previous = vec![ @@ -1870,7 +2204,8 @@ mod tests { &mut MockPpa::default(), ) .unwrap(); - assert!(structural.full_redraw); + assert!(!structural.full_redraw); + assert_eq!(structural.damage_pixels, 3 * 3); assert_eq!(output, full_reference(&ui, ¤t, 16, 8)); state.invalidate(); @@ -1952,6 +2287,131 @@ mod tests { assert_eq!(ppa.blends, 1); } + #[test] + fn fuses_disjoint_same_color_rect_tail_with_alpha_quad_run() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 4.0); + let handle = ui.upload_texture(&[255, 255, 255, 255], 1, 1, spec::psm::PSM_8888); + let color = 0x8000_00ff; + let words = [ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(8, 4), + 0xff20_1008, + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(1, 1), + wh_word(2, 2), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + color, + spec::draw_op::RECT, + xy_word(3, 1), + wh_word(3, 2), + color, + ]; + let expected = full_reference(&ui, &words, 8, 4); + let mut output = vec![0u16; 8 * 4]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 8, 4, &mut ppa) + .unwrap(); + + assert_eq!(output, expected); + assert_eq!(stats.ppa_fills, 2, "damage clear plus background fill"); + assert_eq!(stats.ppa_blends, 1); + assert_eq!(stats.software_ops, 0); + } + + #[test] + fn fuses_opaque_disjoint_rect_tail_without_extra_fills() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 4.0); + let handle = ui.upload_texture(&[255, 255, 255, 255], 1, 1, spec::psm::PSM_8888); + let color = 0xff00_ff00; + let words = [ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(8, 4), + 0xff20_1008, + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(1, 1), + wh_word(2, 2), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + color, + spec::draw_op::RECT, + xy_word(3, 1), + wh_word(3, 2), + color, + ]; + let expected = full_reference(&ui, &words, 8, 4); + let mut output = vec![0u16; 8 * 4]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 8, 4, &mut ppa) + .unwrap(); + + assert_eq!(output, expected); + assert_eq!(stats.ppa_fills, 2, "damage clear plus background fill"); + assert_eq!(stats.ppa_blends, 1); + } + + #[test] + fn overlapping_or_different_alpha_rects_keep_ordered_transactions() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 4.0); + let handle = ui.upload_texture(&[255, 255, 255, 255], 1, 1, spec::psm::PSM_8888); + let quad = [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(1, 1), + wh_word(3, 2), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0x8000_00ff, + ]; + for tail in [ + [ + spec::draw_op::RECT, + xy_word(3, 1), + wh_word(3, 2), + 0x8000_00ff, + ], + [ + spec::draw_op::RECT, + xy_word(4, 1), + wh_word(3, 2), + 0x4000_00ff, + ], + ] { + let mut words = vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(8, 4), + 0xffff_ffff, + ]; + words.extend_from_slice(&quad); + words.extend_from_slice(&tail); + let expected = full_reference(&ui, &words, 8, 4); + let mut output = vec![0u16; 8 * 4]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 8, 4, &mut ppa) + .unwrap(); + + assert_eq!(output, expected); + assert_eq!(stats.ppa_blends, 2); + } + } + #[test] fn linear_alpha_mask_matches_core_edge_sampling() { let mut ui = Ui::new(); @@ -2095,6 +2555,201 @@ mod tests { assert_eq!(ppa.last_mask_max, 192); } + #[test] + fn routes_identity_rgba_texture_to_one_ordered_blend_without_reordering() { + let mut ui = Ui::new(); + ui.set_viewport(4.0, 2.0); + let pixels = vec![ + 255, 0, 0, 255, // opaque red + 0, 255, 0, 128, // half-alpha green + 0, 0, 255, 64, // quarter-alpha blue + 17, 33, 65, 0, // transparent colored texel + 240, 128, 16, 224, // non-symmetric channels pin RGBA order + 9, 201, 73, 177, 80, 40, 200, 96, 33, 66, 99, 255, + ]; + let handle = ui.upload_texture(&pixels, 4, 2, spec::psm::PSM_8888); + let words = [ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(4, 2), + 0xff30_2010, + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(4, 2), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + // A following op proves the blocking blend remains in DrawList + // order instead of being deferred past software/PPA work. + spec::draw_op::RECT, + xy_word(3, 1), + wh_word(1, 1), + 0x8000_ffff, + ]; + let expected = full_reference(&ui, &words, 4, 2); + let mut output = vec![0u16; 8]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 4, 2, &mut ppa) + .unwrap(); + + assert_eq!(output, expected); + assert_eq!(stats.ppa_blends, 2, "RGBA texture plus translucent rect"); + assert_eq!(stats.software_ops, 0); + assert_eq!(ppa.rgba_blends, 1); + assert_eq!( + ppa.last_rgba_source, pixels, + "core RGBA bytes pass through unchanged" + ); + assert_eq!( + ppa.last_source_rect, + Rect { + x: 0, + y: 0, + w: 4, + h: 2, + } + ); + assert_eq!(ppa.last_destination_rect, ppa.last_source_rect); + } + + #[test] + fn rgba_blend_maps_clipped_integral_uvs_to_matching_source_block() { + let mut ui = Ui::new(); + ui.set_viewport(4.0, 4.0); + let mut pixels = Vec::new(); + for y in 0..4u8 { + for x in 0..4u8 { + pixels.extend_from_slice(&[x * 50 + 1, y * 50 + 2, x * 7 + y * 11 + 3, 255]); + } + } + let handle = ui.upload_texture(&pixels, 4, 4, spec::psm::PSM_8888); + let words = [ + spec::draw_op::SCISSOR, + xy_word(1, 1), + wh_word(2, 2), + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(4, 4), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + spec::draw_op::SCISSOR_POP, + ]; + let expected = full_reference(&ui, &words, 4, 4); + let mut output = vec![0u16; 16]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 4, 4, &mut ppa) + .unwrap(); + + assert_eq!(output, expected); + assert_eq!(stats.software_ops, 0); + assert_eq!(ppa.rgba_blends, 1); + assert_eq!( + ppa.last_source_rect, + Rect { + x: 1, + y: 1, + w: 2, + h: 2, + } + ); + assert_eq!(ppa.last_destination_rect, ppa.last_source_rect); + } + + #[test] + fn rgba_modulation_and_declined_hardware_keep_ordered_software_fallback() { + let mut ui = Ui::new(); + ui.set_viewport(2.0, 1.0); + let pixels = [200, 20, 80, 192, 10, 220, 40, 128]; + let handle = ui.upload_texture(&pixels, 2, 1, spec::psm::PSM_8888); + let quad = |modulate| { + [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(2, 1), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + modulate, + ] + }; + + let modulated = quad(0x80ff_80ff); + let mut expected = vec![0u16; 2]; + pocketjs_core::raster::render_scaled_rgb565(&ui, &modulated, &mut expected, 1); + let mut output = vec![0u16; 2]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &modulated, &mut output, 2, 1, &mut ppa) + .unwrap(); + assert_eq!(output, expected); + assert_eq!(ppa.rgba_blends, 0, "PPA cannot express RGB modulation"); + assert_eq!(stats.software_ops, 1); + + let identity = quad(0xffff_ffff); + let mut expected = vec![0u16; 2]; + pocketjs_core::raster::render_scaled_rgb565(&ui, &identity, &mut expected, 1); + let mut output = vec![0u16; 2]; + let mut ppa = MockPpa { + decline_rgba: true, + ..MockPpa::default() + }; + let stats = renderer() + .render(&ui, &identity, &mut output, 2, 1, &mut ppa) + .unwrap(); + assert_eq!(output, expected); + assert_eq!(ppa.rgba_blends, 1, "hardware was attempted once"); + assert_eq!(stats.ppa_blends, 0); + assert_eq!(stats.software_ops, 1); + } + + #[test] + fn rgba_blend_requires_renderer_scale_to_match_core_resource_density() { + let mut ui = Ui::new(); + ui.set_viewport(2.0, 2.0); + let pixels = vec![12, 34, 56, 200].repeat(16); + let handle = ui.upload_texture(&pixels, 4, 4, spec::psm::PSM_8888); + let words = [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(2, 2), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + ]; + let mut output = vec![0u16; 16]; + let mut ppa = MockPpa::default(); + let mut renderer = Renderer::new(RendererConfig { + scale: 2, + min_fill_pixels: 1, + min_blend_pixels: 1, + min_srm_pixels: 1, + }) + .unwrap(); + let stats = renderer + .render(&ui, &words, &mut output, 4, 4, &mut ppa) + .unwrap(); + let mut expected = vec![0u16; 16]; + pocketjs_core::raster::render_scaled_rgb565(&ui, &words, &mut expected, 2); + + assert_eq!(output, expected); + assert_eq!(ppa.rgba_blends, 0); + assert_eq!(stats.software_ops, 1); + } + #[test] fn routes_opaque_psm5650_texture_to_srm() { let mut ui = Ui::new(); diff --git a/engine/core/src/damage.rs b/engine/core/src/damage.rs index c6f839b8..e92812cf 100644 --- a/engine/core/src/damage.rs +++ b/engine/core/src/damage.rs @@ -380,9 +380,19 @@ fn target_screen(ui: &Ui, target: DamageTarget) -> Result { code: u32, words: &'a [u32], + clip_before: DamageRect, bounds: DamageRect, } +impl DecodedOp<'_> { + fn is_identical_to(&self, other: &Self) -> bool { + self.code == other.code + && self.words == other.words + && self.clip_before == other.clip_before + && self.bounds == other.bounds + } +} + struct DamageDecoder<'a> { words: &'a [u32], index: usize, @@ -429,6 +439,7 @@ impl<'a> DamageDecoder<'a> { let end = start.checked_add(len).ok_or(())?; let words = self.words.get(start..end).ok_or(())?; self.index = end; + let clip_before = self.clip; let bounds = match code { spec::draw_op::RECT | spec::draw_op::GRAD_RECT => { @@ -460,6 +471,7 @@ impl<'a> DamageDecoder<'a> { Ok(Some(DecodedOp { code, words, + clip_before, bounds, })) } @@ -493,7 +505,9 @@ fn draw_list_damage( damage.add(new_op.bounds, screen); } } - _ => return Ok(DamagePlan::full(screen)), + _ => { + return structural_draw_list_damage(ui, previous, current, screen); + } } } if !old.is_balanced() || !new.is_balanced() { @@ -502,6 +516,68 @@ fn draw_list_damage( Ok(damage) } +fn structural_draw_list_damage( + ui: &Ui, + previous: &[u32], + current: &[u32], + screen: DamageRect, +) -> Result, DamageError> { + // Stable DrawLists stay on the allocation-free lockstep path above. Once + // their structure diverges, decode both complete lists again so an + // insertion with the same opcode as its following sibling cannot shift + // every later comparison. Only an exactly identical prefix and suffix may + // be retained; the backend clears the unmatched bounds and replays the + // complete current DrawList there, preserving painter and clip semantics. + let old_ops = decode_draw_list(ui, previous, screen)?; + let new_ops = decode_draw_list(ui, current, screen)?; + let mut prefix_len = 0usize; + while old_ops + .get(prefix_len) + .zip(new_ops.get(prefix_len)) + .is_some_and(|(old_op, new_op)| old_op.is_identical_to(new_op)) + { + prefix_len += 1; + } + + let mut old_end = old_ops.len(); + let mut new_end = new_ops.len(); + while old_end > prefix_len + && new_end > prefix_len + && old_ops[old_end - 1].is_identical_to(&new_ops[new_end - 1]) + { + old_end -= 1; + new_end -= 1; + } + + let mut damage = DamagePlan::empty(screen); + for old_op in &old_ops[prefix_len..old_end] { + damage.add(old_op.bounds, screen); + } + for new_op in &new_ops[prefix_len..new_end] { + damage.add(new_op.bounds, screen); + } + Ok(damage) +} + +fn decode_draw_list<'a>( + ui: &Ui, + words: &'a [u32], + screen: DamageRect, +) -> Result>, DamageError> { + let mut decoder = DamageDecoder::new(words, screen); + let mut ops = Vec::new(); + while let Some(op) = decoder + .next(ui) + .map_err(|_| DamageError::MalformedDrawList)? + { + ops.push(op); + } + if !decoder.is_balanced() { + return Err(DamageError::MalformedDrawList); + } + Ok(ops) +} + fn validate_draw_list(ui: &Ui, words: &[u32], screen: DamageRect) -> Result<(), DamageError> { let mut decoder = DamageDecoder::new(words, screen); while decoder @@ -605,6 +681,58 @@ mod tests { ] } + fn append_rect(words: &mut Vec, x: i16, y: i16, w: u16, h: u16, color: u32) { + words.extend_from_slice(&[spec::draw_op::RECT, xy_word(x, y), wh_word(w, h), color]); + } + + fn append_grad_rect(words: &mut Vec, x: i16, y: i16, w: u16, h: u16, color: u32) { + words.extend_from_slice(&[ + spec::draw_op::GRAD_RECT, + xy_word(x, y), + wh_word(w, h), + color, + color, + spec::GradDir::ToRight as u32, + ]); + } + + fn append_scissor(words: &mut Vec, x: i16, y: i16, w: u16, h: u16) { + words.extend_from_slice(&[spec::draw_op::SCISSOR, xy_word(x, y), wh_word(w, h)]); + } + + fn assert_pixel_changes_are_covered( + ui: &Ui, + previous: &[u32], + current: &[u32], + plan: &DamagePlan, + ) { + let (width, height) = ui.viewport(); + let width = width as usize; + let height = height as usize; + let mut previous_pixels = vec![0u8; width * height * 4]; + let mut current_pixels = vec![0u8; width * height * 4]; + crate::raster::render_scaled(ui, previous, &mut previous_pixels, 1); + crate::raster::render_scaled(ui, current, &mut current_pixels, 1); + + for y in 0..height { + for x in 0..width { + let offset = (y * width + x) * 4; + if previous_pixels[offset..offset + 4] == current_pixels[offset..offset + 4] { + continue; + } + assert!( + plan.regions().iter().any(|rect| { + (x as i32) >= rect.x0 + && (x as i32) < rect.x1 + && (y as i32) >= rect.y0 + && (y as i32) < rect.y1 + }), + "changed pixel ({x}, {y}) escaped structural damage {plan:?}", + ); + } + } + } + #[test] fn first_unchanged_and_disjoint_frames_are_classified() { let mut ui = Ui::new(); @@ -629,7 +757,7 @@ mod tests { } #[test] - fn structure_target_and_invalidation_force_full_redraws() { + fn structural_removal_is_local_but_target_and_invalidation_force_full_redraws() { let mut ui = Ui::new(); ui.set_viewport(16.0, 8.0); let previous = vec![ @@ -643,7 +771,8 @@ mod tests { tracker.commit(&ui, &previous, target(16, 8, 1)); let structural = tracker.prepare(&ui, ¤t, target(16, 8, 1)).unwrap(); - assert!(structural.is_full_redraw()); + assert!(!structural.is_full_redraw()); + assert_eq!(structural.bounds(), DamageRect::new(1, 1, 4, 4)); tracker.commit(&ui, ¤t, target(16, 8, 1)); tracker.invalidate(); @@ -665,6 +794,131 @@ mod tests { .is_full_redraw()); } + #[test] + fn structural_insertions_and_removals_preserve_an_identical_suffix() { + let mut ui = Ui::new(); + ui.set_viewport(32.0, 12.0); + + let mut without = Vec::new(); + append_rect(&mut without, 0, 0, 32, 12, 0xff10_1010); + append_rect(&mut without, 24, 2, 4, 4, 0xff00_ff00); + let mut with = Vec::new(); + append_rect(&mut with, 0, 0, 32, 12, 0xff10_1010); + append_grad_rect(&mut with, 4, 2, 4, 4, 0xff00_00ff); + append_rect(&mut with, 24, 2, 4, 4, 0xff00_ff00); + + let mut insertion_tracker = DamageTracker::::new(); + insertion_tracker.commit(&ui, &without, target(32, 12, 1)); + let insertion = insertion_tracker + .prepare(&ui, &with, target(32, 12, 1)) + .unwrap(); + assert!(!insertion.is_full_redraw()); + assert_eq!(insertion.bounds(), DamageRect::new(4, 2, 8, 6)); + assert_pixel_changes_are_covered(&ui, &without, &with, &insertion); + + let mut removal_tracker = DamageTracker::::new(); + removal_tracker.commit(&ui, &with, target(32, 12, 1)); + let removal = removal_tracker + .prepare(&ui, &without, target(32, 12, 1)) + .unwrap(); + assert!(!removal.is_full_redraw()); + assert_eq!(removal.bounds(), DamageRect::new(4, 2, 8, 6)); + assert_pixel_changes_are_covered(&ui, &with, &without, &removal); + } + + #[test] + fn structural_reorder_damages_both_paint_orders_not_the_common_suffix() { + let mut ui = Ui::new(); + ui.set_viewport(32.0, 12.0); + + let mut previous = Vec::new(); + append_rect(&mut previous, 0, 0, 32, 12, 0xff10_1010); + append_rect(&mut previous, 4, 2, 8, 6, 0xff00_00ff); + append_grad_rect(&mut previous, 8, 2, 8, 6, 0xffff_0000); + append_rect(&mut previous, 24, 2, 4, 4, 0xff00_ff00); + let mut current = Vec::new(); + append_rect(&mut current, 0, 0, 32, 12, 0xff10_1010); + append_grad_rect(&mut current, 8, 2, 8, 6, 0xffff_0000); + append_rect(&mut current, 4, 2, 8, 6, 0xff00_00ff); + append_rect(&mut current, 24, 2, 4, 4, 0xff00_ff00); + + let mut tracker = DamageTracker::::new(); + tracker.commit(&ui, &previous, target(32, 12, 1)); + let plan = tracker.prepare(&ui, ¤t, target(32, 12, 1)).unwrap(); + assert!(!plan.is_full_redraw()); + assert_eq!(plan.bounds(), DamageRect::new(4, 2, 16, 8)); + assert_pixel_changes_are_covered(&ui, &previous, ¤t, &plan); + } + + #[test] + fn structural_clip_changes_require_suffix_bounds_to_match() { + let mut ui = Ui::new(); + ui.set_viewport(20.0, 12.0); + + let mut previous = Vec::new(); + append_rect(&mut previous, 0, 0, 20, 12, 0xff10_1010); + append_scissor(&mut previous, 1, 1, 18, 10); + append_scissor(&mut previous, 2, 2, 6, 6); + append_rect(&mut previous, 0, 0, 18, 10, 0xff00_00ff); + previous.push(spec::draw_op::SCISSOR_POP); + previous.push(spec::draw_op::SCISSOR_POP); + + let mut current = Vec::new(); + append_rect(&mut current, 0, 0, 20, 12, 0xff10_1010); + append_scissor(&mut current, 1, 1, 18, 10); + append_grad_rect(&mut current, 17, 1, 2, 2, 0xff00_ff00); + append_scissor(&mut current, 10, 2, 6, 6); + append_rect(&mut current, 0, 0, 18, 10, 0xff00_00ff); + current.push(spec::draw_op::SCISSOR_POP); + current.push(spec::draw_op::SCISSOR_POP); + + let mut tracker = DamageTracker::::new(); + tracker.commit(&ui, &previous, target(20, 12, 1)); + let plan = tracker.prepare(&ui, ¤t, target(20, 12, 1)).unwrap(); + assert!(!plan.is_full_redraw()); + assert_eq!(plan.bounds(), DamageRect::new(2, 1, 19, 8)); + assert_pixel_changes_are_covered(&ui, &previous, ¤t, &plan); + + let shared_words = [spec::draw_op::RECT, xy_word(0, 0), wh_word(4, 4), 1]; + let old = DecodedOp { + code: spec::draw_op::RECT, + words: &shared_words, + clip_before: DamageRect::new(0, 0, 2, 4), + bounds: DamageRect::new(0, 0, 2, 4), + }; + let new = DecodedOp { + code: spec::draw_op::RECT, + words: &shared_words, + clip_before: DamageRect::new(2, 0, 4, 4), + bounds: DamageRect::new(2, 0, 4, 4), + }; + assert!( + !old.is_identical_to(&new), + "clip-derived bounds are part of structural suffix identity", + ); + } + + #[test] + fn structural_same_opcode_insertion_does_not_damage_the_shifted_suffix() { + let mut ui = Ui::new(); + ui.set_viewport(32.0, 12.0); + + let mut previous = Vec::new(); + append_rect(&mut previous, 0, 0, 32, 12, 0xff10_1010); + append_rect(&mut previous, 24, 2, 4, 4, 0xff00_ff00); + let mut current = Vec::new(); + append_rect(&mut current, 0, 0, 32, 12, 0xff10_1010); + append_rect(&mut current, 4, 2, 4, 4, 0xff00_00ff); + append_rect(&mut current, 24, 2, 4, 4, 0xff00_ff00); + + let mut tracker = DamageTracker::::new(); + tracker.commit(&ui, &previous, target(32, 12, 1)); + let plan = tracker.prepare(&ui, ¤t, target(32, 12, 1)).unwrap(); + assert!(!plan.is_full_redraw()); + assert_eq!(plan.bounds(), DamageRect::new(4, 2, 8, 6)); + assert_pixel_changes_are_covered(&ui, &previous, ¤t, &plan); + } + #[test] fn policy_promotes_large_damage_and_rejects_invalid_configuration() { let mut ui = Ui::new(); @@ -740,6 +994,35 @@ mod tests { ), Err(DamageError::MalformedDrawList) ); + + let mut unbalanced_current = Vec::new(); + append_grad_rect(&mut unbalanced_current, 1, 1, 3, 3, 0xff00_00ff); + append_scissor(&mut unbalanced_current, 0, 0, 4, 4); + assert_eq!( + tracker.prepare(&ui, &unbalanced_current, target(16, 8, 1)), + Err(DamageError::MalformedDrawList), + "structural tail decoding must reject an unclosed current clip", + ); + + let mut malformed_current = Vec::new(); + append_grad_rect(&mut malformed_current, 1, 1, 3, 3, 0xff00_00ff); + malformed_current.push(u32::MAX); + assert_eq!( + tracker.prepare(&ui, &malformed_current, target(16, 8, 1)), + Err(DamageError::MalformedDrawList), + "structural tail decoding must reject a malformed current opcode", + ); + + let mut unbalanced_previous = Vec::new(); + append_scissor(&mut unbalanced_previous, 0, 0, 4, 4); + append_rect(&mut unbalanced_previous, 1, 1, 2, 2, 0xff00_00ff); + let mut invalid_snapshot = DamageTracker::::new(); + invalid_snapshot.commit(&ui, &unbalanced_previous, target(16, 8, 1)); + assert_eq!( + invalid_snapshot.prepare(&ui, ¤t, target(16, 8, 1)), + Err(DamageError::MalformedDrawList), + "structural tail decoding must reject an unclosed previous clip", + ); } #[test] diff --git a/engine/core/src/draw.rs b/engine/core/src/draw.rs index a38d08c3..fb4b596c 100644 --- a/engine/core/src/draw.rs +++ b/engine/core/src/draw.rs @@ -14,9 +14,9 @@ //! - glyph cells position along the rotated/scaled frame but stay upright //! and unscaled (bitmap cells); glyphs whose cell top-left leaves the //! screen range or whose cell leaves the clip rect are dropped; -//! - rounded corners and shadows are emitted for axis-aligned boxes as -//! deterministic alpha-covered RECT spans; rotated rounded boxes degrade -//! to square fills; +//! - rounded corners and shadows use deterministic core-owned coverage +//! textures when cacheable, with alpha-covered RECT spans as the bounded +//! fallback; rotated rounded boxes degrade to square fills; //! - opacity multiplies vertex alpha down the subtree (wrong on overlap, //! per docs/DESIGN.md punt list). @@ -75,7 +75,8 @@ fn sinf(x: f32) -> f32 { r = -PI - r; } let x2 = r * r; - r * (1.0 + x2 * (-1.0 / 6.0 + x2 * (1.0 / 120.0 + x2 * (-1.0 / 5040.0 + x2 * (1.0 / 362880.0))))) + r * (1.0 + + x2 * (-1.0 / 6.0 + x2 * (1.0 / 120.0 + x2 * (-1.0 / 5040.0 + x2 * (1.0 / 362880.0))))) } #[inline] @@ -95,11 +96,22 @@ pub struct Affine { } impl Affine { - pub const IDENTITY: Affine = Affine { a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 0.0, ty: 0.0 }; + pub const IDENTITY: Affine = Affine { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + tx: 0.0, + ty: 0.0, + }; #[inline] fn translate(tx: f32, ty: f32) -> Affine { - Affine { tx, ty, ..Affine::IDENTITY } + Affine { + tx, + ty, + ..Affine::IDENTITY + } } /// self ∘ other (apply `other` first, then `self`). @@ -116,7 +128,10 @@ impl Affine { #[inline] fn apply(&self, x: f32, y: f32) -> (f32, f32) { - (self.a * x + self.c * y + self.tx, self.b * x + self.d * y + self.ty) + ( + self.a * x + self.c * y + self.tx, + self.b * x + self.d * y + self.ty, + ) } /// True when the transform maps axis-aligned rects to axis-aligned, @@ -145,7 +160,9 @@ struct Mat34 { } impl Mat34 { - const IDENTITY: Mat34 = Mat34 { m: [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0] }; + const IDENTITY: Mat34 = Mat34 { + m: [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + }; /// self ∘ other (apply `other` first, then `self`). fn then(&self, o: &Mat34) -> Mat34 { @@ -154,7 +171,8 @@ impl Mat34 { let mut out = [0.0f32; 12]; for row in 0..3 { for col in 0..4 { - let mut v = a[row * 4] * b[col] + a[row * 4 + 1] * b[4 + col] + a[row * 4 + 2] * b[8 + col]; + let mut v = + a[row * 4] * b[col] + a[row * 4 + 1] * b[4 + col] + a[row * 4 + 2] * b[8 + col]; if col == 3 { v += a[row * 4 + 3]; } @@ -175,30 +193,40 @@ impl Mat34 { } fn translate(x: f32, y: f32, z: f32) -> Mat34 { - Mat34 { m: [1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, y, 0.0, 0.0, 1.0, z] } + Mat34 { + m: [1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, y, 0.0, 0.0, 1.0, z], + } } fn rot_x(deg: f32) -> Mat34 { let r = deg * (PI / 180.0); let (s, c) = (sinf(r), cosf(r)); // Screen y grows DOWN: positive rotateX tips the top edge away, like CSS. - Mat34 { m: [1.0, 0.0, 0.0, 0.0, 0.0, c, s, 0.0, 0.0, -s, c, 0.0] } + Mat34 { + m: [1.0, 0.0, 0.0, 0.0, 0.0, c, s, 0.0, 0.0, -s, c, 0.0], + } } fn rot_y(deg: f32) -> Mat34 { let r = deg * (PI / 180.0); let (s, c) = (sinf(r), cosf(r)); - Mat34 { m: [c, 0.0, s, 0.0, 0.0, 1.0, 0.0, 0.0, -s, 0.0, c, 0.0] } + Mat34 { + m: [c, 0.0, s, 0.0, 0.0, 1.0, 0.0, 0.0, -s, 0.0, c, 0.0], + } } fn rot_z(deg: f32) -> Mat34 { let r = deg * (PI / 180.0); let (s, c) = (sinf(r), cosf(r)); - Mat34 { m: [c, -s, 0.0, 0.0, s, c, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0] } + Mat34 { + m: [c, -s, 0.0, 0.0, s, c, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + } } fn scale(sx: f32, sy: f32) -> Mat34 { - Mat34 { m: [sx, 0.0, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0] } + Mat34 { + m: [sx, 0.0, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + } } } @@ -216,9 +244,18 @@ enum Item3 { Quad { pts: [(f32, f32); 4], color: u32 }, /// One image node's projected cells. The mesh is the painter-sort unit so /// all TEX_TRIs for its texture remain consecutive for host batching. - TexMesh { cell_start: usize, cell_end: usize, tex: u32, modulate: u32 }, + TexMesh { + cell_start: usize, + cell_end: usize, + tex: u32, + modulate: u32, + }, /// A text node's glyph run, anchored at its projected origin. - Run { slot: u32, origin: (f32, f32), opacity: f32 }, + Run { + slot: u32, + origin: (f32, f32), + opacity: f32, + }, } /// Screen-space clip rect (x0 <= x1, y0 <= y1), f32 but integer-valued. @@ -234,7 +271,12 @@ impl Clip { /// The full-viewport clip (the PSP screen, or whatever `Ui::set_viewport` /// established). fn viewport(screen: (f32, f32)) -> Clip { - Clip { x0: 0.0, y0: 0.0, x1: screen.0, y1: screen.1 } + Clip { + x0: 0.0, + y0: 0.0, + x1: screen.0, + y1: screen.1, + } } fn intersect(&self, o: &Clip) -> Clip { @@ -311,7 +353,11 @@ fn ceilf(x: f32) -> f32 { enum Fill { Flat(u32), /// from/to already opacity-scaled; dir = spec::GradDir ordinal. - Grad { from: u32, to: u32, dir: u32 }, + Grad { + from: u32, + to: u32, + dir: u32, + }, } /// Color of a local-rect corner under a fill. Corner order: 0 TL, 1 TR, @@ -374,29 +420,50 @@ fn pixel_interval_coverage(pixel: i32, start: f32, end: f32) -> u32 { fn gradient_run_limit(fill: &Fill) -> i32 { match *fill { - Fill::Grad { dir, .. } if dir == spec::GradDir::ToLeft as u32 || dir == spec::GradDir::ToRight as u32 => 4, + Fill::Grad { dir, .. } + if dir == spec::GradDir::ToLeft as u32 || dir == spec::GradDir::ToRight as u32 => + { + 4 + } _ => 1_000_000, } } fn vertical_gradient(fill: &Fill) -> bool { match *fill { - Fill::Grad { dir, .. } => dir == spec::GradDir::ToTop as u32 || dir == spec::GradDir::ToBottom as u32, + Fill::Grad { dir, .. } => { + dir == spec::GradDir::ToTop as u32 || dir == spec::GradDir::ToBottom as u32 + } _ => false, } } -fn fill_color_at(fill: &Fill, x0: f32, y0: f32, x1: f32, y1: f32, sx0: i32, sy: i32, sx1: i32, coverage: u32) -> u32 { +fn fill_color_at( + fill: &Fill, + x0: f32, + y0: f32, + x1: f32, + y1: f32, + sx0: i32, + sy: i32, + sx1: i32, + coverage: u32, +) -> u32 { let color = match *fill { Fill::Flat(color) => color, Fill::Grad { from, to, dir } => { - let horizontal = dir == spec::GradDir::ToLeft as u32 || dir == spec::GradDir::ToRight as u32; + let horizontal = + dir == spec::GradDir::ToLeft as u32 || dir == spec::GradDir::ToRight as u32; let (p, denom) = if horizontal { (((sx0 + sx1) as f32 * 0.5) - x0, x1 - x0) } else { (sy as f32 + 0.5 - y0, y1 - y0) }; - let f = if denom <= 0.0 { 0.0 } else { clampf(p / denom, 0.0, 1.0) }; + let f = if denom <= 0.0 { + 0.0 + } else { + clampf(p / denom, 0.0, 1.0) + }; if dir == spec::GradDir::ToTop as u32 || dir == spec::GradDir::ToLeft as u32 { lerp_color(to, from, f) } else { @@ -422,7 +489,9 @@ pub struct DiscCache { impl DiscCache { pub const fn new() -> DiscCache { - DiscCache { entries: Vec::new() } + DiscCache { + entries: Vec::new(), + } } } @@ -432,6 +501,165 @@ impl Default for DiscCache { } } +// Rounded gradients used to expand into one color RECT per row (and, for a +// horizontal gradient, every four columns). Besides being expensive to replay, +// subpixel motion could add/remove edge RECTs and make DrawList damage fall +// back to a full redraw. Keep the exact span raster as a small immutable RGBA +// layer instead: the DrawList then carries a fixed number of TEX_QUADs while +// every backend consumes the same texture contract it already uses for images +// and corner discs. +// +// The cache is deliberately bounded in both entries and bytes. Gradient colors +// and subpixel phases can animate, so an unbounded key cache would retain one +// texture per frame. Entries used by the current frame are pinned during LRU +// eviction; if a screen needs more simultaneous layer memory, that one layer +// takes the analytic span fallback rather than invalidating an earlier op in +// the DrawList being built. +const GRADIENT_CACHE_MAX_ENTRIES: usize = 32; +const GRADIENT_CACHE_MAX_BYTES: usize = 256 * 1024; +const GRADIENT_LAYER_MAX_TILES: usize = 4; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct GradientLayerKey { + sx0: u32, + sy0: u32, + sx1: u32, + sy1: u32, + radius: u32, + ix0: i32, + iy0: i32, + ix1: i32, + iy1: i32, + from: u32, + to: u32, + dir: u32, +} + +#[derive(Clone, Copy, Default)] +struct GradientTile { + handle: i32, + x: u16, + y: u16, + w: u16, + h: u16, + texture_w: u16, + texture_h: u16, +} + +#[derive(Clone, Copy)] +struct GradientLayer { + tiles: [GradientTile; GRADIENT_LAYER_MAX_TILES], + len: usize, +} + +struct GradientCacheEntry { + key: GradientLayerKey, + layer: GradientLayer, + bytes: usize, + last_used: u64, +} + +pub struct GradientCache { + entries: Vec, + bytes: usize, + #[cfg(test)] + enabled: bool, +} + +impl GradientCache { + pub const fn new() -> Self { + Self { + entries: Vec::new(), + bytes: 0, + #[cfg(test)] + enabled: true, + } + } + + #[cfg(test)] + pub(crate) fn disable(&mut self) { + self.enabled = false; + } + + #[cfg(test)] + pub(crate) fn stats(&self) -> (usize, usize) { + (self.entries.len(), self.bytes) + } + + fn remove_entry( + &mut self, + index: usize, + textures: &mut [crate::TexSlot], + tex_free: &mut Vec, + ) { + let entry = self.entries.remove(index); + self.bytes = self.bytes.saturating_sub(entry.bytes); + for tile in entry.layer.tiles[..entry.layer.len].iter() { + crate::tex_release(textures, tex_free, tile.handle); + } + } + + fn get( + &mut self, + key: GradientLayerKey, + frame: u64, + textures: &mut [crate::TexSlot], + tex_free: &mut Vec, + ) -> Option { + #[cfg(test)] + if !self.enabled { + return None; + } + let index = self.entries.iter().position(|entry| entry.key == key)?; + let live = self.entries[index].layer.tiles[..self.entries[index].layer.len] + .iter() + .all(|tile| crate::tex_resolve(textures, tile.handle).is_some()); + if !live { + self.remove_entry(index, textures, tex_free); + return None; + } + self.entries[index].last_used = frame; + Some(self.entries[index].layer) + } + + fn make_room( + &mut self, + bytes: usize, + frame: u64, + textures: &mut [crate::TexSlot], + tex_free: &mut Vec, + ) -> bool { + #[cfg(test)] + if !self.enabled { + return false; + } + if bytes > GRADIENT_CACHE_MAX_BYTES { + return false; + } + while self.entries.len() >= GRADIENT_CACHE_MAX_ENTRIES + || self.bytes.saturating_add(bytes) > GRADIENT_CACHE_MAX_BYTES + { + let Some((index, _)) = self + .entries + .iter() + .enumerate() + .filter(|(_, entry)| entry.last_used != frame) + .min_by_key(|(_, entry)| entry.last_used) + else { + return false; + }; + self.remove_entry(index, textures, tex_free); + } + true + } +} + +impl Default for GradientCache { + fn default() -> Self { + Self::new() + } +} + /// Get (or bake + upload) the AA disc texture for logical `r_px`. The disc is /// a density-scaled 2r x 2r circle, supersampled 4x4, white RGB with coverage alpha /// (PSM_8888), padded to pow2 — corners sample their quadrant and modulate @@ -519,6 +747,233 @@ fn pow2_at_least(n: u32) -> u32 { p } +#[derive(Clone, Copy)] +struct GradientBounds { + x0: i32, + y0: i32, + x1: i32, + y1: i32, +} + +impl GradientBounds { + fn width(self) -> u32 { + (self.x1 - self.x0).max(0) as u32 + } + + fn height(self) -> u32 { + (self.y1 - self.y0).max(0) as u32 + } +} + +#[allow(clippy::too_many_arguments)] +fn rounded_gradient_key( + screen: (f32, f32), + sx0: f32, + sy0: f32, + sx1: f32, + sy1: f32, + radius: f32, + fill: Fill, + clip: &Clip, +) -> Option<(GradientLayerKey, GradientBounds)> { + let Fill::Grad { from, to, dir } = fill else { + return None; + }; + let bounds = GradientBounds { + x0: floorf(sx0).max(floorf(clip.x0)).max(0.0) as i32, + y0: floorf(sy0).max(floorf(clip.y0)).max(0.0) as i32, + x1: ceilf(sx1).min(ceilf(clip.x1)).min(screen.0) as i32, + y1: ceilf(sy1).min(ceilf(clip.y1)).min(screen.1) as i32, + }; + if bounds.width() == 0 || bounds.height() == 0 { + return None; + } + Some(( + GradientLayerKey { + sx0: sx0.to_bits(), + sy0: sy0.to_bits(), + sx1: sx1.to_bits(), + sy1: sy1.to_bits(), + radius: radius.to_bits(), + ix0: bounds.x0, + iy0: bounds.y0, + ix1: bounds.x1, + iy1: bounds.y1, + from, + to, + dir, + }, + bounds, + )) +} + +fn tile_edge(total: u32, index: u32, count: u32) -> u32 { + total * index / count +} + +/// Convert the exact analytic RECT stream for one rounded gradient into a +/// density-scaled immutable layer. Each logical texel is replicated into a +/// density x density block: sampling it at the target density therefore +/// reproduces the old scaled RECT pixels byte-for-byte instead of introducing +/// a new gradient or antialiasing rule. +#[allow(clippy::too_many_arguments)] +fn cache_gradient_layer( + cache: &mut GradientCache, + textures: &mut Vec, + tex_free: &mut Vec, + key: GradientLayerKey, + bounds: GradientBounds, + spans: &[u32], + raster_density: u32, + frame: u64, +) -> Option { + let logical_w = bounds.width(); + let logical_h = bounds.height(); + if logical_w == 0 || logical_h == 0 || raster_density == 0 { + return None; + } + + // Include the possible one-pixel fractional edge in the count even when + // this particular phase lands on an integer. That keeps the number of + // TEX_QUAD ops stable while a translated layer alternates between, e.g., + // 256 and 257 covered logical pixels. + let geometric_w = f32::from_bits(key.sx1) - f32::from_bits(key.sx0); + let geometric_h = f32::from_bits(key.sy1) - f32::from_bits(key.sy0); + let max_logical_per_tile = spec::TEX_MAX_DIM / raster_density; + if max_logical_per_tile == 0 { + return None; + } + let max_phase_w = (ceilf(geometric_w).max(1.0) as u32).saturating_add(1); + let max_phase_h = (ceilf(geometric_h).max(1.0) as u32).saturating_add(1); + let columns = max_phase_w.div_ceil(max_logical_per_tile).max(1); + let rows = max_phase_h.div_ceil(max_logical_per_tile).max(1); + let tile_count = columns.checked_mul(rows)? as usize; + if tile_count > GRADIENT_LAYER_MAX_TILES || logical_w < columns || logical_h < rows { + return None; + } + + let mut layer = GradientLayer { + tiles: [GradientTile::default(); GRADIENT_LAYER_MAX_TILES], + len: tile_count, + }; + let mut bytes = 0usize; + let mut tile_index = 0usize; + for row in 0..rows { + let y0 = tile_edge(logical_h, row, rows); + let y1 = tile_edge(logical_h, row + 1, rows); + for column in 0..columns { + let x0 = tile_edge(logical_w, column, columns); + let x1 = tile_edge(logical_w, column + 1, columns); + let used_w = (x1 - x0).checked_mul(raster_density)?; + let used_h = (y1 - y0).checked_mul(raster_density)?; + let texture_w = pow2_at_least(used_w); + let texture_h = pow2_at_least(used_h); + if texture_w > spec::TEX_MAX_DIM || texture_h > spec::TEX_MAX_DIM { + return None; + } + bytes = bytes.checked_add((texture_w * texture_h * 4) as usize)?; + layer.tiles[tile_index] = GradientTile { + handle: -1, + x: x0 as u16, + y: y0 as u16, + w: (x1 - x0) as u16, + h: (y1 - y0) as u16, + texture_w: texture_w as u16, + texture_h: texture_h as u16, + }; + tile_index += 1; + } + } + if !cache.make_room(bytes, frame, textures, tex_free) { + return None; + } + + // The fallback stream for a rounded gradient is RECT-only. Store its + // straight ABGR source color per logical pixel; uncovered corner pixels + // stay transparent and therefore leave the destination unchanged. + let mut logical = alloc::vec![0u32; (logical_w * logical_h) as usize]; + let mut cursor = 0usize; + while cursor < spans.len() { + if spans.get(cursor).copied() != Some(spec::draw_op::RECT) || cursor + 4 > spans.len() { + return None; + } + let xy = spans[cursor + 1]; + let wh = spans[cursor + 2]; + let x = (xy & 0xffff) as u16 as i16 as i32; + let y = (xy >> 16) as u16 as i16 as i32; + let w = wh & 0xffff; + let h = wh >> 16; + let color = spans[cursor + 3]; + let rx = x - bounds.x0; + let ry = y - bounds.y0; + if rx < 0 || ry < 0 || rx as u32 + w > logical_w || ry as u32 + h > logical_h { + return None; + } + for py in ry as u32..ry as u32 + h { + let start = (py * logical_w + rx as u32) as usize; + logical[start..start + w as usize].fill(color); + } + cursor += 4; + } + + for tile in layer.tiles[..layer.len].iter_mut() { + let texture_w = tile.texture_w as u32; + let texture_h = tile.texture_h as u32; + let byte_len = (texture_w * texture_h * 4) as usize; + let mut chunks = alloc::vec![0u128; byte_len.div_ceil(16)]; + let pixels = + unsafe { core::slice::from_raw_parts_mut(chunks.as_mut_ptr() as *mut u8, byte_len) }; + for logical_y in 0..tile.h as u32 { + for logical_x in 0..tile.w as u32 { + let source = logical[((tile.y as u32 + logical_y) * logical_w + + tile.x as u32 + + logical_x) as usize] + .to_le_bytes(); + let physical_x = logical_x * raster_density; + let physical_y = logical_y * raster_density; + for repeat_y in 0..raster_density { + let row = ((physical_y + repeat_y) * texture_w + physical_x) as usize * 4; + for repeat_x in 0..raster_density { + let offset = row + repeat_x as usize * 4; + pixels[offset..offset + 4].copy_from_slice(&source); + } + } + } + } + let handle = crate::tex_alloc( + textures, + tex_free, + crate::Texture { + data: chunks, + byte_len, + w: texture_w, + h: texture_h, + psm: spec::psm::PSM_8888, + palette: None, + linear: false, + revision: 0, + }, + ); + if handle < 0 { + for allocated in layer.tiles[..layer.len].iter() { + if allocated.handle >= 0 { + crate::tex_release(textures, tex_free, allocated.handle); + } + } + return None; + } + tile.handle = handle; + } + cache.bytes += bytes; + cache.entries.push(GradientCacheEntry { + key, + layer, + bytes, + last_used: frame, + }); + Some(layer) +} + /// A node's local frame: layout position + translate, then rotate/scale /// about its transform origin. Shared by paint and hit_test so pointer /// geometry can never drift from painted geometry. @@ -532,7 +987,11 @@ fn local_affine(l: &crate::tree::LayoutRect, r: &style::Resolved) -> Affine { // rotate == 0 keeps EXACT axis alignment (the trig polyfill is a // few ulp off at multiples of pi/2, which would silently demote // scale-only transforms to the TRI path). - let (s, c) = if r.rotate == 0.0 { (0.0, 1.0) } else { (sinf(rad), cosf(rad)) }; + let (s, c) = if r.rotate == 0.0 { + (0.0, 1.0) + } else { + (sinf(rad), cosf(rad)) + }; let sx = r.scale * r.scale_x; let sy = r.scale * r.scale_y; // translate(c) * rotate * scale * translate(-c) @@ -558,7 +1017,12 @@ fn world_aabb_of(screen: (f32, f32), world: &Affine, w: f32, h: f32) -> Clip { world.apply(w, h), world.apply(0.0, h), ]; - let mut c = Clip { x0: pts[0].0, y0: pts[0].1, x1: pts[0].0, y1: pts[0].1 }; + let mut c = Clip { + x0: pts[0].0, + y0: pts[0].1, + x1: pts[0].0, + y1: pts[0].1, + }; for &(x, y) in &pts[1..] { c.x0 = c.x0.min(x); c.y0 = c.y0.min(y); @@ -582,7 +1046,10 @@ fn local_point(world: &Affine, px: f32, py: f32) -> Option<(f32, f32)> { } let dx = px - world.tx; let dy = py - world.ty; - Some(((world.d * dx - world.c * dy) / det, (world.a * dy - world.b * dx) / det)) + Some(( + (world.d * dx - world.c * dy) / det, + (world.a * dy - world.b * dx) / det, + )) } /// Visit `slot`'s children in PAINT ORDER: document order, stable-sorted by @@ -706,14 +1173,39 @@ pub fn hit_test(tree: &Tree, styles: &StyleTable, screen: (f32, f32), x: f32, y: /// a finger in a list's row gap still resolves to the list — UIKit bounds /// semantics. Everything else (paint order, clips, transforms, opacity /// culling, 3D contexts) matches `hit_test` exactly. -pub fn hit_test_bounds(tree: &Tree, styles: &StyleTable, screen: (f32, f32), x: f32, y: f32) -> i32 { +pub fn hit_test_bounds( + tree: &Tree, + styles: &StyleTable, + screen: (f32, f32), + x: f32, + y: f32, +) -> i32 { hit_point(tree, styles, screen, x, y, false) } -fn hit_point(tree: &Tree, styles: &StyleTable, screen: (f32, f32), x: f32, y: f32, ink: bool) -> i32 { +fn hit_point( + tree: &Tree, + styles: &StyleTable, + screen: (f32, f32), + x: f32, + y: f32, + ink: bool, +) -> i32 { let root_slot = crate::tree::split_id(spec::ROOT_ID).1; let mut hit = 0i32; - hit_walk(tree, styles, screen, root_slot, Affine::IDENTITY, 1.0, Clip::viewport(screen), x, y, ink, &mut hit); + hit_walk( + tree, + styles, + screen, + root_slot, + Affine::IDENTITY, + 1.0, + Clip::viewport(screen), + x, + y, + ink, + &mut hit, + ); hit } @@ -777,7 +1269,9 @@ fn hit_walk( return; } for_children_in_paint_order(tree, styles, slot, |cs| { - hit_walk(tree, styles, screen, cs, world, op, child_clip, px, py, ink, hit); + hit_walk( + tree, styles, screen, cs, world, op, child_clip, px, py, ink, hit, + ); }); } @@ -796,6 +1290,7 @@ struct Walker<'a> { textures: &'a mut Vec, tex_free: &'a mut Vec, discs: &'a mut DiscCache, + gradients: &'a mut GradientCache, raster_density: u32, /// DevTools: slot to capture the world AABB of (u32::MAX = none). inspect_slot: u32, @@ -818,6 +1313,7 @@ pub fn build( textures: &mut Vec, tex_free: &mut Vec, discs: &mut DiscCache, + gradients: &mut GradientCache, raster_density: u32, dl: &mut DrawList, inspect_id: i32, @@ -843,13 +1339,16 @@ pub fn build( textures, tex_free, discs, + gradients, raster_density, inspect_slot, inspect_hit: None, }; let root_slot = crate::tree::split_id(spec::ROOT_ID).1; w.paint(root_slot, Affine::IDENTITY, 1.0, Clip::viewport(screen), dl); - let target = w.inspect_hit.map(|c| (c.x0, c.y0, c.x1 - c.x0, c.y1 - c.y0)); + let target = w + .inspect_hit + .map(|c| (c.x0, c.y0, c.x1 - c.x0, c.y1 - c.y0)); // Highlight glide: the drawn box exponentially approaches the target // (~0.35/draw ≈ converged in 6 draws), so switching the inspected node // slides the box across the screen instead of teleporting it. draw() @@ -874,7 +1373,15 @@ pub fn build( (None, _) => None, }; if let Some((x, y, bw, bh)) = drawn { - w.emit_highlight(dl, &Clip { x0: x, y0: y, x1: x + bw, y1: y + bh }); + w.emit_highlight( + dl, + &Clip { + x0: x, + y0: y, + x1: x + bw, + y1: y + bh, + }, + ); } // Virtual cursor sprite: appended last so nothing paints over it (even // the DevTools highlight sits under the pointer the user is steering). @@ -897,7 +1404,14 @@ pub fn build( } impl<'a> Walker<'a> { - fn paint(&mut self, slot: u32, parent_world: Affine, opacity: f32, clip: Clip, dl: &mut DrawList) { + fn paint( + &mut self, + slot: u32, + parent_world: Affine, + opacity: f32, + clip: Clip, + dl: &mut DrawList, + ) { let node = &self.tree.slots[slot as usize]; let r = style::resolve(node, self.styles, true); if r.display == spec::Display::None as u8 { @@ -934,7 +1448,17 @@ impl<'a> Walker<'a> { self.emit_arc(dl, &world, l.w, l.h, &r, bg_color, &clip); } } else if rounded_ring { - self.emit_rounded_box(dl, &world, 0.0, 0.0, l.w, l.h, r.radius, Fill::Flat(border_color), &clip); + self.emit_rounded_box( + dl, + &world, + 0.0, + 0.0, + l.w, + l.h, + r.radius, + Fill::Flat(border_color), + &clip, + ); let bw = r.border_width.min(l.w * 0.5).min(l.h * 0.5); if has_grad { let fill = Fill::Grad { @@ -942,7 +1466,17 @@ impl<'a> Walker<'a> { to: scale_alpha(r.grad_to, op), dir: r.grad_dir, }; - self.emit_rounded_box(dl, &world, bw, bw, l.w - bw, l.h - bw, (r.radius - bw).max(0.0), fill, &clip); + self.emit_rounded_box( + dl, + &world, + bw, + bw, + l.w - bw, + l.h - bw, + (r.radius - bw).max(0.0), + fill, + &clip, + ); } else { self.emit_rounded_box( dl, @@ -964,14 +1498,35 @@ impl<'a> Walker<'a> { }; self.emit_rounded_box(dl, &world, 0.0, 0.0, l.w, l.h, r.radius, fill, &clip); } else if alpha(bg_color) > 0 { - self.emit_rounded_box(dl, &world, 0.0, 0.0, l.w, l.h, r.radius, Fill::Flat(bg_color), &clip); + self.emit_rounded_box( + dl, + &world, + 0.0, + 0.0, + l.w, + l.h, + r.radius, + Fill::Flat(bg_color), + &clip, + ); } // -- border: 4 inset strips ------------------------------------------ let bw = r.border_width; if !rounded_ring && bw > 0.0 && alpha(border_color) > 0 { if rounded_border { - self.emit_rounded_border(dl, &world, 0.0, 0.0, l.w, l.h, r.radius, bw, Fill::Flat(border_color), &clip); + self.emit_rounded_border( + dl, + &world, + 0.0, + 0.0, + l.w, + l.h, + r.radius, + bw, + Fill::Flat(border_color), + &clip, + ); } else { let bc = Fill::Flat(border_color); let bwx = bw.min(l.w * 0.5); @@ -979,7 +1534,8 @@ impl<'a> Walker<'a> { self.emit_box(dl, &world, 0.0, 0.0, l.w, bwy, bc, &clip); // top self.emit_box(dl, &world, 0.0, l.h - bwy, l.w, l.h, bc, &clip); // bottom self.emit_box(dl, &world, 0.0, bwy, bwx, l.h - bwy, bc, &clip); // left - self.emit_box(dl, &world, l.w - bwx, bwy, l.w, l.h - bwy, bc, &clip); // right + self.emit_box(dl, &world, l.w - bwx, bwy, l.w, l.h - bwy, bc, &clip); + // right } } @@ -1050,7 +1606,19 @@ impl<'a> Walker<'a> { } else { (0.0, 0.0, 1.0, 1.0) }; - self.emit_tex_quad(dl, &world, l.w, l.h, node.tex as u32, op, &clip, fu0, fv0, fu1, fv1); + self.emit_tex_quad( + dl, + &world, + l.w, + l.h, + node.tex as u32, + op, + &clip, + fu0, + fv0, + fu1, + fv1, + ); } // -- children (overflow-hidden scissor around them; z-index stable @@ -1067,7 +1635,10 @@ impl<'a> Walker<'a> { } dl.words.push(spec::draw_op::SCISSOR); dl.words.push(xy_word(child_clip.x0, child_clip.y0)); - dl.words.push(wh_word(child_clip.x1 - child_clip.x0, child_clip.y1 - child_clip.y0)); + dl.words.push(wh_word( + child_clip.x1 - child_clip.x0, + child_clip.y1 - child_clip.y0, + )); scissored = true; } @@ -1119,8 +1690,15 @@ impl<'a> Walker<'a> { for cid in children { if let Some(cs) = self.tree.resolve(cid) { self.collect_3d( - cs, &Mat34::IDENTITY, opacity, root_world, distance, cx, cy, - &mut items, &mut tex_cells, + cs, + &Mat34::IDENTITY, + opacity, + root_world, + distance, + cx, + cy, + &mut items, + &mut tex_cells, ); } } @@ -1132,27 +1710,65 @@ impl<'a> Walker<'a> { Item3::Quad { pts, color } => { let poly: Vec = pts .iter() - .map(|&(x, y)| ClipVert { x, y, color: unpack(color), u: 0.0, v: 0.0 }) + .map(|&(x, y)| ClipVert { + x, + y, + color: unpack(color), + u: 0.0, + v: 0.0, + }) .collect(); let clipped = sutherland_hodgman(&poly, clip); for i in 1..clipped.len().saturating_sub(1) { - emit_tri(dl, &clipped[0], &clipped[i], &clipped[i + 1], clip, self.screen); + emit_tri( + dl, + &clipped[0], + &clipped[i], + &clipped[i + 1], + clip, + self.screen, + ); } } - Item3::TexMesh { cell_start, cell_end, tex, modulate } => { + Item3::TexMesh { + cell_start, + cell_end, + tex, + modulate, + } => { for cell in &tex_cells[cell_start..cell_end] { - let poly: Vec = cell.pts + let poly: Vec = cell + .pts .iter() .zip(cell.uv.iter()) - .map(|(&(x, y), &(u, v))| ClipVert { x, y, color: [255.0; 4], u, v }) + .map(|(&(x, y), &(u, v))| ClipVert { + x, + y, + color: [255.0; 4], + u, + v, + }) .collect(); let clipped = sutherland_hodgman(&poly, clip); for i in 1..clipped.len().saturating_sub(1) { - emit_tex_tri(dl, tex, modulate, &clipped[0], &clipped[i], &clipped[i + 1], clip, self.screen); + emit_tex_tri( + dl, + tex, + modulate, + &clipped[0], + &clipped[i], + &clipped[i + 1], + clip, + self.screen, + ); } } } - Item3::Run { slot, origin, opacity } => { + Item3::Run { + slot, + origin, + opacity, + } => { // (borrows through the walker's &'a Tree field, so the // node ref is not tied to &mut self) let node = &self.tree.slots[slot as usize]; @@ -1237,9 +1853,16 @@ impl<'a> Walker<'a> { let c2 = project(l.w, l.h); let c3 = project(0.0, l.h); let depth = (c0.1 + c1.1 + c2.1 + c3.1) * 0.25; - items.push((depth, Item3::Quad { pts: [c0.0, c1.0, c2.0, c3.0], color })); + items.push(( + depth, + Item3::Quad { + pts: [c0.0, c1.0, c2.0, c3.0], + color, + }, + )); } - if node.node_type == spec::NodeType::Image as u8 && node.tex >= 0 && l.w > 0.0 && l.h > 0.0 { + if node.node_type == spec::NodeType::Image as u8 && node.tex >= 0 && l.w > 0.0 && l.h > 0.0 + { let (fu0, fv0, fu1, fv1) = if node.sprite_frames > 0 { let cols = node.sprite_cols.max(1) as u32; let rows = (node.sprite_frames as u32).div_ceil(cols); @@ -1323,7 +1946,10 @@ impl<'a> Walker<'a> { for i in cell_start + 1..cell_end { let mut j = i; while j > cell_start - && tex_cells[j].depth.total_cmp(&tex_cells[j - 1].depth).is_lt() + && tex_cells[j] + .depth + .total_cmp(&tex_cells[j - 1].depth) + .is_lt() { tex_cells.swap(j, j - 1); j -= 1; @@ -1343,7 +1969,14 @@ impl<'a> Walker<'a> { // Glyphs anchor at the projected text origin and stay upright // (the 2D rotation contract). Depth = the anchor's z. let ((sx, sy), z) = project(0.0, 0.0); - items.push((z + 0.01, Item3::Run { slot, origin: (sx, sy), opacity: op })); + items.push(( + z + 0.01, + Item3::Run { + slot, + origin: (sx, sy), + opacity: op, + }, + )); return; // text children are absorbed into the run } for &cid in &node.children { @@ -1384,7 +2017,11 @@ impl<'a> Walker<'a> { let ring_out = rmid + half; let ring_out2 = ring_out * ring_out; let sweep = clampf(r.arc_sweep, -360.0, 360.0); - let (a0, asweep) = if sweep < 0.0 { (r.arc_start + sweep, -sweep) } else { (r.arc_start, sweep) }; + let (a0, asweep) = if sweep < 0.0 { + (r.arc_start + sweep, -sweep) + } else { + (r.arc_start, sweep) + }; let full = asweep >= 360.0; let major = asweep > 180.0; // 0 deg = 12 o'clock, clockwise positive. @@ -1412,7 +2049,11 @@ impl<'a> Walker<'a> { let in_angle = full || { let cross_s = svx * dy - svy * dx; let cross_e = evx * dy - evy * dx; - if major { cross_s >= 0.0 || cross_e <= 0.0 } else { cross_s >= 0.0 && cross_e <= 0.0 } + if major { + cross_s >= 0.0 || cross_e <= 0.0 + } else { + cross_s >= 0.0 && cross_e <= 0.0 + } }; if in_angle { return true; @@ -1446,7 +2087,11 @@ impl<'a> Walker<'a> { let half_span = sqrtf((ring_out2 - dy2).max(0.0)) + 1.0; let row_x0 = (floorf(cx - half_span) as i32).max(x0); let row_x1 = (ceilf(cx + half_span) as i32).min(x1); - let hole_span = if dy2 < ring_in2 { sqrtf(ring_in2 - dy2) - 1.0 } else { -1.0 }; + let hole_span = if dy2 < ring_in2 { + sqrtf(ring_in2 - dy2) - 1.0 + } else { + -1.0 + }; let (hole_x0, hole_x1) = if hole_span > 1.0 { ((cx - hole_span) as i32, (cx + hole_span) as i32) } else { @@ -1505,7 +2150,17 @@ impl<'a> Walker<'a> { /// path (RECT/GRAD_RECT, clipped with color re-interpolation) or the /// rotated path (Sutherland-Hodgman -> TRI ops). #[allow(clippy::too_many_arguments)] - fn emit_box(&self, dl: &mut DrawList, world: &Affine, x0: f32, y0: f32, x1: f32, y1: f32, fill: Fill, clip: &Clip) { + fn emit_box( + &self, + dl: &mut DrawList, + world: &Affine, + x0: f32, + y0: f32, + x1: f32, + y1: f32, + fill: Fill, + clip: &Clip, + ) { if x1 <= x0 || y1 <= y0 { return; } @@ -1531,7 +2186,9 @@ impl<'a> Walker<'a> { Fill::Grad { from, to, dir } => { // Re-interpolate the endpoint colors over the clipped // span so the visible slice keeps the exact gradient. - let (f0, f1) = if dir == spec::GradDir::ToLeft as u32 || dir == spec::GradDir::ToRight as u32 { + let (f0, f1) = if dir == spec::GradDir::ToLeft as u32 + || dir == spec::GradDir::ToRight as u32 + { let w = sx1 - sx0; ((c.x0 - sx0) / w, (c.x1 - sx0) / w) } else { @@ -1540,13 +2197,17 @@ impl<'a> Walker<'a> { }; // ToTop/ToLeft run against the +axis: fraction measured // from the far edge. - let (gf, gt) = if dir == spec::GradDir::ToTop as u32 || dir == spec::GradDir::ToLeft as u32 { + let (gf, gt) = if dir == spec::GradDir::ToTop as u32 + || dir == spec::GradDir::ToLeft as u32 + { (lerp_color(to, from, f0), lerp_color(to, from, f1)) } else { (lerp_color(from, to, f0), lerp_color(from, to, f1)) }; // Store colors back in "from/to along dir" order. - let (out_from, out_to) = if dir == spec::GradDir::ToTop as u32 || dir == spec::GradDir::ToLeft as u32 { + let (out_from, out_to) = if dir == spec::GradDir::ToTop as u32 + || dir == spec::GradDir::ToLeft as u32 + { (gt, gf) } else { (gf, gt) @@ -1566,14 +2227,27 @@ impl<'a> Walker<'a> { let mut poly: Vec = Vec::with_capacity(8); for (i, &(lx, ly)) in corners.iter().enumerate() { let (sx, sy) = world.apply(lx, ly); - poly.push(ClipVert { x: sx, y: sy, color: unpack(corner_color(&fill, i)), u: 0.0, v: 0.0 }); + poly.push(ClipVert { + x: sx, + y: sy, + color: unpack(corner_color(&fill, i)), + u: 0.0, + v: 0.0, + }); } let clipped = sutherland_hodgman(&poly, clip); if clipped.len() < 3 { return; } for i in 1..clipped.len() - 1 { - emit_tri(dl, &clipped[0], &clipped[i], &clipped[i + 1], clip, self.screen); + emit_tri( + dl, + &clipped[0], + &clipped[i], + &clipped[i + 1], + clip, + self.screen, + ); } } } @@ -1587,15 +2261,40 @@ impl<'a> Walker<'a> { const EDGE: u32 = 0xFFF5B04B; // #4bb0f5 solid const T: f32 = 2.0; self.emit_screen_rect(dl, c.x0, c.y0, c.x1, c.y1, Fill::Flat(FILL), &vp); - self.emit_screen_rect(dl, c.x0 - T, c.y0 - T, c.x1 + T, c.y0, Fill::Flat(EDGE), &vp); - self.emit_screen_rect(dl, c.x0 - T, c.y1, c.x1 + T, c.y1 + T, Fill::Flat(EDGE), &vp); + self.emit_screen_rect( + dl, + c.x0 - T, + c.y0 - T, + c.x1 + T, + c.y0, + Fill::Flat(EDGE), + &vp, + ); + self.emit_screen_rect( + dl, + c.x0 - T, + c.y1, + c.x1 + T, + c.y1 + T, + Fill::Flat(EDGE), + &vp, + ); self.emit_screen_rect(dl, c.x0 - T, c.y0, c.x0, c.y1, Fill::Flat(EDGE), &vp); self.emit_screen_rect(dl, c.x1, c.y0, c.x1 + T, c.y1, Fill::Flat(EDGE), &vp); } /// Screen-space flat/grad rect helper (already-transformed coords). #[allow(clippy::too_many_arguments)] - fn emit_screen_rect(&self, dl: &mut DrawList, x0: f32, y0: f32, x1: f32, y1: f32, fill: Fill, clip: &Clip) { + fn emit_screen_rect( + &self, + dl: &mut DrawList, + x0: f32, + y0: f32, + x1: f32, + y1: f32, + fill: Fill, + clip: &Clip, + ) { if x1 <= x0 || y1 <= y0 { return; } @@ -1827,7 +2526,11 @@ impl<'a> Walker<'a> { let bw = (border_width * scale_y).min(w * 0.5).min(h * 0.5); let r = (radius * scale_y).min(w * 0.5).min(h * 0.5); if bw <= 0.0 || r <= 0.5 { - let local_bw = if scale_y > 0.0 { bw / scale_y } else { border_width }; + let local_bw = if scale_y > 0.0 { + bw / scale_y + } else { + border_width + }; let bwx = local_bw.min((x1 - x0) * 0.5); let bwy = local_bw.min((y1 - y0) * 0.5); self.emit_box(dl, world, x0, y0, x1, y0 + bwy, fill, clip); @@ -1858,13 +2561,22 @@ impl<'a> Walker<'a> { continue; } let row_y = clampf(py as f32 + 0.5, sy0, sy1); - let Some((outer_x0, outer_x1)) = self.rounded_interval_at_row(sx0, sy0, sx1, sy1, r, row_y) else { + let Some((outer_x0, outer_x1)) = + self.rounded_interval_at_row(sx0, sy0, sx1, sy1, r, row_y) + else { continue; }; let inner = if has_inner && pixel_interval_coverage(py, inner_sy0, inner_sy1) > 0 { let inner_row_y = clampf(py as f32 + 0.5, inner_sy0, inner_sy1); - self.rounded_interval_at_row(inner_sx0, inner_sy0, inner_sx1, inner_sy1, inner_r, inner_row_y) + self.rounded_interval_at_row( + inner_sx0, + inner_sy0, + inner_sx1, + inner_sy1, + inner_r, + inner_row_y, + ) } else { None }; @@ -1902,19 +2614,7 @@ impl<'a> Walker<'a> { ); } else { self.emit_fractional_span( - dl, - &fill, - sx0, - sy0, - sx1, - sy1, - py, - 1, - outer_x0, - outer_x1, - ix0, - ix1, - y_coverage, + dl, &fill, sx0, sy0, sx1, sy1, py, 1, outer_x0, outer_x1, ix0, ix1, y_coverage, ); } } @@ -1932,6 +2632,88 @@ impl<'a> Walker<'a> { radius: f32, fill: Fill, clip: &Clip, + ) { + if radius > 0.0 && world.is_axis_aligned() { + let (sx0, sy0) = world.apply(x0, y0); + let (sx1, sy1) = world.apply(x1, y1); + let w = sx1 - sx0; + let h = sy1 - sy0; + let r = (radius * world.d.max(0.0)).min(w * 0.5).min(h * 0.5); + if sx1 > sx0 && sy1 > sy0 && r > 0.5 { + if let Some((key, bounds)) = + rounded_gradient_key(self.screen, sx0, sy0, sx1, sy1, r, fill, clip) + { + if let Some(layer) = + self.gradients + .get(key, self.frame, self.textures, self.tex_free) + { + self.emit_gradient_layer(dl, layer, bounds); + return; + } + + // Generate the pre-existing analytic stream first, then + // bake exactly those source colors. A failed/oversized + // cache attempt simply keeps that stream as the fallback. + let mut spans = DrawList::new(); + self.emit_rounded_box_uncached( + &mut spans, world, x0, y0, x1, y1, radius, fill, clip, + ); + if let Some(layer) = cache_gradient_layer( + self.gradients, + self.textures, + self.tex_free, + key, + bounds, + &spans.words, + self.raster_density, + self.frame, + ) { + self.emit_gradient_layer(dl, layer, bounds); + } else { + dl.words.extend_from_slice(&spans.words); + } + return; + } + } + } + self.emit_rounded_box_uncached(dl, world, x0, y0, x1, y1, radius, fill, clip); + } + + fn emit_gradient_layer(&self, dl: &mut DrawList, layer: GradientLayer, bounds: GradientBounds) { + for tile in layer.tiles[..layer.len].iter() { + let used_w = tile.w as u32 * self.raster_density; + let used_h = tile.h as u32 * self.raster_density; + dl.words.push(spec::draw_op::TEX_QUAD); + dl.words.push(tile.handle as u32); + dl.words.push(xy_word( + (bounds.x0 + tile.x as i32) as f32, + (bounds.y0 + tile.y as i32) as f32, + )); + dl.words.push(wh_word(tile.w as f32, tile.h as f32)); + dl.words.push(0.0f32.to_bits()); + dl.words.push(0.0f32.to_bits()); + dl.words + .push((used_w as f32 / tile.texture_w as f32).to_bits()); + dl.words + .push((used_h as f32 / tile.texture_h as f32).to_bits()); + // The cached pixels already contain gradient color, opacity and + // rounded-edge coverage in straight RGBA form. + dl.words.push(0xffff_ffff); + } + } + + #[allow(clippy::too_many_arguments)] + fn emit_rounded_box_uncached( + &mut self, + dl: &mut DrawList, + world: &Affine, + x0: f32, + y0: f32, + x1: f32, + y1: f32, + radius: f32, + fill: Fill, + clip: &Clip, ) { if radius <= 0.0 || !world.is_axis_aligned() { self.emit_box(dl, world, x0, y0, x1, y1, fill, clip); @@ -1952,8 +2734,8 @@ impl<'a> Walker<'a> { } // Flat fills: four baked-disc corner sprites + three rects — O(1) // ops per box instead of per-row coverage spans (the spans cost - // ~7 ms/frame of PSP CPU on rounded-heavy screens). Gradients keep - // the exact span path below. + // ~7 ms/frame of PSP CPU on rounded-heavy screens). An uncached + // gradient reaches the exact analytic span path below. if let Fill::Flat(color) = fill { let r_px = roundf(r).max(1.0) as u32; // Bake discs only for small radii: UI corner radii recur and @@ -1979,17 +2761,15 @@ impl<'a> Walker<'a> { let qy0 = roundf(sy0); let qx1 = roundf(sx1); let qy1 = roundf(sy1); - let rf = (r_px as f32) - .min((qx1 - qx0) * 0.5) - .min((qy1 - qy0) * 0.5); + let rf = (r_px as f32).min((qx1 - qx0) * 0.5).min((qy1 - qy0) * 0.5); let du = (r_px * self.raster_density) as f32 / dim as f32; // One density-scaled corner quadrant in UV space, drawn // into the same logical `rf` destination geometry. let corners = [ - (qx0, qy0, 0.0, 0.0), // TL quadrant - (qx1 - rf, qy0, du, 0.0), // TR - (qx0, qy1 - rf, 0.0, du), // BL - (qx1 - rf, qy1 - rf, du, du), // BR + (qx0, qy0, 0.0, 0.0), // TL quadrant + (qx1 - rf, qy0, du, 0.0), // TR + (qx0, qy1 - rf, 0.0, du), // BL + (qx1 - rf, qy1 - rf, du, du), // BR ]; for &(cx, cy, u0, v0) in corners.iter() { self.emit_corner_quad(dl, tex, cx, cy, rf, u0, v0, du, color, clip); @@ -2051,7 +2831,9 @@ impl<'a> Walker<'a> { } let inner_x0 = full_start.max(ix0); let inner_x1 = full_end.min(ix1); - self.emit_rounded_span(dl, &fill, sx0, sy0, sx1, sy1, mid_y0, h, inner_x0, inner_x1, 255); + self.emit_rounded_span( + dl, &fill, sx0, sy0, sx1, sy1, mid_y0, h, inner_x0, inner_x1, 255, + ); if full_end < right_edge && full_end >= ix0 && full_end < ix1 @@ -2125,9 +2907,15 @@ impl<'a> Walker<'a> { let inner_x0 = full_start.max(ix0); let inner_x1 = full_end.min(ix1); - self.emit_rounded_span(dl, &fill, sx0, sy0, sx1, sy1, py, 1, inner_x0, inner_x1, y_coverage); + self.emit_rounded_span( + dl, &fill, sx0, sy0, sx1, sy1, py, 1, inner_x0, inner_x1, y_coverage, + ); - if full_end < right_edge && full_end >= ix0 && full_end < ix1 && !(emitted_left_edge && full_end == left_edge) { + if full_end < right_edge + && full_end >= ix0 + && full_end < ix1 + && !(emitted_left_edge && full_end == left_edge) + { let x_coverage = pixel_interval_coverage(full_end, span_x0, span_x1); self.emit_rounded_span( dl, @@ -2146,7 +2934,17 @@ impl<'a> Walker<'a> { } } - fn emit_shadow(&mut self, dl: &mut DrawList, world: &Affine, w: f32, h: f32, radius: f32, level: u32, opacity: f32, clip: &Clip) { + fn emit_shadow( + &mut self, + dl: &mut DrawList, + world: &Affine, + w: f32, + h: f32, + radius: f32, + level: u32, + opacity: f32, + clip: &Clip, + ) { if !world.is_axis_aligned() { return; } @@ -2200,12 +2998,27 @@ impl<'a> Walker<'a> { let mut poly: Vec = Vec::with_capacity(8); for &(lx, ly, u, v) in corners.iter() { let (sx, sy) = world.apply(lx, ly); - poly.push(ClipVert { x: sx, y: sy, color: [255.0; 4], u, v }); + poly.push(ClipVert { + x: sx, + y: sy, + color: [255.0; 4], + u, + v, + }); } let clipped = sutherland_hodgman(&poly, clip); let modulate = scale_alpha(0xffff_ffff, op); for i in 1..clipped.len().saturating_sub(1) { - emit_tex_tri(dl, tex, modulate, &clipped[0], &clipped[i], &clipped[i + 1], clip, self.screen); + emit_tex_tri( + dl, + tex, + modulate, + &clipped[0], + &clipped[i], + &clipped[i + 1], + clip, + self.screen, + ); } return; } @@ -2259,7 +3072,9 @@ impl<'a> Walker<'a> { return; } let slot = r.font_slot as u8; - let Some(atlas) = self.fonts.atlas(slot) else { return }; + let Some(atlas) = self.fonts.atlas(slot) else { + return; + }; let (cell_w, cell_h) = (atlas.cell_w as f32, atlas.cell_h as f32); let mut run = alloc::string::String::new(); // paint() gives us the node ref; re-walk its subtree for the run. @@ -2270,8 +3085,15 @@ impl<'a> Walker<'a> { } let mut scratch = core::mem::take(&mut self.glyph_scratch); scratch.clear(); - self.fonts - .layout_run(&run, slot, r.tracking, r.line_height, r.text_align, box_w, &mut scratch); + self.fonts.layout_run( + &run, + slot, + r.tracking, + r.line_height, + r.text_align, + box_w, + &mut scratch, + ); let start = dl.words.len(); dl.words.push(spec::draw_op::GLYPH_RUN); dl.words.push(0); // patched below: slot | count << 16 diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index fd71ee68..fadec725 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -35,8 +35,8 @@ pub mod codec; pub mod damage; pub mod draw; pub mod layout; -pub mod pak; pub mod package; +pub mod pak; pub mod raster; pub mod spec; pub mod stream; @@ -88,9 +88,9 @@ impl Texture { pub fn palette(&self) -> Option<&[u8]> { // Safe: the palette Vec is always TEX_PALETTE_BYTES/16 chunks // (constructed only by copy_aligned(_, TEX_PALETTE_BYTES)). - self.palette - .as_ref() - .map(|p| unsafe { core::slice::from_raw_parts(p.as_ptr() as *const u8, TEX_PALETTE_BYTES) }) + self.palette.as_ref().map(|p| unsafe { + core::slice::from_raw_parts(p.as_ptr() as *const u8, TEX_PALETTE_BYTES) + }) } fn view(&self) -> TexView<'_> { @@ -176,6 +176,25 @@ pub(crate) fn tex_alloc(slots: &mut Vec, free: &mut Vec, tex: Text make_tex_handle(s.gen, slot) } +/// Release one live texture slot without touching [`Ui::raster_revision`]. +/// +/// Core-owned immutable paint caches use this when evicting an entry. Their +/// generation-tagged handle is part of the DrawList, so replacing an entry +/// changes the corresponding draw op and damage stays local. Public texture +/// mutations still go through [`Ui::free_texture`], which additionally bumps +/// the resource revision because app-owned handles may be consumed outside a +/// DrawList. +pub(crate) fn tex_release(slots: &mut [TexSlot], free: &mut Vec, handle: i32) -> bool { + let Some(slot) = tex_resolve(slots, handle) else { + return false; + }; + let s = &mut slots[slot as usize]; + s.tex = None; + s.gen = ((s.gen as u32 + 1) & TEX_GEN_MASK) as u16; + free.push(slot); + true +} + /// Copy `byte_len` bytes of `src` (caller guarantees `src.len() >= byte_len`) /// into a fresh 16-byte-aligned `u128` backing store. fn copy_aligned(src: &[u8], byte_len: usize) -> Vec { @@ -231,9 +250,12 @@ pub struct Ui { tex_free: Vec, /// Baked rounded-corner disc sprites (see draw::DiscCache). discs: draw::DiscCache, + /// Exact small RGBA layers for rounded gradients (see draw::GradientCache). + gradients: draw::GradientCache, /// Raster pixels baked for each logical UI pixel. Layout and DrawList /// coordinates always remain logical; only core-owned bitmap resources - /// (currently rounded-corner masks) use this density. + /// (rounded-corner masks and exact rounded-gradient layers) use this + /// density. raster_density: u32, /// Changes whenever raster-visible resource bytes or tables change. raster_revision: u64, @@ -295,6 +317,7 @@ impl Ui { textures: Vec::new(), tex_free: Vec::new(), discs: draw::DiscCache::new(), + gradients: draw::GradientCache::new(), raster_density, raster_revision: 1, focused: 0, @@ -318,6 +341,16 @@ impl Ui { self.raster_density } + #[cfg(test)] + pub(crate) fn disable_gradient_cache(&mut self) { + self.gradients.disable(); + } + + #[cfg(test)] + pub(crate) fn gradient_cache_stats(&self) -> (usize, usize) { + self.gradients.stats() + } + /// Monotonic token for texture/font/style contents consumed by renderers. pub fn raster_revision(&self) -> u64 { self.raster_revision @@ -380,7 +413,9 @@ impl Ui { /// Starts transitions for the animatable old→new diff if the node already /// had an established style and the new record carries a transition block. pub fn set_style(&mut self, id: i32, style_id: i32) { - let Some(slot) = self.tree.resolve(id) else { return }; + let Some(slot) = self.tree.resolve(id) else { + return; + }; let old = style::resolve(&self.tree.slots[slot as usize], &self.styles, true); let was_initialized = self.tree.slots[slot as usize].style_initialized; { @@ -405,7 +440,9 @@ impl Ui { if kind == 0xff { return; } - let Some(slot) = self.tree.resolve(id) else { return }; + let Some(slot) = self.tree.resolve(id) else { + return; + }; let bits = prop_bits(kind, value); // A direct set wins over any running animation on the same prop. let nid = self.tree.slots[slot as usize].id(slot); @@ -423,7 +460,9 @@ impl Ui { /// Set the UTF-8 content of a text node. Empty text nodes are excluded /// from layout until they become non-empty. pub fn set_text(&mut self, id: i32, text: &str) { - let Some(slot) = self.tree.resolve(id) else { return }; + let Some(slot) = self.tree.resolve(id) else { + return; + }; if self.tree.slots[slot as usize].node_type != spec::NodeType::Text as u8 || self.tree.slots[slot as usize].text == text { @@ -453,10 +492,8 @@ impl Ui { // the tree text directly, and any later relayout re-collects the // run from the tree (never from the stale taffy context). let r = style::resolve(&self.tree.slots[root_slot as usize], &self.styles, true); - let fixed = r.width.is_finite() - && r.width >= 0.0 - && r.height.is_finite() - && r.height >= 0.0; + let fixed = + r.width.is_finite() && r.width >= 0.0 && r.height.is_finite() && r.height >= 0.0; if !fixed { self.layout.mark_style(root_slot); } @@ -492,7 +529,14 @@ impl Ui { /// stream (for PSM_T8: the index bytes AFTER the palette — the palette /// itself is never compressed) as PackBits-RLE, which must decode to /// EXACTLY w*h*bpp bytes; FLAG_LINEAR requests bilinear sampling. - pub fn upload_texture_flags(&mut self, data: &[u8], w: u32, h: u32, psm: u32, flags: u8) -> i32 { + pub fn upload_texture_flags( + &mut self, + data: &[u8], + w: u32, + h: u32, + psm: u32, + flags: u8, + ) -> i32 { let bpp = match psm { spec::psm::PSM_5650 | spec::psm::PSM_4444 => 2usize, spec::psm::PSM_8888 => 4usize, @@ -508,7 +552,10 @@ impl Ui { if data.len() < TEX_PALETTE_BYTES { return -1; } - (Some(copy_aligned(data, TEX_PALETTE_BYTES)), &data[TEX_PALETTE_BYTES..]) + ( + Some(copy_aligned(data, TEX_PALETTE_BYTES)), + &data[TEX_PALETTE_BYTES..], + ) } else { (None, data) }; @@ -518,8 +565,9 @@ impl Ui { // decode to EXACTLY byte_len bytes (codec contract) — anything // else is a malformed asset. let mut chunks = alloc::vec![0u128; byte_len.div_ceil(16)]; - let dst = - unsafe { core::slice::from_raw_parts_mut(chunks.as_mut_ptr() as *mut u8, byte_len) }; + let dst = unsafe { + core::slice::from_raw_parts_mut(chunks.as_mut_ptr() as *mut u8, byte_len) + }; if !codec::packbits_decode(stream, dst) { return -1; } @@ -552,7 +600,9 @@ impl Ui { /// then the payload — for PSM_T8 a 1024-byte palette then the pixel /// stream). Returns the texture handle, or -1 on malformed blobs. pub fn upload_img_entry(&mut self, blob: &[u8]) -> i32 { - let Some((w, h, psm, flags)) = parse_img_header(blob) else { return -1 }; + let Some((w, h, psm, flags)) = parse_img_header(blob) else { + return -1; + }; self.upload_texture_flags(&blob[8..], w, h, psm, flags) } @@ -564,7 +614,9 @@ impl Ui { /// tiles), out-of-range indices and malformed blobs. Every offset/length /// read is bounds-checked: malformed blobs return -1, never panic. pub fn upload_tileset_tile(&mut self, blob: &[u8], index: u32) -> i32 { - let Some(tile) = parse_tileset_tile(blob, index) else { return -1 }; + let Some(tile) = parse_tileset_tile(blob, index) else { + return -1; + }; // Reassemble the upload_texture_flags PSM_T8 layout (palette, then // pixel stream) — palette and stream live at unrelated offsets in // the entry. @@ -578,7 +630,13 @@ impl Ui { if tile.flags & spec::tileset::FLAG_LINEAR != 0 { img_flags |= spec::img::FLAG_LINEAR; } - self.upload_texture_flags(&data, tile.tile_w, tile.tile_h, spec::psm::PSM_T8, img_flags) + self.upload_texture_flags( + &data, + tile.tile_w, + tile.tile_h, + spec::psm::PSM_T8, + img_flags, + ) } /// Overwrite a live PSM_T8 texture's palette + pixels IN PLACE (the video @@ -591,15 +649,21 @@ impl Ui { /// Callers on the PSP must writeback the texture after (the GE samples /// RAM, not the dcache). pub fn update_texture_t8(&mut self, handle: i32, palette: &[u8], pixels: &[u8]) -> bool { - let Some(slot) = tex_resolve(&self.textures, handle) else { return false }; - let Some(tex) = self.textures[slot as usize].tex.as_mut() else { return false }; + let Some(slot) = tex_resolve(&self.textures, handle) else { + return false; + }; + let Some(tex) = self.textures[slot as usize].tex.as_mut() else { + return false; + }; if tex.psm != spec::psm::PSM_T8 || palette.len() != TEX_PALETTE_BYTES { return false; } if pixels.len() != tex.byte_len { return false; } - let Some(pal) = tex.palette.as_mut() else { return false }; + let Some(pal) = tex.palette.as_mut() else { + return false; + }; unsafe { core::ptr::copy_nonoverlapping( palette.as_ptr(), @@ -621,15 +685,12 @@ impl Ui { /// the slot's generation so every outstanding copy of the handle goes /// stale (resolves to nothing, draws nothing), and push the slot on the /// LIFO free list. Stale/unknown handles are silent no-ops. Freeing a - /// core-internal texture (a baked corner disc) is safe: the DiscCache - /// re-validates its handles each use and re-bakes dead ones. + /// core-internal texture (a baked corner disc or gradient layer) is safe: + /// paint caches re-validate handles and re-bake dead entries on demand. pub fn free_texture(&mut self, handle: i32) { - let Some(slot) = tex_resolve(&self.textures, handle) else { return }; - let s = &mut self.textures[slot as usize]; - s.tex = None; - s.gen = ((s.gen as u32 + 1) & TEX_GEN_MASK) as u16; - self.tex_free.push(slot); - self.bump_raster_revision(); + if tex_release(&mut self.textures, &mut self.tex_free, handle) { + self.bump_raster_revision(); + } } /// Bind an uploaded texture to an image node. Handles are 0-based, so @@ -639,7 +700,9 @@ impl Ui { if tex >= 0 && tex_resolve(&self.textures, tex).is_none() { return; } - let Some(slot) = self.tree.resolve(id) else { return }; + let Some(slot) = self.tree.resolve(id) else { + return; + }; let node = &mut self.tree.slots[slot as usize]; if node.node_type == spec::NodeType::Image as u8 { node.tex = if tex < 0 { -1 } else { tex }; @@ -658,7 +721,9 @@ impl Ui { return; } let frame = self.frame; - let Some(slot) = self.tree.resolve(id) else { return }; + let Some(slot) = self.tree.resolve(id) else { + return; + }; let node = &mut self.tree.slots[slot as usize]; if node.node_type != spec::NodeType::Image as u8 { return; @@ -717,10 +782,13 @@ impl Ui { if !spec::is_animatable(prop) || easing > spec::Easing::SpringBouncy as u8 { return -1; } - let Some(slot) = self.tree.resolve(id) else { return -1 }; + let Some(slot) = self.tree.resolve(id) else { + return -1; + }; let kind = spec::PROP_VALUE_KIND[prop as usize]; let is_color = kind == spec::value_kind::COLOR; - let from = style::resolve(&self.tree.slots[slot as usize], &self.styles, true).get_bits(prop); + let from = + style::resolve(&self.tree.slots[slot as usize], &self.styles, true).get_bits(prop); let to_bits = prop_bits(kind, to); let nid = self.tree.slots[slot as usize].id(slot); if !is_color { @@ -765,7 +833,9 @@ impl Ui { /// Cancel a running animation (leaves the prop at its current value, as a /// dynamic override). pub fn cancel_anim(&mut self, anim_id: i32) { - let Some(tslot) = self.anims.resolve(anim_id) else { return }; + let Some(tslot) = self.anims.resolve(anim_id) else { + return; + }; let (node_id, prop) = { let t = &self.anims.tracks[tslot as usize]; (t.node, t.prop) @@ -786,7 +856,13 @@ impl Ui { /// natively — zero JS runs on focus change. Variant swaps run through the /// record's transition block like `set_style`. pub fn set_focus(&mut self, id: i32) { - let target = if id == 0 { 0 } else if self.tree.resolve(id).is_some() { id } else { return }; + let target = if id == 0 { + 0 + } else if self.tree.resolve(id).is_some() { + id + } else { + return; + }; if target == self.focused { return; } @@ -810,7 +886,9 @@ impl Ui { /// focus; spec op 26 — the JS input layer holds it while the press /// button is down). pub fn set_active(&mut self, id: i32, active: bool) { - let Some(slot) = self.tree.resolve(id) else { return }; + let Some(slot) = self.tree.resolve(id) else { + return; + }; if self.tree.slots[slot as usize].active == active { return; } @@ -1083,7 +1161,10 @@ impl Ui { } } let style_id = self.tree.slots[slot as usize].style_id; - let Some(animation) = self.styles.record(style_id).and_then(|r| r.animation.clone()) + let Some(animation) = self + .styles + .record(style_id) + .and_then(|r| r.animation.clone()) else { return; }; @@ -1112,8 +1193,16 @@ impl Ui { tex_resolve(&self.textures, self.cursor_tex) .and_then(|slot| self.textures[slot as usize].tex.as_ref()) .map(|t| { - let w = if self.cursor_size.0 > 0.0 { self.cursor_size.0 } else { t.w as f32 }; - let h = if self.cursor_size.1 > 0.0 { self.cursor_size.1 } else { t.h as f32 }; + let w = if self.cursor_size.0 > 0.0 { + self.cursor_size.0 + } else { + t.w as f32 + }; + let h = if self.cursor_size.1 > 0.0 { + self.cursor_size.1 + } else { + t.h as f32 + }; ( self.cursor_tex as u32, self.cursor_pos.0 - self.cursor_hot.0, @@ -1134,6 +1223,7 @@ impl Ui { &mut self.textures, &mut self.tex_free, &mut self.discs, + &mut self.gradients, self.raster_density, &mut self.draw_list, self.inspect_id, @@ -1306,7 +1396,9 @@ impl Ui { fn text_layout_root(&self, mut slot: u32) -> u32 { loop { let parent = self.tree.slots[slot as usize].parent; - let Some(parent_slot) = self.tree.resolve(parent) else { return slot }; + let Some(parent_slot) = self.tree.resolve(parent) else { + return slot; + }; if self.tree.slots[parent_slot as usize].node_type != spec::NodeType::Text as u8 { return slot; } @@ -1442,7 +1534,13 @@ fn parse_tileset_tile(blob: &[u8], index: u32) -> Option> { let palette = blob.get(palette_off..palette_off.checked_add(TEX_PALETTE_BYTES)?)?; let start = data_off.checked_add(off as usize)?; let stream = blob.get(start..start.checked_add(len)?)?; - Some(TilesetTile { tile_w, tile_h, flags, palette, stream }) + Some(TilesetTile { + tile_w, + tile_h, + flags, + palette, + stream, + }) } /// Convert a `set_prop`/`animate` f64 payload to raw u32 prop bits per its diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index deecb9b3..2b0a0589 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -198,6 +198,7 @@ struct Rgb565WindowTarget<'a> { pixels: &'a mut [u16], full_stride: usize, full_pixel_len: usize, + origin_row_start: usize, origin_x: usize, origin_y: usize, width: usize, @@ -259,6 +260,21 @@ impl RenderTarget for Rgb565Target<'_> { impl Rgb565WindowTarget<'_> { #[inline] fn local_offset(&self, full_offset: usize) -> usize { + // A full-width row band is already laid out exactly like the source + // surface, just with its leading rows removed. Large software damage + // replays use this shape, so avoid a division and remainder for every + // blended pixel while retaining the same fail-fast bounds contract. + if self.origin_x == 0 && self.width == self.full_stride { + let local = full_offset + .checked_sub(self.origin_row_start) + .unwrap_or(self.pixels.len()); + assert!( + local < self.pixels.len(), + "raster write escaped the compact RGB565 window" + ); + return local; + } + let y = full_offset / self.full_stride; let x = full_offset % self.full_stride; assert!( @@ -426,6 +442,7 @@ pub fn render_scaled_rgb565_window_over( pixels: fb, full_stride: full_width as usize, full_pixel_len: full_width as usize * full_height as usize, + origin_row_start: physical.y0 as usize * full_width as usize, origin_x: physical.x0 as usize, origin_y: physical.y0 as usize, width, @@ -1306,6 +1323,92 @@ mod tests { fb[offset..offset + 4].try_into().unwrap() } + fn rgb565_window_scene() -> Vec { + vec![ + draw_op::RECT, + xy_word(0, 1), + wh_word(13, 7), + 0x8030_70d0, + draw_op::GRAD_RECT, + xy_word(1, 2), + wh_word(11, 4), + 0x9050_d020, + 0xc0e0_3050, + spec::GradDir::ToRight as u32, + draw_op::TRI, + xy_word(2, 1), + xy_word(12, 7), + xy_word(1, 8), + 0x7020_40f0, + 0xa0f0_9030, + 0x60b0_e080, + ] + } + + fn patterned_rgb565(len: usize) -> Vec { + (0..len) + .map(|index| (index as u16).wrapping_mul(4051) ^ 0x5a5a) + .collect() + } + + fn extract_rgb565_window( + full: &[u16], + full_width: usize, + scale: usize, + window: DamageRect, + ) -> Vec { + let x0 = window.x0 as usize * scale; + let x1 = window.x1 as usize * scale; + let y0 = window.y0 as usize * scale; + let y1 = window.y1 as usize * scale; + (y0..y1) + .flat_map(|y| full[y * full_width + x0..y * full_width + x1].iter()) + .copied() + .collect() + } + + #[test] + fn full_width_nonzero_y_rgb565_window_matches_full_target_at_scale_two() { + let mut ui = Ui::new(); + ui.set_viewport(13.0, 9.0); + let scale = 2usize; + let full_width = 13 * scale; + let mut full = patterned_rgb565(full_width * 9 * scale); + let window = DamageRect::new(0, 2, 13, 7); + let mut compact = extract_rgb565_window(&full, full_width, scale, window); + let words = rgb565_window_scene(); + + render_scaled_rgb565_over(&ui, &words, &mut full, scale as u32); + render_scaled_rgb565_window_over(&ui, &words, &mut compact, scale as u32, window); + + assert_eq!( + compact, + extract_rgb565_window(&full, full_width, scale, window), + "the contiguous full-width fast path must preserve painter-order RGB565 quantization" + ); + } + + #[test] + fn offset_rgb565_window_still_matches_full_target_at_scale_two() { + let mut ui = Ui::new(); + ui.set_viewport(13.0, 9.0); + let scale = 2usize; + let full_width = 13 * scale; + let mut full = patterned_rgb565(full_width * 9 * scale); + let window = DamageRect::new(2, 1, 11, 8); + let mut compact = extract_rgb565_window(&full, full_width, scale, window); + let words = rgb565_window_scene(); + + render_scaled_rgb565_over(&ui, &words, &mut full, scale as u32); + render_scaled_rgb565_window_over(&ui, &words, &mut compact, scale as u32, window); + + assert_eq!( + compact, + extract_rgb565_window(&full, full_width, scale, window), + "the offset compact-window mapping must remain byte-exact" + ); + } + #[test] fn linear_sample_coordinates_pin_clamped_edge_semantics() { assert_eq!( @@ -1623,12 +1726,7 @@ mod tests { let mut ui = Ui::new(); ui.set_viewport(96.0, 8.0); let frame = |color: u32| { - let mut words = vec![ - draw_op::RECT, - xy_word(0, 0), - wh_word(96, 8), - 0xff10_0804, - ]; + let mut words = vec![draw_op::RECT, xy_word(0, 0), wh_word(96, 8), 0xff10_0804]; for index in 0..9 { words.extend_from_slice(&[ draw_op::RECT, diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index ec7a45a9..29e87ed5 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -171,7 +171,11 @@ fn encode_atlas_version_density( out.push(0); // flags out.push(raster_density); out.push(0); // reserved - assert_eq!(glyphs.len(), glyph_count as usize, "test blob: glyphCount == cmap entries"); + assert_eq!( + glyphs.len(), + glyph_count as usize, + "test blob: glyphCount == cmap entries" + ); for &(cp, gid, adv) in glyphs { out.extend_from_slice(&cp.to_le_bytes()); out.extend_from_slice(&gid.to_le_bytes()); @@ -191,7 +195,10 @@ fn abgr(r: u8, g: u8, b: u8, a: u8) -> u32 { // ---- DrawList decoding helpers ------------------------------------------------ fn decode_xy(word: u32) -> (i32, i32) { - ((word & 0xffff) as u16 as i16 as i32, (word >> 16) as u16 as i16 as i32) + ( + (word & 0xffff) as u16 as i16 as i32, + (word >> 16) as u16 as i16 as i32, + ) } fn decode_wh(word: u32) -> (i32, i32) { @@ -205,20 +212,29 @@ fn validate_drawlist(words: &[u32]) -> [u32; 9] { let (sw, sh) = (spec::SCREEN_W as i32, spec::SCREEN_H as i32); let xy_ok = |w: u32| { let (x, y) = decode_xy(w); - assert!((0..=sw).contains(&x) && (0..=sh).contains(&y), "coord out of range: ({x},{y})"); + assert!( + (0..=sw).contains(&x) && (0..=sh).contains(&y), + "coord out of range: ({x},{y})" + ); }; let rect_ok = |xyw: u32, whw: u32| { xy_ok(xyw); let (x, y) = decode_xy(xyw); let (w, h) = decode_wh(whw); - assert!(x + w <= sw && y + h <= sh, "rect exceeds screen: {x},{y} {w}x{h}"); + assert!( + x + w <= sw && y + h <= sh, + "rect exceeds screen: {x},{y} {w}x{h}" + ); }; let mut counts = [0u32; 9]; let mut depth = 0i32; let mut i = 0usize; while i < words.len() { let op = words[i]; - assert!((op as usize) < counts.len(), "unknown draw op {op} at word {i}"); + assert!( + (op as usize) < counts.len(), + "unknown draw op {op} at word {i}" + ); counts[op as usize] += 1; match op { spec::draw_op::RECT => { @@ -297,16 +313,34 @@ fn tex_tri_runs(words: &[u32]) -> Vec<(u32, usize)> { previous_was_tex_tri = true; i += 12; } - spec::draw_op::RECT => { previous_was_tex_tri = false; i += 4; } - spec::draw_op::GRAD_RECT => { previous_was_tex_tri = false; i += 6; } + spec::draw_op::RECT => { + previous_was_tex_tri = false; + i += 4; + } + spec::draw_op::GRAD_RECT => { + previous_was_tex_tri = false; + i += 6; + } spec::draw_op::GLYPH_RUN => { previous_was_tex_tri = false; i += 3 + 2 * ((words[i + 1] >> 16) as usize); } - spec::draw_op::TEX_QUAD => { previous_was_tex_tri = false; i += 9; } - spec::draw_op::SCISSOR => { previous_was_tex_tri = false; i += 3; } - spec::draw_op::SCISSOR_POP => { previous_was_tex_tri = false; i += 1; } - spec::draw_op::TRI => { previous_was_tex_tri = false; i += 7; } + spec::draw_op::TEX_QUAD => { + previous_was_tex_tri = false; + i += 9; + } + spec::draw_op::SCISSOR => { + previous_was_tex_tri = false; + i += 3; + } + spec::draw_op::SCISSOR_POP => { + previous_was_tex_tri = false; + i += 1; + } + spec::draw_op::TRI => { + previous_was_tex_tri = false; + i += 7; + } other => panic!("unknown draw op {other} at word {i}"), } } @@ -363,7 +397,7 @@ fn insert_before_dom_move_semantics() { ui.insert_before(wrap, b, 0); ui.tick(); assert_eq!(ui.layout_of(b).unwrap().1, 0.0); // now relative to wrap - // Cycle guard: inserting an ancestor under its descendant is a no-op. + // Cycle guard: inserting an ancestor under its descendant is a no-op. ui.insert_before(b, wrap, 0); ui.tick(); assert_eq!(ui.layout_of(wrap).unwrap().1, 40.0); // still under root, after c+a @@ -375,7 +409,10 @@ fn style_resolution_with_focus_variant() { let red = abgr(255, 0, 0, 255); let green = abgr(0, 255, 0, 255); let mut s = StyleSpec::new(); - s.base = alloc::vec![(spec::prop::BG_COLOR, red), (spec::prop::WIDTH, 100f32.to_bits())]; + s.base = alloc::vec![ + (spec::prop::BG_COLOR, red), + (spec::prop::WIDTH, 100f32.to_bits()) + ]; s.focus = alloc::vec![(spec::prop::BG_COLOR, green)]; assert!(ui.load_styles(&encode_styles(&[s]))); let n = ui.create_node(0); @@ -474,9 +511,30 @@ fn fixed_dt_animation_is_deterministic() { ui.set_prop(n, spec::prop::HEIGHT, 40.0); ui.set_prop(n, spec::prop::BG_COLOR, abgr(200, 100, 50, 255) as f64); ui.insert_before(spec::ROOT_ID, n, 0); - ui.animate(n, spec::prop::TRANSLATE_X, 300.0, 500, spec::Easing::OutBack as u8, 32); - ui.animate(n, spec::prop::ROTATE, 65.0, 400, spec::Easing::Spring as u8, 0); - ui.animate(n, spec::prop::BG_COLOR, abgr(10, 220, 30, 255) as f64, 250, spec::Easing::EaseInOut as u8, 0); + ui.animate( + n, + spec::prop::TRANSLATE_X, + 300.0, + 500, + spec::Easing::OutBack as u8, + 32, + ); + ui.animate( + n, + spec::prop::ROTATE, + 65.0, + 400, + spec::Easing::Spring as u8, + 0, + ); + ui.animate( + n, + spec::prop::BG_COLOR, + abgr(10, 220, 30, 255) as f64, + 250, + spec::Easing::EaseInOut as u8, + 0, + ); let mut frames = Vec::new(); for _ in 0..70 { ui.tick(); @@ -513,7 +571,10 @@ fn gap_column_layout_matches_hand_computed() { kids.push(n); } ui.tick(); - assert_eq!(ui.layout_of(spec::ROOT_ID).unwrap(), (0.0, 0.0, 480.0, 272.0)); + assert_eq!( + ui.layout_of(spec::ROOT_ID).unwrap(), + (0.0, 0.0, 480.0, 272.0) + ); // Column: y = paddingT + sum(prev heights + gaps), x = paddingL. assert_eq!(ui.layout_of(kids[0]).unwrap(), (7.0, 5.0, 100.0, 40.0)); assert_eq!(ui.layout_of(kids[1]).unwrap(), (7.0, 55.0, 100.0, 50.0)); @@ -523,7 +584,11 @@ fn gap_column_layout_matches_hand_computed() { #[test] fn absolute_child_does_not_consume_flex_space() { let mut ui = Ui::new(); - ui.set_prop(spec::ROOT_ID, spec::prop::FLEX_DIR, spec::FlexDir::Row as u32 as f64); + ui.set_prop( + spec::ROOT_ID, + spec::prop::FLEX_DIR, + spec::FlexDir::Row as u32 as f64, + ); let app = ui.create_node(0); ui.set_prop(app, spec::prop::WIDTH, 480.0); @@ -531,7 +596,11 @@ fn absolute_child_does_not_consume_flex_space() { ui.insert_before(spec::ROOT_ID, app, 0); let overlay = ui.create_node(0); - ui.set_prop(overlay, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + overlay, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(overlay, spec::prop::INSET_T, 0.0); ui.set_prop(overlay, spec::prop::INSET_R, 0.0); ui.set_prop(overlay, spec::prop::INSET_B, 0.0); @@ -558,7 +627,11 @@ fn empty_text_nodes_are_excluded_from_layout() { ui.set_prop(spec::ROOT_ID, spec::prop::GAP, 10.0); // align-items start so the text node keeps its measured width instead of // stretching to the column's cross size. - ui.set_prop(spec::ROOT_ID, spec::prop::ALIGN, spec::Align::Start as u32 as f64); + ui.set_prop( + spec::ROOT_ID, + spec::prop::ALIGN, + spec::Align::Start as u32 as f64, + ); let a = ui.create_node(0); ui.set_prop(a, spec::prop::HEIGHT, 20.0); ui.insert_before(spec::ROOT_ID, a, 0); @@ -588,17 +661,21 @@ fn drawlist_clip_invariant_offscreen_rects() { let mut ui = Ui::new(); // Partially off every edge + fully off + rotated partially off. let cases: [(f64, f64, f64); 5] = [ - (450.0, 250.0, 0.0), // off bottom-right - (-30.0, -20.0, 0.0), // off top-left - (600.0, 10.0, 0.0), // fully off right - (400.0, -30.0, 45.0), // rotated, off top-right - (-40.0, 240.0, 30.0), // rotated, off bottom-left + (450.0, 250.0, 0.0), // off bottom-right + (-30.0, -20.0, 0.0), // off top-left + (600.0, 10.0, 0.0), // fully off right + (400.0, -30.0, 45.0), // rotated, off top-right + (-40.0, 240.0, 30.0), // rotated, off bottom-left ]; for (tx, ty, rot) in cases { let n = ui.create_node(0); ui.set_prop(n, spec::prop::WIDTH, 100.0); ui.set_prop(n, spec::prop::HEIGHT, 60.0); - ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + n, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(n, spec::prop::INSET_T, 0.0); ui.set_prop(n, spec::prop::INSET_L, 0.0); ui.set_prop(n, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); @@ -613,19 +690,30 @@ fn drawlist_clip_invariant_offscreen_rects() { let g = ui.create_node(0); ui.set_prop(g, spec::prop::WIDTH, 200.0); ui.set_prop(g, spec::prop::HEIGHT, 40.0); - ui.set_prop(g, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + g, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(g, spec::prop::INSET_T, 0.0); ui.set_prop(g, spec::prop::INSET_L, 0.0); ui.set_prop(g, spec::prop::GRAD_FROM, abgr(0, 0, 0, 255) as f64); ui.set_prop(g, spec::prop::GRAD_TO, abgr(200, 200, 200, 255) as f64); - ui.set_prop(g, spec::prop::GRAD_DIR, spec::GradDir::ToRight as u32 as f64); + ui.set_prop( + g, + spec::prop::GRAD_DIR, + spec::GradDir::ToRight as u32 as f64, + ); ui.set_prop(g, spec::prop::TRANSLATE_X, 380.0); // visible span = half ui.insert_before(spec::ROOT_ID, g, 0); ui.tick(); let words = ui.draw().words.clone(); let counts = validate_drawlist(&words); assert!(counts[spec::draw_op::RECT as usize] > 0); - assert!(counts[spec::draw_op::TRI as usize] > 0, "rotated offscreen boxes clip into TRIs"); + assert!( + counts[spec::draw_op::TRI as usize] > 0, + "rotated offscreen boxes clip into TRIs" + ); assert!(counts[spec::draw_op::GRAD_RECT as usize] > 0); // Find the gradient and check the endpoint re-interpolation: the rect // spans x 380..580, the clip keeps 380..480 = fractions 0.0..0.5, so the @@ -640,8 +728,13 @@ fn drawlist_clip_invariant_offscreen_rects() { let (w, _) = decode_wh(words[i + 2]); assert_eq!((x, w), (380, 100)); assert_eq!(words[i + 3], abgr(0, 0, 0, 255)); // from untouched (clip starts at 0.0) - let expected = crate::anim::interp(abgr(0, 0, 0, 255), abgr(200, 200, 200, 255), 0.5, true); - assert_eq!(words[i + 4], expected, "gradient to-color re-interpolated over the clip"); + let expected = + crate::anim::interp(abgr(0, 0, 0, 255), abgr(200, 200, 200, 255), 0.5, true); + assert_eq!( + words[i + 4], + expected, + "gradient to-color re-interpolated over the clip" + ); found = true; i += 6; } @@ -661,7 +754,11 @@ fn rounded_boxes_emit_subpixel_edge_coverage() { let n = ui.create_node(0); ui.set_prop(n, spec::prop::WIDTH, 36.0); ui.set_prop(n, spec::prop::HEIGHT, 20.0); - ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + n, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(n, spec::prop::INSET_T, 10.5); ui.set_prop(n, spec::prop::INSET_L, 10.5); ui.set_prop(n, spec::prop::RADIUS, 10.0); @@ -703,7 +800,10 @@ fn rounded_boxes_emit_subpixel_edge_coverage() { break; } } - assert!(partial, "the baked disc must carry antialiased coverage alpha"); + assert!( + partial, + "the baked disc must carry antialiased coverage alpha" + ); } #[test] @@ -712,7 +812,11 @@ fn scaled_flat_rounded_box_has_no_gaps_between_fast_path_pieces() { let n = ui.create_node(0); ui.set_prop(n, spec::prop::WIDTH, 70.0); ui.set_prop(n, spec::prop::HEIGHT, 70.0); - ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + n, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(n, spec::prop::INSET_T, 20.0); ui.set_prop(n, spec::prop::INSET_L, 12.0); ui.set_prop(n, spec::prop::RADIUS, 12.0); @@ -743,7 +847,11 @@ fn rounded_corner_masks_follow_raster_density_without_changing_layout() { let n = ui.create_node(0); ui.set_prop(n, spec::prop::WIDTH, 36.0); ui.set_prop(n, spec::prop::HEIGHT, 20.0); - ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + n, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(n, spec::prop::INSET_T, 10.0); ui.set_prop(n, spec::prop::INSET_L, 10.0); ui.set_prop(n, spec::prop::RADIUS, 10.0); @@ -752,11 +860,28 @@ fn rounded_corner_masks_follow_raster_density_without_changing_layout() { ui.tick(); let words = ui.draw().words.clone(); - let i = words.iter().position(|&word| word == spec::draw_op::TEX_QUAD).unwrap(); - let view = ui.texture(words[i + 1] as i32).expect("density-scaled disc texture"); - assert_eq!((view.w, view.h), (64, 64), "20px disc at 2x is padded to 64px"); - assert_eq!(decode_wh(words[i + 3]), (10, 10), "DrawList geometry stays logical"); - assert_eq!(f32::from_bits(words[i + 6]), 20.0 / 64.0, "UV selects one 2x quadrant"); + let i = words + .iter() + .position(|&word| word == spec::draw_op::TEX_QUAD) + .unwrap(); + let view = ui + .texture(words[i + 1] as i32) + .expect("density-scaled disc texture"); + assert_eq!( + (view.w, view.h), + (64, 64), + "20px disc at 2x is padded to 64px" + ); + assert_eq!( + decode_wh(words[i + 3]), + (10, 10), + "DrawList geometry stays logical" + ); + assert_eq!( + f32::from_bits(words[i + 6]), + 20.0 / 64.0, + "UV selects one 2x quadrant" + ); } #[test] @@ -772,7 +897,11 @@ fn transparent_rounded_border_draws_an_outline_not_square_strips() { let n = ui.create_node(0); ui.set_prop(n, spec::prop::WIDTH, 20.0); ui.set_prop(n, spec::prop::HEIGHT, 12.0); - ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + n, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(n, spec::prop::INSET_T, 10.0); ui.set_prop(n, spec::prop::INSET_L, 10.0); ui.set_prop(n, spec::prop::RADIUS, 6.0); @@ -815,30 +944,272 @@ fn transparent_rounded_border_draws_an_outline_not_square_strips() { } assert!(covers_top_mid, "top edge should be present"); assert!(covers_left_mid, "left edge should be present"); - assert!(!covers_outer_corner, "rounded transparent border must not draw square outer corners"); - assert!(!covers_center, "transparent border must not fill the center"); + assert!( + !covers_outer_corner, + "rounded transparent border must not draw square outer corners" + ); + assert!( + !covers_center, + "transparent border must not fill the center" + ); } #[test] -fn rounded_gradients_emit_rect_coverage_spans() { +fn rounded_gradients_emit_cached_texture_layers() { let mut ui = Ui::new(); let n = ui.create_node(0); ui.set_prop(n, spec::prop::WIDTH, 120.0); ui.set_prop(n, spec::prop::HEIGHT, 12.0); - ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + n, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(n, spec::prop::INSET_T, 20.0); ui.set_prop(n, spec::prop::INSET_L, 20.0); ui.set_prop(n, spec::prop::RADIUS, 6.0); ui.set_prop(n, spec::prop::GRAD_FROM, abgr(251, 191, 36, 255) as f64); ui.set_prop(n, spec::prop::GRAD_TO, abgr(217, 119, 6, 255) as f64); - ui.set_prop(n, spec::prop::GRAD_DIR, spec::GradDir::ToRight as u32 as f64); + ui.set_prop( + n, + spec::prop::GRAD_DIR, + spec::GradDir::ToRight as u32 as f64, + ); ui.insert_before(spec::ROOT_ID, n, 0); ui.tick(); - let counts = validate_drawlist(&ui.draw().words.clone()); - assert!(counts[spec::draw_op::RECT as usize] > 0); + let words = ui.draw().words.clone(); + let counts = validate_drawlist(&words); + assert_eq!(counts[spec::draw_op::RECT as usize], 0); + assert_eq!( + counts[spec::draw_op::GRAD_RECT as usize], + 0, + "rounded gradients must not rely on 1px-high GRAD_RECT triangle strips", + ); + assert_eq!(counts[spec::draw_op::TEX_QUAD as usize], 1); + let index = words + .iter() + .position(|&word| word == spec::draw_op::TEX_QUAD) + .unwrap(); + let texture = ui + .texture(words[index + 1] as i32) + .expect("cached rounded-gradient layer"); + assert_eq!(texture.psm, spec::psm::PSM_8888); + assert!(!texture.linear, "exact layers use nearest sampling"); + assert_eq!( + words[index + 8], + 0xffff_ffff, + "opacity is baked into straight RGBA" + ); +} + +#[test] +fn cached_rounded_gradients_are_pixel_exact_and_keep_opcode_structure_during_motion() { + fn fixture(cache: bool) -> (Ui, i32) { + let mut ui = Ui::new_with_raster_density(2); + if !cache { + ui.disable_gradient_cache(); + } + let background = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(background, spec::prop::WIDTH, 300.0); + ui.set_prop(background, spec::prop::HEIGHT, 20.0); + ui.set_prop( + background, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); + ui.set_prop(background, spec::prop::INSET_L, 8.0); + ui.set_prop(background, spec::prop::INSET_T, 16.0); + ui.set_prop( + background, + spec::prop::BG_COLOR, + abgr(15, 23, 42, 255) as f64, + ); + ui.insert_before(spec::ROOT_ID, background, 0); + + let gradient = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(gradient, spec::prop::WIDTH, 256.0); + ui.set_prop(gradient, spec::prop::HEIGHT, 4.0); + ui.set_prop( + gradient, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); + ui.set_prop(gradient, spec::prop::INSET_L, 24.0); + ui.set_prop(gradient, spec::prop::INSET_T, 20.0); + ui.set_prop(gradient, spec::prop::RADIUS, 2.0); + ui.set_prop( + gradient, + spec::prop::GRAD_FROM, + abgr(147, 197, 253, 128) as f64, + ); + ui.set_prop(gradient, spec::prop::GRAD_TO, abgr(34, 211, 238, 0) as f64); + ui.set_prop( + gradient, + spec::prop::GRAD_DIR, + spec::GradDir::ToRight as u32 as f64, + ); + ui.insert_before(spec::ROOT_ID, gradient, 0); + ui.tick(); + (ui, gradient) + } + + let (mut cached, moving) = fixture(true); + let (mut analytic, _) = fixture(false); + let cached_words = cached.draw().words.clone(); + let analytic_words = analytic.draw().words.clone(); + let pixel_len = (spec::SCREEN_W * 2 * spec::SCREEN_H * 2 * 4) as usize; + let mut cached_rgba = alloc::vec![0u8; pixel_len]; + let mut analytic_rgba = alloc::vec![0u8; pixel_len]; + crate::raster::render_scaled(&cached, &cached_words, &mut cached_rgba, 2); + crate::raster::render_scaled(&analytic, &analytic_words, &mut analytic_rgba, 2); + assert_eq!( + cached_rgba, analytic_rgba, + "RGBA output must remain byte-exact" + ); + + let mut cached_565 = alloc::vec![0u16; (spec::SCREEN_W * 2 * spec::SCREEN_H * 2) as usize]; + let mut analytic_565 = alloc::vec![0u16; cached_565.len()]; + crate::raster::render_scaled_rgb565(&cached, &cached_words, &mut cached_565, 2); + crate::raster::render_scaled_rgb565(&analytic, &analytic_words, &mut analytic_565, 2); + assert_eq!( + cached_565, analytic_565, + "RGB565 output must remain byte-exact" + ); + + let counts = validate_drawlist(&cached_words); assert_eq!( - counts[spec::draw_op::GRAD_RECT as usize], 0, - "rounded gradients must not rely on 1px-high GRAD_RECT triangle strips" + counts[spec::draw_op::TEX_QUAD as usize], + 2, + "the 256px layer reserves two density-2 tiles for fractional motion", + ); + assert_eq!(cached.gradient_cache_stats(), (1, 16 * 1024)); + + let target = crate::damage::DamageTarget::new(spec::SCREEN_W * 2, spec::SCREEN_H * 2, 2, 1); + let mut tracker = + crate::damage::DamageTracker::<{ crate::damage::DEFAULT_DAMAGE_REGIONS }>::new(); + tracker.commit(&cached, &cached_words, target); + cached.set_prop(moving, spec::prop::TRANSLATE_X, 0.25); + cached.tick(); + let moved_words = cached.draw().words.clone(); + let moved_counts = validate_drawlist(&moved_words); + assert_eq!(moved_counts[spec::draw_op::TEX_QUAD as usize], 2); + let damage = tracker.prepare(&cached, &moved_words, target).unwrap(); + assert!( + !damage.is_full_redraw(), + "subpixel motion must keep DrawList opcode structure" + ); + assert!(damage.area() < (spec::SCREEN_W * spec::SCREEN_H) as u64 / 2); +} + +#[test] +fn rounded_gradient_phase_churn_reuses_texture_slots_within_the_cache_budget() { + fn gradient(ui: &mut Ui, x: f64, y: f64, width: f64, from: u32, to: u32) -> i32 { + let node = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(node, spec::prop::WIDTH, width); + ui.set_prop(node, spec::prop::HEIGHT, 4.0); + ui.set_prop( + node, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); + ui.set_prop(node, spec::prop::INSET_L, x); + ui.set_prop(node, spec::prop::INSET_T, y); + ui.set_prop(node, spec::prop::RADIUS, 2.0); + ui.set_prop(node, spec::prop::GRAD_FROM, from as f64); + ui.set_prop(node, spec::prop::GRAD_TO, to as f64); + ui.set_prop( + node, + spec::prop::GRAD_DIR, + spec::GradDir::ToRight as u32 as f64, + ); + ui.insert_before(spec::ROOT_ID, node, 0); + node + } + + let mut ui = Ui::new_with_raster_density(2); + // Feature Cards' exact layer shape: two ambient streaks plus three static + // card strips. The wider streak reserves two density-2 tiles; all others + // fit one tile. + let streak_a = gradient( + &mut ui, + 24.0, + 58.0, + 256.0, + abgr(147, 197, 253, 128), + abgr(34, 211, 238, 0), + ); + let streak_b = gradient( + &mut ui, + 210.0, + 246.0, + 224.0, + abgr(103, 232, 249, 102), + abgr(34, 211, 238, 0), + ); + for (index, color) in [ + abgr(59, 130, 246, 255), + abgr(16, 185, 129, 255), + abgr(245, 158, 11, 255), + ] + .into_iter() + .enumerate() + { + gradient( + &mut ui, + 28.0 + index as f64 * 148.0, + 104.0, + 112.0, + color, + color & 0xff00_0000 | ((color & 0x00ff_ffff) >> 1), + ); + } + + let mut slots_after_warmup = 0usize; + for phase in 1..=256u32 { + ui.set_prop( + streak_a, + spec::prop::TRANSLATE_X, + (276.0f32 * phase as f32 / 1200.0) as f64, + ); + ui.set_prop( + streak_b, + spec::prop::TRANSLATE_X, + (-260.0f32 * phase as f32 / 1560.0) as f64, + ); + ui.tick(); + let counts = validate_drawlist(&ui.draw().words.clone()); + assert_eq!( + counts[spec::draw_op::TEX_QUAD as usize], + 6, + "five gradients must keep the same six-tile topology", + ); + assert_eq!(counts[spec::draw_op::RECT as usize], 0); + if phase == 40 { + assert_eq!( + ui.gradient_cache_stats(), + (14, 240 * 1024), + "Feature Cards frame 40 cache high-water contract", + ); + assert_eq!(ui.texture_slot_count(), 20); + } + if phase == 128 { + slots_after_warmup = ui.texture_slot_count(); + } + } + let (entries, bytes) = ui.gradient_cache_stats(); + assert!( + entries <= 16, + "byte budget, not phase count, bounds live entries" + ); + assert!(bytes <= 256 * 1024); + assert!( + ui.texture_slot_count() <= 21, + "eviction must reuse generation slots" + ); + assert_eq!( + ui.texture_slot_count(), + slots_after_warmup, + "texture slot storage must stop growing after the cache is warm", ); } @@ -848,12 +1219,20 @@ fn overflow_hidden_emits_balanced_intersected_scissors() { let outer = ui.create_node(0); ui.set_prop(outer, spec::prop::WIDTH, 100.0); ui.set_prop(outer, spec::prop::HEIGHT, 80.0); - ui.set_prop(outer, spec::prop::OVERFLOW, spec::Overflow::Hidden as u32 as f64); + ui.set_prop( + outer, + spec::prop::OVERFLOW, + spec::Overflow::Hidden as u32 as f64, + ); ui.insert_before(spec::ROOT_ID, outer, 0); let inner = ui.create_node(0); ui.set_prop(inner, spec::prop::WIDTH, 300.0); // overflows outer ui.set_prop(inner, spec::prop::HEIGHT, 300.0); - ui.set_prop(inner, spec::prop::OVERFLOW, spec::Overflow::Hidden as u32 as f64); + ui.set_prop( + inner, + spec::prop::OVERFLOW, + spec::Overflow::Hidden as u32 as f64, + ); ui.set_prop(inner, spec::prop::SHRINK, 0.0); ui.insert_before(outer, inner, 0); let leaf = ui.create_node(0); @@ -904,7 +1283,10 @@ fn overflow_hidden_emits_balanced_intersected_scissors() { if depth == 2 { let (x, y) = decode_xy(words[i + 1]); let (w, h) = decode_wh(words[i + 2]); - assert!(x + w <= 100 && y + h <= 80, "leaf rect not clipped to scissor"); + assert!( + x + w <= 100 && y + h <= 80, + "leaf rect not clipped to scissor" + ); } i += 4; } @@ -938,7 +1320,7 @@ fn text_measurement_against_synthetic_atlas() { assert_eq!(ui.measure_text("AB\nA", 2), 11.0); // max line assert_eq!(ui.measure_text("", 2), 0.0); assert_eq!(ui.measure_text("A", 0), 0.0); // unregistered slot - // cmap miss -> tofu (gid 0) + miss counter, advance = cell width. + // cmap miss -> tofu (gid 0) + miss counter, advance = cell width. assert_eq!(ui.glyph_misses(), 0); assert_eq!(ui.measure_text("Z", 2), 8.0); assert_eq!(ui.glyph_misses(), 1); @@ -952,21 +1334,12 @@ fn text_measurement_against_synthetic_atlas() { fn font_atlas_v3_scales_coverage_without_scaling_layout_metrics() { let glyphs = &[(0xfffd, 0, 8), ('A' as u32, 1, 6), ('B' as u32, 2, 5)]; let mut ui = Ui::new(); - let mut hd = encode_atlas_version_density( - spec::font_atlas::VERSION, - 2, - 2, - 8, - 8, - 7, - 10, - 3, - glyphs, - ); + let mut hd = + encode_atlas_version_density(spec::font_atlas::VERSION, 2, 2, 8, 8, 7, 10, 3, glyphs); // gid 1, logical pixel (0,0): four density-2 samples reduce to their // rounded mean, not the top-left sample. - let bitmap_off = spec::font_atlas::HEADER_SIZE - + glyphs.len() * spec::font_atlas::CMAP_ENTRY_SIZE; + let bitmap_off = + spec::font_atlas::HEADER_SIZE + glyphs.len() * spec::font_atlas::CMAP_ENTRY_SIZE; let coverage_w = 16usize; let coverage_h = 16usize; let gid_1 = bitmap_off + coverage_w * coverage_h; @@ -982,7 +1355,11 @@ fn font_atlas_v3_scales_coverage_without_scaling_layout_metrics() { assert_eq!(atlas.bytes_per_row(), 16); assert_eq!(atlas.glyph_rows(1).len(), 16 * 16); assert_eq!(atlas.logical_coverage(1, 0, 0), 112); - assert_eq!(atlas.logical_coverage(1, 8, 0), 0, "out-of-range is transparent"); + assert_eq!( + atlas.logical_coverage(1, 8, 0), + 0, + "out-of-range is transparent" + ); // Advances, line height, and therefore app layout stay in logical px. assert_eq!(ui.measure_text("AB", 2), 11.0); @@ -994,24 +1371,21 @@ fn font_atlas_v3_scales_coverage_without_scaling_layout_metrics() { assert!(ui.load_font_atlas(&legacy)); let legacy_atlas = ui.font_atlas(3).unwrap(); assert_eq!(legacy_atlas.raster_density, 1); - assert_eq!((legacy_atlas.coverage_width(), legacy_atlas.coverage_height()), (8, 8)); + assert_eq!( + ( + legacy_atlas.coverage_width(), + legacy_atlas.coverage_height() + ), + (8, 8) + ); assert_eq!(legacy_atlas.glyph_rows(1).len(), 8 * 8); assert_eq!(legacy_atlas.logical_coverage(1, 0, 0), 173); assert_eq!(ui.measure_text("AB", 3), 11.0); // Density zero has no meaning in v3, and truncation is checked against // density-scaled coverage rather than only logical cell dimensions. - let invalid_density = encode_atlas_version_density( - spec::font_atlas::VERSION, - 0, - 4, - 8, - 8, - 7, - 10, - 3, - glyphs, - ); + let invalid_density = + encode_atlas_version_density(spec::font_atlas::VERSION, 0, 4, 8, 8, 7, 10, 3, glyphs); assert!(!ui.load_font_atlas(&invalid_density)); assert!(!ui.load_font_atlas(&hd[..hd.len() - 1])); } @@ -1032,7 +1406,11 @@ fn glyph_runs_render_with_alignment_and_color() { let t = ui.create_node(spec::NodeType::Text as u8); ui.set_prop(t, spec::prop::WIDTH, 51.0); ui.set_prop(t, spec::prop::TEXT_COLOR, color as f64); - ui.set_prop(t, spec::prop::TEXT_ALIGN, spec::TextAlign::Right as u32 as f64); + ui.set_prop( + t, + spec::prop::TEXT_ALIGN, + spec::TextAlign::Right as u32 as f64, + ); // Mixed run: element text + text-node child concatenate. ui.set_text(t, "A"); let child = ui.create_node(spec::NodeType::Text as u8); @@ -1043,7 +1421,10 @@ fn glyph_runs_render_with_alignment_and_color() { let words = ui.draw().words.clone(); validate_drawlist(&words); // One run, 2 glyphs, right-aligned in 51px: line w = 11 -> x0 = 40. - let i = words.iter().position(|&w| w == spec::draw_op::GLYPH_RUN).unwrap(); + let i = words + .iter() + .position(|&w| w == spec::draw_op::GLYPH_RUN) + .unwrap(); assert_eq!(words[i + 1], 2 << 16); // slot 0, n = 2 assert_eq!(words[i + 2], color); assert_eq!(decode_xy(words[i + 3]), (40, 1)); // y: (10 - 8) / 2 = 1 @@ -1059,7 +1440,14 @@ fn explicit_animate_lifecycle() { ui.set_prop(n, spec::prop::WIDTH, 100.0); ui.insert_before(spec::ROOT_ID, n, 0); // 10 frames linear 100 -> 200 (layout-dirtying: width relayouts). - let aid = ui.animate(n, spec::prop::WIDTH, 200.0, 167, spec::Easing::Linear as u8, 0); + let aid = ui.animate( + n, + spec::prop::WIDTH, + 200.0, + 167, + spec::Easing::Linear as u8, + 0, + ); assert!(aid > 0); for _ in 0..5 { ui.tick(); @@ -1067,7 +1455,7 @@ fn explicit_animate_lifecycle() { let mid = ui.resolved_style(n).unwrap().width; assert!(mid > 100.0 && mid < 200.0); assert_eq!(ui.layout_of(n).unwrap().2, crate::layout::roundf(mid)); // relayouted this frame - // Cancel freezes the current value (as a dynamic override). + // Cancel freezes the current value (as a dynamic override). ui.cancel_anim(aid); let frozen = ui.resolved_style(n).unwrap().width; assert_eq!(frozen, mid); @@ -1078,7 +1466,14 @@ fn explicit_animate_lifecycle() { // Stale anim id: no-op. ui.cancel_anim(aid); // Run one to completion: final value persists as an override. - let aid2 = ui.animate(n, spec::prop::WIDTH, 300.0, 100, spec::Easing::EaseOut as u8, 0); + let aid2 = ui.animate( + n, + spec::prop::WIDTH, + 300.0, + 100, + spec::Easing::EaseOut as u8, + 0, + ); assert!(aid2 > 0); for _ in 0..20 { ui.tick(); @@ -1096,7 +1491,14 @@ fn explicit_animate_retargets_same_prop_from_current_value() { ui.set_prop(n, spec::prop::WIDTH, 0.0); ui.insert_before(spec::ROOT_ID, n, 0); - let first = ui.animate(n, spec::prop::WIDTH, 100.0, 600, spec::Easing::Linear as u8, 0); + let first = ui.animate( + n, + spec::prop::WIDTH, + 100.0, + 600, + spec::Easing::Linear as u8, + 0, + ); assert!(first > 0); for _ in 0..9 { ui.tick(); @@ -1104,7 +1506,14 @@ fn explicit_animate_retargets_same_prop_from_current_value() { let mid = ui.resolved_style(n).unwrap().width; assert_eq!(mid, 25.0); - let second = ui.animate(n, spec::prop::WIDTH, 200.0, 600, spec::Easing::Linear as u8, 0); + let second = ui.animate( + n, + spec::prop::WIDTH, + 200.0, + 600, + spec::Easing::Linear as u8, + 0, + ); assert!(second > 0); assert_eq!(ui.resolved_style(n).unwrap().width, mid); ui.cancel_anim(first); // stale id: the second animation killed the first. @@ -1158,7 +1567,10 @@ fn opacity_multiplies_down_the_subtree() { ui.tick(); let words = ui.draw().words.clone(); validate_drawlist(&words); - let i = words.iter().position(|&w| w == spec::draw_op::RECT).unwrap(); + let i = words + .iter() + .position(|&w| w == spec::draw_op::RECT) + .unwrap(); let a = words[i + 3] >> 24; // 255 * 0.5 * 0.5 ≈ 64 (rounding via +0.5 in scale_alpha). assert_eq!(a, 64); @@ -1171,7 +1583,11 @@ fn zindex_orders_siblings_stably() { let n = ui.create_node(0); ui.set_prop(n, spec::prop::WIDTH, 10.0); ui.set_prop(n, spec::prop::HEIGHT, 10.0); - ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + n, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(n, spec::prop::INSET_T, 0.0); ui.set_prop(n, spec::prop::INSET_L, 0.0); ui.set_prop(n, spec::prop::Z_INDEX, z); @@ -1211,8 +1627,14 @@ fn image_tex_quad_clips_with_uv_reinterpolation() { assert_eq!(tex, 0); // Validation failures. assert_eq!(ui.upload_texture(&pixels, 17, 16, spec::psm::PSM_8888), -1); - assert_eq!(ui.upload_texture(&pixels, 1024, 16, spec::psm::PSM_8888), -1); - assert_eq!(ui.upload_texture(&pixels[..8], 16, 16, spec::psm::PSM_8888), -1); + assert_eq!( + ui.upload_texture(&pixels, 1024, 16, spec::psm::PSM_8888), + -1 + ); + assert_eq!( + ui.upload_texture(&pixels[..8], 16, 16, spec::psm::PSM_8888), + -1 + ); assert_eq!(ui.upload_texture(&pixels, 16, 16, 99), -1); let view = ui.texture(tex).unwrap(); assert_eq!( @@ -1220,12 +1642,20 @@ fn image_tex_quad_clips_with_uv_reinterpolation() { (1024, 16, 16, spec::psm::PSM_8888) ); assert!(view.palette.is_none() && !view.linear); - assert_eq!(view.pixels.as_ptr() as usize % 16, 0, "texture pixels must be 16-byte aligned"); + assert_eq!( + view.pixels.as_ptr() as usize % 16, + 0, + "texture pixels must be 16-byte aligned" + ); let img = ui.create_node(spec::NodeType::Image as u8); ui.set_prop(img, spec::prop::WIDTH, 100.0); ui.set_prop(img, spec::prop::HEIGHT, 100.0); - ui.set_prop(img, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + img, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(img, spec::prop::INSET_T, 0.0); ui.set_prop(img, spec::prop::INSET_L, 0.0); ui.set_prop(img, spec::prop::TRANSLATE_X, 430.0); // half off right: u1 = 0.5 @@ -1234,7 +1664,10 @@ fn image_tex_quad_clips_with_uv_reinterpolation() { ui.tick(); let words = ui.draw().words.clone(); validate_drawlist(&words); - let i = words.iter().position(|&w| w == spec::draw_op::TEX_QUAD).unwrap(); + let i = words + .iter() + .position(|&w| w == spec::draw_op::TEX_QUAD) + .unwrap(); assert_eq!(words[i + 1], tex as u32); assert_eq!(decode_xy(words[i + 2]), (430, 0)); assert_eq!(decode_wh(words[i + 3]), (50, 100)); @@ -1247,7 +1680,10 @@ fn image_tex_quad_clips_with_uv_reinterpolation() { fn root_is_a_full_screen_flex_column() { let mut ui = Ui::new(); ui.tick(); - assert_eq!(ui.layout_of(spec::ROOT_ID).unwrap(), (0.0, 0.0, 480.0, 272.0)); + assert_eq!( + ui.layout_of(spec::ROOT_ID).unwrap(), + (0.0, 0.0, 480.0, 272.0) + ); let r = ui.resolved_style(spec::ROOT_ID).unwrap(); assert_eq!(r.flex_dir, spec::FlexDir::Col as u8); // Root cannot be destroyed. @@ -1274,14 +1710,18 @@ fn style_table_parse_rejects_garbage() { let good = encode_styles(&[StyleSpec::new()]); assert!(ui.load_styles(&good)); assert!(!ui.load_styles(&good[..good.len() - 1 + 0][..6])); // truncated header - // A record with a style id past the table resolves as unstyled. - // (Compare via raw prop bits — Resolved holds NANs, so PartialEq lies.) + // A record with a style id past the table resolves as unstyled. + // (Compare via raw prop bits — Resolved holds NANs, so PartialEq lies.) let n = ui.create_node(0); ui.set_style(n, 99); let r = ui.resolved_style(n).unwrap(); let d = style::Resolved::default(); for prop in 0u16..=255 { - assert_eq!(r.get_bits(prop as u8), d.get_bits(prop as u8), "prop {prop}"); + assert_eq!( + r.get_bits(prop as u8), + d.get_bits(prop as u8), + "prop {prop}" + ); } } @@ -1298,7 +1738,10 @@ fn scale_only_transform_stays_axis_aligned() { let words = ui.draw().words.clone(); let counts = validate_drawlist(&words); assert_eq!(counts[spec::draw_op::TRI as usize], 0); - let i = words.iter().position(|&w| w == spec::draw_op::RECT).unwrap(); + let i = words + .iter() + .position(|&w| w == spec::draw_op::RECT) + .unwrap(); // Scaled 0.5 about center: 100x50 -> 50x25 at (25, 12.5->13 rounded). assert_eq!(decode_xy(words[i + 1]), (25, 13)); assert_eq!(decode_wh(words[i + 2]), (50, 25)); @@ -1322,7 +1765,10 @@ fn scale_x_transform_is_paint_only_and_can_anchor_left() { let words = ui.draw().words.clone(); let counts = validate_drawlist(&words); assert_eq!(counts[spec::draw_op::TRI as usize], 0); - let i = words.iter().position(|&w| w == spec::draw_op::RECT).unwrap(); + let i = words + .iter() + .position(|&w| w == spec::draw_op::RECT) + .unwrap(); assert_eq!(decode_xy(words[i + 1]), (0, 0)); assert_eq!(decode_wh(words[i + 2]), (50, 20)); } @@ -1346,7 +1792,10 @@ fn root_cannot_be_reparented_under_a_detached_node() { // Also via an ATTACHED parent (plain cycle guard case). ui.insert_before(n, spec::ROOT_ID, 0); ui.tick(); - assert_eq!(ui.layout_of(spec::ROOT_ID).unwrap(), (0.0, 0.0, 480.0, 272.0)); + assert_eq!( + ui.layout_of(spec::ROOT_ID).unwrap(), + (0.0, 0.0, 480.0, 272.0) + ); } #[test] @@ -1373,14 +1822,34 @@ fn size_full_sentinel_is_not_animatable() { ui.insert_before(spec::ROOT_ID, n, 0); ui.tick(); // animate() TO the sentinel: no-op, returns -1, width stays put. - assert_eq!(ui.animate(n, spec::prop::WIDTH, -1.0, 500, spec::Easing::Linear as u8, 0), -1); + assert_eq!( + ui.animate( + n, + spec::prop::WIDTH, + -1.0, + 500, + spec::Easing::Linear as u8, + 0 + ), + -1 + ); for _ in 0..5 { ui.tick(); } assert_eq!(ui.layout_of(n).unwrap().2, 200.0); // animate() FROM the sentinel: also a no-op. ui.set_prop(n, spec::prop::WIDTH, -1.0); - assert_eq!(ui.animate(n, spec::prop::WIDTH, 100.0, 500, spec::Easing::Linear as u8, 0), -1); + assert_eq!( + ui.animate( + n, + spec::prop::WIDTH, + 100.0, + 500, + spec::Easing::Linear as u8, + 0 + ), + -1 + ); ui.tick(); assert_eq!(ui.layout_of(n).unwrap().2, 480.0); // Style transitions between a pixel width and w-full spawn NO width @@ -1398,7 +1867,11 @@ fn size_full_sentinel_is_not_animatable() { ui.tick(); assert_eq!(ui.layout_of(m).unwrap().2, 160.0); ui.set_style(m, 1); - assert_eq!(ui.resolved_style(m).unwrap().width, -1.0, "snap, not a tween"); + assert_eq!( + ui.resolved_style(m).unwrap().width, + -1.0, + "snap, not a tween" + ); ui.tick(); assert_eq!(ui.layout_of(m).unwrap().2, 480.0); } @@ -1439,7 +1912,17 @@ fn auto_endpoints_snap_instead_of_tweening_nan() { // Explicit animate() from auto: no track, target written as an override. let m = ui.create_node(0); ui.insert_before(spec::ROOT_ID, m, 0); - assert_eq!(ui.animate(m, spec::prop::HEIGHT, 50.0, 300, spec::Easing::Linear as u8, 0), -1); + assert_eq!( + ui.animate( + m, + spec::prop::HEIGHT, + 50.0, + 300, + spec::Easing::Linear as u8, + 0 + ), + -1 + ); ui.tick(); assert_eq!(ui.layout_of(m).unwrap().3, 50.0); } @@ -1451,11 +1934,17 @@ fn insert_past_max_tree_depth_is_a_noop() { let mut parent = spec::ROOT_ID; for depth in 1..=spec::MAX_TREE_DEPTH { let n = t.alloc(0); - assert!(t.insert_before(parent, n, 0), "insert at depth {depth} must succeed"); + assert!( + t.insert_before(parent, n, 0), + "insert at depth {depth} must succeed" + ); parent = n; } let over = t.alloc(0); - assert!(!t.insert_before(parent, over, 0), "insert past MAX_TREE_DEPTH must no-op"); + assert!( + !t.insert_before(parent, over, 0), + "insert past MAX_TREE_DEPTH must no-op" + ); // Ui-level smoke: the capped chain still ticks/draws safely. let mut ui = Ui::new(); let mut parent = spec::ROOT_ID; @@ -1515,7 +2004,11 @@ fn cmap_xoff_shifts_glyph_cells_left() { blob[a_entry + 7] = 2; let mut ui = Ui::new(); assert!(ui.load_font_atlas(&blob)); - assert_eq!(ui.measure_text("AB", 0), 11.0, "xoff must not change advances"); + assert_eq!( + ui.measure_text("AB", 0), + 11.0, + "xoff must not change advances" + ); ui.set_prop(spec::ROOT_ID, spec::prop::PADDING_L, 10.0); let t = ui.create_node(spec::NodeType::Text as u8); ui.set_prop(t, spec::prop::TEXT_COLOR, abgr(255, 255, 255, 255) as f64); @@ -1524,9 +2017,20 @@ fn cmap_xoff_shifts_glyph_cells_left() { ui.tick(); let words = ui.draw().words.clone(); validate_drawlist(&words); - let i = words.iter().position(|&w| w == spec::draw_op::GLYPH_RUN).unwrap(); - assert_eq!(decode_xy(words[i + 3]).0, 10 - 2, "'A' cell shifted left by its xoff"); - assert_eq!(decode_xy(words[i + 5]).0, 10 + 6, "'B' (xoff 0) at the plain pen position"); + let i = words + .iter() + .position(|&w| w == spec::draw_op::GLYPH_RUN) + .unwrap(); + assert_eq!( + decode_xy(words[i + 3]).0, + 10 - 2, + "'A' cell shifted left by its xoff" + ); + assert_eq!( + decode_xy(words[i + 5]).0, + 10 + 6, + "'B' (xoff 0) at the plain pen position" + ); } /// Two runs of a whole interaction script (styles, focus transitions, @@ -1591,7 +2095,14 @@ fn slide_anim() -> AnimSpec { fill: spec::style_table::ANIM_FILL_BACKWARDS | spec::style_table::ANIM_FILL_FORWARDS, tracks: alloc::vec![( spec::prop::TRANSLATE_X, - alloc::vec![SegSpec(0, 60, 0f32.to_bits(), 60f32.to_bits(), spec::Easing::Linear as u8, None)], + alloc::vec![SegSpec( + 0, + 60, + 0f32.to_bits(), + 60f32.to_bits(), + spec::Easing::Linear as u8, + None + )], )], } } @@ -1655,7 +2166,14 @@ fn timeline_list_precedence_matches_css() { fill: spec::style_table::ANIM_FILL_BACKWARDS | spec::style_table::ANIM_FILL_FORWARDS, tracks: alloc::vec![( spec::prop::OPACITY, - alloc::vec![SegSpec(0, 30, 0f32.to_bits(), 1f32.to_bits(), spec::Easing::Linear as u8, None)], + alloc::vec![SegSpec( + 0, + 30, + 0f32.to_bits(), + 1f32.to_bits(), + spec::Easing::Linear as u8, + None + )], )], }; let fade_out = AnimSpec { @@ -1665,7 +2183,14 @@ fn timeline_list_precedence_matches_css() { fill: spec::style_table::ANIM_FILL_FORWARDS, tracks: alloc::vec![( spec::prop::OPACITY, - alloc::vec![SegSpec(0, 30, 1f32.to_bits(), 0f32.to_bits(), spec::Easing::Linear as u8, None)], + alloc::vec![SegSpec( + 0, + 30, + 1f32.to_bits(), + 0f32.to_bits(), + spec::Easing::Linear as u8, + None + )], )], }; let mut s = StyleSpec::new(); @@ -1845,7 +2370,11 @@ fn perspective_subdivided_images_form_one_texture_run_per_image() { assert_ne!(cover_tex, reflection_tex); let place = |ui: &mut Ui, node: i32, x: f64, y: f64, w: f64, h: f64| { - ui.set_prop(node, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + node, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(node, spec::prop::INSET_L, x); ui.set_prop(node, spec::prop::INSET_T, y); ui.set_prop(node, spec::prop::WIDTH, w); @@ -1881,7 +2410,10 @@ fn perspective_subdivided_images_form_one_texture_run_per_image() { let runs = tex_tri_runs(&words); assert_eq!( runs, - alloc::vec![(cover_tex as u32, tri_count / 2), (reflection_tex as u32, tri_count / 2)], + alloc::vec![ + (cover_tex as u32, tri_count / 2), + (reflection_tex as u32, tri_count / 2) + ], "each image must be one consecutive PSP texture batch", ); } @@ -1947,7 +2479,16 @@ fn debug_pause_freezes_and_step_advances_one_frame() { ui.set_style(n, 0); ui.tick(); // 600 ms linear width anim: ~4.4 px per 1/60 frame — visible per tick. - assert!(ui.animate(n, spec::prop::WIDTH, 200.0, 600, spec::Easing::Linear as u8, 0) >= 0); + assert!( + ui.animate( + n, + spec::prop::WIDTH, + 200.0, + 600, + spec::Easing::Linear as u8, + 0 + ) >= 0 + ); ui.tick(); ui.draw(); let w1 = ui.layout_of(n).unwrap().2; @@ -1998,7 +2539,11 @@ fn debug_inspect_overlays_and_reports_world_rect() { assert_eq!(ui.debug_rect_xy(), -1, "rect only captured by draw()"); let overlaid = ui.draw().words.len(); // Overlay = translucent fill + 4 edge rects = 5 RECT ops = 20 words. - assert_eq!(overlaid, baseline + 20, "highlight overlay must be appended"); + assert_eq!( + overlaid, + baseline + 20, + "highlight overlay must be appended" + ); assert_eq!(ui.debug_rect_xy(), 10 | (10 << 16)); assert_eq!(ui.debug_rect_wh(), 40 | (40 << 16)); @@ -2042,18 +2587,29 @@ fn debug_inspect_glides_between_targets() { let baseline = ui.draw().words.len(); ui.debug_inspect(a); - assert_eq!(overlay_x(&mut ui, baseline), 10, "first appearance is instant"); + assert_eq!( + overlay_x(&mut ui, baseline), + 10, + "first appearance is instant" + ); ui.debug_inspect(b); let x1 = overlay_x(&mut ui, baseline); - assert!(x1 > 10 && x1 < 200, "glide starts between the boxes, got {x1}"); + assert!( + x1 > 10 && x1 < 200, + "glide starts between the boxes, got {x1}" + ); let x2 = overlay_x(&mut ui, baseline); assert!(x2 > x1, "glide advances every draw, got {x1} -> {x2}"); for _ in 0..30 { ui.draw(); } assert_eq!(overlay_x(&mut ui, baseline), 200, "glide converges exactly"); - assert_eq!(ui.debug_rect_xy(), 200 | (10 << 16), "readback is the target, not the animation"); + assert_eq!( + ui.debug_rect_xy(), + 200 | (10 << 16), + "readback is the target, not the animation" + ); ui.debug_inspect(0); assert_eq!(ui.draw().words.len(), baseline, "clear hides the overlay"); @@ -2099,7 +2655,10 @@ fn set_text_relayout_scope() { // …and paint still shows the new text (reads the tree, not the ctx). let words = ui.draw().words.clone(); validate_drawlist(&words); - let runs = words.iter().filter(|&&w| w == spec::draw_op::GLYPH_RUN).count(); + let runs = words + .iter() + .filter(|&&w| w == spec::draw_op::GLYPH_RUN) + .count(); assert_eq!(runs, 2, "both texts painted"); // Empty <-> non-empty flips are structural (full rebuild). @@ -2186,14 +2745,21 @@ fn packbits_round_trips_against_the_encoder_mirror() { while src.len() < 4096 { state = state.wrapping_mul(1664525).wrapping_add(1013904223); let b = (state >> 24) as u8; - let n = if state & 7 == 0 { (state >> 16 & 0xff) as usize + 1 } else { 1 }; + let n = if state & 7 == 0 { + (state >> 16 & 0xff) as usize + 1 + } else { + 1 + }; for _ in 0..n { src.push(b); } } src.truncate(4096); // Sanity-check the mirror against one spec.ts hand vector. - assert_eq!(packbits_encode(&[1, 1, 1, 1, 2, 3]), alloc::vec![130, 1, 1, 2, 3]); + assert_eq!( + packbits_encode(&[1, 1, 1, 1, 2, 3]), + alloc::vec![130, 1, 1, 2, 3] + ); let enc = packbits_encode(&src); assert!(enc.len() < src.len(), "the vector must actually compress"); let mut dec = alloc::vec![0u8; src.len()]; @@ -2205,13 +2771,28 @@ fn packbits_round_trips_against_the_encoder_mirror() { fn packbits_rejects_malformed_streams_without_panicking() { use crate::codec::packbits_decode; let mut out = [0u8; 4]; - assert!(!packbits_decode(&[3, 1, 2], &mut out), "truncated literal payload"); - assert!(!packbits_decode(&[130], &mut out), "run without a value byte"); + assert!( + !packbits_decode(&[3, 1, 2], &mut out), + "truncated literal payload" + ); + assert!( + !packbits_decode(&[130], &mut out), + "run without a value byte" + ); assert!(!packbits_decode(&[255, 1], &mut out), "run overruns dst"); - assert!(!packbits_decode(&[5, 1, 2, 3, 4, 5, 6], &mut out), "literal overruns dst"); - assert!(!packbits_decode(&[128, 1], &mut out), "src exhausted before dst full"); + assert!( + !packbits_decode(&[5, 1, 2, 3, 4, 5, 6], &mut out), + "literal overruns dst" + ); + assert!( + !packbits_decode(&[128, 1], &mut out), + "src exhausted before dst full" + ); let mut two = [0u8; 2]; - assert!(!packbits_decode(&[128, 5, 0, 1], &mut two), "trailing bytes after exact fit"); + assert!( + !packbits_decode(&[128, 5, 0, 1], &mut two), + "trailing bytes after exact fit" + ); assert!(packbits_decode(&[128, 5], &mut two)); assert_eq!(two, [5, 5]); } @@ -2231,16 +2812,33 @@ fn t8_upload_carries_an_aligned_palette_and_raw_indices() { let tex = ui.upload_texture(&data, 8, 8, spec::psm::PSM_T8); assert_eq!(tex, 0, "first slot at gen 0 is handle 0"); let view = ui.texture(tex).unwrap(); - assert_eq!((view.w, view.h, view.psm, view.linear), (8, 8, spec::psm::PSM_T8, false)); + assert_eq!( + (view.w, view.h, view.psm, view.linear), + (8, 8, spec::psm::PSM_T8, false) + ); assert_eq!(view.pixels, &indices[..]); - assert_eq!(view.pixels.as_ptr() as usize % 16, 0, "indices must be 16-byte aligned"); + assert_eq!( + view.pixels.as_ptr() as usize % 16, + 0, + "indices must be 16-byte aligned" + ); let pal = view.palette.expect("T8 textures carry a palette"); assert_eq!(pal.len(), 1024); - assert_eq!(pal.as_ptr() as usize % 16, 0, "palette must be 16-byte aligned"); + assert_eq!( + pal.as_ptr() as usize % 16, + 0, + "palette must be 16-byte aligned" + ); assert_eq!((pal[4], pal[7]), (1, 255)); // Undersized: palette alone, or palette + short index stream. - assert_eq!(ui.upload_texture(&data[..1023], 8, 8, spec::psm::PSM_T8), -1); - assert_eq!(ui.upload_texture(&data[..1024 + 63], 8, 8, spec::psm::PSM_T8), -1); + assert_eq!( + ui.upload_texture(&data[..1023], 8, 8, spec::psm::PSM_T8), + -1 + ); + assert_eq!( + ui.upload_texture(&data[..1024 + 63], 8, 8, spec::psm::PSM_T8), + -1 + ); } #[test] @@ -2350,7 +2948,11 @@ fn tileset_tile_materializes_pixel_stream_tiles_only() { assert_eq!((view.w, view.h, view.psm), (4, 4, spec::psm::PSM_T8)); assert!(view.linear, "tileset flags bit 1 maps to bilinear sampling"); assert_eq!(view.pixels, &[5u8; 16][..]); - assert_eq!(view.palette.unwrap()[5 * 4], 0xaa, "shared entry palette rides along"); + assert_eq!( + view.palette.unwrap()[5 * 4], + 0xaa, + "shared entry palette rides along" + ); // ABSENT and SOLID tiles are the host's job (drawn as background/RECTs). assert_eq!(ui.upload_tileset_tile(&blob, 1), -1); assert_eq!(ui.upload_tileset_tile(&blob, 2), -1); @@ -2366,8 +2968,16 @@ fn tileset_tile_rejects_malformed_blobs_without_panicking() { let blob = tiny_tileset(); assert_eq!(ui.upload_tileset_tile(&blob, 4), -1, "index out of range"); assert_eq!(ui.upload_tileset_tile(&blob, u32::MAX), -1); - assert_eq!(ui.upload_tileset_tile(&blob[..blob.len() - 1], 3), -1, "truncated stream"); - assert_eq!(ui.upload_tileset_tile(&blob[..ts::HEADER_SIZE], 0), -1, "header only"); + assert_eq!( + ui.upload_tileset_tile(&blob[..blob.len() - 1], 3), + -1, + "truncated stream" + ); + assert_eq!( + ui.upload_tileset_tile(&blob[..ts::HEADER_SIZE], 0), + -1, + "header only" + ); assert_eq!(ui.upload_tileset_tile(&[], 0), -1); let mut bad_magic = blob.clone(); bad_magic[0] ^= 0xff; @@ -2393,7 +3003,11 @@ fn freed_handles_go_stale_and_slots_reuse_under_a_new_generation() { let px = alloc::vec![0xffu8; 8 * 8 * 4]; let a = ui.upload_texture(&px, 8, 8, spec::psm::PSM_8888); let b = ui.upload_texture(&px, 8, 8, spec::psm::PSM_8888); - assert_eq!((a, b), (0, 1), "sequential uploads keep the old 0-based numbering"); + assert_eq!( + (a, b), + (0, 1), + "sequential uploads keep the old 0-based numbering" + ); ui.free_texture(a); assert!(ui.texture(a).is_none(), "freed handle resolves to None"); ui.free_texture(a); // double free: silent no-op @@ -2404,7 +3018,10 @@ fn freed_handles_go_stale_and_slots_reuse_under_a_new_generation() { assert!(c > 0, "handles stay positive (bit 31 clear)"); assert_eq!(c as u32 & spec::TEX_SLOT_MASK, 0, "slot 0 reused LIFO"); assert_eq!(c as u32 >> spec::TEX_SLOT_BITS, 1, "generation bumped"); - assert!(ui.texture(a).is_none(), "the stale handle stays dead after reuse"); + assert!( + ui.texture(a).is_none(), + "the stale handle stays dead after reuse" + ); assert!(ui.texture(c).is_some()); // set_image ignores the stale handle but honors the live one. let img = ui.create_node(spec::NodeType::Image as u8); @@ -2417,7 +3034,10 @@ fn freed_handles_go_stale_and_slots_reuse_under_a_new_generation() { let (h1, _) = ui.texture_at(1).unwrap(); assert_eq!(h1, b); ui.free_texture(c); - assert!(ui.texture_at(0).is_none(), "free slots are skipped by the walk"); + assert!( + ui.texture_at(0).is_none(), + "free slots are skipped by the walk" + ); assert_eq!(ui.texture_slot_count(), 2, "slot storage never shrinks"); assert!(ui.texture_at(2).is_none()); } @@ -2433,7 +3053,10 @@ fn disc_cache_survives_js_freeing_its_texture() { ui.insert_before(spec::ROOT_ID, n, 0); ui.tick(); let find_disc = |words: &[u32]| -> i32 { - let i = words.iter().position(|&w| w == spec::draw_op::TEX_QUAD).unwrap(); + let i = words + .iter() + .position(|&w| w == spec::draw_op::TEX_QUAD) + .unwrap(); words[i + 1] as i32 }; let disc = find_disc(&ui.draw().words.clone()); @@ -2456,7 +3079,11 @@ fn disc_cache_survives_js_freeing_its_texture() { /// (hit tests only consider nodes that paint — see draw::claims_hit). fn abs_box(ui: &mut Ui, parent: i32, x: f64, y: f64, w: f64, h: f64) -> i32 { let n = ui.create_node(0); - ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + n, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(n, spec::prop::INSET_L, x); ui.set_prop(n, spec::prop::INSET_T, y); ui.set_prop(n, spec::prop::WIDTH, w); @@ -2483,16 +3110,28 @@ fn hit_test_topmost_wins_and_containers_pass_through() { // A transparent full-screen wrapper OVER the panel does not occlude it, // but its own painted children do. let wrapper = ui.create_node(0); - ui.set_prop(wrapper, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + wrapper, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(wrapper, spec::prop::INSET_L, 0.0); ui.set_prop(wrapper, spec::prop::INSET_T, 0.0); ui.set_prop(wrapper, spec::prop::WIDTH, 480.0); ui.set_prop(wrapper, spec::prop::HEIGHT, 272.0); ui.insert_before(spec::ROOT_ID, wrapper, 0); ui.tick(); - assert_eq!(ui.hit_test(90.0, 40.0), panel, "overlay layer passes hits through"); + assert_eq!( + ui.hit_test(90.0, 40.0), + panel, + "overlay layer passes hits through" + ); let toast = abs_box(&mut ui, wrapper, 80.0, 30.0, 40.0, 20.0); - assert_eq!(ui.hit_test(90.0, 40.0), toast, "painted overlay content claims"); + assert_eq!( + ui.hit_test(90.0, 40.0), + toast, + "painted overlay content claims" + ); // Outside the viewport (half-open edges): nothing. assert_eq!(ui.hit_test(480.0, 100.0), 0); assert_eq!(ui.hit_test(-1.0, 100.0), 0); @@ -2508,30 +3147,58 @@ fn hit_test_paint_order_and_z_index() { let below = abs_box(&mut ui, spec::ROOT_ID, 10.0, 10.0, 40.0, 40.0); let above = abs_box(&mut ui, spec::ROOT_ID, 30.0, 10.0, 40.0, 40.0); ui.tick(); - assert_eq!(ui.hit_test(35.0, 20.0), above, "document order: later sibling on top"); - assert_eq!(ui.hit_test(15.0, 20.0), below, "non-overlapped area still hits the first"); + assert_eq!( + ui.hit_test(35.0, 20.0), + above, + "document order: later sibling on top" + ); + assert_eq!( + ui.hit_test(15.0, 20.0), + below, + "non-overlapped area still hits the first" + ); // z-index beats document order (mirrors the paint sort). ui.set_prop(below, spec::prop::Z_INDEX, 5.0); - assert_eq!(ui.hit_test(35.0, 20.0), below, "z-index raises the earlier sibling"); + assert_eq!( + ui.hit_test(35.0, 20.0), + below, + "z-index raises the earlier sibling" + ); } #[test] fn hit_test_display_none_overflow_and_transforms() { let mut ui = Ui::new(); let clipper = abs_box(&mut ui, spec::ROOT_ID, 10.0, 10.0, 30.0, 30.0); - ui.set_prop(clipper, spec::prop::OVERFLOW, spec::Overflow::Hidden as u32 as f64); + ui.set_prop( + clipper, + spec::prop::OVERFLOW, + spec::Overflow::Hidden as u32 as f64, + ); let wide = abs_box(&mut ui, clipper, 0.0, 0.0, 200.0, 20.0); ui.tick(); - assert_eq!(ui.hit_test(20.0, 15.0), wide, "inside the clip the child hits"); + assert_eq!( + ui.hit_test(20.0, 15.0), + wide, + "inside the clip the child hits" + ); assert_eq!( ui.hit_test(100.0, 15.0), 0, "outside the overflow-hidden box the child's box is clipped away" ); // display:none removes the whole subtree from hit testing. - ui.set_prop(clipper, spec::prop::DISPLAY, spec::Display::None as u32 as f64); + ui.set_prop( + clipper, + spec::prop::DISPLAY, + spec::Display::None as u32 as f64, + ); assert_eq!(ui.hit_test(20.0, 15.0), 0); - ui.set_prop(clipper, spec::prop::DISPLAY, spec::Display::Flex as u32 as f64); + ui.set_prop( + clipper, + spec::prop::DISPLAY, + spec::Display::Flex as u32 as f64, + ); // Translate moves the hit box with the paint box. let mover = abs_box(&mut ui, spec::ROOT_ID, 100.0, 100.0, 20.0, 20.0); ui.set_prop(mover, spec::prop::TRANSLATE_X, 50.0); @@ -2548,7 +3215,11 @@ fn hit_test_display_none_overflow_and_transforms() { let ghost_child = abs_box(&mut ui, ghost, 0.0, 0.0, 20.0, 20.0); ui.set_prop(ghost, spec::prop::OPACITY, 0.0); ui.tick(); - assert_eq!(ui.hit_test(210.0, 210.0), 0, "invisible subtree hits nothing"); + assert_eq!( + ui.hit_test(210.0, 210.0), + 0, + "invisible subtree hits nothing" + ); ui.set_prop(ghost, spec::prop::OPACITY, 0.5); assert_eq!(ui.hit_test(210.0, 210.0), ghost_child, "faded still claims"); } @@ -2564,7 +3235,11 @@ fn hit_test_variant_styled_hotspots_and_perspective_roots_claim() { plain_style.base = alloc::vec![(spec::prop::WIDTH, 10f32.to_bits())]; assert!(ui.load_styles(&encode_styles(&[hotspot_style, plain_style]))); let hotspot = ui.create_node(0); - ui.set_prop(hotspot, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + hotspot, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(hotspot, spec::prop::INSET_L, 30.0); ui.set_prop(hotspot, spec::prop::INSET_T, 30.0); ui.set_prop(hotspot, spec::prop::WIDTH, 40.0); @@ -2572,11 +3247,19 @@ fn hit_test_variant_styled_hotspots_and_perspective_roots_claim() { ui.set_style(hotspot, 0); ui.insert_before(spec::ROOT_ID, hotspot, 0); ui.tick(); - assert_eq!(ui.hit_test(40.0, 40.0), hotspot, "focus:-styled hotspot claims unfocused"); + assert_eq!( + ui.hit_test(40.0, 40.0), + hotspot, + "focus:-styled hotspot claims unfocused" + ); // A plain-styled unpainted box still passes through (record present, but // no paint in any variant). let wrapper = ui.create_node(0); - ui.set_prop(wrapper, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + wrapper, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(wrapper, spec::prop::INSET_L, 30.0); ui.set_prop(wrapper, spec::prop::INSET_T, 30.0); ui.set_prop(wrapper, spec::prop::WIDTH, 40.0); @@ -2584,7 +3267,11 @@ fn hit_test_variant_styled_hotspots_and_perspective_roots_claim() { ui.set_style(wrapper, 1); ui.insert_before(spec::ROOT_ID, wrapper, 0); ui.tick(); - assert_eq!(ui.hit_test(40.0, 40.0), hotspot, "styled-but-unpainted wrapper passes through"); + assert_eq!( + ui.hit_test(40.0, 40.0), + hotspot, + "styled-but-unpainted wrapper passes through" + ); // A perspective context root claims its own box: a click on visible 3D // content must never fall through to what is painted behind it. let stage = abs_box(&mut ui, spec::ROOT_ID, 100.0, 100.0, 60.0, 60.0); @@ -2592,7 +3279,11 @@ fn hit_test_variant_styled_hotspots_and_perspective_roots_claim() { let card = abs_box(&mut ui, stage, 10.0, 10.0, 40.0, 40.0); ui.tick(); let _ = card; - assert_eq!(ui.hit_test(130.0, 130.0), stage, "3D context root claims, children untestable"); + assert_eq!( + ui.hit_test(130.0, 130.0), + stage, + "3D context root claims, children untestable" + ); } #[test] @@ -2606,42 +3297,75 @@ fn cursor_sprite_draws_last_hides_and_survives_free() { // No cursor bound: no TEX_QUAD at all. let words = ui.draw().words.clone(); - assert_eq!(validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], 0); + assert_eq!( + validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], + 0 + ); // Bound: exactly one TEX_QUAD, as the LAST op, offset by the hotspot. ui.set_cursor(tex, 2.0, 3.0, 0.0, 0.0); ui.set_cursor_pos(100.0, 50.0); let words = ui.draw().words.clone(); - assert_eq!(validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], 1); - let i = words.iter().position(|&w| w == spec::draw_op::TEX_QUAD).unwrap(); + assert_eq!( + validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], + 1 + ); + let i = words + .iter() + .position(|&w| w == spec::draw_op::TEX_QUAD) + .unwrap(); assert_eq!(i + 9, words.len(), "cursor is the final DrawList entry"); assert_eq!(words[i + 1], tex as u32); - assert_eq!(decode_xy(words[i + 2]), (98, 47), "hotspot offsets the sprite"); - assert_eq!(decode_wh(words[i + 3]), (8, 8), "size defaults to the texture"); + assert_eq!( + decode_xy(words[i + 2]), + (98, 47), + "hotspot offsets the sprite" + ); + assert_eq!( + decode_wh(words[i + 3]), + (8, 8), + "size defaults to the texture" + ); // Explicit logical size overrides the texture dimensions. ui.set_cursor(tex, 0.0, 0.0, 16.0, 12.0); let words = ui.draw().words.clone(); - let i = words.iter().position(|&w| w == spec::draw_op::TEX_QUAD).unwrap(); + let i = words + .iter() + .position(|&w| w == spec::draw_op::TEX_QUAD) + .unwrap(); assert_eq!(decode_wh(words[i + 3]), (16, 12)); // The viewport edge clips the sprite instead of wrapping i16 coords. ui.set_cursor_pos(476.0, 268.0); let words = ui.draw().words.clone(); - let i = words.iter().position(|&w| w == spec::draw_op::TEX_QUAD).unwrap(); - assert_eq!(decode_wh(words[i + 3]), (4, 4), "clipped at the screen edge"); + let i = words + .iter() + .position(|&w| w == spec::draw_op::TEX_QUAD) + .unwrap(); + assert_eq!( + decode_wh(words[i + 3]), + (4, 4), + "clipped at the screen edge" + ); // Freeing the bound texture hides the cursor (stale handles draw nothing). ui.free_texture(tex); let words = ui.draw().words.clone(); - assert_eq!(validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], 0); + assert_eq!( + validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], + 0 + ); // Unbinding via tex < 0 also hides it. let tex2 = ui.upload_texture(&[0x80u8; 8 * 8 * 4], 8, 8, spec::psm::PSM_8888); ui.set_cursor(tex2, 0.0, 0.0, 0.0, 0.0); ui.set_cursor(-1, 0.0, 0.0, 0.0, 0.0); let words = ui.draw().words.clone(); - assert_eq!(validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], 0); + assert_eq!( + validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], + 0 + ); } #[test] @@ -2653,7 +3377,11 @@ fn hit_test_never_sees_the_cursor_sprite() { ui.set_cursor_pos(60.0, 60.0); ui.tick(); ui.draw(); - assert_eq!(ui.hit_test(60.0, 60.0), under, "the sprite never occludes the tree"); + assert_eq!( + ui.hit_test(60.0, 60.0), + under, + "the sprite never occludes the tree" + ); } #[test] @@ -2664,21 +3392,45 @@ fn hit_test_frame_edge_overlay_claims_only_its_band() { // edge band and nothing else. let row = abs_box(&mut ui, spec::ROOT_ID, 20.0, 20.0, 100.0, 20.0); let edge = ui.create_node(0); - ui.set_prop(edge, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + edge, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(edge, spec::prop::INSET_L, 0.0); ui.set_prop(edge, spec::prop::INSET_T, 0.0); ui.set_prop(edge, spec::prop::WIDTH, 480.0); ui.set_prop(edge, spec::prop::HEIGHT, 272.0); - ui.set_prop(edge, spec::prop::BEVEL_OUTER_LIGHT, abgr(255, 255, 255, 255) as f64); - ui.set_prop(edge, spec::prop::BEVEL_OUTER_DARK, abgr(0, 0, 0, 255) as f64); + ui.set_prop( + edge, + spec::prop::BEVEL_OUTER_LIGHT, + abgr(255, 255, 255, 255) as f64, + ); + ui.set_prop( + edge, + spec::prop::BEVEL_OUTER_DARK, + abgr(0, 0, 0, 255) as f64, + ); ui.insert_before(spec::ROOT_ID, edge, 0); ui.tick(); - assert_eq!(ui.hit_test(50.0, 30.0), row, "the overlay's transparent center passes through"); - assert_eq!(ui.hit_test(1.0, 100.0), edge, "the painted ring band claims"); + assert_eq!( + ui.hit_test(50.0, 30.0), + row, + "the overlay's transparent center passes through" + ); + assert_eq!( + ui.hit_test(1.0, 100.0), + edge, + "the painted ring band claims" + ); assert_eq!(ui.hit_test(479.0, 100.0), edge, "right band too"); // A border-only well behaves the same: band claims, interior passes. let well = ui.create_node(0); - ui.set_prop(well, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + well, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(well, spec::prop::INSET_L, 20.0); ui.set_prop(well, spec::prop::INSET_T, 15.0); ui.set_prop(well, spec::prop::WIDTH, 200.0); @@ -2688,7 +3440,11 @@ fn hit_test_frame_edge_overlay_claims_only_its_band() { ui.insert_before(spec::ROOT_ID, well, 0); ui.tick(); assert_eq!(ui.hit_test(21.0, 30.0), well, "border band claims"); - assert_eq!(ui.hit_test(50.0, 30.0), row, "border interior passes through"); + assert_eq!( + ui.hit_test(50.0, 30.0), + row, + "border interior passes through" + ); } // ---- STREAM container (.pkst) — stream.rs readers + the video plane --------- @@ -2746,13 +3502,26 @@ fn stream_header_block_round_trips() { assert_eq!((h.video.fps_num, h.video.fps_den), (15, 1)); assert_eq!((h.video.slot_count, h.video.latest_seq), (8, 42)); assert_eq!(h.video.total_frames, 900); - assert_eq!(h.video.slot_size, (32 + 1024 + 256 * 128u32).next_multiple_of(16)); + assert_eq!( + h.video.slot_size, + (32 + 1024 + 256 * 128u32).next_multiple_of(16) + ); assert_eq!((h.audio.sample_rate, h.audio.channels), (22050, 2)); - assert_eq!((h.audio.chunk_frames, h.audio.chunk_count, h.audio.latest_seq), (2048, 64, 17)); + assert_eq!( + ( + h.audio.chunk_frames, + h.audio.chunk_count, + h.audio.latest_seq + ), + (2048, 64, 17) + ); // Ring offsets: seq 1 sits at the ring base; seqs wrap modulo the count. assert_eq!(crate::stream::slot_offset(&h, 1), Some(h.video_off)); assert_eq!(crate::stream::slot_offset(&h, 9), Some(h.video_off)); - assert_eq!(crate::stream::slot_offset(&h, 2), Some(h.video_off + h.video.slot_size)); + assert_eq!( + crate::stream::slot_offset(&h, 2), + Some(h.video_off + h.video.slot_size) + ); assert_eq!(crate::stream::slot_offset(&h, 0), None, "seqs start at 1"); let chunk = crate::stream::chunk_size(2048, 2).unwrap(); assert_eq!(chunk, 16 + 2048 * 2 * 2); @@ -2821,8 +3590,14 @@ fn stream_slot_and_chunk_headers_validate() { let ch = crate::stream::parse_chunk_header(&chunk).expect("valid chunk"); assert_eq!((ch.seq, ch.start_frame), (5, 40960)); chunk[0..4].copy_from_slice(&0u32.to_le_bytes()); - assert!(crate::stream::parse_chunk_header(&chunk).is_none(), "seq 0 = never written"); - assert!(crate::stream::parse_chunk_header(&chunk[..8]).is_none(), "short header"); + assert!( + crate::stream::parse_chunk_header(&chunk).is_none(), + "seq 0 = never written" + ); + assert!( + crate::stream::parse_chunk_header(&chunk[..8]).is_none(), + "short header" + ); } #[test] @@ -2842,13 +3617,22 @@ fn update_texture_t8_overwrites_in_place() { let px = alloc::vec![1u8; 16]; assert!(ui.update_texture_t8(plane, &pal, &px)); let view = ui.texture(plane).expect("plane still live"); - assert_eq!(view.palette.unwrap()[4..8], abgr(0, 255, 0, 255).to_le_bytes()); + assert_eq!( + view.palette.unwrap()[4..8], + abgr(0, 255, 0, 255).to_le_bytes() + ); assert_eq!(view.pixels, &px[..]); assert_eq!(ui.texture_revision(plane), Some(1)); // Size/format/liveness misuse changes nothing and reports false. - assert!(!ui.update_texture_t8(plane, &pal[..100], &px), "short palette"); - assert!(!ui.update_texture_t8(plane, &pal, &px[..8]), "wrong pixel count"); + assert!( + !ui.update_texture_t8(plane, &pal[..100], &px), + "short palette" + ); + assert!( + !ui.update_texture_t8(plane, &pal, &px[..8]), + "wrong pixel count" + ); assert_eq!(ui.texture_revision(plane), Some(1)); let rgba = ui.upload_texture(&[0u8; 4 * 4 * 4], 4, 4, spec::psm::PSM_8888); assert!(!ui.update_texture_t8(rgba, &pal, &px), "non-T8 texture"); @@ -2869,9 +3653,19 @@ fn stream_golden_fixture_parses() { assert!(!h.ended); assert_eq!((h.video.w, h.video.h), (16, 16)); assert_eq!((h.video.fps_num, h.video.fps_den), (15, 1)); - assert_eq!((h.video.slot_count, h.video.latest_seq, h.video.total_frames), (4, 2, 30)); + assert_eq!( + (h.video.slot_count, h.video.latest_seq, h.video.total_frames), + (4, 2, 30) + ); assert_eq!((h.audio.sample_rate, h.audio.channels), (22050, 2)); - assert_eq!((h.audio.chunk_frames, h.audio.chunk_count, h.audio.latest_seq), (64, 4, 1)); + assert_eq!( + ( + h.audio.chunk_frames, + h.audio.chunk_count, + h.audio.latest_seq + ), + (64, 4, 1) + ); // Frame 2 (seq 2): palette bytes are (i + 2) & 255, indices (i * 3) & 255. let off = crate::stream::slot_offset(&h, 2).unwrap() as usize; @@ -2879,9 +3673,16 @@ fn stream_golden_fixture_parses() { let sh = crate::stream::parse_slot_header(slot, &h.video).expect("slot 2 parses"); assert_eq!((sh.seq, sh.frame_index), (2, 1)); let pal = &slot[spec::stream::SLOT_HEADER_SIZE..spec::stream::SLOT_HEADER_SIZE + 1024]; - assert!(pal.iter().enumerate().all(|(i, &b)| b == ((i + 2) & 255) as u8)); - let px = &slot[spec::stream::SLOT_HEADER_SIZE + 1024..spec::stream::SLOT_HEADER_SIZE + 1024 + 256]; - assert!(px.iter().enumerate().all(|(i, &b)| b == ((i * 3) & 255) as u8)); + assert!(pal + .iter() + .enumerate() + .all(|(i, &b)| b == ((i + 2) & 255) as u8)); + let px = + &slot[spec::stream::SLOT_HEADER_SIZE + 1024..spec::stream::SLOT_HEADER_SIZE + 1024 + 256]; + assert!(px + .iter() + .enumerate() + .all(|(i, &b)| b == ((i * 3) & 255) as u8)); // Audio chunk 1: s16 LE samples i * 3 - 64. let coff = crate::stream::chunk_offset(&h, 1).unwrap() as usize; @@ -2908,16 +3709,27 @@ fn stream_golden_fixture_parses() { fn wire_frame_header_round_trips_and_rejects_oversize() { use crate::wire::{encode_frame_header, parse_frame_header}; let mut out = [0u8; 8]; - assert!(encode_frame_header(spec::wire::MSG_VIDEO_SLOT, 1, 1040, &mut out)); + assert!(encode_frame_header( + spec::wire::MSG_VIDEO_SLOT, + 1, + 1040, + &mut out + )); let h = parse_frame_header(&out).expect("round trip"); - assert_eq!((h.kind, h.flags, h.len), (spec::wire::MSG_VIDEO_SLOT, 1, 1040)); + assert_eq!( + (h.kind, h.flags, h.len), + (spec::wire::MSG_VIDEO_SLOT, 1, 1040) + ); assert!(parse_frame_header(&out[..7]).is_none(), "short header"); assert!( !encode_frame_header(0x10, 0, spec::wire::MAX_PAYLOAD as u32 + 1, &mut out), "oversize refused at encode" ); out[4..8].copy_from_slice(&(spec::wire::MAX_PAYLOAD as u32 + 1).to_le_bytes()); - assert!(parse_frame_header(&out).is_none(), "oversize refused at parse"); + assert!( + parse_frame_header(&out).is_none(), + "oversize refused at parse" + ); } #[test] @@ -2982,7 +3794,10 @@ fn wire_payload_parsers_validate_and_survive_truncation() { let (path, got) = parse_stream_open(&open).expect("streamOpen parses"); assert_eq!(path, "media/v"); assert_eq!(got, &block[..]); - assert!(parse_stream_open(&open[..open.len() - 1]).is_none(), "short block refused"); + assert!( + parse_stream_open(&open[..open.len() - 1]).is_none(), + "short block refused" + ); // videoSlot: header · palette · indices. let mut slot = Vec::new(); @@ -2995,10 +3810,16 @@ fn wire_payload_parsers_validate_and_survive_truncation() { slot.extend_from_slice(&[9u8; 1024]); slot.extend_from_slice(&[5u8; 32 * 16]); let msg = parse_video_slot(&slot).expect("slot parses"); - assert_eq!((msg.seq, msg.frame_index, msg.w, msg.h, msg.rle), (2, 7, 32, 16, false)); + assert_eq!( + (msg.seq, msg.frame_index, msg.w, msg.h, msg.rle), + (2, 7, 32, 16, false) + ); assert_eq!(msg.palette.len(), 1024); assert_eq!(msg.indices.len(), 32 * 16); - assert!(parse_video_slot(&slot[..1039]).is_none(), "short slot refused"); + assert!( + parse_video_slot(&slot[..1039]).is_none(), + "short slot refused" + ); let mut zero_seq = slot.clone(); zero_seq[0..4].copy_from_slice(&0u32.to_le_bytes()); assert!(parse_video_slot(&zero_seq).is_none(), "seq 0 refused"); @@ -3009,7 +3830,10 @@ fn wire_payload_parsers_validate_and_survive_truncation() { chunk.extend_from_slice(&2048u32.to_le_bytes()); chunk.extend_from_slice(&[0u8; 64 * 2 * 2]); let msg = parse_audio_chunk(&chunk).expect("chunk parses"); - assert_eq!((msg.seq, msg.start_frame, msg.pcm.len()), (1, 2048, 64 * 2 * 2)); + assert_eq!( + (msg.seq, msg.start_frame, msg.pcm.len()), + (1, 2048, 64 * 2 * 2) + ); assert!(parse_audio_chunk(&chunk[..7]).is_none()); let mut mark = Vec::new(); @@ -3083,7 +3907,14 @@ fn ram_stream_image_equals_the_reference_file_writer() { let px = |seed: u8| -> Vec { (0..32 * 16).map(|i| (i as u8).wrapping_mul(seed)).collect() }; let pcm = |seed: u8| -> Vec { (0..64 * 2 * 2).map(|i| (i as u8) ^ seed).collect() }; for seq in 1..=3u32 { - reference_write_slot(&mut reference, &h, seq, seq * 2, &pal(seq as u8), &px(seq as u8)); + reference_write_slot( + &mut reference, + &h, + seq, + seq * 2, + &pal(seq as u8), + &px(seq as u8), + ); } for seq in 1..=2u32 { reference_write_chunk(&mut reference, &h, seq, seq * 64, &pcm(seq as u8)); @@ -3108,12 +3939,23 @@ fn ram_stream_image_equals_the_reference_file_writer() { } for seq in 1..=2u32 { let bytes = pcm(seq as u8); - assert!(ram.apply_chunk(&AudioChunkMsg { seq, start_frame: seq * 64, pcm: &bytes })); + assert!(ram.apply_chunk(&AudioChunkMsg { + seq, + start_frame: seq * 64, + pcm: &bytes + })); } - ram.apply_mark(&StreamMarkMsg { epoch: 5, ended: true }); + ram.apply_mark(&StreamMarkMsg { + epoch: 5, + ended: true, + }); assert_eq!(ram.buf().len(), reference.len()); - assert_eq!(ram.buf(), &reference[..], "RAM ring == file ring, byte for byte"); + assert_eq!( + ram.buf(), + &reference[..], + "RAM ring == file ring, byte for byte" + ); // And the shared readers see the expected world. let live = crate::stream::parse_header_block(ram.buf()).unwrap(); @@ -3131,7 +3973,9 @@ fn ram_stream_decodes_rle_slots_and_rejects_bad_geometry() { let block = stream_header_block(0, 0, 32, 16, 2, 0, 64, 2, 0); let mut ram = crate::stream_rx::RamStream::open(&block).unwrap(); - let raw: Vec = (0..32 * 16).map(|i| if i < 300 { 7 } else { (i % 5) as u8 }).collect(); + let raw: Vec = (0..32 * 16) + .map(|i| if i < 300 { 7 } else { (i % 5) as u8 }) + .collect(); let rle = packbits_encode(&raw); assert!(rle.len() < raw.len(), "fixture should actually compress"); let palette = alloc::vec![1u8; 1024]; @@ -3150,16 +3994,40 @@ fn ram_stream_decodes_rle_slots_and_rejects_bad_geometry() { // Wrong plane, wrong palette size, wrong index count, truncated RLE. assert!(!ram.apply_slot(&VideoSlotMsg { - seq: 2, frame_index: 1, w: 16, h: 16, rle: false, palette: &palette, indices: &raw, + seq: 2, + frame_index: 1, + w: 16, + h: 16, + rle: false, + palette: &palette, + indices: &raw, })); assert!(!ram.apply_slot(&VideoSlotMsg { - seq: 2, frame_index: 1, w: 32, h: 16, rle: false, palette: &palette[..512], indices: &raw, + seq: 2, + frame_index: 1, + w: 32, + h: 16, + rle: false, + palette: &palette[..512], + indices: &raw, })); assert!(!ram.apply_slot(&VideoSlotMsg { - seq: 2, frame_index: 1, w: 32, h: 16, rle: false, palette: &palette, indices: &raw[..100], + seq: 2, + frame_index: 1, + w: 32, + h: 16, + rle: false, + palette: &palette, + indices: &raw[..100], })); assert!(!ram.apply_slot(&VideoSlotMsg { - seq: 2, frame_index: 1, w: 32, h: 16, rle: true, palette: &palette, indices: &rle[..rle.len() / 2], + seq: 2, + frame_index: 1, + w: 32, + h: 16, + rle: true, + palette: &palette, + indices: &rle[..rle.len() / 2], })); // Failures must not publish a cursor. let h = crate::stream::parse_header_block(ram.buf()).unwrap(); @@ -3228,7 +4096,10 @@ fn ram_stream_reconstructs_the_committed_golden() { pcm: &golden[off + 16..off + 16 + pcm_bytes], })); } - ram.apply_mark(&StreamMarkMsg { epoch: h.epoch, ended: h.ended }); + ram.apply_mark(&StreamMarkMsg { + epoch: h.epoch, + ended: h.ended, + }); // Restore the golden's final cursors exactly (a lapped ring's latest may // exceed the highest resident seq; apply_* published the resident max). let final_v = h.video.latest_seq; @@ -3238,7 +4109,11 @@ fn ram_stream_reconstructs_the_committed_golden() { let mut image = ram.buf().to_vec(); image[v + 20..v + 24].copy_from_slice(&final_v.to_le_bytes()); image[a + 20..a + 24].copy_from_slice(&final_a.to_le_bytes()); - assert_eq!(&image[..], golden, "socket-fed RAM ring == TS-written file, byte for byte"); + assert_eq!( + &image[..], + golden, + "socket-fed RAM ring == TS-written file, byte for byte" + ); } // --------------------------------------------------------------------------- @@ -3250,7 +4125,11 @@ fn hit_test_bounds_claims_pure_layout_containers() { let mut ui = Ui::new(); // An unstyled container (a list viewport): ink-transparent, bounds-solid. let viewport = ui.create_node(0); - ui.set_prop(viewport, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + viewport, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(viewport, spec::prop::INSET_L, 10.0); ui.set_prop(viewport, spec::prop::INSET_T, 10.0); ui.set_prop(viewport, spec::prop::WIDTH, 100.0); @@ -3264,8 +4143,16 @@ fn hit_test_bounds_claims_pure_layout_containers() { // In the row gap: ink misses, bounds resolves to the container's box — // the property that lets a list own its whole viewport without painted // rows under every finger (the touchRect workaround this replaces). - assert_eq!(ui.hit_test(20.0, 80.0), 0, "ink: nothing painted in the gap"); - assert_eq!(ui.hit_test_bounds(20.0, 80.0), viewport, "bounds: the gap is the viewport's box"); + assert_eq!( + ui.hit_test(20.0, 80.0), + 0, + "ink: nothing painted in the gap" + ); + assert_eq!( + ui.hit_test_bounds(20.0, 80.0), + viewport, + "bounds: the gap is the viewport's box" + ); } #[test] @@ -3294,7 +4181,10 @@ fn touch_hit_facts_carry_from_the_down_frame() { #[test] fn touch_decode_reads_both_packings() { - assert_eq!(crate::touch::decode((7 << 18) | (200 << 9) | 300), (7, 300.0, 200.0)); + assert_eq!( + crate::touch::decode((7 << 18) | (200 << 9) | 300), + (7, 300.0, 200.0) + ); assert_eq!( crate::touch::decode(0x8000_0000 | (9 << 20) | (600 << 10) | 700), (9, 700.0, 600.0) @@ -3309,7 +4199,11 @@ fn hit_pass_layers_never_swallow_bounds_hits() { // root): hitPass makes its own box hit-transparent in BOTH walks, while // its children still claim. let overlay = ui.create_node(0); - ui.set_prop(overlay, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop( + overlay, + spec::prop::POS_TYPE, + spec::PosType::Absolute as u32 as f64, + ); ui.set_prop(overlay, spec::prop::INSET_L, 0.0); ui.set_prop(overlay, spec::prop::INSET_T, 0.0); ui.set_prop(overlay, spec::prop::WIDTH, 480.0); @@ -3325,5 +4219,9 @@ fn hit_pass_layers_never_swallow_bounds_hits() { // A toast INSIDE the overlay claims over the content beneath it. let toast = abs_box(&mut ui, overlay, 15.0, 15.0, 30.0, 20.0); assert_eq!(ui.hit_test_bounds(20.0, 20.0), toast); - assert_eq!(ui.hit_test(20.0, 20.0), toast, "ink walk honors overlay content too"); + assert_eq!( + ui.hit_test(20.0, 20.0), + toast, + "ink walk honors overlay content too" + ); } diff --git a/engine/crates/pocket-mod/src/lib.rs b/engine/crates/pocket-mod/src/lib.rs index d166cd74..889c6ea8 100644 --- a/engine/crates/pocket-mod/src/lib.rs +++ b/engine/crates/pocket-mod/src/lib.rs @@ -20,7 +20,7 @@ //! process access. A guest can affect exactly what its mounted surfaces //! express. -use anyhow::{Result, anyhow}; +use anyhow::{Result, anyhow, ensure}; use rquickjs::{CatchResultExt, Context, Ctx, Function, Object, Runtime}; // Surface crates implement ops against the same rquickjs the guest uses. @@ -32,6 +32,17 @@ pub struct Guest { ctx: Context, } +/// Milestones inside one guest frame. Hosts can observe these without putting +/// a clock or platform dependency into `pocket-mod` itself. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GuestFrameEvent { + PrepareBegin, + CallBegin, + CallEnd, + JobsBegin, + JobsEnd { jobs_run: u32 }, +} + impl Guest { /// Create an empty realm with `console.*` installed. Mount surfaces and /// eval the product bundle next; drop and rebuild for a hot reload. @@ -114,8 +125,70 @@ impl Guest { /// A contact present in the array is down/move this frame; absent = released. /// Hosts without touch call [`Guest::frame`] / [`Guest::frame_with_analog`] /// instead; this is the 3-arg `globalThis.frame(buttons, analog, touches)` - /// path for touch targets (Vita, PocketBook). + /// compatibility path for hosts without a touch-hit fact channel. pub fn frame_with_touches(&self, buttons: u32, analog: u32, touches: &[u32]) -> Result<()> { + self.frame_with_touch_data(buttons, analog, touches, None) + } + + /// One guest turn with touch contacts and host-resolved hit facts. `hits` + /// is parallel to `touches` and becomes the fourth argument to + /// `globalThis.frame(buttons, analog, touches, hits)`. + pub fn frame_with_touch_hits( + &self, + buttons: u32, + analog: u32, + touches: &[u32], + hits: &[i32], + ) -> Result<()> { + self.frame_with_touch_hits_observed(buttons, analog, touches, hits, |_| {}) + } + + /// [`Guest::frame_with_touch_hits`] with additive phase observation for + /// host profiling. The observer cannot affect guest semantics; events are + /// emitted around argument preparation, the synchronous `frame()` call, + /// and the subsequent pending-job drain. + pub fn frame_with_touch_hits_observed( + &self, + buttons: u32, + analog: u32, + touches: &[u32], + hits: &[i32], + observe: F, + ) -> Result<()> + where + F: FnMut(GuestFrameEvent), + { + ensure!( + touches.len() == hits.len(), + "pocket-mod: touch/hit arrays must be parallel ({} touches, {} hits)", + touches.len(), + hits.len() + ); + self.frame_with_touch_data_observed(buttons, analog, touches, Some(hits), observe) + } + + fn frame_with_touch_data( + &self, + buttons: u32, + analog: u32, + touches: &[u32], + hits: Option<&[i32]>, + ) -> Result<()> { + self.frame_with_touch_data_observed(buttons, analog, touches, hits, |_| {}) + } + + fn frame_with_touch_data_observed( + &self, + buttons: u32, + analog: u32, + touches: &[u32], + hits: Option<&[i32]>, + mut observe: F, + ) -> Result<()> + where + F: FnMut(GuestFrameEvent), + { + observe(GuestFrameEvent::PrepareBegin); self.ctx.with(|ctx| -> Result<()> { let frame: Option = ctx.globals().get("frame").ok(); if let Some(frame) = frame { @@ -125,29 +198,57 @@ impl Guest { arr.set(i, *t) .map_err(|e| anyhow!("pocket-mod: setting touch {i}: {e}"))?; } - frame - .call::<_, ()>((buttons, analog, arr)) - .catch(&ctx) - .map_err(|e| anyhow!("pocket-mod: frame() threw: {e}"))?; + if let Some(hits) = hits { + let hit_arr = rquickjs::Array::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: allocating touch hit array: {e}"))?; + for (i, hit) in hits.iter().enumerate() { + hit_arr + .set(i, *hit) + .map_err(|e| anyhow!("pocket-mod: setting touch hit {i}: {e}"))?; + } + observe(GuestFrameEvent::CallBegin); + frame + .call::<_, ()>((buttons, analog, arr, hit_arr)) + .catch(&ctx) + .map_err(|e| anyhow!("pocket-mod: frame() threw: {e}"))?; + } else { + observe(GuestFrameEvent::CallBegin); + frame + .call::<_, ()>((buttons, analog, arr)) + .catch(&ctx) + .map_err(|e| anyhow!("pocket-mod: frame() threw: {e}"))?; + } + } else { + observe(GuestFrameEvent::CallBegin); } + observe(GuestFrameEvent::CallEnd); Ok(()) })?; - self.drain_jobs(); + observe(GuestFrameEvent::JobsBegin); + let jobs_run = self.drain_jobs_counted(); + observe(GuestFrameEvent::JobsEnd { jobs_run }); Ok(()) } /// Drain the microtask/job queue (promise reactions). Job exceptions are /// logged, not fatal — matching how hosts treat stray rejections. pub fn drain_jobs(&self) { + self.drain_jobs_counted(); + } + + fn drain_jobs_counted(&self) -> u32 { + let mut jobs_run = 0u32; loop { match self.rt.execute_pending_job() { - Ok(true) => continue, + Ok(true) => jobs_run = jobs_run.saturating_add(1), Ok(false) => break, Err(e) => { + jobs_run = jobs_run.saturating_add(1); log::error!(target: "guest", "pocket-mod: pending job threw: {e:?}"); } } } + jobs_run } /// Whether the evaluated bundle installed `globalThis.frame`. @@ -321,6 +422,58 @@ mod tests { assert_eq!(res, "0:0:-1"); } + #[test] + fn frame_carries_touch_hits_as_fourth_argument() { + let g = Guest::new().unwrap(); + g.eval( + "boot", + "globalThis.res = ''; \ + globalThis.frame = (b, a, t, h) => { \ + globalThis.res = t.length + ':' + h.length + ':' + h[0]; \ + };", + ) + .unwrap(); + let packed = (20u32 << 9) | 10; + g.frame_with_touch_hits(0, pocketjs_core::spec::ANALOG_CENTER, &[packed], &[42]) + .unwrap(); + let res: String = g.with(|ctx| ctx.globals().get("res").unwrap()); + assert_eq!(res, "1:1:42"); + + let err = g + .frame_with_touch_hits(0, pocketjs_core::spec::ANALOG_CENTER, &[packed], &[]) + .unwrap_err(); + assert!(err.to_string().contains("must be parallel")); + } + + #[test] + fn observed_frame_reports_ordered_phases_and_drained_jobs() { + let g = Guest::new().unwrap(); + g.eval( + "boot", + "globalThis.frame = () => { Promise.resolve().then(() => {}); };", + ) + .unwrap(); + let mut events = Vec::new(); + g.frame_with_touch_hits_observed( + 0, + pocketjs_core::spec::ANALOG_CENTER, + &[], + &[], + |event| events.push(event), + ) + .unwrap(); + assert_eq!( + events, + [ + GuestFrameEvent::PrepareBegin, + GuestFrameEvent::CallBegin, + GuestFrameEvent::CallEnd, + GuestFrameEvent::JobsBegin, + GuestFrameEvent::JobsEnd { jobs_run: 1 }, + ] + ); + } + #[test] fn exceptions_carry_js_stack() { let g = Guest::new().unwrap(); diff --git a/engine/crates/pocket-ui-surface/src/lib.rs b/engine/crates/pocket-ui-surface/src/lib.rs index 7f61553b..d35bc5da 100644 --- a/engine/crates/pocket-ui-surface/src/lib.rs +++ b/engine/crates/pocket-ui-surface/src/lib.rs @@ -13,4 +13,4 @@ mod pak; mod surface; pub use pak::{PakEntry, find_pak, walk_pak}; -pub use surface::UiSurface; +pub use surface::{HostOpsProfileSnapshot, UiSurface}; diff --git a/engine/crates/pocket-ui-surface/src/surface.rs b/engine/crates/pocket-ui-surface/src/surface.rs index ce5c21ae..86f33bd7 100644 --- a/engine/crates/pocket-ui-surface/src/surface.rs +++ b/engine/crates/pocket-ui-surface/src/surface.rs @@ -10,7 +10,7 @@ //! `ui.__viewport = {w, h}` tells the framework the logical UI size (the PSP //! host omits it and the framework defaults to 480x272). -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::VecDeque; use std::rc::Rc; @@ -55,10 +55,119 @@ struct Inner { host_abi: Option, } +/// Per-category measurements for calls that crossed the JavaScript HostOps +/// boundary since the previous snapshot. +/// +/// `*_us` measures only time spent inside the Rust HostOps closure, including +/// its borrow and core call. JavaScript execution and argument conversion done +/// by QuickJS before entering the closure are intentionally excluded. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct HostOpsProfileSnapshot { + pub create_calls: u32, + pub create_us: u64, + pub insert_calls: u32, + pub insert_us: u64, + pub style_calls: u32, + pub style_us: u64, + pub prop_calls: u32, + pub prop_us: u64, + pub text_calls: u32, + pub text_us: u64, + pub animate_calls: u32, + pub animate_us: u64, + pub other_calls: u32, + pub other_us: u64, +} + +#[derive(Clone, Copy)] +enum HostOpCategory { + Create, + Insert, + Style, + Prop, + Text, + Animate, + Other, +} + +struct HostOpsProfiler { + clock_us: Cell u64>>, + snapshot: Cell, +} + +impl HostOpsProfiler { + fn new() -> Self { + Self { + clock_us: Cell::new(None), + snapshot: Cell::new(HostOpsProfileSnapshot::default()), + } + } + + #[inline] + fn measure(&self, category: HostOpCategory, op: impl FnOnce() -> R) -> R { + let Some(clock_us) = self.clock_us.get() else { + return op(); + }; + let started_us = clock_us(); + let result = op(); + let elapsed_us = clock_us().saturating_sub(started_us); + let mut snapshot = self.snapshot.get(); + match category { + HostOpCategory::Create => { + snapshot.create_calls = snapshot.create_calls.saturating_add(1); + snapshot.create_us = snapshot.create_us.saturating_add(elapsed_us); + } + HostOpCategory::Insert => { + snapshot.insert_calls = snapshot.insert_calls.saturating_add(1); + snapshot.insert_us = snapshot.insert_us.saturating_add(elapsed_us); + } + HostOpCategory::Style => { + snapshot.style_calls = snapshot.style_calls.saturating_add(1); + snapshot.style_us = snapshot.style_us.saturating_add(elapsed_us); + } + HostOpCategory::Prop => { + snapshot.prop_calls = snapshot.prop_calls.saturating_add(1); + snapshot.prop_us = snapshot.prop_us.saturating_add(elapsed_us); + } + HostOpCategory::Text => { + snapshot.text_calls = snapshot.text_calls.saturating_add(1); + snapshot.text_us = snapshot.text_us.saturating_add(elapsed_us); + } + HostOpCategory::Animate => { + snapshot.animate_calls = snapshot.animate_calls.saturating_add(1); + snapshot.animate_us = snapshot.animate_us.saturating_add(elapsed_us); + } + HostOpCategory::Other => { + snapshot.other_calls = snapshot.other_calls.saturating_add(1); + snapshot.other_us = snapshot.other_us.saturating_add(elapsed_us); + } + } + self.snapshot.set(snapshot); + result + } +} + +#[derive(Clone)] +struct HostOpsHandle { + inner: Rc>, + profiler: Rc, +} + +impl HostOpsHandle { + #[inline] + fn call(&self, category: HostOpCategory, op: impl FnOnce(&mut Inner) -> R) -> R { + self.profiler.measure(category, || { + let mut inner = self.inner.borrow_mut(); + op(&mut inner) + }) + } +} + /// The `ui` surface. Clone-cheap handle; single-threaded like the guest. #[derive(Clone)] pub struct UiSurface { inner: Rc>, + host_ops_profiler: Rc, } impl UiSurface { @@ -88,6 +197,32 @@ impl UiSurface { host_id: "desktop".into(), host_abi: None, })), + host_ops_profiler: Rc::new(HostOpsProfiler::new()), + } + } + + /// Install or remove the monotonic microsecond clock used for HostOps + /// profiling. Changing clocks also clears the pending snapshot so samples + /// from different time domains can never mix. + pub fn set_host_ops_profile_clock(&self, clock_us: Option u64>) { + self.host_ops_profiler.clock_us.set(clock_us); + self.host_ops_profiler + .snapshot + .set(HostOpsProfileSnapshot::default()); + } + + /// Return all HostOps measurements accumulated so far and atomically clear + /// them. The surface is single-threaded, matching the QuickJS guest. + pub fn take_host_ops_profile(&self) -> HostOpsProfileSnapshot { + self.host_ops_profiler + .snapshot + .replace(HostOpsProfileSnapshot::default()) + } + + fn host_ops_handle(&self) -> HostOpsHandle { + HostOpsHandle { + inner: self.inner.clone(), + profiler: self.host_ops_profiler.clone(), } } @@ -230,256 +365,319 @@ impl UiSurface { }; } - let ui = self.inner.clone(); - op!("createNode", move |t: i32| ui - .borrow_mut() - .ui - .create_node(t as u8)); + let ui = self.host_ops_handle(); + op!("createNode", move |t: i32| ui.call( + HostOpCategory::Create, + |inner| inner.ui.create_node(t as u8) + )); - let ui = self.inner.clone(); - op!("destroyNode", move |id: i32| ui - .borrow_mut() - .ui - .destroy_node(id)); + let ui = self.host_ops_handle(); + op!("destroyNode", move |id: i32| ui.call( + HostOpCategory::Other, + |inner| inner.ui.destroy_node(id) + )); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("insertBefore", move |p: i32, c: i32, a: i32| { - ui.borrow_mut().ui.insert_before(p, c, a) + ui.call(HostOpCategory::Insert, |inner| { + inner.ui.insert_before(p, c, a) + }) }); - let ui = self.inner.clone(); - op!("removeChild", move |p: i32, c: i32| ui - .borrow_mut() - .ui - .remove_child(p, c)); + let ui = self.host_ops_handle(); + op!("removeChild", move |p: i32, c: i32| ui.call( + HostOpCategory::Other, + |inner| inner.ui.remove_child(p, c) + )); - let ui = self.inner.clone(); - op!("setStyle", move |id: i32, style: i32| ui - .borrow_mut() - .ui - .set_style(id, style)); + let ui = self.host_ops_handle(); + op!("setStyle", move |id: i32, style: i32| ui.call( + HostOpCategory::Style, + |inner| inner.ui.set_style(id, style) + )); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("setProp", move |id: i32, prop: i32, v: f64| { - ui.borrow_mut().ui.set_prop(id, prop as u8, v) + ui.call(HostOpCategory::Prop, |inner| { + inner.ui.set_prop(id, prop as u8, v) + }) }); // Text ops coerce like the PSP FFI does (JS_ToCString semantics — // Solid legitimately passes numbers through replaceText). - let ui = self.inner.clone(); - op!("setText", move |id: i32, s: Coerced| ui - .borrow_mut() - .ui - .set_text(id, &s.0)); + let ui = self.host_ops_handle(); + op!("setText", move |id: i32, s: Coerced| ui.call( + HostOpCategory::Text, + |inner| inner.ui.set_text(id, &s.0) + )); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("replaceText", move |id: i32, s: Coerced| { - ui.borrow_mut().ui.replace_text(id, &s.0) + ui.call(HostOpCategory::Text, |inner| { + inner.ui.replace_text(id, &s.0) + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!( "uploadTexture", move |buf: TypedArray, w: i32, h: i32, psm: i32| { - let Some(bytes) = buf.as_bytes() else { - return -1; - }; - ui.borrow_mut() - .ui - .upload_texture(bytes, w as u32, h as u32, psm as u32) + ui.call(HostOpCategory::Other, |inner| { + let Some(bytes) = buf.as_bytes() else { + return -1; + }; + inner + .ui + .upload_texture(bytes, w as u32, h as u32, psm as u32) + }) } ); - let ui = self.inner.clone(); - op!("setImage", move |id: i32, tex: i32| ui - .borrow_mut() - .ui - .set_image(id, tex)); + let ui = self.host_ops_handle(); + op!("setImage", move |id: i32, tex: i32| ui.call( + HostOpCategory::Other, + |inner| inner.ui.set_image(id, tex) + )); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("setSprite", move |id: i32, atlas: i32, frames: i32, cols: i32, step: i32| { - ui.borrow_mut().ui.set_sprite( - id, - atlas, - frames.max(0) as u32, - cols.max(0) as u32, - step.max(0) as u32, - ) + ui.call(HostOpCategory::Other, |inner| { + inner.ui.set_sprite( + id, + atlas, + frames.max(0) as u32, + cols.max(0) as u32, + step.max(0) as u32, + ) + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("animate", move |id: i32, prop: i32, to: f64, dur_ms: f64, easing: i32, delay_ms: f64| { - ui.borrow_mut().ui.animate( - id, - prop as u8, - to, - dur_ms.max(0.0) as u32, - easing as u8, - delay_ms.max(0.0) as u32, - ) + ui.call(HostOpCategory::Animate, |inner| { + inner.ui.animate( + id, + prop as u8, + to, + dur_ms.max(0.0) as u32, + easing as u8, + delay_ms.max(0.0) as u32, + ) + }) }); - let ui = self.inner.clone(); - op!("cancelAnim", move |id: i32| ui - .borrow_mut() - .ui - .cancel_anim(id)); + let ui = self.host_ops_handle(); + op!("cancelAnim", move |id: i32| ui.call( + HostOpCategory::Other, + |inner| inner.ui.cancel_anim(id) + )); - let ui = self.inner.clone(); - op!("setFocus", move |id: i32| ui.borrow_mut().ui.set_focus(id)); + let ui = self.host_ops_handle(); + op!("setFocus", move |id: i32| ui.call( + HostOpCategory::Other, + |inner| inner.ui.set_focus(id) + )); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("setActive", move |id: i32, active: i32| { - ui.borrow_mut().ui.set_active(id, active != 0) + ui.call(HostOpCategory::Other, |inner| { + inner.ui.set_active(id, active != 0) + }) }); // Virtual cursor ops (spec ops 27..29, input.cursor). - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("hitTest", move |x: f64, y: f64| { - ui.borrow_mut().ui.hit_test(x as f32, y as f32) + ui.call(HostOpCategory::Other, |inner| { + inner.ui.hit_test(x as f32, y as f32) + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); + op!("hitTestBounds", move |x: f64, y: f64| { + ui.call(HostOpCategory::Other, |inner| { + inner.ui.hit_test_bounds(x as f32, y as f32) + }) + }); + + let ui = self.host_ops_handle(); op!("setCursor", move |tex: i32, hot_x: f64, hot_y: f64, w: f64, h: f64| { - ui.borrow_mut().ui.set_cursor(tex, hot_x as f32, hot_y as f32, w as f32, h as f32) + ui.call(HostOpCategory::Other, |inner| { + inner + .ui + .set_cursor(tex, hot_x as f32, hot_y as f32, w as f32, h as f32) + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("setCursorPos", move |x: f64, y: f64| { - ui.borrow_mut().ui.set_cursor_pos(x as f32, y as f32) + ui.call(HostOpCategory::Other, |inner| { + inner.ui.set_cursor_pos(x as f32, y as f32) + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("loadStyles", move |buf: TypedArray| { - let Some(bytes) = buf.as_bytes() else { - return false; - }; - ui.borrow_mut().ui.load_styles(bytes) + ui.call(HostOpCategory::Other, |inner| { + let Some(bytes) = buf.as_bytes() else { + return false; + }; + inner.ui.load_styles(bytes) + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("loadFontAtlas", move |buf: TypedArray| { - let Some(bytes) = buf.as_bytes() else { - return false; - }; - ui.borrow_mut().ui.load_font_atlas(bytes) + ui.call(HostOpCategory::Other, |inner| { + let Some(bytes) = buf.as_bytes() else { + return false; + }; + inner.ui.load_font_atlas(bytes) + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("measureText", move |s: Coerced, slot: i32| { - ui.borrow_mut().ui.measure_text(&s.0, slot as u8) as f64 + ui.call(HostOpCategory::Other, |inner| { + inner.ui.measure_text(&s.0, slot as u8) as f64 + }) }); // ---- streamed textures (spec ops 23..25) --------------------- - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("loadTileTexture", move |key: Coerced, index: i32| { - if index < 0 { - return -1; - } - let mut inner = ui.borrow_mut(); - let inner = &mut *inner; // split borrow: pak read, core write - match crate::pak::find_pak(&inner.pak, &key.0) { - Some(blob) => inner.ui.upload_tileset_tile(blob, index as u32), - None => -1, - } + ui.call(HostOpCategory::Other, |inner| { + if index < 0 { + return -1; + } + // Split borrow: pak read, core write. + match crate::pak::find_pak(&inner.pak, &key.0) { + Some(blob) => inner.ui.upload_tileset_tile(blob, index as u32), + None => -1, + } + }) }); - let ui = self.inner.clone(); - op!("freeTexture", move |handle: i32| ui - .borrow_mut() - .ui - .free_texture(handle)); + let ui = self.host_ops_handle(); + op!("freeTexture", move |handle: i32| ui.call( + HostOpCategory::Other, + |inner| inner.ui.free_texture(handle) + )); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("uploadImgEntry", move |buf: TypedArray| { - let Some(bytes) = buf.as_bytes() else { - return -1; - }; - ui.borrow_mut().ui.upload_img_entry(bytes) + ui.call(HostOpCategory::Other, |inner| { + let Some(bytes) = buf.as_bytes() else { + return -1; + }; + inner.ui.upload_img_entry(bytes) + }) }); // ---- DevTools ops (spec ops 18..22) + mailbox transport ------ // Same names and semantics as the PSP FFI (hosts/psp/src/ffi.rs + // hosts/psp/src/dbg.rs): the shim's transport resolution and the // devtools bridge work against this host unchanged. - let ui = self.inner.clone(); - op!("debugInspect", move |id: i32| ui - .borrow_mut() - .ui - .debug_inspect(id)); - - let ui = self.inner.clone(); - op!("debugRectXY", move || ui.borrow().ui.debug_rect_xy()); - - let ui = self.inner.clone(); - op!("debugRectWH", move || ui.borrow().ui.debug_rect_wh()); - - let ui = self.inner.clone(); - op!("debugPause", move |on: bool| ui - .borrow_mut() - .ui - .debug_pause(on)); - - let ui = self.inner.clone(); - op!("debugStep", move || ui.borrow_mut().ui.debug_step()); + let ui = self.host_ops_handle(); + op!("debugInspect", move |id: i32| ui.call( + HostOpCategory::Other, + |inner| inner.ui.debug_inspect(id) + )); + + let ui = self.host_ops_handle(); + op!("debugRectXY", move || ui.call( + HostOpCategory::Other, + |inner| inner.ui.debug_rect_xy() + )); + + let ui = self.host_ops_handle(); + op!("debugRectWH", move || ui.call( + HostOpCategory::Other, + |inner| inner.ui.debug_rect_wh() + )); + + let ui = self.host_ops_handle(); + op!("debugPause", move |on: bool| ui.call( + HostOpCategory::Other, + |inner| inner.ui.debug_pause(on) + )); + + let ui = self.host_ops_handle(); + op!("debugStep", move || ui.call( + HostOpCategory::Other, + |inner| inner.ui.debug_step() + )); let mbox = Rc::new(RefCell::new(DbgMailbox::probe())); let m = mbox.clone(); - op!("__dbgActive", move || m.borrow().is_some()); + let ui = self.host_ops_handle(); + op!("__dbgActive", move || ui.call(HostOpCategory::Other, |_| { + m.borrow().is_some() + })); let m = mbox.clone(); + let ui = self.host_ops_handle(); op!("__dbgPoll", move || -> Option { - m.borrow_mut().as_mut().and_then(|b| b.poll()) + ui.call(HostOpCategory::Other, |_| { + m.borrow_mut().as_mut().and_then(|b| b.poll()) + }) }); let m = mbox; + let ui = self.host_ops_handle(); op!("__dbgSend", move |line: Coerced| { - if let Some(b) = m.borrow().as_ref() { - b.send(&line.0); - } + ui.call(HostOpCategory::Other, |_| { + if let Some(b) = m.borrow().as_ref() { + b.send(&line.0); + } + }) }); // ---- host service channel (spec ops 30..32) ------------------ // A stage advertises a companion service only when its package // provides one. Lines cross an in-process queue instead of a // tethered share; apps feature-detect exactly like on PSP. - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("svcOpen", move |app: Coerced| { - let inner = ui.borrow(); - match &inner.svc_allowlist { - None => true, - Some(names) => names.iter().any(|name| name == &app.0), - } + ui.call(HostOpCategory::Other, |inner| { + match &inner.svc_allowlist { + None => true, + Some(names) => names.iter().any(|name| name == &app.0), + } + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("svcPoll", move || -> Option { - let mut inner = ui.borrow_mut(); - if inner.svc_in.is_empty() { - return None; - } - // Batch per the HostOps contract: complete JSON lines, - // newline-terminated, possibly several per poll. - let mut batch = String::new(); - for line in inner.svc_in.drain(..) { - batch.push_str(&line); - batch.push('\n'); - } - Some(batch) + ui.call(HostOpCategory::Other, |inner| { + if inner.svc_in.is_empty() { + return None; + } + // Batch per the HostOps contract: complete JSON lines, + // newline-terminated, possibly several per poll. + let mut batch = String::new(); + for line in inner.svc_in.drain(..) { + batch.push_str(&line); + batch.push('\n'); + } + Some(batch) + }) }); - let ui = self.inner.clone(); + let ui = self.host_ops_handle(); op!("svcSend", move |line: Coerced| { - ui.borrow_mut().svc_out.push_back(line.0); + ui.call(HostOpCategory::Other, |inner| { + inner.svc_out.push_back(line.0); + }) }); // ---- boot tables (PSP contract) + desktop viewport ---------- @@ -538,6 +736,85 @@ fn decode_pix_header(blob: &[u8], pixels_off: usize) -> Option<(u32, u32, u32, & #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static PROFILE_CLOCK_US: AtomicU64 = AtomicU64::new(0); + + fn profile_clock_us() -> u64 { + PROFILE_CLOCK_US.fetch_add(10, Ordering::Relaxed) + } + + #[test] + fn profiles_host_ops_by_category_and_take_clears_the_snapshot() { + PROFILE_CLOCK_US.store(0, Ordering::Relaxed); + let guest = Guest::new().unwrap(); + let surface = UiSurface::new((16.0, 16.0)); + surface.set_host_ops_profile_clock(Some(profile_clock_us)); + surface.mount(&guest).unwrap(); + guest + .eval( + "profile", + "const node = ui.createNode(0);\ + ui.insertBefore(0, node, 0);\ + ui.setStyle(node, 0);\ + ui.setProp(node, 3, 4);\ + ui.setText(node, 'first');\ + ui.replaceText(node, 'second');\ + ui.animate(node, 3, 5, 10, 0, 0);\ + ui.setFocus(node);", + ) + .unwrap(); + + let profile = surface.take_host_ops_profile(); + assert_eq!((profile.create_calls, profile.create_us), (1, 10)); + assert_eq!((profile.insert_calls, profile.insert_us), (1, 10)); + assert_eq!((profile.style_calls, profile.style_us), (1, 10)); + assert_eq!((profile.prop_calls, profile.prop_us), (1, 10)); + assert_eq!((profile.text_calls, profile.text_us), (2, 20)); + assert_eq!((profile.animate_calls, profile.animate_us), (1, 10)); + assert_eq!((profile.other_calls, profile.other_us), (1, 10)); + assert_eq!( + surface.take_host_ops_profile(), + HostOpsProfileSnapshot::default() + ); + } + + #[test] + fn mounts_bounds_hit_fallback_for_touch_guests() { + let guest = Guest::new().unwrap(); + let surface = UiSurface::new((16.0, 16.0)); + let container = surface.with_ui(|ui| { + let id = ui.create_node(0); + ui.set_prop( + id, + pocketjs_core::spec::prop::POS_TYPE, + pocketjs_core::spec::PosType::Absolute as u32 as f64, + ); + ui.set_prop(id, pocketjs_core::spec::prop::INSET_L, 2.0); + ui.set_prop(id, pocketjs_core::spec::prop::INSET_T, 2.0); + ui.set_prop(id, pocketjs_core::spec::prop::WIDTH, 8.0); + ui.set_prop(id, pocketjs_core::spec::prop::HEIGHT, 8.0); + ui.insert_before(pocketjs_core::spec::ROOT_ID, id, 0); + ui.tick(); + id + }); + surface.mount(&guest).unwrap(); + guest + .eval( + "touch-hit", + "globalThis.inkHit = ui.hitTest(4, 4); \ + globalThis.boundsHit = ui.hitTestBounds(4, 4);", + ) + .unwrap(); + let (ink, bounds): (i32, i32) = guest.with(|ctx| { + ( + ctx.globals().get("inkHit").unwrap(), + ctx.globals().get("boundsHit").unwrap(), + ) + }); + assert_eq!(ink, 0, "pure layout containers do not claim ink hits"); + assert_eq!(bounds, container, "bounds fallback must claim the container"); + } #[test] fn empty_service_allowlist_disables_the_companion() { diff --git a/hosts/esp32p4/README.md b/hosts/esp32p4/README.md index ddf302f4..0dd41c82 100644 --- a/hosts/esp32p4/README.md +++ b/hosts/esp32p4/README.md @@ -15,11 +15,31 @@ it is a concrete PPA backend: No full-frame RGB888 or ARGB8888 intermediate is required. +## Complete PocketJS host + +[`waveshare-7b`](./waveshare-7b/README.md) is a product host for the +Waveshare ESP32-P4-WIFI6-Touch-LCD-7B. It runs target-bound PocketJS bundles +in QuickJS with the retained UI surface, 60 Hz lifecycle, button/touch input, +and this PPA renderer; it is separate from the Pocket Vapor firmware for the +same board. + +From the repository root, build or build-and-flash any compatible stock app: + +```sh +bun run esp32p4:device build chrome +bun run esp32p4:device flash cards --port /dev/cu.usbmodem101 +``` + +The command stages a clean generated ESP-IDF project under `dist/esp32p4`, +pins the dated Rust and ESP-IDF toolchains, validates the dependency lock and +segmented flash manifest, and refuses raw offset-zero application writes. + ## Compatibility -The adapter is supported and build-tested with the ESP-IDF `release/v6.0` and -`release/v6.1` branches. Versions older than v6.0 have not been tested. CI -builds `release/v6.0` as the minimum supported baseline. +The adapter is supported with ESP-IDF v5.5.4 through the `release/v6.1` +branch. v5.5.4 is the pinned baseline for the Waveshare 7B product host; CI +also builds `release/v6.0`. Each baseline performs a final ESP-IDF link, not +only a Rust type-check. Only the `esp32p4` target is supported. The adapter does not select a silicon revision, CPU frequency, PSRAM mode, display controller, or panel timing; diff --git a/hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt b/hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt index 1e679f67..e689f58e 100644 --- a/hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt +++ b/hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt @@ -2,7 +2,7 @@ idf_component_register( SRCS "src/pocketjs_ppa.c" INCLUDE_DIRS "include" REQUIRES esp_driver_ppa - PRIV_REQUIRES log + PRIV_REQUIRES esp_mm heap log ) # EspIdfPpaOps normally references this C ABI from a Rust static archive. @@ -13,5 +13,6 @@ target_link_options(${COMPONENT_LIB} INTERFACE "-Wl,--undefined=pocketjs_ppa_destroy" "-Wl,--undefined=pocketjs_ppa_fill_rgb565" "-Wl,--undefined=pocketjs_ppa_blend_a8_rgb565" + "-Wl,--undefined=pocketjs_ppa_blend_rgba8888_rgb565" "-Wl,--undefined=pocketjs_ppa_srm_psm5650_rgb565" ) diff --git a/hosts/esp32p4/components/pocketjs_ppa/README.md b/hosts/esp32p4/components/pocketjs_ppa/README.md index eba7b72a..94348084 100644 --- a/hosts/esp32p4/components/pocketjs_ppa/README.md +++ b/hosts/esp32p4/components/pocketjs_ppa/README.md @@ -1,7 +1,11 @@ # pocketjs_ppa -ESP-IDF component implementing blocking RGB565 FILL, A8-over-RGB565 BLEND, and -PSP PSM5650-to-RGB565 SRM operations for PocketJS on ESP32-P4. +ESP-IDF component implementing blocking RGB565 FILL, A8-over-RGB565 BLEND, +straight-alpha PocketJS RGBA8-over-RGB565 BLEND, and PSP +PSM5650-to-RGB565 SRM operations for PocketJS on ESP32-P4. RGBA8 texture bytes +remain in the core's canonical `[R,G,B,A]` order; the component selects PPA +ARGB8888 input and enables foreground RGB swap instead of allocating a +reordered copy. The public C ABI is normally consumed by the `EspIdfPpaOps` Rust type. See the [ESP32-P4 host documentation](../../README.md) for integration, compatibility, diff --git a/hosts/esp32p4/components/pocketjs_ppa/idf_component.yml b/hosts/esp32p4/components/pocketjs_ppa/idf_component.yml index 7d77939d..c95f68c5 100644 --- a/hosts/esp32p4/components/pocketjs_ppa/idf_component.yml +++ b/hosts/esp32p4/components/pocketjs_ppa/idf_component.yml @@ -6,4 +6,4 @@ documentation: https://github.com/pocket-stack/pocketjs/tree/main/hosts/esp32p4 targets: - esp32p4 dependencies: - idf: ">=6.0,<6.2" + idf: ">=5.5.4,<6.2" diff --git a/hosts/esp32p4/components/pocketjs_ppa/include/pocketjs_ppa.h b/hosts/esp32p4/components/pocketjs_ppa/include/pocketjs_ppa.h index b48fd919..4d4ec822 100644 --- a/hosts/esp32p4/components/pocketjs_ppa/include/pocketjs_ppa.h +++ b/hosts/esp32p4/components/pocketjs_ppa/include/pocketjs_ppa.h @@ -61,6 +61,32 @@ int pocketjs_ppa_blend_a8_rgb565( uint8_t global_alpha ); +/** + * Blend a PocketJS PSM8888 texture over RGB565. Source bytes are straight + * alpha RGBA (`R,G,B,A`); the implementation maps them to the PPA's + * little-endian ARGB8888 input without modifying the source buffer. + * Source and destination rectangles must have identical dimensions. + */ +int pocketjs_ppa_blend_rgba8888_rgb565( + pocketjs_ppa_handle_t handle, + uint16_t *destination, + size_t destination_pixels, + uint32_t width, + uint32_t height, + const uint8_t *source, + size_t source_len, + uint32_t source_width, + uint32_t source_height, + uint32_t source_x, + uint32_t source_y, + uint32_t source_rect_width, + uint32_t source_rect_height, + uint32_t destination_x, + uint32_t destination_y, + uint32_t destination_rect_width, + uint32_t destination_rect_height +); + int pocketjs_ppa_srm_psm5650_rgb565( pocketjs_ppa_handle_t handle, uint16_t *destination, diff --git a/hosts/esp32p4/components/pocketjs_ppa/src/pocketjs_ppa.c b/hosts/esp32p4/components/pocketjs_ppa/src/pocketjs_ppa.c index ea91e782..39936f7b 100644 --- a/hosts/esp32p4/components/pocketjs_ppa/src/pocketjs_ppa.c +++ b/hosts/esp32p4/components/pocketjs_ppa/src/pocketjs_ppa.c @@ -5,7 +5,10 @@ #include #include "driver/ppa.h" +#include "esp_cache.h" +#include "esp_heap_caps.h" #include "esp_log.h" +#include "esp_private/esp_cache_private.h" static const char *TAG = "pocketjs_ppa"; @@ -16,6 +19,7 @@ struct pocketjs_ppa_context { bool fill_error_logged; bool blend_error_logged; bool srm_error_logged; + bool srm_cache_error_logged; }; static bool surface_is_valid( @@ -143,7 +147,7 @@ esp_err_t pocketjs_ppa_create(pocketjs_ppa_handle_t *out_handle) } *out_handle = handle; - ESP_LOGI(TAG, "RGB565 backend ready: FILL + A8 BLEND + SRM"); + ESP_LOGI(TAG, "RGB565 backend ready: FILL + A8/RGBA8 BLEND + SRM"); return ESP_OK; } @@ -312,6 +316,126 @@ int pocketjs_ppa_blend_a8_rgb565( return 1; } +int pocketjs_ppa_blend_rgba8888_rgb565( + pocketjs_ppa_handle_t handle, + uint16_t *destination, + size_t destination_pixels, + uint32_t width, + uint32_t height, + const uint8_t *source, + size_t source_len, + uint32_t source_width, + uint32_t source_height, + uint32_t source_x, + uint32_t source_y, + uint32_t source_rect_width, + uint32_t source_rect_height, + uint32_t destination_x, + uint32_t destination_y, + uint32_t destination_rect_width, + uint32_t destination_rect_height +) +{ + if (handle == NULL || + handle->blend == NULL || + !surface_is_valid( + destination, + destination_pixels, + width, + height, + sizeof(*destination) + ) || + source == NULL || + source_width == 0 || + source_height == 0 || + (size_t)source_width > SIZE_MAX / (size_t)source_height || + (size_t)source_width * (size_t)source_height > SIZE_MAX / 4U) { + return 0; + } + + const size_t source_required = + (size_t)source_width * (size_t)source_height * 4U; + const size_t destination_size = + destination_pixels * sizeof(*destination); + if (source_len < source_required || + byte_ranges_overlap( + source, + source_required, + destination, + destination_size + ) || + !rect_is_valid( + source_width, + source_height, + source_x, + source_y, + source_rect_width, + source_rect_height + ) || + !rect_is_valid( + width, + height, + destination_x, + destination_y, + destination_rect_width, + destination_rect_height + ) || + source_rect_width != destination_rect_width || + source_rect_height != destination_rect_height) { + return 0; + } + + const ppa_blend_oper_config_t operation = { + .in_bg = { + .buffer = destination, + .pic_w = width, + .pic_h = height, + .block_w = destination_rect_width, + .block_h = destination_rect_height, + .block_offset_x = destination_x, + .block_offset_y = destination_y, + .blend_cm = PPA_BLEND_COLOR_MODE_RGB565, + }, + .in_fg = { + .buffer = source, + .pic_w = source_width, + .pic_h = source_height, + .block_w = source_rect_width, + .block_h = source_rect_height, + .block_offset_x = source_x, + .block_offset_y = source_y, + .blend_cm = PPA_BLEND_COLOR_MODE_ARGB8888, + }, + .out = { + .buffer = destination, + .buffer_size = destination_size, + .pic_w = width, + .pic_h = height, + .block_offset_x = destination_x, + .block_offset_y = destination_y, + .blend_cm = PPA_BLEND_COLOR_MODE_RGB565, + }, + .bg_alpha_update_mode = PPA_ALPHA_NO_CHANGE, + .fg_alpha_update_mode = PPA_ALPHA_NO_CHANGE, + // PocketJS PSM8888 is [R,G,B,A]. The PPA's little-endian ARGB8888 + // input is [B,G,R,A], so swapping foreground R/B maps the core bytes + // without a reorder buffer. Alpha remains straight/unpremultiplied. + .fg_rgb_swap = true, + .mode = PPA_TRANS_MODE_BLOCKING, + }; + + const esp_err_t result = ppa_do_blend(handle->blend, &operation); + if (result != ESP_OK) { + log_operation_failure_once( + "RGBA8 blend", + result, + &handle->blend_error_logged + ); + return 0; + } + return 1; +} + static bool exact_scale( uint32_t source_extent, uint32_t destination_extent, @@ -333,6 +457,74 @@ static bool exact_scale( return true; } +static esp_err_t writeback_srm_destination_window( + uint16_t *destination, + size_t destination_size, + uint32_t width, + uint32_t destination_y, + uint32_t destination_height +) +{ + size_t alignment = 0; + esp_err_t result = esp_cache_get_alignment( + MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA, + &alignment + ); + if (result != ESP_OK) { + return result; + } + + /* Match ESP-IDF 5.5's SRM output window exactly. The driver invalidates + * this aligned, full-stride row range before DMA, but unlike FILL it does + * not first write back dirty CPU lines. Preserve ordered software -> SRM + * rendering by committing those lines before the driver's invalidate. */ + if (alignment == 0 || + (alignment & (alignment - 1U)) != 0 || + ((uintptr_t)destination & (alignment - 1U)) != 0 || + (destination_size & (alignment - 1U)) != 0 || + width > SIZE_MAX / sizeof(*destination)) { + return ESP_ERR_INVALID_ARG; + } + + const size_t row_bytes = (size_t)width * sizeof(*destination); + if (destination_y > SIZE_MAX / row_bytes || + destination_height > SIZE_MAX / row_bytes) { + return ESP_ERR_INVALID_SIZE; + } + const size_t window_offset = (size_t)destination_y * row_bytes; + const size_t window_size = (size_t)destination_height * row_bytes; + if (window_offset > destination_size || + window_size > destination_size - window_offset) { + return ESP_ERR_INVALID_SIZE; + } + + const uintptr_t window_start = + (uintptr_t)destination + (uintptr_t)window_offset; + const uintptr_t aligned_start = window_start & ~(alignment - 1U); + const size_t leading_bytes = (size_t)(window_start - aligned_start); + if (window_size > SIZE_MAX - leading_bytes) { + return ESP_ERR_INVALID_SIZE; + } + const size_t covered_bytes = leading_bytes + window_size; + if (covered_bytes > SIZE_MAX - (alignment - 1U)) { + return ESP_ERR_INVALID_SIZE; + } + const size_t aligned_size = + (covered_bytes + alignment - 1U) & ~(alignment - 1U); + const size_t aligned_offset = + (size_t)(aligned_start - (uintptr_t)destination); + if (aligned_offset > destination_size || + aligned_size > destination_size - aligned_offset) { + return ESP_ERR_INVALID_SIZE; + } + + return esp_cache_msync( + (void *)aligned_start, + aligned_size, + ESP_CACHE_MSYNC_FLAG_DIR_C2M + ); +} + int pocketjs_ppa_srm_psm5650_rgb565( pocketjs_ppa_handle_t handle, uint16_t *destination, @@ -418,6 +610,22 @@ int pocketjs_ppa_srm_psm5650_rgb565( return 0; } + const esp_err_t cache_result = writeback_srm_destination_window( + destination, + destination_size, + width, + destination_y, + destination_rect_height + ); + if (cache_result != ESP_OK) { + log_operation_failure_once( + "SRM destination cache writeback", + cache_result, + &handle->srm_cache_error_logged + ); + return 0; + } + const ppa_srm_rotation_angle_t rotations[] = { PPA_SRM_ROTATION_ANGLE_0, PPA_SRM_ROTATION_ANGLE_90, diff --git a/hosts/esp32p4/runtime/Cargo.lock b/hosts/esp32p4/runtime/Cargo.lock new file mode 100644 index 00000000..b5ab8363 --- /dev/null +++ b/hosts/esp32p4/runtime/Cargo.lock @@ -0,0 +1,547 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "pocket-mod" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "pocketjs-core", + "rquickjs", +] + +[[package]] +name = "pocket-ui-surface" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "pocket-mod", + "pocketjs-core", +] + +[[package]] +name = "pocketjs-core" +version = "0.1.0" +dependencies = [ + "taffy", +] + +[[package]] +name = "pocketjs-esp32p4-ppa" +version = "0.1.0" +dependencies = [ + "pocketjs-core", +] + +[[package]] +name = "pocketjs-esp32p4-runtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "pocket-mod", + "pocket-ui-surface", + "pocketjs-core", + "pocketjs-esp32p4-ppa", + "rquickjs", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + +[[package]] +name = "rquickjs" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e04e4eedfb060b503b5f0a2644abb890b0b3620d3fb674f9455f230014964e4" +dependencies = [ + "rquickjs-core", + "rquickjs-macro", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e4f499ac5b943d97ee6dbc44f23c2c10426f420f7d2f1793d6318911b6608c" +dependencies = [ + "hashbrown", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-macro" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbcc8219b70ee2faa08d5339f47f15741cba2ce0cb9640e8495f2ab51293f50" +dependencies = [ + "convert_case", + "fnv", + "ident_case", + "indexmap", + "proc-macro-crate", + "proc-macro2", + "quote", + "rquickjs-core", + "syn 2.0.119", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13ac243b86a74120814ef7e9e30ad5a2c1199b7b9963b1cf7c84e4cdc1cad99" +dependencies = [ + "bindgen", + "cc", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfde4e2f8595f222ceaae1fb16b4963952e9b33e358869dc4cd6316b0e0790cd" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] diff --git a/hosts/esp32p4/runtime/Cargo.toml b/hosts/esp32p4/runtime/Cargo.toml new file mode 100644 index 00000000..177c8a0b --- /dev/null +++ b/hosts/esp32p4/runtime/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "pocketjs-esp32p4-runtime" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +crate-type = ["rlib", "staticlib"] + +[dependencies] +anyhow = "1" +log = "0.4" +# The direct dependency enables target-correct bindings for ESP-IDF's RISC-V +# ABI. pocket-mod and this crate then share the same rquickjs instance. +rquickjs = { version = "0.12", features = ["bindgen"] } +pocket-mod = { path = "../../../engine/crates/pocket-mod" } +pocket-ui-surface = { path = "../../../engine/crates/pocket-ui-surface" } +pocketjs-core = { path = "../../../engine/core", features = ["std"] } +pocketjs-esp32p4-ppa = { path = "../../../engine/backends/esp32p4-ppa", features = ["std"] } + +[features] +default = [] +esp-idf = ["pocketjs-esp32p4-ppa/esp-idf"] + +[profile.release] +panic = "abort" +# This runtime owns a 960x544 software/PPA render hot path and ships in a +# 15 MiB application partition. Optimize frame time first; size optimization +# measurably penalizes full-frame raster work on the ESP32-P4. +opt-level = 3 +codegen-units = 1 +lto = true + +[workspace] diff --git a/hosts/esp32p4/runtime/examples/headless.rs b/hosts/esp32p4/runtime/examples/headless.rs new file mode 100644 index 00000000..c7a5e54c --- /dev/null +++ b/hosts/esp32p4/runtime/examples/headless.rs @@ -0,0 +1,102 @@ +//! Render a target-bound ESP32-P4 bundle with the exact device runtime on the +//! development machine. This is the pre-flash acceptance gate for QuickJS, +//! HostOps, PAK assets, fixed ticks, and the RGB565 renderer. + +use std::env; +use std::ffi::c_char; +use std::fs; +use std::ptr; + +use pocketjs_esp32p4_runtime::{ + pocketjs_runtime_create, pocketjs_runtime_destroy, pocketjs_runtime_frame, + pocketjs_runtime_framebuffer_hash, pocketjs_runtime_framebuffer_height, + pocketjs_runtime_framebuffer_width, pocketjs_runtime_last_error, PocketJsFrameStats, +}; + +fn runtime_error() -> String { + let required = pocketjs_runtime_last_error(ptr::null_mut(), 0); + let mut bytes = vec![0u8; required.saturating_add(1)]; + pocketjs_runtime_last_error(bytes.as_mut_ptr().cast::(), bytes.len()); + String::from_utf8_lossy(&bytes[..required]).into_owned() +} + +fn rgb565_to_ppm(framebuffer: &[u16], width: usize, height: usize) -> Vec { + let mut ppm = format!("P6\n{width} {height}\n255\n").into_bytes(); + ppm.reserve(framebuffer.len() * 3); + for pixel in framebuffer { + let red = ((pixel >> 11) & 0x1f) as u8; + let green = ((pixel >> 5) & 0x3f) as u8; + let blue = (pixel & 0x1f) as u8; + ppm.extend_from_slice(&[ + (red << 3) | (red >> 2), + (green << 2) | (green >> 4), + (blue << 3) | (blue >> 2), + ]); + } + ppm +} + +fn main() -> Result<(), Box> { + let arguments = env::args().skip(1).collect::>(); + if arguments.len() < 3 || arguments.len() > 4 { + return Err("usage: headless [frames]".into()); + } + let javascript = fs::read(&arguments[0])?; + let pak = fs::read(&arguments[1])?; + let output = &arguments[2]; + let frame_count = arguments + .get(3) + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(3); + if frame_count == 0 { + return Err("frames must be greater than zero".into()); + } + + let runtime = pocketjs_runtime_create( + javascript.as_ptr(), + javascript.len(), + pak.as_ptr(), + pak.len(), + ); + if runtime.is_null() { + return Err(format!("PocketJS runtime boot failed: {}", runtime_error()).into()); + } + + let width = pocketjs_runtime_framebuffer_width() as usize; + let height = pocketjs_runtime_framebuffer_height() as usize; + let mut framebuffer = vec![0u16; width * height]; + let mut stats = PocketJsFrameStats::default(); + for _ in 0..frame_count { + if pocketjs_runtime_frame( + runtime, + 0, + ptr::null(), + 0, + framebuffer.as_mut_ptr(), + framebuffer.len(), + &mut stats, + ) == 0 + { + pocketjs_runtime_destroy(runtime); + return Err(format!("PocketJS frame failed: {}", runtime_error()).into()); + } + } + + let framebuffer_hash = + pocketjs_runtime_framebuffer_hash(framebuffer.as_ptr(), framebuffer.len()); + fs::write(output, rgb565_to_ppm(&framebuffer, width, height))?; + pocketjs_runtime_destroy(runtime); + println!( + "PJHEADLESS frame={} draw={:016x} framebuffer={:016x} ppa={} software={} damage={} pixels={} output={}", + stats.frame, + stats.draw_hash, + framebuffer_hash, + stats.ppa_fills + stats.ppa_blends + stats.ppa_srm, + stats.software_ops, + stats.damage_regions, + stats.damage_pixels, + output, + ); + Ok(()) +} diff --git a/hosts/esp32p4/runtime/src/lib.rs b/hosts/esp32p4/runtime/src/lib.rs new file mode 100644 index 00000000..8cb1cee7 --- /dev/null +++ b/hosts/esp32p4/runtime/src/lib.rs @@ -0,0 +1,793 @@ +//! Complete PocketJS Guest runtime for ESP32-P4 products. +//! +//! The board host owns ESP-IDF, the panel, GT911 input, frame pacing and the +//! RGB565 presentation buffer. This static library owns the reusable PocketJS +//! half of the process: one QuickJS realm (`pocket-mod`), the complete `ui` +//! HostOps surface, pak feeding, the retained core, deterministic fixed ticks, +//! and the hybrid PPA/software DrawList renderer. + +use std::ffi::c_char; +#[cfg(test)] +use std::ffi::CStr; +#[cfg(feature = "esp-idf")] +use std::ffi::CString; +use std::mem::size_of; +use std::ptr; +use std::slice; +use std::str; +use std::sync::{Mutex, Once}; + +use pocket_mod::{Guest, GuestFrameEvent}; +use pocket_ui_surface::UiSurface; +#[cfg(feature = "esp-idf")] +use pocketjs_esp32p4_ppa::EspIdfPpaOps; +use pocketjs_esp32p4_ppa::{ + PpaOps, Rect, RenderTargetState, Renderer, RendererConfig, SrmTransform, +}; + +const LOGICAL_WIDTH: u32 = 480; +const LOGICAL_HEIGHT: u32 = 272; +const RASTER_DENSITY: u32 = 2; +const FRAMEBUFFER_WIDTH: u32 = LOGICAL_WIDTH * RASTER_DENSITY; +const FRAMEBUFFER_HEIGHT: u32 = LOGICAL_HEIGHT * RASTER_DENSITY; +const FRAMEBUFFER_PIXELS: usize = FRAMEBUFFER_WIDTH as usize * FRAMEBUFFER_HEIGHT as usize; +const HOST_ID: &str = "esp32p4-waveshare-7b-dev"; +const HOST_ABI: u32 = 6; +const HOST_FRAME_RATE: u32 = 60; +const CORE_TICKS_PER_FRAME: u32 = 60 / HOST_FRAME_RATE; + +static INSTALL_LOGGER: Once = Once::new(); +static LAST_ERROR: Mutex = Mutex::new(String::new()); + +#[cfg(feature = "esp-idf")] +extern "C" { + fn pocketjs_esp32p4_log(level: u32, message: *const c_char); + fn esp_timer_get_time() -> i64; +} + +#[inline] +fn profile_elapsed(started_us: Option, ended_us: Option) -> u32 { + match (started_us, ended_us) { + (Some(started_us), Some(ended_us)) => { + ended_us.saturating_sub(started_us).min(u32::MAX as u64) as u32 + } + _ => 0, + } +} + +#[cfg(feature = "esp-idf")] +fn esp_profile_clock_us() -> u64 { + let now = unsafe { esp_timer_get_time() }; + now.max(0) as u64 +} + +#[inline] +fn profile_duration_us(duration_us: u64) -> u32 { + duration_us.min(u32::MAX as u64) as u32 +} + +struct RuntimeLogger; + +impl log::Log for RuntimeLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Info + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let message = format!("{}: {}", record.target(), record.args()); + #[cfg(feature = "esp-idf")] + { + let sanitized = message.replace('\0', "\\0"); + if let Ok(value) = CString::new(sanitized) { + let level = match record.level() { + log::Level::Error => 1, + log::Level::Warn => 2, + log::Level::Info => 3, + log::Level::Debug => 4, + log::Level::Trace => 5, + }; + unsafe { pocketjs_esp32p4_log(level, value.as_ptr()) }; + } + } + #[cfg(not(feature = "esp-idf"))] + eprintln!("{message}"); + } + + fn flush(&self) {} +} + +static LOGGER: RuntimeLogger = RuntimeLogger; + +fn install_logger() { + INSTALL_LOGGER.call_once(|| { + if log::set_logger(&LOGGER).is_ok() { + log::set_max_level(log::LevelFilter::Info); + } + }); +} + +fn remember_error(error: impl std::fmt::Display) { + let message = error.to_string(); + log::error!(target: "runtime", "{message}"); + if let Ok(mut target) = LAST_ERROR.lock() { + *target = message; + } +} + +struct HostPpa { + #[cfg(feature = "esp-idf")] + hardware: Option, +} + +impl HostPpa { + fn new() -> Self { + #[cfg(feature = "esp-idf")] + { + match EspIdfPpaOps::new() { + Ok(hardware) => { + log::info!(target: "render", "ESP32-P4 PPA FILL/BLEND/SRM ready"); + Self { + hardware: Some(hardware), + } + } + Err(error) => { + log::warn!( + target: "render", + "PPA registration failed ({error}); ordered RGB565 software fallback remains active" + ); + Self { hardware: None } + } + } + } + #[cfg(not(feature = "esp-idf"))] + { + Self {} + } + } + + fn accelerated(&self) -> bool { + #[cfg(feature = "esp-idf")] + { + self.hardware.is_some() + } + #[cfg(not(feature = "esp-idf"))] + { + false + } + } +} + +impl PpaOps for HostPpa { + fn profile_clock_us(&self) -> Option { + #[cfg(feature = "esp-idf")] + { + let now = unsafe { esp_timer_get_time() }; + return (now >= 0).then_some(now as u64); + } + #[cfg(not(feature = "esp-idf"))] + { + None + } + } + + fn fill_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + rect: Rect, + color: u16, + ) -> bool { + #[cfg(feature = "esp-idf")] + if let Some(hardware) = self.hardware.as_mut() { + return hardware.fill_rgb565(destination, width, height, rect, color); + } + let _ = (destination, width, height, rect, color); + false + } + + fn blend_a8_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + mask: &[u8], + rect: Rect, + color: [u8; 3], + global_alpha: u8, + ) -> bool { + #[cfg(feature = "esp-idf")] + if let Some(hardware) = self.hardware.as_mut() { + return hardware.blend_a8_rgb565( + destination, + width, + height, + mask, + rect, + color, + global_alpha, + ); + } + let _ = (destination, width, height, mask, rect, color, global_alpha); + false + } + + fn blend_rgba8888_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + source_rect: Rect, + destination_rect: Rect, + ) -> bool { + #[cfg(feature = "esp-idf")] + if let Some(hardware) = self.hardware.as_mut() { + return hardware.blend_rgba8888_rgb565( + destination, + width, + height, + source, + source_width, + source_height, + source_rect, + destination_rect, + ); + } + let _ = ( + destination, + width, + height, + source, + source_width, + source_height, + source_rect, + destination_rect, + ); + false + } + + fn srm_psm5650_to_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + source_rect: Rect, + destination_rect: Rect, + transform: SrmTransform, + ) -> bool { + #[cfg(feature = "esp-idf")] + if let Some(hardware) = self.hardware.as_mut() { + return hardware.srm_psm5650_to_rgb565( + destination, + width, + height, + source, + source_width, + source_height, + source_rect, + destination_rect, + transform, + ); + } + let _ = ( + destination, + width, + height, + source, + source_width, + source_height, + source_rect, + destination_rect, + transform, + ); + false + } +} + +pub struct PocketRuntime { + guest: Guest, + surface: UiSurface, + renderer: Renderer, + target: RenderTargetState, + ppa: HostPpa, + frame: u32, + last_draw_hash: u64, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct PocketJsFrameStats { + pub frame: u32, + pub draw_hash: u64, + pub ppa_fills: u32, + pub ppa_blends: u32, + pub ppa_srm: u32, + pub software_ops: u32, + pub damage_regions: u32, + pub damage_pixels: u32, + pub full_redraw: u32, + pub ppa_active: u32, + pub ui_update_us: u32, + pub hit_test_us: u32, + pub guest_frame_us: u32, + pub core_tick_us: u32, + pub draw_list_us: u32, + pub render_us: u32, + pub damage_clear_us: u32, + pub mask_build_us: u32, + pub software_us: u32, + pub ppa_fill_us: u32, + pub ppa_blend_us: u32, + pub ppa_srm_us: u32, + pub guest_prepare_us: u32, + pub guest_call_us: u32, + pub guest_jobs_us: u32, + pub guest_jobs_run: u32, + pub host_create_calls: u32, + pub host_create_us: u32, + pub host_insert_calls: u32, + pub host_insert_us: u32, + pub host_style_calls: u32, + pub host_style_us: u32, + pub host_prop_calls: u32, + pub host_prop_us: u32, + pub host_text_calls: u32, + pub host_text_us: u32, + pub host_animate_calls: u32, + pub host_animate_us: u32, + pub host_other_calls: u32, + pub host_other_us: u32, + pub damage_x: u32, + pub damage_y: u32, + pub damage_w: u32, + pub damage_h: u32, +} + +fn bytes<'a>(pointer: *const u8, length: usize) -> anyhow::Result<&'a [u8]> { + if length == 0 { + return Ok(&[]); + } + if pointer.is_null() { + anyhow::bail!("non-empty buffer has a null pointer"); + } + Ok(unsafe { slice::from_raw_parts(pointer, length) }) +} + +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn draw_hash(words: &[u32]) -> u64 { + let bytes = unsafe { + slice::from_raw_parts(words.as_ptr().cast::(), words.len() * size_of::()) + }; + fnv1a64(bytes) +} + +fn create_runtime(java_script: &[u8], pak: &[u8]) -> anyhow::Result { + let source = str::from_utf8(java_script)?; + let surface = UiSurface::new_with_density( + (LOGICAL_WIDTH as f32, LOGICAL_HEIGHT as f32), + RASTER_DENSITY, + ); + surface.set_identity(HOST_ID, HOST_ABI); + surface.feed_pak(pak); + + let guest = Guest::new()?; + surface.mount(&guest)?; + guest.with(|context| -> rquickjs::Result<()> { + context.globals().set("__simHz", HOST_FRAME_RATE)?; + Ok(()) + })?; + guest.eval("app.js", source)?; + anyhow::ensure!(guest.has_frame(), "app.js did not install globalThis.frame"); + #[cfg(feature = "esp-idf")] + surface.set_host_ops_profile_clock(Some(esp_profile_clock_us)); + + let renderer = Renderer::new(RendererConfig { + scale: RASTER_DENSITY, + ..RendererConfig::default() + }) + .ok_or_else(|| anyhow::anyhow!("invalid ESP32-P4 renderer configuration"))?; + + Ok(PocketRuntime { + guest, + surface, + renderer, + target: RenderTargetState::new(), + ppa: HostPpa::new(), + frame: 0, + last_draw_hash: 0, + }) +} + +/// Boot one target-bound PocketJS bundle and pak. The returned handle is owned +/// by the caller and must be released with `pocketjs_runtime_destroy`. +#[no_mangle] +pub extern "C" fn pocketjs_runtime_create( + java_script: *const u8, + java_script_len: usize, + pak: *const u8, + pak_len: usize, +) -> *mut PocketRuntime { + install_logger(); + let result = (|| { + let java_script = bytes(java_script, java_script_len)?; + let pak = bytes(pak, pak_len)?; + create_runtime(java_script, pak) + })(); + match result { + Ok(runtime) => Box::into_raw(Box::new(runtime)), + Err(error) => { + remember_error(error); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn pocketjs_runtime_destroy(runtime: *mut PocketRuntime) { + if !runtime.is_null() { + unsafe { drop(Box::from_raw(runtime)) }; + } +} + +/// Force the next frame to repaint the complete persistent render target. +/// +/// Product hosts use this only for explicit acceptance benchmarks or after +/// losing the contents of their framebuffer. Normal animation continues to +/// use the backend-neutral DrawList damage tracker. +#[no_mangle] +pub extern "C" fn pocketjs_runtime_invalidate_target(runtime: *mut PocketRuntime) { + if let Some(runtime) = unsafe { runtime.as_mut() } { + runtime.target.invalidate(); + } +} + +/// Run one 60 Hz host frame. The guest turns once, the retained core advances +/// one deterministic 60 Hz tick, and the current DrawList is incrementally +/// rendered into the caller's persistent 960x544 RGB565 buffer. +#[no_mangle] +pub extern "C" fn pocketjs_runtime_frame( + runtime: *mut PocketRuntime, + buttons: u32, + touches: *const u32, + touch_count: usize, + framebuffer: *mut u16, + framebuffer_pixels: usize, + out_stats: *mut PocketJsFrameStats, +) -> i32 { + if runtime.is_null() || framebuffer.is_null() || framebuffer_pixels != FRAMEBUFFER_PIXELS { + remember_error("invalid runtime or RGB565 framebuffer contract"); + return 0; + } + if touch_count > 8 { + remember_error("PocketJS accepts at most eight simultaneous touch contacts"); + return 0; + } + let touches = if touch_count == 0 { + &[] + } else if touches.is_null() { + remember_error("non-empty touch snapshot has a null pointer"); + return 0; + } else { + // `touches` has a u32 element type at the ABI boundary, so the C host + // is responsible for the same alignment and lifetime as any C array. + unsafe { slice::from_raw_parts(touches, touch_count) } + }; + let runtime = unsafe { &mut *runtime }; + let framebuffer = unsafe { slice::from_raw_parts_mut(framebuffer, framebuffer_pixels) }; + + let result = (|| -> anyhow::Result { + let ui_started_us = runtime.ppa.profile_clock_us(); + // Resolve each new contact against the committed frame before the + // guest mutates UI state. The core carries that node id until lift. + let hit_started_us = runtime.ppa.profile_clock_us(); + let mut hits = [0i32; 8]; + let hit_count = runtime + .surface + .with_ui(|ui| ui.touch_hits(touches, &mut hits)); + let hit_test_us = profile_elapsed(hit_started_us, runtime.ppa.profile_clock_us()); + let _ = runtime.surface.take_host_ops_profile(); + let guest_started_us = runtime.ppa.profile_clock_us(); + let mut guest_prepare_started_us = guest_started_us; + let mut guest_call_started_us = None; + let mut guest_jobs_started_us = None; + let mut guest_prepare_us = 0; + let mut guest_call_us = 0; + let mut guest_jobs_us = 0; + let mut guest_jobs_run = 0; + let profile_clock = &runtime.ppa; + runtime.guest.frame_with_touch_hits_observed( + buttons, + pocketjs_core::spec::ANALOG_CENTER, + touches, + &hits[..hit_count], + |event| { + let now_us = profile_clock.profile_clock_us(); + match event { + GuestFrameEvent::PrepareBegin => guest_prepare_started_us = now_us, + GuestFrameEvent::CallBegin => { + guest_prepare_us = profile_elapsed(guest_prepare_started_us, now_us); + guest_call_started_us = now_us; + } + GuestFrameEvent::CallEnd => { + guest_call_us = profile_elapsed(guest_call_started_us, now_us); + } + GuestFrameEvent::JobsBegin => guest_jobs_started_us = now_us, + GuestFrameEvent::JobsEnd { jobs_run } => { + guest_jobs_us = profile_elapsed(guest_jobs_started_us, now_us); + guest_jobs_run = jobs_run; + } + } + }, + )?; + let host_ops = runtime.surface.take_host_ops_profile(); + let guest_frame_us = profile_elapsed(guest_started_us, runtime.ppa.profile_clock_us()); + let tick_started_us = runtime.ppa.profile_clock_us(); + for _ in 0..CORE_TICKS_PER_FRAME { + runtime.surface.tick(); + } + let core_tick_us = profile_elapsed(tick_started_us, runtime.ppa.profile_clock_us()); + let ui_update_us = profile_elapsed(ui_started_us, runtime.ppa.profile_clock_us()); + + let draw_started_us = runtime.ppa.profile_clock_us(); + let renderer = &mut runtime.renderer; + let target = &mut runtime.target; + let ppa = &mut runtime.ppa; + let mut draw_list_us = 0; + let mut render_us = 0; + let (stats, current_draw_hash) = runtime.surface.with_ui(|ui| { + // Ui::draw mutates the retained DrawList. Clone only the compact + // word stream so the renderer can borrow Ui again for textures and + // font atlases while RefCell still owns the single mutable core. + let words = ui.draw().words.clone(); + let current_draw_hash = draw_hash(&words); + draw_list_us = profile_elapsed(draw_started_us, ppa.profile_clock_us()); + let render_started_us = ppa.profile_clock_us(); + let stats = renderer.render_incremental( + target, + ui, + &words, + framebuffer, + FRAMEBUFFER_WIDTH, + FRAMEBUFFER_HEIGHT, + ppa, + ); + render_us = profile_elapsed(render_started_us, ppa.profile_clock_us()); + (stats, current_draw_hash) + }); + let stats = stats.ok_or_else(|| anyhow::anyhow!("RGB565 renderer rejected the frame"))?; + runtime.frame = runtime.frame.wrapping_add(1); + runtime.last_draw_hash = current_draw_hash; + Ok(PocketJsFrameStats { + frame: runtime.frame, + draw_hash: current_draw_hash, + ppa_fills: stats.ppa_fills, + ppa_blends: stats.ppa_blends, + ppa_srm: stats.ppa_srm, + software_ops: stats.software_ops, + damage_regions: stats.damage_regions, + damage_pixels: stats.damage_pixels, + full_redraw: stats.full_redraw as u32, + ppa_active: runtime.ppa.accelerated() as u32, + ui_update_us, + hit_test_us, + guest_frame_us, + core_tick_us, + draw_list_us, + render_us, + damage_clear_us: stats.damage_clear_us, + mask_build_us: stats.mask_build_us, + software_us: stats.software_us, + ppa_fill_us: stats.ppa_fill_us, + ppa_blend_us: stats.ppa_blend_us, + ppa_srm_us: stats.ppa_srm_us, + guest_prepare_us, + guest_call_us, + guest_jobs_us, + guest_jobs_run, + host_create_calls: host_ops.create_calls, + host_create_us: profile_duration_us(host_ops.create_us), + host_insert_calls: host_ops.insert_calls, + host_insert_us: profile_duration_us(host_ops.insert_us), + host_style_calls: host_ops.style_calls, + host_style_us: profile_duration_us(host_ops.style_us), + host_prop_calls: host_ops.prop_calls, + host_prop_us: profile_duration_us(host_ops.prop_us), + host_text_calls: host_ops.text_calls, + host_text_us: profile_duration_us(host_ops.text_us), + host_animate_calls: host_ops.animate_calls, + host_animate_us: profile_duration_us(host_ops.animate_us), + host_other_calls: host_ops.other_calls, + host_other_us: profile_duration_us(host_ops.other_us), + damage_x: stats.damage_bounds.x, + damage_y: stats.damage_bounds.y, + damage_w: stats.damage_bounds.w, + damage_h: stats.damage_bounds.h, + }) + })(); + + match result { + Ok(stats) => { + if !out_stats.is_null() { + unsafe { *out_stats = stats }; + } + 1 + } + Err(error) => { + remember_error(error); + 0 + } + } +} + +#[no_mangle] +pub extern "C" fn pocketjs_runtime_last_error(buffer: *mut c_char, capacity: usize) -> usize { + let message = LAST_ERROR + .lock() + .map(|value| value.clone()) + .unwrap_or_default(); + let bytes = message.as_bytes(); + if !buffer.is_null() && capacity > 0 { + let count = bytes.len().min(capacity - 1); + unsafe { + ptr::copy_nonoverlapping(bytes.as_ptr(), buffer.cast::(), count); + *buffer.add(count) = 0; + } + } + bytes.len() +} + +#[no_mangle] +pub extern "C" fn pocketjs_runtime_framebuffer_hash( + framebuffer: *const u16, + framebuffer_pixels: usize, +) -> u64 { + if framebuffer.is_null() || framebuffer_pixels != FRAMEBUFFER_PIXELS { + return 0; + } + let bytes = unsafe { + slice::from_raw_parts( + framebuffer.cast::(), + framebuffer_pixels * size_of::(), + ) + }; + fnv1a64(bytes) +} + +#[no_mangle] +pub extern "C" fn pocketjs_runtime_host_id() -> *const c_char { + static HOST: &[u8] = b"esp32p4-waveshare-7b-dev\0"; + HOST.as_ptr().cast() +} + +#[no_mangle] +pub extern "C" fn pocketjs_runtime_host_abi() -> u32 { + HOST_ABI +} + +#[no_mangle] +pub extern "C" fn pocketjs_runtime_framebuffer_width() -> u32 { + FRAMEBUFFER_WIDTH +} + +#[no_mangle] +pub extern "C" fn pocketjs_runtime_framebuffer_height() -> u32 { + FRAMEBUFFER_HEIGHT +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boots_a_real_quickjs_guest_and_renders_one_frame() { + let source = br#" + globalThis.frameCount = 0; + globalThis.frame = () => { globalThis.frameCount++; }; + "#; + let mut runtime = create_runtime(source, &[]).unwrap(); + let mut framebuffer = vec![0u16; FRAMEBUFFER_PIXELS]; + let mut stats = PocketJsFrameStats::default(); + assert_eq!( + pocketjs_runtime_frame( + &mut runtime, + 0, + ptr::null(), + 0, + framebuffer.as_mut_ptr(), + framebuffer.len(), + &mut stats, + ), + 1 + ); + assert_eq!(stats.frame, 1); + assert_eq!(stats.full_redraw, 1); + assert_eq!( + (stats.damage_x, stats.damage_y, stats.damage_w, stats.damage_h), + (0, 0, FRAMEBUFFER_WIDTH, FRAMEBUFFER_HEIGHT) + ); + assert_ne!( + pocketjs_runtime_framebuffer_hash(framebuffer.as_ptr(), framebuffer.len()), + 0 + ); + let frame_count: i32 = runtime + .guest + .with(|context| context.globals().get("frameCount").unwrap()); + assert_eq!(frame_count, 1); + } + + #[test] + fn target_invalidation_forces_exactly_the_next_frame_to_repaint() { + let source = br#" + globalThis.frame = () => {}; + "#; + let mut runtime = create_runtime(source, &[]).unwrap(); + let mut framebuffer = vec![0u16; FRAMEBUFFER_PIXELS]; + let mut stats = PocketJsFrameStats::default(); + let render = |runtime: &mut PocketRuntime, + framebuffer: &mut [u16], + stats: &mut PocketJsFrameStats| { + pocketjs_runtime_frame( + runtime, + 0, + ptr::null(), + 0, + framebuffer.as_mut_ptr(), + framebuffer.len(), + stats, + ) + }; + + assert_eq!(render(&mut runtime, &mut framebuffer, &mut stats), 1); + assert_eq!(stats.full_redraw, 1); + assert_eq!(render(&mut runtime, &mut framebuffer, &mut stats), 1); + assert_eq!(stats.full_redraw, 0); + assert_eq!( + (stats.damage_x, stats.damage_y, stats.damage_w, stats.damage_h), + (0, 0, 0, 0) + ); + + pocketjs_runtime_invalidate_target(&mut runtime); + assert_eq!(render(&mut runtime, &mut framebuffer, &mut stats), 1); + assert_eq!(stats.full_redraw, 1); + assert_eq!(render(&mut runtime, &mut framebuffer, &mut stats), 1); + assert_eq!(stats.full_redraw, 0); + + // Destruction/benchmark cleanup may call this after ownership moved. + pocketjs_runtime_invalidate_target(ptr::null_mut()); + } + + #[test] + fn rejects_a_bundle_without_the_host_frame_contract() { + let error = create_runtime(b"globalThis.answer = 42;", &[]) + .err() + .expect("missing frame must fail"); + assert!(error.to_string().contains("globalThis.frame")); + } + + #[test] + fn exported_geometry_matches_the_private_target_surface() { + assert_eq!(size_of::(), 184); + assert_eq!(pocketjs_runtime_framebuffer_width(), 960); + assert_eq!(pocketjs_runtime_framebuffer_height(), 544); + assert_eq!(pocketjs_runtime_host_abi(), 6); + let host = unsafe { CStr::from_ptr(pocketjs_runtime_host_id()) }; + assert_eq!(host.to_str().unwrap(), HOST_ID); + } +} diff --git a/hosts/esp32p4/waveshare-7b/.gitignore b/hosts/esp32p4/waveshare-7b/.gitignore new file mode 100644 index 00000000..4e923453 --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/.gitignore @@ -0,0 +1,6 @@ +/build/ +/managed_components/ +/sdkconfig +/sdkconfig.old +/main/app.js +/main/app.pak diff --git a/hosts/esp32p4/waveshare-7b/CMakeLists.txt b/hosts/esp32p4/waveshare-7b/CMakeLists.txt new file mode 100644 index 00000000..38308fab --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.22) + +# This directory is a copyable firmware-project template. Generated projects +# stay checkout-bound so the local ESP-IDF component and the Rust archive can +# never be picked up silently from another PocketJS checkout. +set(IDF_TARGET "esp32p4") + +# PocketJS owns rendering and talks directly to the BSP's DPI/touch APIs. +# Apply this before ESP-IDF discovers components so the Waveshare BSP excludes +# its optional LVGL task, canvas, draw buffers, and input adapter. +add_compile_definitions(BSP_CONFIG_NO_GRAPHIC_LIB=1) + +if(NOT DEFINED POCKETJS_REPO_ROOT AND NOT "$ENV{POCKETJS_REPO_ROOT}" STREQUAL "") + set(POCKETJS_REPO_ROOT "$ENV{POCKETJS_REPO_ROOT}") +endif() +if(NOT DEFINED POCKETJS_REPO_ROOT OR + NOT EXISTS "${POCKETJS_REPO_ROOT}/hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt") + message(FATAL_ERROR + "Set POCKETJS_REPO_ROOT to the PocketJS checkout that produced this firmware project") +endif() + +list(APPEND EXTRA_COMPONENT_DIRS + "${POCKETJS_REPO_ROOT}/hosts/esp32p4/components" +) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_esp32p4_waveshare_7b) diff --git a/hosts/esp32p4/waveshare-7b/README.md b/hosts/esp32p4/waveshare-7b/README.md new file mode 100644 index 00000000..88d9bb5e --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/README.md @@ -0,0 +1,86 @@ +# Full PocketJS on Waveshare ESP32-P4 7B + +This is the board-side ESP-IDF template for the complete PocketJS runtime, +not Pocket Vapor. It hosts a target-bound JavaScript bundle in QuickJS, mounts +the full UI HostOps surface, renders its 480x272 logical viewport at density 2 +into a persistent 960x544 RGB565 PSRAM buffer, and centers that buffer at +`32,28` on the board's 1024x600 EK79007 panel. Presentation does not use LVGL: +ESP-IDF's DMA2D framebuffer-copy helper copies the compact render target into +the inactive one of two native 1024x600 scanout buffers. Each native buffer +tracks its own accumulated dirty bounding rectangle, so an incremental frame +copies only the rows and columns that buffer has missed since it was last +frontmost. The host then queues that native buffer with the DPI driver and +returns ownership of the old front buffer only after `on_refresh_done` +observes the frame-boundary flip. +Both native buffers are explicitly cleared so the surrounding border remains +black, while EK79007 hardware mirror bits replace the former software rotation. + +GT911 is polled directly. Its board transform and the former LVGL 180-degree +input mapping are both preserved before positions in the content rectangle are +delivered as logical PocketJS touch contacts. Guest turns, retained core ticks, +and board presentation keep the normal PocketJS 60 Hz cadence. + +Generated firmware projects copy the four root files and the source files in +`main/`, then place their compiled `app.js` and `app.pak` in `main/`. Configure +the project with two checkout-bound absolute paths: + +```sh +bun run esp32p4:device build chrome +bun run esp32p4:device flash cards --port /dev/cu.usbmodem101 +``` + +Those commands compile the target-bound bundle, cross-build the complete +QuickJS runtime, stage a clean project, and use ESP-IDF's generated segmented +flash plan. For direct template development, configure the same paths +manually: + +```sh +export POCKETJS_REPO_ROOT=/absolute/path/to/pocketjs +export POCKETJS_RUST_LIB=/absolute/path/to/libpocketjs_esp32p4_runtime.a +idf.py build +``` + +The reproducible board dependency graph is ESP-IDF v5.5.4 and Waveshare BSP +v1.0.4. The upstream BSP manifest still pulls `esp_lvgl_port` v2.7.2 and LVGL +v9.2.2, so they remain pinned in `dependencies.lock`, but +`BSP_CONFIG_NO_GRAPHIC_LIB=1` removes them from the board runtime path. +`dependencies.lock` is copied from the verified bring-up for this exact +hardware. The presentation path intentionally uses ESP-IDF v5.5.4's private +`esp_async_fbcpy.h` helper, so the host CMake file pins its private include path; +an IDF upgrade must revalidate that API and the full-present benchmark below. + +At 115200 baud the runtime emits `PJREADY`, periodic `PJFRAME`/`PJPERF`, and +physical `PJTOUCH source=gt911` receipts. `PJPERF` separates runtime, +DMA2D copy, native-buffer submission, refresh-boundary wait, and total frame +work without logging in the per-frame hot path. `PJFRAME` reports both the +renderer's `damage_bounds=x,y,w,h` and the actual `copied_pixels`; the latter +can be larger when an inactive native buffer must catch up with more than one +incremental frame. UART line commands are: + +- `H` — repeat the ready/identity receipt; +- `D` — hash and print current render statistics; +- `P ` — inject that PocketJS button bitmask for one frame, then release; + outside an active benchmark, the exact consuming frame immediately emits + `PJFRAME` and `PJPERF` receipts. During a benchmark the injection still takes + effect, but that per-frame receipt pair is suppressed with the other hot-path + diagnostics; +- `B ` — invalidate the retained renderer target before each of 1–600 + frames. This forces a full DrawList raster while resource caches remain warm, + and reports `mode=forced-full-raster`; +- `V ` — keep normal incremental rendering but force all 522,240 + content pixels through DMA2D and a visible native-buffer flip on every one of + 1–600 frames. This isolates full-target presentation throughput. + +Both benchmark commands emit one `PJBENCH` receipt with coverage counts, +elapsed/effective FPS, deadline misses, and runtime/present/total average, p95, +and maximum. `p95_pass=1 max_pass=1` means every requested operation happened +and the measured window met the 60 Hz budget. Cumulative wall deadlines use +`ceil(n * 1,000,000 / 60)` microseconds, so rounding a single frame up to +16,667 microseconds cannot accumulate into a looser long-window threshold. Do +not use `V` to claim that a full DrawList can be re-rasterized at 60 fps: `B` +reports that separate cost with already-warm resource caches. +Periodic framebuffer hashing is suppressed during either benchmark window. + +The firmware image uses a 15 MiB factory-app partition starting at `0x10000`. +Flash it with the generated project's `idf.py flash`; the ESP32-P4 bootloader +offset and the other segmented images come from its `flasher_args.json`. diff --git a/hosts/esp32p4/waveshare-7b/ci-build.sh b/hosts/esp32p4/waveshare-7b/ci-build.sh new file mode 100755 index 00000000..4117ff3e --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/ci-build.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Final-link the complete QuickJS host inside Espressif's pinned IDF image. +# The normal device command builds a real app; CI uses the smallest valid guest +# so runtime/C ABI, BSP, dependency-lock, and linker regressions stay covered. + +readonly POCKETJS_CI_TOOLCHAIN="nightly-2026-07-02" +readonly POCKETJS_CI_TARGET="riscv32imafc-esp-espidf" +readonly POCKETJS_CI_ROOT="$(git rev-parse --show-toplevel)" +readonly POCKETJS_CI_TMP="$(mktemp -d /tmp/pocketjs-esp32p4-ci.XXXXXX)" +readonly POCKETJS_CI_RUSTUP="${POCKETJS_CI_TMP}/rustup" +readonly POCKETJS_CI_CARGO="${POCKETJS_CI_TMP}/cargo" +trap 'rm -rf "${POCKETJS_CI_TMP}"' EXIT + +if ! command -v curl >/dev/null || + ! command -v clang >/dev/null || + ! ldconfig -p 2>/dev/null | grep -q libclang; then + apt-get update -qq + apt-get install -y -qq --no-install-recommends ca-certificates clang curl libclang-dev +fi + +export RUSTUP_HOME="${POCKETJS_CI_RUSTUP}" +export CARGO_HOME="${POCKETJS_CI_CARGO}" +export PATH="${CARGO_HOME}/bin:${PATH}" +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | + sh -s -- -y --profile minimal --default-toolchain none +rustup toolchain install "${POCKETJS_CI_TOOLCHAIN}" --profile minimal --component rust-src + +readonly POCKETJS_CI_GCC="$(command -v riscv32-esp-elf-gcc)" +readonly POCKETJS_CI_AR="$(command -v riscv32-esp-elf-ar)" +readonly POCKETJS_CI_SYSROOT="$(${POCKETJS_CI_GCC} -print-sysroot)" +readonly POCKETJS_CI_GCC_INCLUDE="$(${POCKETJS_CI_GCC} -print-file-name=include)" +readonly POCKETJS_CI_GCC_FIXED="$(${POCKETJS_CI_GCC} -print-file-name=include-fixed)" + +export CARGO_TARGET_DIR="${POCKETJS_CI_TMP}/rust-target" +export CARGO_TARGET_RISCV32IMAFC_ESP_ESPIDF_RUSTFLAGS="-C relocation-model=static" +export CC_riscv32imafc_esp_espidf="${POCKETJS_CI_GCC}" +export AR_riscv32imafc_esp_espidf="${POCKETJS_CI_AR}" +export CFLAGS_riscv32imafc_esp_espidf="-mabi=ilp32f -march=rv32imafc_zicsr_zifencei_xesppie -Wno-error=incompatible-pointer-types -fno-pic -fno-pie" +export BINDGEN_EXTRA_CLANG_ARGS="--target=riscv32-unknown-elf --sysroot=${POCKETJS_CI_SYSROOT} -isystem ${POCKETJS_CI_GCC_INCLUDE} -isystem ${POCKETJS_CI_GCC_FIXED} -isystem ${POCKETJS_CI_SYSROOT}/include" + +cargo "+${POCKETJS_CI_TOOLCHAIN}" build \ + --locked \ + --manifest-path "${POCKETJS_CI_ROOT}/hosts/esp32p4/runtime/Cargo.toml" \ + --release \ + --lib \ + --target "${POCKETJS_CI_TARGET}" \ + --features esp-idf \ + -Z build-std=std,panic_abort + +readonly POCKETJS_CI_RUST_LIB="${CARGO_TARGET_DIR}/${POCKETJS_CI_TARGET}/release/libpocketjs_esp32p4_runtime.a" +readonly POCKETJS_CI_APP_JS="${POCKETJS_CI_TMP}/app.js" +readonly POCKETJS_CI_APP_PAK="${POCKETJS_CI_TMP}/app.pak" +readonly POCKETJS_CI_BUILD="${POCKETJS_CI_TMP}/idf-build" +printf '%s\n' 'globalThis.frame = function () {};' >"${POCKETJS_CI_APP_JS}" +: >"${POCKETJS_CI_APP_PAK}" + +idf.py \ + -C "${POCKETJS_CI_ROOT}/hosts/esp32p4/waveshare-7b" \ + -B "${POCKETJS_CI_BUILD}" \ + -D "POCKETJS_REPO_ROOT=${POCKETJS_CI_ROOT}" \ + -D "POCKETJS_RUST_LIB=${POCKETJS_CI_RUST_LIB}" \ + -D "POCKETJS_APP_JS=${POCKETJS_CI_APP_JS}" \ + -D "POCKETJS_APP_PAK=${POCKETJS_CI_APP_PAK}" \ + -D "POCKETJS_APP_TITLE=CI" \ + -D "POCKETJS_BUILD_ID=ci-final-link" \ + build + +test -s "${POCKETJS_CI_BUILD}/pocketjs_esp32p4_waveshare_7b.bin" +python - "${POCKETJS_CI_BUILD}/flasher_args.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + flash_files = json.load(source)["flash_files"] +required = {"0x2000", "0x8000", "0x10000"} +missing = required.difference(flash_files) +if missing: + raise SystemExit(f"segmented flash manifest is missing: {sorted(missing)}") +PY diff --git a/hosts/esp32p4/waveshare-7b/dependencies.lock b/hosts/esp32p4/waveshare-7b/dependencies.lock new file mode 100644 index 00000000..b1f72122 --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/dependencies.lock @@ -0,0 +1,126 @@ +dependencies: + espressif/cmake_utilities: + component_hash: 351350613ceafba240b761b4ea991e0f231ac7a9f59a9ee901f751bddc0bb18f + dependencies: + - name: idf + require: private + version: '>=4.1' + source: + registry_url: https://components.espressif.com + type: service + version: 0.5.3 + espressif/esp_codec_dev: + component_hash: df70f10af8d7b922add7b9d07372c9c97ab356e58d72b7a892050227c2d44348 + dependencies: + - name: idf + require: private + version: '>=4.0' + source: + registry_url: https://components.espressif.com + type: service + version: 1.5.11 + espressif/esp_lcd_ek79007: + component_hash: 8005700b7f10c7136b6e2a3f19a48f972aa1d13ed107ed298574e8d24d17ea83 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32p4 + version: 1.0.4 + espressif/esp_lcd_touch: + component_hash: 3f85a7d95af876f1a6ecca8eb90a81614890d0f03a038390804e5a77e2caf862 + dependencies: + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.1 + espressif/esp_lcd_touch_gt911: + component_hash: 07f678e4202d79bbad917805dcec4134ff08344177720f32ec4898267ad9e394 + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.2.0 + - name: idf + require: private + version: '>=5.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.0~3 + espressif/esp_lvgl_port: + component_hash: b6360960f47b6776462e7092861b3ea66477ffb762a01baa0aecbb3d74cd50f4 + dependencies: + - name: idf + require: private + version: '>=5.1' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: public + version: '>=8,<10' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.7.2 + idf: + source: + type: idf + version: 5.5.4 + lvgl/lvgl: + component_hash: 096c69af22eaf8a2b721e3913da91918c5e6bf1a762a113ec01f401aa61337a0 + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 9.2.2 + waveshare/esp32_p4_wifi6_touch_lcd_7b: + component_hash: 324a9667293e657b64a58b4c726e3babe2a8934a6154e7a9d51357a58fb7da0b + dependencies: + - name: espressif/esp_codec_dev + registry_url: https://components.espressif.com + require: public + version: ~1.5 + - name: espressif/esp_lcd_ek79007 + registry_url: https://components.espressif.com + require: private + version: 1.* + - name: espressif/esp_lcd_touch_gt911 + registry_url: https://components.espressif.com + require: private + version: ^1 + - name: espressif/esp_lvgl_port + registry_url: https://components.espressif.com + require: public + version: ^2 + - name: idf + require: private + version: '>=5.3' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: private + version: '>=8,<10' + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32p4 + version: 1.0.4 +direct_dependencies: +- espressif/esp_lvgl_port +- idf +- lvgl/lvgl +- waveshare/esp32_p4_wifi6_touch_lcd_7b +manifest_hash: fcdac87e384ba5ba9fadd693602aff1a7a8297b9ad1e611039b8a6586e622fe9 +target: esp32p4 +version: 2.0.0 diff --git a/hosts/esp32p4/waveshare-7b/main/CMakeLists.txt b/hosts/esp32p4/waveshare-7b/main/CMakeLists.txt new file mode 100644 index 00000000..8fd99ef7 --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/main/CMakeLists.txt @@ -0,0 +1,63 @@ +set(POCKETJS_APP_JS "${CMAKE_CURRENT_LIST_DIR}/app.js" CACHE FILEPATH + "Target-bound PocketJS JavaScript bundle") +set(POCKETJS_APP_PAK "${CMAKE_CURRENT_LIST_DIR}/app.pak" CACHE FILEPATH + "Target-bound PocketJS asset pak") +set(POCKETJS_APP_TITLE "PocketJS" CACHE STRING "Application title for device receipts") +set(POCKETJS_BUILD_ID "unknown" CACHE STRING "Deterministic firmware build id") + +idf_component_register( + SRCS "pocketjs_esp32p4.c" + INCLUDE_DIRS "." + REQUIRES + waveshare__esp32_p4_wifi6_touch_lcd_7b + pocketjs_ppa + esp_driver_uart + esp_lcd + esp_lcd_touch + esp_mm + esp_timer + heap + pthread + vfs +) + +# ESP-IDF's DPI driver uses this private helper for its own stride-aware +# DMA2D framebuffer copy. PocketJS uses the same pinned-v5.5.4 primitive to +# populate an inactive native framebuffer before a tear-free page flip. +target_include_directories(${COMPONENT_LIB} PRIVATE + "$ENV{IDF_PATH}/components/esp_lcd/priv_include" +) + +# ESP-IDF includes component CMake files once in an isolated early-expansion +# pass that only discovers dependencies. Cache arguments and imported targets +# are intentionally handled during the real configure pass below. +if(NOT CMAKE_BUILD_EARLY_EXPANSION) +if(NOT DEFINED POCKETJS_RUST_LIB AND NOT "$ENV{POCKETJS_RUST_LIB}" STREQUAL "") + set(POCKETJS_RUST_LIB "$ENV{POCKETJS_RUST_LIB}") +endif() +if(NOT DEFINED POCKETJS_RUST_LIB OR NOT EXISTS "${POCKETJS_RUST_LIB}") + message(FATAL_ERROR + "Set POCKETJS_RUST_LIB to an absolute libpocketjs_esp32p4_runtime.a path") +endif() +foreach(bundle_path IN ITEMS "${POCKETJS_APP_JS}" "${POCKETJS_APP_PAK}") + if(NOT EXISTS "${bundle_path}") + message(FATAL_ERROR "PocketJS bundle input not found: ${bundle_path}") + endif() +endforeach() + +# RENAME_TO makes the C symbols independent of the generated project's +# absolute path: _binary_app_js_{start,end} and _binary_app_pak_{start,end}. +target_add_binary_data(${COMPONENT_LIB} "${POCKETJS_APP_JS}" BINARY RENAME_TO app_js) +target_add_binary_data(${COMPONENT_LIB} "${POCKETJS_APP_PAK}" BINARY RENAME_TO app_pak) + +add_prebuilt_library(pocketjs_esp32p4_runtime "${POCKETJS_RUST_LIB}" + REQUIRES pocketjs_ppa + PRIV_REQUIRES log pthread vfs +) +target_link_libraries(${COMPONENT_LIB} PRIVATE pocketjs_esp32p4_runtime) + +target_compile_definitions(${COMPONENT_LIB} PRIVATE + POCKETJS_APP_TITLE="${POCKETJS_APP_TITLE}" + POCKETJS_BUILD_ID="${POCKETJS_BUILD_ID}" +) +endif() diff --git a/hosts/esp32p4/waveshare-7b/main/idf_component.yml b/hosts/esp32p4/waveshare-7b/main/idf_component.yml new file mode 100644 index 00000000..b398ed31 --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/main/idf_component.yml @@ -0,0 +1,11 @@ +# Exact dependency graph used by the connected Waveshare +# ESP32-P4-WIFI6-Touch-LCD-7B HW V1.0. +# Hardware reference: +# https://github.com/waveshareteam/ESP32-P4-WIFI6-Touch-LCD-7B/tree/c39554b3299f86403cd36820f6fa4767c84ef5f1/examples/ESP-IDF/10_lvgl_demo_v9 +dependencies: + waveshare/esp32_p4_wifi6_touch_lcd_7b: "1.0.4" + # The upstream BSP still declares these graphically optional components as + # dependencies even when BSP_CONFIG_NO_GRAPHIC_LIB excludes their runtime + # path. Pin them so the reproducible board graph cannot drift during builds. + espressif/esp_lvgl_port: "2.7.2" + lvgl/lvgl: "9.2.*" diff --git a/hosts/esp32p4/waveshare-7b/main/pocketjs_esp32p4.c b/hosts/esp32p4/waveshare-7b/main/pocketjs_esp32p4.c new file mode 100644 index 00000000..99d41130 --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/main/pocketjs_esp32p4.c @@ -0,0 +1,1088 @@ +/* Full PocketJS host for the Waveshare ESP32-P4-WIFI6-Touch-LCD-7B. + * + * The reusable Rust static library owns QuickJS, HostOps, retained UI state, + * and hybrid PPA/software DrawList rendering. This board boundary owns the + * exact EK79007/GT911 BSP, the persistent RGB565 shadow target, DMA2D copies + * into double native DPI framebuffers, frame-boundary flips, touch-coordinate + * conversion, frame pacing, and UART receipts. + */ +#include "pocketjs_runtime.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "bsp/display.h" +#include "bsp/touch.h" +#include "driver/uart.h" +#include "driver/uart_vfs.h" +#include "esp_cache.h" +#include "esp_async_fbcpy.h" +#include "esp_err.h" +#include "esp_heap_caps.h" +#include "esp_lcd_mipi_dsi.h" +#include "esp_lcd_panel_ops.h" +#include "esp_lcd_touch.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "hal/lcd_types.h" + +#ifndef POCKETJS_APP_TITLE +#define POCKETJS_APP_TITLE "PocketJS" +#endif +#ifndef POCKETJS_BUILD_ID +#define POCKETJS_BUILD_ID "unknown" +#endif + +#define PJ_BOARD_ID "waveshare-esp32-p4-wifi6-touch-lcd-7b" +#define PJ_LOGICAL_WIDTH 480 +#define PJ_LOGICAL_HEIGHT 272 +#define PJ_RASTER_DENSITY 2 +#define PJ_FRAMEBUFFER_WIDTH (PJ_LOGICAL_WIDTH * PJ_RASTER_DENSITY) +#define PJ_FRAMEBUFFER_HEIGHT (PJ_LOGICAL_HEIGHT * PJ_RASTER_DENSITY) +#define PJ_FRAMEBUFFER_PIXELS ((size_t)PJ_FRAMEBUFFER_WIDTH * PJ_FRAMEBUFFER_HEIGHT) +#define PJ_FRAMEBUFFER_BYTES (PJ_FRAMEBUFFER_PIXELS * sizeof(uint16_t)) +#define PJ_PANEL_WIDTH 1024 +#define PJ_PANEL_HEIGHT 600 +#define PJ_PANEL_FRAMEBUFFER_BYTES \ + ((size_t)PJ_PANEL_WIDTH * PJ_PANEL_HEIGHT * sizeof(uint16_t)) +#define PJ_CONTENT_X 32 +#define PJ_CONTENT_Y 28 +#define PJ_FRAME_RATE 60 +#define PJ_RECEIPT_PERIOD 60 +#define PJ_CACHE_ALIGNMENT 128 +#define PJ_BENCHMARK_MAX_FRAMES 600 +#define PJ_FRAME_BUDGET_US ((1000000 + PJ_FRAME_RATE - 1) / PJ_FRAME_RATE) + +#if !CONFIG_BSP_LCD_COLOR_FORMAT_RGB565 +#error "PocketJS direct DPI host requires CONFIG_BSP_LCD_COLOR_FORMAT_RGB565" +#endif + +_Static_assert(BSP_LCD_H_RES == PJ_PANEL_WIDTH, "selected BSP panel width must be 1024"); +_Static_assert(BSP_LCD_V_RES == PJ_PANEL_HEIGHT, "selected BSP panel height must be 600"); +_Static_assert(PJ_CONTENT_X * 2 + PJ_FRAMEBUFFER_WIDTH == PJ_PANEL_WIDTH, + "PocketJS framebuffer must be horizontally centered"); +_Static_assert(PJ_CONTENT_Y * 2 + PJ_FRAMEBUFFER_HEIGHT == PJ_PANEL_HEIGHT, + "PocketJS framebuffer must be vertically centered"); +_Static_assert(PJ_LOGICAL_WIDTH <= 511 && PJ_LOGICAL_HEIGHT <= 511, + "legacy packed-touch coordinates are nine bits per axis"); +_Static_assert(PJ_FRAMEBUFFER_BYTES % PJ_CACHE_ALIGNMENT == 0, + "framebuffer size must preserve the PPA cache-line contract"); + +extern const uint8_t app_js_start[] asm("_binary_app_js_start"); +extern const uint8_t app_js_end[] asm("_binary_app_js_end"); +extern const uint8_t app_pak_start[] asm("_binary_app_pak_start"); +extern const uint8_t app_pak_end[] asm("_binary_app_pak_end"); + +static const char *TAG = "pocketjs-p4"; + +typedef struct { + bool down; + int16_t panel_x; + int16_t panel_y; + int16_t logical_x; + int16_t logical_y; + uint32_t packed; +} TouchSnapshot; + +typedef struct { + uint32_t runtime_us; + uint32_t present_copy_us; + uint32_t present_submit_us; + uint32_t present_wait_us; + uint32_t total_us; + bool presented; + bool full_present; + uint32_t copied_pixels; +} FrameTiming; + +typedef struct { + uint32_t x; + uint32_t y; + uint32_t w; + uint32_t h; + bool valid; +} DamageRect; + +typedef struct { + uint64_t present_copy_sum_us; + uint32_t frames; + uint32_t presents; + uint64_t runtime_sum_us; + uint64_t present_submit_sum_us; + uint64_t present_wait_sum_us; + uint64_t total_sum_us; + uint32_t runtime_max_us; + uint32_t present_copy_max_us; + uint32_t present_submit_max_us; + uint32_t present_wait_max_us; + uint32_t total_max_us; +} TimingWindow; + +typedef enum { + PJ_BENCHMARK_NONE = 0, + PJ_BENCHMARK_FORCED_FULL_RASTER, + PJ_BENCHMARK_FULL_PRESENT, +} BenchmarkMode; + +typedef struct { + bool active; + BenchmarkMode mode; + uint32_t requested_frames; + uint32_t completed_frames; + uint32_t raster_full_frames; + uint32_t present_full_frames; + uint64_t copied_pixels; + uint32_t deadline_misses; + int64_t started_us; + int64_t completed_us; + uint32_t runtime_samples[PJ_BENCHMARK_MAX_FRAMES]; + uint32_t present_samples[PJ_BENCHMARK_MAX_FRAMES]; + uint32_t total_samples[PJ_BENCHMARK_MAX_FRAMES]; +} BenchmarkState; + +static bsp_lcd_handles_t lcd_handles; +static esp_lcd_touch_handle_t touch_handle; +static SemaphoreHandle_t refresh_done; +static SemaphoreHandle_t framebuffer_copy_done; +static volatile bool flip_armed; +static uint16_t *native_framebuffers[2]; +static DamageRect pending_native_damage[2]; +static uint8_t front_framebuffer; +static esp_async_fbcpy_handle_t framebuffer_copy; +static uint16_t *framebuffer; +static PocketRuntime *runtime; +static PocketJsFrameStats last_stats; +static TouchSnapshot current_touch = { + .down = false, + .panel_x = -1, + .panel_y = -1, + .logical_x = -1, + .logical_y = -1, + .packed = 0, +}; +static FrameTiming last_timing; +static TimingWindow timing_window; +static BenchmarkState benchmark; +static uint64_t last_screen_hash; +static uint32_t last_buttons; +static uint32_t injected_buttons; +static uint8_t injected_frames; +static bool runtime_ready; + +static DamageRect full_content_damage(void) { + return (DamageRect) { + .x = 0, + .y = 0, + .w = PJ_FRAMEBUFFER_WIDTH, + .h = PJ_FRAMEBUFFER_HEIGHT, + .valid = true, + }; +} + +static bool damage_rect_valid(DamageRect damage) { + return damage.valid && damage.w > 0 && damage.h > 0 && + damage.x < PJ_FRAMEBUFFER_WIDTH && damage.y < PJ_FRAMEBUFFER_HEIGHT && + damage.w <= PJ_FRAMEBUFFER_WIDTH - damage.x && + damage.h <= PJ_FRAMEBUFFER_HEIGHT - damage.y; +} + +static void union_damage(DamageRect *pending, DamageRect damage) { + if (!damage_rect_valid(damage)) return; + if (!damage_rect_valid(*pending)) { + *pending = damage; + return; + } + + uint32_t x0 = pending->x < damage.x ? pending->x : damage.x; + uint32_t y0 = pending->y < damage.y ? pending->y : damage.y; + uint32_t pending_x1 = pending->x + pending->w; + uint32_t pending_y1 = pending->y + pending->h; + uint32_t damage_x1 = damage.x + damage.w; + uint32_t damage_y1 = damage.y + damage.h; + uint32_t x1 = pending_x1 > damage_x1 ? pending_x1 : damage_x1; + uint32_t y1 = pending_y1 > damage_y1 ? pending_y1 : damage_y1; + *pending = (DamageRect) { + .x = x0, + .y = y0, + .w = x1 - x0, + .h = y1 - y0, + .valid = true, + }; +} + +static void mark_native_damage(DamageRect damage) { + union_damage(&pending_native_damage[0], damage); + union_damage(&pending_native_damage[1], damage); +} + +/* Rust's logger calls this symbol when the esp-idf feature is enabled. */ +void pocketjs_esp32p4_log(uint32_t level, const char *message) { + if (message == NULL) return; + switch (level) { + case 1: ESP_LOGE(TAG, "%s", message); break; + case 2: ESP_LOGW(TAG, "%s", message); break; + case 4: ESP_LOGD(TAG, "%s", message); break; + case 5: ESP_LOGV(TAG, "%s", message); break; + default: ESP_LOGI(TAG, "%s", message); break; + } +} + +static TouchSnapshot touch_snapshot(bool down, int32_t panel_x, int32_t panel_y) { + TouchSnapshot next = { + .down = false, + .panel_x = (int16_t)panel_x, + .panel_y = (int16_t)panel_y, + .logical_x = -1, + .logical_y = -1, + .packed = 0, + }; + + if (down) { + int32_t content_x = panel_x - PJ_CONTENT_X; + int32_t content_y = panel_y - PJ_CONTENT_Y; + if (content_x >= 0 && content_x < PJ_FRAMEBUFFER_WIDTH && + content_y >= 0 && content_y < PJ_FRAMEBUFFER_HEIGHT) { + next.down = true; + next.logical_x = (int16_t)(content_x / PJ_RASTER_DENSITY); + next.logical_y = (int16_t)(content_y / PJ_RASTER_DENSITY); + next.packed = ((uint32_t)next.logical_y << 9) | (uint32_t)next.logical_x; + } + } + return next; +} + +static bool touch_changed(const TouchSnapshot *left, const TouchSnapshot *right) { + return left->down != right->down || left->packed != right->packed || + left->panel_x != right->panel_x || left->panel_y != right->panel_y; +} + +static bool poll_touch(TouchSnapshot *out_touch) { + esp_lcd_touch_point_data_t point = {0}; + uint8_t point_count = 0; + ESP_ERROR_CHECK(esp_lcd_touch_read_data(touch_handle)); + ESP_ERROR_CHECK(esp_lcd_touch_get_data(touch_handle, &point, &point_count, 1)); + + TouchSnapshot next; + if (point_count > 0) { + /* bsp_touch_new applies the board's native GT911 mirror. LVGL then used + * to apply the display's 180-degree rotation to input a second time. + * Reproduce that public panel-coordinate contract without LVGL. */ + int32_t rotated_x = PJ_PANEL_WIDTH - (int32_t)point.x - 1; + int32_t rotated_y = PJ_PANEL_HEIGHT - (int32_t)point.y - 1; + next = touch_snapshot(true, rotated_x, rotated_y); + } else { + /* Match the old LVGL event semantics: a release keeps the last physical + * point for diagnostics, while no logical contact is delivered. */ + next = touch_snapshot(false, current_touch.panel_x, current_touch.panel_y); + } + *out_touch = next; + return touch_changed(&next, ¤t_touch); +} + +static bool IRAM_ATTR color_trans_done( + esp_lcd_panel_handle_t panel, + esp_lcd_dpi_panel_event_data_t *event, + void *user_context) { + (void)panel; + (void)event; + (void)user_context; + /* The native-framebuffer draw path invokes this synchronously only after + * cur_fb_index points at the requested back buffer. Arm the next refresh + * here so a refresh racing the driver's cache sync can never release the + * old front buffer early. Missing that race only waits one extra refresh. */ + flip_armed = true; + return false; +} + +static bool IRAM_ATTR refresh_trans_done( + esp_lcd_panel_handle_t panel, + esp_lcd_dpi_panel_event_data_t *event, + void *user_context) { + (void)panel; + (void)event; + if (!flip_armed) return false; + flip_armed = false; + BaseType_t task_woken = pdFALSE; + xSemaphoreGiveFromISR((SemaphoreHandle_t)user_context, &task_woken); + return task_woken == pdTRUE; +} + +static bool IRAM_ATTR framebuffer_copy_trans_done( + esp_async_fbcpy_handle_t copy, + esp_async_fbcpy_event_data_t *event, + void *user_context) { + (void)copy; + (void)event; + BaseType_t task_woken = pdFALSE; + xSemaphoreGiveFromISR((SemaphoreHandle_t)user_context, &task_woken); + return task_woken == pdTRUE; +} + +static void display_init(void) { + ESP_ERROR_CHECK(bsp_display_new_with_handles(NULL, &lcd_handles)); + + /* draw_bitmap only updates the centered PocketJS rectangle. Explicitly + * establish the native framebuffer's black border instead of relying on + * allocator contents or an undocumented panel-driver startup state. */ + void *first_framebuffer = NULL; + void *second_framebuffer = NULL; + ESP_ERROR_CHECK(esp_lcd_dpi_panel_get_frame_buffer( + lcd_handles.panel, 2, &first_framebuffer, &second_framebuffer)); + native_framebuffers[0] = first_framebuffer; + native_framebuffers[1] = second_framebuffer; + for (size_t i = 0; i < 2; i++) { + memset(native_framebuffers[i], 0, PJ_PANEL_FRAMEBUFFER_BYTES); + ESP_ERROR_CHECK(esp_cache_msync( + native_framebuffers[i], + PJ_PANEL_FRAMEBUFFER_BYTES, + ESP_CACHE_MSYNC_FLAG_DIR_C2M | ESP_CACHE_MSYNC_FLAG_INVALIDATE | + ESP_CACHE_MSYNC_FLAG_UNALIGNED)); + pending_native_damage[i] = full_content_damage(); + } + front_framebuffer = 0; + + /* The previous LVGL canvas performed a 180-degree transform in software. + * Configure the equivalent EK79007 MADCTL mirror bits without touching + * PocketJS' compact render target; physical corner tests remain the final + * display/input orientation acceptance. */ + ESP_ERROR_CHECK(esp_lcd_panel_mirror(lcd_handles.panel, true, true)); + + refresh_done = xSemaphoreCreateBinary(); + if (refresh_done == NULL) { + ESP_LOGE(TAG, "could not create DPI refresh-completion semaphore"); + ESP_ERROR_CHECK(ESP_ERR_NO_MEM); + } + const esp_lcd_dpi_panel_event_callbacks_t callbacks = { + .on_color_trans_done = color_trans_done, + .on_refresh_done = refresh_trans_done, + }; + ESP_ERROR_CHECK(esp_lcd_dpi_panel_register_event_callbacks( + lcd_handles.panel, &callbacks, refresh_done)); + + framebuffer_copy_done = xSemaphoreCreateBinary(); + if (framebuffer_copy_done == NULL) { + ESP_LOGE(TAG, "could not create framebuffer-copy semaphore"); + ESP_ERROR_CHECK(ESP_ERR_NO_MEM); + } + const esp_async_fbcpy_config_t copy_config = {}; + ESP_ERROR_CHECK(esp_async_fbcpy_install(©_config, &framebuffer_copy)); + + ESP_ERROR_CHECK(bsp_touch_new(NULL, &touch_handle)); + /* Preserve the BSP's native GT911 transform explicitly. poll_touch then + * applies the second 180-degree mapping that LVGL previously contributed + * before PocketJS' content offset and density are evaluated. */ + ESP_ERROR_CHECK(esp_lcd_touch_set_swap_xy(touch_handle, false)); + ESP_ERROR_CHECK(esp_lcd_touch_set_mirror_x(touch_handle, true)); + ESP_ERROR_CHECK(esp_lcd_touch_set_mirror_y(touch_handle, true)); + + /* EK79007 starts the DPI video stream from panel_init and intentionally + * leaves the generic disp_on_off callback unset in component v1.0.4. */ + ESP_ERROR_CHECK(bsp_display_backlight_on()); +} + +/* ---- UART device receipt protocol ------------------------------------- */ +static char serial_line[64]; +static uint8_t serial_length; + +static void serial_init(void) { + const uart_config_t config = { + .baud_rate = 115200, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .source_clk = UART_SCLK_DEFAULT, + }; + ESP_ERROR_CHECK(uart_param_config(UART_NUM_0, &config)); + ESP_ERROR_CHECK(uart_set_pin( + UART_NUM_0, + UART_PIN_NO_CHANGE, + UART_PIN_NO_CHANGE, + UART_PIN_NO_CHANGE, + UART_PIN_NO_CHANGE)); + ESP_ERROR_CHECK(uart_driver_install(UART_NUM_0, 1024, 0, 0, NULL, 0)); + uart_vfs_dev_use_driver(UART_NUM_0); +} + +static void receipt_ready(void) { + printf( + "PJREADY board=%s chip=%s host=%s abi=%" PRIu32 + " app=%s build=%s quickjs=1 logical=%dx%d framebuffer=%dx%d" + " panel=%dx%d content=%d,%d,%d,%d fps=%d ppa=%" PRIu32 + " present=rgb565-shadow-dma2d-damage-native-double-buffer rotation=mirror-xy\n", + PJ_BOARD_ID, + CONFIG_IDF_TARGET, + pocketjs_runtime_host_id(), + pocketjs_runtime_host_abi(), + POCKETJS_APP_TITLE, + POCKETJS_BUILD_ID, + PJ_LOGICAL_WIDTH, + PJ_LOGICAL_HEIGHT, + PJ_FRAMEBUFFER_WIDTH, + PJ_FRAMEBUFFER_HEIGHT, + PJ_PANEL_WIDTH, + PJ_PANEL_HEIGHT, + PJ_CONTENT_X, + PJ_CONTENT_Y, + PJ_FRAMEBUFFER_WIDTH, + PJ_FRAMEBUFFER_HEIGHT, + PJ_FRAME_RATE, + last_stats.ppa_active); +} + +static void receipt_frame(void) { + last_screen_hash = pocketjs_runtime_framebuffer_hash(framebuffer, PJ_FRAMEBUFFER_PIXELS); + printf( + "PJFRAME frame=%" PRIu32 " draw=%016" PRIx64 " screen=%016" PRIx64 + " ppa_fill=%" PRIu32 " ppa_blend=%" PRIu32 " ppa_srm=%" PRIu32 + " software=%" PRIu32 " damage_regions=%" PRIu32 " damage_pixels=%" PRIu32 + " damage_bounds=%" PRIu32 ",%" PRIu32 ",%" PRIu32 ",%" PRIu32 + " full=%" PRIu32 " present_full=%d copied_pixels=%" PRIu32 + " front_fb=%u buttons=0x%08" PRIx32 " touch=%d\n", + last_stats.frame, + last_stats.draw_hash, + last_screen_hash, + last_stats.ppa_fills, + last_stats.ppa_blends, + last_stats.ppa_srm, + last_stats.software_ops, + last_stats.damage_regions, + last_stats.damage_pixels, + last_stats.damage_x, + last_stats.damage_y, + last_stats.damage_w, + last_stats.damage_h, + last_stats.full_redraw, + last_timing.full_present ? 1 : 0, + last_timing.copied_pixels, + (unsigned)front_framebuffer, + last_buttons, + current_touch.down ? 1 : 0); + printf( + "PJPROFILE frame=%" PRIu32 " runtime_us=%" PRIu32 + " ui_update_us=%" PRIu32 " hit_test_us=%" PRIu32 + " guest_frame_us=%" PRIu32 " guest_prepare_us=%" PRIu32 + " guest_call_us=%" PRIu32 " guest_jobs_us=%" PRIu32 + " guest_jobs_run=%" PRIu32 " core_tick_us=%" PRIu32 + " host_create_calls=%" PRIu32 " host_create_us=%" PRIu32 + " host_insert_calls=%" PRIu32 " host_insert_us=%" PRIu32 + " host_style_calls=%" PRIu32 " host_style_us=%" PRIu32 + " host_prop_calls=%" PRIu32 " host_prop_us=%" PRIu32 + " host_text_calls=%" PRIu32 " host_text_us=%" PRIu32 + " host_animate_calls=%" PRIu32 " host_animate_us=%" PRIu32 + " host_other_calls=%" PRIu32 " host_other_us=%" PRIu32 + " draw_list_us=%" PRIu32 + " render_us=%" PRIu32 " damage_clear_us=%" PRIu32 + " mask_build_us=%" PRIu32 " software_us=%" PRIu32 + " ppa_fill_us=%" PRIu32 " ppa_blend_us=%" PRIu32 + " ppa_srm_us=%" PRIu32 "\n", + last_stats.frame, + last_timing.runtime_us, + last_stats.ui_update_us, + last_stats.hit_test_us, + last_stats.guest_frame_us, + last_stats.guest_prepare_us, + last_stats.guest_call_us, + last_stats.guest_jobs_us, + last_stats.guest_jobs_run, + last_stats.core_tick_us, + last_stats.host_create_calls, + last_stats.host_create_us, + last_stats.host_insert_calls, + last_stats.host_insert_us, + last_stats.host_style_calls, + last_stats.host_style_us, + last_stats.host_prop_calls, + last_stats.host_prop_us, + last_stats.host_text_calls, + last_stats.host_text_us, + last_stats.host_animate_calls, + last_stats.host_animate_us, + last_stats.host_other_calls, + last_stats.host_other_us, + last_stats.draw_list_us, + last_stats.render_us, + last_stats.damage_clear_us, + last_stats.mask_build_us, + last_stats.software_us, + last_stats.ppa_fill_us, + last_stats.ppa_blend_us, + last_stats.ppa_srm_us); +} + +static uint32_t maximum_u32(uint32_t left, uint32_t right) { + return left > right ? left : right; +} + +static uint32_t elapsed_us(int64_t start, int64_t end) { + uint64_t elapsed = end > start ? (uint64_t)(end - start) : 0; + return elapsed > UINT32_MAX ? UINT32_MAX : (uint32_t)elapsed; +} + +static void record_timing(TimingWindow *window, const FrameTiming *timing) { + window->frames++; + window->runtime_sum_us += timing->runtime_us; + window->total_sum_us += timing->total_us; + window->runtime_max_us = maximum_u32(window->runtime_max_us, timing->runtime_us); + window->total_max_us = maximum_u32(window->total_max_us, timing->total_us); + if (timing->presented) { + window->presents++; + window->present_copy_sum_us += timing->present_copy_us; + window->present_submit_sum_us += timing->present_submit_us; + window->present_wait_sum_us += timing->present_wait_us; + window->present_copy_max_us = + maximum_u32(window->present_copy_max_us, timing->present_copy_us); + window->present_submit_max_us = + maximum_u32(window->present_submit_max_us, timing->present_submit_us); + window->present_wait_max_us = + maximum_u32(window->present_wait_max_us, timing->present_wait_us); + } +} + +static uint64_t timing_average(uint64_t sum, uint32_t count) { + return count == 0 ? 0 : sum / count; +} + +static void receipt_performance(bool reset_window) { + printf( + "PJPERF frame=%" PRIu32 + " runtime_us=%" PRIu32 " present_copy_us=%" PRIu32 + " present_submit_us=%" PRIu32 + " present_wait_us=%" PRIu32 " total_us=%" PRIu32 + " window_frames=%" PRIu32 " window_presents=%" PRIu32 + " runtime_avg_us=%" PRIu64 " runtime_max_us=%" PRIu32 + " present_copy_avg_us=%" PRIu64 " present_copy_max_us=%" PRIu32 + " present_submit_avg_us=%" PRIu64 " present_submit_max_us=%" PRIu32 + " present_wait_avg_us=%" PRIu64 " present_wait_max_us=%" PRIu32 + " total_avg_us=%" PRIu64 " total_max_us=%" PRIu32 "\n", + last_stats.frame, + last_timing.runtime_us, + last_timing.present_copy_us, + last_timing.present_submit_us, + last_timing.present_wait_us, + last_timing.total_us, + timing_window.frames, + timing_window.presents, + timing_average(timing_window.runtime_sum_us, timing_window.frames), + timing_window.runtime_max_us, + timing_average(timing_window.present_copy_sum_us, timing_window.presents), + timing_window.present_copy_max_us, + timing_average(timing_window.present_submit_sum_us, timing_window.presents), + timing_window.present_submit_max_us, + timing_average(timing_window.present_wait_sum_us, timing_window.presents), + timing_window.present_wait_max_us, + timing_average(timing_window.total_sum_us, timing_window.frames), + timing_window.total_max_us); + if (reset_window) memset(&timing_window, 0, sizeof(timing_window)); +} + +static int compare_u32(const void *left, const void *right) { + uint32_t lhs = *(const uint32_t *)left; + uint32_t rhs = *(const uint32_t *)right; + return lhs > rhs ? 1 : lhs < rhs ? -1 : 0; +} + +typedef struct { + uint64_t average; + uint32_t p95; + uint32_t maximum; +} MetricSummary; + +static MetricSummary summarize_samples(uint32_t *samples, uint32_t count) { + MetricSummary summary = {0}; + if (count == 0) return summary; + + uint64_t sum = 0; + for (uint32_t i = 0; i < count; i++) sum += samples[i]; + qsort(samples, count, sizeof(samples[0]), compare_u32); + uint32_t p95_index = ((count * 95 + 99) / 100) - 1; + summary.average = sum / count; + summary.p95 = samples[p95_index]; + summary.maximum = samples[count - 1]; + return summary; +} + +static uint64_t benchmark_deadline_offset_us(uint32_t completed_frames) { + return ((uint64_t)completed_frames * 1000000ULL + PJ_FRAME_RATE - 1) / + PJ_FRAME_RATE; +} + +static const char *benchmark_mode_name(BenchmarkMode mode) { + switch (mode) { + case PJ_BENCHMARK_FORCED_FULL_RASTER: return "forced-full-raster"; + case PJ_BENCHMARK_FULL_PRESENT: return "full-present"; + default: return "none"; + } +} + +static void receipt_benchmark(void) { + MetricSummary runtime_summary = summarize_samples( + benchmark.runtime_samples, benchmark.completed_frames); + MetricSummary present_summary = summarize_samples( + benchmark.present_samples, benchmark.completed_frames); + MetricSummary total_summary = summarize_samples( + benchmark.total_samples, benchmark.completed_frames); + uint64_t elapsed = benchmark.started_us == 0 || benchmark.completed_us == 0 + ? 0 + : (uint64_t)(benchmark.completed_us - benchmark.started_us); + uint64_t wall_budget = + benchmark_deadline_offset_us(benchmark.completed_frames); + bool presentation_coverage_complete = + benchmark.present_full_frames == benchmark.completed_frames && + benchmark.copied_pixels == + (uint64_t)benchmark.completed_frames * PJ_FRAMEBUFFER_PIXELS; + bool coverage_complete = presentation_coverage_complete && + (benchmark.mode != PJ_BENCHMARK_FORCED_FULL_RASTER || + benchmark.raster_full_frames == benchmark.completed_frames); + bool sustained = elapsed <= wall_budget && benchmark.deadline_misses == 0; + bool p95_pass = coverage_complete && sustained && + total_summary.p95 <= PJ_FRAME_BUDGET_US; + bool max_pass = coverage_complete && sustained && + total_summary.maximum <= PJ_FRAME_BUDGET_US; + uint64_t effective_fps_milli = elapsed == 0 + ? 0 + : (uint64_t)benchmark.completed_frames * 1000000000ULL / elapsed; + + printf( + "PJBENCH mode=%s frames=%" PRIu32 + " raster_full_frames=%" PRIu32 " present_full_frames=%" PRIu32 + " copied_pixels=%" PRIu64 " wall_us=%" PRIu64 + " effective_fps_milli=%" PRIu64 " deadline_misses=%" PRIu32 + " runtime_avg_us=%" PRIu64 " runtime_p95_us=%" PRIu32 + " runtime_max_us=%" PRIu32 + " present_avg_us=%" PRIu64 " present_p95_us=%" PRIu32 + " present_max_us=%" PRIu32 + " total_avg_us=%" PRIu64 " total_p95_us=%" PRIu32 + " total_max_us=%" PRIu32 " budget_us=%d p95_pass=%d max_pass=%d\n", + benchmark_mode_name(benchmark.mode), + benchmark.completed_frames, + benchmark.raster_full_frames, + benchmark.present_full_frames, + benchmark.copied_pixels, + elapsed, + effective_fps_milli, + benchmark.deadline_misses, + runtime_summary.average, + runtime_summary.p95, + runtime_summary.maximum, + present_summary.average, + present_summary.p95, + present_summary.maximum, + total_summary.average, + total_summary.p95, + total_summary.maximum, + PJ_FRAME_BUDGET_US, + p95_pass ? 1 : 0, + max_pass ? 1 : 0); +} + +static void record_benchmark_frame(void) { + uint32_t index = benchmark.completed_frames; + if (!benchmark.active || index >= benchmark.requested_frames || + index >= PJ_BENCHMARK_MAX_FRAMES) { + return; + } + + benchmark.runtime_samples[index] = last_timing.runtime_us; + benchmark.present_samples[index] = + last_timing.present_copy_us + last_timing.present_submit_us + + last_timing.present_wait_us; + benchmark.total_samples[index] = last_timing.total_us; + benchmark.completed_frames++; + if (last_stats.full_redraw) benchmark.raster_full_frames++; + if (last_timing.full_present) benchmark.present_full_frames++; + benchmark.copied_pixels += last_timing.copied_pixels; + + int64_t completed_us = esp_timer_get_time(); + int64_t deadline_us = benchmark.started_us + + (int64_t)benchmark_deadline_offset_us(benchmark.completed_frames); + if (completed_us > deadline_us) benchmark.deadline_misses++; + + if (benchmark.completed_frames == benchmark.requested_frames) { + benchmark.active = false; + /* Freeze wall time before sorting samples or emitting the receipt. */ + benchmark.completed_us = completed_us; + receipt_benchmark(); + /* Do not mix forced full-redraw measurements into the normal periodic + * performance window reported after the benchmark. */ + memset(&timing_window, 0, sizeof(timing_window)); + } +} + +static void receipt_touch(const TouchSnapshot *touch) { + printf( + "PJTOUCH source=gt911 down=%d panel=%d,%d logical=%d,%d packed=%08" PRIx32 "\n", + touch->down ? 1 : 0, + touch->panel_x, + touch->panel_y, + touch->logical_x, + touch->logical_y, + touch->packed); +} + +static void receipt_error(const char *stage) { + char error[256] = {0}; + (void)pocketjs_runtime_last_error(error, sizeof(error)); + printf("PJERROR stage=%s message=%s\n", stage, error[0] == '\0' ? "unknown" : error); +} + +static bool parse_button_mask(const char *line, uint32_t *mask) { + const char *cursor = line + 1; + char *end = NULL; + unsigned long value; + while (*cursor == ' ' || *cursor == '\t') cursor++; + if (*cursor == '\0' || *cursor == '-') return false; + errno = 0; + value = strtoul(cursor, &end, 0); + if (errno != 0 || end == cursor || value > UINT32_MAX) return false; + while (*end == ' ' || *end == '\t') end++; + if (*end != '\0') return false; + *mask = (uint32_t)value; + return true; +} + +static bool parse_benchmark_frames(const char *line, uint32_t *frames) { + uint32_t parsed = 0; + if (!parse_button_mask(line, &parsed) || parsed == 0 || + parsed > PJ_BENCHMARK_MAX_FRAMES) { + return false; + } + *frames = parsed; + return true; +} + +static void start_benchmark(BenchmarkMode mode, uint32_t frames) { + memset(&benchmark, 0, sizeof(benchmark)); + /* Start the benchmark with a clean periodic timing window too: its forced + * full redraws are a separate measurement mode. */ + memset(&timing_window, 0, sizeof(timing_window)); + benchmark.active = true; + benchmark.mode = mode; + benchmark.requested_frames = frames; + printf( + "PJACK benchmark_mode=%s benchmark_frames=%" PRIu32 "\n", + benchmark_mode_name(mode), + frames); +} + +static void handle_serial_line(void) { + uint32_t mask; + uint32_t benchmark_frames; + if (serial_length == 0) return; + serial_line[serial_length] = '\0'; + if (strcmp(serial_line, "H") == 0) { + if (runtime_ready) receipt_ready(); + else printf("PJSTATUS ready=0\n"); + } else if (strcmp(serial_line, "D") == 0) { + if (benchmark.active) { + printf( + "PJSTATUS benchmark=1 mode=%s completed=%" PRIu32 + " requested=%" PRIu32 "\n", + benchmark_mode_name(benchmark.mode), + benchmark.completed_frames, + benchmark.requested_frames); + } else { + receipt_frame(); + receipt_performance(false); + } + } else if (serial_line[0] == 'P' && parse_button_mask(serial_line, &mask)) { + injected_buttons = mask; + injected_frames = 1; + printf("PJACK buttons=0x%08" PRIx32 " frames=1\n", mask); + } else if (serial_line[0] == 'B' && + parse_benchmark_frames(serial_line, &benchmark_frames)) { + start_benchmark(PJ_BENCHMARK_FORCED_FULL_RASTER, benchmark_frames); + } else if (serial_line[0] == 'V' && + parse_benchmark_frames(serial_line, &benchmark_frames)) { + start_benchmark(PJ_BENCHMARK_FULL_PRESENT, benchmark_frames); + } else { + printf("PJERR command=%s\n", serial_line); + } +} + +static void serial_poll(void) { + uint8_t byte; + int count; + while ((count = uart_read_bytes(UART_NUM_0, &byte, 1, 0)) == 1) { + if (byte == '\r') continue; + if (byte == '\n') { + handle_serial_line(); + serial_length = 0; + } else if (serial_length + 1 < sizeof(serial_line)) { + serial_line[serial_length++] = (char)byte; + } else { + serial_length = 0; + printf("PJERR line-too-long\n"); + } + } + if (count < 0) ESP_LOGW(TAG, "UART read failed: %d", count); +} + +static bool present_shadow(FrameTiming *timing, bool force_full_present) { + const uint8_t back_framebuffer = front_framebuffer ^ 1U; + uint16_t *back = native_framebuffers[back_framebuffer]; + DamageRect damage = force_full_present + ? full_content_damage() + : pending_native_damage[back_framebuffer]; + if (!damage_rect_valid(damage)) { + ESP_LOGE(TAG, "native back buffer has no valid pending damage"); + return false; + } + if (xSemaphoreTake(refresh_done, 0) == pdTRUE || flip_armed) { + ESP_LOGE(TAG, "unexpected stale DPI flip-completion state"); + return false; + } + + int64_t copy_start = esp_timer_get_time(); + if (xSemaphoreTake(framebuffer_copy_done, 0) == pdTRUE) { + ESP_LOGE(TAG, "unexpected stale framebuffer-copy completion signal"); + return false; + } + /* The compact shadow has a 960-pixel stride. Sync complete affected rows: + * row starts and lengths stay cache-line aligned while DMA2D still copies + * only the tight x-range. */ + uint16_t *source_rows = + framebuffer + (size_t)damage.y * PJ_FRAMEBUFFER_WIDTH; + size_t source_rows_bytes = + (size_t)damage.h * PJ_FRAMEBUFFER_WIDTH * sizeof(uint16_t); + esp_err_t source_sync = esp_cache_msync( + source_rows, + source_rows_bytes, + ESP_CACHE_MSYNC_FLAG_DIR_C2M | ESP_CACHE_MSYNC_FLAG_UNALIGNED); + if (source_sync != ESP_OK) { + ESP_LOGE(TAG, "RGB565 shadow cache sync failed: %s", esp_err_to_name(source_sync)); + return false; + } + esp_async_fbcpy_trans_desc_t transaction = { + .src_buffer = framebuffer, + .dst_buffer = back, + .src_buffer_size_x = PJ_FRAMEBUFFER_WIDTH, + .src_buffer_size_y = PJ_FRAMEBUFFER_HEIGHT, + .dst_buffer_size_x = PJ_PANEL_WIDTH, + .dst_buffer_size_y = PJ_PANEL_HEIGHT, + .src_offset_x = damage.x, + .src_offset_y = damage.y, + .dst_offset_x = PJ_CONTENT_X + damage.x, + .dst_offset_y = PJ_CONTENT_Y + damage.y, + .copy_size_x = damage.w, + .copy_size_y = damage.h, + .pixel_format_unique_id = { + .color_type_id = LCD_COLOR_FMT_RGB565, + }, + }; + esp_err_t copy = esp_async_fbcpy( + framebuffer_copy, + &transaction, + framebuffer_copy_trans_done, + framebuffer_copy_done); + if (copy != ESP_OK) { + ESP_LOGE(TAG, "DMA2D RGB565 presentation copy failed: %s", esp_err_to_name(copy)); + return false; + } + if (xSemaphoreTake(framebuffer_copy_done, portMAX_DELAY) != pdTRUE) { + ESP_LOGE(TAG, "framebuffer-copy wait failed"); + return false; + } + timing->present_copy_us = elapsed_us(copy_start, esp_timer_get_time()); + + int64_t submit_start = esp_timer_get_time(); + esp_err_t draw = esp_lcd_panel_draw_bitmap( + lcd_handles.panel, + PJ_CONTENT_X + damage.x, + PJ_CONTENT_Y + damage.y, + PJ_CONTENT_X + damage.x + damage.w, + PJ_CONTENT_Y + damage.y + damage.h, + back); + timing->present_submit_us = elapsed_us(submit_start, esp_timer_get_time()); + if (draw != ESP_OK) { + flip_armed = false; + ESP_LOGE(TAG, "native DPI flip submission failed: %s", esp_err_to_name(draw)); + return false; + } + + /* on_color_trans_done arms the flip synchronously inside draw_bitmap, but a + * refresh ISR may complete it before draw_bitmap returns. Accept either the + * still-armed state or its already-delivered completion token. Read the arm + * first: if the ISR races after that read, the blocking take below consumes + * the token it produces. */ + int64_t wait_start = esp_timer_get_time(); + bool refresh_pending = flip_armed; + bool refresh_completed = false; + if (!refresh_pending) { + refresh_completed = xSemaphoreTake(refresh_done, 0) == pdTRUE; + } + if (!refresh_pending && !refresh_completed) { + ESP_LOGE(TAG, "DPI driver did not acknowledge native framebuffer submission"); + return false; + } + + if (!refresh_completed && + xSemaphoreTake(refresh_done, portMAX_DELAY) != pdTRUE) { + ESP_LOGE(TAG, "DPI refresh-completion wait failed"); + return false; + } + timing->present_wait_us = elapsed_us(wait_start, esp_timer_get_time()); + front_framebuffer = back_framebuffer; + pending_native_damage[back_framebuffer] = (DamageRect) {0}; + timing->presented = true; + timing->full_present = damage.x == 0 && damage.y == 0 && + damage.w == PJ_FRAMEBUFFER_WIDTH && damage.h == PJ_FRAMEBUFFER_HEIGHT; + timing->copied_pixels = damage.w * damage.h; + return true; +} + +static bool render_and_present( + uint32_t buttons, + bool force_full_redraw, + bool force_full_present, + int64_t frame_start) { + const uint32_t *touches = current_touch.down ? ¤t_touch.packed : NULL; + size_t touch_count = current_touch.down ? 1 : 0; + if (force_full_redraw) pocketjs_runtime_invalidate_target(runtime); + + int64_t runtime_start = esp_timer_get_time(); + int ok = pocketjs_runtime_frame( + runtime, + buttons, + touches, + touch_count, + framebuffer, + PJ_FRAMEBUFFER_PIXELS, + &last_stats); + int64_t runtime_end = esp_timer_get_time(); + + FrameTiming timing = { + .runtime_us = elapsed_us(runtime_start, runtime_end), + }; + if (ok && (last_stats.full_redraw || last_stats.damage_regions > 0)) { + DamageRect damage = last_stats.full_redraw + ? full_content_damage() + : (DamageRect) { + .x = last_stats.damage_x, + .y = last_stats.damage_y, + .w = last_stats.damage_w, + .h = last_stats.damage_h, + .valid = true, + }; + if (!damage_rect_valid(damage)) { + ESP_LOGE( + TAG, + "runtime returned invalid damage bounds: %" PRIu32 ",%" PRIu32 + ",%" PRIu32 ",%" PRIu32, + damage.x, + damage.y, + damage.w, + damage.h); + ok = 0; + } else { + mark_native_damage(damage); + } + } + bool back_buffer_needs_catch_up = + damage_rect_valid(pending_native_damage[front_framebuffer ^ 1U]); + if (ok && (force_full_present || last_stats.full_redraw || + last_stats.damage_regions > 0 || back_buffer_needs_catch_up)) { + ok = present_shadow(&timing, force_full_present); + } + timing.total_us = elapsed_us(frame_start, esp_timer_get_time()); + last_timing = timing; + record_timing(&timing_window, &timing); + return ok != 0; +} + +void app_main(void) { + TickType_t last_wake; + uint32_t frame_phase = 0; + const size_t java_script_len = (size_t)(app_js_end - app_js_start); + const size_t pak_len = (size_t)(app_pak_end - app_pak_start); + + setvbuf(stdout, NULL, _IONBF, 0); + serial_init(); + + framebuffer = heap_caps_aligned_alloc( + PJ_CACHE_ALIGNMENT, + PJ_FRAMEBUFFER_BYTES, + MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA); + if (framebuffer == NULL) { + ESP_LOGE(TAG, "could not allocate %u-byte RGB565 framebuffer in PSRAM", + (unsigned)PJ_FRAMEBUFFER_BYTES); + ESP_ERROR_CHECK(ESP_ERR_NO_MEM); + } + memset(framebuffer, 0, PJ_FRAMEBUFFER_BYTES); + + display_init(); + if (pocketjs_runtime_framebuffer_width() != PJ_FRAMEBUFFER_WIDTH || + pocketjs_runtime_framebuffer_height() != PJ_FRAMEBUFFER_HEIGHT) { + ESP_LOGE(TAG, "Rust runtime and board framebuffer contracts disagree"); + ESP_ERROR_CHECK(ESP_ERR_INVALID_SIZE); + } + + runtime = pocketjs_runtime_create(app_js_start, java_script_len, app_pak_start, pak_len); + if (runtime == NULL) { + receipt_error("boot"); + ESP_ERROR_CHECK(ESP_FAIL); + } + + last_wake = xTaskGetTickCount(); + for (;;) { + int64_t frame_start = esp_timer_get_time(); + TouchSnapshot touch; + if (poll_touch(&touch)) { + current_touch = touch; + receipt_touch(&touch); + } + serial_poll(); + + bool injected_frame = injected_frames > 0; + last_buttons = injected_frame ? injected_buttons : 0; + bool benchmark_frame = benchmark.active; + if (benchmark_frame && benchmark.completed_frames == 0) { + benchmark.started_us = frame_start; + } + bool force_full_redraw = + benchmark_frame && benchmark.mode == PJ_BENCHMARK_FORCED_FULL_RASTER; + bool force_full_present = + benchmark_frame && benchmark.mode == PJ_BENCHMARK_FULL_PRESENT; + if (!render_and_present( + last_buttons, + force_full_redraw, + force_full_present, + frame_start)) { + receipt_error("frame"); + ESP_ERROR_CHECK(ESP_FAIL); + } + if (injected_frames > 0) injected_frames--; + if (benchmark_frame) record_benchmark_frame(); + + if (!runtime_ready) { + runtime_ready = true; + receipt_ready(); + if (!benchmark_frame) { + receipt_frame(); + receipt_performance(false); + } + } else if (injected_frame && !benchmark_frame) { + /* A serial injection is a diagnostic operation. Emit the exact frame + * that consumed it instead of making the caller race the next D command. */ + receipt_frame(); + receipt_performance(false); + } else if (!benchmark_frame && last_stats.frame % PJ_RECEIPT_PERIOD == 0) { + receipt_frame(); + receipt_performance(true); + } + + /* Exact 60 Hz average at a 1 kHz FreeRTOS tick without drift. */ + frame_phase += configTICK_RATE_HZ; + TickType_t frame_ticks = frame_phase / PJ_FRAME_RATE; + frame_phase %= PJ_FRAME_RATE; + xTaskDelayUntil(&last_wake, frame_ticks); + } +} diff --git a/hosts/esp32p4/waveshare-7b/main/pocketjs_runtime.h b/hosts/esp32p4/waveshare-7b/main/pocketjs_runtime.h new file mode 100644 index 00000000..5177c31b --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/main/pocketjs_runtime.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct PocketRuntime PocketRuntime; + +typedef struct { + uint32_t frame; + uint64_t draw_hash; + uint32_t ppa_fills; + uint32_t ppa_blends; + uint32_t ppa_srm; + uint32_t software_ops; + uint32_t damage_regions; + uint32_t damage_pixels; + uint32_t full_redraw; + uint32_t ppa_active; + uint32_t ui_update_us; + uint32_t hit_test_us; + uint32_t guest_frame_us; + uint32_t core_tick_us; + uint32_t draw_list_us; + uint32_t render_us; + uint32_t damage_clear_us; + uint32_t mask_build_us; + uint32_t software_us; + uint32_t ppa_fill_us; + uint32_t ppa_blend_us; + uint32_t ppa_srm_us; + uint32_t guest_prepare_us; + uint32_t guest_call_us; + uint32_t guest_jobs_us; + uint32_t guest_jobs_run; + uint32_t host_create_calls; + uint32_t host_create_us; + uint32_t host_insert_calls; + uint32_t host_insert_us; + uint32_t host_style_calls; + uint32_t host_style_us; + uint32_t host_prop_calls; + uint32_t host_prop_us; + uint32_t host_text_calls; + uint32_t host_text_us; + uint32_t host_animate_calls; + uint32_t host_animate_us; + uint32_t host_other_calls; + uint32_t host_other_us; + uint32_t damage_x; + uint32_t damage_y; + uint32_t damage_w; + uint32_t damage_h; +} PocketJsFrameStats; + +_Static_assert(offsetof(PocketJsFrameStats, draw_hash) == 8, + "PocketJsFrameStats must match Rust repr(C) alignment"); +_Static_assert(offsetof(PocketJsFrameStats, damage_x) == 168, + "PocketJsFrameStats damage ABI must remain append-only"); +_Static_assert(offsetof(PocketJsFrameStats, damage_h) == 180, + "PocketJsFrameStats damage ABI must remain contiguous"); +_Static_assert(sizeof(PocketJsFrameStats) == 184, + "PocketJsFrameStats must match the Rust C ABI"); + +PocketRuntime *pocketjs_runtime_create( + const uint8_t *java_script, + size_t java_script_len, + const uint8_t *pak, + size_t pak_len +); +void pocketjs_runtime_destroy(PocketRuntime *runtime); +int pocketjs_runtime_frame( + PocketRuntime *runtime, + uint32_t buttons, + const uint32_t *touches, + size_t touch_count, + uint16_t *framebuffer, + size_t framebuffer_pixels, + PocketJsFrameStats *out_stats +); +void pocketjs_runtime_invalidate_target(PocketRuntime *runtime); +size_t pocketjs_runtime_last_error(char *buffer, size_t capacity); +uint64_t pocketjs_runtime_framebuffer_hash( + const uint16_t *framebuffer, + size_t framebuffer_pixels +); +const char *pocketjs_runtime_host_id(void); +uint32_t pocketjs_runtime_host_abi(void); +uint32_t pocketjs_runtime_framebuffer_width(void); +uint32_t pocketjs_runtime_framebuffer_height(void); + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp32p4/waveshare-7b/partitions.csv b/hosts/esp32p4/waveshare-7b/partitions.csv new file mode 100644 index 00000000..be5a5b35 --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0xf00000, diff --git a/hosts/esp32p4/waveshare-7b/sdkconfig.defaults b/hosts/esp32p4/waveshare-7b/sdkconfig.defaults new file mode 100644 index 00000000..3ad9fc22 --- /dev/null +++ b/hosts/esp32p4/waveshare-7b/sdkconfig.defaults @@ -0,0 +1,47 @@ +# ESP32-P4 v1.x-safe defaults for Waveshare ESP32-P4-WIFI6-Touch-LCD-7B. +# The connected part is revision 1.3, so retain the vendor's pre-v3 choice. +CONFIG_IDF_TARGET="esp32p4" +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_1=y + +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHSIZE_32MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="32MB" +CONFIG_SPI_FLASH_SUPPORT_GD_CHIP=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" + +CONFIG_SPIRAM=y +CONFIG_SPIRAM_SPEED_200M=y +CONFIG_SPIRAM_XIP_FROM_PSRAM=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 +CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM=y +CONFIG_CACHE_L2_CACHE_256KB=y +CONFIG_CACHE_L2_CACHE_LINE_128B=y + +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_ESP_MAIN_TASK_STACK_SIZE=49152 +CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=8192 +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESPTOOLPY_MONITOR_BAUD=115200 +CONFIG_FREERTOS_HZ=1000 + +CONFIG_BSP_LCD_DPI_BUFFER_NUMS=2 +CONFIG_BSP_LCD_COLOR_FORMAT_RGB565=y +# CONFIG_BSP_DISPLAY_LVGL_AVOID_TEAR is not set + +CONFIG_LV_OS_FREERTOS=y +CONFIG_LV_USE_CLIB_MALLOC=y +CONFIG_LV_USE_CLIB_STRING=y +CONFIG_LV_USE_CLIB_SPRINTF=y +CONFIG_LV_DEF_REFR_PERIOD=15 +CONFIG_LV_OBJ_STYLE_CACHE=y +CONFIG_LV_DRAW_SW_DRAW_UNIT_CNT=2 +CONFIG_LV_ATTRIBUTE_FAST_MEM_USE_IRAM=y +CONFIG_LV_USE_CANVAS=y +# CONFIG_LV_BUILD_EXAMPLES is not set + +CONFIG_IDF_EXPERIMENTAL_FEATURES=y diff --git a/hosts/pocketbook/src/main.rs b/hosts/pocketbook/src/main.rs index 2936f08e..28c28ca0 100644 --- a/hosts/pocketbook/src/main.rs +++ b/hosts/pocketbook/src/main.rs @@ -210,7 +210,9 @@ fn tick( full: bool, ) -> Result<()> { let (buttons, analog, touches) = input.snapshot(); - guest.frame_with_touches(buttons, analog, &touches)?; + let mut hits = [0i32; 8]; + let hit_count = surface.with_ui(|ui| ui.touch_hits(&touches, &mut hits)); + guest.frame_with_touch_hits(buttons, analog, &touches, &hits[..hit_count])?; surface.tick(); diff --git a/package.json b/package.json index 2cd16b45..c2f4e300 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,8 @@ "psp:switch": "bun psplink", "vita": "bun tools/vita.ts", "symbian": "bun tools/symbian.ts", + "esp32p4:bundle": "bun tools/esp32p4.ts", + "esp32p4:device": "bun tools/esp32p4-device.ts", "vita:art": "bun tools/generate-vita-livearea.ts", "vita:art:check": "bun tools/generate-vita-livearea.ts --check", "e2e:vita": "bun tests/e2e/vita3k.ts", @@ -143,7 +145,7 @@ "e2e:launcher": "bun tests/e2e/launcher-ppsspp.ts", "e2e:launcher:vita": "bun tests/e2e/launcher-vita3k.ts", "pocket:pack": "bun tools/pocket-pack.ts", - "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/gesture.test.ts tests/kinetics.test.ts tests/osk-controller.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-data.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/virtual-list.test.ts tests/touch-activation.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts hero-main --framework=octane >/dev/null && bun test --conditions=browser tests/octane-smoke.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", + "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/esp32p4-profile.test.ts tests/esp32p4-device.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/gesture.test.ts tests/kinetics.test.ts tests/osk-controller.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-data.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/virtual-list.test.ts tests/touch-activation.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts hero-main --framework=octane >/dev/null && bun test --conditions=browser tests/octane-smoke.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", "tape": "bun tools/tape.ts", "tape:check": "bun tools/tape.ts replay hero-main tests/tapes/hero-main.tape.json --assert tests/tapes/hero-main.hashes.json", "devtools": "bun tools/devtools.ts", @@ -154,11 +156,14 @@ "vapor:gb": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb", "vapor:nes": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes", "vapor:esp32": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target esp32", + "vapor:esp32p4": "bun vapor/scripts/esp32.ts build --board waveshare-esp32-p4-wifi6-touch-lcd-7b", "vapor:playdate": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx --target playdate --playdate-mode simulator", "vapor:playdate:device": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx --target playdate --playdate-mode device", "vapor:playdate:both": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx --target playdate --playdate-mode both", "vapor:esp32:flash": "bun vapor/scripts/esp32.ts flash", "vapor:esp32:verify": "bun vapor/scripts/esp32.ts verify", + "vapor:esp32p4:flash": "bun vapor/scripts/esp32.ts flash --board waveshare-esp32-p4-wifi6-touch-lcd-7b", + "vapor:esp32p4:verify": "bun vapor/scripts/esp32.ts verify --board waveshare-esp32-p4-wifi6-touch-lcd-7b", "vapor:dev": "bun vapor/scripts/dev.ts", "vapor:check": "bun vapor/compiler/cli.ts check vapor/examples/todo/todo.tsx" }, diff --git a/tests/esp32p4-device.test.ts b/tests/esp32p4-device.test.ts new file mode 100644 index 00000000..905c94cf --- /dev/null +++ b/tests/esp32p4-device.test.ts @@ -0,0 +1,255 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Esp32P4BundleArtifacts } from "../tools/esp32p4.ts"; +import { + assertSafeEsp32P4GeneratedProject, + createEsp32P4RustEnvironment, + ESP32P4_RUST_CFLAGS, + ESP32P4_RUSTFLAGS, + parseEsp32P4DeviceArgs, + parseNulEnvironment, + resolveEsp32P4DevicePaths, + stageEsp32P4DeviceProject, + validateEsp32P4FlasherArgs, +} from "../tools/esp32p4-device.ts"; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), "pocketjs-esp32p4-device-")); + temporaryDirectories.push(path); + return path; +} + +afterEach(() => { + for (const path of temporaryDirectories.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("ESP32-P4 device arguments", () => { + test("parses build and optionally port-bound flash commands", () => { + expect(parseEsp32P4DeviceArgs(["build", "chrome"])).toEqual({ + command: "build", + app: "chrome", + }); + expect( + parseEsp32P4DeviceArgs([ + "flash", + "apps/chrome/pocket.json", + "--port", + "/dev/cu.usbmodem101", + ]), + ).toEqual({ + command: "flash", + app: "apps/chrome/pocket.json", + port: "/dev/cu.usbmodem101", + }); + expect(parseEsp32P4DeviceArgs(["flash", "chrome", "--port=/dev/cu.test"])) + .toEqual({ command: "flash", app: "chrome", port: "/dev/cu.test" }); + }); + + test("rejects incomplete, repeated, unknown, and build-only port arguments", () => { + expect(() => parseEsp32P4DeviceArgs([])).toThrow("expected command build or flash"); + expect(() => parseEsp32P4DeviceArgs(["build"])).toThrow("requires an app"); + expect(() => parseEsp32P4DeviceArgs(["flash", "chrome", "--port"])).toThrow( + "requires a serial device path", + ); + expect(() => + parseEsp32P4DeviceArgs([ + "flash", + "chrome", + "--port=/dev/a", + "--port=/dev/b", + ]) + ).toThrow("may only be given once"); + expect(() => parseEsp32P4DeviceArgs(["flash", "chrome", "--erase"])).toThrow( + "unknown argument", + ); + expect(() => + parseEsp32P4DeviceArgs(["build", "chrome", "--port=/dev/cu.test"]) + ).toThrow("only valid with flash"); + }); +}); + +describe("ESP32-P4 generated project", () => { + test("uses checkout-bound deterministic output paths and guards cleanup", () => { + const root = temporaryDirectory(); + const paths = resolveEsp32P4DevicePaths(root); + expect(paths.projectDirectory).toBe(join(root, "dist/esp32p4/gen-waveshare-7b")); + expect(paths.rustLibraryPath).toBe( + join( + root, + "dist/esp32p4/rust-target/riscv32imafc-esp-espidf/release/" + + "libpocketjs_esp32p4_runtime.a", + ), + ); + expect(() => + assertSafeEsp32P4GeneratedProject(paths.projectDirectory, root) + ).not.toThrow(); + expect(() => assertSafeEsp32P4GeneratedProject(join(root, "dist"), root)).toThrow( + "refusing to replace", + ); + }); + + test("stages the explicit template allowlist and preserves its lock bytes", () => { + const root = temporaryDirectory(); + const paths = resolveEsp32P4DevicePaths(root); + const templateMain = join(paths.templateDirectory, "main"); + mkdirSync(templateMain, { recursive: true }); + const rootFiles = [ + "CMakeLists.txt", + "dependencies.lock", + "sdkconfig.defaults", + "partitions.csv", + ]; + const mainFiles = [ + "CMakeLists.txt", + "idf_component.yml", + "pocketjs_esp32p4.c", + "pocketjs_runtime.h", + ]; + for (const file of rootFiles) { + writeFileSync(join(paths.templateDirectory, file), `root:${file}\0`); + } + for (const file of mainFiles) writeFileSync(join(templateMain, file), `main:${file}`); + mkdirSync(join(paths.templateDirectory, "managed_components")); + mkdirSync(join(paths.templateDirectory, "build")); + writeFileSync(join(paths.templateDirectory, "sdkconfig"), "ignored"); + + const bundleDirectory = join(root, "dist/esp32p4"); + mkdirSync(bundleDirectory, { recursive: true }); + const javascriptPath = join(bundleDirectory, "test.js"); + const pakPath = join(bundleDirectory, "test.pak"); + writeFileSync(javascriptPath, "globalThis.test = true;"); + writeFileSync(pakPath, new Uint8Array([0, 1, 2, 255])); + const bundle = { + frameworkRoot: root, + javascriptPath, + pakPath, + } as Esp32P4BundleArtifacts; + + stageEsp32P4DeviceProject(bundle, paths); + + expect(readdirSync(paths.projectDirectory).sort()).toEqual([ + "CMakeLists.txt", + "dependencies.lock", + "main", + "partitions.csv", + "sdkconfig.defaults", + ]); + expect(readdirSync(paths.mainDirectory).sort()).toEqual([ + "CMakeLists.txt", + "app.js", + "app.pak", + "idf_component.yml", + "pocketjs_esp32p4.c", + "pocketjs_runtime.h", + ]); + expect(existsSync(join(paths.projectDirectory, "managed_components"))).toBe(false); + expect(existsSync(join(paths.projectDirectory, "build"))).toBe(false); + expect(existsSync(join(paths.projectDirectory, "sdkconfig"))).toBe(false); + expect(readFileSync(join(paths.projectDirectory, "dependencies.lock"))).toEqual( + readFileSync(join(paths.templateDirectory, "dependencies.lock")), + ); + }); +}); + +describe("ESP32-P4 cross-build environment", () => { + test("parses sourced environments without losing equals signs", () => { + expect(parseNulEnvironment(Buffer.from("PATH=/idf/bin:/bin\0TOKEN=a=b=c\0\0"))) + .toEqual({ PATH: "/idf/bin:/bin", TOKEN: "a=b=c" }); + }); + + test("pins the target compiler ABI and static Rust relocation model", () => { + const environment = createEsp32P4RustEnvironment( + { PATH: "/idf/bin:/bin", KEEP: "yes" }, + { + gccPath: "/idf/bin/riscv32-esp-elf-gcc", + arPath: "/idf/bin/riscv32-esp-elf-ar", + sysroot: "/idf/sysroot", + gccInclude: "/idf/gcc/include", + gccFixedInclude: "/idf/gcc/include-fixed", + bindgenArguments: + "'--target=riscv32-unknown-elf' '--sysroot=/idf/sysroot' " + + "'-isystem' '/idf/gcc/include'", + }, + "/tmp/rust-target", + ); + expect(environment.KEEP).toBe("yes"); + expect(environment.CC_riscv32imafc_esp_espidf).toEndWith("riscv32-esp-elf-gcc"); + expect(environment.AR_riscv32imafc_esp_espidf).toEndWith("riscv32-esp-elf-ar"); + expect(environment.CFLAGS_riscv32imafc_esp_espidf).toBe(ESP32P4_RUST_CFLAGS); + expect(ESP32P4_RUST_CFLAGS).toContain("-mabi=ilp32f"); + expect(ESP32P4_RUST_CFLAGS).toContain("-march=rv32imafc_zicsr_zifencei_xesppie"); + expect(ESP32P4_RUST_CFLAGS).toContain("-Wno-error=incompatible-pointer-types"); + expect(ESP32P4_RUST_CFLAGS).toContain("-fno-pic"); + expect(ESP32P4_RUST_CFLAGS).toContain("-fno-pie"); + expect(environment.CARGO_TARGET_RISCV32IMAFC_ESP_ESPIDF_RUSTFLAGS).toBe( + ESP32P4_RUSTFLAGS, + ); + expect(environment.BINDGEN_EXTRA_CLANG_ARGS).toContain( + "--target=riscv32-unknown-elf", + ); + }); +}); + +describe("ESP32-P4 segmented flash manifest", () => { + function validFlashFixture(): { buildDirectory: string; manifest: unknown } { + const buildDirectory = temporaryDirectory(); + mkdirSync(join(buildDirectory, "bootloader")); + mkdirSync(join(buildDirectory, "partition_table")); + writeFileSync(join(buildDirectory, "bootloader/bootloader.bin"), "boot"); + writeFileSync(join(buildDirectory, "partition_table/partition-table.bin"), "parts"); + writeFileSync(join(buildDirectory, "pocketjs_esp32p4_waveshare_7b.bin"), "app"); + return { + buildDirectory, + manifest: { + flash_files: { + "0x2000": "bootloader/bootloader.bin", + "0x8000": "partition_table/partition-table.bin", + "0x10000": "pocketjs_esp32p4_waveshare_7b.bin", + }, + app: { + offset: "0x10000", + file: "pocketjs_esp32p4_waveshare_7b.bin", + }, + }, + }; + } + + test("accepts the generated bootloader, partition, and app segments", () => { + const fixture = validFlashFixture(); + expect(validateEsp32P4FlasherArgs(fixture.manifest, fixture.buildDirectory)) + .toMatchObject({ appOffset: 0x10000 }); + }); + + test("fails closed on raw offset zero or missing required segments", () => { + const fixture = validFlashFixture(); + const rawZero = structuredClone(fixture.manifest) as { + flash_files: Record; + }; + rawZero.flash_files["0x0"] = "pocketjs_esp32p4_waveshare_7b.bin"; + expect(() => validateEsp32P4FlasherArgs(rawZero, fixture.buildDirectory)).toThrow( + "unsafe ESP32-P4 flash offset", + ); + + const missingBootloader = structuredClone(fixture.manifest) as { + flash_files: Record; + }; + delete missingBootloader.flash_files["0x2000"]; + expect(() => + validateEsp32P4FlasherArgs(missingBootloader, fixture.buildDirectory) + ).toThrow("omit required segment 0x2000"); + }); +}); diff --git a/tests/esp32p4-profile.test.ts b/tests/esp32p4-profile.test.ts new file mode 100644 index 00000000..13cdb639 --- /dev/null +++ b/tests/esp32p4-profile.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { assertNativeHostContract, type HostOps } from "../framework/src/host.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { validatePlatformContractRegistry } from "../framework/src/manifest/resolve.ts"; +import { + ESP32P4_WAVESHARE_7B_BOARD_ID, + ESP32P4_WAVESHARE_7B_CONTENT_RECT, + ESP32P4_WAVESHARE_7B_DEV_CONTRACTS, + ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + ESP32P4_WAVESHARE_7B_GUEST_SURFACE, + ESP32P4_WAVESHARE_7B_LOGICAL_VIEWPORT, + ESP32P4_WAVESHARE_7B_PANEL, + resolveEsp32P4Waveshare7BBuildPlan, +} from "../tools/esp32p4-profile.ts"; +import { + planEsp32P4Bundle, + resolveEsp32P4ManifestPath, +} from "../tools/esp32p4.ts"; + +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const chromeManifestPath = join(root, "apps/chrome/pocket.json"); +const chromeManifest: unknown = JSON.parse(await Bun.file(chromeManifestPath).text()); + +describe("experimental ESP32-P4 Waveshare 7B profile", () => { + test("stays private and separates the guest surface from the physical panel", () => { + expect(Object.hasOwn(POCKET_TARGETS, ESP32P4_WAVESHARE_7B_DEV_TARGET_ID)).toBe(false); + expect(validatePlatformContractRegistry(ESP32P4_WAVESHARE_7B_DEV_CONTRACTS)).toEqual([]); + + const profile = ESP32P4_WAVESHARE_7B_DEV_CONTRACTS.targets[ + ESP32P4_WAVESHARE_7B_DEV_TARGET_ID + ]; + expect(profile).toEqual({ + hostAbi: 6, + platform: "esp32p4", + form: "takeover", + display: { + physicalViewport: [960, 544], + logicalViewports: [[480, 272]], + presentations: ["integer-fit"], + rasterDensity: 2, + }, + capabilities: ["input.buttons", "input.touch", "text.glyphs.baked"], + }); + expect(ESP32P4_WAVESHARE_7B_LOGICAL_VIEWPORT).toEqual([480, 272]); + expect(ESP32P4_WAVESHARE_7B_GUEST_SURFACE).toEqual([960, 544]); + expect(ESP32P4_WAVESHARE_7B_PANEL).toEqual([1024, 600]); + expect(ESP32P4_WAVESHARE_7B_CONTENT_RECT).toEqual({ + x: 32, + y: 28, + width: 960, + height: 544, + }); + }); + + test("resolves a target-bound density-2 plan with the private host identity", async () => { + const plan = resolveEsp32P4Waveshare7BBuildPlan(chromeManifest); + expect(plan.target).toEqual({ + id: "esp32p4-waveshare-7b-dev", + hostAbi: 6, + }); + expect(plan.viewport).toEqual({ + logical: [480, 272], + physical: [960, 544], + presentation: "integer-fit", + rasterDensity: 2, + }); + expect(plan.features).toEqual({ + "input.buttons": true, + "text.glyphs.baked": true, + }); + expect(verifyPlanHash(plan)).toBe(true); + + const touchManifest: unknown = await Bun.file( + new URL("./fixtures/manifests/requires-touch.json", import.meta.url), + ).json(); + expect( + resolveEsp32P4Waveshare7BBuildPlan(touchManifest).features["input.touch"], + ).toBe(true); + }); + + test("binds bundles to the exact native target and ABI", () => { + const plan = resolveEsp32P4Waveshare7BBuildPlan(chromeManifest); + expect( + extractHostBuildInputs(plan, { + expectedTarget: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + }), + ).toMatchObject({ + appOutput: "chrome-main", + target: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + hostAbi: ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + }); + + const matching = { + __host: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + __hostAbi: ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + } as HostOps; + expect(() => + assertNativeHostContract(matching, { + target: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + hostAbi: ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + }) + ).not.toThrow(); + expect(() => + assertNativeHostContract( + { ...matching, __host: "psp" }, + { + target: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + hostAbi: ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + }, + ) + ).toThrow("native target mismatch"); + expect(() => + assertNativeHostContract( + { ...matching, __hostAbi: 5 }, + { + target: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + hostAbi: ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + }, + ) + ).toThrow("native host ABI mismatch"); + }); +}); + +describe("ESP32-P4 bundle artifact planning", () => { + test("accepts a stock app name or an explicit manifest path", () => { + expect(resolveEsp32P4ManifestPath("chrome", { frameworkRoot: root })).toBe( + chromeManifestPath, + ); + expect( + resolveEsp32P4ManifestPath("apps/chrome/pocket.json", { + cwd: root, + frameworkRoot: root, + }), + ).toBe(chromeManifestPath); + }); + + test("plans deterministic target-bound JS, PAK, and plan artifacts", () => { + const artifacts = planEsp32P4Bundle("chrome", { + cwd: root, + frameworkRoot: root, + }); + expect(artifacts.target).toEqual({ + id: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + hostAbi: ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + }); + expect(artifacts.board).toEqual({ + id: ESP32P4_WAVESHARE_7B_BOARD_ID, + panel: [1024, 600], + contentRect: { x: 32, y: 28, width: 960, height: 544 }, + }); + expect(artifacts.manifestPath).toBe(chromeManifestPath); + expect(artifacts.frameworkRoot).toBe(root); + expect(artifacts.projectRoot).toBe(root); + expect(artifacts.outputDirectory).toBe(join(root, "dist/esp32p4")); + expect(artifacts.planPath).toBe(join(root, "dist/esp32p4/chrome-main.plan.json")); + expect(artifacts.javascriptPath).toBe(join(root, "dist/esp32p4/chrome-main.js")); + expect(artifacts.pakPath).toBe(join(root, "dist/esp32p4/chrome-main.pak")); + expect(planEsp32P4Bundle("chrome", { frameworkRoot: root })).toEqual(artifacts); + }); +}); diff --git a/tools/esp32p4-device.ts b/tools/esp32p4-device.ts new file mode 100644 index 00000000..7e7d9458 --- /dev/null +++ b/tools/esp32p4-device.ts @@ -0,0 +1,730 @@ +#!/usr/bin/env bun + +// Reproducible full-PocketJS firmware builder/flasher for the Waveshare +// ESP32-P4-WIFI6-Touch-LCD-7B. +// +// bun tools/esp32p4-device.ts build chrome +// bun tools/esp32p4-device.ts flash chrome --port /dev/cu.usbmodem101 + +import { createHash } from "node:crypto"; +import { + accessSync, + constants, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; +import { loadBoard } from "../vapor/compiler/boards.ts"; +import { + esp32IdfVersion, + resolveEspIdfEnvironment, + type EspIdfEnvironment, +} from "../vapor/compiler/esp32.ts"; +import { + buildEsp32P4Bundle, + ESP32P4_FRAMEWORK_ROOT, + type Esp32P4BundleArtifacts, +} from "./esp32p4.ts"; +import { ESP32P4_WAVESHARE_7B_BOARD_ID } from "./esp32p4-profile.ts"; + +export const ESP32P4_IDF_VERSION = "v5.5.4" as const; +export const ESP32P4_RUST_TOOLCHAIN = "nightly-2026-07-02"; +export const ESP32P4_RUST_TARGET = "riscv32imafc-esp-espidf"; +export const ESP32P4_RUST_CFLAGS = [ + "-mabi=ilp32f", + "-march=rv32imafc_zicsr_zifencei_xesppie", + "-Wno-error=incompatible-pointer-types", + "-fno-pic", + "-fno-pie", +].join(" "); +export const ESP32P4_RUSTFLAGS = "-C relocation-model=static"; +export const ESP32P4_APPLICATION_PARTITION_BYTES = 0xf00000; + +const TEMPLATE_ROOT_FILES = [ + "CMakeLists.txt", + "dependencies.lock", + "sdkconfig.defaults", + "partitions.csv", +] as const; +const TEMPLATE_MAIN_FILES = [ + "CMakeLists.txt", + "idf_component.yml", + "pocketjs_esp32p4.c", + "pocketjs_runtime.h", +] as const; +const FIRMWARE_FILENAME = "pocketjs_esp32p4_waveshare_7b.bin"; + +export type Esp32P4DeviceCommand = "build" | "flash"; + +export interface Esp32P4DeviceArguments { + readonly command: Esp32P4DeviceCommand; + readonly app: string; + readonly port?: string; +} + +export interface Esp32P4DevicePaths { + readonly frameworkRoot: string; + readonly outputDirectory: string; + readonly templateDirectory: string; + readonly projectDirectory: string; + readonly mainDirectory: string; + readonly buildDirectory: string; + readonly runtimeManifestPath: string; + readonly rustTargetDirectory: string; + readonly rustLibraryPath: string; + readonly firmwareImagePath: string; + readonly flasherArgsPath: string; +} + +export interface Esp32P4GccToolchain { + readonly gccPath: string; + readonly arPath: string; + readonly sysroot: string; + readonly gccInclude: string; + readonly gccFixedInclude: string; + readonly bindgenArguments: string; +} + +export interface Esp32P4DeviceBuildResult { + readonly app: string; + readonly title: string; + readonly buildId: string; + readonly projectDirectory: string; + readonly buildDirectory: string; + readonly firmwareImagePath: string; + readonly firmwareBytes: number; + readonly rustLibraryPath: string; + readonly idfEnvironment: EspIdfEnvironment; +} + +interface InternalBuildResult { + readonly result: Esp32P4DeviceBuildResult; + readonly paths: Esp32P4DevicePaths; + readonly activatedEnvironment: Record; + readonly idfExecutable: string; +} + +class Esp32P4DeviceArgumentError extends Error {} + +function argumentError(message: string): Esp32P4DeviceArgumentError { + return new Esp32P4DeviceArgumentError(`pocket esp32p4 device: ${message}`); +} + +export function parseEsp32P4DeviceArgs( + args: readonly string[], +): Esp32P4DeviceArguments { + const command = args[0]; + if (command !== "build" && command !== "flash") { + throw argumentError("expected command build or flash"); + } + const app = args[1]?.trim(); + if (!app || app.startsWith("--")) { + throw argumentError(`${command} requires an app name or pocket.json path`); + } + + let port: string | undefined; + for (let index = 2; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--port") { + if (port !== undefined) throw argumentError("--port may only be given once"); + const value = args[index + 1]?.trim(); + if (!value || value.startsWith("--")) { + throw argumentError("--port requires a serial device path"); + } + port = value; + index += 1; + continue; + } + if (argument.startsWith("--port=")) { + if (port !== undefined) throw argumentError("--port may only be given once"); + port = argument.slice("--port=".length).trim(); + if (!port) throw argumentError("--port requires a serial device path"); + continue; + } + throw argumentError(`unknown argument ${JSON.stringify(argument)}`); + } + if (command === "build" && port !== undefined) { + throw argumentError("--port is only valid with flash"); + } + return port === undefined ? { command, app } : { command, app, port }; +} + +export function resolveEsp32P4DevicePaths( + frameworkRoot = ESP32P4_FRAMEWORK_ROOT, +): Esp32P4DevicePaths { + const root = resolve(frameworkRoot); + const outputDirectory = join(root, "dist", "esp32p4"); + const projectDirectory = join(outputDirectory, "gen-waveshare-7b"); + const rustTargetDirectory = join(outputDirectory, "rust-target"); + const buildDirectory = join(projectDirectory, "build"); + return { + frameworkRoot: root, + outputDirectory, + templateDirectory: join(root, "hosts", "esp32p4", "waveshare-7b"), + projectDirectory, + mainDirectory: join(projectDirectory, "main"), + buildDirectory, + runtimeManifestPath: join(root, "hosts", "esp32p4", "runtime", "Cargo.toml"), + rustTargetDirectory, + rustLibraryPath: join( + rustTargetDirectory, + ESP32P4_RUST_TARGET, + "release", + "libpocketjs_esp32p4_runtime.a", + ), + firmwareImagePath: join(buildDirectory, FIRMWARE_FILENAME), + flasherArgsPath: join(buildDirectory, "flasher_args.json"), + }; +} + +/** Refuse to recursively remove anything except this checkout's generated project. */ +export function assertSafeEsp32P4GeneratedProject( + projectDirectory: string, + frameworkRoot: string, +): void { + const root = resolve(frameworkRoot); + const project = resolve(projectDirectory); + const expected = join(root, "dist", "esp32p4", "gen-waveshare-7b"); + if (project !== expected || relative(root, project).startsWith("..")) { + throw new Error(`refusing to replace non-generated ESP32-P4 path: ${project}`); + } +} + +function requireRegularFile(path: string, description: string): void { + if (!existsSync(path) || !statSync(path).isFile()) { + throw new Error(`${description} not found: ${path}`); + } +} + +function requireDirectory(path: string, description: string): void { + if (!existsSync(path) || !statSync(path).isDirectory()) { + throw new Error(`${description} not found: ${path}`); + } +} + +function filesEqual(left: string, right: string): boolean { + return readFileSync(left).equals(readFileSync(right)); +} + +/** Stage only the source template contract, never its ignored local build state. */ +export function stageEsp32P4DeviceProject( + bundle: Esp32P4BundleArtifacts, + paths = resolveEsp32P4DevicePaths(bundle.frameworkRoot), +): void { + if (resolve(bundle.frameworkRoot) !== paths.frameworkRoot) { + throw new Error("ESP32-P4 bundle and generated project belong to different checkouts"); + } + assertSafeEsp32P4GeneratedProject(paths.projectDirectory, paths.frameworkRoot); + for (const file of TEMPLATE_ROOT_FILES) { + requireRegularFile(join(paths.templateDirectory, file), `ESP32-P4 template ${file}`); + } + for (const file of TEMPLATE_MAIN_FILES) { + requireRegularFile( + join(paths.templateDirectory, "main", file), + `ESP32-P4 template main/${file}`, + ); + } + requireRegularFile(bundle.javascriptPath, "ESP32-P4 JavaScript bundle"); + requireRegularFile(bundle.pakPath, "ESP32-P4 asset pak"); + + rmSync(paths.projectDirectory, { recursive: true, force: true }); + mkdirSync(paths.mainDirectory, { recursive: true }); + for (const file of TEMPLATE_ROOT_FILES) { + copyFileSync(join(paths.templateDirectory, file), join(paths.projectDirectory, file)); + } + for (const file of TEMPLATE_MAIN_FILES) { + copyFileSync( + join(paths.templateDirectory, "main", file), + join(paths.mainDirectory, file), + ); + } + copyFileSync(bundle.javascriptPath, join(paths.mainDirectory, "app.js")); + copyFileSync(bundle.pakPath, join(paths.mainDirectory, "app.pak")); + + for (const file of TEMPLATE_ROOT_FILES) { + if (!filesEqual(join(paths.templateDirectory, file), join(paths.projectDirectory, file))) { + throw new Error(`staged ESP32-P4 template file changed while copying: ${file}`); + } + } + for (const file of TEMPLATE_MAIN_FILES) { + if ( + !filesEqual( + join(paths.templateDirectory, "main", file), + join(paths.mainDirectory, file), + ) + ) { + throw new Error(`staged ESP32-P4 template file changed while copying: main/${file}`); + } + } +} + +export function parseNulEnvironment(bytes: Uint8Array): Record { + const environment: Record = {}; + for (const entry of Buffer.from(bytes).toString("utf8").split("\0")) { + if (!entry) continue; + const separator = entry.indexOf("="); + if (separator <= 0) continue; + environment[entry.slice(0, separator)] = entry.slice(separator + 1); + } + return environment; +} + +function processEnvironment(): Record { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => { + return entry[1] !== undefined; + }), + ); +} + +function shellForActivation(): string { + const configured = process.env.SHELL; + if ( + configured && + existsSync(configured) && + (basename(configured) === "bash" || basename(configured) === "zsh") + ) { + return configured; + } + const shell = Bun.which("zsh") ?? Bun.which("bash"); + if (!shell) throw new Error("ESP-IDF requires bash or zsh to source export.sh"); + return shell; +} + +async function captureProcess( + command: string, + args: readonly string[], + options: { readonly cwd?: string; readonly env?: Record } = {}, +): Promise { + const child = Bun.spawn([command, ...args], { + cwd: options.cwd, + env: options.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).arrayBuffer(), + new Response(child.stderr).text(), + child.exited, + ]); + if (exitCode !== 0) { + const detail = stderr.trim(); + throw new Error( + `command failed (${exitCode}): ${command} ${args.join(" ")}` + + (detail ? `\n${detail}` : ""), + ); + } + return new Uint8Array(stdout); +} + +async function runProcess( + command: string, + args: readonly string[], + options: { readonly cwd?: string; readonly env?: Record } = {}, +): Promise { + const child = Bun.spawn([command, ...args], { + cwd: options.cwd, + env: options.env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`command failed (${exitCode}): ${command} ${args.join(" ")}`); + } +} + +function findExecutable(name: string, environment: Record): string { + const candidates = isAbsolute(name) + ? [name] + : (environment.PATH ?? "").split(":").filter(Boolean).map((directory) => + join(directory, name) + ); + for (const candidate of candidates) { + try { + accessSync(candidate, constants.X_OK); + if (statSync(candidate).isFile()) return resolve(candidate); + } catch { + // Keep looking through the activated PATH. + } + } + throw new Error(`executable ${name} not found in the activated ESP-IDF environment`); +} + +function readIdfRelease(idfPath: string): string { + const versionPath = join(idfPath, "tools", "cmake", "version.cmake"); + requireRegularFile(versionPath, "ESP-IDF version metadata"); + const source = readFileSync(versionPath, "utf8"); + const part = (name: "MAJOR" | "MINOR" | "PATCH"): string => { + const value = source.match(new RegExp(`set\\(IDF_VERSION_${name}\\s+([0-9]+)\\)`))?.[1]; + if (!value) throw new Error(`cannot read ESP-IDF ${name.toLowerCase()} version from ${versionPath}`); + return value; + }; + return `v${part("MAJOR")}.${part("MINOR")}.${part("PATCH")}`; +} + +async function activateEspIdfEnvironment( + idfEnvironment: EspIdfEnvironment, +): Promise> { + const exportScript = join(idfEnvironment.idfPath, "export.sh"); + requireRegularFile(exportScript, `${idfEnvironment.idfVersion} ESP-IDF export script`); + const baseEnvironment = { + ...processEnvironment(), + IDF_PATH: idfEnvironment.idfPath, + IDF_TOOLS_PATH: idfEnvironment.idfToolsPath, + }; + const bytes = await captureProcess( + shellForActivation(), + [ + "-lc", + 'source "$1/export.sh" >/dev/null && env -0', + "pocketjs-esp32p4-idf", + idfEnvironment.idfPath, + ], + { env: baseEnvironment }, + ); + return parseNulEnvironment(bytes); +} + +function shellWords(words: readonly string[]): string { + return words.map((word) => `'${word.replaceAll("'", "'\\''")}'`).join(" "); +} + +export function createEsp32P4RustEnvironment( + activatedEnvironment: Record, + toolchain: Esp32P4GccToolchain, + rustTargetDirectory: string, +): Record { + return { + ...activatedEnvironment, + CARGO_TARGET_DIR: resolve(rustTargetDirectory), + CARGO_TARGET_RISCV32IMAFC_ESP_ESPIDF_RUSTFLAGS: ESP32P4_RUSTFLAGS, + CC_riscv32imafc_esp_espidf: toolchain.gccPath, + AR_riscv32imafc_esp_espidf: toolchain.arPath, + CFLAGS_riscv32imafc_esp_espidf: ESP32P4_RUST_CFLAGS, + BINDGEN_EXTRA_CLANG_ARGS: toolchain.bindgenArguments, + }; +} + +async function resolveGccToolchain( + activatedEnvironment: Record, +): Promise { + const gccPath = findExecutable("riscv32-esp-elf-gcc", activatedEnvironment); + const arPath = findExecutable("riscv32-esp-elf-ar", activatedEnvironment); + const query = async (argument: string): Promise => { + const output = await captureProcess(gccPath, [argument], { env: activatedEnvironment }); + const value = Buffer.from(output).toString("utf8").trim(); + if (!value) throw new Error(`${gccPath} returned no value for ${argument}`); + return resolve(value); + }; + const [sysroot, gccInclude, gccFixedInclude] = await Promise.all([ + query("-print-sysroot"), + query("-print-file-name=include"), + query("-print-file-name=include-fixed"), + ]); + const systemInclude = join(sysroot, "include"); + requireDirectory(sysroot, "RISC-V GCC sysroot"); + requireDirectory(gccInclude, "RISC-V GCC include directory"); + requireDirectory(gccFixedInclude, "RISC-V GCC fixed include directory"); + requireDirectory(systemInclude, "RISC-V GCC system include directory"); + const bindgenArguments = shellWords([ + "--target=riscv32-unknown-elf", + `--sysroot=${sysroot}`, + "-isystem", + gccInclude, + "-isystem", + gccFixedInclude, + "-isystem", + systemInclude, + ]); + return { gccPath, arPath, sysroot, gccInclude, gccFixedInclude, bindgenArguments }; +} + +function firmwareBuildId(paths: Esp32P4DevicePaths): string { + const hasher = createHash("sha256"); + hasher.update(`${ESP32P4_IDF_VERSION}\0${ESP32P4_RUST_TOOLCHAIN}\0`); + hasher.update(`${ESP32P4_RUST_TARGET}\0${ESP32P4_RUST_CFLAGS}\0${ESP32P4_RUSTFLAGS}\0`); + const inputs = [ + ...TEMPLATE_ROOT_FILES.map((file) => join(paths.projectDirectory, file)), + ...TEMPLATE_MAIN_FILES.map((file) => join(paths.mainDirectory, file)), + join(paths.mainDirectory, "app.js"), + join(paths.mainDirectory, "app.pak"), + paths.rustLibraryPath, + ]; + for (const path of inputs) { + requireRegularFile(path, "ESP32-P4 build-id input"); + hasher.update(`${relative(paths.frameworkRoot, path)}\0`); + hasher.update(readFileSync(path)); + hasher.update("\0"); + } + return hasher.digest("hex").slice(0, 16); +} + +function requireSafeAppTitle(title: string): string { + // The template injects this cache string into a quoted C definition. Reject + // CMake list/C-string metacharacters until that boundary owns escaping. + if (!title || /[;"\\\r\n]/.test(title)) { + throw new Error( + `ESP32-P4 app title cannot contain semicolon, quote, backslash, or newline: ` + + JSON.stringify(title), + ); + } + return title; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function validateEsp32P4FlasherArgs( + value: unknown, + buildDirectory: string, +): { readonly appOffset: number; readonly flashFiles: readonly string[] } { + if (!isRecord(value) || !isRecord(value.flash_files)) { + throw new Error("ESP32-P4 flasher_args.json has no flash_files map"); + } + const flashFiles: string[] = []; + for (const [offsetText, file] of Object.entries(value.flash_files)) { + const offset = Number.parseInt(offsetText, 0); + if (!Number.isSafeInteger(offset) || offset <= 0) { + throw new Error(`unsafe ESP32-P4 flash offset ${JSON.stringify(offsetText)}`); + } + if (typeof file !== "string" || !file) { + throw new Error(`invalid ESP32-P4 flash file at ${offsetText}`); + } + const path = resolve(buildDirectory, file); + const pathWithinBuild = relative(resolve(buildDirectory), path); + if (pathWithinBuild.startsWith("..") || isAbsolute(pathWithinBuild)) { + throw new Error(`ESP32-P4 flash file escapes its build directory: ${file}`); + } + requireRegularFile(path, `ESP32-P4 segmented flash image at ${offsetText}`); + if (statSync(path).size === 0) { + throw new Error(`ESP32-P4 segmented flash image is empty: ${path}`); + } + flashFiles.push(path); + } + if (!isRecord(value.app) || value.app.offset !== "0x10000") { + throw new Error("ESP32-P4 app image must be described at offset 0x10000"); + } + if (value.app.file !== FIRMWARE_FILENAME) { + throw new Error(`ESP32-P4 flasher app is not ${FIRMWARE_FILENAME}`); + } + for (const requiredOffset of ["0x2000", "0x8000", "0x10000"]) { + if (!(requiredOffset in value.flash_files)) { + throw new Error(`ESP32-P4 flasher args omit required segment ${requiredOffset}`); + } + } + return { appOffset: 0x10000, flashFiles }; +} + +function validateBuildOutputs(paths: Esp32P4DevicePaths): number { + const templateLock = join(paths.templateDirectory, "dependencies.lock"); + const generatedLock = join(paths.projectDirectory, "dependencies.lock"); + if (!filesEqual(templateLock, generatedLock)) { + throw new Error( + "ESP-IDF changed the generated dependencies.lock; refresh the reviewed board template lock", + ); + } + requireRegularFile(paths.flasherArgsPath, "ESP32-P4 flasher arguments"); + let flasherArgs: unknown; + try { + flasherArgs = JSON.parse(readFileSync(paths.flasherArgsPath, "utf8")); + } catch (error) { + throw new Error(`invalid JSON in ${paths.flasherArgsPath}`, { cause: error }); + } + validateEsp32P4FlasherArgs(flasherArgs, paths.buildDirectory); + requireRegularFile(paths.firmwareImagePath, "ESP32-P4 application image"); + const firmwareBytes = statSync(paths.firmwareImagePath).size; + if (firmwareBytes === 0 || firmwareBytes > ESP32P4_APPLICATION_PARTITION_BYTES) { + throw new Error( + `ESP32-P4 application image is ${firmwareBytes} bytes; partition limit is ` + + `${ESP32P4_APPLICATION_PARTITION_BYTES}`, + ); + } + return firmwareBytes; +} + +async function buildWithContext(app: string): Promise { + const paths = resolveEsp32P4DevicePaths(); + const bundle = await buildEsp32P4Bundle(app); + const board = loadBoard(ESP32P4_WAVESHARE_7B_BOARD_ID); + const idfEnvironment = resolveEspIdfEnvironment(board); + const expectedVersion = esp32IdfVersion(board); + if (expectedVersion !== ESP32P4_IDF_VERSION || idfEnvironment.idfVersion !== expectedVersion) { + throw new Error( + `Waveshare ESP32-P4 requires ${ESP32P4_IDF_VERSION}, got ${idfEnvironment.idfVersion}`, + ); + } + const actualVersion = readIdfRelease(idfEnvironment.idfPath); + if (actualVersion !== expectedVersion) { + throw new Error( + `ESP-IDF release mismatch: ${idfEnvironment.idfPath} is ${actualVersion}, ` + + `but this board requires ${expectedVersion}`, + ); + } + + const activatedEnvironment = await activateEspIdfEnvironment(idfEnvironment); + const idfExecutable = findExecutable("idf.py", activatedEnvironment); + const rustupExecutable = findExecutable("rustup", activatedEnvironment); + const gccToolchain = await resolveGccToolchain(activatedEnvironment); + const rustEnvironment = createEsp32P4RustEnvironment( + activatedEnvironment, + gccToolchain, + paths.rustTargetDirectory, + ); + const rustupTool = async (tool: "cargo" | "rustc"): Promise => { + const output = await captureProcess( + rustupExecutable, + ["which", tool, "--toolchain", ESP32P4_RUST_TOOLCHAIN], + { env: activatedEnvironment }, + ); + const path = Buffer.from(output).toString("utf8").trim(); + requireRegularFile(path, `${ESP32P4_RUST_TOOLCHAIN} ${tool}`); + return resolve(path); + }; + // Calling `rustup run ... cargo` alone is insufficient when another rustc + // appears earlier in an activated PATH: Cargo resolves its compiler again. + // Pin both executables to the dated toolchain so build-std cannot silently + // mix a Homebrew stable compiler with the nightly Cargo frontend. + const [cargoExecutable, rustcExecutable] = await Promise.all([ + rustupTool("cargo"), + rustupTool("rustc"), + ]); + rustEnvironment.RUSTC = rustcExecutable; + rustEnvironment.RUSTUP_TOOLCHAIN = ESP32P4_RUST_TOOLCHAIN; + requireRegularFile(paths.runtimeManifestPath, "ESP32-P4 Rust runtime manifest"); + await runProcess( + cargoExecutable, + [ + "build", + "--manifest-path", + paths.runtimeManifestPath, + "--release", + "--locked", + "--lib", + "--target", + ESP32P4_RUST_TARGET, + "--features", + "esp-idf", + "-Z", + "build-std=std,panic_abort", + ], + { cwd: paths.frameworkRoot, env: rustEnvironment }, + ); + requireRegularFile(paths.rustLibraryPath, "ESP32-P4 Rust static library"); + if (statSync(paths.rustLibraryPath).size === 0) { + throw new Error(`ESP32-P4 Rust static library is empty: ${paths.rustLibraryPath}`); + } + + stageEsp32P4DeviceProject(bundle, paths); + const buildId = firmwareBuildId(paths); + const appTitle = requireSafeAppTitle(bundle.plan.app.title); + const idfBuildEnvironment = { + ...activatedEnvironment, + POCKETJS_REPO_ROOT: paths.frameworkRoot, + POCKETJS_RUST_LIB: paths.rustLibraryPath, + }; + await runProcess( + idfExecutable, + [ + "-C", + paths.projectDirectory, + "-B", + paths.buildDirectory, + "-D", + `POCKETJS_REPO_ROOT=${paths.frameworkRoot}`, + "-D", + `POCKETJS_RUST_LIB=${paths.rustLibraryPath}`, + "-D", + `POCKETJS_APP_TITLE=${appTitle}`, + "-D", + `POCKETJS_BUILD_ID=${buildId}`, + "build", + ], + { cwd: paths.frameworkRoot, env: idfBuildEnvironment }, + ); + const firmwareBytes = validateBuildOutputs(paths); + return { + paths, + activatedEnvironment: idfBuildEnvironment, + idfExecutable, + result: { + app: bundle.plan.app.output, + title: bundle.plan.app.title, + buildId, + projectDirectory: paths.projectDirectory, + buildDirectory: paths.buildDirectory, + firmwareImagePath: paths.firmwareImagePath, + firmwareBytes, + rustLibraryPath: paths.rustLibraryPath, + idfEnvironment, + }, + }; +} + +export async function buildEsp32P4Device( + app: string, +): Promise { + return (await buildWithContext(app)).result; +} + +export async function flashEsp32P4Device( + app: string, + port?: string, +): Promise { + const built = await buildWithContext(app); + const args = [ + "-C", + built.paths.projectDirectory, + "-B", + built.paths.buildDirectory, + ]; + if (port) args.push("-p", port); + // idf.py reads flasher_args.json and writes the bootloader, partition table, + // and application at their generated offsets. Never substitute write_flash. + args.push("flash"); + await runProcess(built.idfExecutable, args, { + cwd: built.paths.frameworkRoot, + env: built.activatedEnvironment, + }); + return built.result; +} + +function printUsage(): void { + console.error( + "usage: bun tools/esp32p4-device.ts build \n" + + " bun tools/esp32p4-device.ts flash [--port /dev/cu.*]", + ); +} + +export async function esp32P4DeviceMain(args: readonly string[]): Promise { + if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { + printUsage(); + return; + } + const parsed = parseEsp32P4DeviceArgs(args); + const result = parsed.command === "build" + ? await buildEsp32P4Device(parsed.app) + : await flashEsp32P4Device(parsed.app, parsed.port); + const relativeImage = relative(ESP32P4_FRAMEWORK_ROOT, result.firmwareImagePath); + console.log( + `PocketJS ESP32-P4 ${parsed.command}: ${result.app} build=${result.buildId} ` + + `${result.firmwareBytes} bytes -> ${relativeImage}`, + ); +} + +if (import.meta.main) { + try { + await esp32P4DeviceMain(Bun.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + if (error instanceof Esp32P4DeviceArgumentError) printUsage(); + process.exitCode = 1; + } +} diff --git a/tools/esp32p4-profile.ts b/tools/esp32p4-profile.ts new file mode 100644 index 00000000..1520bbd0 --- /dev/null +++ b/tools/esp32p4-profile.ts @@ -0,0 +1,78 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; + +/** + * Experimental full-PocketJS guest profile for the Waveshare 7B host. + * + * This deliberately stays outside the production `POCKET_TARGETS` registry. + * The native QuickJS/HostOps host is still being brought up, so only the + * manifest compiler may opt into this profile today. + * + * The guest surface follows the PocketBook precedent: PocketJS renders its + * 480x272 logical viewport at density 2 into a nominal 960x544 RGB565 surface. + * The board presenter centers that surface on the real 1024x600 panel. Keeping + * the two facts separate preserves the exact integer-fit guest contract while + * retaining truthful board geometry for input and presentation integration. + */ +export const ESP32P4_WAVESHARE_7B_DEV_TARGET_ID = "esp32p4-waveshare-7b-dev"; +export const ESP32P4_WAVESHARE_7B_DEV_HOST_ABI = 6; +export const ESP32P4_WAVESHARE_7B_BOARD_ID = + "waveshare-esp32-p4-wifi6-touch-lcd-7b"; +export const ESP32P4_WAVESHARE_7B_LOGICAL_VIEWPORT = [480, 272] as const; +export const ESP32P4_WAVESHARE_7B_GUEST_SURFACE = [960, 544] as const; +export const ESP32P4_WAVESHARE_7B_PANEL = [1024, 600] as const; +export const ESP32P4_WAVESHARE_7B_CONTENT_RECT = { + x: 32, + y: 28, + width: 960, + height: 544, +} as const; + +export const ESP32P4_WAVESHARE_7B_DEV_CONTRACTS = + definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [ESP32P4_WAVESHARE_7B_DEV_TARGET_ID]: { + hostAbi: ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + platform: "esp32p4", + form: "takeover", + display: { + physicalViewport: ESP32P4_WAVESHARE_7B_GUEST_SURFACE, + logicalViewports: [ESP32P4_WAVESHARE_7B_LOGICAL_VIEWPORT], + presentations: ["integer-fit"], + rasterDensity: 2, + }, + capabilities: [ + "input.buttons", + "input.touch", + "text.glyphs.baked", + ], + }, + }), + ); + +export function resolveEsp32P4Waveshare7BBuildPlan( + input: unknown, +): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID }, + ESP32P4_WAVESHARE_7B_DEV_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket esp32p4: manifest did not resolve: ${resolution.diagnostics + .map( + (diagnostic) => + `${diagnostic.path || "/"}: ${diagnostic.message}`, + ) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/esp32p4.ts b/tools/esp32p4.ts new file mode 100644 index 00000000..d43e099b --- /dev/null +++ b/tools/esp32p4.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env bun + +// Experimental full-PocketJS bundle compiler for the Waveshare ESP32-P4 7B. +// +// This command stops at target-bound JavaScript + PAK artifacts. The native +// QuickJS/HostOps runtime, firmware build, flashing, and device verification +// remain separate host responsibilities. +// +// bun tools/esp32p4.ts chrome +// bun tools/esp32p4.ts apps/cards/pocket.json + +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { + ESP32P4_WAVESHARE_7B_BOARD_ID, + ESP32P4_WAVESHARE_7B_CONTENT_RECT, + ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + ESP32P4_WAVESHARE_7B_PANEL, + resolveEsp32P4Waveshare7BBuildPlan, +} from "./esp32p4-profile.ts"; + +export const ESP32P4_FRAMEWORK_ROOT = resolve( + fileURLToPath(new URL("..", import.meta.url)), +); +export const ESP32P4_BUNDLE_OUTPUT_DIRECTORY = join( + ESP32P4_FRAMEWORK_ROOT, + "dist/esp32p4", +); + +export interface Esp32P4BundleArtifacts { + readonly target: { + readonly id: typeof ESP32P4_WAVESHARE_7B_DEV_TARGET_ID; + readonly hostAbi: typeof ESP32P4_WAVESHARE_7B_DEV_HOST_ABI; + }; + readonly board: { + readonly id: typeof ESP32P4_WAVESHARE_7B_BOARD_ID; + readonly panel: typeof ESP32P4_WAVESHARE_7B_PANEL; + readonly contentRect: typeof ESP32P4_WAVESHARE_7B_CONTENT_RECT; + }; + readonly manifestPath: string; + readonly frameworkRoot: string; + readonly projectRoot: string; + readonly outputDirectory: string; + readonly planPath: string; + readonly javascriptPath: string; + readonly pakPath: string; + readonly plan: ResolvedBuildPlan; +} + +export interface Esp32P4BundlePlanOptions { + /** Resolve explicit manifest paths from here. Stock app names ignore it. */ + readonly cwd?: string; + /** Test-only checkout boundary; production callers use this repository. */ + readonly frameworkRoot?: string; +} + +function isStockAppName(value: string): boolean { + return /^[a-z0-9][a-z0-9._-]*$/i.test(value) && !value.endsWith(".json"); +} + +export function resolveEsp32P4ManifestPath( + input: string, + options: Esp32P4BundlePlanOptions = {}, +): string { + const value = input.trim(); + if (!value) throw new Error("pocket esp32p4: an app name or pocket.json path is required"); + const frameworkRoot = resolve(options.frameworkRoot ?? ESP32P4_FRAMEWORK_ROOT); + const cwd = resolve(options.cwd ?? process.cwd()); + const manifestPath = isStockAppName(value) + ? join(frameworkRoot, "apps", value, "pocket.json") + : resolve(cwd, value); + if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) { + const description = isStockAppName(value) + ? `stock app ${JSON.stringify(value)}` + : `manifest ${JSON.stringify(value)}`; + throw new Error( + `pocket esp32p4: cannot find ${description} at ${manifestPath}`, + ); + } + return manifestPath; +} + +/** Find the root against which the manifest's repository-relative entry lives. */ +export function inferEsp32P4ProjectRoot( + manifestPath: string, + entry: string, +): string { + let candidate = dirname(manifestPath); + while (true) { + const entryPath = resolve(candidate, entry); + if (existsSync(entryPath) && statSync(entryPath).isFile()) return candidate; + const parent = dirname(candidate); + if (parent === candidate) { + throw new Error( + `pocket esp32p4: cannot find entry ${JSON.stringify(entry)} above ${manifestPath}`, + ); + } + candidate = parent; + } +} + +/** + * Resolve the target plan and every deterministic output path without writing. + * The native host can consume the same verified plan through + * `extractHostBuildInputs()` once its runtime lands. + */ +export function planEsp32P4Bundle( + input: string, + options: Esp32P4BundlePlanOptions = {}, +): Esp32P4BundleArtifacts { + const frameworkRoot = resolve(options.frameworkRoot ?? ESP32P4_FRAMEWORK_ROOT); + const manifestPath = resolveEsp32P4ManifestPath(input, { + ...options, + frameworkRoot, + }); + let manifest: unknown; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + throw new Error(`pocket esp32p4: invalid JSON in ${manifestPath}`, { + cause: error, + }); + } + const plan = resolveEsp32P4Waveshare7BBuildPlan(manifest); + const projectRoot = inferEsp32P4ProjectRoot(manifestPath, plan.app.entry); + const outputDirectory = frameworkRoot === ESP32P4_FRAMEWORK_ROOT + ? ESP32P4_BUNDLE_OUTPUT_DIRECTORY + : join(frameworkRoot, "dist/esp32p4"); + const basename = plan.app.output; + return { + target: { + id: ESP32P4_WAVESHARE_7B_DEV_TARGET_ID, + hostAbi: ESP32P4_WAVESHARE_7B_DEV_HOST_ABI, + }, + board: { + id: ESP32P4_WAVESHARE_7B_BOARD_ID, + panel: ESP32P4_WAVESHARE_7B_PANEL, + contentRect: ESP32P4_WAVESHARE_7B_CONTENT_RECT, + }, + manifestPath, + frameworkRoot, + projectRoot, + outputDirectory, + planPath: join(outputDirectory, `${basename}.plan.json`), + javascriptPath: join(outputDirectory, `${basename}.js`), + pakPath: join(outputDirectory, `${basename}.pak`), + plan, + }; +} + +export async function buildEsp32P4Bundle( + input: string, + options: Esp32P4BundlePlanOptions = {}, +): Promise { + const artifacts = planEsp32P4Bundle(input, options); + mkdirSync(artifacts.outputDirectory, { recursive: true }); + writeFileSync( + artifacts.planPath, + JSON.stringify(artifacts.plan, null, 2) + "\n", + ); + + const bun = Bun.which("bun") ?? process.execPath; + const build = Bun.spawn( + [ + bun, + join(artifacts.frameworkRoot, "tools/build.ts"), + `--plan=${artifacts.planPath}`, + `--project-root=${artifacts.projectRoot}`, + `--outdir=${artifacts.outputDirectory}`, + ], + { + cwd: artifacts.projectRoot, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ); + const exitCode = await build.exited; + if (exitCode !== 0) { + throw new Error(`pocket esp32p4: compiler failed with exit ${exitCode}`); + } + for (const output of [artifacts.javascriptPath, artifacts.pakPath]) { + if (!existsSync(output) || !statSync(output).isFile()) { + throw new Error(`pocket esp32p4: compiler did not produce ${output}`); + } + } + + console.log( + `PocketJS ESP32-P4 bundle: ${artifacts.plan.app.output} -> ` + + `${relative(artifacts.frameworkRoot, artifacts.outputDirectory)}/`, + ); + console.log( + ` host ${artifacts.target.id} ABI ${artifacts.target.hostAbi}; ` + + `panel ${artifacts.board.panel[0]}x${artifacts.board.panel[1]}, ` + + `content ${artifacts.board.contentRect.width}x${artifacts.board.contentRect.height}` + + `+${artifacts.board.contentRect.x}+${artifacts.board.contentRect.y}`, + ); + return artifacts; +} + +function usage(message?: string): never { + if (message) console.error(`pocket esp32p4: ${message}`); + console.error("usage: bun tools/esp32p4.ts "); + process.exit(1); +} + +if (import.meta.main) { + const args = Bun.argv.slice(2); + if (args.length !== 1 || args[0] === "--help" || args[0] === "-h") { + usage(args.length > 1 ? "expected exactly one app or manifest" : undefined); + } + await buildEsp32P4Bundle(args[0]); +} diff --git a/vapor/BOARDS.md b/vapor/BOARDS.md index 2ceb504f..cb160fae 100644 --- a/vapor/BOARDS.md +++ b/vapor/BOARDS.md @@ -1,9 +1,12 @@ # Boards: how the AOT target family scales -The MeowBit (PR #154) made Pocket Vapor's problem concrete: PocketJS just -built a capability system for PSP/Vita (`contracts/spec/platforms.ts`, -pocket.json v2), and the ESP32 fits none of its assumptions. This document -records the design that keeps both worlds honest as MCU boards multiply. +The MeowBit (PR #154) made Pocket Vapor's problem concrete, and the +Waveshare ESP32-P4-WIFI6-Touch-LCD-7B proved that the board layer also has +to span different MCU hosts without leaking their SDKs into app code. +PocketJS already built a capability system for PSP/Vita +(`contracts/spec/platforms.ts`, pocket.json v2), and these AOT devices fit +none of its assumptions. This document records the design that keeps both +worlds honest as MCU boards multiply. ## Two execution classes, two admission machineries @@ -27,7 +30,7 @@ observable framework behavior, never hardware. Fixed console-style AOT targets such as Playdate live directly in `VAPOR_TARGETS` and their runtime/compiler contract. They do not enter the -ESP32-only board JSON schema: a fixed SDK target is not an open-ended +MCU board JSON schema: a fixed SDK target is not an open-ended chip/panel/pin combination. A pocket.json may declare which classes it ships as @@ -36,7 +39,13 @@ refuses a manifest that ships no guest artifact (`execution.guestExcluded`). ## A board is data; the runtime contract stays code -`vapor/boards/.json` is the devicetree of one device: +`vapor/boards/.json` is the devicetree of one device. `chip` is the +tag of a strict union: `esp32` selects the classic SPI/GPIO host, while +`esp32p4` selects the P4 BSP/touch host. Their hardware fields cannot be +mixed. + +The classic profile remains byte-for-byte compatible with the MeowBit +profile: ```jsonc { @@ -58,23 +67,65 @@ refuses a manifest that ships no guest artifact (`execution.guestExcluded`). } ``` +The Waveshare P4 profile describes its BSP-owned EK79007 panel and GT911 +touch host instead of inventing SPI or button GPIOs: + +```jsonc +{ + "board": "waveshare-esp32-p4-wifi6-touch-lcd-7b", + "title": "Waveshare ESP32-P4-WIFI6-Touch-LCD-7B", + "chip": "esp32p4", + "lcd": { + "bsp": "waveshare-esp32-p4-wifi6-touch-lcd-7b", + "controller": "ek79007", + "width": 1024, "height": 600, + "cell": [30, 30] + }, + "input": { + "kind": "touch", + "controller": "gt911", + "virtualButtons": ["a", "b", "select", "start", "right", "left", "up", "down", "r"], + "absent": ["l"] + } +} +``` + +Both boards carry the same `VAPOR_TARGETS.esp32` 20×18 compiled grid. On +the P4, 30×30 physical cells make a 600×540 content surface starting at +`(16, 48)` on the left; the remaining right side of the 1024×600 landscape +panel holds the host-owned virtual D-pad and action buttons. The P4 runtime +retains the official LVGL example's 180° display rotation, which keeps the +BSP's displayed controls and transformed GT911 coordinates paired; that +rotation remains host code, not an application input concept. + `vapor/compiler/boards.ts` loads and validates it, then derives the compile definitions the ESP-IDF build injects (`boardDefinitions`). Adding a device -means adding a JSON file — never editing the compiler or the C. +within a supported chip host means adding a JSON file; adding a new chip +host still requires an explicit runtime and a new tagged schema branch. + +Three validation rules carry the weight: -Two validation rules carry the weight: +- **Chip branches are strict.** Unknown fields are rejected. A classic + profile must provide its SPI panel pins and six-button GPIO pad; a P4 + profile must provide the supported BSP, EK79007 panel, and GT911 touch + coverage. In particular, a P4 profile cannot claim fake GPIO buttons. - **Chords are pinned to the runtime.** The release-latch chord decoder is fixed in `runtime/esp32/vapor_esp32.c`; a board declares *which* of those chords its pad exposes, and validation rejects any pair that differs from the C. Data can describe the runtime; it cannot contradict it. + - **Coverage is total.** Every one of the ten Pocket buttons must have - exactly one spelling per board: a direct pad key, a runtime chord, or an - explicit `absent`. Silence is how coverage claims rot. + exactly one spelling per board: a direct pad key, a runtime chord, a + member of the host-owned touch `virtualButtons`, or an explicit `absent`. + The P4 host maps GT911 regions to the existing `Button` ids; application + code still sees only the hardware-neutral Pocket input contract. Silence + is how coverage claims rot. What deliberately stays code: the frame loop, the panel init sequences, the -chord decoder, the UART receipt protocol. A board file selects among -behaviors the runtime already has; it never programs new ones. +chord decoder, the P4 touch-region geometry, and the UART receipt protocol. +A board file selects among behaviors the runtime already has; it never +programs new ones. ## Demands are derived, never authored @@ -102,8 +153,8 @@ GBA's wide layout doesn't demand `R` from a 20×18 board. | code | severity | meaning | |---|---|---| | VB101 | error | logical grid × cell size does not fit the panel | -| VB102 | error | app uses a button the board neither wires nor chords | -| VB103 | warn | button only reachable as a two-key chord — the VS104 of input | +| VB102 | error | app uses a button for which the board declares no mapping | +| VB103 | warn | button only reachable as a two-key chord on a classic ESP32 board — the VS104 of input | `check` prints board rows in the same matrix as targets. Board rows inform; they never fail the check — an app is not obligated to fit every board, the @@ -138,15 +189,13 @@ don't enumerate monitors; they declare breakpoints and the client decides. ## Deliberately not built yet -- **ESP32 relative-axis adapters.** Vapor's generated ABI already models +- **ESP32-family relative-axis adapters.** Vapor's generated ABI already + models signed canonical `RelativeAxis` deltas and Playdate maps its crank to - Primary in millidegrees. An - ESP32 board may only declare an encoder/wheel after its runtime implements - pulse decoding, direction, and `pulsesPerStep`; until then admission must - fail instead of accepting an inert axis. -- **Per-board grid geometry.** Today the `esp32` target owns 20×18 and the - board must fit it. The second board with a different panel promotes - geometry to a board field and turns `VAPOR_TARGETS.esp32` into a family. + Primary in millidegrees. An ESP32-family board may only declare an + encoder/wheel after its runtime implements pulse decoding, direction, and + `pulsesPerStep`; until then admission must fail instead of accepting an + inert axis. - **Chord tables as data.** Needs the C decoder to read a generated table first; until then data is pinned to the runtime's fixed chords. - **A build service.** Source + companion-local compile is enough until diff --git a/vapor/README.md b/vapor/README.md index 937db309..101766ef 100644 --- a/vapor/README.md +++ b/vapor/README.md @@ -175,22 +175,26 @@ the 50×30 one-bit contract. ## Commands The ESP32 `flash` and default `verify` commands below write the connected -board; make a full-flash backup first as described in -[`runtime/esp32/README.md`](runtime/esp32/README.md). The standalone -`todo.esp32.bin` is app-only and, if written manually, belongs at -`0x10000`—never offset zero. Prefer the segmented flash script. +board. Make a full-flash backup first using the target-specific instructions: +[`runtime/esp32/README.md`](runtime/esp32/README.md) for the 4 MiB MeowBit, +or [`runtime/esp32p4/README.md`](runtime/esp32p4/README.md) for the 32 MiB +Waveshare 7B. Application-only images belong at `0x10000`, never offset +zero; prefer the target's segmented flash script. ```sh bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx # → dist/vapor/todo.gba bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb # → todo.gb (32 KB) bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes # → todo.nes (40 KB) -bun run vapor:esp32 # → app-only todo.esp32.bin + gen-esp32/ +bun run vapor:esp32 # → MeowBit app-only todo.esp32.bin + gen-esp32/ +bun run vapor:esp32p4 # → Waveshare 7B app + board-scoped ESP-IDF project bun run vapor:playdate # → crank-driven Todo Simulator .pdx bun run vapor:playdate:device # → crank-driven Todo device .pdx bun run vapor:playdate:both # → both independent .pdx packages bun run vapor:playdate:smoke # → six-button regression fixture bun run vapor:esp32:flash # build + flash the connected ESP32 MeowBit bun run vapor:esp32:verify # build + flash + replay the Vue-oracle tape +bun run vapor:esp32p4:flash # build + flash the Waveshare ESP32-P4 7B board +bun run vapor:esp32p4:verify # flash + replay the same logical-grid tape bun vapor/scripts/play.ts # build + open in mGBA bun vapor/scripts/dev.ts [app.tsx] # visible oracle in the browser bun vapor/compiler/cli.ts check [--strict] # cross-target diagnostics matrix @@ -199,20 +203,40 @@ bun test vapor/tests/ # oracle + compiler + ``` Toolchains: `arm-none-eabi-gcc` + `mgba` (GBA/GB), `sdcc` + `rgbfix` (GB), -`cc65` (NES, emulated by the jsnes dev-dependency), **ESP-IDF v6.0.2**, -and the Playdate SDK CMake/pdc toolchain -(ESP32; set `IDF_PATH` / `IDF_TOOLS_PATH` when auto-discovery does not find -the installation). Oracle tests run with bun alone. Notable per-target facts the +`cc65` (NES, emulated by the jsnes dev-dependency), ESP-IDF, and the Playdate +SDK CMake/pdc toolchain. The MeowBit uses ESP-IDF v6.0.2; the Waveshare +ESP32-P4 uses ESP-IDF v5.5.4, the latest release in the vendor-recommended +v5.5.1-v5.5.4 range for this board. +Install the matching release, then set `IDF_PATH` / `IDF_TOOLS_PATH` when +auto-discovery does not find it. The scripts discover an existing toolchain +but do not download one; the macOS cache candidates are +`~/Library/Caches/esp-idf/v6.0.2` and `~/Library/Caches/esp-idf/v5.5.4`. +Oracle tests run with bun alone. Notable per-target facts the runtime absorbs: the console shadow grid IS the debug block (fixed WRAM/CPU-RAM addresses), so the harness reads the logical screen even while a 1 MHz SM83 trickles VRAM through vblank; DMG has one palette, so logical palettes map to baked glyph styles; NES fits grid + pool + views into 2 KB -of CPU RAM with the font in CHR-ROM; ESP32 rasterizes the same logical -20×18 grid into RGB565 on a 160×128 ST7735; Playdate maps a 50×30 grid +of CPU RAM with the font in CHR-ROM; the classic ESP32/MeowBit target +rasterizes the same logical 20×18 grid into RGB565 on a 160×128 ST7735; +Playdate maps a 50×30 grid byte-for-cell into its 400×240 1bpp framebuffer; and sdcc 4.6's SM83 port miscompiles some u8-by-u8 multiplies, so generated indexing is u16 pointer arithmetic and bit masks come from a ROM table. +The Waveshare target keeps its generated files separate at +`dist/vapor/todo.esp32-waveshare-esp32-p4-wifi6-touch-lcd-7b.bin` and +`dist/vapor/gen-esp32-waveshare-esp32-p4-wifi6-touch-lcd-7b/`. Its GT911 +controls render on screen and dispatch the same hardware-neutral `Button` +ids used by every Pocket Vapor app; device SDK touch concepts never enter +app code. The generated project pins the HW-V1.0-compatible Waveshare 7B BSP +at v1.0.4, LVGL at 9.2.x, and the LVGL port at v2.7.2 (the last release +before its RGB565 API began requiring newer LVGL); it also records the exact +official EK79007/GT911 example revision, targets the board's 32 MiB flash, +and retains ESP32-P4 revision-1 support. The complete registry resolution is +checked in at `runtime/esp32p4/dependencies.lock`, copied into every generated +project, and included in the firmware build id; a clean build therefore cannot +silently resolve different transitive drivers under an unchanged receipt. + ## Layout ``` diff --git a/vapor/boards/waveshare-esp32-p4-wifi6-touch-lcd-7b.json b/vapor/boards/waveshare-esp32-p4-wifi6-touch-lcd-7b.json new file mode 100644 index 00000000..a7b40984 --- /dev/null +++ b/vapor/boards/waveshare-esp32-p4-wifi6-touch-lcd-7b.json @@ -0,0 +1,18 @@ +{ + "board": "waveshare-esp32-p4-wifi6-touch-lcd-7b", + "title": "Waveshare ESP32-P4-WIFI6-Touch-LCD-7B", + "chip": "esp32p4", + "lcd": { + "bsp": "waveshare-esp32-p4-wifi6-touch-lcd-7b", + "controller": "ek79007", + "width": 1024, + "height": 600, + "cell": [30, 30] + }, + "input": { + "kind": "touch", + "controller": "gt911", + "virtualButtons": ["a", "b", "select", "start", "right", "left", "up", "down", "r"], + "absent": ["l"] + } +} diff --git a/vapor/compiler/boards.ts b/vapor/compiler/boards.ts index 42b15e2d..c6c0d16c 100644 --- a/vapor/compiler/boards.ts +++ b/vapor/compiler/boards.ts @@ -38,7 +38,13 @@ export const RUNTIME_CHORDS: Readonly { return value !== null && typeof value === "object" && !Array.isArray(value); } +function requireKnownKeys( + name: string, + value: Record, + what: string, + allowed: readonly string[], +): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw new BoardError(name, `unknown ${what} field ${JSON.stringify(key)}`); + } +} + +function requireButtonList(name: string, value: unknown, what: string): PocketButtonName[] { + if (!Array.isArray(value)) throw new BoardError(name, `${what} must be an array`); + const buttons: PocketButtonName[] = []; + for (const button of value) { + if (!(POCKET_PAD as readonly unknown[]).includes(button)) + throw new BoardError(name, `unknown pocket button ${JSON.stringify(button)} in ${what}`); + if (buttons.includes(button as PocketButtonName)) + throw new BoardError(name, `duplicate pocket button ${JSON.stringify(button)} in ${what}`); + buttons.push(button as PocketButtonName); + } + return buttons; +} + function requireInt(name: string, value: unknown, what: string, min: number, max: number): number { if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new BoardError(name, `${what} must be an integer in [${min}, ${max}], got ${JSON.stringify(value)}`); @@ -82,16 +136,92 @@ function requireInt(name: string, value: unknown, what: string, min: number, max /** Validate a raw board document; throws a descriptive error on any defect. */ export function parseBoard(name: string, raw: unknown): VaporBoard { if (!isRecord(raw)) throw new BoardError(name, "document must be a JSON object"); + requireKnownKeys(name, raw, "board", ["board", "title", "chip", "lcd", "input"]); if (raw.board !== name) throw new BoardError(name, `"board" must equal the file name, got ${JSON.stringify(raw.board)}`); if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new BoardError(name, "board names are lowercase kebab-case"); if (typeof raw.title !== "string" || raw.title.length === 0) throw new BoardError(name, '"title" must be a non-empty string'); - if (raw.chip !== "esp32") - throw new BoardError(name, `the only board runtime today is "esp32", got ${JSON.stringify(raw.chip)}`); + if (raw.chip !== "esp32" && raw.chip !== "esp32p4") + throw new BoardError(name, `chip must be "esp32" or "esp32p4", got ${JSON.stringify(raw.chip)}`); const lcd = raw.lcd; if (!isRecord(lcd)) throw new BoardError(name, '"lcd" must be an object'); + + if (raw.chip === "esp32p4") { + requireKnownKeys(name, lcd, "lcd", ["bsp", "controller", "width", "height", "cell"]); + if (lcd.bsp !== ESP32P4_BSP) + throw new BoardError( + name, + `lcd.bsp must be "${ESP32P4_BSP}", got ${JSON.stringify(lcd.bsp)}`, + ); + if (lcd.controller !== ESP32P4_PANEL.controller) + throw new BoardError( + name, + `lcd.controller must be "${ESP32P4_PANEL.controller}", got ${JSON.stringify(lcd.controller)}`, + ); + const width = requireInt(name, lcd.width, "lcd.width", 1, 4096); + const height = requireInt(name, lcd.height, "lcd.height", 1, 4096); + if (width !== ESP32P4_PANEL.width || height !== ESP32P4_PANEL.height) + throw new BoardError( + name, + `the ${ESP32P4_PANEL.controller} BSP panel must be ${ESP32P4_PANEL.width}x${ESP32P4_PANEL.height}, got ${width}x${height}`, + ); + if (!Array.isArray(lcd.cell) || lcd.cell.length !== 2) + throw new BoardError(name, "lcd.cell must be [width, height]"); + const cell = [ + requireInt(name, lcd.cell[0], "lcd.cell[0]", 1, 256), + requireInt(name, lcd.cell[1], "lcd.cell[1]", 1, 256), + ] as const; + if (cell[0] !== ESP32P4_PANEL.cell[0] || cell[1] !== ESP32P4_PANEL.cell[1]) + throw new BoardError( + name, + `the ${ESP32P4_BSP} touch layout requires lcd.cell [${ESP32P4_PANEL.cell.join(", ")}], got ${JSON.stringify(cell)}`, + ); + + const input = raw.input; + if (!isRecord(input)) throw new BoardError(name, '"input" must be an object'); + requireKnownKeys(name, input, "input", ["kind", "controller", "virtualButtons", "absent"]); + if (input.kind !== "touch") + throw new BoardError(name, `input.kind must be "touch", got ${JSON.stringify(input.kind)}`); + if (input.controller !== "gt911") + throw new BoardError(name, `input.controller must be "gt911", got ${JSON.stringify(input.controller)}`); + const virtualButtons = requireButtonList(name, input.virtualButtons, "input.virtualButtons"); + const absent = requireButtonList(name, input.absent, "input.absent"); + + for (const button of POCKET_PAD) { + const spellings = [virtualButtons.includes(button), absent.includes(button)].filter(Boolean).length; + if (spellings !== 1) + throw new BoardError( + name, + `pocket button "${button}" must have exactly one spelling (virtual button or absent), found ${spellings}`, + ); + } + if ( + virtualButtons.length !== ESP32P4_VIRTUAL_BUTTONS.length || + ESP32P4_VIRTUAL_BUTTONS.some((button) => !virtualButtons.includes(button)) + ) + throw new BoardError( + name, + `input.virtualButtons must match the ${ESP32P4_BSP} touch layout ${JSON.stringify(ESP32P4_VIRTUAL_BUTTONS)}`, + ); + + return { + board: name, + title: raw.title, + chip: "esp32p4", + lcd: { + bsp: ESP32P4_BSP, + controller: ESP32P4_PANEL.controller, + width, + height, + cell: ESP32P4_PANEL.cell, + }, + input: { kind: "touch", controller: "gt911", virtualButtons, absent }, + }; + } + + requireKnownKeys(name, lcd, "lcd", ["controller", "width", "height", "cell", "madctl", "pins"]); if (typeof lcd.controller !== "string" || !(lcd.controller in LCD_CONTROLLERS)) throw new BoardError(name, `lcd.controller must be one of ${Object.keys(LCD_CONTROLLERS).join(", ")}`); const width = requireInt(name, lcd.width, "lcd.width", 1, 1024); @@ -114,10 +244,11 @@ export function parseBoard(name: string, raw: unknown): VaporBoard { const wired = pin === "sclk" || pin === "mosi" || pin === "cs" || pin === "dc"; return [pin, requireInt(name, rawLcdPins[pin], `lcd.pins.${pin}`, wired ? 0 : -1, 48)]; }), - ) as VaporBoard["lcd"]["pins"]; + ) as Esp32Board["lcd"]["pins"]; const input = raw.input; if (!isRecord(input)) throw new BoardError(name, '"input" must be an object'); + requireKnownKeys(name, input, "input", ["pins", "chorded", "absent"]); const rawPadPins = input.pins; if (!isRecord(rawPadPins)) throw new BoardError(name, '"input.pins" must be an object'); for (const extra of Object.keys(rawPadPins)) @@ -130,7 +261,7 @@ export function parseBoard(name: string, raw: unknown): VaporBoard { PAD_KEYS.map((key) => [key, requireInt(name, rawPadPins[key], `input.pins.${key}`, 0, 48)]), ) as Record; - const chorded: VaporBoard["input"]["chorded"] = {}; + const chorded: Esp32Board["input"]["chorded"] = {}; if (input.chorded !== undefined) { if (!isRecord(input.chorded)) throw new BoardError(name, '"input.chorded" must be an object'); for (const [button, pair] of Object.entries(input.chorded)) { @@ -146,15 +277,7 @@ export function parseBoard(name: string, raw: unknown): VaporBoard { } } - const absent: PocketButtonName[] = []; - if (input.absent !== undefined) { - if (!Array.isArray(input.absent)) throw new BoardError(name, '"input.absent" must be an array'); - for (const button of input.absent) { - if (!(POCKET_PAD as readonly string[]).includes(button)) - throw new BoardError(name, `unknown pocket button ${JSON.stringify(button)} in input.absent`); - absent.push(button as PocketButtonName); - } - } + const absent = input.absent === undefined ? [] : requireButtonList(name, input.absent, "input.absent"); // Every pocket button must be accounted for exactly once: direct pad key, // runtime chord, or declared absent. Silence is how coverage claims rot. @@ -204,6 +327,30 @@ export function listBoards(): string[] { * the derivation must stay byte-stable for a given board file. */ export function boardDefinitions(board: VaporBoard): string[] { + if (board.chip === "esp32p4") { + const virtualMask = board.input.virtualButtons.reduce( + (mask, button) => mask | (1 << POCKET_PAD.indexOf(button)), + 0, + ); + const absentMask = board.input.absent.reduce( + (mask, button) => mask | (1 << POCKET_PAD.indexOf(button)), + 0, + ); + return [ + `VP_BOARD_ID=\\"${board.board}\\"`, + `VP_CHIP_ID=\\"${board.chip}\\"`, + `VP_BSP_ID=\\"${board.lcd.bsp}\\"`, + `VP_PANEL_ID=\\"${board.lcd.controller}\\"`, + `VP_LCD_WIDTH=${board.lcd.width}`, + `VP_LCD_HEIGHT=${board.lcd.height}`, + `VP_LCD_CELL_W=${board.lcd.cell[0]}`, + `VP_LCD_CELL_H=${board.lcd.cell[1]}`, + `VP_TOUCH_ID=\\"${board.input.controller}\\"`, + `VP_TOUCH_BUTTON_MASK=0x${virtualMask.toString(16)}`, + `VP_ABSENT_BUTTON_MASK=0x${absentMask.toString(16)}`, + ]; + } + const { lcd, input } = board; return [ `VP_ESP32_BOARD=\\"${board.board}\\"`, @@ -262,6 +409,15 @@ export function admitBoard( issues.push({ code: "VB102", severity: "error", message: `unknown button id ${id} in demands` }); continue; } + if (board.chip === "esp32p4") { + if (board.input.virtualButtons.includes(button)) continue; + issues.push({ + code: "VB102", + severity: "error", + message: `app uses "${button}" but ${board.board} has no mapping for it`, + }); + continue; + } if ((PAD_KEYS as readonly string[]).includes(button)) continue; if (button in board.input.chorded) { const pair = board.input.chorded[button]!; diff --git a/vapor/compiler/cli.ts b/vapor/compiler/cli.ts index cf5010b1..1abd25df 100644 --- a/vapor/compiler/cli.ts +++ b/vapor/compiler/cli.ts @@ -2,7 +2,7 @@ // vapor/compiler/cli.ts — compile a Pocket Vapor component to a cartridge. // // bun vapor/compiler/cli.ts [--target gba|gb|nes|esp32|playdate] [--out dist/vapor] -// [--playdate-mode simulator|device|both] +// [--board meowbit] [--playdate-mode simulator|device|both] // bun vapor/compiler/cli.ts check [--strict] [--json] // // `check` runs the compiler frontend for EVERY target and prints the @@ -16,6 +16,7 @@ import { basename, join, resolve } from "node:path"; import { admitBoard, listBoards, loadBoard, POCKET_PAD, type BoardIssue } from "./boards.ts"; import { compileVaporApp, VAPOR_TARGETS, type CompiledApp, type VaporTargetName } from "./compile.ts"; +import { esp32ArtifactStem } from "./esp32.ts"; import { buildRom } from "./rom.ts"; import type { PlaydateBuildMode } from "./playdate.ts"; @@ -109,7 +110,7 @@ const entry = args.find((a) => !a.startsWith("--")); if (!entry) { console.error( "usage: bun vapor/compiler/cli.ts [--target gba|gb|nes|esp32|playdate] " + - "[--out ] [--playdate-mode simulator|device|both]", + "[--out ] [--board ] [--playdate-mode simulator|device|both]", ); process.exit(2); } @@ -133,6 +134,14 @@ if (target !== "playdate" && playdateModeIdx >= 0) { console.error("--playdate-mode requires --target playdate"); process.exit(2); } +const boardIdx = args.indexOf("--board"); +if (target !== "esp32" && boardIdx >= 0) { + console.error("--board requires --target esp32"); + process.exit(2); +} +const esp32Board = target === "esp32" + ? loadBoard(boardIdx >= 0 ? args[boardIdx + 1] : "meowbit") + : undefined; const source = await Bun.file(entry).text(); const name = basename(entry).replace(/\.tsx$/, ""); @@ -156,12 +165,12 @@ const ext = : target === "nes" ? "nes" : target === "esp32" - ? "esp32.bin" + ? `${esp32ArtifactStem(esp32Board!)}.bin` : target === "playdate" ? null : target satisfies never; const output = ext ? join(outDir, `${name}.${ext}`) : join(outDir, name); -const artifacts = await buildRom(app, target, output, { playdateMode }); +const artifacts = await buildRom(app, target, output, { playdateMode, esp32Board }); await Bun.write(join(outDir, `${name}.${target}.debug.json`), JSON.stringify(app.debugSlots, null, 2)); for (const artifact of artifacts) { const platform = artifact.platform ? `/${artifact.platform}` : ""; diff --git a/vapor/compiler/esp32.ts b/vapor/compiler/esp32.ts index af4c34c5..05a3b4cd 100644 --- a/vapor/compiler/esp32.ts +++ b/vapor/compiler/esp32.ts @@ -1,5 +1,5 @@ -// vapor/compiler/esp32.ts — package a generated Pocket Vapor app as a -// classic-ESP32 application image. +// vapor/compiler/esp32.ts — package a generated Pocket Vapor app as an +// ESP-IDF application image. // // The generated ESP-IDF project is intentionally retained next to the // firmware. Besides making the build reproducible, this gives the hardware @@ -13,8 +13,69 @@ import { admitBoard, boardDefinitions, loadBoard, type VaporBoard } from "./boar import { VAPOR_TARGETS, type CompiledApp } from "./compile.ts"; const RUNTIME = resolve(import.meta.dir, "..", "runtime"); -const IDF_VERSION = "v6.0.2"; export const DEFAULT_BOARD = "meowbit"; +export const ESP32P4_DEPENDENCY_LOCK = join(RUNTIME, "esp32p4", "dependencies.lock"); +export const ESP32P4_WAVESHARE_REFERENCE_COMMIT = "c39554b3299f86403cd36820f6fa4767c84ef5f1"; +export const ESP32P4_WAVESHARE_BSP_VERSION = "1.0.4"; +export const ESP32P4_WAVESHARE_BSP_COMMIT = "83cf66c7effd60394c7d2f6eff39b93f2bcf3664"; +export const ESP32P4_LVGL_PORT_VERSION = "2.7.2"; +export const ESP32P4_LVGL_PORT_RGB565_SWAPPED_COMMIT = + "327344fee53d0110828bf185f0d9eb1f684e5c80"; + +const IDF_RELEASES = { + esp32: { version: "v6.0.2", cacheKey: "v6.0.2" }, + esp32p4: { version: "v5.5.4", cacheKey: "v5.5.4" }, +} as const; + +export function esp32IdfVersion(board: VaporBoard): "v5.5.4" | "v6.0.2" { + return IDF_RELEASES[board.chip].version; +} + +const ESP32P4_WAVESHARE_COMPONENT = { + name: "waveshare/esp32_p4_wifi6_touch_lcd_7b", + version: ESP32P4_WAVESHARE_BSP_VERSION, +} as const; + +/** Keep each non-default board's generated project away from the MeowBit + * project, sdkconfig cache, and managed-component lockfile. */ +export function esp32ProjectName(board: VaporBoard): string { + return board.board === DEFAULT_BOARD ? "gen-esp32" : `gen-esp32-${board.board}`; +} + +/** Preserve the existing MeowBit artifact name while preventing another + * board's firmware or receipt from overwriting it. */ +export function esp32ArtifactStem(board: VaporBoard): string { + return board.board === DEFAULT_BOARD ? "esp32" : `esp32-${board.board}`; +} + +function runtimeName(board: VaporBoard): "esp32" | "esp32p4" { + return board.chip; +} + +function flashBytes(board: VaporBoard): number { + return board.chip === "esp32p4" ? 32 * 1024 * 1024 : 4 * 1024 * 1024; +} + +function componentManifest(board: VaporBoard): string | undefined { + if (board.chip !== "esp32p4") return undefined; + const component = ESP32P4_WAVESHARE_COMPONENT; + return [ + "# GENERATED by vapor/compiler/esp32.ts. DO NOT EDIT.", + "# Hardware reference:", + `# https://github.com/waveshareteam/ESP32-P4-WIFI6-Touch-LCD-7B/tree/${ESP32P4_WAVESHARE_REFERENCE_COMMIT}/examples/ESP-IDF/10_lvgl_demo_v9`, + `# BSP v${ESP32P4_WAVESHARE_BSP_VERSION} source commit: ${ESP32P4_WAVESHARE_BSP_COMMIT}`, + `# esp_lvgl_port post-v${ESP32P4_LVGL_PORT_VERSION} RGB565 API change: ${ESP32P4_LVGL_PORT_RGB565_SWAPPED_COMMIT}`, + "dependencies:", + ` ${component.name}: "${component.version}"`, + // 2.8.0 added LV_COLOR_FORMAT_RGB565_SWAPPED without guarding it for + // LVGL 9.2, while 2.7.2 includes the LVGL 9.2 software-rotation fixes. + ` espressif/esp_lvgl_port: "${ESP32P4_LVGL_PORT_VERSION}"`, + // The official LVGL 9 example owns this application-level constraint; + // the BSP itself intentionally supports both LVGL 8 and 9. + ' lvgl/lvgl: "9.2.*"', + "", + ].join("\n"); +} function cmakeString(value: string): string { return `"${value.replaceAll("\\", "/").replaceAll('"', '\\"')}"`; @@ -69,6 +130,7 @@ async function runIdf( export interface EspIdfEnvironment { idfPath: string; idfToolsPath: string; + idfVersion: "v5.5.4" | "v6.0.2"; } function firstDirectory( @@ -85,14 +147,17 @@ function firstDirectory( ); } -/** Locate an explicit, cached, or standard ESP-IDF installation. */ -export function resolveEspIdfEnvironment(): EspIdfEnvironment { +/** Locate the board's explicit, cached, or standard ESP-IDF installation. */ +export function resolveEspIdfEnvironment( + board: VaporBoard = loadBoard(DEFAULT_BOARD), +): EspIdfEnvironment { const home = homedir(); + const release = IDF_RELEASES[board.chip]; return { idfPath: firstDirectory( process.env.IDF_PATH, [ - join(home, "Library", "Caches", "esp-idf", IDF_VERSION), + join(home, "Library", "Caches", "esp-idf", release.cacheKey), join(home, "esp", "esp-idf"), ], "export.sh", @@ -105,21 +170,40 @@ export function resolveEspIdfEnvironment(): EspIdfEnvironment { ], "python_env", ), + idfVersion: release.version, }; } +async function requireMatchingIdfRelease(environment: EspIdfEnvironment): Promise { + const versionFile = join(environment.idfPath, "tools", "cmake", "version.cmake"); + await requireFile(versionFile, `${environment.idfVersion} ESP-IDF version metadata`); + const source = await Bun.file(versionFile).text(); + const part = (name: "MAJOR" | "MINOR" | "PATCH"): string | undefined => + source.match(new RegExp(`set\\(IDF_VERSION_${name}\\s+([0-9]+)\\)`))?.[1]; + const actual = `v${part("MAJOR")}.${part("MINOR")}.${part("PATCH")}`; + if (actual !== environment.idfVersion) { + throw new Error( + `ESP-IDF release mismatch: ${environment.idfPath} is ${actual}, but this board requires ${environment.idfVersion}`, + ); + } +} + /** Run a command in the matching ESP-IDF environment used by the builder. */ -export async function runEspIdf(args: string[]): Promise { - const { idfPath, idfToolsPath } = resolveEspIdfEnvironment(); +export async function runEspIdf( + args: string[], + environment: EspIdfEnvironment = resolveEspIdfEnvironment(), +): Promise { + const { idfPath, idfToolsPath } = environment; await requireFile(join(idfPath, "export.sh"), "ESP-IDF export script"); await runIdf(idfPath, idfToolsPath, args); } function mainCmake(debugStateBytes: number, buildId: string, board: VaporBoard): string { const target = VAPOR_TARGETS.esp32; + const runtime = runtimeName(board); const sources = [ join(RUNTIME, "vapor_core.c"), - join(RUNTIME, "esp32", "vapor_esp32.c"), + join(RUNTIME, runtime, `vapor_${runtime}.c`), "gen_app.c", ]; const definitions = [ @@ -132,13 +216,17 @@ function mainCmake(debugStateBytes: number, buildId: string, board: VaporBoard): ...boardDefinitions(board), ]; + const requirements = board.chip === "esp32p4" + ? "waveshare__esp32_p4_wifi6_touch_lcd_7b esp_driver_uart esp_timer vfs" + : "esp_driver_gpio esp_driver_spi esp_driver_uart esp_timer vfs"; + return [ "# GENERATED by vapor/compiler/esp32.ts. DO NOT EDIT.", "idf_component_register(", " SRCS", ...sources.map((source) => ` ${cmakeString(source)}`), ` INCLUDE_DIRS ${cmakeString(RUNTIME)}`, - " REQUIRES esp_driver_gpio esp_driver_spi esp_driver_uart esp_timer vfs", + ` REQUIRES ${requirements}`, ")", "", "target_compile_definitions(${COMPONENT_LIB} PRIVATE", @@ -153,6 +241,7 @@ export interface Esp32BuildResult { projectDir: string; buildDir: string; buildId: string; + idfEnvironment: EspIdfEnvironment; } /** Identity of the generated app plus every source/config input flashed. @@ -160,13 +249,22 @@ export interface Esp32BuildResult { * refactor that keeps the definitions byte-identical keeps the id. */ export async function esp32BuildId(app: CompiledApp, board: VaporBoard): Promise { const hasher = new Bun.CryptoHasher("sha256"); - hasher.update(`${IDF_VERSION}\n${JSON.stringify(boardDefinitions(board))}\n${app.c}\n`); - for (const path of [ + const runtime = runtimeName(board); + const idfVersion = IDF_RELEASES[board.chip].version; + hasher.update(`${idfVersion}\n${JSON.stringify(boardDefinitions(board))}\n`); + const manifest = componentManifest(board); + // Classic ESP32 boards had no component manifest in their historical hash + // preimage. Do not insert an empty-manifest newline and invalidate receipts. + if (manifest) hasher.update(`${manifest}\n`); + hasher.update(`${app.c}\n`); + const inputs = [ join(RUNTIME, "vapor.h"), join(RUNTIME, "vapor_core.c"), - join(RUNTIME, "esp32", "vapor_esp32.c"), - join(RUNTIME, "esp32", "sdkconfig.defaults"), - ]) { + join(RUNTIME, runtime, `vapor_${runtime}.c`), + join(RUNTIME, runtime, "sdkconfig.defaults"), + ]; + if (board.chip === "esp32p4") inputs.push(ESP32P4_DEPENDENCY_LOCK); + for (const path of inputs) { hasher.update(await Bun.file(path).arrayBuffer()); } return hasher.digest("hex").slice(0, 16); @@ -178,15 +276,19 @@ export async function buildEsp32Firmware( board: VaporBoard = loadBoard(DEFAULT_BOARD), ): Promise { const target = VAPOR_TARGETS.esp32; - const refusals = admitBoard({ buttonsUsed: [] }, board, target).filter((i) => i.severity === "error"); + const refusals = admitBoard({ buttonsUsed: app.buttonsUsed }, board, target).filter( + (issue) => issue.severity === "error", + ); if (refusals.length > 0) { throw new Error(`board ${board.board} cannot host the ${target.width}x${target.height} grid:\n${refusals.map((i) => `${i.code} ${i.message}`).join("\n")}`); } const output = resolve(outFirmware); const outputDir = dirname(output); - const projectDir = join(outputDir, "gen-esp32"); + const runtime = runtimeName(board); + const projectDir = join(outputDir, esp32ProjectName(board)); const mainDir = join(projectDir, "main"); const buildDir = join(projectDir, "build"); + const idfEnvironment = resolveEspIdfEnvironment(board); const buildId = await esp32BuildId(app, board); const debugStateEnd = Math.max( 1, @@ -196,16 +298,25 @@ export async function buildEsp32Firmware( // is a string; include that tail padding in the runtime-owned buffer. const debugStateBytes = (debugStateEnd + 3) & ~3; - await requireFile(join(RUNTIME, "esp32", "sdkconfig.defaults"), "ESP32 sdkconfig defaults"); + await requireFile(join(RUNTIME, runtime, "sdkconfig.defaults"), `${board.chip} sdkconfig defaults`); + if (board.chip === "esp32p4") { + await requireFile(ESP32P4_DEPENDENCY_LOCK, "ESP32-P4 managed-component lock"); + } await mkdir(mainDir, { recursive: true }); await Bun.write(join(mainDir, "gen_app.c"), app.c); await Bun.write(join(mainDir, "CMakeLists.txt"), mainCmake(debugStateBytes, buildId, board)); + const manifest = componentManifest(board); + if (manifest) await Bun.write(join(mainDir, "idf_component.yml"), manifest); + if (board.chip === "esp32p4") { + await Bun.write(join(projectDir, "dependencies.lock"), Bun.file(ESP32P4_DEPENDENCY_LOCK)); + } await Bun.write( join(projectDir, "CMakeLists.txt"), [ "# GENERATED by vapor/compiler/esp32.ts. DO NOT EDIT.", "cmake_minimum_required(VERSION 3.22)", + ...(board.chip === "esp32p4" ? ['set(IDF_TARGET "esp32p4")'] : []), "include($ENV{IDF_PATH}/tools/cmake/project.cmake)", "idf_build_set_property(MINIMAL_BUILD ON)", "project(pocket_vapor)", @@ -214,7 +325,7 @@ export async function buildEsp32Firmware( ); await Bun.write( join(projectDir, "sdkconfig.defaults"), - Bun.file(join(RUNTIME, "esp32", "sdkconfig.defaults")), + Bun.file(join(RUNTIME, runtime, "sdkconfig.defaults")), ); // sdkconfig is generated state, not an input. Removing it makes changes to // sdkconfig.defaults deterministic while preserving the compiler cache. @@ -228,26 +339,40 @@ export async function buildEsp32Firmware( // gaps between bootloader, partition table, and app with 0xff, so writing // it at offset zero would erase the ESP32's NVS/PHY partitions. The flash // command below remains IDF's partition-aware segmented operation. - await runEspIdf([ - "-C", - projectDir, - "-B", - buildDir, - "build", + await requireFile( + join(idfEnvironment.idfPath, "export.sh"), + `${idfEnvironment.idfVersion} ESP-IDF export script`, + ); + await requireMatchingIdfRelease(idfEnvironment); + await runIdf(idfEnvironment.idfPath, idfEnvironment.idfToolsPath, [ + "-C", projectDir, "-B", buildDir, "build", ]); + if (board.chip === "esp32p4") { + const generatedLock = await Bun.file(join(projectDir, "dependencies.lock")).text(); + const pinnedLock = await Bun.file(ESP32P4_DEPENDENCY_LOCK).text(); + if (generatedLock !== pinnedLock) { + throw new Error( + `ESP-IDF changed the pinned component lock; review and update ${ESP32P4_DEPENDENCY_LOCK}`, + ); + } + } const appImage = join(buildDir, "pocket_vapor.bin"); await requireFile(appImage, "ESP32 application image"); await Bun.write(output, Bun.file(appImage)); const firmware = Bun.file(output); await requireFile(output, "ESP32 application image"); - if (firmware.size === 0 || firmware.size > 4 * 1024 * 1024 - 0x10000) { - throw new Error(`invalid ESP32 application image size ${firmware.size}: ${output}`); + const maxAppBytes = flashBytes(board) - 0x10000; + if (firmware.size === 0 || firmware.size > maxAppBytes) { + throw new Error( + `invalid ${board.chip} application image size ${firmware.size} (limit ${maxAppBytes}): ${output}`, + ); } return { romBytes: firmware.size, projectDir, buildDir, buildId, + idfEnvironment, }; } diff --git a/vapor/compiler/rom.ts b/vapor/compiler/rom.ts index 0aeca0a0..3162d648 100644 --- a/vapor/compiler/rom.ts +++ b/vapor/compiler/rom.ts @@ -11,6 +11,7 @@ import { $ } from "bun"; import { dirname, join } from "node:path"; import { nesFontBytes, VAPOR_TARGETS, type CompiledApp, type VaporTargetName } from "./compile.ts"; import { buildEsp32Firmware } from "./esp32.ts"; +import type { VaporBoard } from "./boards.ts"; import { buildPlaydatePackages, type PlaydateBuildMode, @@ -54,6 +55,7 @@ export interface BuiltArtifact { export interface BuildRomOptions { playdateMode?: PlaydateBuildMode; + esp32Board?: VaporBoard; } type SingleArtifactTarget = Exclude; @@ -62,6 +64,7 @@ async function buildSingleArtifact( app: CompiledApp, target: SingleArtifactTarget, outPath: string, + options: BuildRomOptions, ): Promise { if (target === "gba") { const { romBytes } = await buildGbaRom(app, outPath); @@ -76,7 +79,7 @@ async function buildSingleArtifact( return { path: outPath, kind: "rom", bytes: romBytes }; } if (target === "esp32") { - const { romBytes } = await buildEsp32Firmware(app, outPath); + const { romBytes } = await buildEsp32Firmware(app, outPath, options.esp32Board); return { path: outPath, kind: "firmware", bytes: romBytes }; } target satisfies never; @@ -110,7 +113,7 @@ export async function buildRom( bytes, })); } - return [await buildSingleArtifact(app, target, outPath)]; + return [await buildSingleArtifact(app, target, outPath, options)]; } // ---- GBA ------------------------------------------------------------------- diff --git a/vapor/runtime/esp32p4/README.md b/vapor/runtime/esp32p4/README.md new file mode 100644 index 00000000..ad912787 --- /dev/null +++ b/vapor/runtime/esp32p4/README.md @@ -0,0 +1,135 @@ +# Pocket Vapor ESP32-P4 runtime + +This runtime targets one exact board: the +Waveshare ESP32-P4-WIFI6-Touch-LCD-7B HW V1.0. It uses the 1024×600 +EK79007 MIPI-DSI panel, GT911 touch controller, and 32 MiB flash. Pocket +Vapor renders its 20×18 logical grid and nine hardware-neutral virtual +buttons through LVGL; no JavaScript engine runs on the device. + +The reproducible toolchain is ESP-IDF v5.5.4, Waveshare BSP v1.0.4, +EK79007 driver v1.0.4, `esp_lvgl_port` v2.7.2, and LVGL v9.2.2. The +resolved component graph is checked in as [`dependencies.lock`](dependencies.lock). + +## Build, flash, and verify + +The board-scoped commands and artifacts are separate from the classic ESP32 +MeowBit target: + +```sh +bun run vapor:esp32p4 +# dist/vapor/todo.esp32-waveshare-esp32-p4-wifi6-touch-lcd-7b.bin +# dist/vapor/gen-esp32-waveshare-esp32-p4-wifi6-touch-lcd-7b/ + +bun run vapor:esp32p4:flash +bun run vapor:esp32p4:verify +``` + +Both `flash` and the default `verify` write the connected board. Pass an +explicit port when more than one serial device is present. To verify an +already-flashed matching build without writing flash, opt in explicitly: + +```sh +bun vapor/scripts/esp32.ts verify \ + --board waveshare-esp32-p4-wifi6-touch-lcd-7b \ + --no-flash \ + --port /dev/cu.usbmodem5B901842141 +``` + +## Make a full backup before the first flash + +Do **not** reuse the MeowBit backup command. This board requires +`--chip esp32p4` and a full `0x2000000`-byte (32 MiB) read. A 4 MiB read is +not a complete backup. + +First use `chip-id` and `flash-id` to confirm the connected ESP32-P4, MAC, +and 32 MiB flash. Name the backup directory from the lowercase MAC without +colons so images from different boards cannot be confused. The following +macOS example uses the stable 230400-baud read used on the connected board: + +```sh +PORT="/dev/cu/..." +DEVICE_KEY="esp32p4-<12-hex-mac>" +BACKUP_DIR="${HOME}/Library/Caches/pocketjs/device-backups/${DEVICE_KEY}" +BACKUP="${BACKUP_DIR}/$(date +%F)-pre-pocket-vapor-full-flash.bin" +BACKUP_NAME="$(basename "$BACKUP")" + +uvx --from esptool esptool --chip esp32p4 --port "$PORT" chip-id +uvx --from esptool esptool --chip esp32p4 --port "$PORT" read-mac +uvx --from esptool esptool --chip esp32p4 --port "$PORT" flash-id +mkdir -p "$BACKUP_DIR" +uvx --from esptool esptool --chip esp32p4 --port "$PORT" --baud 230400 \ + read-flash 0x0 0x2000000 "$BACKUP" +test "$(stat -f '%z' "$BACKUP")" = 33554432 +( + cd "$BACKUP_DIR" + shasum -a 256 "$BACKUP_NAME" > "${BACKUP_NAME}.sha256" + shasum -a 256 -c "${BACKUP_NAME}.sha256" +) +``` + +Keep the backup and checksum outside `dist/`, because generated output is +disposable. Do not interrupt the read, accept a short file, or use an image +captured from another board: the full image can contain device-specific +factory state. + +For the device connected during the 2026-08-03 bring-up, the pre-flash +evidence is: + +- MAC: `e8:f6:0a:e6:cf:2f` +- backup: `/Users/evan/Library/Caches/pocketjs/device-backups/esp32p4-e8f60ae6cf2f/2026-08-03-pre-pocket-vapor-full-flash.bin` +- checksum sidecar: `/Users/evan/Library/Caches/pocketjs/device-backups/esp32p4-e8f60ae6cf2f/2026-08-03-pre-pocket-vapor-full-flash.bin.sha256` +- size: `33,554,432` bytes +- SHA-256: `212d5892d2a973541fc0144845987741e56d3bed733cf8d2f3ba7e4fcc303789` + +This is live-machine evidence, not a file stored in this repository. + +## Segmented Pocket Vapor flash layout + +The generated `.bin` next to `dist/vapor` is the **application-only** image. +It belongs at `0x10000`, never at offset zero. The normal board command uses +ESP-IDF's segmented flash operation: + +| image | ESP32-P4 offset | +|---|---:| +| bootloader | `0x2000` | +| partition table | `0x8000` | +| Pocket Vapor app | `0x10000` | + +These offsets are recorded in the generated project's +`build/flasher_args.json`. Prefer `bun run vapor:esp32p4:flash` or +`idf.py flash` from that project over a hand-written `write-flash` command. +In particular, the classic ESP32 bootloader offset `0x1000` is wrong for +this ESP32-P4 image. + +## Full-image restore is recovery-only + +A full restore overwrites the entire boot chain, partition table, factory +state, application, and every other flash region. It is not part of the +normal Pocket Vapor build/flash loop. Do not run `erase-flash` first, and do +not restore a downloaded factory image when the board's own verified backup +is available. + +Only after deliberately choosing recovery, reconnect the same board, verify +its MAC, verify the exact 32 MiB file and checksum, and then write that +verified image at offset zero: + +```sh +PORT="/dev/cu/..." +BACKUP=/absolute/path/to/this-board-pre-pocket-vapor-full-flash.bin +BACKUP_DIR="$(dirname "$BACKUP")" +BACKUP_NAME="$(basename "$BACKUP")" + +test "$(stat -f '%z' "$BACKUP")" = 33554432 +(cd "$BACKUP_DIR" && shasum -a 256 -c "${BACKUP_NAME}.sha256") +uvx --from esptool esptool --chip esp32p4 --port "$PORT" chip-id +uvx --from esptool esptool --chip esp32p4 --port "$PORT" read-mac + +# Destructive recovery step: only for the same board and verified image. +uvx --from esptool esptool --chip esp32p4 --port "$PORT" --baud 230400 \ + write-flash 0x0 "$BACKUP" +``` + +Writing and hash verification prove transport and flash contents, not that +the application booted or the screen and touch controls work. Complete +acceptance still requires `PVREADY`, logical-grid replay with zero +tripwires, a visible EK79007 frame, and physical GT911 touch checks. diff --git a/vapor/runtime/esp32p4/dependencies.lock b/vapor/runtime/esp32p4/dependencies.lock new file mode 100644 index 00000000..d6f61127 --- /dev/null +++ b/vapor/runtime/esp32p4/dependencies.lock @@ -0,0 +1,125 @@ +dependencies: + espressif/cmake_utilities: + component_hash: 351350613ceafba240b761b4ea991e0f231ac7a9f59a9ee901f751bddc0bb18f + dependencies: + - name: idf + require: private + version: '>=4.1' + source: + registry_url: https://components.espressif.com + type: service + version: 0.5.3 + espressif/esp_codec_dev: + component_hash: df70f10af8d7b922add7b9d07372c9c97ab356e58d72b7a892050227c2d44348 + dependencies: + - name: idf + require: private + version: '>=4.0' + source: + registry_url: https://components.espressif.com + type: service + version: 1.5.11 + espressif/esp_lcd_ek79007: + component_hash: 8005700b7f10c7136b6e2a3f19a48f972aa1d13ed107ed298574e8d24d17ea83 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32p4 + version: 1.0.4 + espressif/esp_lcd_touch: + component_hash: 3f85a7d95af876f1a6ecca8eb90a81614890d0f03a038390804e5a77e2caf862 + dependencies: + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.1 + espressif/esp_lcd_touch_gt911: + component_hash: 07f678e4202d79bbad917805dcec4134ff08344177720f32ec4898267ad9e394 + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.2.0 + - name: idf + require: private + version: '>=5.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.0~3 + espressif/esp_lvgl_port: + component_hash: b6360960f47b6776462e7092861b3ea66477ffb762a01baa0aecbb3d74cd50f4 + dependencies: + - name: idf + require: private + version: '>=5.1' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: public + version: '>=8,<10' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.7.2 + idf: + source: + type: idf + version: 5.5.4 + lvgl/lvgl: + component_hash: 096c69af22eaf8a2b721e3913da91918c5e6bf1a762a113ec01f401aa61337a0 + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 9.2.2 + waveshare/esp32_p4_wifi6_touch_lcd_7b: + component_hash: 324a9667293e657b64a58b4c726e3babe2a8934a6154e7a9d51357a58fb7da0b + dependencies: + - name: espressif/esp_codec_dev + registry_url: https://components.espressif.com + require: public + version: ~1.5 + - name: espressif/esp_lcd_ek79007 + registry_url: https://components.espressif.com + require: private + version: 1.* + - name: espressif/esp_lcd_touch_gt911 + registry_url: https://components.espressif.com + require: private + version: ^1 + - name: espressif/esp_lvgl_port + registry_url: https://components.espressif.com + require: public + version: ^2 + - name: idf + require: private + version: '>=5.3' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: private + version: '>=8,<10' + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32p4 + version: 1.0.4 +direct_dependencies: +- espressif/esp_lvgl_port +- lvgl/lvgl +- waveshare/esp32_p4_wifi6_touch_lcd_7b +manifest_hash: ce16049dbf32fc9318ae4110a035f235622f56a134875feedf37074974d1e01f +target: esp32p4 +version: 2.0.0 diff --git a/vapor/runtime/esp32p4/sdkconfig.defaults b/vapor/runtime/esp32p4/sdkconfig.defaults new file mode 100644 index 00000000..22ef963a --- /dev/null +++ b/vapor/runtime/esp32p4/sdkconfig.defaults @@ -0,0 +1,41 @@ +# ESP32-P4 v1.x-safe defaults for Waveshare ESP32-P4-WIFI6-Touch-LCD-7B. +# The connected ESP32-P4 is revision 1.3; keep the vendor's pre-v3 selection +# and minimum-revision-1 settings instead of selecting v3-only silicon. +CONFIG_IDF_TARGET="esp32p4" +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_1=y + +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHSIZE_32MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="32MB" +CONFIG_SPI_FLASH_SUPPORT_GD_CHIP=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y + +CONFIG_SPIRAM=y +CONFIG_SPIRAM_SPEED_200M=y +CONFIG_SPIRAM_XIP_FROM_PSRAM=y +CONFIG_CACHE_L2_CACHE_256KB=y +CONFIG_CACHE_L2_CACHE_LINE_128B=y + +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_FREERTOS_HZ=1000 + +CONFIG_BSP_LCD_DPI_BUFFER_NUMS=1 +CONFIG_BSP_LCD_COLOR_FORMAT_RGB565=y +# CONFIG_BSP_DISPLAY_LVGL_AVOID_TEAR is not set + +CONFIG_LV_OS_FREERTOS=y +CONFIG_LV_USE_CLIB_MALLOC=y +CONFIG_LV_USE_CLIB_STRING=y +CONFIG_LV_USE_CLIB_SPRINTF=y +CONFIG_LV_DEF_REFR_PERIOD=15 +CONFIG_LV_OBJ_STYLE_CACHE=y +CONFIG_LV_DRAW_SW_DRAW_UNIT_CNT=2 +CONFIG_LV_ATTRIBUTE_FAST_MEM_USE_IRAM=y +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_24=y +CONFIG_LV_USE_FONT_COMPRESSED=y + +CONFIG_IDF_EXPERIMENTAL_FEATURES=y diff --git a/vapor/runtime/esp32p4/vapor_esp32p4.c b/vapor/runtime/esp32p4/vapor_esp32p4.c new file mode 100644 index 00000000..3845aeef --- /dev/null +++ b/vapor/runtime/esp32p4/vapor_esp32p4.c @@ -0,0 +1,490 @@ +/* vapor/runtime/esp32p4/vapor_esp32p4.c — Pocket Vapor on the Waveshare + * ESP32-P4-WIFI6-Touch-LCD-7B. + * + * The generated application and vapor_core.c retain the same fixed-memory + * contract as every Pocket Vapor target. This hardware boundary uses the + * board vendor's ESP-IDF BSP for the 1024x600 EK79007 MIPI-DSI panel and + * GT911 touch controller. LVGL owns display/input allocations; generated app + * state remains allocator-free. The UI is rotated exactly as the vendor's + * LVGL v9 example so displayed controls and touch coordinates remain paired. + * + * Touch callbacks never enter generated app code. They enqueue Pocket Button + * ids, and the app_main task exclusively owns app_on_button/app_flush and the + * logical grid. This keeps device-specific touch concepts out of app code. + */ +#include "vapor.h" + +#include +#include +#include + +#include "bsp/esp-bsp.h" +#include "driver/uart.h" +#include "driver/uart_vfs.h" +#include "esp_err.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "lvgl.h" + +#ifndef VP_BOARD_ID +#define VP_BOARD_ID "waveshare-esp32-p4-wifi6-touch-lcd-7b" +#endif +#ifndef VP_BUILD_ID +#define VP_BUILD_ID "unknown" +#endif +#ifndef VP_DEBUG_STATE_BYTES +#define VP_DEBUG_STATE_BYTES 1 +#endif +#ifndef VP_LCD_ENABLED +#define VP_LCD_ENABLED 1 +#endif +#ifndef VP_LCD_WIDTH +#define VP_LCD_WIDTH 1024 +#endif +#ifndef VP_LCD_HEIGHT +#define VP_LCD_HEIGHT 600 +#endif +#ifndef VP_LCD_CELL_W +#define VP_LCD_CELL_W 30 +#endif +#ifndef VP_LCD_CELL_H +#define VP_LCD_CELL_H 30 +#endif +#ifndef VP_TOUCH_BUTTON_MASK +#define VP_TOUCH_BUTTON_MASK 0x1ff +#endif +#ifndef VP_ABSENT_BUTTON_MASK +#define VP_ABSENT_BUTTON_MASK 0x200 +#endif + +#define VP_PHYS_W (VP_GRID_W * VP_LCD_CELL_W) +#define VP_PHYS_H (VP_GRID_H * VP_LCD_CELL_H) +#define VP_GRID_X 16 +#define VP_GRID_Y 48 +#define VP_TOUCH_QUEUE_DEPTH 16 + +_Static_assert(VP_GRID_W == 20, "ESP32-P4 touch layout requires the 20-column Vapor grid"); +_Static_assert(VP_GRID_H == 18, "ESP32-P4 touch layout requires the 18-row Vapor grid"); +_Static_assert(VP_LCD_WIDTH == 1024, "Waveshare ESP32-P4-7B panel width must be 1024"); +_Static_assert(VP_LCD_HEIGHT == 600, "Waveshare ESP32-P4-7B panel height must be 600"); +_Static_assert(BSP_LCD_H_RES == VP_LCD_WIDTH, "Pocket Vapor width must match the selected Waveshare BSP"); +_Static_assert(BSP_LCD_V_RES == VP_LCD_HEIGHT, "Pocket Vapor height must match the selected Waveshare BSP"); +_Static_assert(VP_LCD_CELL_W == 30 && VP_LCD_CELL_H == 30, "ESP32-P4-7B cells must be 30x30"); +_Static_assert(VP_TOUCH_BUTTON_MASK == 0x1ff, "touch UI must expose Pocket buttons A through R"); +_Static_assert(VP_ABSENT_BUTTON_MASK == 0x200, "the Waveshare touch UI must declare only L absent"); +_Static_assert(VP_GRID_H <= 32, "dirty-row mask supports at most 32 rows"); +_Static_assert(VP_PHYS_W <= VP_LCD_WIDTH, "logical grid is wider than the panel"); +_Static_assert(VP_GRID_Y + VP_PHYS_H <= VP_LCD_HEIGHT, "logical grid is taller than the panel"); +_Static_assert(VP_DEBUG_STATE_BYTES <= 65535, "debug-state receipt length must fit in u16"); + +/* Runtime-owned logical screen. */ +u8 vp_grid_ch[VP_GRID_H][VP_GRID_W]; +u8 vp_grid_pal[VP_GRID_H][VP_GRID_W]; + +static const char *TAG = "pocket-vapor-p4"; +static u32 frame_no; +static u32 flush_no; +static u32 lcd_commit_no; +static QueueHandle_t touch_queue; +static lv_display_t *display; +static lv_obj_t *cells[VP_GRID_H][VP_GRID_W]; + +/* Pocket Button ids from vapor/host/input.ts. */ +enum { + VP_BUTTON_A = 0, + VP_BUTTON_B = 1, + VP_BUTTON_SELECT = 2, + VP_BUTTON_START = 3, + VP_BUTTON_RIGHT = 4, + VP_BUTTON_LEFT = 5, + VP_BUTTON_UP = 6, + VP_BUTTON_DOWN = 7, + VP_BUTTON_R = 8, +}; + +typedef struct { + const char *label; + u8 button; + int16_t x; + int16_t y; + int16_t width; + int16_t height; +} touch_button_spec; + +static const touch_button_spec touch_buttons[] = { + {"UP", VP_BUTTON_UP, 700, 115, 75, 75}, + {"LEFT", VP_BUTTON_LEFT, 625, 195, 75, 75}, + {"DOWN", VP_BUTTON_DOWN, 700, 275, 75, 75}, + {"RIGHT", VP_BUTTON_RIGHT, 775, 195, 75, 75}, + {"A", VP_BUTTON_A, 915, 125, 85, 85}, + {"B", VP_BUTTON_B, 875, 225, 85, 85}, + {"SELECT", VP_BUTTON_SELECT, 640, 425, 155, 65}, + {"START", VP_BUTTON_START, 825, 425, 165, 65}, + {"R", VP_BUTTON_R, 825, 515, 165, 65}, +}; + +/* Treat hitboxes as half-open rectangles. Validate bounds, exact button + * coverage, and pairwise disjointness before the BSP starts any hardware. */ +static esp_err_t validate_touch_layout(void) { + size_t i, j; + u16 button_mask = 0; + + for (i = 0; i < sizeof(touch_buttons) / sizeof(touch_buttons[0]); i++) { + const touch_button_spec *a = &touch_buttons[i]; + if (a->button >= 16 || a->width <= 0 || a->height <= 0 || a->x < 0 || a->y < 0 || + a->x + a->width > VP_LCD_WIDTH || a->y + a->height > VP_LCD_HEIGHT || + (button_mask & (u16)(1u << a->button))) { + ESP_LOGE(TAG, "invalid touch hitbox %s", a->label); + return ESP_ERR_INVALID_ARG; + } + button_mask |= (u16)(1u << a->button); + + for (j = 0; j < i; j++) { + const touch_button_spec *b = &touch_buttons[j]; + if (a->x < b->x + b->width && b->x < a->x + a->width && + a->y < b->y + b->height && b->y < a->y + a->height) { + ESP_LOGE(TAG, "touch hitboxes %s and %s overlap", a->label, b->label); + return ESP_ERR_INVALID_ARG; + } + } + } + + if (button_mask != VP_TOUCH_BUTTON_MASK) { + ESP_LOGE( + TAG, + "touch button mask 0x%x does not match board mask 0x%x", + (unsigned)button_mask, + (unsigned)VP_TOUCH_BUTTON_MASK); + return ESP_ERR_INVALID_ARG; + } + return ESP_OK; +} + +static lv_color_t rgb565_color(u16 color) { + uint8_t r = (uint8_t)(((color >> 11) & 0x1f) * 255 / 31); + uint8_t g = (uint8_t)(((color >> 5) & 0x3f) * 255 / 63); + uint8_t b = (uint8_t)((color & 0x1f) * 255 / 31); + return lv_color_make(r, g, b); +} + +static const char *button_name(u8 button) { + switch (button) { + case VP_BUTTON_A: return "A"; + case VP_BUTTON_B: return "B"; + case VP_BUTTON_SELECT: return "SELECT"; + case VP_BUTTON_START: return "START"; + case VP_BUTTON_RIGHT: return "RIGHT"; + case VP_BUTTON_LEFT: return "LEFT"; + case VP_BUTTON_UP: return "UP"; + case VP_BUTTON_DOWN: return "DOWN"; + case VP_BUTTON_R: return "R"; + default: return "UNKNOWN"; + } +} + +/* Runs on the BSP's LVGL task. App state is deliberately not touched here. */ +static void touch_button_clicked(lv_event_t *event) { + u8 button; + if (lv_event_get_code(event) != LV_EVENT_CLICKED) return; + button = (u8)(uintptr_t)lv_event_get_user_data(event); + (void)xQueueSend(touch_queue, &button, 0); +} + +static void style_text(lv_obj_t *object, const lv_font_t *font, lv_color_t color) { + lv_obj_set_style_text_font(object, font, LV_PART_MAIN); + lv_obj_set_style_text_color(object, color, LV_PART_MAIN); + lv_obj_set_style_text_align(object, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); +} + +static void create_cell_grid(lv_obj_t *screen) { + u8 y, x; + for (y = 0; y < VP_GRID_H; y++) { + for (x = 0; x < VP_GRID_W; x++) { + lv_obj_t *cell = lv_label_create(screen); + cells[y][x] = cell; + lv_obj_set_pos(cell, VP_GRID_X + x * VP_LCD_CELL_W, VP_GRID_Y + y * VP_LCD_CELL_H); + lv_obj_set_size(cell, VP_LCD_CELL_W, VP_LCD_CELL_H); + lv_obj_set_style_border_width(cell, 0, LV_PART_MAIN); + lv_obj_set_style_radius(cell, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(cell, 0, LV_PART_MAIN); + lv_obj_set_style_pad_top(cell, 3, LV_PART_MAIN); + lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_color(cell, rgb565_color(vp_paper565[0]), LV_PART_MAIN); + style_text(cell, &lv_font_montserrat_20, rgb565_color(vp_ink565[0])); + lv_label_set_long_mode(cell, LV_LABEL_LONG_CLIP); + lv_label_set_text(cell, " "); + } + } +} + +static void create_touch_buttons(lv_obj_t *screen) { + size_t i; + for (i = 0; i < sizeof(touch_buttons) / sizeof(touch_buttons[0]); i++) { + const touch_button_spec *spec = &touch_buttons[i]; + lv_obj_t *button = lv_button_create(screen); + lv_obj_t *label; + lv_obj_set_pos(button, spec->x, spec->y); + lv_obj_set_size(button, spec->width, spec->height); + lv_obj_set_style_radius(button, 18, LV_PART_MAIN); + lv_obj_set_style_bg_color(button, lv_color_hex(0x252B35), LV_PART_MAIN); + lv_obj_set_style_bg_color(button, lv_color_hex(0x4F7DFF), LV_PART_MAIN | LV_STATE_PRESSED); + lv_obj_set_style_border_width(button, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(button, lv_color_hex(0x617086), LV_PART_MAIN); + lv_obj_set_style_shadow_width(button, 12, LV_PART_MAIN); + lv_obj_set_style_shadow_opa(button, LV_OPA_30, LV_PART_MAIN); + lv_obj_add_event_cb(button, touch_button_clicked, LV_EVENT_CLICKED, (void *)(uintptr_t)spec->button); + + label = lv_label_create(button); + style_text(label, &lv_font_montserrat_20, lv_color_hex(0xF8FAFC)); + lv_label_set_text(label, spec->label); + lv_obj_center(label); + } +} + +static void create_ui(void) { + lv_obj_t *screen = lv_screen_active(); + lv_obj_t *title; + lv_obj_t *hint; + + lv_obj_remove_flag(screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(screen, lv_color_hex(0x10141B), LV_PART_MAIN); + lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, LV_PART_MAIN); + + title = lv_label_create(screen); + style_text(title, &lv_font_montserrat_24, lv_color_hex(0xF3F6FC)); + lv_label_set_text_fmt(title, "Pocket Vapor - %s", vp_app_title); + lv_obj_set_width(title, VP_PHYS_W); + lv_obj_set_pos(title, VP_GRID_X, 9); + + create_cell_grid(screen); + + hint = lv_label_create(screen); + style_text(hint, &lv_font_montserrat_20, lv_color_hex(0x94A3B8)); + lv_label_set_text(hint, "Touch controls"); + lv_obj_set_width(hint, VP_LCD_WIDTH - 640); + lv_obj_set_pos(hint, 640, 28); + + create_touch_buttons(screen); +} + +static void display_init(void) { + bsp_display_cfg_t cfg = { + .lvgl_port_cfg = ESP_LVGL_PORT_INIT_CONFIG(), + .buffer_size = BSP_LCD_DRAW_BUFF_SIZE, + .double_buffer = BSP_LCD_DRAW_BUFF_DOUBLE, + .flags = { + .buff_dma = true, + .buff_spiram = false, + .sw_rotate = true, + }, + }; + + display = bsp_display_start_with_config(&cfg); + if (display == NULL) { + ESP_LOGE(TAG, "EK79007/GT911 BSP initialization failed"); + ESP_ERROR_CHECK(ESP_FAIL); + } + /* Match the official Waveshare LVGL v9 example. esp_lvgl_port applies the + * same transform to the display and its associated GT911 input device. */ + bsp_display_rotate(display, LV_DISPLAY_ROTATION_180); + if (!bsp_display_lock(0)) { + ESP_LOGE(TAG, "could not acquire LVGL lock while creating UI"); + ESP_ERROR_CHECK(ESP_ERR_TIMEOUT); + } + create_ui(); + bsp_display_unlock(); + ESP_ERROR_CHECK(bsp_display_backlight_on()); +} + +static void display_commit_rows(void) { + u32 dirty = vp_rows_dirty; + u8 y, x; + if (!dirty) return; + if (!bsp_display_lock(0)) { + vp_tripwires |= VP_TRIP_PLATFORM_RENDER; + return; + } + for (y = 0; y < VP_GRID_H; y++) { + if (!(dirty & vp_bit32[y])) continue; + for (x = 0; x < VP_GRID_W; x++) { + u8 ch = vp_grid_ch[y][x]; + u8 pair = vp_grid_pal[y][x]; + char text[2]; + /* Palette ids originate in compiler-generated paint code and index the + * generated RGB565 tables directly, as in the classic ESP32 runtime. */ + if (ch < 0x20 || ch > 0x7e) ch = '?'; + text[0] = (char)ch; + text[1] = '\0'; + lv_obj_set_style_bg_color(cells[y][x], rgb565_color(vp_paper565[pair]), LV_PART_MAIN); + lv_obj_set_style_text_color(cells[y][x], rgb565_color(vp_ink565[pair]), LV_PART_MAIN); + lv_label_set_text(cells[y][x], text); + } + } + bsp_display_unlock(); + vp_rows_dirty &= ~dirty; + lcd_commit_no++; +} + +/* ---- UART device receipt protocol ---------------------------------------- */ +static char serial_line[32]; +static u8 serial_len; + +static void serial_init(void) { + const uart_config_t config = { + .baud_rate = 115200, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .source_clk = UART_SCLK_DEFAULT, + }; + ESP_ERROR_CHECK(uart_param_config(UART_NUM_0, &config)); + ESP_ERROR_CHECK( + uart_set_pin(UART_NUM_0, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE)); + ESP_ERROR_CHECK(uart_driver_install(UART_NUM_0, 512, 0, 0, NULL, 0)); + uart_vfs_dev_use_driver(UART_NUM_0); +} + +static void print_hex(const u8 *bytes, u16 len) { + static const char hex[] = "0123456789abcdef"; + u16 i; + for (i = 0; i < len; i++) { + putchar(hex[bytes[i] >> 4]); + putchar(hex[bytes[i] & 15]); + } + putchar('\n'); +} + +static void receipt_ready(void) { + printf( + "PVREADY board=%s chip=%s build=%s grid=%dx%d lcd=%d panel=%dx%d cell=%dx%d frame=%lu flush=%lu commit=%lu\n", + VP_BOARD_ID, + CONFIG_IDF_TARGET, + VP_BUILD_ID, + VP_GRID_W, + VP_GRID_H, + VP_LCD_ENABLED, + VP_LCD_WIDTH, + VP_LCD_HEIGHT, + VP_LCD_CELL_W, + VP_LCD_CELL_H, + (unsigned long)frame_no, + (unsigned long)flush_no, + (unsigned long)lcd_commit_no); +} + +static void receipt_grid(void) { + volatile u8 state[VP_DEBUG_STATE_BYTES]; + u16 state_len = app_debug_state(state); + printf( + "PVGRID frame=%lu flush=%lu commit=%lu trips=%u state=%u\n", + (unsigned long)frame_no, + (unsigned long)flush_no, + (unsigned long)lcd_commit_no, + vp_tripwires, + state_len); + printf("PVCH "); + print_hex((const u8 *)vp_grid_ch, VP_GRID_W * VP_GRID_H); + printf("PVPA "); + print_hex((const u8 *)vp_grid_pal, VP_GRID_W * VP_GRID_H); + printf("PVEND\n"); +} + +static void runtime_reset(void) { + vp_tripwires = 0; + vp_rows_dirty = 0; + vp_row_clear(0, VP_GRID_H); + app_init(); + if (app_flush()) flush_no++; + /* app_init paints every effect, but unchanged boot cells can otherwise + * retain a clean bit after an in-process reset. Force the physical frame. */ + vp_rows_dirty = VP_GRID_H == 32 ? 0xffffffffUL : (vp_bit32[VP_GRID_H] - 1); + display_commit_rows(); + receipt_ready(); +} + +static void dispatch_button(u8 button) { + if (button >= 10) return; + app_on_button(button); + if (app_flush()) flush_no++; + display_commit_rows(); + printf( + "PVACK button=%u frame=%lu flush=%lu commit=%lu trips=%u\n", + button, + (unsigned long)frame_no, + (unsigned long)flush_no, + (unsigned long)lcd_commit_no, + vp_tripwires); +} + +static void handle_serial_line(void) { + int button; + if (serial_len == 0) return; + serial_line[serial_len] = '\0'; + if (serial_line[0] == 'H') + receipt_ready(); + else if (serial_line[0] == 'D') + receipt_grid(); + else if (serial_line[0] == 'R') + runtime_reset(); + else if (sscanf(serial_line, "P %d", &button) == 1 && button >= 0 && button < 10) + dispatch_button((u8)button); + else + printf("PVERR command=%s\n", serial_line); +} + +static void serial_poll(void) { + u8 ch; + int n; + while ((n = uart_read_bytes(UART_NUM_0, &ch, 1, 0)) == 1) { + if (ch == '\r') continue; + if (ch == '\n') { + handle_serial_line(); + serial_len = 0; + } else if (serial_len + 1 < sizeof(serial_line)) { + serial_line[serial_len++] = (char)ch; + } else { + serial_len = 0; + printf("PVERR line-too-long\n"); + } + } + if (n < 0) ESP_LOGW(TAG, "UART read failed: %d", n); +} + +void app_main(void) { + TickType_t last_wake; + u32 frame_phase = 0; + u8 button; + setvbuf(stdout, NULL, _IONBF, 0); + ESP_ERROR_CHECK(validate_touch_layout()); + + touch_queue = xQueueCreate(VP_TOUCH_QUEUE_DEPTH, sizeof(u8)); + if (touch_queue == NULL) { + ESP_LOGE(TAG, "could not create touch queue"); + ESP_ERROR_CHECK(ESP_ERR_NO_MEM); + } + + serial_init(); + display_init(); + runtime_reset(); + last_wake = xTaskGetTickCount(); + + for (;;) { + TickType_t frame_ticks; + while (xQueueReceive(touch_queue, &button, 0) == pdTRUE) { + printf("PVTOUCH button=%u name=%s\n", button, button_name(button)); + dispatch_button(button); + } + serial_poll(); + if (app_flush()) flush_no++; + display_commit_rows(); + frame_no++; + /* Exact 60 Hz average without accumulating drift at a 1 kHz tick rate. */ + frame_phase += configTICK_RATE_HZ; + frame_ticks = frame_phase / 60; + frame_phase %= 60; + xTaskDelayUntil(&last_wake, frame_ticks); + } +} diff --git a/vapor/scripts/esp32.ts b/vapor/scripts/esp32.ts index 1b133e3b..98dc8880 100644 --- a/vapor/scripts/esp32.ts +++ b/vapor/scripts/esp32.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun -// Build, flash, and verify Pocket Vapor on a USB-connected ESP32 MeowBit. +// Build, flash, and verify Pocket Vapor on a supported USB-connected ESP32. // +// bun vapor/scripts/esp32.ts build [--board meowbit] // bun vapor/scripts/esp32.ts flash [--port /dev/cu.usbmodem...] [--board meowbit] // bun vapor/scripts/esp32.ts verify [--port ...] [--no-flash] [--board meowbit] // @@ -18,6 +19,7 @@ import { loadBoard } from "../compiler/boards.ts"; import { buildEsp32Firmware, DEFAULT_BOARD, + esp32ArtifactStem, esp32BuildId, resolveEspIdfEnvironment, runEspIdf, @@ -30,12 +32,13 @@ import { TODO_TAPE } from "../tests/todo-tape.ts"; const ROOT = resolve(import.meta.dir, "..", ".."); const ENTRY = join(ROOT, "vapor", "examples", "todo", "todo.tsx"); const OUT = join(ROOT, "dist", "vapor"); -const FIRMWARE = join(OUT, "todo.esp32.bin"); -const RECEIPT = join(OUT, "esp32-device-receipt.json"); const TARGET = VAPOR_TARGETS.esp32; const BAUD = 115200; const CELLS = TARGET.width * TARGET.height; const BOARD = loadBoard(option("--board") ?? DEFAULT_BOARD); +const ARTIFACT_STEM = esp32ArtifactStem(BOARD); +const FIRMWARE = join(OUT, `todo.${ARTIFACT_STEM}.bin`); +const RECEIPT = join(OUT, `${ARTIFACT_STEM}-device-receipt.json`); interface GridReceipt { header: string; @@ -87,7 +90,7 @@ class SerialLines { private readonly child: Bun.PipedSubprocess; static async open(port: string): Promise { - const { idfToolsPath } = resolveEspIdfEnvironment(); + const { idfToolsPath } = resolveEspIdfEnvironment(BOARD); const pythonRoot = join(idfToolsPath, "python_env"); const envs = readdirSync(pythonRoot) .filter((name) => /^idf.+_env$/.test(name)) @@ -274,18 +277,21 @@ async function build(): Promise<{ } async function flash(buildResult: Esp32BuildResult, port: string): Promise { - console.log(`\nflashing ${basename(FIRMWARE)} to ${port} at ${BAUD} baud...`); - await runEspIdf([ - "-C", - buildResult.projectDir, - "-B", - buildResult.buildDir, - "-p", - port, - "-b", - String(BAUD), - "flash", - ]); + console.log(`\nflashing ${basename(FIRMWARE)} for ${BOARD.board} to ${port} at ${BAUD} baud...`); + await runEspIdf( + [ + "-C", + buildResult.projectDir, + "-B", + buildResult.buildDir, + "-p", + port, + "-b", + String(BAUD), + "flash", + ], + buildResult.idfEnvironment, + ); await waitForPort(port); } @@ -403,22 +409,28 @@ async function verify( } const command = process.argv[2] ?? "verify"; -if (command !== "flash" && command !== "verify") { +if (command !== "build" && command !== "flash" && command !== "verify") { console.error( - "usage: bun vapor/scripts/esp32.ts flash|verify [--port /dev/cu.usbmodem...] [--no-flash] [--board meowbit]", + "usage: bun vapor/scripts/esp32.ts build|flash|verify [--port /dev/cu.usbmodem...] [--no-flash] [--board meowbit]", ); process.exit(2); } -const port = autoPort(); const source = await Bun.file(ENTRY).text(); let app = compileVaporApp(ENTRY, source, "VAPOR TODO", "esp32"); let firmware: Esp32BuildResult | undefined; -if (command === "flash" || !process.argv.includes("--no-flash")) { +if (command === "build" || command === "flash" || !process.argv.includes("--no-flash")) { ({ app, firmware } = await build()); - await flash(firmware, port); } -if (command === "verify") { - const expectedBuildId = firmware?.buildId ?? await esp32BuildId(app, BOARD); - await verify(port, app, expectedBuildId); +if (command === "build") { + console.log( + `${FIRMWARE} (${(firmware!.romBytes / 1024).toFixed(1)} KB, firmware for ${BOARD.board})`, + ); +} else { + const port = autoPort(); + if (firmware) await flash(firmware, port); + if (command === "verify") { + const expectedBuildId = firmware?.buildId ?? await esp32BuildId(app, BOARD); + await verify(port, app, expectedBuildId); + } } diff --git a/vapor/tests/boards.test.ts b/vapor/tests/boards.test.ts index a8cfd4e7..7df57913 100644 --- a/vapor/tests/boards.test.ts +++ b/vapor/tests/boards.test.ts @@ -18,6 +18,7 @@ import { compileVaporApp, VAPOR_TARGETS } from "../compiler/compile.ts"; import { Button } from "../host/input.ts"; const ENTRY = join(import.meta.dir, "..", "examples", "todo", "todo.tsx"); +const P4_BOARD = "waveshare-esp32-p4-wifi6-touch-lcd-7b"; function meowbitRaw(): any { return { @@ -40,6 +41,27 @@ function meowbitRaw(): any { }; } +function p4Raw(): any { + return { + board: P4_BOARD, + title: "Waveshare ESP32-P4-WIFI6-Touch-LCD-7B", + chip: "esp32p4", + lcd: { + bsp: P4_BOARD, + controller: "ek79007", + width: 1024, + height: 600, + cell: [30, 30], + }, + input: { + kind: "touch", + controller: "gt911", + virtualButtons: ["a", "b", "select", "start", "right", "left", "up", "down", "r"], + absent: ["l"], + }, + }; +} + describe("board registry", () => { test("POCKET_PAD mirrors the Button ids apps compile against", () => { for (const [name, id] of Object.entries(Button) as [string, number][]) { @@ -54,6 +76,37 @@ describe("board registry", () => { expect(board.input.absent).toEqual(["l"]); }); + test("Waveshare ESP32-P4 is a registered, chip-tagged BSP board", () => { + expect(listBoards()).toContain(P4_BOARD); + const board = loadBoard(P4_BOARD); + expect(board.chip).toBe("esp32p4"); + if (board.chip !== "esp32p4") throw new Error("chip discriminator did not narrow the board"); + expect(board.lcd).toEqual({ + bsp: P4_BOARD, + controller: "ek79007", + width: 1024, + height: 600, + cell: [30, 30], + }); + expect(board.input).toEqual({ + kind: "touch", + controller: "gt911", + virtualButtons: ["a", "b", "select", "start", "right", "left", "up", "down", "r"], + absent: ["l"], + }); + expect(board.input.virtualButtons.map((button) => POCKET_PAD.indexOf(button))).toEqual([ + Button.A, + Button.B, + Button.Select, + Button.Start, + Button.Right, + Button.Left, + Button.Up, + Button.Down, + Button.R, + ]); + }); + test("meowbit derives the exact definitions PR #154 flashed (buildId stability)", () => { // This list is the board half of esp32BuildId. If this test breaks, the // firmware identity of every flashed MeowBit changes with it — that must @@ -83,6 +136,24 @@ describe("board registry", () => { ]); }); + test("ESP32-P4 definitions carry stable BSP identity without fake GPIO buttons", () => { + const definitions = boardDefinitions(loadBoard(P4_BOARD)); + expect(definitions).toEqual([ + `VP_BOARD_ID=\\"${P4_BOARD}\\"`, + 'VP_CHIP_ID=\\"esp32p4\\"', + `VP_BSP_ID=\\"${P4_BOARD}\\"`, + 'VP_PANEL_ID=\\"ek79007\\"', + "VP_LCD_WIDTH=1024", + "VP_LCD_HEIGHT=600", + "VP_LCD_CELL_W=30", + "VP_LCD_CELL_H=30", + 'VP_TOUCH_ID=\\"gt911\\"', + "VP_TOUCH_BUTTON_MASK=0x1ff", + "VP_ABSENT_BUTTON_MASK=0x200", + ]); + expect(definitions.some((definition) => /VP_BUTTON_(?:UP|DOWN|LEFT|RIGHT|A|B)=/.test(definition))).toBe(false); + }); + test("rejects a chord the runtime does not decode", () => { const raw = meowbitRaw(); raw.input.chorded.start = ["a", "up"]; @@ -109,10 +180,63 @@ describe("board registry", () => { const chip = meowbitRaw(); chip.chip = "rp2040"; - expect(() => parseBoard("meowbit", chip)).toThrow(/only board runtime today is "esp32"/); + expect(() => parseBoard("meowbit", chip)).toThrow(/chip must be "esp32" or "esp32p4"/); expect(() => parseBoard("other-name", meowbitRaw())).toThrow(/"board" must equal the file name/); }); + + test("rejects malformed or GPIO-shaped ESP32-P4 profiles", () => { + const bsp = p4Raw(); + bsp.lcd.bsp = "some-other-board"; + expect(() => parseBoard(P4_BOARD, bsp)).toThrow(/lcd\.bsp must be/); + + const panel = p4Raw(); + panel.lcd.controller = "st7789"; + expect(() => parseBoard(P4_BOARD, panel)).toThrow(/lcd\.controller must be "ek79007"/); + + const geometry = p4Raw(); + geometry.lcd.width = 800; + expect(() => parseBoard(P4_BOARD, geometry)).toThrow(/ek79007 BSP panel must be 1024x600/); + + const cell = p4Raw(); + cell.lcd.cell = [31, 30]; + expect(() => parseBoard(P4_BOARD, cell)).toThrow(/touch layout requires lcd\.cell \[30, 30\]/); + + const touch = p4Raw(); + touch.input.controller = "ft5x06"; + expect(() => parseBoard(P4_BOARD, touch)).toThrow(/input\.controller must be "gt911"/); + + const fakeGpio = p4Raw(); + fakeGpio.input.pins = { up: 1 }; + expect(() => parseBoard(P4_BOARD, fakeGpio)).toThrow(/unknown input field "pins"/); + + const unknown = p4Raw(); + unknown.lcd.rotation = 90; + expect(() => parseBoard(P4_BOARD, unknown)).toThrow(/unknown lcd field "rotation"/); + }); + + test("rejects gaps, overlaps, duplicates, and unknown P4 virtual buttons", () => { + const gap = p4Raw(); + gap.input.virtualButtons = gap.input.virtualButtons.filter((button: string) => button !== "r"); + expect(() => parseBoard(P4_BOARD, gap)).toThrow(/"r" must have exactly one spelling.*found 0/); + + const overlap = p4Raw(); + overlap.input.absent = ["l", "r"]; + expect(() => parseBoard(P4_BOARD, overlap)).toThrow(/"r" must have exactly one spelling.*found 2/); + + const duplicate = p4Raw(); + duplicate.input.virtualButtons.push("a"); + expect(() => parseBoard(P4_BOARD, duplicate)).toThrow(/duplicate pocket button "a"/); + + const unknown = p4Raw(); + unknown.input.virtualButtons[0] = "home"; + expect(() => parseBoard(P4_BOARD, unknown)).toThrow(/unknown pocket button "home"/); + + const runtimeDrift = p4Raw(); + runtimeDrift.input.virtualButtons[8] = "l"; + runtimeDrift.input.absent = ["r"]; + expect(() => parseBoard(P4_BOARD, runtimeDrift)).toThrow(/virtualButtons must match.*touch layout/); + }); }); describe("aot admission: derived demands vs board profile", () => { @@ -146,4 +270,26 @@ describe("aot admission: derived demands vs board profile", () => { const issues = admitBoard({ buttonsUsed: [] }, board, { width: 30, height: 20 }); expect(issues.map((issue) => issue.code)).toEqual(["VB101"]); }); + + test("todo admits on the P4 through GT911 virtual Button coverage", async () => { + const source = await Bun.file(ENTRY).text(); + const app = compileVaporApp(ENTRY, source, "VAPOR TODO", "esp32"); + expect(app.buttonsUsed).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + expect(admitBoard({ buttonsUsed: app.buttonsUsed }, loadBoard(P4_BOARD), grid)).toEqual([]); + }); + + test("P4 refuses absent L and grids larger than its declared 30px cells", () => { + const board = loadBoard(P4_BOARD); + expect(admitBoard({ buttonsUsed: [Button.L] }, board, grid)).toEqual([ + { + code: "VB102", + severity: "error", + message: `app uses "l" but ${P4_BOARD} has no mapping for it`, + }, + ]); + expect(admitBoard({ buttonsUsed: [] }, board, { width: 35, height: 18 })[0]).toMatchObject({ + code: "VB101", + severity: "error", + }); + }); }); diff --git a/vapor/tests/compiler.test.ts b/vapor/tests/compiler.test.ts index cb2f1a15..043b4d3c 100644 --- a/vapor/tests/compiler.test.ts +++ b/vapor/tests/compiler.test.ts @@ -4,7 +4,15 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import { loadBoard } from "../compiler/boards.ts"; import { compileVaporApp, VAPOR_TARGETS, VaporCompileError } from "../compiler/compile.ts"; -import { esp32BuildId } from "../compiler/esp32.ts"; +import { + esp32ArtifactStem, + esp32BuildId, + esp32IdfVersion, + esp32ProjectName, + ESP32P4_LVGL_PORT_RGB565_SWAPPED_COMMIT, + ESP32P4_LVGL_PORT_VERSION, + ESP32P4_DEPENDENCY_LOCK, +} from "../compiler/esp32.ts"; import { FONT8 } from "../compiler/font.gen.ts"; const ENTRY = join(import.meta.dir, "..", "examples", "todo", "todo.tsx"); @@ -104,11 +112,41 @@ describe("pocket vapor compiler", () => { expect(await esp32BuildId(same, board)).toBe(id); expect(await esp32BuildId(changed, board)).not.toBe(id); + const todoSource = await Bun.file(ENTRY).text(); + const todo = compileVaporApp(ENTRY, todoSource, "VAPOR TODO", "esp32"); + expect(await esp32BuildId(todo, board)).toBe("a6f3b6489877b73e"); + const rewired = structuredClone(board); rewired.input.pins.b = 14; expect(await esp32BuildId(app, rewired)).not.toBe(id); }); + test("ESP32 board artifacts are isolated without renaming the MeowBit defaults", () => { + const meowbit = loadBoard("meowbit"); + const p4 = loadBoard("waveshare-esp32-p4-wifi6-touch-lcd-7b"); + expect(esp32ArtifactStem(meowbit)).toBe("esp32"); + expect(esp32ProjectName(meowbit)).toBe("gen-esp32"); + expect(esp32ArtifactStem(p4)).toBe( + "esp32-waveshare-esp32-p4-wifi6-touch-lcd-7b", + ); + expect(esp32ProjectName(p4)).toBe( + "gen-esp32-waveshare-esp32-p4-wifi6-touch-lcd-7b", + ); + expect(esp32IdfVersion(meowbit)).toBe("v6.0.2"); + expect(esp32IdfVersion(p4)).toBe("v5.5.4"); + expect(ESP32P4_LVGL_PORT_VERSION).toBe("2.7.2"); + expect(ESP32P4_LVGL_PORT_RGB565_SWAPPED_COMMIT).toHaveLength(40); + }); + + test("ESP32-P4 managed components are locked in a hashed source input", async () => { + const lock = await Bun.file(ESP32P4_DEPENDENCY_LOCK).text(); + for (const version of ["0.5.3", "1.5.11", "1.0.4", "1.2.1", "1.2.0~3", "2.7.2", "5.5.4", "9.2.2"]) { + expect(lock).toContain(`version: ${version}`); + } + expect(lock).toContain("component_hash:"); + expect(lock).toContain("target: esp32p4"); + }); + test("effect masks subscribe conditional reads on both arms", async () => { const source = await Bun.file(ENTRY).text(); const app = compileVaporApp(ENTRY, source);