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
12 changes: 8 additions & 4 deletions crates/copperline-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use copperline::config::{Config, Overscan};
use copperline::emulator::{build_machine, Emulator};
use copperline::serial::{ChannelSerialHandle, ChannelSerialSink};
use copperline::video::deinterlace::Deinterlacer;
use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_FB_PIXELS};
use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_CANVAS_PIXELS};
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
Expand Down Expand Up @@ -242,7 +242,7 @@ impl WebEmu {
Ok(WebEmu {
emu,
audio,
fb: vec![0u32; MAX_FB_PIXELS],
fb: vec![0u32; MAX_CANVAS_PIXELS],
deinterlacer: Deinterlacer::new(),
present: Vec::new(),
present_width: FB_WIDTH,
Expand Down Expand Up @@ -322,10 +322,13 @@ impl WebEmu {
let visible_start_vpos = self.emu.bus().frame_visible_start_vpos();
bitplane::render(self.emu.bus_mut(), &mut self.fb);
let geometry = self.emu.bus().frame_geometry();
let canvas_scale = self.emu.bus().frame_canvas_scale();
let field_rows = present_common::post_process_rendered_field(
&mut self.fb,
geometry,
canvas_scale,
self.emu.bus().frame_presentation_h_window(),
self.emu.bus().frame_presentation_v_window(),
visible_start_vpos,
0,
Overscan::Tv,
Expand All @@ -334,6 +337,7 @@ impl WebEmu {
self.deinterlacer.push_field(
&self.fb,
field_rows,
FB_WIDTH * canvas_scale,
base.bplcon0 & 0x0004 != 0,
base.long_field,
!geometry.programmable,
Expand All @@ -360,9 +364,9 @@ impl WebEmu {
dst.copy_from_slice(&woven[src..src + present_common::TV_PAL_CAPTURED_WIDTH]);
}
} else {
self.present_width = FB_WIDTH;
self.present_width = self.deinterlacer.output_width();
self.present_rows = woven_rows;
let active = woven_rows * FB_WIDTH;
let active = woven_rows * self.present_width;
self.present.resize(active, 0);
self.present.copy_from_slice(&woven[..active]);
}
Expand Down
7 changes: 4 additions & 3 deletions docs/debugger/control.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,10 @@ Diagnostic captures: `trace.start {path?, max_lines?}`, `trace.status`,

State and capture: `state.save {path}`, `state.load {path}` (re-arms
the reverse-debug ring on the loaded timeline), `capture.screenshot
{path?}` (raw framebuffer PNG, 716 pixels wide; with an active RTG
screen it is the board frame downsampled to that width at the board's
native row count), `capture.digest`
{path?}` (raw framebuffer PNG, 716 pixels wide -- 1432 for a
programmable super-hi-res scan's 35 ns canvas; with an active RTG
screen it is the board frame downsampled to 716 at the board's
native row count; the response reports the width), `capture.digest`
(FNV-1a hash of the rendered frame -- the cheap change-detection
primitive, identical in both server modes), `machine.reset
{kind: "warm"|"cold"}`.
Expand Down
4 changes: 3 additions & 1 deletion docs/guide/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ through wasm-bindgen; the page's JavaScript drives everything from
symmetric overscan margins, so the canvas carries none of the bezel-mask
black columns of the full framebuffer; non-standard frames (true
overscan, NTSC, programmable scans) keep the full 716-pixel width, as on
the desktop (see [the presentation internals](../internals/video.md)).
the desktop, and a programmable super-hi-res scan carries its double
(1432-pixel, 35 ns pitch) canvas straight to the browser canvas (see
[the presentation internals](../internals/video.md)).
There is no wgpu in the build, which keeps the wasm
around 1.4 MiB (about 0.6 MiB over the wire).
- **Audio**: Paula's 44.1 kHz stereo mix is drained once per animation frame
Expand Down
33 changes: 27 additions & 6 deletions docs/internals/video.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,33 @@ 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).
windows against the zero origin). A programmable frame is presented like a
multisync monitor on both axes: when the mode programs its sync pulses, the
glass shows the line from the HSYNC trailing edge to the next pulse
(VARHSYEN) and the frame from the VSYNC trailing edge to the next pulse
(VARVSYEN), so the picture sits where the mode's own porches place it, with
blanked border rows above and below the programmed vertical window. Without
a programmed horizontal sync the whole line maps onto the glass
time-linearly (each colour clock covers 227/line_cck of a standard clock's
width); without a programmed vertical sync the captured rows keep covering
the full glass height.

Super-hi-res output: Denise/Lisa resolve every 35 ns sample through the
full palette pipeline (ECS Denise carries at most two bitplanes into
SHRES; AGA Lisa runs the complete 8-bit index path, e.g. the 4-plane
FMODE=3 Linux amifb console). A programmable scan that drives SHRES
renders a double-width canvas at the 35 ns pixel pitch
(`canvas_scale_for`): each of the two per-column samples is emitted as
its own framebuffer pixel, and the presentation, screenshots, and the
browser canvas carry the doubled width through (the desktop window shows
it 1:1 on a 2x HiDPI texture). Every logical coordinate in the replay --
comparators, fetch origins, sprite positions, the collision buffers --
stays in the classic hi-res-pitch domain; only the framebuffer writes
fan out, with non-SHRES pixels and sprites doubled. Standard 15 kHz
scans keep the classic single-width canvas byte-identical; their SHRES
screens still blend each 35 ns pair into the 70 ns pixel. Sprite
positions remain at hi-res resolution on either canvas (true 35 ns
sprite placement is a remaining TODO).

Two vertical edge cases the replay honours:

Expand Down
8 changes: 6 additions & 2 deletions src/bin/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use copperline::config::{Config, ConfigOverrides, Overscan};
use copperline::emulator::build_machine;
use copperline::timebase::Instant;
use copperline::video::deinterlace::Deinterlacer;
use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_FB_PIXELS};
use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_CANVAS_PIXELS};
use std::path::PathBuf;

struct StdoutLogger;
Expand Down Expand Up @@ -121,7 +121,7 @@ fn main() -> Result<()> {
emu.set_paced(false);
emu.reset_stats();

let mut fb = vec![0u32; MAX_FB_PIXELS];
let mut fb = vec![0u32; MAX_CANVAS_PIXELS];
let mut deinterlacer = Deinterlacer::new();
let mut last_rendered: Option<u64> = None;
let mut rendered_frames: u64 = 0;
Expand All @@ -141,10 +141,13 @@ fn main() -> Result<()> {
let visible_start_vpos = emu.bus().frame_visible_start_vpos();
bitplane::render(emu.bus_mut(), &mut fb);
let geometry = emu.bus().frame_geometry();
let canvas_scale = emu.bus().frame_canvas_scale();
let field_rows = present_common::post_process_rendered_field(
&mut fb,
geometry,
canvas_scale,
emu.bus().frame_presentation_h_window(),
emu.bus().frame_presentation_v_window(),
visible_start_vpos,
0,
Overscan::Tv,
Expand All @@ -153,6 +156,7 @@ fn main() -> Result<()> {
deinterlacer.push_field(
&fb,
field_rows,
FB_WIDTH * canvas_scale,
base.bplcon0 & 0x0004 != 0,
base.long_field,
!geometry.programmable,
Expand Down
52 changes: 52 additions & 0 deletions src/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,10 @@ pub struct Bus {
current_frame_presentation_h_window: Option<(i32, u32)>,
#[serde(skip)]
last_frame_presentation_h_window: Option<(i32, u32)>,
#[serde(skip)]
current_frame_presentation_v_window: Option<(i32, u32)>,
#[serde(skip)]
last_frame_presentation_v_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 @@ -2405,6 +2409,8 @@ impl Bus {
),
current_frame_presentation_h_window: None,
last_frame_presentation_h_window: None,
current_frame_presentation_v_window: None,
last_frame_presentation_v_window: None,
last_frame_geometry: FrameGeometry::standard(
RENDER_VISIBLE_START_VPOS,
PAL_LINES,
Expand Down Expand Up @@ -3062,6 +3068,8 @@ impl Bus {
FrameGeometry::standard(RENDER_VISIBLE_START_VPOS, frame_lines, false);
self.current_frame_presentation_h_window = None;
self.last_frame_presentation_h_window = None;
self.current_frame_presentation_v_window = None;
self.last_frame_presentation_v_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 @@ -3288,6 +3296,8 @@ impl Bus {
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.current_frame_presentation_v_window = self.compute_presentation_v_window();
self.last_frame_presentation_v_window = self.current_frame_presentation_v_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 @@ -4662,6 +4672,37 @@ impl Bus {
Some((src_x0, visible_cck * 4))
}

/// The vertical glass window for presenting a programmable scan, as
/// (captured rows above the glass top negated, glass rows): a multisync
/// monitor locks its vertical deflection to the programmed sync pulse,
/// so the glass covers the frame from the sync trailing edge to the
/// next pulse and the picture sits where the mode's own porches place
/// it. The offset is the captured window's first line relative to
/// VSSTOP. None on standard frames or when the mode leaves vertical
/// sync unprogrammed (the presentation then keeps stretching the
/// captured rows over the full glass height).
pub fn frame_presentation_v_window(&self) -> Option<(i32, u32)> {
if self.last_frame_render_base.is_some() {
self.last_frame_presentation_v_window
} else {
self.current_frame_presentation_v_window
}
}

/// Vertical counterpart of `compute_presentation_h_window`, latched at
/// the same frame wrap.
pub(crate) fn compute_presentation_v_window(&self) -> Option<(i32, u32)> {
let geometry = self.current_frame_geometry;
if !geometry.programmable {
return None;
}
let (vsstrt, vsstop) = self.agnus.programmable_vsync_window()?;
let sync_len = vsstop - vsstrt;
let glass_lines = geometry.frame_lines.saturating_sub(sync_len).max(1);
let offset = geometry.visible_start_vpos as i32 - vsstop as i32;
Some((offset, glass_lines))
}

pub fn frame_geometry(&self) -> FrameGeometry {
if self.last_frame_render_base.is_some() {
self.last_frame_geometry
Expand All @@ -4670,6 +4711,17 @@ impl Bus {
}
}

/// Canvas supersample factor of the frame the renderer is about to
/// draw (see `bitplane::canvas_scale_for`): callers that render
/// straight from the bus size their buffer and stride with this.
pub fn frame_canvas_scale(&self) -> usize {
crate::video::bitplane::canvas_scale_for(
self.frame_geometry().programmable,
self.frame_render_base().bplcon0,
self.frame_render_events(),
)
}

/// Beam-line count for the frame the renderer is about to draw. This is
/// latched with `FrameGeometry`; the fallback covers old in-process
/// snapshots whose transient geometry field predates `frame_lines`.
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 @@ -25,6 +25,7 @@ impl Bus {
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_presentation_v_window = self.current_frame_presentation_v_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 @@ -143,6 +144,7 @@ impl Bus {
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();
self.current_frame_presentation_v_window = self.compute_presentation_v_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 @@ -801,6 +801,23 @@ impl Agnus {
(strt < stop && stop <= u32::from(self.htotal)).then_some((strt, stop))
}

/// The programmed vertical sync pulse (VSSTRT..VSSTOP, lines) of a
/// VARBEAMEN scan whose vertical 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_vsync_window(&self) -> Option<(u32, u32)> {
const BEAMCON0_VARVSYEN: u16 = 1 << 9;
if !self.revision.is_ecs()
|| self.beamcon0 & BEAMCON0_VARBEAMEN == 0
|| self.beamcon0 & BEAMCON0_VARVSYEN == 0
{
return None;
}
let strt = u32::from(self.vsstrt);
let stop = u32::from(self.vsstop);
(strt < stop && stop <= u32::from(self.vtotal)).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
30 changes: 13 additions & 17 deletions src/control/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::debugger::{BreakCond, CondOp, CondOperand, DebugStop, WatchSource};
use crate::emulator::Emulator;
use crate::inputsched::JoyState;
use crate::timetravel::ReverseOutcome;
use crate::video::{FB_WIDTH, MAX_FB_PIXELS};
use crate::video::{FB_WIDTH, MAX_CANVAS_PIXELS};
use serde_json::{json, Map, Value};
use std::path::PathBuf;

Expand Down Expand Up @@ -1424,20 +1424,15 @@ pub fn exec_core(emu: &mut Emulator, ctx: &mut SessionCtx, op: &CoreOp) -> Resul
}
CoreOp::Digest => Ok(digest_value(emu)),
CoreOp::Screenshot { path } => {
let (fb, lines) = render_frame(emu);
let (fb, lines, width) = render_frame(emu);
let path = path
.clone()
.unwrap_or_else(crate::screenshot::auto_filename);
crate::screenshot::save(
&path,
&fb[..FB_WIDTH * lines],
FB_WIDTH as u32,
lines as u32,
)
.map_err(|e| CtlError::io(format!("saving screenshot: {e:#}")))?;
crate::screenshot::save(&path, &fb[..width * lines], width as u32, lines as u32)
.map_err(|e| CtlError::io(format!("saving screenshot: {e:#}")))?;
Ok(json!({
"path": path.display().to_string(),
"width": FB_WIDTH,
"width": width,
"height": lines,
}))
}
Expand Down Expand Up @@ -1713,20 +1708,21 @@ fn wave_status_value(status: &crate::waveform::WaveStatus) -> Value {
/// display path, returning the buffer and its visible line count. Both
/// `capture.digest` and `capture.screenshot` use this in BOTH server
/// modes, so captures are mode-identical and comparable.
fn render_frame(emu: &Emulator) -> (Vec<u32>, usize) {
fn render_frame(emu: &Emulator) -> (Vec<u32>, usize, usize) {
// An RTG board driving the display supersedes the chipset output,
// exactly as the window presentation does.
let mut fb = Vec::new();
let mut scratch = Vec::new();
if let Some((rows, _, _)) =
crate::video::present_common::compose_rtg_present(emu.bus(), &mut scratch, &mut fb)
{
return (fb, rows);
return (fb, rows, FB_WIDTH);
}
fb = vec![0u32; MAX_FB_PIXELS];
fb = vec![0u32; MAX_CANVAS_PIXELS];
crate::video::bitplane::render_display_only(emu.bus(), &mut fb);
let lines = emu.bus().frame_geometry().visible_lines;
(fb, lines)
let width = FB_WIDTH * emu.bus().frame_canvas_scale();
(fb, lines, width)
}

/// FNV-1a over the framebuffer words (little-endian byte order), for
Expand All @@ -1745,12 +1741,12 @@ fn fnv1a64(words: &[u32]) -> u64 {
/// Frame digest payload shared by the request/response capture method and the
/// opt-in streaming frame notification.
pub(crate) fn digest_value(emu: &Emulator) -> Value {
let (fb, lines) = render_frame(emu);
let digest = fnv1a64(&fb[..FB_WIDTH * lines]);
let (fb, lines, width) = render_frame(emu);
let digest = fnv1a64(&fb[..width * lines]);
json!({
"algo": "fnv1a64",
"digest": format!("{digest:016x}"),
"width": FB_WIDTH,
"width": width,
"height": lines,
"frame": emu.bus().emulated_frames(),
})
Expand Down
Loading
Loading