How it works · write-up
HD / HIGH FPS scrolling jitter — verified 2026-10-06
The video has regular 60.0375 FPS timestamps, but its background and terrain do not move uniformly. Two defects in presentation interpolation explain the observed jumps. The initial investigation was read-only; the repair and its recorded validation are described below.
Recording and measurement
Folder: E:/xenon_runs/hd-highfps-cadence-20261006-r583.validation
- Video:
hd-highfps-cadence-20261006-r583-sprite.mp4 - Observations:
hd-highfps-cadence-20261006-r583.x2events - Capture: 1280×800, HD atlas, HIGH FPS enabled, QSV H.264.
Measured 300 native-resolution video frames starting at 2 seconds. Actual first frame is video index 121, VBL 14490, time 2.015408 seconds. For every adjacent pair, searched vertical translations from -12 to +12 output pixels and compared aligned grayscale rows. No downscaling or frame-rate conversion was used.
Independent patches on both sides gave the same translation sequences:
| Layer | Patches (x, y, width, height) | Measured translation over 299 pairs |
|---|---|---|
| Background | (280,240,120,320), (940,250,100,320) | 262 holds, 37 downward jumps of 4 output pixels |
| Terrain | (0,130,24,520), (1256,130,24,520) | 267 steps of 1 pixel, 24 holds, 8 jumps of 4 pixels |
All 75 corresponding canonical game observations report camera delta +1, with exactly four VBLs between observations. The background's captured source offset alternates between a delta of 0 and -1, reflecting half-speed parallax. Camera travel is constant in this sample.
Analysis files are in the ignored work/ directory:
analyze_scroll_motion.py, scroll-motion-r583.json, scroll-r583-2s.png.
The script can be rerun with the video and observations as its two arguments.
Background: fractional motion is rounded away
SpriteRenderQuad.source.background.sourceOffsetY is a uint32_t
(src/includes/renderFrame.h:183). The extrapolator computes a floating-point
offset but rounds it with lroundf before assigning that integer
(src/spriteStreamInterpolation.c:567–570). The desktop renderer then calculates
UVs from this already-rounded value (src/sdlGpuRenderView.c:1299–1300).
Consequently one original game pixel is the smallest background movement, which is four output pixels in this recording. With half-speed parallax, the observed sequence repeats seven held video frames followed by one four-pixel step: about 7.5 actual background position changes per second, despite a 60 FPS file.
Simply changing the field to float is insufficient for completely even motion.
Each real frame currently displays its captured integer offset directly; only
held VBLs use extrapolation (src/screentrace.c:688). Because the real background
offset is dithered, this would still reset a fractional prediction to a stepped
integer anchor every four VBLs. The presentation scroll must preserve its
fractional phase across those real updates, while following genuine speed or
direction changes.
Terrain: zero fine scroll is mistaken for a missing reference
FindSharedWallTileTopBandY only accepts terrain quads with y < 0
(src/spriteStreamInterpolation.c:244). At fine scroll zero the valid top row is
at y=0, so this function reports no reference. Both newest and older frames
must have a reference for terrain extrapolation to run
(src/spriteStreamInterpolation.c:472–473).
Interpolation therefore stops for two consecutive logical frames: first when the newest frame has zero fine scroll, then when that frame becomes the older sample. At the next real frame, terrain snaps forward to catch up.
The first measured occurrence:
| VBL / video time | Observation |
|---|---|
| 14546 / 2.948159 s | Game frame 220: tile rows 0,16,32,…; reference incorrectly absent |
| 14547–14549 / 2.964815–2.998127 s | Terrain held for three video frames |
| 14550 / 3.014784 s | Terrain jumps 4 output pixels |
| 14551–14553 / 3.031440–3.064752 s | Terrain held for another three video frames |
| 14554 / 3.081409 s | Terrain jumps another 4 output pixels, then resumes 1-pixel steps |
The same defect repeats at game frames 236, 252 and 268, every 64 VBLs (approximately 1.066 seconds). Reference discovery also unnecessarily depends on a nonempty top row; an empty top row can cause a similar gap.
Recommended repair
- Preserve a fractional background presentation offset through the quad and UV path. Maintain a continuous presentation phase across dithered real-frame anchors; rounding should happen only through final output-pixel sampling.
- Capture RAM scroll with the draw frame, independent of visible tile content. Use the applied camera delta directly for all terrain quads. Capture the fine scroll for top-row placement, including zero. Reading live RAM during presentation would mix a completed draw with a newer game frame.
- Repeat the layer translation measurements, including fine-scroll wrap, background wrap, stopping, reversing, and pausing. Whole-image changes alone do not establish smooth scrolling: moving ships can make all 60 pictures different while terrain or background remains held.
These are rendering corrections. They do not require changing game logic, autopilot, encoder frame rate, or adding a delayed gameplay frame.
Implemented repair and live validation
The draw stream now captures camera position (0xcd0), applied delta (0xcd8),
terrain fine scroll (0xcd6), and background base/cursor (0x4f000/0x436) at
the terrain draw entry. Four tagged scalar snapshots retain the current capture
and recent completed frames. The renderer requests the snapshot for the same
completed frame as its quads; interpolation owns its ordinary quad history.
Terrain uses this captured applied delta directly. The former per-tile searches for a visible negative-Y top row have been removed. Top-row positioning also uses the captured fine scroll rather than reading RAM for every tile.
Disassembly of the fresh recording's RAM and ReVa's /mydumpat0 confirmed that
$7A7A loads $CD8 before calling $702C. Background movement is therefore
half the applied camera movement; $CDC is game-clock parity, rather than an
independent speed register. The interpolator preserves a floating-point
background phase across these dithered integer captures. The real cursor
still anchors it, with a new phase on scene discontinuities or discrepancies
beyond one pixel. Both real and held VBLs present that fractional phase.
Background sourceOffsetY is now float through C quads and UV calculation.
The 48-byte quad layout is unchanged; the Web decoder reads the background
field as float while textured door offsets remain integer. Paused repaints keep
the cached presentation without advancing time or snapping to raw game pixels.
Recorded verification, using the same start checkpoint as r583:
E:/xenon_runs/hd-highfps-scroll-fixed-20261006-r584.validation
- Sprite video:
hd-highfps-scroll-fixed-20261006-r584-sprite.mp4 - Original video:
hd-highfps-scroll-fixed-20261006-r584-original.mp4 - Observations:
hd-highfps-scroll-fixed-20261006-r584.x2events - Both videos finalized and decoded successfully, with continuous VBL indices, 60.0375 FPS timestamps and mono audio. The emulator was visible.
Repeating the same five-second measurement on both sides:
| Layer | Before (r583) | After (r584) |
|---|---|---|
| Terrain | 267 one-pixel steps, 24 holds, 8 four-pixel jumps | 299 one-pixel steps, no holds or larger jumps |
| Background | 262 holds, 37 four-pixel jumps | 150 holds, 149 one-pixel steps, alternating evenly |
The background's remaining alternating output-pixel holds are expected: half-speed parallax moves 0.5 output pixels per VBL at this capture scale, and nearest-neighbour sampling resolves that into alternating 0/1 output-pixel steps. The former whole-game-pixel quantization is gone. Background wrap near game frames 378–380 also retains this regular motion.
Across all 241 common game-frame observations, controls, shields, lives, score, scroll and the complete object observations are identical to r583. No damage/life-loss/stall incidents were recorded in the short validation.
Sudden camera reversals still cause corrections inherent in the current extrapolation of a completed game frame. In r584 these occur at approximately 13.791 and 14.125 seconds; captured camera delta reverses at frames 382 and 387. These are direction-change corrections, distinct from the fixed constant-speed quantization and periodic terrain-reference failures. This repair does not add a buffered game-frame delay or change ordinary object prediction.
Repeatable checks
The measurement tool is now tracked as xenon_tools/analyze_hd_scroll.py.
It requires a 1280×800 video and its .mp4.vbl sidecar:
python xenon_tools\analyze_hd_scroll.py "E:\xenon_runs\hd-highfps-scroll-fixed-20261006-r584.validation\hd-highfps-scroll-fixed-20261006-r584-sprite.mp4" "E:\xenon_runs\hd-highfps-scroll-fixed-20261006-r584.validation\hd-highfps-scroll-fixed-20261006-r584.x2events" --start 2 --seconds 5 --output work\scroll-motion-r584.json
unit-sprite-interpolation exercises constant forward/backward scroll, zero
fine scroll and absent top-row quads, background wrap, stops/reversals, paused
repaints, scene changes, skipped frames and resets. Assertions remain active in
Release builds. node web/tests/sprite-quad.mjs verifies fractional background
offsets and integer door offsets against the actual TypeScript decoder.
Official Release Hatari, native replay DLL, Web replay WASM, Web bundles and browser-game WASM builds passed. The focused C test and Web layout test passed.
Starfield verification after the scroll repair
The user reported similar star jitter. Verification on 2026-10-06 confirms that it remains in the same r584 sprite video listed above. No starfield rendering or game-control changes were made during this investigation.
The five-second sample starts at video time 2 seconds, preserving all 300
native video frames. Star slot 5, record address 0x3D1FA, has fixed logical
X=97 (output X=388) and colour index 4. Tracking its four-pixel-high rendered
square gives these unambiguous consecutive-frame jumps:
| Sprite-video time | VBL | Centre Y before → after, output pixels |
|---|---|---|
| 2.598377 s | 14526 | 544.5 → 549.5 |
| 4.530504 s | 14642 | 672.5 → 677.5 |
| 5.196754 s | 14682 | 716.5 → 721.5 |
| 5.796380 s | 14718 | 756.5 → 761.5 |
Both frames in each pair have the complete four-row star footprint. Other tracking samples can be contaminated by passing sprites/background colours or compression, so they are not used to claim large jumps or reverse motion.
ReVa's /mydumpat0 and the recording's fresh start.ram agree on the original
code: Starfield_UpdateAndDraw_48Stars at $2B1A loads the applied camera
delta from $CD8, then calls Starfield_UpdateVerticalPositions at $2ABA.
Each six-byte star record at $3D1DC + 6*i contains:
- word 0: framebuffer byte offset, giving integer Y via division by 160;
- word 1: horizontal pixel bitmask;
- word 2: fractional vertical phase, reduced modulo
$180(384).
The update adds delta * (384 + 8*i) to phase, carries whole scanlines into
word 0, and wraps at 192 lines. Thus the continuous rate is
delta * (1 + i/48) logical pixels per game frame. At the measured cadence
(four VBLs per game frame, 4× output), slot 5 should move approximately
1.10417 output pixels per video frame with constant delta +1.
The capture at $2B20 reads words 0 and 1, but explicitly omits phase.
SpriteStreamInterpolation_Extrapolate then estimates a star's rate from
two or three integer positions. It returns to the captured integer anchor
on every real update, discarding the interpolated fraction. This produces
both jumps when a two-scanline source step arrives and small backward
corrections after an overestimate. The generic zero-velocity guard can also
mistake a dithered hold for an actual stop; it is not the cause of the four
jumps above, where the source keeps moving.
A standalone diagnostic fed the captured stars back through the unchanged
production spriteStreamInterpolation.c. Across 299 adjacent output steps
in this window, slot 5 produced 228 × 1.0, 48 × 1.5, 7 × 5.0 and 16 × -0.5
output-pixel deltas before final rasterisation. These are continuous quad
coordinates, not a claim that every fractional reverse step is visible in
the compressed video. The four jumps listed above are visible-video evidence.
Slot 3, which is visible throughout the corresponding logical-frame window,
also reproduces the same pattern, so missing/occluded stars cannot explain
the main defect.
The appropriate repair is to capture each vertical star's existing phase
with its draw, reconstruct Y + phase/384, and use the known per-slot rate
and captured applied delta for held VBLs. Keep integer native drawing when
HIGH FPS is off; preserve wrap, occlusion and the separate perspective-star
mode. This removes position fitting for gameplay stars and needs no new
simulation. It is the star equivalent of preserving the background's
fractional phase, rather than trying to improve averaging of rounded values.
Diagnostic scripts and data from this verification are under ignored
work/: verify_starfield_r584.py, starfield-video-r584.json,
probe_star_interpolation.c, starfield-input.bin,
starfield-production-trace.csv and starfield-jitter-comparison.json.
Implemented star and fixed-point precision (2026-10-06)
Baseline work was committed as cc7deffd before these changes. Presentation
precision is now captured separately from the original integer blit rectangle.
It is applied by the HIGH FPS interpolator, including on real update VBLs;
HIGH FPS off retains the native integer coordinates.
Gameplay stars
At the existing post-update hook $2B20, capture record word 2 as phase/384.
Star slot i advances at appliedCameraDelta * (1+i/48) logical pixels per game
update. Extrapolate from the captured phase at that rate and wrap modulo 192.
This removes all history searches and velocity/acceleration fitting for new
gameplay-star captures, including immediately after an occluded star reappears.
Occlusion still uses the game's native integer framebuffer pixel.
Perspective stars
The old $85B4 entry hook read last frame's depth and visibility. The game
decrements depth by 512, may respawn the star, then plots it in the same loop.
Capture each updated record at $8628, immediately before its native occlusion
test. A0 points six bytes beyond that record. Retain the integer DIVS projection
for authentic geometry, plus the fractional floating projection for presentation.
The recorded motion scalar is -512/z; future offsets from screen center
(160,100) are divided by 1-512*t/z. Clamp depth at the 512-unit near plane;
random respawns are accepted from the next actual capture, never guessed.
The $85B4 hook only establishes the existing starfield identity mode.
This was checked against the common-code ReVa program /mydumpat0, including
the post-update store at $85EC, occlusion branch at $8638, hidden-depth
marker at $8670, and loop at $8676.
Fixed-point objects
The high words at object offsets $20/$24 remain native integer X/Y. For
verified 16.16 movement handlers, add the unsigned low words at $22/$26,
divided by 65536, to presentation coordinates before fitting motion. This also
handles negative positions: high word -1 plus low word $C000 equals -0.25.
Recover and reapply sprite origins as before; a changing animation origin is
not movement.
src/includes/spriteStreamPrecision.h contains the explicit handler allowlist:
common directional projectiles and scripted sine handlers, Level 1/2 formation
leaders, Level 2 scripted formations, Level 3 scripted/radial/composite families
and reflecting projectiles, Level 4 scripted sine enemies, and Level 5 path
trains. Level-overlay handlers are guarded by current level. Unknown handlers,
integer objects, attachment offsets, and synthetic prepared composite draws
are not assumed to contain fractions. The common $4180 and $9ABE/$9ACA
movement format was rechecked with ReVa; the overlay list follows the existing
C script predictor's confirmed fixed-point inputs.
No autopilot or collision model changed. RAM, integer captured draw X/Y,
object observations, XapObservedDraw rectangles and all planner inputs retain
their existing values. Rendering never writes these fractions back into RAM.
Recording and Web compatibility
Protocol 18 retains the previous 38-byte draw prefix and appends three network
float32 values: fraction_x, fraction_y, star_motion (50 bytes per draw).
Frame capture flag 0x10 advertises this layout; draw flag 0x20 declares that
its precision is meaningful. These values are display metadata and do not enter
the C planner's observed geometry. The Python decoder, native C replay decoder,
WASM replay bridge and authoritative Web quad parser were updated together.
Historical recordings remain readable and retain the legacy motion fallback.
The render quad is 56 bytes: fractions at offsets 48/52, star motion at offset 36 in the flat-color union. Native and TypeScript layout checks cover these offsets. Integer texture row offsets and fractional background offsets keep their existing meanings.
Verification
All runs used visible Release Hatari, resident C control, HD sprites, HIGH FPS, 1280x800 sprite MP4 plus authentic MP4, QSV H.264 and mono AAC, and graceful stop.
Run folder under E:/xenon_runs/ |
Result |
|---|---|
hd-stars-fixed-20261006-r585.validation |
241 overlapping observations identical to r584, including controls, objects, shield, lives, score and scroll. No backwards jumps in tracked star slots 3 and 5 over the five-second sample. Slot 5's former 5-output-pixel steps disappeared; remaining pixel-footprint variation includes rasterization and partial background occlusion. |
hd-perspective-interstitial-20261006-r586.validation |
GET READY checkpoint at frame 51280 captured 3,178 post-update perspective-star draws, followed by gameplay stars. Every frame decoded in Web/WASM replay; checkpoint restore reproduced 50 frames. |
hd-fixed-objects-20261006-r587.validation |
All 240 actively controlled frames (176–415) match r584 in controls, RAM-derived telemetry, complete object observations, shield, lives, score and scroll. 1,408 precise textured draws, including 74 directional-projectile draws; 1,185 have nonzero fractions. Zero damage, lost lives or stalls. DLL live status matches 240/240; WASM and native replay controls match 240/240. |
The final r587 record is after input release; its control differs from the still-controlled baseline shutdown record. This is validation cleanup, not a gameplay decision difference. The ordinary pending-input parity check likewise reports this final release boundary; the recorded native decision status agrees.
Both video files and .x2events use the run-folder basename. Gameplay/star
diagnostics are in ignored work/ (starfield-video-r585.json,
fixed-object-gameplay-comparison.json). Unit coverage includes all 48 star
speeds in forward/backward/stopped scrolling, wrap, perspective depth/respawn,
negative fixed-point coordinates, preserved origins, overlay rejection,
precision record decoding and clearing decoder metadata for older recordings.
The desktop, native DLL, game WASM, replay WASM and Web bundles were rebuilt.