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
10 changes: 9 additions & 1 deletion docs/internals/timing.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ carries across line boundaries. Missed comparators therefore produce the
hardware's "old stop" behaviours: a rewritten-too-late DDFSTOP lets the run
continue to the hardware-stop drain, a DDFSTRT match past $D8 starts a run
that wraps through horizontal blanking into the next line, and an
early-blanked DDFSTRT ($10 with SHW still down) never starts on OCS. The
early-blanked DDFSTRT ($10 with SHW still down) never starts on OCS on a
fresh line. Because OCS only clears SHW when a fetch run completes, the
latch survives a run-less line: the next line's below-$18 DDFSTRT match
then does arm a run, anchored at its raw comparator position, so such
lines fetch on alternating rasters with the picture sitting linearly left
of the standard grid (its early words run through the left border). The
renderer honours this through the captured run geometry: a below-$18 run
origin keeps its raw fetch grid, and a line without a captured fetch
paints nothing (vAmigaTS Agnus/DDF/DDF/oldhwstop3/4 A500 photos). The
per-line fetch table is rebuilt when DDFSTRT/DDFSTOP/BPLCON0/DMACON/DIW
writes land (DDF writes commit to the comparators four colour clocks after
the write slot; an old DDFSTOP still fires on its commit clock, an old
Expand Down
71 changes: 69 additions & 2 deletions src/bus/ddf_line.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ pub(super) struct DdfSeqLine {
/// First fetch colour clock of the line, if any.
pub first_fetch_cck: Option<u16>,
/// The run's first fetch-unit boundary (first fetch minus its unit
/// offset): the position that anchors word 0 on the display.
/// offset): the position that anchors word 0 on the display. `None`
/// when the line began inside a run carried across the line wrap: the
/// continuation tail is not a comparator-anchored origin (its unit
/// counter carries over mid-word), so the renderer keeps the register
/// view for such lines.
pub run_origin_cck: Option<u16>,
/// Sequencer state after the line's walk (becomes the next line's
/// initial state).
Expand Down Expand Up @@ -227,6 +231,10 @@ impl Bus {
let (_, hard_stop) = crate::chipset::agnus::ddf_hard_bounds(self.harddis_active());
let signals =
seq::line_signals_with_hard_stop(0xFFFF, 0xFFFF, hard_stop, line_ccks, &extra);
// A line that begins with BPRUN already up continues a run carried
// across the line wrap; its first fetches are a mid-unit tail, not
// a comparator-anchored run origin.
let run_carried_in = state.bprun;
let fetches = seq::walk_line(
self.aga_enabled(),
self.ddf_seq_ecs_rules(),
Expand Down Expand Up @@ -268,7 +276,9 @@ impl Bus {
line.words_per_plane[plane].max(line.word_idx_at[idx] + 1);
if line.first_fetch_cck.is_none() {
line.first_fetch_cck = Some(f.cck);
line.run_origin_cck = Some(f.cck.saturating_sub(u16::from(f.counter)));
if !run_carried_in {
line.run_origin_cck = Some(f.cck.saturating_sub(u16::from(f.counter)));
}
}
}
line
Expand Down Expand Up @@ -572,6 +582,63 @@ mod tests {
assert_eq!(table.first_fetch_cck, None);
}

#[test]
fn run_carried_across_the_line_wrap_reports_no_origin() {
let mut bus = empty_bus();
bus.agnus.dmacon = DMACON_DMAEN | DMACON_BPLEN;
bus.denise.diwstrt = 0x2C81;
bus.denise.diwstop = 0x2CC1;
// A start past the hardware stop arms a run the missed RHW cannot
// stop: it wraps through horizontal blanking into the next line.
bus.denise.ddfstrt = 0x00E0;
bus.denise.ddfstop = 0x00FF;
bus.denise.bplcon0 = 0x4200;
// Roll over from a line above the vertical window so the armed
// line starts from a clean (not carried) state.
bus.agnus.vpos = 0x2C;
bus.ddf_seq_on_line_rollover(0x2B);
{
let table = bus.ddf_seq_line_table();
assert_eq!(table.run_origin_cck, Some(0xE0));
assert!(table.end_state.bprun, "run carries across the wrap");
}
bus.agnus.vpos = 0x2D;
bus.ddf_seq_on_line_rollover(0x2C);
let table = bus.ddf_seq_line_table();
// The wrapped line fetches from its start, but the tail is not a
// comparator-anchored origin: the renderer keeps the register view.
assert!(table.first_fetch_cck.is_some());
assert_eq!(table.run_origin_cck, None);
}

#[test]
fn start_below_hard_window_reports_its_raw_origin_on_the_armed_line() {
let mut bus = empty_bus();
bus.agnus.dmacon = DMACON_DMAEN | DMACON_BPLEN;
bus.denise.diwstrt = 0x2C81;
bus.denise.diwstop = 0x2CC1;
bus.denise.ddfstrt = 0x0010;
bus.denise.ddfstop = 0x0010;
bus.denise.bplcon0 = 0x4200;
// The rolled-over line (above the vertical window) fires the $10
// comparator with SHW still down, so nothing fetches; SHW armed at
// $18 survives into this line.
bus.agnus.vpos = 0x2C;
bus.ddf_seq_on_line_rollover(0x2B);
{
// The surviving SHW latch arms the run at the raw $10 grid and
// the missed stop drains through the hardware stop.
let table = bus.ddf_seq_line_table();
assert_eq!(table.run_origin_cck, Some(0x10));
assert_eq!(table.words_per_plane[0], 26);
}
bus.agnus.vpos = 0x2D;
bus.ddf_seq_on_line_rollover(0x2C);
// The completed run cleared SHW: the next line starts nothing.
let table = bus.ddf_seq_line_table();
assert_eq!(table.first_fetch_cck, None);
}

#[test]
fn mid_line_stop_rewrite_reaches_the_walk() {
let mut bus = empty_bus();
Expand Down
59 changes: 59 additions & 0 deletions src/chipset/ddf_sequencer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,65 @@ mod tests {
assert_eq!(fetches.first().map(|f| f.cck), Some(0x61));
}

#[test]
fn ocs_start_below_hard_window_runs_on_alternating_lines_from_its_raw_grid() {
// DDFSTRT below the hardwired start ($10 < $18): the comparator
// fires while SHW is still down, so a fresh line starts no run. SHW
// set at $18 survives the line end on OCS (only a completed fetch
// run clears it), so the NEXT line's $10 match arms a run anchored
// at the raw $10 grid; the missed DDFSTOP leaves it to the RHW
// drain ($D8 unit). The run then alternates: line with run clears
// SHW, line without re-arms it. Hardware-verified by the vAmigaTS
// Agnus/DDF oldhwstop3/4 A500 photos (via the vAmiga sequencer).
let mut state = ready_state(lores4());
// Preceding line with a standard window: its completed run leaves
// SHW cleared.
let _ = walk_static(false, 0x60, 0xA0, &mut state);
assert!(!state.shw);

let first = walk_static(false, 0x10, 0x10, &mut state);
assert!(first.is_empty(), "fresh line: SHW still down at $10");
assert!(state.shw, "SHW armed at $18 survives the line end");

let second = walk_static(false, 0x10, 0x10, &mut state);
assert_eq!(
second.first().map(|f| f.cck),
Some(0x11),
"run anchors at the raw $10 unit, not the hard start"
);
assert_eq!(second.last().map(|f| f.cck), Some(0xDF), "RHW drain");
assert_eq!(words_for_plane(&second, 0), 26);
assert!(!state.shw, "the completed run clears SHW again");

let third = walk_static(false, 0x10, 0x10, &mut state);
assert!(third.is_empty(), "alternating lines stay empty");

// A reachable DDFSTOP still ends the armed run at its position.
let stopped = walk_static(false, 0x10, 0xA0, &mut state);
assert_eq!(stopped.first().map(|f| f.cck), Some(0x11));
assert_eq!(stopped.last().map(|f| f.cck), Some(0xA7));
assert_eq!(words_for_plane(&stopped, 0), 19);
}

#[test]
fn ecs_start_below_hard_window_latches_and_runs_every_line_from_shw() {
// Same registers on ECS: BPHSTART is a latch, so the $10 match arms
// it every line and the run starts at the hard window ($18) on
// every line (SHW is cleared at each line end on ECS).
let mut state = ready_state(lores4());
let _ = walk_static(true, 0x60, 0xA0, &mut state);

for line in 0..3 {
let fetches = walk_static(true, 0x10, 0xA0, &mut state);
assert_eq!(
fetches.first().map(|f| f.cck),
Some(0x19),
"line {line}: run starts at the hard window start"
);
assert_eq!(fetches.last().map(|f| f.cck), Some(0xA7), "line {line}");
}
}

#[test]
fn mid_line_stop_rewrite_before_match_moves_the_stop() {
// A DDFSTOP rewrite landing before the old value matches replaces
Expand Down
93 changes: 74 additions & 19 deletions src/video/bitplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1183,14 +1183,18 @@ impl ControlState {
// colour-clock origin, not the hard DDF start $18.
let align = |hpos: i32| -> i32 {
let gulp = self.fetch_period() as i32;
let aligned = if self.fetch_quantum() == 1 {
if self.fetch_quantum() == 1 {
// FMODE=0 placement rounds UP to the gulp grid and stays
// linear below the hardwired start window: a run armed from
// a DDFSTRT below $18 (surviving SHW latch) places its
// picture at the raw grid position, left of the standard
// slots (vAmigaTS Agnus/DDF oldhwstop3/4 A500 photos).
hpos.div_euclid(gulp) * gulp + if hpos.rem_euclid(gulp) != 0 { gulp } else { 0 }
} else {
hpos.div_euclid(gulp) * gulp
};
// Clamped to the DDF hard start: placement before the first usable
// fetch position is not visible.
aligned.max(BITPLANE_DDF_HARD_START as i32)
// Wide-FMODE gulps clamp to the DDF hard start: placement
// before the first usable fetch position is not visible.
(hpos.div_euclid(gulp) * gulp).max(BITPLANE_DDF_HARD_START as i32)
}
};
let ddf_native_shift = (align(effective_ddf_start_hpos(
self.agnus_revision,
Expand Down Expand Up @@ -2413,13 +2417,22 @@ fn line_control_at_x(

/// Rewrite a row's DDFSTRT/DDFSTOP so the register-derived fetch geometry
/// matches the captured sequencer run (origin colour clock + word count).
/// FMODE=0 only; runs that wrap through horizontal blanking (origin before
/// the hardware start window) keep the register view.
/// FMODE=0 only. A run origin below the hardware start window ($18) is kept
/// as-is: a DDFSTRT comparator match below $18 starts the run at its raw
/// position when the sequencer's SHW latch survived from the previous line,
/// and the fetched picture sits linearly left of the standard grid
/// (hardware-verified by the vAmigaTS Agnus/DDF oldhwstop3/4 A500 photos).
fn apply_captured_fetch_geometry(control: &mut ControlState, origin: u16, words: usize) {
if control.fetch_quantum() != 1 || words == 0 {
return;
}
if origin < BITPLANE_DDF_HARD_START {
// DDFSTRT 0 doubles as the "no window programmed" sentinel throughout
// the register-derived paths, so a run genuinely armed at colour clock
// 0 (DDFSTRT=$00 with a surviving SHW latch) cannot be expressed as a
// synthesized window; keep the register view. TODO: replace the zero
// sentinel with an explicit window-valid flag so origin-0 runs can be
// placed exactly.
if origin == 0 {
return;
}
let words_per_unit = (8 / control.fetch_cck_per_word() as usize).max(1);
Expand Down Expand Up @@ -3477,10 +3490,20 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult {
// row's DDFSTRT/DDFSTOP from them so every register-derived fetch and
// paint derivation (word plans, words-per-row, picture origin) agrees
// with what the DMA actually did.
for (y, control) in base_controls.iter_mut().enumerate() {
for y in 0..base_controls.len() {
if let Some(row) = captured_bitplane_rows.get(y).and_then(Option::as_ref) {
if let Some(origin) = row.fetch_origin_cck {
apply_captured_fetch_geometry(control, origin, row.words_per_row);
apply_captured_fetch_geometry(&mut base_controls[y], origin, row.words_per_row);
// The row's control segments (mid-row register writes) must
// agree as well: the sequencer already folded those writes
// into the captured run, and a segment still carrying the
// raw DDFSTRT/DDFSTOP values would re-derive a different
// word count or picture origin for the same row (the
// oldhwstop band-entry rows, where the copper rewrite lands
// at the start of the next line's segment list).
for segment in &mut control_segments[y] {
apply_captured_fetch_geometry(&mut segment.control, origin, row.words_per_row);
}
}
}
}
Expand Down Expand Up @@ -3646,7 +3669,18 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult {
if !line_has_valid_ddf_window(control, row_control_segments) {
continue;
}
let words_per_row = line_words_per_row(base_controls[y], row_control_segments);
// A captured sequencer run with a comparator-anchored origin is
// the authority for the row's word count: mid-row DDF rewrites
// logged as control segments describe windows the sequencer
// never ran (the oldhwstop band-entry rows), and the register-
// derived maximum would otherwise disagree with the captured
// planes and push the row onto the fallback re-fetch path.
let words_per_row = captured_bitplane_rows
.get(y)
.and_then(Option::as_ref)
.filter(|row| row.fetch_origin_cck.is_some() && row.words_per_row != 0)
.map(|row| row.words_per_row)
.unwrap_or_else(|| line_words_per_row(base_controls[y], row_control_segments));
let beam_y = visible_line0 as u32 + y as u32;
replay_bitplane_pointer_events_through_beam(
render_events,
Expand Down Expand Up @@ -3679,6 +3713,25 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult {
if captured_row.is_none() && !control.bitplane_dma_enabled() {
continue;
}
// A DDFSTRT comparator below the hardwired start window ($18)
// only arms a run when the sequencer's SHW latch survived from
// the previous line (OCS clears it when a fetch run completes,
// so such runs happen on alternating lines at most). The DMA
// capture records which lines actually ran; a line without a
// captured fetch fetched nothing, so the register-derived
// window must not synthesize a picture for it (vAmigaTS
// Agnus/DDF oldhwstop3/4: the black no-run lines between the
// early-origin runs).
if captured_row.is_none()
&& control.fetch_quantum() == 1
&& (1..BITPLANE_DDF_HARD_START).contains(&effective_ddf_start_hpos_raw(
control.agnus_revision,
control.hires() || control.shres(),
control.ddfstrt,
))
{
continue;
}
// Denise's playfield output arms on BPL1DAT loads. A mode whose
// fetch table carries no plane streams at all (overprogrammed
// hi-res/SHRES BPU) never loads BPL1DAT, so nothing displays -
Expand Down Expand Up @@ -4906,11 +4959,12 @@ fn effective_ddf_stop_hpos(revision: AgnusRevision, hires: bool, raw: u16) -> u1

fn effective_ddf_start_hpos(revision: AgnusRevision, hires: bool, raw: u16) -> u16 {
let start = effective_ddf_start_hpos_raw(revision, hires, raw);
if start == 0 {
0
} else {
start.clamp(BITPLANE_DDF_HARD_START, BITPLANE_DDF_HARD_STOP)
}
// A DDFSTRT below the hardwired start window ($18) is NOT clamped: the
// comparator match position anchors the fetch grid at its raw value
// whenever the sequencer arms a run at all (the SHW latch can survive
// from the previous line), so word counts and picture placement stay
// linear in the raw register (vAmigaTS Agnus/DDF oldhwstop3/4 photos).
start.min(BITPLANE_DDF_HARD_STOP)
}

fn effective_ddf_window(
Expand All @@ -4920,7 +4974,9 @@ fn effective_ddf_window(
ddfstop: u16,
harddis: bool,
) -> Option<(u16, u16)> {
let (hard_start, hard_stop) = ddf_hard_bounds(harddis);
// The start is not floored to the hardwired window: a DDFSTRT below $18
// keeps its raw fetch-grid position (see `effective_ddf_start_hpos`).
let (_, hard_stop) = ddf_hard_bounds(harddis);
let start = effective_ddf_start_hpos_raw(revision, hires, ddfstrt);
let mut stop = effective_ddf_stop_hpos(revision, hires, ddfstop);
if start == 0 || start > hard_stop {
Expand All @@ -4929,7 +4985,6 @@ fn effective_ddf_window(
if matches!(revision, AgnusRevision::Ocs) && stop == start {
stop = hard_stop;
}
let start = start.max(hard_start);
let stop = stop.min(hard_stop);
(stop >= start).then_some((start, stop))
}
Expand Down
33 changes: 31 additions & 2 deletions src/video/bitplane/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1636,12 +1636,16 @@ fn ddf_overscan_fetches_still_advance_bitplane_pointers() {
assert_eq!(state.words_per_row(true, 640), 42);
assert_eq!(state.words_per_row(false, 320), 21);

let hard_clipped = RenderState {
// A DDFSTRT below the hardwired start keeps its raw fetch-grid anchor
// (an armed run really starts at $10; vAmigaTS oldhwstop3/4), so the
// word count stays linear in the raw value; only the stop is clipped
// to the hardware stop window.
let early_start = RenderState {
ddfstrt: 0x0010,
ddfstop: 0x00E0,
..blank_state()
};
assert_eq!(hard_clipped.words_per_row(false, 320), 25);
assert_eq!(early_start.words_per_row(false, 320), 26);

let ocs_equal = RenderState {
ddfstrt: 0x0038,
Expand Down Expand Up @@ -6795,3 +6799,28 @@ fn unchanged_control_writes_do_not_create_render_segments() {
assert_eq!(control_segments[line][0].x, 32);
assert_eq!(control_segments[line][0].control.bplcon1, 0x0022);
}

#[test]
fn fetch_origin_below_hard_start_stays_linear_in_ddfstrt() {
// A run armed from a DDFSTRT below the hardwired start window ($18)
// anchors its fetch grid at the raw comparator position (vAmigaTS
// Agnus/DDF oldhwstop3/4 A500 photos: the DDFSTRT=$10 rows sit exactly
// ($38-$10)*2 lo-res pixels left of the standard picture, with the
// early words running through the left border). The placement shift
// must stay linear below $18 instead of clamping to the hard start.
let mk = |strt: u16| RenderState {
ddfstrt: strt,
ddfstop: 0x00D0,
diwstrt: 0x2C81,
diwstop: 0x2CC1,
..blank_state()
};
assert_eq!(mk(0x38).fetch_origin_native_shift(false, 2), 0);
// Early-DDF placement is linear (diw1-calibrated) ...
assert_eq!(mk(0x30).fetch_origin_native_shift(false, 2), 16);
// ... and keeps the same line below the hard start: 5 words of the
// $10-anchored row lie left of the standard window edge.
assert_eq!(mk(0x10).fetch_origin_native_shift(false, 2), 80);
assert_eq!(mk(0x10).native_x_offset(false, 2), 80);
assert_eq!(mk(0x10).fetch_start_native_x(false, 2), 0);
}
Loading