嗨,正如您已经了解的那样,这是我反向工程和Neuromant移植历史的延续。

神经巫师的反面。 第1部分:精灵
神经巫师的反面。 第2部分:渲染字体
神经巫师的反面。 第3部分:完成渲染,制作游戏
今天,让我们从两个好消息开始:
总的来说,一切进展顺利,也许很快我们将至少获得一些可玩的版本。 像往常一样,在削减的前提下,让我们谈谈目前取得的成就和取得的成就。
他开始处理声音。 las,在游戏资源中,没有什么类似于音频,并且由于我不知道音乐在MS-DOS中的工作方式 ,因此从哪里开始非常不清楚。 在阅读了各种SoundBlaster之后 ,我想到的最好的办法是滚动反汇编的代码,以期看到一些熟悉的签名。 无论寻找什么,他通常都会找到,即使不是他要找的东西( Ida的评论):
sub_20416: ... mov ax, [si+8] out 42h, al ; 8253-5 (AT: 8254.2). mov al, ah out 42h, al ; Timer 8253-5 (AT: 8254.2). mov bx, [si+0Ah] and bl, 3 in al, 61h ; PC/XT PPI port B bits: ; 0: Tmr 2 gate ═╦═► OR 03H=spkr ON ; 1: Tmr 2 data ═╝ AND 0fcH=spkr OFF ; 3: 1=read high switches ; 4: 0=enable RAM parity checking ; 5: 0=enable I/O channel check ; 6: 0=hold keyboard clock low ; 7: 0=enable kbrd and al, 0FCh or al, bl out 61h, al ; PC/XT PPI port B bits: ; 0: Tmr 2 gate ═╦═► OR 03H=spkr ON ; 1: Tmr 2 data ═╝ AND 0fcH=spkr OFF ; 3: 1=read high switches ; 4: 0=enable RAM parity checking ; 5: 0=enable I/O channel check ; 6: 0=hold keyboard clock low ; 7: 0=enable kbrd
在经历了计时器8253-5之后,我遇到了一篇文章 , 该文章成为了了解正在发生的事情的第一把钥匙。 下面我将尝试解释什么。
因此,在IBM-PC时代,可负担得起的声卡出现之前,最常见的声音再现设备是所谓的PC Speaker ,也称为“蜂鸣器”。 在大多数情况下,此设备仅是通过四针连接器连接到主板的常规扬声器。 根据该想法,蜂鸣器可以再现两级矩形脉冲(对应于两个电压电平,通常为0V和+ 5V),并通过PPI(可编程外围接口)控制器的第61个端口进行控制。 具体来说,发送到端口的值的前两位负责控制“扬声器”(请参阅in al, 61h
和out 61h, al
注释)。
就像我说的(略有不同),我们的扬声器可以处于两种状态- “进入”和“离开” ( “低”-“高” , “关”-“开” , “关”-“开” ,什么)。 为了产生一个脉冲,有必要将当前状态更改为相反状态,并在一段时间后返回。 例如,可以通过操作端口61的第一位(从头开始计数)直接完成此操作,如下所示:
PULSE: in al, 61h ; and al, 11111100b ; ... or al, 00000010b ; ... ; , 0 out 61h, al ; 61- mov cx, 100 ; DELAY: loop DELAY ; in al, 61h ; and al, 11111100b ; out 61h, al ; 61-
执行此代码的结果将如下所示:
loop DELAY +5V +----------------------+ ! ! 0V ---+ +-------------------------- or al, 00000010b and al, 11111100b out 61h, al out 61h, al
延迟重复PULSE ,我们得到一个矩形信号:
mov dx, 100 ; 100 PULSE: ... mov cx, 100 WAIT: loop WAIT dec dx jnz PULSE PULSE +5V +---------+ +---------+ +---------+ ! ! ! ! ! ! 0V ---+ +---------+ +---------+ +--- loop WAIT
如果在第一种情况下我们几乎听不到任何声音,那么在第二种情况下,我们会听到频率的音调,具体取决于执行此代码的机器的速度。 这很好,但是会带来一些困难。 无论如何,都有一种更方便的方式来控制扬声器。
游戏可编程的三通道计时器- 英特尔8253 ,其第二通道(从零开始)连接到蜂鸣器。 此计时器从Intel 8254时钟接收信号,每秒发送1193180个脉冲(〜1.193 MHz),并可在指定数量的脉冲后针对特定反应进行编程。 这些反应之一是向扬声器发送方波。 换句话说, 8253可以以可调节频率的矩形信号的发生器的形式工作,这使得在扬声器上合成各种声音效果相对容易。 这是您需要的:
- 将计时器的第二个通道设置为矩形信号生成模式。 为此,将特殊的单字节值写入端口43( 8253模式/命令寄存器 )。 就我而言,这是
10110110B
(更多详细信息在这里 ):
Bits Usage 6 and 7 Select channel : 1 0 = Channel 2 4 and 5 Access mode : 1 1 = Access mode: lobyte/hibyte 1 to 3 Operating mode : 0 1 1 = Mode 3 (square wave generator) 0 BCD/Binary mode: 0 = 16-bit binary
在第二个频道上设置所需的频率。 为此,从最小到最老,逐个字节地,我们向第42个端口( 8253通道2数据端口 )发送一个等于1193180 / freq
的值,其中freq
是所需的频率值,以赫兹为单位。
允许扬声器从计时器接收脉冲。 为此,请将端口61( PPI )中值的前两位设置为1。 事实是,如果将零位设置为1,则将第一个解释为“开关”:
Bit 0 Effect ----------------------------------------------------------------- 0 The state of the speaker will follow bit 1 of port 61h 1 The speaker will be connected to PIT channel 2, bit 1 is used as switch ie 0 = not connected, 1 = connected.
结果,我们有以下图片:
mov al, 10110110b out 43h, al ; mov ax, 02E9Bh ; 1193180 / 100 = ~0x2E9B out 42h, al ; mov al, ah out 42h, al ; in al, 61h ; or al, 00000011b ; 1 out 61h, al ; ... ; ~100 in al, 61h and al, 11111100b out 61h, al ;
这正是我一开始引用的代码(初始化除外,但我在另一个函数中找到了): si + 8
有一个分频器发送至端口42, si + 0Ah
-扬声器状态( “开”-“关” )记录在端口61中。
回放机制非常简单明了,但随后您必须处理定时问题。 研究了附近的代码后,我看到在初始化计时器的同一函数( sub_2037A
,然后是init_8253
)中, 第八个中断处理程序被sub_20416
函数替代(以下play_sample
)。 很快就知道该中断大约每秒产生18.2次,并用于更新系统时间。 如果您需要每秒执行18次操作(通常,还必须在挂钩内调用原始处理程序,则必须在每秒内执行一次某些操作),则替换此中断的处理程序是一种常见的做法。 基于此,可以发现每(1 / 18.2) * 1000 ~ 55
向发电机充电一个下一个频率。
计划是这样的:
- 在提取下一个分频器的那一行上的
play_sample
函数中放置一个断点; - 根据公式
freq = 1193180 / divisor
计算freq = 1193180 / divisor
; - 在某种音频编辑器中生成55ms的平方频率信号(我使用Adobe Audition );
- 重复前三个步骤,直到至少累积3秒钟。
所以我从主菜单开始了旋律的开始,但是播放的时间比必要的慢了10倍。 然后,我将“采样”的持续时间从55毫秒减少到5毫秒-变得更好了,但还不是那样。 在我找到本文之前,时间问题一直悬而未决。 事实证明,第八个中断是通过馈送相同的8253产生的,该8253的零通道连接到中断控制器( PIC )。 机器启动时, BIOS会将通道设置为零以生成频率为〜18.2 Hz的脉冲(即,每〜54.9毫秒产生一次中断)。 但是,可以对零通道进行重新编程,使其产生更高频率的脉冲,为此,类似于第二个通道,您需要向第40个端口写入一个等于1193180 / freq
的值,其中freq
是所需的频率值,以赫兹为单位。 这发生在init_8253
函数中,只是最初我没有适当注意它:
init_8253: ... mov al, 0B6h ; 10110110b out 43h, al ; Timer 8253-5 (AT: 8254.2). mov ax, 13B1h out 40h, al ; Timer 8253-5 (AT: 8254.2). mov al, ah out 40h, al ; Timer 8253-5 (AT: 8254.2).
将值13B1h
转换为频率: 1193180 / 13B1h ~ 236.7
,则每个“样本”大约得到(1 / 236.7) * 1000 ~ 4.2
1193180 / 13B1h ~ 236.7
。 难题已发展。
然后,技术问题-实现从游戏中提取配乐的功能。 但事实是,第42个端口中记录的分频器的值未明确存储。 它们是通过一些棘手的算法计算出来的,其输入数据及其工作区域直接位于游戏的可执行文件中(根据Ida ,在第七部分)。 同样,在这些功能中,没有轨道结束的迹象,当没有其他要播放的内容时,该算法将无限地产生扬声器的零状态。 但是我没有打扰,并且像解压缩算法( 第一部分 )那样,我只是移植到了64位汇编器上,它设置了用于回放的音轨的功能以及用于获得下一个频率的算法(我完全采用了第七段)。
而且有效。 之后,我实现了所选音轨的音轨生成功能( PCM,44100 Hz,8位,单声道 ;做了类似在DosBox的扬声器仿真器中使用的生成器的操作 )。 我用一个简单的无声计数器解决了结尾符号的问题:数秒-我们完成了算法。 将结果轨道包装在WAV标头中并将结果保存到文件中,我从主菜单中获得了确切的轨道。 以及下面[或在资源查看器中,您可以收听的另外13首曲目], 该资源查看器现在具有内置播放器,并且可以将任何曲目保存在.WAV中] :
[研究这个问题,我了解了更高级的“蜂鸣器”技术,例如使用脉冲宽度调制来获得质量较差的PCM声音再现。 在本文的结尾,我将提供一系列材料,您可以从中学习更多。
在第二部分中 ,当考虑各种资源格式时,我建议.ANH文件包含用于位置背景(即,用于存储在.PIC中的图像)的动画。 [是这样。]完成声音后,我决定检查一下。 纯粹假设动画直接应用于存储在内存中的背景图像(不是视频内存 ,而是在sprite链中 ),我决定在应用动画之前和之后分别转储此内存(查看光标指向顶部的位置)字母字符串“ S”):



3DE6:0E26 03 B4 44 B3 ... ; 3DE6:0E26 03 BC CC B3 ... ;
正是我所期望的-深红色(0x4)变为亮红色(0xC)。 现在,您可以尝试设置断点以更改地址上的值,例如3DE6:0E28
,如果幸运的话,进行反向跟踪。 [我很幸运。]断点将我引到了直接更改给定地址上的值的一行: xor es:[bx], al
。 检查了周围的环境后,我轻松地建立了一个从主循环到此时的调用链: sub_1231E (xor es:[bx], al) <- sub_12222 <- sub_105F6 <- sub_1038F ( )
我不会详细介绍如何反转动画过程。 这是一项相当常规和有条理的工作,但是如果清楚地划定了边界(收到的回溯路线是这些边界),这并不是很难。 但是我忍不住说到底发生了什么。
首先关于.ANH格式。 实际上,它是一组容器, .ANH文件中的第一个单词是其中的容器数:
typedef struct anh_hdr_t { uint16_t anh_entries; } anh_hdr_t;
容器本身是背景元素的单独动画。 您可以为容器选择一个标头,其中包含其字节大小和它表示的动画中的帧数。 标题旁边是下一帧的持续时间(延迟)的值以及该帧本身的字节相对于第一帧的字节的偏移量。 此类对的数量显然等于帧数:
typedef struct anh_entry_hdr_t { uint16_t entry_size; uint16_t total_frames; } anh_entry_hdr_t; typedef struct anh_frame_data_t { uint16_t frame_sleep; uint16_t frame_offset; } anh_frame_data_t; ... extern uint8_t *anh; anh_hdr_t *hdr = (anh_hdr_t*)anh; anh_entry_hdr_t *entry = (anh_entry_hdr_t*)(anh + sizeof(anh_hdr_t)); for (uint16_t u = 0; u < anh->anh_entries; u++) { uint8_t *p = (uint8_t*)entry; anh_frame_data_t *first_frame_data = (anh_frame_data_t*)(p + sizeof(anh_entry_hdr_t)); uint8_t *first_frame_bytes = p + (entry->total_frames * sizeof(anh_frame_data_t)); for (uint16_t k = 0; k < entry->total_frames; k++) { anh_frame_data_t *frame_data = first_frame_data + k; uint8_t *frame_bytes = first_frame_bytes + frame_data->frame_offset; ... } p += (entry->entry_size + 2); entry = (anh_entry_hdr_t*)p; }
一个单独的帧由一个四字节的标头组成,该标头包含其线性尺寸和相对于背景图像的位移,以及由我已经通过Run Length熟悉的算法编码的帧像素:
typedef struct anh_frame_hdr { uint8_t bg_x_offt; uint8_t bg_y_offt; uint8_t frame_width; uint8_t frame_height; };
背景上框架的“叠加”看起来像这样:
extern uint8_t *level_bg; uint8_t frame_pix[8192]; anh_frame_hdr *hdr = (anh_frame_hdr*)frame_bytes; uint16_t frame_len = hdr->frame_width * hdr->frame_height; decode_rle(frame + sizeof(anh_frame_hdr), frame_len, frame_pix); uint16_t bg_offt = (hdr->bg_y_offt * 152) + hdr->bg_x_offt + 0xFB4E; uint16_t bg_skip = 152 - hdr->frame_width; uint8_t *p1 = frame_pix, *p2 = level_bg; for (uint16_t i = hdr->frame_height; i != 0; i--) { for (uint16_t j = hdr->frame_width; j != 0; j--) { *p2++ ^= *p1++; } p2 += bg_skip; }
这是.ANH格式 ,但是还有另一种结构可以使其全部正常工作:
typedef struct bg_animation_control_table_t { uint16_t total_frames; uint8_t *first_frame_data; uint8_t *first_frame_bytes; uint16_t sleep; uint16_t curr_frame; } bg_animation_control_table_t;
在游戏本身中,全局声明了至少四个这种结构的数组。 加载下一个.ANH文件后,内部的动画数量也存储在全局变量中,并且数组的元素按以下方式初始化:
extern uint8_t *anh; uint16_t g_anim_amount = 0; bg_animation_control_table_t g_anim_ctl[4]; ... anh_hdr_t *hdr = (anh_hdr_t*)anh; anh_entry_hdr_t *entry = (anh_entry_hdr_t*)(anh + sizeof(anh_hdr_t)); g_anim_amount = hdr->anh_entries; for (uint16_t u = 0; u < g_anim_amount; u++) { uint8_t *p = (uint8_t*)entry; g_anim_ctl[u].total_frames = entry->total_frames; g_anim_ctl[u].first_frame_data = p + sizeof(anh_entry_hdr_t); g_anim_ctl[u].first_frame_bytes = g_anim_ctl[u].first_frame_data + (entry->total_frames * sizeof(anh_frame_data_t)); g_anim_ctl[u].sleep = *(uint16_t*)(g_animation_control[u].first_frame_data); g_anim_ctl[u].curr_frame = 0; p += (entry->entry_size + 2); entry = (anh_entry_hdr_t*)p; }
最后,应用动画:
for (uint16_t u = 0; u < g_anim_amount; u++) { bg_animation_control_table_t *anim = &g_anim_ctl[u]; if (anim->sleep-- == 0) { anh_frame_data_t *data = (anh_frame_data_t*)anim->first_frame_data + anim->curr_frame; ... if (++anim->curr_frame == anim->total_frames) { anim->curr_frame = 0; data = (anh_frame_data_t*)anim->first_frame_data; } else { data++; } anim->sleep = data->frame_sleep; } }
我们得到以下内容[您可以在资源查看器中看到更多内容] :
动画现在有几个问题。 第一个是在我的代码中,我播放了所有可用的动画,但是原始动画检查一些全局标志,这些标志指示是否滚动下一个动画。 第二,由于某些动画将原来不存在的对象添加到屏幕上。 而且由于帧在背景上“吵架”,因此通过周期性滚动,每隔一圈对象就会消失。 例如,在这里看起来可能是这样:
但是现在,让我们保持现状。
还记得第一部分中未知的解压缩算法吗? 与开发人员几乎没有联系, viiri不仅确定了它是哪种算法,还编写了自己的解码器版本,从而在代码库中替换了可怕的三行汇编器。 在这方面,我请viiri就完成的工作写一篇短文。 这样做了,但是在我给出它之前,需要先说几句关于该理论的内容。
为了压缩资源, Neuromancer的开发人员使用了Huffman代码 。 这是使用前缀码对信息进行编码的第一种有效方法。 在编码理论中,具有可变长度字的代码和其中没有代码字是另一个前缀的代码被称为前缀代码。 即,如果在前缀代码中包括单词“ a” ,则该代码中不存在单词“ ab” 。 此属性使您可以将这种代码编码的消息唯一地分解为单词。
霍夫曼算法的思想是,知道消息中某个字母字符出现的可能性,我们可以描述构造由整数个位数组成的可变长度代码的过程。 出现概率较高的符号分配有较短的代码,相反,概率较低的符号则分配有较长的代码。 通常,将编码过程简化为构造最佳代码树,并在其基础上将消息符号映射到相应的代码。 接收到的代码的prefix属性使您可以唯一地解码压缩的消息。
该算法有一个明显的缺点(实际上不是一个缺点,但现在只有这一点很重要)。 事实是,为了恢复压缩消息的内容,解码器必须知道编码器使用的字符出现频率表。 在这方面,必须与概率表或代码树本身(游戏中使用的选项)一起传输编码消息。 附加数据的大小可能会相对较大,这会严重影响压缩效率。
关于如何处理此问题以及解码器和游戏中实现的解码器的信息告诉viiri :
值得一提的是,整个游戏都是完全用汇编语言编写的,因此代码中包含有趣的解决方案,技巧和优化。
按照程序。 需要sub_1ff94
( build_code_table
)函数从文件中加载压缩的霍夫曼树。 要解码静态霍夫曼码(有时是动态的 ,并且此要求不适用)和消息一起解码,必须传输代码树,这是霍夫曼码到真实字符码的映射。 这棵树足够大,因此最好以某种方式有效地存储它。 最正确的方法是使用规范代码 ( MOAR )。 由于它们的特性,有一种非常有趣且有效的方式来存储树(用于PKZip存档器的Deflate压缩方法的实现中)。 但是游戏中没有使用规范代码,而是执行直接树遍历 ,并且对于每个顶点,如果节点不是叶子,则将位0写入输出流;如果节点不是叶子,则将位1写入输出流,然后接下来的8位是角色代码节点。 解码时,会执行类似的树遍历,这在游戏中可以看到。 有一个例子和一些解释。
build_code_table build_code_table proc near call getbit ; jb short loc_1FFA9 ; ... shl dx, 1 inc bx call build_code_table ; build_code_table or dl, 1 call build_code_table ; build_code_table shr dx, 1 dec bx ret loc_1FFA9: call sub_1FFC2 ; (8 ) ... ; ret sub_1FF94 endp sub_1FFC2 proc near sub di, di mov ch, 8 loc_1FFC6: call getbit rcl di, 1 dec ch jnz short loc_1FFC6 retn sub_1FFC2 endp
getbit
( sub_1ffd0
)从输入流中读取一个位。 她的分析使我们可以得出结论,即从16位ax
寄存器中提取了各个位,而lodsw
指令从内存中加载了16位ax
寄存器的值,该指令从流中加载了两个字节,但是由于Intel处理器的字节序为低字节序,因此xchg
重新排列寄存器的一半。 此外,流中的位顺序有些不合逻辑-第一个不是最低位,而是最高有效位。 , shl
, jb
.
getbit getbit proc near or cl, cl jz short loc_1FFD9 dec cl shl ax, 1 retn loc_1FFD9: cmp si, 27B6h jz short loc_1FFE7 ; lodsw xchg al, ah mov cl, 0Fh shl ax, 1 retn loc_1FFE7: call sub_202FC ; lodsw xchg al, ah mov cl, 0Fh shl ax, 1 retn getbit endp
viiri , :
huffman_decompress typedef struct node_t { uint8_t value; struct node_t *left, *right; } node_t; static uint8_t *g_src = NULL; static int getbits(int numbits) { ... } static uint32_t getl_le() { } static node_t* build_tree(void) { node_t *node = (node_t*)calloc(1, sizeof(node_t)); if (getbits(1)) { node->right = NULL; node->left = NULL; node->value = getbits(8); } else { node->right = build_tree(); node->left = build_tree(); node->value = 0; } return node; } int huffman_decompress(uint8_t *src, uint8_t *dst) { int length, i = 0; node_t *root, *node; g_src = src; length = getl_le(); node = root = build_tree(); while (i < length) { node = getbits(1) ? node->left : node->right; if (!node->left) { dst[i++] = node->value; node = root; } } ... }
:
build_code_table
. , , . , . , , , — ( huffman_decompress
).
? ! ! , . ( ): . , 3- , N , (3 — N) .
ABCD
: A - 0b, B - 10b, C - 110b, D - 111b
. ( ), , :
| | |
---|
0 00b | 1个 | 一 |
10 0b | 2 | 乙 |
110 b | 3 | ç |
111 b | 3 | d |
, . , , , 010b
— . . , 'A' 0b
, , . :
| | | |
---|
0 | 0 00b | 1个 | 一 |
1个 | 0 01b | 1个 | 一 |
2 | 0 10b | 1个 | 一 |
3 | 0 11b | 1个 | 一 |
4 | 10 0b | 2 | 乙 |
5 | 10 1b | 2 | 乙 |
6 | 110 b | 3 | ç |
7 | 111 b | 3 | d |
, 011010111b
:
- :
011b
; - ,
011b (3)
, A
, ; 011b
1, , : 110b
;- ,
110b (6)
,
, ; - , .
«» 8- . 256 . 8 . , , :
: — , . , . 4 — 32- .
, . .
, github . , , , - [ , README.md] .
, , 2015- :
- LibNeuroRoutines (, MASM) — , , . (
neuro_routines.h
) , . , :
- (
huffman_decompression.c
, decompression.c
); - (
cp437.c
); - (
dialog.c
); - (
audio.c
).
- NeuromancerWin64 () — . . , «» , , . CSFML ( SFML C ).
- ResourceBrowser (C++) — . MFC -, .DAT -. :
- BMP (8bpp) ( IMH , PIC );
- ( ANH );
- WAV (PCM, 44100Hz, 8bps, mono) ( SOUND ).
LibNeuroRoutines . LibNeuroRoutines CSFML ( ResourceBrowser SFML ).
64- Windows . , LibNeuroRoutines 64- MASM (Microsoft Macro Assembler) . — , 64- . , NASM FASM , , . VS 2015 — MASM .
, . C . , ( , MFC ).
, . - , .
. ? , . , . - , . , , , . ().
- Make sound from the speaker using assembly
- Programming the PC Speaker
- PC Speaker
- Programmable Interval Timer
- Making C Sing
- Beyond Beep-Boop: Mastering the PC Speaker