From 50bf5cc771e9f1b4d3c09d22ec1f47c304b30703 Mon Sep 17 00:00:00 2001 From: Andrea Bogazzi Date: Sat, 9 May 2026 23:36:33 +0200 Subject: [PATCH 01/18] feat: dual-mode menu core with switchable native FB reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a runtime-switchable "FB mode" alongside the original menu behavior. When status[9]=0 (default), the menu core renders the original cosine+LFSR pattern through the unchanged PAL/NTSC scandoubler timing — every existing menu surface (HDMI wallpaper compositor, OSD, F1 wallpaper cycle) keeps working. When status[9]=1, native_video_top takes over and feeds VGA from the 320x240 RGBX8888 framebuffer the HPS-side launcher writes into DDR. Carried forward from codex/zaparoo-rgbx8888-native-core (PR #2): - rtl/native_video_reader.sv — DDR burst reader with ping-pong buffers - rtl/native_video_timing.sv — 320x240 NTSC native CRT timing - rtl/native_video_top.sv — wrapper - PLL output1 20 MHz -> 27.027 MHz (required for 15.734 kHz NTSC line rate) Deliberately NOT carried forward — preserves original menu functionality: - CONF_STR title stays "MENU" (so is_menu() in Main_MiSTer still matches, F1 wallpaper cycling still works) - VIDEO_ARX/ARY stay 0/0 (no aspect-ratio change in cosine mode) - PAL/NTSC scandoubler ce_pix logic intact - Original cosine HV counters intact Mux on status[9] & native_active, with fallback to the cosine path until the first DDR frame is loaded so the output is never undriven. The DDR clear loop is removed — it was one-time boot scaffolding using the same DDRAM_* signals the native reader now owns. native_video_reader holds ddr_rd/ddr_we low when status[9]=0, so DDR is unused in the default mode. --- files.qip | 4 +- menu.sv | 88 ++++++---- rtl/native_video_reader.sv | 339 +++++++++++++++++++++++++++++++++++++ rtl/native_video_timing.sv | 96 +++++++++++ rtl/native_video_top.sv | 96 +++++++++++ rtl/pll/pll_0002.v | 2 +- 6 files changed, 592 insertions(+), 33 deletions(-) create mode 100644 rtl/native_video_reader.sv create mode 100644 rtl/native_video_timing.sv create mode 100644 rtl/native_video_top.sv diff --git a/files.qip b/files.qip index 830b5981..93247c16 100644 --- a/files.qip +++ b/files.qip @@ -1,5 +1,7 @@ set_global_assignment -name SYSTEMVERILOG_FILE rtl/sdram.sv -set_global_assignment -name SYSTEMVERILOG_FILE rtl/ddram.sv set_global_assignment -name VERILOG_FILE rtl/lfsr.v set_global_assignment -name SYSTEMVERILOG_FILE rtl/cos.sv +set_global_assignment -name SYSTEMVERILOG_FILE rtl/native_video_reader.sv +set_global_assignment -name SYSTEMVERILOG_FILE rtl/native_video_timing.sv +set_global_assignment -name SYSTEMVERILOG_FILE rtl/native_video_top.sv set_global_assignment -name SYSTEMVERILOG_FILE menu.sv diff --git a/menu.sv b/menu.sv index 5160e639..12492003 100644 --- a/menu.sv +++ b/menu.sv @@ -336,36 +336,15 @@ always @(posedge clk_sys) begin state <= state+1'd1; end 16: begin - sdram_addr <= addr[24:0]; - sdram_din <= 0; - sdram_we <= we; + sdram_we <= 0; end endcase end end -ddram ddr -( - .*, - .reset(RESET), - .dout(), - .din(0), - .rd(0), - .ready() -); - -reg we; -reg [28:0] addr = 0; - -always @(posedge clk_sys) begin - reg [4:0] cnt = 9; - - if(~RESET & cfg[15]) begin - cnt <= cnt + 1'b1; - we <= &cnt; - if(cnt == 8) addr <= addr + 1'd1; - end -end +// DDR clear loop removed: native_video_reader owns DDRAM_* signals. +// When status[9]=0 the reader is held in idle (rd=0, we=0) and DDR is unused; +// when status[9]=1 the reader takes over to fetch the linux-rendered framebuffer. //////////////////////////// MT32pi ////////////////////////////////// @@ -550,11 +529,58 @@ cos cos(vvc + {vc>>forced_scandoubler, 2'b00}, cos_out); wire [7:0] comp_v = (cos_g >= rnd_c) ? {cos_g - rnd_c, 2'b00} : 8'd0; -assign VGA_DE = ~(HBlank | VBlank); -assign VGA_HS = HSync; -assign VGA_VS = VSync; -assign VGA_G = comp_v; -assign VGA_R = comp_v; -assign VGA_B = comp_v; +// Runtime FB-mode gate driven by the HPS-side launcher via status[9]. +wire mode_zaparoo = status[9]; + +wire [7:0] native_r; +wire [7:0] native_g; +wire [7:0] native_b; +wire native_hs; +wire native_vs; +wire native_de; +wire native_active; + +native_video_top native_video +( + .clk_sys (clk_sys), + .clk_vid (CLK_VIDEO), + .ce_pix (ce_pix), + .reset (RESET), + + .ddr_busy (DDRAM_BUSY), + .ddr_burstcnt (DDRAM_BURSTCNT), + .ddr_addr (DDRAM_ADDR), + .ddr_dout (DDRAM_DOUT), + .ddr_dout_ready (DDRAM_DOUT_READY), + .ddr_rd (DDRAM_RD), + .ddr_din (DDRAM_DIN), + .ddr_be (DDRAM_BE), + .ddr_we (DDRAM_WE), + + .vga_r (native_r), + .vga_g (native_g), + .vga_b (native_b), + .vga_hs (native_hs), + .vga_vs (native_vs), + .vga_de (native_de), + .vga_hblank (), + .vga_vblank (), + .enable (mode_zaparoo), + .active (native_active) +); + +// Mode A (default): cosine+LFSR pattern drives RGB and the original PAL/NTSC +// scandoubler timing drives sync/DE. HDMI wallpaper compositor runs unchanged. +// Mode B (status[9]=1, frame ready): native_video_top drives RGB+sync from the +// linux-rendered 320x240 RGBX8888 buffer in DDR. Falls back to cosine until the +// first frame is loaded so the screen is never undriven. +wire use_native = mode_zaparoo & native_active; + +assign VGA_DE = use_native ? native_de : ~(HBlank | VBlank); +assign VGA_HS = use_native ? native_hs : HSync; +assign VGA_VS = use_native ? native_vs : VSync; +assign VGA_R = use_native ? native_r : comp_v; +assign VGA_G = use_native ? native_g : comp_v; +assign VGA_B = use_native ? native_b : comp_v; endmodule diff --git a/rtl/native_video_reader.sv b/rtl/native_video_reader.sv new file mode 100644 index 00000000..19310280 --- /dev/null +++ b/rtl/native_video_reader.sv @@ -0,0 +1,339 @@ +// Zaparoo native video DDR reader. +// DDR contract: +// 0x3A000000: control word, (frame_counter << 2) | active_buffer +// 0x3A000100: buffer 0, 320x240 RGBX8888 +// 0x3A04B100: buffer 1, 320x240 RGBX8888 + +module native_video_reader +( + input wire ddr_clk, + input wire ddr_busy, + output reg [7:0] ddr_burstcnt, + output reg [28:0] ddr_addr, + input wire [63:0] ddr_dout, + input wire ddr_dout_ready, + output reg ddr_rd, + output wire [63:0] ddr_din, + output wire [7:0] ddr_be, + output wire ddr_we, + + input wire clk_vid, + input wire ce_pix, + input wire reset, + input wire de, + input wire vblank, + input wire new_frame, + input wire new_line, + input wire [8:0] vcount, + + output reg [7:0] r_out, + output reg [7:0] g_out, + output reg [7:0] b_out, + input wire enable, + output wire frame_ready +); + +assign ddr_din = 64'd0; +assign ddr_be = 8'hFF; +assign ddr_we = 1'b0; + +localparam [28:0] CTRL_ADDR = 29'h07400000; +localparam [28:0] BUF0_ADDR = 29'h07400020; +localparam [28:0] BUF1_ADDR = 29'h07409620; +localparam [7:0] LINE_BURST = 8'd160; +localparam [28:0] LINE_STRIDE = 29'd160; +localparam [8:0] V_ACTIVE = 9'd240; +localparam [19:0] TIMEOUT_MAX = 20'hF_FFFF; + +reg [1:0] enable_sync; +always @(posedge ddr_clk) begin + if(reset) enable_sync <= 2'b0; + else enable_sync <= {enable_sync[0], enable}; +end +wire enable_ddr = enable_sync[1]; + +reg [1:0] new_frame_sync; +always @(posedge ddr_clk) begin + if(reset) new_frame_sync <= 2'b0; + else new_frame_sync <= {new_frame_sync[0], new_frame}; +end +wire new_frame_ddr = ~new_frame_sync[1] & new_frame_sync[0]; + +reg [1:0] new_line_sync; +always @(posedge ddr_clk) begin + if(reset) new_line_sync <= 2'b0; + else new_line_sync <= {new_line_sync[0], new_line}; +end +wire new_line_ddr = ~new_line_sync[1] & new_line_sync[0]; + +reg [1:0] vblank_sync; +always @(posedge ddr_clk) begin + if(reset) vblank_sync <= 2'b0; + else vblank_sync <= {vblank_sync[0], vblank}; +end +wire vblank_ddr = vblank_sync[1]; + +reg [1:0] reset_vid_sync; +always @(posedge clk_vid or posedge reset) begin + if(reset) reset_vid_sync <= 2'b11; + else reset_vid_sync <= {reset_vid_sync[0], 1'b0}; +end +wire reset_vid = reset_vid_sync[1]; + +reg frame_ready_reg; +reg [1:0] frame_ready_sync; +always @(posedge clk_vid) begin + if(reset_vid) frame_ready_sync <= 2'b0; + else frame_ready_sync <= {frame_ready_sync[0], frame_ready_reg}; +end +wire frame_ready_vid = frame_ready_sync[1]; +assign frame_ready = frame_ready_vid; + +localparam [3:0] ST_IDLE = 4'd0; +localparam [3:0] ST_POLL_CTRL = 4'd1; +localparam [3:0] ST_WAIT_CTRL = 4'd2; +localparam [3:0] ST_CHECK_CTRL = 4'd3; +localparam [3:0] ST_READ_LINE = 4'd4; +localparam [3:0] ST_WAIT_LINE = 4'd5; +localparam [3:0] ST_LINE_DONE = 4'd6; +localparam [3:0] ST_WAIT_DISPLAY = 4'd7; + +reg [3:0] state; +reg [31:0] ctrl_word; +reg [29:0] prev_frame_counter; +reg [28:0] buf_base_addr; +reg [8:0] cur_line; +reg [7:0] beat_count; +reg first_frame_loaded; +reg preloading; +reg [19:0] timeout_cnt; +reg fifo_wr; +reg [63:0] fifo_wr_data; +wire fifo_full; + +reg [3:0] fifo_aclr_cnt; +wire fifo_aclr_ddr_active = (fifo_aclr_cnt != 4'd0); +wire fifo_aclr = reset | fifo_aclr_ddr_active; + +always @(posedge ddr_clk) begin + if(reset) begin + state <= ST_IDLE; + ddr_rd <= 1'b0; + ddr_burstcnt <= 8'd1; + ddr_addr <= 29'd0; + ctrl_word <= 32'd0; + prev_frame_counter <= 30'd0; + buf_base_addr <= BUF0_ADDR; + cur_line <= 9'd0; + beat_count <= 8'd0; + first_frame_loaded <= 1'b0; + frame_ready_reg <= 1'b0; + preloading <= 1'b0; + timeout_cnt <= 20'd0; + fifo_wr <= 1'b0; + fifo_wr_data <= 64'd0; + fifo_aclr_cnt <= 4'd0; + end + else begin + fifo_wr <= 1'b0; + if(fifo_aclr_cnt != 4'd0) fifo_aclr_cnt <= fifo_aclr_cnt - 4'd1; + if(!ddr_busy) ddr_rd <= 1'b0; + + if(state == ST_WAIT_LINE && ddr_dout_ready) begin + fifo_wr <= 1'b1; + fifo_wr_data <= ddr_dout; + beat_count <= beat_count + 8'd1; + timeout_cnt <= 20'd0; + end + + case(state) + ST_IDLE: begin + if(enable_ddr && new_frame_ddr) state <= ST_POLL_CTRL; + end + + ST_POLL_CTRL: begin + if(!ddr_busy) begin + ddr_addr <= CTRL_ADDR; + ddr_burstcnt <= 8'd1; + ddr_rd <= 1'b1; + timeout_cnt <= 20'd0; + state <= ST_WAIT_CTRL; + end + end + + ST_WAIT_CTRL: begin + if(ddr_dout_ready) begin + ctrl_word <= ddr_dout[31:0]; + timeout_cnt <= 20'd0; + state <= ST_CHECK_CTRL; + end + else if(timeout_cnt == TIMEOUT_MAX) state <= ST_IDLE; + else timeout_cnt <= timeout_cnt + 20'd1; + end + + ST_CHECK_CTRL: begin + if(ctrl_word[31:2] != prev_frame_counter) begin + prev_frame_counter <= ctrl_word[31:2]; + buf_base_addr <= ctrl_word[0] ? BUF1_ADDR : BUF0_ADDR; + cur_line <= 9'd0; + preloading <= 1'b1; + fifo_aclr_cnt <= 4'd8; + if(first_frame_loaded) frame_ready_reg <= 1'b1; + state <= ST_READ_LINE; + end + else if(first_frame_loaded) begin + cur_line <= 9'd0; + preloading <= 1'b1; + fifo_aclr_cnt <= 4'd8; + state <= ST_READ_LINE; + end + else begin + state <= ST_IDLE; + end + end + + ST_READ_LINE: begin + if(!ddr_busy && !fifo_aclr_ddr_active) begin + ddr_addr <= buf_base_addr + (cur_line * LINE_STRIDE); + ddr_burstcnt <= LINE_BURST; + ddr_rd <= 1'b1; + beat_count <= 8'd0; + timeout_cnt <= 20'd0; + state <= ST_WAIT_LINE; + end + end + + ST_WAIT_LINE: begin + if(beat_count == LINE_BURST) state <= ST_LINE_DONE; + else if(timeout_cnt == TIMEOUT_MAX) state <= ST_IDLE; + else if(!ddr_dout_ready) timeout_cnt <= timeout_cnt + 20'd1; + end + + ST_LINE_DONE: begin + cur_line <= cur_line + 9'd1; + if(cur_line == V_ACTIVE - 9'd1) begin + first_frame_loaded <= 1'b1; + frame_ready_reg <= 1'b1; + preloading <= 1'b0; + state <= ST_IDLE; + end + else if(preloading && cur_line < 9'd1) begin + state <= ST_READ_LINE; + end + else begin + preloading <= 1'b0; + state <= ST_WAIT_DISPLAY; + end + end + + ST_WAIT_DISPLAY: begin + if(cur_line < V_ACTIVE && new_line_ddr && !vblank_ddr) state <= ST_READ_LINE; + end + + default: state <= ST_IDLE; + endcase + end +end + +wire [63:0] fifo_rd_data; +wire fifo_empty; +reg fifo_rd; + +dcfifo #( + .intended_device_family ("Cyclone V"), + .lpm_numwords (512), + .lpm_showahead ("ON"), + .lpm_type ("dcfifo"), + .lpm_width (64), + .lpm_widthu (9), + .overflow_checking ("ON"), + .rdsync_delaypipe (4), + .underflow_checking ("ON"), + .use_eab ("ON"), + .wrsync_delaypipe (4) +) line_fifo ( + .aclr (fifo_aclr), + .data (fifo_wr_data), + .rdclk (clk_vid), + .rdreq (fifo_rd), + .wrclk (ddr_clk), + .wrreq (fifo_wr), + .q (fifo_rd_data), + .rdempty (fifo_empty), + .wrfull (fifo_full), + .eccstatus(), + .rdfull (), + .rdusedw (), + .wrempty (), + .wrusedw () +); + +reg [63:0] pixel_word; +reg pixel_high; +reg pixel_word_valid; + +wire [31:0] pixel_low = pixel_word[31:0]; +wire [31:0] pixel_high_word = pixel_word[63:32]; + +task automatic output_pixel; + input [31:0] pixel; + begin + // linuxfb write path lands as B,G,R,X in DDR on MiSTer; swap here + // so launcher can keep doing row memcpy with no CPU-side repack. + r_out <= pixel[23:16]; + g_out <= pixel[15:8]; + b_out <= pixel[7:0]; + end +endtask + +always @(posedge clk_vid) begin + if(reset_vid) begin + fifo_rd <= 1'b0; + r_out <= 8'd0; + g_out <= 8'd0; + b_out <= 8'd0; + pixel_word <= 64'd0; + pixel_high <= 1'b0; + pixel_word_valid <= 1'b0; + end + else begin + fifo_rd <= 1'b0; + + if(ce_pix) begin + if(de && frame_ready_vid) begin + if(pixel_word_valid) begin + if(pixel_high) begin + output_pixel(pixel_high_word); + pixel_word_valid <= 1'b0; + pixel_high <= 1'b0; + end + else begin + output_pixel(pixel_low); + pixel_high <= 1'b1; + end + end + else if(!fifo_empty) begin + pixel_word <= fifo_rd_data; + pixel_word_valid <= 1'b1; + pixel_high <= 1'b1; + fifo_rd <= 1'b1; + output_pixel(fifo_rd_data[31:0]); + end + else begin + r_out <= 8'd0; + g_out <= 8'd0; + b_out <= 8'd0; + end + end + else begin + r_out <= 8'd0; + g_out <= 8'd0; + b_out <= 8'd0; + pixel_high <= 1'b0; + pixel_word_valid <= 1'b0; + end + end + end +end + +endmodule diff --git a/rtl/native_video_timing.sv b/rtl/native_video_timing.sv new file mode 100644 index 00000000..54a2aee4 --- /dev/null +++ b/rtl/native_video_timing.sv @@ -0,0 +1,96 @@ +// Zaparoo native video timing: 320x240 at 15.734 kHz from 27 MHz / 4. + +module native_video_timing +( + input wire clk, + input wire ce_pix, + input wire reset, + + output reg hsync, + output reg vsync, + output reg hblank, + output reg vblank, + output reg de, + output reg [9:0] hcount, + output reg [8:0] vcount, + output reg new_frame, + output reg new_line +); + +localparam [9:0] H_ACTIVE = 10'd320; +localparam [9:0] H_FP = 10'd14; +localparam [5:0] H_SYNC = 6'd32; +localparam [9:0] H_BP = 10'd63; +localparam [9:0] H_TOTAL = 10'd429; + +localparam [8:0] V_ACTIVE = 9'd240; +localparam [8:0] V_FP = 9'd6; +localparam [4:0] V_SYNC = 5'd3; +localparam [8:0] V_BP = 9'd13; +localparam [8:0] V_TOTAL = 9'd262; + +localparam [9:0] H_SYNC_START = H_ACTIVE + H_FP; +localparam [9:0] H_SYNC_END = H_SYNC_START + H_SYNC; +localparam [8:0] V_SYNC_START = V_ACTIVE + V_FP; +localparam [8:0] V_SYNC_END = V_SYNC_START + V_SYNC; + +always @(posedge clk) begin + if(reset) begin + hcount <= 10'd0; + vcount <= 9'd0; + hsync <= 1'b0; + vsync <= 1'b0; + hblank <= 1'b0; + vblank <= 1'b0; + de <= 1'b1; + new_frame <= 1'b0; + new_line <= 1'b0; + end + else if(ce_pix) begin + reg next_hblank; + reg next_vblank; + + new_frame <= 1'b0; + new_line <= 1'b0; + + if(hcount == H_TOTAL - 10'd1) begin + hcount <= 10'd0; + if(vcount == V_TOTAL - 9'd1) vcount <= 9'd0; + else vcount <= vcount + 9'd1; + end + else begin + hcount <= hcount + 10'd1; + end + + if(hcount == H_ACTIVE - 10'd1) hblank <= 1'b1; + else if(hcount == H_TOTAL - 10'd1) hblank <= 1'b0; + + if(hcount == H_SYNC_START - 10'd1) hsync <= 1'b1; + else if(hcount == H_SYNC_END - 10'd1) hsync <= 1'b0; + + if(hcount == H_TOTAL - 10'd1) begin + if(vcount == V_ACTIVE - 9'd1) vblank <= 1'b1; + else if(vcount == V_TOTAL - 9'd1) vblank <= 1'b0; + + if(vcount == V_SYNC_START - 9'd1) vsync <= 1'b1; + else if(vcount == V_SYNC_END - 9'd1) vsync <= 1'b0; + end + + if(hcount == H_ACTIVE - 10'd1) new_line <= 1'b1; + if(hcount == H_TOTAL - 10'd1 && vcount == V_ACTIVE - 9'd1) new_frame <= 1'b1; + + next_hblank = hblank; + if(hcount == H_ACTIVE - 10'd1) next_hblank = 1'b1; + else if(hcount == H_TOTAL - 10'd1) next_hblank = 1'b0; + + next_vblank = vblank; + if(hcount == H_TOTAL - 10'd1) begin + if(vcount == V_ACTIVE - 9'd1) next_vblank = 1'b1; + else if(vcount == V_TOTAL - 9'd1) next_vblank = 1'b0; + end + + de <= ~next_hblank & ~next_vblank; + end +end + +endmodule diff --git a/rtl/native_video_top.sv b/rtl/native_video_top.sv new file mode 100644 index 00000000..de4c0f9b --- /dev/null +++ b/rtl/native_video_top.sv @@ -0,0 +1,96 @@ +// Zaparoo native video wrapper: timing + RGBX8888 DDR reader. + +module native_video_top +( + input wire clk_sys, + input wire clk_vid, + input wire ce_pix, + input wire reset, + + input wire ddr_busy, + output wire [7:0] ddr_burstcnt, + output wire [28:0] ddr_addr, + input wire [63:0] ddr_dout, + input wire ddr_dout_ready, + output wire ddr_rd, + output wire [63:0] ddr_din, + output wire [7:0] ddr_be, + output wire ddr_we, + + output wire [7:0] vga_r, + output wire [7:0] vga_g, + output wire [7:0] vga_b, + output wire vga_hs, + output wire vga_vs, + output wire vga_de, + output wire vga_hblank, + output wire vga_vblank, + + input wire enable, + output wire active +); + +wire tim_hs; +wire tim_vs; +wire tim_hblank; +wire tim_vblank; +wire tim_de; +wire [8:0] tim_vcount; +wire tim_new_frame; +wire tim_new_line; + +native_video_timing timing +( + .clk (clk_vid), + .ce_pix (ce_pix), + .reset (reset), + .hsync (tim_hs), + .vsync (tim_vs), + .hblank (tim_hblank), + .vblank (tim_vblank), + .de (tim_de), + .hcount (), + .vcount (tim_vcount), + .new_frame (tim_new_frame), + .new_line (tim_new_line) +); + +wire frame_ready; + +native_video_reader reader +( + .ddr_clk (clk_sys), + .ddr_busy (ddr_busy), + .ddr_burstcnt (ddr_burstcnt), + .ddr_addr (ddr_addr), + .ddr_dout (ddr_dout), + .ddr_dout_ready (ddr_dout_ready), + .ddr_rd (ddr_rd), + .ddr_din (ddr_din), + .ddr_be (ddr_be), + .ddr_we (ddr_we), + + .clk_vid (clk_vid), + .ce_pix (ce_pix), + .reset (reset), + .de (tim_de), + .vblank (tim_vblank), + .new_frame (tim_new_frame), + .new_line (tim_new_line), + .vcount (tim_vcount), + + .r_out (vga_r), + .g_out (vga_g), + .b_out (vga_b), + .enable (enable), + .frame_ready (frame_ready) +); + +assign vga_hs = tim_hs; +assign vga_vs = tim_vs; +assign vga_de = tim_de; +assign vga_hblank = tim_hblank; +assign vga_vblank = tim_vblank; +assign active = enable & frame_ready; + +endmodule diff --git a/rtl/pll/pll_0002.v b/rtl/pll/pll_0002.v index 7320a578..4c7ed140 100644 --- a/rtl/pll/pll_0002.v +++ b/rtl/pll/pll_0002.v @@ -25,7 +25,7 @@ module pll_0002( .output_clock_frequency0("100.000000 MHz"), .phase_shift0("0 ps"), .duty_cycle0(50), - .output_clock_frequency1("20.000000 MHz"), + .output_clock_frequency1("27027027 Hz"), .phase_shift1("0 ps"), .duty_cycle1(50), .output_clock_frequency2("0 MHz"), From ef38ec12e6b0a8804cb2af5878f1f1acb9dfcbe9 Mon Sep 17 00:00:00 2001 From: Andrea Bogazzi Date: Sun, 10 May 2026 00:26:10 +0200 Subject: [PATCH 02/18] fix: drive both modes from the native NTSC timing for clean CRT sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit kept the original cosine timing block (H_TOTAL=638, forced_scandoubler-conditional ce_pix) but switched the PLL to 27.027 MHz. That produced a line rate of ~42 kHz scandoubled or ~21 kHz interlaced — nothing standard, so a CRT could not lock on the analog VGA output. This commit makes native_video_timing the single source of truth for sync and DE in both modes: - ce_pix is now a fixed /4 divider of CLK_VIDEO -> 6.756 MHz pixel rate -> 15.749 kHz line rate (within 0.1% of NTSC 15.734 kHz). - VGA_HS/VS/DE always come from native_video_top regardless of status[9]. - The cosine + LFSR fallback paints into the active area only; outside DE we drive black so sync stays clean. - vvc steps on native_new_frame instead of the old vc wrap; cos LUT is indexed by vcount from the shared timing. - native_video_top exposes vcount and new_frame so the cosine path can reuse the same vertical position the FB reader sees. The cosine pattern still renders (it was always intended as fallback noise), but now at NTSC-spec 320x240 timing instead of broken 27 MHz / 638-cycle timing. Sync locks on real CRTs. PAL parametrisation is deferred — native_video_timing is currently NTSC-only. forced_scandoubler is still wired from hps_io but unused; preserve it as a known placeholder for the eventual PAL/scandoubler follow-up. --- menu.sv | 128 +++++++++++++++------------------------- rtl/native_video_top.sv | 16 +++-- 2 files changed, 59 insertions(+), 85 deletions(-) diff --git a/menu.sv b/menu.sv index 12492003..781f24de 100644 --- a/menu.sv +++ b/menu.sv @@ -459,77 +459,21 @@ wire PAL = status[4]; wire FB = status[5]; wire [2:0] led = status[8:6]; -reg [9:0] hc; -reg [9:0] vc; -reg [9:0] vvc; - -reg [lfsr_n:0] rnd_reg; -wire [lfsr_n:0] rnd; - -wire [5:0] rnd_c = {rnd_reg[0],rnd_reg[1],rnd_reg[2],rnd_reg[2],rnd_reg[2],rnd_reg[2]}; - -lfsr #(lfsr_n) random(rnd); - -always @(posedge CLK_VIDEO) begin - if(forced_scandoubler) ce_pix <= 1; - else ce_pix <= ~ce_pix; - - if(ce_pix) begin - if(hc == 637) begin - hc <= 0; - if(vc == (PAL ? (forced_scandoubler ? 623 : 311) : (forced_scandoubler ? 523 : 261))) begin - vc <= 0; - vvc <= vvc + 9'd6; - end else begin - vc <= vc + 1'd1; - end - end else begin - hc <= hc + 1'd1; - end - - rnd_reg <= rnd; - end -end - -reg HBlank; -reg HSync; -reg VBlank; -reg VSync; - -reg ce_pix; +// Pixel clock: CLK_VIDEO = 27.027 MHz; ce_pix /4 = ~6.756 MHz, which gives +// an NTSC-spec 15.734 kHz line rate when fed into native_video_timing +// (H_TOTAL=429). Both the cosine fallback and the FB reader use this ce_pix. +reg [1:0] ce_div; +reg ce_pix; always @(posedge CLK_VIDEO) begin - if (hc == 529) HBlank <= 1; - else if (hc == 0) HBlank <= 0; - - if (hc == 544) begin - HSync <= 1; - - if(PAL) begin - if(vc == (forced_scandoubler ? 609 : 304)) VSync <= 1; - else if (vc == (forced_scandoubler ? 617 : 308)) VSync <= 0; - - if(vc == (forced_scandoubler ? 601 : 300)) VBlank <= 1; - else if (vc == 0) VBlank <= 0; - end - else begin - if(vc == (forced_scandoubler ? 490 : 245)) VSync <= 1; - else if (vc == (forced_scandoubler ? 496 : 248)) VSync <= 0; - - if(vc == (forced_scandoubler ? 480 : 240)) VBlank <= 1; - else if (vc == 0) VBlank <= 0; - end - end - - if (hc == 590) HSync <= 0; + if (RESET) ce_div <= 2'd0; + else ce_div <= ce_div + 2'd1; + ce_pix <= (ce_div == 2'd0); end -reg [7:0] cos_out; -wire [5:0] cos_g = cos_out[7:3]+6'd32; -cos cos(vvc + {vc>>forced_scandoubler, 2'b00}, cos_out); - -wire [7:0] comp_v = (cos_g >= rnd_c) ? {cos_g - rnd_c, 2'b00} : 8'd0; - -// Runtime FB-mode gate driven by the HPS-side launcher via status[9]. +// Native video timing + DDR reader. Timing outputs (sync, DE, vcount, frame +// edge) are the SINGLE source of truth for VGA scanout in both modes — that's +// what guarantees the CRT sees a clean 15.734 kHz line rate whether we're +// painting cosine noise or reading a Linux-rendered framebuffer. wire mode_zaparoo = status[9]; wire [7:0] native_r; @@ -538,6 +482,8 @@ wire [7:0] native_b; wire native_hs; wire native_vs; wire native_de; +wire [8:0] native_vcount; +wire native_new_frame; wire native_active; native_video_top native_video @@ -565,22 +511,46 @@ native_video_top native_video .vga_de (native_de), .vga_hblank (), .vga_vblank (), + .vga_vcount (native_vcount), + .vga_new_frame (native_new_frame), .enable (mode_zaparoo), .active (native_active) ); -// Mode A (default): cosine+LFSR pattern drives RGB and the original PAL/NTSC -// scandoubler timing drives sync/DE. HDMI wallpaper compositor runs unchanged. -// Mode B (status[9]=1, frame ready): native_video_top drives RGB+sync from the -// linux-rendered 320x240 RGBX8888 buffer in DDR. Falls back to cosine until the -// first frame is loaded so the screen is never undriven. +// Cosine + LFSR fallback noise pattern, painted into the 320x240 active area +// of the shared native timing. vvc steps once per frame; the LFSR walks every +// pixel; cos LUT is indexed by vvc + vcount so the pattern shifts vertically +// over time. Outside the active area we drive black to keep sync clean. +reg [9:0] vvc; +reg [lfsr_n:0] rnd_reg; +wire [lfsr_n:0] rnd; +wire [5:0] rnd_c = {rnd_reg[0],rnd_reg[1],rnd_reg[2],rnd_reg[2],rnd_reg[2],rnd_reg[2]}; + +lfsr #(lfsr_n) random(rnd); + +always @(posedge CLK_VIDEO) begin + if (RESET) vvc <= 10'd0; + else if (native_new_frame) vvc <= vvc + 10'd6; + if (ce_pix) rnd_reg <= rnd; +end + +reg [7:0] cos_out; +wire [5:0] cos_g = cos_out[7:3] + 6'd32; +cos cos(vvc + {native_vcount, 2'b00}, cos_out); + +wire [7:0] comp_v = (cos_g >= rnd_c) ? {cos_g - rnd_c, 2'b00} : 8'd0; + +// Mode A (default): cosine pattern paints into the native active area. +// Mode B (status[9]=1, frame ready): DDR-read RGB replaces the cosine pattern. +// Sync/DE come from the same native timing in both cases — the CRT sees one +// continuous, NTSC-spec signal regardless of which RGB source is selected. wire use_native = mode_zaparoo & native_active; -assign VGA_DE = use_native ? native_de : ~(HBlank | VBlank); -assign VGA_HS = use_native ? native_hs : HSync; -assign VGA_VS = use_native ? native_vs : VSync; -assign VGA_R = use_native ? native_r : comp_v; -assign VGA_G = use_native ? native_g : comp_v; -assign VGA_B = use_native ? native_b : comp_v; +assign VGA_DE = native_de; +assign VGA_HS = native_hs; +assign VGA_VS = native_vs; +assign VGA_R = use_native ? native_r : (native_de ? comp_v : 8'd0); +assign VGA_G = use_native ? native_g : (native_de ? comp_v : 8'd0); +assign VGA_B = use_native ? native_b : (native_de ? comp_v : 8'd0); endmodule diff --git a/rtl/native_video_top.sv b/rtl/native_video_top.sv index de4c0f9b..55cf43b9 100644 --- a/rtl/native_video_top.sv +++ b/rtl/native_video_top.sv @@ -25,6 +25,8 @@ module native_video_top output wire vga_de, output wire vga_hblank, output wire vga_vblank, + output wire [8:0] vga_vcount, + output wire vga_new_frame, input wire enable, output wire active @@ -86,11 +88,13 @@ native_video_reader reader .frame_ready (frame_ready) ); -assign vga_hs = tim_hs; -assign vga_vs = tim_vs; -assign vga_de = tim_de; -assign vga_hblank = tim_hblank; -assign vga_vblank = tim_vblank; -assign active = enable & frame_ready; +assign vga_hs = tim_hs; +assign vga_vs = tim_vs; +assign vga_de = tim_de; +assign vga_hblank = tim_hblank; +assign vga_vblank = tim_vblank; +assign vga_vcount = tim_vcount; +assign vga_new_frame = tim_new_frame; +assign active = enable & frame_ready; endmodule From 3f84a8e928f9525b98bfb66cd286a2f4667b34b2 Mon Sep 17 00:00:00 2001 From: Andrea Bogazzi Date: Sun, 10 May 2026 15:49:26 +0200 Subject: [PATCH 03/18] feat: add OSD H/V image centering offsets (+/-8 px/lines) Shifts the active image by repartitioning the native timing's front and back porches; H_TOTAL/V_TOTAL stay fixed so the CRT keeps the same line and frame rate. V blanking rebalanced from 6/3/13 to 8/3/11 to give a symmetric +/-8 budget without changing refresh rate. Co-Authored-By: Claude Opus 4.7 --- menu.sv | 17 +++++++++++++++-- rtl/native_video_timing.sv | 23 +++++++++++++++++------ rtl/native_video_top.sv | 8 +++++++- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/menu.sv b/menu.sv index 781f24de..3c983925 100644 --- a/menu.sv +++ b/menu.sv @@ -208,10 +208,16 @@ assign LED_POWER[0]= FB ? led[2] : act_cnt2[26] ? act_cnt2[25:18] > act_cnt2[7:0 `include "build_id.v" +// Image centering: 4-bit signed in OSD ordering 0,+1..+7,-8..-1 so that the +// power-on default (status bits = 0) maps to "no shift". Bit pattern matches +// 4-bit two's complement when reinterpreted as signed. localparam CONF_STR = { "MENU;UART31250,MIDI;", "-;", - "V,v",`BUILD_DATE + "O[13:10],H Offset,0,+1,+2,+3,+4,+5,+6,+7,-8,-7,-6,-5,-4,-3,-2,-1;", + "O[17:14],V Offset,0,+1,+2,+3,+4,+5,+6,+7,-8,-7,-6,-5,-4,-3,-2,-1;", + "-;", + "V,v",`BUILD_DATE }; wire forced_scandoubler; @@ -514,7 +520,14 @@ native_video_top native_video .vga_vcount (native_vcount), .vga_new_frame (native_new_frame), .enable (mode_zaparoo), - .active (native_active) + .active (native_active), + + // status[13:10] / status[17:14] are 4-bit fields whose bit pattern + // matches signed two's complement when the OSD enum is ordered + // 0,+1..+7,-8..-1 (see CONF_STR). $signed() makes the reinterpretation + // explicit at the port boundary. + .h_offset ($signed(status[13:10])), + .v_offset ($signed(status[17:14])) ); // Cosine + LFSR fallback noise pattern, painted into the 320x240 active area diff --git a/rtl/native_video_timing.sv b/rtl/native_video_timing.sv index 54a2aee4..6d46c8d3 100644 --- a/rtl/native_video_timing.sv +++ b/rtl/native_video_timing.sv @@ -1,4 +1,7 @@ // Zaparoo native video timing: 320x240 at 15.734 kHz from 27 MHz / 4. +// h_offset/v_offset (signed -8..+7) shift the image by repartitioning +// front porch and back porch. H_TOTAL/V_TOTAL are invariant, so line +// rate and frame rate are unchanged regardless of offset values. module native_video_timing ( @@ -6,6 +9,10 @@ module native_video_timing input wire ce_pix, input wire reset, + // Image centering: positive = shift right/down (FP shrinks, BP grows). + input wire signed [3:0] h_offset, // -8..+7 pixels (budget H_FP=14 / H_BP=63) + input wire signed [3:0] v_offset, // -8..+7 lines (budget V_FP=8 / V_BP=11) + output reg hsync, output reg vsync, output reg hblank, @@ -23,16 +30,20 @@ localparam [5:0] H_SYNC = 6'd32; localparam [9:0] H_BP = 10'd63; localparam [9:0] H_TOTAL = 10'd429; +// V blanking rebalanced from 6/3/13 to 8/3/11 to give symmetric ±8 budget +// while preserving V_TOTAL=262 (and thus 59.94 Hz refresh). localparam [8:0] V_ACTIVE = 9'd240; -localparam [8:0] V_FP = 9'd6; +localparam [8:0] V_FP = 9'd8; localparam [4:0] V_SYNC = 5'd3; -localparam [8:0] V_BP = 9'd13; +localparam [8:0] V_BP = 9'd11; localparam [8:0] V_TOTAL = 9'd262; -localparam [9:0] H_SYNC_START = H_ACTIVE + H_FP; -localparam [9:0] H_SYNC_END = H_SYNC_START + H_SYNC; -localparam [8:0] V_SYNC_START = V_ACTIVE + V_FP; -localparam [8:0] V_SYNC_END = V_SYNC_START + V_SYNC; +// Sync starts shift with the offset; two's-complement subtraction in +// unsigned arithmetic yields the correct result at both ends of the range. +wire [9:0] H_SYNC_START = H_ACTIVE + (H_FP - {{6{h_offset[3]}}, h_offset}); +wire [9:0] H_SYNC_END = H_SYNC_START + H_SYNC; +wire [8:0] V_SYNC_START = V_ACTIVE + (V_FP - {{5{v_offset[3]}}, v_offset}); +wire [8:0] V_SYNC_END = V_SYNC_START + V_SYNC; always @(posedge clk) begin if(reset) begin diff --git a/rtl/native_video_top.sv b/rtl/native_video_top.sv index 55cf43b9..3e3127fa 100644 --- a/rtl/native_video_top.sv +++ b/rtl/native_video_top.sv @@ -29,7 +29,11 @@ module native_video_top output wire vga_new_frame, input wire enable, - output wire active + output wire active, + + // OSD image centering: signed -8..+7 pixels/lines, 0 = no shift. + input wire signed [3:0] h_offset, + input wire signed [3:0] v_offset ); wire tim_hs; @@ -46,6 +50,8 @@ native_video_timing timing .clk (clk_vid), .ce_pix (ce_pix), .reset (reset), + .h_offset (h_offset), + .v_offset (v_offset), .hsync (tim_hs), .vsync (tim_vs), .hblank (tim_hblank), From 95a5153590f3e36d2325e9aab899e05f48b05f0b Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 10 Jun 2026 17:00:56 +0800 Subject: [PATCH 04/18] fix: widen native video centering range --- menu.sv | 19 ++++++++----------- rtl/native_video_timing.sv | 17 +++++++++-------- rtl/native_video_top.sv | 4 ++-- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/menu.sv b/menu.sv index 3c983925..ec9e4fcb 100644 --- a/menu.sv +++ b/menu.sv @@ -208,14 +208,13 @@ assign LED_POWER[0]= FB ? led[2] : act_cnt2[26] ? act_cnt2[25:18] > act_cnt2[7:0 `include "build_id.v" -// Image centering: 4-bit signed in OSD ordering 0,+1..+7,-8..-1 so that the -// power-on default (status bits = 0) maps to "no shift". Bit pattern matches -// 4-bit two's complement when reinterpreted as signed. +// Image centering options use signed two's-complement ordering with 0 first, +// so the power-on default (status bits = 0) selects the calibrated base timing. localparam CONF_STR = { "MENU;UART31250,MIDI;", "-;", - "O[13:10],H Offset,0,+1,+2,+3,+4,+5,+6,+7,-8,-7,-6,-5,-4,-3,-2,-1;", - "O[17:14],V Offset,0,+1,+2,+3,+4,+5,+6,+7,-8,-7,-6,-5,-4,-3,-2,-1;", + "O[15:10],H Offset,0,+1,+2,+3,+4,+5,+6,+7,+8,+9,+10,+11,+12,+13,+14,+15,+16,+17,+18,+19,+20,+21,+22,+23,+24,+25,+26,+27,+28,+29,+30,+31,-32,-31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1;", + "O[19:16],V Offset,0,+1,+2,+3,+4,+5,+6,+7,-8,-7,-6,-5,-4,-3,-2,-1;", "-;", "V,v",`BUILD_DATE }; @@ -522,12 +521,10 @@ native_video_top native_video .enable (mode_zaparoo), .active (native_active), - // status[13:10] / status[17:14] are 4-bit fields whose bit pattern - // matches signed two's complement when the OSD enum is ordered - // 0,+1..+7,-8..-1 (see CONF_STR). $signed() makes the reinterpretation - // explicit at the port boundary. - .h_offset ($signed(status[13:10])), - .v_offset ($signed(status[17:14])) + // Status bit patterns match signed two's complement because CONF_STR orders + // each OSD enum as 0,+1..max,min..-1. $signed() makes that explicit here. + .h_offset ($signed(status[15:10])), + .v_offset ($signed(status[19:16])) ); // Cosine + LFSR fallback noise pattern, painted into the 320x240 active area diff --git a/rtl/native_video_timing.sv b/rtl/native_video_timing.sv index 6d46c8d3..56d44002 100644 --- a/rtl/native_video_timing.sv +++ b/rtl/native_video_timing.sv @@ -1,7 +1,6 @@ // Zaparoo native video timing: 320x240 at 15.734 kHz from 27 MHz / 4. -// h_offset/v_offset (signed -8..+7) shift the image by repartitioning -// front porch and back porch. H_TOTAL/V_TOTAL are invariant, so line -// rate and frame rate are unchanged regardless of offset values. +// h_offset/v_offset shift the image by repartitioning front porch and back +// porch. H_TOTAL/V_TOTAL are invariant, so line/frame rates stay unchanged. module native_video_timing ( @@ -10,8 +9,8 @@ module native_video_timing input wire reset, // Image centering: positive = shift right/down (FP shrinks, BP grows). - input wire signed [3:0] h_offset, // -8..+7 pixels (budget H_FP=14 / H_BP=63) - input wire signed [3:0] v_offset, // -8..+7 lines (budget V_FP=8 / V_BP=11) + input wire signed [5:0] h_offset, // -32..+31 pixels (budget H_FP=38 / H_BP=39) + input wire signed [3:0] v_offset, // -8..+7 lines (budget V_FP=8 / V_BP=11) output reg hsync, output reg vsync, @@ -25,9 +24,11 @@ module native_video_timing ); localparam [9:0] H_ACTIVE = 10'd320; -localparam [9:0] H_FP = 10'd14; +// 38/32/39 keeps total blanking fixed while moving the default image 24 px +// left from the earlier CRT-specific 14/32/63 porch split. +localparam [9:0] H_FP = 10'd38; localparam [5:0] H_SYNC = 6'd32; -localparam [9:0] H_BP = 10'd63; +localparam [9:0] H_BP = 10'd39; localparam [9:0] H_TOTAL = 10'd429; // V blanking rebalanced from 6/3/13 to 8/3/11 to give symmetric ±8 budget @@ -40,7 +41,7 @@ localparam [8:0] V_TOTAL = 9'd262; // Sync starts shift with the offset; two's-complement subtraction in // unsigned arithmetic yields the correct result at both ends of the range. -wire [9:0] H_SYNC_START = H_ACTIVE + (H_FP - {{6{h_offset[3]}}, h_offset}); +wire [9:0] H_SYNC_START = H_ACTIVE + (H_FP - {{4{h_offset[5]}}, h_offset}); wire [9:0] H_SYNC_END = H_SYNC_START + H_SYNC; wire [8:0] V_SYNC_START = V_ACTIVE + (V_FP - {{5{v_offset[3]}}, v_offset}); wire [8:0] V_SYNC_END = V_SYNC_START + V_SYNC; diff --git a/rtl/native_video_top.sv b/rtl/native_video_top.sv index 3e3127fa..897b1c84 100644 --- a/rtl/native_video_top.sv +++ b/rtl/native_video_top.sv @@ -31,8 +31,8 @@ module native_video_top input wire enable, output wire active, - // OSD image centering: signed -8..+7 pixels/lines, 0 = no shift. - input wire signed [3:0] h_offset, + // OSD image centering: 0 = default porch split. + input wire signed [5:0] h_offset, input wire signed [3:0] v_offset ); From 3a19e6db7e6de55053b3f410615cc6643cfe4e2e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 10 Jun 2026 18:03:12 +0800 Subject: [PATCH 05/18] fix: use safer native video centering steps --- menu.sv | 12 ++++++------ rtl/native_video_timing.sv | 10 +++++----- rtl/native_video_top.sv | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/menu.sv b/menu.sv index ec9e4fcb..5f90dac9 100644 --- a/menu.sv +++ b/menu.sv @@ -213,8 +213,8 @@ assign LED_POWER[0]= FB ? led[2] : act_cnt2[26] ? act_cnt2[25:18] > act_cnt2[7:0 localparam CONF_STR = { "MENU;UART31250,MIDI;", "-;", - "O[15:10],H Offset,0,+1,+2,+3,+4,+5,+6,+7,+8,+9,+10,+11,+12,+13,+14,+15,+16,+17,+18,+19,+20,+21,+22,+23,+24,+25,+26,+27,+28,+29,+30,+31,-32,-31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1;", - "O[19:16],V Offset,0,+1,+2,+3,+4,+5,+6,+7,-8,-7,-6,-5,-4,-3,-2,-1;", + "O[13:10],H Offset,0,+2,+4,+6,+8,+10,+12,+14,-16,-14,-12,-10,-8,-6,-4,-2;", + "O[17:14],V Offset,0,+1,+2,+3,+4,+5,+6,+7,-8,-7,-6,-5,-4,-3,-2,-1;", "-;", "V,v",`BUILD_DATE }; @@ -521,10 +521,10 @@ native_video_top native_video .enable (mode_zaparoo), .active (native_active), - // Status bit patterns match signed two's complement because CONF_STR orders - // each OSD enum as 0,+1..max,min..-1. $signed() makes that explicit here. - .h_offset ($signed(status[15:10])), - .v_offset ($signed(status[19:16])) + // H offset is a 4-bit signed OSD value doubled into 2-pixel steps. + // V offset is a normal 4-bit signed OSD value in 1-line steps. + .h_offset ($signed({status[13:10], 1'b0})), + .v_offset ($signed(status[17:14])) ); // Cosine + LFSR fallback noise pattern, painted into the 320x240 active area diff --git a/rtl/native_video_timing.sv b/rtl/native_video_timing.sv index 56d44002..02ea58da 100644 --- a/rtl/native_video_timing.sv +++ b/rtl/native_video_timing.sv @@ -9,7 +9,7 @@ module native_video_timing input wire reset, // Image centering: positive = shift right/down (FP shrinks, BP grows). - input wire signed [5:0] h_offset, // -32..+31 pixels (budget H_FP=38 / H_BP=39) + input wire signed [4:0] h_offset, // -16..+14 pixels (budget H_FP=26 / H_BP=51) input wire signed [3:0] v_offset, // -8..+7 lines (budget V_FP=8 / V_BP=11) output reg hsync, @@ -24,11 +24,11 @@ module native_video_timing ); localparam [9:0] H_ACTIVE = 10'd320; -// 38/32/39 keeps total blanking fixed while moving the default image 24 px +// 26/32/51 keeps total blanking fixed while moving the default image 12 px // left from the earlier CRT-specific 14/32/63 porch split. -localparam [9:0] H_FP = 10'd38; +localparam [9:0] H_FP = 10'd26; localparam [5:0] H_SYNC = 6'd32; -localparam [9:0] H_BP = 10'd39; +localparam [9:0] H_BP = 10'd51; localparam [9:0] H_TOTAL = 10'd429; // V blanking rebalanced from 6/3/13 to 8/3/11 to give symmetric ±8 budget @@ -41,7 +41,7 @@ localparam [8:0] V_TOTAL = 9'd262; // Sync starts shift with the offset; two's-complement subtraction in // unsigned arithmetic yields the correct result at both ends of the range. -wire [9:0] H_SYNC_START = H_ACTIVE + (H_FP - {{4{h_offset[5]}}, h_offset}); +wire [9:0] H_SYNC_START = H_ACTIVE + (H_FP - {{5{h_offset[4]}}, h_offset}); wire [9:0] H_SYNC_END = H_SYNC_START + H_SYNC; wire [8:0] V_SYNC_START = V_ACTIVE + (V_FP - {{5{v_offset[3]}}, v_offset}); wire [8:0] V_SYNC_END = V_SYNC_START + V_SYNC; diff --git a/rtl/native_video_top.sv b/rtl/native_video_top.sv index 897b1c84..36cbedb3 100644 --- a/rtl/native_video_top.sv +++ b/rtl/native_video_top.sv @@ -32,7 +32,7 @@ module native_video_top output wire active, // OSD image centering: 0 = default porch split. - input wire signed [5:0] h_offset, + input wire signed [4:0] h_offset, input wire signed [3:0] v_offset ); From 5db804a02a3b17505ceada5d4aaf269be2352d09 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Thu, 11 Jun 2026 11:37:22 +0800 Subject: [PATCH 06/18] feat: broadcast-geometry native video with PAL and 480i modes Implements the FPGA side of docs/native-video-plan.md (all phases; the zaparoo-launcher side comes separately). - PLL output 1 retargeted 27.027027 -> 27.000000 MHz, giving exact NTSC (15734.27 Hz) and PAL (15625.00 Hz) line rates. - native_video_timing rebuilt around per-mode parameter sets: 352x240p60 (Switchres ntsc porches), 720x480i60 (CEA-861, 262+263-line fields with half-line vsync offset on the odd field), 352x288p50 (Switchres pal). Mode and trims latch at the field wrap; offsets clamp to -8..+8 px / -8..+2 lines. Field flips at the start of vblank so the reader's line preload always fetches the parity about to be displayed. - native_video_reader parses DDR control word1: magic 0x5A50 selects the v2 layout (buffers +0x1000/+0x180000, tight stride) and carries mode and h/v offsets; without magic the legacy 320x240 layout is scanned centered with 16-px side bars. word0 == 0 and DDR timeouts now clear frame_ready so the core reverts to the noise pattern instead of scanning a dead buffer. 480i fetches source line 2*line+field as two 180-beat bursts; line FIFO deepened to 1024 words for the interlaced 2-line preload. - menu.sv: OSD video options removed (CONF_STR back to stock), ce_pix divider switches /4 / /2 by mode, VGA_F1 driven by the field bit. - Self-checking iverilog testbenches in tb/ (run via tb/run.sh) verify all mode timings in exact pixel ticks, the half-line interlace (both vsync intervals exactly 262.5 lines), offset clamping, the v2/legacy fetch sequences, double buffering, writer-stop reversion, and timeout recovery. - Readme documents the native video output and the forced_scandoubler / vga_scaler=1 note. --- Readme.md | 13 + docs/native-video-plan.md | 482 +++++++++++++++++++++++++++++++++++ menu.sv | 61 +++-- rtl/native_video_reader.sv | 206 +++++++++++---- rtl/native_video_timing.sv | 141 +++++++--- rtl/native_video_top.sv | 71 ++++-- rtl/pll/pll_0002.v | 2 +- tb/dcfifo_sim.sv | 70 +++++ tb/native_video_reader_tb.sv | 291 +++++++++++++++++++++ tb/native_video_timing_tb.sv | 229 +++++++++++++++++ tb/run.sh | 13 + 11 files changed, 1449 insertions(+), 130 deletions(-) create mode 100644 docs/native-video-plan.md create mode 100644 tb/dcfifo_sim.sv create mode 100644 tb/native_video_reader_tb.sv create mode 100644 tb/native_video_timing_tb.sv create mode 100755 tb/run.sh diff --git a/Readme.md b/Readme.md index 224bb29d..e6c5d4ce 100644 --- a/Readme.md +++ b/Readme.md @@ -1,5 +1,18 @@ # Startup core for MiSTer +## Native CRT video (this fork) + +This fork drives the analog output with a native 15 kHz signal generated by +the core itself: 352x240p60 (NTSC) by default, with 720x480i60 and 352x288p50 +(PAL) selectable by the ARM-side launcher through a DDR control block (see +`docs/native-video-plan.md`). There are no video options in the OSD — the +mode and the H/V centering trims are owned by the launcher; the core shows +its noise pattern until the launcher publishes frames. + +Note: `forced_scandoubler` (the "Forced scandoubler" MiSTer.ini setting) is +ignored by this core — the analog output is always 15 kHz. If your VGA output +feeds a 31 kHz-only monitor, set `vga_scaler=1` in MiSTer.ini instead. + * **ESC** - Back/Options * **Enter** - OK * **F1** - Cycle Background/Wallpaper diff --git a/docs/native-video-plan.md b/docs/native-video-plan.md new file mode 100644 index 00000000..8894b4fd --- /dev/null +++ b/docs/native-video-plan.md @@ -0,0 +1,482 @@ +# Native CRT video: findings, recommendations, and implementation plan + +**Status:** proposal — agreed direction, not yet implemented +**Scope:** this core (Menu_MiSTer fork) + `zaparoo-launcher` (the ARM-side writer) +**Date:** 2026-06-11 + +This document explains why the native video output currently only looks right +on the CRT it was calibrated on, what a "standard" 15 kHz signal actually is, +and a phased plan to fix geometry (240p), add PAL (288p50), and add a +high-resolution interlaced mode (480i60). + +--- + +## 1. Current architecture + +The fork replaces the Menu core's noise-pattern video with: + +| Piece | File | Role | +|---|---|---| +| Timing generator | `rtl/native_video_timing.sv` | Produces hsync/vsync/blanking/DE at 15 kHz from a 27.027 MHz clock ÷ 4 | +| DDR reader | `rtl/native_video_reader.sv` | Polls a control word in DDR3 each vblank, streams the active framebuffer line-by-line through a clock-crossing FIFO | +| Wrapper | `rtl/native_video_top.sv` | Wires the two together | +| Mode mux | `menu.sv` | `status[9]` selects noise pattern vs. framebuffer; OSD H/V offset trims | + +ARM side (`zaparoo-launcher/src/app/native_video_writer.cpp`): Qt renders the +UI to `/dev/fb0` (320x240 RGBX8888, set up via `vmode`), and a copy thread +memcpys each frame into one of two DDR buffers, then publishes it: + +``` +0x3A000000 control word: (frame_counter << 2) | active_buffer +0x3A000100 buffer 0: 320x240 RGBX8888, tight stride (1280 B) +0x3A04B100 buffer 1 +mmap region: 0xA0000 (640 KB) +``` + +The FPGA reads the control word at the start of each vblank; when the counter +changes it switches to the published buffer (double buffering, no tearing). +Byte order is swapped in RTL (`output_pixel`) so the app can memcpy linuxfb +BGRX rows without repacking. + +This path deliberately bypasses MiSTer's scaler (`docs/native-core-poc.md` in +zaparoo-launcher): analog output comes straight from the core's `VGA_*` +signals. The framework (`sys/vga_out.sv`) only applies gamma/csync — it does +not retime anything — so **whatever timing this core generates is exactly what +the CRT receives.** The HDMI side is unaffected; ascal rescales any input +timing. + +--- + +## 2. Background: how a CRT decides where and how big the picture is + +A CRT has no concept of pixels or resolutions. Each scanline of the signal is: + +``` + sync pulse → back porch → active video → front porch → next sync + (4.7 µs) (delay) (the picture) (delay) +``` + +The sync pulse is the only positional reference the TV has. The set's +deflection circuitry is factory-adjusted so that the **broadcast-standard** +active region — about **52.7 µs** of the 63.6 µs NTSC line — slightly +*overfills* the visible tube. That deliberate overfill is **overscan**: +typically 3–8% of the picture is cropped at each edge, varying from set to set +and drifting with age. The same applies vertically, measured in scanlines. + +Consequences: + +- If your active video is **shorter than 52.7 µs**, the picture is narrower + than the tube — black side borders that no porch adjustment can remove. +- If your active video starts **later than ~9.4 µs after the sync edge** + (4.7 µs sync + 4.7 µs back porch), the picture sits right of center. +- Because overscan varies per set, anything important drawn near the edges + will be cut off on *some* sets no matter what you do. + +Broadcasters solved the per-set variation problem decades ago: **fill the full +standard active area, and keep important content inside "safe areas"** +(SMPTE SD guidelines: *action safe* = the central 90%, *title safe* = the +central 80%). The picture bleeds past every tube's edges; the content never +does. This is the "safe values" approach this plan adopts. + +For calibration intuition: real consoles (NES/SNES/Genesis) output ~47.7 µs of +active video — about 10% narrower than broadcast — which is why console games +show small side borders on a well-calibrated set. GroovyMAME/Switchres, the +de-facto reference for driving CRTs from emulators, instead generates +modelines that stretch the emulated image across the full 52.7 µs. We follow +the Switchres model. + +--- + +## 3. Findings: the current signal vs. the standard + +Measured from HEAD of `fix/native-video-centering` +(pixel clock = 27.027 MHz ÷ 4 = 6.757 MHz, H total 429 px, V total 262 lines): + +| Parameter | Current (HEAD) | NTSC standard | Verdict | +|---|---|---|---| +| Line rate | **15 750 Hz** | 15 734.26 Hz | Wrong PLL: 27.027 MHz is the 1.001 NTSC factor applied *backwards*. Plain **27.000 MHz** with the same ÷4 and 429-px line gives exactly 15 734.27 Hz (27 000 000 / 1716). The "15.734 kHz" comments in the code are aspirational, not true. | +| Field rate | 60.12 Hz | 59.94–60.05 | Follows from the PLL error. Harmless on CRTs but off-spec. | +| H active | 320 px = **47.4 µs** | **52.66 µs** | ~10% too narrow. This is the "too small" complaint, and it is unfixable by porch tuning. | +| H sync→active delay | 83 px = 12.3 µs | 9.4 µs | For a 47.4 µs-wide image, *centered* would be 12.0 µs — so HEAD is now roughly centered. The original Codex porches (FP/sync/BP = 14/32/63) gave **14.1 µs ≈ 5% right shift** — the "offset right on everyone else's CRT" complaint. | +| V geometry | 240 active; vsync at line 248 (FP 8) | ~241 visible; vsync at line 243 (FP 3) | Picture sits ~5 lines high of standard. | + +History of the H porch split (FP/sync/BP in pixels): + +- `061a888` (Codex original): **14/32/63** — calibrated to one specific CRT, + ~5% right of standard for everyone else. +- `95a5153`: 38/32/39 — overcorrected ~9 px left of centered. +- `3a19e6d` (HEAD): **26/32/51** — within ~2 px of centered *for a 47.4 µs + image*. Centering is now fine; width is not. + +These commits also widened the OSD H offset range to ±16 px in 2-px steps to +chase per-CRT centering. That widening is deliberately reverted by this plan: +once the geometry is standard, the trim is a nicety, and the supported range +goes back to **±8 px in 1-px steps** (carried in the control word, not the +OSD — see section 5.1). + +**Key insight:** the OSD H/V offset options treat the symptom. A 47.4 µs +picture can never fill a tube calibrated for 52.7 µs, and any porch split +that's perfect for one CRT is wrong on the next. The fix is broadcast +geometry (section 4) plus safe-area UI rules (section 6). + +### Other defects found during review + +1. **PAL is silently dropped.** `wire PAL = status[4]` in `menu.sv` is now + dead; the old noise generator honored it. 50 Hz-only CRTs get an NTSC + signal. (Addressed by Phase B below.) +2. **`forced_scandoubler` is ignored.** Users whose VGA output feeds a + 31 kHz-only monitor previously got a doubled signal from the menu core; + now they must set `vga_scaler=1` in MiSTer.ini. Acceptable for a + CRT-targeted fork, but it should be stated in the README. +3. **Reader never falls back when the writer stops.** `stopNativeVideoWriter()` + zeroes the control word, but `frame_ready` stays latched and the core scans + the stale (black) buffer forever instead of reverting to the noise pattern. + `ctrl == 0` should clear `frame_ready`. +4. **FIFO preload is at its safe maximum already.** The reader preloads 2 + lines during vblank then fetches one line per scanline. Note for Phase A: + at the new 176-word line length, preloading a 3rd line would overflow the + 512-word FIFO mid-frame (peak occupancy ~368 words with 2-line preload; + 3-line preload peaks above 512 and `overflow_checking` silently drops + writes). Keep 2 lines, or deepen the FIFO to 1024 if more margin is wanted. +5. Reader timeout paths (`ST_WAIT_CTRL`/`ST_WAIT_LINE` → `ST_IDLE`) also leave + `frame_ready` stale; same fix as (3). + +--- + +## 4. Target timings + +Everything derives from **one PLL change**: output 1 of `rtl/pll/pll_0002.v` +goes from 27.027027 MHz to **27.000000 MHz** — the universal SD video clock +(it is exactly 1716 × NTSC line rate and 1728 × PAL line rate). + +| Mode | ce_pix | H total | H active / FP / sync / BP (px) | V total | V active / FP / sync / BP (lines) | Line rate | Refresh | +|---|---|---|---|---|---|---|---| +| **0: 240p60** (default) | 27 ÷ 4 = 6.75 MHz | 429 | **352** / 12 / 32 / 33 | 262 | **240** / 3 / 3 / 16 | 15 734.27 Hz | 60.05 Hz | +| **1: 480i60** | 27 ÷ 2 = 13.5 MHz | 858 | **720** / 19 / 62 / 57 | 525 (262+263 fields) | 240 / 4 / 3 / 15–16 per field | 15 734.27 Hz | 59.94 Hz interlaced | +| **2: 288p50** (PAL) | 6.75 MHz | 432 | **352** / 11 / 32 / 37 | 312 | **288** / 3 / 3 / 18 | 15 625.00 Hz | 50.08 Hz | + +Where these numbers come from: + +- **Switchres monitor presets** (`monitor.cpp`, the GroovyMAME engine): + - `ntsc`: 15 734.26 Hz; H porches 1.5 / 4.7 / 4.7 µs; V 3 / 3 / 15 lines. + - `pal`: 15 625 Hz; H porches 1.5 / 4.7 / 5.8 µs. +- **CEA-861 720x480i**: H total 858 @ 13.5 MHz, FP 19 / sync 62 / BP 57. +- **SMPTE 170M**: 63.556 µs line, 10.9 µs blanking, 52.66 µs active. + +The H porch pixel values above are the preset µs values converted at the pixel +clock, nudged by ≤ 0.3 µs so the active width sits centered in the standard +window. Sanity checks: 352+12+32+33 = 429; 352+11+32+37 = 432; 720+19+62+57 = 858. + +Why these active sizes: + +- **352 px @ 6.75 MHz = 52.15 µs ≈ 99% of NTSC standard active width** (and + ~100% of PAL's 52 µs). The picture fills every screen edge-to-edge with + normal overscan crop. 352x240 / 352x288 are standard SIF resolutions; + pixel aspect ratio is the BT.601-classic **10:11** (~9% narrower than + square — fine to ignore for a UI, but stated for completeness). +- **PAL gets 288 active lines, not 240.** PAL tubes show ~288 lines; a + 240-line picture at 50 Hz would be visibly undersized vertically. The app + renders 352x288 in PAL mode. +- **480i uses the CEA-861 numbers verbatim** — the most universally accepted + SD interlaced timing in existence. + +### 480i specifics + +Interlacing is *not* just doubling the line count. The 525-line frame is two +fields of 262 and 263 lines, and the **odd field's vsync must be asserted half +a scanline (429 ce_pix clocks at 13.5 MHz) later** than the even field's. +That half-line offset is what makes the CRT draw the second field's lines +*between* the first field's — without it both fields land on the same +scanlines ("line pairing") and you get 240p with combing. + +MiSTer framework support is already there: + +- `VGA_F1` (field number) is a standard core output — currently hardwired to + `0` in `menu.sv`. It must toggle per field in 480i. +- `sys/sys_top.v` wires `VGA_F1` → ascal's `i_fl`; ascal auto-detects + interlace and deinterlaces for HDMI, so HDMI users keep working. +- The analog path passes core sync through untouched; csync generation in + `sys_top.v` handles interlaced cores today (PSX, Saturn, Genesis all output + real 480i this way). +- Reference implementation for the half-line trick: + `MiSTer-devel/PSX_MiSTer rtl/gpu_videoout_async.vhd` (search "half line + later"). + +--- + +## 5. DDR contract v2 + +Designed now so Phases A–C don't break each other or deployed frontends. +Versioned via a magic value; layout sized for the largest mode: + +``` +0x3A000000 word0: (frame_counter << 2) | active_buffer (unchanged) +0x3A000004 word1: [31:16] magic 0x5A50 ("ZP") + [15:8] h_offset, signed, pixels (+ = right; core honors −8…+8) + [7:4] v_offset, signed, lines (+ = down; core honors −8…+2) + [3:0] mode: 0 = 352x240 @ 60p (NTSC) + 1 = 720x480 @ 60i + 2 = 352x288 @ 50p (PAL) +0x3A001000 buffer 0 (page-aligned; sized for max mode: 720*480*4 = 1.35 MB) +0x3A180000 buffer 1 +mmap region: 0x300000 (3 MB) +stride: always tight, width * 4 bytes +``` + +Design points: + +- The reader already fetches the control word as one 64-bit DDR beat and + discards the top half — **word1 costs nothing extra to read**. One read per + vblank picks up frame counter, buffer index, mode, and offset trims + atomically. +- **The control block replaces every OSD video option** (see section 5.1): + there is no CRT-mode toggle and no OSD offset menu. A valid magic plus a + changing frame counter *is* the mode signal — the core shows the noise + pattern until the launcher publishes frames and reverts when word0 clears. + Offsets come from word1 and are owned by a calibration screen in the + launcher. +- Offsets and mode cross from the DDR clock domain into the video timing + domain as quasi-static values: two-flop synchronize and latch them at the + frame boundary (`new_frame`) so a mid-frame update can't corrupt sync. RTL + clamps offsets to the porch budget of the active mode (effective FP/BP + never < 2 px / 1 line), so a buggy or out-of-range value degrades to a + saturated shift, never a broken signal. +- **Legacy compatibility:** if word1 has no magic, the core treats the region + as today's layout (320x240 buffers at +0x100 / +0x4B100) and scans it + centered in the 352-px active area with black side bars (16 px each side). + An already-deployed launcher keeps working against the new core; the new + launcher's fb-geometry validation already self-disables cleanly against an + old core. No flag day. +- Mode changes apply at frame boundaries. Modes 0↔1 keep the same line rate, + so the CRT re-locks almost instantly; switching to/from PAL is a bigger + retune (50↔60 Hz) and takes a moment, as on real hardware. +- Address-space safety: MiSTer reserves 0x20000000+ of DDR for the FPGA side; + 0x30000000–0x3FFFFFFF is core-owned and the menu core uses none of it + elsewhere. The 3 MB region at 0x3A000000 conflicts with nothing (the + framework scaler framebuffers live at 0x20000000+). +- In 480i the FPGA reads source line `vcount*2 + field`, so the app renders + one normal progressive 720x480 frame — no field-splitting on the ARM side. + +DDR bandwidth is a non-issue: worst case (480i) is 720×480×4 B × 60 ≈ 80 MB/s +of sequential bursts against a multi-GB/s DDR3 port that nothing else in the +menu core touches. + +### 5.1 Removing the OSD video options entirely + +Question raised during review: can the "Video" section / second OSD page go +away, with the CRT mode toggle and the H/V offset trims moving into the ARM +launcher? **Yes — and it simplifies the core.** Every OSD video option maps +onto something the v2 control block already carries: + +| OSD option today | Replacement | +|---|---| +| CRT/native mode toggle (`status[9]`) | Implicit: valid magic + advancing frame counter in the control block ⇒ native scanout; word0 = 0 or stale ⇒ noise pattern. The launcher "turns on CRT mode" simply by publishing frames. `status[9]` and its CONF_STR entry are deleted. | +| H Offset list (`status[13:10]`) | `word1[15:8]` signed pixel trim (−8…+8, 1-px steps), set from a calibration screen in the launcher (arrow keys, live preview), persisted in the launcher's own config. | +| V Offset list (`status[17:14]`) | `word1[7:4]` signed line trim (−8…+2), same screen. | + +`CONF_STR` shrinks back to the stock menu core entry +(`"MENU;UART31250,MIDI;-;V,v"` + build date): one page, no Video section. The +core stops using `status[]` for video entirely. + +Why this is the right direction, beyond decluttering: + +- **One contract, one owner.** Mode and trims live next to the frames they + describe, set by the same process that renders them, read atomically in the + same 64-bit beat. No second control path through hps_io status bits. +- **Better calibration UX.** The launcher can draw a border test pattern + *while* the user nudges offsets — the OSD lists couldn't show the effect on + a full-bleed image, and 16-entry enum lists are a clumsy way to express + "nudge left a bit". Per-device persistence lives with the rest of the + launcher's config instead of MiSTer's core-config blob. +- **Fewer moving parts in RTL.** The offset inputs move from + `status`-decoding in `menu.sv` to the reader's already-synchronized control + parse; the OSD enum↔signed-value mapping tricks disappear. + +Trade-offs / notes: + +- A user running the **legacy (pre-v2) launcher** gets no trims (word1 absent + → offsets = 0). Acceptable: the new default timing is standard, trims are a + nicety, and legacy mode is compat-only. +- If the framebuffer path is off (noise pattern), there is nothing to + calibrate against — also fine, calibration belongs in the app. +- The MiSTer OSD overlay itself (main menu, file browser) is untouched; this + removes only the core's *option entries*, not the OSD. + +**Rejected alternative:** having the ARM app poke core status bits through a +patched Main_MiSTer (the Zaparoo_MiSTer fork could add a command for it). +Works, but spreads the video contract across three codebases and a Main fork +that must track upstream, for zero functional gain over the DDR words the +core already reads every vblank. + +--- + +## 6. App-side rules (zaparoo-launcher) + +These are as much a part of the fix as the RTL — geometry alone doesn't solve +"every CRT crops differently": + +1. **Render full-bleed.** Background art/color must reach all four edges of + the framebuffer; the outer few percent will be cropped on most sets and + visible on a few. +2. **Safe areas** (SMPTE SD guidelines): + - All interactive/meaningful content inside the central **90%** + (*action safe*: ~317x216 of 352x240, ~317x259 of 352x288, ~648x432 of + 720x480). + - Text you must be able to read inside the central **80%** + (*title safe*: ~282x192 / ~282x230 / ~576x384). +3. **Pixel aspect ratio is 10:11** (pixels slightly narrower than square) in + all three modes. A perfect circle needs ~10% more width in pixels. Safe to + ignore for boxes-and-text UI; matters if rendering logos/art that must not + look squished. +4. **480i flicker discipline:** every scanline is repainted 30 times/second, + so 1-px horizontal lines and fine text shimmer. Use ≥2 px horizontal + strokes, avoid hard 1-px horizontal edges, or apply a mild vertical blur + (the standard trick in console-era 480i dashboards). The existing CRT + typography rules in `docs/native-core-poc.md` (integer snapping, bitmap + fonts) stay in force. +5. **Own the centering trims** (section 5.1): a calibration screen that draws + an edge/border test pattern and lets the user nudge H/V offsets with live + preview, publishing them via control word1 and persisting them in the + launcher config. Defaults are zero — the standard timing is the centering + mechanism; trims only compensate for miscentered sets. + +--- + +## 7. Implementation plan + +### Phase A — broadcast-geometry 240p (the main fix) + +FPGA (this repo): + +1. **`rtl/pll/pll_0002.v`**: `output_clock_frequency1` 27.027027 MHz → + `27.000000 MHz`. (Same single-line style as the earlier 20→27.027 change; + no other PLL params move.) +2. **`rtl/native_video_timing.sv`**: mode-0 constants — H 352/12/32/33, + V 240/3/3/16. Structure the constants as per-mode parameter sets selected + by a `mode` input (tied to 0 until Phases B/C) so later modes are additive. + Offset budgets change: positive H offset eats the now-small 12-px front + porch. **Trim range is deliberately reverted to ±8 px H, 1-px steps** + (this branch had widened it to ±16 in 2-px steps while the porches were + the centering mechanism — with broadcast-fill geometry the trim is a + nicety, and ±8 px ≈ ±1.2 µs is plenty). V range −8…+2 lines. RTL clamps + to these ranges and additionally never lets effective FP/BP drop below + 2 px / 1 line, so out-of-range word1 values saturate instead of breaking + sync. +3. **`rtl/native_video_reader.sv`**: + - Parse word1 (`ddr_dout[63:32]`) in `ST_WAIT_CTRL`: magic present → v2 + layout (buffers at word addresses 0x07400200 / 0x07430000, line burst + 176 words) and extract mode + h/v offsets; absent → legacy layout + (0x07400020 / 0x07409620, 160 words, offsets 0) displayed centered with + 16-px black bars (needs `hcount` from the timing module, already + exported but unconnected). Forward the synchronized offsets to the + timing module, latched at `new_frame`. + - `word0 == 0` → clear `frame_ready` and `first_frame_loaded` → core + reverts to the noise pattern (fixes defects 3/5 in section 3). + - Keep the 2-line preload (see defect 4 — it's already at the FIFO's safe + maximum); optionally deepen the FIFO to 1024 words for margin. +4. **`menu.sv`**: remove all video options from `CONF_STR` (back to the stock + `"MENU;UART31250,MIDI;-;V,v"` + build date — one OSD page, no Video + section); delete `status[9]` / `status[17:10]` decoding and the offset + wiring from `hps_io` (offsets now arrive via the reader's control parse, + section 5.1); correct the stale "15.734 kHz" comments (true again after + the PLL fix); README note about `forced_scandoubler`/`vga_scaler`. +5. **Testbench before synthesis** (see section 8). + +Frontend (`zaparoo-launcher`): + +6. `--crt` path sets fb0 to **352x240** 32bpp (`vmode -r 352 240 rgb32` + equivalent); writer constants: width 352, stride 1408, frame size 0x52800, + buffers at +0x1000 / +0x180000, region 0x300000; write magic + mode + + offset word on init (offsets from saved config, default 0) and clear both + words on stop. +7. UI safe-area pass per section 6; calibration screen for the H/V trims + (border test pattern + arrow-key nudge within ±8 px / −8…+2 lines, + persisted in launcher config). +8. Release coordination: core's legacy mode covers old-launcher/new-core; the + launcher's existing fb-geometry validation covers new-launcher/old-core + (writer disables itself, core shows noise — obvious, not subtle breakage). + +### Phase B — PAL 288p50 + +9. Timing mode 2: H total 432 (352/11/32/37), V total 312 (288/3/3/18). + Same 6.75 MHz clock; line rate exactly 15 625 Hz. +10. Reader: 288-line frame, same stride; fits existing buffer slots + (352×288×4 = 396 KB < 1.35 MB slot). +11. Launcher: a "video standard: NTSC / PAL" user setting → renders 352x288 + and publishes mode 2. PAL sets that accept 60 Hz RGB ("PAL-60", most of + them via SCART) can simply stay on mode 0; mode 2 is for strict-50 Hz + sets and correct-speed feel in PAL regions. + +### Phase C — 480i60 (after A/B verified on real CRTs) + +12. Timing mode 1: ce_pix ÷2 (13.5 MHz); H 858 total (720/19/62/57); 525-line + dual-field vertical counter; **half-line (429-clock) vsync offset on the + odd field**; field bit out → `VGA_F1` (replace the hardwired 0 in + `menu.sv`). +13. Reader: source line = `vcount*2 + field`; 720 px = 360 DDR words/line + exceeds the 8-bit burst counter, so fetch each line as **2×180-beat + bursts**; FIFO sizing: 360-word lines × 2-line preload = 720 words → + deepen FIFO to 1024. +14. Launcher: 720x480 rendering path; per-screen mode selection (e.g. launcher + UI in 240p, text-heavy screens in 480i); flicker styling per section 6. + +### Explicitly out of scope / rejected + +- **Using MiSTer's scaler framebuffer instead** — already rejected by the + project (`native-core-poc.md`): the whole point is core-owned, low-latency, + exact 15 kHz output. +- **31 kHz / 480p output** for VGA PC monitors — different audience; the + framework's `vga_scaler=1` path already serves it. +- **Changing the pixel clock to stretch 320 px across 52.7 µs** (the literal + Switchres approach, ~6.1 MHz dot clock) — works, but leaves the 27 MHz + family for no benefit; widening the framebuffer is cleaner on every axis. + +--- + +## 8. Verification + +1. **Simulation first** (no Quartus needed): a small testbench on + `native_video_timing` that measures, in µs/lines against section 4's table: + line period, sync width, sync→active delay, active width, frame period — + and for 480i: field alternation, the half-line vsync offset, and total + 525 lines/frame. This is cheap and catches every off-by-one that matters. +2. **CI build** (existing GitHub Actions Quartus workflow) for timing closure + and resource sanity. +3. **Hardware checklist** (per phase): + - Launcher renders a cross-hatch + border test pattern (240p-test-suite + style: 1-px frame at the extreme edge, safe-area rectangles at 90%/80%). + - Verify fill/centering on **at least 2–3 different CRTs** plus a capture + device (OSSC/RetroTINK profile or capture card reporting measured line + rate — should read 15.734 kHz exactly after the PLL fix). + - Legacy-compat check: old launcher against new core → centered 320x240 + with side bars. + - Writer-stop check: kill the launcher → noise pattern returns. + - Trim check: launcher calibration screen nudges the picture live in both + axes; values survive a launcher restart; out-of-range word1 values + saturate without disturbing sync. + - OSD check: core options reduced to a single page (no Video section); + the OSD overlay itself still renders and is usable in every mode. + - HDMI side still locks (ascal) in every mode. + - 480i: confirm real interlacing (no line pairing) — fine horizontal lines + should shimmer, not stack; capture device should report 480i, not 240p. + +--- + +## 9. References + +- Switchres monitor presets (GroovyMAME): + `github.com/antonioginer/switchres` `monitor.cpp` — `ntsc`, `pal`, + `arcade_15` ranges (porch values in µs/ms). +- SMPTE 170M / standard NTSC line structure: 63.556 µs line, 10.9 µs + blanking, 52.66 µs active, 9.4 µs sync→active. +- CEA-861 720x480i timing: 858/19/62/57 @ 13.5 MHz, 525 lines. +- SMPTE safe areas (SD practice): action safe 90%, title safe 80% + (HD-era ST 2046-1 relaxed these to 93%/90% — use the SD numbers for + consumer CRTs). +- PSX_MiSTer `rtl/gpu_videoout_async.vhd` — half-line vsync offset reference. +- MiSTer framework: `sys/sys_top.v` (`VGA_F1` → ascal `i_fl`; csync), + `sys/vga_out.sv` (analog path is timing-transparent). +- ARM writer: `zaparoo-launcher/src/app/native_video_writer.cpp`, + `zaparoo-launcher/docs/native-core-poc.md`. +- Pixel aspect ratio / SIF background: BT.601 (704x480 → PAR 10:11; 352x240 + inherits it). diff --git a/menu.sv b/menu.sv index 5f90dac9..f6b84ea2 100644 --- a/menu.sv +++ b/menu.sv @@ -185,7 +185,9 @@ assign DDRAM_CLK = clk_sys; assign CE_PIXEL = ce_pix; assign VGA_SL = 0; -assign VGA_F1 = 0; +// Field number for 480i: ascal (HDMI) keys deinterlacing off this, and the +// analog csync path passes it through. 0 in the progressive modes. +assign VGA_F1 = native_field; assign VIDEO_ARX = 0; assign VIDEO_ARY = 0; assign VGA_SCALER= 0; @@ -207,15 +209,12 @@ wire [26:0] act_cnt2 = {~act_cnt[26],act_cnt[25:0]}; assign LED_POWER[0]= FB ? led[2] : act_cnt2[26] ? act_cnt2[25:18] > act_cnt2[7:0] : act_cnt2[25:18] <= act_cnt2[7:0]; -`include "build_id.v" -// Image centering options use signed two's-complement ordering with 0 first, -// so the power-on default (status bits = 0) selects the calibrated base timing. +`include "build_id.v" +// No video options here: native video mode and centering trims arrive via +// the DDR control block written by the launcher (see rtl/native_video_reader.sv). localparam CONF_STR = { "MENU;UART31250,MIDI;", "-;", - "O[13:10],H Offset,0,+2,+4,+6,+8,+10,+12,+14,-16,-14,-12,-10,-8,-6,-4,-2;", - "O[17:14],V Offset,0,+1,+2,+3,+4,+5,+6,+7,-8,-7,-6,-5,-4,-3,-2,-1;", - "-;", "V,v",`BUILD_DATE }; @@ -348,8 +347,9 @@ always @(posedge clk_sys) begin end // DDR clear loop removed: native_video_reader owns DDRAM_* signals. -// When status[9]=0 the reader is held in idle (rd=0, we=0) and DDR is unused; -// when status[9]=1 the reader takes over to fetch the linux-rendered framebuffer. +// The reader polls the launcher's control block once per vblank; until the +// launcher publishes frames it issues a single 64-bit read per frame and the +// core shows the noise pattern. //////////////////////////// MT32pi ////////////////////////////////// @@ -460,27 +460,28 @@ end localparam lfsr_n = 63; -wire PAL = status[4]; wire FB = status[5]; wire [2:0] led = status[8:6]; -// Pixel clock: CLK_VIDEO = 27.027 MHz; ce_pix /4 = ~6.756 MHz, which gives -// an NTSC-spec 15.734 kHz line rate when fed into native_video_timing -// (H_TOTAL=429). Both the cosine fallback and the FB reader use this ce_pix. +// Pixel clock: CLK_VIDEO = 27.000 MHz (the universal SD video clock). +// ce_pix /4 = 6.75 MHz gives exactly 15734.27 Hz (NTSC, 429-px line) and +// 15625.00 Hz (PAL, 432-px line); the 480i mode runs /2 = 13.5 MHz with an +// 858-px line for the same 15734.27 Hz. Both the cosine fallback and the FB +// reader use this ce_pix. +wire [1:0] native_mode; reg [1:0] ce_div; reg ce_pix; always @(posedge CLK_VIDEO) begin if (RESET) ce_div <= 2'd0; else ce_div <= ce_div + 2'd1; - ce_pix <= (ce_div == 2'd0); + ce_pix <= (native_mode == 2'd1) ? ce_div[0] : (ce_div == 2'd0); end // Native video timing + DDR reader. Timing outputs (sync, DE, vcount, frame // edge) are the SINGLE source of truth for VGA scanout in both modes — that's -// what guarantees the CRT sees a clean 15.734 kHz line rate whether we're -// painting cosine noise or reading a Linux-rendered framebuffer. -wire mode_zaparoo = status[9]; - +// what guarantees the CRT sees a clean 15 kHz line rate whether we're +// painting cosine noise or reading a Linux-rendered framebuffer. Mode and +// centering trims come from the launcher's DDR control block, not the OSD. wire [7:0] native_r; wire [7:0] native_g; wire [7:0] native_b; @@ -489,6 +490,7 @@ wire native_vs; wire native_de; wire [8:0] native_vcount; wire native_new_frame; +wire native_field; wire native_active; native_video_top native_video @@ -518,17 +520,13 @@ native_video_top native_video .vga_vblank (), .vga_vcount (native_vcount), .vga_new_frame (native_new_frame), - .enable (mode_zaparoo), - .active (native_active), - - // H offset is a 4-bit signed OSD value doubled into 2-pixel steps. - // V offset is a normal 4-bit signed OSD value in 1-line steps. - .h_offset ($signed({status[13:10], 1'b0})), - .v_offset ($signed(status[17:14])) + .vga_mode (native_mode), + .vga_field (native_field), + .active (native_active) ); -// Cosine + LFSR fallback noise pattern, painted into the 320x240 active area -// of the shared native timing. vvc steps once per frame; the LFSR walks every +// Cosine + LFSR fallback noise pattern, painted into the active area of the +// shared native timing (352x240 when no launcher is publishing frames). vvc steps once per frame; the LFSR walks every // pixel; cos LUT is indexed by vvc + vcount so the pattern shifts vertically // over time. Outside the active area we drive black to keep sync clean. reg [9:0] vvc; @@ -550,11 +548,12 @@ cos cos(vvc + {native_vcount, 2'b00}, cos_out); wire [7:0] comp_v = (cos_g >= rnd_c) ? {cos_g - rnd_c, 2'b00} : 8'd0; -// Mode A (default): cosine pattern paints into the native active area. -// Mode B (status[9]=1, frame ready): DDR-read RGB replaces the cosine pattern. +// Default: cosine pattern paints into the native active area. Once the +// launcher publishes frames (valid control block, advancing counter), the +// DDR-read RGB replaces the cosine pattern; it reverts when the writer stops. // Sync/DE come from the same native timing in both cases — the CRT sees one -// continuous, NTSC-spec signal regardless of which RGB source is selected. -wire use_native = mode_zaparoo & native_active; +// continuous, broadcast-spec signal regardless of which RGB source is selected. +wire use_native = native_active; assign VGA_DE = native_de; assign VGA_HS = native_hs; diff --git a/rtl/native_video_reader.sv b/rtl/native_video_reader.sv index 19310280..07055b20 100644 --- a/rtl/native_video_reader.sv +++ b/rtl/native_video_reader.sv @@ -1,8 +1,21 @@ // Zaparoo native video DDR reader. -// DDR contract: -// 0x3A000000: control word, (frame_counter << 2) | active_buffer -// 0x3A000100: buffer 0, 320x240 RGBX8888 -// 0x3A04B100: buffer 1, 320x240 RGBX8888 +// +// DDR contract v2 (one 64-bit beat at 0x3A000000, read each vblank): +// word0 [31:0]: (frame_counter << 2) | active_buffer; 0 = writer stopped +// word1 [63:32]: [31:16] magic 0x5A50 ("ZP") +// [15:8] h_offset, signed pixels (+ = right) +// [7:4] v_offset, signed lines (+ = down) +// [3:0] mode: 0 = 352x240p60, 1 = 720x480i60, 2 = 352x288p50 +// 0x3A001000: buffer 0 0x3A180000: buffer 1 (tight stride, width*4 B) +// +// Legacy contract (word1 magic absent): 320x240 buffers at 0x3A000100 / +// 0x3A04B100; the picture is scanned centered in the 352-px active area +// with 16-px black bars each side, offsets 0, mode 0. +// +// In 480i the app publishes one progressive 720x480 frame; this reader +// fetches source line vcount*2 + field, so no field-splitting on the ARM +// side. 720 px = 360 words exceeds the 8-bit burst counter, so 480i lines +// are fetched as two 180-beat bursts. module native_video_reader ( @@ -24,12 +37,18 @@ module native_video_reader input wire vblank, input wire new_frame, input wire new_line, - input wire [8:0] vcount, + input wire field, + input wire [9:0] hcount, + + // Quasi-static, ddr_clk domain: caller synchronizes into the video + // domain; the timing module latches them at the field wrap. + output reg [1:0] mode_out, + output reg signed [7:0] h_offset_out, + output reg signed [3:0] v_offset_out, output reg [7:0] r_out, output reg [7:0] g_out, output reg [7:0] b_out, - input wire enable, output wire frame_ready ); @@ -38,19 +57,16 @@ assign ddr_be = 8'hFF; assign ddr_we = 1'b0; localparam [28:0] CTRL_ADDR = 29'h07400000; -localparam [28:0] BUF0_ADDR = 29'h07400020; -localparam [28:0] BUF1_ADDR = 29'h07409620; -localparam [7:0] LINE_BURST = 8'd160; -localparam [28:0] LINE_STRIDE = 29'd160; -localparam [8:0] V_ACTIVE = 9'd240; +localparam [28:0] BUF0_LEGACY = 29'h07400020; +localparam [28:0] BUF1_LEGACY = 29'h07409620; +localparam [28:0] BUF0_V2 = 29'h07400200; +localparam [28:0] BUF1_V2 = 29'h07430000; +localparam [15:0] MAGIC_V2 = 16'h5A50; localparam [19:0] TIMEOUT_MAX = 20'hF_FFFF; -reg [1:0] enable_sync; -always @(posedge ddr_clk) begin - if(reset) enable_sync <= 2'b0; - else enable_sync <= {enable_sync[0], enable}; -end -wire enable_ddr = enable_sync[1]; +// Legacy 320-px picture centered in the 352-px active area. +localparam [9:0] LEGACY_BAR_L = 10'd16; +localparam [9:0] LEGACY_BAR_R = 10'd336; reg [1:0] new_frame_sync; always @(posedge ddr_clk) begin @@ -73,6 +89,15 @@ always @(posedge ddr_clk) begin end wire vblank_ddr = vblank_sync[1]; +// Field is stable for a whole field; the reader samples it only while +// scanning, long after the edge. +reg [1:0] field_sync; +always @(posedge ddr_clk) begin + if(reset) field_sync <= 2'b0; + else field_sync <= {field_sync[0], field}; +end +wire field_ddr = field_sync[1]; + reg [1:0] reset_vid_sync; always @(posedge clk_vid or posedge reset) begin if(reset) reset_vid_sync <= 2'b11; @@ -89,6 +114,14 @@ end wire frame_ready_vid = frame_ready_sync[1]; assign frame_ready = frame_ready_vid; +reg legacy_mode; +reg [1:0] legacy_sync; +always @(posedge clk_vid) begin + if(reset_vid) legacy_sync <= 2'b0; + else legacy_sync <= {legacy_sync[0], legacy_mode}; +end +wire legacy_vid = legacy_sync[1]; + localparam [3:0] ST_IDLE = 4'd0; localparam [3:0] ST_POLL_CTRL = 4'd1; localparam [3:0] ST_WAIT_CTRL = 4'd2; @@ -100,10 +133,12 @@ localparam [3:0] ST_WAIT_DISPLAY = 4'd7; reg [3:0] state; reg [31:0] ctrl_word; +reg [31:0] ctrl_word1; reg [29:0] prev_frame_counter; reg [28:0] buf_base_addr; reg [8:0] cur_line; reg [7:0] beat_count; +reg burst_idx; reg first_frame_loaded; reg preloading; reg [19:0] timeout_cnt; @@ -111,6 +146,21 @@ reg fifo_wr; reg [63:0] fifo_wr_data; wire fifo_full; +// Per-frame fetch geometry, registered in ST_CHECK_CTRL from the parsed +// control block: line length in 64-bit words, line count, interlace flag. +reg [8:0] line_words; +reg [8:0] scan_lines; +reg scan_interlaced; +reg two_bursts; + +wire magic_ok = (ctrl_word1[31:16] == MAGIC_V2); +wire [1:0] ctrl_mode = (ctrl_word1[3:0] > 4'd2) ? 2'd0 : ctrl_word1[1:0]; + +// 480i: source line = displayed line * 2 + field, from one progressive frame. +wire [8:0] src_line = scan_interlaced ? ({cur_line[7:0], 1'b0} + {8'd0, field_ddr}) : cur_line; +wire [28:0] line_base = buf_base_addr + src_line * line_words; +wire [7:0] burst_len = two_bursts ? 8'd180 : line_words[7:0]; + reg [3:0] fifo_aclr_cnt; wire fifo_aclr_ddr_active = (fifo_aclr_cnt != 4'd0); wire fifo_aclr = reset | fifo_aclr_ddr_active; @@ -122,10 +172,12 @@ always @(posedge ddr_clk) begin ddr_burstcnt <= 8'd1; ddr_addr <= 29'd0; ctrl_word <= 32'd0; + ctrl_word1 <= 32'd0; prev_frame_counter <= 30'd0; - buf_base_addr <= BUF0_ADDR; + buf_base_addr <= BUF0_LEGACY; cur_line <= 9'd0; beat_count <= 8'd0; + burst_idx <= 1'b0; first_frame_loaded <= 1'b0; frame_ready_reg <= 1'b0; preloading <= 1'b0; @@ -133,6 +185,14 @@ always @(posedge ddr_clk) begin fifo_wr <= 1'b0; fifo_wr_data <= 64'd0; fifo_aclr_cnt <= 4'd0; + legacy_mode <= 1'b0; + mode_out <= 2'd0; + h_offset_out <= 8'sd0; + v_offset_out <= 4'sd0; + line_words <= 9'd160; + scan_lines <= 9'd240; + scan_interlaced <= 1'b0; + two_bursts <= 1'b0; end else begin fifo_wr <= 1'b0; @@ -148,7 +208,7 @@ always @(posedge ddr_clk) begin case(state) ST_IDLE: begin - if(enable_ddr && new_frame_ddr) state <= ST_POLL_CTRL; + if(new_frame_ddr) state <= ST_POLL_CTRL; end ST_POLL_CTRL: begin @@ -164,38 +224,71 @@ always @(posedge ddr_clk) begin ST_WAIT_CTRL: begin if(ddr_dout_ready) begin ctrl_word <= ddr_dout[31:0]; + ctrl_word1 <= ddr_dout[63:32]; timeout_cnt <= 20'd0; state <= ST_CHECK_CTRL; end - else if(timeout_cnt == TIMEOUT_MAX) state <= ST_IDLE; + else if(timeout_cnt == TIMEOUT_MAX) begin + // Stale frame_ready would scan a dead buffer forever; + // drop back to the noise pattern instead. + frame_ready_reg <= 1'b0; + first_frame_loaded <= 1'b0; + state <= ST_IDLE; + end else timeout_cnt <= timeout_cnt + 20'd1; end ST_CHECK_CTRL: begin - if(ctrl_word[31:2] != prev_frame_counter) begin - prev_frame_counter <= ctrl_word[31:2]; - buf_base_addr <= ctrl_word[0] ? BUF1_ADDR : BUF0_ADDR; - cur_line <= 9'd0; - preloading <= 1'b1; - fifo_aclr_cnt <= 4'd8; - if(first_frame_loaded) frame_ready_reg <= 1'b1; - state <= ST_READ_LINE; - end - else if(first_frame_loaded) begin - cur_line <= 9'd0; - preloading <= 1'b1; - fifo_aclr_cnt <= 4'd8; - state <= ST_READ_LINE; + if(ctrl_word == 32'd0) begin + // Writer stopped (or never started): revert to the noise + // pattern and forget the previous session. + frame_ready_reg <= 1'b0; + first_frame_loaded <= 1'b0; + prev_frame_counter <= 30'd0; + legacy_mode <= 1'b0; + mode_out <= 2'd0; + h_offset_out <= 8'sd0; + v_offset_out <= 4'sd0; + state <= ST_IDLE; end else begin - state <= ST_IDLE; + legacy_mode <= ~magic_ok; + mode_out <= magic_ok ? ctrl_mode : 2'd0; + h_offset_out <= magic_ok ? $signed(ctrl_word1[15:8]) : 8'sd0; + v_offset_out <= magic_ok ? $signed(ctrl_word1[7:4]) : 4'sd0; + line_words <= magic_ok ? ((ctrl_mode == 2'd1) ? 9'd360 : 9'd176) : 9'd160; + scan_lines <= (magic_ok && ctrl_mode == 2'd2) ? 9'd288 : 9'd240; + scan_interlaced <= magic_ok && (ctrl_mode == 2'd1); + two_bursts <= magic_ok && (ctrl_mode == 2'd1); + + if(ctrl_word[31:2] != prev_frame_counter) begin + prev_frame_counter <= ctrl_word[31:2]; + buf_base_addr <= ctrl_word[0] ? (magic_ok ? BUF1_V2 : BUF1_LEGACY) + : (magic_ok ? BUF0_V2 : BUF0_LEGACY); + cur_line <= 9'd0; + burst_idx <= 1'b0; + preloading <= 1'b1; + fifo_aclr_cnt <= 4'd8; + if(first_frame_loaded) frame_ready_reg <= 1'b1; + state <= ST_READ_LINE; + end + else if(first_frame_loaded) begin + cur_line <= 9'd0; + burst_idx <= 1'b0; + preloading <= 1'b1; + fifo_aclr_cnt <= 4'd8; + state <= ST_READ_LINE; + end + else begin + state <= ST_IDLE; + end end end ST_READ_LINE: begin if(!ddr_busy && !fifo_aclr_ddr_active) begin - ddr_addr <= buf_base_addr + (cur_line * LINE_STRIDE); - ddr_burstcnt <= LINE_BURST; + ddr_addr <= line_base + (burst_idx ? 29'd180 : 29'd0); + ddr_burstcnt <= burst_len; ddr_rd <= 1'b1; beat_count <= 8'd0; timeout_cnt <= 20'd0; @@ -204,14 +297,27 @@ always @(posedge ddr_clk) begin end ST_WAIT_LINE: begin - if(beat_count == LINE_BURST) state <= ST_LINE_DONE; - else if(timeout_cnt == TIMEOUT_MAX) state <= ST_IDLE; + if(beat_count == burst_len) begin + if(two_bursts && !burst_idx) begin + burst_idx <= 1'b1; + state <= ST_READ_LINE; + end + else begin + burst_idx <= 1'b0; + state <= ST_LINE_DONE; + end + end + else if(timeout_cnt == TIMEOUT_MAX) begin + frame_ready_reg <= 1'b0; + first_frame_loaded <= 1'b0; + state <= ST_IDLE; + end else if(!ddr_dout_ready) timeout_cnt <= timeout_cnt + 20'd1; end ST_LINE_DONE: begin cur_line <= cur_line + 9'd1; - if(cur_line == V_ACTIVE - 9'd1) begin + if(cur_line == scan_lines - 9'd1) begin first_frame_loaded <= 1'b1; frame_ready_reg <= 1'b1; preloading <= 1'b0; @@ -227,7 +333,7 @@ always @(posedge ddr_clk) begin end ST_WAIT_DISPLAY: begin - if(cur_line < V_ACTIVE && new_line_ddr && !vblank_ddr) state <= ST_READ_LINE; + if(cur_line < scan_lines && new_line_ddr && !vblank_ddr) state <= ST_READ_LINE; end default: state <= ST_IDLE; @@ -239,13 +345,15 @@ wire [63:0] fifo_rd_data; wire fifo_empty; reg fifo_rd; +// 1024 words: 480i preloads 2 x 360-word lines (720 words peak); the +// progressive modes peak around 368 words with their 176-word lines. dcfifo #( .intended_device_family ("Cyclone V"), - .lpm_numwords (512), + .lpm_numwords (1024), .lpm_showahead ("ON"), .lpm_type ("dcfifo"), .lpm_width (64), - .lpm_widthu (9), + .lpm_widthu (10), .overflow_checking ("ON"), .rdsync_delaypipe (4), .underflow_checking ("ON"), @@ -275,6 +383,10 @@ reg pixel_word_valid; wire [31:0] pixel_low = pixel_word[31:0]; wire [31:0] pixel_high_word = pixel_word[63:32]; +// Legacy frames are 320 px wide inside the 352-px active area: black bars +// for the first/last 16 px, FIFO pixels in between. +wire fetch_active = de && (!legacy_vid || (hcount >= LEGACY_BAR_L && hcount < LEGACY_BAR_R)); + task automatic output_pixel; input [31:0] pixel; begin @@ -300,7 +412,7 @@ always @(posedge clk_vid) begin fifo_rd <= 1'b0; if(ce_pix) begin - if(de && frame_ready_vid) begin + if(fetch_active && frame_ready_vid) begin if(pixel_word_valid) begin if(pixel_high) begin output_pixel(pixel_high_word); @@ -325,6 +437,12 @@ always @(posedge clk_vid) begin b_out <= 8'd0; end end + else if(de) begin + // Legacy side bars: keep the partially consumed word. + r_out <= 8'd0; + g_out <= 8'd0; + b_out <= 8'd0; + end else begin r_out <= 8'd0; g_out <= 8'd0; diff --git a/rtl/native_video_timing.sv b/rtl/native_video_timing.sv index 02ea58da..0d442cde 100644 --- a/rtl/native_video_timing.sv +++ b/rtl/native_video_timing.sv @@ -1,6 +1,15 @@ -// Zaparoo native video timing: 320x240 at 15.734 kHz from 27 MHz / 4. -// h_offset/v_offset shift the image by repartitioning front porch and back -// porch. H_TOTAL/V_TOTAL are invariant, so line/frame rates stay unchanged. +// Zaparoo native video timing: standard-definition CRT modes from 27 MHz. +// +// mode 0: 352x240p60 (NTSC) ce_pix = 27/4 = 6.75 MHz, 429x262, 15734.27 Hz +// mode 1: 720x480i60 (CEA-861) ce_pix = 27/2 = 13.5 MHz, 858x525, 15734.27 Hz +// mode 2: 352x288p50 (PAL) ce_pix = 27/4 = 6.75 MHz, 432x312, 15625.00 Hz +// +// mode_in/h_offset_in/v_offset_in are quasi-static (two-flop synchronized by +// the caller) and are latched here at the field wrap so a mid-frame update +// can't corrupt sync. Offsets shift the image by repartitioning front/back +// porch; totals are invariant, so line/frame rates never move. Out-of-range +// offsets are clamped to the supported -8..+8 px / -8..+2 line window, which +// keeps every mode's effective porches at or above 2 px / 1 line. module native_video_timing ( @@ -8,36 +17,69 @@ module native_video_timing input wire ce_pix, input wire reset, - // Image centering: positive = shift right/down (FP shrinks, BP grows). - input wire signed [4:0] h_offset, // -16..+14 pixels (budget H_FP=26 / H_BP=51) - input wire signed [3:0] v_offset, // -8..+7 lines (budget V_FP=8 / V_BP=11) + input wire [1:0] mode_in, + input wire signed [7:0] h_offset_in, // + = right, honored -8..+8 px + input wire signed [3:0] v_offset_in, // + = down, honored -8..+2 lines + output reg [1:0] mode, // latched active mode; selects the ce_pix divider + output reg field, // 480i field number, 0 in progressive modes output reg hsync, output reg vsync, output reg hblank, output reg vblank, output reg de, output reg [9:0] hcount, - output reg [8:0] vcount, + output reg [8:0] vcount, // line within the current field output reg new_frame, output reg new_line ); -localparam [9:0] H_ACTIVE = 10'd320; -// 26/32/51 keeps total blanking fixed while moving the default image 12 px -// left from the earlier CRT-specific 14/32/63 porch split. -localparam [9:0] H_FP = 10'd26; -localparam [5:0] H_SYNC = 6'd32; -localparam [9:0] H_BP = 10'd51; -localparam [9:0] H_TOTAL = 10'd429; - -// V blanking rebalanced from 6/3/13 to 8/3/11 to give symmetric ±8 budget -// while preserving V_TOTAL=262 (and thus 59.94 Hz refresh). -localparam [8:0] V_ACTIVE = 9'd240; -localparam [8:0] V_FP = 9'd8; -localparam [4:0] V_SYNC = 5'd3; -localparam [8:0] V_BP = 9'd11; -localparam [8:0] V_TOTAL = 9'd262; +localparam [1:0] MODE_NTSC = 2'd0; +localparam [1:0] MODE_480I = 2'd1; +localparam [1:0] MODE_PAL = 2'd2; + +// Per-mode parameter sets (Switchres ntsc/pal presets, CEA-861 for 480i). +// 480i: 525-line frame as two fields of 262 (field 0) and 263 (field 1) +// lines; field 1 additionally asserts vsync half a line late (see below). +reg [9:0] H_ACTIVE, H_FP, H_BP, H_TOTAL; +reg [6:0] H_SYNC; +reg [8:0] V_ACTIVE, V_FP, V_BP, V_TOTAL; +reg [4:0] V_SYNC; + +always @* begin + case(mode) + MODE_480I: begin + H_ACTIVE = 10'd720; H_FP = 10'd19; H_SYNC = 7'd62; H_BP = 10'd57; H_TOTAL = 10'd858; + V_ACTIVE = 9'd240; V_FP = 9'd4; V_SYNC = 5'd3; + V_BP = field ? 9'd16 : 9'd15; + V_TOTAL = field ? 9'd263 : 9'd262; + end + MODE_PAL: begin + H_ACTIVE = 10'd352; H_FP = 10'd11; H_SYNC = 7'd32; H_BP = 10'd37; H_TOTAL = 10'd432; + V_ACTIVE = 9'd288; V_FP = 9'd3; V_SYNC = 5'd3; V_BP = 9'd18; V_TOTAL = 9'd312; + end + default: begin // MODE_NTSC + H_ACTIVE = 10'd352; H_FP = 10'd12; H_SYNC = 7'd32; H_BP = 10'd33; H_TOTAL = 10'd429; + V_ACTIVE = 9'd240; V_FP = 9'd3; V_SYNC = 5'd3; V_BP = 9'd16; V_TOTAL = 9'd262; + end + endcase +end + +wire [1:0] next_mode = (mode_in == 2'd3) ? MODE_NTSC : mode_in; + +function automatic signed [4:0] clamp_h(input signed [7:0] v); + if (v > 8'sd8) clamp_h = 5'sd8; + else if (v < -8'sd8) clamp_h = -5'sd8; + else clamp_h = v[4:0]; +endfunction + +function automatic signed [3:0] clamp_v(input signed [3:0] v); + if (v > 4'sd2) clamp_v = 4'sd2; + else clamp_v = v; +endfunction + +reg signed [4:0] h_offset; +reg signed [3:0] v_offset; // Sync starts shift with the offset; two's-complement subtraction in // unsigned arithmetic yields the correct result at both ends of the range. @@ -46,8 +88,25 @@ wire [9:0] H_SYNC_END = H_SYNC_START + H_SYNC; wire [8:0] V_SYNC_START = V_ACTIVE + (V_FP - {{5{v_offset[3]}}, v_offset}); wire [8:0] V_SYNC_END = V_SYNC_START + V_SYNC; +// In 480i the odd field's vsync transitions half a scanline (H_TOTAL/2 +// ce_pix clocks) after the line boundary, interleaving its scanlines +// between the even field's. Without this both fields land on the same +// scanlines (line pairing). vs_step fires once per line at the point a +// vsync edge may occur; vs_line is the line whose start (field 0) or +// midpoint (field 1) that edge aligns to. +wire vs_step = field ? (hcount == (H_TOTAL >> 1) - 10'd1) + : (hcount == H_TOTAL - 10'd1); +wire [8:0] vs_line = field ? vcount : (vcount + 9'd1); + +wire line_wrap = (hcount == H_TOTAL - 10'd1); +wire field_wrap = line_wrap && (vcount == V_TOTAL - 9'd1); + always @(posedge clk) begin if(reset) begin + mode <= MODE_NTSC; + field <= 1'b0; + h_offset <= 5'sd0; + v_offset <= 4'sd0; hcount <= 10'd0; vcount <= 9'd0; hsync <= 1'b0; @@ -65,7 +124,7 @@ always @(posedge clk) begin new_frame <= 1'b0; new_line <= 1'b0; - if(hcount == H_TOTAL - 10'd1) begin + if(line_wrap) begin hcount <= 10'd0; if(vcount == V_TOTAL - 9'd1) vcount <= 9'd0; else vcount <= vcount + 9'd1; @@ -74,29 +133,49 @@ always @(posedge clk) begin hcount <= hcount + 10'd1; end + // Mode and trims apply only at the field wrap, with counters at + // zero, so every line of a field is cut from one parameter set. + if(field_wrap) begin + mode <= next_mode; + if(next_mode != MODE_480I) field <= 1'b0; + h_offset <= clamp_h(h_offset_in); + v_offset <= clamp_v(v_offset_in); + end + if(hcount == H_ACTIVE - 10'd1) hblank <= 1'b1; - else if(hcount == H_TOTAL - 10'd1) hblank <= 1'b0; + else if(line_wrap) hblank <= 1'b0; if(hcount == H_SYNC_START - 10'd1) hsync <= 1'b1; else if(hcount == H_SYNC_END - 10'd1) hsync <= 1'b0; - if(hcount == H_TOTAL - 10'd1) begin + if(vs_step) begin + if(vs_line == V_SYNC_START) vsync <= 1'b1; + else if(vs_line == V_SYNC_END) vsync <= 1'b0; + end + + if(line_wrap) begin if(vcount == V_ACTIVE - 9'd1) vblank <= 1'b1; else if(vcount == V_TOTAL - 9'd1) vblank <= 1'b0; - - if(vcount == V_SYNC_START - 9'd1) vsync <= 1'b1; - else if(vcount == V_SYNC_END - 9'd1) vsync <= 1'b0; end if(hcount == H_ACTIVE - 10'd1) new_line <= 1'b1; - if(hcount == H_TOTAL - 10'd1 && vcount == V_ACTIVE - 9'd1) new_frame <= 1'b1; + // Field flips at the START of vblank, not the field wrap: the + // reader preloads the next field's first lines right after + // new_frame, so the field it reads must already be the one about + // to be displayed. The vsync inside this blanking interval then + // uses the new field's phase, which keeps the half-line + // alternation intact (intervals stay exactly 262.5 lines). + if(line_wrap && vcount == V_ACTIVE - 9'd1) begin + new_frame <= 1'b1; + field <= (mode == MODE_480I) ? ~field : 1'b0; + end next_hblank = hblank; if(hcount == H_ACTIVE - 10'd1) next_hblank = 1'b1; - else if(hcount == H_TOTAL - 10'd1) next_hblank = 1'b0; + else if(line_wrap) next_hblank = 1'b0; next_vblank = vblank; - if(hcount == H_TOTAL - 10'd1) begin + if(line_wrap) begin if(vcount == V_ACTIVE - 9'd1) next_vblank = 1'b1; else if(vcount == V_TOTAL - 9'd1) next_vblank = 1'b0; end diff --git a/rtl/native_video_top.sv b/rtl/native_video_top.sv index 36cbedb3..0de69a9b 100644 --- a/rtl/native_video_top.sv +++ b/rtl/native_video_top.sv @@ -1,4 +1,9 @@ // Zaparoo native video wrapper: timing + RGBX8888 DDR reader. +// +// Mode and centering trims arrive from the launcher through the DDR control +// block (parsed by the reader in the ddr_clk domain). They are quasi-static: +// two-flop synchronized here into the video clock, then latched by the +// timing module at the field wrap. module native_video_top ( @@ -28,12 +33,9 @@ module native_video_top output wire [8:0] vga_vcount, output wire vga_new_frame, - input wire enable, - output wire active, - - // OSD image centering: 0 = default porch split. - input wire signed [4:0] h_offset, - input wire signed [3:0] v_offset + output wire [1:0] vga_mode, // active timing mode; selects ce_pix divider + output wire vga_field, // 480i field number (VGA_F1) + output wire active ); wire tim_hs; @@ -41,26 +43,44 @@ wire tim_vs; wire tim_hblank; wire tim_vblank; wire tim_de; +wire [9:0] tim_hcount; wire [8:0] tim_vcount; wire tim_new_frame; wire tim_new_line; +wire tim_field; + +wire [1:0] rd_mode; +wire signed [7:0] rd_h_offset; +wire signed [3:0] rd_v_offset; + +reg [1:0] mode_sync [1:0]; +reg [7:0] h_offset_sync [1:0]; +reg [3:0] v_offset_sync [1:0]; +always @(posedge clk_vid) begin + mode_sync[0] <= rd_mode; mode_sync[1] <= mode_sync[0]; + h_offset_sync[0] <= rd_h_offset; h_offset_sync[1] <= h_offset_sync[0]; + v_offset_sync[0] <= rd_v_offset; v_offset_sync[1] <= v_offset_sync[0]; +end native_video_timing timing ( - .clk (clk_vid), - .ce_pix (ce_pix), - .reset (reset), - .h_offset (h_offset), - .v_offset (v_offset), - .hsync (tim_hs), - .vsync (tim_vs), - .hblank (tim_hblank), - .vblank (tim_vblank), - .de (tim_de), - .hcount (), - .vcount (tim_vcount), - .new_frame (tim_new_frame), - .new_line (tim_new_line) + .clk (clk_vid), + .ce_pix (ce_pix), + .reset (reset), + .mode_in (mode_sync[1]), + .h_offset_in ($signed(h_offset_sync[1])), + .v_offset_in ($signed(v_offset_sync[1])), + .mode (vga_mode), + .field (tim_field), + .hsync (tim_hs), + .vsync (tim_vs), + .hblank (tim_hblank), + .vblank (tim_vblank), + .de (tim_de), + .hcount (tim_hcount), + .vcount (tim_vcount), + .new_frame (tim_new_frame), + .new_line (tim_new_line) ); wire frame_ready; @@ -85,12 +105,16 @@ native_video_reader reader .vblank (tim_vblank), .new_frame (tim_new_frame), .new_line (tim_new_line), - .vcount (tim_vcount), + .field (tim_field), + .hcount (tim_hcount), + + .mode_out (rd_mode), + .h_offset_out (rd_h_offset), + .v_offset_out (rd_v_offset), .r_out (vga_r), .g_out (vga_g), .b_out (vga_b), - .enable (enable), .frame_ready (frame_ready) ); @@ -101,6 +125,7 @@ assign vga_hblank = tim_hblank; assign vga_vblank = tim_vblank; assign vga_vcount = tim_vcount; assign vga_new_frame = tim_new_frame; -assign active = enable & frame_ready; +assign vga_field = tim_field; +assign active = frame_ready; endmodule diff --git a/rtl/pll/pll_0002.v b/rtl/pll/pll_0002.v index 4c7ed140..833cc2b9 100644 --- a/rtl/pll/pll_0002.v +++ b/rtl/pll/pll_0002.v @@ -25,7 +25,7 @@ module pll_0002( .output_clock_frequency0("100.000000 MHz"), .phase_shift0("0 ps"), .duty_cycle0(50), - .output_clock_frequency1("27027027 Hz"), + .output_clock_frequency1("27.000000 MHz"), .phase_shift1("0 ps"), .duty_cycle1(50), .output_clock_frequency2("0 MHz"), diff --git a/tb/dcfifo_sim.sv b/tb/dcfifo_sim.sv new file mode 100644 index 00000000..47b29b95 --- /dev/null +++ b/tb/dcfifo_sim.sv @@ -0,0 +1,70 @@ +// Behavioral stand-in for the Altera dcfifo megafunction (showahead mode), +// simulation only. No real CDC modeling — pointers are plain integers. + +module dcfifo #( + parameter intended_device_family = "", + parameter lpm_numwords = 1024, + parameter lpm_showahead = "ON", + parameter lpm_type = "dcfifo", + parameter lpm_width = 64, + parameter lpm_widthu = 10, + parameter overflow_checking = "ON", + parameter rdsync_delaypipe = 4, + parameter underflow_checking = "ON", + parameter use_eab = "ON", + parameter wrsync_delaypipe = 4 +)( + input wire aclr, + input wire [lpm_width-1:0] data, + input wire rdclk, + input wire rdreq, + input wire wrclk, + input wire wrreq, + output wire [lpm_width-1:0] q, + output wire rdempty, + output wire wrfull, + output wire [1:0] eccstatus, + output wire rdfull, + output wire [lpm_widthu-1:0] rdusedw, + output wire wrempty, + output wire [lpm_widthu-1:0] wrusedw +); + +reg [lpm_width-1:0] mem [0:lpm_numwords-1]; +integer wptr = 0, rptr = 0; +integer peak_used = 0; // TB-visible; may be reset hierarchically +integer overflow_count = 0; // writes dropped (overflow_checking semantics) +integer underflow_count = 0; + +wire [31:0] used = wptr - rptr; + +assign q = mem[rptr % lpm_numwords]; +assign rdempty = (used == 0); +assign wrfull = (used >= lpm_numwords); +assign eccstatus = 2'b00; +assign rdfull = wrfull; +assign wrempty = rdempty; +assign rdusedw = used[lpm_widthu-1:0]; +assign wrusedw = used[lpm_widthu-1:0]; + +always @(posedge wrclk or posedge aclr) begin + if (aclr) wptr <= 0; + else if (wrreq) begin + if (wrfull) overflow_count = overflow_count + 1; + else begin + mem[wptr % lpm_numwords] <= data; + wptr <= wptr + 1; + if (wptr + 1 - rptr > peak_used) peak_used = wptr + 1 - rptr; + end + end +end + +always @(posedge rdclk or posedge aclr) begin + if (aclr) rptr <= 0; + else if (rdreq) begin + if (rdempty) underflow_count = underflow_count + 1; + else rptr <= rptr + 1; + end +end + +endmodule diff --git a/tb/native_video_reader_tb.sv b/tb/native_video_reader_tb.sv new file mode 100644 index 00000000..b51faeb4 --- /dev/null +++ b/tb/native_video_reader_tb.sv @@ -0,0 +1,291 @@ +// System-level testbench for native_video_top (timing + reader) against a +// behavioral DDR model and tb/dcfifo_sim.sv. Verifies the v2 DDR contract: +// +// - no writer (word0 == 0): core stays inactive, only control polls issued +// - v2 frames: correct buffer base, 176-word line bursts, mode + h/v +// offsets parsed from word1 and latched by the timing module +// - double buffering: counter change switches buffer base +// - writer stop: word0 -> 0 drops active (frame_ready) again +// - legacy contract (no magic): 160-word lines from the legacy buffers, +// picture centered with 16-px black side bars +// - PAL (mode 2): 288 line fetches +// - 480i (mode 1): two 180-beat bursts per line, source line = 2*line+field, +// alternating per field; FIFO never overflows +// - DDR timeout: unresponsive bus drops active instead of latching stale +// +// Run: tb/run.sh + +`timescale 1ns/1ps + +module native_video_reader_tb; + +reg clk_sys = 0; always #5 clk_sys = ~clk_sys; // 100 MHz DDR-side +reg clk_vid = 0; always #18.5185 clk_vid = ~clk_vid; // 27 MHz +reg reset = 1; + +wire [1:0] vmode; +wire vfield; + +// ce_pix divider mirrors menu.sv. +reg [1:0] ce_div = 0; +reg ce_pix = 0; +always @(posedge clk_vid) begin + if (reset) ce_div <= 2'd0; + else ce_div <= ce_div + 2'd1; + ce_pix <= (vmode == 2'd1) ? ce_div[0] : (ce_div == 2'd0); +end + +// ---- DDR model ------------------------------------------------------------- +localparam [28:0] CTRL_ADDR = 29'h07400000; +localparam [28:0] BUF0_LEGACY = 29'h07400020; +localparam [28:0] BUF1_LEGACY = 29'h07409620; +localparam [28:0] BUF0_V2 = 29'h07400200; +localparam [28:0] BUF1_V2 = 29'h07430000; +localparam [63:0] PIX_DATA = 64'h00FFAA55_00FFAA55; // B,G,R,X = 55,AA,FF,00 + +wire ddr_rd; +wire [28:0] ddr_addr; +wire [7:0] ddr_burstcnt; +reg [63:0] ddr_dout = 0; +reg ddr_dout_ready = 0; + +reg [63:0] ctrl_q = 64'd0; // {word1, word0} as published by the "writer" +reg respond_en = 1; + +integer req_n = 0; +reg [28:0] req_addr [0:199999]; +reg [7:0] req_cnt [0:199999]; + +reg [28:0] cur_addr = 0; +integer cur_left = 0; +integer lat = 0; + +always @(posedge clk_sys) begin + ddr_dout_ready <= 0; + if (ddr_rd) begin + req_addr[req_n] = ddr_addr; + req_cnt[req_n] = ddr_burstcnt; + req_n = req_n + 1; + if (respond_en) begin + cur_addr <= ddr_addr; + cur_left <= ddr_burstcnt; + lat <= 3; + end + end + else if (cur_left != 0) begin + if (lat != 0) lat <= lat - 1; + else begin + ddr_dout <= (cur_addr == CTRL_ADDR) ? ctrl_q : PIX_DATA; + ddr_dout_ready <= 1; + cur_addr <= cur_addr + 29'd1; + cur_left <= cur_left - 1; + end + end +end + +// ---- DUT -------------------------------------------------------------------- +wire [7:0] vga_r, vga_g, vga_b; +wire new_frame, active; + +native_video_top dut +( + .clk_sys (clk_sys), + .clk_vid (clk_vid), + .ce_pix (ce_pix), + .reset (reset), + + .ddr_busy (1'b0), + .ddr_burstcnt (ddr_burstcnt), + .ddr_addr (ddr_addr), + .ddr_dout (ddr_dout), + .ddr_dout_ready (ddr_dout_ready), + .ddr_rd (ddr_rd), + .ddr_din (), + .ddr_be (), + .ddr_we (), + + .vga_r (vga_r), + .vga_g (vga_g), + .vga_b (vga_b), + .vga_hs (), + .vga_vs (), + .vga_de (), + .vga_hblank (), + .vga_vblank (), + .vga_vcount (), + .vga_new_frame (new_frame), + .vga_mode (vmode), + .vga_field (vfield), + .active (active) +); + +// ---- helpers ----------------------------------------------------------------- +integer errors = 0; + +task check(input string name, input integer got, input integer exp); + begin + if (got !== exp) begin + errors = errors + 1; + $display("FAIL %-36s got %0d (0x%0h), expected %0d (0x%0h)", name, got, got, exp, exp); + end + else $display("pass %-36s %0d", name, got); + end +endtask + +task wait_frames(input integer n); + repeat (n) @(posedge new_frame); +endtask + +// Publish a control block: word0 = (counter << 2) | buffer. +task publish(input bit magic, input [3:0] pmode, input signed [7:0] hoff, + input signed [3:0] voff, input integer counter, input bit buffer); + begin + ctrl_q = {magic ? 16'h5A50 : 16'h0000, hoff, voff, pmode, + counter[29:0], 1'b0, buffer}; + end +endtask + +// Verify one whole frame's DDR request sequence: a control poll followed by +// nlines line fetches of bpl bursts of blen beats each, line l fetched from +// base + src*stride + b*blen, where src = l (progressive) or 2*l + field +// (intl set). Also asserts the FIFO never overflowed during the frame. +task check_frame_fetch(input string tag, input [28:0] base, input integer stride, + input integer nlines, input integer blen, input integer bpl, + input bit intl, output reg fld); + integer s, l, b, idx, src; + begin + @(posedge new_frame); + @(negedge clk_vid); + fld = vfield; + s = req_n; + dut.reader.line_fifo.peak_used = 0; + dut.reader.line_fifo.overflow_count = 0; + @(posedge new_frame); + check({tag, " fifo overflow-free"}, dut.reader.line_fifo.overflow_count, 0); + $display("info %s fifo peak occupancy: %0d / 1024 words", tag, dut.reader.line_fifo.peak_used); + check({tag, " ctrl poll addr"}, req_addr[s], CTRL_ADDR); + check({tag, " ctrl poll burst"}, req_cnt[s], 1); + check({tag, " requests/frame"}, req_n >= s + 1 + nlines*bpl, 1); + for (l = 0; l < nlines; l = l + 1) begin + src = intl ? (2*l + fld) : l; + for (b = 0; b < bpl; b = b + 1) begin + idx = s + 1 + l*bpl + b; + if (req_addr[idx] !== base + src*stride + b*blen || req_cnt[idx] !== blen[7:0]) begin + errors = errors + 1; + $display("FAIL %s line %0d burst %0d: got addr 0x%0h cnt %0d, expected addr 0x%0h cnt %0d", + tag, l, b, req_addr[idx], req_cnt[idx], base + src*stride + b*blen, blen); + l = nlines; b = bpl; // bail after first mismatch + end + end + end + $display("pass %s frame fetch sequence (%0d lines x %0d bursts, field %0d)", tag, nlines, bpl, fld); + end +endtask + +// Sample vga_r at a given (hcount, vcount) pixel tick. +task sample_r(input integer hx, input integer vx, output [7:0] r); + begin + @(posedge clk_vid); + while (!(ce_pix && dut.timing.hcount == hx[9:0] && dut.timing.vcount == vx[8:0] && dut.timing.de)) + @(posedge clk_vid); + r = vga_r; + end +endtask + +reg [7:0] rs; +reg fld_a, fld_b; + +// ---- test sequence ------------------------------------------------------------ +initial begin + repeat (20) @(posedge clk_sys); + reset = 0; + + // Phase 0: no writer. + $display("--- phase 0: no writer ---"); + wait_frames(3); + check("idle: active low", {31'd0, active}, 0); + check("idle: only ctrl polls", req_cnt[req_n-1], 1); + check("idle: poll addr", req_addr[req_n-1], CTRL_ADDR); + + // Phase 1: v2 writer, mode 0, offsets +5/-3, buffer 0. + $display("--- phase 1: v2 mode 0 ---"); + publish(1, 4'd0, 8'sd5, -4'sd3, 1, 0); + wait (active === 1'b1); + wait_frames(2); + check("v2: mode latched", vmode, 0); + check("v2: h_offset latched", dut.timing.h_offset, 5); + check("v2: v_offset latched", dut.timing.v_offset, -3); + check_frame_fetch("v2-buf0", BUF0_V2, 176, 240, 176, 1, 0, fld_a); + sample_r(100, 100, rs); check("v2: interior pixel R", rs, 8'hFF); + sample_r(6, 100, rs); check("v2: no left bar", rs, 8'hFF); + + // Phase 2: counter advances with buffer 1. + $display("--- phase 2: double buffer ---"); + publish(1, 4'd0, 8'sd5, -4'sd3, 2, 1); + wait_frames(2); + check_frame_fetch("v2-buf1", BUF1_V2, 176, 240, 176, 1, 0, fld_a); + + // Phase 3: writer stops. + $display("--- phase 3: writer stop ---"); + ctrl_q = 64'd0; + wait_frames(3); + check("stop: active drops", {31'd0, active}, 0); + check("stop: mode reverts", vmode, 0); + + // Phase 4: legacy writer (no magic). + $display("--- phase 4: legacy contract ---"); + publish(0, 4'd0, 8'sd0, 4'sd0, 3, 0); + wait (active === 1'b1); + wait_frames(2); + check("legacy: offsets zero", dut.timing.h_offset, 0); + check_frame_fetch("legacy", BUF0_LEGACY, 160, 240, 160, 1, 0, fld_a); + sample_r(6, 100, rs); check("legacy: left bar black", rs, 8'h00); + sample_r(345, 100, rs); check("legacy: right bar black", rs, 8'h00); + sample_r(100, 100, rs); check("legacy: interior pixel", rs, 8'hFF); + + // Phase 5: PAL. + $display("--- phase 5: v2 mode 2 (PAL) ---"); + publish(1, 4'd2, 8'sd0, 4'sd0, 4, 0); + wait (vmode === 2'd2); + wait_frames(2); + check_frame_fetch("pal", BUF0_V2, 176, 288, 176, 1, 0, fld_a); + + // Phase 6: 480i. + $display("--- phase 6: v2 mode 1 (480i) ---"); + publish(1, 4'd1, 8'sd0, 4'sd0, 5, 0); + wait (vmode === 2'd1); + wait_frames(2); + check_frame_fetch("480i-a", BUF0_V2, 360, 240, 180, 2, 1, fld_a); + wait_frames(1); // realign so the next measured field has opposite parity + check_frame_fetch("480i-b", BUF0_V2, 360, 240, 180, 2, 1, fld_b); + check("480i: both field parities seen", {31'd0, fld_a ^ fld_b}, 1); + + // Phase 7: DDR stops responding mid-session. + $display("--- phase 7: DDR timeout ---"); + publish(1, 4'd0, 8'sd0, 4'sd0, 6, 0); + wait (vmode === 2'd0); + wait_frames(3); + check("pre-timeout: active", {31'd0, active}, 1); + respond_en = 0; + wait (active === 1'b0); + $display("pass timeout: active dropped"); + respond_en = 1; + publish(1, 4'd0, 8'sd0, 4'sd0, 7, 0); + wait (active === 1'b1); + $display("pass timeout: recovered after writer republish"); + + if (errors == 0) $display("ALL CHECKS PASSED"); + else begin + $display("%0d CHECK(S) FAILED", errors); + $fatal(1); + end + $finish; +end + +initial begin + #3_000_000_000; + $display("TIMEOUT"); + $fatal(1); +end + +endmodule diff --git a/tb/native_video_timing_tb.sv b/tb/native_video_timing_tb.sv new file mode 100644 index 00000000..dd174ff6 --- /dev/null +++ b/tb/native_video_timing_tb.sv @@ -0,0 +1,229 @@ +// Self-checking testbench for native_video_timing (docs/native-video-plan.md §8). +// +// Measures, in exact ce_pix ticks, per mode: line period, hsync width, +// sync→active delay, active width, field period, vsync width, active lines +// per field — and for 480i: field alternation and the half-line vsync offset +// (both vsync intervals must be exactly 262.5 lines = 225225 ticks, which is +// impossible without the offset). Also exercises the h/v offset trims and +// their clamping. +// +// Run: tb/run.sh + +`timescale 1ns/1ps + +module native_video_timing_tb; + +reg clk = 0; +always #18.5185 clk = ~clk; // 27 MHz + +reg reset = 1; + +reg [1:0] mode_in = 2'd0; +reg signed [7:0] h_offset_in = 8'sd0; +reg signed [3:0] v_offset_in = 4'sd0; + +wire [1:0] mode; +wire field, hsync, vsync, hblank, vblank, de, new_frame, new_line; +wire [9:0] hcount; +wire [8:0] vcount; + +// ce_pix divider mirrors menu.sv: /4 (6.75 MHz) progressive, /2 (13.5 MHz) 480i. +reg [1:0] ce_div = 0; +reg ce_pix = 0; +always @(posedge clk) begin + if (reset) ce_div <= 2'd0; + else ce_div <= ce_div + 2'd1; + ce_pix <= (mode == 2'd1) ? ce_div[0] : (ce_div == 2'd0); +end + +native_video_timing dut +( + .clk (clk), + .ce_pix (ce_pix), + .reset (reset), + .mode_in (mode_in), + .h_offset_in (h_offset_in), + .v_offset_in (v_offset_in), + .mode (mode), + .field (field), + .hsync (hsync), + .vsync (vsync), + .hblank (hblank), + .vblank (vblank), + .de (de), + .hcount (hcount), + .vcount (vcount), + .new_frame (new_frame), + .new_line (new_line) +); + +// ---- monitor: everything in ce_pix ticks --------------------------------- +// DUT outputs are sampled one tick late, uniformly, so intervals are exact. +integer tick = 0; +reg hs_d = 0, vs_d = 0, de_d = 0; +reg await_de = 0; +reg vs_field = 0, vs_field_d = 0; + +integer hs_rise_tick = 0, vs_rise_tick = 0, de_rise_tick = 0; +integer hs_period = 0, hs_width = 0; +integer de_width = 0, hs_to_de = 0, vs_to_de = 0; +integer vs_period = 0, vs_period_d = 0, vs_width = 0; +integer de_lines = 0, de_lines_last = 0; +integer vs_count = 0; + +always @(posedge clk) begin + if (ce_pix) begin + if (hsync & ~hs_d) begin + hs_period <= tick - hs_rise_tick; + hs_rise_tick <= tick; + end + if (~hsync & hs_d) hs_width <= tick - hs_rise_tick; + + if (de & ~de_d) begin + de_rise_tick <= tick; + hs_to_de <= tick - hs_rise_tick; + de_lines <= de_lines + 1; + if (await_de) begin + vs_to_de <= tick - vs_rise_tick; + await_de <= 0; + end + end + if (~de & de_d) de_width <= tick - de_rise_tick; + + if (vsync & ~vs_d) begin + vs_period_d <= vs_period; + vs_period <= tick - vs_rise_tick; + vs_rise_tick <= tick; + de_lines_last <= de_lines; + de_lines <= 0; + vs_field_d <= vs_field; + vs_field <= field; + await_de <= 1; + vs_count <= vs_count + 1; + end + if (~vsync & vs_d) vs_width <= tick - vs_rise_tick; + + hs_d <= hsync; + vs_d <= vsync; + de_d <= de; + tick <= tick + 1; + end +end + +// ---- helpers --------------------------------------------------------------- +integer errors = 0; + +task check(input string name, input integer got, input integer exp); + begin + if (got !== exp) begin + errors = errors + 1; + $display("FAIL %-32s got %0d, expected %0d", name, got, exp); + end + else $display("pass %-32s %0d", name, got); + end +endtask + +// Wait n vsync rising edges (plenty for the field-wrap latch + a full +// measurable frame after any control change). +task settle(input integer n); + integer target; + begin + target = vs_count + n; + wait (vs_count >= target); + @(posedge clk); + end +endtask + +// ---- test sequence --------------------------------------------------------- +initial begin + repeat (8) @(posedge clk); + reset = 0; + + // ---- mode 0: 352x240p60 NTSC -------------------------------------- + settle(3); + $display("--- mode 0: 352x240p60 (line 63.556us, 15734.27 Hz, 60.05 Hz) ---"); + check("ntsc line period (px)", hs_period, 429); + check("ntsc hsync width (px)", hs_width, 32); + check("ntsc sync->active (px)", hs_to_de, 65); // 32 sync + 33 BP + check("ntsc active width (px)", de_width, 352); + check("ntsc field period (px)", vs_period, 429*262); + check("ntsc vsync width (px)", vs_width, 429*3); + check("ntsc active lines", de_lines_last, 240); + check("ntsc vsync->active (px)", vs_to_de, 429*19); // 3 sync + 16 BP + check("ntsc field flat", {31'd0, vs_field}, 0); + + // ---- mode 2: 352x288p50 PAL ---------------------------------------- + mode_in = 2'd2; + settle(4); + $display("--- mode 2: 352x288p50 (line 64.000us, 15625.00 Hz, 50.08 Hz) ---"); + check("pal line period (px)", hs_period, 432); + check("pal hsync width (px)", hs_width, 32); + check("pal sync->active (px)", hs_to_de, 69); // 32 sync + 37 BP + check("pal active width (px)", de_width, 352); + check("pal field period (px)", vs_period, 432*312); + check("pal vsync width (px)", vs_width, 432*3); + check("pal active lines", de_lines_last, 288); + + // ---- mode 1: 720x480i60 --------------------------------------------- + mode_in = 2'd1; + settle(5); + $display("--- mode 1: 720x480i60 (line 63.556us, 15734.27 Hz, 59.94 Hz) ---"); + check("480i line period (px)", hs_period, 858); + check("480i hsync width (px)", hs_width, 62); + check("480i sync->active (px)", hs_to_de, 119); // 62 sync + 57 BP + check("480i active width (px)", de_width, 720); + // Both vsync intervals = 262.5 lines exactly: proves the half-line + // offset (integer field lengths would give 262*858 / 263*858). + check("480i field period A (px)", vs_period, 225225); + check("480i field period B (px)", vs_period_d, 225225); + check("480i vsync width (px)", vs_width, 858*3); + check("480i active lines/field", de_lines_last, 240); + check("480i fields alternate", {31'd0, vs_field ^ vs_field_d}, 1); + + // ---- offset trims + clamping (mode 0) ------------------------------- + mode_in = 2'd0; + settle(4); + $display("--- offset trims (mode 0) ---"); + + h_offset_in = 8'sd8; settle(4); + check("h=+8 sync->active", hs_to_de, 73); + check("h=+8 active width", de_width, 352); + check("h=+8 line period", hs_period, 429); + h_offset_in = -8'sd8; settle(4); + check("h=-8 sync->active", hs_to_de, 57); + h_offset_in = 8'sd100; settle(4); + check("h=+100 clamps to +8", hs_to_de, 73); + h_offset_in = -8'sd100; settle(4); + check("h=-100 clamps to -8", hs_to_de, 57); + h_offset_in = 8'sd0; + + v_offset_in = 4'sd2; settle(4); + check("v=+2 vsync->active", vs_to_de, 429*21); + check("v=+2 field period", vs_period, 429*262); + v_offset_in = -4'sd8; settle(4); + check("v=-8 vsync->active", vs_to_de, 429*11); + v_offset_in = 4'sd7; settle(4); + check("v=+7 clamps to +2", vs_to_de, 429*21); + v_offset_in = 4'sd0; + + // ---- mode change sanity: back to NTSC after everything -------------- + settle(4); + check("ntsc restore line period", hs_period, 429); + check("ntsc restore sync->active", hs_to_de, 65); + check("ntsc restore field flat", {31'd0, vs_field}, 0); + + if (errors == 0) $display("ALL CHECKS PASSED"); + else begin + $display("%0d CHECK(S) FAILED", errors); + $fatal(1); + end + $finish; +end + +initial begin + #2_000_000_000; // 2 s simulated-time guard + $display("TIMEOUT"); + $fatal(1); +end + +endmodule diff --git a/tb/run.sh b/tb/run.sh new file mode 100755 index 00000000..8de90d0b --- /dev/null +++ b/tb/run.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Simulate the native video testbenches with Icarus Verilog. +set -e +cd "$(dirname "$0")" + +iverilog -g2012 -o native_video_timing_tb.vvp \ + ../rtl/native_video_timing.sv native_video_timing_tb.sv +vvp native_video_timing_tb.vvp + +iverilog -g2012 -o native_video_reader_tb.vvp \ + ../rtl/native_video_timing.sv ../rtl/native_video_reader.sv \ + ../rtl/native_video_top.sv dcfifo_sim.sv native_video_reader_tb.sv +vvp native_video_reader_tb.vvp From 23bcb88f3b031f82a47f06c0bf7b297d237cecb2 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Thu, 11 Jun 2026 11:40:20 +0800 Subject: [PATCH 07/18] docs: add frontend implementation brief for native video v2 - Self-contained handoff document for the zaparoo-launcher team: the v2 DDR contract (word layout, buffer addresses, publish/stop protocol, write ordering), the 352x240 writer changes, safe-area and 480i flicker rules, the calibration screen spec, and the hardware verification checklist. --- docs/native-video-frontend-brief.md | 174 ++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/native-video-frontend-brief.md diff --git a/docs/native-video-frontend-brief.md b/docs/native-video-frontend-brief.md new file mode 100644 index 00000000..2c0bd3c3 --- /dev/null +++ b/docs/native-video-frontend-brief.md @@ -0,0 +1,174 @@ +# Frontend implementation brief: native CRT video v2 (zaparoo-launcher) + +**Audience:** the zaparoo-launcher team / an implementation agent with no prior +context. This document is self-contained; `docs/native-video-plan.md` (same +repo) has the full background and rationale if you want it. +**Counterpart:** Menu_MiSTer fork, branch `fix/native-video-centering` — the +FPGA side of everything below is implemented, simulated, and pushed. The +launcher work in this brief is the only remaining piece. +**Existing code this modifies:** `src/app/native_video_writer.cpp` and the +`--crt` startup path in zaparoo-launcher (see also its `docs/native-core-poc.md`). + +--- + +## 1. What changed and why you're doing this + +The menu core no longer outputs a 320x240 picture with hand-tuned porches, and +it no longer has any OSD video options. It now generates broadcast-standard +15 kHz timing in three modes, and **everything the launcher used to rely on +the OSD for (CRT mode on/off, H/V centering) now travels through the DDR +control block you already write**. Key consequences for the app: + +- The framebuffer is now **352x240** (not 320x240). 352 px fills a standard + NTSC/PAL active line edge-to-edge; the old 320 was ~10% too narrow on every + correctly calibrated CRT. +- The picture now *overscans* like broadcast TV: the outer few percent of the + framebuffer is cropped on most sets. The UI must adopt safe-area rules + (section 5) — this is as much a part of the fix as the FPGA work. +- There is no "CRT mode" toggle anywhere. **Publishing frames IS the mode + switch**: the core shows its noise pattern until your control word goes + live and reverts when you zero it. +- Two new modes exist when you're ready for them: **720x480i60** (mode 1) and + **352x288p50 PAL** (mode 2). The core side is done; you opt in per-frame + via the mode field. + +Backward compatibility is handled on the core side: an old launcher writing +the legacy 320x240 layout still displays (centered with 16-px black side +bars), and your existing fb-geometry validation already self-disables the +writer against an old core. Ship order doesn't matter. + +## 2. DDR contract v2 (normative) + +Physical base `0x3A000000`, mmap **0x300000** (3 MB, up from 640 KB). + +| Offset | Contents | +|---|---| +| `+0x0` | **word0**: `(frame_counter << 2) \| active_buffer`. Bit 1 reserved, write 0. `0` means "writer stopped". | +| `+0x4` | **word1**: `[31:16]` magic `0x5A50` ("ZP"); `[15:8]` h_offset, signed int8, pixels, + = right; `[7:4]` v_offset, signed 4-bit, lines, + = down; `[3:0]` mode | +| `+0x1000` | buffer 0 | +| `+0x180000` | buffer 1 | + +Modes: `0` = 352x240 @ 60p (NTSC, default), `1` = 720x480 @ 60i, +`2` = 352x288 @ 50p (PAL). Stride is always tight (`width * 4` bytes). +Pixel format is unchanged: memcpy linuxfb BGRX rows as-is; the core swaps +bytes in RTL. + +Per-mode framebuffer numbers: + +| Mode | fb size | stride | frame bytes | +|---|---|---|---| +| 0 | 352x240 | 1408 | 0x52800 (337 920) | +| 2 | 352x288 | 1408 | 0x63000 (405 504) | +| 1 | 720x480 | 2880 | 0x151800 (1 382 400) | + +Protocol rules: + +1. **Init:** write word1 (magic + mode + saved offsets) **before** the first + word0 publish. The core reads both words in one atomic 64-bit beat once + per vblank, so word1-then-word0 ordering guarantees the first frame is + interpreted correctly. +2. **Publish:** render into the inactive buffer, then write word0 once with + the incremented counter and that buffer's index (single 32-bit store — + this is the atomic commit). Counter is 30 bits, start at 1. +3. **Mode/offset change at runtime:** update word1 first, then bump word0. + The core latches mode and offsets at the field boundary; modes 0↔1 keep + the same line rate (instant re-lock), 0/1↔2 is a 50↔60 Hz retune (the CRT + takes a moment, like real hardware). +4. **Stop:** zero word0 (zero word1 too for tidiness). The core reverts to + its noise pattern within one frame. This is also your crash-recovery + story — if the launcher dies and the words go stale, the core keeps + scanning the last frame; only a zeroed word0 releases it, so keep the + existing stop-handler behavior. +5. **Offsets:** the core honors **−8…+8 px** horizontal, **−8…+2 lines** + vertical, and clamps anything outside (a garbage word1 degrades to a + saturated shift, never broken sync). Don't rely on the clamp — keep the + calibration UI within those ranges. +6. **480i is rendered progressive:** publish one normal 720x480 frame; the + core extracts fields itself (reads source line `2*line + field`). No + field splitting, no half-frame timing on the ARM side. + +## 3. Task 1 — Phase A (required): 352x240 writer + safe-area UI + +This is the must-ship piece; modes 1 and 2 are follow-ups. + +1. `--crt` startup sets fb0 to **352x240 32bpp** (the `vmode -r 352 240 + rgb32` equivalent of the current 320x240 setup). Update the fb-geometry + validation to expect 352x240. +2. Update writer constants: width 352, stride 1408, frame size 0x52800, + buffers at `+0x1000` / `+0x180000`, mmap 0x300000. +3. Write word1 on init: magic `0x5A50`, mode 0, offsets from launcher config + (default 0/0). Clear both words on stop. +4. UI safe-area pass (section 5). +5. Calibration screen (section 6). + +Acceptance: on hardware with the new core, the launcher UI fills a CRT +edge-to-edge; killing the launcher returns the noise pattern; a capture +device reports 15.734 kHz / 240p. + +## 4. Tasks 2 & 3 — PAL and 480i (when ready) + +**PAL (mode 2):** add a "video standard: NTSC / PAL" user setting. PAL +renders **352x288** and publishes mode 2. Note most PAL sets accept 60 Hz +RGB over SCART ("PAL-60"), so mode 0 remains a fine default in PAL regions; +mode 2 is for strict-50 Hz sets and correct-speed feel. + +**480i (mode 1):** add a 720x480 rendering path and (optionally) per-screen +mode selection — e.g. main UI in 240p, text-heavy screens in 480i. +Flicker discipline is mandatory (section 5, rule 4). + +## 5. UI rendering rules (apply to every mode) + +These are not suggestions; geometry alone doesn't fix "every CRT crops +differently": + +1. **Render full-bleed.** Background art/color must reach all four edges. + The outer few percent will be cropped on most sets and visible on a few — + both must look intentional. +2. **Safe areas** (SMPTE SD practice): + - *Action safe* (all interactive/meaningful content): central **90%** — + ~317x216 of 352x240, ~317x259 of 352x288, ~648x432 of 720x480. + - *Title safe* (text that must be readable): central **80%** — + ~282x192 / ~282x230 / ~576x384. +3. **Pixel aspect ratio is 10:11** (pixels ~9% narrower than square) in all + three modes. Ignorable for boxes-and-text; correct for logos/art that + must not look squished (a true circle needs ~10% more width in pixels). +4. **480i flicker discipline:** every scanline repaints 30x/second, so 1-px + horizontal lines and fine text shimmer. Use ≥2 px horizontal strokes, + avoid hard 1-px horizontal edges, or apply a mild vertical blur (the + standard console-era 480i dashboard trick). Existing CRT typography rules + in `native-core-poc.md` (integer snapping, bitmap fonts) stay in force. + +## 6. Calibration screen + +The launcher now owns centering (the OSD options are gone): + +- Draw a border test pattern (240p-test-suite style: 1-px frame at the + extreme edge, rectangles at the 90% and 80% safe areas, cross-hatch). +- Arrow keys nudge h_offset (−8…+8, 1-px steps) and v_offset (−8…+2), + publishing word1 live so the user sees the picture move in real time. +- Persist the values in launcher config; load them at init. Defaults are + zero — the standard timing is the centering mechanism, trims only + compensate for miscentered sets. + +## 7. Verification checklist (frontend-visible items) + +- Fill/centering on **2–3 different CRTs** plus a capture device (should + report 15.734 kHz exactly; 480i should be detected as 480i, not 240p). +- Writer-stop: kill the launcher → noise pattern returns. +- Trim screen: live nudge both axes; values survive a restart; out-of-range + values (if forced) shift-and-saturate without disturbing sync. +- Compat matrix: old launcher + new core → centered 320x240 with side bars; + new launcher + old core → writer self-disables via fb-geometry validation, + core shows noise (obvious, not subtle, breakage). +- 480i: fine horizontal lines should shimmer, not stack (no line pairing). +- HDMI output still locks in every mode (the core's ascal path handles it; + just confirm). + +## 8. Reference + +- FPGA-side spec and rationale: `docs/native-video-plan.md` (Menu_MiSTer). +- RTL that consumes this contract: `rtl/native_video_reader.sv` (the word1 + parse and buffer addresses are the source of truth, with simulation + coverage in `tb/native_video_reader_tb.sv`). +- Current writer: `src/app/native_video_writer.cpp` (zaparoo-launcher). +- Why the scaler is bypassed: `docs/native-core-poc.md` (zaparoo-launcher). From 9f1c2d6ced9b0f6446430506c09bb55ff8381d11 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Thu, 11 Jun 2026 12:39:28 +0800 Subject: [PATCH 08/18] fix: generate 27.000 MHz from a dedicated video PLL The fitter rejected 27.000000 MHz on the shared PLL: all outputs of one PLL divide a common VCO, and lcm(100 MHz clk_sys, 27 MHz) = 2700 MHz is beyond the Cyclone V's 600-1600 MHz VCO range. 27.027027 MHz (1000/37) is exactly the closest sharable frequency, which is why stock MiSTer uses it. - Revert rtl/pll/pll_0002.v to stock; its 27.027 MHz output is now unconnected. - Add rtl/pll_video.v (+ pll_video_0002.v, qip): dedicated PLL whose sole output is exact 27.000000 MHz (VCO 1350 MHz = 50 x 27, C = 50); CLK_VIDEO comes from it, and the native video path holds in reset until it locks. - Add menu.sdc declaring the video clock asynchronous to all other clocks. It was absent from sys_top.sdc's exclusive clock groups, so TimeQuest analyzed the two-flop synchronizer and line-FIFO crossings as related paths (worst slack -10.4 ns, TNS -1529). All crossings are designed CDC structures. With the constraint, the full Quartus 17.0.2 compile meets timing on every domain (worst setup slack +0.53 ns, TNS 0), which the previous 27.027 MHz baseline did not (-4.8 ns, TNS -426). - Document the shared-VCO constraint in docs/native-video-plan.md. --- docs/native-video-plan.md | 23 ++++++--- files.qip | 2 + menu.sdc | 12 +++++ menu.sv | 17 +++++-- rtl/pll/pll_0002.v | 2 +- rtl/pll_video.qip | 6 +++ rtl/pll_video.v | 25 ++++++++++ rtl/pll_video/pll_video_0002.v | 86 ++++++++++++++++++++++++++++++++++ 8 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 menu.sdc create mode 100644 rtl/pll_video.qip create mode 100644 rtl/pll_video.v create mode 100644 rtl/pll_video/pll_video_0002.v diff --git a/docs/native-video-plan.md b/docs/native-video-plan.md index 8894b4fd..bba293e0 100644 --- a/docs/native-video-plan.md +++ b/docs/native-video-plan.md @@ -145,9 +145,17 @@ geometry (section 4) plus safe-area UI rules (section 6). ## 4. Target timings -Everything derives from **one PLL change**: output 1 of `rtl/pll/pll_0002.v` -goes from 27.027027 MHz to **27.000000 MHz** — the universal SD video clock -(it is exactly 1716 × NTSC line rate and 1728 × PAL line rate). +Everything derives from one clock change: CLK_VIDEO goes from 27.027027 MHz +to **27.000000 MHz** — the universal SD video clock (it is exactly 1716 × +NTSC line rate and 1728 × PAL line rate). + +> **Implementation note (found at fit time):** 27.000 MHz cannot come from +> the existing PLL. All outputs of one PLL divide a shared VCO, and +> lcm(100 MHz clk_sys, 27 MHz) = 2700 MHz exceeds the Cyclone V's +> 600–1600 MHz VCO range — 27.027027 (1000 MHz / 37) is precisely the +> closest sharable frequency, which is why stock MiSTer uses it. The fix is +> a dedicated video PLL (`rtl/pll_video.v`, VCO 1350 MHz = 50 × 27, C = 50) +> whose sole output drives CLK_VIDEO; `pll_0002.v` stays stock. | Mode | ce_pix | H total | H active / FP / sync / BP (px) | V total | V active / FP / sync / BP (lines) | Line rate | Refresh | |---|---|---|---|---|---|---|---| @@ -348,9 +356,12 @@ These are as much a part of the fix as the RTL — geometry alone doesn't solve FPGA (this repo): -1. **`rtl/pll/pll_0002.v`**: `output_clock_frequency1` 27.027027 MHz → - `27.000000 MHz`. (Same single-line style as the earlier 20→27.027 change; - no other PLL params move.) +1. ~~`rtl/pll/pll_0002.v`: `output_clock_frequency1` 27.027027 MHz → + `27.000000 MHz`.~~ Superseded: the shared PLL cannot fit 27.000 MHz (see + the implementation note in §4). Instead `pll_0002.v` stays stock and a + new dedicated `rtl/pll_video.v` (+ `rtl/pll_video/pll_video_0002.v`, + `rtl/pll_video.qip`) generates CLK_VIDEO = 27.000000 MHz; menu.sv holds + the native video path in reset until it locks. 2. **`rtl/native_video_timing.sv`**: mode-0 constants — H 352/12/32/33, V 240/3/3/16. Structure the constants as per-mode parameter sets selected by a `mode` input (tied to 0 until Phases B/C) so later modes are additive. diff --git a/files.qip b/files.qip index 93247c16..b1b07c40 100644 --- a/files.qip +++ b/files.qip @@ -1,3 +1,5 @@ +set_global_assignment -name QIP_FILE rtl/pll_video.qip +set_global_assignment -name SDC_FILE menu.sdc set_global_assignment -name SYSTEMVERILOG_FILE rtl/sdram.sv set_global_assignment -name VERILOG_FILE rtl/lfsr.v set_global_assignment -name SYSTEMVERILOG_FILE rtl/cos.sv diff --git a/menu.sdc b/menu.sdc new file mode 100644 index 00000000..0a01b314 --- /dev/null +++ b/menu.sdc @@ -0,0 +1,12 @@ +# Core-level timing constraints (processed after sys/sys_top.sdc). + +# CLK_VIDEO comes from a dedicated PLL (rtl/pll_video.v) and is asynchronous +# to every other clock in the design: all crossings into and out of the video +# domain go through two-flop synchronizers or the line FIFO's dual-clock +# logic (rtl/native_video_reader.sv, rtl/native_video_top.sv). Without this +# group, derive_pll_clocks leaves the 27 MHz output related to the other +# clocks (shared 50 MHz reference, and absent from sys_top.sdc's exclusive +# groups), and the fitter tries to close those CDC paths against a ~1 ns +# edge relationship. +set_clock_groups -asynchronous \ + -group [get_clocks { *|pll_video|pll_video_inst|altera_pll_i|*[*].*|divclk}] diff --git a/menu.sv b/menu.sv index f6b84ea2..6aab28c3 100644 --- a/menu.sv +++ b/menu.sv @@ -237,10 +237,21 @@ pll pll .refclk(CLK_50M), .rst(0), .outclk_0(clk_sys), - .outclk_1(CLK_VIDEO), + .outclk_1(), // stock 27.027 MHz output, unused (see pll_video) .locked(locked) ); +// Exact 27.000000 MHz video clock from its own PLL: 27 MHz can't share a +// VCO with the 100 MHz clk_sys (lcm = 2700 MHz, above the VCO ceiling). +wire vid_locked; +pll_video pll_video +( + .refclk(CLK_50M), + .rst(0), + .outclk_0(CLK_VIDEO), + .locked(vid_locked) +); + ///////////////////// SDRAM /////////////////// // @@ -472,7 +483,7 @@ wire [1:0] native_mode; reg [1:0] ce_div; reg ce_pix; always @(posedge CLK_VIDEO) begin - if (RESET) ce_div <= 2'd0; + if (RESET | ~vid_locked) ce_div <= 2'd0; else ce_div <= ce_div + 2'd1; ce_pix <= (native_mode == 2'd1) ? ce_div[0] : (ce_div == 2'd0); end @@ -498,7 +509,7 @@ native_video_top native_video .clk_sys (clk_sys), .clk_vid (CLK_VIDEO), .ce_pix (ce_pix), - .reset (RESET), + .reset (RESET | ~vid_locked), .ddr_busy (DDRAM_BUSY), .ddr_burstcnt (DDRAM_BURSTCNT), diff --git a/rtl/pll/pll_0002.v b/rtl/pll/pll_0002.v index 833cc2b9..4c7ed140 100644 --- a/rtl/pll/pll_0002.v +++ b/rtl/pll/pll_0002.v @@ -25,7 +25,7 @@ module pll_0002( .output_clock_frequency0("100.000000 MHz"), .phase_shift0("0 ps"), .duty_cycle0(50), - .output_clock_frequency1("27.000000 MHz"), + .output_clock_frequency1("27027027 Hz"), .phase_shift1("0 ps"), .duty_cycle1(50), .output_clock_frequency2("0 MHz"), diff --git a/rtl/pll_video.qip b/rtl/pll_video.qip new file mode 100644 index 00000000..e8d4b6d1 --- /dev/null +++ b/rtl/pll_video.qip @@ -0,0 +1,6 @@ +set_global_assignment -name VERILOG_FILE [file join $::quartus(qip_path) "pll_video.v"] +set_global_assignment -name VERILOG_FILE [file join $::quartus(qip_path) "pll_video/pll_video_0002.v"] + +set_instance_assignment -name PLL_COMPENSATION_MODE DIRECT -to "*pll_video_0002*|altera_pll:altera_pll_i*|*" +set_instance_assignment -name PLL_AUTO_RESET ON -to "*pll_video_0002*|altera_pll:altera_pll_i*|*" +set_instance_assignment -name PLL_BANDWIDTH_PRESET AUTO -to "*pll_video_0002*|altera_pll:altera_pll_i*|*" diff --git a/rtl/pll_video.v b/rtl/pll_video.v new file mode 100644 index 00000000..1fbb347d --- /dev/null +++ b/rtl/pll_video.v @@ -0,0 +1,25 @@ +// Dedicated video PLL: exact 27.000000 MHz for SD CRT timing. +// +// This cannot come from the main PLL: every output of a PLL divides the +// same VCO, and the smallest common multiple of 100 MHz (clk_sys) and +// 27 MHz is 2700 MHz — outside the Cyclone V's 600-1600 MHz VCO range. +// (That constraint is why stock MiSTer uses 27.027027 MHz: 1000 MHz / 37 +// is the closest to 27 MHz a VCO shared with 100 MHz can reach.) +// Standalone, 27 MHz is exact: VCO = 50 MHz x 27 = 1350 MHz, C = 50. + +`timescale 1 ps / 1 ps +module pll_video ( + input wire refclk, // refclk.clk + input wire rst, // reset.reset + output wire outclk_0, // outclk0.clk + output wire locked // locked.export + ); + + pll_video_0002 pll_video_inst ( + .refclk (refclk), // refclk.clk + .rst (rst), // reset.reset + .outclk_0 (outclk_0), // outclk0.clk + .locked (locked) // locked.export + ); + +endmodule diff --git a/rtl/pll_video/pll_video_0002.v b/rtl/pll_video/pll_video_0002.v new file mode 100644 index 00000000..0980b88b --- /dev/null +++ b/rtl/pll_video/pll_video_0002.v @@ -0,0 +1,86 @@ +`timescale 1ns/10ps +module pll_video_0002( + + // interface 'refclk' + input wire refclk, + + // interface 'reset' + input wire rst, + + // interface 'outclk0' + output wire outclk_0, + + // interface 'locked' + output wire locked +); + + altera_pll #( + .fractional_vco_multiplier("false"), + .reference_clock_frequency("50.0 MHz"), + .operation_mode("direct"), + .number_of_clocks(1), + .output_clock_frequency0("27.000000 MHz"), + .phase_shift0("0 ps"), + .duty_cycle0(50), + .output_clock_frequency1("0 MHz"), + .phase_shift1("0 ps"), + .duty_cycle1(50), + .output_clock_frequency2("0 MHz"), + .phase_shift2("0 ps"), + .duty_cycle2(50), + .output_clock_frequency3("0 MHz"), + .phase_shift3("0 ps"), + .duty_cycle3(50), + .output_clock_frequency4("0 MHz"), + .phase_shift4("0 ps"), + .duty_cycle4(50), + .output_clock_frequency5("0 MHz"), + .phase_shift5("0 ps"), + .duty_cycle5(50), + .output_clock_frequency6("0 MHz"), + .phase_shift6("0 ps"), + .duty_cycle6(50), + .output_clock_frequency7("0 MHz"), + .phase_shift7("0 ps"), + .duty_cycle7(50), + .output_clock_frequency8("0 MHz"), + .phase_shift8("0 ps"), + .duty_cycle8(50), + .output_clock_frequency9("0 MHz"), + .phase_shift9("0 ps"), + .duty_cycle9(50), + .output_clock_frequency10("0 MHz"), + .phase_shift10("0 ps"), + .duty_cycle10(50), + .output_clock_frequency11("0 MHz"), + .phase_shift11("0 ps"), + .duty_cycle11(50), + .output_clock_frequency12("0 MHz"), + .phase_shift12("0 ps"), + .duty_cycle12(50), + .output_clock_frequency13("0 MHz"), + .phase_shift13("0 ps"), + .duty_cycle13(50), + .output_clock_frequency14("0 MHz"), + .phase_shift14("0 ps"), + .duty_cycle14(50), + .output_clock_frequency15("0 MHz"), + .phase_shift15("0 ps"), + .duty_cycle15(50), + .output_clock_frequency16("0 MHz"), + .phase_shift16("0 ps"), + .duty_cycle16(50), + .output_clock_frequency17("0 MHz"), + .phase_shift17("0 ps"), + .duty_cycle17(50), + .pll_type("General"), + .pll_subtype("General") + ) altera_pll_i ( + .rst (rst), + .outclk (outclk_0), + .locked (locked), + .fboutclk ( ), + .fbclk (1'b0), + .refclk (refclk) + ); +endmodule From b0fe65a4de6ba3d0f5390bf6af1dcb9edb405928 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Thu, 11 Jun 2026 12:55:15 +0800 Subject: [PATCH 09/18] docs: clarify CRT mode coordination in the frontend brief - The app-level CRT mode (--crt: pixel fonts, CRT layout, DDR writer) stays; only the core-side status[9] enable and offset status bits are gone. The old wording ("no CRT mode toggle anywhere") read as if the concept itself was removed. - Add section 3 documenting the existing Main_MiSTer coordination (config/zaparoo_launcher_crt.bin read at menu-core load, OSD toggle respawning only the frontend) and the required Main-fork edits: drop the dead status writes, move offset ownership to the launcher, update fb mode to 352x240/1408, and widen the DDR blank to 0x300000. - Sketch a launcher-side toggle option via a reserved exit code so neither Main nor the system needs a restart. --- docs/native-video-frontend-brief.md | 82 +++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/docs/native-video-frontend-brief.md b/docs/native-video-frontend-brief.md index 2c0bd3c3..2ea4604e 100644 --- a/docs/native-video-frontend-brief.md +++ b/docs/native-video-frontend-brief.md @@ -7,7 +7,9 @@ repo) has the full background and rationale if you want it. FPGA side of everything below is implemented, simulated, and pushed. The launcher work in this brief is the only remaining piece. **Existing code this modifies:** `src/app/native_video_writer.cpp` and the -`--crt` startup path in zaparoo-launcher (see also its `docs/native-core-poc.md`). +`--crt` startup path in zaparoo-launcher (see also its `docs/native-core-poc.md`), +plus `support/zaparoo/alt_launcher.cpp` / `launcher_pages.cpp` in the +Main_MiSTer fork (section 3). --- @@ -24,10 +26,13 @@ control block you already write**. Key consequences for the app: correctly calibrated CRT. - The picture now *overscans* like broadcast TV: the outer few percent of the framebuffer is cropped on most sets. The UI must adopt safe-area rules - (section 5) — this is as much a part of the fix as the FPGA work. -- There is no "CRT mode" toggle anywhere. **Publishing frames IS the mode - switch**: the core shows its noise pattern until your control word goes - live and reverts when you zero it. + (section 6) — this is as much a part of the fix as the FPGA work. +- The *core-side* CRT enable is gone: the new core has no `status[9]` bit + and no OSD video options. **Publishing frames IS the core's mode switch**: + it shows its noise pattern until your control word goes live and reverts + when you zero it. The *app-level* CRT mode (the `--crt` startup path: + pixel fonts, CRT layout, DDR writer) is unchanged and very much stays — + see section 3 for how it's coordinated now. - Two new modes exist when you're ready for them: **720x480i60** (mode 1) and **352x288p50 PAL** (mode 2). The core side is done; you opt in per-frame via the mode field. @@ -87,7 +92,53 @@ Protocol rules: core extracts fields itself (reads source line `2*line + field`). No field splitting, no half-frame timing on the ARM side. -## 3. Task 1 — Phase A (required): 352x240 writer + safe-area UI +## 3. ARM-side coordination: who turns CRT mode on + +"CRT mode" remains a real mode of the *app*: it decides whether the launcher +renders pixel fonts and CRT layout into the DDR writer (`--crt`) or runs the +normal HDMI/scaler path. The Main_MiSTer fork already owns that decision and +the mechanism survives v2 almost unchanged: + +- **Persisted state:** `config/zaparoo_launcher_crt.bin` (1-byte bool, + written via `FileSaveConfig`). Main reads it when the menu core loads + (`zaparoo_alt_launcher_init_for_menu()` in `support/zaparoo/alt_launcher.cpp`) + and spawns the frontend with or without `--crt`. +- **Toggling:** the OSD "Zaparoo Frontend → Video" page calls + `alt_launcher_toggle_crt()`, which persists the new value, SIGTERMs the + frontend, and respawns it with the new flag. **No Main restart is needed** + — only the frontend process bounces. Keep this; a full Main re-exec is + strictly worse (slower, drops core state) and buys nothing. + +What v2 changes in Main (these are required Main-fork edits, same effort +bucket as Task 1): + +1. `user_io_status_set("[9]", …)` everywhere in `alt_launcher.cpp` is now a + no-op — the new core has no CRT status bit. Delete the writes and the + 500 ms re-assert timer. The frontend publishing word0/word1 *is* the + enable; Main's job shrinks to fb-mode setup, blanking, and spawning. +2. The H/V offset status writes (`[13:10]`/`[17:14]`) are dead too. Offsets + move into DDR word1, which only the frontend writes. Remove the OSD + "H Offset"/"V Offset" entries in `launcher_pages.cpp` and the + `zaparoo_video_offsets.bin` handling; the launcher owns centering now + (section 7). Optional nicety: on first run, the launcher migrates the + two bytes from `config/zaparoo_video_offsets.bin` into its own config so + existing users keep their calibration. +3. `set_native_crt_fb_mode()`: 320x240 stride 1280 → **352x240 stride 1408**. +4. `blank_native_crt_fb()`: region size 0xA0000 → **0x300000**. Under v2, + zeroing the region isn't just ghost-clearing — a zeroed word0 means + "writer stopped", so the blank deterministically parks the core on its + noise pattern until the new frontend instance publishes. + +Open choice (pick during implementation): if the CRT toggle should also live +in the launcher's own settings UI, don't have the launcher restart Main. +Instead: launcher writes `zaparoo_launcher_crt.bin` itself and exits with a +reserved exit code (e.g. 42 = "re-read CRT config and respawn me"); Main's +`alt_launcher_poll()` exit handler treats that code as a respawn-with-reload +instead of `return_to_normal_mode()`. That's a ~10-line Main change and +reuses the existing respawn machinery. The OSD toggle can stay as a second +entry point — both paths converge on the same persisted bool + respawn. + +## 4. Task 1 — Phase A (required): 352x240 writer + safe-area UI This is the must-ship piece; modes 1 and 2 are follow-ups. @@ -98,14 +149,14 @@ This is the must-ship piece; modes 1 and 2 are follow-ups. buffers at `+0x1000` / `+0x180000`, mmap 0x300000. 3. Write word1 on init: magic `0x5A50`, mode 0, offsets from launcher config (default 0/0). Clear both words on stop. -4. UI safe-area pass (section 5). -5. Calibration screen (section 6). +4. UI safe-area pass (section 6). +5. Calibration screen (section 7). Acceptance: on hardware with the new core, the launcher UI fills a CRT edge-to-edge; killing the launcher returns the noise pattern; a capture device reports 15.734 kHz / 240p. -## 4. Tasks 2 & 3 — PAL and 480i (when ready) +## 5. Tasks 2 & 3 — PAL and 480i (when ready) **PAL (mode 2):** add a "video standard: NTSC / PAL" user setting. PAL renders **352x288** and publishes mode 2. Note most PAL sets accept 60 Hz @@ -114,9 +165,9 @@ mode 2 is for strict-50 Hz sets and correct-speed feel. **480i (mode 1):** add a 720x480 rendering path and (optionally) per-screen mode selection — e.g. main UI in 240p, text-heavy screens in 480i. -Flicker discipline is mandatory (section 5, rule 4). +Flicker discipline is mandatory (section 6, rule 4). -## 5. UI rendering rules (apply to every mode) +## 6. UI rendering rules (apply to every mode) These are not suggestions; geometry alone doesn't fix "every CRT crops differently": @@ -138,9 +189,10 @@ differently": standard console-era 480i dashboard trick). Existing CRT typography rules in `native-core-poc.md` (integer snapping, bitmap fonts) stay in force. -## 6. Calibration screen +## 7. Calibration screen -The launcher now owns centering (the OSD options are gone): +The launcher now owns centering (the core's status bits are gone and Main's +OSD offset entries go with them — see section 3, item 2): - Draw a border test pattern (240p-test-suite style: 1-px frame at the extreme edge, rectangles at the 90% and 80% safe areas, cross-hatch). @@ -150,7 +202,7 @@ The launcher now owns centering (the OSD options are gone): zero — the standard timing is the centering mechanism, trims only compensate for miscentered sets. -## 7. Verification checklist (frontend-visible items) +## 8. Verification checklist (frontend-visible items) - Fill/centering on **2–3 different CRTs** plus a capture device (should report 15.734 kHz exactly; 480i should be detected as 480i, not 240p). @@ -164,7 +216,7 @@ The launcher now owns centering (the OSD options are gone): - HDMI output still locks in every mode (the core's ascal path handles it; just confirm). -## 8. Reference +## 9. Reference - FPGA-side spec and rationale: `docs/native-video-plan.md` (Menu_MiSTer). - RTL that consumes this contract: `rtl/native_video_reader.sv` (the word1 From 5d0feb9a6d72cf3a9c6626e5feb9555f122106a2 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 10:08:24 +0800 Subject: [PATCH 10/18] fix: reject stale and legacy native video writers Require the v2 magic and evidence of a live writer before exposing DDR pixels. Remove the old 320-pixel no-magic layout; idle trusted writers retain their frame. Cover stale startup, counter advance and missing-magic rejection in the reader regression. --- rtl/native_video_reader.sv | 81 +++++++++++++++++------------------- rtl/native_video_top.sv | 1 - tb/native_video_reader_tb.sv | 47 +++++++++++++-------- 3 files changed, 68 insertions(+), 61 deletions(-) diff --git a/rtl/native_video_reader.sv b/rtl/native_video_reader.sv index 07055b20..7160a6fe 100644 --- a/rtl/native_video_reader.sv +++ b/rtl/native_video_reader.sv @@ -8,9 +8,15 @@ // [3:0] mode: 0 = 352x240p60, 1 = 720x480i60, 2 = 352x288p50 // 0x3A001000: buffer 0 0x3A180000: buffer 1 (tight stride, width*4 B) // -// Legacy contract (word1 magic absent): 320x240 buffers at 0x3A000100 / -// 0x3A04B100; the picture is scanned centered in the 352-px active area -// with 16-px black bars each side, offsets 0, mode 0. +// The magic is mandatory, and a block is only painted once the writer has +// been shown to be live. Nothing clears DDR on this core's startup, so a +// previous core's leftovers sit at CTRL_ADDR looking like a control block; +// without both checks the reader latches onto that and scans out garbage +// instead of idle video, permanently. A writer proves itself either by +// starting after the reader has seen no writer (word0 == 0 or no magic), or by +// advancing the counter under a block that was already there at reset. Stale +// DDR does neither. Note this is not a heartbeat: once trusted, a writer may +// idle indefinitely without republishing. // // In 480i the app publishes one progressive 720x480 frame; this reader // fetches source line vcount*2 + field, so no field-splitting on the ARM @@ -38,7 +44,6 @@ module native_video_reader input wire new_frame, input wire new_line, input wire field, - input wire [9:0] hcount, // Quasi-static, ddr_clk domain: caller synchronizes into the video // domain; the timing module latches them at the field wrap. @@ -57,17 +62,11 @@ assign ddr_be = 8'hFF; assign ddr_we = 1'b0; localparam [28:0] CTRL_ADDR = 29'h07400000; -localparam [28:0] BUF0_LEGACY = 29'h07400020; -localparam [28:0] BUF1_LEGACY = 29'h07409620; localparam [28:0] BUF0_V2 = 29'h07400200; localparam [28:0] BUF1_V2 = 29'h07430000; localparam [15:0] MAGIC_V2 = 16'h5A50; localparam [19:0] TIMEOUT_MAX = 20'hF_FFFF; -// Legacy 320-px picture centered in the 352-px active area. -localparam [9:0] LEGACY_BAR_L = 10'd16; -localparam [9:0] LEGACY_BAR_R = 10'd336; - reg [1:0] new_frame_sync; always @(posedge ddr_clk) begin if(reset) new_frame_sync <= 2'b0; @@ -114,14 +113,6 @@ end wire frame_ready_vid = frame_ready_sync[1]; assign frame_ready = frame_ready_vid; -reg legacy_mode; -reg [1:0] legacy_sync; -always @(posedge clk_vid) begin - if(reset_vid) legacy_sync <= 2'b0; - else legacy_sync <= {legacy_sync[0], legacy_mode}; -end -wire legacy_vid = legacy_sync[1]; - localparam [3:0] ST_IDLE = 4'd0; localparam [3:0] ST_POLL_CTRL = 4'd1; localparam [3:0] ST_WAIT_CTRL = 4'd2; @@ -140,6 +131,11 @@ reg [8:0] cur_line; reg [7:0] beat_count; reg burst_idx; reg first_frame_loaded; +// Gates frame_ready. Set when the reader observes no writer (so whatever +// publishes next started while it was watching), and when the counter changes +// under a block that was already present at reset. Never cleared afterwards: +// an idle writer that stops republishing keeps its last frame on screen. +reg writer_trusted; reg preloading; reg [19:0] timeout_cnt; reg fifo_wr; @@ -174,18 +170,18 @@ always @(posedge ddr_clk) begin ctrl_word <= 32'd0; ctrl_word1 <= 32'd0; prev_frame_counter <= 30'd0; - buf_base_addr <= BUF0_LEGACY; + buf_base_addr <= BUF0_V2; cur_line <= 9'd0; beat_count <= 8'd0; burst_idx <= 1'b0; first_frame_loaded <= 1'b0; + writer_trusted <= 1'b0; frame_ready_reg <= 1'b0; preloading <= 1'b0; timeout_cnt <= 20'd0; fifo_wr <= 1'b0; fifo_wr_data <= 64'd0; fifo_aclr_cnt <= 4'd0; - legacy_mode <= 1'b0; mode_out <= 2'd0; h_offset_out <= 8'sd0; v_offset_out <= 4'sd0; @@ -239,37 +235,43 @@ always @(posedge ddr_clk) begin end ST_CHECK_CTRL: begin - if(ctrl_word == 32'd0) begin - // Writer stopped (or never started): revert to the noise - // pattern and forget the previous session. + if(ctrl_word == 32'd0 || !magic_ok) begin + // Writer stopped, never started, or this is not a control + // block at all: revert to idle video and forget the + // previous session. frame_ready_reg <= 1'b0; first_frame_loaded <= 1'b0; + // Nothing is publishing right now, so the next block to + // appear started under observation and can be trusted. + writer_trusted <= 1'b1; prev_frame_counter <= 30'd0; - legacy_mode <= 1'b0; mode_out <= 2'd0; h_offset_out <= 8'sd0; v_offset_out <= 4'sd0; state <= ST_IDLE; end else begin - legacy_mode <= ~magic_ok; - mode_out <= magic_ok ? ctrl_mode : 2'd0; - h_offset_out <= magic_ok ? $signed(ctrl_word1[15:8]) : 8'sd0; - v_offset_out <= magic_ok ? $signed(ctrl_word1[7:4]) : 4'sd0; - line_words <= magic_ok ? ((ctrl_mode == 2'd1) ? 9'd360 : 9'd176) : 9'd160; - scan_lines <= (magic_ok && ctrl_mode == 2'd2) ? 9'd288 : 9'd240; - scan_interlaced <= magic_ok && (ctrl_mode == 2'd1); - two_bursts <= magic_ok && (ctrl_mode == 2'd1); + mode_out <= ctrl_mode; + h_offset_out <= $signed(ctrl_word1[15:8]); + v_offset_out <= $signed(ctrl_word1[7:4]); + line_words <= (ctrl_mode == 2'd1) ? 9'd360 : 9'd176; + scan_lines <= (ctrl_mode == 2'd2) ? 9'd288 : 9'd240; + scan_interlaced <= (ctrl_mode == 2'd1); + two_bursts <= (ctrl_mode == 2'd1); if(ctrl_word[31:2] != prev_frame_counter) begin prev_frame_counter <= ctrl_word[31:2]; - buf_base_addr <= ctrl_word[0] ? (magic_ok ? BUF1_V2 : BUF1_LEGACY) - : (magic_ok ? BUF0_V2 : BUF0_LEGACY); + buf_base_addr <= ctrl_word[0] ? BUF1_V2 : BUF0_V2; cur_line <= 9'd0; burst_idx <= 1'b0; preloading <= 1'b1; fifo_aclr_cnt <= 4'd8; - if(first_frame_loaded) frame_ready_reg <= 1'b1; + // A counter moving under a block we already fetched is + // a live writer even if it was there at reset. + if(first_frame_loaded) begin + writer_trusted <= 1'b1; + frame_ready_reg <= 1'b1; + end state <= ST_READ_LINE; end else if(first_frame_loaded) begin @@ -319,7 +321,7 @@ always @(posedge ddr_clk) begin cur_line <= cur_line + 9'd1; if(cur_line == scan_lines - 9'd1) begin first_frame_loaded <= 1'b1; - frame_ready_reg <= 1'b1; + frame_ready_reg <= writer_trusted; preloading <= 1'b0; state <= ST_IDLE; end @@ -383,10 +385,6 @@ reg pixel_word_valid; wire [31:0] pixel_low = pixel_word[31:0]; wire [31:0] pixel_high_word = pixel_word[63:32]; -// Legacy frames are 320 px wide inside the 352-px active area: black bars -// for the first/last 16 px, FIFO pixels in between. -wire fetch_active = de && (!legacy_vid || (hcount >= LEGACY_BAR_L && hcount < LEGACY_BAR_R)); - task automatic output_pixel; input [31:0] pixel; begin @@ -412,7 +410,7 @@ always @(posedge clk_vid) begin fifo_rd <= 1'b0; if(ce_pix) begin - if(fetch_active && frame_ready_vid) begin + if(de && frame_ready_vid) begin if(pixel_word_valid) begin if(pixel_high) begin output_pixel(pixel_high_word); @@ -438,7 +436,6 @@ always @(posedge clk_vid) begin end end else if(de) begin - // Legacy side bars: keep the partially consumed word. r_out <= 8'd0; g_out <= 8'd0; b_out <= 8'd0; diff --git a/rtl/native_video_top.sv b/rtl/native_video_top.sv index 0de69a9b..0b55f623 100644 --- a/rtl/native_video_top.sv +++ b/rtl/native_video_top.sv @@ -106,7 +106,6 @@ native_video_reader reader .new_frame (tim_new_frame), .new_line (tim_new_line), .field (tim_field), - .hcount (tim_hcount), .mode_out (rd_mode), .h_offset_out (rd_h_offset), diff --git a/tb/native_video_reader_tb.sv b/tb/native_video_reader_tb.sv index b51faeb4..fa82dab1 100644 --- a/tb/native_video_reader_tb.sv +++ b/tb/native_video_reader_tb.sv @@ -6,8 +6,9 @@ // offsets parsed from word1 and latched by the timing module // - double buffering: counter change switches buffer base // - writer stop: word0 -> 0 drops active (frame_ready) again -// - legacy contract (no magic): 160-word lines from the legacy buffers, -// picture centered with 16-px black side bars +// - stale DDR: a magic-carrying block already present at reset with a +// frozen counter never activates (this is what a previous core leaves +// behind); a counter advance under it does activate; no magic never does // - PAL (mode 2): 288 line fetches // - 480i (mode 1): two 180-beat bursts per line, source line = 2*line+field, // alternating per field; FIFO never overflows @@ -37,8 +38,6 @@ end // ---- DDR model ------------------------------------------------------------- localparam [28:0] CTRL_ADDR = 29'h07400000; -localparam [28:0] BUF0_LEGACY = 29'h07400020; -localparam [28:0] BUF1_LEGACY = 29'h07409620; localparam [28:0] BUF0_V2 = 29'h07400200; localparam [28:0] BUF1_V2 = 29'h07430000; localparam [63:0] PIX_DATA = 64'h00FFAA55_00FFAA55; // B,G,R,X = 55,AA,FF,00 @@ -217,7 +216,7 @@ initial begin check("v2: v_offset latched", dut.timing.v_offset, -3); check_frame_fetch("v2-buf0", BUF0_V2, 176, 240, 176, 1, 0, fld_a); sample_r(100, 100, rs); check("v2: interior pixel R", rs, 8'hFF); - sample_r(6, 100, rs); check("v2: no left bar", rs, 8'hFF); + sample_r(6, 100, rs); check("v2: paints to the edge", rs, 8'hFF); // Phase 2: counter advances with buffer 1. $display("--- phase 2: double buffer ---"); @@ -232,27 +231,39 @@ initial begin check("stop: active drops", {31'd0, active}, 0); check("stop: mode reverts", vmode, 0); - // Phase 4: legacy writer (no magic). - $display("--- phase 4: legacy contract ---"); - publish(0, 4'd0, 8'sd0, 4'sd0, 3, 0); + // Phase 4: a block already present at reset is a previous core's DDR + // leftovers, not a writer, and must never be painted. + $display("--- phase 4: stale control block rejected ---"); + publish(1, 4'd0, 8'sd0, 4'sd0, 9, 0); + reset = 1; repeat (20) @(posedge clk_sys); reset = 0; + wait_frames(8); + check("stale at reset: inactive", {31'd0, active}, 0); + sample_r(100, 100, rs); check("stale at reset: idle not DDR", rs !== 8'hFF, 1); + + // ...but a writer that then starts advancing the counter is real. + publish(1, 4'd0, 8'sd0, 4'sd0, 10, 1); wait (active === 1'b1); - wait_frames(2); - check("legacy: offsets zero", dut.timing.h_offset, 0); - check_frame_fetch("legacy", BUF0_LEGACY, 160, 240, 160, 1, 0, fld_a); - sample_r(6, 100, rs); check("legacy: left bar black", rs, 8'h00); - sample_r(345, 100, rs); check("legacy: right bar black", rs, 8'h00); - sample_r(100, 100, rs); check("legacy: interior pixel", rs, 8'hFF); + $display("pass stale block: counter advance activates"); + + // No magic is rejected outright, however trusted the writer was. + publish(0, 4'd0, 8'sd0, 4'sd0, 11, 0); + wait_frames(4); + check("no magic: active drops", {31'd0, active}, 0); + sample_r(100, 100, rs); check("no magic: idle not DDR", rs !== 8'hFF, 1); + ctrl_q = 64'd0; + wait_frames(3); // Phase 5: PAL. $display("--- phase 5: v2 mode 2 (PAL) ---"); - publish(1, 4'd2, 8'sd0, 4'sd0, 4, 0); + publish(1, 4'd2, 8'sd0, 4'sd0, 12, 0); wait (vmode === 2'd2); + wait (active === 1'b1); wait_frames(2); check_frame_fetch("pal", BUF0_V2, 176, 288, 176, 1, 0, fld_a); // Phase 6: 480i. $display("--- phase 6: v2 mode 1 (480i) ---"); - publish(1, 4'd1, 8'sd0, 4'sd0, 5, 0); + publish(1, 4'd1, 8'sd0, 4'sd0, 13, 0); wait (vmode === 2'd1); wait_frames(2); check_frame_fetch("480i-a", BUF0_V2, 360, 240, 180, 2, 1, fld_a); @@ -262,7 +273,7 @@ initial begin // Phase 7: DDR stops responding mid-session. $display("--- phase 7: DDR timeout ---"); - publish(1, 4'd0, 8'sd0, 4'sd0, 6, 0); + publish(1, 4'd0, 8'sd0, 4'sd0, 14, 0); wait (vmode === 2'd0); wait_frames(3); check("pre-timeout: active", {31'd0, active}, 1); @@ -270,7 +281,7 @@ initial begin wait (active === 1'b0); $display("pass timeout: active dropped"); respond_en = 1; - publish(1, 4'd0, 8'sd0, 4'sd0, 7, 0); + publish(1, 4'd0, 8'sd0, 4'sd0, 15, 0); wait (active === 1'b1); $display("pass timeout: recovered after writer republish"); From 20ff77532494e985c36fa31937602c2936472bd5 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 10:09:31 +0800 Subject: [PATCH 11/18] feat: add vblank scanout and black Menu startup Integrate attributed MagiK latch RTL with 1080p limits and a namespaced, exclusive mapping module qualified for MiSTer 6.18.38. Preserve native CRT video and timing while idle RGB stays black; retain legacy framebuffer takeover. Register sources in files.qip and select placement seed 3 to close the existing HDMI scaler path without relaxing clock constraints. Final setup slack is +0.525 ns. Preserve GPL source attribution and the separate kernel-loader license classification. --- .gitignore | 12 + files.qip | 4 + kernel/scanout-slots/Makefile | 28 + kernel/scanout-slots/zaparoo_scanout.c | 209 +++++++ .../scanout-slots/zaparoo_scanout_platform.h | 22 + kernel/scanout-slots/zaparoo_scanout_uapi.h | 36 ++ menu.qsf | 2 +- menu.sv | 53 +- rtl/zaparoo_bootstrap_video.sv | 17 + sys/mister_magik_bootstrap_black.sv | 23 + sys/mister_magik_latch_protocol.svh | 134 ++++ sys/mister_magik_latch_sys_top_bridge.sv | 123 ++++ sys/mister_magik_vblank_latch.sv | 581 ++++++++++++++++++ sys/sys_top.v | 85 ++- tb/bootstrap_video_tb.sv | 32 + tb/scanout_tb.sv | 154 +++++ 16 files changed, 1471 insertions(+), 44 deletions(-) create mode 100644 kernel/scanout-slots/Makefile create mode 100644 kernel/scanout-slots/zaparoo_scanout.c create mode 100644 kernel/scanout-slots/zaparoo_scanout_platform.h create mode 100644 kernel/scanout-slots/zaparoo_scanout_uapi.h create mode 100644 rtl/zaparoo_bootstrap_video.sv create mode 100644 sys/mister_magik_bootstrap_black.sv create mode 100644 sys/mister_magik_latch_protocol.svh create mode 100644 sys/mister_magik_latch_sys_top_bridge.sv create mode 100644 sys/mister_magik_vblank_latch.sv create mode 100644 tb/bootstrap_video_tb.sv create mode 100644 tb/scanout_tb.sv diff --git a/.gitignore b/.gitignore index a80016f1..1f2d08c9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,15 @@ c5_pin_model_dump.txt *.xml *_netlist *.cdf +/kernel/.build/ +/kernel/**/*.o +/kernel/**/*.ko +/kernel/**/*.mod +/kernel/**/*.mod.c +/kernel/**/.*.cmd +/kernel/**/Module.symvers +/kernel/**/modules.order +/kernel/**/.tmp_versions/ +/test-output/ +.pi/ +.claude/ diff --git a/files.qip b/files.qip index b1b07c40..e5ed8ab4 100644 --- a/files.qip +++ b/files.qip @@ -7,3 +7,7 @@ set_global_assignment -name SYSTEMVERILOG_FILE rtl/native_video_reader.sv set_global_assignment -name SYSTEMVERILOG_FILE rtl/native_video_timing.sv set_global_assignment -name SYSTEMVERILOG_FILE rtl/native_video_top.sv set_global_assignment -name SYSTEMVERILOG_FILE menu.sv +set_global_assignment -name SYSTEMVERILOG_FILE sys/mister_magik_vblank_latch.sv +set_global_assignment -name SYSTEMVERILOG_FILE sys/mister_magik_latch_sys_top_bridge.sv +set_global_assignment -name SYSTEMVERILOG_FILE sys/mister_magik_bootstrap_black.sv +set_global_assignment -name SYSTEMVERILOG_FILE rtl/zaparoo_bootstrap_video.sv diff --git a/kernel/scanout-slots/Makefile b/kernel/scanout-slots/Makefile new file mode 100644 index 00000000..22bcb822 --- /dev/null +++ b/kernel/scanout-slots/Makefile @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Nigel Breslaw +# Zaparoo fork: local builds against the qualified MiSTer kernel. + +KERNEL_SRC ?= ../.build/linux-6.18 +KERNEL_BUILD ?= ../.build/kernel-6.18 +ARCH ?= arm +CROSS_COMPILE ?= arm-none-linux-gnueabihf- +LOCALVERSION ?= -MiSTer + +obj-m += zaparoo_scanout.o + +.PHONY: all qualify clean +all: qualify + $(MAKE) -C $(abspath $(KERNEL_SRC)) O=$(abspath $(KERNEL_BUILD)) M=$(CURDIR) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) LOCALVERSION=$(LOCALVERSION) modules + +# Pin source/config as well as vermagic; never bless a different 6.18 build. +qualify: + test "$(ARCH)" = arm + test "$(LOCALVERSION)" = -MiSTer + test "$$(git -C $(abspath $(KERNEL_SRC)) rev-parse HEAD)" = aec7dc3aa4846385736f1d54c9155e3b3c726708 + git -C $(abspath $(KERNEL_SRC)) diff --quiet HEAD -- . + printf '%s %s\n' 0d010a3d551cbffcd91af7850f3f745ce73f3bb911cfd56ead902fc9b6c69823 $(abspath $(KERNEL_BUILD))/.config | sha256sum -c - + test -s $(abspath $(KERNEL_BUILD))/Module.symvers + test "$$($(CROSS_COMPILE)gcc -dumpfullversion -dumpversion)" = 10.2.1 + +clean: + $(MAKE) -C $(abspath $(KERNEL_SRC)) O=$(abspath $(KERNEL_BUILD)) M=$(CURDIR) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) LOCALVERSION=$(LOCALVERSION) clean diff --git a/kernel/scanout-slots/zaparoo_scanout.c b/kernel/scanout-slots/zaparoo_scanout.c new file mode 100644 index 00000000..1a9f4e26 --- /dev/null +++ b/kernel/scanout-slots/zaparoo_scanout.c @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Nigel Breslaw +// Zaparoo fork: namespaced device, 6.18 platform validation, lifetime ownership. + +/* Bounded write-combined RGB565 slots. No FPGA commands, DMA, or IRQ ownership. + * Reservations belong to the open file and all its VMAs, not module residency. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "zaparoo_scanout_platform.h" +#include "zaparoo_scanout_uapi.h" + +#define DEVICE_NAME "zaparoo-scanout" + +static DEFINE_MUTEX(owner_lock); +static bool owned; +static struct resource *slot_resources[ZAPAROO_SCANOUT_SLOT_COUNT]; +static const unsigned long slot_addresses[] = { + ZAPAROO_SCANOUT_SLOT0_PHYS, ZAPAROO_SCANOUT_SLOT1_PHYS +}; +static const struct zaparoo_scanout_layout layout = { + .abi_version = ZAPAROO_SCANOUT_ABI_VERSION, + .slot_count = ZAPAROO_SCANOUT_SLOT_COUNT, + .max_width = ZAPAROO_SCANOUT_MAX_WIDTH, + .max_height = ZAPAROO_SCANOUT_MAX_HEIGHT, + .max_stride_bytes = ZAPAROO_SCANOUT_MAX_STRIDE, + .slot_capacity_bytes = ZAPAROO_SCANOUT_CAPACITY, + .map_bytes = ZAPAROO_SCANOUT_MAP_BYTES, + .flags = ZAPAROO_SCANOUT_WRITE_COMBINE | ZAPAROO_SCANOUT_EXCLUSIVE_OWNER, + .slots = { + { ZAPAROO_SCANOUT_SLOT0_PHYS, 0 }, + { ZAPAROO_SCANOUT_SLOT1_PHYS, ZAPAROO_SCANOUT_SLOT1_SELECTOR }, + }, +}; + +/* 6.18 no longer exports registered_fb. Check the exact root-level DT window + * instead: slots are above its complete aperture, independent of live fb mode. + * Raw property reads deliberately use non-GPL-only exports. Do not mislabel + * the GPL-3.0 source's loader classification to access GPL-only helpers. + */ +static int validate_platform(void) +{ + struct device_node *node; + const __be32 *cells; + int len, ret = -ENODEV; + + if (strcmp(UTS_RELEASE, ZAPAROO_SCANOUT_KERNEL_RELEASE) || + !of_machine_is_compatible(ZAPAROO_SCANOUT_MACHINE)) + return -ENODEV; + cells = of_get_property(of_root, "#address-cells", &len); + if (!cells || len != 4 || be32_to_cpup(cells) != 1) + return -ENODEV; + cells = of_get_property(of_root, "#size-cells", &len); + if (!cells || len != 4 || be32_to_cpup(cells) != 1) + return -ENODEV; + node = of_find_compatible_node(NULL, NULL, "MiSTer_fb"); + if (!node) + return -ENODEV; + cells = of_get_property(node, "reg", &len); + if (node->parent == of_root && cells && len == 8 && + be32_to_cpup(cells) == ZAPAROO_SCANOUT_FB_DT_BASE && + be32_to_cpup(cells + 1) == ZAPAROO_SCANOUT_FB_DT_BYTES) + ret = 0; + of_node_put(node); + return ret; +} + +static void release_slots(void) +{ + unsigned int i; + for (i = 0; i < ARRAY_SIZE(slot_resources); i++) { + if (slot_resources[i]) { + release_mem_region(slot_addresses[i], ZAPAROO_SCANOUT_MAP_BYTES); + slot_resources[i] = NULL; + } + } +} + +static int scanout_open(struct inode *inode, struct file *file) +{ + unsigned int i; + int ret = 0; + if (!capable(CAP_SYS_RAWIO) || !(file->f_mode & FMODE_READ) || + !(file->f_mode & FMODE_WRITE)) + return -EPERM; + mutex_lock(&owner_lock); + if (owned) { + ret = -EBUSY; + goto out; + } + /* Busy System RAM or another cooperating driver's region also rejects + * these requests. Never map Linux-managed RAM on a different boot setup. + */ + for (i = 0; i < ARRAY_SIZE(slot_addresses); i++) { + slot_resources[i] = request_mem_region_exclusive(slot_addresses[i], + ZAPAROO_SCANOUT_MAP_BYTES, DEVICE_NAME); + if (!slot_resources[i]) { + release_slots(); + ret = -EBUSY; + goto out; + } + } + owned = true; +out: + mutex_unlock(&owner_lock); + return ret; +} + +static int scanout_release(struct inode *inode, struct file *file) +{ + /* VMA file references postpone this until every mapping is gone, even + * after close(fd). The final release may run in a different task. + */ + mutex_lock(&owner_lock); + release_slots(); + owned = false; + mutex_unlock(&owner_lock); + return 0; +} + +static int scanout_mmap(struct file *file, struct vm_area_struct *vma) +{ + unsigned long phys; + if (vma->vm_end - vma->vm_start != ZAPAROO_SCANOUT_MAP_BYTES || + !(vma->vm_flags & VM_SHARED) || !(vma->vm_flags & VM_READ) || + !(vma->vm_flags & VM_WRITE) || (vma->vm_flags & VM_EXEC)) + return -EINVAL; + if (!vma->vm_pgoff) + phys = ZAPAROO_SCANOUT_SLOT0_PHYS; + else if (vma->vm_pgoff == ZAPAROO_SCANOUT_SLOT1_SELECTOR / PAGE_SIZE) + phys = ZAPAROO_SCANOUT_SLOT1_PHYS; + else + return -EINVAL; + + vma->vm_page_prot = pgprot_writecombine(vma->vm_page_prot); + /* The pinned kernel's __mmap_new_vma invokes this callback before + * inserting the new VMA into its tree. Initialize flags here; changing + * a published VMA would instead require the per-VMA locking helpers. + */ + vm_flags_init(vma, (vma->vm_flags & ~(VM_EXEC | VM_MAYEXEC)) | + VM_IO | VM_PFNMAP | VM_DONTEXPAND | VM_DONTDUMP | VM_DONTCOPY); + if (remap_pfn_range(vma, vma->vm_start, phys >> PAGE_SHIFT, + ZAPAROO_SCANOUT_MAP_BYTES, vma->vm_page_prot)) + return -EAGAIN; + return 0; +} + +static long scanout_ioctl(struct file *file, unsigned int command, unsigned long argument) +{ + if (command != ZAPAROO_SCANOUT_GET_LAYOUT) + return -ENOTTY; + return copy_to_user((void __user *)argument, &layout, sizeof(layout)) ? -EFAULT : 0; +} + +static const struct file_operations scanout_fops = { + .owner = THIS_MODULE, + .open = scanout_open, + .release = scanout_release, + .unlocked_ioctl = scanout_ioctl, + .mmap = scanout_mmap, + .llseek = NULL, +}; +static struct miscdevice scanout_device = { + .minor = MISC_DYNAMIC_MINOR, + .name = DEVICE_NAME, + .fops = &scanout_fops, + .mode = 0600, +}; + +static int __init scanout_init(void) +{ + BUILD_BUG_ON(sizeof(layout) != 64); + BUILD_BUG_ON(ZAPAROO_SCANOUT_MAP_BYTES & ~PAGE_MASK); + BUILD_BUG_ON(ZAPAROO_SCANOUT_SLOT0_PHYS & ~PAGE_MASK); + BUILD_BUG_ON(ZAPAROO_SCANOUT_SLOT1_PHYS & ~PAGE_MASK); + BUILD_BUG_ON(ZAPAROO_SCANOUT_SLOT1_SELECTOR & ~PAGE_MASK); + BUILD_BUG_ON(ZAPAROO_SCANOUT_CAPACITY != ZAPAROO_SCANOUT_MAX_STRIDE * ZAPAROO_SCANOUT_MAX_HEIGHT); + BUILD_BUG_ON(ZAPAROO_SCANOUT_CAPACITY > ZAPAROO_SCANOUT_MAP_BYTES); + BUILD_BUG_ON(ZAPAROO_SCANOUT_FB_DT_BASE + ZAPAROO_SCANOUT_FB_DT_BYTES > ZAPAROO_SCANOUT_SLOT0_PHYS); + BUILD_BUG_ON(ZAPAROO_SCANOUT_SLOT0_PHYS + ZAPAROO_SCANOUT_MAP_BYTES > ZAPAROO_SCANOUT_SLOT1_PHYS); + if (validate_platform()) { + pr_err("zaparoo_scanout: unsupported kernel/device-tree platform\n"); + return -ENODEV; + } + return misc_register(&scanout_device); +} + +static void __exit scanout_exit(void) +{ + misc_deregister(&scanout_device); +} +module_init(scanout_init); +module_exit(scanout_exit); +MODULE_DESCRIPTION("Zaparoo exclusive write-combined scanout slots"); +MODULE_AUTHOR("Nigel Breslaw; Zaparoo Project contributors"); +/* Linux's loader classification is not the source license. */ +MODULE_LICENSE("Proprietary"); +MODULE_INFO(source_license, "GPL-3.0-or-later"); +MODULE_INFO(kernel_revision, ZAPAROO_SCANOUT_KERNEL_REVISION); diff --git a/kernel/scanout-slots/zaparoo_scanout_platform.h b/kernel/scanout-slots/zaparoo_scanout_platform.h new file mode 100644 index 00000000..b53ddb42 --- /dev/null +++ b/kernel/scanout-slots/zaparoo_scanout_platform.h @@ -0,0 +1,22 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright (C) 2026 Nigel Breslaw + * Zaparoo fork: 1080p slots outside fbdev, pinned MiSTer 6.18 platform. + */ +#ifndef ZAPAROO_SCANOUT_PLATFORM_H +#define ZAPAROO_SCANOUT_PLATFORM_H + +#define ZAPAROO_SCANOUT_KERNEL_RELEASE "6.18.38-MiSTer" +#define ZAPAROO_SCANOUT_KERNEL_REVISION "aec7dc3aa4846385736f1d54c9155e3b3c726708" +#define ZAPAROO_SCANOUT_MACHINE "altr,socfpga-cyclone5" +#define ZAPAROO_SCANOUT_FB_DT_BASE 0x22000000UL +#define ZAPAROO_SCANOUT_FB_DT_BYTES 0x00800000UL +#define ZAPAROO_SCANOUT_SLOT0_PHYS 0x23000000UL +#define ZAPAROO_SCANOUT_SLOT1_PHYS 0x23400000UL +#define ZAPAROO_SCANOUT_MAX_WIDTH 1920UL +#define ZAPAROO_SCANOUT_MAX_HEIGHT 1080UL +#define ZAPAROO_SCANOUT_MAX_STRIDE 3840UL +#define ZAPAROO_SCANOUT_CAPACITY 4147200UL +#define ZAPAROO_SCANOUT_MAP_BYTES 4149248UL +#define ZAPAROO_SCANOUT_SLOT1_SELECTOR 8294400UL + +#endif diff --git a/kernel/scanout-slots/zaparoo_scanout_uapi.h b/kernel/scanout-slots/zaparoo_scanout_uapi.h new file mode 100644 index 00000000..9f004c0b --- /dev/null +++ b/kernel/scanout-slots/zaparoo_scanout_uapi.h @@ -0,0 +1,36 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright (C) 2026 Nigel Breslaw + * Zaparoo fork: separate device identity and exclusive mapping ownership. + */ +#ifndef ZAPAROO_SCANOUT_UAPI_H +#define ZAPAROO_SCANOUT_UAPI_H + +#include +#include + +#define ZAPAROO_SCANOUT_ABI_VERSION 1U +#define ZAPAROO_SCANOUT_SLOT_COUNT 2U +#define ZAPAROO_SCANOUT_WRITE_COMBINE 0x00000001U +#define ZAPAROO_SCANOUT_EXCLUSIVE_OWNER 0x00000002U + +struct zaparoo_scanout_slot { + __u32 physical_address; + __u32 mmap_offset_bytes; +}; + +struct zaparoo_scanout_layout { + __u32 abi_version; + __u32 slot_count; + __u32 max_width; + __u32 max_height; + __u32 max_stride_bytes; + __u32 slot_capacity_bytes; + __u32 map_bytes; + __u32 flags; + struct zaparoo_scanout_slot slots[ZAPAROO_SCANOUT_SLOT_COUNT]; + __u32 reserved[4]; +}; + +/* Distinct ioctl namespace: a MagiK device must not pass this handshake. */ +#define ZAPAROO_SCANOUT_GET_LAYOUT _IOR('Z', 0x01, struct zaparoo_scanout_layout) +#endif diff --git a/menu.qsf b/menu.qsf index 577fcc3e..ac77c577 100644 --- a/menu.qsf +++ b/menu.qsf @@ -54,7 +54,7 @@ set_global_assignment -name ALM_REGISTER_PACKING_EFFORT LOW set_global_assignment -name OPTIMIZE_POWER_DURING_SYNTHESIS OFF set_global_assignment -name ROUTER_REGISTER_DUPLICATION ON set_global_assignment -name FITTER_AGGRESSIVE_ROUTABILITY_OPTIMIZATION ALWAYS -set_global_assignment -name SEED 1 +set_global_assignment -name SEED 3 source sys/sys.tcl source sys/sys_analog.tcl diff --git a/menu.sv b/menu.sv index 6aab28c3..1a6025ea 100644 --- a/menu.sv +++ b/menu.sv @@ -469,8 +469,6 @@ end ///////////////////// VIDEO /////////////////// -localparam lfsr_n = 63; - wire FB = status[5]; wire [2:0] led = status[8:6]; @@ -499,8 +497,6 @@ wire [7:0] native_b; wire native_hs; wire native_vs; wire native_de; -wire [8:0] native_vcount; -wire native_new_frame; wire native_field; wire native_active; @@ -529,48 +525,21 @@ native_video_top native_video .vga_de (native_de), .vga_hblank (), .vga_vblank (), - .vga_vcount (native_vcount), - .vga_new_frame (native_new_frame), + .vga_vcount (), + .vga_new_frame (), .vga_mode (native_mode), .vga_field (native_field), .active (native_active) ); -// Cosine + LFSR fallback noise pattern, painted into the active area of the -// shared native timing (352x240 when no launcher is publishing frames). vvc steps once per frame; the LFSR walks every -// pixel; cos LUT is indexed by vvc + vcount so the pattern shifts vertically -// over time. Outside the active area we drive black to keep sync clean. -reg [9:0] vvc; -reg [lfsr_n:0] rnd_reg; -wire [lfsr_n:0] rnd; -wire [5:0] rnd_c = {rnd_reg[0],rnd_reg[1],rnd_reg[2],rnd_reg[2],rnd_reg[2],rnd_reg[2]}; - -lfsr #(lfsr_n) random(rnd); - -always @(posedge CLK_VIDEO) begin - if (RESET) vvc <= 10'd0; - else if (native_new_frame) vvc <= vvc + 10'd6; - if (ce_pix) rnd_reg <= rnd; -end - -reg [7:0] cos_out; -wire [5:0] cos_g = cos_out[7:3] + 6'd32; -cos cos(vvc + {native_vcount, 2'b00}, cos_out); - -wire [7:0] comp_v = (cos_g >= rnd_c) ? {cos_g - rnd_c, 2'b00} : 8'd0; - -// Default: cosine pattern paints into the native active area. Once the -// launcher publishes frames (valid control block, advancing counter), the -// DDR-read RGB replaces the cosine pattern; it reverts when the writer stops. -// Sync/DE come from the same native timing in both cases — the CRT sees one -// continuous, broadcast-spec signal regardless of which RGB source is selected. -wire use_native = native_active; - -assign VGA_DE = native_de; -assign VGA_HS = native_hs; -assign VGA_VS = native_vs; -assign VGA_R = use_native ? native_r : (native_de ? comp_v : 8'd0); -assign VGA_G = use_native ? native_g : (native_de ? comp_v : 8'd0); -assign VGA_B = use_native ? native_b : (native_de ? comp_v : 8'd0); +// Black idle source keeps timing and downstream OSD alive until either the +// HDMI latch or the native CRT writer supplies a frame. +zaparoo_bootstrap_video bootstrap_video ( + .native_active(native_active), + .native_rgb({native_r, native_g, native_b}), + .de_in(native_de), .hs_in(native_hs), .vs_in(native_vs), + .rgb_out({VGA_R, VGA_G, VGA_B}), + .de_out(VGA_DE), .hs_out(VGA_HS), .vs_out(VGA_VS) +); endmodule diff --git a/rtl/zaparoo_bootstrap_video.sv b/rtl/zaparoo_bootstrap_video.sv new file mode 100644 index 00000000..083c87ae --- /dev/null +++ b/rtl/zaparoo_bootstrap_video.sv @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Preserve native CRT frames; only the idle Menu source is bootstrap black. +`timescale 1ns/1ps +module zaparoo_bootstrap_video ( + input wire native_active, + input wire [23:0] native_rgb, + input wire de_in, hs_in, vs_in, + output wire [23:0] rgb_out, + output wire de_out, hs_out, vs_out +); + wire [23:0] black_rgb; + mister_magik_bootstrap_black black ( + .rgb_in(native_rgb), .de_in(de_in), .hs_in(hs_in), .vs_in(vs_in), + .rgb_out(black_rgb), .de_out(de_out), .hs_out(hs_out), .vs_out(vs_out) + ); + assign rgb_out = native_active ? native_rgb : black_rgb; +endmodule diff --git a/sys/mister_magik_bootstrap_black.sv b/sys/mister_magik_bootstrap_black.sv new file mode 100644 index 00000000..6536b5fb --- /dev/null +++ b/sys/mister_magik_bootstrap_black.sv @@ -0,0 +1,23 @@ +// Copyright (C) 2026 Nigel Breslaw +// SPDX-License-Identifier: GPL-3.0-or-later + +`timescale 1ns/1ps + +module mister_magik_bootstrap_black +( + input wire [23:0] rgb_in, + input wire de_in, + input wire hs_in, + input wire vs_in, + output wire [23:0] rgb_out, + output wire de_out, + output wire hs_out, + output wire vs_out +); + + assign rgb_out = 24'd0; + assign de_out = de_in; + assign hs_out = hs_in; + assign vs_out = vs_in; + +endmodule diff --git a/sys/mister_magik_latch_protocol.svh b/sys/mister_magik_latch_protocol.svh new file mode 100644 index 00000000..b83f71eb --- /dev/null +++ b/sys/mister_magik_latch_protocol.svh @@ -0,0 +1,134 @@ +/* Upstream: https://github.com/NigelBreslaw/MiSTer-MagiK + * Revision d70c141fd25996459867dce341070a0a81a9683d, + * mister/platform/fpga/menu-vblank-latch/mister_magik_latch_protocol.svh. + * Includes 1080p limits/CRC from local Zaparoo demo Menu_MiSTer snapshot + * 8ab57fdf153dd2fad733aa9691942c88e23c02ef. + * No local generator; keep constants aligned with the paired frontend. */ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright (C) 2026 Nigel Breslaw */ + +localparam [7:0] MAGIK_UIO_SET_FBUF_LATCH = 8'h57; +localparam [7:0] MAGIK_UIO_GET_FBUF_LATCH = 8'h58; +localparam [7:0] MAGIK_UIO_GET_FBUF_LATCH_CAPS = 8'h59; +localparam [7:0] MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS = 8'h5A; +localparam [7:0] MAGIK_UIO_GET_FBUF_LATCH_RECEIPT = 8'h5B; +localparam [15:0] MAGIK_FBUF_LATCH_MAGIC = 16'h4D47; +localparam [15:0] MAGIK_FBUF_STATUS_MAGIC = 16'h4D48; +localparam [15:0] MAGIK_FBUF_CAPS_MAGIC = 16'h4D49; +localparam [15:0] MAGIK_FBUF_DIAGNOSTICS_MAGIC = 16'h4D4A; +localparam [15:0] MAGIK_FBUF_RECEIPT_MAGIC = 16'h4D4B; +localparam [15:0] MAGIK_FBUF_PROTOCOL_VERSION = 16'd4; +localparam [15:0] MAGIK_FBUF_PROTOCOL_V4 = 16'd4; +localparam [15:0] MAGIK_FBUF_CAPS_FLAGS = 16'h01FF; +// Zaparoo fork extension: native 1080p RGB565 limits (paired with the +// enlarged scanout-slots module in kernel/scanout-slots/). +localparam [15:0] MAGIK_FBUF_MAX_WIDTH = 16'd1920; +localparam [15:0] MAGIK_FBUF_MAX_HEIGHT = 16'd1080; +localparam [15:0] MAGIK_FBUF_MAX_STRIDE = 16'd3840; +localparam [4:0] MAGIK_FBUF_V4_CAPS_WORDS = 5'd6; +localparam [4:0] MAGIK_FBUF_V4_SET_PAYLOAD_WORDS = 5'd11; +localparam [4:0] MAGIK_FBUF_V4_SET_WORDS = 5'd12; +localparam [4:0] MAGIK_FBUF_V4_STATUS_WORDS = 5'd16; +localparam [4:0] MAGIK_FBUF_V4_DIAGNOSTICS_WORDS = 5'd7; +localparam [4:0] MAGIK_FBUF_V4_RECEIPT_WORDS = 5'd11; + +localparam [15:0] MAGIK_RECEIPT_NONE = 16'd0; +localparam [15:0] MAGIK_RECEIPT_ACCEPTED = 16'd1; +localparam [15:0] MAGIK_RECEIPT_REJECTED = 16'd2; + +localparam [15:0] MAGIK_CAP_RGB565 = 16'h0001; +localparam [15:0] MAGIK_CAP_DOUBLE_BUFFER = 16'h0002; +localparam [15:0] MAGIK_CAP_VARIABLE_GEOMETRY = 16'h0004; +localparam [15:0] MAGIK_CAP_TRANSACTIONAL_POST = 16'h0008; +localparam [15:0] MAGIK_CAP_COHERENT_STATUS = 16'h0010; +localparam [15:0] MAGIK_CAP_STATUS_CRC = 16'h0020; +localparam [15:0] MAGIK_CAP_POST_CRC = 16'h0040; +localparam [15:0] MAGIK_CAP_REJECTION_CONTEXT = 16'h0080; +localparam [15:0] MAGIK_CAP_AUTHORITATIVE_RECEIPT = 16'h0100; + +localparam integer MAGIK_STATUS_ACTIVE_ENABLED = 0; +localparam integer MAGIK_STATUS_PENDING_ENABLED = 1; +localparam integer MAGIK_STATUS_PENDING = 2; +localparam integer MAGIK_STATUS_MAGIK_OWNERSHIP = 3; +localparam integer MAGIK_STATUS_REJECT_REASON_SHIFT = 4; +localparam integer MAGIK_STATUS_REJECT_REASON_WIDTH = 4; + +localparam [3:0] MAGIK_REJECT_NONE = 4'd0; +localparam [3:0] MAGIK_REJECT_MISSING_WORD = 4'd1; +localparam [3:0] MAGIK_REJECT_DUPLICATE_WORD = 4'd2; +localparam [3:0] MAGIK_REJECT_OUT_OF_ORDER = 4'd3; +localparam [3:0] MAGIK_REJECT_POST_CLOSE = 4'd4; +localparam [3:0] MAGIK_REJECT_BAD_CRC = 4'd5; +localparam [3:0] MAGIK_REJECT_INVALID_MODE = 4'd6; +localparam [3:0] MAGIK_REJECT_INVALID_BASE = 4'd7; +localparam [3:0] MAGIK_REJECT_INVALID_GEOMETRY = 4'd8; +localparam [3:0] MAGIK_REJECT_INVALID_STRIDE = 4'd9; +localparam [3:0] MAGIK_REJECT_INVALID_BOUNDS = 4'd10; +localparam [3:0] MAGIK_REJECT_ADDRESS_WRAP = 4'd11; +localparam [3:0] MAGIK_REJECT_RESTARTED = 4'd12; +localparam [3:0] MAGIK_REJECT_SHIFTED_WORD = 4'd13; +localparam [3:0] MAGIK_REJECT_PENDING_BUSY = 4'd14; +localparam [3:0] MAGIK_REJECT_RESERVED = 4'd15; + +localparam [15:0] MAGIK_CRC_POLYNOMIAL = 16'h1021; +localparam [15:0] MAGIK_CRC_INITIAL = 16'hFFFF; +localparam [15:0] MAGIK_CRC_FINAL_XOR = 16'h0000; + +// Caps golden updated for the Zaparoo 1080p limit extension: +// payload [4, 0x1FF, 1920, 1080, 3840], CRC-16/CCITT-FALSE 0x2984. +localparam [15:0] MAGIK_GOLDEN_CAPS_V4_0 = 16'h0004; +localparam [15:0] MAGIK_GOLDEN_CAPS_V4_1 = 16'h01FF; +localparam [15:0] MAGIK_GOLDEN_CAPS_V4_2 = 16'h0780; +localparam [15:0] MAGIK_GOLDEN_CAPS_V4_3 = 16'h0438; +localparam [15:0] MAGIK_GOLDEN_CAPS_V4_4 = 16'h0F00; +localparam [15:0] MAGIK_GOLDEN_CAPS_V4_CRC = 16'h2984; + +localparam [15:0] MAGIK_GOLDEN_SET_V4_0 = 16'h8014; +localparam [15:0] MAGIK_GOLDEN_SET_V4_1 = 16'h9000; +localparam [15:0] MAGIK_GOLDEN_SET_V4_2 = 16'h227E; +localparam [15:0] MAGIK_GOLDEN_SET_V4_3 = 16'h03C0; +localparam [15:0] MAGIK_GOLDEN_SET_V4_4 = 16'h021C; +localparam [15:0] MAGIK_GOLDEN_SET_V4_5 = 16'h0000; +localparam [15:0] MAGIK_GOLDEN_SET_V4_6 = 16'h03BF; +localparam [15:0] MAGIK_GOLDEN_SET_V4_7 = 16'h0000; +localparam [15:0] MAGIK_GOLDEN_SET_V4_8 = 16'h021B; +localparam [15:0] MAGIK_GOLDEN_SET_V4_9 = 16'h0780; +localparam [15:0] MAGIK_GOLDEN_SET_V4_10 = 16'h002B; +localparam [15:0] MAGIK_GOLDEN_SET_V4_CRC = 16'h56F5; + +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_0 = 16'h002A; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_1 = 16'h002B; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_2 = 16'h000F; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_3 = 16'h0003; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_4 = 16'h0004; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_5 = 16'h9000; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_6 = 16'h227E; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_7 = 16'h03C0; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_8 = 16'h021C; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_9 = 16'h0780; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_10 = 16'h0007; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_11 = 16'h0009; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_12 = 16'h0064; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_13 = 16'h0065; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_14 = 16'h0065; +localparam [15:0] MAGIK_GOLDEN_STATUS_V4_CRC = 16'h917A; + +localparam [15:0] MAGIK_GOLDEN_DIAGNOSTICS_V4_0 = 16'h0007; +localparam [15:0] MAGIK_GOLDEN_DIAGNOSTICS_V4_1 = 16'h0001; +localparam [15:0] MAGIK_GOLDEN_DIAGNOSTICS_V4_2 = 16'h000B; +localparam [15:0] MAGIK_GOLDEN_DIAGNOSTICS_V4_3 = 16'h0000; +localparam [15:0] MAGIK_GOLDEN_DIAGNOSTICS_V4_4 = 16'h0058; +localparam [15:0] MAGIK_GOLDEN_DIAGNOSTICS_V4_5 = 16'h0000; +localparam [15:0] MAGIK_GOLDEN_DIAGNOSTICS_V4_CRC = 16'hEF7D; + +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_0 = 16'h0065; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_1 = 16'h002B; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_2 = 16'h0001; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_3 = 16'h0065; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_4 = 16'h002B; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_5 = 16'h0065; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_6 = 16'h002B; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_7 = 16'h0064; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_8 = 16'h002A; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_9 = 16'h0000; +localparam [15:0] MAGIK_GOLDEN_RECEIPT_V4_CRC = 16'h5881; diff --git a/sys/mister_magik_latch_sys_top_bridge.sv b/sys/mister_magik_latch_sys_top_bridge.sv new file mode 100644 index 00000000..201fa814 --- /dev/null +++ b/sys/mister_magik_latch_sys_top_bridge.sv @@ -0,0 +1,123 @@ +// Copyright (C) 2026 Nigel Breslaw +// SPDX-License-Identifier: GPL-3.0-or-later + +`timescale 1ns/1ps +`default_nettype none + +// Production adapter between Main's UIO word stream and the latch protocol. +// Its independent command counter follows the same io_uio/io_strobe framing as +// sys_top, so the exact adapter driven in simulation is the one built into RBFs. +module mister_magik_latch_sys_top_bridge ( + input wire clk_sys, + input wire hdmi_vbl, + input wire io_uio, + input wire io_strobe, + input wire [15:0] io_din, + + input wire active_lfb_en, + input wire [31:0] active_lfb_base, + input wire [11:0] active_lfb_width, + input wire [11:0] active_lfb_height, + input wire [13:0] active_lfb_stride, + + output wire response_valid, + output wire [15:0] response_data, + output wire apply, + output wire apply_accepted, + output wire legacy_write, + output wire [3:0] active_word_index, + + output wire route_en, + output wire route_flt, + output wire [5:0] route_fmt, + output wire [11:0] route_width, + output wire [11:0] route_height, + output wire [11:0] route_hmin, + output wire [11:0] route_hmax, + output wire [11:0] route_vmin, + output wire [11:0] route_vmax, + output wire [31:0] route_base, + output wire [13:0] route_stride, + + output wire pending, + output wire [15:0] pending_seq, + output wire [15:0] active_seq, + output wire [15:0] post_count, + output wire [15:0] flip_count, + output wire [15:0] drop_count, + output wire [15:0] reject_count, + output wire [15:0] active_route_epoch +); + + reg [7:0] command = 8'd0; + reg has_command = 1'b0; + reg [7:0] word_count = 8'd0; + + wire command_start = io_uio && io_strobe && !has_command; + wire command_data = io_uio && io_strobe && has_command; + wire [7:0] command_id = has_command ? command : io_din[7:0]; + assign active_word_index = word_count[3:0]; + assign legacy_write = + command_data && (command == 8'h2f) && (word_count < 8'd10); + assign apply_accepted = apply && !legacy_write; + + always @(posedge clk_sys) begin + if(!io_uio) begin + command <= 8'd0; + has_command <= 1'b0; + word_count <= 8'd0; + end + else if(io_strobe) begin + if(!has_command) begin + command <= io_din[7:0]; + has_command <= 1'b1; + word_count <= 8'd0; + end + else begin + word_count <= word_count + 1'd1; + end + end + end + + mister_magik_vblank_latch latch ( + .clk_sys(clk_sys), + .hdmi_vbl(hdmi_vbl), + .cmd_start(command_start), + .cmd_data(command_data), + .cmd_id(command_id), + .word_index(active_word_index), + .data_in(io_din), + .active_lfb_en(active_lfb_en), + .active_lfb_base(active_lfb_base), + .active_lfb_width(active_lfb_width), + .active_lfb_height(active_lfb_height), + .active_lfb_stride(active_lfb_stride), + .apply_accepted(apply_accepted), + .legacy_write(legacy_write), + .response_valid(response_valid), + .response_data(response_data), + .apply(apply), + .route_en(route_en), + .route_flt(route_flt), + .route_fmt(route_fmt), + .route_width(route_width), + .route_height(route_height), + .route_hmin(route_hmin), + .route_hmax(route_hmax), + .route_vmin(route_vmin), + .route_vmax(route_vmax), + .route_base(route_base), + .route_stride(route_stride), + .pending(pending), + .pending_seq(pending_seq), + .active_seq(active_seq), + .post_count(post_count), + .flip_count(flip_count), + .drop_count(drop_count), + .reject_count(reject_count), + .active_route_epoch(active_route_epoch) + ); + +endmodule + +`default_nettype wire diff --git a/sys/mister_magik_vblank_latch.sv b/sys/mister_magik_vblank_latch.sv new file mode 100644 index 00000000..03a405e8 --- /dev/null +++ b/sys/mister_magik_vblank_latch.sv @@ -0,0 +1,581 @@ +// Copyright (C) 2026 Nigel Breslaw +// SPDX-License-Identifier: GPL-3.0-or-later + +`timescale 1ns/1ps +`default_nettype none + +module mister_magik_vblank_latch ( + input wire clk_sys, + input wire hdmi_vbl, + input wire cmd_start, + input wire cmd_data, + input wire [7:0] cmd_id, + input wire [3:0] word_index, + input wire [15:0] data_in, + + input wire active_lfb_en, + input wire [31:0] active_lfb_base, + input wire [11:0] active_lfb_width, + input wire [11:0] active_lfb_height, + input wire [13:0] active_lfb_stride, + input wire apply_accepted, + input wire legacy_write, + + output wire response_valid, + output reg [15:0] response_data, + output wire apply, + + output reg route_en = 1'b0, + output reg route_flt = 1'b0, + output reg [5:0] route_fmt = 6'd0, + output reg [11:0] route_width = 12'd0, + output reg [11:0] route_height = 12'd0, + output reg [11:0] route_hmin = 12'd0, + output reg [11:0] route_hmax = 12'd0, + output reg [11:0] route_vmin = 12'd0, + output reg [11:0] route_vmax = 12'd0, + output reg [31:0] route_base = 32'd0, + output reg [13:0] route_stride = 14'd0, + + output reg pending = 1'b0, + output reg [15:0] pending_seq = 16'd0, + output reg [15:0] active_seq = 16'd0, + output reg [15:0] post_count = 16'd0, + output reg [15:0] flip_count = 16'd0, + output reg [15:0] drop_count = 16'd0, + output reg [15:0] reject_count = 16'd0, + output reg [15:0] active_route_epoch = 16'd0 +); + + `include "mister_magik_latch_protocol.svh" + + function automatic [15:0] crc_byte; + input [15:0] current; + input [7:0] value; + integer bit_index; + reg [15:0] next; + begin + next = current ^ {value, 8'h00}; + for(bit_index = 0; bit_index < 8; bit_index = bit_index + 1) begin + if(next[15]) next = (next << 1) ^ MAGIK_CRC_POLYNOMIAL; + else next = next << 1; + end + crc_byte = next; + end + endfunction + + function automatic [15:0] crc_word; + input [15:0] current; + input [15:0] value; + begin + crc_word = crc_byte(crc_byte(current, value[15:8]), value[7:0]); + end + endfunction + + function automatic [15:0] crc_header; + input [7:0] command; + input [15:0] non_crc_words; + reg [15:0] next; + begin + next = crc_word(MAGIK_CRC_INITIAL, {8'd0, command}); + next = crc_word(next, MAGIK_FBUF_PROTOCOL_VERSION); + crc_header = crc_word(next, non_crc_words); + end + endfunction + + reg rx_open = 1'b0; + reg rx_faulted = 1'b0; + reg [3:0] rx_expected = 4'd0; + reg [10:0] rx_mask = 11'd0; + reg [15:0] rx_crc = 16'd0; + reg [15:0] rx_mode = 16'd0; + reg [31:0] rx_base = 32'd0; + reg [15:0] rx_width_word = 16'd0; + reg [15:0] rx_height_word = 16'd0; + reg [15:0] rx_hmin_word = 16'd0; + reg [15:0] rx_hmax_word = 16'd0; + reg [15:0] rx_vmin_word = 16'd0; + reg [15:0] rx_vmax_word = 16'd0; + reg [15:0] rx_stride_word = 16'd0; + reg [15:0] rx_seq = 16'd0; + reg [25:0] rx_row_span = 26'd0; + reg rx_address_wrap = 1'b0; + + reg [3:0] last_reject_reason = MAGIK_REJECT_NONE; + reg [15:0] last_reject_expected_index = 16'hffff; + reg [15:0] last_reject_observed_index = 16'hffff; + reg [15:0] last_reject_command = 16'd0; + reg [15:0] last_reject_receiver_flags = 16'd0; + reg magik_ownership = 1'b0; + reg [15:0] attempted_transaction = 16'd0; + reg [15:0] accepted_transaction = 16'd0; + reg [15:0] pending_transaction = 16'd0; + reg [15:0] active_transaction = 16'd0; + reg [15:0] accepted_seq = 16'd0; + reg [15:0] receipt_attempted_transaction = 16'd0; + reg [15:0] receipt_attempted_sequence = 16'd0; + reg [15:0] receipt_disposition = MAGIK_RECEIPT_NONE; + reg [3:0] receipt_reject_reason = MAGIK_REJECT_NONE; + + // Read commands are serialized, so one bank preserves each command-start + // snapshot without carrying three mutually exclusive register arrays. + reg [15:0] response_snapshot [0:14]; + reg [15:0] tx_crc = 16'd0; + reg [3:0] tx_expected = 4'd0; + reg [7:0] tx_command = 8'd0; + + (* altera_attribute = "-name SYNCHRONIZER_IDENTIFICATION FORCED_IF_ASYNCHRONOUS" *) + reg vbl_meta = 1'b0; + (* altera_attribute = "-name SYNCHRONIZER_IDENTIFICATION FORCED_IF_ASYNCHRONOUS" *) + reg vbl_sys = 1'b0; + reg vbl_old = 1'b0; + wire vbl_rise = ~vbl_old & vbl_sys; + assign apply = pending && vbl_rise; + + wire [15:0] live_status_flags = + ({12'd0, last_reject_reason} << MAGIK_STATUS_REJECT_REASON_SHIFT) | + (active_lfb_en ? (16'd1 << MAGIK_STATUS_ACTIVE_ENABLED) : 16'd0) | + ((pending && route_en) ? (16'd1 << MAGIK_STATUS_PENDING_ENABLED) : 16'd0) | + (pending ? (16'd1 << MAGIK_STATUS_PENDING) : 16'd0) | + (magik_ownership ? (16'd1 << MAGIK_STATUS_MAGIK_OWNERSHIP) : 16'd0); + + wire rx_reserved_fields = + (|rx_width_word[15:12]) || (|rx_height_word[15:12]) || + (|rx_hmin_word[15:12]) || (|rx_hmax_word[15:12]) || + (|rx_vmin_word[15:12]) || (|rx_vmax_word[15:12]) || + (|rx_stride_word[15:14]); + wire [11:0] rx_width = rx_width_word[11:0]; + wire [11:0] rx_height = rx_height_word[11:0]; + wire [11:0] rx_hmin = rx_hmin_word[11:0]; + wire [11:0] rx_hmax = rx_hmax_word[11:0]; + wire [11:0] rx_vmin = rx_vmin_word[11:0]; + wire [11:0] rx_vmax = rx_vmax_word[11:0]; + wire [13:0] rx_stride = rx_stride_word[13:0]; + wire [11:0] rx_height_minus_one = rx_height - 12'd1; + wire [25:0] rx_next_row_span = + rx_height_minus_one * data_in[13:0]; + wire [32:0] rx_pipelined_end_address = + {1'b0, rx_base} + + {{7{1'b0}}, rx_row_span} + + {{20{1'b0}}, rx_width, 1'b0}; + + reg [3:0] semantic_reject; + always @(*) begin + semantic_reject = MAGIK_REJECT_NONE; + if(!rx_mode[15]) begin + if((rx_mode != 16'd0) || (rx_base != 32'd0) || + (rx_width_word != 16'd0) || (rx_height_word != 16'd0) || + (rx_hmin_word != 16'd0) || (rx_hmax_word != 16'd0) || + (rx_vmin_word != 16'd0) || (rx_vmax_word != 16'd0) || + (rx_stride_word != 16'd0)) + semantic_reject = MAGIK_REJECT_INVALID_MODE; + end + else if((|rx_mode[13:6]) || (rx_mode[5:0] != 6'h14)) + semantic_reject = MAGIK_REJECT_INVALID_MODE; + else if(rx_reserved_fields) + semantic_reject = MAGIK_REJECT_RESERVED; + else if((rx_base == 32'd0) || rx_base[0]) + semantic_reject = MAGIK_REJECT_INVALID_BASE; + else if((rx_width == 0) || + ({4'd0, rx_width} > MAGIK_FBUF_MAX_WIDTH) || + (rx_height == 0) || + ({4'd0, rx_height} > MAGIK_FBUF_MAX_HEIGHT)) + semantic_reject = MAGIK_REJECT_INVALID_GEOMETRY; + else if(rx_stride[0] || (rx_stride < ({2'd0, rx_width} << 1)) || + ({2'd0, rx_stride} > MAGIK_FBUF_MAX_STRIDE)) + semantic_reject = MAGIK_REJECT_INVALID_STRIDE; + else if((rx_hmin > rx_hmax) || (rx_vmin > rx_vmax)) + semantic_reject = MAGIK_REJECT_INVALID_BOUNDS; + else if(rx_address_wrap) + semantic_reject = MAGIK_REJECT_ADDRESS_WRAP; + end + + assign response_valid = + (cmd_start && ((cmd_id == MAGIK_UIO_SET_FBUF_LATCH) || + (cmd_id == MAGIK_UIO_GET_FBUF_LATCH) || + (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_CAPS) || + (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS) || + (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_RECEIPT))) || + (cmd_data && ((cmd_id == MAGIK_UIO_GET_FBUF_LATCH) || + (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_CAPS) || + (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS) || + (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_RECEIPT))); + + always @(*) begin + response_data = 16'd0; + if(cmd_start) begin + case(cmd_id) + MAGIK_UIO_SET_FBUF_LATCH: response_data = MAGIK_FBUF_LATCH_MAGIC; + MAGIK_UIO_GET_FBUF_LATCH: response_data = MAGIK_FBUF_STATUS_MAGIC; + MAGIK_UIO_GET_FBUF_LATCH_CAPS: response_data = MAGIK_FBUF_CAPS_MAGIC; + MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS: + response_data = MAGIK_FBUF_DIAGNOSTICS_MAGIC; + MAGIK_UIO_GET_FBUF_LATCH_RECEIPT: + response_data = MAGIK_FBUF_RECEIPT_MAGIC; + default: response_data = 16'd0; + endcase + end + else if(cmd_data && (cmd_id == MAGIK_UIO_GET_FBUF_LATCH)) begin + if(word_index < 4'd15) response_data = response_snapshot[word_index]; + else if(word_index == 4'd15) + response_data = tx_crc ^ MAGIK_CRC_FINAL_XOR; + end + else if(cmd_data && (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_CAPS)) begin + case(word_index) + 4'd0: response_data = MAGIK_FBUF_PROTOCOL_VERSION; + 4'd1: response_data = MAGIK_FBUF_CAPS_FLAGS; + 4'd2: response_data = MAGIK_FBUF_MAX_WIDTH; + 4'd3: response_data = MAGIK_FBUF_MAX_HEIGHT; + 4'd4: response_data = MAGIK_FBUF_MAX_STRIDE; + 4'd5: response_data = tx_crc ^ MAGIK_CRC_FINAL_XOR; + default: response_data = 16'd0; + endcase + end + else if(cmd_data && (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS)) begin + if(word_index < 4'd6) response_data = response_snapshot[word_index]; + else if(word_index == 4'd6) + response_data = tx_crc ^ MAGIK_CRC_FINAL_XOR; + end + else if(cmd_data && (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_RECEIPT)) begin + if(word_index < 4'd10) response_data = response_snapshot[word_index]; + else if(word_index == 4'd10) + response_data = tx_crc ^ MAGIK_CRC_FINAL_XOR; + end + end + + always @(posedge clk_sys) begin + vbl_meta <= hdmi_vbl; + vbl_sys <= vbl_meta; + vbl_old <= vbl_sys; + + if(cmd_start && (cmd_id == MAGIK_UIO_GET_FBUF_LATCH)) begin + response_snapshot[0] <= active_seq; + response_snapshot[1] <= pending_seq; + response_snapshot[2] <= live_status_flags; + response_snapshot[3] <= flip_count; + response_snapshot[4] <= post_count; + response_snapshot[5] <= active_lfb_base[15:0]; + response_snapshot[6] <= active_lfb_base[31:16]; + response_snapshot[7] <= {4'd0, active_lfb_width}; + response_snapshot[8] <= {4'd0, active_lfb_height}; + response_snapshot[9] <= {2'd0, active_lfb_stride}; + response_snapshot[10] <= reject_count; + response_snapshot[11] <= active_route_epoch; + response_snapshot[12] <= active_transaction; + response_snapshot[13] <= pending_transaction; + response_snapshot[14] <= accepted_transaction; + tx_crc <= crc_header(MAGIK_UIO_GET_FBUF_LATCH, 16'd15); + tx_expected <= 4'd0; + tx_command <= MAGIK_UIO_GET_FBUF_LATCH; + end + else if(cmd_start && (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_CAPS)) begin + tx_crc <= crc_header(MAGIK_UIO_GET_FBUF_LATCH_CAPS, 16'd5); + tx_expected <= 4'd0; + tx_command <= MAGIK_UIO_GET_FBUF_LATCH_CAPS; + end + else if(cmd_start && (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS)) begin + response_snapshot[0] <= reject_count; + response_snapshot[1] <= {12'd0, last_reject_reason}; + response_snapshot[2] <= last_reject_expected_index; + response_snapshot[3] <= last_reject_observed_index; + response_snapshot[4] <= last_reject_command; + response_snapshot[5] <= last_reject_receiver_flags; + tx_crc <= crc_header(MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS, 16'd6); + tx_expected <= 4'd0; + tx_command <= MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS; + end + else if(cmd_start && (cmd_id == MAGIK_UIO_GET_FBUF_LATCH_RECEIPT)) begin + response_snapshot[0] <= rx_open ? attempted_transaction : + receipt_attempted_transaction; + response_snapshot[1] <= rx_open ? rx_seq : receipt_attempted_sequence; + response_snapshot[2] <= rx_open ? MAGIK_RECEIPT_REJECTED : + receipt_disposition; + response_snapshot[3] <= accepted_transaction; + response_snapshot[4] <= accepted_seq; + response_snapshot[5] <= pending_transaction; + response_snapshot[6] <= pending_seq; + response_snapshot[7] <= active_transaction; + response_snapshot[8] <= active_seq; + response_snapshot[9] <= rx_open ? {12'd0, MAGIK_REJECT_MISSING_WORD} : + {12'd0, receipt_reject_reason}; + tx_crc <= crc_header(MAGIK_UIO_GET_FBUF_LATCH_RECEIPT, 16'd10); + tx_expected <= 4'd0; + tx_command <= MAGIK_UIO_GET_FBUF_LATCH_RECEIPT; + end + else if(cmd_data && (cmd_id == tx_command) && + (word_index == tx_expected)) begin + if((tx_command == MAGIK_UIO_GET_FBUF_LATCH) && (word_index < 4'd15)) begin + tx_crc <= crc_word(tx_crc, response_snapshot[word_index]); + tx_expected <= tx_expected + 1'd1; + end + else if((tx_command == MAGIK_UIO_GET_FBUF_LATCH_CAPS) && + (word_index < 4'd5)) begin + case(word_index) + 4'd0: tx_crc <= crc_word(tx_crc, MAGIK_FBUF_PROTOCOL_VERSION); + 4'd1: tx_crc <= crc_word(tx_crc, MAGIK_FBUF_CAPS_FLAGS); + 4'd2: tx_crc <= crc_word(tx_crc, MAGIK_FBUF_MAX_WIDTH); + 4'd3: tx_crc <= crc_word(tx_crc, MAGIK_FBUF_MAX_HEIGHT); + 4'd4: tx_crc <= crc_word(tx_crc, MAGIK_FBUF_MAX_STRIDE); + // The enclosing range check makes this defensive arm unreachable. + /* verilator coverage_off */ + default: tx_crc <= tx_crc; + /* verilator coverage_on */ + endcase + tx_expected <= tx_expected + 1'd1; + end + else if((tx_command == MAGIK_UIO_GET_FBUF_LATCH_DIAGNOSTICS) && + (word_index < 4'd6)) begin + tx_crc <= crc_word(tx_crc, response_snapshot[word_index]); + tx_expected <= tx_expected + 1'd1; + end + else if((tx_command == MAGIK_UIO_GET_FBUF_LATCH_RECEIPT) && + (word_index < 4'd10)) begin + tx_crc <= crc_word(tx_crc, response_snapshot[word_index]); + tx_expected <= tx_expected + 1'd1; + end + end + + if(legacy_write) begin + magik_ownership <= 1'b0; + active_seq <= 16'd0; + active_transaction <= 16'd0; + accepted_seq <= 16'd0; + accepted_transaction <= 16'd0; + pending_seq <= 16'd0; + pending_transaction <= 16'd0; + pending <= 1'b0; + active_route_epoch <= active_route_epoch + 1'd1; + end + else if(apply_accepted) begin + magik_ownership <= 1'b1; + active_seq <= pending_seq; + active_transaction <= pending_transaction; + pending_seq <= 16'd0; + pending_transaction <= 16'd0; + active_route_epoch <= active_route_epoch + 1'd1; + flip_count <= flip_count + 1'd1; + pending <= 1'b0; + end + + if(cmd_start) begin + if(rx_open) begin + reject_count <= reject_count + 1'd1; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= 16'd0; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + if(cmd_id == MAGIK_UIO_SET_FBUF_LATCH) + last_reject_reason <= MAGIK_REJECT_RESTARTED; + else + last_reject_reason <= MAGIK_REJECT_MISSING_WORD; + receipt_attempted_transaction <= attempted_transaction; + receipt_attempted_sequence <= rx_seq; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= (cmd_id == MAGIK_UIO_SET_FBUF_LATCH) ? + MAGIK_REJECT_RESTARTED : MAGIK_REJECT_MISSING_WORD; + end + if(cmd_id == MAGIK_UIO_SET_FBUF_LATCH) begin + attempted_transaction <= attempted_transaction + 1'd1; + receipt_attempted_transaction <= attempted_transaction + 1'd1; + receipt_attempted_sequence <= 16'd0; + receipt_disposition <= MAGIK_RECEIPT_NONE; + receipt_reject_reason <= MAGIK_REJECT_NONE; + rx_open <= 1'b1; + rx_faulted <= 1'b0; + rx_seq <= 16'd0; + rx_expected <= 4'd0; + rx_mask <= 11'd0; + rx_crc <= crc_header(MAGIK_UIO_SET_FBUF_LATCH, 16'd11); + rx_row_span <= 26'd0; + rx_address_wrap <= 1'b0; + end + else begin + rx_open <= 1'b0; + rx_faulted <= 1'b1; + end + end + else if(cmd_data && (cmd_id == MAGIK_UIO_SET_FBUF_LATCH)) begin + if(!rx_open) begin + if(!rx_faulted) begin + reject_count <= reject_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_POST_CLOSE; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= MAGIK_REJECT_POST_CLOSE; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + rx_faulted <= 1'b1; + end + end + else if((word_index < rx_expected) && + (word_index + 1'd1 == rx_expected)) begin + reject_count <= reject_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_DUPLICATE_WORD; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= MAGIK_REJECT_DUPLICATE_WORD; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + rx_open <= 1'b0; + rx_faulted <= 1'b1; + end + else if(word_index < rx_expected) begin + reject_count <= reject_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_OUT_OF_ORDER; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= MAGIK_REJECT_OUT_OF_ORDER; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + rx_open <= 1'b0; + rx_faulted <= 1'b1; + end + else if((word_index == 4'd11) && (rx_expected != 4'd11)) begin + reject_count <= reject_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_MISSING_WORD; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= MAGIK_REJECT_MISSING_WORD; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + rx_open <= 1'b0; + rx_faulted <= 1'b1; + end + else if(word_index > rx_expected) begin + reject_count <= reject_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_SHIFTED_WORD; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= MAGIK_REJECT_SHIFTED_WORD; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + rx_open <= 1'b0; + rx_faulted <= 1'b1; + end + else if(word_index < 4'd11) begin + rx_mask[word_index] <= 1'b1; + rx_crc <= crc_word(rx_crc, data_in); + rx_expected <= rx_expected + 1'd1; + case(word_index) + 4'd0: rx_mode <= data_in; + 4'd1: rx_base[15:0] <= data_in; + 4'd2: rx_base[31:16] <= data_in; + 4'd3: rx_width_word <= data_in; + 4'd4: rx_height_word <= data_in; + 4'd5: rx_hmin_word <= data_in; + 4'd6: rx_hmax_word <= data_in; + 4'd7: rx_vmin_word <= data_in; + 4'd8: rx_vmax_word <= data_in; + 4'd9: begin + rx_stride_word <= data_in; + rx_row_span <= rx_next_row_span; + end + 4'd10: begin + rx_seq <= data_in; + receipt_attempted_sequence <= data_in; + rx_address_wrap <= + rx_pipelined_end_address > 33'h100000000; + end + // word_index < 11 and exact ordering restrict this case to 0..10. + /* verilator coverage_off */ + default: begin end + /* verilator coverage_on */ + endcase + end + else begin + rx_open <= 1'b0; + rx_faulted <= 1'b0; + // Exact in-order framing makes a CRC commit reachable only after all + // eleven payload bits are set. Keep the mask check as defense in depth. + /* verilator coverage_off */ + if(rx_mask != 11'h7ff) begin + reject_count <= reject_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_MISSING_WORD; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= MAGIK_REJECT_MISSING_WORD; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + rx_faulted <= 1'b1; + end + /* verilator coverage_on */ + else if(data_in != (rx_crc ^ MAGIK_CRC_FINAL_XOR)) begin + reject_count <= reject_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_BAD_CRC; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= MAGIK_REJECT_BAD_CRC; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + rx_faulted <= 1'b1; + end + else if(pending) begin + reject_count <= reject_count + 1'd1; + drop_count <= drop_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_PENDING_BUSY; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= MAGIK_REJECT_PENDING_BUSY; + rx_faulted <= 1'b1; + end + else if(semantic_reject != MAGIK_REJECT_NONE) begin + reject_count <= reject_count + 1'd1; + last_reject_reason <= semantic_reject; + receipt_disposition <= MAGIK_RECEIPT_REJECTED; + receipt_reject_reason <= semantic_reject; + last_reject_expected_index <= {12'd0, rx_expected}; + last_reject_observed_index <= {12'd0, word_index}; + last_reject_command <= {8'd0, cmd_id}; + last_reject_receiver_flags <= {14'd0, rx_faulted, rx_open}; + rx_faulted <= 1'b1; + end + else begin + route_en <= rx_mode[15]; + route_flt <= rx_mode[14]; + route_fmt <= rx_mode[5:0]; + route_base <= rx_base; + route_width <= rx_width; + route_height <= rx_height; + route_hmin <= rx_hmin; + route_hmax <= rx_hmax; + route_vmin <= rx_vmin; + route_vmax <= rx_vmax; + route_stride <= rx_stride; + pending_seq <= rx_seq; + pending_transaction <= attempted_transaction; + accepted_transaction <= attempted_transaction; + accepted_seq <= rx_seq; + pending <= 1'b1; + post_count <= post_count + 1'd1; + last_reject_reason <= MAGIK_REJECT_NONE; + receipt_disposition <= MAGIK_RECEIPT_ACCEPTED; + receipt_reject_reason <= MAGIK_REJECT_NONE; + end + end + end + end + + `ifndef SYNTHESIS + always @(posedge clk_sys) begin + if(!pending) begin + assert(accepted_seq == active_seq) + else $fatal(1, "accepted N / active N-1 / no pending is forbidden"); + assert(accepted_transaction == active_transaction) + else $fatal(1, "active transaction must originate from the accepted receipt"); + end + end + `endif + +endmodule + +`default_nettype wire diff --git a/sys/sys_top.v b/sys/sys_top.v index 5f9dfa4e..99fca245 100644 --- a/sys/sys_top.v +++ b/sys/sys_top.v @@ -231,7 +231,7 @@ end // gp_in[31] = 0 - quick flag that FPGA is initialized (HPS reads 1 when FPGA is not in user mode) // used to avoid lockups while JTAG loading -wire [31:0] gp_in = {1'b0, btn_user | btn[1], btn_osd | btn[0], io_dig, 8'd0, io_ver, io_ack, io_wide, io_dout | io_dout_sys}; +wire [31:0] gp_in = {1'b0, btn_user | btn[1], btn_osd | btn[0], io_dig, 8'd0, io_ver, io_ack, io_wide, io_dout | io_dout_sys | magik_io_dout}; wire [31:0] gp_out; wire [1:0] io_ver = 1; // 0 - obsolete. 1 - optimized HPS I/O. 2,3 - reserved for future. @@ -350,6 +350,73 @@ reg [12:0] arc2x = 0; reg [12:0] arc2y = 0; reg [15:0] io_dout_sys; +// GPL-3.0-or-later latch adapter by Nigel Breslaw; see mister_magik_latch_sys_top_bridge.sv. +wire magik_response_valid; +wire [15:0] magik_response_data; +wire magik_lfb_apply_accepted; +wire magik_lfb_en; +wire magik_lfb_flt; +wire [5:0] magik_lfb_fmt; +wire [11:0] magik_lfb_width; +wire [11:0] magik_lfb_height; +wire [11:0] magik_lfb_hmin; +wire [11:0] magik_lfb_hmax; +wire [11:0] magik_lfb_vmin; +wire [11:0] magik_lfb_vmax; +wire [31:0] magik_lfb_base; +wire [13:0] magik_lfb_stride; + +mister_magik_latch_sys_top_bridge magik_latch_bridge +( + .clk_sys(clk_sys), + .hdmi_vbl(hdmi_vbl), + .io_uio(io_uio), + .io_strobe(io_strobe), + .io_din(io_din), + .active_lfb_en(LFB_EN), + .active_lfb_base(LFB_BASE), + .active_lfb_width(LFB_WIDTH), + .active_lfb_height(LFB_HEIGHT), + .active_lfb_stride(LFB_STRIDE), + .response_valid(magik_response_valid), + .response_data(magik_response_data), + .apply(), + .apply_accepted(magik_lfb_apply_accepted), + .legacy_write(), + .active_word_index(), + .route_en(magik_lfb_en), + .route_flt(magik_lfb_flt), + .route_fmt(magik_lfb_fmt), + .route_width(magik_lfb_width), + .route_height(magik_lfb_height), + .route_hmin(magik_lfb_hmin), + .route_hmax(magik_lfb_hmax), + .route_vmin(magik_lfb_vmin), + .route_vmax(magik_lfb_vmax), + .route_base(magik_lfb_base), + .route_stride(magik_lfb_stride), + .pending(), + .pending_seq(), + .active_seq(), + .post_count(), + .flip_count(), + .drop_count(), + .reject_count(), + .active_route_epoch() +); + +// Independent response register avoids decoder assignments overwriting replies. +// Diagnostic command 0x45 counts response strobes, including payload words. +reg [15:0] magik_io_dout = 16'd0; +reg [7:0] magik_response_strobes = 8'd0; +always @(posedge clk_sys) begin + if(~io_uio) magik_io_dout <= 16'd0; + else if(io_strobe) begin + magik_io_dout <= magik_response_valid ? magik_response_data : 16'd0; + if(magik_response_valid) magik_response_strobes <= magik_response_strobes + 1'd1; + end +end + always@(posedge clk_sys) begin reg [7:0] cmd; reg has_cmd; @@ -358,6 +425,20 @@ always@(posedge clk_sys) begin reg [4:0] acx_att; reg [7:0] fb_crc; + if(magik_lfb_apply_accepted) begin + LFB_EN <= magik_lfb_en; + LFB_FLT <= magik_lfb_flt; + LFB_FMT <= magik_lfb_fmt; + LFB_WIDTH <= magik_lfb_width; + LFB_HEIGHT <= magik_lfb_height; + LFB_HMIN <= magik_lfb_hmin; + LFB_HMAX <= magik_lfb_hmax; + LFB_VMIN <= magik_lfb_vmin; + LFB_VMAX <= magik_lfb_vmax; + LFB_BASE <= magik_lfb_base; + LFB_STRIDE <= magik_lfb_stride; + end + coef_wr <= 0; `ifndef MISTER_DEBUG_NOHDMI @@ -396,6 +477,8 @@ always@(posedge clk_sys) begin if(io_din[7:0] == 'h40) io_dout_sys <= fb_crc; `endif if(io_din[7:0] == 'h42) io_dout_sys <= {1'b1, frame_cnt}; + if(io_din[7:0] == 'h44) io_dout_sys <= 1; + if(io_din[7:0] == 'h45) io_dout_sys <= {8'h5A, magik_response_strobes}; end else begin cnt <= cnt + 1'd1; diff --git a/tb/bootstrap_video_tb.sv b/tb/bootstrap_video_tb.sv new file mode 100644 index 00000000..ce9fbfc2 --- /dev/null +++ b/tb/bootstrap_video_tb.sv @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +`timescale 1ns/1ps +module bootstrap_video_tb; + reg native_active = 0; + reg [23:0] native_rgb = 0; + reg de_in = 0, hs_in = 0, vs_in = 0; + wire [23:0] rgb_out; + wire de_out, hs_out, vs_out; + zaparoo_bootstrap_video dut (.*); + initial begin + // A valid native CRT writer must never be covered by bootstrap black. + // Writer stop returns to black without changing any timing signals. + for (integer active = 0; active < 2; active = active + 1) begin + native_active = active[0]; + for (integer timing = 0; timing < 8; timing = timing + 1) begin + {de_in, hs_in, vs_in} = timing[2:0]; + for (integer color = 0; color < 256; color = color + 1) begin + native_rgb = {color[7:0], ~color[7:0], color[7:0]}; + #1; + assert(rgb_out == (native_active ? native_rgb : 24'd0)) + else $fatal(1, "idle black/native CRT mux mismatch"); + assert({de_out, hs_out, vs_out} == {de_in, hs_in, vs_in}) + else $fatal(1, "bootstrap changed timing"); + end + end + end + native_active = 0; #1; + assert(rgb_out == 0) else $fatal(1, "writer stop did not restore black"); + $display("PASS: idle black, native CRT passthrough, timing and writer stop"); + $finish; + end +endmodule diff --git a/tb/scanout_tb.sv b/tb/scanout_tb.sv new file mode 100644 index 00000000..913830d4 --- /dev/null +++ b/tb/scanout_tb.sv @@ -0,0 +1,154 @@ +// Copyright (C) 2026 Nigel Breslaw +// SPDX-License-Identifier: GPL-3.0-or-later +// Derived from tb_mister_magik_sys_top_integration.sv; Zaparoo bus/540p checks. +`timescale 1ns/1ps +module scanout_tb; + `include "mister_magik_latch_protocol.svh" + reg clk = 0; + always #5 clk = ~clk; + reg vblank = 0, selected = 0, strobe = 0; + reg [15:0] data_in = 0; + wire response_valid, apply, accepted, legacy, enabled, filtered, pending; + wire [15:0] response_data, active_seq, rejects; + wire [3:0] word_index; + wire [5:0] format; + wire [11:0] width, height, hmin, hmax, vmin, vmax; + wire [31:0] base; + wire [13:0] stride; + reg active_enabled = 0; + reg [31:0] active_base = 0; + reg [11:0] active_width = 0, active_height = 0; + reg [13:0] active_stride = 0; + reg [15:0] reply = 0; + mister_magik_latch_sys_top_bridge dut ( + .clk_sys(clk), .hdmi_vbl(vblank), .io_uio(selected), + .io_strobe(strobe), .io_din(data_in), + .active_lfb_en(active_enabled), .active_lfb_base(active_base), + .active_lfb_width(active_width), .active_lfb_height(active_height), + .active_lfb_stride(active_stride), .response_valid(response_valid), + .response_data(response_data), .apply(apply), .apply_accepted(accepted), + .legacy_write(legacy), .active_word_index(word_index), + .route_en(enabled), .route_flt(filtered), .route_fmt(format), + .route_width(width), .route_height(height), .route_hmin(hmin), + .route_hmax(hmax), .route_vmin(vmin), .route_vmax(vmax), + .route_base(base), .route_stride(stride), .pending(pending), + .pending_seq(), .active_seq(active_seq), .post_count(), + .flip_count(), .drop_count(), .reject_count(rejects), .active_route_epoch() + ); + // Mirror sys_top's separate response register and legacy-write priority. + always @(posedge clk) begin + if (!selected) reply <= 0; + else if (strobe) reply <= response_valid ? response_data : 16'd0; + if (accepted) begin + active_enabled <= enabled; + active_base <= base; + active_width <= width; + active_height <= height; + active_stride <= stride; + end + if (legacy) begin + case (word_index) + 0: active_enabled <= data_in[15]; + 1: active_base[15:0] <= data_in; + 2: active_base[31:16] <= data_in; + default: begin end + endcase + end + end + function automatic [15:0] crc_word(input [15:0] crc, input [15:0] word); + reg [15:0] next; + begin + next = crc; + for (integer bit_index = 15; bit_index >= 0; bit_index = bit_index - 1) + next = (next << 1) ^ ((next[15] ^ word[bit_index]) ? 16'h1021 : 16'd0); + return next; + end + endfunction + task automatic transfer(input [15:0] value, output [15:0] response); + @(negedge clk); data_in = value; strobe = 1; + @(posedge clk); #1; response = reply; strobe = 0; + endtask + task automatic begin_command(input [7:0] command, input [15:0] magic); + reg [15:0] response; + @(negedge clk); selected = 1; + transfer({8'd0, command}, response); + assert (response == magic) else $fatal(1, "command %h: %h != %h", command, response, magic); + endtask + task automatic end_command; + @(negedge clk); selected = 0; strobe = 0; + @(posedge clk); #1; + endtask + task automatic post(input bit corrupt, input [15:0] sequence_id); + reg [15:0] words [0:10]; + reg [15:0] crc, response; + // 960x540 RGB565 from qualified slot zero into full 1920x1080 HDMI. + words[0] = 16'h8014; + words[1] = 16'h0000; + words[2] = 16'h2300; + words[3] = 16'd960; + words[4] = 16'd540; + words[5] = 16'd0; + words[6] = 16'd1919; + words[7] = 16'd0; + words[8] = 16'd1079; + words[9] = 16'd1920; + words[10] = sequence_id; + crc = crc_word(crc_word(crc_word(16'hffff, 16'h57), 16'd4), 16'd11); + begin_command(8'h57, MAGIK_FBUF_LATCH_MAGIC); + for (integer i = 0; i < 11; i = i + 1) begin + crc = crc_word(crc, words[i]); + transfer(words[i], response); + end + transfer(crc ^ (corrupt ? 16'd1 : 16'd0), response); + end_command(); + endtask + initial begin + reg [15:0] response, receipt [0:10]; + repeat (3) @(posedge clk); + end_command(); + begin_command(8'h59, MAGIK_FBUF_CAPS_MAGIC); + transfer(0, response); assert(response == 4) else $fatal; + transfer(0, response); assert(response == 16'h1ff) else $fatal; + transfer(0, response); assert(response == 1920) else $fatal; + transfer(0, response); assert(response == 1080) else $fatal; + transfer(0, response); assert(response == 3840) else $fatal; + transfer(0, response); assert(response == 16'h2984) else $fatal; + end_command(); + post(1, 1); + assert(!pending && !active_enabled && rejects == 1) else $fatal(1, "bad CRC changed route"); + post(0, 2); + assert(pending && !active_enabled) else $fatal(1, "route changed before vblank"); + post(0, 3); + assert(pending && rejects == 2) else $fatal(1, "pending route overwritten"); + begin_command(8'h5b, MAGIK_FBUF_RECEIPT_MAGIC); + for (integer i = 0; i < 11; i = i + 1) transfer(0, receipt[i]); + assert(receipt[1] == 3 && receipt[2] == MAGIK_RECEIPT_REJECTED && + receipt[4] == 2 && receipt[6] == 2 && receipt[9] == {12'd0, MAGIK_REJECT_PENDING_BUSY}) + else $fatal(1, "receipt lost rejected/accepted distinction"); + end_command(); + @(negedge clk); vblank = 1; + repeat (5) @(posedge clk); #1; + assert(active_enabled && active_base == 32'h23000000 && active_width == 960 && + active_height == 540 && active_stride == 1920 && active_seq == 2 && !pending && + hmax == 1919 && vmax == 1079) + else $fatal(1, "540p/full HDMI route not applied atomically"); + @(negedge clk); vblank = 0; + repeat (4) @(posedge clk); + post(0, 4); + begin_command(8'h2f, 0); + @(negedge clk); vblank = 1; + while (!apply) @(negedge clk); + data_in = 0; strobe = 1; + @(posedge clk); #1; strobe = 0; + assert(!pending && active_seq == 0 && !active_enabled) + else $fatal(1, "legacy collision failed to reclaim ownership"); + transfer(16'h4444, response); transfer(16'h2222, response); + end_command(); + assert(active_base == 32'h22224444) else $fatal(1, "legacy framebuffer restore lost"); + $display("PASS: scanout CAPS, CRC, pending/receipts, 540p HDMI, vblank, legacy takeover"); + $finish; + end + initial begin + #20000; $fatal(1, "scanout simulation timeout"); + end +endmodule From 72d55364402249d72632c322c59c190794e72a7c Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 10:10:53 +0800 Subject: [PATCH 12/18] ci: qualify scanout modules and gate RTL timing Build the pinned kernel/module pair and publish a separate .ko artifact. Reuse the same Icarus runner locally and in CI for native video, latch and bootstrap regressions. Reject incomplete or negative Quartus timing reports even when compilation exits successfully. --- .github/workflows/ci_build.yml | 53 ++++++++++++++++- kernel/build-scanout.sh | 60 +++++++++++++++++++ kernel/scanout-slots/README.md | 103 +++++++++++++++++++++++++++++++++ tb/check_timing.py | 35 +++++++++++ tb/run.sh | 26 +++++++-- tb/test_check_timing.py | 41 +++++++++++++ 6 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 kernel/build-scanout.sh create mode 100644 kernel/scanout-slots/README.md create mode 100644 tb/check_timing.py create mode 100644 tb/test_check_timing.py diff --git a/.github/workflows/ci_build.yml b/.github/workflows/ci_build.yml index be130438..cdd70c35 100644 --- a/.github/workflows/ci_build.yml +++ b/.github/workflows/ci_build.yml @@ -9,6 +9,53 @@ permissions: contents: read jobs: + rtl-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Install RTL simulator + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y iverilog + + - name: Test timing-report gate + run: python3 -B -m unittest discover -s tb -p 'test_*.py' + + - name: Run RTL regressions + run: sh tb/run.sh + + scanout-module: + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v6 + + - name: Install kernel build tools + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + build-essential bc bison flex libssl-dev libelf-dev \ + libgmp-dev libmpc-dev libmpfr-dev kmod xz-utils git curl ca-certificates + rustup toolchain install 1.95.0 --profile minimal --no-self-update + + - name: Cache verified compiler archive + uses: actions/cache@v4 + with: + path: kernel/.build/ci/gcc-arm-10.2-2020.11-x86_64-arm-none-linux-gnueabihf.tar.xz + key: scanout-gcc-102825ae56c9e00142d06f35d2bdd3299edb6060e84a275a25b095e66fd3fc2a + + - name: Build qualified kernel and module + run: bash kernel/build-scanout.sh + + - name: Upload scanout module + uses: actions/upload-artifact@v4 + with: + name: zaparoo-scanout-6.18.38-MiSTer + path: kernel/scanout-slots/zaparoo_scanout.ko + if-no-files-found: error + build: runs-on: ubuntu-latest @@ -24,8 +71,10 @@ jobs: theypsilon/quartus-lite-c5:17.0.2.docker0 \ /opt/intelFPGA_lite/quartus/bin/quartus_sh --flow compile menu - - name: Check build output - run: test -f output_files/menu.rbf + - name: Check build output and timing + run: | + test -f output_files/menu.rbf + python3 -B tb/check_timing.py output_files/menu.sta.rpt - name: Upload RBF uses: actions/upload-artifact@v4 diff --git a/kernel/build-scanout.sh b/kernel/build-scanout.sh new file mode 100644 index 00000000..5a829901 --- /dev/null +++ b/kernel/build-scanout.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Build the exact kernel/module pair accepted by scanout-slots/Makefile. +set -euo pipefail + +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +build_root=${BUILD_ROOT:-"$root/kernel/.build/ci"} +kernel_src=${KERNEL_SRC:-"$build_root/linux"} +kernel_build="$build_root/kernel" +revision=aec7dc3aa4846385736f1d54c9155e3b3c726708 +package=gcc-arm-10.2-2020.11-x86_64-arm-none-linux-gnueabihf +mkdir -p "$build_root" + +if [[ -z ${CROSS_COMPILE:-} ]]; then + archive="$build_root/$package.tar.xz" + if [[ ! -f $archive ]]; then + curl --fail --location --retry 3 --connect-timeout 30 --max-time 1200 \ + "https://developer.arm.com/-/media/Files/downloads/gnu-a/10.2-2020.11/binrel/$package.tar.xz" \ + --output "$archive.partial" + mv "$archive.partial" "$archive" + fi + # SHA-256: https://github.com/buildroot/buildroot/blob/2021.02/toolchain/toolchain-external/toolchain-external-arm-arm/toolchain-external-arm-arm.hash + printf '%s %s\n' 102825ae56c9e00142d06f35d2bdd3299edb6060e84a275a25b095e66fd3fc2a "$archive" | sha256sum -c - + tar -xJf "$archive" -C "$build_root" + export CROSS_COMPILE="$build_root/$package/bin/arm-none-linux-gnueabihf-" +fi + +test "$("${CROSS_COMPILE}gcc" -dumpfullversion -dumpversion)" = 10.2.1 +if [[ -z ${KERNEL_SRC:-} ]]; then + if [[ ! -d $kernel_src ]]; then + git init "$kernel_src" + git -C "$kernel_src" remote add origin https://github.com/MiSTer-devel/Linux-Kernel_MiSTer.git + fi + if ! git -C "$kernel_src" cat-file -e "$revision^{commit}" 2>/dev/null; then + git -C "$kernel_src" fetch --depth=1 origin "$revision" + fi + if git -C "$kernel_src" rev-parse --verify HEAD >/dev/null 2>&1; then + git -C "$kernel_src" diff --quiet HEAD -- . + fi + git -C "$kernel_src" checkout --detach "$revision" +fi +# Explicit source overrides are read-only inputs, never reset or checked out. +test "$(git -C "$kernel_src" rev-parse HEAD)" = "$revision" +git -C "$kernel_src" diff --quiet HEAD -- . + +# Pin optional tool detection as well as the target compiler. Rust 1.95.0 is +# needed only to reproduce the qualified Kconfig fingerprint; no Rust is built. +export RUSTUP_TOOLCHAIN=1.95.0 +rustc --version | grep -E '^rustc 1\.95\.0 ' +export PAHOLE=false +export BINDGEN=false +kernel_args=(-C "$kernel_src" "O=$kernel_build" ARCH=arm + "CROSS_COMPILE=$CROSS_COMPILE" LOCALVERSION=-MiSTer) +make "${kernel_args[@]}" MiSTer_defconfig +printf '%s %s\n' 0d010a3d551cbffcd91af7850f3f745ce73f3bb911cfd56ead902fc9b6c69823 "$kernel_build/.config" | sha256sum -c - +# modules_prepare alone cannot supply a genuine Module.symvers. +make "${kernel_args[@]}" -j"${JOBS:-$(nproc)}" vmlinux modules +make -C "$root/kernel/scanout-slots" KERNEL_SRC="$kernel_src" KERNEL_BUILD="$kernel_build" CROSS_COMPILE="$CROSS_COMPILE" +vermagic=$(modinfo -F vermagic "$root/kernel/scanout-slots/zaparoo_scanout.ko") +test "${vermagic% }" = '6.18.38-MiSTer SMP mod_unload ARMv7 p2v8' +sha256sum "$root/kernel/scanout-slots/zaparoo_scanout.ko" diff --git a/kernel/scanout-slots/README.md b/kernel/scanout-slots/README.md new file mode 100644 index 00000000..90ed93c4 --- /dev/null +++ b/kernel/scanout-slots/README.md @@ -0,0 +1,103 @@ +# Zaparoo scanout slots + +This GPL-3.0-or-later component derives from Nigel Breslaw's MagiK scanout-slot +module and the Zaparoo demo's 1080p extension. Keep its source and attribution +with this Menu fork. It is a separate kernel artifact, not linked into the +frontend. Source imports retain their original license; the Linux module +loader's license classification is separate and must not be changed to gain +access to GPL-only kernel exports. + +## Compatibility policy + +Initially support only the exact qualified MiSTer 6.18 kernel build. Do not +force-load a module, update the kernel, or reuse an unverified module. Unknown +and older kernels retain the ordinary fb0 frontend path. Future kernel changes +require rebuilding and requalifying the module and its memory-map contract. + +Use a Zaparoo-specific module/device identity and ABI. Do not install over +`mem_wc.ko` or `mister_magik_scanout_slots.ko`, or unload someone else's module. + +## Shared hardware + +DreamSTer's `mem_wc` provides a generic write-combined physical-memory mapper. +MagiK's module provides bounded scanout slots. These are not interchangeable +interfaces. A loaded module is not necessarily an active renderer. + +Only one Zaparoo client may own the slots at a time. Resource reservations must +last until the last mapping/file reference closes, including after process +termination. Idle module residency must not reserve another application's +memory indefinitely. Main must coordinate the frontend's FPGA bus access and +terminate its child when Main exits. + +Resource reservations prevent conflicting cooperative drivers, but do not +stop arbitrary `/dev/mem` or `/dev/mem_wc` mappings or another FPGA bitstream. +Conflict detection and lifecycle handoff are required; this is not a security +boundary against another privileged process starting an unrestricted mapper. +Do not claim concurrent DreamSTer/MagiK/Zaparoo rendering is supported. + +## Upstream references + +- https://github.com/NigelBreslaw/MiSTer-MagiK/tree/main/mister/platform/kernel/scanout-slots +- https://github.com/skmp/minicast/tree/master/mem_wc +- https://github.com/MiSTer-devel/Linux-Kernel_MiSTer/tree/MiSTer-v6.18 + +## Qualified build + +CI builds the module in a separate job from Quartus and uploads +`zaparoo-scanout-6.18.38-MiSTer` containing `zaparoo_scanout.ko`. It does not +install the module or change the device kernel. Only the checksum-verified +compiler archive is cached; kernel output and `Module.symvers` are built fresh. + +Run the same build from the Menu repository root. Install the build packages +listed in `.github/workflows/ci_build.yml`, then: + +```sh +rustup toolchain install 1.95.0 --profile minimal +bash kernel/build-scanout.sh +``` + +The script pins the kernel revision and verifies the GNU ARM 10.2.1 archive's +SHA-256 before extracting it. Rust 1.95.0 reproduces Kconfig's tool-detection +fields; the kernel/module build does not compile Rust. All downloaded inputs +and kernel output stay under ignored `kernel/.build/ci/`. + +For local reuse, `CROSS_COMPILE` may point to Main's qualified GNU toolchain +(not the frontend's musl compiler), and `KERNEL_SRC` may point to an existing +clean checkout of the pinned revision. `BUILD_ROOT` accepts an absolute build +path, and `JOBS` controls parallelism. The full kernel build creates real +`Module.symvers`; do not suppress modpost errors or substitute +`modules_prepare` alone. + +The module Makefile rejects a different source revision, tracked source edits, +config fingerprint, compiler version or missing symbol table. Do not loosen +these checks to make an unknown build pass. Expected vermagic: +`6.18.38-MiSTer SMP mod_unload ARMv7 p2v8`. + +Qualification evidence: + +| Input | SHA-256 | +|---|---| +| Generated `.config` | `0d010a3d551cbffcd91af7850f3f745ce73f3bb911cfd56ead902fc9b6c69823` | +| `drivers/video/fbdev/MiSTer_fb.c` | `f4044889e96a843a54bde091737825043b71b6bb8994fe3f92387cccd6ee3924` | +| `arch/arm/boot/dts/intel/socfpga/socfpga_cyclone5_de10_nano.dts` | `5c03d8ffb9e1477523d6434c5255db46433158f771fb6288320c42f8d3484938` | + +ABI v1 uses `/dev/zaparoo-scanout`, ioctl `_IOR('Z', 1, layout)` and a 64-byte +layout. Slots start at `0x23000000` and `0x23400000`, outside the complete +`MiSTer_fb` DT aperture (`0x22000000`, 8 MiB). Each has 4,147,200 usable bytes +and a 4,149,248-byte mapping. Slot-one mmap selector is 8,294,400, **not its +physical address**. Only exact shared read/write mappings are accepted; +executable mappings and fork inheritance are disabled. + +6.18 compatibility decisions: + +- `registered_fb` is no longer exported. Validate the pinned root-level DT + aperture instead of linking to that private symbol. +- `no_llseek` is gone; use a NULL file-operation entry. +- Published-VMA setters require GPL-only locking helpers. The pinned kernel + invokes `.mmap` on a newly allocated VMA before insertion into the tree, so + initialize its flags with `vm_flags_init`. Recheck this ordering for a new + kernel; do not change the loader license marker to bypass modpost. + +Successful compilation is **not hardware qualification**. Before installing +or distributing the artifact, verify the matched Main/frontend/Menu stack on +the target device, including ownership handoff, crash recovery and fb0 fallback. diff --git a/tb/check_timing.py b/tb/check_timing.py new file mode 100644 index 00000000..3cd783f9 --- /dev/null +++ b/tb/check_timing.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Reject failed or incomplete Quartus timing reports, even after exit status 0.""" + +import re +import sys +from decimal import Decimal +from pathlib import Path + +METRICS = {"setup", "hold", "recovery", "removal", "minimum pulse width"} +SLACK = re.compile( + r"^Info \(332146\): Worst-case ([a-z ]+) slack is (-?\d+(?:\.\d+)?)\s*$", + re.MULTILINE, +) + + +def check_timing(report: str) -> None: + if "Timing requirements not met" in report: + raise ValueError("Quartus reports unmet timing requirements") + seen = set() + for metric, value in SLACK.findall(report): + if metric not in METRICS: + continue + seen.add(metric) + if Decimal(value) < 0: + raise ValueError(f"negative {metric} slack: {value} ns") + if seen != METRICS: + raise ValueError(f"missing timing results: {', '.join(sorted(METRICS - seen))}") + + +if __name__ == "__main__": + try: + check_timing(Path(sys.argv[1]).read_text()) + except (IndexError, OSError, ValueError) as error: + sys.exit(f"Timing check failed: {error}") + print("PASS: setup, hold, recovery, removal and pulse-width timing") diff --git a/tb/run.sh b/tb/run.sh index 8de90d0b..07e24e3f 100755 --- a/tb/run.sh +++ b/tb/run.sh @@ -1,13 +1,29 @@ #!/bin/sh -# Simulate the native video testbenches with Icarus Verilog. +# Simulate native video, scanout and bootstrap with Icarus Verilog. set -e cd "$(dirname "$0")" -iverilog -g2012 -o native_video_timing_tb.vvp \ +mkdir -p ../test-output/rtl + +run_test() { + top=$1 + shift + echo "=== $top ===" + iverilog -g2012 -I ../sys -s "$top" -o "../test-output/rtl/$top.vvp" "$@" + timeout 600 vvp "../test-output/rtl/$top.vvp" +} + +run_test bootstrap_video_tb \ + ../sys/mister_magik_bootstrap_black.sv ../rtl/zaparoo_bootstrap_video.sv \ + bootstrap_video_tb.sv + +run_test scanout_tb \ + ../sys/mister_magik_vblank_latch.sv ../sys/mister_magik_latch_sys_top_bridge.sv \ + scanout_tb.sv + +run_test native_video_timing_tb \ ../rtl/native_video_timing.sv native_video_timing_tb.sv -vvp native_video_timing_tb.vvp -iverilog -g2012 -o native_video_reader_tb.vvp \ +run_test native_video_reader_tb \ ../rtl/native_video_timing.sv ../rtl/native_video_reader.sv \ ../rtl/native_video_top.sv dcfifo_sim.sv native_video_reader_tb.sv -vvp native_video_reader_tb.vvp diff --git a/tb/test_check_timing.py b/tb/test_check_timing.py new file mode 100644 index 00000000..3c05294c --- /dev/null +++ b/tb/test_check_timing.py @@ -0,0 +1,41 @@ +import unittest + +from check_timing import METRICS, check_timing + + +def report(value="0.042"): + return "\n".join( + f"Info (332146): Worst-case {metric} slack is {value}" + for metric in sorted(METRICS) + ) + + +class TimingCheckTests(unittest.TestCase): + def test_positive_and_zero_pass(self): + check_timing(report()) + check_timing(report("0.000")) + + def test_each_negative_metric_fails(self): + for metric in METRICS: + with self.subTest(metric=metric), self.assertRaises(ValueError): + check_timing(report().replace(f"{metric} slack is 0.042", f"{metric} slack is -0.016")) + + def test_incomplete_or_unrecognized_report_fails(self): + for text in ("", "Quartus compilation successful", report().split("\n", 1)[1], report("NaN")): + with self.subTest(text=text), self.assertRaises(ValueError): + check_timing(text) + + def test_critical_warning_fails_even_with_positive_summary(self): + with self.assertRaises(ValueError): + check_timing(report() + "\nCritical Warning (332148): Timing requirements not met") + + def test_negative_duplicate_is_not_hidden(self): + with self.assertRaises(ValueError): + check_timing(report("-0.016") + "\n" + report()) + + def test_crlf_report_passes(self): + check_timing(report().replace("\n", "\r\n")) + + +if __name__ == "__main__": + unittest.main() From 92a8f3e235eb5e72152339f9b16d084215c2ffcd Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 10:13:50 +0800 Subject: [PATCH 13/18] docs: clean up Menu guidance and rename README Preserve intentional removal of obsolete planning documents, replace stale references with live source contracts and document local validation and the v2-only native writer requirement. Keep the README's existing CRLF formatting across the rename. --- Readme.md => README.md | 75 +++-- docs/native-video-frontend-brief.md | 226 ------------- docs/native-video-plan.md | 493 ---------------------------- tb/native_video_timing_tb.sv | 2 +- 4 files changed, 46 insertions(+), 750 deletions(-) rename Readme.md => README.md (62%) delete mode 100644 docs/native-video-frontend-brief.md delete mode 100644 docs/native-video-plan.md diff --git a/Readme.md b/README.md similarity index 62% rename from Readme.md rename to README.md index e6c5d4ce..1d77c6db 100644 --- a/Readme.md +++ b/README.md @@ -1,30 +1,45 @@ -# Startup core for MiSTer - -## Native CRT video (this fork) - -This fork drives the analog output with a native 15 kHz signal generated by -the core itself: 352x240p60 (NTSC) by default, with 720x480i60 and 352x288p50 -(PAL) selectable by the ARM-side launcher through a DDR control block (see -`docs/native-video-plan.md`). There are no video options in the OSD — the -mode and the H/V centering trims are owned by the launcher; the core shows -its noise pattern until the launcher publishes frames. - -Note: `forced_scandoubler` (the "Forced scandoubler" MiSTer.ini setting) is -ignored by this core — the analog output is always 15 kHz. If your VGA output -feeds a 31 kHz-only monitor, set `vga_scaler=1` in MiSTer.ini instead. - -* **ESC** - Back/Options -* **Enter** - OK -* **F1** - Cycle Background/Wallpaper -* **F9** - Go to Linux terminal (F12 - back) -* **F11** - Bluetooth Pairing Script -* **F12** - Recent Cores - -## Notes: -* Core supports sub-folders started with _ character. -* Regardless the place of RBF file, boot rom/vhd should be placed into either root of SD card or core's dedicated folder (should be created in root of SD card). -* Joystick (including emulation by keyboard) buttons defined in this core is default map for all cores unless defined in particalar core. - -## Wallpaper -* Place menu.png or menu.jpg to the root of SD card to have it as background on HDMI (you can use vga_scaler=1 if you want it on VGA). -* Create "wallpapers" folder and place multiple .jpg or .png to it. Use **F1** to cycle between standard MiSTer backgrounds and wallpapers in folder. +# Startup core for MiSTer + +## Native CRT video (this fork) + +This fork drives the analog output with a native 15 kHz signal generated by +the core itself: 352x240p60 (NTSC) by default, with 720x480i60 and 352x288p50 +(PAL) selectable by the ARM-side launcher through a DDR control block (see +[DDR contract](rtl/native_video_reader.sv)). There are no video options in the OSD — the +mode and the H/V centering trims are owned by the launcher; the core outputs +black until the launcher publishes frames. + +Native writers must use the v2 magic and publish a live frame counter. The +legacy 320-pixel, no-magic DDR contract is no longer accepted. + +Note: `forced_scandoubler` (the "Forced scandoubler" MiSTer.ini setting) is +ignored by this core — the analog output is always 15 kHz. If your VGA output +feeds a 31 kHz-only monitor, set `vga_scaler=1` in MiSTer.ini instead. + +* **ESC** - Back/Options +* **Enter** - OK +* **F1** - Cycle Background/Wallpaper +* **F9** - Go to Linux terminal (F12 - back) +* **F11** - Bluetooth Pairing Script +* **F12** - Recent Cores + +## RTL tests + +Install Icarus Verilog 12 and GNU coreutils, then run `sh tb/run.sh` from the +repository root. CI runs the same native timing, DDR reader, scanout latch, +and bootstrap-black suites on Ubuntu 24.04. Assertions, compilation failures, +and a 600-second timeout per simulation fail the run. Generated simulators +stay in ignored `test-output/rtl/`. + +Quartus builds must also pass `python3 tb/check_timing.py output_files/menu.sta.rpt`. +CI rejects missing or negative setup, hold, recovery, removal and pulse-width +results, even when Quartus reports successful compilation. + +## Notes: +* Core supports sub-folders started with _ character. +* Regardless the place of RBF file, boot rom/vhd should be placed into either root of SD card or core's dedicated folder (should be created in root of SD card). +* Joystick (including emulation by keyboard) buttons defined in this core is default map for all cores unless defined in particalar core. + +## Wallpaper +* Place menu.png or menu.jpg to the root of SD card to have it as background on HDMI (you can use vga_scaler=1 if you want it on VGA). +* Create "wallpapers" folder and place multiple .jpg or .png to it. Use **F1** to cycle between standard MiSTer backgrounds and wallpapers in folder. diff --git a/docs/native-video-frontend-brief.md b/docs/native-video-frontend-brief.md deleted file mode 100644 index 2ea4604e..00000000 --- a/docs/native-video-frontend-brief.md +++ /dev/null @@ -1,226 +0,0 @@ -# Frontend implementation brief: native CRT video v2 (zaparoo-launcher) - -**Audience:** the zaparoo-launcher team / an implementation agent with no prior -context. This document is self-contained; `docs/native-video-plan.md` (same -repo) has the full background and rationale if you want it. -**Counterpart:** Menu_MiSTer fork, branch `fix/native-video-centering` — the -FPGA side of everything below is implemented, simulated, and pushed. The -launcher work in this brief is the only remaining piece. -**Existing code this modifies:** `src/app/native_video_writer.cpp` and the -`--crt` startup path in zaparoo-launcher (see also its `docs/native-core-poc.md`), -plus `support/zaparoo/alt_launcher.cpp` / `launcher_pages.cpp` in the -Main_MiSTer fork (section 3). - ---- - -## 1. What changed and why you're doing this - -The menu core no longer outputs a 320x240 picture with hand-tuned porches, and -it no longer has any OSD video options. It now generates broadcast-standard -15 kHz timing in three modes, and **everything the launcher used to rely on -the OSD for (CRT mode on/off, H/V centering) now travels through the DDR -control block you already write**. Key consequences for the app: - -- The framebuffer is now **352x240** (not 320x240). 352 px fills a standard - NTSC/PAL active line edge-to-edge; the old 320 was ~10% too narrow on every - correctly calibrated CRT. -- The picture now *overscans* like broadcast TV: the outer few percent of the - framebuffer is cropped on most sets. The UI must adopt safe-area rules - (section 6) — this is as much a part of the fix as the FPGA work. -- The *core-side* CRT enable is gone: the new core has no `status[9]` bit - and no OSD video options. **Publishing frames IS the core's mode switch**: - it shows its noise pattern until your control word goes live and reverts - when you zero it. The *app-level* CRT mode (the `--crt` startup path: - pixel fonts, CRT layout, DDR writer) is unchanged and very much stays — - see section 3 for how it's coordinated now. -- Two new modes exist when you're ready for them: **720x480i60** (mode 1) and - **352x288p50 PAL** (mode 2). The core side is done; you opt in per-frame - via the mode field. - -Backward compatibility is handled on the core side: an old launcher writing -the legacy 320x240 layout still displays (centered with 16-px black side -bars), and your existing fb-geometry validation already self-disables the -writer against an old core. Ship order doesn't matter. - -## 2. DDR contract v2 (normative) - -Physical base `0x3A000000`, mmap **0x300000** (3 MB, up from 640 KB). - -| Offset | Contents | -|---|---| -| `+0x0` | **word0**: `(frame_counter << 2) \| active_buffer`. Bit 1 reserved, write 0. `0` means "writer stopped". | -| `+0x4` | **word1**: `[31:16]` magic `0x5A50` ("ZP"); `[15:8]` h_offset, signed int8, pixels, + = right; `[7:4]` v_offset, signed 4-bit, lines, + = down; `[3:0]` mode | -| `+0x1000` | buffer 0 | -| `+0x180000` | buffer 1 | - -Modes: `0` = 352x240 @ 60p (NTSC, default), `1` = 720x480 @ 60i, -`2` = 352x288 @ 50p (PAL). Stride is always tight (`width * 4` bytes). -Pixel format is unchanged: memcpy linuxfb BGRX rows as-is; the core swaps -bytes in RTL. - -Per-mode framebuffer numbers: - -| Mode | fb size | stride | frame bytes | -|---|---|---|---| -| 0 | 352x240 | 1408 | 0x52800 (337 920) | -| 2 | 352x288 | 1408 | 0x63000 (405 504) | -| 1 | 720x480 | 2880 | 0x151800 (1 382 400) | - -Protocol rules: - -1. **Init:** write word1 (magic + mode + saved offsets) **before** the first - word0 publish. The core reads both words in one atomic 64-bit beat once - per vblank, so word1-then-word0 ordering guarantees the first frame is - interpreted correctly. -2. **Publish:** render into the inactive buffer, then write word0 once with - the incremented counter and that buffer's index (single 32-bit store — - this is the atomic commit). Counter is 30 bits, start at 1. -3. **Mode/offset change at runtime:** update word1 first, then bump word0. - The core latches mode and offsets at the field boundary; modes 0↔1 keep - the same line rate (instant re-lock), 0/1↔2 is a 50↔60 Hz retune (the CRT - takes a moment, like real hardware). -4. **Stop:** zero word0 (zero word1 too for tidiness). The core reverts to - its noise pattern within one frame. This is also your crash-recovery - story — if the launcher dies and the words go stale, the core keeps - scanning the last frame; only a zeroed word0 releases it, so keep the - existing stop-handler behavior. -5. **Offsets:** the core honors **−8…+8 px** horizontal, **−8…+2 lines** - vertical, and clamps anything outside (a garbage word1 degrades to a - saturated shift, never broken sync). Don't rely on the clamp — keep the - calibration UI within those ranges. -6. **480i is rendered progressive:** publish one normal 720x480 frame; the - core extracts fields itself (reads source line `2*line + field`). No - field splitting, no half-frame timing on the ARM side. - -## 3. ARM-side coordination: who turns CRT mode on - -"CRT mode" remains a real mode of the *app*: it decides whether the launcher -renders pixel fonts and CRT layout into the DDR writer (`--crt`) or runs the -normal HDMI/scaler path. The Main_MiSTer fork already owns that decision and -the mechanism survives v2 almost unchanged: - -- **Persisted state:** `config/zaparoo_launcher_crt.bin` (1-byte bool, - written via `FileSaveConfig`). Main reads it when the menu core loads - (`zaparoo_alt_launcher_init_for_menu()` in `support/zaparoo/alt_launcher.cpp`) - and spawns the frontend with or without `--crt`. -- **Toggling:** the OSD "Zaparoo Frontend → Video" page calls - `alt_launcher_toggle_crt()`, which persists the new value, SIGTERMs the - frontend, and respawns it with the new flag. **No Main restart is needed** - — only the frontend process bounces. Keep this; a full Main re-exec is - strictly worse (slower, drops core state) and buys nothing. - -What v2 changes in Main (these are required Main-fork edits, same effort -bucket as Task 1): - -1. `user_io_status_set("[9]", …)` everywhere in `alt_launcher.cpp` is now a - no-op — the new core has no CRT status bit. Delete the writes and the - 500 ms re-assert timer. The frontend publishing word0/word1 *is* the - enable; Main's job shrinks to fb-mode setup, blanking, and spawning. -2. The H/V offset status writes (`[13:10]`/`[17:14]`) are dead too. Offsets - move into DDR word1, which only the frontend writes. Remove the OSD - "H Offset"/"V Offset" entries in `launcher_pages.cpp` and the - `zaparoo_video_offsets.bin` handling; the launcher owns centering now - (section 7). Optional nicety: on first run, the launcher migrates the - two bytes from `config/zaparoo_video_offsets.bin` into its own config so - existing users keep their calibration. -3. `set_native_crt_fb_mode()`: 320x240 stride 1280 → **352x240 stride 1408**. -4. `blank_native_crt_fb()`: region size 0xA0000 → **0x300000**. Under v2, - zeroing the region isn't just ghost-clearing — a zeroed word0 means - "writer stopped", so the blank deterministically parks the core on its - noise pattern until the new frontend instance publishes. - -Open choice (pick during implementation): if the CRT toggle should also live -in the launcher's own settings UI, don't have the launcher restart Main. -Instead: launcher writes `zaparoo_launcher_crt.bin` itself and exits with a -reserved exit code (e.g. 42 = "re-read CRT config and respawn me"); Main's -`alt_launcher_poll()` exit handler treats that code as a respawn-with-reload -instead of `return_to_normal_mode()`. That's a ~10-line Main change and -reuses the existing respawn machinery. The OSD toggle can stay as a second -entry point — both paths converge on the same persisted bool + respawn. - -## 4. Task 1 — Phase A (required): 352x240 writer + safe-area UI - -This is the must-ship piece; modes 1 and 2 are follow-ups. - -1. `--crt` startup sets fb0 to **352x240 32bpp** (the `vmode -r 352 240 - rgb32` equivalent of the current 320x240 setup). Update the fb-geometry - validation to expect 352x240. -2. Update writer constants: width 352, stride 1408, frame size 0x52800, - buffers at `+0x1000` / `+0x180000`, mmap 0x300000. -3. Write word1 on init: magic `0x5A50`, mode 0, offsets from launcher config - (default 0/0). Clear both words on stop. -4. UI safe-area pass (section 6). -5. Calibration screen (section 7). - -Acceptance: on hardware with the new core, the launcher UI fills a CRT -edge-to-edge; killing the launcher returns the noise pattern; a capture -device reports 15.734 kHz / 240p. - -## 5. Tasks 2 & 3 — PAL and 480i (when ready) - -**PAL (mode 2):** add a "video standard: NTSC / PAL" user setting. PAL -renders **352x288** and publishes mode 2. Note most PAL sets accept 60 Hz -RGB over SCART ("PAL-60"), so mode 0 remains a fine default in PAL regions; -mode 2 is for strict-50 Hz sets and correct-speed feel. - -**480i (mode 1):** add a 720x480 rendering path and (optionally) per-screen -mode selection — e.g. main UI in 240p, text-heavy screens in 480i. -Flicker discipline is mandatory (section 6, rule 4). - -## 6. UI rendering rules (apply to every mode) - -These are not suggestions; geometry alone doesn't fix "every CRT crops -differently": - -1. **Render full-bleed.** Background art/color must reach all four edges. - The outer few percent will be cropped on most sets and visible on a few — - both must look intentional. -2. **Safe areas** (SMPTE SD practice): - - *Action safe* (all interactive/meaningful content): central **90%** — - ~317x216 of 352x240, ~317x259 of 352x288, ~648x432 of 720x480. - - *Title safe* (text that must be readable): central **80%** — - ~282x192 / ~282x230 / ~576x384. -3. **Pixel aspect ratio is 10:11** (pixels ~9% narrower than square) in all - three modes. Ignorable for boxes-and-text; correct for logos/art that - must not look squished (a true circle needs ~10% more width in pixels). -4. **480i flicker discipline:** every scanline repaints 30x/second, so 1-px - horizontal lines and fine text shimmer. Use ≥2 px horizontal strokes, - avoid hard 1-px horizontal edges, or apply a mild vertical blur (the - standard console-era 480i dashboard trick). Existing CRT typography rules - in `native-core-poc.md` (integer snapping, bitmap fonts) stay in force. - -## 7. Calibration screen - -The launcher now owns centering (the core's status bits are gone and Main's -OSD offset entries go with them — see section 3, item 2): - -- Draw a border test pattern (240p-test-suite style: 1-px frame at the - extreme edge, rectangles at the 90% and 80% safe areas, cross-hatch). -- Arrow keys nudge h_offset (−8…+8, 1-px steps) and v_offset (−8…+2), - publishing word1 live so the user sees the picture move in real time. -- Persist the values in launcher config; load them at init. Defaults are - zero — the standard timing is the centering mechanism, trims only - compensate for miscentered sets. - -## 8. Verification checklist (frontend-visible items) - -- Fill/centering on **2–3 different CRTs** plus a capture device (should - report 15.734 kHz exactly; 480i should be detected as 480i, not 240p). -- Writer-stop: kill the launcher → noise pattern returns. -- Trim screen: live nudge both axes; values survive a restart; out-of-range - values (if forced) shift-and-saturate without disturbing sync. -- Compat matrix: old launcher + new core → centered 320x240 with side bars; - new launcher + old core → writer self-disables via fb-geometry validation, - core shows noise (obvious, not subtle, breakage). -- 480i: fine horizontal lines should shimmer, not stack (no line pairing). -- HDMI output still locks in every mode (the core's ascal path handles it; - just confirm). - -## 9. Reference - -- FPGA-side spec and rationale: `docs/native-video-plan.md` (Menu_MiSTer). -- RTL that consumes this contract: `rtl/native_video_reader.sv` (the word1 - parse and buffer addresses are the source of truth, with simulation - coverage in `tb/native_video_reader_tb.sv`). -- Current writer: `src/app/native_video_writer.cpp` (zaparoo-launcher). -- Why the scaler is bypassed: `docs/native-core-poc.md` (zaparoo-launcher). diff --git a/docs/native-video-plan.md b/docs/native-video-plan.md deleted file mode 100644 index bba293e0..00000000 --- a/docs/native-video-plan.md +++ /dev/null @@ -1,493 +0,0 @@ -# Native CRT video: findings, recommendations, and implementation plan - -**Status:** proposal — agreed direction, not yet implemented -**Scope:** this core (Menu_MiSTer fork) + `zaparoo-launcher` (the ARM-side writer) -**Date:** 2026-06-11 - -This document explains why the native video output currently only looks right -on the CRT it was calibrated on, what a "standard" 15 kHz signal actually is, -and a phased plan to fix geometry (240p), add PAL (288p50), and add a -high-resolution interlaced mode (480i60). - ---- - -## 1. Current architecture - -The fork replaces the Menu core's noise-pattern video with: - -| Piece | File | Role | -|---|---|---| -| Timing generator | `rtl/native_video_timing.sv` | Produces hsync/vsync/blanking/DE at 15 kHz from a 27.027 MHz clock ÷ 4 | -| DDR reader | `rtl/native_video_reader.sv` | Polls a control word in DDR3 each vblank, streams the active framebuffer line-by-line through a clock-crossing FIFO | -| Wrapper | `rtl/native_video_top.sv` | Wires the two together | -| Mode mux | `menu.sv` | `status[9]` selects noise pattern vs. framebuffer; OSD H/V offset trims | - -ARM side (`zaparoo-launcher/src/app/native_video_writer.cpp`): Qt renders the -UI to `/dev/fb0` (320x240 RGBX8888, set up via `vmode`), and a copy thread -memcpys each frame into one of two DDR buffers, then publishes it: - -``` -0x3A000000 control word: (frame_counter << 2) | active_buffer -0x3A000100 buffer 0: 320x240 RGBX8888, tight stride (1280 B) -0x3A04B100 buffer 1 -mmap region: 0xA0000 (640 KB) -``` - -The FPGA reads the control word at the start of each vblank; when the counter -changes it switches to the published buffer (double buffering, no tearing). -Byte order is swapped in RTL (`output_pixel`) so the app can memcpy linuxfb -BGRX rows without repacking. - -This path deliberately bypasses MiSTer's scaler (`docs/native-core-poc.md` in -zaparoo-launcher): analog output comes straight from the core's `VGA_*` -signals. The framework (`sys/vga_out.sv`) only applies gamma/csync — it does -not retime anything — so **whatever timing this core generates is exactly what -the CRT receives.** The HDMI side is unaffected; ascal rescales any input -timing. - ---- - -## 2. Background: how a CRT decides where and how big the picture is - -A CRT has no concept of pixels or resolutions. Each scanline of the signal is: - -``` - sync pulse → back porch → active video → front porch → next sync - (4.7 µs) (delay) (the picture) (delay) -``` - -The sync pulse is the only positional reference the TV has. The set's -deflection circuitry is factory-adjusted so that the **broadcast-standard** -active region — about **52.7 µs** of the 63.6 µs NTSC line — slightly -*overfills* the visible tube. That deliberate overfill is **overscan**: -typically 3–8% of the picture is cropped at each edge, varying from set to set -and drifting with age. The same applies vertically, measured in scanlines. - -Consequences: - -- If your active video is **shorter than 52.7 µs**, the picture is narrower - than the tube — black side borders that no porch adjustment can remove. -- If your active video starts **later than ~9.4 µs after the sync edge** - (4.7 µs sync + 4.7 µs back porch), the picture sits right of center. -- Because overscan varies per set, anything important drawn near the edges - will be cut off on *some* sets no matter what you do. - -Broadcasters solved the per-set variation problem decades ago: **fill the full -standard active area, and keep important content inside "safe areas"** -(SMPTE SD guidelines: *action safe* = the central 90%, *title safe* = the -central 80%). The picture bleeds past every tube's edges; the content never -does. This is the "safe values" approach this plan adopts. - -For calibration intuition: real consoles (NES/SNES/Genesis) output ~47.7 µs of -active video — about 10% narrower than broadcast — which is why console games -show small side borders on a well-calibrated set. GroovyMAME/Switchres, the -de-facto reference for driving CRTs from emulators, instead generates -modelines that stretch the emulated image across the full 52.7 µs. We follow -the Switchres model. - ---- - -## 3. Findings: the current signal vs. the standard - -Measured from HEAD of `fix/native-video-centering` -(pixel clock = 27.027 MHz ÷ 4 = 6.757 MHz, H total 429 px, V total 262 lines): - -| Parameter | Current (HEAD) | NTSC standard | Verdict | -|---|---|---|---| -| Line rate | **15 750 Hz** | 15 734.26 Hz | Wrong PLL: 27.027 MHz is the 1.001 NTSC factor applied *backwards*. Plain **27.000 MHz** with the same ÷4 and 429-px line gives exactly 15 734.27 Hz (27 000 000 / 1716). The "15.734 kHz" comments in the code are aspirational, not true. | -| Field rate | 60.12 Hz | 59.94–60.05 | Follows from the PLL error. Harmless on CRTs but off-spec. | -| H active | 320 px = **47.4 µs** | **52.66 µs** | ~10% too narrow. This is the "too small" complaint, and it is unfixable by porch tuning. | -| H sync→active delay | 83 px = 12.3 µs | 9.4 µs | For a 47.4 µs-wide image, *centered* would be 12.0 µs — so HEAD is now roughly centered. The original Codex porches (FP/sync/BP = 14/32/63) gave **14.1 µs ≈ 5% right shift** — the "offset right on everyone else's CRT" complaint. | -| V geometry | 240 active; vsync at line 248 (FP 8) | ~241 visible; vsync at line 243 (FP 3) | Picture sits ~5 lines high of standard. | - -History of the H porch split (FP/sync/BP in pixels): - -- `061a888` (Codex original): **14/32/63** — calibrated to one specific CRT, - ~5% right of standard for everyone else. -- `95a5153`: 38/32/39 — overcorrected ~9 px left of centered. -- `3a19e6d` (HEAD): **26/32/51** — within ~2 px of centered *for a 47.4 µs - image*. Centering is now fine; width is not. - -These commits also widened the OSD H offset range to ±16 px in 2-px steps to -chase per-CRT centering. That widening is deliberately reverted by this plan: -once the geometry is standard, the trim is a nicety, and the supported range -goes back to **±8 px in 1-px steps** (carried in the control word, not the -OSD — see section 5.1). - -**Key insight:** the OSD H/V offset options treat the symptom. A 47.4 µs -picture can never fill a tube calibrated for 52.7 µs, and any porch split -that's perfect for one CRT is wrong on the next. The fix is broadcast -geometry (section 4) plus safe-area UI rules (section 6). - -### Other defects found during review - -1. **PAL is silently dropped.** `wire PAL = status[4]` in `menu.sv` is now - dead; the old noise generator honored it. 50 Hz-only CRTs get an NTSC - signal. (Addressed by Phase B below.) -2. **`forced_scandoubler` is ignored.** Users whose VGA output feeds a - 31 kHz-only monitor previously got a doubled signal from the menu core; - now they must set `vga_scaler=1` in MiSTer.ini. Acceptable for a - CRT-targeted fork, but it should be stated in the README. -3. **Reader never falls back when the writer stops.** `stopNativeVideoWriter()` - zeroes the control word, but `frame_ready` stays latched and the core scans - the stale (black) buffer forever instead of reverting to the noise pattern. - `ctrl == 0` should clear `frame_ready`. -4. **FIFO preload is at its safe maximum already.** The reader preloads 2 - lines during vblank then fetches one line per scanline. Note for Phase A: - at the new 176-word line length, preloading a 3rd line would overflow the - 512-word FIFO mid-frame (peak occupancy ~368 words with 2-line preload; - 3-line preload peaks above 512 and `overflow_checking` silently drops - writes). Keep 2 lines, or deepen the FIFO to 1024 if more margin is wanted. -5. Reader timeout paths (`ST_WAIT_CTRL`/`ST_WAIT_LINE` → `ST_IDLE`) also leave - `frame_ready` stale; same fix as (3). - ---- - -## 4. Target timings - -Everything derives from one clock change: CLK_VIDEO goes from 27.027027 MHz -to **27.000000 MHz** — the universal SD video clock (it is exactly 1716 × -NTSC line rate and 1728 × PAL line rate). - -> **Implementation note (found at fit time):** 27.000 MHz cannot come from -> the existing PLL. All outputs of one PLL divide a shared VCO, and -> lcm(100 MHz clk_sys, 27 MHz) = 2700 MHz exceeds the Cyclone V's -> 600–1600 MHz VCO range — 27.027027 (1000 MHz / 37) is precisely the -> closest sharable frequency, which is why stock MiSTer uses it. The fix is -> a dedicated video PLL (`rtl/pll_video.v`, VCO 1350 MHz = 50 × 27, C = 50) -> whose sole output drives CLK_VIDEO; `pll_0002.v` stays stock. - -| Mode | ce_pix | H total | H active / FP / sync / BP (px) | V total | V active / FP / sync / BP (lines) | Line rate | Refresh | -|---|---|---|---|---|---|---|---| -| **0: 240p60** (default) | 27 ÷ 4 = 6.75 MHz | 429 | **352** / 12 / 32 / 33 | 262 | **240** / 3 / 3 / 16 | 15 734.27 Hz | 60.05 Hz | -| **1: 480i60** | 27 ÷ 2 = 13.5 MHz | 858 | **720** / 19 / 62 / 57 | 525 (262+263 fields) | 240 / 4 / 3 / 15–16 per field | 15 734.27 Hz | 59.94 Hz interlaced | -| **2: 288p50** (PAL) | 6.75 MHz | 432 | **352** / 11 / 32 / 37 | 312 | **288** / 3 / 3 / 18 | 15 625.00 Hz | 50.08 Hz | - -Where these numbers come from: - -- **Switchres monitor presets** (`monitor.cpp`, the GroovyMAME engine): - - `ntsc`: 15 734.26 Hz; H porches 1.5 / 4.7 / 4.7 µs; V 3 / 3 / 15 lines. - - `pal`: 15 625 Hz; H porches 1.5 / 4.7 / 5.8 µs. -- **CEA-861 720x480i**: H total 858 @ 13.5 MHz, FP 19 / sync 62 / BP 57. -- **SMPTE 170M**: 63.556 µs line, 10.9 µs blanking, 52.66 µs active. - -The H porch pixel values above are the preset µs values converted at the pixel -clock, nudged by ≤ 0.3 µs so the active width sits centered in the standard -window. Sanity checks: 352+12+32+33 = 429; 352+11+32+37 = 432; 720+19+62+57 = 858. - -Why these active sizes: - -- **352 px @ 6.75 MHz = 52.15 µs ≈ 99% of NTSC standard active width** (and - ~100% of PAL's 52 µs). The picture fills every screen edge-to-edge with - normal overscan crop. 352x240 / 352x288 are standard SIF resolutions; - pixel aspect ratio is the BT.601-classic **10:11** (~9% narrower than - square — fine to ignore for a UI, but stated for completeness). -- **PAL gets 288 active lines, not 240.** PAL tubes show ~288 lines; a - 240-line picture at 50 Hz would be visibly undersized vertically. The app - renders 352x288 in PAL mode. -- **480i uses the CEA-861 numbers verbatim** — the most universally accepted - SD interlaced timing in existence. - -### 480i specifics - -Interlacing is *not* just doubling the line count. The 525-line frame is two -fields of 262 and 263 lines, and the **odd field's vsync must be asserted half -a scanline (429 ce_pix clocks at 13.5 MHz) later** than the even field's. -That half-line offset is what makes the CRT draw the second field's lines -*between* the first field's — without it both fields land on the same -scanlines ("line pairing") and you get 240p with combing. - -MiSTer framework support is already there: - -- `VGA_F1` (field number) is a standard core output — currently hardwired to - `0` in `menu.sv`. It must toggle per field in 480i. -- `sys/sys_top.v` wires `VGA_F1` → ascal's `i_fl`; ascal auto-detects - interlace and deinterlaces for HDMI, so HDMI users keep working. -- The analog path passes core sync through untouched; csync generation in - `sys_top.v` handles interlaced cores today (PSX, Saturn, Genesis all output - real 480i this way). -- Reference implementation for the half-line trick: - `MiSTer-devel/PSX_MiSTer rtl/gpu_videoout_async.vhd` (search "half line - later"). - ---- - -## 5. DDR contract v2 - -Designed now so Phases A–C don't break each other or deployed frontends. -Versioned via a magic value; layout sized for the largest mode: - -``` -0x3A000000 word0: (frame_counter << 2) | active_buffer (unchanged) -0x3A000004 word1: [31:16] magic 0x5A50 ("ZP") - [15:8] h_offset, signed, pixels (+ = right; core honors −8…+8) - [7:4] v_offset, signed, lines (+ = down; core honors −8…+2) - [3:0] mode: 0 = 352x240 @ 60p (NTSC) - 1 = 720x480 @ 60i - 2 = 352x288 @ 50p (PAL) -0x3A001000 buffer 0 (page-aligned; sized for max mode: 720*480*4 = 1.35 MB) -0x3A180000 buffer 1 -mmap region: 0x300000 (3 MB) -stride: always tight, width * 4 bytes -``` - -Design points: - -- The reader already fetches the control word as one 64-bit DDR beat and - discards the top half — **word1 costs nothing extra to read**. One read per - vblank picks up frame counter, buffer index, mode, and offset trims - atomically. -- **The control block replaces every OSD video option** (see section 5.1): - there is no CRT-mode toggle and no OSD offset menu. A valid magic plus a - changing frame counter *is* the mode signal — the core shows the noise - pattern until the launcher publishes frames and reverts when word0 clears. - Offsets come from word1 and are owned by a calibration screen in the - launcher. -- Offsets and mode cross from the DDR clock domain into the video timing - domain as quasi-static values: two-flop synchronize and latch them at the - frame boundary (`new_frame`) so a mid-frame update can't corrupt sync. RTL - clamps offsets to the porch budget of the active mode (effective FP/BP - never < 2 px / 1 line), so a buggy or out-of-range value degrades to a - saturated shift, never a broken signal. -- **Legacy compatibility:** if word1 has no magic, the core treats the region - as today's layout (320x240 buffers at +0x100 / +0x4B100) and scans it - centered in the 352-px active area with black side bars (16 px each side). - An already-deployed launcher keeps working against the new core; the new - launcher's fb-geometry validation already self-disables cleanly against an - old core. No flag day. -- Mode changes apply at frame boundaries. Modes 0↔1 keep the same line rate, - so the CRT re-locks almost instantly; switching to/from PAL is a bigger - retune (50↔60 Hz) and takes a moment, as on real hardware. -- Address-space safety: MiSTer reserves 0x20000000+ of DDR for the FPGA side; - 0x30000000–0x3FFFFFFF is core-owned and the menu core uses none of it - elsewhere. The 3 MB region at 0x3A000000 conflicts with nothing (the - framework scaler framebuffers live at 0x20000000+). -- In 480i the FPGA reads source line `vcount*2 + field`, so the app renders - one normal progressive 720x480 frame — no field-splitting on the ARM side. - -DDR bandwidth is a non-issue: worst case (480i) is 720×480×4 B × 60 ≈ 80 MB/s -of sequential bursts against a multi-GB/s DDR3 port that nothing else in the -menu core touches. - -### 5.1 Removing the OSD video options entirely - -Question raised during review: can the "Video" section / second OSD page go -away, with the CRT mode toggle and the H/V offset trims moving into the ARM -launcher? **Yes — and it simplifies the core.** Every OSD video option maps -onto something the v2 control block already carries: - -| OSD option today | Replacement | -|---|---| -| CRT/native mode toggle (`status[9]`) | Implicit: valid magic + advancing frame counter in the control block ⇒ native scanout; word0 = 0 or stale ⇒ noise pattern. The launcher "turns on CRT mode" simply by publishing frames. `status[9]` and its CONF_STR entry are deleted. | -| H Offset list (`status[13:10]`) | `word1[15:8]` signed pixel trim (−8…+8, 1-px steps), set from a calibration screen in the launcher (arrow keys, live preview), persisted in the launcher's own config. | -| V Offset list (`status[17:14]`) | `word1[7:4]` signed line trim (−8…+2), same screen. | - -`CONF_STR` shrinks back to the stock menu core entry -(`"MENU;UART31250,MIDI;-;V,v"` + build date): one page, no Video section. The -core stops using `status[]` for video entirely. - -Why this is the right direction, beyond decluttering: - -- **One contract, one owner.** Mode and trims live next to the frames they - describe, set by the same process that renders them, read atomically in the - same 64-bit beat. No second control path through hps_io status bits. -- **Better calibration UX.** The launcher can draw a border test pattern - *while* the user nudges offsets — the OSD lists couldn't show the effect on - a full-bleed image, and 16-entry enum lists are a clumsy way to express - "nudge left a bit". Per-device persistence lives with the rest of the - launcher's config instead of MiSTer's core-config blob. -- **Fewer moving parts in RTL.** The offset inputs move from - `status`-decoding in `menu.sv` to the reader's already-synchronized control - parse; the OSD enum↔signed-value mapping tricks disappear. - -Trade-offs / notes: - -- A user running the **legacy (pre-v2) launcher** gets no trims (word1 absent - → offsets = 0). Acceptable: the new default timing is standard, trims are a - nicety, and legacy mode is compat-only. -- If the framebuffer path is off (noise pattern), there is nothing to - calibrate against — also fine, calibration belongs in the app. -- The MiSTer OSD overlay itself (main menu, file browser) is untouched; this - removes only the core's *option entries*, not the OSD. - -**Rejected alternative:** having the ARM app poke core status bits through a -patched Main_MiSTer (the Zaparoo_MiSTer fork could add a command for it). -Works, but spreads the video contract across three codebases and a Main fork -that must track upstream, for zero functional gain over the DDR words the -core already reads every vblank. - ---- - -## 6. App-side rules (zaparoo-launcher) - -These are as much a part of the fix as the RTL — geometry alone doesn't solve -"every CRT crops differently": - -1. **Render full-bleed.** Background art/color must reach all four edges of - the framebuffer; the outer few percent will be cropped on most sets and - visible on a few. -2. **Safe areas** (SMPTE SD guidelines): - - All interactive/meaningful content inside the central **90%** - (*action safe*: ~317x216 of 352x240, ~317x259 of 352x288, ~648x432 of - 720x480). - - Text you must be able to read inside the central **80%** - (*title safe*: ~282x192 / ~282x230 / ~576x384). -3. **Pixel aspect ratio is 10:11** (pixels slightly narrower than square) in - all three modes. A perfect circle needs ~10% more width in pixels. Safe to - ignore for boxes-and-text UI; matters if rendering logos/art that must not - look squished. -4. **480i flicker discipline:** every scanline is repainted 30 times/second, - so 1-px horizontal lines and fine text shimmer. Use ≥2 px horizontal - strokes, avoid hard 1-px horizontal edges, or apply a mild vertical blur - (the standard trick in console-era 480i dashboards). The existing CRT - typography rules in `docs/native-core-poc.md` (integer snapping, bitmap - fonts) stay in force. -5. **Own the centering trims** (section 5.1): a calibration screen that draws - an edge/border test pattern and lets the user nudge H/V offsets with live - preview, publishing them via control word1 and persisting them in the - launcher config. Defaults are zero — the standard timing is the centering - mechanism; trims only compensate for miscentered sets. - ---- - -## 7. Implementation plan - -### Phase A — broadcast-geometry 240p (the main fix) - -FPGA (this repo): - -1. ~~`rtl/pll/pll_0002.v`: `output_clock_frequency1` 27.027027 MHz → - `27.000000 MHz`.~~ Superseded: the shared PLL cannot fit 27.000 MHz (see - the implementation note in §4). Instead `pll_0002.v` stays stock and a - new dedicated `rtl/pll_video.v` (+ `rtl/pll_video/pll_video_0002.v`, - `rtl/pll_video.qip`) generates CLK_VIDEO = 27.000000 MHz; menu.sv holds - the native video path in reset until it locks. -2. **`rtl/native_video_timing.sv`**: mode-0 constants — H 352/12/32/33, - V 240/3/3/16. Structure the constants as per-mode parameter sets selected - by a `mode` input (tied to 0 until Phases B/C) so later modes are additive. - Offset budgets change: positive H offset eats the now-small 12-px front - porch. **Trim range is deliberately reverted to ±8 px H, 1-px steps** - (this branch had widened it to ±16 in 2-px steps while the porches were - the centering mechanism — with broadcast-fill geometry the trim is a - nicety, and ±8 px ≈ ±1.2 µs is plenty). V range −8…+2 lines. RTL clamps - to these ranges and additionally never lets effective FP/BP drop below - 2 px / 1 line, so out-of-range word1 values saturate instead of breaking - sync. -3. **`rtl/native_video_reader.sv`**: - - Parse word1 (`ddr_dout[63:32]`) in `ST_WAIT_CTRL`: magic present → v2 - layout (buffers at word addresses 0x07400200 / 0x07430000, line burst - 176 words) and extract mode + h/v offsets; absent → legacy layout - (0x07400020 / 0x07409620, 160 words, offsets 0) displayed centered with - 16-px black bars (needs `hcount` from the timing module, already - exported but unconnected). Forward the synchronized offsets to the - timing module, latched at `new_frame`. - - `word0 == 0` → clear `frame_ready` and `first_frame_loaded` → core - reverts to the noise pattern (fixes defects 3/5 in section 3). - - Keep the 2-line preload (see defect 4 — it's already at the FIFO's safe - maximum); optionally deepen the FIFO to 1024 words for margin. -4. **`menu.sv`**: remove all video options from `CONF_STR` (back to the stock - `"MENU;UART31250,MIDI;-;V,v"` + build date — one OSD page, no Video - section); delete `status[9]` / `status[17:10]` decoding and the offset - wiring from `hps_io` (offsets now arrive via the reader's control parse, - section 5.1); correct the stale "15.734 kHz" comments (true again after - the PLL fix); README note about `forced_scandoubler`/`vga_scaler`. -5. **Testbench before synthesis** (see section 8). - -Frontend (`zaparoo-launcher`): - -6. `--crt` path sets fb0 to **352x240** 32bpp (`vmode -r 352 240 rgb32` - equivalent); writer constants: width 352, stride 1408, frame size 0x52800, - buffers at +0x1000 / +0x180000, region 0x300000; write magic + mode + - offset word on init (offsets from saved config, default 0) and clear both - words on stop. -7. UI safe-area pass per section 6; calibration screen for the H/V trims - (border test pattern + arrow-key nudge within ±8 px / −8…+2 lines, - persisted in launcher config). -8. Release coordination: core's legacy mode covers old-launcher/new-core; the - launcher's existing fb-geometry validation covers new-launcher/old-core - (writer disables itself, core shows noise — obvious, not subtle breakage). - -### Phase B — PAL 288p50 - -9. Timing mode 2: H total 432 (352/11/32/37), V total 312 (288/3/3/18). - Same 6.75 MHz clock; line rate exactly 15 625 Hz. -10. Reader: 288-line frame, same stride; fits existing buffer slots - (352×288×4 = 396 KB < 1.35 MB slot). -11. Launcher: a "video standard: NTSC / PAL" user setting → renders 352x288 - and publishes mode 2. PAL sets that accept 60 Hz RGB ("PAL-60", most of - them via SCART) can simply stay on mode 0; mode 2 is for strict-50 Hz - sets and correct-speed feel in PAL regions. - -### Phase C — 480i60 (after A/B verified on real CRTs) - -12. Timing mode 1: ce_pix ÷2 (13.5 MHz); H 858 total (720/19/62/57); 525-line - dual-field vertical counter; **half-line (429-clock) vsync offset on the - odd field**; field bit out → `VGA_F1` (replace the hardwired 0 in - `menu.sv`). -13. Reader: source line = `vcount*2 + field`; 720 px = 360 DDR words/line - exceeds the 8-bit burst counter, so fetch each line as **2×180-beat - bursts**; FIFO sizing: 360-word lines × 2-line preload = 720 words → - deepen FIFO to 1024. -14. Launcher: 720x480 rendering path; per-screen mode selection (e.g. launcher - UI in 240p, text-heavy screens in 480i); flicker styling per section 6. - -### Explicitly out of scope / rejected - -- **Using MiSTer's scaler framebuffer instead** — already rejected by the - project (`native-core-poc.md`): the whole point is core-owned, low-latency, - exact 15 kHz output. -- **31 kHz / 480p output** for VGA PC monitors — different audience; the - framework's `vga_scaler=1` path already serves it. -- **Changing the pixel clock to stretch 320 px across 52.7 µs** (the literal - Switchres approach, ~6.1 MHz dot clock) — works, but leaves the 27 MHz - family for no benefit; widening the framebuffer is cleaner on every axis. - ---- - -## 8. Verification - -1. **Simulation first** (no Quartus needed): a small testbench on - `native_video_timing` that measures, in µs/lines against section 4's table: - line period, sync width, sync→active delay, active width, frame period — - and for 480i: field alternation, the half-line vsync offset, and total - 525 lines/frame. This is cheap and catches every off-by-one that matters. -2. **CI build** (existing GitHub Actions Quartus workflow) for timing closure - and resource sanity. -3. **Hardware checklist** (per phase): - - Launcher renders a cross-hatch + border test pattern (240p-test-suite - style: 1-px frame at the extreme edge, safe-area rectangles at 90%/80%). - - Verify fill/centering on **at least 2–3 different CRTs** plus a capture - device (OSSC/RetroTINK profile or capture card reporting measured line - rate — should read 15.734 kHz exactly after the PLL fix). - - Legacy-compat check: old launcher against new core → centered 320x240 - with side bars. - - Writer-stop check: kill the launcher → noise pattern returns. - - Trim check: launcher calibration screen nudges the picture live in both - axes; values survive a launcher restart; out-of-range word1 values - saturate without disturbing sync. - - OSD check: core options reduced to a single page (no Video section); - the OSD overlay itself still renders and is usable in every mode. - - HDMI side still locks (ascal) in every mode. - - 480i: confirm real interlacing (no line pairing) — fine horizontal lines - should shimmer, not stack; capture device should report 480i, not 240p. - ---- - -## 9. References - -- Switchres monitor presets (GroovyMAME): - `github.com/antonioginer/switchres` `monitor.cpp` — `ntsc`, `pal`, - `arcade_15` ranges (porch values in µs/ms). -- SMPTE 170M / standard NTSC line structure: 63.556 µs line, 10.9 µs - blanking, 52.66 µs active, 9.4 µs sync→active. -- CEA-861 720x480i timing: 858/19/62/57 @ 13.5 MHz, 525 lines. -- SMPTE safe areas (SD practice): action safe 90%, title safe 80% - (HD-era ST 2046-1 relaxed these to 93%/90% — use the SD numbers for - consumer CRTs). -- PSX_MiSTer `rtl/gpu_videoout_async.vhd` — half-line vsync offset reference. -- MiSTer framework: `sys/sys_top.v` (`VGA_F1` → ascal `i_fl`; csync), - `sys/vga_out.sv` (analog path is timing-transparent). -- ARM writer: `zaparoo-launcher/src/app/native_video_writer.cpp`, - `zaparoo-launcher/docs/native-core-poc.md`. -- Pixel aspect ratio / SIF background: BT.601 (704x480 → PAR 10:11; 352x240 - inherits it). diff --git a/tb/native_video_timing_tb.sv b/tb/native_video_timing_tb.sv index dd174ff6..e1ec8ecc 100644 --- a/tb/native_video_timing_tb.sv +++ b/tb/native_video_timing_tb.sv @@ -1,4 +1,4 @@ -// Self-checking testbench for native_video_timing (docs/native-video-plan.md §8). +// Self-checking testbench for rtl/native_video_timing.sv. // // Measures, in exact ce_pix ticks, per mode: line period, hsync width, // sync→active delay, active width, field period, vsync width, active lines From 3fc67721bfdf3aa03e6e46d6632b48ed97ca16fe Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 10:48:30 +0800 Subject: [PATCH 14/18] fix: restore upstream snow cadence behind stock OSD Reuse the stock noise source and grayscale formula while gating the stretched native frame pulse on pixel enable. Keep black during handoff and give native frontend RGB priority. Cover cadence, phase wrap and background selection in RTL tests. The Menu-only candidate passed simulation and timing, then live visual acceptance without rebooting or restarting Core. Preserve the original README CRLF representation across its earlier rename. --- .github/workflows/ci_build.yml | 2 +- README.md | 91 +++++++++++++++++----------------- files.qip | 1 + menu.sv | 45 +++++++++++++++-- rtl/zaparoo_bootstrap_video.sv | 7 ++- rtl/zaparoo_snow_phase.sv | 18 +++++++ tb/bootstrap_video_tb.sv | 12 ++++- tb/run.sh | 2 + tb/snow_phase_tb.sv | 44 ++++++++++++++++ 9 files changed, 169 insertions(+), 53 deletions(-) create mode 100644 rtl/zaparoo_snow_phase.sv create mode 100644 tb/snow_phase_tb.sv diff --git a/.github/workflows/ci_build.yml b/.github/workflows/ci_build.yml index cdd70c35..0beca842 100644 --- a/.github/workflows/ci_build.yml +++ b/.github/workflows/ci_build.yml @@ -11,7 +11,7 @@ permissions: jobs: rtl-tests: runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index 1d77c6db..808defb5 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,46 @@ -# Startup core for MiSTer - -## Native CRT video (this fork) - -This fork drives the analog output with a native 15 kHz signal generated by -the core itself: 352x240p60 (NTSC) by default, with 720x480i60 and 352x288p50 -(PAL) selectable by the ARM-side launcher through a DDR control block (see -[DDR contract](rtl/native_video_reader.sv)). There are no video options in the OSD — the -mode and the H/V centering trims are owned by the launcher; the core outputs -black until the launcher publishes frames. - -Native writers must use the v2 magic and publish a live frame counter. The -legacy 320-pixel, no-magic DDR contract is no longer accepted. - -Note: `forced_scandoubler` (the "Forced scandoubler" MiSTer.ini setting) is -ignored by this core — the analog output is always 15 kHz. If your VGA output -feeds a 31 kHz-only monitor, set `vga_scaler=1` in MiSTer.ini instead. - -* **ESC** - Back/Options -* **Enter** - OK -* **F1** - Cycle Background/Wallpaper -* **F9** - Go to Linux terminal (F12 - back) -* **F11** - Bluetooth Pairing Script -* **F12** - Recent Cores - -## RTL tests - -Install Icarus Verilog 12 and GNU coreutils, then run `sh tb/run.sh` from the -repository root. CI runs the same native timing, DDR reader, scanout latch, -and bootstrap-black suites on Ubuntu 24.04. Assertions, compilation failures, -and a 600-second timeout per simulation fail the run. Generated simulators -stay in ignored `test-output/rtl/`. - -Quartus builds must also pass `python3 tb/check_timing.py output_files/menu.sta.rpt`. -CI rejects missing or negative setup, hold, recovery, removal and pulse-width -results, even when Quartus reports successful compilation. - -## Notes: -* Core supports sub-folders started with _ character. -* Regardless the place of RBF file, boot rom/vhd should be placed into either root of SD card or core's dedicated folder (should be created in root of SD card). -* Joystick (including emulation by keyboard) buttons defined in this core is default map for all cores unless defined in particalar core. - -## Wallpaper -* Place menu.png or menu.jpg to the root of SD card to have it as background on HDMI (you can use vga_scaler=1 if you want it on VGA). -* Create "wallpapers" folder and place multiple .jpg or .png to it. Use **F1** to cycle between standard MiSTer backgrounds and wallpapers in folder. +# Startup core for MiSTer + +## Native CRT video (this fork) + +This fork drives the analog output with a native 15 kHz signal generated by +the core itself: 352x240p60 (NTSC) by default, with 720x480i60 and 352x288p50 +(PAL) selectable by the ARM-side launcher through a DDR control block (see +[DDR contract](rtl/native_video_reader.sv)). There are no video options in the OSD — the +mode and the H/V centering trims are owned by the launcher. Idle video stays +black during frontend handoff; upstream-style snow appears behind the stock +OSD. Native frontend frames take priority over both backgrounds. + +Native writers must use the v2 magic and publish a live frame counter. The +legacy 320-pixel, no-magic DDR contract is no longer accepted. + +Note: `forced_scandoubler` (the "Forced scandoubler" MiSTer.ini setting) is +ignored by this core — the analog output is always 15 kHz. If your VGA output +feeds a 31 kHz-only monitor, set `vga_scaler=1` in MiSTer.ini instead. + +* **ESC** - Back/Options +* **Enter** - OK +* **F1** - Cycle Background/Wallpaper +* **F9** - Go to Linux terminal (F12 - back) +* **F11** - Bluetooth Pairing Script +* **F12** - Recent Cores + +## RTL tests + +Install Icarus Verilog 12 and GNU coreutils, then run `sh tb/run.sh` from the +repository root. CI runs the same native timing, DDR reader, scanout latch, +snow cadence and bootstrap-black suites on Ubuntu 24.04. Assertions, compilation failures, +and a 600-second timeout per simulation fail the run. Generated simulators +stay in ignored `test-output/rtl/`. + +Quartus builds must also pass `python3 tb/check_timing.py output_files/menu.sta.rpt`. +CI rejects missing or negative setup, hold, recovery, removal and pulse-width +results, even when Quartus reports successful compilation. + +## Notes: +* Core supports sub-folders started with _ character. +* Regardless the place of RBF file, boot rom/vhd should be placed into either root of SD card or core's dedicated folder (should be created in root of SD card). +* Joystick (including emulation by keyboard) buttons defined in this core is default map for all cores unless defined in particalar core. + +## Wallpaper +* Place menu.png or menu.jpg to the root of SD card to have it as background on HDMI (you can use vga_scaler=1 if you want it on VGA). +* Create "wallpapers" folder and place multiple .jpg or .png to it. Use **F1** to cycle between standard MiSTer backgrounds and wallpapers in folder. diff --git a/files.qip b/files.qip index e5ed8ab4..9d2f59a1 100644 --- a/files.qip +++ b/files.qip @@ -11,3 +11,4 @@ set_global_assignment -name SYSTEMVERILOG_FILE sys/mister_magik_vblank_latch.sv set_global_assignment -name SYSTEMVERILOG_FILE sys/mister_magik_latch_sys_top_bridge.sv set_global_assignment -name SYSTEMVERILOG_FILE sys/mister_magik_bootstrap_black.sv set_global_assignment -name SYSTEMVERILOG_FILE rtl/zaparoo_bootstrap_video.sv +set_global_assignment -name SYSTEMVERILOG_FILE rtl/zaparoo_snow_phase.sv diff --git a/menu.sv b/menu.sv index 1a6025ea..f41855cf 100644 --- a/menu.sv +++ b/menu.sv @@ -497,6 +497,8 @@ wire [7:0] native_b; wire native_hs; wire native_vs; wire native_de; +wire [8:0] native_vcount; +wire native_new_frame; wire native_field; wire native_active; @@ -525,18 +527,53 @@ native_video_top native_video .vga_de (native_de), .vga_hblank (), .vga_vblank (), - .vga_vcount (), - .vga_new_frame (), + .vga_vcount (native_vcount), + .vga_new_frame (native_new_frame), .vga_mode (native_mode), .vga_field (native_field), .active (native_active) ); -// Black idle source keeps timing and downstream OSD alive until either the -// HDMI latch or the native CRT writer supplies a frame. +// Keep upstream's asynchronous noise source and grayscale weighting. Only +// the frame-phase enable differs: native timing stretches new_frame over +// several video clocks, so it must be sampled on ce_pix, not every clock. +wire [62:0] snow_random; +reg [2:0] snow_sample = 0; +wire [9:0] snow_phase; +wire [7:0] snow_cos; +wire [5:0] snow_level = {1'b0, snow_cos[7:3]} + 6'd32; +wire [5:0] snow_noise = {snow_sample[0], snow_sample[1], {4{snow_sample[2]}}}; +wire [7:0] snow_pixel = (snow_level >= snow_noise) ? + {snow_level - snow_noise, 2'b00} : 8'd0; + +lfsr #(.N(63)) snow_source(snow_random); +cos snow_wave(snow_phase, snow_cos); +zaparoo_snow_phase snow_motion ( + .clk(CLK_VIDEO), .reset(RESET | ~vid_locked), .ce_pix(ce_pix), + .new_frame(native_new_frame), .vcount(native_vcount), .phase(snow_phase) +); + +// OSD status comes from the HDMI domain. Main disables OSD before handing +// video to the frontend, so startup stays black without a new bus command. +(* altera_attribute = "-name SYNCHRONIZER_IDENTIFICATION FORCED_IF_ASYNCHRONOUS" *) +reg [1:0] snow_osd_sync = 0; +always @(posedge CLK_VIDEO) begin + if (RESET | ~vid_locked) begin + snow_sample <= 0; + snow_osd_sync <= 0; + end else begin + if (ce_pix) snow_sample <= snow_random[2:0]; + snow_osd_sync <= {snow_osd_sync[0], OSD_STATUS}; + end +end + +// Black handoff, snow behind stock OSD, or native frontend RGB. All three +// share the same native sync/DE; no video-mode switch is needed. zaparoo_bootstrap_video bootstrap_video ( .native_active(native_active), .native_rgb({native_r, native_g, native_b}), + .show_snow(snow_osd_sync[1]), + .snow_rgb({snow_pixel, snow_pixel, snow_pixel}), .de_in(native_de), .hs_in(native_hs), .vs_in(native_vs), .rgb_out({VGA_R, VGA_G, VGA_B}), .de_out(VGA_DE), .hs_out(VGA_HS), .vs_out(VGA_VS) diff --git a/rtl/zaparoo_bootstrap_video.sv b/rtl/zaparoo_bootstrap_video.sv index 083c87ae..117137fb 100644 --- a/rtl/zaparoo_bootstrap_video.sv +++ b/rtl/zaparoo_bootstrap_video.sv @@ -1,9 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// Preserve native CRT frames; only the idle Menu source is bootstrap black. +// Native CRT frames take priority; idle stays black except behind stock OSD. `timescale 1ns/1ps module zaparoo_bootstrap_video ( input wire native_active, input wire [23:0] native_rgb, + input wire show_snow, + input wire [23:0] snow_rgb, input wire de_in, hs_in, vs_in, output wire [23:0] rgb_out, output wire de_out, hs_out, vs_out @@ -13,5 +15,6 @@ module zaparoo_bootstrap_video ( .rgb_in(native_rgb), .de_in(de_in), .hs_in(hs_in), .vs_in(vs_in), .rgb_out(black_rgb), .de_out(de_out), .hs_out(hs_out), .vs_out(vs_out) ); - assign rgb_out = native_active ? native_rgb : black_rgb; + assign rgb_out = native_active ? native_rgb : + (show_snow && de_in) ? snow_rgb : black_rgb; endmodule diff --git a/rtl/zaparoo_snow_phase.sv b/rtl/zaparoo_snow_phase.sv new file mode 100644 index 00000000..725d2836 --- /dev/null +++ b/rtl/zaparoo_snow_phase.sv @@ -0,0 +1,18 @@ +`timescale 1ns/1ps +// Upstream Menu advances its cosine phase by six once per displayed frame. +// native_video_timing holds new_frame until the next pixel-enable edge. +module zaparoo_snow_phase ( + input wire clk, + input wire reset, + input wire ce_pix, + input wire new_frame, + input wire [8:0] vcount, + output wire [9:0] phase +); + reg [9:0] frame_phase = 0; + always @(posedge clk) begin + if (reset) frame_phase <= 0; + else if (ce_pix && new_frame) frame_phase <= frame_phase + 10'd6; + end + assign phase = frame_phase + {vcount[7:0], 2'b00}; +endmodule diff --git a/tb/bootstrap_video_tb.sv b/tb/bootstrap_video_tb.sv index ce9fbfc2..3486fad8 100644 --- a/tb/bootstrap_video_tb.sv +++ b/tb/bootstrap_video_tb.sv @@ -3,6 +3,8 @@ module bootstrap_video_tb; reg native_active = 0; reg [23:0] native_rgb = 0; + reg show_snow = 0; + reg [23:0] snow_rgb = 24'habcdef; reg de_in = 0, hs_in = 0, vs_in = 0; wire [23:0] rgb_out; wire de_out, hs_out, vs_out; @@ -26,7 +28,15 @@ module bootstrap_video_tb; end native_active = 0; #1; assert(rgb_out == 0) else $fatal(1, "writer stop did not restore black"); - $display("PASS: idle black, native CRT passthrough, timing and writer stop"); + show_snow = 1; de_in = 1; #1; + assert(rgb_out == snow_rgb) else $fatal(1, "OSD did not reveal snow"); + de_in = 0; #1; + assert(rgb_out == 0) else $fatal(1, "snow painted outside DE"); + native_active = 1; #1; + assert(rgb_out == native_rgb) else $fatal(1, "snow covered native frontend"); + native_active = 0; show_snow = 0; de_in = 1; #1; + assert(rgb_out == 0) else $fatal(1, "OSD close did not restore black"); + $display("PASS: black handoff, OSD snow, native CRT priority and timing"); $finish; end endmodule diff --git a/tb/run.sh b/tb/run.sh index 07e24e3f..0a0c0bd8 100755 --- a/tb/run.sh +++ b/tb/run.sh @@ -17,6 +17,8 @@ run_test bootstrap_video_tb \ ../sys/mister_magik_bootstrap_black.sv ../rtl/zaparoo_bootstrap_video.sv \ bootstrap_video_tb.sv +run_test snow_phase_tb ../rtl/zaparoo_snow_phase.sv snow_phase_tb.sv + run_test scanout_tb \ ../sys/mister_magik_vblank_latch.sv ../sys/mister_magik_latch_sys_top_bridge.sv \ scanout_tb.sv diff --git a/tb/snow_phase_tb.sv b/tb/snow_phase_tb.sv new file mode 100644 index 00000000..3763b056 --- /dev/null +++ b/tb/snow_phase_tb.sv @@ -0,0 +1,44 @@ +`timescale 1ns/1ps +module snow_phase_tb; + reg clk = 0; + always #5 clk = ~clk; + reg reset = 1, ce_pix = 0, new_frame = 0; + reg [8:0] vcount = 0; + wire [9:0] phase; + zaparoo_snow_phase dut (.*); + + task automatic field_pulse(input integer clocks_per_pixel); + for (integer i = 0; i < clocks_per_pixel; i = i + 1) begin + @(negedge clk); + new_frame = 1; + ce_pix = (i == clocks_per_pixel - 1); + @(posedge clk); #1; + end + @(negedge clk); new_frame = 0; ce_pix = 0; + endtask + + initial begin + @(posedge clk); #1; + assert(phase == 0) else $fatal(1, "phase not reset"); + @(negedge clk); reset = 0; + field_pulse(4); + assert(phase == 6) else $fatal(1, "240p frame advanced more than once"); + field_pulse(2); + assert(phase == 12) else $fatal(1, "480i field advanced more than once"); + repeat (4) begin @(negedge clk); ce_pix = 1; end + @(posedge clk); #1; + assert(phase == 12) else $fatal(1, "phase advanced without frame edge"); + vcount = 9'd1; #1; + assert(phase == 16) else $fatal(1, "wrong upstream line phase"); + vcount = 9'd256; #1; + assert(phase == 12) else $fatal(1, "line phase failed to wrap"); + vcount = 0; + for (integer i = 0; i < 170; i = i + 1) field_pulse(4); + assert(phase == 8) else $fatal(1, "frame phase failed to wrap"); + $display("PASS: upstream snow cadence, 240p/480i enable and phase wrap"); + $finish; + end + initial begin + #20000; $fatal(1, "snow phase simulation timeout"); + end +endmodule From fa8e7652c075307fd912a811eaff286f2eacbbdb Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 12:44:14 +0800 Subject: [PATCH 15/18] ci: pin actions and disable checkout credentials --- .github/workflows/ci_build.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci_build.yml b/.github/workflows/ci_build.yml index 999f4e77..c4f7b30e 100644 --- a/.github/workflows/ci_build.yml +++ b/.github/workflows/ci_build.yml @@ -14,7 +14,9 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Install RTL simulator run: | @@ -31,7 +33,9 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 120 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Install kernel build tools run: | @@ -42,7 +46,7 @@ jobs: rustup toolchain install 1.95.0 --profile minimal --no-self-update - name: Cache verified compiler archive - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: kernel/.build/ci/gcc-arm-10.2-2020.11-x86_64-arm-none-linux-gnueabihf.tar.xz key: scanout-gcc-102825ae56c9e00142d06f35d2bdd3299edb6060e84a275a25b095e66fd3fc2a @@ -51,7 +55,7 @@ jobs: run: bash kernel/build-scanout.sh - name: Upload scanout module - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: zaparoo-scanout-6.18.38-MiSTer path: kernel/scanout-slots/zaparoo_scanout.ko @@ -61,7 +65,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Build with Quartus Lite run: | @@ -78,7 +84,7 @@ jobs: python3 -B tb/check_timing.py output_files/menu.sta.rpt - name: Upload RBF - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: menu-rbf path: output_files/menu.rbf From 85cdbe3bb78f04db21abb6a9fc9069a0b053dc3e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 12:48:17 +0800 Subject: [PATCH 16/18] fix: check running kernel release before scanout mapping --- kernel/scanout-slots/zaparoo_scanout.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kernel/scanout-slots/zaparoo_scanout.c b/kernel/scanout-slots/zaparoo_scanout.c index 1a9f4e26..eac3033f 100644 --- a/kernel/scanout-slots/zaparoo_scanout.c +++ b/kernel/scanout-slots/zaparoo_scanout.c @@ -16,7 +16,7 @@ #include #include #include -#include +#include #include "zaparoo_scanout_platform.h" #include "zaparoo_scanout_uapi.h" @@ -54,7 +54,7 @@ static int validate_platform(void) const __be32 *cells; int len, ret = -ENODEV; - if (strcmp(UTS_RELEASE, ZAPAROO_SCANOUT_KERNEL_RELEASE) || + if (strcmp(utsname()->release, ZAPAROO_SCANOUT_KERNEL_RELEASE) || !of_machine_is_compatible(ZAPAROO_SCANOUT_MACHINE)) return -ENODEV; cells = of_get_property(of_root, "#address-cells", &len); From f54f764bded6da72e29712fbc870ec23391bd994 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 13:00:55 +0800 Subject: [PATCH 17/18] fix: keep pixel enable low during video reset --- files.qip | 1 + menu.sv | 14 +++++++------- rtl/zaparoo_pixel_enable.sv | 21 +++++++++++++++++++++ tb/pixel_enable_tb.sv | 37 +++++++++++++++++++++++++++++++++++++ tb/run.sh | 2 ++ 5 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 rtl/zaparoo_pixel_enable.sv create mode 100644 tb/pixel_enable_tb.sv diff --git a/files.qip b/files.qip index 9d2f59a1..fd608e59 100644 --- a/files.qip +++ b/files.qip @@ -12,3 +12,4 @@ set_global_assignment -name SYSTEMVERILOG_FILE sys/mister_magik_latch_sys_top_br set_global_assignment -name SYSTEMVERILOG_FILE sys/mister_magik_bootstrap_black.sv set_global_assignment -name SYSTEMVERILOG_FILE rtl/zaparoo_bootstrap_video.sv set_global_assignment -name SYSTEMVERILOG_FILE rtl/zaparoo_snow_phase.sv +set_global_assignment -name SYSTEMVERILOG_FILE rtl/zaparoo_pixel_enable.sv diff --git a/menu.sv b/menu.sv index 8ac18dbb..5cbcfdba 100644 --- a/menu.sv +++ b/menu.sv @@ -327,13 +327,13 @@ wire [2:0] led = status[8:6]; // 858-px line for the same 15734.27 Hz. Both the cosine fallback and the FB // reader use this ce_pix. wire [1:0] native_mode; -reg [1:0] ce_div; -reg ce_pix; -always @(posedge CLK_VIDEO) begin - if (RESET | ~vid_locked) ce_div <= 2'd0; - else ce_div <= ce_div + 2'd1; - ce_pix <= (native_mode == 2'd1) ? ce_div[0] : (ce_div == 2'd0); -end +wire ce_pix; +zaparoo_pixel_enable pixel_enable ( + .clk(CLK_VIDEO), + .reset(RESET | ~vid_locked), + .mode(native_mode), + .ce_pix(ce_pix) +); // Native video timing + DDR reader. Timing outputs (sync, DE, vcount, frame // edge) are the SINGLE source of truth for VGA scanout in both modes — that's diff --git a/rtl/zaparoo_pixel_enable.sv b/rtl/zaparoo_pixel_enable.sv new file mode 100644 index 00000000..45418eb9 --- /dev/null +++ b/rtl/zaparoo_pixel_enable.sv @@ -0,0 +1,21 @@ +`timescale 1ns/1ps +// Keep the exported pixel enable idle while reset is held or the video PLL +// is unlocked. Restart the divider phase when video becomes available. +module zaparoo_pixel_enable ( + input wire clk, + input wire reset, + input wire [1:0] mode, + output reg ce_pix +); + reg [1:0] ce_div; + always @(posedge clk) begin + if (reset) begin + ce_div <= 2'd0; + ce_pix <= 1'b0; + end + else begin + ce_div <= ce_div + 2'd1; + ce_pix <= (mode == 2'd1) ? ce_div[0] : (ce_div == 2'd0); + end + end +endmodule diff --git a/tb/pixel_enable_tb.sv b/tb/pixel_enable_tb.sv new file mode 100644 index 00000000..892540a3 --- /dev/null +++ b/tb/pixel_enable_tb.sv @@ -0,0 +1,37 @@ +`timescale 1ns/1ps +module pixel_enable_tb; + reg clk = 0; + always #5 clk = ~clk; + reg reset = 1; + reg [1:0] mode = 0; + wire ce_pix; + zaparoo_pixel_enable dut (.*); + + initial begin + // Both progressive modes, interlace, and the reserved-mode fallback. + // Assert reset at every divider phase, as PLL lock may disappear mid-frame. + for (integer m = 0; m < 4; m = m + 1) begin + for (integer phase = 0; phase < 4; phase = phase + 1) begin + @(negedge clk); reset = 1; mode = m; + repeat (5) begin + @(posedge clk); #1; + assert(ce_pix === 1'b0) else $fatal(1, "pixel enable active during reset"); + end + @(negedge clk); reset = 0; + for (integer i = 0; i < 8 + phase; i = i + 1) begin + @(posedge clk); + if (i == 0) + assert(ce_pix === 1'b0) else $fatal(1, "consumer saw stale enable at reset release"); + #1; + assert(ce_pix === ((m == 1) ? (i % 2 == 1) : (i % 4 == 0))) + else $fatal(1, "divider cadence/phase changed: mode=%0d cycle=%0d", m, i); + end + end + end + $display("PASS: pixel enable reset, restart phase, progressive and interlaced cadence"); + $finish; + end + initial begin + #20000; $fatal(1, "pixel enable test timeout"); + end +endmodule diff --git a/tb/run.sh b/tb/run.sh index 0a0c0bd8..337a1b39 100755 --- a/tb/run.sh +++ b/tb/run.sh @@ -19,6 +19,8 @@ run_test bootstrap_video_tb \ run_test snow_phase_tb ../rtl/zaparoo_snow_phase.sv snow_phase_tb.sv +run_test pixel_enable_tb ../rtl/zaparoo_pixel_enable.sv pixel_enable_tb.sv + run_test scanout_tb \ ../sys/mister_magik_vblank_latch.sv ../sys/mister_magik_latch_sys_top_bridge.sv \ scanout_tb.sv From 1e07a6eacd5e2b0582b7863c354dfffc3280c157 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 9 Sep 2026 13:13:17 +0800 Subject: [PATCH 18/18] test: cover vblank gaps during legacy takeover --- tb/scanout_tb.sv | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tb/scanout_tb.sv b/tb/scanout_tb.sv index 913830d4..57030d6a 100644 --- a/tb/scanout_tb.sv +++ b/tb/scanout_tb.sv @@ -145,6 +145,28 @@ module scanout_tb; transfer(16'h4444, response); transfer(16'h2222, response); end_command(); assert(active_base == 32'h22224444) else $fatal(1, "legacy framebuffer restore lost"); + for (integer gap = 0; gap < 9; gap = gap + 1) begin + @(negedge clk); vblank = 0; + repeat (4) @(posedge clk); + post(0, 16'd10 + gap); + assert(pending) else $fatal(1, "gap probe must start with pending route"); + begin_command(8'h2f, 0); + for (integer word = 0; word < 10; word = word + 1) begin + transfer(16'ha000 + word, response); + if (word == gap) begin + @(negedge clk); vblank = 1; + repeat (8) begin + @(posedge clk); #1; + assert(!pending && !accepted) + else $fatal(1, "route applied in legacy gap %0d", gap); + end + end + end + end_command(); + assert(active_base == 32'ha002a001) + else $fatal(1, "legacy descriptor corrupted across gap %0d", gap); + end + $display("PASS: all nine inter-word legacy vblank gaps preserve takeover"); $display("PASS: scanout CAPS, CRC, pending/receipts, 540p HDMI, vblank, legacy takeover"); $finish; end