term_panel¶
Source: Verilog/Terminals/rtl/term_panel.v
Where it sits (Nexys): nd120_nexys4ddr_top > terminal_top > term_panel
- instance path: TERMINAL.g_panel.PANEL
Used in: terminal_top (Nexys, MiSTer, MEGA65 R6, MEGA65 R3)
Contains: font_rom, term_panel_rom
Module hierarchy - All modules

Schematic¶
Drawn from the Verilog: the yosys netlist of the Nexys 4 DDR build, instance TERMINAL.g_panel.PANEL. 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).
Parameters¶
| Parameter | Default |
|---|---|
FONT_FILE |
"../font/font8x16.hex" |
ORIGIN_X |
80 |
ORIGIN_Y |
420 |
ORIGIN_X2 |
160 |
ORIGIN_Y2 |
440 |
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 | [11:0] |
x |
visible pixel column, 0..H_VISIBLE-1 (only valid while de) (from vga_timing.x) |
| input | [11:0] |
y |
visible pixel row, 0..V_VISIBLE-1 (only valid while de) (from vga_timing.y) |
| input | 1 |
mode |
0 = 1x glyphs, 1 = 2x |
| input | 1 |
enable |
0 = draw nothing at all |
| input | 1 |
frame_tick |
one pulse per frame, un-delayed (for blink) (from text_screen.frame_end) |
| input | [3:0] |
pil |
current program level, 0..15 |
| input | [15:0] |
actlv |
|
| input | [3:0] |
utilization |
0..8, eighths of a bargraph |
| input | [3:0] |
cache_hit |
0..8, eighths of a bargraph |
| input | [1:0] |
ring |
PCR protect ring, 0..3 |
| input | 1 |
paging_on |
PONI |
| input | 1 |
interrupt_on |
IONI |
| input | 1 |
running |
CPU running (already de-inverted) |
| input | 1 |
cpu_red |
|
| input | 1 |
cpu_green |
|
| input | 1 |
hdd_rd |
|
| input | 1 |
hdd_wr |
|
| input | 1 |
flp_rd |
|
| input | 1 |
flp_wr |
|
| input | [4:0] |
up_hours |
|
| input | [5:0] |
up_minutes |
|
| input | [5:0] |
up_seconds |
|
| input | [15:0] |
mips |
|
| output | 1 |
active |
this pixel belongs to the panel |
| output | [2:0] |
colour |
palette index, see the localparams below |
Verilog source¶
Verilog/Terminals/rtl/term_panel.v on GitHub.
Show the Verilog of term_panel (762 lines)
//============================================================================
//! ND-120 operator panel, drawn below the console text.
//!
//! Part of the board-independent terminal core (Verilog/Terminals/).
//!
//! Recreates the machine's own folio panel in the empty screen area under the
//! 80x25 grid. Geometry and legends come from the photographed hardware
//! (Pictures/ronny/20230618_193546.jpg, active-levels.png), and the FIELDS come
//! from the original schematic: the ND-120's panel processor - an MC68705U3 at
//! board position 35C, sheet 40 of 50 dated 5-OCT-1987, transcribed in this
//! repo as IO_PANCAL_40.v - samples exactly these signals on its Port D:
//!
//! PD0/PD1 PCR0/PCR1 -> PROTECT RING
//! PD2 PONI -> PAGING ON/OFF
//! PD3 IONI -> INTERRUPT ON/OFF
//! PD4 LHIT -> CACHE HIT RATE
//! PD5 LEV0 -> UTILIZATION (idle == running at level 0)
//!
//! So the panel is a VIEW of signals the machine already computes. Nothing here
//! is invented, and where a field has no honest source it is not drawn.
//!
//! TWO DELIBERATE DEPARTURES FROM THE REAL PANEL, both because the alternative
//! would be a display that looks right and is wrong:
//!
//! CURRENT LEVEL, not ACTIVE LEVEL. The real display lights every level that
//! is active at once, fed from the microprogram in PANC packets, with
//! afterglow so a single instruction on a level stays visible. We have PIL -
//! the one level running now. The picture would be identical and the claim
//! would not, so the caption is changed. There is NO afterglow any more: a
//! lamp shows the levels used during the frame being drawn, and nothing
//! older (see the per-frame occupancy note below for why the afterglow was
//! the bug that lit the whole row).
//!
//! UP:hh:mm:ss, not DAY/TIME. The real clock is a battery-backed MM58274 that
//! survives a power failure. There is no panel processor in our RTL and no
//! calendar; this counts from reset and is labelled as uptime.
//!
//! It has its OWN font ROM rather than sharing the console's. That costs about
//! two block RAMs and buys the absence of an arbiter between two renderers on a
//! 2-clock pipeline - a trade worth making on a part with 100+ of them.
//!
//! Written 28-AUG-2026.
//============================================================================
`default_nettype none
module term_panel #(
parameter FONT_FILE = "../font/font8x16.hex",
//! Where the panel sits, in LOGICAL pixels (before the mode's 2x scaling).
//! 80 columns of 8 pixels is 640 wide - the same width as the console grid,
//! which is why it lines up underneath it with no arithmetic.
parameter integer ORIGIN_X = 80,
parameter integer ORIGIN_Y = 420,
//! Mode-1 origin, also in LOGICAL pixels - i.e. physical/2, because at 2x
//! every logical pixel lasts two real ones. The text grid sits differently
//! in the two modes, so the panel has to as well; sharing one origin is
//! what pushed the panel off the bottom of a 1080p screen.
parameter integer ORIGIN_X2 = 160,
parameter integer ORIGIN_Y2 = 440
) (
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)
//! Raw pixel counters from the timing generator, and the mode.
//!
//! This module's pipeline is THREE clocks deep, one more than the text
//! path, because it had to be split to close timing at 148.4 MHz. That
//! difference is deliberate and harmless: the panel occupies its own screen
//! region, so nothing has to line up with the text grid pixel for pixel and
//! the whole panel simply lands one pixel further right.
input wire [11:0] x, //! visible pixel column, 0..H_VISIBLE-1 (only valid while de) (from vga_timing.x)
input wire [11:0] y, //! visible pixel row, 0..V_VISIBLE-1 (only valid while de) (from vga_timing.y)
input wire mode, //! 0 = 1x glyphs, 1 = 2x
input wire enable, //! 0 = draw nothing at all
//! One pulse per frame. EVERYTHING displayed is latched on it - see the
//! frame-snapshot note below.
input wire frame_tick, //! one pulse per frame, un-delayed (for blink) (from text_screen.frame_end)
// ---- what the machine is doing -------------------------------------
input wire [3:0] pil, //! current program level, 0..15
//! ACTIVE LEVEL word from the panel processor: the two data bytes of the
//! microcode's LDPANC 0x0A command, sent every 20 ms. This IS what the
//! real panel's row shows. 0 until the first command has arrived; the
//! row then switches from the PIL-derived view to this, for good.
input wire [15:0] actlv,
input wire [3:0] utilization, //! 0..8, eighths of a bargraph
input wire [3:0] cache_hit, //! 0..8, eighths of a bargraph
input wire [1:0] ring, //! PCR protect ring, 0..3
input wire paging_on, //! PONI
input wire interrupt_on, //! IONI
input wire running, //! CPU running (already de-inverted)
//! Disc activity, one bit per direction per device. These are ACCESS
//! STROBES - high for a request, not for the duration of a transfer - so
//! they are held below rather than displayed directly; a single sector
//! request is far too brief to see on a 60 Hz screen.
//! The CPU board's own two lamps, ND3202D.v:143 LED[0]/LED[1]. RED =
//! Master Clear / MACL in progress, GREEN = initialisation complete,
//! which the microcode writes ONLY at MACL2 - so GREEN lit means the
//! CPU self-test PASSED. See boot-sequence.md section 10.
input wire cpu_red,
input wire cpu_green,
input wire hdd_rd,
input wire hdd_wr,
input wire flp_rd,
input wire flp_wr,
input wire [4:0] up_hours,
input wire [5:0] up_minutes,
input wire [5:0] up_seconds,
//! MIPS from mips_counter, {d3,d2,d1,d0} BCD, rendered as "d3d2.d1d0".
//! Updates once per second, so the per-frame latch below is plenty.
input wire [15:0] mips,
output wire active, //! this pixel belongs to the panel
output wire [2:0] colour //! palette index, see the localparams below
);
// Palette indices. The BOARD maps these to its own colour depth - 12-bit on
// the Nexys ladder, 24-bit on MiSTer - so nothing board-specific lives here.
localparam [2:0] C_BLACK = 3'd0;
localparam [2:0] C_TEXT = 3'd1; //! console text ink
localparam [2:0] C_FASCIA = 3'd2; //! the panel's near-black body
localparam [2:0] C_SILK = 3'd3; //! silkscreen label white
localparam [2:0] C_LCD = 3'd4; //! LCD ground, green-grey
localparam [2:0] C_LCDINK = 3'd5; //! LCD segment, dark olive
localparam [2:0] C_LIT = 3'd6; //! lit legend, red
localparam [2:0] C_DARK = 3'd7; //! unlit legend
// Block glyphs synthesised into the font's control-code slots by
// font/make_font.py - see the note there.
localparam [7:0] G_BAR0 = 8'h01; //! +0..8 for eighths filled
//! A lamp is two cells wide, so each half has its own glyph - the outer pixel
//! of each is blank, giving 14 lit pixels with a 2 px gap to the next level.
//! One glyph used twice either runs the lamps together (full width) or splits
//! each one down the middle (narrow).
localparam [7:0] G_LEVEL_ON_L = 8'h0A;
localparam [7:0] G_LEVEL_ON_R = 8'h0E;
//! No unlit-lamp glyph. There was never a thin bar on the real panel
//! (Ronny, 28-AUG-2026) - a cell is showing its segment or it is blank. The
//! 0x0B / 0x0F half-glyphs are still generated by make_font.py but nothing
//! draws them; left in the font rather than regenerating it for a removal.
localparam integer PANEL_COLS = 80;
localparam integer PANEL_ROWS = 5;
//--------------------------------------------------------------------------
// Which panel cell is this pixel in
//--------------------------------------------------------------------------
//! Logical position inside the panel. At 2x every logical pixel lasts two
//! real ones, exactly as in text_screen - one shift, no other difference.
wire [11:0] s_lx = mode ? {1'b0, x[11:1]} : x;
wire [11:0] s_ly = mode ? {1'b0, y[11:1]} : y;
wire [11:0] s_org_x = mode ? ORIGIN_X2[11:0] : ORIGIN_X[11:0];
wire [11:0] s_org_y = mode ? ORIGIN_Y2[11:0] : ORIGIN_Y[11:0];
wire signed [12:0] s_px = $signed({1'b0, s_lx}) - $signed({1'b0, s_org_x});
wire signed [12:0] s_py = $signed({1'b0, s_ly}) - $signed({1'b0, s_org_y});
wire s_in_panel = enable &&
(s_px >= 0) && (s_px < PANEL_COLS * 8) &&
(s_py >= 0) && (s_py < PANEL_ROWS * 16);
wire [11:0] s_ux = s_px[11:0];
wire [11:0] s_uy = s_py[11:0];
wire [6:0] c_col = s_ux[9:3];
wire [2:0] c_row = s_uy[6:4];
wire [2:0] c_pixel_col = s_ux[2:0];
wire [3:0] c_pixel_row = s_uy[3:0];
//--------------------------------------------------------------------------
// PIPELINE STAGE 1 - registered cell position
//
// Everything above is arithmetic on the raw pixel counters: subtract the
// origin, compare the bounds, slice out row and column. Everything below is a
// ROM lookup, a character mux and a font-ROM address. Doing all of it in one
// clock is what failed timing at 148.4 MHz:
//
// Source: TERMINAL/SCREEN/TIMING/s_vcount_reg[2]
// Dest: PANEL/PANELFONT font ROM address
// Data Path Delay: 8.013 ns Logic Levels: 12 (budget 6.737 ns)
// Slack: -1.889 ns
//
// Splitting it here costs one clock of latency and nothing else. The panel is
// its own screen region - nothing has to line up with the text grid pixel for
// pixel - so the whole panel simply lands one pixel further right, which no
// monitor and no person can see.
//--------------------------------------------------------------------------
reg [6:0] r1_col;
reg [2:0] r1_row;
reg [2:0] r1_pixel_col;
reg [3:0] r1_pixel_row;
reg r1_in_panel;
always @(posedge clk) begin
r1_col <= c_col;
r1_row <= c_row;
r1_pixel_col <= c_pixel_col;
r1_pixel_row <= c_pixel_row;
r1_in_panel <= s_in_panel;
end
wire [6:0] s_col = r1_col;
wire [2:0] s_row = r1_row;
wire [2:0] s_pixel_col = r1_pixel_col;
wire [3:0] s_pixel_row = r1_pixel_row;
//--------------------------------------------------------------------------
// The static layer
//--------------------------------------------------------------------------
wire [7:0] s_rom_char;
term_panel_rom ROM (
.addr({2'b0, s_row} * PANEL_COLS[8:0] + {2'b0, s_col}),
.data(s_rom_char)
);
//--------------------------------------------------------------------------
// The live layer - what goes in the cells the ROM left as 0x00
//--------------------------------------------------------------------------
// Column origins come from the generated ROM's own localparams in spirit;
// repeated here because Verilog cannot read a child's parameters. make_panel.py
// prints them into the ROM so the two can be checked against each other.
localparam integer COL_UTIL_BAR = 1;
localparam integer UTIL_BAR_W = 11;
localparam integer COL_HIT_BAR = 14;
localparam integer HIT_BAR_W = 10;
localparam integer COL_RING_VALUE = 35;
localparam integer COL_INT_VALUE = 43;
localparam integer COL_PAGE_VALUE = 53;
localparam integer COL_UPTIME_VALUE = 4;
localparam integer COL_LEVELS = 24;
localparam integer COL_HDD_R = 60;
localparam integer COL_HDD_W = 62;
localparam integer COL_FLP_R = 65;
localparam integer COL_FLP_W = 67;
localparam integer COL_LEGEND = 72;
localparam integer COL_MIPS_VALUE = 63;
localparam integer COL_CPU_RED = 64;
localparam integer COL_CPU_GREEN = 66;
//! Which of the 16 level cells this column is in, and whether it is lit.
//!
//! COUNTED FROM THE RIGHT. make_panel.py prints the ruler high-to-low - level
//! 15 leftmost, level 0 rightmost, exactly as on the real fascia:
//! col = COL_LEVELS + (15 - lvl) * LEVEL_CELL_W
//! Counting left to right here mirrored the display against its own ruler, so
//! an idle machine sitting on level 1 or 2 lit the cells under the digits 14
//! and 13. From hardware: "level 14,14,13 is always on".
wire [3:0] s_level_pair = (s_col - COL_LEVELS[6:0]) >> 1;
wire [3:0] s_level_index = 4'd15 - s_level_pair;
//! ROW 2 ONLY. Without the row test this claimed columns 24-55 on every row,
//! and because it is the first branch of the mux it overwrote the PROTECT
//! RING digit, INTERRUPT and PAGING values that live on row 1. From hardware:
//! those fields "seem a bit random".
//!
//! ONE column of each pair, not both. The cell is 2 columns wide so the octal
//! ruler's two-digit labels fit underneath, but the lamp itself is a single
//! square - two filled columns read as two lamps per level.
wire s_in_levels = (s_row == 3'd2) &&
(s_col >= COL_LEVELS[6:0]) &&
(s_col < COL_LEVELS[6:0] + 7'd32);
wire s_level_lamp = s_in_levels && (s_col[0] == COL_LEVELS[0]);
//--------------------------------------------------------------------------
// THE FRAME SNAPSHOT
//
// Every value the panel draws is captured once per frame and held for all of
// it. Not tidiness - the fix for what the display actually looked like.
//
// The inputs change on the CPU's schedule, not the beam's: PIL moves every
// few microseconds, the glow counters decay continuously, the meters publish
// whenever their window closes. Rendering straight from them lets a lamp be
// lit while the beam draws its top half and dark by the bottom - a single
// glyph not even rendering consistently down its own 16 rows. That is what
// "shadows on the boxes" and "unstable rendering" were on hardware.
//
// The STATUS fields go slower still. PROTECT RING, INTERRUPT and PAGING
// flipping the instant an instruction changes them is not what the real
// machine did - its panel processor sends packets every 20 ms and the LCD has
// response time on top, which Ronny puts at 200-300 ms. SLOW_FRAMES gives
// ~267 ms. A field that changes 60 times a second is a strobe, not a display.
//--------------------------------------------------------------------------
localparam integer SLOW_FRAMES = 16; //! ~267 ms at 60 Hz
reg [4:0] s_slow_cnt;
reg [3:0] r_disk; //! {flp_wr, flp_rd, hdd_wr, hdd_rd}, held
reg r_cpu_red, r_cpu_green; //! CPU board lamps, frame-held
reg [15:0] r_lamp;
reg [3:0] r_util, r_hit, r_pil;
reg [1:0] r_ring;
reg r_paging, r_interrupt, r_running;
reg [4:0] r_up_h;
reg [5:0] r_up_m, r_up_s;
reg [15:0] r_mips;
//! PER-FRAME OCCUPANCY - a lamp means "the CPU was on this level at some
//! point during the frame you are looking at". Nothing older than that.
//!
//! The ND-100 Reference Manual, on the ACTIVE LEVEL display:
//!
//! "There are 16 positions (0-15), one for each level. A one ( I ) is set
//! in ONE of these positions, indicating the active level."
//!
//! Singular. One position at a time. The problem is that a 60 Hz screen
//! cannot show a level the CPU visits for a microsecond unless something
//! stretches the visit, and the way it was stretched here was the bug.
//!
//! HISTORY OF THE BUG (measured on the Nexys with an ILA, 29-AUG-2026, TPE
//! INSTRUCTION test): PIL pulses to 12, 13, 14, 15 for exactly 15 CPU clocks
//! each - about 1 us - with ~150 clocks at level 0 in between. The machine
//! never RUNS at those levels, it touches them. The previous code reloaded
//! an afterglow counter for `pil` on EVERY clock and decayed it one step per
//! frame over 63 frames (~1 s; the version before that used 15 frames and
//! had the same fault four times less visibly). A 1 us blip therefore became
//! a lamp lit for a whole second, every level the microcode brushes past
//! stayed lit, and the row saturated - "ACTIVE LEVEL shows all 16 segments".
//!
//! Now: `s_seen` accumulates a one-hot of every level PIL takes during the
//! frame being drawn. At `frame_tick` that vector is latched into `r_lamp`
//! (below) and `s_seen` starts again, seeded with the level current at the
//! tick so a level that is live across the boundary is never dropped. The
//! smear is one frame (~16 ms) instead of ~1 s, and a level the CPU has
//! stopped using is dark on the very next frame. It still shows several
//! lamps when the CPU really does touch several levels inside one frame,
//! because that is true.
//!
//! TWO states: full box or blank. An earlier version left a thin bar behind
//! as a "fading" trace, which read as debris. The real LCD cell is either
//! showing its segment or it is not.
//!
//! Counted in FRAMES, not clocks: the frame rate is 60 Hz in both video
//! modes while the pixel clock is not, so the window is the same length at
//! 800x600 and at 1080p.
//!
//! What the real machine shows here is ACTLV - the "levels active" word the
//! microcode sends the panel processor as LDPANC 0x0A - which is state, not
//! a sampled bus. PIL has no counterpart on the real panel. This is the
//! least-wrong thing to do with PIL; a latched-ACTLV row is the right end
//! state now that panel commands reach the FIFO (commits 1a8c0e1/d80ef7f).
reg [15:0] s_seen;
wire [15:0] s_pil_onehot = 16'd1 << pil;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) s_seen <= 16'd0;
else if (frame_tick) s_seen <= s_pil_onehot; // new frame, seeded with the current level
else s_seen <= s_seen | s_pil_onehot; // every level touched this frame
end
//! All 16 lamps as one vector, so the frame latch captures them together.
//! Disc lamps get the same treatment as the level afterglow, and for the
//! same reason: a disc request is a strobe a few clocks long, which at 60 Hz
//! would be invisible almost every time it happened. Held for ~0.25 s so a
//! single sector access registers as a visible blink.
reg [3:0] s_disk_hold[0:3];
wire [3:0] s_disk_in = {flp_wr, flp_rd, hdd_wr, hdd_rd};
integer di;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (di = 0; di < 4; di = di + 1) s_disk_hold[di] <= 4'd0;
end else begin
for (di = 0; di < 4; di = di + 1) begin
if (s_disk_in[di[1:0]]) s_disk_hold[di] <= 4'hF;
else if (frame_tick && s_disk_hold[di] != 4'd0)
s_disk_hold[di] <= s_disk_hold[di] - 4'd1;
end
end
end
wire [3:0] s_disk_now;
genvar dk;
generate
for (dk = 0; dk < 4; dk = dk + 1) begin : g_disk
assign s_disk_now[dk] = (s_disk_hold[dk] != 4'd0);
end
endgenerate
//! The level lamps for the frame about to be drawn: everything seen since
//! the last tick, plus the level current AT the tick (which `s_seen` only
//! folds in on the next clock, so it is added here to avoid missing it).
wire [15:0] s_lamp_now_pil = s_seen | s_pil_onehot;
//! WHICH SOURCE THE ROW SHOWS (29-AUG-2026). PIL is the CGA's level bus,
//! and the ILA showed it stepping through 12, 13, 14, 15 for ~1 us each
//! every few hundred clocks - the microcode selecting other levels'
//! registers, not the machine running there. Any time-based view of PIL,
//! per frame included, lights the whole row on a machine that is idle.
//! Ronny reported exactly that (29-AUG-2026, "the ACTIVE LEVEL bug has not
//! been fixed"). The real panel never looks at PIL: the microcode sends it
//! the ACTIVE LEVEL word (LDPANC 0x0A) and the 68705 displays that. So once
//! an ACTLV word has arrived the row shows it, latched per frame, and the
//! PIL view is only the fallback for builds without the panel processor.
reg s_actlv_seen;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) s_actlv_seen <= 1'b0;
else if (actlv != 16'd0) s_actlv_seen <= 1'b1;
end
//! HOW LONG AN ACTLV LAMP STAYS ON (29-AUG-2026, evening). The first
//! ACTLV row sampled the word once, AT the frame tick, and showed that for
//! one frame. Ronny on the Nexys: the all-16-lit bug is gone, but "the
//! active box flickers like stupid when there is a lot of changes" - a
//! level that was active for a few ms between two ticks was never shown,
//! and one that was active at the tick was shown for 16 ms and dropped.
//! Two changes, both in frames so they are the same at 800x600 and 1080p:
//!
//! 1. `s_actlv_acc` ORs the word over the whole frame, exactly as `s_seen`
//! does for PIL, so a level active anywhere inside a frame is shown.
//! 2. each lamp then stays on for ACTLV_HOLD_FRAMES frames counted from
//! the frame it was last seen in. Ronny asked for the old time doubled:
//! 1 frame (16 ms) -> 2 frames (33 ms). Raise the parameter if the row
//! still flickers; the counter is 3 bits wide, so up to 7 frames
//! (117 ms) needs no other change. The PIL fallback below keeps its
//! strict one-frame view - its testbench pins that down, and it is not
//! what the board shows.
localparam integer ACTLV_HOLD_FRAMES = 2;
reg [15:0] s_actlv_acc;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) s_actlv_acc <= 16'd0;
else if (frame_tick) s_actlv_acc <= actlv; // new frame, seeded with the word now
else s_actlv_acc <= s_actlv_acc | actlv; // every level active this frame
end
//! everything seen since the last tick, plus the word AT the tick (which
//! `s_actlv_acc` only folds in on the next clock)
wire [15:0] s_actlv_frame = s_actlv_acc | actlv;
//! Per-lamp hold, in frames still to show AFTER the frame it was seen in.
reg [2:0] s_actlv_hold[0:15];
wire [15:0] s_actlv_held;
integer hi;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (hi = 0; hi < 16; hi = hi + 1) s_actlv_hold[hi] <= 3'd0;
end else if (frame_tick) begin
for (hi = 0; hi < 16; hi = hi + 1) begin
if (s_actlv_frame[hi]) s_actlv_hold[hi] <= ACTLV_HOLD_FRAMES - 1;
else if (s_actlv_hold[hi] != 3'd0) s_actlv_hold[hi] <= s_actlv_hold[hi] - 3'd1;
end
end
end
genvar hk;
generate
for (hk = 0; hk < 16; hk = hk + 1) begin : g_actlv_hold
assign s_actlv_held[hk] = (s_actlv_hold[hk] != 3'd0);
end
endgenerate
wire [15:0] s_lamp_now = s_actlv_seen ? (s_actlv_frame | s_actlv_held) : s_lamp_now_pil;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
s_slow_cnt <= 5'd0;
r_disk <= 4'd0;
r_cpu_red <= 1'b0; r_cpu_green <= 1'b0;
r_lamp <= 16'd0; r_util <= 4'd0; r_hit <= 4'd0; r_pil <= 4'd0;
r_ring <= 2'd0; r_paging <= 1'b0; r_interrupt <= 1'b0; r_running <= 1'b0;
r_up_h <= 5'd0; r_up_m <= 6'd0; r_up_s <= 6'd0;
r_mips <= 16'd0;
end else if (frame_tick) begin
// Per frame: the things that must keep up with the machine. The lamp
// vector especially - it is the occupancy of exactly this frame, and
// sampling it any slower would either drop visits or smear them.
r_lamp <= s_lamp_now;
r_disk <= s_disk_now;
// Held per frame like everything else - these are steady states, not
// pulses, so they need no glow counter, only a stable value to draw.
r_cpu_red <= cpu_red;
r_cpu_green <= cpu_green;
r_util <= utilization;
r_hit <= cache_hit;
r_pil <= pil;
r_up_h <= up_hours;
r_up_m <= up_minutes;
r_up_s <= up_seconds;
r_mips <= mips;
if (s_slow_cnt == SLOW_FRAMES[4:0] - 5'd1) begin
s_slow_cnt <= 5'd0;
r_ring <= ring;
r_paging <= paging_on;
r_interrupt <= interrupt_on;
r_running <= running;
end else begin
s_slow_cnt <= s_slow_cnt + 5'd1;
end
end
end
//! Lit means "the CPU was on this level during this frame". Nothing is
//! drawn otherwise - see the note on two states above.
wire s_level_lit = r_lamp[s_level_index];
//! Uptime digits. Two cells per field plus the colons, laid out hh:mm:ss.
wire [3:0] s_up_col = s_col - COL_UPTIME_VALUE[6:0];
reg [7:0] s_uptime_char;
always @(*) begin
case (s_up_col)
4'd0: s_uptime_char = 8'h30 + {4'b0, r_up_h / 5'd10};
4'd1: s_uptime_char = 8'h30 + {4'b0, r_up_h % 5'd10};
4'd2: s_uptime_char = ":";
4'd3: s_uptime_char = 8'h30 + {4'b0, r_up_m / 6'd10};
4'd4: s_uptime_char = 8'h30 + {4'b0, r_up_m % 6'd10};
4'd5: s_uptime_char = ":";
4'd6: s_uptime_char = 8'h30 + {4'b0, r_up_s / 6'd10};
4'd7: s_uptime_char = 8'h30 + {4'b0, r_up_s % 6'd10};
default: s_uptime_char = 8'h20;
endcase
end
//! MIPS digits, "XX.XX". The dot is drawn here rather than stored in the
//! ROM so the whole 5-cell field is one dynamic run, like uptime.
wire [2:0] s_mips_col = s_col[2:0] - COL_MIPS_VALUE[2:0];
reg [7:0] s_mips_char;
always @(*) begin
case (s_mips_col)
3'd0: s_mips_char = 8'h30 + {4'b0, r_mips[15:12]};
3'd1: s_mips_char = 8'h30 + {4'b0, r_mips[11:8]};
3'd2: s_mips_char = ".";
3'd3: s_mips_char = 8'h30 + {4'b0, r_mips[7:4]};
3'd4: s_mips_char = 8'h30 + {4'b0, r_mips[3:0]};
default: s_mips_char = 8'h20;
endcase
end
//! ON / OFF, three cells wide so "ON " and "OFF" both fit.
function [7:0] onoff_char;
input integer offset;
input on;
begin
if (on) onoff_char = (offset == 0) ? "O" : (offset == 1) ? "N" : " ";
else onoff_char = (offset == 0) ? "O" : (offset == 1) ? "F" : "F";
end
endfunction
//! The bargraphs. A cell shows a full block if it is entirely below the
//! level, an empty one if entirely above - the same eighths the real LCD
//! bargraph grows in.
function [7:0] bar_char;
input [3:0] value; // 0..8 eighths of the whole bar
input [3:0] bar_cell; // which cell of the bar - NOT `cell`, which is a
// Verilog-2001 config keyword and a parse error
input [3:0] width;
reg [7:0] filled;
begin
filled = (value * width) / 4'd8;
bar_char = (bar_cell < filled[3:0]) ? (G_BAR0 + 8'd8) : G_BAR0;
end
endfunction
reg [7:0] s_live_char;
reg [2:0] s_live_colour;
always @(*) begin
s_live_char = 8'h20;
s_live_colour = C_LCDINK;
if (s_in_levels) begin
// Only the first column of the pair carries the lamp; the second is the
// gap between lamps, so each level reads as one square.
// Both columns, each with its own half-glyph, so the pair forms one
// 14 px lamp with a clear gap to the next level. Three states: a full box
// for the level running now, a thin bar for one visited in the last
// quarter second, nothing at all otherwise.
s_live_char = !s_level_lit ? 8'h20
: s_level_lamp ? G_LEVEL_ON_L
: G_LEVEL_ON_R;
s_live_colour = C_LCDINK;
end else if (s_col >= COL_UTIL_BAR[6:0] &&
s_col < COL_UTIL_BAR[6:0] + UTIL_BAR_W[6:0] && s_row == 3'd1) begin
s_live_char = bar_char(r_util, s_col - COL_UTIL_BAR[6:0], UTIL_BAR_W[3:0]);
s_live_colour = C_LCDINK;
end else if (s_col >= COL_HIT_BAR[6:0] &&
s_col < COL_HIT_BAR[6:0] + HIT_BAR_W[6:0] && s_row == 3'd1) begin
s_live_char = bar_char(r_hit, s_col - COL_HIT_BAR[6:0], HIT_BAR_W[3:0]);
s_live_colour = C_LCDINK;
end else if (s_col == COL_RING_VALUE[6:0] && s_row == 3'd1) begin
s_live_char = 8'h30 + {6'b0, r_ring};
s_live_colour = C_LCDINK;
end else if (s_col >= COL_INT_VALUE[6:0] && s_col < COL_INT_VALUE[6:0] + 7'd3
&& s_row == 3'd1) begin
s_live_char = onoff_char(s_col - COL_INT_VALUE[6:0], r_interrupt);
s_live_colour = C_LCDINK;
end else if (s_col >= COL_PAGE_VALUE[6:0] && s_col < COL_PAGE_VALUE[6:0] + 7'd3
&& s_row == 3'd1) begin
s_live_char = onoff_char(s_col - COL_PAGE_VALUE[6:0], r_paging);
s_live_colour = C_LCDINK;
end else if (s_row == 3'd2 && s_col >= COL_UPTIME_VALUE[6:0]
&& s_col < COL_UPTIME_VALUE[6:0] + 7'd8) begin
s_live_char = s_uptime_char;
s_live_colour = C_LCDINK;
end else if (s_row == 3'd2 && s_col >= COL_MIPS_VALUE[6:0]
&& s_col < COL_MIPS_VALUE[6:0] + 7'd5) begin
s_live_char = s_mips_char;
s_live_colour = C_LCDINK;
end else if (s_row == 3'd1 &&
(s_col == COL_HDD_R[6:0] || s_col == COL_HDD_W[6:0] ||
s_col == COL_FLP_R[6:0] || s_col == COL_FLP_W[6:0])) begin
//! The letter appears ONLY while that disc is active. An idle lamp is
//! blank, not a dimmed letter - a letter that is always there reads as a
//! label rather than an indicator, and the LCD should be empty when
//! nothing is happening. The cell is reversed at the same time (see
//! s_disk_reversed), so an active lamp is a filled box with the letter
//! knocked out of it.
s_live_char = (s_col == COL_HDD_R[6:0] && r_disk[0]) ? "R"
: (s_col == COL_HDD_W[6:0] && r_disk[1]) ? "W"
: (s_col == COL_FLP_R[6:0] && r_disk[2]) ? "R"
: (s_col == COL_FLP_W[6:0] && r_disk[3]) ? "W"
: 8'h20;
s_live_colour = C_LCDINK;
end else if (s_col >= COL_LEGEND[6:0] && s_col < COL_LEGEND[6:0] + 7'd7) begin
// The two lit legend words, driven by the RUN line. On the real fascia
// only the currently usable words are lit; here RUNNING and OPCOM are
// mutually exclusive, which is what the machine actually reports.
if (s_row == 3'd1) begin
s_live_char = 8'h20;
s_live_colour = r_running ? C_LIT : C_DARK;
end else begin
s_live_char = 8'h20;
s_live_colour = C_DARK;
end
end
end
//--------------------------------------------------------------------------
// Compose: static text where the ROM has some, live text where it does not
//--------------------------------------------------------------------------
wire s_is_dynamic = (s_rom_char == 8'h00);
wire [7:0] c_char = s_is_dynamic ? s_live_char : s_rom_char;
//--------------------------------------------------------------------------
// PIPELINE STAGE 2 - registered character, straight into the font ROM
//
// The layout ROM is a 169-entry case and the live-value mux sits on top of
// it. Registering the result here means the font ROM sees a flop output
// rather than the far end of that logic, which is the other half of the
// 8 ns path above.
//--------------------------------------------------------------------------
reg [7:0] r2_char;
always @(posedge clk) r2_char <= c_char;
wire [7:0] s_char = r2_char;
//! The LCD window - rows 1 and 2 across the fields, which is the lit area of
//! the real display. Everything else is fascia.
wire s_in_lcd = (s_row == 3'd1) || (s_row == 3'd2);
//! The octal ruler's alternating triplets are REVERSED OUT on the real
//! fascia: dark digits on a light block, at {14,13,12} {8,7,6} {2,1,0}.
wire [4:0] s_ruler_level = 5'd15 - ((s_col - COL_LEVELS[6:0]) >> 1);
//! An active disc lamp REVERSES its cell, so the R or W is knocked out of a
//! filled box. The letters themselves are static text in term_panel_rom, so
//! nothing here has to draw them - only decide the box.
wire s_disk_reversed = (s_row == 3'd1) &&
((s_col == COL_HDD_R[6:0] && r_disk[0]) ||
(s_col == COL_HDD_W[6:0] && r_disk[1]) ||
(s_col == COL_FLP_R[6:0] && r_disk[2]) ||
(s_col == COL_FLP_W[6:0] && r_disk[3]));
//! The CPU board's two lamps, on row 3 to the right of the octal ruler.
//! Same idiom as the disc lamps: the R and G letters are static text in
//! term_panel_rom, and a LIT lamp REVERSES its cell so the letter is
//! knocked out of a filled box. Unlit is a dim letter rather than blank -
//! unlike the disc lamps these are permanent CPU state, not activity, so
//! there is always something meaningful to read.
wire s_in_cpu_lamp = (s_row == 3'd3) &&
((s_col == COL_CPU_RED[6:0]) || (s_col == COL_CPU_GREEN[6:0]));
wire s_cpu_lamp_lit = ((s_col == COL_CPU_RED[6:0]) && r_cpu_red) ||
((s_col == COL_CPU_GREEN[6:0]) && r_cpu_green);
wire s_cpu_reversed = s_in_cpu_lamp && s_cpu_lamp_lit;
//! RED uses the fascia's lit-legend red; GREEN reuses the console ink,
//! which IS green on every board (the Tandberg terminals were green), so
//! the lamps come out in their real colours with no new palette entry.
wire [2:0] s_cpu_lamp_colour = (s_col == COL_CPU_GREEN[6:0]) ? C_TEXT : C_LIT;
wire s_ruler_reversed = (s_row == 3'd3) && s_in_levels &&
((s_ruler_level >= 5'd12 && s_ruler_level <= 5'd14) ||
(s_ruler_level >= 5'd6 && s_ruler_level <= 5'd8) ||
(s_ruler_level <= 5'd2));
//! Declared here, ABOVE the font ROM that reads it, not further down beside
//! the other delay registers. Verilator accepts a forward reference; Vivado
//! does not, and that exact mistake - a signal used above its declaration -
//! is what stopped the first Nexys console build dead on 28-AUG-2026.
reg [3:0] s_pixel_row_d1;
wire [7:0] s_font_pixels;
font_rom #(
.FONT_FILE(FONT_FILE)
) PANELFONT (
.clk (clk),
.char_code({2'b00, s_char[6:0]}), // page 0 always - the panel draws no graphics
.row (s_pixel_row_d1),
.pixels (s_font_pixels)
);
//--------------------------------------------------------------------------
// Two clocks of delay, matching the text pipeline exactly
//--------------------------------------------------------------------------
//! STILL TWO DEEP, even though the pipeline gained two stages - and getting
//! this wrong is subtle enough to be worth spelling out.
//!
//! These delay lines are fed from the STAGE 1 registers, so they already
//! start one clock in. The font path is: stage 1 -> layout ROM -> stage 2
//! (r2_char) -> font ROM's registered output = 3 clocks. A signal entering
//! here at stage 1 therefore needs exactly 2 more, not 3.
//!
//! Making them 3 deep put the region flags at 4 clocks against the font's 3.
//! The panel testbench caught it immediately - "claimed 639 of 640 pixels"
//! and one pixel outside the origin - which on a screen would have been a
//! one-pixel smear nobody would ever have investigated.
reg [2:0] s_pixel_col_d1, s_pixel_col_d2;
reg [1:0] s_in_panel_dly;
reg [1:0] s_in_lcd_dly, s_reversed_dly, s_silk_dly;
reg [2:0] s_colour_d1, s_colour_d2;
//! Which colour this cell's ink is. Silkscreen labels are white on fascia;
//! everything inside the LCD window is dark olive on green-grey.
wire [2:0] s_ink = s_in_cpu_lamp ? (s_cpu_lamp_lit ? s_cpu_lamp_colour : C_DARK)
: s_in_lcd ? (s_is_dynamic ? s_live_colour : C_LCDINK)
: ((s_row == 3'd1 || s_row == 3'd2) ? C_LCDINK : C_SILK);
always @(posedge clk) begin
s_pixel_row_d1 <= s_pixel_row;
s_pixel_col_d1 <= s_pixel_col;
s_pixel_col_d2 <= s_pixel_col_d1;
s_in_panel_dly <= {s_in_panel_dly[0], r1_in_panel};
s_in_lcd_dly <= {s_in_lcd_dly[0], s_in_lcd};
s_reversed_dly <= {s_reversed_dly[0], s_ruler_reversed | s_disk_reversed | s_cpu_reversed};
s_silk_dly <= {s_silk_dly[0], s_in_lcd ? 1'b0 : 1'b1};
s_colour_d1 <= s_ink;
s_colour_d2 <= s_colour_d1;
end
wire s_glyph_bit = s_font_pixels[3'd7 - s_pixel_col_d2];
//! A reversed-out ruler cell swaps ink and ground.
wire s_ink_here = s_glyph_bit ^ s_reversed_dly[1];
assign active = s_in_panel_dly[1];
assign colour = !s_in_panel_dly[1] ? C_BLACK
: s_in_lcd_dly[1] ? (s_ink_here ? C_LCDINK : C_LCD)
: s_ink_here ? s_colour_d2
: C_FASCIA;
endmodule
`default_nettype wire