feat(core): per-realm tick rate — fixed-step at a declared hz - #257
feat(core): per-realm tick rate — fixed-step at a declared hz#257NathanWalker wants to merge 3 commits into
Conversation
…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>
Review — the 60 Hz safety claim fully verified; the non-60 feature has two real bugs and no pairing invariantVerdict: 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)
The three core findings
Secondary findings
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 |
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.
Ui::set_tick_rate(hz)(gated onframe == 0) threads dt through the spring integrators and ms→frame conversions; exact integer hz is kept alongside dt so frame counts stay byte-stable.UiSurface::set_tick_rate,pocket_apple_set_tick_rate/pocket_apple_core_set_tick_rate(1..=240, ERR_BAD_STATE after the realm ticks), andPocketSurfaceView.tickRatepinningpreferredFrameRateRangeto the declared rate.tools/build.ts --hzdefines__POCKET_TICK_HZ__; clock, kinetics, input, and deepzoom derive their per-tick constants from it (rebased asx ** (60/hz)with an explicit=== 60early-return so the default path keeps the original literals). A bundle built at one rate must run at that rate.pocket ios build|stage|play --hz=60|120stagestickHzthroughcurrent.json; the shell pins the surface to it.CADisableMinimumFrameDurationOnPhoneadded to the shell plist (required for >60 on iPhone).JSX at ${TICKS_PER_SECOND} FPS.).Validation
maximumFramesPerSecond = 60, so a 120 realm there renders at half speed by design — device-only evidence.cargo build -p pocket-appleclean,bunx tsc --noEmitclean, canonicalbun tools/test.tsunit stages match the pre-change baseline, VALID_HZ at 60 is byte-identical to the old literal.hosts/web/engine.jshardcodes 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.