NybblesIO-x86-汇编笔记-全-
NybblesIO x86 汇编笔记(全)
001:为MS-DOS街机游戏制作参考代码 🎮

在本节课中,我们将学习如何为MS-DOS环境下的街机游戏项目编写参考实现代码。我们将重点探讨x86汇编语言编程、DOSBox调试环境配置、以及一个图形化编辑器工具中按钮交互功能的实现。
概述
本节内容基于一个实际的开发流,旨在为后续的教育视频系列构建一个功能完整的参考实现。我们将看到开发者如何设置开发环境、重构代码以提升可维护性,并实现核心的鼠标交互逻辑。整个过程涉及DOS下的汇编编程、内存管理、图形渲染和输入处理。


开发环境与工具链 🛠️
上一节我们介绍了项目的背景,本节中我们来看看开发所使用的具体环境和工具。

为了在MS-DOS环境下进行开发,我使用了一套定制的工具链:
- DOSBox: 主要的开发和测试环境。我使用了一个自定义编译版本,集成了社区修复补丁的调试器,这对于底层调试至关重要。
- 文本编辑器: 使用TSE Pro(原Q编辑器),这是我长期在DOS下使用的编辑器。
- 汇编器: 主要使用A86/D86汇编器。它语法简洁高效,远胜于当时的其他商业汇编器。
- 编译器/调试器: 安装了Turbo C++和Open Watcom,用于可能的C语言模块和调试。
- 辅助工具: 包括Deluxe Paint(图形编辑)、DN(文件管理器)和一些音乐追踪器(Mod Tracker)。
选择DOSBox是因为它在VGA硬件模拟方面非常出色,尽管其性能并非完美。对于需要精确计时的音频任务,可能需要VirtualBox或Parallels等虚拟机。
项目结构与内存模型 💾

在深入代码细节之前,理解项目的整体结构和所采用的内存模型非常重要。
整个项目(游戏及其配套编辑器工具)都采用.COM文件格式。这种格式虽然简单,但在实模式下提供了极大的灵活性。MS-DOS将机器的控制权几乎完全交给程序,开发者可以自由使用640KB基本内存中的各个段。
- 代码段: 一个64KB的段存放程序代码和一些内联数据。目前游戏引擎约4-5KB,编辑器约5KB,其中相当一部分是数据结构。
- 数据段: 其他内存段用于存放游戏资源,如使用一个完整的段(64KB)存放图块(Tile),精灵控制表等。渲染器使用的数据格式紧凑高效。
- 显存与缓冲: 在常规内存中分配了一个64KB块作为后备缓冲区(Back Buffer)。所有绘图操作先在此进行,然后在垂直消隐期间通过“翻页”操作快速复制到VGA显存。
这种模型下,即使最终的游戏,其.COM文件大小也不太可能超过32KB,留有充足的内存空间存放资源。
按钮系统的实现 🖱️
上一节我们介绍了项目的内存布局,本节中我们来看看编辑器工具中用户界面按钮的具体实现。
按钮是编辑器GUI的核心交互元素。其功能包括绘制自身和处理鼠标点击。
按钮数据结构
首先,我们定义了一个结构体来描述按钮的所有属性。在汇编中,我们使用标签和宏来构建这个结构。
; 按钮结构体定义
STRUC Button
.flags dw ? ; 状态标志位(如是否启用、是否最后一个)
.text dw ? ; 指向按钮文本字符串的指针
.textPos dw ? ; 文本在按钮内的坐标 (Y, X)
.pos dw ? ; 按钮左上角坐标 (Y, X)
.size dw ? ; 按钮尺寸 (高度, 宽度)
.func dw ? ; 点击回调函数的指针
ENDSTRUC
以下是初始化按钮数组的示例代码:
; 使用宏初始化按钮数组
buttons:
ButtonDef <ENABLED_BIT, offset txt_new, (5<<8)|10, (10<<8)|20, (20<<8)|60, 0>
ButtonDef <ENABLED_BIT, offset txt_load, (5<<8)|90, (10<<8)|20, (20<<8)|60, 0>
ButtonDef <ENABLED_BIT|LAST_BIT, offset txt_exit, (5<<8)|170, (10<<8)|20, (20<<8)|60, offset exit_callback>

绘制按钮




按钮的绘制被重构到一个独立的函数 draw_buttons 中,使主绘制循环更清晰。绘制过程包括:
- 遍历按钮数组。
- 检查是否启用,未启用则跳过。
- 绘制一个填充矩形作为按钮背景。
- 在矩形顶部和底部各画一条黑线作为边框。
- 在指定位置绘制按钮文本,并可以微调字符间距。






处理鼠标点击








鼠标点击检测在 fire_buttons 函数中实现。其逻辑如下:
- 首先检查鼠标左键是否被按下,如果没有则直接返回。
- 遍历按钮数组,同样跳过未启用的按钮。
- 对于每个启用的按钮,获取鼠标位置(考虑热点偏移)和按钮的矩形区域。
- 进行边界检查,判断鼠标是否在按钮范围内。核心比较公式为:
mouse_x >= button_x且mouse_x <= (button_x + button_width)mouse_y >= button_y且mouse_y <= (button_y + button_height)
- 如果点击在按钮内,且该按钮有回调函数(
func指针非空),则调用该函数。

在实现过程中,需要特别注意坐标系统。由于使用的视频模式(Mode Q)特性,屏幕坐标通常以 (Y, X) 的顺序打包在一个字(word)中,高低字节分别代表Y和X,这可以避免乘法运算,提升性能。但在比较时需要确保顺序一致。
调试技巧 🐛
在实现复杂逻辑时,调试是必不可少的。DOSBox的内置调试器在此发挥了关键作用。



我定义了一个 BRK 宏,它在代码中插入 INT 3 指令。在DOSBox调试器中,可以设置断点在这个中断上。例如,在鼠标点击检测的开始处插入 BRK,运行程序后点击鼠标,调试器就会中断,允许开发者单步执行并检查寄存器、内存状态,从而验证逻辑是否正确。

例如,可以检查 AX 寄存器中是否包含了正确的按钮坐标,或者 BX 寄存器中的鼠标位置是否在预期的范围内。

总结


本节课中我们一起学习了为MS-DOS街机游戏项目构建参考实现代码的实践过程。我们从配置开发环境(DOSBox、A86汇编器)开始,了解了.COM格式程序的内存模型优势。然后,我们深入探讨了编辑器工具中一个核心功能——按钮交互系统的实现,包括其数据结构的定义、绘制流程以及鼠标点击检测的逻辑。最后,我们还介绍了如何使用DOSBox调试器来辅助开发和验证代码逻辑。




这个过程展示了如何在有限的硬件环境下进行高效的系统级编程,将图形渲染、输入处理和内存管理紧密结合,为开发完整的街机游戏奠定了坚实的基础。
002:构建MS-DOS游戏编辑器工具
概述
在本节课中,我们将学习如何为一个MS-DOS下的复古街机游戏项目构建一个数据编辑器工具。这个工具名为“BankEd”,用于创建和编辑游戏资源,如背景图块、精灵和声音数据。我们将深入探讨其x86汇编语言实现,涵盖从程序启动、内存管理、图形模式设置到用户界面交互(如按钮和文本输入)的完整流程。课程将重点解析代码结构、数据组织以及核心功能的实现原理。
程序启动与初始化
首先,我们的程序是一个MS-DOS的COM文件。COM文件是一个可以直接加载到内存中执行的64K内存映像。操作系统将控制权完全交给我们的程序。


程序入口点首先需要跳过数据区,跳转到真正的启动代码。
jmp start
start标签是我们的程序起点。接下来,我们需要初始化内存。我们使用的是分段内存模型,虽然理解起来需要一些功夫,但一旦掌握,使用起来并不复杂。
我们首先为控制RAM分配内存。控制RAM是一个特殊的内存区域,用于存储游戏引擎的核心数据结构和指针。
allocate_control_ram:
; 宏调用,分配下一个内存段作为控制RAM
allocate_to_next_segment_for_size control_ram
; 将分配的内存清零
clear_with_zeros
; 设置扩展段寄存器指向控制RAM
mov es, control_ram_pointer
; 设置基址指针以便访问结构
mov bp, 0
控制RAM结构体包含了指向各种游戏资源(如精灵图块、背景图块)的指针,以及精灵、图块、背景地图和定时器的元数据。
图形模式设置
我们的游戏使用VGA图形模式。我们并非使用标准的Mode 13h(320x200,256色),而是通过修改VGA寄存器,将其设置为一种特殊的“链式”模式,我们称之为Mode Q(256x256,256色)。
这种模式的优点是,由于屏幕是正方形,我们可以用一个字(word)寄存器来存储像素地址:高字节是Y坐标,低字节是X坐标。这样,我们无需进行乘法运算即可定位像素,非常方便。

set_mode_q:
; 进入标准VGA Mode 13h
mov ax, 13h
int 10h
; 应用Mode Q的寄存器参数
mov si, offset mode_q_registers
call program_vga_mode
program_vga_mode函数接收一个指向寄存器-值对数组的指针,并循环将其写入对应的VGA控制寄存器。
我们使用一个64K的后台缓冲区(back buffer)在常规RAM中进行所有绘制操作,然后通过一个函数将这个缓冲区的内容复制到实际的VRAM中。
输入系统
为了获得高效的游戏输入,我们需要接管DOS的键盘中断服务例程(ISR)。这允许我们实时获取按键的按下和释放事件。
init_keyboard_isr:
; 保存原始键盘中断向量
; 安装我们自己的键盘中断处理程序
; 新的ISR将扫描码放入一个队列
我们的输入系统将扫描码队列转换为更高级别的“输入事件”。我们定义了一个输入事件数组,每个事件对应一个特定的操作(如退出、移动)。还有一个“绑定”数组,将输入事件映射到具体的回调函数。

; 定义输入事件
def_input_event KEY, ESCAPE
def_input_event KEY, LEFT
; ... 其他事件
; 定义绑定
def_binding QUIT, exit_callback
def_binding MOVE_LEFT, move_left_callback
主循环会调用update_input来处理队列,更新事件状态,然后调用fire_bindings来触发相应的回调。
用户界面:按钮
编辑器工具包含一个按钮界面。每个按钮由一个数据结构定义,包含其状态、文本、位置、大小和点击回调函数。
; 按钮结构示例
button_new:
db BUTTON_ENABLED ; 标志位
dw offset label_new ; 文本标签指针
dw 0, 20 ; 文本在按钮内的偏移 (x, y)
dw 0, 20 ; 按钮位置 (x, y)
dw 38, 10 ; 按钮大小 (宽, 高)
dw offset new_callback ; 回调函数指针
绘制按钮的代码会遍历按钮数组,检查其是否启用,然后根据其位置和大小在屏幕上绘制矩形和文本。
处理鼠标点击的逻辑(fire_buttons)会检查鼠标位置是否在任意启用按钮的边界框内。如果是,则调用该按钮的回调函数。我们最初尝试用字(word)比较来同时检查X和Y坐标,但这种方法行不通,必须分别对X和Y坐标进行独立的无符号比较。
fire_buttons:
; 检查鼠标左键是否按下
; 遍历按钮数组
.for_each_button:
; 检查按钮是否启用
; 分别比较鼠标Y坐标与按钮顶部和底部
; 分别比较鼠标X坐标与按钮左侧和右侧
; 如果都在范围内,则调用回调函数




状态管理
工具的操作被建模为一系列状态,例如“无文件状态”、“新建文件状态”、“加载文件状态”。我们有一个状态数组,每个状态包含一个处理函数(回调)。

; 状态定义宏
def_state NO_FILE, no_file_state_callback
def_state NEW_FILE, new_file_state_callback
def_state LOAD_FILE, load_file_state_callback
一个全局变量current_state指向当前活跃的状态。主循环的update函数会调用当前状态的处理函数。这允许每个状态拥有不同的行为。例如,在“无文件状态”下,按钮是可点击的;而在“新建文件状态”下,我们可能进入文本输入模式,按钮暂时失效。

状态转换通常由按钮回调触发,它们简单地改变current_state指针的值。







文本输入系统
我们需要一个文本输入系统来处理像输入文件名这样的操作。我们定义了一个文本字段数据结构。
; 文本字段结构
; 标志位(启用、只读等)
; 最大字符数
; 屏幕位置 (x, y)
; 指向有效字符键列表的指针
; 指向文本缓冲区的指针
; 当前光标索引

我们使用宏来方便地定义文本字段及其关联的缓冲区。

; 定义文本字段宏
def_text_field BANK_FILENAME, 11, 0, 20, offset valid_filename_keys


有效键列表定义了该字段允许输入的字符(例如,对于文件名,是字母、数字和点)。列表中的每个条目包含扫描码和对应的ASCII字符。
文本输入处理函数将:
- 检查是否有活动的文本字段。
- 从键盘缓冲区读取输入。
- 验证按键是否在有效键列表中。
- 根据按键更新缓冲区(插入字符、移动光标、删除等)。
- 处理回车(确认)和ESC(取消)键,并调用文本字段的回调函数。
光标(一个闪烁的插入符)的绘制是独立的,其位置由活动文本字段的当前光标索引决定。
核心游戏引擎功能




虽然本课重点在编辑器工具,但工具和游戏共享同一个底层引擎。引擎已实现的功能包括:


- 视频系统:支持两层背景(带透明度)、精灵(带透明度和调色板)、字体、线条和矩形的绘制。渲染目标是后台缓冲区,然后快速翻页(flip)到VRAM。
- 输入系统:键盘和鼠标输入已集成,游戏手柄支持待完成。
- 定时器系统:基于回调的定时器,用于控制帧率计数、光标闪烁等。
- 声音系统:Adlib音乐和Sound Blaster波形输出支持已部分完成。
引擎被设计为既可运行在DOSBox(用于开发/教学),也可移植到真实的复古硬件或我们正在设计的街机板上。



总结

本节课中,我们一起探索了一个用x86汇编语言编写的MS-DOS游戏编辑器工具的构建过程。我们从程序启动、分段内存管理和VGA图形模式设置开始,逐步深入到输入系统、用户界面按钮的实现、基于状态机的程序流程控制,以及文本输入系统的设计。
我们学习了如何通过直接操作硬件(如VGA寄存器、键盘中断)来获得高性能,同时也看到了如何组织数据结构和代码来构建一个模块化、可扩展的系统。虽然汇编语言需要关注更多底层细节,但它提供了对机器的完全控制,并且其概念——CPU指令、内存访问、I/O——是理解所有现代软件基础的宝贵知识。

这个“BankEd”工具是通往创建完整复古街机游戏道路上的关键一步,它使我们能够将美术、音乐和关卡数据整合到游戏中。在接下来的课程中,我们将继续完善这个工具,并最终使用它来构建我们的游戏。
003:回顾、TODO与BANKED工具编码 🛠️
概述
在本节课中,我们将回顾一个正在进行的x86汇编语言项目,该项目旨在为MS-DOS环境开发一款街机游戏。我们将重点讨论项目结构、内存管理、以及一个名为“BANKED”的数据编辑工具的编码实现。该工具用于管理游戏资源(如图块、精灵、声音等),并将其组织成称为“银行”的逻辑单元。



项目背景与目标
上一节我们介绍了项目的基本情况。本节中,我们来看看项目的核心目标:创建一个教育性的视频系列,从零开始教授汇编语言编程,并以一个完整的街机游戏作为实践项目。


这个项目使用DOSBox作为运行环境,并采用VGA的13H模式(256x256分辨率,256色)。游戏引擎已经具备了背景控制、精灵显示、输入处理和帧率显示等基础功能。
目前,游戏引擎主要缺少声音部分,但更紧迫的需求是游戏数据。我们需要一个工具来创建和编辑这些数据,这就是“BANKED”工具的作用。
内存管理模型
在深入工具之前,我们需要理解MS-DOS下.COM程序的内存模型,这是数据存储和加载的基础。
一个.COM程序从地址 100h(即十进制256)开始加载。从 0 到 100h 的区域称为程序段前缀(PSP)。这意味着我们的代码和数据初始时被限制在大约64KB的空间内。


公式:可用内存 ≈ 64KB - 256字节

然而,DOS会将控制权完全交给程序,允许我们访问全部的1MB常规内存。虽然采用的是分段内存模型,但一旦理解,管理起来并不复杂。

我们可以分配额外的内存段来存储数据。例如,游戏引擎已经分配了以下几个段:
- 一个段用于代码和少量控制数据(初始的64KB内)。
- 一个段作为后缓冲区,用于绘图(因为特定的VGA模式无法直接写入)。
- 一个段作为控制RAM,存放游戏状态、指针和定时器。
- 额外的段用于存储图块数据和精灵数据。
通过调用自定义的 allocate 函数(基于DOS内存分配中断),我们可以动态地在常规内存中预留出这些段。
代码示例:内存分配
; 假设我们有一个控制RAM指针
control_ram_seg dw ?
; 分配一个16KB的段作为控制RAM
allocate 1024 ; 1024 paragraphs = 16KB
mov [control_ram_seg], ax ; ax返回段地址
BANKED工具的设计
现在,我们来看看用于创建游戏资源的“BANKED”工具的核心设计。
核心概念:银行与块
工具围绕两个核心概念构建:
- 银行:一个逻辑上的数据集合,例如“所有背景图块”或“所有玩家精灵”。
- 块:银行中固定大小的数据存储单元。我们设计每个块的大小为4096字节。




设计决策:选择4096字节是为了与x86分段内存模型良好契合。一个内存段是64KB,恰好可以容纳16个这样的块(16 * 4096 = 65536)。这使得将整个银行加载到一个内存段中变得非常高效。
文件结构
BANKED工具生成的文件是一个二进制文件,具有以下结构:
- 文件头:一个很小的头部,包含魔数(用于文件识别)和块大小信息。
- 银行头块:一种特殊类型的块,包含银行的元数据。
- 银行ID和类型。
- 银行名称。
- 一个包含16个偏移量的数组,指向属于该银行的数据块。
- 其他标志位(如“脏”标志表示未保存,“删除”标志表示逻辑删除)。
- 数据块:存储实际资源数据(如图块像素、精灵像素)的块。每个块有一个很小的头部(包含类型、ID和标志),后面是有效载荷数据。
文件布局可视化:
[文件头]
[银行A的头块] -> 指向 [数据块1] [数据块2] ...
[银行B的头块] -> 指向 [数据块3] ...
[数据块1]
[数据块2]
[数据块3]
...
文件是追加式的。编辑现有块时原地更新,添加新数据时追加新块,删除数据时只标记而不立即清理。
工具与游戏的数据加载差异
这是设计中的一个关键点:
- 在BANKED工具中:我们需要加载完整的文件结构,包括所有银行头和数据块头,以便进行编辑和管理。
- 在游戏引擎中:我们只需要纯数据。游戏会调用一个特殊的加载函数,指定银行ID和目标缓冲区。该函数会定位到相应的银行,然后仅将其数据块的有效载荷部分连续地读入目标缓冲区,忽略所有头信息。这样,游戏内存中得到的就是紧密排列的、可直接索引的图块或精灵数组。


当前进展与TODO列表
在本次编码会话中,我们主要实现了BANKED工具的基础框架。以下是完成的工作和接下来的任务。
已实现的功能
- 定义了数据结构:在汇编代码中定义了银行头块、数据块、文件头的结构体。
- 实现了基础内存分配:为工具本身分配了用于存储银行头信息的内存段。
- 创建了文件I/O模块:封装了DOS中断调用,实现了文件的创建、打开、关闭、读取、写入、定位、重命名和删除。
- 实现了银行查找函数:可以通过银行ID在内存中的银行头段里快速定位到特定的银行头。
- 实现了
bank_new函数:在工具中创建新银行。它会分配一个完整的64KB段用于存放该银行的未来数据块,并在银行头段中初始化一个银行头记录。 - 实现了
bank_file_create函数:创建新的银行文件并写入初始文件头。
接下来的任务(TODO)
为了使BANKED工具可用,我们需要继续完成以下核心功能:


以下是需要实现的功能列表:
- 错误处理系统:创建一个查找表,将DOS返回的错误代码转换为可读的字符串,并在工具界面中显示。
- 文件加载:实现从磁盘加载现有银行文件到内存结构的逻辑。
- 文件保存:实现将内存中的银行数据保存到文件。策略是:写入一个临时文件,遍历所有银行头和数据块,跳过已删除的,然后删除原文件并将临时文件重命名。这同时实现了“压缩”功能。
- 数据块管理:实现函数,用于为当前编辑的银行获取或分配新的数据块。
- 用户界面联动:将“新建”、“加载”、“保存”按钮与后台的文件操作函数连接起来。完善文本输入框的处理逻辑,用于输入文件名。
- 实现编辑器视图:调整工具底部的显示区域,使其能够完整显示一个数据块的内容(例如,128个8x8图块或32个16x16精灵)。实现分页浏览不同数据块的功能。




总结


本节课中我们一起学习了如何为一个实际的MS-DOS汇编语言游戏项目设计和实现数据管理工具。我们深入探讨了分段内存模型的实际应用,设计了基于“银行”和“块”的资源管理系统,并开始了BANKED工具的编码工作。
我们完成了数据结构的定义、基础文件操作和银行创建逻辑。接下来的工作将集中在完成文件的加载/保存闭环、错误处理以及用户界面的完善上。一旦工具完成,我们就可以开始创建游戏所需的图形和音频资源,从而推动游戏开发进入下一个阶段。




教程内容翻译整理自 nybbles.io 的编程实况流“p03 p2 x86 Assembly: Review, TODO, and BANKED tool coding”。
004:错误消息、存储库类型选择与存储库文件 📁
概述
在本节课中,我们将学习如何在x86汇编语言项目中处理错误消息、实现状态栈管理、创建对话框以及优化按钮渲染逻辑。我们将构建一个工具,用于编辑游戏数据存储库(bank),并逐步完善其用户界面和交互逻辑。
状态栈管理 🧱
上一节我们介绍了工具的基本状态管理。本节中,我们将看到单一“当前状态”变量的局限性,并引入状态栈的概念。

当用户在不同界面(如主界面、新建文件对话框、错误提示框)之间导航时,我们需要记住返回路径。一个简单的状态指针无法处理嵌套的状态转换(例如,从主界面进入新建状态,再弹出错误提示)。因此,我们需要一个栈来管理状态历史。
核心概念:状态栈实现
我们分配一块32字节(16个状态)的内存作为栈,并维护一个栈指针。栈从高地址向低地址增长。
数据结构定义:
; 状态结构体
struc state_def
.code dw ? ; 状态标识码
.pad db ? ; 填充字节,保证结构体大小为偶数
.callback dd ? ; 该状态的回调函数指针
endstruc

; 状态栈指针变量
state_stack_ptr dw ?
栈操作宏:
; 状态入栈宏
%macro state_push 1
push bx
mov bx, [state_stack_ptr]
sub bx, 2
mov [bx], word %1
mov [state_stack_ptr], bx
pop bx
%endmacro
; 状态出栈宏
%macro state_pop 0
mov bx, [state_stack_ptr]
add bx, 2
mov [state_stack_ptr], bx
%endmacro
; 获取栈顶状态到BP寄存器
%macro state_top 0
push bx
mov bx, [state_stack_ptr]
mov bp, [bx]
pop bx
%endmacro







程序启动时,我们将初始状态(如“无文件”状态)压入栈中。当用户点击“新建”按钮时,我们将“新建文件”状态压栈。当用户按下ESC或回车时,我们执行state_pop返回到前一个状态。














文本字段与回调处理 ⌨️

上一节我们实现了状态栈。本节中,我们来看看如何让文本输入框与状态转换协同工作。
每个文本字段(如输入文件名的字段)都有一个关联的回调函数。当用户在字段中按下回车(接受输入)或ESC(取消)时,会触发此回调。
回调函数处理逻辑:
bank_fname_callback:
cmp al, 0 ; AL=0 表示按下ESC
je .handle_escape
; 处理回车:验证文件名,尝试创建或加载文件
; ...
jmp .done
.handle_escape:
state_pop ; 取消操作,弹出当前状态,返回上一状态
.done:
ret

在“新建”按钮的回调中,我们不仅需要压入新状态,还要激活对应的文本字段并将光标置于末尾:
bank_new_callback:
state_push state_new_file ; 压入新状态
; ... 获取文本字段指针到BP ...
mov byte [bp+text_field.flags], TEXT_FIELD_ENABLED ; 激活字段
xor bx, bx
mov bl, TXT_CH_IDX_BANK_FNAME ; 设置字段索引
call text_field_end ; 将光标移到字段末尾
ret





对话框与消息框 🪟

当需要向用户显示错误或确认信息时,我们使用一个模态对话框(消息框)。本节中,我们来实现它的显示和基本交互。
消息框是一个独立的状态。当进入此状态时,我们需要:
- 禁用主界面上的其他按钮。
- 启用消息框专属的“确定”和“取消”按钮。
- 在屏幕指定区域绘制一个框体并显示消息文本。
消息框显示函数:
message_box_show:
state_push state_message_box ; 进入消息框状态
mov byte [message_box_enabled], 1 ; 启用绘制
; 启用“确定”和“取消”按钮
button_set button_ok, F_BUTTON_ENABLED | F_BUTTON_VISIBLE
button_set button_cancel, F_BUTTON_ENABLED | F_BUTTON_VISIBLE
ret
消息框状态回调:
在此状态的回调函数中,我们主要检查按键。按下ESC键视为取消,并退出消息框状态。
state_message_box.callback:
call input_get_key
cmp al, KEY_ESCAPE
jne .done
mov byte [message_box_enabled], 0 ; 禁用消息框
button_set button_ok, 0 ; 禁用按钮
button_set button_cancel, 0
state_pop ; 弹出消息框状态,返回之前状态
mov al, 1 ; 告知主循环已处理按键
.done:
ret






消息文本存储:
我们预留一块内存来存储消息框的多行文本。每行是一个Pascal风格字符串(长度前缀+内容)。
message_box_lines:
%rep 10 ; 10行
string_reserve 32 ; 每行预留32字节空间,初始长度为0
%endrep
db 0FFh ; 结束标记
string_reserve是一个新宏,它分配指定长度的空间并用空格填充,但将长度字节设为0,表示空字符串。





按钮渲染优化 🎨

为了在对话框弹出时清晰地显示哪些按钮可用,我们优化了按钮的渲染逻辑。本节中,我们为按钮添加了“可见”和“启用”两个独立标志。
按钮的标志位(F_BUTTON_VISIBLE 和 F_BUTTON_ENABLED)现在被分开处理:
- 可见:按钮是否被绘制在屏幕上。
- 启用:按钮是否可点击(并影响其文本颜色)。
按钮绘制逻辑更新:
在遍历按钮列表进行绘制的函数中,我们首先检查F_BUTTON_VISIBLE标志。如果不可见,则跳过。如果可见,则根据F_BUTTON_ENABLED标志决定文本颜色(启用时为亮色,禁用时为暗色)。
draw_buttons:
mov bp, button_array
.loop:
mov ax, [bp+button.flags]
test ax, F_BUTTON_VISIBLE
jz .next_button ; 不可见,跳过
; ... 计算位置和绘制背景 ...
test ax, F_BUTTON_ENABLED
jnz .text_enabled
mov ch, COLOR_DARK_GRAY ; 禁用状态文本颜色
jmp .draw_text
.text_enabled:
mov ch, COLOR_LIGHT_GRAY ; 启用状态文本颜色
.draw_text:
; ... 绘制按钮文本 ...
.next_button:
add bp, button_size
jmp .loop

按钮设置宏:
为了方便地修改按钮状态,我们创建了一个button_set宏:
%macro button_set 2 ; %1=按钮标签, %2=标志位值
push bp
mov bp, %1
mov [bp+button.flags], word %2
pop bp
%endmacro
这样,在弹出消息框时,我们可以轻松地禁用主界面按钮,并启用消息框的按钮。


字符串绘制优化 ✨

在绘制消息框文本时,我们进一步优化了字符串绘制函数。本节中,我们添加了两个简单的优化以提升效率。




优化1:跳过空字符串
如果传入的Pascal字符串长度为0,则直接返回,避免不必要的循环。
draw_string:
; ... 设置SI为字符串指针 ...
xor cx, cx
mov cl, [si] ; 获取长度字节
jcxz .done ; 如果长度为0,直接结束
; ... 正常绘制逻辑 ...
.done:
ret
优化2:跳过空格
在绘制每个字符时,如果遇到空格(ASCII 0x20),我们可以直接跳到更新位置的计算,而无需进行颜色判断和像素绘制。
.draw_char_loop:
lodsb ; 加载字符到AL
cmp al, ' '
je .skip_space ; 是空格,跳过绘制步骤
; ... 正常处理非空格字符 ...
jmp .next_char
.skip_space:
; ... 仅更新绘制位置X坐标 ...
.next_char:
loop .draw_char_loop
这些优化虽然微小,但体现了在资源受限环境下(如DOS实模式)进行编程时的效率考量。

总结 🎯



本节课中我们一起学习了:
- 状态栈管理:使用栈来处理嵌套的UI状态转换,实现了
state_push、state_pop和state_top宏。 - 文本字段回调:将文本输入框的完成事件(回车/ESC)与状态转换逻辑连接起来。
- 对话框实现:创建了一个模态消息框状态,可以显示多行文本,并包含“确定”和“取消”按钮。
- 按钮状态优化:区分了按钮的“可见性”与“启用”状态,并据此改变渲染效果。
- 绘制例程优化:在字符串绘制中添加了对空字符串和空格的特殊处理,提升了渲染效率。


通过这些步骤,我们工具的基础UI框架变得更加健壮,能够处理更复杂的用户交互流程,为后续实现文件操作和存储库编辑功能打下了坚实基础。
005:实现更多编辑器状态机




概述
在本节课中,我们将继续构建x86汇编语言编辑器。我们将配置DOSBox环境,重构代码以提升模块化,并设计编辑器用户界面的核心布局与交互逻辑。课程将涵盖开发环境设置、代码结构优化以及UI组件的初步实现。

DOSBox环境配置与构建

上一节我们介绍了项目背景,本节中我们来看看开发环境的搭建细节。
我使用的DOSBox版本是从其官网下载源代码后自行构建的。构建时启用了内置调试器选项,虽然它功能较为基础,但在项目早期阶段用于调试中断有一定帮助。需要注意的是,标准构建的调试器在中断处理上存在缺陷,我应用了一个社区补丁来修复此问题。
以下是我的DOSBox配置文件(dosbox.conf)的核心设置:
fullscreen=false
fulldouble=false
windowresolution=1920x1280
output=opengl
machine=svga_s3
memsize=16
frameskip=1
aspect=false
scaler=normal2x
core=auto
cputype=pentium_slow
cycles=max
cycleup=10
cycledown=20
这些设置旨在模拟接近原始Pentium的环境,并确保图形显示的稳定性。
项目结构与构建系统
配置好环境后,我们来看看项目的代码组织方式。
我使用A86汇编器,并通过Makefile管理构建过程。项目主要生成两个.COM文件:游戏引擎和编辑器工具。Makefile利用A86的特性,通过命令行定义条件编译常量。
以下是Makefile的简化结构:
# 定义目标和源文件
GAME_SRC = game.8
TOOL_SRC = tool.8

# 使用A86汇编,并定义DEBUG常量
all: game.com tool.com
game.com: $(GAME_SRC)
a86 $(GAME_SRC) =debug game.com

tool.com: $(TOOL_SRC)
a86 $(TOOL_SRC) =debug tool.com
通过=debug这样的语法,我们可以在汇编代码中使用#if debug来进行条件编译。
代码重构与模块化
在理解了构建系统后,我们开始优化代码结构。之前的代码都集中在主程序文件中,显得臃肿。我将其拆分为多个模块,每个模块负责特定功能,并使用统一的两字符前缀命名规范。
以下是重构后的模块概览:
- 定时器模块 (
ct_): 处理计时器和回调函数。 - 按钮模块 (
bt_): 管理按钮的绘制、状态检测和触发逻辑。 - 文本框模块 (
tf_): 处理文本输入、验证和编辑。 - 状态机模块 (
st_): 提供状态栈的推送、弹出和检查功能。 - 消息框模块 (
mb_): 负责消息提示框的显示。
每个模块将内部函数标记为“私有”(以下划线开头),并通过宏提供简洁的公共接口。这种结构提升了代码的可读性和可维护性。
状态管理与按钮交互修复
重构代码后,我们遇到了一个具体的交互问题需要解决。





在测试时,点击“加载”按钮会导致程序锁死。问题根源在于鼠标状态轮询与按钮回调之间的时序冲突。按钮按下事件会在多个帧中被反复触发,导致状态函数被递归调用,最终栈溢出。

我们通过两个修改解决了这个问题:
- 立即禁用按钮: 在按钮回调函数中,首先将按钮设置为禁用状态,防止其在同一操作中被多次触发。
- 添加状态检查: 在进入新状态前,先检查当前是否已处于该状态。如果是,则不再执行状态推送操作。



状态检查的宏实现如下:
; ST_CHECK 宏:检查栈顶是否为指定状态
; 输入:state - 要检查的状态值
; 输出:AL = 1 (是) 或 0 (否)
ST_CHECK MACRO state
push bp
mov bp, sp
mov al, [st_stack_top] ; 假设 st_stack_top 指向栈顶
cmp al, state
je @is_same
mov al, 0
jmp @check_done
@is_same:
mov al, 1
@check_done:
pop bp
ENDM
这个模式确保了状态转换的稳定性。





编辑器用户界面设计



解决了底层交互问题,现在我们可以专注于编辑器的视觉布局和交互设计。
我重新规划了编辑器主界面的布局,以容纳更多功能:
- 顶部标签栏: 显示已创建的数据块(如精灵、图块、调色板)标签,支持左右翻页。
- 中央编辑区: 根据所选数据类型,显示放大编辑视图(例如像素网格)。
- 底部面板: 显示数据块的缩略图网格,并提供块间导航按钮。
- 侧边控制区: 放置“添加”、“移除”、“清除”等银行级操作按钮,以及上下文相关的按钮(如“选择调色板”、“选择图块集”)。
我编写了原型代码来绘制标签、导航箭头和编辑网格,以确定合适的尺寸和布局。例如,图块编辑网格可以放大显示8x8像素,而精灵编辑网格则显示16x16像素。
数据块管理逻辑设计
界面布局确定后,我们需要设计其背后数据管理的核心逻辑。


我重新考虑了数据块(Bank)的内存分配策略。最初计划为每个数据块分配完整的64KB段,但这对于调色板(约1KB)或背景(约4KB)等小型数据会造成浪费。
新的方案是:在创建数据块时,根据其类型指定所需的最大块数(每块4KB)。例如,精灵和图块库可能需要16块,而调色板只需1块,背景可能需要2块。
此外,为了处理数据块删除可能产生的内存碎片,我们采用了一种“压缩”策略:当删除一个数据块时,程序会在内存中将剩余有效数据块重新紧凑排列,模拟垃圾回收的效果,以保持内存使用的连续性。
下一步行动计划
基于以上设计和原型,我们制定了接下来的实现步骤:
- 扩展按钮结构: 支持背景色、边框色定义,以及“仅文本”、“无渲染”等标志,用于导航箭头等特殊按钮。
- 实现网格选择逻辑: 创建新的数据结构,用于处理在缩略图网格上的鼠标点击,计算对应的行、列索引。
- 修改数据块头结构: 在头信息中存储该数据块分配的最大块数。
- 实现用户界面联动: 完成“新建”、“添加”按钮的完整流程,使点击后能创建数据块并更新标签栏显示。
- 完善编辑器视图: 根据所选数据块类型,切换并渲染对应的编辑界面(像素编辑器、调色板编辑器等)。

总结
本节课中我们一起学习了如何配置和优化x86汇编开发环境,通过重构代码提升了项目的模块化程度,并深入设计了编辑器工具的用户界面与核心数据管理逻辑。我们解决了状态机中的交互缺陷,规划了内存分配策略,并绘制了UI原型。下一步将着手实现这些设计,逐步构建出一个功能完整的图形化资源编辑器。
006:将数据块连接到用户界面




在本节课中,我们将继续为MS-DOS街机项目编写x86汇编代码。我们将重构原型代码,创建状态回调函数,并将功能模块化,以便更清晰地管理不同的数据块(如瓦片、精灵、字体)的编辑状态。核心任务包括:将标签栏和查看器分离,实现基于状态的动态回调机制,以及为按钮系统添加更多自定义功能。





概述
上一节我们完成了用户界面的基础框架。本节中,我们将重点重构代码,将标签栏、数据块查看器和编辑器逻辑分离。我们将创建一个状态系统,根据用户选择的标签(如瓦片、精灵、字体)动态调用相应的绘图和处理器函数。同时,我们还将增强按钮系统,支持仅显示文本的按钮和自定义背景/边框颜色。
重构标签栏与查看器
首先,我们需要将绘制标签栏和下方查看器区域的代码从主绘制函数中分离出来,形成独立的函数和宏。
创建标签绘制函数

我们创建了 draw_tab 函数来绘制单个标签,以及 draw_tabs 宏来循环绘制所有标签。标签现在可以显示为激活或非激活状态,颜色会相应变化。

; 伪代码示例:绘制标签的逻辑
draw_tab:
; 从栈中获取标签编号和位置
; 比较标签编号与当前选中的标签变量
setz_m al, selected_tab ; 自定义宏:如果相等,al=1,否则al=0
; 根据al的值设置背景色和文字颜色
; 绘制标签矩形和文字
ret
实现查看器回调机制
我们引入了一个函数指针变量 viewer_callback。根据当前所处的状态(如瓦片编辑状态、精灵编辑状态),我们将该指针设置为对应的绘图函数。主循环中的查看器绘制代码会检查这个指针,如果不为空,则调用它来绘制特定内容。


; 伪代码示例:查看器绘制逻辑
draw_viewer:
; 绘制公共的控制元素(如上下箭头、区块标签)
; 检查 viewer_callback 是否为空
cmp [viewer_callback], 0
je .skip_callback
call [viewer_callback] ; 调用状态特定的绘图函数
.skip_callback:
ret
参数化通用编辑器



由于瓦片、精灵和字体本质上都是基于网格的图形,我们可以使用同一个编辑器,只需传入不同的参数(如单元尺寸、放大像素尺寸)进行配置。



我们创建了 tile_ed_draw 函数,它接受尺寸参数,从而能够绘制8x8、16x16等不同规格的网格。
; 伪代码示例:参数化绘图调用
; 对于瓦片库:尺寸为8,放大像素为15
push 15
push 8
call tile_ed_draw
; 对于精灵库:尺寸为16,放大像素为8
push 8
push 16
call tile_ed_draw
增强按钮系统
为了支持更灵活的UI,我们改进了按钮系统。


添加“仅文本”按钮标志
我们为按钮数据添加了一个 BUTTON_TEXT_ONLY 标志。当设置此标志时,按钮只绘制文本,不绘制背景框和边框。这使得我们可以创建看起来像普通标签但具有按钮功能的控件(如翻页箭头)。
支持自定义背景和边框颜色
按钮宏现在接受独立的背景色和边框颜色参数,允许我们为不同类型的按钮(如数据块类型选择按钮)设置不同的视觉样式,使其更突出。
; 更新后的按钮宏示例
button_def BUTTON_PREV, BUTTON_ENABLED|BUTTON_TEXT_ONLY, "<", (248<<8)|85, 6, 6, 0, 0, prev_callback
按钮绘制逻辑重构
绘制函数被重写,首先检查 BUTTON_TEXT_ONLY 标志。如果是仅文本按钮,则跳过绘制背景和边框的步骤,直接绘制文本。否则,按照常规流程先绘制带颜色的矩形框,再绘制文本。
实现数据块类型选择界面
我们设计了一个弹出式小窗口,用于在添加新数据块时选择类型(瓦片、精灵、字体、调色板等)。这个窗口本身由按钮构成,点击任一按钮将触发创建对应类型数据块的回调函数。
我们创建了 pick_box_draw 函数来绘制这个选择框,并为其内部的每个类型选项创建了对应的按钮。






修复鼠标光标裁剪问题










在完善UI的过程中,我们发现鼠标光标在屏幕边缘绘制时会出现裁剪错误。我们修改了鼠标绘制代码,添加了对Y轴和X轴的边界检查,确保光标不会绘制到屏幕可视区域之外。





核心思路是在绘制每个像素前,检查其坐标是否超出屏幕边界(对于256x256的图形模式,边界是255)。如果超出,则跳过该像素的绘制。



; 伪代码示例:鼠标绘制的裁剪逻辑
draw_mouse:
; 获取鼠标位置 (X, Y)
; 检查 Y+16 是否 > 255
; 检查 X+16 是否 > 255
; 在循环内部,如果当前行或列的像素坐标超出边界,则调整循环或跳过绘制
ret

添加字符串复制功能
为了方便UI中的文本处理,我们添加了一个 string_copy 函数和对应的宏。它允许我们将一个字符串复制到另一个内存位置,这在填充对话框或消息时非常有用。
; 字符串复制宏示例
string_copy length, source, destination
总结
本节课中我们一起学习了如何将用户界面与底层数据块管理连接起来。我们通过重构代码,建立了清晰的状态回调机制,使得标签选择能够动态切换不同的编辑视图。我们增强了按钮系统,使其支持更丰富的视觉样式和功能。此外,我们还开始构建数据块类型选择界面,并解决了鼠标绘制的边缘裁剪问题。

这些工作为工具的核心功能——创建、查看和编辑不同的图形数据块——奠定了坚实的基础。下一节,我们将继续完善数据块的创建和文件操作逻辑。
007:将存储库连接到用户界面(第二部分)


概述
在本节课中,我们将继续学习如何将存储库(banks)系统连接到用户界面。我们将实现标签页的滚动、选择逻辑,并处理创建新文件时的用户确认流程。课程内容涉及内存管理、状态机处理和用户交互。
上一节回顾与本节目标
上一节我们介绍了存储库系统的基本结构和初始连接。本节中,我们来看看如何实现标签页的滚动浏览、正确的标签选择,以及如何处理用户尝试创建新文件时的确认对话框。
核心概念:内存段与偏移量
在x86实模式下,内存访问通过段寄存器和偏移量的组合来实现。一个段的大小是64KB,起始地址是16字节(一个段落)的倍数。
公式:
物理地址 = 段寄存器值 * 16 + 偏移量
例如,如果 ES = 0x3CBE,DI = 0x0100,那么访问的物理地址是:
0x3CBE0 + 0x0100 = 0x3CCE0


核心概念:状态机
用户界面使用一个简单的状态机来管理不同的屏幕和模式(例如,主界面、文件命名、消息框)。状态被压入一个栈中,当前活动的状态是栈顶的状态。


代码示例(状态检查):
; 检查当前是否处于“bank”状态
mov ax, [state_current]
cmp ax, STATE_BANK
je .in_bank_state
实现标签页滚动与选择
滚动偏移量管理
为了实现标签页的左右滚动,我们引入了一个变量 bank_start_offset。它表示当前显示的第一个存储库头在内存块中的偏移量。
代码示例(更新滚动偏移量):
; “上一个”按钮回调(向左滚动)
bank_previous:
push ax
mov ax, [bank_start_offset]
cmp ax, 0
je .done ; 如果已经在开头,则不做任何操作
sub ax, SIZE_BANK_BLOCK ; 减去一个存储库块的大小
mov [bank_start_offset], ax
.done:
pop ax
ret

; “下一个”按钮回调(向右滚动)
bank_next:
push ax
mov ax, [bank_start_offset]
mov bx, [bank_headers_offset] ; 已使用的存储库头总偏移量
sub bx, SIZE_BANK_BLOCK ; 指向最后一个有效块
cmp ax, bx
jae .done ; 如果已经到达末尾,则不做任何操作
add ax, SIZE_BANK_BLOCK ; 增加一个存储库块的大小
mov [bank_start_offset], ax
.done:
pop ax
ret

标签选择逻辑修正
当用户点击一个标签时,我们需要计算出该标签对应的实际存储库索引。这需要将标签的序号(1-4)与当前的滚动起始索引相加。
代码示例(处理标签点击):
; 假设按钮回调传递了标签号(1-4)在 AL 中
handle_tab_click:
mov bl, al ; BL = 标签号 (1-4)
dec bl ; 转换为0基 (0-3)
mov al, [bank_start_index] ; 获取当前滚动起始索引
add al, bl ; AL = 实际存储库索引
mov [selected_bank], al ; 存储选中的存储库
; ... 后续更新UI逻辑 ...
绘图例程的调整
在绘制标签时,绘图函数需要知道从哪个存储库头开始绘制,以及当前选中的是哪一个。
代码示例(标签绘图循环):
draw_tabs:
mov es, [bank_headers_seg] ; ES 指向存储库头段
mov bp, [bank_start_offset] ; BP 作为当前绘制偏移量
mov cx, 4 ; 最多绘制4个标签
.draw_loop:
; 检查是否到达有效存储库头的末尾
cmp byte [es:bp], 0 ; ID为0表示空槽位
je .exit
; 调用绘制单个标签的例程,传递 BP(偏移量)和 CX(标签序号)
call draw_single_tab
add bp, SIZE_BANK_BLOCK ; 移动到下一个存储库头
loop .draw_loop
.exit:
ret
处理新文件创建的确认流程
消息框全局状态
为了避免状态栈的复杂操作,我们使用全局变量来处理消息框的结果。
定义全局变量:
mb_action_flag db 0 ; 0=无动作,1=有动作发生
mb_result_flag db 0 ; 0=取消,1=确定

消息框检查宏
我们创建一个宏,供其他状态函数检查是否有待处理的消息框操作。
代码示例(MB_CHECK 宏):
; 宏:检查消息框动作
; 输出:AL = 0(无动作),1(取消),2(确定)
%macro MB_CHECK 0
mov al, 0
cmp byte [mb_action_flag], 0
je %%no_action
; 有动作发生,检查结果
cmp byte [mb_result_flag], 0
je %%was_cancel
mov al, 2 ; 动作为“确定”
jmp %%clear_flag
%%was_cancel:
mov al, 1 ; 动作为“取消”
%%clear_flag:
mov byte [mb_action_flag], 0 ; 清除标志
%%no_action:
%endmacro
新文件回调中的逻辑
当用户点击“新建”按钮时,如果已经有一个文件处于活动状态,我们需要弹出确认对话框。
代码示例(新文件回调):
new_file_callback:
; 首先,无论何种情况,都进入文件命名编辑状态
push STATE_EDIT_FILENAME
; 检查当前是否已处于 BANK 状态(即有文件已打开)
call state_check
cmp al, STATE_BANK
jne .proceed_normal ; 如果不是,直接继续
; 如果是,则需要警告用户。设置并显示消息框。
; 复制警告信息到消息框缓冲区...
; ...
push STATE_MESSAGE_BOX ; 将消息框状态压栈
ret ; 状态机将切换到消息框

.proceed_normal:
; 正常的文件命名流程...
; 启用文本输入框等...


消息框按钮回调
消息框的“确定”和“取消”按钮需要设置全局状态标志。

代码示例(消息框按钮回调):
; “确定”按钮回调
msgbox_ok_callback:
mov byte [mb_action_flag], 1
mov byte [mb_result_flag], 1 ; 1 代表“确定”
; 隐藏消息框,弹出其状态
; ...
ret

; “取消”按钮回调
msgbox_cancel_callback:
mov byte [mb_action_flag], 1
mov byte [mb_result_flag], 0 ; 0 代表“取消”
; 隐藏消息框,弹出其状态
; ...
ret
返回编辑状态后的处理
当从消息框返回到文件命名编辑状态后,该状态需要检查消息框的结果。
代码示例(编辑状态检查消息框):
edit_filename_state_callback:
MB_CHECK ; 检查消息框动作
cmp al, 1 ; AL=1 表示用户点击了“取消”
jne .continue_edit
; 用户取消了操作
pop_state ; 弹出当前的编辑状态
; 重置UI,将“新建”按钮重新启用等...
ret
.continue_edit:
; 正常的编辑逻辑...
; 处理键盘输入,更新文本字段...
用户界面辅助工具
文本字段标志设置宏
为了简化代码,我们创建了类似于按钮设置的宏来操作文本字段。
代码示例(TF_SET 宏):
; 宏:设置文本字段标志
; 参数1:文本字段ID
; 参数2:标志值(如 TF_READONLY)
%macro TF_SET 2
push bp
mov bp, %1 ; 文本字段结构地址
or [bp+field.flags], %2 ; 设置标志位
pop bp
%endmacro



总结
本节课中我们一起学习了如何完善存储库系统的用户界面。我们实现了:
- 标签页滚动:通过管理
bank_start_offset变量,使用“上一个/下一个”按钮控制显示哪些存储库。 - 正确的标签选择:将标签序号与滚动偏移量结合,确保点击标签时能选中正确的存储库。
- 用户确认流程:使用全局状态变量 (
mb_action_flag,mb_result_flag) 和MB_CHECK宏,优雅地处理了创建新文件时的覆盖警告,避免了复杂的状态栈操作。 - 代码优化:引入了
TF_SET等宏,使UI控件的管理更加清晰简洁。



通过这些工作,工具的核心导航和基础交互已基本完成,为下一步实现具体的存储库编辑器(如图块、精灵编辑器)打下了坚实的基础。
008:增强状态机以包含进入、离开和更新回调
概述
在本节课中,我们将学习如何增强一个x86汇编语言项目中的状态机,为其添加enter(进入)、leave(离开)和update(更新)回调函数。我们将通过修改现有的MS-DOS银行工具项目来实现这一改进,使状态转换的逻辑更加清晰和模块化。
项目背景与计划调整
本节将介绍当前项目的背景,以及主播对未来直播内容和项目安排的思考。
这个项目是MS-DOS街机游戏参考实现的一部分。主播正在制作一个非直播的、结构更严谨的x86汇编语言教育系列视频,而当前直播的项目是为那个系列所做的背景研究和参考材料。这有助于在录制有固定时长和进度的教育视频时,提前规划好内容。

主播计划调整未来的直播项目安排。当前MS-DOS参考实现项目将在本周结束后暂时从直播日程中移除,以便将更多时间投入到核心项目ReU(一个游戏开发工具)上,目标是赶在10月的波特兰复古游戏博览会上展示。同时,主播也对构建一个面向“无聊商业应用”的编译器和平台有着浓厚的兴趣。
修复键盘中断服务程序
上一节我们介绍了项目的背景,本节中我们来看看在真实硬件上测试时遇到的一个具体问题及其解决方案。
在真实的Packard Bell Pentium II硬件上测试时,发现键盘中断服务程序存在一个问题:键盘缓冲区会很快被填满。经过排查,问题在于原来的ISR(中断服务程序)错误地“链式调用”了旧的BIOS键盘ISR。
解决方案:不再调用旧的ISR,而是直接替换它,并在程序结束时重置回原来的ISR。同时,需要在ISR中正确地清除中断标志以防止递归中断,并在结束时重新设置。
以下是修复后的键盘ISR代码框架:
keyboard_isr:
cli ; 清除中断标志,防止递归中断
; ... 处理键盘扫描码 ...
; 不再调用旧的 int 09h
; 发送EOI(中断结束)信号给键盘控制器
mov al, 20h
out 20h, al
sti ; 重新设置中断标志
iret
增强状态机设计
上一节我们修复了一个硬件兼容性问题,本节中我们来看看本次教程的核心内容:增强状态机的设计。
原有的状态机结构在状态转换时逻辑分散,不够清晰。受Rust和ARM汇编中类似实现的启发,我们计划为每个状态添加三个回调函数:
enter: 当进入该状态时调用。leave: 当离开该状态时调用。update: 在该状态处于激活时,每帧调用。
状态数据结构:
我们扩展了状态的数据结构,使其包含三个函数指针。
; 状态结构体定义
struc state
.enter resw 1 ; 进入状态回调函数指针
.update resw 1 ; 状态更新回调函数指针
.leave resw 1 ; 离开状态回调函数指针
endstruc
实现状态机宏与回调
上一节我们设计了新的状态机结构,本节中我们来看看如何用汇编宏和代码实现它。
我们需要创建或修改几个关键的宏来管理状态栈和回调调用。
状态定义宏:
这个宏用于方便地定义一个新的状态实例。
%macro DEF_STATE 3 ; %1=状态名, %2=enter回调, %3=update回调, %4=leave回调
state_%1:
dw %2 ; enter回调
dw %3 ; update回调
dw %4 ; leave回调
%endmacro
状态栈操作宏:
以下是ST_PUSH(状态压栈)和ST_POP(状态出栈)宏的实现逻辑。
ST_PUSH宏的伪代码逻辑:
- 检查当前栈顶是否有状态。如果有,则调用其
leave回调。 - 将新状态的指针压入状态栈。
- 调用新状态的
enter回调。
ST_POP宏的伪代码逻辑:
- 调用当前栈顶状态的
leave回调。 - 将状态指针从栈中弹出。
- 调用新的栈顶状态的
enter回调。
在实现这些宏时,需要特别注意边界情况,例如当栈为空时进行第一次压栈操作,不应尝试调用leave回调。
整合到银行工具项目
上一节我们实现了状态机的核心宏,本节中我们来看看如何将这些改动整合到具体的MS-DOS银行工具项目中。
我们将重构工具的状态逻辑。首先,引入一个base(基础)状态作为初始状态和状态转换的枢纽,它负责根据是否有文件加载来决定下一步进入no_file(无文件)状态还是bank(银行数据)状态。

以下是整合步骤:
- 创建回调函数:为
no_file、new_file(新建文件)、bank等状态编写enter、update、leave回调函数。例如,no_file状态的enter回调负责启用“新建”按钮并将文本字段设置为只读。 - 使用新状态机:将原来的状态切换代码(如按钮处理)改为使用新的
ST_PUSH和ST_POP宏。 - 初始状态设置:程序启动时,将
base状态压入状态栈。

通过这样的重构,原来分散在更新循环中的状态转换逻辑(如显示消息框、启用/禁用按钮)被清晰地归纳到了各个状态的enter和leave回调中,使得代码更易于理解和维护。


总结






本节课中我们一起学习了如何增强一个x86汇编项目中的状态机。我们首先了解了项目的背景和未来的计划。然后,我们解决了一个在真实硬件上出现的键盘ISR问题。接着,我们设计了为状态添加enter、leave和update回调的新结构,并用汇编宏实现了状态栈的管理逻辑。最后,我们将这套新机制成功整合到了MS-DOS银行工具项目中,使状态管理的代码变得更加模块化和清晰。这次重构为项目后续的功能扩展奠定了良好的基础。
009:银行编辑器状态机实现
概述
在本节课中,我们将学习如何为MS-DOS下的x86汇编语言游戏引擎实现一个银行编辑器。我们将重点构建一个状态机,用于管理编辑器在不同模式(如瓦片编辑、精灵编辑)之间的切换。课程将涵盖状态栈管理、按钮去抖动、以及通过跳转表实现状态转换等核心概念。




状态机基础结构
上一节我们介绍了银行编辑器的整体概念。本节中,我们来看看状态机是如何在汇编层面组织和运行的。
状态机由一系列状态构成,每个状态对应编辑器的一种模式(如“无文件”、“银行总览”、“瓦片编辑”)。我们使用一个数据结构来定义每个状态的行为。
状态结构定义:
; 状态结构体
struc state_action
.code resw 1 ; 状态代码
.pad resb 1 ; 填充字节,用于对齐
.enter_cb resw 1 ; 进入状态时的回调函数指针
.update_cb resw 1 ; 状态运行时的更新回调函数指针
.leave_cb resw 1 ; 离开状态时的回调函数指针
endstruc


状态栈管理:
状态栈是一个包含16个字的数组(32字节),用于存储当前活跃的状态序列。栈指针(state_stack_ptr)初始指向栈底。当推入新状态时,指针减2;弹出状态时,指针加2。
状态切换宏:
我们使用宏来封装状态栈的推入(push)、弹出(pop)和检查(check)操作。例如,push宏会将新状态的地址放入栈中,并调用其enter回调函数。
银行选择与状态转换
在实现了基础状态机后,我们需要将编辑器的标签页(Tab)点击与具体的编辑状态关联起来。
以下是实现此功能的关键步骤:
-
获取银行指针:当用户点击一个标签页时,我们需要获取对应银行数据结构的指针。这通过
bank_pointer宏完成,它根据传入的索引计算银行头在内存段中的偏移量。
公式:bank_address = bank_header_segment_base + (index * bank_block_size) -
确定银行类型:从银行头数据结构中读取类型字段(例如,1=精灵,2=瓦片)。


- 使用跳转表进行状态分发:根据银行类型,通过一个跳转表(Jump Table)调用相应的状态初始化函数。这模拟了高级语言中的
switch语句。
代码示例:
通过将类型值(转换为零基索引并乘以2)与跳转表基地址相加,我们可以获取到目标函数的地址并跳转过去。; 跳转表定义 bank_select_jumps: dw sprite_bank_selected dw tile_bank_selected dw background_bank_selected ; ... 其他类型

- 状态栈管理:在进入新的编辑状态前,需要管理状态栈:
- 如果当前处于基础的“银行”状态,则直接推入新状态。
- 如果已处于某个编辑状态,则需要先弹出当前状态(调用其
leave回调),再推入新状态。这确保了状态栈不会无限增长。
按钮系统与去抖动
状态转换由界面上的按钮触发。由于程序运行速度极快(超过100帧/秒),一次物理点击可能会在多个连续帧中被检测到,导致状态被多次切换。因此,我们需要为按钮系统实现去抖动(Debouncing)逻辑。
以下是按钮处理的流程:
- 按钮按下检测:在每一帧中,检查鼠标左键是否按下。
- 命中测试:如果按下,则遍历所有按钮,检查鼠标坐标是否在按钮的矩形区域内。
- 去抖动标志:每个按钮数据结构中有一个去抖动标志位。当检测到有效的点击时:
- 如果该按钮的去抖动标志未设置,则设置该标志,并调用按钮关联的回调函数(触发状态切换)。
- 如果该标志已设置,则忽略此次点击,直到标志被清除。
- 标志重置:当鼠标左键被释放(未按下)时,遍历所有按钮,清除那些设置了去抖动标志的按钮的标志位。这样,按钮就准备好响应下一次点击了。
这个机制确保了即使鼠标驱动程序报告了多次连续的“按下”状态,每个按钮点击也只触发一次动作。








总结


本节课中我们一起学习了x86汇编语言下银行编辑器状态机的完整实现。我们构建了一个支持进入、更新和离开回调的状态机框架,并通过跳转表将银行类型映射到具体的编辑状态。同时,我们为按钮系统实现了去抖动逻辑,确保了用户交互的准确性和可靠性。这些核心机制为后续实现具体的瓦片、精灵等编辑器功能奠定了坚实的基础。下一节,我们将开始深入各个编辑状态,实现具体的绘制和交互逻辑。
010:调色板库编辑器
在本节课中,我们将学习如何为MS-DOS下的游戏开发工具实现一个调色板编辑器。我们将重点讲解如何管理调色板数据块、渲染调色板选择界面,以及处理用户交互。课程内容基于x86汇编语言,适合希望深入了解底层图形编程的初学者。
概述与准备工作


上一节我们完成了工具中状态机与标签页的集成。本节中,我们将专注于调色板编辑器的实现。调色板是图形编程中的核心概念,它定义了屏幕上可以显示的颜色集合。在VGA图形模式下,一个调色板通常包含256个颜色条目,每个条目由红、绿、蓝(RGB)三个分量组成。

我们的目标是创建一个界面,允许用户从16个调色板块中选择一个,然后编辑该调色块中的16种颜色。界面上方将显示当前选中的调色板及其RGB值,下方则显示所有可选的调色板块。
调色板数据结构与初始化
首先,我们需要理解调色板在内存中是如何组织的。当我们创建一个新的调色板库时,会调用 bank_new 函数。该函数会为调色板数据分配内存块。
以下是分配和初始化调色板库的关键代码逻辑:
; 假设我们请求创建包含2个块的调色板库
; bank_type = palette, num_blocks = 2
call bank_new
函数内部会通过 MM_Reserve 分配内存段,并设置库头信息,包括最大块数(max_blocks)。对于调色板,每个数据块对应一个包含16种颜色的调色板。

实现调色板查看器回调
工具的核心是状态机,每个状态(如调色板模式、瓦片模式)都有一个对应的“查看器回调函数”。这个函数负责绘制该状态下的主界面。


当我们进入调色板库状态时,需要执行以下步骤:
- 将查看器回调函数设置为
palette_viewer。 - 根据选中的标签页,获取对应的库头指针。
- 重置当前块索引为0。
- 更新界面显示的“总块数”和“当前块号”字符串。
- 启用“上一个块”和“下一个块”的导航按钮。


以下是进入调色板状态时的代码框架:
enter_palette_bank:
; 设置回调函数
mov [viewer_callback], offset palette_viewer
; 获取当前标签页对应的库指针到BP
tab_block_macro
; 重置块索引
mov [block_index], 0
; 更新界面字符串
call update_block_total_string
call update_block_number_string
; 启用导航按钮
call btn_enable_block_prev
call btn_enable_block_next
ret
离开该状态时,我们需要清除回调并禁用导航按钮。
绘制调色板选择界面
palette_viewer 函数的主要任务是在屏幕下方绘制16个调色板块的选择区域。每个块用一个16x16像素的色块表示,并标有编号(0-15)。
绘制逻辑如下:
- 计算每个色块在屏幕上的位置(X, Y坐标)。
- 使用
set_palette_index和set_palette_rgb宏来设置VGA调色板寄存器,以显示正确的颜色。 - 在色块旁边绘制其编号。
我们计划将16个色块排列成两行,每行8个,以确保它们能适应屏幕。色块的颜色来自当前加载的调色板数据。
连接交互与状态更新
当用户点击下方的某个调色板块时,我们需要:
- 捕获鼠标点击事件。
- 计算点击发生在哪个色块上。
- 将选中的调色板索引(0-15)存储到状态变量中。
- 触发界面刷新,使上方的颜色编辑区域显示新选中调色板的16种颜色及其RGB值。
RGB值将以文本输入框的形式显示在每种颜色旁边,允许用户直接修改。修改值会实时调用 set_palette_rgb 宏来更新硬件调色板,从而实现即时预览。
调试与代码优化
在开发过程中,我们遇到并修复了几个问题:
- 栈指针错误:在
bank_new函数中,向栈压入一个值后,错误地调整了栈指针(SP),这可能导致后续程序崩溃。修正为正确的调整量。 - 变量访问错误:在渲染代码中,错误地访问了结构体中的变量,导致显示异常。修正了源操作数。
- 宏的改进:将频繁使用的十进制数字转字符串代码封装成了宏
S$dec2,使调用处的代码更清晰。 - 调色板设置拆分:将设置调色板的操作拆分为
set_palette_index和set_palette_rgb两个宏,这样更便于批量加载整个调色板。



总结与后续工作









本节课中我们一起学习了如何为汇编语言工具实现一个调色板编辑器。我们完成了以下核心工作:
- 理解了调色板库的数据结构及其初始化过程。
- 实现了状态机的回调机制,将调色板查看器集成到工具中。
- 绘制了调色板选择界面,并规划了交互逻辑。
- 修复了开发过程中遇到的几个关键错误,并优化了代码结构。




目前,调色板选择界面的绘制已基本完成。接下来的工作是:
- 在界面右侧为每种颜色添加R、G、B三个文本输入框。
- 完成第二列色块的绘制,以显示全部16个调色板块。
- 将文本输入框的数据结构与事件处理代码连接起来,实现完整的颜色编辑功能。


通过本节课,你不仅看到了一个具体功能的实现过程,也体会到了在汇编层进行系统编程时,对数据结构和状态管理的细致考量。这些概念是底层软件开发的基础。
011:调色板库编辑器(第二部分)📊
概述
在本节课中,我们将继续学习如何为MS-DOS下的游戏引擎开发工具。具体来说,我们将深入探讨调色板库编辑器的实现,包括如何在汇编语言中创建和管理数据块(block),以及如何从VGA硬件读取调色板数据并填充到我们自定义的数据结构中。我们还将学习如何通过宏(macro)来优化重复的键盘处理代码,使程序结构更清晰、更易于维护。
调色板库与数据块结构
上一节我们介绍了调色板库的基本概念和头信息。本节中,我们来看看如何为库创建具体的数据块。
一个调色板库(bank)可以包含多个数据块(block)。每个块都有一个小的头部,用于存储类型、ID和标志等信息,后面跟着实际的数据。对于调色板数据,一个块就足够了,因为它可以容纳768字节(256种颜色,每种颜色由红、绿、蓝3个字节组成)。
创建新数据块
为了管理块,我们需要一个函数来在已选中的库中创建新块。以下是创建新块的核心步骤:
- 获取当前库的块偏移量:每个库的头部都记录了下一次可用块的起始内存位置。
- 更新库头部:将块偏移量增加一个块的大小(4096字节),并标记库为“脏”(表示已修改,需要保存)。
- 初始化块头部:在新块的位置写入块类型、唯一的块ID和初始标志。
- 设置寄存器:函数返回时,段寄存器
ES和基址指针BP将指向新创建的块,方便后续直接操作数据。
以下是block_new函数的核心逻辑框架:
; 假设已通过 bank_pointer 宏选中了一个库
block_new:
push es
push bp
; 1. 从库头部获取当前块偏移量到 BX
mov bx, [es:bank_block_offset]
; 2. 更新库头部的偏移量,并标记为脏
add bx, size_of_block
mov [es:bank_block_offset], bx
mov al, [es:bank_flags]
or al, FLAG_BANK_DIRTY
mov [es:bank_flags], al
; 3. 切换ES到块的段,BP指向新块
mov es, [bank_block_segment]
mov bp, bx
; 4. 初始化块头部
mov byte [es:bp+block_type], BLOCK_TYPE_DATA
mov al, [bank_block_id_counter]
mov [es:bp+block_id], al
inc byte [bank_block_id_counter]
mov byte [es:bp+block_flags], FLAG_BLOCK_DIRTY
; 5. 清理并返回
pop bp ; 恢复原来的BP(指向库头部)
; ES:BP 现在指向新块的数据区
ret
从VGA硬件读取调色板
创建了空的调色板块后,我们需要用当前VGA硬件的调色板数据来填充它。VGA调色板有256个条目,每个条目是一个RGB三元组。
与VGA硬件交互
我们需要通过特定的I/O端口来读取调色板数据。以下是两个关键的宏:


sc_pal_r:设置要读取的调色板索引(0-255)。sc_rgb_r:读取当前选定索引的RGB值,并存储到一个三字节的结构中。
以下是填充调色板块的代码流程:
; DI 指向块内数据区的开始(跳过头部)
mov di, bp
add di, block_data_offset
; CX 作为计数器,设置为256(颜色数)
mov cx, 256
.fill_palette_loop:
; 1. 设置要读取的调色板索引
sc_pal_r cx ; 假设CX是当前索引(0-255)
; 2. 读取RGB到临时结构
sc_rgb_r color_temp_struct ; color_temp_struct 定义为 resb 3
; 3. 将RGB值存入块中
mov al, [color_temp_struct + red]
stosb ; 存储红色分量,并递增DI
mov al, [color_temp_struct + green]
stosb ; 存储绿色分量
mov al, [color_temp_struct + blue]
stosb ; 存储蓝色分量
; 4. 循环
loop .fill_palette_loop
这段代码循环256次,将整个VGA调色板复制到我们创建的数据块中,作为编辑的起点。
优化键盘处理:使用宏生成跳转表
在编辑器的不同视图(如调色板、图块、精灵)中,键盘处理逻辑(如方向键移动选择框、翻页)非常相似。为了避免编写大量重复的代码,我们可以创建一个宏来动态生成跳转表(jump table)。

宏的工作原理
这个宏(例如叫key_common)接受参数(如状态名、索引变量名、最大值等),然后在编译时生成一段代码和一个跳转表。跳转表将扫描码(scan code)映射到对应的处理函数地址。


以下是宏生成的代码结构示例:

; 生成的跳转表数据
key_table_state:
db SCANCODE_UP
dw callback_up
db SCANCODE_DOWN
dw callback_down
db SCANCODE_LEFT
dw callback_left
db SCANCODE_RIGHT
dw callback_right
db SCANCODE_PGUP
dw callback_pgup
db SCANCODE_PGDN
dw callback_pgdw
db 0 ; 结束标记


; 生成的键盘处理逻辑
handle_keys_state:
mov bp, key_table_state ; BP指向跳转表
call key_get ; 获取按键扫描码到AL
cmp al, 0
je .done
.search_loop:
cmp al, [bp] ; 与表中的扫描码比较
je .found
add bp, 3 ; 跳到下一个表项(1字节扫描码 + 2字节地址)
cmp byte [bp], 0 ; 是否到表尾?
jne .search_loop
jmp .done
.found:
mov bx, [bp+1] ; 获取处理函数地址
call bx ; 调用对应的处理函数
mov al, 1 ; 设置已处理标志
.done:
ret








使用宏的好处
- 代码复用:不同的编辑器状态只需调用同一个宏并传入不同参数。
- 避免分支限制:x86汇编中条件跳转(
jcc)有距离限制,而通过call指令调用跳转表中的函数则没有这个问题。 - 结构清晰:将按键逻辑集中管理,易于调试和扩展。




在调色板、图块和精灵编辑器的更新函数中,我们现在可以这样简洁地调用:

update_tile_bank:
key_common tile, tile_index, 132, 22
; ... 其他更新逻辑
ret
update_sprite_bank:
key_common sprite, sprite_index, 36, 12
; ... 其他更新逻辑
ret
渲染图块数据
对于图块库,我们不仅需要管理数据,还需要将其渲染到屏幕上。图块数据通常以半字节(nibble,4位)打包的形式存储,每个半字节代表一个像素的颜色索引(0-15)。

渲染逻辑
渲染函数需要:
- 从数据块中读取打包的像素数据。
- 将每个半字节解包,并根据指定的调色板获取实际颜色。
- 在屏幕上绘制一个“大像素”(一个矩形),因为我们的编辑器视图是放大显示的。
以下是渲染一个图块行的简化逻辑:
; 输入:SI指向源数据,BH/BL为起始坐标,DX为“大像素”尺寸,CL为调色板号
draw_tile_row:
mov ch, TILE_WIDTH ; 每行像素数
.row_loop:
lodsb ; 从[DS:SI]加载一个字节到AL,SI++
; 解包高4位和低4位
mov ah, al
shr ah, 4 ; AH = 高4位(第一个像素)
and al, 0x0F ; AL = 低4位(第二个像素)
; 绘制第一个大像素(使用AH中的颜色索引)
push cx
mov ch, ah ; 颜色索引
; 调用绘制矩形函数,使用(BH,BL)为左上角,DX为尺寸,(CH,CL)为颜色信息
call draw_filled_rect
add bh, dl ; X坐标增加一个像素宽度
; 绘制第二个大像素(使用AL中的颜色索引)
mov ch, al
call draw_filled_rect
add bh, dl ; X坐标再增加
pop cx
sub ch, 2 ; 已处理两个像素
jnz .row_loop ; 继续处理此行
ret


在视图的回调函数中,我们遍历当前块中的所有图块数据,为每个图块调用此渲染逻辑,从而在编辑器网格中显示出来。





总结
本节课中我们一起学习了x86汇编语言游戏工具开发的几个核心进阶主题:
- 数据块管理:我们实现了在内存库中动态创建和初始化数据块的功能,这是构建复杂数据编辑器的基础。
- 硬件交互:学习了如何通过I/O端口与VGA硬件通信,读取当前的调色板数据,并将其集成到我们自己的数据模型中。
- 代码优化技巧:我们创建了一个强大的宏,用于生成键盘处理的跳转表,极大地减少了重复代码,提高了项目的可维护性和清晰度。
- 数据渲染:探讨了如何将存储的图块数据(半字节打包格式)解包并渲染到屏幕上,实现了编辑器的可视化部分。


通过这些实践,我们不仅加深了对x86汇编和MS-DOS环境下编程的理解,也掌握了构建实用工具所需的系统化思维和代码组织方法。在接下来的课程中,我们将在此基础上,为精灵和字体编辑器添加类似的功能,并最终完成整个银行编辑工具。
012:鼠标区域选择、粗位网格、调色板与背景
概述
在本节课中,我们将学习如何在x86汇编语言中实现一个图形编辑工具的核心功能。具体内容包括:通过鼠标在矩形区域内进行选择、处理粗位网格(fat-bit grids)数据、管理调色板(palettes)以及处理背景(backgrounds)。我们将从修复内存初始化问题开始,逐步实现鼠标交互和图形数据的编辑。
12.1:项目背景与本周目标
上一节我们介绍了项目的基本框架。本节中,我们来看看本周的具体工作安排。
我目前正在为我的街机游戏引擎开发一个参考实现,这个实现将用于指导我即将制作的教育视频系列。本周,我们将在直播中完成这个MS-DOS参考实现的主要部分。
昨天,我在直播之外修复了一些问题,现在图块(tiles)、精灵(sprites) 和调色板(palettes) 的显示已经基本正常。不同的数据块(banks)都能正确显示,粗位编辑器也能展示其中的数据。
接下来的核心任务是:
- 实现一个通用的函数,用于处理鼠标在指定矩形网格区域内的点击选择。
- 完善数据块(bank)的初始化过程,确保所有块(blocks)都被正确初始化为预设模式(如棋盘格),而不仅仅是分配和清零内存。
12.2:实现鼠标网格选择功能
上一节我们明确了需要通用的鼠标选择功能。本节中,我们来实现这个核心的 grid_select 函数。
这个函数的目标是:给定一个屏幕上的矩形区域、网格的列数和每个网格单元的像素尺寸,当用户在此区域内按下鼠标左键时,函数能计算出鼠标点击所在网格的线性索引(linear index)。
以下是该函数的关键步骤:
- 检查鼠标状态:首先检测鼠标左键是否被按下。如果没有,则直接返回。
- 计算相对坐标:获取鼠标坐标,减去矩形区域的左上角坐标,得到相对于区域左上角(0,0)的坐标。
- 转换为网格索引:将相对坐标的X和Y值分别除以每个网格单元的宽度和高度(需考虑网格间的间隔),得到网格内的
(x, y)坐标。 - 计算线性索引:使用公式
index = y * columns + x将网格坐标转换为线性索引。 - 返回结果:通过寄存器
AX返回结果。AH用于指示是否发生有效选择(1为是,0为否),AL则存储计算出的线性索引值。

为了让调用更简洁,我创建了一个宏 mouse_grid_select 来封装参数传递和函数调用。

; 伪代码示例:grid_select 函数核心逻辑
; 输入:栈上传递参数 (x, y, width, height, columns, cell_size)
; 输出:AH=选择状态,AL=网格索引
grid_select:
test_left_mouse_button
jz no_selection
calculate_relative_coords
divide_by_cell_size ; 得到 grid_x, grid_y
linear_index = grid_y * columns + grid_x
mov ah, 1
mov al, linear_index
ret
no_selection:
mov ax, 0
ret
这个通用函数随后被应用到图块编辑器、精灵编辑器和调色板选择器中,只需传入不同的区域参数和网格大小即可。


12.3:完善数据块初始化机制
在实现了鼠标交互后,我们发现数据块的初始化机制有待完善。之前,bank_new 函数只分配内存段并将其清零,但并未初始化每个块(block)的头部信息和数据内容。
理想情况下,不同类型的资源库应有不同的初始化模式:
- 图块库(Tile Bank) 和精灵库(Sprite Bank):每个块应初始化为一个棋盘格图案,表示尚未编辑。
- 调色板库(Palette Bank):应加载默认的VGA调色板。
为了更灵活,我决定扩展 bank_new 函数。现在,它在创建库时会接受一个回调函数指针。该函数会为每个块(对于图块和精灵是16次,调色板是1次)被调用,并将ES:BP设置为指向当前块的正确位置。这样,回调函数就可以执行特定的初始化操作,例如写入块头或填充默认数据。
; 伪代码示例:改进后的 bank_new 初始化循环
bank_new:
mov cx, max_blocks ; 例如,图块库为16
init_loop:
setup_block_frame ; 设置 ES:BP 指向当前块
call user_callback ; 调用传入的回调函数进行初始化
loop init_loop

通过这种方式,我们确保了所有库在创建时都处于完全初始化的就绪状态,为后续的编辑操作打下了坚实的基础。
12.4:集成调色板选择与键盘快捷键
上一节我们完善了底层的数据管理。本节中,我们来看看如何提升用户交互体验,特别是集成调色板选择和键盘快捷键。
调色板选择:在编辑图块或精灵时,艺术家可能需要预览同一图形在不同调色板下的效果。因此,我在编辑器界面添加了调色板显示和切换控件。
- 在界面左上角添加了“Pal”标签和当前调色板编号显示。
- 添加了“上一个(<)”和“下一个(>)”按钮,用于切换调色板。
- 同时,增加了键盘快捷键:左括号
[和 右括号]也可以循环切换调色板索引。
颜色选择快捷键:在编辑器的颜色选择区域(通常显示16种颜色),除了用鼠标点击,我还添加了键盘快捷键以提高效率。
- 按键 0 到 9 可以直接选择前10种颜色。
- (后续计划)使用 Ctrl+0 到 Ctrl+5 来选择剩下的6种颜色。
这些交互改进使得编辑工作流更加流畅,用户可以根据习惯选择鼠标或键盘进行操作。
12.5:编辑器的数据流与联动更新


现在,让我们将各个部分连接起来,理解整个编辑器的数据流。





当用户在颜色选择区(通过鼠标或键盘)选中一个颜色索引后,这个索引值被存储为“当前活动颜色”。随后,当用户在主要的图块/精灵编辑网格中点击某个像素时:
- 通过
grid_select函数计算出被点击像素的线性索引。 - 根据当前活动颜色,计算出需要修改的目标内存地址。对于4色(2bpp)的图块,每个像素由2个位表示,可能需要修改一个字节中的高半字节或低半字节。
- 直接修改
ES:BP指向的块数据内存中的相应字节。 - 由于显示逻辑每一帧都会从相同的内存地址读取数据并重绘屏幕,所以修改会立即反映在右侧的放大编辑视图和左侧的缩略图列表中。


这样就实现了一个完整的、双向联动的编辑循环。内存是唯一的事实来源,所有视图都是它的实时反映。




12.6:未解决的问题与未来计划
虽然我们已经取得了显著进展,但仍有一些问题需要解决,并为下周的工作制定了计划。

当前问题:
- 调色板数据输入:如何优雅地编辑调色板中每个颜色的RGB值?是弹出一个对话框,还是在界面上创建48个(16色×3通道)输入字段?这需要实现文本框之间的Tab切换等额外功能。
- 背景编辑器:背景编辑器需要关联一个特定的图块库。我考虑在编辑器界面添加一个“图块集”按钮或下拉选择器,用于选择当前文件中的哪个图块库作为背景的源。
- 文件I/O:目前所有编辑都在内存中进行,尚未实现将数据保存到磁盘文件(.BANK文件)的功能。

下周计划:
下周将是MS-DOS版本工具开发的最后一周。目标是:
- 完成图块、精灵和字体编辑器的功能,确保它们的行为一致且完善。
- 尽可能推进调色板编辑器的数据输入界面。
- 开始着手背景编辑器的基本框架,至少实现图块集的选择和基础编辑。
完成这些后,这个参考实现将告一段落。接下来的视频课程制作将基于这个原型,但可能会为了教学清晰度而进行重构或简化。
总结

本节课中,我们一起学习了如何为一个图形编辑工具实现核心的交互与数据管理功能。我们创建了通用的 grid_select 函数来处理鼠标网格选择,重构了 bank_new 的初始化过程以支持可定制的回调,增加了调色板切换和键盘快捷键来改善用户体验,并理解了编辑器内部数据联动更新的原理。尽管在调色板编辑和背景编辑等方面仍有工作要做,但我们已经为图块和精灵的像素级编辑建立了一个强大且可扩展的基础。
013:精灵、瓦片与字体编辑器收尾 🎮
在本节课中,我们将学习如何完成一个用于游戏开发的精灵、瓦片和字体编辑器。我们将重点重构网格选择功能,使其更加通用,并集成到各个编辑模块中。同时,我们会处理半字节编码的像素数据,并修复一些界面和内存相关的错误。

概述
上一节我们实现了基础的网格选择功能。本节中,我们将对其进行重构,使其成为一个可复用的组件,并集成到瓦片、精灵和字体编辑器中。我们还将实现半字节编码的像素绘制逻辑,并解决编辑器界面中的一些交互问题。
重构网格选择模块
首先,我们需要将网格选择逻辑从主模块移动到专门的瓦片编辑器模块中,使其更加通用。以下是重构的核心步骤:
-
创建通用配置结构:我们定义了一个配置结构,用于存储每个编辑器网格的特定参数,如像素放大尺寸、网格宽高和最大索引值。
; 伪代码示例:配置结构 TileEditorConfig: .fatWidth: db ? ; 放大网格宽度 .fatHeight: db ? ; 放大网格高度 .maxIndex: dw ? ; 最大索引值 -
更新网格选择函数:修改网格选择函数,使其接收一个指向索引变量的指针和最大索引值作为参数。这样,函数可以直接更新内存中的索引值,而无需通过寄存器传递。
; 函数原型:更新网格选择 ; 输入:指向索引变量的指针,最大索引值 ; 输出:更新内存中的索引值 -
集成到各编辑器:为瓦片、精灵和字体编辑器分别创建更新函数,这些函数调用通用的网格选择逻辑,并传入各自的配置参数。
实现半字节编码绘制
在编辑器中,像素数据以半字节形式编码存储。这意味着每个字节包含两个像素的信息(高4位和低4位)。绘制时需要根据点击位置修改正确的半字节。
以下是处理半字节编码的核心逻辑:
-
计算字节索引:将网格中的点击索引除以2,得到对应的字节在数据块中的位置。
mov ax, [tileFatIndex] ; 获取点击索引 shr ax, 1 ; 除以2,得到字节索引 -
判断高低半字节:检查原始索引的最低有效位,以确定需要修改的是字节的高半字节还是低半字节。
test byte [tileFatIndex], 1 ; 测试最低位 jnz .isOdd ; 如果为1,是奇数索引(低半字节) ; 否则为偶数索引(高半字节)

- 更新像素数据:
- 对于高半字节,将新的颜色值左移4位,然后与原始字节的低半字节进行逻辑或操作。
- 对于低半字节,保留原始字节的高半字节,将新的颜色值与低4位进行逻辑或操作。
; 更新高半字节示例 mov cl, [selectedColor] shl cl, 4 ; 左移4位到高半字节 and byte [si], 0x0F ; 清除目标字节的高半字节 or byte [si], cl ; 设置新的高半字节颜色值 ; 更新低半字节示例 mov cl, [selectedColor] and cl, 0x0F ; 确保颜色值在低4位 and byte [si], 0xF0 ; 清除目标字节的低半字节 or byte [si], cl ; 设置新的低半字节颜色值




修复界面与交互问题
在开发过程中,我们发现了一些需要修复的问题:

- 选择框遮挡像素:调整选择框的绘制逻辑,使其绘制在瓦片或精灵的外部边缘,而不是覆盖在像素上。
- 按钮误触:将“退出”等关键按钮移动到编辑区域之外,防止在绘制时意外点击。
- 调色板内存错误:修复调色板编辑器在写入时错误覆盖其他内存区域的问题。这通常是由于内存地址计算错误或银行切换逻辑有误导致的。

总结
本节课中我们一起学习了如何完成精灵、瓦片和字体编辑器的核心功能。通过重构网格选择模块,我们实现了代码的复用和模块化。通过处理半字节编码,我们掌握了在内存中高效存储和修改像素数据的方法。最后,通过修复界面和内存问题,我们提升了编辑器的稳定性和用户体验。

这个编辑器工具是构建完整游戏资产管线的重要一步,为后续的游戏开发打下了坚实的基础。
014:模式13h图形编程入门教程 🎮

在本节课中,我们将要学习如何在DOSBox环境下,使用x86汇编语言进入并操作经典的VGA模式13h(320x200分辨率,256色)。我们将从硬件基础讲起,逐步实现一个能够绘制像素点的程序,并最终将其封装成一个可调用的函数。
概述:硬件与内存模型 💻
上一节我们介绍了x86汇编的基础知识,本节中我们来看看如何操作图形模式。首先需要理解目标硬件环境。本教程针对的是1980年代至1990年代初期的传统PC硬件,在DOSBox中模拟运行。现代PC使用SDL等库,本教程内容不适用。
在传统PC架构中,CPU(如8088、80286)工作在实模式下,使用分段内存模型。VGA显示控制器的视频内存(VRAM)位于固定的内存段地址:A000h。这是一个段地址(或称“节”地址),每个段代表16字节的起始位置。


模式13h(MCGA模式)的分辨率为320像素宽,200像素高,使用256色调色板。每个像素在内存中由一个字节表示,其值(0x00 到 0xFF)作为索引,指向一个由红、绿、蓝三元组定义的调色板。这与现代的真彩色(如24位或32位色)显示方式不同。
计算像素位置 🧮

在图形编程中,显示器的逻辑宽度(Width)和物理跨度(Pitch或Stride)是两个重要概念。跨度指的是一行像素在内存中占用的总字节数。
对于模式13h,由于每个像素占1字节,所以宽度与跨度相等:
跨度(Stride) = 宽度(Width) = 320 字节
在现代图形系统中,由于像素可能包含多个字节(如RGBA各占1字节),跨度通常大于逻辑宽度。
计算屏幕上任意一点 (X, Y) 对应内存偏移量的公式如下:
偏移量(Offset) = Y * 跨度 + X
由于跨度是320,且X和Y坐标都从0开始,因此内存偏移量的范围是 0 到 63999(即 0xFA00)。

进入与退出图形模式 🔄
要使用图形模式,首先需要通过BIOS中断调用切换到模式13h,程序结束时再切换回文本模式。
以下是核心的汇编代码片段:
; 进入模式13h (320x200x256)
mov ah, 0 ; 功能号:设置显示模式
mov al, 13h ; 模式号:13h
int 10h ; 调用BIOS视频中断
; ... 此处执行图形操作 ...





; 退出程序前,切换回文本模式 (模式03h)
mov ah, 0
mov al, 03h
int 10h
代码首先将 AH 寄存器设为0(设置模式功能),AL 寄存器设为 13h(目标模式),然后调用 int 10h 中断。退出时使用同样的方法,将 AL 设为 03h 即可返回80x25的文本模式。


x86实模式寄存器简介 🧠

在深入绘图前,有必要了解16位x86 CPU的核心寄存器。它们是程序与硬件交互的桥梁。

以下是主要的16位寄存器及其8位组成部分:
- AX (累加器): 可拆分为 AH (高8位) 和 AL (低8位)。
- BX (基址寄存器): 可拆分为 BH 和 BL。常用于内存寻址。
- CX (计数寄存器): 可拆分为 CH 和 CL。常用于循环计数。
- DX (数据寄存器): 可拆分为 DH 和 DL。常用于I/O操作。
- BP (基址指针): 16位,不可拆分。常用于访问堆栈帧中的参数。
- SI (源变址寄存器) 和 DI (目的变址寄存器): 16位,不可拆分。用于字符串/内存块操作。
- 段寄存器: CS (代码段), DS (数据段), ES (附加段), SS (堆栈段)。它们定义了内存段的起始地址。

为了获得最佳性能,应尽量使用寄存器来存储临时数据,减少内存访问。

绘制一个像素点 ⚫


现在,我们将结合以上知识,实现向屏幕中心 (100, 100) 绘制一个像素点的功能。



操作步骤如下:
- 将附加段寄存器
ES设置为视频内存段地址A000h。 - 根据公式
偏移量 = Y * 320 + X计算目标像素的内存偏移量。 - 使用段超越前缀
ES:,将颜色值写入计算出的内存地址。


以下是实现代码:
; 1. 设置ES段寄存器指向视频内存
mov ax, 0A000h
mov es, ax ; ES = A000h
; 2. 计算像素偏移量 (Y=100, X=100)
mov ax, 100 ; AX = Y坐标
mov cx, 320 ; CX = 屏幕跨度
mul cx ; DX:AX = AX * CX (结果在AX中)
add ax, 100 ; AX = AX + X坐标
; 3. 将偏移量存入BX(BX可用于内存寻址)
mov bx, ax ; BX = 像素偏移量
; 4. 设置颜色并写入视频内存
mov al, 7 ; AL = 颜色值 (例如7是浅灰色)
mov [es:bx], al ; 在ES:BX指向的内存地址写入AL的值
代码解释:
- 由于不能直接将立即数
A000h移入段寄存器ES,汇编器(如TASM)可能会在背后生成PUSH AX/MOV AX, 0A000h/MOV ES, AX/POP AX的指令序列。 mul cx指令将AX与CX相乘,结果存储在DX:AX中(本例中结果小于65535,所以主要在AX中)。[es:bx]表示一个内存地址,其段地址由ES指定,偏移量由BX指定。这行代码将AL中的颜色值写入该地址。
创建可重用的绘图函数 🔧






上一节我们成功绘制了单个像素,本节中我们来看看如何将绘图逻辑封装成一个可接收参数、可重复调用的函数。我们将通过堆栈传递参数,并使用宏来简化调用过程。


定义参数结构与函数框架



首先,我们定义一个结构体来描述通过堆栈传递的参数。由于x86是小端字节序,参数在内存中的顺序需要注意。
; 定义一个基于BP寄存器的结构体,用于访问堆栈参数
STRUC PlotParams
.retaddr resw 1 ; 占位:函数返回地址 (2字节)
.color resb 1 ; 颜色值 (1字节)
.pad resb 1 ; 填充字节,使对齐到字边界 (1字节)
.x resw 1 ; X坐标 (2字节)
.y resw 1 ; Y坐标 (2字节)
ENDSTRUC
在函数内部,我们首先保存寄存器状态,然后设置 BP 来访问堆栈中的参数。
_plot:
push bp ; 保存调用者的BP
mov bp, sp ; BP指向当前堆栈帧
push es ; 保存ES寄存器
push ax
push bx
push cx
; ... 保存其他需要使用的寄存器 ...
; 此时可以通过BP访问参数
; [bp + PlotParams.y] 获取Y坐标
; [bp + PlotParams.x] 获取X坐标
; [bp + PlotParams.color] 获取颜色值
; ... 绘图逻辑 ...
pop cx ; 按相反顺序恢复寄存器
pop bx
pop ax
pop es
pop bp
ret ; 返回调用者
使用宏简化函数调用



为了避免每次调用函数时都手动编写压参、调用、清栈的代码,我们创建一个宏。
; 定义一个宏,用于调用绘图函数
%macro PLOT 3 ; 宏名,接受3个参数:Y, X, Color
push %1 ; 压入Y坐标 (16位)
push %2 ; 压入X坐标 (16位)
push %3 ; 压入颜色值 (16位,仅低字节有效)
call _plot ; 调用绘图函数
add sp, 6 ; 清理堆栈 (3个参数 * 2字节 = 6字节)
%endmacro
现在,在程序中可以这样调用绘图函数:
PLOT 100, 100, 7 ; 在(100,100)绘制一个浅灰色像素
循环绘制与程序优化 🚀
有了绘图函数,我们可以通过循环来填充整个屏幕或绘制图形。同时,我们也需要考虑代码的优化。




循环绘制示例
以下代码演示了如何使用循环和 PLOT 宏来填充整个屏幕,每行变化一次颜色。
mov ax, 0A000h
mov es, ax ; 设置视频内存段 (只需一次)
xor ax, ax ; AX = 0 (Y坐标)
xor bx, bx ; BX = 0 (X坐标)
mov dx, 1 ; DX = 1 (颜色,初始值)



screen_loop:
PLOT ax, bx, dx ; 调用宏绘制像素
inc bx ; X坐标加1
cmp bx, 320 ; 是否到达行尾?
jl .same_line ; 如果小于320,继续同一行
; 新的一行
xor bx, bx ; X坐标归零
inc ax ; Y坐标加1
inc dx ; 颜色值加1 (每行换色)
cmp ax, 200 ; 是否到达屏幕底部?
jl screen_loop ; 如果小于200,继续循环
jmp .drawing_done ; 否则结束绘制




.same_line:
jmp screen_loop ; 继续绘制当前行的下一个像素



.drawing_done:
优化思路




为了提升性能,可以考虑以下优化:
- 减少冗余操作:在循环开始前设置一次
ES寄存器并保存,循环结束后恢复,而不是在每次绘图函数内部设置。 - 精简寄存器保存:在绘图函数
_plot内部,只保存和恢复那些确实会被修改的寄存器,而不是全部通用寄存器。 - 使用更高效的指令:例如,在循环中使用
LOOP指令,或利用BX、BP寄存器的索引寻址特性。
优化后的函数框架可能如下:
_plot_fast:
push bp
mov bp, sp
; 只保存必要的寄存器,例如BX可能被调用者使用
push bx
mov ax, [bp + PlotParams.y]
mov cx, 320
mul cx
add ax, [bp + PlotParams.x]
mov bx, ax
mov al, [bp + PlotParams.color]
mov [es:bx], al ; 假设ES已在外部设置好
pop bx
pop bp
ret
相应的,调用宏和主循环也不再需要函数内部处理 ES。


等待按键与程序退出 ⏸️
图形绘制完成后,我们通常希望屏幕保持显示,直到用户按下任意键后再退出图形模式并结束程序。


以下是实现等待按键和正确退出的代码:
; ... 图形绘制完成 ...
; 等待按键
mov ah, 0 ; BIOS功能:等待按键
int 16h ; 调用键盘中断,返回的字符在AL中
; 切换回文本模式
mov ax, 0003h ; AH=0 (设置模式), AL=03h (80x25文本模式)
int 10h
; 正确退出程序,返回DOS
mov ax, 4C00h ; AH=4Ch (程序退出), AL=00h (返回码)
int 21h ; 调用DOS中断
使用 int 16h 与 AH=0 会等待用户按下一个键。int 21h 与 AX=4C00h 是DOS下终止程序的正确方式,它确保了资源被正确释放。
总结 📝
本节课中我们一起学习了x86汇编语言在模式13h下的图形编程基础。我们从了解传统PC的硬件内存模型开始,学习了如何计算像素位置、如何通过BIOS中断切换显示模式。接着,我们掌握了核心的像素绘制原理,并将其实现为一个接收堆栈参数的可调用函数,期间还使用了宏来简化调用过程。最后,我们通过循环示例演示了如何填充屏幕,并探讨了基本的优化思路,以及如何等待用户输入并优雅地退出程序。


通过本教程,你获得了在DOS环境下进行基础图形编程的能力。以此为基础,你可以进一步实现画线、绘制形状、显示位图等更复杂的图形功能。

浙公网安备 33010602011771号