feat(video): track windows and compose color effects

Add persistent window comparators, region masks, alpha blending, and brightness effects.
This commit is contained in:
0xrsydn 2026-10-05 12:55:35 +07:00
commit 9d14dd7cf4
9 changed files with 1408 additions and 0 deletions

95
src/video/effects.rs Normal file
View file

@ -0,0 +1,95 @@
//! Window masks and five-bit color arithmetic for snapshots and row capture.
//! Snapshots use half-open, wrapping bounds. Row capture supplies vertical flags
//! and horizontal comparator history. Layer/effect selection remains row-sampled.
use super::Pixel;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Effects {
pub horizontal: [u16; 2],
pub vertical: [u16; 2],
pub inside: u16,
pub outside: u16,
pub control: u16,
pub alpha: u16,
pub brightness: u16,
}
impl Effects {
pub(super) fn mask(
&self,
display: u16,
x: usize,
y: usize,
object: bool,
vertical: Option<[bool; 2]>,
horizontal: Option<&super::windows::horizontal::HorizontalWindows>,
) -> u16 {
if display & 0xe000 == 0 {
return 0x3f;
}
for window in 0..2 {
if display & (0x2000 << window) != 0
&& horizontal.map_or_else(
|| contains(self.horizontal[window], x),
|state| state.pixels[window][x],
)
&& vertical
.map_or_else(|| contains(self.vertical[window], y), |flags| flags[window])
{
return (self.inside >> (window * 8)) & 0x3f;
}
}
if display & 0x9000 == 0x9000 && object {
(self.outside >> 8) & 0x3f
} else {
self.outside & 0x3f
}
}
pub(super) fn apply(&self, top: Pixel, below: Option<Pixel>, enabled: bool) -> u16 {
if !enabled {
return top.color;
}
let mode = (self.control >> 6) & 3;
let first = self.control & (1 << top.layer) != 0;
if top.semi_transparent || (mode == 1 && first) {
if let Some(second) = below.filter(|p| self.control & (0x100 << p.layer) != 0) {
let a = (self.alpha & 31).min(16);
let b = ((self.alpha >> 8) & 31).min(16);
return channels(top.color, second.color, |x, y| {
((x * a + y * b) >> 4).min(31)
});
}
}
// Semi-transparent OBJ falls back to a selected brightness effect only
// when no eligible second target is directly below it.
if first && mode >= 2 {
let coefficient = (self.brightness & 31).min(16);
return channels(top.color, 0, |x, _| {
if mode == 2 {
x + (((31 - x) * coefficient) >> 4)
} else {
x - ((x * coefficient) >> 4)
}
});
}
top.color
}
}
fn contains(bounds: u16, coordinate: usize) -> bool {
let start = usize::from(bounds >> 8);
let end = usize::from(bounds & 255);
if start > end {
coordinate >= start || coordinate < end
} else {
(start..end).contains(&coordinate)
}
}
fn channels(first: u16, second: u16, operation: impl Fn(u16, u16) -> u16) -> u16 {
[0, 5, 10].into_iter().fold(0, |color, shift| {
color | (operation((first >> shift) & 31, (second >> shift) & 31) << shift)
})
}

133
src/video/windows.rs Normal file
View file

@ -0,0 +1,133 @@
//! Vertical WIN0/WIN1 comparators. State survives frame wrap and register writes.
//! Horizontal comparators retain per-column history separately from vertical state.
pub(crate) mod horizontal;
use crate::display::{CYCLES_PER_LINE, LINES_PER_FRAME};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct WindowEdges {
pub active: [bool; 2],
started: bool,
}
impl WindowEdges {
/// Evaluate each crossed line start, including hidden lines. With fixed bounds,
/// only the latest matching edge matters, so large clock batches remain O(1).
pub fn advance(&mut self, phase: u32, cycles: u32, bounds: [u16; 2]) {
if cycles == 0 {
return;
}
if !self.started {
// Allow host setup before the first clock advance at reset's line zero.
// Later writes at an already-entered line never replay its comparator.
let row = phase / CYCLES_PER_LINE;
for (window, bounds) in bounds.into_iter().enumerate() {
if row == u32::from(bounds >> 8) {
self.active[window] = true;
}
if row == u32::from(bounds & 255) {
self.active[window] = false;
}
}
self.started = true;
}
let first = u64::from(phase / CYCLES_PER_LINE) + 1;
let last = (u64::from(phase) + u64::from(cycles)) / u64::from(CYCLES_PER_LINE);
let period = u64::from(LINES_PER_FRAME);
let latest = |row: u16| {
if u32::from(row) >= LINES_PER_FRAME {
return None; // VCOUNT never reaches values 228..255.
}
let distance = (last + period - u64::from(row)) % period;
last.checked_sub(distance).filter(|&edge| edge >= first)
};
for (window, bounds) in bounds.into_iter().enumerate() {
let top = latest(bounds >> 8);
let bottom = latest(bounds & 255);
match (top, bottom) {
(Some(top), Some(bottom)) => self.active[window] = top > bottom,
(Some(_), None) => self.active[window] = true,
(None, Some(_)) => self.active[window] = false,
(None, None) => {} // No edge: retain state, even across frames.
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::display::CYCLES_PER_FRAME;
fn step(active: &mut bool, bounds: u16, row: u32) {
if row == u32::from(bounds >> 8) {
*active = true;
}
if row == u32::from(bounds & 255) {
*active = false;
}
}
#[test]
fn all_bounds_match_independent_edge_iteration_across_two_frames() {
for bounds in 0..=u16::MAX {
let mut edges = WindowEdges::default();
let mut expected = false;
for row in 0..=LINES_PER_FRAME * 2 {
step(&mut expected, bounds, row % LINES_PER_FRAME);
}
edges.advance(0, 2 * CYCLES_PER_FRAME, [bounds; 2]);
assert_eq!(edges.active, [expected; 2], "bounds={bounds:#06x}");
}
}
#[test]
fn random_batches_and_extreme_batch_match_independent_edge_iteration() {
let mut seed = 0x60c3_273bu32;
for case in 0..500 {
let mut next = || {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
seed
};
let bounds = [next() as u16, next() as u16];
let phase = next() % CYCLES_PER_FRAME;
let cycles = if case == 0 {
u32::MAX
} else {
next() % (7 * CYCLES_PER_FRAME)
};
let mut edges = WindowEdges {
active: [next() & 4 != 0, next() & 8 != 0],
started: true,
};
let mut expected = edges.active;
for boundary in u64::from(phase / CYCLES_PER_LINE) + 1
..=(u64::from(phase) + u64::from(cycles)) / u64::from(CYCLES_PER_LINE)
{
for window in 0..2 {
step(
&mut expected[window],
bounds[window],
(boundary % u64::from(LINES_PER_FRAME)) as u32,
);
}
}
edges.advance(phase, cycles, bounds);
assert_eq!(edges.active, expected, "phase={phase} cycles={cycles}");
}
}
#[test]
fn zero_cycles_and_writes_do_not_replay_an_entered_line() {
let mut edges = WindowEdges::default();
edges.advance(0, 0, [0x0010, 0x0020]);
assert!(!edges.started);
edges.advance(0, 1, [0x0010, 0x0020]);
assert_eq!(edges.active, [true; 2]);
edges.advance(1, CYCLES_PER_LINE - 1, [0x0110, 0x0101]);
assert_eq!(edges.active, [true, false]); // Bottom wins when edges coincide.
edges.advance(CYCLES_PER_LINE, 1, [0x0101, 0x0110]);
assert_eq!(edges.active, [true, false]); // The row1 comparator already ran.
}
}

View file

@ -0,0 +1,168 @@
//! Horizontal comparator history only, not pixel-fetch or color-register timing.
//! Sample X=0..255 at line cycles 0,4,..1020; right-edge matches clear after left.
use crate::{display::CYCLES_PER_LINE, video::WIDTH};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HorizontalWindows {
active: [bool; 2],
pub pixels: [[bool; WIDTH]; 2],
}
impl Default for HorizontalWindows {
fn default() -> Self {
Self {
active: [false; 2],
pixels: [[false; WIDTH]; 2],
}
}
}
impl HorizontalWindows {
/// Consume the half-open clock interval [phase, phase+cycles). A write at
/// cycle 4*x therefore precedes X's comparison; a write at 4*x+1 is too late.
/// Process at most three line segments, even across arbitrarily many frames.
pub fn advance(&mut self, phase: u32, cycles: u32, bounds: [u16; 2]) {
if cycles == 0 {
return;
}
let start = phase % CYCLES_PER_LINE;
let end = u64::from(start) + u64::from(cycles);
let last_line = (end - 1) / u64::from(CYCLES_PER_LINE);
if last_line == 0 {
self.segment(start, end as u32, bounds);
return;
}
self.segment(start, CYCLES_PER_LINE, bounds);
if last_line > 1 {
if last_line > 2 {
// Two or more full intervening lines: both edges have fired,
// so the last full line starts in the stable wrapping state.
self.active = bounds.map(|b| (b >> 8) > (b & 255));
}
// Retain unvisited pixels from the previous line, just as repeated
// single-cycle advancement does. Never clear history on line entry.
self.segment(0, CYCLES_PER_LINE, bounds);
}
let tail = ((end - 1) % u64::from(CYCLES_PER_LINE) + 1) as u32;
self.segment(0, tail, bounds);
}
fn segment(&mut self, start: u32, end: u32, bounds: [u16; 2]) {
let first = start.div_ceil(4).min(256) as usize;
let last = end.div_ceil(4).min(256) as usize;
if first == last {
return;
}
for (window, bounds) in bounds.into_iter().enumerate() {
let left = usize::from(bounds >> 8);
let right = usize::from(bounds & 255);
let edges = if left <= right {
[(left, true), (right, false)]
} else {
[(right, false), (left, true)]
};
let mut from = first;
for (edge, state) in edges {
if (first..last).contains(&edge) {
self.pixels[window][from.min(WIDTH)..edge.min(WIDTH)].fill(self.active[window]);
self.active[window] = state;
from = edge;
}
}
self.pixels[window][from.min(WIDTH)..last.min(WIDTH)].fill(self.active[window]);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reference(state: &mut HorizontalWindows, start: u32, cycles: u32, bounds: [u16; 2]) {
// Independent dot iteration, including offscreen columns and hidden lines.
let end = u64::from(start) + u64::from(cycles);
let mut tick = u64::from(start.div_ceil(4)) * 4;
while tick < end {
let x = (tick % u64::from(CYCLES_PER_LINE)) / 4;
if x < 256 {
for (window, bounds) in bounds.into_iter().enumerate() {
if x == u64::from(bounds >> 8) {
state.active[window] = true;
}
if x == u64::from(bounds & 255) {
state.active[window] = false;
}
if x < WIDTH as u64 {
state.pixels[window][x as usize] = state.active[window];
}
}
}
tick += 4;
}
}
#[test]
fn all_bounds_match_dot_iteration_for_startup_and_wrapped_next_line() {
for bounds in 0..=u16::MAX {
let mut actual = HorizontalWindows::default();
let mut expected = actual.clone();
for phase in [0, CYCLES_PER_LINE] {
actual.advance(phase, CYCLES_PER_LINE, [bounds; 2]);
reference(&mut expected, phase, CYCLES_PER_LINE, [bounds; 2]);
assert_eq!(actual, expected, "bounds={bounds:#x} phase={phase}");
}
}
}
#[test]
fn arbitrary_partial_lines_and_bound_writes_match_dot_iteration() {
let mut seed = 0x371b_c321u32;
let mut next = || {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
seed
};
let mut actual = HorizontalWindows::default();
let mut expected = actual.clone();
let mut phase = 0;
for _ in 0..2000 {
let bounds = [next() as u16, next() as u16];
let cycles = next() % (9 * CYCLES_PER_LINE);
actual.advance(phase, cycles, bounds);
reference(&mut expected, phase, cycles, bounds);
assert_eq!(
actual, expected,
"phase={phase} cycles={cycles} bounds={bounds:?}"
);
phase = (phase + cycles) % (228 * CYCLES_PER_LINE);
}
}
#[test]
fn largest_batch_has_same_state_as_short_batch_with_same_final_phase() {
for start in [0, 1, 79, 955, 1006, 1021, 1231] {
let mut actual = HorizontalWindows::default();
let mut expected = actual.clone();
let cycles = u32::MAX;
// After several complete fixed-bound lines all earlier history is replaced.
let short = 4 * CYCLES_PER_LINE + cycles % CYCLES_PER_LINE;
actual.advance(start, cycles, [0xf00a, 0x1428]);
reference(&mut expected, start, short, [0xf00a, 0x1428]);
assert_eq!(actual, expected);
}
}
#[test]
fn zero_cycles_do_not_sample_or_replay_edges() {
let mut state = HorizontalWindows::default();
state.advance(0, 0, [0x0010; 2]);
assert_eq!(state, HorizontalWindows::default());
state.advance(0, 1, [0x0010; 2]);
assert!(state.active[0] && state.pixels[0][0]);
let before = state.clone();
state.advance(1, 0, [0; 2]);
assert_eq!(state, before);
state.advance(1, 3, [0; 2]);
assert_eq!(state, before); // X0 already passed, X1 has not started.
}
}