From 64428bdd850508d24f2d21a152111dfe2b8bd3fe Mon Sep 17 00:00:00 2001 From: Alex-create-beep <2965312221@qq.com> Date: Wed, 23 Sep 2026 21:21:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E4=BB=BF=E7=9C=9F=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E5=8F=B0=E9=80=82=E9=85=8D=E6=96=B0=E7=89=88=E6=98=BE?= =?UTF-8?q?=E5=AD=98=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A5=BD=20xsim=20?= =?UTF-8?q?=E9=87=8C=E8=B7=91=20python=20=E7=9A=84=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tb_saolei.v / tb_saolei_play.v: - dump 从 1200 字节(1 字节/格)改成 2400 字节(每格 2 字节) - 变量地址按新 ELF 更新(g_state 0x7AC、mine_bm 0x86C、g_ncells 0x8B4 ...) - 已揭开格数直接读 opened_cnt(0x7B4),通关目标改成 g_ncells - g_mines fb2png.py: - 支持每格 2 字节(byte0 字形[5:0] / byte1 颜色[2:0]) - 字库不再手工抄表,改成直接解析 cpu21_vga.v 的 font_row(),RTL 加字模会自动跟随 - 字形 16..41 显示成 A-Z play_saolei.tcl: - 用绝对路径找系统 Python:xsim 的环境里 py 会选中 Vivado 自带的 python3.8, 报 SRE module mismatch - 清掉 PYTHONHOME/PYTHONPATH,否则系统 python 也会去 Vivado 的 lib 找模块 - render/save_shot 补 global python 实测:hard 模式 30x16 棋盘在终端正常显示,点击展开的区域横跨屏幕第 13~22 行 (含中线以下的 8 行)-> 证实 FB_WORDS 那个"只能写前 15 行"的 bug 已修好 --- pipelined-cpu/srcs/tb/tb_saolei.v | 73 ++++++++++-------- pipelined-cpu/srcs/tb/tb_saolei_play.v | 4 +- pipelined-cpu/tools/play_saolei.tcl | 24 +++++- saolei/tools/fb2png.py | 103 ++++++++++++++++--------- 4 files changed, 130 insertions(+), 74 deletions(-) diff --git a/pipelined-cpu/srcs/tb/tb_saolei.v b/pipelined-cpu/srcs/tb/tb_saolei.v index f64cc74..a7fe4cd 100644 --- a/pipelined-cpu/srcs/tb/tb_saolei.v +++ b/pipelined-cpu/srcs/tb/tb_saolei.v @@ -215,18 +215,23 @@ module tb_saolei; end endtask - // g_exit=0x04 g_in_level=0x08 acc_y=0x40C acc_x=0x410 cur_c=0x414 - // cur_r=0x418 g_first_done=0x420 g_state=0x424 - // g_evq_lost=0x490 g_evq_tail=0x494 g_evq_head=0x498 + // 最新版(显存每格 2 字节、难度三档不同尺寸)变量地址: + // g_exit=0x04 g_in_level=0x08 acc_y=0x78C acc_x=0x790 cur_c=0x794 + // cur_r=0x798 g_difficulty=0x7A0 g_mines=0x7A4 g_first_done=0x7A8 + // g_state=0x7AC flags_cnt=0x7B0 opened_cnt=0x7B4 + // flag_bm=0x7F4 open_bm=0x830 mine_bm=0x86C + // g_ncells=0x8B4 g_evq_lost=0x8C0 g_evq_tail=0x8C4 g_evq_head=0x8C8 task dump_vars; begin - $display(" [vars] exit=%0d in_level=%0d cur=(r%0d,c%0d) acc=(%0d,%0d) first=%0d state=%0d lost=%0d evq(h=%0d,t=%0d)", + $display(" [vars] exit=%0d in_level=%0d cur=(r%0d,c%0d) acc=(%0d,%0d) diff=%0d mines=%0d first=%0d state=%0d flags=%0d opened=%0d lost=%0d evq(h=%0d,t=%0d)", dut.ram[32'h0004 >> 2], dut.ram[32'h0008 >> 2], - dut.ram[32'h0418 >> 2], dut.ram[32'h0414 >> 2], - dut.ram[32'h0410 >> 2], dut.ram[32'h040C >> 2], - dut.ram[32'h0420 >> 2], dut.ram[32'h0424 >> 2], - dut.ram[32'h0490 >> 2], - dut.ram[32'h0498 >> 2], dut.ram[32'h0494 >> 2]); + dut.ram[32'h0798 >> 2], dut.ram[32'h0794 >> 2], + dut.ram[32'h0790 >> 2], dut.ram[32'h078C >> 2], + dut.ram[32'h07A0 >> 2], dut.ram[32'h07A4 >> 2], + dut.ram[32'h07A8 >> 2], dut.ram[32'h07AC >> 2], + dut.ram[32'h07B0 >> 2], dut.ram[32'h07B4 >> 2], + dut.ram[32'h08C0 >> 2], + dut.ram[32'h08C8 >> 2], dut.ram[32'h08C4 >> 2]); end endtask @@ -235,7 +240,7 @@ module tb_saolei; integer k2, b2; begin nz_cnt = 0; - for (k2 = 0; k2 < 300; k2 = k2 + 1) + for (k2 = 0; k2 < 600; k2 = k2 + 1) for (b2 = 0; b2 < 4; b2 = b2 + 1) if (dut.fb[k2][b2*8 +: 8] != 8'h00) nz_cnt = nz_cnt + 1; @@ -247,8 +252,8 @@ module tb_saolei; $fwrite(fh, "CURSOR %0d %0d\n", cursor_row, cursor_col); $fwrite(fh, "CTRL 0x%02X\n", dut.u_vga.ctrl_i[7:0]); $fwrite(fh, "STATS fb_we=%0d nonzero=%0d\n", fb_we_cnt, nz_cnt); - $fwrite(fh, "FB 1200\n"); - for (w = 0; w < 300; w = w + 1) + $fwrite(fh, "FB 2400\n"); + for (w = 0; w < 600; w = w + 1) for (b = 0; b < 4; b = b + 1) $fwrite(fh, "%02X\n", dut.fb[w][b*8 +: 8]); $fclose(fh); @@ -261,7 +266,7 @@ module tb_saolei; endtask // ---------------------------------------------------------------- 完整一局用的工具 - // 从 RAM 的位图里取一位(mine_bm @0x470 / open_bm @0x450,字内小端) + // 从 RAM 的位图里取一位(mine_bm @0x86C / open_bm @0x830 / flag_bm @0x7F4) function integer bitof; input integer base; input integer idx; @@ -270,14 +275,11 @@ module tb_saolei; end endfunction - // 已揭开格数(直接数 open_bm 的 1),目标 = 256 - 40 = 216 + // 已揭开格数:直接读程序里的 opened_cnt(0x7B4) integer open_n; task count_open; - integer k3; begin - open_n = 0; - for (k3 = 0; k3 < 256; k3 = k3 + 1) - open_n = open_n + bitof(32'h0000_0450, k3); + open_n = dut.ram[32'h07B4 >> 2]; end endtask @@ -376,7 +378,7 @@ module tb_saolei; for (cc = 0; cc < 16; cc = cc + 1) begin idx = rr * 16 + cc; goto_cell(rr, cc); - if (bitof(32'h0000_0470, idx) != 0) begin + if (bitof(32'h0000_086C, idx) != 0) begin sw_flag = 1'b1; #(30 * 1000); click_t; @@ -388,18 +390,20 @@ module tb_saolei; // 中途退出关卡就立即报警并收手 if (dut.ram[32'h0008 >> 2] == 0) begin $display(" [game] *** 在盘面 (%0d,%0d) 处退出了关卡!cur=(%0d,%0d) state=%0d", - rr, cc, dut.ram[32'h0418 >> 2], dut.ram[32'h0414 >> 2], - dut.ram[32'h0424 >> 2]); + rr, cc, dut.ram[32'h0798 >> 2], dut.ram[32'h0794 >> 2], + dut.ram[32'h07AC >> 2]); disable sweep_blk; end // 赢了/输了也收手 - if (dut.ram[32'h0424 >> 2] != 0) + if (dut.ram[32'h07AC >> 2] != 0) disable sweep_blk; end count_open; - $display(" [game] 第 %0d 行扫完:已揭开 %0d / 216, state=%0d, 旗=%0d, cur=(%0d,%0d)", - rr, open_n, dut.ram[32'h0424 >> 2], dut.ram[32'h0428 >> 2], - dut.ram[32'h0418 >> 2], dut.ram[32'h0414 >> 2]); + $display(" [game] 第 %0d 行扫完:已揭开 %0d / %0d, state=%0d, 旗=%0d, cur=(%0d,%0d)", + rr, open_n, + (dut.ram[32'h08B4 >> 2] - dut.ram[32'h07A4 >> 2]), + dut.ram[32'h07AC >> 2], dut.ram[32'h07B0 >> 2], + dut.ram[32'h0798 >> 2], dut.ram[32'h0794 >> 2]); if (rr == 3) dump_fb("fb_10_sweep_a.txt"); if (rr == 7) @@ -424,31 +428,36 @@ module tb_saolei; click_t; #(2 * 1000000); dump_fb("fb_01_level.txt"); - $display(" [game] 进关卡:in_level=%0d state=%0d", dut.ram[32'h0008 >> 2], dut.ram[32'h0424 >> 2]); + $display(" [game] 进关卡:in_level=%0d state=%0d", dut.ram[32'h0008 >> 2], dut.ram[32'h07AC >> 2]); // 3) 关卡内点盘面中心一格:放雷 + 首次展开(第一下必安全) click; #(3 * 1000000); dump_fb("fb_02_firstclick.txt"); count_open; - $display(" [game] 首次点击后:first=%0d 已揭开 %0d / 216 state=%0d", - dut.ram[32'h0420 >> 2], open_n, dut.ram[32'h0424 >> 2]); + $display(" [game] 首次点击后:first=%0d 已揭开 %0d / %0d state=%0d diff=%0d", + dut.ram[32'h07A8 >> 2], open_n, + (dut.ram[32'h08B4 >> 2] - dut.ram[32'h07A4 >> 2]), + dut.ram[32'h07AC >> 2], dut.ram[32'h07A0 >> 2]); // 4) 蛇形扫完全盘:揭开全部 216 个安全格 + 给 40 颗雷插旗 -> 目标通关 sweep_board; #(3 * 1000000); dump_fb("fb_13_after_sweep.txt"); count_open; - $display(" [game] 扫完后:已揭开 %0d / 216, 旗=%0d, state=%0d, 丢事件=%0d", - open_n, dut.ram[32'h0428 >> 2], dut.ram[32'h0424 >> 2], dut.ram[32'h0490 >> 2]); + $display(" [game] 扫完后:已揭开 %0d / %0d, 旗=%0d, state=%0d, 丢事件=%0d", + open_n, (dut.ram[32'h08B4 >> 2] - dut.ram[32'h07A4 >> 2]), + dut.ram[32'h07B0 >> 2], dut.ram[32'h07AC >> 2], dut.ram[32'h08C0 >> 2]); // 5) 通关后再点一下 = 返回背景(回到开始界面) click_t; #(3 * 1000000); dump_fb("fb_14_back_to_start.txt"); - $display(" [game] === 结束:state=%0d(1=WIN 2=LOSE)已揭开 %0d/216 旗=%0d 丢事件=%0d ===", - dut.ram[32'h0424 >> 2], open_n, dut.ram[32'h0428 >> 2], dut.ram[32'h0490 >> 2]); + $display(" [game] === 结束:state=%0d(1=WIN 2=LOSE)已揭开 %0d/%0d 旗=%0d 丢事件=%0d ===", + dut.ram[32'h07AC >> 2], open_n, + (dut.ram[32'h08B4 >> 2] - dut.ram[32'h07A4 >> 2]), + dut.ram[32'h07B0 >> 2], dut.ram[32'h08C0 >> 2]); $finish; end diff --git a/pipelined-cpu/srcs/tb/tb_saolei_play.v b/pipelined-cpu/srcs/tb/tb_saolei_play.v index 682ee71..2263d18 100644 --- a/pipelined-cpu/srcs/tb/tb_saolei_play.v +++ b/pipelined-cpu/srcs/tb/tb_saolei_play.v @@ -189,8 +189,8 @@ module tb_saolei_play; $fwrite(df, "CURSOR %0d %0d\n", dut.u_vga.cursor_i[12:8], dut.u_vga.cursor_i[4:0]); $fwrite(df, "CTRL 0x%02X\n", dut.u_vga.ctrl_i[7:0]); - $fwrite(df, "FB 1200\n"); - for (w2 = 0; w2 < 300; w2 = w2 + 1) + $fwrite(df, "FB 2400\n"); + for (w2 = 0; w2 < 600; w2 = w2 + 1) for (b2 = 0; b2 < 4; b2 = b2 + 1) $fwrite(df, "%02X\n", dut.fb[w2][b2*8 +: 8]); $fclose(df); diff --git a/pipelined-cpu/tools/play_saolei.tcl b/pipelined-cpu/tools/play_saolei.tcl index f6b13a7..c5d5a9a 100644 --- a/pipelined-cpu/tools/play_saolei.tcl +++ b/pipelined-cpu/tools/play_saolei.tcl @@ -34,6 +34,22 @@ set script_dir [file dirname [file normalize [info script]]] set fb2png [file normalize [file join $script_dir .. .. saolei tools fb2png.py]] +# 找系统 Python:xsim 的 PATH 里带着 Vivado 自带的 python3.8,直接 exec py 会撞上它 +# (SRE module mismatch),所以用绝对路径优先。 +proc find_python {} { + foreach ver {314 313 312 311 310} { + set c [file join $::env(LOCALAPPDATA) Programs Python "Python$ver" python.exe] + if {[file exists $c]} { return $c } + } + return "py" +} +set python [find_python] +# xsim 的进程环境里带着 Vivado 的 PYTHONHOME,会让**系统 python** 也去 Vivado 的 +# lib 里找模块(SRE module mismatch)。清掉它,子进程才不会继承。 +catch { unset ::env(PYTHONHOME) } +catch { unset ::env(PYTHONPATH) } +puts " (python: $python)" + # 按键字符 -> 人类可读的动作(只用于回显) array set KEYNAME { c "左键点击" u "上" w "上" d "下" s "下" @@ -56,12 +72,12 @@ if {![file exists "cmd.txt"]} { # 把 now.txt 渲染到终端(ANSI 彩色棋盘) proc render {} { - global fb2png + global fb2png python if {![file exists "now.txt"]} { puts " (now.txt not ready yet - run a bit first)" return } - if {[catch { exec py $fb2png --term now.txt >@stdout 2>@stderr } err]} { + if {[catch { exec $python $fb2png --term now.txt >@stdout 2>@stderr } err]} { puts " render failed: $err" puts " try manually: py \"$fb2png\" --term now.txt" } @@ -85,11 +101,11 @@ file mkdir $shot_dir set shot_n 0 proc save_shot {tag} { - global fb2png shot_dir + global fb2png shot_dir python if {![file exists "now.txt"]} { return } incr ::shot_n set name [format "%s/%03d_%s.png" $shot_dir $::shot_n $tag] - if {[catch { exec py $fb2png --quiet now.txt $name } err]} { + if {[catch { exec $python $fb2png --quiet now.txt $name } err]} { puts " (screenshot failed: $err)" return } diff --git a/saolei/tools/fb2png.py b/saolei/tools/fb2png.py index 8607ffd..8fa96eb 100644 --- a/saolei/tools/fb2png.py +++ b/saolei/tools/fb2png.py @@ -9,43 +9,70 @@ dump 格式(tb_saolei.v 里的 dump_fb 任务生成): CURSOR CTRL 0x # [0] 光标可见 [6:4] 背景色 - FB 1200 # 随后 1200 行十六进制字节,按格号 0..1199 + FB 2400 # 随后 2400 行十六进制字节:每格 2 字节(字形、颜色) -渲染依据(与 RTL 严格对应,改 RTL 时这两个表要同步): - * 字库 cpu21_vga.v 的 font_row():8x8、bit7 是最左像素,放大 2 倍 = 16x16 一格 +渲染依据(与 RTL 严格对应): + * 字库:直接从 cpu21_vga.v 的 font_row() 解析(8x8、bit7 最左,放大 2 倍 = 16x16 一格) + —— RTL 改了字库(比如新增 A-Z)这里会自动跟随,不用手工抄表 * 调色板 cpu21_vga.v 的 pal():3 位 -> 4:4:4 RGB(0xA 分量 = 170) + * 显存每格 2 字节:byte0 = 字形[5:0],byte1 = 颜色[2:0] * 光标 cpu21_vga.v 的 cursor_box:格子四边一圈 2 像素宽的黄色框(常亮) * 画面 40x30 格 -> 640x480 另外还会在终端打印一份 ASCII 图,方便在没有图片查看器的地方快速看结果。 """ +import os +import re import struct import sys import zlib COLS, ROWS = 40, 30 CELL_PX = 16 # 8x8 字模放大 2 倍 +FB_BYTES = COLS * ROWS * 2 # 每格 2 字节(字形 + 颜色)= 2400 -# --- 字库(8 行/字,bit7 = 最左),抄自 cpu21_vga.v 的 font_row() --- -FONT = { - 0: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], # 空白 - 1: [0x18, 0x38, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00], # 1 - 2: [0x3C, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x7E, 0x00], # 2 - 3: [0x3C, 0x66, 0x06, 0x1C, 0x06, 0x66, 0x3C, 0x00], # 3 - 4: [0x0C, 0x1C, 0x3C, 0x6C, 0x7E, 0x0C, 0x0C, 0x00], # 4 - 5: [0x7E, 0x60, 0x7C, 0x06, 0x06, 0x66, 0x3C, 0x00], # 5 - 6: [0x1C, 0x30, 0x60, 0x7C, 0x66, 0x66, 0x3C, 0x00], # 6 - 7: [0x7E, 0x66, 0x0C, 0x18, 0x18, 0x18, 0x18, 0x00], # 7 - 8: [0x3C, 0x66, 0x66, 0x3C, 0x66, 0x66, 0x3C, 0x00], # 8 - 9: [0xFF, 0x81, 0xBD, 0xA5, 0xA5, 0xBD, 0x81, 0xFF], # 未揭开 - 10: [0x40, 0x70, 0x78, 0x70, 0x40, 0x40, 0x40, 0x00], # 旗 - 11: [0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C], # 雷 - 12: [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81], # 爆炸 - 13: [0x3C, 0x66, 0x06, 0x0C, 0x18, 0x00, 0x18, 0x00], # 问号 - 14: [0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00], # 横线 - 15: [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], # 实心 -} +RTL_VGA = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "..", "pipelined-cpu", "srcs", "rtl", + "cpu21_vga.v")) + + +def load_font_from_rtl(path=None): + """直接从 cpu21_vga.v 的 font_row() 里解析 8x8 字模,返回 {字形号: [8 行]}。 + + RTL 里每个字形形如 + 6'd16: font_row = (r == 3'd0) ? 8'h7C : (r == 3'd1) ? 8'h66 : ... ; + 或全部相同行时形如 + 6'd15: font_row = 8'hFF; + 两种情况都能解析。 + """ + path = path or RTL_VGA + try: + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + except OSError as e: + raise SystemExit("读不到 RTL 字库 %s:%s" % (path, e)) + m = re.search(r"function\s*\[7:0\]\s*font_row.*?endfunction", text, re.S) + if not m: + raise SystemExit("%s 里找不到 font_row()" % path) + + font = {} + parts = re.split(r"6'd(\d+)\s*:", m.group(0)) + for i in range(1, len(parts) - 1, 2): # 奇数下标是字形号,后一格是它的分支体 + idx, seg = int(parts[i]), parts[i + 1] + rows = [0] * 8 + pairs = re.findall(r"\(r == 3'd(\d+)\)\s*\?\s*8'h([0-9A-Fa-f]{2})", seg) + if pairs: + for k, v in pairs: + rows[int(k)] = int(v, 16) + else: # 形如 "font_row = 8'hFF;":8 行一样 + one = re.search(r"font_row\s*=\s*8'h([0-9A-Fa-f]{2})", seg) + if one: + rows = [int(one.group(1), 16)] * 8 + font[idx] = rows + if not font: + raise SystemExit("%s 的 font_row() 没解析出任何字形" % path) + return font # --- 调色板(3 位 -> RGB),抄自 cpu21_vga.v 的 pal(),4 位分量按 *17 扩到 8 位 --- PAL = [ @@ -98,18 +125,24 @@ def parse_dump(path): fb.append(int(line, 16)) except ValueError: pass - if len(fb) != 1200: - raise SystemExit("显存字节数不对:%d(应为 1200)" % len(fb)) + if len(fb) != FB_BYTES: + raise SystemExit("显存字节数不对:%d(应为 %d;新版每格 2 字节)" + % (len(fb), FB_BYTES)) return cur, ctrl, fb +def cell_of(fb, idx): + """第 idx 格 -> (字形, 颜色):byte0 = 字形[5:0],byte1 = 颜色[2:0]。""" + return fb[2 * idx] & 0x3F, fb[2 * idx + 1] & 0x07 + + def cell_char(g): - """字形号 -> 一个可见字符(字形 1..8 就是数字 1..8)。""" - if g in GLYPH_CH: - return GLYPH_CH[g] + """字形号 -> 一个可见字符(1..8 数字,16..41 = A-Z,其余见 GLYPH_CH)。""" + if 16 <= g <= 41: + return chr(ord('A') + g - 16) if 1 <= g <= 8: - return chr(ord('0') + g) - return ' ' + return str(g) + return GLYPH_CH.get(g, '?') def dump_note(path): @@ -140,8 +173,7 @@ def draw_term(fb, cur, cursor_box, note=""): for y in range(ROWS): cells = [] for x in range(COLS): - cell = fb[y * COLS + x] - g, c = cell & 0xF, (cell >> 4) & 0x7 + g, c = cell_of(fb, y * COLS + x) r, gg, b = PAL[c] ch = cell_char(g) if cursor_box and (x, y) == cur: @@ -151,7 +183,7 @@ def draw_term(fb, cur, cursor_box, note=""): cells.append("\x1b[38;2;%d;%d;%dm%s" % (r * 17, gg * 17, b * 17, ch)) out.append("%2d " % y + "".join(cells) + "\x1b[0m\n") out.append("\x1b[0m") - out.append("图例: #=未揭开 P=旗 *=雷 X=爆炸 ?=问号 1..8=数字 空格=已揭开的空地;反色格 = 光标\n") + out.append("图例: #=未揭开 P=旗 *=雷 X=爆炸 A-Z=文字 1..8=数字 空格=已揭开的空地;反色格 = 光标\n") sys.stdout.write("".join(out)) sys.stdout.flush() @@ -165,13 +197,13 @@ def main(): if quiet: args.remove("--quiet") if quiet: # 只出图不说话(给脚本批量截图用) - import os sys.stdout = open(os.devnull, "w") if not args: raise SystemExit(__doc__) src = args[0] dst = args[1] if len(args) > 1 else src.rsplit(".", 1)[0] + ".png" cur, ctrl, fb = parse_dump(src) + font = load_font_from_rtl() bg = (ctrl >> 4) & 0x7 cursor_box = bool(ctrl & 1) and cur[0] is not None @@ -190,9 +222,8 @@ def main(): cy, fy = y // CELL_PX, (y % CELL_PX) // 2 for x in range(COLS * CELL_PX): cx, fx = x // CELL_PX, (x % CELL_PX) // 2 - cell = fb[cy * COLS + cx] - g, c = cell & 0xF, (cell >> 4) & 0x7 - px_on = (FONT[g][fy] >> (7 - fx)) & 1 + g, c = cell_of(fb, cy * COLS + cx) + px_on = (font.get(g, [0] * 8)[fy] >> (7 - fx)) & 1 col = c if px_on else bg if cursor_box and (cx, cy) == cur: # 光标格:四边 2 像素黄色框,与 cpu21_vga.v 的 cursor_box 一致