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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/hero/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -81,7 +82,7 @@ export default function Hero(props: HeroProps = {}) {
<View class="flex-row gap-4">
<Stat
label="FPS"
value={String(props.presentationHz ?? 60)}
value={String(props.presentationHz ?? TICKS_PER_SECOND)}
cls="text-lg text-emerald-600 font-bold"
/>
<Stat
Expand All @@ -103,7 +104,7 @@ export default function Hero(props: HeroProps = {}) {
</Text>
<View class="flex-row flex-wrap items-center justify-between">
<Text class="text-4xl text-slate-950 font-bold">
{props.headline ?? "JSX at 60 FPS."}
{props.headline ?? `JSX at ${TICKS_PER_SECOND} FPS.`}
</Text>
<Image class="w-10 h-10" src={spinnerSrc()} />
</View>
Expand Down
6 changes: 4 additions & 2 deletions contracts/spec/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
10 changes: 10 additions & 0 deletions docs/DETERMINISM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions engine/apple/apple/PocketSurfaceView.h
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ NS_ASSUME_NONNULL_BEGIN
// Convenience: reads <name>.js and <name>.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=<n>`).
@property(nonatomic) uint32_t tickRate;

// Starts/stops the CADisplayLink. start after evalBundle succeeds.
- (void)start;
- (void)stop;
Expand Down
29 changes: 27 additions & 2 deletions engine/apple/apple/PocketSurfaceView.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
}
Expand Down
22 changes: 18 additions & 4 deletions engine/apple/examples/render_hero.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
//! 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.

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;
Expand All @@ -26,7 +29,7 @@ fn last_error() -> String {
}
}

fn run_instance(bundle: &[u8], pak: &[u8]) -> (Vec<u8>, u32, u32, u64) {
fn run_instance(bundle: &[u8], pak: &[u8], tick_hz: Option<u32>) -> (Vec<u8>, u32, u32, u64) {
let handle = pocket_apple_create(DENSITY, WIDTH, HEIGHT);
assert!(!handle.is_null(), "create failed: {}", last_error());
assert_eq!(
Expand All @@ -35,6 +38,14 @@ fn run_instance(bundle: &[u8], pak: &[u8]) -> (Vec<u8>, 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()),
Expand Down Expand Up @@ -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::<u32>().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;
Expand Down
21 changes: 18 additions & 3 deletions engine/apple/include/pocket_apple.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
41 changes: 39 additions & 2 deletions engine/apple/src/core_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -40,6 +43,10 @@ pub struct PocketAppleCore {
svc_in: VecDeque<String>,
svc_out: VecDeque<String>,
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<R>(
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)]
Expand Down
42 changes: 38 additions & 4 deletions engine/apple/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
//! is `Rc<RefCell<..>>`). 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};
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading