Skip to content

terminal_ctrl_tdv

Source: Verilog/Terminals/rtl/terminal_ctrl_tdv.v

Where it sits (Nexys): nd120_nexys4ddr_top > terminal_top > terminal_ctrl_tdv - instance path: TERMINAL.CTRL

Used in: terminal_top (Nexys, MiSTer, MEGA65 R6, MEGA65 R3)

Contains: no other modules.

Module hierarchy - All modules

terminal_ctrl_tdv symbol

Schematic

Drawn from the Verilog: the yosys netlist of the Nexys 4 DDR build, instance TERMINAL.CTRL. 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_tdv schematic

Parameters

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

Ports

Direction Width Name Description
input 1 clk pixel clock (from nd120_console_mega65.clk and others)
input 1 rst_n (active low) async reset, active low (from nd120_console_mega65.rst_n and others)
input 1 byte_valid
input [7:0] byte_data
output 1 ready
output 1 ram_we
output [AWIDTH-1:0] ram_waddr
output [15:0] ram_wdata
output [AWIDTH-1:0] ram_raddr2
input [15:0] ram_rdata2
output [7:0] top_row which stored row is displayed at the top (to text_screen.top_row)
output [7:0] cursor_col
output [7:0] cursor_row cursor position in SCREEN coordinates (to text_screen.cursor_row)
output 1 cursor_enable draw the cursor at all (to text_screen.cursor_enable)
output 1 rev_screen
output 1 blink_on
input 1 frame_end one pulse per frame, un-delayed (for blink) (from text_screen.frame_end)
output 1 bell one pix_clk per BEL received (to terminal_top.bell)
output [3:0] leds DECLL (CSI Ps q) - the VT100 keyboard lamps L1-L4 (to terminal_top.leds)
output 1 dbg_box_mode
output 1 dbg_saw_esc6

Verilog source

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

Show the Verilog of terminal_ctrl_tdv (818 lines)
//============================================================================
//! Terminal control - TDV2200 (terminal type 93, Tandberg TDV-2200/9S
//! ND-NOTIS)
//!
//! Part of the board-independent terminal core (Verilog/Terminals/).
//! Sibling of terminal_ctrl.v (VT100, type 6) - same engine (clear/scroll/
//! copy/apply state machines, cursor-move arithmetic, address functions are
//! IDENTICAL, terminal-type-agnostic screen mechanics), different PARSER:
//! a TDV does not speak ECMA-48 C0 meanings or DEC CSI cursor letters for
//! input, and neither does SINTRAN when it thinks it is talking to one.
//!
//! GROUND TRUTH (see ps2_ascii_table_tdv.v's header for the full source
//! list) and the REAL CAPTURED PED-at-type-93 startup this parser is built
//! to survive byte-exact (Verilog/Terminals/docs/SPEC-tdv2200.md has the
//! full account):
//!   ESC Q                          exit/enable EC switch - swallowed
//!   ESC[30;7;80l  ESC[62;62h       mode set/reset, UNMARKED (no '?'),
//!                                  function unknown - swallowed generically
//!   ESC P L10 ESC\  (x4)           DCS soft-key programming - SKIPPED AS A
//!                                  UNIT, never rendered (matches RetroTerm's
//!                                  own TDV2200Emulator, which does not
//!                                  implement DCS soft-key programming either)
//!   ESC[001;001H                   CUP, ZERO-PADDED parameters
//!   ESC[2J                         ED, standard
//!
//! C0 CONTROL CODES have TDV-native meanings, not ASCII/ECMA-48 ones - this
//! is the single biggest difference from terminal_ctrl.v. Cursor movement is
//! C0 bytes (BS=left, VT=down, CAN=right, FS=up, GS=home), NOT escape
//! sequences - confirmed on real hardware (FINDINGS-2026-08-20.md: SENDKEY
//! HOME transmitted GS 0x1D, cursor landed on the PED: line, "exactly the
//! documented behaviour").
//!
//! DLE (0x10) starts 2-byte BINARY cursor addressing: row byte masked to 5
//! bits (0-24), column byte masked to 7 bits (0-79) - 7, not 5: 5 bits
//! cannot address column 79, and the RetroTerm source code comment disputes
//! the 5-bit claim some docs make on exactly this basis. The same masking
//! is tolerant of BOTH a raw 0-based encoding and SINTRAN's 0x7F+n 1-based
//! biased encoding - masking off the low 5/7 bits collapses both cases to
//! the same result, so no mode flag is needed to pick which one arrived.
//!
//! CHARACTER SETS: "ESC <digit>" (NDSS1-9, e.g. "ESC 6" for Box) - a bare
//! TWO-byte designation, NOT VT100's three-byte "ESC ( <final>". Only NDSS6
//! (Box, font page 3 - see font/make_font.py) actually renders differently;
//! every other digit falls back to plain ASCII (page 0), same conservative
//! choice terminal_ctrl.v makes for any VT100 charset it does not implement.
//! Found missing and fixed 31-AUG-2026 against a live SCONF capture: cell
//! 0x60 printed as a literal backtick instead of a top-left corner, because
//! this parser only recognised VT100's ESC( form, which a TDV never sends.
//!
//! DEFERRED, NOT IMPLEMENTED (no evidence PED/LED use them; add only if a
//! live capture shows otherwise): ND private rectangle ops (z { } u v ~ | p
//! q s t), Tektronix/ND graphics, DCS soft-key/PUSH-key PROGRAMMING (only
//! skip-and-discard is implemented), 132-column mode (no TDV terminal type
//! supports it), NDSS1-5/7-9 (Graphics I/II, Math, Greek, Diacritics, NIX,
//! T, ND private) and the ISO 646 national-variant switch (ESC %).
//!
//! CELL LAYOUT, clock domain, blink: identical to terminal_ctrl.v - see
//! that file's header.
//!
//! Written 31-AUG-2026.
//============================================================================

`default_nettype none

module terminal_ctrl_tdv #(
    parameter integer COLS     = 80,
    //! 80x25 is TDV2200 geometry - one more row than VT100's 80x24, the
    //! 25th row PED's status line uses (confirmed: FINDINGS-2026-08-20.md,
    //! "PED as a TDV gets one more text line... Line: 1-21... 25th row").
    parameter integer ROWS     = 25,
    parameter integer AWIDTH   = 11,
    parameter integer TAB_STOP = 8,
    parameter integer BLINK_FRAMES = 30
) (
    input wire clk,  //! pixel clock (from nd120_console_mega65.clk and others)
    input wire rst_n,  //! async reset, active low (from nd120_console_mega65.rst_n and others)

    input  wire       byte_valid,
    input  wire [7:0] byte_data,
    output wire       ready,

    output reg               ram_we,
    output reg  [AWIDTH-1:0] ram_waddr,
    output reg  [      15:0] ram_wdata,
    output wire [AWIDTH-1:0] ram_raddr2,
    input  wire [      15:0] ram_rdata2,

    output reg  [7:0] top_row,  //! which stored row is displayed at the top (to text_screen.top_row)
    output reg  [7:0] cursor_col,
    output reg  [7:0] cursor_row,  //! cursor position in SCREEN coordinates (to text_screen.cursor_row)
    output wire       cursor_enable,  //! draw the cursor at all (to text_screen.cursor_enable)
    output reg        rev_screen,
    output wire       blink_on,

    input  wire frame_end,  //! one pulse per frame, un-delayed (for blink) (from text_screen.frame_end)
    output reg  bell,  //! one pix_clk per BEL received (to terminal_top.bell)
    output reg  [3:0] leds,  //! DECLL (CSI Ps q) - the VT100 keyboard lamps L1-L4 (to terminal_top.leds)

    //! Box-charset debug taps (01-SEP-2026): live s_g0_gfx state, and a
    //! STICKY latch set the first time ESC 6 (NDSS6/Box) is ever received
    //! and never cleared by anything but rst_n - directly answers "has
    //! SINTRAN ever sent the box designation at all" without racing a
    //! live snapshot against a designation that toggles back off.
    output wire dbg_box_mode,
    output reg  dbg_saw_esc6
);
  assign dbg_box_mode = s_g0_gfx;

  localparam [15:0] BLANK_CELL = {8'h00, 8'h20};

  //--------------------------------------------------------------------------
  // Engine states - identical shape to terminal_ctrl.v (terminal-type-
  // agnostic screen mechanics).
  //--------------------------------------------------------------------------
  localparam [2:0] ST_RUN     = 3'd0;
  localparam [2:0] ST_CLEAR   = 3'd1;
  localparam [2:0] ST_COPY_RD = 3'd2;
  localparam [2:0] ST_COPY_WR = 3'd3;
  localparam [2:0] ST_APPLY   = 3'd4;

  reg [2:0] s_state;

  //--------------------------------------------------------------------------
  // Parser states. P_DLE1/P_DLE2 (binary cursor addressing) and P_DCS/
  // P_DCS_ESC (skip a soft-key programming block) are new; P_ESC/P_ESCINT/
  // P_CSI carry the same shape as terminal_ctrl.v, dispatching different
  // finals.
  //--------------------------------------------------------------------------
  localparam [2:0] P_GROUND  = 3'd0;
  localparam [2:0] P_ESC     = 3'd1;
  localparam [2:0] P_ESCINT  = 3'd2;
  localparam [2:0] P_CSI     = 3'd3;
  localparam [2:0] P_DLE1    = 3'd4;  //! DLE seen, awaiting the row byte
  localparam [2:0] P_DLE2    = 3'd5;  //! row byte seen, awaiting the column byte
  localparam [2:0] P_DCS     = 3'd6;  //! inside ESC P ... - discard until ESC \
  localparam [2:0] P_DCS_ESC = 3'd7;  //! saw ESC while skipping a DCS block

  reg [2:0] p_state;
  reg [7:0] s_par  [0:3];
  reg [2:0] s_npar;
  reg       s_priv;
  reg       s_ign;
  reg [7:0] s_escint;
  reg [4:0] s_dle_row;  //! latched between P_DLE1 and P_DLE2

  //--------------------------------------------------------------------------
  // Terminal modes and rendition state - the subset TDV mode actually uses.
  // No DECOM/DECAWM/DECSTBM/LNM: not confirmed in any TDV capture, and an
  // unimplemented mode already swallows safely (see dispatch_csi).
  //--------------------------------------------------------------------------
  reg s_cursor_vis;

  reg s_at_rev, s_at_bold, s_at_ul, s_at_blink;
  reg s_shift;    //! SO/SI: 0 = G0 active, 1 = G1 active
  reg s_g0_gfx;   //! G0 designated NDSS6/Box (ESC 6) - cell bit 12, font
                  //! page 3 (TDV2200 character set 2). SCONF draws with it
                  //! (live capture 31-AUG-2026). A locking designation.
  reg s_g1_gfx;   //! G1's own designation - never set (no NDSS G1 form
                  //! implemented yet), so G1 always reads as plain ASCII

  //! SS2 (ESC N) - single-shift the NEXT character only through the
  //! TDV2200's CHARACTER SET 2 (cell bit 12, font page 3 - the SAME set the
  //! ESC 6 Box designation selects; on a real TDV2200 they are one alphabet,
  //! and RetroCore maps both to bank 2). This is the mechanism PED's
  //! box-drawing (frame/fullbar in PLANC-SCREEN-H, per NDInsight's
  //! VTM-TERMINAL-INTERFACES.md) actually uses: RetroTerm's own
  //! TDVEmulatorBase.cs hardwires G2 to set 2 PERMANENTLY (no designation
  //! escape at all - see _g2CharacterSet's initializer), so ESC N + a data
  //! byte is the box-drawing character right there, not a delayed G0 mode
  //! change. Confirmed against a live capture 01-SEP-2026: PED repeats
  //! `ESC N <char>` once per graphics cell (e.g. the position ruler), never
  //! a lasting SO. One-shot: cleared the instant put_char consumes it.
  //!
  //! Set 2 is NOT the VT100 DEC Special Graphics alphabet. Measured on the
  //! Nexys 01-SEP-2026: with SS2 pointed at the DEC page, PED's frames came
  //! out as DIAMONDS, because in set 2 code 0x60 is the horizontal line
  //! (DEC: diamond) and the corners/tees sit at 0x61-0x6A. Page 3 carries
  //! the real TDV2200 character ROM as dumped in RetroCore's FontTDV2200.cs
  //! (font/tdv2200_set2_from_retrocore.py).
  reg s_ss2_armed;

  reg s_pending;  //! last-column flag - same VT100-derived autowrap behavior;
                  //! a TDV wraps too, and nothing in the captures says otherwise.

  reg [7:0] s_cl_row, s_cl_col, s_cl_erow, s_cl_ecol;

  reg [7:0] s_cp_dst;
  reg [7:0] s_cp_col;
  reg       s_cp_down;

  //! Deferred cursor-move commit - same two-phase reasoning as
  //! terminal_ctrl.v (latch operands one cycle, commit the next) to keep
  //! the combinational cone off the cursor registers short. TDV has no
  //! scroll region, so there is no floor/ceiling clamp to register
  //! separately - every move clamps straight to the screen edges.
  //!
  //! CUU/CUD/CUF/CUB exist because SINTRAN's own cursor-move ECHO for an
  //! arrow keypress is standard VT100 CSI (ESC[A/B/C/D), not another bare
  //! TDV byte - measured on real hardware (RetroCore trace, 31-AUG-2026):
  //! sending TDV-native FS (cursor up) got ESC[A back, sending BS (cursor
  //! left) got ESC[D back, and so on for all four. Found missing here:
  //! this parser only handled H/f (CUP) - A/B/C/D fell through the
  //! `default: ;` and never moved the cursor, which is why arrows looked
  //! completely dead in PED even though the keyboard side was correct all
  //! along and SINTRAN was receiving and processing every keypress.
  localparam [3:0] AP_CUP = 4'd0;
  localparam [3:0] AP_CUU = 4'd1;
  localparam [3:0] AP_CUD = 4'd2;
  localparam [3:0] AP_CUF = 4'd3;
  localparam [3:0] AP_CUB = 4'd4;
  //! CHA (col-absolute) and VPA (row-absolute) - ported from terminal_ctrl.v
  //! along with the cursor moves above: SINTRAN's type-93 line speaks the
  //! same base ECMA-48 CSI set VT100 does (RetroTerm's own class hierarchy
  //! has TDV2200Emulator derive from the identical core VT100Emulator
  //! uses, adding ND extensions on top - not a separate protocol), so this
  //! parser should carry the same base set terminal_ctrl.v already proved,
  //! not reinvent a narrower one.
  localparam [3:0] AP_CHA = 4'd5;
  localparam [3:0] AP_VPA = 4'd6;
  //! SGR walks s_par like AP_MODE/AP_LED do in terminal_ctrl.v - one
  //! parameter per clock via s_ap_idx, not the two-phase latch above.
  localparam [3:0] AP_SGR = 4'd7;
  reg [3:0] s_ap_kind;
  reg       s_ap_phase;
  reg [7:0] s_ap_row_q;  //! CUP/VPA's row operand, or CUU/CUD/CUF/CUB/CHA's count/column
  reg [7:0] s_ap_col_q;  //! CUP's column operand only
  reg [2:0] s_ap_idx;    //! SGR parameter walk index

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

  reg       s_wr_hold;
  reg [7:0] s_wr_char;

  //--------------------------------------------------------------------------
  // Address arithmetic - identical to terminal_ctrl.v.
  //--------------------------------------------------------------------------

  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

  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

  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

  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 - identical to terminal_ctrl.v.
  //--------------------------------------------------------------------------

  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;

  //--------------------------------------------------------------------------
  // Screen movers - identical mechanics to terminal_ctrl.v, no DECSTBM
  // region (a TDV capture has never shown one; the ring always covers the
  // whole screen, which is the same code path terminal_ctrl.v uses for its
  // no-region case).
  //--------------------------------------------------------------------------

  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

  task automatic scroll_up;
    begin
      top_row <= (top_row == ROWS - 1) ? 8'd0 : (top_row + 8'd1);
      start_clear(ROWS[7:0] - 8'd1, 8'd0, ROWS[7:0] - 8'd1, COLS[7:0] - 8'd1);
    end
  endtask

  task automatic scroll_down;
    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
  endtask

  //! Cursor down (VT/LF); scrolls at the bottom row.
  task automatic do_index;
    begin
      if (cursor_row == ROWS - 1) scroll_up;
      else cursor_row <= cursor_row + 8'd1;
    end
  endtask

  //! Cursor up (FS); scrolls at the top row.
  task automatic do_rev_index;
    begin
      if (cursor_row == 8'd0) scroll_down;
      else cursor_row <= cursor_row - 8'd1;
    end
  endtask

  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);
      // ONE graphics bit (cell bit 12 -> font page 3, the TDV2200 character
      // set 2). It is set by EITHER of the two things that select that set on
      // a real TDV2200, which are the same alphabet:
      //   - SS2 (ESC N): a single shift, so s_ss2_armed is true for exactly
      //     THIS character, whatever G0/G1 is invoked; and
      //   - the invoked set being NDSS6/Box (ESC 6): s_g0_gfx / s_g1_gfx, a
      //     locking designation that stays until the program changes it.
      // s_ss2_armed wins and is cleared here, unconditionally, the instant
      // this character is consumed - true single-shift semantics (one
      // character only). Bits 15:13 are spare.
      //
      // This is the build-24 structure, which synthesised and rendered
      // correctly on the Nexys; a two-bit / five-page variant (SS2 on its own
      // page) was dropped by Vivado's optimiser and aliased to page 0 - see
      // font_rom.v's header.
      ram_wdata <= {3'b000, (s_ss2_armed ? 1'b1 : (s_shift ? s_g1_gfx : s_g0_gfx)),
                    s_at_blink, s_at_ul, s_at_bold, s_at_rev, ch};
      s_ss2_armed <= 1'b0;
      if (cursor_col == COLS - 1) begin
        s_pending <= 1'b1;
      end else begin
        cursor_col <= cursor_col + 8'd1;
      end
    end
  endtask

  task automatic reset_modes;
    begin
      top_row      <= 8'd0;
      cursor_col   <= 8'd0;
      cursor_row   <= 8'd0;
      s_cursor_vis <= 1'b1;
      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_ss2_armed  <= 1'b0;
      s_pending    <= 1'b0;
      leds         <= 4'b0000;
      p_state      <= P_GROUND;
    end
  endtask

  //--------------------------------------------------------------------------
  // CSI final dispatch. A/B/C/D (cursor moves), G/d (CHA/VPA), H/f (CUP),
  // J (ED), K (EL) and m (SGR) are the base ECMA-48/VT100 set, ported from
  // terminal_ctrl.v - see the AP_CHA/AP_VPA declaration above for why: a
  // type-93 line speaks this same base set, confirmed on real hardware
  // for A/B/C/D (SINTRAN's cursor-move ECHO for a TDV keypress) and for m
  // (the captured PED startup's own "ESC[2;7m" dim-reverse status line).
  // Every other final, INCLUDING every h/l mode set/reset (mode 62 among
  // them, "function unknown" per the real TDV2200 termcap's own init
  // string) is the existing `default: ;` - swallowed, never printed,
  // never corrupts parser state. That is deliberate: it is what lets an
  // unknown mode number arrive safely instead of needing to be enumerated
  // first, and it is also why DECSTBM/scroll-region CSI (`r`) and the DEC
  // private modes stay out - no capture has shown TDV mode using them.
  //--------------------------------------------------------------------------

  task automatic dispatch_csi;
    input [7:0] fin;
    begin
      case (fin)
        "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

        "m": begin  // SGR - walk the parameter list, one per clock
          s_ap_kind <= AP_SGR;
          s_ap_idx  <= 3'd0;
          if (s_npar == 3'd0) s_npar <= 3'd1;  // no params = SGR 0 (reset)
          s_state   <= ST_APPLY;
        end

        "J": begin
          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
          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

        default: ;  // every h/l mode, every ND-private final: swallowed

      endcase
    end
  endtask

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

  always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      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_dle_row <= 5'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_CUP; s_ap_phase <= 1'b0; s_ap_idx <= 3'd0;
      s_ap_row_q <= 8'd0; s_ap_col_q <= 8'd0;
      dbg_saw_esc6 <= 1'b0;
      reset_modes;
    end else begin
      ram_we <= 1'b0;
      bell   <= 1'b0;

      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

        //----------------------------------------------------------------
        ST_COPY_RD: begin
          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;
            s_cp_dst <= s_cp_dst + 8'd1;
            s_state  <= ST_COPY_RD;
          end
        end

        //----------------------------------------------------------------
        // Deferred cursor-move commit for AP_CUP/CUU/CUD/CUF/CUB/CHA/VPA
        // (two-phase, see the declaration above for why), OR the SGR
        // parameter walk for AP_SGR (one param per clock, same shape as
        // terminal_ctrl.v's AP_SGR/AP_MODE/AP_LED).
        //----------------------------------------------------------------
        ST_APPLY: begin : apply
          reg [8:0] ap_sum;

          if (s_ap_kind != AP_SGR) begin
            if (!s_ap_phase) begin
              s_ap_row_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_CUP: begin
                  cursor_row <= (s_ap_row_q == 8'd0) ? 8'd0
                              : (s_ap_row_q >= ROWS) ? (ROWS[7:0] - 8'd1)
                              : (s_ap_row_q - 8'd1);
                  cursor_col <= (s_ap_col_q == 8'd0) ? 8'd0
                              : (s_ap_col_q >= COLS) ? (COLS[7:0] - 8'd1)
                              : (s_ap_col_q - 8'd1);
                end
                AP_CUU: cursor_row <=
                    ({1'b0, cursor_row} > {1'b0, s_ap_row_q})
                    ? (cursor_row - s_ap_row_q) : 8'd0;
                AP_CUD: begin
                  ap_sum = {1'b0, cursor_row} + {1'b0, s_ap_row_q};
                  cursor_row <= (ap_sum < ROWS) ? ap_sum[7:0] : (ROWS[7:0] - 8'd1);
                end
                AP_CUF: begin
                  ap_sum = {1'b0, cursor_col} + {1'b0, s_ap_row_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_row_q})
                    ? (cursor_col - s_ap_row_q) : 8'd0;
                AP_CHA: cursor_col <=
                    (s_ap_row_q <= COLS) ? (s_ap_row_q - 8'd1) : (COLS[7:0] - 8'd1);
                AP_VPA: cursor_row <=
                    (s_ap_row_q <= ROWS) ? (s_ap_row_q - 8'd1) : (ROWS[7:0] - 8'd1);
                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_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
        end

        //----------------------------------------------------------------
        ST_RUN: begin
          if (s_wr_hold) begin
            s_wr_hold <= 1'b0;
            put_char(s_wr_char);
          end else if (byte_valid) begin

            //--------------------------------------------------------------
            // RAW-BYTE-CAPTURE states first: P_DLE1/P_DLE2 (the two binary
            // cursor-addressing bytes) and P_DCS (the soft-key programming
            // payload) must see EVERY byte value literally, including ones
            // below 0x20 - a row/column byte or a payload byte is routinely
            // < 0x20 and must NOT be reinterpreted as a C0 control. This is
            // checked before the general C0 dispatch below, which otherwise
            // would steal those bytes (found by the testbench: DLE's own
            // row/col bytes, and the DCS terminator's own ESC, were both
            // being caught here first).
            //--------------------------------------------------------------
            if (p_state == P_DLE1) begin
              s_dle_row <= byte_data[4:0];
              p_state   <= P_DLE2;
            end else if (p_state == P_DLE2) begin
              p_state    <= P_GROUND;
              s_pending  <= 1'b0;
              cursor_row <= (s_dle_row >= ROWS[4:0]) ? (ROWS[7:0]-8'd1) : {3'b000, s_dle_row};
              cursor_col <= (byte_data[6:0] >= COLS[6:0]) ? (COLS[7:0]-8'd1) : {1'b0, byte_data[6:0]};
            end else if (p_state == P_DCS) begin
              if (byte_data == 8'h1B) p_state <= P_DCS_ESC;
              // else: still inside the DCS payload, discard the byte.
            end else if (p_state == P_DCS_ESC) begin
              if (byte_data == 8'h5C) p_state <= P_GROUND;  // ST - done (0x5C = backslash; 8'hXX not "\\", which Quartus' lexer rejects)
              else p_state <= P_DCS;  // not the terminator, keep skipping

            //--------------------------------------------------------------
            // C0 controls - TDV-NATIVE meanings, not ASCII ones. This block
            // is the core difference from terminal_ctrl.v. DLE and ESC hand
            // off to their own parser states; everything else executes
            // immediately without disturbing whatever state p_state is in
            // (matching terminal_ctrl.v's "an executable C0 arriving inside
            // a sequence executes without abandoning it" rule - real hosts
            // do this, and nothing here has evidence it does NOT apply to
            // a TDV too).
            //--------------------------------------------------------------
            end else if (byte_data < 8'h20) begin
              case (byte_data)
                8'h02, 8'h03: ;                                          // STX/ETX video off/on - not modeled
                8'h04: start_clear(cursor_row, 8'd0, cursor_row, COLS[7:0]-8'd1);  // EOT erase line
                8'h05: leds[0] <= 1'b1;                                  // ENQ LED1 on
                8'h06: leds[1] <= 1'b1;                                  // ACK LED2 on
                8'h07: bell <= 1'b1;                                     // BEL
                8'h08: begin s_pending <= 1'b0; if (cursor_col != 0) cursor_col <= cursor_col - 8'd1; end  // BS cursor left
                8'h09: begin                                             // HT
                  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: begin s_pending <= 1'b0; do_index; end            // LF cursor down
                8'h0B: begin s_pending <= 1'b0; do_index; end            // VT cursor down
                8'h0C: begin s_pending <= 1'b0; do_rev_index; end        // FF roll up (scroll back)
                8'h0D: begin s_pending <= 1'b0; cursor_col <= 8'd0; end  // CR
                8'h0E: s_shift <= 1'b1;                                  // SO -> G1
                8'h0F: s_shift <= 1'b0;                                  // SI -> G0
                8'h10: p_state <= P_DLE1;                                // DLE - binary cursor addressing
                8'h15: leds[2] <= 1'b1;                                  // NAK LED3 on
                8'h16: leds <= 4'b0000;                                  // SYN all lamps off
                8'h17: begin s_pending <= 1'b0; do_index; end            // ETB roll down (scroll forward)
                8'h18: begin s_pending <= 1'b0; if (cursor_col != COLS-1) cursor_col <= cursor_col + 8'd1; end  // CAN cursor right
                8'h19: start_clear(8'd0, 8'd0, ROWS[7:0]-8'd1, COLS[7:0]-8'd1);  // EM erase page
                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
                8'h1C: begin s_pending <= 1'b0; do_rev_index; end        // FS cursor up
                8'h1D: begin                                             // GS cursor home
                  s_pending  <= 1'b0;
                  cursor_row <= 8'd0;
                  cursor_col <= 8'd0;
                end
                default: ;  // NUL and anything else: dropped
              endcase

            end else begin
              case (p_state)

                //--------------------------------------------------------
                P_GROUND: begin
                  if (byte_data < 8'h7F) begin
                    if (s_pending) begin
                      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 as a display byte, and 0x80-0xFF: dropped.
                end

                // P_DLE1/P_DLE2/P_DCS/P_DCS_ESC are handled in the
                // raw-byte-capture branch above, before this switch is
                // ever reached - they cannot appear here.

                //--------------------------------------------------------
                P_ESC: begin
                  p_state <= P_GROUND;
                  case (byte_data)
                    "[": p_state <= P_CSI;
                    "P": p_state <= P_DCS;             // DCS - see above
                    8'h20, "#", "(", ")", "*", "+", "%": begin
                      s_escint <= byte_data;
                      p_state  <= P_ESCINT;
                    end
                    "c": begin                          // RIS-equivalent full reset
                      reset_modes;
                      start_clear(8'd0, 8'd0, ROWS[7:0] - 8'd1, COLS[7:0] - 8'd1);
                    end
                    // NDSS1-9: bare "ESC <digit>" character-set designation
                    // (Verilog/Terminals/docs/SPEC-tdv2200.md) - NOT VT100's
                    // three-byte "ESC ( <final>". Only NDSS6 (Box) actually
                    // renders differently (font page 3, confirmed against a
                    // live SCONF capture 31-AUG-2026: cell 0x60 printed as a
                    // literal backtick instead of a top-left corner before
                    // this was implemented). Any other designation - incl.
                    // "1" back to plain ASCII - falls back to page 0, same
                    // conservative choice as an unimplemented VT100 charset.
                    "1", "2", "3", "4", "5", "6", "7", "8", "9": begin
                      s_g0_gfx <= (byte_data == "6");
                      if (byte_data == "6") dbg_saw_esc6 <= 1'b1;
                    end
                    // SS2 (ESC N): single-shift the NEXT character only
                    // through the graphics font - see s_ss2_armed's
                    // declaration for why this, not NDSS6, is PED's real
                    // box-drawing mechanism. SS3 (ESC O) would shift to
                    // G3/GraphicsII (circles/shapes) - no font page built
                    // for it yet, swallowed same as before (default arm).
                    "N": s_ss2_armed <= 1'b1;
                    // "Q" (exit/enable EC switch) and anything else
                    // unrecognised: swallowed.
                    default: ;
                  endcase
                end

                //--------------------------------------------------------
                // ESC + intermediate: "#" (double-height/width lines) and
                // "%" (ISO 646 national variant) are parsed and dropped -
                // deferred, no live capture has shown them used yet. "(" ")"
                // "*" "+" (VT100's G0-G3 designation prefixes) are consumed
                // the same way in case anything ever sends them, but they
                // are not how a TDV designates its own character sets (see
                // the NDSS digit case in P_ESC above) - a byte here never
                // leaks as garbage text either way.
                //--------------------------------------------------------
                P_ESCINT: begin
                  if (byte_data >= 8'h20 && byte_data <= 8'h2F) begin
                    s_escint <= byte_data;
                  end else begin
                    p_state <= P_GROUND;  // final byte consumed, nothing acted on
                  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;
                    else if (s_npar < 3'd4) s_npar <= s_npar + 3'd1;
                    else s_ign <= 1'b1;
                  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;
                  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;
                  end
                end

                default: p_state <= P_GROUND;

              endcase
            end
          end
        end

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

      endcase
    end
  end

endmodule

`default_nettype wire