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
1 change: 1 addition & 0 deletions crates/copperline-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ impl WebEmu {
let field_rows = present_common::post_process_rendered_field(
&mut self.fb,
geometry,
self.emu.bus().frame_presentation_h_window(),
visible_start_vpos,
0,
Overscan::Tv,
Expand Down
13 changes: 11 additions & 2 deletions docs/internals/video.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,16 @@ acts as the hardware display-window flip-flop: it decides when the frame's
chip-RAM snapshot and bitplane DMA capture begin, but changing DIWSTRT later
in the field does not recenter the already-visible top border. Programmable
VARBEAMEN scans instead use their programmed visible window as the render
origin.
origin. Under VARBEAMEN, Denise's horizontal counter restarts at 0 with the
programmable line rather than free-running at the standard 15 kHz phase, so
the DIW and sprite comparators sit later on the canvas by that origin
difference (Linux/m68k amifb and the KS3.1 DblPAL screen both program their
windows against the zero origin). Horizontally, a programmable frame is
presented like a multisync monitor: when the mode programs its sync pulse
(VARHSYEN), the glass shows the line from the HSYNC trailing edge to the
next pulse, so the picture sits where the mode's own porches place it;
without a programmed sync the whole line maps onto the glass time-linearly
(each colour clock covers 227/line_cck of a standard clock's width).

Two vertical edge cases the replay honours:

Expand Down Expand Up @@ -299,7 +308,7 @@ either the render worker or the synchronous fallback.

The frontend-independent half of this pass lives in
`video/present_common.rs`: the post-render pipeline (vertical/horizontal
recentring, the TV bezel mask, programmable-scan stretch) plus the
recentring, the TV bezel mask, programmable-scan presentation) plus the
standard-window and TV-aperture constants and the geometry predicates that
key on them. `window/present.rs` re-exports everything there, so the
desktop path is unchanged; headless consumers -- `cpu.rs`'s debug
Expand Down
1 change: 1 addition & 0 deletions src/bin/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ fn main() -> Result<()> {
let field_rows = present_common::post_process_rendered_field(
&mut fb,
geometry,
emu.bus().frame_presentation_h_window(),
visible_start_vpos,
0,
Overscan::Tv,
Expand Down
48 changes: 48 additions & 0 deletions src/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,15 @@ pub struct Bus {
/// vs ECS/AGA VARBEAMEN programmable scan); see `FrameGeometry`.
current_frame_geometry: FrameGeometry,
last_frame_geometry: FrameGeometry,
/// Sync-anchored presentation window latched at the frame wrap with the
/// geometry, so the presented frame never mixes its latched geometry
/// with sync registers rewritten after the wrap. A pure presentation
/// cache: never serialized (restored None and recomputed after a state
/// load, like `FrameGeometry::frame_lines`).
#[serde(skip)]
current_frame_presentation_h_window: Option<(i32, u32)>,
#[serde(skip)]
last_frame_presentation_h_window: Option<(i32, u32)>,
lazy_collision_vpos: u32,
lazy_collision_hpos: u32,
/// Sticky gate for the per-frame collision accumulation. Collision results
Expand Down Expand Up @@ -2394,6 +2403,8 @@ impl Bus {
PAL_LINES,
false,
),
current_frame_presentation_h_window: None,
last_frame_presentation_h_window: None,
last_frame_geometry: FrameGeometry::standard(
RENDER_VISIBLE_START_VPOS,
PAL_LINES,
Expand Down Expand Up @@ -3049,6 +3060,8 @@ impl Bus {
FrameGeometry::standard(RENDER_VISIBLE_START_VPOS, frame_lines, false);
self.last_frame_geometry =
FrameGeometry::standard(RENDER_VISIBLE_START_VPOS, frame_lines, false);
self.current_frame_presentation_h_window = None;
self.last_frame_presentation_h_window = None;
self.lazy_collision_vpos = RENDER_VISIBLE_START_VPOS;
self.lazy_collision_hpos = RENDER_COPPER_WAIT_HPOS_FB0;
self.collision_tracking_active = false;
Expand Down Expand Up @@ -3273,6 +3286,8 @@ impl Bus {
}
self.last_frame_visible_start_vpos = self.current_frame_visible_start_vpos;
self.last_frame_geometry.visible_start_vpos = self.current_frame_visible_start_vpos;
self.current_frame_presentation_h_window = self.compute_presentation_h_window();
self.last_frame_presentation_h_window = self.current_frame_presentation_h_window;
self.lazy_collision_vpos = self.current_frame_visible_start_vpos;
self.ocs_same_line_diw_start_blocked_vpos = None;
self.reset_frame_capture_buffers();
Expand Down Expand Up @@ -4614,6 +4629,39 @@ impl Bus {

/// Display geometry of the frame the renderer is about to draw
/// (the completed frame once one exists, like `frame_render_base`).
/// The horizontal glass window for presenting a programmable scan, as
/// (source x, width) in framebuffer pixels: a multisync monitor
/// anchors its visible raster at the horizontal sync pulse, showing
/// the line from the sync trailing edge to the next pulse. The
/// captured aperture starts 31 colour clocks into the line (fb x = 0
/// sits at Denise tick 62), so the sync-anchored window's origin can
/// be negative. None on standard frames or when the mode leaves sync
/// unprogrammed (the presentation then falls back to the time-linear
/// whole-line map).
pub fn frame_presentation_h_window(&self) -> Option<(i32, u32)> {
if self.last_frame_render_base.is_some() {
self.last_frame_presentation_h_window
} else {
self.current_frame_presentation_h_window
}
}

/// Compute the window from the live Agnus sync latches and the
/// just-latched frame geometry; called at the frame wrap (and after a
/// state load) so presentation always sees a consistent snapshot.
pub(crate) fn compute_presentation_h_window(&self) -> Option<(i32, u32)> {
const CAPTURE_APERTURE_START_CCK: i32 = 31;
let geometry = self.current_frame_geometry;
if !geometry.programmable {
return None;
}
let (hsstrt, hsstop) = self.agnus.programmable_hsync_window()?;
let sync_len = hsstop - hsstrt;
let visible_cck = geometry.line_cck.saturating_sub(sync_len).max(1);
let src_x0 = (hsstop as i32 - CAPTURE_APERTURE_START_CCK) * 4;
Some((src_x0, visible_cck * 4))
}

pub fn frame_geometry(&self) -> FrameGeometry {
if self.last_frame_render_base.is_some() {
self.last_frame_geometry
Expand Down
2 changes: 2 additions & 0 deletions src/bus/frame_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ impl Bus {
self.last_frame_render_base = Some(self.current_frame_render_base);
self.last_frame_visible_start_vpos = self.current_frame_visible_start_vpos;
self.last_frame_geometry = self.current_frame_geometry;
self.last_frame_presentation_h_window = self.current_frame_presentation_h_window;
self.last_frame_render_events = std::mem::take(&mut self.current_frame_render_events);
} else {
self.last_frame_render_base = None;
Expand Down Expand Up @@ -141,6 +142,7 @@ impl Bus {
// LOF; record the settled value for the field about to render.
self.current_frame_render_base.long_field = self.agnus.lof;
self.current_frame_geometry = self.compute_frame_geometry();
self.current_frame_presentation_h_window = self.compute_presentation_h_window();
if self.current_frame_geometry.programmable {
self.current_frame_visible_start_vpos = self.current_frame_geometry.visible_start_vpos;
self.lazy_collision_vpos = self.current_frame_visible_start_vpos;
Expand Down
17 changes: 17 additions & 0 deletions src/chipset/agnus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,23 @@ impl Agnus {
self.revision.is_ecs() && self.beamcon0 & BEAMCON0_LOLDIS != 0
}

/// The programmed horizontal sync pulse (HSSTRT..HSSTOP, colour
/// clocks) of a VARBEAMEN scan whose sync generator is under
/// programmable control, or None when the mode leaves sync on the
/// hardware defaults or programs a degenerate pulse.
pub fn programmable_hsync_window(&self) -> Option<(u32, u32)> {
const BEAMCON0_VARHSYEN: u16 = 1 << 8;
if !self.revision.is_ecs()
|| self.beamcon0 & BEAMCON0_VARBEAMEN == 0
|| self.beamcon0 & BEAMCON0_VARHSYEN == 0
{
return None;
}
let strt = u32::from(self.hsstrt);
let stop = u32::from(self.hsstop);
(strt < stop && stop <= u32::from(self.htotal)).then_some((strt, stop))
}

pub fn programmable_line_cck(&self) -> Option<u32> {
if !self.revision.is_ecs() || self.beamcon0 & BEAMCON0_VARBEAMEN == 0 {
return None;
Expand Down
8 changes: 8 additions & 0 deletions src/cpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,14 @@ impl M68kMachine {
&mut fb,
visible_start,
);
} else if let Some((src_x0, src_w)) = self.bus.bus.frame_presentation_h_window() {
crate::screenshot::stretch_rows_x_window(
&mut fb,
crate::video::FB_WIDTH,
geometry.visible_lines,
src_x0,
src_w,
);
} else if geometry.line_cck != 227 {
crate::screenshot::stretch_rows_x(
&mut fb,
Expand Down
34 changes: 34 additions & 0 deletions src/screenshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,40 @@ pub fn stretch_rows_x(fb: &mut [u32], width: usize, rows: usize, src_num: u32, s
}
}

/// Like [`stretch_rows_x`], but resampling an explicit source window:
/// output pixel x samples source position src_x0 + (x + 0.5) * src_w /
/// width - 0.5. The presentation uses this to show the visible interval
/// of a programmable scan the way a multisync monitor does: the glass is
/// anchored at the horizontal sync pulse, not at the capture buffer's
/// left edge, so `src_x0` may be negative (the sync tail sits left of
/// the captured aperture) and positions outside the buffer clamp to its
/// edge columns (the border colour).
pub fn stretch_rows_x_window(fb: &mut [u32], width: usize, rows: usize, src_x0: i32, src_w: u32) {
debug_assert!(fb.len() >= width * rows);
if width == 0 || src_w == 0 || (src_x0 == 0 && src_w as usize == width) {
return;
}
let mut scratch = vec![0u32; width];
for y in 0..rows {
let row = &mut fb[y * width..(y + 1) * width];
scratch.copy_from_slice(row);
for (x, out) in row.iter_mut().enumerate() {
// Source-pixel centre in 24.8 fixed point:
// src_x0 + (x + 0.5) * src_w / width - 0.5.
let pos = ((src_x0 as i64) << 8)
+ ((2 * x as i64 + 1) * src_w as i64 * 128 / width as i64 - 128);
let pos = pos.clamp(0, ((width - 1) as i64) << 8) as usize;
let src_x0i = pos >> 8;
let frac = (pos & 0xFF) as u32;
*out = if frac == 0 || src_x0i + 1 >= width {
scratch[src_x0i]
} else {
crate::video::blend_rgba(scratch[src_x0i], scratch[src_x0i + 1], frac)
};
}
}
}

/// Pick a default filename for an interactive screenshot grab.
pub fn auto_filename() -> PathBuf {
let ts = crate::timestamp::compact_now();
Expand Down
75 changes: 67 additions & 8 deletions src/video/bitplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,10 +491,15 @@ fn scan_h_window_line(
mut record: Option<&mut HWindowRow>,
) {
let free_run = beam_line < 9 && !is_ecs;
let fb_start_tick = H_COUNTER_FB_START_TICK;
// On a programmable (VARBEAMEN) scan Denise's counter restarts with
// the line instead of free-running at the standard 15 kHz phase; see
// ACTIVE_CANVAS_SHIFT_H (the shift equals H_COUNTER_LINE_ORIGIN
// there, making the line-start counter 0).
let mut counter = if free_run {
(H_COUNTER_LINE_ORIGIN + beam_line * 0x1C6) & 0x1FF
} else {
H_COUNTER_LINE_ORIGIN
H_COUNTER_LINE_ORIGIN - active_canvas_shift_h()
};
let mut hstrt = control.diw_h_start() as i32;
let mut hstop = control.diw_h_stop() as i32;
Expand All @@ -507,7 +512,7 @@ fn scan_h_window_line(
hstop = control.diw_h_stop() as i32;
seg_idx += 1;
}
if record.is_some() && tick == H_COUNTER_FB_START_TICK && *flop {
if record.is_some() && tick == fb_start_tick && *flop {
open_from = Some(0);
}
let was_open = *flop;
Expand All @@ -519,7 +524,7 @@ fn scan_h_window_line(
}
if *flop != was_open {
if let Some(row) = record.as_deref_mut() {
let fb_tick = tick - H_COUNTER_FB_START_TICK;
let fb_tick = tick - fb_start_tick;
if (0..FB_WIDTH as i32 / 2).contains(&fb_tick) {
let x = (fb_tick * 2) as usize;
if *flop {
Expand Down Expand Up @@ -821,13 +826,14 @@ impl ControlState {
if display_window_unprogrammed(self.diwstrt, self.diwstop) {
return (0, FB_WIDTH);
}
let anchor = DIW_HSTART_FB0 - active_canvas_shift_h();
let start = self.diw_h_start() as i32;
let mut stop = self.diw_h_stop() as i32;
if stop <= start {
stop += 0x100;
}
let left = ((start - DIW_HSTART_FB0).max(0) as usize * 2).min(FB_WIDTH);
let mut right = ((stop - DIW_HSTART_FB0).max(0) as usize * 2).min(FB_WIDTH);
let left = ((start - anchor).max(0) as usize * 2).min(FB_WIDTH);
let mut right = ((stop - anchor).max(0) as usize * 2).min(FB_WIDTH);
if FB_WIDTH.saturating_sub(right) <= 2 {
right = FB_WIDTH;
}
Expand Down Expand Up @@ -1268,8 +1274,19 @@ impl ControlState {
}

fn fetch_origin_native_shift(&self, diw_h_start: u16, pixel_repeat: usize) -> i32 {
let display_native_shift =
((diw_h_start as i32 - self.fetch_reference()) * 2) / pixel_repeat as i32;
// Native samples per DIW H unit (one lo-res pixel): 1 in lo-res,
// 2 in hi-res, 4 in super-hi-res. The last factor comes from the
// two native samples per framebuffer pixel in SHRES; without it a
// non-standard DIW<->DDF relation placed SHRES content at half its
// real offset (Linux amifb: DIW $45 with DDF $18, 60 units left of
// standard - the picture landed 194 native px left of the window
// and the console lost its first 24 columns; lo-res/hi-res and
// every standard-DIW SHRES screen are unchanged, the terms below
// are zero or scale-independent there).
let native_per_h_unit_num = 2 * self.native_samples_per_framebuffer_pixel() as i32;
let display_native_shift = (diw_h_start as i32 - self.fetch_reference())
* native_per_h_unit_num
/ pixel_repeat as i32;
let standard_ddf = if self.hires() || self.shres() {
0x003C
} else {
Expand Down Expand Up @@ -1331,7 +1348,9 @@ impl ControlState {
// content shifted right by (DIW_HSTART_FB0 - diw_h_start) lores pixels
// and lost its right edge off the framebuffer.
let clamped_window_native =
((DIW_HSTART_FB0 - diw_h_start as i32).max(0) * 2) / pixel_repeat as i32;
((DIW_HSTART_FB0 - active_canvas_shift_h() - diw_h_start as i32).max(0)
* native_per_h_unit_num)
/ pixel_repeat as i32;
// Lo-res FMODE=0 placement is linear in DDFSTRT: moving DDFSTRT one
// 8-cck fetch period earlier moves the picture exactly 16 lo-res
// pixels left. Real hardware confirms this (vAmigaTS
Expand Down Expand Up @@ -3231,6 +3250,9 @@ fn palette_event_sequences_equivalent(a: &[BeamRegisterWrite], b: &[BeamRegister
/// end-of-frame swap, so rendering is a pure function of this bundle.
pub struct RenderInput {
geometry: FrameGeometry,
/// Sync-anchored glass window for programmable scans
/// ([`crate::bus::Bus::frame_presentation_h_window`]).
presentation_h_window: Option<(i32, u32)>,
visible_start_vpos: u32,
palette_split: (Palette, Palette, bool),
render_base: RenderRegisterSnapshot,
Expand Down Expand Up @@ -3266,6 +3288,7 @@ impl RenderInput {
pub fn from_bus(bus: &Bus) -> Self {
Self {
geometry: bus.frame_geometry(),
presentation_h_window: bus.frame_presentation_h_window(),
visible_start_vpos: bus.frame_visible_start_vpos(),
palette_split: bus.frame_palette_split(),
render_base: bus.frame_render_base(),
Expand Down Expand Up @@ -3301,6 +3324,7 @@ impl RenderInput {
dst.extend_from_slice(src);
}
self.geometry = bus.frame_geometry();
self.presentation_h_window = bus.frame_presentation_h_window();
self.visible_start_vpos = bus.frame_visible_start_vpos();
self.palette_split = bus.frame_palette_split();
self.render_base = bus.frame_render_base();
Expand Down Expand Up @@ -3350,6 +3374,10 @@ impl RenderInput {
self.geometry
}

pub fn presentation_h_window(&self) -> Option<(i32, u32)> {
self.presentation_h_window
}

pub fn visible_start_vpos(&self) -> u32 {
self.visible_start_vpos
}
Expand Down Expand Up @@ -3424,6 +3452,30 @@ thread_local! {
const { std::cell::Cell::new((0xFF, 0xFF)) };
}

thread_local! {
/// The running render's comparator-origin shift in DIW H units,
/// captured from the [`RenderInput`] at [`render_from_input`]'s entry
/// like the debug masks. Standard scans use 0 and keep every
/// calibrated mapping bit-identical. On a programmable (VARBEAMEN)
/// scan Denise's horizontal counter restarts at 0 with the line
/// instead of free-running at the standard phase, so every
/// comparator-anchored position (the DIW window and sprite HSTART
/// matches) sits H_COUNTER_LINE_ORIGIN units later on the canvas;
/// beam-anchored positions (fetch slots, register-write landings) do
/// not move. Calibrated on two independent guests: Linux/m68k amifb
/// programs DIW $45 against HSYNC $0C..$1A, which only leaves the
/// documented back porch under a zero line-start counter (under the
/// standard phase the window would open inside the sync pulse and
/// the console's first column falls off the canvas), and the KS3.1
/// DblPAL screen's DIW $5B places its picture flush with the same
/// zero origin.
static ACTIVE_CANVAS_SHIFT_H: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
}

pub(super) fn active_canvas_shift_h() -> i32 {
ACTIVE_CANVAS_SHIFT_H.with(|shift| shift.get())
}

fn active_debug_plane_mask() -> u8 {
ACTIVE_DEBUG_MASKS.with(|masks| masks.get().0)
}
Expand All @@ -3435,6 +3487,13 @@ pub(super) fn active_debug_sprite_mask() -> u8 {
pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult {
let render_started = Instant::now();
ACTIVE_DEBUG_MASKS.with(|masks| masks.set((input.debug_plane_mask, input.debug_sprite_mask)));
ACTIVE_CANVAS_SHIFT_H.with(|shift| {
shift.set(if input.geometry.programmable {
H_COUNTER_LINE_ORIGIN
} else {
0
})
});
let mut render_timing = VideoRenderFrameTiming::default();
let mut state = RenderState::from_snapshot(input.render_base);
let geometry = input.geometry;
Expand Down
Loading
Loading