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.
|
||||
}
|
||||
}
|
||||
61
tests/effects.rs
Normal file
61
tests/effects.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use gba_rust::{
|
||||
io::*,
|
||||
memory::{Memory, MemoryError, OAM_START as OAM, PALETTE_START as PAL, VRAM_START as VRAM},
|
||||
video::{rgb555_to_rgb888 as rgb, Framebuffer, HEIGHT, WIDTH},
|
||||
};
|
||||
|
||||
#[path = "effects/colors.rs"]
|
||||
mod colors;
|
||||
#[path = "effects/demo.rs"]
|
||||
mod demo;
|
||||
#[path = "effects/windows.rs"]
|
||||
mod windows;
|
||||
|
||||
fn setup() -> Memory {
|
||||
let mut m = Memory::new(vec![]).unwrap();
|
||||
m.write16(DISPCNT, 0x700).unwrap();
|
||||
m.write16(PAL, 0x4210).unwrap();
|
||||
for (bg, color) in [0x001f, 0x03e0, 0x7c00].into_iter().enumerate() {
|
||||
let bg = bg as u32;
|
||||
m.write16(PAL + (bg + 1) * 2, color).unwrap();
|
||||
m.write16(BG0CNT + bg * 2, (((24 + bg) << 8) | (bg << 2) | bg) as u16)
|
||||
.unwrap();
|
||||
for offset in (0..32).step_by(2) {
|
||||
m.write16(VRAM + bg * 0x4000 + offset, (bg as u16 + 1) * 0x1111)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for obj in 0..128 {
|
||||
m.write16(OAM + obj * 8, 0x200).unwrap();
|
||||
}
|
||||
for offset in (0..32).step_by(2) {
|
||||
m.write16(VRAM + 0x10000 + offset, 0x1111).unwrap();
|
||||
}
|
||||
m.write16(PAL + 0x202, 0x7fff).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
fn render(m: &Memory) -> Framebuffer {
|
||||
let mut f = Framebuffer::default();
|
||||
m.render_frame(&mut f).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
fn pixel(m: &Memory, x: usize, y: usize) -> u32 {
|
||||
render(m).pixels()[y * WIDTH + x]
|
||||
}
|
||||
|
||||
fn object(m: &mut Memory, index: u32, a: u16, b: u16, c: u16) {
|
||||
m.write16(OAM + index * 8, a).unwrap();
|
||||
m.write16(OAM + index * 8 + 2, b).unwrap();
|
||||
m.write16(OAM + index * 8 + 4, c).unwrap();
|
||||
}
|
||||
|
||||
fn window(m: &mut Memory, h: u16, v: u16, inside: u16, outside: u16) {
|
||||
m.write16(DISPCNT, m.read16(DISPCNT).unwrap() | 0x2000)
|
||||
.unwrap();
|
||||
m.write16(WIN0H, h).unwrap();
|
||||
m.write16(WIN0V, v).unwrap();
|
||||
m.write16(WININ, inside).unwrap();
|
||||
m.write16(WINOUT, outside).unwrap();
|
||||
}
|
||||
172
tests/effects/colors.rs
Normal file
172
tests/effects/colors.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn alpha_uses_five_bit_channels_clamps_coefficients_and_saturates_sums() {
|
||||
let mut m = setup();
|
||||
m.write16(BLDCNT, 0x0241).unwrap(); // BG0 with BG1.
|
||||
for (coefficients, expected) in [
|
||||
(0, 0),
|
||||
(0x0808, 0x01ef),
|
||||
(0x0010, 31),
|
||||
(0x1000, 0x3e0),
|
||||
(0x1f1f, 0x3ff),
|
||||
] {
|
||||
m.write16(BLDALPHA, coefficients).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(expected));
|
||||
}
|
||||
m.write16(PAL + 2, 0x7fff).unwrap();
|
||||
m.write16(PAL + 4, 0x7fff).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x7fff));
|
||||
m.write16(PAL + 2, 1).unwrap();
|
||||
m.write16(PAL + 4, 1).unwrap();
|
||||
m.write16(BLDALPHA, 0x0808).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(1)); // Sum fractional terms before rounding.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_requires_top_first_target_and_immediate_second_target() {
|
||||
let mut m = setup();
|
||||
m.write16(BLDALPHA, 0x0808).unwrap();
|
||||
for control in [0x0441, 0x0242, 0x0142, 0x2041, 0x0240] {
|
||||
m.write16(BLDCNT, control).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(31), "control={control:x}");
|
||||
}
|
||||
m.write16(BLDCNT, 0x0441).unwrap();
|
||||
m.write16(VRAM + 0x4000, 0).unwrap(); // Transparent BG1 exposes BG2.
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x3c0f));
|
||||
assert_eq!(pixel(&m, 4, 0), rgb(31));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_remove_layers_before_selecting_blend_targets() {
|
||||
let mut m = setup();
|
||||
m.write16(BLDALPHA, 0x0808).unwrap();
|
||||
m.write16(BLDCNT, 0x0441).unwrap();
|
||||
window(&mut m, 0x0008, 0x0008, 0x25, 0x3f); // Hide BG1 only inside.
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x3c0f));
|
||||
assert_eq!(pixel(&m, 8, 0), rgb(31));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_uses_selected_top_layer_and_clamped_write_only_coefficient() {
|
||||
let mut m = setup();
|
||||
m.write16(PAL + 2, 0x4210).unwrap();
|
||||
for (control, coefficient, expected) in [
|
||||
(0x81, 0, 0x4210),
|
||||
(0x81, 8, 0x5ef7),
|
||||
(0x81, 31, 0x7fff),
|
||||
(0xc1, 8, 0x2108),
|
||||
(0xc1, 16, 0),
|
||||
(0xc1, 31, 0),
|
||||
(0x82, 16, 0x4210),
|
||||
(0x01, 16, 0x4210),
|
||||
] {
|
||||
m.write16(BLDCNT, control).unwrap();
|
||||
m.write32(BLDY, coefficient).unwrap();
|
||||
assert_eq!(m.read16(BLDY).unwrap(), 0);
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(expected));
|
||||
}
|
||||
m.write16(BLDCNT, 0x81).unwrap();
|
||||
m.write8(BLDY, 8).unwrap();
|
||||
m.write8(BLDY + 1, 0xff).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x5ef7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backdrop_is_a_target_but_cannot_blend_with_itself() {
|
||||
let mut m = setup();
|
||||
m.write16(DISPCNT, 0).unwrap();
|
||||
m.write16(BLDCNT, 0x2060).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x4210));
|
||||
m.write16(BLDCNT, 0xa0).unwrap();
|
||||
m.write16(BLDY, 16).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x7fff));
|
||||
m.write16(DISPCNT, 0x100).unwrap();
|
||||
m.write16(BLDCNT, 0x2041).unwrap();
|
||||
m.write16(BLDALPHA, 0x1000).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x4210)); // Backdrop is not pre-brightened.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_can_be_first_or_second_target_but_never_blends_with_another_object() {
|
||||
let mut m = setup();
|
||||
m.write16(DISPCNT, 0x1100).unwrap();
|
||||
object(&mut m, 0, 0, 0, 0);
|
||||
object(&mut m, 1, 0, 0, 0xc00); // Lower priority; never a second OBJ target.
|
||||
m.write16(BLDALPHA, 0x0808).unwrap();
|
||||
m.write16(BLDCNT, 0x0150).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x3dff)); // White OBJ + red BG0.
|
||||
m.write16(BLDCNT, 0x1050).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x7fff)); // No OBJ-to-OBJ blend.
|
||||
object(&mut m, 0, 0, 0, 0x400); // OBJ0 below BG0; later OBJ still excluded.
|
||||
m.write16(BLDCNT, 0x1041).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x3dff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semi_transparent_obj_overrides_mode_and_first_target_selection() {
|
||||
let mut m = setup();
|
||||
m.write16(DISPCNT, 0x1100).unwrap();
|
||||
object(&mut m, 0, 0x400, 0, 0);
|
||||
m.write16(BLDALPHA, 0x0808).unwrap();
|
||||
m.write16(BLDY, 16).unwrap();
|
||||
for mode in 0..4 {
|
||||
m.write16(BLDCNT, 0x100 | (mode << 6)).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x3dff));
|
||||
}
|
||||
window(&mut m, 0x0004, 0x0008, 0x11, 0x31);
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x7fff)); // Window can disable forced alpha.
|
||||
assert_eq!(pixel(&m, 4, 0), rgb(0x3dff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semi_transparent_obj_falls_back_to_selected_brightness_without_second_target() {
|
||||
let mut m = setup();
|
||||
m.write16(DISPCNT, 0x1100).unwrap();
|
||||
object(&mut m, 0, 0x400, 0, 0);
|
||||
m.write16(BLDY, 16).unwrap();
|
||||
for (control, expected) in [(0xc0, 0x7fff), (0xd0, 0), (0x10, 0x7fff), (0x1d0, 0)] {
|
||||
m.write16(BLDCNT, control).unwrap();
|
||||
// Last case has a second target; zero alpha coefficients produce black.
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(expected));
|
||||
}
|
||||
m.write16(BLDALPHA, 0x0808).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x3dff)); // Alpha takes precedence over brightness.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_effects_work_in_affine_and_all_bitmap_modes() {
|
||||
for mode in 1..=5 {
|
||||
let mut m = setup();
|
||||
m.write16(DISPCNT, 0x400 | mode).unwrap();
|
||||
m.write16(BG2CNT, 0).unwrap();
|
||||
m.write16(BG2PA, 256).unwrap();
|
||||
m.write16(BG2PD, 256).unwrap();
|
||||
if mode <= 2 {
|
||||
m.write16(BG2CNT, 0x1000).unwrap(); // Byte map at 0x8000, tile0 at zero.
|
||||
m.write16(VRAM + 0x8000, 0).unwrap();
|
||||
m.write16(VRAM, 0x0101).unwrap();
|
||||
} else {
|
||||
m.write16(VRAM, if mode == 4 { 0x0101 } else { 31 })
|
||||
.unwrap();
|
||||
}
|
||||
m.write16(BLDCNT, 0x2044).unwrap();
|
||||
m.write16(BLDALPHA, 0x1000).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x4210), "mode={mode}");
|
||||
window(&mut m, 0x0001, 0x0001, 4, 0x24);
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(31));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn green_swap_follows_effects_and_forced_blank_bypasses_every_effect() {
|
||||
let mut m = setup();
|
||||
m.write16(PAL + 2, 0x3e0).unwrap();
|
||||
m.write16(BLDCNT, 0xc1).unwrap();
|
||||
m.write16(BLDY, 16).unwrap();
|
||||
window(&mut m, 0x0001, 0x0001, 1, 0x21);
|
||||
m.write16(GREENSWAP, 1).unwrap();
|
||||
assert_eq!(&render(&m).pixels()[..2], &[0, 0xff00]);
|
||||
m.write16(DISPCNT, 0xffff).unwrap();
|
||||
assert!(render(&m).pixels().iter().all(|&p| p == 0xffffff));
|
||||
}
|
||||
166
tests/effects/demo.rs
Normal file
166
tests/effects/demo.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
use super::*;
|
||||
use gba_rust::{
|
||||
effects_demo::{EffectsDemo, EFFECTS_STATE},
|
||||
input::{Button, Buttons},
|
||||
tile_demo::TileDemo,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn cpu_demo_moves_wraps_cancels_resets_and_updates_effect_registers_during_vblank() {
|
||||
let mut demo = EffectsDemo::new().unwrap();
|
||||
let mut frame = Framebuffer::default();
|
||||
assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0);
|
||||
let mut expected_x = 0u32;
|
||||
let mut expected_y = 0u32;
|
||||
let sequence = [
|
||||
Buttons::default(),
|
||||
Buttons::default()
|
||||
.with(Button::Left, true)
|
||||
.with(Button::Up, true),
|
||||
Buttons::default()
|
||||
.with(Button::Right, true)
|
||||
.with(Button::Down, true),
|
||||
Buttons::default()
|
||||
.with(Button::Right, true)
|
||||
.with(Button::Left, true),
|
||||
Buttons::default().with(Button::A, true),
|
||||
Buttons::default().with(Button::B, true),
|
||||
Buttons::default()
|
||||
.with(Button::A, true)
|
||||
.with(Button::B, true),
|
||||
Buttons::default()
|
||||
.with(Button::Start, true)
|
||||
.with(Button::Right, true),
|
||||
Buttons::default(),
|
||||
];
|
||||
for (i, buttons) in sequence.into_iter().enumerate() {
|
||||
let before = demo.machine().cycles();
|
||||
demo.frame(buttons, &mut frame).unwrap();
|
||||
let memory = demo.machine().memory();
|
||||
// Explicit expected movements keep this independent of the ARM program.
|
||||
if i == 1 {
|
||||
expected_x = 510;
|
||||
expected_y = 510;
|
||||
}
|
||||
if i == 2 || i == 7 {
|
||||
expected_x = 0;
|
||||
expected_y = 0;
|
||||
}
|
||||
assert_eq!(memory.read32(EFFECTS_STATE).unwrap(), i as u32 + 1);
|
||||
assert_eq!(memory.read32(EFFECTS_STATE + 4).unwrap(), expected_x);
|
||||
assert_eq!(memory.read32(EFFECTS_STATE + 8).unwrap(), expected_y);
|
||||
assert_eq!(memory.read16(DISPCNT).unwrap(), 0x3340);
|
||||
assert_eq!(memory.read16(WININ).unwrap(), 0x1f);
|
||||
assert_eq!(memory.read16(WINOUT).unwrap(), 0x3f);
|
||||
assert_eq!(memory.read16(BLDALPHA).unwrap(), 0x808);
|
||||
assert_eq!(
|
||||
memory.read16(BLDCNT).unwrap(),
|
||||
match i {
|
||||
4 | 6 => 0x251,
|
||||
5 => 0xff,
|
||||
_ => 0xbf,
|
||||
}
|
||||
);
|
||||
assert_eq!(memory.display_position().scanline, 160);
|
||||
assert!(demo.machine().cycles() > before);
|
||||
// The center is in WIN0 and therefore unchanged by any effect.
|
||||
assert_eq!(frame.pixels()[80 * WIDTH + 120], rgb(0x7fff));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_brightness_demo_matches_tile_scene_inside_and_outside_moving_window() {
|
||||
let mut demo = EffectsDemo::new().unwrap();
|
||||
let mut base = TileDemo::new().unwrap();
|
||||
let mut actual = Framebuffer::default();
|
||||
let mut original = Framebuffer::default();
|
||||
// Include wrapped scroll, affine objects, and brightness changes.
|
||||
for (index, buttons) in [
|
||||
Buttons::default()
|
||||
.with(Button::Left, true)
|
||||
.with(Button::Up, true),
|
||||
Buttons::default()
|
||||
.with(Button::B, true)
|
||||
.with(Button::L, true),
|
||||
Buttons::default()
|
||||
.with(Button::B, true)
|
||||
.with(Button::R, true),
|
||||
Buttons::default().with(Button::Start, true),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
demo.frame(buttons, &mut actual).unwrap();
|
||||
base.frame(buttons, &mut original).unwrap();
|
||||
let (left, top) = if index < 3 { (62, 38) } else { (64, 40) };
|
||||
for y in 0..HEIGHT {
|
||||
for x in 0..WIDTH {
|
||||
let old = original.pixels()[y * WIDTH + x];
|
||||
// Recover the exact five-bit channels from RGB888.
|
||||
let channels = [(old >> 19) & 31, (old >> 11) & 31, (old >> 3) & 31];
|
||||
let mut expected = 0u16;
|
||||
for (channel, shift) in channels.into_iter().zip([0, 5, 10]) {
|
||||
let changed = if (left..left + 112).contains(&x) && (top..top + 80).contains(&y)
|
||||
{
|
||||
channel
|
||||
} else if index == 1 || index == 2 {
|
||||
channel - channel / 2
|
||||
} else {
|
||||
channel + (31 - channel) / 2
|
||||
};
|
||||
expected |= (changed as u16) << shift;
|
||||
}
|
||||
assert_eq!(
|
||||
actual.pixels()[y * WIDTH + x],
|
||||
rgb(expected),
|
||||
"frame={index} pixel={x},{y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_window_demo_retains_active_region_when_bottom_moves_beyond_vcount_range() {
|
||||
let mut demo = EffectsDemo::new().unwrap();
|
||||
let mut frame = Framebuffer::default();
|
||||
for index in 0..54 {
|
||||
demo.frame(Buttons::default().with(Button::Down, true), &mut frame)
|
||||
.unwrap();
|
||||
// x=80 avoids foreground crosses and sprites. It stays inside WIN0's X bounds.
|
||||
let sy = 2 * (index + 1);
|
||||
let raw: u16 =
|
||||
[0x260, 0x3a0, 0x7d20, 0x7e80][((80 / 32 + sy / 32) % 2) * 2 + (80 / 2 + sy / 2) % 2];
|
||||
let expected = if index == 53 {
|
||||
// New bottom=228 cannot match any line. Last frame's active flag survives.
|
||||
raw
|
||||
} else {
|
||||
[0, 5, 10].into_iter().fold(0, |value, shift| {
|
||||
let channel = (raw >> shift) & 31;
|
||||
value | ((channel + (31 - channel) / 2) << shift)
|
||||
})
|
||||
};
|
||||
assert_eq!(frame.pixels()[80], rgb(expected), "frame={index}");
|
||||
}
|
||||
let mut snapshot = Framebuffer::default();
|
||||
demo.machine().memory().render_frame(&mut snapshot).unwrap();
|
||||
assert_ne!(snapshot.pixels()[80], frame.pixels()[80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dma_word_write_updates_selection_and_coefficients_together() {
|
||||
use gba_rust::{
|
||||
cpu::Cpu,
|
||||
dma::DMA_BASE,
|
||||
machine::{Machine, StepKind},
|
||||
};
|
||||
let mut m = setup();
|
||||
m.write32(0x0200_0000, 0x0808_0241).unwrap();
|
||||
m.write32(DMA_BASE + 36, 0x0200_0000).unwrap();
|
||||
m.write32(DMA_BASE + 40, BLDCNT).unwrap();
|
||||
m.write32(DMA_BASE + 44, 0x8400_0001).unwrap();
|
||||
let mut machine = Machine::new(Cpu::new(0x0300_0000), m);
|
||||
assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 3 });
|
||||
assert_eq!(machine.memory().read32(BLDCNT).unwrap(), 0x0808_0241);
|
||||
assert_eq!(pixel(machine.memory(), 0, 0), rgb(0x1ef));
|
||||
}
|
||||
176
tests/effects/windows.rs
Normal file
176
tests/effects/windows.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn register_masks_byte_merges_and_write_only_reads() {
|
||||
let mut m = setup();
|
||||
for (address, mask) in [
|
||||
(WININ, 0x3f3f),
|
||||
(WINOUT, 0x3f3f),
|
||||
(BLDCNT, 0x3fff),
|
||||
(BLDALPHA, 0x1f1f),
|
||||
] {
|
||||
assert_eq!(m.read16(address).unwrap(), 0);
|
||||
m.write16(address, 0xffff).unwrap();
|
||||
assert_eq!(m.read16(address).unwrap(), mask);
|
||||
m.write8(address, 0).unwrap();
|
||||
assert_eq!(m.read16(address).unwrap(), mask & 0xff00);
|
||||
m.write8(address + 1, 0).unwrap();
|
||||
assert_eq!(m.read16(address).unwrap(), 0);
|
||||
}
|
||||
for address in [WIN0H, WIN1H, WIN0V, WIN1V, BLDY] {
|
||||
m.write16(address, 0xffff).unwrap();
|
||||
assert_eq!(m.read16(address).unwrap(), 0);
|
||||
}
|
||||
m.write32(BLDY, u32::MAX).unwrap();
|
||||
assert_eq!(m.read32(BLDY).unwrap(), 0);
|
||||
assert_eq!(
|
||||
m.read8(0x0400_0058),
|
||||
Err(MemoryError::Unmapped(0x0400_0058))
|
||||
);
|
||||
assert_eq!(
|
||||
m.read8(0x0401_0048),
|
||||
Err(MemoryError::Unmapped(0x0401_0048))
|
||||
);
|
||||
assert_eq!(
|
||||
m.write16(WININ + 1, 1),
|
||||
Err(MemoryError::Unaligned(WININ + 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_enabled_windows_ignore_zero_masks_but_apply_effects() {
|
||||
let mut m = setup();
|
||||
m.write16(BLDCNT, 0xc1).unwrap();
|
||||
m.write16(BLDY, 16).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), 0);
|
||||
m.write16(DISPCNT, 0x2700).unwrap(); // Empty WIN0; outside mask is zero.
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x4210));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_are_half_open_and_byte_writes_preserve_other_bound() {
|
||||
let mut m = setup();
|
||||
window(&mut m, 0, 0, 1, 2);
|
||||
m.write8(WIN0H, 30).unwrap();
|
||||
m.write8(WIN0H + 1, 10).unwrap();
|
||||
m.write8(WIN0V, 40).unwrap();
|
||||
m.write8(WIN0V + 1, 20).unwrap();
|
||||
let f = render(&m);
|
||||
for y in 0..HEIGHT {
|
||||
for x in 0..WIDTH {
|
||||
let inside = (10..30).contains(&x) && (20..40).contains(&y);
|
||||
assert_eq!(
|
||||
f.pixels()[y * WIDTH + x],
|
||||
rgb(if inside { 31 } else { 0x3e0 })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverted_bounds_wrap_equal_bounds_are_empty_and_offscreen_bounds_clip() {
|
||||
let mut m = setup();
|
||||
for (h, v) in [
|
||||
(0xdc14, 0x960a),
|
||||
(0xfafa, 0x00ff),
|
||||
(0xf005, 0xa005),
|
||||
(0x00ff, 0x00ff),
|
||||
] {
|
||||
window(&mut m, h, v, 1, 2);
|
||||
let f = render(&m);
|
||||
for y in 0..HEIGHT {
|
||||
for x in 0..WIDTH {
|
||||
// Independent modular-distance definition of a half-open interval.
|
||||
let inside = (x as u8).wrapping_sub((h >> 8) as u8)
|
||||
< (h as u8).wrapping_sub((h >> 8) as u8)
|
||||
&& (y as u8).wrapping_sub((v >> 8) as u8)
|
||||
< (v as u8).wrapping_sub((v >> 8) as u8);
|
||||
assert_eq!(
|
||||
f.pixels()[y * WIDTH + x],
|
||||
rgb(if inside { 31 } else { 0x3e0 })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win0_overrides_win1_without_combining_masks() {
|
||||
let mut m = setup();
|
||||
window(&mut m, 0x0a1e, 0x0a1e, 0x0200, 4); // WIN0 hides all; WIN1 selects BG1.
|
||||
m.write16(WIN1H, 0x1428).unwrap();
|
||||
m.write16(WIN1V, 0x1428).unwrap();
|
||||
m.write16(DISPCNT, 0x6700).unwrap();
|
||||
assert_eq!(pixel(&m, 25, 25), rgb(0x4210));
|
||||
assert_eq!(pixel(&m, 35, 35), rgb(0x03e0));
|
||||
assert_eq!(pixel(&m, 45, 45), rgb(0x7c00));
|
||||
m.write16(DISPCNT, 0x6500).unwrap(); // Window cannot enable a globally disabled BG.
|
||||
assert_eq!(pixel(&m, 35, 35), rgb(0x4210));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_effect_bit_is_independent_of_layer_bits_and_backdrop_is_always_visible() {
|
||||
let mut m = setup();
|
||||
window(&mut m, 0x0008, 0x0008, 1, 0x21);
|
||||
m.write16(BLDCNT, 0xc1).unwrap();
|
||||
m.write16(BLDY, 16).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(31));
|
||||
assert_eq!(pixel(&m, 8, 0), 0);
|
||||
m.write16(WINOUT, 0x20).unwrap();
|
||||
m.write16(BLDCNT, 0xa0).unwrap(); // Brighten backdrop.
|
||||
assert_eq!(pixel(&m, 8, 0), rgb(0x7fff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_window_uses_nonzero_texels_not_color_priority_or_visible_obj_mask() {
|
||||
let mut m = setup();
|
||||
m.write16(DISPCNT, 0x9700).unwrap();
|
||||
m.write16(WINOUT, 0x0102).unwrap(); // OBJ region BG0 only, outside BG1 only.
|
||||
object(&mut m, 0, 0x800, 0, 0xfc00); // Palette15, priority3 are irrelevant.
|
||||
m.write16(VRAM + 0x10000, 0x1010).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x3e0));
|
||||
assert_eq!(pixel(&m, 1, 0), rgb(31));
|
||||
assert_eq!(pixel(&m, 8, 0), rgb(0x3e0));
|
||||
m.write16(DISPCNT, 0x8700).unwrap(); // OBJ master enable is also required.
|
||||
assert_eq!(pixel(&m, 1, 0), rgb(0x3e0));
|
||||
m.write16(DISPCNT, 0x1700).unwrap(); // Window OBJ never becomes a visible sprite.
|
||||
assert_eq!(pixel(&m, 1, 0), rgb(31));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_objects_do_not_occlude_object_window_and_rectangles_take_precedence() {
|
||||
let mut m = setup();
|
||||
m.write16(DISPCNT, 0x9700).unwrap();
|
||||
m.write16(WINOUT, 0x0102).unwrap();
|
||||
object(&mut m, 0, 0, 0, 0); // Occupies the same pixels as the later window OBJ.
|
||||
object(&mut m, 127, 0x800, 0, 0);
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(31)); // OBJ mask hides color OBJ, not window coverage.
|
||||
window(&mut m, 0x0002, 0x0008, 4, 0x0102);
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x7c00));
|
||||
assert_eq!(pixel(&m, 2, 0), rgb(31));
|
||||
m.write16(WIN1H, 0x0004).unwrap();
|
||||
m.write16(WIN1V, 0x0008).unwrap();
|
||||
m.write16(WININ, 0x0204).unwrap();
|
||||
m.write16(DISPCNT, 0xf700).unwrap();
|
||||
assert_eq!(pixel(&m, 2, 0), rgb(0x3e0));
|
||||
assert_eq!(pixel(&m, 4, 0), rgb(31));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_window_supports_regular_flips_and_affine_sampling() {
|
||||
let mut m = setup();
|
||||
m.write16(DISPCNT, 0x9700).unwrap();
|
||||
m.write16(WINOUT, 0x0102).unwrap();
|
||||
for offset in (0..32).step_by(2) {
|
||||
m.write16(VRAM + 0x10000 + offset, 0).unwrap();
|
||||
}
|
||||
m.write16(VRAM + 0x10000, 1).unwrap();
|
||||
object(&mut m, 0, 0x800, 0x1000, 0);
|
||||
assert_eq!(pixel(&m, 7, 0), rgb(31));
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(0x3e0));
|
||||
object(&mut m, 0, 0x900, 0, 0);
|
||||
m.write16(OAM + 6, 256).unwrap();
|
||||
m.write16(OAM + 30, 256).unwrap();
|
||||
assert_eq!(pixel(&m, 0, 0), rgb(31));
|
||||
assert_eq!(pixel(&m, 1, 0), rgb(0x3e0));
|
||||
}
|
||||
228
tests/window_tracking.rs
Normal file
228
tests/window_tracking.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
use gba_rust::{
|
||||
cpu::Cpu,
|
||||
display::{
|
||||
CYCLES_PER_FRAME as FRAME, CYCLES_PER_LINE as LINE, HBLANK_START as HB, VBLANK_START as VB,
|
||||
},
|
||||
dma::DMA_BASE,
|
||||
io::*,
|
||||
machine::Machine,
|
||||
memory::{Memory, PALETTE_START as PAL, ROM_START},
|
||||
video::{rgb555_to_rgb888 as rgb, Framebuffer, HEIGHT, WIDTH},
|
||||
};
|
||||
|
||||
#[path = "window_tracking/horizontal.rs"]
|
||||
mod horizontal;
|
||||
|
||||
fn setup(bounds: u16) -> Memory {
|
||||
let mut m = Memory::new(vec![]).unwrap();
|
||||
m.set_scanline_rendering(true);
|
||||
m.write16(DISPCNT, 0x2000).unwrap();
|
||||
m.write16(PAL, 31).unwrap();
|
||||
m.write16(WIN0H, 240).unwrap();
|
||||
m.write16(WIN1H, 240).unwrap();
|
||||
m.write16(WIN0V, bounds).unwrap();
|
||||
m.write16(WININ, 0x2020).unwrap();
|
||||
m.write16(WINOUT, 0).unwrap();
|
||||
m.write16(BLDCNT, 0xe0).unwrap(); // Darken backdrop only when effects are enabled.
|
||||
m.write16(BLDY, 16).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
fn present(m: &Memory) -> Framebuffer {
|
||||
let mut f = Framebuffer::default();
|
||||
assert!(m.present_frame(&mut f).unwrap());
|
||||
f
|
||||
}
|
||||
|
||||
fn finish(m: &mut Memory) -> Framebuffer {
|
||||
let position = m.display_position();
|
||||
let phase = u32::from(position.scanline) * LINE + u32::from(position.line_cycle);
|
||||
m.advance_cycles(VB - phase);
|
||||
present(m)
|
||||
}
|
||||
|
||||
fn check_rows(f: &Framebuffer, inside: impl Fn(usize) -> bool) {
|
||||
for y in 0..HEIGHT {
|
||||
let expected = if inside(y) { 0 } else { rgb(31) };
|
||||
assert!(
|
||||
f.pixels()[y * WIDTH..(y + 1) * WIDTH]
|
||||
.iter()
|
||||
.all(|&p| p == expected),
|
||||
"row={y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_static_windows_match_debug_snapshots() {
|
||||
for bounds in [0x00a0, 0x0130, 0x0405, 0x9fa0, 0x0000, 0x0505, 0x00ff] {
|
||||
let mut m = setup(bounds);
|
||||
let mut expected = Framebuffer::default();
|
||||
m.render_frame(&mut expected).unwrap();
|
||||
assert_eq!(
|
||||
finish(&mut m).pixels(),
|
||||
expected.pixels(),
|
||||
"bounds={bounds:#x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_top_after_entry_does_not_close_an_active_window() {
|
||||
let mut m = setup(0x0210);
|
||||
m.advance_cycles(4 * LINE + HB);
|
||||
m.write8(WIN0V + 1, 12).unwrap();
|
||||
let f = finish(&mut m);
|
||||
check_rows(&f, |y| (2..16).contains(&y));
|
||||
let mut snapshot = Framebuffer::default();
|
||||
m.render_frame(&mut snapshot).unwrap();
|
||||
check_rows(&snapshot, |y| (12..16).contains(&y));
|
||||
assert_eq!(present(&m).pixels(), f.pixels()); // Snapshot rendering has no latch side effects.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_top_behind_vcount_does_not_open_a_window() {
|
||||
let mut m = setup(0x0a14);
|
||||
m.advance_cycles(5 * LINE + HB);
|
||||
m.write8(WIN0V + 1, 2).unwrap();
|
||||
check_rows(&finish(&mut m), |_| false);
|
||||
m.advance_cycles(FRAME - VB);
|
||||
check_rows(&finish(&mut m), |y| (2..20).contains(&y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bottom_keeps_window_open_across_frame_wrap() {
|
||||
let mut m = setup(0x0214);
|
||||
m.advance_cycles(5 * LINE + HB);
|
||||
m.write8(WIN0V, 4).unwrap();
|
||||
check_rows(&finish(&mut m), |y| y >= 2);
|
||||
m.advance_cycles(FRAME - VB);
|
||||
check_rows(&finish(&mut m), |y| y < 4); // No unconditional line-zero reset.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_before_line_start_take_effect_but_writes_after_it_do_not_replay_edges() {
|
||||
for before in [false, true] {
|
||||
let mut m = setup(0x1420);
|
||||
m.advance_cycles(5 * LINE - u32::from(before));
|
||||
m.write16(WIN0V, 0x0508).unwrap();
|
||||
m.advance_cycles(0); // Does not trigger a comparator.
|
||||
check_rows(&finish(&mut m), |y| before && (5..8).contains(&y));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hblank_writes_cannot_replay_the_current_rows_comparator() {
|
||||
let mut m = setup(0x0208);
|
||||
m.advance_cycles(3 * LINE + HB);
|
||||
m.write16(WIN0V, 0x0203).unwrap(); // Bottom already missed this row.
|
||||
check_rows(&finish(&mut m), |y| y >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_line_edges_run_and_unreachable_edges_do_not_fire() {
|
||||
for top in [160, 200, 227, 228, 255] {
|
||||
let mut m = setup((top << 8) | 8);
|
||||
check_rows(&finish(&mut m), |_| false); // Reset starts outside; top not reached yet.
|
||||
m.advance_cycles(FRAME - VB);
|
||||
check_rows(&finish(&mut m), |y| top < 228 && y < 8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_bounds_clear_only_at_the_matching_edge_and_bottom_wins() {
|
||||
let mut m = setup(0x0214);
|
||||
m.advance_cycles(4 * LINE);
|
||||
m.write16(WIN0V, 0x0808).unwrap();
|
||||
check_rows(&finish(&mut m), |y| (2..8).contains(&y));
|
||||
|
||||
let mut m = setup(0x0214);
|
||||
m.advance_cycles(4 * LINE);
|
||||
m.write16(WIN0V, 0xffff).unwrap(); // Neither edge is reachable by VCOUNT.
|
||||
check_rows(&finish(&mut m), |y| y >= 2);
|
||||
m.advance_cycles(FRAME - VB);
|
||||
check_rows(&finish(&mut m), |_| true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_disable_and_forced_blank_do_not_pause_comparators() {
|
||||
for control in [0x4000, 0x2080] {
|
||||
let mut m = setup(0x0210);
|
||||
m.write16(DISPCNT, control).unwrap();
|
||||
m.advance_cycles(5 * LINE);
|
||||
m.write16(DISPCNT, 0x2000).unwrap();
|
||||
let f = finish(&mut m);
|
||||
for y in 0..HEIGHT {
|
||||
let expected = if y < 5 && control & 0x80 != 0 {
|
||||
0xffffff
|
||||
} else if (5..16).contains(&y) {
|
||||
0
|
||||
} else {
|
||||
rgb(31)
|
||||
};
|
||||
assert_eq!(
|
||||
f.pixels()[y * WIDTH],
|
||||
expected,
|
||||
"control={control:#x} row={y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win0_and_win1_keep_independent_state_and_win0_keeps_precedence() {
|
||||
let mut m = setup(0x0206);
|
||||
m.write32(WIN0V, 0x0408_0206).unwrap(); // Independent byte merging for both windows.
|
||||
m.write16(DISPCNT, 0x6000).unwrap();
|
||||
m.write16(WININ, 0x2000).unwrap(); // WIN0 allows red; WIN1 darkens to black.
|
||||
m.advance_cycles(3 * LINE + HB);
|
||||
m.write8(WIN0V + 1, 5).unwrap(); // Does not close WIN0 before row5.
|
||||
m.advance_cycles(LINE);
|
||||
m.write8(WIN1V + 1, 7).unwrap(); // Does not close WIN1 before row7.
|
||||
check_rows(&finish(&mut m), |y| (6..8).contains(&y));
|
||||
assert_eq!(m.read32(WIN0V).unwrap(), 0); // Registers remain write-only.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hblank_bounds_and_mask_writes_apply_to_subsequent_rows() {
|
||||
let mut m = setup(0x020a);
|
||||
m.advance_cycles(4 * LINE + HB);
|
||||
m.write16(WIN0H, 0x0a14).unwrap();
|
||||
m.advance_cycles(LINE);
|
||||
m.write16(WININ, 0).unwrap();
|
||||
let f = finish(&mut m);
|
||||
for y in 0..HEIGHT {
|
||||
for x in 0..WIDTH {
|
||||
let dark = (2..5).contains(&y) || (y == 5 && (10..20).contains(&x));
|
||||
assert_eq!(f.pixels()[y * WIDTH + x], if dark { 0 } else { rgb(31) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hblank_dma_programs_future_edges_without_recomputing_interval_membership() {
|
||||
let mut m = setup(0x1020);
|
||||
for y in 0..160 {
|
||||
m.write16(0x0200_0000 + y * 2, if y == 0 { 0x0104 } else { 0xc804 })
|
||||
.unwrap();
|
||||
}
|
||||
m.write32(DMA_BASE, 0x0200_0000).unwrap();
|
||||
m.write32(DMA_BASE + 4, WIN0V).unwrap();
|
||||
m.write32(DMA_BASE + 8, 0xa240_0001).unwrap();
|
||||
m.write8(HALTCNT, 0).unwrap();
|
||||
let mut machine = Machine::new(Cpu::new(ROM_START), m);
|
||||
machine.run_until_vblank(1000).unwrap();
|
||||
check_rows(&present(machine.memory()), |y| (1..4).contains(&y));
|
||||
assert!(machine.halted());
|
||||
assert_eq!(machine.memory().read16(DMA_BASE + 10).unwrap(), 0xa240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_disabled_still_tracks_hidden_edges_and_enabling_does_not_reset_state() {
|
||||
let mut m = setup(0xc808);
|
||||
m.set_scanline_rendering(false);
|
||||
m.advance_cycles(FRAME); // Hidden row200 opens the window, retained at row0.
|
||||
m.set_scanline_rendering(true);
|
||||
m.set_scanline_rendering(true);
|
||||
check_rows(&finish(&mut m), |y| y < 8);
|
||||
}
|
||||
209
tests/window_tracking/horizontal.rs
Normal file
209
tests/window_tracking/horizontal.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
use super::*;
|
||||
|
||||
fn scene(horizontal: u16) -> Memory {
|
||||
let mut m = setup(160);
|
||||
m.write16(WIN0H, horizontal).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
fn check_line(f: &Framebuffer, y: usize, inside: impl Fn(usize) -> bool) {
|
||||
for x in 0..WIDTH {
|
||||
assert_eq!(
|
||||
f.pixels()[y * WIDTH + x],
|
||||
if inside(x) { 0 } else { rgb(31) },
|
||||
"pixel={x},{y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_left_after_entry_preserves_earlier_pixels_and_active_state() {
|
||||
let mut m = scene(0x0a50);
|
||||
m.advance_cycles(4 * 20 + 1);
|
||||
m.write8(WIN0H + 1, 40).unwrap();
|
||||
let f = finish(&mut m);
|
||||
check_line(&f, 0, |x| (10..80).contains(&x));
|
||||
check_line(&f, 1, |x| (40..80).contains(&x));
|
||||
let mut snapshot = Framebuffer::default();
|
||||
m.render_frame(&mut snapshot).unwrap();
|
||||
check_line(&snapshot, 0, |x| (40..80).contains(&x));
|
||||
assert_eq!(present(&m).pixels(), f.pixels());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_left_behind_current_column_does_not_open_an_inactive_window() {
|
||||
let mut m = scene(0x6478);
|
||||
m.advance_cycles(4 * 20);
|
||||
m.write8(WIN0H + 1, 10).unwrap();
|
||||
let f = finish(&mut m);
|
||||
check_line(&f, 0, |_| false);
|
||||
check_line(&f, 1, |x| (10..120).contains(&x));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_right_keeps_window_open_into_the_next_line() {
|
||||
let mut m = scene(0x0a50);
|
||||
m.advance_cycles(4 * 30);
|
||||
m.write8(WIN0H, 5).unwrap();
|
||||
let f = finish(&mut m);
|
||||
check_line(&f, 0, |x| x >= 10);
|
||||
check_line(&f, 1, |x| !(5..10).contains(&x));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_four_cycle_sampling_boundaries_and_zero_cycle_advances_are_observable() {
|
||||
for elapsed in [79, 80, 81, 83] {
|
||||
let mut m = scene(0x6450);
|
||||
m.advance_cycles(elapsed);
|
||||
m.write16(WIN0H, 0x1450).unwrap();
|
||||
m.advance_cycles(0);
|
||||
let f = finish(&mut m);
|
||||
check_line(&f, 0, |x| elapsed <= 80 && (20..80).contains(&x));
|
||||
check_line(&f, 1, |x| (20..80).contains(&x));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_bounds_clear_at_the_edge_not_at_register_write() {
|
||||
for equal in [20, 40] {
|
||||
let mut m = scene(0x0ac8);
|
||||
m.advance_cycles(4 * 30);
|
||||
m.write16(WIN0H, equal * 0x101).unwrap();
|
||||
let f = finish(&mut m);
|
||||
check_line(&f, 0, |x| x >= 10 && (equal == 20 || x < 40));
|
||||
check_line(&f, 1, |x| equal == 20 && x < 20);
|
||||
check_line(&f, 2, |_| false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_columns_including_late_hblank_set_next_lines_wrapping_state() {
|
||||
for left in [240, 251, 252, 255] {
|
||||
let mut m = scene((left << 8) | 10);
|
||||
let f = finish(&mut m);
|
||||
check_line(&f, 0, |_| false); // No previous line at startup.
|
||||
check_line(&f, 1, |x| x < 10);
|
||||
check_line(&f, 159, |x| x < 10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hblank_writes_can_cancel_a_late_offscreen_edge_without_changing_captured_row() {
|
||||
let mut m = scene(0xff0a);
|
||||
m.advance_cycles(HB);
|
||||
m.write8(WIN0H + 1, 20).unwrap(); // X255 not reached; X20 already passed.
|
||||
let f = finish(&mut m);
|
||||
check_line(&f, 0, |_| false);
|
||||
check_line(&f, 1, |x| x >= 20);
|
||||
check_line(&f, 2, |x| !(10..20).contains(&x));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_window_histories_preserve_win0_precedence() {
|
||||
let mut m = scene(0x0a50);
|
||||
m.write16(WIN1H, 0x1464).unwrap();
|
||||
m.write16(WIN1V, 160).unwrap();
|
||||
m.write16(DISPCNT, 0x6000).unwrap();
|
||||
m.write16(WININ, 0x2000).unwrap(); // WIN0 red, WIN1 black.
|
||||
m.advance_cycles(4 * 30);
|
||||
m.write32(WIN0H, 0x1464_3250).unwrap(); // Move WIN0's left edge only.
|
||||
let f = finish(&mut m);
|
||||
check_line(&f, 0, |x| (80..100).contains(&x));
|
||||
check_line(&f, 1, |x| (20..50).contains(&x) || (80..100).contains(&x));
|
||||
assert_eq!(m.read32(WIN0H).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_windows_and_forced_blank_do_not_pause_horizontal_history() {
|
||||
for control in [0x4000, 0x2080] {
|
||||
let mut m = scene(0x0a50);
|
||||
m.write16(DISPCNT, control).unwrap();
|
||||
m.advance_cycles(4 * 30);
|
||||
m.write16(DISPCNT, 0x2000).unwrap();
|
||||
let f = finish(&mut m);
|
||||
// Enable/blank bits still sample at HBlank, unlike WIN0H comparator history.
|
||||
check_line(&f, 0, |x| (10..80).contains(&x));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hblank_dma_can_set_an_offscreen_edge_before_the_next_line() {
|
||||
let mut m = scene(0);
|
||||
for row in 0..160 {
|
||||
m.write16(0x0200_0000 + row * 2, 0xff0a).unwrap();
|
||||
}
|
||||
m.write32(DMA_BASE, 0x0200_0000).unwrap();
|
||||
m.write32(DMA_BASE + 4, WIN0H).unwrap();
|
||||
m.write32(DMA_BASE + 8, 0xa240_0001).unwrap();
|
||||
m.write8(HALTCNT, 0).unwrap();
|
||||
let mut machine = Machine::new(Cpu::new(ROM_START), m);
|
||||
machine.run_until_vblank(1000).unwrap();
|
||||
let f = present(machine.memory());
|
||||
check_line(&f, 0, |_| false);
|
||||
for y in 1..HEIGHT {
|
||||
check_line(&f, y, |x| x < 10);
|
||||
}
|
||||
assert!(machine.halted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_store_crossing_a_comparator_uses_committed_bounds_not_final_instruction_time() {
|
||||
for after in [false, true] {
|
||||
let mut m = scene(0x6450);
|
||||
let code = [
|
||||
0xe3a0_0301, // MOV r0,#0x04000000
|
||||
0xe3a0_1b05, // MOV r1,#0x1400
|
||||
0xe381_1050, // ORR r1,r1,#0x50
|
||||
0xe1c0_14b0, // STRH r1,[r0,#0x40] (WIN0H)
|
||||
];
|
||||
for (i, value) in code.into_iter().enumerate() {
|
||||
m.write32(0x0300_0000 + i as u32 * 4, value).unwrap();
|
||||
}
|
||||
let mut machine = Machine::new(Cpu::new(0x0300_0000), m);
|
||||
for _ in 0..3 {
|
||||
machine.step().unwrap();
|
||||
}
|
||||
let cycles = machine.cycles() as u32;
|
||||
machine
|
||||
.memory_mut()
|
||||
.advance_cycles(if after { 81 } else { 79 } - cycles);
|
||||
machine.step().unwrap();
|
||||
assert!(machine.cycles() > 80);
|
||||
let f = finish(machine.memory_mut());
|
||||
check_line(&f, 0, |x| !after && (20..80).contains(&x));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_colors_still_sample_at_hblank_while_window_edges_keep_history() {
|
||||
let mut m = scene(0x0a50);
|
||||
m.advance_cycles(4 * 30);
|
||||
m.write8(WIN0H + 1, 40).unwrap();
|
||||
m.write16(PAL, 0x7c00).unwrap();
|
||||
let f = finish(&mut m);
|
||||
for x in 0..WIDTH {
|
||||
assert_eq!(
|
||||
f.pixels()[x],
|
||||
if (10..80).contains(&x) {
|
||||
0
|
||||
} else {
|
||||
rgb(0x7c00)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_enable_preserves_partial_line_history_and_hidden_lines_update_flags() {
|
||||
let mut m = scene(0x0a50);
|
||||
m.set_scanline_rendering(false);
|
||||
m.advance_cycles(4 * 30);
|
||||
m.write8(WIN0H + 1, 40).unwrap();
|
||||
m.set_scanline_rendering(true); // Before first HBlank, this frame is still complete.
|
||||
check_line(&finish(&mut m), 0, |x| (10..80).contains(&x));
|
||||
m.set_scanline_rendering(false);
|
||||
m.write16(WIN0H, 0xff0a).unwrap();
|
||||
m.advance_cycles(FRAME - VB);
|
||||
m.set_scanline_rendering(true);
|
||||
check_line(&finish(&mut m), 0, |x| x < 10);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue