feat(video): blend colors the VIC cannot draw by alternating two it can - #291
Draft
kfox wants to merge 1 commit into
Draft
feat(video): blend colors the VIC cannot draw by alternating two it can#291kfox wants to merge 1 commit into
kfox wants to merge 1 commit into
Conversation
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 Report❌ Patch coverage is 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. |
kfox
marked this pull request as draft
August 15, 2026 19:34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
[color].flicker_blend— hires holds two screen pages over one shared bitmap and flips$D018between 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_deltacaps 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:
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:
Any threshold inside [0.0155, 0.1214] classifies all six correctly. The default sits mid-gap at 0.075;
MAX_ALLOWED_LUMA_DELTA = 0.12sits just under the onset, so the knob's entire range stays below the lowest delta actually observed to flicker, and pastWARN_LUMA_DELTA = 0.10the arming path says so.tests/test_flicker_blend.pypins 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.pyregisters anon_palette_changelistener 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:
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.mdleads 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 checkgreen (ruff,mypy --strict21 files, pyright 0 errors, 4116 tests),make site-check39 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:
host_palette = auto -> u64and 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,skippedsteady, no dropped-frame growth.SCENE_CONFIG_JSONcarries 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.