Skip to content

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

Merged
doodlewind merged 6 commits into
pocket-stack:mainfrom
NathanWalker:feat/tick-rate
Aug 14, 2026
Merged

feat(core): per-realm tick rate — fixed-step at a declared hz#257
doodlewind merged 6 commits into
pocket-stack:mainfrom
NathanWalker:feat/tick-rate

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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.

@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

@doodlewind

Copy link
Copy Markdown
Collaborator

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 d00b12cb pin fix also landed inside #256's merge, cherry-picked with authorship preserved, so it falls out too).

What this PR still needs before merge, from the review above:

  1. ANIM TABLE bakes at 60 (framework/compiler/animation.ts:136) — animate-* keyframes, loops, and strokes play at 2× in a --hz=120 bundle while transition-* plays correctly.
  2. eval_bundle → set_tick_rate order converts mount-time animate()/spring() at 60 (hero's underline: 700 ms → 350 ms of virtual time on a 120 realm). Requiring the rate before eval_bundle fixes it.
  3. No bundle-hz ↔ core-hz pairing invariant — plan, boot handshake, and --no-build staging can all disagree silently.

Also still owed on the plugin side: @nativescript/pocketjs is 0.1.0 on npm, published before this commit — no tickRate in its headers or index.d.ts (verified in the tarball), so the shell's pocket.tickRate = … is a silent no-op against it and --hz=120 degrades quietly. It needs a republish against the post-#255 engine/apple with this PR's ABI once this lands.

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 engine/core dt threading holds up on the ESP32 build path, which local review couldn't cover.

🤖 Generated with Claude Code

NathanWalker added a commit to NativeScript/pocketjs that referenced this pull request Aug 11, 2026
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>
@NathanWalker

NathanWalker commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Once this is rebased and merged, I can publish a 0.2.1 of @nativescript/pocketjs with the rebuilt .xcframework.

NathanWalker and others added 2 commits August 12, 2026 21:29
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>
@doodlewind

Copy link
Copy Markdown
Collaborator

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

  • Rebased with git rebase --onto origin/main 2e343a53, replaying only 06cf3b1a — the two commits shared with the merged feat(apple): iOS host — pocket-apple core crate, PocketSurfaceView, external-guest mode #255/feat(ios): pocket ios — transitional ios-dev target, NativeScript shell, play on the simulator #256 drop out. Zero conflicts, and the resulting diff is byte-identical in scope to the original commit: 18 files, +303/−69. (A plain git merge main reports 12 conflicting files; they were all just the already-merged commits colliding with their squashes, hence the --onto form.)
  • Bumped the shell's plugin pin to an exact 0.2.0 (hosts/apple/ns-shell/package.json). This belongs in this PR rather than a separate one: ^0.1.0 is self-consistent on main (main's shell never touches tickRate), but this PR's pocket.tickRate = staged.tickHz ?? 60 needs an API that only exists from 0.2.0 — and ^0.1.0 resolves to >=0.1.0 <0.2.0, so it cannot reach it. I verified @nativescript/pocketjs@0.2.0 (published today 16:48) 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 follows the ios-quickjs entry beside it and the repo's toolchain-pin idiom; 0.2.1 will need a one-line bump here.
  • Fixed a half-migration this PR introduced (apps/hero/app.tsx): the headline now derives from TICKS_PER_SECOND but hero's own FPS tile still defaulted to a literal 60, so a plain hero bundle at --hz=120 rendered "JSX at 120 FPS." next to a 60 FPS tile. The tile now defaults to TICKS_PER_SECOND too. At 60 this is String(60) either way, so no golden can churn.

Verification on the rebased branch

  • bun run test: 11/11 stages green (40 s), both before and after the hero fix.
  • bunx tsc --noEmit clean; cargo build -p pocket-apple clean; engine/core 112 passed.
  • Rebuilt hero-main --hz=120 and rendered it through the pocket-apple example host: 180 frames, non-blank, byte-identical across two instances, headline and tile both reading 120.

Still yours, and one sequencing note

The three findings from the review stand untouched: ANIM TABLE bakes at 60, eval_bundle → set_tick_rate converts mount-time animations at 60, no bundle-hz ↔ core-hz pairing invariant.

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 pocket_apple.h ships the create -> load_pak* -> [set_identity] -> eval_bundle -> [set_tick_rate] contract in its header comment. If that order changes (item 2's fix), it changes the published ABI's documented call order, so it is cheaper to settle before 0.2.1 than after.

🤖 Generated with Claude Code

NathanWalker and others added 4 commits August 13, 2026 17:56
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>
@NathanWalker

Copy link
Copy Markdown
Contributor Author

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)

msToFrames now counts frames at the build's rate: tools/build.ts passes --hz into the baker (setAnimationTickRate) before compileClasses, so ANIM TABLE segment endpoints, loop periods and delays all bake at the rate the realm runs. The 60 path is the identical expression (round(ms*hz/1000) with hz=60), so existing tables are byte-stable by construction; tests/tailwind.test.ts pins a 1 s spin baking 120 frames at 120, and motions builds clean at both rates (143 timelines each).

2. The rate precedes eval_bundle (6eb4adf)

Took your fix shape exactly: the documented order is now create -> load_pak* -> [set_identity] -> [set_tick_rate] -> eval_bundle, and pocket_apple_set_tick_rate is rejected after eval_bundle like set_identity — the mount now publishes the rate to the guest (item 3), so it has the same lifecycle as the other mount-published facts. Mount-time animate()/spring() therefore convert at the declared rate; hero's underline sweep is 84 frames at 120, not 42.

Decisions taken along the way:

  • PocketSurfaceView applies the rate in the tickRate setter, not in startstart ran after evalBundle, which is exactly the too-late window. start now only pins the display link, and a too-late or out-of-range set surfaces through lastError/onError and keeps the declared rate (this also resolves the discarded-return finding).
  • Core mode has no eval boundary, so pocket_apple_core_set_tick_rate is rejected after the first core_animate or tick — animate is the first ms-to-frames conversion, which is the actual hazard. The embedder declares the rate before evaluating its guest, and on the namespace it mounts (item 3).
  • Ui::set_tick_rate gates on a ticked flag rather than frame — a debug_paused realm never advances frame, which left the step size mutable mid-run (your gate finding) — takes the 240 ceiling into the core as pocketjs_core::MAX_TICK_HZ (above it ms_to_frames' as u32 narrowing truncates), and returns whether it applied.
  • render_hero accepts POCKET_TICK_HZ=N, so the non-60 validation flow you used stays possible — and is now required for non-60 bundles.

3. The pairing invariant (9bc75dd)

Baked into the identity handshake as you suggested: UiSurface::mount publishes ui.__tickHz (which is what makes item 2's ordering necessary rather than stylistic), and assertNativeHostContract refuses a mismatch for every native mount, plan-built or not — plan-less bundles bake a rate too. Absent __tickHz means 60: hosts that predate per-realm rates only ever drove 60, so every existing 60 pairing keeps mounting and every non-60 bundle on a rate-silent host fails actionably. Your reproduced case is closed both ways: a --hz=120 hero through render_hero without a declared rate now dies at eval with PocketJS: tick-rate mismatch (bundle baked at 120 Hz, host drives 60 Hz) ...; with POCKET_TICK_HZ=120 it renders 180 frames, non-blank, byte-identical across two instances.

The CLI half: pocket ios build writes a build stamp (dist/ios/<app>/build-stamp.json: tickHz + density) and play --no-build stages the stamp's facts instead of the flags' defaults. An explicitly conflicting --hz/--density is an error, and pre-stamp artifacts ask for one rebuild. build --hz=120 then play --no-build can no longer stage tickHz: 60 — and even a hand-staged mismatch now dies at mount on-device.

Secondary findings that rode along (d6ae5f0 + the above)

  • VEL_APPROACH takes the 60-path early return — the complement rebase recovering 0.35 exactly was float luck, per your note.
  • The clock throws at boot on a non-integer/out-of-range define: divisorsOf(59.94) is [] and every tick loop downstream would silently no-op, and build.ts is not the define's only producer.
  • DETERMINISM.md and the FIXED_DT comment state the default-vs-declared relationship (the committed goldens and tapes all run the default; the subsampling contract reads per-realm).
  • iOS guest builds pin --extra-chars=0123456789, so the hz-derived headline/tile digits never depend on spinner-0X.svg luck. Deliberately NOT in hero itself: adding codepoints to the shared atlas would churn the committed 60 goldens.

Deliberately not in this PR

  • pocketbook / pocket-widget migration: now exactly the one-call fix you described, but it changes those hosts' observable ms-animation timing, so tapes churn — it deserves its own PR with its own evidence.
  • audio.ts framesForTick: still divides by 60. With the pairing invariant a non-60 realm can only mount on a host that declares non-60, and no audio-capable host can declare one — the zero-drift statement is no longer reachable in a false state. Must be fixed before any audio-capable host grows a declared rate; flagged as follow-up.
  • simulationHz defaulting to the tick rate (frames == ticks at any declared rate) I kept as the intended semantics: presentation follows the realm, per-frame constants keep their per-frame meaning. If you read deepzoom's mixed per-tick/per-frame constants as a bug rather than two deliberate units, happy to revisit in a follow-up.
  • wasm ui_set_tick_rate export for sim-side non-60 coverage: follow-up; the new tests cover the compiler bake, the core gates and the guest-side assert directly.

Verification

  • bun run test 11/11 green (37.0 s) here and on the rebased feat(apps): iphone16-demo  #258 stack; bunx tsc --noEmit clean; engine/core 113 passed (one new: the paused-tick gate; the fixed-once test additionally pins the 240 ceiling and the applied/rejected returns); cargo build -p pocket-apple and the example clean.
  • 120 hero through render_hero: declared, 180 deterministic frames; undeclared, the mount refusal above. Plain 60 hero renders unchanged through an undeclared host.

The 0.2.x consequence you flagged

The 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 __tickHz) — loud, not wrong — while 60 Hz on 0.2.0 is untouched. 0.2.1 then rebuilds the xcframework from post-merge main with the new header and surface, publishes __tickHz from _mountUi in external-guest mode, orders the rate ahead of guest eval in both plugin views, and takes the one-line shell pin bump.

🤖 Generated with Claude Code

@doodlewind
doodlewind merged commit 557f8c4 into pocket-stack:main Aug 14, 2026
2 checks passed
doodlewind pushed a commit that referenced this pull request Aug 14, 2026
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.
@doodlewind

Copy link
Copy Markdown
Collaborator

Merged as 557f8c4. Verified the three fixes before merging rather than taking the descriptions at face value:

  • Keyframe bake: at bakeHz = 60 msToFrames is the character-identical expression, so the byte-stability claim holds by construction, not by test.
  • Order fix: the declared-120 hero renders 1688152 damage px against 1576992 before — consistent with the underline sweeping 84 frames instead of 42, i.e. mount-time animate() now converting at the declared rate.
  • Pairing invariant: I re-ran the exact case that passed silently during review — a --hz=120 bundle on an undeclared host now dies at eval with the mismatch error, and renders 180 deterministic frames once POCKET_TICK_HZ=120 declares it. The assert sitting ahead of if (!expected) return is what makes it cover plan-less bundles too, and __tickHz absent ⇒ 60 keeps every pre-rate host mounting.
  • Core gates: ticked flag instead of frame (closes the debug_pause hole), MAX_TICK_HZ ceiling, applied/rejected return — all three gate findings.

engine/core 113 passed. Gate 11/11 green, including merged against current main (#270's allocator change does not interact). One unit-stage red along the way was the known Gatekeeper stall on iphone2g-device-transaction (16.2 s in-gate, 6/6 in 3.7 s standalone, green on rerun) — not this branch.

Your four deferrals all read as correct scope calls. The audio.ts framesForTick reasoning in particular checks out: with the pairing invariant a non-60 realm can only mount on a host that declares non-60, and no audio-capable host can, so the zero-drift statement is unreachable in a false state — worth keeping the follow-up flagged for whenever one grows a declared rate.

Ready for the 0.2.1 rebuild whenever you are; the shell pin bump is the one line in hosts/apple/ns-shell/package.json.

doodlewind added a commit that referenced this pull request Aug 14, 2026
#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.
doodlewind pushed a commit that referenced this pull request Aug 14, 2026
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.
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