Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/internals/video.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,23 @@ comparator. A vblank arm sequence with VSTART equal to VSTOP therefore
displays full-height columns, which is how Gen-X draws the vertical
edge-masking line sprites of its shutter transitions.

Because DMA fetches land in the same SPRxPOS/CTL/DATA/DATB registers a
CPU/Copper write hits, Denise keeps two views of them: the CPU/Copper write
shadow (`sprpos`/`sprctl`/`sprdata`/`sprdatb`/`spr_armed`), which the
manual replay above and the live collision path are calibrated against, and
the hardware-true view (`spr_hw_*`), which additionally receives every
sprite DMA fetch -- a DATA fetch arms it, the vstop control fetch
(including the 0/0 list terminator) disarms it. The DMA-idle latched
redisplay seeds from the hardware-true view: software relies on the
terminator's CTL to silence a channel for good, so a later bare
SPRxDATA arm must redisplay the DMA-written words, not the last manual
pattern (Hamazing's scene switch writes SPRxDATA=$0000 after a DMA sprite
scene and expects invisible sprites; the stale write-shadow pattern would
paint full-height bars). Only the authoritative sprite-DMA pass for a line
writes the hardware view through: pre-display lines are computed twice, and
the pre-display replay at the display start owns them (`sprprobe-latch` in
timing-test/ pins the whole sequence).

The mapping from beam coordinates to framebuffer x is anchored by
constants that encode the hardware's fetch-to-display pipeline delays --
register writes, palette writes, and bitplane data each land at their own
Expand Down
10 changes: 10 additions & 0 deletions src/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1274,11 +1274,21 @@ pub struct RenderRegisterSnapshot {
pub bplpt: [u32; 8],
pub bpldat: [u16; 8],
pub sprpt: [u32; 8],
/// CPU/Copper write-shadow sprite registers (see the matching fields
/// on `Denise`): the render replay and live collisions are calibrated
/// against these.
pub sprpos: [u16; 8],
pub sprctl: [u16; 8],
pub sprdata: [u16; 8],
pub sprdatb: [u16; 8],
pub spr_armed: [bool; 8],
/// Hardware-true sprite registers (manual writes AND sprite DMA
/// fetches): the armed-latch redisplay source for DMA-idle frames.
pub spr_hw_pos: [u16; 8],
pub spr_hw_ctl: [u16; 8],
pub spr_hw_data: [u16; 8],
pub spr_hw_datb: [u16; 8],
pub spr_hw_armed: [bool; 8],
pub bpl1mod: i16,
pub bpl2mod: i16,
pub palette: Palette,
Expand Down
2 changes: 1 addition & 1 deletion src/bus/custom_regs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,7 @@ impl Bus {
if idx < 8 {
match reg {
0x0 => {
self.denise.sprpos[idx] = val;
self.denise.write_sprpos(idx, val);
self.latch_display_sprite_dma_control_from_registers(
idx,
SpriteControlRegisterWrite::Pos,
Expand Down
65 changes: 59 additions & 6 deletions src/bus/frame_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,12 @@ impl Bus {
self.harddis_active(),
);
if slot_hpos == slot1_hpos {
self.sprite_slot1(sprite, vpos, spren, ddf_blocked);
self.sprite_slot1(sprite, vpos, spren, ddf_blocked, true);
} else {
// Off-screen lines evolve the channel state only;
// the visible field's capture starts at the display
// start.
let _ = self.sprite_slot2(sprite, vpos, spren, ddf_blocked);
let _ = self.sprite_slot2(sprite, vpos, spren, ddf_blocked, true);
}
}
}
Expand Down Expand Up @@ -583,6 +583,14 @@ impl Bus {
let mut fetched_lines = 0usize;
let bitplane_bplcon0 = self.effective_bitplane_bplcon0();
let bitplane_dmacon = self.effective_bitplane_dmacon();
// Lines above the display start are provisional here: the
// pre-display replay re-runs them at the display start with the
// frame's final DMACON/SPRxPT event timeline and owns their latch
// write-through (sprite_slot1 doc). TODO: a mid-frame DIWSTRT
// rewrite that moves the display start re-runs the overlap; the
// accurate model writes the latch once, at the single hardware
// fetch time of each slot.
let latch_write_through = vpos >= self.display_start_vpos_for_current_control();
for (sprite, &slot1_hpos) in SPRITE_DMA_SLOT1_HPOS.iter().enumerate() {
// Each sprite line uses two hardware DMA slots: $15+4N fetches
// POS or DATA, $17+4N fetches CTL or DATB. Both crossings are
Expand Down Expand Up @@ -619,7 +627,13 @@ impl Bus {
// even where vertical blank inhibits the DMA slots.
self.sprite_comparators_catch_up(sprite, vpos, dmacon_now);
if !vblank_inhibited {
self.sprite_slot1(sprite, vpos, spren_at(dmacon_now), ddf_blocked);
self.sprite_slot1(
sprite,
vpos,
spren_at(dmacon_now),
ddf_blocked,
latch_write_through,
);
}
}
if !(old_hpos..new_hpos).contains(&slot2_hpos) {
Expand All @@ -637,7 +651,13 @@ impl Bus {
}
let mut captured_line = false;
{
let line = self.sprite_slot2(sprite, vpos, slot2_enabled, ddf_blocked);
let line = self.sprite_slot2(
sprite,
vpos,
slot2_enabled,
ddf_blocked,
latch_write_through,
);
if let Some(line) = line {
// COPPERLINE_DIAG_SPRCAP=BEAMY|all: log captured sprite
// lines on that beam line (frame, channel, position, words,
Expand Down Expand Up @@ -709,8 +729,8 @@ impl Bus {
if self.sprite_dma_inhibited_by_vertical_blank_at(vpos) {
return None;
}
self.sprite_slot1(sprite, vpos, true, false);
self.sprite_slot2(sprite, vpos, true, false)
self.sprite_slot1(sprite, vpos, true, false, true);
self.sprite_slot2(sprite, vpos, true, false, true)
}

/// Lazily run the start-of-line comparator update for every line since
Expand Down Expand Up @@ -786,12 +806,19 @@ impl Bus {
/// control word's POS part is fetched. On other lines an enabled
/// channel fetches the line's DATA word(s), sampled at this slot's beam
/// time; the second slot assembles and emits the line.
/// `latch_write_through` says whether this pass is the authoritative
/// sprite-DMA pass for the line: pre-display lines are computed twice
/// (a provisional live pass, then the pre-display replay at the display
/// start re-runs them with the frame's final DMACON/SPRxPT event
/// timeline and owns the result), and only the authoritative pass may
/// land its fetches in the Denise display latches.
pub(super) fn sprite_slot1(
&mut self,
sprite: usize,
vpos: u32,
spren: bool,
ddf_blocked: bool,
latch_write_through: bool,
) {
let beam_y = vpos as i32;
let mut state = self.display_dma_sprite_state[sprite];
Expand All @@ -804,6 +831,9 @@ impl Bus {
if let Some(words) = self.fetch_sprite_words(sprite) {
state.poke_pos(words[0]);
state.reevaluate_comparators_at(beam_y);
if latch_write_through {
self.denise.dma_write_sprpos(sprite, words[0]);
}
}
}
} else if state.dma_enabled
Expand All @@ -815,6 +845,11 @@ impl Bus {
state.pending_data = Some((words[0], [words[1], words[2], words[3]]));
state.pending_line_vpos = beam_y;
state.last_data_fetch_vpos = beam_y;
// The DATA fetch arms the display latch exactly like a
// manual SPRxDATA write.
if latch_write_through {
self.denise.dma_write_sprdata(sprite, words[0]);
}
}
}
self.display_dma_sprite_state[sprite] = state;
Expand All @@ -833,6 +868,7 @@ impl Bus {
vpos: u32,
spren: bool,
ddf_blocked: bool,
latch_write_through: bool,
) -> Option<CapturedSpriteLine> {
let beam_y = vpos as i32;
let mut state = self.display_dma_sprite_state[sprite];
Expand All @@ -848,6 +884,14 @@ impl Bus {
if let Some(words) = self.fetch_sprite_words(sprite) {
state.poke_ctl(words[0]);
state.reevaluate_comparators_at(beam_y);
// The CTL fetch is a SPRxCTL write: it disarms the live
// display latch. Software relies on the null terminator's
// CTL to silence a channel for good (arming it later with
// SPRxDATA redisplays whatever the registers hold, so the
// latch has to track DMA truth, not the last manual write).
if latch_write_through {
self.denise.dma_write_sprctl(sprite, words[0]);
}
}
}
self.display_dma_sprite_state[sprite] = state;
Expand All @@ -861,6 +905,10 @@ impl Bus {
{
if let Some(words) = self.fetch_sprite_words(sprite) {
datb_fetched = Some((words[0], [words[1], words[2], words[3]]));
// The DATB fetch lands in SPRxDATB like a manual write.
if latch_write_through {
self.denise.dma_write_sprdatb(sprite, words[0]);
}
}
}
// Sprites captured as "held" at the visible start are repainted by
Expand Down Expand Up @@ -1498,6 +1546,11 @@ impl Bus {
sprdata: self.denise.sprdata,
sprdatb: self.denise.sprdatb,
spr_armed: self.denise.spr_armed,
spr_hw_pos: self.denise.spr_hw_pos,
spr_hw_ctl: self.denise.spr_hw_ctl,
spr_hw_data: self.denise.spr_hw_data,
spr_hw_datb: self.denise.spr_hw_datb,
spr_hw_armed: self.denise.spr_hw_armed,
bpl1mod: self.denise.bpl1mod,
bpl2mod: self.denise.bpl2mod,
palette: self.denise.palette,
Expand Down
68 changes: 68 additions & 0 deletions src/bus/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5998,6 +5998,74 @@ fn sprite_dma_zero_height_descriptor_terminates_stream() {
assert!(lines.is_empty());
}

#[test]
fn sprite_dma_fetches_land_in_hardware_latch_view() {
// A DMA DATA fetch arms the hardware-true display latch and overwrites
// its data words; a DMA CTL fetch (the vstop control words, here the
// 0/0 terminator) disarms it and leaves the fetched control words in
// the registers. The CPU/Copper write shadow never sees DMA, so the
// calibrated manual replay keeps its inputs. Software relies on the
// hardware view across a scene switch: Hamazing re-arms its sprites
// with SPRxDATA=$0000 after a DMA sprite scene and expects the
// DMA-written zeros (an invisible sprite), not a redisplay of the last
// manually written pattern as full-height stale bars.
let mut bus = empty_bus();

// A manual arm long before the DMA scene: both views hold the bar.
assert!(!bus.write_custom_word_from(0x140, 0x1080, BeamWriteSource::Cpu));
assert!(!bus.write_custom_word_from(0x146, 0xAAAA, BeamWriteSource::Cpu));
assert!(!bus.write_custom_word_from(0x144, 0xFFFF, BeamWriteSource::Cpu));
assert!(bus.denise.spr_armed[0]);
assert!(bus.denise.spr_hw_armed[0]);

// One DMA data line, then the 0/0 terminator.
let sprite_ptr = 0x0100usize;
let (pos, ctl) = sprite_control_words(0x2C, 0x2D, 0x0091);
write_chip_word(&mut bus, sprite_ptr, pos);
write_chip_word(&mut bus, sprite_ptr + 2, ctl);
write_chip_word(&mut bus, sprite_ptr + 4, 0x1234);
write_chip_word(&mut bus, sprite_ptr + 6, 0x0000);
write_chip_word(&mut bus, sprite_ptr + 8, 0);
write_chip_word(&mut bus, sprite_ptr + 10, 0);

bus.agnus.dmacon = DMACON_DMAEN | DMACON_SPREN;
bus.denise.sprpt[0] = sprite_ptr as u32;
bus.display_dma_sprpt[0] = sprite_ptr as u32;

sprite_fetch_control_words_at_reset_line(&mut bus);
let line = bus.captured_sprite_line_at(0, 0x2C);
assert!(line.is_some());
assert!(
bus.denise.spr_hw_armed[0],
"the DATA fetch arms the hardware latch"
);
assert_eq!(bus.denise.spr_hw_data[0], 0x1234);
assert_eq!(bus.denise.spr_hw_datb[0], 0x0000);

// The vstop line consumes the terminator: POS/CTL land in the hardware
// view and the CTL write disarms it.
let line = bus.captured_sprite_line_at(0, 0x2D);
assert!(line.is_none());
assert!(
!bus.denise.spr_hw_armed[0],
"the terminator CTL fetch disarms the hardware latch"
);
assert_eq!(bus.denise.spr_hw_pos[0], 0);
assert_eq!(bus.denise.spr_hw_ctl[0], 0);

// The write shadow never sees DMA: still the manual bar.
assert!(bus.denise.spr_armed[0]);
assert_eq!(bus.denise.sprdata[0], 0xFFFF);
assert_eq!(bus.denise.sprdatb[0], 0xAAAA);

// The scene-switch arm-with-zero: the hardware view arms with A=0 over
// the DMA-written B=0 -- an invisible sprite, not the stale manual bar.
assert!(!bus.write_custom_word_from(0x144, 0x0000, BeamWriteSource::Cpu));
assert!(bus.denise.spr_hw_armed[0]);
assert_eq!(bus.denise.spr_hw_data[0], 0x0000);
assert_eq!(bus.denise.spr_hw_datb[0], 0x0000);
}

#[test]
fn sprite_dma_capture_wraps_control_words_at_chip_ram_end() {
let mut bus = empty_bus();
Expand Down
63 changes: 63 additions & 0 deletions src/chipset/denise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,28 @@ pub struct Denise {
pub bplpt: [u32; 8],
pub bpldat: [u16; 8],
pub sprpt: [u32; 8],
/// SPRxPOS/CTL/DATA/DATB as CPU/Copper writes left them, with `spr_armed`
/// tracking those writes only (DATA arms, CTL disarms). The render
/// replay's manual-sprite model is calibrated against this
/// write-shadow view; sprite DMA fetches do not land here.
pub sprpos: [u16; 8],
pub sprctl: [u16; 8],
pub sprdata: [u16; 8],
pub sprdatb: [u16; 8],
pub spr_armed: [bool; 8],
/// The same registers as the hardware holds them: last-writer-wins
/// across CPU/Copper writes AND sprite DMA fetches (a DMA DATA fetch
/// arms the display latch, a DMA CTL fetch -- vstop control words or
/// the 0/0 list terminator -- disarms it). Feeds the armed-latch
/// redisplay in frames with sprite DMA idle: software relies on the
/// terminator's CTL to silence a channel for good, so a later bare
/// SPRxDATA write redisplays the DMA-written words, not the last
/// manual pattern (Hamazing scene-switch regression class).
pub spr_hw_pos: [u16; 8],
pub spr_hw_ctl: [u16; 8],
pub spr_hw_data: [u16; 8],
pub spr_hw_datb: [u16; 8],
pub spr_hw_armed: [bool; 8],
pub bpl1mod: i16,
pub bpl2mod: i16,
pub diwstrt: u16,
Expand Down Expand Up @@ -380,6 +397,11 @@ impl Denise {
sprdata: [0; 8],
sprdatb: [0; 8],
spr_armed: [false; 8],
spr_hw_pos: [0; 8],
spr_hw_ctl: [0; 8],
spr_hw_data: [0; 8],
spr_hw_datb: [0; 8],
spr_hw_armed: [false; 8],
bpl1mod: 0,
bpl2mod: 0,
diwstrt: 0,
Expand Down Expand Up @@ -434,23 +456,64 @@ impl Denise {
}
}

pub fn write_sprpos(&mut self, idx: usize, val: u16) {
if idx < self.sprpos.len() {
self.sprpos[idx] = val;
self.spr_hw_pos[idx] = val;
}
}

pub fn write_sprctl(&mut self, idx: usize, val: u16) {
if idx < self.sprctl.len() {
self.sprctl[idx] = val;
self.spr_armed[idx] = false;
self.spr_hw_ctl[idx] = val;
self.spr_hw_armed[idx] = false;
}
}

pub fn write_sprdata(&mut self, idx: usize, val: u16) {
if idx < self.sprdata.len() {
self.sprdata[idx] = val;
self.spr_armed[idx] = true;
self.spr_hw_data[idx] = val;
self.spr_hw_armed[idx] = true;
}
}

pub fn write_sprdatb(&mut self, idx: usize, val: u16) {
if idx < self.sprdatb.len() {
self.sprdatb[idx] = val;
self.spr_hw_datb[idx] = val;
}
}

/// A sprite DMA fetch landing in the registers: same effect as the
/// matching CPU/Copper write on the hardware-true view, but invisible
/// to the CPU/Copper write shadow the render replay is seeded from.
pub fn dma_write_sprpos(&mut self, idx: usize, val: u16) {
if idx < self.spr_hw_pos.len() {
self.spr_hw_pos[idx] = val;
}
}

pub fn dma_write_sprctl(&mut self, idx: usize, val: u16) {
if idx < self.spr_hw_ctl.len() {
self.spr_hw_ctl[idx] = val;
self.spr_hw_armed[idx] = false;
}
}

pub fn dma_write_sprdata(&mut self, idx: usize, val: u16) {
if idx < self.spr_hw_data.len() {
self.spr_hw_data[idx] = val;
self.spr_hw_armed[idx] = true;
}
}

pub fn dma_write_sprdatb(&mut self, idx: usize, val: u16) {
if idx < self.spr_hw_datb.len() {
self.spr_hw_datb[idx] = val;
}
}

Expand Down
Loading
Loading