feat(video): track windows and compose color effects
Add persistent window comparators, region masks, alpha blending, and brightness effects.
This commit is contained in:
parent
fad034ae74
commit
9d14dd7cf4
9 changed files with 1408 additions and 0 deletions
95
src/video/effects.rs
Normal file
95
src/video/effects.rs
Normal 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
133
src/video/windows.rs
Normal 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.
|
||||
}
|
||||
}
|
||||
168
src/video/windows/horizontal.rs
Normal file
168
src/video/windows/horizontal.rs
Normal 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.
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue