Xenon 2

How it works · write-up

High-FPS shop jitter: confirmed capture-slot initialization regression

xenondoc/HIGH_FPS_SHOP_JITTER_20261006.MD · 10 KB · updated 2026-10-06

Investigated on 2026-10-06 after the two complete campaigns at commit d551bf55317eb7992ef54e2d2295620bc71cded2. The subsequent repair initializes the missing fields in the scaled draw constructor; validation is recorded below.

Reproduction and evidence

Recording folder: E:/xenon_runs/ffmpeg-full-hd-highfps-20261006-r589-1.validation.

  • Canonical observations: ffmpeg-full-hd-highfps-20261006-r589-1.x2events.
  • HD video: ffmpeg-full-hd-highfps-20261006-r589-1-sprite.mp4.
  • Authentic video: ffmpeg-full-hd-highfps-20261006-r589-1-original.mp4.
  • HD sprite dimensions are 1280×800, four times native coordinates per axis.
  • First shop observations span frames 2038–2916; second shop spans 6292–7283.
  • In the HD video, inspect approximately 02:17–02:20 (VBL 21900 onward).

The shop chrome's native positions stay constant in all 1871 observations from the first two shops. Its draw entries nevertheless carry variable fractional coordinates and the precision flag that should belong to gameplay objects/stars only.

Chrome entry Native position Observations with nonzero precision Y-fraction range
0xfffd8000, left panel border (192,9) 201 0–0.979167
0xfffd8001, right panel border (304,9) 179 0–0.979167
0xfffd8002, top panel border (192,0) 190 0–0.916667
0xfffd8003, mid-panel divider (192,105) 193 0–0.958333
0xfffd8004, bottom-right decoration (224,182) 177 0–0.979167
0xfffd8005, bottom button bar (0,164) 180 0–0.937500

Concrete example: at canonical frame 2042, VBL 21870, the top border has native (192,0), fraction_x=0, fraction_y=0.8333333, star_motion=1.1666666, and wire flags 36 / 0x24. These are the scaled UI flag (0x04) combined with presentation precision (0x20). The star motion value is also stale: a static shop border has no star motion.

With HIGH FPS on, that border is presented at Y=0.8333333 rather than Y=0, about 3.33 output pixels lower in the 4× HD recording. Other reused slots carry different phases, so separate borders, grid graphics, text and doors shift independently on successive captures. HIGH FPS off uses the integer draw positions and does not expose the stale fractional coordinates.

An independent three-second measurement on the static EXIT button found six vertical jumps among 179 adjacent HD pictures, ranging from −3 to +3 output pixels; all 179 original-video pairs stayed at zero translation. An earlier left-border crop also included animated TV noise and overestimated motion, so the repaired-video comparison uses the static EXIT button instead. The captured integer coordinates also never move while fractional precision changes.

Root cause and exact path

d551bf55 added fractionX, fractionY, starMotion, and hasPresentationPrecision to reusable DrawMaskedSpriteEntry ring slots. It initializes them in DrawCommandStream_PushMaskedSprite and DrawCommandStream_PushFlatColorQuad, but omitted DrawCommandStream_PushMaskedSpriteScaled in src/drawCommandStream.c, starting at line 241.

That scaled pusher obtains the next existing ring entry at line 264 and overwrites its ordinary fields. The four new fields retain whatever the previous occupant stored. The buffer is not freshly allocated for each draw, so a previous star or fixed-point projectile can donate its precision to a completely unrelated shop graphic.

DrawCommandStream_ReplayShopCacheSlot deliberately routes all textured shop entries through that scaled pusher (line 3881), to keep their discrete UI positions fixed between real captures. Its existing hold rule is intact. This is not missing velocity fitting, incorrect shop timing or a shader defect.

SpriteStreamBuild_AppendEntry copies the inherited fractions and sets SPRITE_QUAD_FLAG_PRESENTATION_PRECISION. In SpriteStreamInterpolation_Extrapolate, lines 443–447 add the fractions to the copied position before reaching the isScaledSize hold branch. Holding the position therefore holds the already-contaminated position. Every next capture can inherit a different fraction and visibly jump.

No autopilot or emulated-game state changes are involved. The exact gameplay comparison still passes because the corruption is in host draw metadata.

Correct repair and required validation

Initialize the new fields in DrawCommandStream_PushMaskedSpriteScaled, just as the other two ring-entry pushers already do:

entry->fractionX = entry->fractionY = entry->starMotion = 0.0f;
entry->hasPresentationPrecision = false;

Do this at entry creation, rather than changing the GPU shader, rounding all coordinates, or disabling HIGH FPS globally. Real fixed-point objects and stars must retain their explicitly captured fractions.

The same scaled pusher serves the background mosaic, zoom text, title logo and intro graphics; the initialization repair should cover all callers, with no per-shop workaround. Audit any additional entry-construction paths for the same omission. Existing contaminated .x2events recordings would require an explicit compatibility rule when rebuilt for playback; initialization only fixes newly captured data. Encoded MP4 frames cannot be corrected by rebuilding the recording decoder.

Validation should deliberately reuse a slot previously populated with nonzero star/fixed-point precision, push a scaled UI draw, and check that its precision is cleared. Then record a focused visible HD/HIGH FPS shop run and verify both the draw flags/fractions and consecutive static-border video positions. Check gameplay stars/projectiles still preserve their intended fractional movement.

Analysis artifacts

Ignored, reproducible local analysis tools:

  • work/analyze_shop_precision.py: reads the recorded shop draw fields.
  • work/shop-precision-r589.json: per-element native positions, precision flags, fractional distributions and representative frame examples.
  • work/measure_shop_jitter.py: compares original/HD static-border patches.
  • work/shop-static-jitter-r589.json: static EXIT-button translations and residuals.

Commands from the repository root:

python work/analyze_shop_precision.py E:/xenon_runs/ffmpeg-full-hd-highfps-20261006-r589-1.validation/ffmpeg-full-hd-highfps-20261006-r589-1.x2events --output work/shop-precision-r589.json
python work/measure_shop_jitter.py E:/xenon_runs/ffmpeg-full-hd-highfps-20261006-r589-1.validation --output work/shop-static-jitter-r589.json

Lesson: adding fields to a reused draw record requires auditing every pusher, not only the gameplay paths that set those fields intentionally. A zero-filled fresh-entry test cannot catch stale state inherited across ring-slot reuse.

Repair validation

DrawCommandStream_PushMaskedSpriteScaled now clears the three floating-point precision fields and their presence flag, matching the other two constructors. The audit found no other entry-construction path missing this reset.

python work/test_shop_entry_reuse.py compiles the actual scaled constructor extracted from the old and repaired sources. The old constructor fails after reusing a star/projectile slot; the repaired constructor passes 100 deliberately contaminated draws across a three-slot ring, preserving native UI positions. The existing test-sprite-interpolation.c checks also pass, including vertical and perspective stars and fixed-point object presentation.

The full visible HD/HIGH FPS campaign is recorded under E:/xenon_runs/ffmpeg-full-hd-highfps-shopfix-20261006-r590-1.validation. It completed from frame 0 through spaceship defeat, the complete final shop dialogue and playable Level 1 restarting at frame 51356. Recording finalized at frame 51365, with no cleanup errors. The resident C pilot retained three lives and 33 shield; there were no lost lives or detected stalls. The manifest records the executable and snapshot hashes, Git revision and working-tree status; working-tree.patch retains the repair. The official Release build and native replay DLL were rebuilt before launch.

All six chrome pieces have zero offsets across 10673 shop observations. Across the entire recording, 1317664 scaled draws have no inherited precision, while 1041299 gameplay draws retain their precision flags. The repaired HD EXIT-button sample has zero vertical jumps in all 179 adjacent pairs, matching the original video. The equivalent pre-fix HD sample had six jumps. These checks cover capture metadata and the actual encoded image.

Gameplay comparison against r589 matched input, player/shop state and raw object state in all 51356 campaign frames, through final-shop exit. The sixteen shield-damage incidents also match; this rendering fix adds no gameplay regression and does not remove the existing damage incidents.

The sprite recording is H.264 at 1280×800, with the authentic recording at 640×400. The stored rate is 491827/8192 (about 60.038 FPS). Both recordings were closed through the recording API before the paused Hatari instance exited. Renderer source files were retained in source/capture alongside the normal campaign source archive; the analysis directory contains the slot-reuse probe, shop precision checks, static-video measurements and full gameplay comparison.

The complete MP4 audit passes for both files: 174863 decoded frames each, consecutive VBL indexes, no decoding errors, and identical audible mono AAC tracks at 44100 Hz. Audio/video duration drift is six microseconds. Sprite video size is 2484940539 bytes; authentic video size is 390934525 bytes. The audit is retained as analysis/shopfix-video-audit-r590.json.

Reproduce the repaired video check from the repository root:

python work/measure_shop_jitter.py E:/xenon_runs/ffmpeg-full-hd-highfps-shopfix-20261006-r590-1.validation --output work/shop-static-jitter-r590.json