【深圳 IO 攻略】番外篇:早期谜题的极致优化方案

本文首发于 B 站《深圳 IO》文集(https://www.bilibili.com/read/readlist/rl569860)。原创不易,转载请注明出处。
当我们接触了新的元件,以及新的 MC 系列芯片指令后,我们可以再回过头来,将早期的一些关卡的三项指标优化到极致。
第 2 关:信号放大器,将电量优化到 133,代码行数减少到 3 行
电路图和代码如下:


这里不得不提两个新的隐藏特性:
1. 逻辑门的输入电平信号可以是 0~100 里的任意数字,不一定非得 0 或 100。当输入电平 <50 时,视为假;当输入信号 ≥50 时,视为真。


2. 有多个信号同时接在同一个【只写】p 口时,大的电平信号会覆盖小的电平信号,最终输出的电平值是所有输出信号中的最大值。

说完了这两个隐藏特性,回到本题。

这道题的输入量只有 0、25、50 三种,对应的输出是 0、50、100。
其实如果输入量只有 0、50 两种的话,由于 0 < 50,50 ≥ 50,我们只需要一个【或门】将其转成 0、100 信号就 OK 了。
现在因为有第三种信号:25,直接用或门的话,输出信号是 0。我们的芯片做的就是这样的事:当信号是 25 时,手动将输出信号扩大为 50。
首先我们需要把 p1 口清零,然后检查 p0 口连接的【控制输入】是否为非 0 值(tcp p0 p1,读只写 p1 口会得到立即数 0,同时清除写入 p1 口的数据)。如果是 0 值,什么都不用做,上方的或门以及 p1 口都会输出 0,两者的较大值也是 0(slp 1)。只要是非 0 值,我们就需要给 p1 口手动输出 50,取 50 及或门中的较大值作为最终的输出(+ mov 50 p1, slp 1)。如果是 25 值,上方的或门会输出 0,我们输出的 50 会将较小的 0 值覆盖。如果是 50 值,上方的或门会输出 100,会将我们输出的 50 覆盖掉。
我们用一个【或门】和一个写了三行代码的 MC4000 芯片,完成了“将 0、25、50 信号映射成 0、50、100 信号”的任务。点击左下角的【模拟】,稍等片刻,便会弹出结算界面:

贵了 1 块钱,但是电量由 240 降到了 133,代码行数由 4 行减少到了 3 行。
第 3 关:脉冲发生器,将电量优化到 120
这一关可以使用一个【与非门】和一个【与门】搭出一个【振荡电路】。电路图和代码如下:


左边的 LC70G08 只用到了下方的输出,是一个【与非门】;右边的 LC70G08 只用到了上方输出,是一个【与门】。下方用【与非门】和【与门】来代指这两个逻辑门。
当按钮没按下时,与门一定会有一个 0 输入,最终反馈到【脉冲】输出上的值一定是 0。与此同时,与非门也同样会有一个 0 输入,这样反馈到 p1,进而反馈到 p0(与非门 1 号输入)的值会保持为 100。如下图所示:

而当按钮按下时,精彩的地方就出现了,我们逐秒分析:
①第 1 秒里,信号刚来时,p0 和按钮的值都为 100,与非门的输出为 0。但是!不要忘了芯片的存在!这个 0 的输出量会经过芯片的 p1 口,进而刷新 p0 口的值,与非门的 1 号输入量会在这一秒内被改写成 0!等到芯片执行到 slp 1 睡眠过去后,与非门的输出会稳定在 100,作为与门的 1 号输入。而与门的 2 号输入和按钮一致,所以与门的两路输入在这一秒里最终都会定格在 100,这一秒的脉冲输出为 100。如下图所示:


②第 2 秒里,按钮信号仍然保持着 100。芯片睡醒后,会像第一秒那样翻转 p0 口的值,改写【与非门输出兼与门 1 号输入】的值,睡过去后,本秒内【脉冲】输出的值会稳定在 0。


③只要【按钮】信号一直保持着在,逻辑就会在①、②间反复横跳,【脉冲】输出就会在 100、0 间反复横跳。
我们再仔细观察一下这个电路图,我们每秒钟都会将【上一秒的与非门 1 号输入】和【按钮输入】的与非结果作为【本秒的与非门 1 号输入】。由于振荡脉冲只在按钮按下时发生,此时相当于“每秒钟都将【上一秒的与非门 1 号输入】取反后作为【本秒的与非门 1 号输入】”。每秒钟都取上一秒钟的反,这就实现了【振荡电路】。
点击左下角的【模拟】,稍等片刻,便会弹出结算界面:

第 4 关:动画 ESPORTS 标志,将成本压缩到 6 块钱
本关不使用任何逻辑门,改为用一块 MC6000 + 一块 DX-300 的组合,硬编码出所有输出口的时序,即可将成本压缩到 6 块钱。当然代价是巨大的电量和巨长的代码行数。电路图和代码如下:


前三行代码是用于 dat 寄存器的【取反】操作,dat 为 0 时更新为 100,dat 为 100 时更新为 0(tcp dat 50, - mov 100 dat, + mov 0 dat)。因为 dat 寄存器不能像 acc 那样用 not 指令取反,所以我们只能退而求其次使用这样的方式取反。
点击 0 和点击 1 是互反的,我们这里用 dat 寄存器同时表示【这一秒的点击 0 信号】和【下一秒的点击 1 信号】,所以我们在将 dat 取反后,这一秒内赋给喝 0(mov dat p1),等睡过一秒后,下一秒里再赋给喝 1(第 13 行,slp 1 后 mov dat p0)。
第 5~9 行的代码是用于控制喝 0~喝 2 的。任何时候,这三个信号都有且只有一个是 100。所以接到 DX-300 上以后,只可能给 DX-300 赋 1、10、100 中的一个值。我们观察时序图,不难发现以下规律:
喝 0~喝 2 的时序以 10 秒为周期循环。具体点哪个灯只跟当前秒数除以 10 的余数有关。
一个周期里,第 0~5 秒时,喝 0 点亮;第 6 或第 9 秒时,喝 1 点亮;第 7~8 秒时,喝 2 点亮。
如果我们先把第 9 秒给排除在外,其实我们不难发现,点亮的灯和当前周期的秒数呈现一个三态关系:秒数 < 6 时点亮喝 0;秒数 = 6 时点亮喝 1;秒数 > 6 时点亮喝 2。那么,我们再把第 9 秒的情况给加上,这就变成了“秒数 > 6 且秒数 < 9 时点亮喝 2”。
这里我们同时将秒数为 6 和秒数为 9 作为“共同的中间状态”,将其余情况作为端点状态。我们先假设当前处于中间状态,给 DX-300 赋 10 点亮喝 1(mov 10 x2),然后再细致判断当前是否处于端点状态(tcp acc 6)。如果当前秒数小于 6,毫无疑问,撤销喝 1 的点灯状态,改为点亮喝 0(- mov 1 x2)。而当秒数大于 6 时,我们需要把秒数为 9 这样的“非端点状态”排除掉,需要进一步判断秒数是否小于 9(+ tlt acc 9)。若秒数为 9,则“仍视为中间状态”,不执行任何操作。仅当秒数大于 6 且同时小于 9 时,才处于端点状态,才能撤销喝 1 的点灯状态,改为点亮喝 2(+ mov 100 x2)。确定了具体点的灯后,我们休眠一秒(slp 1),然后令经过的秒数 +1(add 1),并按 10 取余(dgt 0,只取个位数相当于取除以 10 的余数)。进入下一秒后,不要忘了将点击 1 更改为上一秒点击 0 的状态值(mov dat p0)。
点击左下角的【模拟】,稍等片刻,便会弹出结算界面:

如此,我们便用一块 MC6000 + 一块 DX-300,共计 6 块钱的成本,成功为 5 个 p 口生成了时序图。
第 5 关:游戏积分器,将代码行数缩减到 6 行
本题使用 ROM 打表,可以将代码行数缩减到 6 行。电路图和代码如下:


我们将【得分】和【犯规】两个输入信号通过 DX-300 转接,合并成一个输入信号。这样我们每秒钟的输入信号就有 0(无变化)、1(得 1 分)和 10(扣 2 分)三种,对应的分数增量分别为 0、1、-2。我们使用一块 ROM,将 0、1、-2 的分数增量写在 0、1、10 地址的空间处。
每秒钟,我们都从 DX-300 中读取输入信号的值,然后将 ROM 的地址值置为和该信号值一致(mov x1 x3)。此时我们的指针所指向的数字对应着本轮的分数增量,我们将 acc 加上该增量值(add x2)。然后我们进一步判断分数是否 <0(tcp acc 0)。分数不能出现负数,一旦计算出的实时小于 0,就需要将分数强制置为 0(- sub acc)。计算完毕后,将实际的分数值发送给显示器(mov acc x0)后,休眠一秒,进入下一个时钟周期。
点击左下角的【模拟】,稍等片刻,便会弹出结算界面:

我们使用打表的方法,不将分数增量写在代码中,而是写在 ROM 中,将实际的代码行数由 8 行减少到了 6 行。
第 6 关:调谐最优化引擎,将代码行数压缩到 6 行
我们将代码做个微调,即可将行数压缩到 6 行。电路图和代码如下:


我们先不管三七二十一,先把原始信号存到 acc 里再说(mov p0 acc)。然后再判断是否有最优化信号(tcp x0 0),有最优化信号时,执行 ×4,-150 的操作(+ mul 4, + sub 150)。最后,将 acc 发给输出端口(mov acc p1)并休眠(slp 1)。
点击左下角的【模拟】,稍等片刻,便会弹出结算界面:

我们来回顾一下前一个方案的代码:
前一个方案的数据流向有两种可能:p0→p1 或 p0→acc→p1。本方案将数据流向统一成了 p0→acc→p1 一种,节省了一行代码,但也导致不需要最优化处理时会多在 acc 这里停留一步。