Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions contracts/spec/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export interface TargetProfile<C extends string = string> {
/** 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[];
}
Expand Down Expand Up @@ -256,6 +262,10 @@ export const POCKET_TARGETS = defineTargetRegistry<PocketCapabilityId, {
presentations: ["integer-fit"],
rasterDensity: 2,
},
// E-ink doesn't need 60; 30 keeps animations smooth while sparing CPU
// and battery. hosts/pocketbook TICK_HZ mirrors this value the way its
// HOST_ID/HOST_ABI consts mirror the identity above.
tickHz: 30,
capabilities: ["input.buttons", "input.touch", "text.glyphs.baked"],
},
// The flat pocket-widget shell (examples/note-widget is the stock host):
Expand Down
6 changes: 5 additions & 1 deletion engine/crates/pocket-widget/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ pub struct WidgetConfig {
pub title: String,
/// Initial window size in logical px.
pub size: (u32, u32),
/// Fixed simulation rate — the guest cadence (60 = the PSP's).
/// Fixed simulation rate — the guest cadence (60 = the PSP's). A game
/// embedding a PocketJS realm must declare this same rate on its surface
/// before mount (`UiSurface::set_tick_rate`): the core converts ms
/// animations at the declared rate, so an undeclared non-60 cadence runs
/// them at the wrong wall-clock speed.
pub tick_hz: f32,
/// Render cap for the active case (eases, drags). The loop sleeps
/// between frames; dirt reported while pacing is latched, never lost.
Expand Down
11 changes: 11 additions & 0 deletions engine/pocket3d/examples/handheld/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ use winit::keyboard::KeyCode;
use device::Device;
use media::MediaService;

/// 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;

/// Keys the widget polls for held state (the shared uihost map + I/J/K/L
/// as a keyboard nub).
const KEYS: [KeyCode; 14] = [
Expand Down Expand Up @@ -1122,6 +1126,12 @@ fn boot(args: &Args, settings: &device::StageSettings) -> 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.
Expand Down Expand Up @@ -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()
},
Expand Down
11 changes: 11 additions & 0 deletions engine/pocket3d/examples/note-widget/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions framework/src/manifest/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions framework/src/manifest/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions hosts/pocketbook/docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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()?;
Expand All @@ -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; }
Expand Down
17 changes: 13 additions & 4 deletions hosts/pocketbook/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -110,6 +115,10 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver<Event>) -> 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()?;
Expand Down Expand Up @@ -146,7 +155,7 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver<Event>) -> 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 {
Expand Down
17 changes: 17 additions & 0 deletions tests/platform-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 15 additions & 3 deletions tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`}` +
Expand Down