From adeaba8840fc6f6ec367718e20bc929f7060e59a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 14 Aug 2026 11:26:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(hosts):=20fixed-rate=20targets=20own=20the?= =?UTF-8?q?ir=20cadence=20=E2=80=94=20pocketbook=20declares=2030?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TargetProfile.tickHz names the rate a fixed-cadence host drives; the resolver copies it into plan.target and plan builds bake it (a conflicting --hz errors; rate-less targets keep their plan shape, hash and --hz flow). hosts/pocketbook declares 30 before mount and derives its wall step from the same constant, so ms animations run wall-true instead of half speed. The widget examples wire one TICK_HZ constant through WidgetConfig.tick_hz and the surface declaration so the two cadences cannot drift. Co-Authored-By: Claude Fable 5 --- contracts/spec/platforms.ts | 10 ++++++++++ engine/crates/pocket-widget/src/shell.rs | 6 +++++- engine/pocket3d/examples/handheld/src/main.rs | 11 +++++++++++ .../pocket3d/examples/note-widget/src/main.rs | 11 +++++++++++ framework/src/manifest/plan.ts | 3 +++ framework/src/manifest/resolve.ts | 4 ++++ hosts/pocketbook/docs/IMPLEMENTATION.md | 8 ++++++-- hosts/pocketbook/src/main.rs | 17 +++++++++++++---- tests/platform-contracts.test.ts | 17 +++++++++++++++++ tools/build.ts | 18 +++++++++++++++--- 10 files changed, 95 insertions(+), 10 deletions(-) diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index d3b6d079..104f3def 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -97,6 +97,12 @@ export interface TargetProfile { /** Shell posture (see TARGET_FORMS). */ readonly form: TargetForm; readonly display: DisplayProfile; + /** Core ticks per second the stock host drives, when the target fixes it. + * The single owner of a fixed cadence: the host declares it before mount + * (UiSurface::set_tick_rate) and plan builds bake it, so bundle and host + * pair by construction. Absent = the host drives (or stages per run, + * like ios-dev's --hz) the spec 60. */ + readonly tickHz?: number; /** Framework APIs implemented and tested by this stock host. */ readonly capabilities: readonly C[]; } @@ -256,6 +262,10 @@ export const POCKET_TARGETS = defineTargetRegistry Result<(Guest, UiSurfa // profile. The outer OS window is widget-shaped; the mounted screen is a // fixed embedded target (contracts/spec/platforms.ts), so macos-widget is wrong. surface.set_identity("macos-embedded", 3); + // The rate the shell drives (WidgetConfig.tick_hz below) — declared so + // the realm converts ms animations at the driven cadence. + anyhow::ensure!( + surface.set_tick_rate(TICK_HZ), + "declaring the {TICK_HZ} Hz tick rate failed" + ); // A package declares both its host adapter contract and the guest-facing // channel name. Only that exact channel may open; a typo or unrelated app // cannot discover the media companion accidentally. @@ -1231,6 +1241,7 @@ fn main() -> Result<()> { WidgetConfig { title: "Pocket Stage".into(), size: settings.window_size, + tick_hz: TICK_HZ as f32, max_fps: args.max_fps, ..Default::default() }, diff --git a/engine/pocket3d/examples/note-widget/src/main.rs b/engine/pocket3d/examples/note-widget/src/main.rs index dd218f36..f2458f92 100644 --- a/engine/pocket3d/examples/note-widget/src/main.rs +++ b/engine/pocket3d/examples/note-widget/src/main.rs @@ -31,6 +31,10 @@ use pocket_ui_wgpu::{UiRenderer, UiSurface}; use pocket_widget::shell::{FlatWidget, WidgetConfig}; use winit::keyboard::KeyCode; +/// The cadence this shell drives — one value owns WidgetConfig.tick_hz AND +/// the realm's declared rate (boot's set_tick_rate), so they cannot drift. +const TICK_HZ: u32 = 60; + /// Header strip height in logical px — mirrors HEADER_H in apps/note/app.tsx. const HEADER_H: f32 = 30.0; /// Header pixels reserved for the toggle/••• buttons (not a drag region). @@ -740,6 +744,12 @@ fn boot(args: &Args) -> Result<(Guest, UiSurface)> { // The platform-contract identity plan-built bundles assert // (contracts/spec/platforms.ts POCKET_TARGETS["macos-widget"]). surface.set_identity("macos-widget", 3); + // The rate the shell drives (WidgetConfig.tick_hz below) — declared so + // the realm converts ms animations at the driven cadence. + anyhow::ensure!( + surface.set_tick_rate(TICK_HZ), + "declaring the {TICK_HZ} Hz tick rate failed" + ); surface.feed_pak(&pak); let guest = Guest::new()?; surface.mount(&guest)?; @@ -770,6 +780,7 @@ fn main() -> Result<()> { WidgetConfig { title: "Pocket Note".into(), size: args.size, + tick_hz: TICK_HZ as f32, resizable: true, min_size: (240, 180), ime: true, diff --git a/framework/src/manifest/plan.ts b/framework/src/manifest/plan.ts index ab2718b8..d60bb186 100644 --- a/framework/src/manifest/plan.ts +++ b/framework/src/manifest/plan.ts @@ -10,6 +10,9 @@ export interface ResolvedBuildPlanContent { readonly target: { readonly id: string; readonly hostAbi: number; + /** The fixed rate the target's host drives (TargetProfile.tickHz); the + * build bakes it. Omitted when the host selects the rate per run. */ + readonly tickHz?: number; }; readonly viewport: { readonly logical: Viewport; diff --git a/framework/src/manifest/resolve.ts b/framework/src/manifest/resolve.ts index 0953ea81..8af9c87d 100644 --- a/framework/src/manifest/resolve.ts +++ b/framework/src/manifest/resolve.ts @@ -355,6 +355,10 @@ export function resolveBuildPlan( target: { id: request.target, hostAbi: profile.hostAbi, + // Omitted (not undefined) when the profile names no fixed rate: + // canonicalJson refuses undefined values, and rate-less plans keep + // their pre-tickHz hash. + ...(profile.tickHz !== undefined ? { tickHz: profile.tickHz } : {}), }, viewport: { logical, diff --git a/hosts/pocketbook/docs/IMPLEMENTATION.md b/hosts/pocketbook/docs/IMPLEMENTATION.md index fe96629e..7d77b48e 100644 --- a/hosts/pocketbook/docs/IMPLEMENTATION.md +++ b/hosts/pocketbook/docs/IMPLEMENTATION.md @@ -186,7 +186,8 @@ mod refresh; const LOGICAL_W: u32 = 480; const LOGICAL_H: u32 = 320; // 480×320 @ density 2 → 960×640, integer-fit on panel const DENSITY: u32 = 2; -const TICK_MS: u64 = 33; // ~30 fps logical tick; e-ink doesn't need 60 +const TICK_HZ: u32 = 30; // the declared realm rate; e-ink doesn't need 60 +const TICK: Duration = Duration::from_micros(1_000_000 / TICK_HZ as u64); fn main() -> Result<()> { env_logger::init(); @@ -216,6 +217,9 @@ fn main() -> Result<()> { let surface = UiSurface::new_with_density((LOGICAL_W as f32, LOGICAL_H as f32), DENSITY); surface.set_identity("pocketbook", HOST_ABI); // §8 — must match platforms.ts + // The declared realm rate — must match pocketbook.tickHz in + // platforms.ts; plan builds bake it and refuse any other host rate. + anyhow::ensure!(surface.set_tick_rate(TICK_HZ), "tick rate refused"); surface.feed_pak(&pak); let guest = Guest::new()?; @@ -232,7 +236,7 @@ fn main() -> Result<()> { while running { // Pull events until the tick deadline; drain everything pending. - let deadline = last_tick + Duration::from_millis(TICK_MS); + let deadline = last_tick + TICK; loop { let now = Instant::now(); if now >= deadline { break; } diff --git a/hosts/pocketbook/src/main.rs b/hosts/pocketbook/src/main.rs index 2936f08e..8a6127d7 100644 --- a/hosts/pocketbook/src/main.rs +++ b/hosts/pocketbook/src/main.rs @@ -31,9 +31,14 @@ use framebuffer::DirtyRect; const HOST_ID: &str = "pocketbook"; const HOST_ABI: u32 = 5; -/// Logical tick cadence. E-ink doesn't need 60 fps; ~30 fps keeps animations -/// smooth while sparing CPU and battery. -const TICK_MS: u64 = 33; +/// The declared realm rate — core ticks per second. E-ink doesn't need 60; +/// 30 keeps animations smooth while sparing CPU and battery. Must match +/// `pocketbook.tickHz` in contracts/spec/platforms.ts: plan-built bundles +/// bake that rate and refuse a host driving any other. +const TICK_HZ: u32 = 30; +/// Wall-clock step between ticks, derived from the declared rate so ms-based +/// animations run wall-true. +const TICK: Duration = Duration::from_micros(1_000_000 / TICK_HZ as u64); /// Logical viewport the pocketbook target profile bakes bundles for /// (contracts/spec/platforms.ts). Must match the bundle: the framework lays @@ -110,6 +115,10 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Re let surface = UiSurface::new_with_density((geo.logical_w as f32, geo.logical_h as f32), geo.density); surface.set_identity(HOST_ID, HOST_ABI); + anyhow::ensure!( + surface.set_tick_rate(TICK_HZ), + "declaring the {TICK_HZ} Hz tick rate failed" + ); surface.feed_pak(&pak); let guest = Guest::new()?; @@ -146,7 +155,7 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Re let mut last_tick = Instant::now(); loop { // Pull events until the tick deadline, then drain any burst. - let deadline = last_tick + Duration::from_millis(TICK_MS); + let deadline = last_tick + TICK; let mut quit = false; let mut full = false; loop { diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 9a959ffa..96f149f5 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -177,6 +177,13 @@ describe("platform registry", () => { presentations: ["integer-fit"], rasterDensity: 2, }); + // The one fixed-cadence stock host: the profile owns the 30 Hz rate + // (hosts/pocketbook declares it, plan builds bake it). Every other + // target drives — or stages per run — the spec 60, so it names none. + expect(POCKET_TARGETS.pocketbook.tickHz).toBe(30); + expect(POCKET_TARGETS.psp.tickHz).toBeUndefined(); + expect(POCKET_TARGETS.vita.tickHz).toBeUndefined(); + expect(POCKET_TARGETS["macos-widget"].tickHz).toBeUndefined(); // The desktop widget target: dynamic viewport, real pointer/text/IME, // runtime glyph baking — and honestly NO nub or synthesized cursor. expect(POCKET_TARGETS["macos-widget"].capabilities).toEqual([ @@ -271,6 +278,16 @@ describe("semantic resolution", () => { expect(verifyPlanHash(result.plan)).toBe(true); }); + test("a fixed-rate target's plan carries the profile rate", () => { + const onButtons = structuredClone(portableInput) as any; + onButtons.engine.capabilities.requires = ["input.buttons", "text.glyphs.baked"]; + const result = validateAndResolveBuildPlan(onButtons, { target: "pocketbook" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.plan.target).toEqual({ id: "pocketbook", hostAbi: 5, tickHz: 30 }); + expect(verifyPlanHash(result.plan)).toBe(true); + }); + test("desktop-widget capabilities are first-class: PSP admission refuses them", () => { // A widget-only app REQUIRES the desktop surface — a PSP plan must be // rejected at resolve time, not discovered broken at runtime. diff --git a/tools/build.ts b/tools/build.ts index 58ed61d8..de937505 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -209,12 +209,24 @@ if (densityFlag !== undefined && (!Number.isInteger(densityFlag) || densityFlag const rasterDensity = buildPlan?.viewport.rasterDensity ?? densityFlag ?? 1; // Tick rate: the realm's virtual-time step, baked into the bundle because -// every ms-to-frame conversion in the framework resolves against it. The -// plan does not own it, so --hz is accepted with or without --plan. +// every ms-to-frame conversion in the framework resolves against it. A +// fixed-rate target owns it through the plan (pocketbook drives 30); --hz +// serves plan-less builds and targets whose host stages the rate per run +// (ios-dev), where the plan carries none. if (hzFlag !== undefined && (!Number.isInteger(hzFlag) || hzFlag < 1 || hzFlag > 240)) { throw new Error("PocketJS build: --hz wants an integer from 1 through 240"); } -const tickHz = hzFlag ?? 60; +if ( + buildPlan?.target.tickHz !== undefined && + hzFlag !== undefined && + hzFlag !== buildPlan.target.tickHz +) { + throw new Error( + `PocketJS build: --hz=${hzFlag} conflicts with the ${buildPlan.target.id} plan rate ` + + `${buildPlan.target.tickHz} — the target's host drives that rate`, + ); +} +const tickHz = hzFlag ?? buildPlan?.target.tickHz ?? 60; console.log( `PocketJS build: ${appName} (${entry}, framework=${framework}` + `${tickHz === 60 ? "" : `, ${tickHz}Hz`}` +