Skip to content

terminal_ctrl

Source: Verilog/Terminals/rtl/terminal_ctrl.v

Hierarchy: not instantiated by any of the 9 build tops (elaborated by yosys).

Module hierarchy - All modules

terminal_ctrl symbol

Schematic

Drawn from the Verilog: no build top uses this module, so it was elaborated from its own file with no defines and default parameters. Sub-modules are boxes (click the picture to open it full size; there every sub-module box links to its page, and every wire shows its Verilog name).

terminal_ctrl schematic

Parameters

Parameter Default
COLS 80
ROWS 24
AWIDTH 11
TAB_STOP 8
BLINK_FRAMES 30

Ports

Direction Width Name Description
input 1 clk pixel clock
input 1 rst_n (active low) async reset, active low
input 1 byte_valid one clock per byte
input [7:0] byte_data
output 1 ready low while an engine runs - hold the source
output 1 ram_we
output [AWIDTH-1:0] ram_waddr
output [15:0] ram_wdata
output [AWIDTH-1:0] ram_raddr2 copy-engine read address
input [15:0] ram_rdata2 registered, valid the clock after
output [7:0] top_row
output [7:0] cursor_col
output [7:0] cursor_row
output 1 cursor_enable blink phase AND DECTCEM
output 1 rev_screen DECSCNM - whole-screen reverse video
output 1 blink_on blink phase for the blink attribute
input 1 frame_end one pulse per video frame, for the blink
output 1 bell one clock per BEL
output [3:0] leds DECLL (CSI Ps q) - the VT100 keyboard lamps L1-L4

Verilog source

Verilog/Terminals/rtl/terminal_ctrl.v on GitHub.

Show the Verilog of terminal_ctrl (914 lines)
//============================================================================
//! Terminal control - a VT100 (ANSI/ECMA-48 subset)
//!
//! Part of the board-independent terminal core (Verilog/Terminals/).
//! Plan: Verilog/Terminals/docs/PLAN-vt100-terminal-core.md
//!
//! Takes one byte at a time and turns it into character-RAM writes and cursor
//! movement. This is the terminal SINTRAN terminal type 6 (DEC-VT100)
//! expects.
//!
//! DECISION 30-AUG-2026 (Ronny): plain VT100, not TDV2200. The specification
//! source is RetroTerm ($ND_REPOS/RetroTerm, MIT, Ronny's own), whose
//! class hierarchy settles the relationship: TDV2200Emulator derives from the
//! same ECMA-48 core VT100Emulator is, and adds ND private CSI finals,
//! rectangles, work areas and nine character sets on top. We build the base.
//! The earlier TDV-native C0 build (FF=roll, EM=erase, DLE addressing) was
//! REMOVED with this rewrite - the C0 codes here have their ASCII meanings
//! again. If TDV2200 is ever wanted, it is a delta on this parser, not a
//! rewrite: route the ND finals (z u v ~ < { } | p q s t) and ND private
//! modes (40/66/67/68/69) out of the CSI dispatch below.
//!
//! What is implemented (verified against RetroTerm's TerminalEmulatorBase):
//!
//!   C0:   BEL BS HT LF VT FF CR SO SI, CAN/SUB abort a sequence,
//!         ESC starts one. Everything else is dropped.
//!   ESC:  D (IND)  E (NEL)  M (RI)  7/8 (DECSC/DECRC)  c (RIS)
//!         = > (keypad modes, swallowed)  H (HTS, swallowed - fixed 8 tabs)
//!         ( ) * + <final> character-set designation: final '0' = DEC Special
//!         Graphics, anything else = US ASCII. Only G0 ( '(' ) and G1 ( ')' )
//!         are stored; G2/G3 designations are parsed and dropped.
//!   CSI:  A B C D (cursor, margin-aware)   G (CHA)  d (VPA)
//!         H f (CUP, origin-aware)          J (ED 0/1/2)  K (EL 0/1/2)
//!         m (SGR: 0 1 4 5 7 22 24 25 27)   r (DECSTBM)
//!         h l (modes - see below)          q (DECLL, the four lamps)
//!   Modes (CSI ? n h/l): 5 DECSCNM  6 DECOM  7 DECAWM  25 DECTCEM.
//!         Unmarked mode 20 (LNM). Every other mode number is swallowed.
//!
//! Deliberately NOT implemented, and why:
//!   - Reports (DA, DSR, CPR, ESC Z): they need a transmit path back to the
//!     host, which this module does not have. SINTRAN does not probe - the
//!     terminal type is configured (@SET-TERMINAL-TYPE n 6), not negotiated.
//!   - IL/DL/ICH/DCH: VT102, not VT100.
//!   - 132 columns, double-width/height, smooth scroll, tab set/clear.
//!
//! THE PARSER IS A STATE MACHINE, NOT A MATCHER. Escape sequences arrive
//! split across bytes as a matter of course (one byte per UART frame), so
//! there is never a whole sequence in a buffer to match against. The states
//! follow RetroTerm's EscapeSequenceParser.cs: GROUND -> ESC -> CSI, with
//! intermediates 0x20-0x2F collected, private markers 0x3C-0x3F flagged, a
//! final 0x40-0x7E dispatching. Rules that matter and are easy to lose:
//!   - ESC inside any sequence abandons it and starts a new one.
//!   - CAN (0x18) and SUB (0x1A) abandon the sequence, output nothing.
//!   - An executable C0 (BS, CR, LF...) arriving INSIDE a sequence executes
//!     without abandoning the sequence. Real hosts do this.
//!   - A sequence with an intermediate byte we do not know is swallowed
//!     whole, never printed.
//!
//! AUTOWRAP is the VT100's "last column flag": printing in column 79 sets a
//! pending-wrap flag instead of moving; the NEXT printable resolves it as
//! CR+LF (scrolling if needed) and then prints. Without this, every 80-column
//! line ends in a spurious blank line - the classic wrong-wrap bug. CUP and
//! friends clear the flag. Default ON: SINTRAN writes 80-column tables and
//! expects them to wrap.
//!
//! SCROLLING. With the scroll region covering the whole screen (the normal
//! state), scrolling moves the top-of-screen pointer `top_row` - one register
//! increment plus blanking the row that has come round. With a DECSTBM region
//! set, the ring trick cannot work (rows outside the region must not move),
//! so a copy engine moves the region row by row through the second RAM port:
//! two clocks per cell, 160 per row - a full 23-row region scroll is ~3.8k
//! clocks (~96 us at 40 MHz). `ready` is low throughout; terminal_top has a
//! FIFO in front of this module so a 115200 console cannot lose bytes to it.
//!
//! CELL LAYOUT written to the character RAM (text_screen.v reads it):
//!   [7:0]  character code      [8]  reverse   [9]  bold (stored, not drawn)
//!   [10]   underline           [11] blink     [12] DEC graphics charset
//!   [15:13] reserved
//!
//! CLOCK DOMAIN: everything here runs on the pixel clock, the same as the
//! character RAM and the screen. Bytes cross into this domain BEFORE they
//! arrive here - see terminal_top.v.
//!
//! Rewritten 30-AUG-2026 (was the TDV-native C0 controller, 27-AUG-2026).
//============================================================================

`default_nettype none

module terminal_ctrl #(
    parameter integer COLS     = 80,
    //! 80x24 is DEC VT100 geometry (RetroTerm EmulatorFactory.cs:197).
    //! The earlier 25 was TDV 2200 geometry and left with the TDV build.
    parameter integer ROWS     = 24,
    parameter integer AWIDTH   = 11,
    parameter integer TAB_STOP = 8,
    //! Cursor/attribute blink period in frames. 30 frames at ~60 Hz ~ 1 Hz.
    parameter integer BLINK_FRAMES = 30
) (
    input wire clk,    //! pixel clock
    input wire rst_n,  //! async reset, active low

    // Byte in, already in this clock domain
    input  wire       byte_valid,  //! one clock per byte
    input  wire [7:0] byte_data,
    output wire       ready,       //! low while an engine runs - hold the source

    // Character RAM port A - write, plus the copy engine's read
    output reg               ram_we,
    output reg  [AWIDTH-1:0] ram_waddr,
    output reg  [      15:0] ram_wdata,
    output wire [AWIDTH-1:0] ram_raddr2,  //! copy-engine read address
    input  wire [      15:0] ram_rdata2,  //! registered, valid the clock after

    // Screen state, consumed by text_screen
    output reg  [7:0] top_row,
    output reg  [7:0] cursor_col,
    output reg  [7:0] cursor_row,
    output wire       cursor_enable,  //! blink phase AND DECTCEM
    output reg        rev_screen,     //! DECSCNM - whole-screen reverse video
    output wire       blink_on,       //! blink phase for the blink attribute

    input  wire frame_end,  //! one pulse per video frame, for the blink
    output reg  bell,       //! one clock per BEL
    output reg  [3:0] leds  //! DECLL (CSI Ps q) - the VT100 keyboard lamps L1-L4
);

  localparam [15:0] BLANK_CELL = {8'h00, 8'h20};  //! space, no attributes

  //--------------------------------------------------------------------------
  // Engine states - multi-clock screen operations. `ready` is low outside RUN.
  //--------------------------------------------------------------------------
  localparam [2:0] ST_RUN     = 3'd0;
  localparam [2:0] ST_CLEAR   = 3'd1;  //! blank cells (r1,c1)..(r2,c2), screen coords
  localparam [2:0] ST_COPY_RD = 3'd2;  //! region scroll: read one cell
  localparam [2:0] ST_COPY_WR = 3'd3;  //! region scroll: write it one row over
  localparam [2:0] ST_APPLY   = 3'd4;  //! walk SGR / mode / lamp parameters

  reg [2:0] s_state;

  //--------------------------------------------------------------------------
  // Parser states - where we are inside an escape sequence
  //--------------------------------------------------------------------------
  localparam [1:0] P_GROUND = 2'd0;
  localparam [1:0] P_ESC    = 2'd1;  //! ESC seen, awaiting the next byte
  localparam [1:0] P_ESCINT = 2'd2;  //! ESC + intermediate(s): ( ) * + #
  localparam [1:0] P_CSI    = 2'd3;  //! inside ESC [

  reg [1:0] p_state;
  reg [7:0] s_par  [0:3];  //! numeric parameters, saturating at 255
  reg [2:0] s_npar;        //! how many parameters have been started
  reg       s_priv;        //! saw a private marker (0x3C-0x3F, e.g. '?')
  reg       s_ign;         //! unknown intermediate - swallow to the final
  reg [7:0] s_escint;      //! the ESC intermediate byte itself

  //--------------------------------------------------------------------------
  // Terminal modes and rendition state
  //--------------------------------------------------------------------------
  reg s_origin;      //! DECOM  - cursor addressing relative to the region
  reg s_autowrap;    //! DECAWM - default ON, see the header
  reg s_cursor_vis;  //! DECTCEM
  reg s_lnm;         //! LNM - LF implies CR

  reg [7:0] s_rtop;  //! scroll region, screen rows, inclusive
  reg [7:0] s_rbot;

  reg s_at_rev, s_at_bold, s_at_ul, s_at_blink;  //! current SGR state
  reg s_shift;    //! SO/SI: 0 = G0 active, 1 = G1 active
  reg s_g0_gfx;   //! G0 designated DEC Special Graphics (ESC ( 0)
  reg s_g1_gfx;   //! G1 designated DEC Special Graphics (ESC ) 0)

  reg s_pending;  //! the VT100 last-column flag (see header)

  // DECSC/DECRC saved state
  reg [7:0] s_sv_row, s_sv_col;
  reg s_sv_rev, s_sv_bold, s_sv_ul, s_sv_blink;
  reg s_sv_origin, s_sv_shift, s_sv_g0, s_sv_g1;

  //! A printable held over while the wrap-triggered scroll runs; written the
  //! moment the engine is idle again.
  reg       s_wr_hold;
  reg [7:0] s_wr_char;

  // Clear engine bounds, SCREEN coordinates, inclusive
  reg [7:0] s_cl_row, s_cl_col, s_cl_erow, s_cl_ecol;

  // Copy engine (region scroll)
  reg [7:0] s_cp_dst;  //! destination screen row this pass
  reg [7:0] s_cp_col;
  reg       s_cp_down; //! 0 = scroll up (src = dst+1), 1 = down (src = dst-1)

  // Parameter-apply engine. The four cursor moves also execute here, ONE
  // CYCLE after their final byte - a timing fix, not a semantics change:
  // synthesized at the 1080p pixel clock (139.7 MHz) the one-cycle path
  // "byte decode -> margin mux -> 9-bit add -> clamp -> cursor_row" missed
  // by 0.33 ns (first Vivado run of this file, 30-AUG-2026, worst path
  // cursor_row_reg[2] -> cursor_row_reg[1]). Deferring the execute means the
  // cursor registers are enabled from a REGISTERED kind, and the margins
  // used are the REGISTERED ones below - the front half of that path is
  // gone. Cost: one extra 7 ns cycle per cursor-move sequence.
  localparam [3:0] AP_SGR  = 4'd0;
  localparam [3:0] AP_MODE = 4'd1;
  localparam [3:0] AP_LED  = 4'd2;
  localparam [3:0] AP_CUU  = 4'd3;
  localparam [3:0] AP_CUD  = 4'd4;
  localparam [3:0] AP_CUF  = 4'd5;
  localparam [3:0] AP_CUB  = 4'd6;
  localparam [3:0] AP_CUP  = 4'd7;  //! CUP/HVP - row and column
  localparam [3:0] AP_VPA  = 4'd8;
  localparam [3:0] AP_CHA  = 4'd9;
  reg [3:0] s_ap_kind;
  reg [2:0] s_ap_idx;
  reg       s_ap_enable;  //! for modes: 'h' = 1, 'l' = 0
  reg       s_ap_priv;

  //! The deferred moves run in TWO apply cycles: first the operands are
  //! latched out of the parameter array (s_par -> ap_n_q/ap_col_q is the
  //! only logic in that cycle), then the move commits from the latched
  //! operands. Round two of the 139.7 MHz work: after the first deferral
  //! the one surviving violated endpoint (WNS -0.001 ns) was
  //! s_par_reg[0][4] -> cursor_row_reg[3] - the parameter feeding the
  //! CUP origin add directly. Deferring alone could not cut that cone,
  //! because it starts at the parameter register, not at the byte decode;
  //! latching the operand does.
  reg       s_ap_phase;  //! 0 = latch operands, 1 = commit
  reg [7:0] s_ap_n_q;    //! par_or(s_par[0], 1), latched
  reg [7:0] s_ap_col_q;  //! par_or(s_par[1], 1), latched (CUP's column)

  //! Cursor bounds, REGISTERED - one cycle behind their inputs, which is
  //! exactly current at the commit cycle: neither the final byte nor the
  //! operand-latch cycle moves the cursor or the region.
  reg [7:0] s_floor_q;   //! CUU stops here (region top if inside the region)
  reg [7:0] s_ceil_q;    //! CUD stops here
  reg [7:0] s_ofloor_q;  //! CUP/VPA origin: region top under DECOM, else 0
  reg [7:0] s_oceil_q;   //! CUP/VPA limit: region bottom under DECOM, else ROWS-1

  assign ready = (s_state == ST_RUN) && !s_wr_hold;

  //--------------------------------------------------------------------------
  // Address arithmetic
  //--------------------------------------------------------------------------

  //! stored row for a given screen row = (top_row + screen_row) mod ROWS.
  //! automatic, not the Verilog default: task and function locals are STATIC
  //! unless you say otherwise, and this one is called from several places in
  //! the same always block. Static locals shared between call sites are a
  //! real synthesis hazard, not a style point.
  function automatic [7:0] stored_row;
    input [7:0] top;
    input [7:0] screen;
    reg [8:0] sum;
    begin
      sum = {1'b0, top} + {1'b0, screen};
      stored_row = (sum >= ROWS) ? (sum[7:0] - ROWS[7:0]) : sum[7:0];
    end
  endfunction

  //! address = row * COLS + col. COLS = 80 = 64 + 16, so two shifts and an add
  //! - no multiplier inferred.
  function automatic [AWIDTH-1:0] cell_addr;
    input [7:0] row;
    input [7:0] col;
    begin
      cell_addr = {row, 6'b0} + {row, 4'b0} + col;
    end
  endfunction

  //! A CSI parameter with its default: 0 or absent means `dflt` (ECMA-48).
  function automatic [7:0] par_or;
    input [7:0] value;
    input [7:0] dflt;
    begin
      par_or = (value == 8'd0) ? dflt : value;
    end
  endfunction

  //! Accumulate one decimal digit into a parameter, saturating at 255 - a
  //! host sending "CSI 999 A" must clamp, not wrap to a small number.
  function automatic [7:0] dig_acc;
    input [7:0] cur;
    input [7:0] digit;
    reg [11:0] wide;
    begin
      wide = {4'd0, cur} * 12'd10 + {8'd0, digit[3:0]};
      dig_acc = (wide > 12'd255) ? 8'd255 : wide[7:0];
    end
  endfunction

  //! The copy engine's source row: one below (scroll up) or above (down) the
  //! destination, converted to a stored row here so the mapping through the
  //! top_row ring is applied exactly once, in one place.
  wire [7:0] s_cp_src = s_cp_down ? (s_cp_dst - 8'd1) : (s_cp_dst + 8'd1);
  assign ram_raddr2 = cell_addr(stored_row(top_row, s_cp_src), s_cp_col);

  //--------------------------------------------------------------------------
  // Cursor / attribute blink - free-running off the frame pulse
  //--------------------------------------------------------------------------

  reg [7:0] s_blink_count;
  reg       s_blink_on;

  always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      s_blink_count <= 8'd0;
      s_blink_on    <= 1'b1;
    end else if (frame_end) begin
      if (s_blink_count == BLINK_FRAMES - 1) begin
        s_blink_count <= 8'd0;
        s_blink_on    <= ~s_blink_on;
      end else begin
        s_blink_count <= s_blink_count + 8'd1;
      end
    end
  end

  assign cursor_enable = s_blink_on && s_cursor_vis;
  assign blink_on      = s_blink_on;

  //--------------------------------------------------------------------------
  // The screen movers. Tasks because LF, RI, the wrap and CUP all need them
  //  and they are the fiddly part.
  //--------------------------------------------------------------------------

  //! Blank a rectangle of cells, screen coordinates, inclusive, row-major.
  task automatic start_clear;
    input [7:0] r1; input [7:0] c1;
    input [7:0] r2; input [7:0] c2;
    begin
      s_cl_row  <= r1;
      s_cl_col  <= c1;
      s_cl_erow <= r2;
      s_cl_ecol <= c2;
      s_state   <= ST_CLEAR;
    end
  endtask

  //! Scroll the region up one line (text moves up, blank line at the bottom).
  //! Full-screen region: rotate the ring and blank the row that came round.
  //! Partial region: the copy engine, then blank the bottom region row.
  task automatic scroll_up;
    begin
      if (s_rtop == 8'd0 && s_rbot == ROWS - 1) begin
        top_row <= (top_row == ROWS - 1) ? 8'd0 : (top_row + 8'd1);
        // top_row is updated this same edge, so next cycle's stored_row()
        // already maps screen row ROWS-1 onto the vacated storage row.
        start_clear(ROWS[7:0] - 8'd1, 8'd0, ROWS[7:0] - 8'd1, COLS[7:0] - 8'd1);
      end else begin
        s_cp_down <= 1'b0;
        s_cp_dst  <= s_rtop;
        s_cp_col  <= 8'd0;
        s_state   <= ST_COPY_RD;
      end
    end
  endtask

  //! Scroll the region down one line (text moves down, blank line at the top).
  task automatic scroll_down;
    begin
      if (s_rtop == 8'd0 && s_rbot == ROWS - 1) begin
        top_row <= (top_row == 8'd0) ? (ROWS[7:0] - 8'd1) : (top_row - 8'd1);
        start_clear(8'd0, 8'd0, 8'd0, COLS[7:0] - 8'd1);
      end else begin
        s_cp_down <= 1'b1;
        s_cp_dst  <= s_rbot;
        s_cp_col  <= 8'd0;
        s_state   <= ST_COPY_RD;
      end
    end
  endtask

  //! Cursor down; at the region's bottom line this scrolls instead. Below the
  //! region (possible with DECOM off) it stops at the screen edge - it must
  //! never scroll a region the cursor is not in.
  task automatic do_index;
    begin
      if (cursor_row == s_rbot) scroll_up;
      else if (cursor_row < ROWS - 1) cursor_row <= cursor_row + 8'd1;
    end
  endtask

  //! Cursor up; at the region's top line this scrolls down instead.
  task automatic do_rev_index;
    begin
      if (cursor_row == s_rtop) scroll_down;
      else if (cursor_row != 8'd0) cursor_row <= cursor_row - 8'd1;
    end
  endtask

  //! Write one printable at the cursor and advance. Printing in the LAST
  //! column sets the pending-wrap flag instead of moving - the VT100 rule.
  task automatic put_char;
    input [7:0] ch;
    begin
      ram_we    <= 1'b1;
      ram_waddr <= cell_addr(stored_row(top_row, cursor_row), cursor_col);
      ram_wdata <= {3'b000, (s_shift ? s_g1_gfx : s_g0_gfx),
                    s_at_blink, s_at_ul, s_at_bold, s_at_rev, ch};
      if (cursor_col == COLS - 1) begin
        if (s_autowrap) s_pending <= 1'b1;
      end else begin
        cursor_col <= cursor_col + 8'd1;
      end
    end
  endtask

  //! Everything RIS resets and power-up starts from. The screen clear itself
  //! is started by the caller.
  task automatic reset_modes;
    begin
      top_row      <= 8'd0;
      cursor_col   <= 8'd0;
      cursor_row   <= 8'd0;
      s_rtop       <= 8'd0;
      s_rbot       <= ROWS[7:0] - 8'd1;
      s_origin     <= 1'b0;
      s_autowrap   <= 1'b1;   // see the header - deliberate, not the manual default
      s_cursor_vis <= 1'b1;
      s_lnm        <= 1'b0;
      rev_screen   <= 1'b0;
      s_at_rev     <= 1'b0;
      s_at_bold    <= 1'b0;
      s_at_ul      <= 1'b0;
      s_at_blink   <= 1'b0;
      s_shift      <= 1'b0;
      s_g0_gfx     <= 1'b0;
      s_g1_gfx     <= 1'b0;
      s_pending    <= 1'b0;
      leds         <= 4'b0000;
      p_state      <= P_GROUND;
    end
  endtask

  //--------------------------------------------------------------------------
  // CSI final dispatch - one task so the byte-intake case stays readable
  //--------------------------------------------------------------------------

  task automatic dispatch_csi;
    input [7:0] fin;
    reg [7:0] t, b;
    begin
      // (All cursor-move arithmetic lives in ST_APPLY, off registered
      // operands and registered bounds - the 139.7 MHz work. Nothing here
      // touches cursor_row/cursor_col except through engine starts.)

      case (fin)

        // Every cursor move executes in ST_APPLY, two cycles after its
        // final byte - see the AP_* localparams for why (139.7 MHz).
        "A": begin s_pending <= 1'b0; s_ap_kind <= AP_CUU; s_ap_phase <= 1'b0; s_state <= ST_APPLY; end
        "B": begin s_pending <= 1'b0; s_ap_kind <= AP_CUD; s_ap_phase <= 1'b0; s_state <= ST_APPLY; end
        "C": begin s_pending <= 1'b0; s_ap_kind <= AP_CUF; s_ap_phase <= 1'b0; s_state <= ST_APPLY; end
        "D": begin s_pending <= 1'b0; s_ap_kind <= AP_CUB; s_ap_phase <= 1'b0; s_state <= ST_APPLY; end
        "G": begin s_pending <= 1'b0; s_ap_kind <= AP_CHA; s_ap_phase <= 1'b0; s_state <= ST_APPLY; end
        "d": begin s_pending <= 1'b0; s_ap_kind <= AP_VPA; s_ap_phase <= 1'b0; s_state <= ST_APPLY; end
        "H", "f": begin
          s_pending <= 1'b0; s_ap_kind <= AP_CUP; s_ap_phase <= 1'b0; s_state <= ST_APPLY;
        end

        "J": begin  // ED - erase in display. Does NOT move the cursor.
          s_pending <= 1'b0;
          case (s_par[0])
            8'd1: start_clear(8'd0, 8'd0, cursor_row, cursor_col);
            8'd2: start_clear(8'd0, 8'd0, ROWS[7:0] - 8'd1, COLS[7:0] - 8'd1);
            default: start_clear(cursor_row, cursor_col,
                                 ROWS[7:0] - 8'd1, COLS[7:0] - 8'd1);
          endcase
        end

        "K": begin  // EL - erase in line
          s_pending <= 1'b0;
          case (s_par[0])
            8'd1: start_clear(cursor_row, 8'd0, cursor_row, cursor_col);
            8'd2: start_clear(cursor_row, 8'd0, cursor_row, COLS[7:0] - 8'd1);
            default: start_clear(cursor_row, cursor_col,
                                 cursor_row, COLS[7:0] - 8'd1);
          endcase
        end

        "m", "h", "l", "q": begin  // SGR / SM / RM / DECLL - walk the list
          s_ap_kind   <= (fin == "m") ? AP_SGR : (fin == "q") ? AP_LED : AP_MODE;
          s_ap_enable <= (fin == "h");
          s_ap_priv   <= s_priv;
          s_ap_idx    <= 3'd0;
          // No parameters at all still means one default parameter (SGR 0
          // resets, DECLL 0 clears) - ECMA-48's omitted-parameter rule.
          if (s_npar == 3'd0) s_npar <= 3'd1;  // s_par[0] is already 0
          s_state <= ST_APPLY;
        end

        "r": begin  // DECSTBM - set region, home the cursor (origin-aware)
          t = par_or(s_par[0], 8'd1);
          b = par_or(s_par[1], ROWS[7:0]);
          if (t < b && b <= ROWS) begin
            s_rtop     <= t - 8'd1;
            s_rbot     <= b - 8'd1;
            cursor_row <= s_origin ? (t - 8'd1) : 8'd0;
            cursor_col <= 8'd0;
            s_pending  <= 1'b0;
          end
        end

        default: ;  // unknown final - swallowed, never printed

      endcase
    end
  endtask

  //--------------------------------------------------------------------------
  // Main sequencer
  //--------------------------------------------------------------------------

  always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      // Power-up: blank the whole screen. On a real FPGA the character RAM
      // holds whatever the tool loaded, so this is not optional.
      s_state   <= ST_CLEAR;
      s_cl_row  <= 8'd0;
      s_cl_col  <= 8'd0;
      s_cl_erow <= ROWS[7:0] - 8'd1;
      s_cl_ecol <= COLS[7:0] - 8'd1;
      ram_we    <= 1'b0;
      ram_waddr <= {AWIDTH{1'b0}};
      ram_wdata <= BLANK_CELL;
      bell      <= 1'b0;
      s_wr_hold <= 1'b0;
      s_wr_char <= 8'd0;
      s_npar    <= 3'd0;
      s_priv    <= 1'b0;
      s_ign     <= 1'b0;
      s_escint  <= 8'd0;
      s_par[0]  <= 8'd0; s_par[1] <= 8'd0; s_par[2] <= 8'd0; s_par[3] <= 8'd0;
      s_cp_dst  <= 8'd0; s_cp_col <= 8'd0; s_cp_down <= 1'b0;
      s_ap_kind <= AP_SGR; s_ap_idx <= 3'd0;
      s_ap_enable <= 1'b0; s_ap_priv <= 1'b0;
      s_ap_phase <= 1'b0; s_ap_n_q <= 8'd1; s_ap_col_q <= 8'd1;
      s_floor_q <= 8'd0; s_ceil_q <= ROWS[7:0] - 8'd1;
      s_ofloor_q <= 8'd0; s_oceil_q <= ROWS[7:0] - 8'd1;
      s_sv_row <= 8'd0; s_sv_col <= 8'd0;
      s_sv_rev <= 1'b0; s_sv_bold <= 1'b0; s_sv_ul <= 1'b0; s_sv_blink <= 1'b0;
      s_sv_origin <= 1'b0; s_sv_shift <= 1'b0; s_sv_g0 <= 1'b0; s_sv_g1 <= 1'b0;
      reset_modes;
    end else begin
      // Defaults - overridden below where something actually happens.
      ram_we <= 1'b0;
      bell   <= 1'b0;

      // Cursor bounds for the deferred moves - see the AP_* note above.
      s_floor_q  <= (cursor_row >= s_rtop) ? s_rtop : 8'd0;
      s_ceil_q   <= (cursor_row <= s_rbot) ? s_rbot : (ROWS[7:0] - 8'd1);
      s_ofloor_q <= s_origin ? s_rtop : 8'd0;
      s_oceil_q  <= s_origin ? s_rbot : (ROWS[7:0] - 8'd1);

      case (s_state)

        //--------------------------------------------------------------------
        ST_CLEAR: begin
          ram_we    <= 1'b1;
          ram_waddr <= cell_addr(stored_row(top_row, s_cl_row), s_cl_col);
          ram_wdata <= BLANK_CELL;

          if (s_cl_row == s_cl_erow && s_cl_col == s_cl_ecol) begin
            s_state <= ST_RUN;
          end else if (s_cl_col == COLS - 1) begin
            s_cl_col <= 8'd0;
            s_cl_row <= s_cl_row + 8'd1;
          end else begin
            s_cl_col <= s_cl_col + 8'd1;
          end
        end

        //--------------------------------------------------------------------
        // Region scroll: read src cell this clock, write it to dst the next.
        // Two clocks per cell; see the header for the arithmetic.
        //--------------------------------------------------------------------
        ST_COPY_RD: begin
          // ram_raddr2 is combinational from s_cp_dst/s_cp_col; the RAM
          // registers the read on this edge, so rdata2 is valid in COPY_WR.
          s_state <= ST_COPY_WR;
        end

        ST_COPY_WR: begin
          ram_we    <= 1'b1;
          ram_waddr <= cell_addr(stored_row(top_row, s_cp_dst), s_cp_col);
          ram_wdata <= ram_rdata2;

          if (s_cp_col != COLS - 1) begin
            s_cp_col <= s_cp_col + 8'd1;
            s_state  <= ST_COPY_RD;
          end else begin
            s_cp_col <= 8'd0;
            if (!s_cp_down) begin
              // Scrolling up: rows walk rtop..rbot-1, then blank rbot.
              if (s_cp_dst == s_rbot - 1) begin
                start_clear(s_rbot, 8'd0, s_rbot, COLS[7:0] - 8'd1);
              end else begin
                s_cp_dst <= s_cp_dst + 8'd1;
                s_state  <= ST_COPY_RD;
              end
            end else begin
              // Scrolling down: rows walk rbot..rtop+1, then blank rtop.
              if (s_cp_dst == s_rtop + 1) begin
                start_clear(s_rtop, 8'd0, s_rtop, COLS[7:0] - 8'd1);
              end else begin
                s_cp_dst <= s_cp_dst - 8'd1;
                s_state  <= ST_COPY_RD;
              end
            end
          end
        end

        //--------------------------------------------------------------------
        // Walk the parameter list of SGR / SM / RM / DECLL, one per clock -
        // and execute the deferred cursor moves (see the AP_* note). The
        // moves clamp margin-aware: a cursor inside the DECSTBM region is
        // confined to it, one outside to the screen (RetroTerm
        // MoveCursor*WithinMargins) - via the registered bounds.
        //--------------------------------------------------------------------
        ST_APPLY: begin : apply
          reg [8:0] ap_sum;

          if (s_ap_kind >= AP_CUU) begin
            // A deferred cursor move. Phase 0 latches the operands - the
            // ONLY logic between s_par and a register that cycle - and
            // phase 1 commits from registers alone.
            if (!s_ap_phase) begin
              s_ap_n_q   <= par_or(s_par[0], 8'd1);
              s_ap_col_q <= par_or(s_par[1], 8'd1);
              s_ap_phase <= 1'b1;
            end else begin
              s_state <= ST_RUN;
              case (s_ap_kind)
                AP_CUU: cursor_row <=
                    ({1'b0, cursor_row} > {1'b0, s_floor_q} + {1'b0, s_ap_n_q})
                    ? (cursor_row - s_ap_n_q) : s_floor_q;
                AP_CUD: begin
                  ap_sum = {1'b0, cursor_row} + {1'b0, s_ap_n_q};
                  cursor_row <= (ap_sum < {1'b0, s_ceil_q}) ? ap_sum[7:0] : s_ceil_q;
                end
                AP_CUF: begin
                  ap_sum = {1'b0, cursor_col} + {1'b0, s_ap_n_q};
                  cursor_col <= (ap_sum < COLS - 1) ? ap_sum[7:0] : (COLS[7:0] - 8'd1);
                end
                AP_CUB: cursor_col <=
                    ({1'b0, cursor_col} > {1'b0, s_ap_n_q})
                    ? (cursor_col - s_ap_n_q) : 8'd0;
                AP_CHA: cursor_col <=
                    (s_ap_n_q <= COLS) ? (s_ap_n_q - 8'd1) : (COLS[7:0] - 8'd1);
                AP_VPA: begin
                  ap_sum = {1'b0, s_ofloor_q} + {1'b0, s_ap_n_q} - 9'd1;
                  cursor_row <= (ap_sum < {1'b0, s_oceil_q}) ? ap_sum[7:0] : s_oceil_q;
                end
                AP_CUP: begin
                  ap_sum = {1'b0, s_ofloor_q} + {1'b0, s_ap_n_q} - 9'd1;
                  cursor_row <= (ap_sum < {1'b0, s_oceil_q}) ? ap_sum[7:0] : s_oceil_q;
                  cursor_col <= (s_ap_col_q <= COLS) ? (s_ap_col_q - 8'd1)
                                                     : (COLS[7:0] - 8'd1);
                end
                default: ;
              endcase
            end
          end else if (s_ap_idx >= s_npar) begin
            s_state <= ST_RUN;
          end else begin
            s_ap_idx <= s_ap_idx + 3'd1;
            case (s_ap_kind)

              AP_SGR: begin
                case (s_par[s_ap_idx[1:0]])
                  8'd0: begin
                    s_at_rev <= 1'b0; s_at_bold <= 1'b0;
                    s_at_ul  <= 1'b0; s_at_blink <= 1'b0;
                  end
                  8'd1:  s_at_bold  <= 1'b1;
                  8'd4:  s_at_ul    <= 1'b1;
                  8'd5:  s_at_blink <= 1'b1;
                  8'd7:  s_at_rev   <= 1'b1;
                  8'd22: s_at_bold  <= 1'b0;
                  8'd24: s_at_ul    <= 1'b0;
                  8'd25: s_at_blink <= 1'b0;
                  8'd27: s_at_rev   <= 1'b0;
                  default: ;  // 2 (dim), colours, the rest: swallowed
                endcase
              end

              AP_MODE: begin
                if (s_ap_priv) begin
                  case (s_par[s_ap_idx[1:0]])
                    8'd5: rev_screen <= s_ap_enable;       // DECSCNM
                    8'd6: begin                            // DECOM - homes cursor
                      s_origin   <= s_ap_enable;
                      cursor_row <= s_ap_enable ? s_rtop : 8'd0;
                      cursor_col <= 8'd0;
                      s_pending  <= 1'b0;
                    end
                    8'd7: begin                            // DECAWM
                      s_autowrap <= s_ap_enable;
                      if (!s_ap_enable) s_pending <= 1'b0;
                    end
                    8'd25: s_cursor_vis <= s_ap_enable;    // DECTCEM
                    default: ;  // ?1 ?3 ?4 ?8 ... swallowed
                  endcase
                end else begin
                  case (s_par[s_ap_idx[1:0]])
                    8'd20: s_lnm <= s_ap_enable;           // LNM
                    default: ;
                  endcase
                end
              end

              AP_LED: begin
                case (s_par[s_ap_idx[1:0]])
                  8'd0: leds <= 4'b0000;
                  8'd1: leds[0] <= 1'b1;
                  8'd2: leds[1] <= 1'b1;
                  8'd3: leds[2] <= 1'b1;
                  8'd4: leds[3] <= 1'b1;
                  default: ;
                endcase
              end

              default: ;
            endcase
          end
        end

        //--------------------------------------------------------------------
        ST_RUN: begin
          if (s_wr_hold) begin
            // The printable that triggered a wrap, now that the scroll it
            // caused has finished. Column is already 0.
            s_wr_hold <= 1'b0;
            put_char(s_wr_char);
          end else if (byte_valid) begin

            //----------------------------------------------------------------
            // C0 controls first. Executable ones act WITHOUT abandoning a
            // sequence in progress; CAN/SUB abandon it; ESC restarts it.
            //----------------------------------------------------------------
            if (byte_data < 8'h20) begin
              case (byte_data)
                8'h07: bell <= 1'b1;                                     // BEL
                8'h08: begin                                             // BS
                  s_pending <= 1'b0;
                  if (cursor_col != 0) cursor_col <= cursor_col - 8'd1;
                end
                8'h09: begin                                             // HT
                  // Next multiple of TAB_STOP, stopping at the right margin.
                  // (The old form jumped from column 71 straight to 79 - the
                  // comparison ran on the CURRENT column, not the next stop.)
                  s_pending <= 1'b0;
                  if (((cursor_col / TAB_STOP) + 8'd1) * TAB_STOP >= COLS)
                    cursor_col <= COLS[7:0] - 8'd1;
                  else
                    cursor_col <= ((cursor_col / TAB_STOP) + 8'd1) * TAB_STOP;
                end
                8'h0A, 8'h0B, 8'h0C: begin                               // LF VT FF
                  s_pending <= 1'b0;
                  if (s_lnm) cursor_col <= 8'd0;
                  do_index;
                end
                8'h0D: begin                                             // CR
                  s_pending  <= 1'b0;
                  cursor_col <= 8'd0;
                end
                8'h0E: s_shift <= 1'b1;                                  // SO -> G1
                8'h0F: s_shift <= 1'b0;                                  // SI -> G0
                8'h18, 8'h1A: p_state <= P_GROUND;                       // CAN SUB
                8'h1B: begin                                             // ESC
                  p_state <= P_ESC;
                  s_npar  <= 3'd0;
                  s_priv  <= 1'b0;
                  s_ign   <= 1'b0;
                  s_par[0] <= 8'd0; s_par[1] <= 8'd0;
                  s_par[2] <= 8'd0; s_par[3] <= 8'd0;
                end
                default: ;  // NUL, ENQ, the TDV lamp codes... all dropped
              endcase

            end else begin
              case (p_state)

                //------------------------------------------------------------
                P_GROUND: begin
                  if (byte_data < 8'h7F) begin
                    if (s_pending && s_autowrap) begin
                      // Resolve the last-column flag: CR + LF now, print after
                      // the scroll (if any) has run.
                      s_pending  <= 1'b0;
                      cursor_col <= 8'd0;
                      s_wr_hold  <= 1'b1;
                      s_wr_char  <= byte_data;
                      do_index;
                    end else begin
                      put_char(byte_data);
                    end
                  end
                  // 0x7F DEL and 0x80-0xFF: dropped. The ND-120 is a 7-bit
                  // machine; a high bit here is line noise, not a character.
                end

                //------------------------------------------------------------
                P_ESC: begin
                  p_state <= P_GROUND;  // every arm below that stays is explicit
                  case (byte_data)
                    "[": begin
                      p_state <= P_CSI;
                    end
                    8'h20, "#", "(", ")", "*", "+", "%": begin
                      s_escint <= byte_data;
                      p_state  <= P_ESCINT;
                    end
                    "D": begin s_pending <= 1'b0; do_index; end          // IND
                    "E": begin                                           // NEL
                      s_pending  <= 1'b0;
                      cursor_col <= 8'd0;
                      do_index;
                    end
                    "M": begin s_pending <= 1'b0; do_rev_index; end      // RI
                    "7": begin                                           // DECSC
                      s_sv_row    <= cursor_row;
                      s_sv_col    <= cursor_col;
                      s_sv_rev    <= s_at_rev;
                      s_sv_bold   <= s_at_bold;
                      s_sv_ul     <= s_at_ul;
                      s_sv_blink  <= s_at_blink;
                      s_sv_origin <= s_origin;
                      s_sv_shift  <= s_shift;
                      s_sv_g0     <= s_g0_gfx;
                      s_sv_g1     <= s_g1_gfx;
                    end
                    "8": begin                                           // DECRC
                      cursor_row <= s_sv_row;
                      cursor_col <= s_sv_col;
                      s_at_rev   <= s_sv_rev;
                      s_at_bold  <= s_sv_bold;
                      s_at_ul    <= s_sv_ul;
                      s_at_blink <= s_sv_blink;
                      s_origin   <= s_sv_origin;
                      s_shift    <= s_sv_shift;
                      s_g0_gfx   <= s_sv_g0;
                      s_g1_gfx   <= s_sv_g1;
                      s_pending  <= 1'b0;
                    end
                    "c": begin                                           // RIS
                      reset_modes;
                      start_clear(8'd0, 8'd0, ROWS[7:0] - 8'd1, COLS[7:0] - 8'd1);
                    end
                    // '=' '>' keypad modes, 'H' HTS, 'Z' DECID, 'N' 'O' SS2/3,
                    // 'n' 'o' LS2/3 and anything unknown: swallowed.
                    default: ;
                  endcase
                end

                //------------------------------------------------------------
                // ESC + intermediate. Character-set designation is the one
                // that matters: final '0' = DEC Special Graphics (the VT100
                // line-drawing set the SINTRAN full-screen tools use for
                // boxes - PED's init sends ESC ) 0). Everything else, incl.
                // ESC # line sizes, is parsed and dropped.
                //------------------------------------------------------------
                P_ESCINT: begin
                  if (byte_data >= 8'h20 && byte_data <= 8'h2F) begin
                    s_escint <= byte_data;  // keep the LAST intermediate
                  end else begin
                    p_state <= P_GROUND;
                    case (s_escint)
                      "(": s_g0_gfx <= (byte_data == "0");
                      ")": s_g1_gfx <= (byte_data == "0");
                      default: ;  // * + (G2/G3), # (line size), % : dropped
                    endcase
                  end
                end

                //------------------------------------------------------------
                P_CSI: begin
                  if (byte_data >= "0" && byte_data <= "9") begin
                    if (s_npar == 3'd0) begin
                      s_npar   <= 3'd1;
                      s_par[0] <= dig_acc(s_par[0], byte_data);
                    end else if (s_npar <= 3'd4) begin
                      s_par[s_npar[1:0] - 2'd1]
                        <= dig_acc(s_par[s_npar[1:0] - 2'd1], byte_data);
                    end
                  end else if (byte_data == ";") begin
                    if (s_npar == 3'd0) s_npar <= 3'd2;  // leading ';' = omitted first
                    else if (s_npar < 3'd4) s_npar <= s_npar + 3'd1;
                    else s_ign <= 1'b1;  // more than 4 parameters - swallow
                  end else if (byte_data >= 8'h3C && byte_data <= 8'h3F) begin
                    s_priv <= 1'b1;  // '<' '=' '>' '?'
                  end else if (byte_data >= 8'h20 && byte_data <= 8'h2F) begin
                    s_ign <= 1'b1;   // intermediate we know nothing about
                  end else if (byte_data >= 8'h40 && byte_data <= 8'h7E) begin
                    p_state <= P_GROUND;
                    if (!s_ign) dispatch_csi(byte_data);
                  end else begin
                    p_state <= P_GROUND;  // 0x7F or garbage - abandon
                  end
                end

                default: p_state <= P_GROUND;

              endcase
            end
          end
        end

        //--------------------------------------------------------------------
        default: s_state <= ST_RUN;

      endcase
    end
  end

endmodule

`default_nettype wire