feat(core): per-realm tick rate — fixed-step at a declared hz - #257
Conversation
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 |
|
Status update: #255 and #256 are merged — this branch now needs a rebase onto main, which will drop the two commits it shares with the merged stack (your What this PR still needs before merge, from the review above:
Also still owed on the plugin side: One positive data point since the review: the esp32p4 workflow ran on this branch (ESP-IDF release/v6.0 + Rust renderer) and both jobs pass — the 🤖 Generated with Claude Code |
npm's 0.1.0 predates tickRate, so a shell setting pocket.tickRate against it is a silent no-op (pocket-stack/pocketjs#257 review). Publish is gated on that PR landing: the bundled xcframework must be rebuilt from the merged engine/apple first, and the shell's ^0.1.0 range must move to ^0.2.0 to resolve this release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Once this is rebased and merged, I can publish a 0.2.1 of |
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>
… tile from the baked rate Review fixes for pocket-stack#257, on top of the rebase onto main: - hosts/apple/ns-shell pinned @nativescript/pocketjs to an exact 0.2.0. This commit's `pocket.tickRate = staged.tickHz ?? 60` needs an API that first ships in 0.2.0, and `^0.1.0` resolves to `>=0.1.0 <0.2.0`, so the shell could never install it. (The caret is self-consistent on main, whose shell never touches tickRate — the requirement arrives with this PR.) Verified 0.2.0 carries the whole ABI: tickRateProperty in index.js, tickRate in index.d.ts, and pocket_apple_set_tick_rate / pocket_apple_core_set_tick_rate / PocketSurfaceView.tickRate in the packed xcframework headers. Exact rather than caret matches the ios-quickjs entry beside it and the repo's toolchain-pin idiom. - apps/hero's FPS tile derives from TICKS_PER_SECOND like the headline already does. The headline became dynamic in this PR while the tile kept a literal 60, so a plain hero bundle at --hz=120 rendered "JSX at 120 FPS." beside a 60 FPS tile. At 60 both spellings are String(60), so no golden can churn. Verified on the rebased branch: `bun run test` 11/11 stages green, `bunx tsc --noEmit` clean, `cargo build -p pocket-apple` clean, engine/core 112 passed, and a rebuilt hero-main --hz=120 renders 180 frames non-blank and byte-identical across two instances with headline and tile both reading 120. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
06cf3b1 to
c71d9c8
Compare
|
Rebased onto main and pushed, so the branch is a clean single commit again and the three design items are the only thing between this and merge. What I did
Verification on the rebased branch
Still yours, and one sequencing noteThe three findings from the review stand untouched: ANIM TABLE bakes at 60, On the plugin: 0.2.0 is already out and already carries the ABI, so a 0.2.1 rebuild after merge is a rebuild, not a blocker. But note the order question is now partly baked into a published binary — 0.2.0's 🤖 Generated with Claude Code |
The ANIM TABLE counts frames and the core plays one segment frame per tick, so a --hz=120 bundle previously played every animate-* utility, loop period and stroke arc at 2x: msToFrames hardcoded 60 while transition-* (stored in ms, converted at runtime) played correctly. setAnimationTickRate threads the build's --hz into the baker; at 60 the conversion is the identical expression, so existing tables are byte-stable by construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ount The sanctioned order was eval_bundle -> set_tick_rate, but the guest builds its tree during eval: mount() runs synchronously, onMount fires, and every mount-time animate()/spring() reaches ms-to-frames while the realm still steps at 60 — a declared-120 realm converted hero's 700 ms underline sweep to 350 ms of virtual time. The rate now precedes eval_bundle (rejected after, like set_identity), the surface publishes it to the guest as ui.__tickHz at mount, and PocketSurfaceView applies it in the tickRate setter instead of at start (which now only pins the display link) so a too-late set fails loudly through lastError/onError. Core-mode has no eval boundary, so pocket_apple_core_set_tick_rate is rejected after the first core_animate or tick. Ui::set_tick_rate gains the 240 Hz ceiling (above it ms_to_frames' u32 narrowing truncates), reports whether it applied, and gates on a ticked flag rather than the frame counter — a debug_pause'd realm never advances frame, which left the step size mutable mid-run. render_hero takes POCKET_TICK_HZ for non-60 bundles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing anywhere ensured a bundle's baked rate matched the rate its core was stepped at — a 120-baked bundle mounted and ran half-speed on a 60-stepped core without a whisper. Bundles now assert the pairing where they already assert target/hostAbi: the host declares its rate as ui.__tickHz (published by UiSurface at mount; absent means the 60 default, which is what every pre-rate host ran), and assertNativeHostContract refuses a mismatch for every native mount, plan-built or not. The define is read at call time so tests can exercise the non-60 paths. pocket ios build writes a build stamp (tickHz + density) next to its artifacts, and play --no-build stages the stamp's facts instead of the flags' defaults — build --hz=120 then play --no-build previously staged tickHz: 60 with no record of what the bundle was baked at. An explicit conflicting flag is an error. iOS guest builds also pin the digit glyphs (--extra-chars) so the hz-derived headline and FPS tile never depend on incidental literals for coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…loud-failure guards DETERMINISM.md and the FIXED_DT comment still asserted an unconditional 1/60 s step; both now state the default-vs-declared relationship the tick-rate mechanism actually implements. The clock throws at boot on a non-integer or out-of-range __POCKET_TICK_HZ__ — divisorsOf(59.94) is [] and every tick loop downstream would silently no-op, and tools/build.ts is not the only producer of the define. DeepZoom's VEL_APPROACH takes the 60-path early return like every other rebased constant: its rebase runs through the complement, and 1 - (1 - 0.35) recovering 0.35 exactly was float luck rather than construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three core findings are addressed on the branch — four commits on top of your rebase. Each needed a design decision; here is what I chose and why. 1. Keyframe timelines bake at the declared rate (9d66e9c)
2. The rate precedes eval_bundle (6eb4adf)Took your fix shape exactly: the documented order is now Decisions taken along the way:
3. The pairing invariant (9bc75dd)Baked into the identity handshake as you suggested: The CLI half: Secondary findings that rode along (d6ae5f0 + the above)
Deliberately not in this PR
Verification
The 0.2.x consequence you flaggedThe call order is settled before 0.2.1, per your sequencing note. Against the published 0.2.0 a non-60 bundle now refuses to mount (nothing in 0.2.0 publishes 🤖 Generated with Claude Code |
Following the apps/iphone2g-demo pattern, a hero wrapper whose copy matches the device the ios-dev embedding actually runs on: Tap Me, an iPhone 16 Pro device line, and an FPS tile tied to the baked tick rate. Review registered it in the admission matrix (vita-only through the shared touch + integer-fit contract, private ios-dev otherwise) and regenerated the launcher display union, which the unit and launcher-sim gate stages both pin. Rebased onto main after #257; local gate 11/11 stages green, bunx tsc --noEmit clean.
|
Merged as
Your four deferrals all read as correct scope calls. The Ready for the 0.2.1 rebuild whenever you are; the shell pin bump is the one line in |
#273) main has failed bun run site:build — a release-workflow step — since #257 merged, which is what broke the v0.10.0 publish (it failed before any npm publish; both packages stayed at 0.9.3). The playground serves @pocketjs/framework/clock out of one hand-written re-export module that named only ticksPerFrame, while #257 made hero's headline and FPS tile derive from TICKS_PER_SECOND; the module audit added in #254 caught it. One line names the export. Only the solid entry needs it — the vue-vapor and octane import maps carry no clock entry. Verified: site:build completes with 28 playground variants linked, gate 11/11 green.
The one-line pin bump #257 left owed. 0.2.1 is the plugin rebuild from post-merge main: verified in the published tarball that its xcframework header carries the post-#257 call order (create -> load_pak* -> [set_identity] -> [set_tick_rate] -> eval_bundle, not 0.2.0's), that _mountUi publishes __tickHz next to __host/__hostAbi, and that createNativeView declares tickRate ahead of guest eval — so external-guest mounts complete the pairing invariant rather than failing it. The binary differs in size from 0.2.0's, i.e. a real rebuild. Gate 11/11 green, bunx tsc --noEmit clean, no test pins the version. This closes the known limitation recorded in the 0.10.0 changelog entry: iOS at 120 Hz no longer waits on an unpublished plugin.
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.