Skip to content

feat(video): blend colors the VIC cannot draw by alternating two it can - #291

Draft
kfox wants to merge 1 commit into
mainfrom
flicker-eligibility
Draft

feat(video): blend colors the VIC cannot draw by alternating two it can#291
kfox wants to merge 1 commit into
mainfrom
flicker-eligibility

Conversation

@kfox

@kfox kfox commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Adds [color].flicker_blend — hires holds two screen pages over one shared bitmap and flips $D018 between them every video field, so the eye fuses each cell's pair of hardware colors into a shade the VIC cannot draw. Off by default.

The alternation is owned by a C64-side raster IRQ and free-runs at the VIC field rate whatever the host is doing, so it needs no unusual link speed, no REU and no sampler — the host just uploads the pair. Both fields share the bitmap (a differing mask would flicker geometry), so a frame costs one extra 1000-byte page: ≈26.0 ms against 20.8 ms on the Ultimate link.

Eligibility is an absolute luminance delta, not a contrast ratio

This is the part worth reviewing. flicker_max_luma_delta caps how far apart in brightness a pair may be, in linear light as a fraction of peak white.

A contrast ratio was the obvious first choice and it is wrong in the one place it matters. Michelson divides by the pair's own mean brightness, so it is maximally pessimistic exactly where the eye is least sensitive:

pair ΔY Michelson old rule new rule
Black + Dark Gray 0.069 1.000 refused eligible
Black + Blue 0.053 1.000 refused eligible
Black + Brown 0.051 1.000 refused eligible
Cyan + Yellow 0.263 0.194 eligible refused
White + Light Green 0.218 0.133 eligible refused

Black against anything scores 1.0 by construction, so the darkest pairs — which fuse best of all — could never qualify at any setting. In the other direction it admitted Cyan+Yellow, which on an Ultimate 64 is 0.26 ΔY, as hard a flicker as anything on the test chart. Against the emitted palette the two rules agree on only 9 of ~20 pairs.

Weber contrast and a Ferry-Porter frequency term were tried against the same bands and both degraded the separation; a chroma-swing term did too, which is the expected result — chroma flicker fuses at a far lower rate than luminance flicker, so it is not the binding constraint.

Where the threshold comes from

Six candidate pairs rendered as full-width flat bands and judged by eye on a CRT:

pair ΔY on the display
Medium Gray + Light Blue 0.0002 solid
Red + Dark Gray 0.0110 solid
Blue + Dark Gray 0.0155 solid
White + Yellow 0.1214 flickers slightly
Light Green + Light Gray 0.2669 flickers most
Orange + Light Gray 0.3229 flickers

Any threshold inside [0.0155, 0.1214] classifies all six correctly. The default sits mid-gap at 0.075; MAX_ALLOWED_LUMA_DELTA = 0.12 sits just under the onset, so the knob's entire range stays below the lowest delta actually observed to flicker, and past WARN_LUMA_DELTA = 0.10 the arming path says so. tests/test_flicker_blend.py pins those six bands, so the rule cannot drift away from what the display did.

Eligibility is per machine

ΔY is measured against the active palette, so this follows [hardware].host_palette (#290): what fuses is a statement about the light one machine emits, not about "the C64 palette". The VIC-II rendering gives 18 eligible pairs, an Ultimate 64 gives 21, and they agree on 10. flicker.py registers an on_palette_change listener rather than computing its tables at import — a stale table would admit pairs that flicker on the machine in front of you, which is the single failure this module exists to prevent.

What it buys

Gradient banding, not a general palette upgrade — spatial dither already synthesizes intermediate colors wherever there is texture to hide them in. Mean Lab error against the plain path, perceptual metric:

content VIC-II palette Ultimate 64 palette
chromatic gradient (blue→cyan) −33.8 % −26.8 %
vertical dusk gradient −20.4 % −14.8 %
luminance ramp (black→white) −9.1 % −15.7 %
warm sky gradient −8.2 % −1.0 %
soft radial glow −1.8 % −0.5 %
photograph −1.3 % −0.9 %

Safety

A blended area alternates at 25 Hz (PAL) / 30 Hz (NTSC), inside the recognized photosensitive-seizure band, which is why the feature is opt-in against the project's usual "quality on by default" preference, why the cap is clamped rather than advisory, and why docs/caveats.md leads with it. It also does not survive a 30 fps capture — a card records the fields, not the fusion — though the preview and [recording] paths reconstruct from the write stream and so show the fused result with no flicker at all.

Verification

Offline: make check green (ruff, mypy --strict 21 files, pyright 0 errors, 4116 tests), make site-check 39 pages. The 6502 handler is exercised by executing it under py65 rather than comparing bytes — the phase gate that keeps a bank swap from transposing the A/B page roles is not something a byte comparison can check.

On an Ultimate 64, hires slideshow at the default 0.075:

  • resolved host_palette = auto -> u64 and armed 21 blend pairs, 37 effective colors — matching the offline prediction pair for pair, including the three black pairs the old rule could not reach.
  • errors=0.00/s, skipped steady, no dropped-frame growth.
  • Sampling the display at field resolution over 12 s gives 710 consecutive runs of length 1 — strict field-by-field alternation between exactly two states, with no drift and no aliasing.
  • The two fields carry the intended pairs (Purple+Orange, Purple+Brown, Brown+Dark Gray, Black+Dark Gray) over identical geometry, and averaging them in linear light gives the plum, warm-grey and extra dark-ramp step none of which the VIC can draw.
  • SCENE_CONFIG_JSON carries the renamed field.

Still eyes-on: whether 0.075 reads as color rather than flicker across a range of content on a real display, which is a judgment the capture path cannot make.

Hold two screen pages over one shared bitmap and flip $D018 between them
every video field, so the eye fuses each cell's pair of hardware colors
into an intermediate shade — the Dragon Breed / Mayhem in Monsterland
trick, behind [color].flicker_blend.

The alternation is owned by a C64-side raster IRQ and free-runs at the VIC
field rate whatever the host is doing, so this needs no unusual link speed,
no REU and no sampler. Both fields share the bitmap — a differing mask
would flicker geometry rather than color — so a frame costs one extra
1000-byte page, ~26.0 ms against 20.8 ms on the Ultimate link.

What it fixes is gradient banding rather than the palette generally:
spatial dither already synthesizes intermediate colors wherever there is
texture to hide them in, so a chromatic gradient gains 27-34% and a
photograph ~1%. It requires the perceptual metric and forces it — blends
are defined by linear-light fusion and Lab-measured gaps, and fitting cells
in weighted BGR scores the widened palette WORSE than the 16 solids.

Off by default. A blended area alternates at 25 Hz (PAL) / 30 Hz (NTSC),
inside the recognized photosensitive-seizure band, so flicker_max_luma_delta
caps how far apart in brightness a pair may be: the quantity that governs
both the hazard and whether a pair reads as color instead of flicker.

That cap is an absolute luminance delta rather than a contrast ratio, and
the difference is not cosmetic. A ratio divides by the pair's own mean
brightness, so it is maximally pessimistic exactly where the eye is least
sensitive — black against anything scores 1.0, which made the darkest and
best-fusing pairs unreachable at any setting — while admitting pairs 0.26
apart that flicker hard. The threshold itself is measured: candidate pairs
were rendered as flat bands and judged on a CRT, everything up to 0.0155
read as solid and the first visible flicker was at 0.1214, so the default
sits mid-gap at 0.075 and the hard clamp sits just under the onset.

Eligibility follows [hardware].host_palette, since what fuses is the light
one machine emits: the VIC-II rendering and an Ultimate 64 agree on only
about half the eligible set, so the tables rebuild on a palette swap rather
than being computed once at import.

It also does not survive a 30 fps capture, which records fields rather than
the fusion; the preview and recording paths reconstruct from the write
stream and so show the fused result with no flicker at all.

The handler is exercised by executing it under py65 rather than comparing
bytes — the phase gate that keeps a bank swap from transposing the A/B page
roles is not something a byte comparison can check.
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.55556% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.26%. Comparing base (68369ac) to head (7ea26cd).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
c64cast/video/flicker.py 94.38% 4 Missing and 1 partial ⚠️
c64cast/video/modes/hires.py 91.66% 2 Missing and 3 partials ⚠️
c64cast/scenes/scenes.py 0.00% 3 Missing ⚠️
c64cast/video/framebuffer.py 90.47% 1 Missing and 1 partial ⚠️
c64cast/video/modes/bitmap.py 90.00% 1 Missing and 1 partial ⚠️
c64cast/video/modes_irq.py 83.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #291      +/-   ##
==========================================
+ Coverage   83.19%   83.26%   +0.07%     
==========================================
  Files         148      149       +1     
  Lines       26485    26693     +208     
  Branches     3860     3884      +24     
==========================================
+ Hits        22033    22225     +192     
- Misses       3609     3619      +10     
- Partials      843      849       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@kfox
kfox marked this pull request as draft August 15, 2026 19:34
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.

1 participant