From cb076a243591c4f2050a860b7d5ddcede415b0db Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 9 Aug 2026 16:23:39 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(core):=20per-realm=20tick=20rate=20?= =?UTF-8?q?=E2=80=94=20fixed-step=20at=20a=20declared=20hz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1/60 s step becomes a per-realm constant chosen before the first tick: Ui::set_tick_rate threads dt through the spring integrators and ms-to-frame conversions (exact integer hz kept alongside dt so frame counts stay byte-stable), UiSurface and the pocket-apple C ABI expose it (pocket_apple_set_tick_rate / pocket_apple_core_set_tick_rate, gated on the first tick), and PocketSurfaceView pins its CADisplayLink to the declared rate. Guest-side virtual time bakes the same way glyphs do: tools/build.ts --hz defines __POCKET_TICK_HZ__, clock/kinetics/input/deepzoom derive their per-tick constants from it (the 60 path stays bit-for-bit the original), and pocket ios gains --hz=60|120 staged through current.json to the shell. Hero's headline reads the baked rate. Defaults everywhere remain 60, so existing goldens, tapes, and bundles are unchanged. Co-Authored-By: Claude Fable 5 --- apps/hero/app.tsx | 3 +- engine/apple/apple/PocketSurfaceView.h | 5 +++ engine/apple/apple/PocketSurfaceView.m | 15 ++++++- engine/apple/include/pocket_apple.h | 14 +++++- engine/apple/src/core_host.rs | 30 ++++++++++++- engine/apple/src/lib.rs | 32 ++++++++++++- engine/core/src/anim.rs | 32 ++++++------- engine/core/src/lib.rs | 44 +++++++++++++++--- engine/core/src/tests.rs | 37 ++++++++++++++- .../crates/pocket-ui-surface/src/surface.rs | 6 +++ framework/src/clock.ts | 28 +++++++++--- framework/src/deepzoom.ts | 38 +++++++++------- framework/src/input.ts | 4 +- framework/src/kinetics.ts | 18 +++++--- .../ns-shell/App_Resources/iOS/Info.plist | 2 + hosts/apple/ns-shell/src/app.ts | 5 ++- tools/build.ts | 14 +++++- tools/ios.ts | 45 ++++++++++++++++--- 18 files changed, 303 insertions(+), 69 deletions(-) diff --git a/apps/hero/app.tsx b/apps/hero/app.tsx index f5eb28ec..cee1151d 100644 --- a/apps/hero/app.tsx +++ b/apps/hero/app.tsx @@ -10,6 +10,7 @@ import { type NodeMirror, } from "@pocketjs/framework/components"; import { animate } from "@pocketjs/framework/animation"; +import { TICKS_PER_SECOND } from "@pocketjs/framework/clock"; import { createSpriteAnimation } from "@pocketjs/framework/lifecycle"; import { frameworkName } from "@pocketjs/framework/solid"; @@ -103,7 +104,7 @@ export default function Hero(props: HeroProps = {}) { - {props.headline ?? "JSX at 60 FPS."} + {props.headline ?? `JSX at ${TICKS_PER_SECOND} FPS.`} diff --git a/engine/apple/apple/PocketSurfaceView.h b/engine/apple/apple/PocketSurfaceView.h index 76aa3b12..18163aa2 100644 --- a/engine/apple/apple/PocketSurfaceView.h +++ b/engine/apple/apple/PocketSurfaceView.h @@ -63,6 +63,11 @@ NS_ASSUME_NONNULL_BEGIN // Convenience: reads .js and .pak from a directory. - (BOOL)loadAppNamed:(NSString *)name fromDirectory:(NSString *)directory; +// Ticks per second of guest virtual time, and the rate the display link is +// pinned to. 0 means the 60 Hz default. Read once, by start; the bundle must +// have been built for the same rate (`pocket ios build --hz=`). +@property(nonatomic) uint32_t tickRate; + // Starts/stops the CADisplayLink. start after evalBundle succeeds. - (void)start; - (void)stop; diff --git a/engine/apple/apple/PocketSurfaceView.m b/engine/apple/apple/PocketSurfaceView.m index e6b49c36..e36b9aa4 100644 --- a/engine/apple/apple/PocketSurfaceView.m +++ b/engine/apple/apple/PocketSurfaceView.m @@ -15,6 +15,9 @@ static const char *const kPocketSurfaceHostId = "ios-dev"; static const uint32_t kPocketSurfaceHostAbi = 7; +// spec FIXED_DT — the rate a realm runs at when `tickRate` is left unset. +static const uint32_t kPocketSurfaceDefaultTickRate = 60; + typedef struct { __weak UITouch *touch; CGPoint point; @@ -204,10 +207,18 @@ - (void)start { return; } _running = YES; + uint32_t rate = _tickRate > 0 ? _tickRate : kPocketSurfaceDefaultTickRate; + // ERR_BAD_STATE here means a restart after the realm already ticked, which + // keeps the rate the first start declared. + if (_handle != NULL) { + pocket_apple_set_tick_rate(_handle, rate); + } else if (_coreHandle != NULL) { + pocket_apple_core_set_tick_rate(_coreHandle, rate); + } _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayTick:)]; if (@available(iOS 15.0, *)) { - // The core advances in exact 1/60 s steps; cap the link to match. - _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(60, 60, 60); + // The core advances in exact 1/rate s steps; pin the link to match. + _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(rate, rate, rate); } [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; } diff --git a/engine/apple/include/pocket_apple.h b/engine/apple/include/pocket_apple.h index f9d64e04..c4cd953e 100644 --- a/engine/apple/include/pocket_apple.h +++ b/engine/apple/include/pocket_apple.h @@ -3,10 +3,11 @@ // handle from one thread (in practice the main thread, with CADisplayLink). // // Call order per handle: -// create -> load_pak* -> [set_identity] -> eval_bundle +// create -> load_pak* -> [set_identity] -> eval_bundle -> [set_tick_rate] // -> per tick: frame, render -> destroy // load_pak/set_identity are rejected after eval_bundle: the surface publishes -// both to the guest when `ui` is mounted. +// both to the guest when `ui` is mounted. set_tick_rate is rejected after the +// first frame: the step size has to be constant for a realm's whole run. #ifndef POCKET_APPLE_H #define POCKET_APPLE_H @@ -50,6 +51,11 @@ PocketApple *pocket_apple_create(uint32_t density, uint32_t logical_width, int32_t pocket_apple_set_identity(PocketApple *handle, const char *host_id, uint32_t host_abi); +// Ticks per second of guest virtual time (1..240, default 60); rejected after +// the first frame. The bundle must be built for the same rate, and the +// display link must be driven at it. +int32_t pocket_apple_set_tick_rate(PocketApple *handle, uint32_t hz); + int32_t pocket_apple_load_pak(PocketApple *handle, const uint8_t *bytes, size_t length); @@ -137,6 +143,10 @@ int32_t pocket_apple_core_post_event(PocketAppleCore *handle, const char *line); void pocket_apple_core_drain_effects(PocketAppleCore *handle, PocketAppleEffectCallback callback, void *context); +// Ticks per second of the core's virtual time (1..240, default 60); rejected +// after the first tick. Same bundle/display-link pairing as the guest mode. +int32_t pocket_apple_core_set_tick_rate(PocketAppleCore *handle, uint32_t hz); + void pocket_apple_core_tick(PocketAppleCore *handle); int32_t pocket_apple_core_render(PocketAppleCore *handle, PocketAppleFrame *out); void pocket_apple_core_destroy(PocketAppleCore *handle); diff --git a/engine/apple/src/core_host.rs b/engine/apple/src/core_host.rs index 82bd80e1..e9b3792a 100644 --- a/engine/apple/src/core_host.rs +++ b/engine/apple/src/core_host.rs @@ -14,10 +14,13 @@ use pocketjs_core::damage::{DamagePolicy, DamageTracker}; use pocketjs_core::raster; use pocketjs_core::Ui; -use crate::{set_last_error, PocketAppleFrame, POCKET_APPLE_MAX_DAMAGE_REGIONS}; +use crate::{ + set_last_error, PocketAppleFrame, MAX_TICK_HZ, MIN_TICK_HZ, POCKET_APPLE_MAX_DAMAGE_REGIONS, +}; const OK: i32 = 0; const ERR_BAD_ARGUMENT: i32 = -1; +const ERR_BAD_STATE: i32 = -2; const ERR_PANIC: i32 = -4; pub struct SpriteReg { @@ -40,6 +43,7 @@ pub struct PocketAppleCore { svc_in: VecDeque, svc_out: VecDeque, svc_poll_batch: CString, + ticked: bool, } fn with_core( @@ -111,6 +115,7 @@ pub extern "C" fn pocket_apple_core_create( svc_in: VecDeque::new(), svc_out: VecDeque::new(), svc_poll_batch: CString::default(), + ticked: false, })) }); result.unwrap_or(std::ptr::null_mut()) @@ -490,9 +495,30 @@ pub extern "C" fn pocket_apple_core_drain_effects( // ---- frame ---------------------------------------------------------------- +/// Ticks per second of the core's virtual time. 1..=240; the guest bundle +/// mounted over this core must be built for the same rate. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_set_tick_rate(handle: *mut PocketAppleCore, hz: u32) -> i32 { + with_core(handle, ERR_PANIC, |state| { + if state.ticked { + set_last_error("tick rate must be set before the first tick"); + return ERR_BAD_STATE; + } + if !(MIN_TICK_HZ..=MAX_TICK_HZ).contains(&hz) { + set_last_error("tick rate must be 1 through 240 Hz"); + return ERR_BAD_ARGUMENT; + } + state.ui.set_tick_rate(hz); + OK + }) +} + #[unsafe(no_mangle)] pub extern "C" fn pocket_apple_core_tick(handle: *mut PocketAppleCore) { - with_core(handle, (), |state| state.ui.tick()); + with_core(handle, (), |state| { + state.ticked = true; + state.ui.tick(); + }); } #[unsafe(no_mangle)] diff --git a/engine/apple/src/lib.rs b/engine/apple/src/lib.rs index 7841c215..73b967e3 100644 --- a/engine/apple/src/lib.rs +++ b/engine/apple/src/lib.rs @@ -14,7 +14,9 @@ //! Call order per handle: `create` → `load_pak`* → `eval_bundle` → per tick //! `frame` then `render` → `destroy`. `load_pak` and `set_identity` are //! rejected after `eval_bundle` because the surface publishes both to the -//! guest at mount time. +//! guest at mount time. `set_tick_rate` survives `eval_bundle` (nothing about +//! it reaches the guest at mount) but is rejected after the first `frame`, +//! because the step size has to be constant for a realm's whole run. use std::cell::RefCell; use std::ffi::{c_char, CString}; @@ -32,6 +34,11 @@ use pocketjs_core::spec; pub const POCKET_APPLE_ABI_VERSION: u32 = 1; pub const POCKET_APPLE_MAX_DAMAGE_REGIONS: usize = DEFAULT_DAMAGE_REGIONS; +/// Accepted `set_tick_rate` range: covers every Apple display cadence from a +/// throttled 1 Hz up to the 240 Hz headroom above ProMotion's 120. +pub(crate) const MIN_TICK_HZ: u32 = 1; +pub(crate) const MAX_TICK_HZ: u32 = 240; + const OK: i32 = 0; const ERR_BAD_ARGUMENT: i32 = -1; const ERR_BAD_STATE: i32 = -2; @@ -61,6 +68,7 @@ pub struct PocketApple { logical_width: u32, logical_height: u32, mounted: bool, + ticked: bool, effect_callback: Option<(PocketAppleEffectCallback, *mut std::ffi::c_void)>, } @@ -145,6 +153,7 @@ pub extern "C" fn pocket_apple_create( logical_width, logical_height, mounted: false, + ticked: false, effect_callback: None, })) }); @@ -176,6 +185,26 @@ pub extern "C" fn pocket_apple_set_identity( }) } +/// Ticks (and therefore `pocket_apple_frame` calls) per second of guest +/// virtual time. 1..=240; the guest bundle must be built for the same rate. +/// Unlike `set_identity` this is accepted after `eval_bundle` (nothing about +/// it is published to the guest at mount) but not after the first frame. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_set_tick_rate(handle: *mut PocketApple, hz: u32) -> i32 { + with_handle(handle, ERR_PANIC, |state| { + if state.ticked { + set_last_error("tick rate must be set before the first frame"); + return ERR_BAD_STATE; + } + if !(MIN_TICK_HZ..=MAX_TICK_HZ).contains(&hz) { + set_last_error("tick rate must be 1 through 240 Hz"); + return ERR_BAD_ARGUMENT; + } + state.surface.set_tick_rate(hz); + OK + }) +} + #[unsafe(no_mangle)] pub extern "C" fn pocket_apple_load_pak( handle: *mut PocketApple, @@ -256,6 +285,7 @@ pub extern "C" fn pocket_apple_frame( set_last_error("frame before eval_bundle"); return ERR_BAD_STATE; } + state.ticked = true; let touch_words: &[u32] = if touches.is_null() || touch_count == 0 { &[] } else { diff --git a/engine/core/src/anim.rs b/engine/core/src/anim.rs index e938bd23..3b213d43 100644 --- a/engine/core/src/anim.rs +++ b/engine/core/src/anim.rs @@ -1,7 +1,8 @@ -//! Tween/spring tracks — fixed dt = spec::FIXED_DT per tick, never wall -//! clock. Frame content is a pure function of frame index (byte-exact -//! goldens depend on it): easings are polynomial closed forms, springs are a -//! deterministic semi-implicit-Euler damped oscillator at the fixed dt. +//! Tween/spring tracks — fixed dt per tick (the realm's tick rate, spec +//! default spec::FIXED_DT), never wall clock. Frame content is a pure +//! function of frame index (byte-exact goldens depend on it): easings are +//! polynomial closed forms, springs are a deterministic semi-implicit-Euler +//! damped oscillator at that fixed dt. //! //! Value plumbing (see lib.rs): a running track writes its per-frame value //! into the node's `anim_values`; on completion a transition track simply @@ -12,12 +13,12 @@ use alloc::vec::Vec; use crate::spec; -/// Convert a duration in ms to whole 60 Hz frames (>= 1). Widened to u64 so -/// host-controlled durations near u32::MAX cannot overflow `ms * 60` (the -/// result always fits back in u32: max ~257.7M frames). +/// Convert a duration in ms to whole `hz`-rate frames (>= 1). Widened to u64 +/// so host-controlled durations near u32::MAX cannot overflow `ms * hz` (the +/// result always fits back in u32: max ~257.7M frames at 60 Hz). #[inline] -pub fn ms_to_frames(ms: u32) -> u32 { - (((ms as u64 * 60 + 500) / 1000) as u32).max(1) +pub fn ms_to_frames(ms: u32, hz: u32) -> u32 { + (((ms as u64 * hz as u64 + 500) / 1000) as u32).max(1) } /// Where a track came from (decides completion semantics — see lib.rs). @@ -192,8 +193,8 @@ pub fn interp(from: u32, to: u32, f: f32, is_color: bool) -> u32 { } impl Track { - /// Advance one fixed-dt frame. Returns (current raw value, done). - pub fn step(&mut self) -> (u32, bool) { + /// Advance one fixed-`dt` frame. Returns (current raw value, done). + pub fn step(&mut self, dt: f32) -> (u32, bool) { self.elapsed += 1; if self.elapsed <= self.delay { return (self.from, false); @@ -209,8 +210,8 @@ impl Track { (180.0f32, 12.0f32) // underdamped: visible bounce }; let a = k * (1.0 - self.spring_x) - c * self.spring_v; - self.spring_v += a * spec::FIXED_DT; - self.spring_x += self.spring_v * spec::FIXED_DT; + self.spring_v += a * dt; + self.spring_x += self.spring_v * dt; let done = absf(1.0 - self.spring_x) < 0.0005 && absf(self.spring_v) < 0.01; let f = if done { 1.0 } else { self.spring_x }; (interp(self.from, self.to, f, self.is_color), done) @@ -274,6 +275,7 @@ impl Anims { dur_ms: u32, easing: u8, delay_ms: u32, + hz: u32, ) -> i32 { self.kill_for(node, prop); let slot = match self.free.pop() { @@ -312,8 +314,8 @@ impl Anims { kind, from, to, - delay: if delay_ms == 0 { 0 } else { ms_to_frames(delay_ms) }, - dur: ms_to_frames(dur_ms), + delay: if delay_ms == 0 { 0 } else { ms_to_frames(delay_ms, hz) }, + dur: ms_to_frames(dur_ms, hz), easing, elapsed: 0, spring_x: 0.0, diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index fd71ee68..a25f5017 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -12,8 +12,10 @@ //! from its old parent first. anchor 0 = append. //! - `destroy_node` destroys the subtree, frees its anim tracks, and clears //! focus if the focused node is inside. -//! - `tick()` advances EXACTLY spec::FIXED_DT per call (frame content is a -//! pure function of frame index — byte-exact goldens depend on it). +//! - `tick()` advances EXACTLY one fixed step per call — spec::FIXED_DT +//! unless `set_tick_rate` declared another rate before the first tick +//! (frame content is a pure function of frame index — byte-exact +//! goldens depend on it). //! - `draw()` output is fully CPU-clipped: every coordinate in the DrawList //! is inside [0, SCREEN_W] x [0, SCREEN_H] (see spec.ts DRAWLIST comment). //! @@ -52,6 +54,10 @@ pub use draw::DrawList; /// CLUT byte size: 256 entries x u32 ABGR (the GE CLUT8 palette). const TEX_PALETTE_BYTES: usize = 1024; +/// Integer form of `spec::FIXED_DT` — the tick rate a realm runs at unless +/// `set_tick_rate` declares another one before the first `tick()`. +const DEFAULT_TICK_HZ: u32 = 60; + /// One uploaded texture. Pixels are copied into 16-byte-aligned storage so /// the PSP GE can sample them directly (the wasm rasterizer reads them via /// `Ui::texture`). @@ -252,6 +258,12 @@ pub struct Ui { touch_table: touch::HitTable, /// Frame counter advanced by `tick()` (drives fixed-dt animation). frame: u64, + /// Seconds of virtual time one `tick()` advances. + dt: f32, + /// The integer rate backing `dt`. Kept alongside it so duration-ms to + /// frame-count conversions stay exact integer arithmetic (round-tripping + /// through `1.0 / dt` would perturb byte-exact goldens). + tick_hz: u32, /// DevTools (spec ops 18..22, docs/DEVTOOLS.md). All default-off. inspect_id: i32, /// World AABB (x, y, w, h) of the inspected node, captured by the last @@ -305,6 +317,8 @@ impl Ui { cursor_pos: (0.0, 0.0), touch_table: touch::HitTable::default(), frame: 0, + dt: spec::FIXED_DT, + tick_hz: DEFAULT_TICK_HZ, inspect_id: 0, inspect_rect: None, inspect_drawn: None, @@ -318,6 +332,23 @@ impl Ui { self.raster_density } + /// Declare how many `tick()` calls make one second of virtual time + /// (spec default 60). Ignored once the first `tick()` has run: a realm's + /// frame content is a pure function of its frame index, so the step size + /// has to be constant for the whole run. + pub fn set_tick_rate(&mut self, hz: u32) { + if hz == 0 || self.frame != 0 { + return; + } + self.tick_hz = hz; + self.dt = 1.0 / hz as f32; + } + + /// Ticks per second of virtual time (see `set_tick_rate`). + pub fn tick_rate(&self) -> u32 { + self.tick_hz + } + /// Monotonic token for texture/font/style contents consumed by renderers. pub fn raster_revision(&self) -> u64 { self.raster_revision @@ -754,6 +785,7 @@ impl Ui { dur_ms, easing, delay_ms, + self.tick_hz, ); if anim_id > 0 { let node = &mut self.tree.slots[slot as usize]; @@ -922,8 +954,9 @@ impl Ui { // ---- frame ------------------------------------------------------------- - /// Advance one frame: tick animations by exactly spec::FIXED_DT, then - /// re-run layout if dirty. Call once per vblank, BEFORE `draw()`. + /// Advance one frame: tick animations by exactly one `set_tick_rate` + /// step, then re-run layout if dirty. Call once per vblank, BEFORE + /// `draw()`. pub fn tick(&mut self) { if self.paused { if !self.step_pending { @@ -937,7 +970,7 @@ impl Ui { if !self.anims.tracks[tslot as usize].alive { continue; } - let (value, done) = self.anims.tracks[tslot as usize].step(); + let (value, done) = self.anims.tracks[tslot as usize].step(self.dt); let (node_id, prop, kind, to) = { let t = &self.anims.tracks[tslot as usize]; (t.node, t.prop, t.kind, t.to) @@ -1365,6 +1398,7 @@ impl Ui { tr.dur_ms as u32, tr.easing, tr.delay_ms as u32, + self.tick_hz, ); if aid > 0 { spawned[prop as usize] = true; diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index ec7a45a9..6ccb009d 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -765,6 +765,38 @@ fn ui_rejects_zero_raster_density() { let _ = Ui::new_with_raster_density(0); } +#[test] +fn tick_rate_is_fixed_once_the_realm_has_ticked() { + let mut ui = Ui::new(); + assert_eq!(ui.tick_rate(), 60, "spec default"); + ui.set_tick_rate(0); + assert_eq!(ui.tick_rate(), 60, "0 Hz is not a rate"); + ui.set_tick_rate(120); + assert_eq!(ui.tick_rate(), 120); + ui.tick(); + ui.set_tick_rate(60); + assert_eq!(ui.tick_rate(), 120, "a running realm keeps its step size"); +} + +#[test] +fn a_120_hz_realm_runs_a_tween_over_twice_the_frames() { + let mut at = |hz: u32| { + let mut ui = Ui::new(); + ui.set_tick_rate(hz); + let n = ui.create_node(0); + ui.insert_before(spec::ROOT_ID, n, 0); + ui.animate(n, spec::prop::OPACITY, 0.0, 200, 0, 0); + let mut frames = 0; + while ui.resolved_style(n).unwrap().opacity > 0.0 && frames < 1000 { + ui.tick(); + frames += 1; + } + frames + }; + assert_eq!(at(60), 12, "200 ms at 60 Hz"); + assert_eq!(at(120), 24, "the same 200 ms of virtual time"); +} + #[test] fn transparent_rounded_border_draws_an_outline_not_square_strips() { let mut ui = Ui::new(); @@ -1405,8 +1437,9 @@ fn size_full_sentinel_is_not_animatable() { #[test] fn huge_durations_do_not_overflow() { - assert!(crate::anim::ms_to_frames(u32::MAX) >= 1); // would panic pre-fix - assert_eq!(crate::anim::ms_to_frames(100_000_000), 6_000_000); + assert!(crate::anim::ms_to_frames(u32::MAX, 60) >= 1); // would panic pre-fix + assert!(crate::anim::ms_to_frames(u32::MAX, 240) >= 1); + assert_eq!(crate::anim::ms_to_frames(100_000_000, 60), 6_000_000); let mut ui = Ui::new(); let n = ui.create_node(0); ui.insert_before(spec::ROOT_ID, n, 0); diff --git a/engine/crates/pocket-ui-surface/src/surface.rs b/engine/crates/pocket-ui-surface/src/surface.rs index 3c75dda3..a688501f 100644 --- a/engine/crates/pocket-ui-surface/src/surface.rs +++ b/engine/crates/pocket-ui-surface/src/surface.rs @@ -208,6 +208,12 @@ impl UiSurface { } } + /// Declare how many ticks make one second of virtual time (default 60). + /// Ignored once the core has ticked (see `Ui::set_tick_rate`). + pub fn set_tick_rate(&self, hz: u32) { + self.inner.borrow_mut().ui.set_tick_rate(hz); + } + /// Advance the core one fixed-dt frame (call once per host tick, after /// the guest turn, before rendering). pub fn tick(&self) { diff --git a/framework/src/clock.ts b/framework/src/clock.ts index cd0a0809..1363f6c8 100644 --- a/framework/src/clock.ts +++ b/framework/src/clock.ts @@ -13,11 +13,29 @@ // hz-portable express time in seconds — `after(seconds, cb)` here, ms-based // animation/transition classes in styles — never in raw frame counts. -/** Core ticks per second of virtual time (spec FIXED_DT = 1/60 s per tick). */ -export const TICKS_PER_SECOND = 60; +// Replaced by tools/build.ts (`--hz=`). `typeof` keeps bundles built by +// anything else, and the test/sim runs that import this module directly, +// valid at the spec rate. +declare const __POCKET_TICK_HZ__: number; + +/** + * Core ticks per second of virtual time. The realm's tick rate is baked into + * the bundle, so a bundle only runs correctly on a surface driven at the same + * rate. Spec default is FIXED_DT = 1/60 s per tick; 120 is the ProMotion rate. + */ +export const TICKS_PER_SECOND = + typeof __POCKET_TICK_HZ__ === "number" && __POCKET_TICK_HZ__ > 0 ? __POCKET_TICK_HZ__ : 60; /** The simulation rates that divide the core tick rate exactly. */ -export const VALID_HZ: readonly number[] = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60]; +export const VALID_HZ: readonly number[] = divisorsOf(TICKS_PER_SECOND); + +function divisorsOf(n: number): number[] { + const out: number[] = []; + for (let d = 1; d <= n; d++) { + if (n % d === 0) out.push(d); + } + return out; +} let hz = TICKS_PER_SECOND; let frame = -1; // advanced to 0 on the first pump; -1 = "before boot frame" @@ -29,7 +47,7 @@ interface Timer { } let timers: Timer[] = []; -/** Snap an arbitrary rate to the nearest exact divisor of 60. */ +/** Snap an arbitrary rate to the nearest exact divisor of TICKS_PER_SECOND. */ export function normalizeHz(raw: number): number { if (!Number.isFinite(raw) || raw <= 0) return TICKS_PER_SECOND; let best = VALID_HZ[0]; @@ -44,7 +62,7 @@ export function simulationHz(): number { return hz; } -/** Core ticks the host must run per virtual frame (60 / hz, always exact). */ +/** Core ticks the host runs per virtual frame (TICKS_PER_SECOND / hz, exact). */ export function ticksPerFrame(): number { return TICKS_PER_SECOND / hz; } diff --git a/framework/src/deepzoom.ts b/framework/src/deepzoom.ts index 0a3b8528..b447f147 100644 --- a/framework/src/deepzoom.ts +++ b/framework/src/deepzoom.ts @@ -27,7 +27,7 @@ import { onCleanup, type JSX as SolidJSX } from "solid-js"; import { BTN, ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; -import { ticksPerFrame } from "./clock.ts"; +import { ticksPerFrame, TICKS_PER_SECOND } from "./clock.ts"; import { getOps, hostViewport } from "./host.ts"; import { analogX, analogY, onFrame } from "./frame.ts"; import * as hot from "./hot.ts"; @@ -117,21 +117,25 @@ export interface DeepZoomProps { onView?: (view: DeepZoomView) => void; } -// Motion constants are PER 1/60s TICK and scaled by the virtual-clock policy -// (ticksPerFrame = 60/simulationHz) each frame, so a one-second nub hold pans -// the same document distance at every simulationHz — DeepZoom trajectories -// obey the same subsampling property as core animations (docs/DETERMINISM.md). -// +// Motion constants are PER TICK and scaled by the virtual-clock policy +// (ticksPerFrame = TICKS_PER_SECOND/simulationHz) each frame, so a one-second +// nub hold pans the same document distance at every simulationHz — DeepZoom +// trajectories obey the same subsampling property as core animations +// (docs/DETERMINISM.md). They are quoted for the spec 1/60 s tick and +// re-based once here for a realm that declared another rate, so a second of +// held input also travels the same distance at every tick rate. +const TICK_SCALE = 60 / TICKS_PER_SECOND; +const perTick = (at60: number) => (TICK_SCALE === 1 ? at60 : at60 ** TICK_SCALE); // Screen-space pan speed at full nub tilt (px/tick) — zoom-invariant. -const PAN_SPEED = 7; +const PAN_SPEED = 7 * TICK_SCALE; // D-pad pan speed (px/tick) for stickless hosts. -const DPAD_SPEED = 5; -// Zoom factor per tick while a trigger is held (~×2 in 20 ticks). -const ZOOM_STEP = 1.035; +const DPAD_SPEED = 5 * TICK_SCALE; +// Zoom factor per tick while a trigger is held (~×2 in 20 ticks at 60 Hz). +const ZOOM_STEP = perTick(1.035); // Velocity smoothing per tick: approach factor toward the input target, and // the decay once input releases (momentum glide). -const VEL_APPROACH = 0.35; -const VEL_DECAY = 0.88; +const VEL_APPROACH = 1 - perTick(1 - 0.35); +const VEL_DECAY = perTick(0.88); // Switch mip level only when the ideal level differs this long (frames), so // a zoom hovering at a boundary doesn't thrash mount/unmount. const LEVEL_DEBOUNCE = 8; @@ -402,11 +406,11 @@ export function DeepZoom(props: DeepZoomProps): SolidJSX.Element { syncLiveViewport(); if (doc !== props.doc) initDoc(props.doc); // app swapped pages - // Virtual-clock scaling: 60/simulationHz ticks elapse per frame. The - // integrator runs ONCE PER TICK (not once per frame with a dt factor) so - // a low-hz trajectory is the exact subsample of the 60 Hz one — the same - // discrete recurrence, evaluated at the same tick indices, from inputs - // held constant across the frame (docs/DETERMINISM.md). + // Virtual-clock scaling: TICKS_PER_SECOND/simulationHz ticks elapse per + // frame. The integrator runs ONCE PER TICK (not once per frame with a dt + // factor) so a low-hz trajectory is the exact subsample of the full-rate + // one — the same discrete recurrence, evaluated at the same tick indices, + // from inputs held constant across the frame (docs/DETERMINISM.md). const dt = ticksPerFrame(); const gesture = props.gestureSource?.() ?? null; diff --git a/framework/src/input.ts b/framework/src/input.ts index a1e40a1a..af6291f4 100644 --- a/framework/src/input.ts +++ b/framework/src/input.ts @@ -36,7 +36,7 @@ // untouched (they run in frame.ts before this module). import { BTN, IMG_FLAG_RLE, PSM, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; -import { ticksPerFrame } from "./clock.ts"; +import { ticksPerFrame, TICKS_PER_SECOND } from "./clock.ts"; import { analogX, analogY } from "./frame.ts"; import { getHost, getOps, hostViewport, type HostOps } from "./host.ts"; import { get as pakGet } from "./pak.ts"; @@ -765,7 +765,7 @@ function cursorFrame(buttons: number, pressed: number, released: number): boolea } let moved = c.fresh; if (vx !== 0 || vy !== 0) { - const dt = ticksPerFrame() / 60; + const dt = ticksPerFrame() / TICKS_PER_SECOND; const nx = Math.min(Math.max(c.x + vx * dt, 0), c.vw - 1); const ny = Math.min(Math.max(c.y + vy * dt, 0), c.vh - 1); if (nx !== c.x || ny !== c.y) { diff --git a/framework/src/kinetics.ts b/framework/src/kinetics.ts index 4a13c151..348a22ee 100644 --- a/framework/src/kinetics.ts +++ b/framework/src/kinetics.ts @@ -30,7 +30,7 @@ import { createSignal, type Accessor } from "solid-js"; import { BTN, SCREEN_H } from "../../contracts/spec/spec.ts"; import { analogY } from "./analog.ts"; -import { simulationHz, ticksPerFrame } from "./clock.ts"; +import { simulationHz, ticksPerFrame, TICKS_PER_SECOND } from "./clock.ts"; import { onFrame } from "./frame.ts"; export type ScrollerState = "idle" | "tracking" | "fling" | "spring" | "chase" | "tween"; @@ -89,11 +89,15 @@ export interface Scroller { step(): void; } -// Fling decay per 1/60 s tick. 0.9672 ≡ UIScrollView's 0.998/ms at 16.667 ms -// (0.998^16.667); 0.846 ≡ the 0.99/ms paging rate. Literals on purpose — -// computing them at runtime would put a transcendental in the sim path. -const DECAY_NORMAL = 0.9672; -const DECAY_FAST = 0.846; +// Fling decay per tick, quoted for a 1/60 s tick. 0.9672 ≡ UIScrollView's +// 0.998/ms at 16.667 ms (0.998^16.667); 0.846 ≡ the 0.99/ms paging rate. +// Literals on purpose — computing them from the per-ms rate would put a +// transcendental in the sim path. A realm on another tick rate re-bases them +// once here, so the decay stays the same per second of virtual time. +const perTick = (at60: number) => + TICKS_PER_SECOND === 60 ? at60 : at60 ** (60 / TICKS_PER_SECOND); +const DECAY_NORMAL = perTick(0.9672); +const DECAY_FAST = perTick(0.846); /** Fling rest threshold, px per virtual second. */ const FLING_MIN_V = 4; /** Rubber-band slope at the edge (the classic iOS coefficient). */ @@ -108,7 +112,7 @@ const SPRING_SETTLE_V = 8; /** The apps/im chase pump constants. */ const CHASE_RATE = 0.3; const CHASE_SNAP = 0.6; -const TICK_DT = 1 / 60; +const TICK_DT = 1 / TICKS_PER_SECOND; /** Displayed rubber travel for `x` px of out-of-bounds drag: asymptote d, * slope RUBBER_COEFF at the edge. */ diff --git a/hosts/apple/ns-shell/App_Resources/iOS/Info.plist b/hosts/apple/ns-shell/App_Resources/iOS/Info.plist index 90de7ad4..371a9d6c 100644 --- a/hosts/apple/ns-shell/App_Resources/iOS/Info.plist +++ b/hosts/apple/ns-shell/App_Resources/iOS/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion en CFBundleDisplayName diff --git a/hosts/apple/ns-shell/src/app.ts b/hosts/apple/ns-shell/src/app.ts index 5b7630b9..d5f44e34 100644 --- a/hosts/apple/ns-shell/src/app.ts +++ b/hosts/apple/ns-shell/src/app.ts @@ -6,7 +6,7 @@ import { Application, File, Frame, GridLayout, Page, Screen, knownFolders } from import { PocketHostView, PocketView } from '@nativescript/pocketjs'; type BridgeCommand = { t?: string; id?: number; kind?: string; payload?: { n?: number } }; -type StagedApp = { app: string; externalGuest?: boolean }; +type StagedApp = { app: string; externalGuest?: boolean; tickHz?: number }; type StagedPlan = { viewport: { logical: [number, number]; rasterDensity: number } }; function readJson(relativePath: string): T { @@ -30,6 +30,9 @@ function createMainPage(): Page { // Glyph atlases bake at build density; the surface must raster at the same // scale or text renders soft. Never leave this to the screen-scale default. pocket.density = plan.viewport.rasterDensity; + // Virtual time is baked into the bundle the same way glyphs are baked into + // the atlases: the display link has to run at the rate it was built for. + pocket.tickRate = staged.tickHz ?? 60; const width = Screen.mainScreen.widthDIPs; pocket.width = width as never; pocket.height = Math.round((width * logicalHeight) / logicalWidth) as never; diff --git a/tools/build.ts b/tools/build.ts index c7e3dfc0..bb0c27b9 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -87,6 +87,7 @@ let configFlagged = false; let useConfig = true; let planPath: string | undefined; let densityFlag: number | undefined; +let hzFlag: number | undefined; let projectRoot = process.cwd(); for (const a of args) { if (a.startsWith("--extra-chars=")) extraChars = a.slice("--extra-chars=".length); @@ -99,6 +100,7 @@ for (const a of args) { else if (a.startsWith("--project-root=")) projectRoot = resolvePath(a.slice("--project-root=".length)); else if (a.startsWith("--outdir=")) DIST = resolvePath(a.slice("--outdir=".length)) + "/"; else if (a.startsWith("--density=")) densityFlag = Number(a.slice("--density=".length)); + else if (a.startsWith("--hz=")) hzFlag = Number(a.slice("--hz=".length)); else if (!a.startsWith("-")) appArg = a; } @@ -116,7 +118,7 @@ if (planPath) { } if (!appArg) { - console.error("usage: bun tools/build.ts [--plan=] [--framework=solid|vue-vapor|octane] [--extra-chars=...] [--density=N]"); + console.error("usage: bun tools/build.ts [--plan=] [--framework=solid|vue-vapor|octane] [--extra-chars=...] [--density=N] [--hz=N]"); process.exit(1); } @@ -205,8 +207,17 @@ if (densityFlag !== undefined && (!Number.isInteger(densityFlag) || densityFlag throw new Error("PocketJS build: --density wants an integer from 1 through 255"); } 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. +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; console.log( `PocketJS build: ${appName} (${entry}, framework=${framework}` + + `${tickHz === 60 ? "" : `, ${tickHz}Hz`}` + `${buildPlan ? `, target=${buildPlan.target.id}, raster=${rasterDensity}x, plan=${buildPlan.planHash.slice(0, 20)}…` : ""})`, ); @@ -469,6 +480,7 @@ const result = await Bun.build({ __POCKET_HOST_ABI__: String(buildPlan?.target.hostAbi ?? 0), __POCKET_FEATURES__: JSON.stringify(buildPlan?.features ?? {}), __POCKET_PIXEL_RATIO__: String(rasterDensity), + __POCKET_TICK_HZ__: String(tickHz), ...(framework === "vue-vapor" ? { document: "globalThis.__pocketDocument" } : {}), diff --git a/tools/ios.ts b/tools/ios.ts index b290858b..cd516908 100644 --- a/tools/ios.ts +++ b/tools/ios.ts @@ -28,6 +28,9 @@ const DEFAULT_SHELL = resolve(ROOT, "hosts/apple/ns-shell"); const XCFRAMEWORK_SCRIPT = resolve(ROOT, "engine/apple/build-xcframework.sh"); const XCFRAMEWORK_DIST = resolve(ROOT, "engine/apple/dist/PocketApple.xcframework"); const MIN_IOS_RUNTIME = 16; +/** Display cadences a PocketSurfaceView can be pinned to: 60, or ProMotion. */ +const IOS_TICK_RATES = [60, 120]; +const IOS_DEFAULT_TICK_RATE = 60; interface CommandResult { exitCode: number; @@ -77,6 +80,16 @@ function flagValue(args: readonly string[], name: string): string | undefined { return index >= 0 ? args[index + 1] : undefined; } +function tickRateFlag(args: readonly string[]): number { + const raw = flagValue(args, "--hz"); + if (raw === undefined) return IOS_DEFAULT_TICK_RATE; + const hz = Number(raw); + if (!IOS_TICK_RATES.includes(hz)) { + throw new Error(`pocket ios: --hz wants ${IOS_TICK_RATES.join(" or ")}`); + } + return hz; +} + function check(label: string, ok: boolean, detail?: string): boolean { console.log(` [${ok ? "ok" : "missing"}] ${label}${detail ? `: ${detail}` : ""}`); return ok; @@ -287,7 +300,7 @@ function normalizeDemoName(demo: string): string { return demo.replace(/-main$/, ""); } -async function buildGuest(demoArg: string, density: number): Promise { +async function buildGuest(demoArg: string, density: number, tickHz: number): Promise { const demo = normalizeDemoName(demoArg); const manifest = demoManifestFor(ROOT, demo); const plan = resolveIOSDevBuildPlan(manifest, density); @@ -299,7 +312,13 @@ async function buildGuest(demoArg: string, density: number): Promise { const options: StageOptions = { shellDir: resolve(flagValue(args, "--shell-dir") ?? DEFAULT_SHELL), externalGuest: args.includes("--external-guest"), + tickHz: tickRateFlag(args), pluginPath: flagValue(args, "--plugin-path"), runtimeTgz: flagValue(args, "--runtime-tgz"), }; @@ -411,7 +440,7 @@ async function play(demoArg: string, args: readonly string[]): Promise { throw new Error("pocket ios: --no-build but no prior guest artifacts — drop the flag"); } } else { - artifacts = await buildGuest(demoArg, density); + artifacts = await buildGuest(demoArg, density, options.tickHz); } stageAssets(artifacts, options); await installShellDependencies(options); @@ -442,13 +471,17 @@ const HELP = `PocketJS Apple / iOS toolchain pocket ios setup add the two Rust iOS targets; print install hints for the rest pocket ios devices list the arm64 iOS simulators this target can run on pocket ios native [--force] build engine/apple/dist/PocketApple.xcframework - pocket ios build [--density=1..${IOS_DEV_MAX_DENSITY}] + pocket ios build [--density=1..${IOS_DEV_MAX_DENSITY}] [--hz=${IOS_TICK_RATES.join("|")}] resolve the ${IOS_DEV_TARGET_ID} plan and emit dist/ios// pocket ios stage [flags] build + copy assets into the shell, without launching pocket ios play [flags] stage, then build and launch the shell on the simulator flags for stage/play: --density=1..${IOS_DEV_MAX_DENSITY} guest raster density (default ${IOS_DEV_DEFAULT_DENSITY}; glyphs bake at this scale) + --hz=${IOS_TICK_RATES.join("|")} ticks per second of guest time (default ${IOS_DEFAULT_TICK_RATE}; 120 for ProMotion) + Glyphs are density-baked; timing is hz-baked. A bundle + only runs correctly at the hz it was built with, so the + shell is staged with that rate. --external-guest evaluate the guest in the shell's own runtime (PocketHostView) --device= pick a specific simulator (default: booted, else newest runtime) --rebuild-native rebuild PocketApple.xcframework first (needs Rust iOS targets) @@ -482,7 +515,7 @@ export async function iosMain(args: readonly string[] = Bun.argv.slice(2)): Prom case "build": { if (!rest[0] || rest[0].startsWith("--")) throw new Error("pocket ios build: missing app name"); const density = Number(flagValue(rest, "--density") ?? IOS_DEV_DEFAULT_DENSITY); - const artifacts = await buildGuest(rest[0], density); + const artifacts = await buildGuest(rest[0], density, tickRateFlag(rest)); console.log(`pocket ios: built ${artifacts.bundle}`); return; } From c71d9c822f02a8c16df5fd3d390c2ee099e547a6 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:35:46 +0800 Subject: [PATCH 2/6] fix(ios): pin the shell to the tick-rate plugin and derive hero's FPS tile from the baked rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #257, on top of the rebase onto main: - hosts/apple/ns-shell pinned @nativescript/pocketjs to an exact 0.2.0. This commit's `pocket.tickRate = staged.tickHz ?? 60` needs an API that first ships in 0.2.0, and `^0.1.0` resolves to `>=0.1.0 <0.2.0`, so the shell could never install it. (The caret is self-consistent on main, whose shell never touches tickRate — the requirement arrives with this PR.) Verified 0.2.0 carries the whole ABI: tickRateProperty in index.js, tickRate in index.d.ts, and pocket_apple_set_tick_rate / pocket_apple_core_set_tick_rate / PocketSurfaceView.tickRate in the packed xcframework headers. Exact rather than caret matches the ios-quickjs entry beside it and the repo's toolchain-pin idiom. - apps/hero's FPS tile derives from TICKS_PER_SECOND like the headline already does. The headline became dynamic in this PR while the tile kept a literal 60, so a plain hero bundle at --hz=120 rendered "JSX at 120 FPS." beside a 60 FPS tile. At 60 both spellings are String(60), so no golden can churn. Verified on the rebased branch: `bun run test` 11/11 stages green, `bunx tsc --noEmit` clean, `cargo build -p pocket-apple` clean, engine/core 112 passed, and a rebuilt hero-main --hz=120 renders 180 frames non-blank and byte-identical across two instances with headline and tile both reading 120. Co-Authored-By: Claude Opus 5 --- apps/hero/app.tsx | 2 +- hosts/apple/ns-shell/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hero/app.tsx b/apps/hero/app.tsx index cee1151d..e956e305 100644 --- a/apps/hero/app.tsx +++ b/apps/hero/app.tsx @@ -82,7 +82,7 @@ export default function Hero(props: HeroProps = {}) { Date: Thu, 13 Aug 2026 17:56:50 -0700 Subject: [PATCH 3/6] fix(compiler): bake keyframe timelines at the declared tick rate The ANIM TABLE counts frames and the core plays one segment frame per tick, so a --hz=120 bundle previously played every animate-* utility, loop period and stroke arc at 2x: msToFrames hardcoded 60 while transition-* (stored in ms, converted at runtime) played correctly. setAnimationTickRate threads the build's --hz into the baker; at 60 the conversion is the identical expression, so existing tables are byte-stable by construction. Co-Authored-By: Claude Fable 5 --- framework/compiler/animation.ts | 31 +++++++++++++++++++++++++------ tests/tailwind.test.ts | 23 +++++++++++++++++++++++ tools/build.ts | 5 ++++- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/framework/compiler/animation.ts b/framework/compiler/animation.ts index 56895f92..b69ce0cc 100644 --- a/framework/compiler/animation.ts +++ b/framework/compiler/animation.ts @@ -3,10 +3,13 @@ // Tailwind-config-shaped input (`theme.keyframes` + `theme.animation`, same // authoring surface as tailwind.config.js) compiles into the styles.bin ANIM // TABLE (spec.ts): each CSS `animation` shorthand entry becomes per-prop -// SEGMENT lists with frame-precise endpoints at the fixed 60 Hz dt. The core -// never interprets percentages, calc() or easing strings at runtime — a -// timeline is pure data ("prop P: bits A -> bits B over frames [t0,t1) under -// easing E"), which is what keeps playback deterministic and byte-exact. +// SEGMENT lists with frame-precise endpoints at the realm's declared tick +// rate (default 60 Hz; tools/build.ts --hz declares another and the core +// plays one segment frame per tick, so the table must bake at the same rate +// the realm runs). The core never interprets percentages, calc() or easing +// strings at runtime — a timeline is pure data ("prop P: bits A -> bits B +// over frames [t0,t1) under easing E"), which is what keeps playback +// deterministic and byte-exact. // // Bake-ability rules ([R], same spirit as `rounded-full`): // - keyframe values must be build-time absolute: px numbers, degrees, @@ -99,6 +102,22 @@ export function registerAnimationTheme(theme: AnimationTheme | undefined): void resetAnimationBake(); } +/** The tick rate timelines bake at — one segment frame is one core tick. */ +let bakeHz = 60; + +/** Declare the realm's tick rate before compileClasses (build.ts passes its + * --hz value). Timelines already baked at another rate are dropped: a table + * can only ever hold frames counted at one rate. */ +export function setAnimationTickRate(hz: number): void { + if (!Number.isInteger(hz) || hz < 1 || hz > 240) { + err(`tick rate must be an integer from 1 through 240, got ${hz}`); + } + if (hz !== bakeHz) { + bakeHz = hz; + resetAnimationBake(); + } +} + /** Drop all baked state (tests / fresh compile passes). */ export function resetAnimationBake(): void { baked = []; @@ -132,9 +151,9 @@ function parseTime(tok: string): number | null { return m[2] === "s" ? v * 1000 : v; } -/** ms -> whole 60 Hz frames (round-half-up, min 0). */ +/** ms -> whole frames at the declared tick rate (round-half-up, min 0). */ export function msToFrames(ms: number): number { - return Math.max(0, Math.round((ms * 60) / 1000)); + return Math.max(0, Math.round((ms * bakeHz) / 1000)); } /** px-dimension value: number | "12px" | "12" | "0". */ diff --git a/tests/tailwind.test.ts b/tests/tailwind.test.ts index a1cc11f0..e076de72 100644 --- a/tests/tailwind.test.ts +++ b/tests/tailwind.test.ts @@ -28,6 +28,7 @@ import { bakedTimelines, registerAnimationTheme, resetAnimationBake, + setAnimationTickRate, } from "../framework/compiler/animation.ts"; function props(rec: StyleRecord | null, variant: "base" | "focus" | "active" = "base"): Map { @@ -398,6 +399,28 @@ describe("baked keyframe animations", () => { expect(tl.tracks[0].segments[0].easing).toBe(ENUMS.Easing.Linear); }); + test("timelines bake at the declared tick rate", () => { + try { + setAnimationTickRate(120); + const rec = parseClassLiteral("animate-spin"); + const tl = bakedTimelines()[rec!.animation!.anims[0]]; + expect(tl.periodFrames).toBe(120); // 1 s of virtual time is hz frames + registerAnimationTheme({ + keyframes: { fade: { from: { opacity: 0 }, to: { opacity: 1 } } }, + animation: { fade: { value: "fade 0.5s linear 0.25s", loop: "2s" } }, + }); + const fade = parseClassLiteral("animate-fade")!; + const ftl = bakedTimelines()[fade.animation!.anims[0]]; + expect(ftl.periodFrames).toBe(60); // 0.5 s + expect(ftl.delayFrames).toBe(30); // 0.25 s + expect(fade.animation!.loopFrames).toBe(240); // 2 s + expect(() => setAnimationTickRate(59.94)).toThrow(/integer from 1 through 240/); + } finally { + registerAnimationTheme(undefined); + setAnimationTickRate(60); + } + }); + test("theme keyframes bake per-prop segments with frame-exact stops", () => { registerAnimationTheme({ keyframes: { diff --git a/tools/build.ts b/tools/build.ts index bb0c27b9..58ed61d8 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -40,7 +40,7 @@ import { } from "../framework/compiler/jsx-plugin.ts"; import type { PocketConfig } from "../framework/src/config.ts"; import { verifyPlanHash, type ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; -import { registerAnimationTheme } from "../framework/compiler/animation.ts"; +import { registerAnimationTheme, setAnimationTickRate } from "../framework/compiler/animation.ts"; import { compileClasses, generateStylesModule } from "../framework/compiler/tailwind.ts"; import { bakeAtlases } from "../framework/compiler/bake-font.ts"; import { bakeSvg } from "../framework/compiler/bake-svg.ts"; @@ -290,6 +290,9 @@ console.log(` pass 1: ${visited.size} module(s), ${classStrings.length} candida // --------------------------------------------------------------------------- registerAnimationTheme(config.theme); +// Keyframe timelines are frame-baked; they must count frames at the same +// rate the realm ticks (transition-* stays in ms and converts at runtime). +setAnimationTickRate(tickHz); const styles = compileClasses(classStrings); if (styles.records.length === 0) { console.warn(" tailwind: no class literals compiled — is the app unstyled?"); From 6eb4adf15d2647e37e4f75ba55a6bb1b05c51684 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 13 Aug 2026 18:00:42 -0700 Subject: [PATCH 4/6] fix(apple): declare the tick rate before eval_bundle, publish it at mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sanctioned order was eval_bundle -> set_tick_rate, but the guest builds its tree during eval: mount() runs synchronously, onMount fires, and every mount-time animate()/spring() reaches ms-to-frames while the realm still steps at 60 — a declared-120 realm converted hero's 700 ms underline sweep to 350 ms of virtual time. The rate now precedes eval_bundle (rejected after, like set_identity), the surface publishes it to the guest as ui.__tickHz at mount, and PocketSurfaceView applies it in the tickRate setter instead of at start (which now only pins the display link) so a too-late set fails loudly through lastError/onError. Core-mode has no eval boundary, so pocket_apple_core_set_tick_rate is rejected after the first core_animate or tick. Ui::set_tick_rate gains the 240 Hz ceiling (above it ms_to_frames' u32 narrowing truncates), reports whether it applied, and gates on a ticked flag rather than the frame counter — a debug_pause'd realm never advances frame, which left the step size mutable mid-run. render_hero takes POCKET_TICK_HZ for non-60 bundles. Co-Authored-By: Claude Fable 5 --- engine/apple/apple/PocketSurfaceView.h | 7 ++-- engine/apple/apple/PocketSurfaceView.m | 28 +++++++++++---- engine/apple/examples/render_hero.rs | 22 +++++++++--- engine/apple/include/pocket_apple.h | 21 ++++++----- engine/apple/src/core_host.rs | 19 +++++++--- engine/apple/src/lib.rs | 36 ++++++++++--------- engine/core/src/lib.rs | 25 +++++++++---- engine/core/src/tests.rs | 23 +++++++++--- .../crates/pocket-ui-surface/src/surface.rs | 13 +++++-- 9 files changed, 140 insertions(+), 54 deletions(-) diff --git a/engine/apple/apple/PocketSurfaceView.h b/engine/apple/apple/PocketSurfaceView.h index 18163aa2..632a5a73 100644 --- a/engine/apple/apple/PocketSurfaceView.h +++ b/engine/apple/apple/PocketSurfaceView.h @@ -64,8 +64,11 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)loadAppNamed:(NSString *)name fromDirectory:(NSString *)directory; // Ticks per second of guest virtual time, and the rate the display link is -// pinned to. 0 means the 60 Hz default. Read once, by start; the bundle must -// have been built for the same rate (`pocket ios build --hz=`). +// pinned to. 0 means the 60 Hz default. Set before the bundle evaluates +// (evalBundle here, or the embedding runtime's guest eval in external mode): +// the mount publishes the rate to the guest as ui.__tickHz, and a later set +// is rejected through lastError/onError, keeping the declared rate. The +// bundle must have been built for the same rate (`pocket ios build --hz=`). @property(nonatomic) uint32_t tickRate; // Starts/stops the CADisplayLink. start after evalBundle succeeds. diff --git a/engine/apple/apple/PocketSurfaceView.m b/engine/apple/apple/PocketSurfaceView.m index e36b9aa4..e16154f7 100644 --- a/engine/apple/apple/PocketSurfaceView.m +++ b/engine/apple/apple/PocketSurfaceView.m @@ -202,19 +202,33 @@ - (BOOL)loadAppNamed:(NSString *)name fromDirectory:(NSString *)directory { return [self loadPak:pak] && [self evalBundle:bundle label:name]; } +- (void)setTickRate:(uint32_t)tickRate { + // Applied to the realm immediately: the rate has to be declared before the + // bundle evaluates (the mount publishes it as ui.__tickHz, and mount-time + // animate() calls convert ms to frames at the rate in force). A rejected + // set surfaces through lastError/onError and leaves the old rate pinned. + uint32_t rate = tickRate > 0 ? tickRate : kPocketSurfaceDefaultTickRate; + int32_t status = 0; + if (_coreHandle != NULL) { + status = pocket_apple_core_set_tick_rate(_coreHandle, rate); + } else if (_handle != NULL) { + status = pocket_apple_set_tick_rate(_handle, rate); + } + if (status != 0) { + [self captureError]; + return; + } + _tickRate = tickRate; +} + - (void)start { if (_running || (_handle == NULL && _coreHandle == NULL)) { return; } _running = YES; + // The realm's rate was declared through setTickRate before the bundle + // evaluated; the display link is pinned to the same cadence here. uint32_t rate = _tickRate > 0 ? _tickRate : kPocketSurfaceDefaultTickRate; - // ERR_BAD_STATE here means a restart after the realm already ticked, which - // keeps the rate the first start declared. - if (_handle != NULL) { - pocket_apple_set_tick_rate(_handle, rate); - } else if (_coreHandle != NULL) { - pocket_apple_core_set_tick_rate(_coreHandle, rate); - } _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayTick:)]; if (@available(iOS 15.0, *)) { // The core advances in exact 1/rate s steps; pin the link to match. diff --git a/engine/apple/examples/render_hero.rs b/engine/apple/examples/render_hero.rs index e8fc16cc..64601af3 100644 --- a/engine/apple/examples/render_hero.rs +++ b/engine/apple/examples/render_hero.rs @@ -3,6 +3,8 @@ //! component-only bundle that installs no frame() and cannot boot here: //! bun tools/build.ts hero-main //! cargo run -p pocket-apple --example render_hero -- ../dist/hero-main.js ../dist/hero-main.pak /tmp/hero +//! A bundle built with --hz=N needs POCKET_TICK_HZ=N in the environment — +//! bundles refuse a host whose declared rate differs from their baked one. //! Exit is nonzero if two independent instances disagree on the final frame //! (determinism check) or the frame is blank. @@ -10,7 +12,8 @@ use std::ffi::CString; use pocket_apple::{ pocket_apple_create, pocket_apple_destroy, pocket_apple_eval_bundle, pocket_apple_frame, - pocket_apple_last_error, pocket_apple_load_pak, pocket_apple_render, PocketAppleFrame, + pocket_apple_last_error, pocket_apple_load_pak, pocket_apple_render, + pocket_apple_set_tick_rate, PocketAppleFrame, }; const WIDTH: u32 = 480; @@ -26,7 +29,7 @@ fn last_error() -> String { } } -fn run_instance(bundle: &[u8], pak: &[u8]) -> (Vec, u32, u32, u64) { +fn run_instance(bundle: &[u8], pak: &[u8], tick_hz: Option) -> (Vec, u32, u32, u64) { let handle = pocket_apple_create(DENSITY, WIDTH, HEIGHT); assert!(!handle.is_null(), "create failed: {}", last_error()); assert_eq!( @@ -35,6 +38,14 @@ fn run_instance(bundle: &[u8], pak: &[u8]) -> (Vec, u32, u32, u64) { "load_pak failed: {}", last_error() ); + if let Some(hz) = tick_hz { + assert_eq!( + pocket_apple_set_tick_rate(handle, hz), + 0, + "set_tick_rate({hz}) failed: {}", + last_error() + ); + } let label = CString::new("hero").unwrap(); assert_eq!( pocket_apple_eval_bundle(handle, bundle.as_ptr(), bundle.len(), label.as_ptr()), @@ -101,9 +112,12 @@ fn main() { let bundle = std::fs::read(bundle_path).expect("read bundle"); let pak = std::fs::read(pak_path).expect("read pak"); + let tick_hz = std::env::var("POCKET_TICK_HZ") + .ok() + .map(|raw| raw.parse::().expect("POCKET_TICK_HZ must be an integer")); - let (first, w, h, damage_a) = run_instance(&bundle, &pak); - let (second, _, _, damage_b) = run_instance(&bundle, &pak); + let (first, w, h, damage_a) = run_instance(&bundle, &pak, tick_hz); + let (second, _, _, damage_b) = run_instance(&bundle, &pak, tick_hz); let non_blank = first.chunks_exact(4).any(|px| px[0] != 0 || px[1] != 0 || px[2] != 0); let deterministic = first == second; diff --git a/engine/apple/include/pocket_apple.h b/engine/apple/include/pocket_apple.h index c4cd953e..a53dd8cf 100644 --- a/engine/apple/include/pocket_apple.h +++ b/engine/apple/include/pocket_apple.h @@ -3,11 +3,12 @@ // handle from one thread (in practice the main thread, with CADisplayLink). // // Call order per handle: -// create -> load_pak* -> [set_identity] -> eval_bundle -> [set_tick_rate] +// create -> load_pak* -> [set_identity] -> [set_tick_rate] -> eval_bundle // -> per tick: frame, render -> destroy -// load_pak/set_identity are rejected after eval_bundle: the surface publishes -// both to the guest when `ui` is mounted. set_tick_rate is rejected after the -// first frame: the step size has to be constant for a realm's whole run. +// load_pak/set_identity/set_tick_rate are all rejected after eval_bundle: +// the surface publishes them to the guest when `ui` is mounted (the rate as +// ui.__tickHz), and the bundle's mount-time animate() calls convert ms to +// frames at the rate in force while it evaluates. #ifndef POCKET_APPLE_H #define POCKET_APPLE_H @@ -51,9 +52,10 @@ PocketApple *pocket_apple_create(uint32_t density, uint32_t logical_width, int32_t pocket_apple_set_identity(PocketApple *handle, const char *host_id, uint32_t host_abi); -// Ticks per second of guest virtual time (1..240, default 60); rejected after -// the first frame. The bundle must be built for the same rate, and the -// display link must be driven at it. +// Ticks per second of guest virtual time (1..240, default 60); rejected +// after eval_bundle — the mount publishes it as ui.__tickHz and bundles +// refuse a rate other than the one they were built for. The display link +// must be driven at the same rate. int32_t pocket_apple_set_tick_rate(PocketApple *handle, uint32_t hz); int32_t pocket_apple_load_pak(PocketApple *handle, const uint8_t *bytes, @@ -144,7 +146,10 @@ void pocket_apple_core_drain_effects(PocketAppleCore *handle, PocketAppleEffectC void *context); // Ticks per second of the core's virtual time (1..240, default 60); rejected -// after the first tick. Same bundle/display-link pairing as the guest mode. +// after the first core_animate or tick — animate converts ms to frames at +// the rate then in force, so declare the rate before the guest evaluates, +// and declare it on the mounted namespace as ui.__tickHz. Same +// bundle/display-link pairing as the guest mode. int32_t pocket_apple_core_set_tick_rate(PocketAppleCore *handle, uint32_t hz); void pocket_apple_core_tick(PocketAppleCore *handle); diff --git a/engine/apple/src/core_host.rs b/engine/apple/src/core_host.rs index e9b3792a..7d6c575b 100644 --- a/engine/apple/src/core_host.rs +++ b/engine/apple/src/core_host.rs @@ -44,6 +44,9 @@ pub struct PocketAppleCore { svc_out: VecDeque, svc_poll_batch: CString, ticked: bool, + /// Whether any `core_animate` ran — an ms-to-frames conversion at the + /// rate then in force, which `set_tick_rate` must therefore precede. + animated: bool, } fn with_core( @@ -116,6 +119,7 @@ pub extern "C" fn pocket_apple_core_create( svc_out: VecDeque::new(), svc_poll_batch: CString::default(), ticked: false, + animated: false, })) }); result.unwrap_or(std::ptr::null_mut()) @@ -317,6 +321,7 @@ pub extern "C" fn pocket_apple_core_animate( delay_ms: u32, ) -> i32 { with_core(handle, -1, |state| { + state.animated = true; state .ui .animate(id, prop as u8, to, duration_ms, easing as u8, delay_ms) @@ -496,19 +501,25 @@ pub extern "C" fn pocket_apple_core_drain_effects( // ---- frame ---------------------------------------------------------------- /// Ticks per second of the core's virtual time. 1..=240; the guest bundle -/// mounted over this core must be built for the same rate. +/// mounted over this core must be built for the same rate, and the ui +/// namespace the embedder mounts must declare it as `ui.__tickHz`. Rejected +/// after the first `core_animate` or tick: animate converts ms to frames at +/// the rate then in force, so declare the rate before the guest evaluates. #[unsafe(no_mangle)] pub extern "C" fn pocket_apple_core_set_tick_rate(handle: *mut PocketAppleCore, hz: u32) -> i32 { with_core(handle, ERR_PANIC, |state| { - if state.ticked { - set_last_error("tick rate must be set before the first tick"); + if state.ticked || state.animated { + set_last_error("tick rate must be set before the first animate or tick"); return ERR_BAD_STATE; } if !(MIN_TICK_HZ..=MAX_TICK_HZ).contains(&hz) { set_last_error("tick rate must be 1 through 240 Hz"); return ERR_BAD_ARGUMENT; } - state.ui.set_tick_rate(hz); + if !state.ui.set_tick_rate(hz) { + set_last_error("tick rate must be set before the realm ticks"); + return ERR_BAD_STATE; + } OK }) } diff --git a/engine/apple/src/lib.rs b/engine/apple/src/lib.rs index 73b967e3..ffce2cf2 100644 --- a/engine/apple/src/lib.rs +++ b/engine/apple/src/lib.rs @@ -11,12 +11,13 @@ //! is `Rc>`). Create, drive, and destroy a handle from one thread — //! in practice the main thread, alongside CADisplayLink. //! -//! Call order per handle: `create` → `load_pak`* → `eval_bundle` → per tick -//! `frame` then `render` → `destroy`. `load_pak` and `set_identity` are -//! rejected after `eval_bundle` because the surface publishes both to the -//! guest at mount time. `set_tick_rate` survives `eval_bundle` (nothing about -//! it reaches the guest at mount) but is rejected after the first `frame`, -//! because the step size has to be constant for a realm's whole run. +//! Call order per handle: `create` → `load_pak`* → [`set_identity`] → +//! [`set_tick_rate`] → `eval_bundle` → per tick `frame` then `render` → +//! `destroy`. `load_pak`, `set_identity` and `set_tick_rate` are all +//! rejected after `eval_bundle` because the surface publishes them to the +//! guest at mount time — and the guest converts its mount-time `animate()` +//! durations to frames at the rate in force while the bundle evaluates, so +//! a rate declared later would have silently converted them at 60. use std::cell::RefCell; use std::ffi::{c_char, CString}; @@ -35,9 +36,10 @@ pub const POCKET_APPLE_ABI_VERSION: u32 = 1; pub const POCKET_APPLE_MAX_DAMAGE_REGIONS: usize = DEFAULT_DAMAGE_REGIONS; /// Accepted `set_tick_rate` range: covers every Apple display cadence from a -/// throttled 1 Hz up to the 240 Hz headroom above ProMotion's 120. +/// throttled 1 Hz up to the 240 Hz headroom above ProMotion's 120 (the +/// core's own ceiling — `pocketjs_core::MAX_TICK_HZ`). pub(crate) const MIN_TICK_HZ: u32 = 1; -pub(crate) const MAX_TICK_HZ: u32 = 240; +pub(crate) const MAX_TICK_HZ: u32 = pocketjs_core::MAX_TICK_HZ; const OK: i32 = 0; const ERR_BAD_ARGUMENT: i32 = -1; @@ -68,7 +70,6 @@ pub struct PocketApple { logical_width: u32, logical_height: u32, mounted: bool, - ticked: bool, effect_callback: Option<(PocketAppleEffectCallback, *mut std::ffi::c_void)>, } @@ -153,7 +154,6 @@ pub extern "C" fn pocket_apple_create( logical_width, logical_height, mounted: false, - ticked: false, effect_callback: None, })) }); @@ -187,20 +187,25 @@ pub extern "C" fn pocket_apple_set_identity( /// Ticks (and therefore `pocket_apple_frame` calls) per second of guest /// virtual time. 1..=240; the guest bundle must be built for the same rate. -/// Unlike `set_identity` this is accepted after `eval_bundle` (nothing about -/// it is published to the guest at mount) but not after the first frame. +/// Rejected after `eval_bundle`, like `set_identity`: the mount publishes +/// the rate to the guest as `ui.__tickHz`, and the bundle's mount-time +/// `animate()` calls convert ms to frames at the rate in force while it +/// evaluates. #[unsafe(no_mangle)] pub extern "C" fn pocket_apple_set_tick_rate(handle: *mut PocketApple, hz: u32) -> i32 { with_handle(handle, ERR_PANIC, |state| { - if state.ticked { - set_last_error("tick rate must be set before the first frame"); + if state.mounted { + set_last_error("tick rate must be set before eval_bundle"); return ERR_BAD_STATE; } if !(MIN_TICK_HZ..=MAX_TICK_HZ).contains(&hz) { set_last_error("tick rate must be 1 through 240 Hz"); return ERR_BAD_ARGUMENT; } - state.surface.set_tick_rate(hz); + if !state.surface.set_tick_rate(hz) { + set_last_error("tick rate must be set before the realm ticks"); + return ERR_BAD_STATE; + } OK }) } @@ -285,7 +290,6 @@ pub extern "C" fn pocket_apple_frame( set_last_error("frame before eval_bundle"); return ERR_BAD_STATE; } - state.ticked = true; let touch_words: &[u32] = if touches.is_null() || touch_count == 0 { &[] } else { diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index a25f5017..519f89bf 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -58,6 +58,11 @@ const TEX_PALETTE_BYTES: usize = 1024; /// `set_tick_rate` declares another one before the first `tick()`. const DEFAULT_TICK_HZ: u32 = 60; +/// Highest declarable tick rate. Above this the `ms * hz` intermediate in +/// `ms_to_frames` would overflow its `as u32` narrowing for ordinary +/// durations, and no display drives faster anyway. +pub const MAX_TICK_HZ: u32 = 240; + /// One uploaded texture. Pixels are copied into 16-byte-aligned storage so /// the PSP GE can sample them directly (the wasm rasterizer reads them via /// `Ui::texture`). @@ -258,6 +263,10 @@ pub struct Ui { touch_table: touch::HitTable, /// Frame counter advanced by `tick()` (drives fixed-dt animation). frame: u64, + /// Whether `tick()` has ever run. The `set_tick_rate` gate — `frame` + /// alone would miss a realm whose every tick was swallowed by + /// `debug_pause`, leaving the step size mutable mid-run. + ticked: bool, /// Seconds of virtual time one `tick()` advances. dt: f32, /// The integer rate backing `dt`. Kept alongside it so duration-ms to @@ -317,6 +326,7 @@ impl Ui { cursor_pos: (0.0, 0.0), touch_table: touch::HitTable::default(), frame: 0, + ticked: false, dt: spec::FIXED_DT, tick_hz: DEFAULT_TICK_HZ, inspect_id: 0, @@ -333,15 +343,17 @@ impl Ui { } /// Declare how many `tick()` calls make one second of virtual time - /// (spec default 60). Ignored once the first `tick()` has run: a realm's - /// frame content is a pure function of its frame index, so the step size - /// has to be constant for the whole run. - pub fn set_tick_rate(&mut self, hz: u32) { - if hz == 0 || self.frame != 0 { - return; + /// (spec default 60, at most `MAX_TICK_HZ`). Rejected once the first + /// `tick()` has run — even a `debug_pause`d one: a realm's frame content + /// is a pure function of its frame index, so the step size has to be + /// constant for the whole run. Returns whether the rate was applied. + pub fn set_tick_rate(&mut self, hz: u32) -> bool { + if hz == 0 || hz > MAX_TICK_HZ || self.ticked { + return false; } self.tick_hz = hz; self.dt = 1.0 / hz as f32; + true } /// Ticks per second of virtual time (see `set_tick_rate`). @@ -958,6 +970,7 @@ impl Ui { /// step, then re-run layout if dirty. Call once per vblank, BEFORE /// `draw()`. pub fn tick(&mut self) { + self.ticked = true; if self.paused { if !self.step_pending { return; diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index 6ccb009d..d75233e9 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -769,15 +769,30 @@ fn ui_rejects_zero_raster_density() { fn tick_rate_is_fixed_once_the_realm_has_ticked() { let mut ui = Ui::new(); assert_eq!(ui.tick_rate(), 60, "spec default"); - ui.set_tick_rate(0); - assert_eq!(ui.tick_rate(), 60, "0 Hz is not a rate"); - ui.set_tick_rate(120); + assert!(!ui.set_tick_rate(0), "0 Hz is not a rate"); + assert_eq!(ui.tick_rate(), 60); + assert!(!ui.set_tick_rate(crate::MAX_TICK_HZ + 1), "above the ceiling"); + assert_eq!(ui.tick_rate(), 60); + assert!(ui.set_tick_rate(crate::MAX_TICK_HZ), "the ceiling itself is a rate"); + assert!(ui.set_tick_rate(120)); assert_eq!(ui.tick_rate(), 120); ui.tick(); - ui.set_tick_rate(60); + assert!(!ui.set_tick_rate(60)); assert_eq!(ui.tick_rate(), 120, "a running realm keeps its step size"); } +#[test] +fn tick_rate_is_fixed_even_when_every_tick_was_paused() { + let mut ui = Ui::new(); + ui.debug_pause(true); + ui.tick(); + assert!( + !ui.set_tick_rate(120), + "a swallowed tick still starts the run — the frame counter alone would readmit a rate change here" + ); + assert_eq!(ui.tick_rate(), 60); +} + #[test] fn a_120_hz_realm_runs_a_tween_over_twice_the_frames() { let mut at = |hz: u32| { diff --git a/engine/crates/pocket-ui-surface/src/surface.rs b/engine/crates/pocket-ui-surface/src/surface.rs index a688501f..eb7b3f9b 100644 --- a/engine/crates/pocket-ui-surface/src/surface.rs +++ b/engine/crates/pocket-ui-surface/src/surface.rs @@ -209,9 +209,12 @@ impl UiSurface { } /// Declare how many ticks make one second of virtual time (default 60). - /// Ignored once the core has ticked (see `Ui::set_tick_rate`). - pub fn set_tick_rate(&self, hz: u32) { - self.inner.borrow_mut().ui.set_tick_rate(hz); + /// Call before `mount`: the mount publishes the rate to the guest as + /// `ui.__tickHz`, and bundles refuse a rate other than the one they were + /// built for. Rejected once the core has ticked (see `Ui::set_tick_rate`); + /// returns whether the rate was applied. + pub fn set_tick_rate(&self, hz: u32) -> bool { + self.inner.borrow_mut().ui.set_tick_rate(hz) } /// Advance the core one fixed-dt frame (call once per host tick, after @@ -527,6 +530,10 @@ impl UiSurface { if let Some(abi) = inner.host_abi { ns.set("__hostAbi", abi)?; } + // The realm's declared tick rate. Bundles bake theirs the way + // glyphs bake density, and refuse a host running another — + // which is why set_tick_rate must precede mount. + ns.set("__tickHz", inner.ui.tick_rate())?; Ok(()) }) From 9bc75dd4b0c557bb48f7fbd27459715fb8001fa3 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 13 Aug 2026 18:03:27 -0700 Subject: [PATCH 5/6] feat(framework): enforce the bundle-hz / core-hz pairing at mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing anywhere ensured a bundle's baked rate matched the rate its core was stepped at — a 120-baked bundle mounted and ran half-speed on a 60-stepped core without a whisper. Bundles now assert the pairing where they already assert target/hostAbi: the host declares its rate as ui.__tickHz (published by UiSurface at mount; absent means the 60 default, which is what every pre-rate host ran), and assertNativeHostContract refuses a mismatch for every native mount, plan-built or not. The define is read at call time so tests can exercise the non-60 paths. pocket ios build writes a build stamp (tickHz + density) next to its artifacts, and play --no-build stages the stamp's facts instead of the flags' defaults — build --hz=120 then play --no-build previously staged tickHz: 60 with no record of what the bundle was baked at. An explicit conflicting flag is an error. iOS guest builds also pin the digit glyphs (--extra-chars) so the hz-derived headline and FPS tile never depend on incidental literals for coverage. Co-Authored-By: Claude Fable 5 --- framework/src/host.ts | 28 +++++++++++++++++++++- tests/ios-profile.test.ts | 24 +++++++++++++++++++ tests/renderer.test.ts | 36 ++++++++++++++++++++++++++++ tools/ios.ts | 49 +++++++++++++++++++++++++++++++++++---- 4 files changed, 132 insertions(+), 5 deletions(-) diff --git a/framework/src/host.ts b/framework/src/host.ts index 266d9c94..26b29188 100644 --- a/framework/src/host.ts +++ b/framework/src/host.ts @@ -22,6 +22,11 @@ import { // legacy/test bundles valid until they opt into a ResolvedBuildPlan. declare const __POCKET_TARGET__: string; declare const __POCKET_HOST_ABI__: number; +// Replaced by tools/build.ts in EVERY build (default 60, `--hz` declares +// another). Read at call time, not module time, so tests can exercise the +// non-60 paths through a globalThis stand-in — a bundler define replaces the +// identifier with a literal either way. +declare const __POCKET_TICK_HZ__: number; export interface BuildHostContract { readonly target: string; @@ -208,6 +213,10 @@ export interface HostOps { __host?: string; /** Version of the JS/native HostOps ABI implemented by this namespace. */ __hostAbi?: number; + /** Ticks per second of virtual time the host drives this realm at. Absent + * means the spec default 60 — hosts that predate per-realm rates only + * ever ran 60. Bundles bake their rate (`--hz`) and refuse another. */ + __tickHz?: number; } /** Desktop hosts publish their logical UI size as `ui.__viewport` (the core @@ -239,11 +248,28 @@ export function embeddedBuildHostContract(): BuildHostContract | null { return target && hostAbi > 0 ? { target, hostAbi } : null; } -/** Fail before mounting when a bundle was packaged with the wrong native host. */ +/** Fail before mounting when a bundle was packaged with the wrong native + * host, or baked for a tick rate the host does not drive. The rate check + * runs for every native mount — plan-less bundles bake a rate too. */ export function assertNativeHostContract( ops: HostOps, expected: BuildHostContract | null = embeddedBuildHostContract(), ): void { + const baked = + typeof __POCKET_TICK_HZ__ === "number" && __POCKET_TICK_HZ__ > 0 + ? __POCKET_TICK_HZ__ + : 60; + const declared = ops.__tickHz ?? 60; + if (declared !== baked) { + throw new Error( + ops.__tickHz === undefined + ? `PocketJS: this bundle bakes ${baked} Hz virtual time but the host declares no ui.__tickHz, ` + + "which means the 60 Hz default — declare the rate before mount and drive the surface at it " + + "(pocket_apple set_tick_rate before eval_bundle; PocketSurfaceView.tickRate)" + : `PocketJS: tick-rate mismatch (bundle baked at ${baked} Hz, host drives ${declared} Hz) — ` + + "a bundle only runs correctly at the rate it was built with (`--hz`), like glyphs at their density", + ); + } if (!expected) return; if (typeof ops.__host !== "string") { throw new Error( diff --git a/tests/ios-profile.test.ts b/tests/ios-profile.test.ts index fd523fd2..1e8ca22a 100644 --- a/tests/ios-profile.test.ts +++ b/tests/ios-profile.test.ts @@ -105,6 +105,30 @@ describe("private iOS build profile", () => { expect(surface).toContain("pocket_apple_set_identity(_handle, kPocketSurfaceHostId,"); }); + test("the tick rate is declared before the bundle evaluates and published at mount", () => { + // Bundles bake their rate and refuse a host whose ui.__tickHz differs + // (framework/src/host.ts assertNativeHostContract), which only works if + // the rate reaches the realm before eval: the C ABI orders + // [set_tick_rate] ahead of eval_bundle, the surface applies the property + // in its setter (start only pins the display link), and the mounted + // namespace carries __tickHz. + const header = readFileSync( + join(REPOSITORY, "engine/apple/include/pocket_apple.h"), + "utf8", + ); + expect(header).toContain("[set_tick_rate] -> eval_bundle"); + const surface = readFileSync(SURFACE_VIEW_PATH, "utf8"); + expect(surface).toContain("- (void)setTickRate:"); + expect(surface.slice(surface.indexOf("- (void)start"))).not.toContain( + "set_tick_rate", + ); + const mount = readFileSync( + join(REPOSITORY, "engine/crates/pocket-ui-surface/src/surface.rs"), + "utf8", + ); + expect(mount).toContain('ns.set("__tickHz", inner.ui.tick_rate())'); + }); + test("type-checks the nsengine demo's explicit imports", () => { const result = checkAppTypes({ entry: ENTRY_PATH, diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 67429d5d..3ea27721 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -894,6 +894,42 @@ describe("host detection (host.ts)", () => { ).toThrow(/ABI mismatch/); }); + test("tick-rate pairing: bundle-baked hz must match the host's declared rate", () => { + const ops = makeMockHost().ops; + + // A 60-baked bundle accepts hosts that predate __tickHz (they only ever + // ran 60) and hosts that declare 60 — with or without a plan contract. + expect(() => assertNativeHostContract(ops, null)).not.toThrow(); + ops.__tickHz = 60; + expect(() => assertNativeHostContract(ops, null)).not.toThrow(); + + // A host driving another rate is refused even when the plan matches. + ops.__host = "vita"; + ops.__hostAbi = 1; + ops.__tickHz = 120; + expect(() => + assertNativeHostContract(ops, { target: "vita", hostAbi: 1 }), + ).toThrow(/tick-rate mismatch/); + + // A non-60 bundle (the define is read at call time — see host.ts) needs + // the host to declare that exact rate; silence means the 60 default. + const globals = globalThis as { __POCKET_TICK_HZ__?: number }; + try { + globals.__POCKET_TICK_HZ__ = 120; + expect(() => assertNativeHostContract(ops, null)).not.toThrow(); + delete ops.__tickHz; + expect(() => assertNativeHostContract(ops, null)).toThrow( + /declares no ui\.__tickHz/, + ); + ops.__tickHz = 60; + expect(() => assertNativeHostContract(ops, null)).toThrow( + /tick-rate mismatch/, + ); + } finally { + delete globals.__POCKET_TICK_HZ__; + } + }); + test("native namespace passed explicitly stays native / non-strict", () => { // Demo entries pass globalThis.ui to render(); object identity must keep // the namespace native instead of turning it into an diff --git a/tools/ios.ts b/tools/ios.ts index cd516908..a99c313e 100644 --- a/tools/ios.ts +++ b/tools/ios.ts @@ -80,9 +80,11 @@ function flagValue(args: readonly string[], name: string): string | undefined { return index >= 0 ? args[index + 1] : undefined; } -function tickRateFlag(args: readonly string[]): number { +/** The explicit --hz value, or undefined when the flag is absent — callers + * fall back to the default (fresh builds) or the build stamp (--no-build). */ +function tickRateFlag(args: readonly string[]): number | undefined { const raw = flagValue(args, "--hz"); - if (raw === undefined) return IOS_DEFAULT_TICK_RATE; + if (raw === undefined) return undefined; const hz = Number(raw); if (!IOS_TICK_RATES.includes(hz)) { throw new Error(`pocket ios: --hz wants ${IOS_TICK_RATES.join(" or ")}`); @@ -296,6 +298,19 @@ interface GuestArtifacts { planPath: string; } +/** What a build baked into its artifacts — the facts staging must agree + * with. Written next to the artifacts because the resolved plan cannot + * carry them: the plan is hash-sealed and does not own the tick rate. */ +interface BuildStamp { + app: string; + tickHz: number; + density: number; +} + +function buildStampPath(demo: string): string { + return resolve(ROOT, `dist/ios/${demo}/build-stamp.json`); +} + function normalizeDemoName(demo: string): string { return demo.replace(/-main$/, ""); } @@ -318,6 +333,9 @@ async function buildGuest(demoArg: string, density: number, tickHz: number): Pro `--project-root=${ROOT}`, `--outdir=${outdir}`, `--hz=${tickHz}`, + // The headline/FPS copy renders the baked rate's digits; pin them so + // glyph coverage never depends on incidental literals elsewhere. + "--extra-chars=0123456789", ], { inherit: true }, ); @@ -329,6 +347,8 @@ async function buildGuest(demoArg: string, density: number, tickHz: number): Pro if (!existsSync(bundle) || !existsSync(pak)) { throw new Error(`pocket ios: expected ${bundle} and ${pak} after the build`); } + const stamp: BuildStamp = { app: inputs.appOutput, tickHz, density }; + writeFileSync(buildStampPath(demo), JSON.stringify(stamp, null, 2) + "\n"); return { appOutput: inputs.appOutput, bundle, pak, planPath }; } @@ -408,10 +428,11 @@ async function vendPluginXcframework(options: StageOptions): Promise { async function play(demoArg: string, args: readonly string[]): Promise { const density = Number(flagValue(args, "--density") ?? IOS_DEV_DEFAULT_DENSITY); + const requestedHz = tickRateFlag(args); const options: StageOptions = { shellDir: resolve(flagValue(args, "--shell-dir") ?? DEFAULT_SHELL), externalGuest: args.includes("--external-guest"), - tickHz: tickRateFlag(args), + tickHz: requestedHz ?? IOS_DEFAULT_TICK_RATE, pluginPath: flagValue(args, "--plugin-path"), runtimeTgz: flagValue(args, "--runtime-tgz"), }; @@ -439,6 +460,26 @@ async function play(demoArg: string, args: readonly string[]): Promise { if (!existsSync(artifacts.bundle) || !existsSync(artifacts.pak)) { throw new Error("pocket ios: --no-build but no prior guest artifacts — drop the flag"); } + // Timing (and glyph scale) are baked into the reused artifacts; staging + // must repeat the bundle's facts, never the flags' defaults. Bundles + // refuse a mismatched rate at mount, so a stale stage fails on-device. + if (!existsSync(buildStampPath(demo))) { + throw new Error( + "pocket ios: --no-build but the prior build predates build stamps — rebuild once without it", + ); + } + const stamp = JSON.parse(readFileSync(buildStampPath(demo), "utf8")) as BuildStamp; + if (requestedHz !== undefined && requestedHz !== stamp.tickHz) { + throw new Error( + `pocket ios: --no-build reuses a ${stamp.tickHz} Hz build but --hz=${requestedHz} was asked — rebuild, or drop --hz`, + ); + } + if (flagValue(args, "--density") !== undefined && density !== stamp.density) { + throw new Error( + `pocket ios: --no-build reuses a density-${stamp.density} build but --density=${density} was asked — rebuild, or drop --density`, + ); + } + options.tickHz = stamp.tickHz; } else { artifacts = await buildGuest(demoArg, density, options.tickHz); } @@ -515,7 +556,7 @@ export async function iosMain(args: readonly string[] = Bun.argv.slice(2)): Prom case "build": { if (!rest[0] || rest[0].startsWith("--")) throw new Error("pocket ios build: missing app name"); const density = Number(flagValue(rest, "--density") ?? IOS_DEV_DEFAULT_DENSITY); - const artifacts = await buildGuest(rest[0], density, tickRateFlag(rest)); + const artifacts = await buildGuest(rest[0], density, tickRateFlag(rest) ?? IOS_DEFAULT_TICK_RATE); console.log(`pocket ios: built ${artifacts.bundle}`); return; } From d6ae5f03d32fa35f36b95d144b9ce22c02c23f45 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 13 Aug 2026 18:04:27 -0700 Subject: [PATCH 6/6] docs(core): align the determinism contract with per-realm rates; two loud-failure guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DETERMINISM.md and the FIXED_DT comment still asserted an unconditional 1/60 s step; both now state the default-vs-declared relationship the tick-rate mechanism actually implements. The clock throws at boot on a non-integer or out-of-range __POCKET_TICK_HZ__ — divisorsOf(59.94) is [] and every tick loop downstream would silently no-op, and tools/build.ts is not the only producer of the define. DeepZoom's VEL_APPROACH takes the 60-path early return like every other rebased constant: its rebase runs through the complement, and 1 - (1 - 0.35) recovering 0.35 exactly was float luck rather than construction. Co-Authored-By: Claude Fable 5 --- contracts/spec/spec.ts | 6 ++++-- docs/DETERMINISM.md | 10 ++++++++++ framework/src/clock.ts | 16 ++++++++++++++-- framework/src/deepzoom.ts | 6 ++++-- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index 8257bb5c..0ef3896b 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -1442,6 +1442,8 @@ export const ANALOG_CENTER = 0x8080; // --------------------------------------------------------------------------- // Fixed timestep // --------------------------------------------------------------------------- -/** Core animation/tick timestep: exactly 1/60 s. Frame content is a pure - * function of frame index — this is what makes byte-exact goldens possible. */ +/** Core animation/tick timestep: exactly 1/60 s unless the realm declared + * another rate before its first tick (Ui::set_tick_rate; still fixed for + * the whole run — 1/hz s, hz at most 240). Frame content is a pure function + * of frame index — this is what makes byte-exact goldens possible. */ export const FIXED_DT = 1 / 60; diff --git a/docs/DETERMINISM.md b/docs/DETERMINISM.md index eaeb69c2..d2ee31dd 100644 --- a/docs/DETERMINISM.md +++ b/docs/DETERMINISM.md @@ -33,6 +33,16 @@ core ticks. The core never changes: ms-based animations, transitions and baked timelines cover the same **virtual time** at every rate — a 300 ms tween is 300 ms at 60 Hz and 300 ms at 2 Hz, just sampled coarser. +The 60 above is the **spec default tick rate**, not a constant of the model: +a realm may declare another whole rate (1..240) before its first tick +(`Ui::set_tick_rate`; `tools/build.ts --hz` bakes the same rate into the +bundle), and the step stays fixed at `1/hz` s for the whole run — the frame +counter remains the only clock. The declared rate is part of the mount +contract: the host publishes it as `ui.__tickHz` and a bundle refuses a host +driving any rate but the one it was built with. Everything this document +derives holds per realm with 60 read as that realm's rate; the committed +goldens and tapes all run the default. + Hosts publish the policy as `globalThis.__simHz` before the bundle evals (web host: `?hz=2`; sim host: scenario option; PSP: standalone packages at 60, multi-app packages at 20). Apps read time through the clock API and stay diff --git a/framework/src/clock.ts b/framework/src/clock.ts index 1363f6c8..fef8ae6a 100644 --- a/framework/src/clock.ts +++ b/framework/src/clock.ts @@ -22,9 +22,21 @@ declare const __POCKET_TICK_HZ__: number; * Core ticks per second of virtual time. The realm's tick rate is baked into * the bundle, so a bundle only runs correctly on a surface driven at the same * rate. Spec default is FIXED_DT = 1/60 s per tick; 120 is the ProMotion rate. + * A number that is not a whole 1..240 rate throws HERE, at boot: downstream, + * divisorsOf(59.94) is [] and every tick loop would silently no-op. */ -export const TICKS_PER_SECOND = - typeof __POCKET_TICK_HZ__ === "number" && __POCKET_TICK_HZ__ > 0 ? __POCKET_TICK_HZ__ : 60; +export const TICKS_PER_SECOND = validTickHz( + typeof __POCKET_TICK_HZ__ === "number" ? __POCKET_TICK_HZ__ : 60, +); + +function validTickHz(hz: number): number { + if (!Number.isInteger(hz) || hz < 1 || hz > 240) { + throw new Error( + `PocketJS: __POCKET_TICK_HZ__ must be an integer from 1 through 240, got ${hz}`, + ); + } + return hz; +} /** The simulation rates that divide the core tick rate exactly. */ export const VALID_HZ: readonly number[] = divisorsOf(TICKS_PER_SECOND); diff --git a/framework/src/deepzoom.ts b/framework/src/deepzoom.ts index b447f147..d43fc66f 100644 --- a/framework/src/deepzoom.ts +++ b/framework/src/deepzoom.ts @@ -133,8 +133,10 @@ const DPAD_SPEED = 5 * TICK_SCALE; // Zoom factor per tick while a trigger is held (~×2 in 20 ticks at 60 Hz). const ZOOM_STEP = perTick(1.035); // Velocity smoothing per tick: approach factor toward the input target, and -// the decay once input releases (momentum glide). -const VEL_APPROACH = 1 - perTick(1 - 0.35); +// the decay once input releases (momentum glide). The approach rebase runs +// through the complement, so its 60 path takes the early return explicitly — +// 1 - (1 - 0.35) recovering 0.35 exactly is float luck, not construction. +const VEL_APPROACH = TICK_SCALE === 1 ? 0.35 : 1 - perTick(1 - 0.35); const VEL_DECAY = perTick(0.88); // Switch mip level only when the ideal level differs this long (frames), so // a zoom hovering at a boundary doesn't thrash mount/unmount.