Skip to content

feat(core): per-realm tick rate — fixed-step at a declared hz - #257

Open
NathanWalker wants to merge 3 commits into
pocket-stack:mainfrom
NathanWalker:feat/tick-rate
Open

feat(core): per-realm tick rate — fixed-step at a declared hz#257
NathanWalker wants to merge 3 commits into
pocket-stack:mainfrom
NathanWalker:feat/tick-rate

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Aug 10, 2026

Copy link
Copy Markdown

Stacked on #256 (which stacks on #255) — review the top commit only; rebases as the stack merges. Delivers the ProMotion follow-up #256's description records.

What

The 1/60 s step becomes a per-realm constant declared before the first tick. Sessions stay fixed-step and deterministic at their declared rate; every default remains 60 and the 60 Hz path is bit-for-bit the original — goldens, tapes, and existing bundles are untouched.

  • Core: Ui::set_tick_rate(hz) (gated on frame == 0) threads dt through the spring integrators and ms→frame conversions; exact integer hz is kept alongside dt so frame counts stay byte-stable.
  • Surface/ABI: UiSurface::set_tick_rate, pocket_apple_set_tick_rate / pocket_apple_core_set_tick_rate (1..=240, ERR_BAD_STATE after the realm ticks), and PocketSurfaceView.tickRate pinning preferredFrameRateRange to the declared rate.
  • Guest virtual time bakes like glyphs do: tools/build.ts --hz defines __POCKET_TICK_HZ__; clock, kinetics, input, and deepzoom derive their per-tick constants from it (rebased as x ** (60/hz) with an explicit === 60 early-return so the default path keeps the original literals). A bundle built at one rate must run at that rate.
  • CLI: pocket ios build|stage|play --hz=60|120 stages tickHz through current.json; the shell pins the surface to it. CADisableMinimumFrameDurationOnPhone added to the shell plist (required for >60 on iPhone).
  • Hero's headline now reads the baked rate (JSX at ${TICKS_PER_SECOND} FPS.).

Validation

  • iPhone 16 Pro (ProMotion), 120 Hz bundles: hero's wall-clock FPS counter reads 120 on device; springs, sprite motion, and the guest↔host channel behave identically to 60 Hz — just at 120 steps/s.
  • Simulator honesty check: simulators report maximumFramesPerSecond = 60, so a 120 realm there renders at half speed by design — device-only evidence.
  • engine/core: 112 tests green (+2 new: the pre-first-tick gate, 120 Hz tween frame counts). cargo build -p pocket-apple clean, bunx tsc --noEmit clean, canonical bun tools/test.ts unit stages match the pre-change baseline, VALID_HZ at 60 is byte-identical to the old literal.
  • Known drift risk (pre-existing): hosts/web/engine.js hardcodes its own 60-anchored VALID_HZ with no import path to clock.ts — untouched here, noted for when the web host wants non-60 realms.

NathanWalker and others added 3 commits August 9, 2026 11:51
…xternal-guest mode

A new engine/apple workspace member packaging modern iOS as a PocketJS
host. The composition mirrors hosts/pocketbook: one pocket_mod::Guest
realm, one pocket_ui_surface::UiSurface, and pocketjs_core::raster driven
incrementally through a DamageTracker, exposed behind a small C ABI.

- engine/apple/src/lib.rs — guest-owning mode: create/load_pak/eval_bundle/
  frame/render/hit_test_bounds/destroy, plus an effect channel over the
  ui.svc* ops (set_effect_callback drains guest svcSend lines during frame;
  post_event queues lines for the guest's next svcPoll).
- engine/apple/src/core_host.rs — external-guest mode: pocket_apple_core_*
  owns only the core, pak feed, raster pipeline, and svc queues, for hosts
  whose JS engine lives elsewhere (demonstrated with the NativeScript
  runtime evaluating the guest bundle in its own context).
- engine/apple/apple/PocketSurfaceView.{h,m} — CADisplayLink capped at
  60 Hz, latched touch contacts (a down+up between two ticks still reaches
  the guest as one present frame then a release), aspect-fit inverse touch
  mapping, damage-gated compositing of the ARGB32 framebuffer.
- engine/apple/build-xcframework.sh — clang-linked dynamic framework per
  slice (device arm64 + simulator arm64), no Xcode project.
- pocket-ui-surface additionally mounts hitTestBounds (spec op 42), the
  touch-path hit authority the gesture layer prefers over the ink-claiming
  hitTest.
- apps/nsengine — reference guest: an effect driver over svcSend, a
  per-frame poll pump, a focusable pressable button, and a platform-reach
  probe that distinguishes a sidecar realm from an embedding-runtime host.

rquickjs uses its bindgen feature: no pregenerated bindings exist for
aarch64-apple-ios targets. Validation: the render_hero example drives the
ABI end to end — 180 frames of apps/hero/main.tsx at 480x272 density 2
render non-blank and byte-identical across two independent instances.
Build guests with --density matching the surface density (glyphs bake at
build time; density 4 supersamples cleanly on 3x screens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ll, play on the simulator

The CLI follow-up feat/apple-host's description names: a dev-profile target
id and a first-class run flow for the Apple host. `pocket play ios nsengine`
builds a guest from a resolved plan, stages it into a committed NativeScript
shell, and launches it on an arm64 iOS simulator.

tools/ios-profile.ts registers the transitional ios-dev profile (hostAbi 7,
platform ios, form embedded, fixed 480x272, raster density 1..4 as a registry
input, capabilities input.touch + text.glyphs.baked) following the
iphone2g-profile pattern: out of POCKET_TARGETS until device acceptance.
apps/nsengine gains a committed manifest — the root-template fallback demands
input.buttons, which this surface cannot advertise.

PocketSurfaceView now applies pocket_apple_set_identity("ios-dev", 7) at
init: plan-built bundles bake __POCKET_TARGET__/__POCKET_HOST_ABI__ and
assertNativeHostContract refuses hosts that publish nothing; the C ABI
existed but nothing called it. External-guest hosts (@nativescript/pocketjs)
mount the same pair on their ui namespace, and a source-text test guards the
agreement.

tools/ios.ts owns the flow — doctor/setup (symbian shape; Rust targets are
the only mutation, and only --rebuild-native needs them since the published
plugin ships a prebuilt PocketApple.xcframework), devices, native, build,
stage, and play (admissible-simulator pick, simctl boot, ns run ios
--device <udid> --no-hmr --justlaunch). --plugin-path/--runtime-tgz point the
shell at local builds for pre-publish validation and restore the committed
template afterwards. pocket play ios delegates here; bin.mjs gains the ios
passthrough.

hosts/apple/ns-shell is the committed shell: plan-driven (viewport, density,
and guest mode read from the staged plan + current.json), answers the ns.ping
service channel, and runs in place so repeat runs rebuild incrementally
(~47 s cold, ~16 s warm on Apple Silicon). Its tsconfig pins
@nativescript/core paths so the plugin's typings resolve from a file:
symlink.

Validated on the iOS 26.5 simulator in both guest modes: unprompted ns.ping
round trip renders pong 1, and --external-guest reads
UIDevice.currentDevice.systemVersion from guest code. Unit stage green
including tests/ios-profile.test.ts, the nsengine admission-matrix row, and
the CLI dispatch cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@doodlewind

Copy link
Copy Markdown
Collaborator

Review — the 60 Hz safety claim fully verified; the non-60 feature has two real bugs and no pairing invariant

Verdict: the headline claim holds — every touched hot path is algebraically and bit-wise identical at hz=60 — so this is safe for every existing target. But a declared-120 realm today plays baked keyframe animations at 2× speed and converts mount-time animations at the wrong rate, and nothing anywhere enforces that a bundle's baked hz matches the core's set rate. I'd hold this until at least the first two are addressed; the 60-path verification means holding it costs existing targets nothing. No code pushed to this branch — all three core items need design decisions that are yours to make.

Verified bit-for-bit at 60 (per touched path)

  • anim.rs:20 ms*hz+500/1000 — pure integer, same op order. anim.rs:213-214 spring dt — statement/operand order preserved, f32 throughout, and 1.0/60u32 as f32 const-evals to exactly spec::FIXED_DT, so the explicit set_tick_rate(60) iOS always performs is not a different dt.
  • clock.ts TICKS_PER_SECOND fallback; divisorsOf(60) reproduces the old VALID_HZ array element-for-element and in order (order matters: normalizeHz ties with strict <).
  • kinetics.ts:97-100 and deepzoom.ts:127-138 both early-return at 60 (=== 60 ? at60 : at60 ** (60/hz)) rather than relying on x ** 1; deepzoom's VEL_APPROACH = 1 - perTick(1 - 0.35) is the one constant whose 60-path goes through arithmetic — it round-trips exactly for 0.35, but by float luck, not construction; worth an early-return for symmetry.
  • The exponent model itself is correct (d(hz) = d60^(60/hz)d(hz)^hz = d60^60, rate-invariant per second), including the complement rebase for the approach factor.
  • Hero headline at 60 renders the identical string; the digits' glyph coverage survives via the spinner-0X.svg string literals, though — see the fragility note below.
  • Empirically: engine/core 112/112 tests (matches the PR body), bunx tsc --noEmit clean, and I built iphone16-demo-main at --hz=120 and rendered it through the pocket-apple example host: headline and FPS tile read 120, 180 frames deterministic. No golden churn at 60 — the full JS gate at this commit fails only in the launcher sim stage, which is feat(ios): pocket ios — transitional ios-dev target, NativeScript shell, play on the simulator #256's registry issue (fixed on that branch), not this commit's.

The three core findings

  1. Baked keyframe timelines are still baked at 60. framework/compiler/animation.ts:136-137 msToFrames hardcodes 60 and doesn't read __POCKET_TICK_HZ__; the ANIM TABLE segment endpoints, loop periods, and stroke arcs (:485-548) are all frame-baked at build time and the core plays one segment-frame per tick. So a --hz=120 bundle plays every animate-* utility, loop, and stroke at exactly 2× — while transition-* (stored as dur_ms, converted at runtime with tick_hz) plays correctly. This is precisely the "virtual time bakes like glyphs" category the PR claims to cover; apps/motions is the visible casualty.
  2. The sanctioned call order converts mount-time animations at 60. pocket_apple.h:6 documents eval_bundle → [set_tick_rate], but the guest builds its tree during eval: mount() runs synchronously, onMount fires, and apps/hero/app.tsx:57-59's animate(underline, …, {dur: 700}) reaches Ui::ms_to_frames while tick_hz is still 60. On a declared-120 realm the underline sweep is 42 frames = 350 ms of virtual time instead of 700 ms (delay 150→75 ms). Every mount-time animate()/spring() in every app is affected. Fix shape: require the rate before eval_bundle (reject after mount), rather than after.
  3. Nothing enforces bundle-hz == core-hz. The plan doesn't own it (plan.ts:5-23, and tools/build.ts:213 says so), the boot handshake checks only __host/__hostAbi (host.ts:243-264), and the CLI can disagree with itself: pocket ios build hero --hz=120 then play hero --no-build stages tickHz: 60 into current.json with no record of what the bundle was baked at — a 120-baked bundle on a 60-driven core, silently. I reproduced the mismatch class locally: the render_hero host never calls set_tick_rate, and a 120-baked bundle mounts and renders on its 60-stepped core without a whisper. It's also today's default state on device: the published @nativescript/pocketjs@0.1.0 predates this commit and has no tickRate in its headers or index.d.ts (verified in the tarball), so the shell's pocket.tickRate = staged.tickHz ?? 60 is a no-op property write against it. The natural fix is baking the rate into the identity handshake (e.g. __POCKET_TICK_HZ__ checked at mount like target/hostAbi) plus a build stamp for --no-build.

Secondary findings

  • The two in-tree off-60 hosts weren't migrated to the mechanism built for them: hosts/pocketbook ticks at ~30 Hz (TICK_MS = 33) against a core that believes 60 — every ms-based animation runs at half wall-clock speed there today, and UiSurface::set_tick_rate(30) is now exactly the fix; pocket-widget's WidgetConfig.tick_hz accumulator has the same shape. Post-PR the repo has three cadence notions with no single owner.
  • Range/gate enforcement lives only in the outermost C layer: Ui::set_tick_rate (lib.rs:339-345) has no upper bound (u32::MAX accepted; ms_to_frames' as u32 silently truncates above hz≈4000), UiSurface passes through unchecked, and a debug_paused realm can change its step after arbitrarily many ticks (frame doesn't advance while paused). PocketSurfaceView.m:209-221 discards the set_tick_rate return for both the tolerable case and the bad-argument case, then pins the display link anyway.
  • Contract layer still asserts what this PR relaxed: contracts/spec/spec.ts:1445-1447 "exactly 1/60 s" with gen-rust.ts:223-225 actively throwing if FIXED_DT !== 1/60; docs/DETERMINISM.md:12,27-28,58 (the subsampling contract) untouched; contracts/spec/audio.ts:139-150 framesForTick divides by a literal 60, so a 120 realm consumes PCM at 2× and the zero-drift theorem's statement goes false.
  • A declared rate silently doubles default simulationHz (clock.ts:40,104), rescaling every per-frame constant (kinetics chase, dpad scroll, deepzoom's LEVEL_DEBOUNCE — rebased per-tick constants and un-rebased per-frame ones now coexist in the same file); sprite clocks are per-tick by contract but the shipped demo's spinner visibly changes speed with --hz.
  • Coverage: engine/wasm exports no ui_set_tick_rate, so sim/goldens/tapes can't reach a non-60 realm at all; no test anywhere references --hz/tickHz/tickRate; the two new core tests pin the gate and one 2× frame count but no spring trajectory and nothing guest-side. divisorsOf accepts any positive define — divisorsOf(59.94)[]normalizeHz undefined → tick loops silently no-op; only tools/build.ts guards integrality and it is not the only producer (clock.ts:16-18 explicitly invites other bundlers).
  • Hero headline fragility: the template quasis exclude the digits from glyph collection; 60/120 survive only because spinner-0X.svg/value="42" string literals happen to contain those digits. An --extra-chars pin (or a digits constant in a StringLiteral) would make it deliberate.
  • hosts/web/engine.js VALID_HZ note in the PR body is accurate — and understated: :114 let simHz = 60 and :224 const ticks = 60 / simHz hardcode the anchor twice more; hosts/sim/sim.ts imports TICKS_PER_SECOND yet still hardcodes 60 in its tape expander (:168-179).

Also for the record: frame-count byte-stability at 60 is by construction (identical expression), and the 2× relation the new test asserts holds for 200 ms but is not a general property (8 ms → 1 frame at both rates — the sub-frame floor); nothing pins that boundary.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants