diff --git a/apps/hero/app.tsx b/apps/hero/app.tsx index f5eb28ec..e956e305 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"; @@ -81,7 +82,7 @@ export default function Hero(props: HeroProps = {}) { - {props.headline ?? "JSX at 60 FPS."} + {props.headline ?? `JSX at ${TICKS_PER_SECOND} FPS.`} 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/engine/apple/apple/PocketSurfaceView.h b/engine/apple/apple/PocketSurfaceView.h index 76aa3b12..632a5a73 100644 --- a/engine/apple/apple/PocketSurfaceView.h +++ b/engine/apple/apple/PocketSurfaceView.h @@ -63,6 +63,14 @@ 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. 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. - (void)start; - (void)stop; diff --git a/engine/apple/apple/PocketSurfaceView.m b/engine/apple/apple/PocketSurfaceView.m index e6b49c36..e16154f7 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; @@ -199,15 +202,37 @@ - (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; _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/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 f9d64e04..a53dd8cf 100644 --- a/engine/apple/include/pocket_apple.h +++ b/engine/apple/include/pocket_apple.h @@ -3,10 +3,12 @@ // 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] -> [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. +// 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 @@ -50,6 +52,12 @@ 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 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, size_t length); @@ -137,6 +145,13 @@ 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 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); 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..7d6c575b 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,10 @@ pub struct PocketAppleCore { svc_in: VecDeque, 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( @@ -111,6 +118,8 @@ pub extern "C" fn pocket_apple_core_create( svc_in: VecDeque::new(), svc_out: VecDeque::new(), svc_poll_batch: CString::default(), + ticked: false, + animated: false, })) }); result.unwrap_or(std::ptr::null_mut()) @@ -312,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) @@ -490,9 +500,36 @@ 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, 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 || 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; + } + if !state.ui.set_tick_rate(hz) { + set_last_error("tick rate must be set before the realm ticks"); + return ERR_BAD_STATE; + } + 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..ffce2cf2 100644 --- a/engine/apple/src/lib.rs +++ b/engine/apple/src/lib.rs @@ -11,10 +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. +//! 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}; @@ -32,6 +35,12 @@ 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 (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 = pocketjs_core::MAX_TICK_HZ; + const OK: i32 = 0; const ERR_BAD_ARGUMENT: i32 = -1; const ERR_BAD_STATE: i32 = -2; @@ -176,6 +185,31 @@ 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. +/// 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.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; + } + if !state.surface.set_tick_rate(hz) { + set_last_error("tick rate must be set before the realm ticks"); + return ERR_BAD_STATE; + } + OK + }) +} + #[unsafe(no_mangle)] pub extern "C" fn pocket_apple_load_pak( handle: *mut PocketApple, 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..519f89bf 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,15 @@ 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; + +/// 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`). @@ -252,6 +263,16 @@ 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 + /// 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 +326,9 @@ 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, inspect_rect: None, inspect_drawn: None, @@ -318,6 +342,25 @@ impl Ui { self.raster_density } + /// Declare how many `tick()` calls make one second of virtual time + /// (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`). + 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 +797,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,9 +966,11 @@ 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) { + self.ticked = true; if self.paused { if !self.step_pending { return; @@ -937,7 +983,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 +1411,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..d75233e9 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -765,6 +765,53 @@ 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"); + 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(); + 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| { + 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 +1452,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..eb7b3f9b 100644 --- a/engine/crates/pocket-ui-surface/src/surface.rs +++ b/engine/crates/pocket-ui-surface/src/surface.rs @@ -208,6 +208,15 @@ impl UiSurface { } } + /// Declare how many ticks make one second of virtual time (default 60). + /// 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 /// the guest turn, before rendering). pub fn tick(&self) { @@ -521,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(()) }) 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/framework/src/clock.ts b/framework/src/clock.ts index cd0a0809..fef8ae6a 100644 --- a/framework/src/clock.ts +++ b/framework/src/clock.ts @@ -13,11 +13,41 @@ // 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. + * 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 = 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[] = [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 +59,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 +74,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..d43fc66f 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,27 @@ 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; +// 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. const LEVEL_DEBOUNCE = 8; @@ -402,11 +408,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/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/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/package.json b/hosts/apple/ns-shell/package.json index 03691d04..64445037 100644 --- a/hosts/apple/ns-shell/package.json +++ b/hosts/apple/ns-shell/package.json @@ -5,7 +5,7 @@ "main": "src/app.ts", "dependencies": { "@nativescript/core": "9.1.0-alpha.11", - "@nativescript/pocketjs": "^0.1.0" + "@nativescript/pocketjs": "0.2.0" }, "devDependencies": { "@nativescript/ios-quickjs": "9.0.0-preview.3", 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/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/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 c7e3dfc0..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"; @@ -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)}…` : ""})`, ); @@ -279,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?"); @@ -469,6 +483,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..a99c313e 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,18 @@ function flagValue(args: readonly string[], name: string): string | undefined { return index >= 0 ? args[index + 1] : undefined; } +/** 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 undefined; + 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; @@ -283,11 +298,24 @@ 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$/, ""); } -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 +327,16 @@ async function buildGuest(demoArg: string, density: number): 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: requestedHz ?? IOS_DEFAULT_TICK_RATE, pluginPath: flagValue(args, "--plugin-path"), runtimeTgz: flagValue(args, "--runtime-tgz"), }; @@ -410,8 +460,28 @@ 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); + artifacts = await buildGuest(demoArg, density, options.tickHz); } stageAssets(artifacts, options); await installShellDependencies(options); @@ -442,13 +512,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 +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); + const artifacts = await buildGuest(rest[0], density, tickRateFlag(rest) ?? IOS_DEFAULT_TICK_RATE); console.log(`pocket ios: built ${artifacts.bundle}`); return; }