← 所有文章

在浏览器中重现 MacPaint:1984 年的源代码就是规范

PixelPaint 是对 MacPaint 1.3 的可用重现,可在浏览器中通过 /paint 运行。它逐项对照 Bill Atkinson 的原始 Pascal 源代码验证了每一项行为——而这份源代码,由计算机历史博物馆(Computer History Museum)于 2010 年公开。双击橡皮擦,它会擦除整个窗口,然后把您先前使用的工具交还回来,因为这正是 ChooseTool 在 MacPaint.p 第 3651 行所做的事。 {.answer-block}

2010 年,计算机历史博物馆经苹果许可,公开了 MacPaint 1.3 的源代码——正是 Bill Atkinson 随初代 Macintosh 于 1984 年 1 月一同发布的那款应用。1 这次公开在博物馆的馆藏目录中登记为藏品编号 102658076。2 在我的机器上,MacPaint.p 有 5,804 行 Apple Pascal 代码,PaintAsm.a 则是 2,738 行 68000 汇编代码。Pascal 文件的第 3 行,全文如下:

{  BitMap Painting Program by Bill Atkinson }

这次公开,改变了衡量一次重现的标准。在此之前,重建 MacPaint 意味着眯着眼盯着截图和模拟器反复猜测;在此之后,则有了确凿的依据。当我决定把 PixelPaint 认真做完时,我立下的规矩很简单:只要答案就摆在一份我能读到的文件里,任何行为就不能靠猜测发布。这份许可是非商业性质的,而这次移植是行为层面的——我阅读 Pascal 代码是为了弄清程序做了什么,然后在 JavaScript 中从零实现,从不逐行翻译代码。

这篇文章要讲的,是这条规矩付出了什么代价,又换来了什么。简而言之:源代码不可或缺,却又不够。程序的行为藏在字里行间——藏在常量里、位掩码里、注释里,藏在一段过程的形态之中——而把它提取出来,是考古,不是誊抄。

源代码不等于行为

一次忠实的重现需要三样工具,而我最终三样都用上了:

  1. 源代码作为规范。 每一处有争议的行为,都通过阅读实现它的那段过程来定夺,并注明行号。
  2. 一台运行中的原版作为参照标准。 Infinite Mac 能在浏览器中启动真正的 System 时代 Mac,磁盘上装着真正的 MacPaint。5 当源代码在手感上语焉不详时——喷枪的节奏、快速移动时画笔的插值——由模拟器来一锤定音。
  3. 一个独立实现作为交叉校验。 针对文件格式,我用 Python 另写了一个解码器,与应用不共享任何代码,并要求两者在双向转换中逐字节一致。

唯一不能用的工具,是记忆——无论是我的记忆,还是互联网的记忆。关于 MacPaint,那些”人尽皆知”的说法,一旦需要让某个像素精确地出现在某个坐标上,大多都会暴露出定义不清的问题。

1984 年,双击意味着什么

这里有一个截图永远告诉不了您的行为。在 MacPaint 中,双击工具面板里的某个工具是一条命令。相关的分派逻辑集中在一段过程里——ChooseTool,位于 MacPaint.p:3651–3699:

  • 橡皮擦:擦除整个窗口,然后恢复到此前选中的工具。
  • 画笔:打开画笔形状选择器。
  • 选取框:选中整个窗口。
  • 抓手:打开”显示整页”(Show Page)。
  • 铅笔:切换 FatBits,即像素级缩放。

橡皮擦这一分支有一个只有源代码才会揭示的细节。在这一切之前,第 3643 行有一道守卫判断:

IF theTool <> eraseTool THEN prevTool := theTool;

橡皮擦永远不会成为”上一个工具”。于是当一次双击擦除了所有内容后,程序会把您真正在用的画笔或铅笔还给您——橡皮擦只是过客,而非归宿。Atkinson 在这行恢复语句上的注释说得很直白:{ we wont need the eraser anymore }。这是用一个条件判断表达出来的交互设计,从外部完全看不出来,直到您注意到:MacPaint 从不会在一次清屏之后把您晾在橡皮擦上。PixelPaint 依据这段过程实现了全部五种双击行为,并由一套自动化浏览器测试对每一种进行端到端断言。

选取框这一分支里还藏着第二个细节。当双击选中整个窗口时,源代码会在设定选区之前,给矩形的右边和下边各加一。这就引出了差一错误(off-by-one)。

两个差一错误,以及谁才是对的

项目进行到一半时,一轮审查在我的构建中发现了两处”预览与实际不符”的问题:

  1. 选取框实际捕获的范围,似乎比它的橡皮筋预览少了一个像素。
  2. 橡皮擦落下的印记,比它的光标预览大了一个像素。

这两处都是那种花三十秒挪一个 +1 就能”修好”的 bug——往哪个方向挪都行。而拥有源代码的全部意义就在于:您没得选。您得去查清楚,到底哪一边错了。

选取框不是 bug。 QuickDraw 的矩形是右边界、下边界不含的:一个从 (10,10) 到 (20,20) 的矩形跨越 10 个像素,而非 11 个。在我的构建里,橡皮筋和实际捕获在这一约定下本就一致——从 10,10 拖到 20,20 选中的恰好是 10×10。审查实际拿来对比的,是形状工具的预览(它正确地把闭区间的最后一个像素也包含在内)与选取框的开区间捕获。两种不同的约定,都对,只是恰好挨在一起。处理办法:什么都不改,把原因写下来。

橡皮擦是 bug——是我的 bug。 在原版中,橡皮擦的方块与它的光标完全相等:一个 16×16 的方块,原样落下(EraseSome,MacPaint.p:2210,使用工具光标自身的掩码)。而我落下的印记是按 2*floor(size/2)+1 计算的,这让一个 8 像素的橡皮擦擦出了一个 9 像素宽的洞。修正后,印记恰好跨越 size 个像素:如今一个 8 像素的橡皮擦擦除的是第 16 到第 23 列,而第 15 列和第 24 列不受影响,已逐像素验证。在 FatBits 内部,橡皮擦缩小到恰好 2×2,这一点同样出自源代码(MacPaint.p:2214)。

从这一对问题中提炼出来的规矩,成了整个项目的脊梁:当预览与实际动作不一致时,由原版来裁定谁在撒谎。

是页面,不是画布

MacPaint 最具结构性的构思很容易被忽略,因为它是空间层面的。文档并不是窗口。文档是一张固定为 576×720 像素的页面——在 MacPaint.p:108–109 中声明为编译期常量——而屏幕上的绘图区,只是望向这张页面的一扇窗口。抓手让窗口在页面上平移(ScrollDoc,:2778);”显示整页”(ShowPage,:4074)则缩小到整张纸,并允许把窗口矩形拖到新的位置。在 72 DPI 下,576×720 恰好是 8×10 英寸:这份文档是按纸张尺寸设计的,而非屏幕。

PixelPaint 起初用的是一块视口大小的缓冲区,这意味着:在 MacPaint 拥有一份文档的地方,它只有一块画布。围绕真正的模型重建它,是整个项目中改动最大的一处,也由此暴露出一个彻头彻尾属于 2026 年的约束:iOS 会把画布的后备存储上限压在 16.7 兆像素左右。若不假思索地按整页乘以 FatBits 的缩放倍数来分配,就要吃掉 26.5 兆像素——而这样一块画布,会在我希望它能正常工作的那些 iPad 上悄无声息地渲染成空白。这次移植让显示画布保持视口大小,转而施加一个文档空间的视图变换;在 8× 缩放下,后备存储实测为 0.42 兆像素,一整帧”整页绘制加渲染”耗时 6.4 毫秒,不到 60 Hz 下一帧的时间。Atkinson 用隐藏的离屏缓冲区解决了 128K 的内存预算;4 而浏览器移植版,则用一个变换解决了一道隐藏的分配上限。同样的克制,只是撞的墙不同。

一台 1984 年的 Mac 能读懂的文件

一个无法与原版交换文档的重现,只是一座立体模型。MacPaint 的文件格式由源代码本身记录在案:一个 512 字节的头部,随后是页面,即 720 条扫描线、每条 72 字节,用 PackBits 压缩——这是一种游程编码方案,Pascal 一侧从不实现它,只做声明(PackBits/UnpackBits,在 MacPaint.p:420–421 标注为 EXTERNAL;MyTools.a 中的汇编胶水代码把它们作为系统陷阱来分派)。头部携带了程序的图案调色板,因此一份文档会记住绘制它时所用的图案。

PixelPaint 能读写这种格式。导出时,页面会经过 Atkinson 本人的误差扩散抖动算法处理,转为 1 位——以 128 为阈值,每个像素的误差被分成八份、推给六个相邻像素,其中两份被刻意丢弃,这正是 Atkinson 抖动算法对比强烈的原因。纯黑白图画则原封不动地通过,因为它们的误差恒等于零。用 Bill Atkinson 的抖动算法去写 Bill Atkinson 的文件格式,这里头有一种令人愉悦的循环感。

验证环节,正是”独立实现”这件工具证明自身价值的地方。应用内的 PackBits 编解码器,对测试样本的往返转换做到了逐字节一致。一个导出的文件,经由那个独立的 Python 实现解码后,得到了正确的头部版本、完好无损的图案,以及恰好 720 条各 72 字节的扫描线,且每一个字节都被消费完毕。一个由 Python 一侧编码的文件——封装在 MacBinary 中,导入器通过偏移量 65 处的文件类型来识别它——在 PixelPaint 中打开后,其边框与对角线都落在了计算所得的像素上。导出、清空、再导入,重现出的打包状态哈希值完全一致。两个实现,两个方向,没有共享代码。

字里行间

最深的领悟,来自任何功能清单都不会呈现的细节——那些只有通过阅读才能发现的东西。

网格是一个位掩码。 MacPaint 的 8 像素网格吸附并不适用于每一个工具。ChooseTool 通过把工具索引与一个赤裸的十六进制常量 $50BF3000 做比对来决定是否启用吸附,而那份人类可读的 Pascal 集合,则被留作注释。吸附本身是四舍五入到最近值,实现方式是先加 4 再截断到 8 的倍数(GridPoint,MacPaint.p:513)。PixelPaint 完全遵循这份工具集:选取框、文本、直线、矩形、椭圆、多边形会吸附;徒手工具则从不吸附。

Shift 约束比”非横即竖”聪明得多。 Constrain(MacPaint.p:875)通过把两个方向的增量都钳制到较小的那个,从而将线条吸附到 45°——并且当一个轴以二比一的优势压过另一个轴时,额外吸附到纯水平或纯垂直。我见过的每一个仿制品都实现了横/竖那一半,却略过了对角线优势模型。源代码用三十行给出了完整的算法。

图案就是墨水。 BrushPaint 的函数签名同时接收画笔和一个图案(MacPaint.p:2024)。画笔和喷枪并不是用”黑色”来作画;它们始终透过当前选中的图案来上色。我原样采纳了这一点,它改变了绘画的手感——图案选择不再是一个填充选项,而变成了颜料本身。

“描边”藏着一个隐藏变体。 按住 Shift,轮廓的偏移量就会从 2 变成 3,源代码里用注释 { asymmetric shadow } 做了标注(MacPaint.p:1898)。一个来自 1984 年的单行彩蛋,就此保留了下来。

文本是实心墨水,而源代码修好了我的 bug。 在触摸测试中,输入的文字有时会提交零个像素。原因在于:我的文本提交逻辑让字形像素经过填充图案的过滤,于是一个稀疏的图案就悄无声息地把字母吞掉了。原版从不这么做——无论图案如何,文本都以实心前景墨水绘制(UpdateText/PatchText,MacPaint.p:992–1106)。阅读这段过程,比调试我自己的臆断要快,也把这个修复板上钉钉、不容争辩。

还有一个发现,记录在此:PaintAsm.a 里有一个名为 Monkey 的函数——它是 Macintosh 团队所用的随机输入压力测试器的钩子,由一个名为 MonkeyLives 的标志守护。Atkinson 把他的测试工具和他的位块传输器(blitter)放在了同一个文件里发布。匠人会把自己的工装夹具留在工作台上。

我原样保留了什么,又改动了什么

忠实是这次的设计原则,所以偏离之处很少,都是有意为之,并且写进了应用内部——“关于”对话框把它们一一列出,就像一部影印复刻版会声明自己的改动之处一样:

  • 一块 16 色调色板,叠加在 1 位引擎之上。抖动算法和 .mac 导出路径,能在您需要时随时提供地道的单色效果。
  • 一个 100 步的撤销栈。 原版只有恰好一级撤销,因为 Atkinson 保留了两块窗口大小的离屏缓冲区——当前状态与上一状态——并在两者之间来回切换。4 那是对 128K 内存的一个英雄式解法。重现这个限制不过是一场角色扮演;它当年所应对的内存模型,早已不复存在。
  • 可选的橡皮擦尺寸、一个可选的散点喷涂模式,以及参考图描摹。 这些都是新增功能,要么默认关闭,要么明显是现代产物,没有一项挤占原版的行为。

同样是有意为之,原版界面的一部分没有被移植:磁盘文档的生命周期(保存、另存为、还原、关闭)属于一台以软盘为基础的机器,如今被持续自动保存加上显式导出所取代。但”文件 > 打印”保留了下来——PrintDoc(MacPaint.p:4307)为原版的”文件”菜单收尾,而打印时只渲染画作本身,像素清晰锐利,绝不会带上浏览器的界面框架。

整个项目里最诡异的 bug,压根就不是 1984 年的问题。文件保存悄无声息地失败了好几周,原因是我自己的分析脚本拦截了锚点点击——包括对 blob: URL 的点击——而保存代码在点击后同步地撤销了这个 blob URL,赶在浏览器开始下载之前。一个 1984 年的程序不会跟自己的遥测数据较劲。而在 2026 年重现它,看来是会的。

去画点什么吧

那些一眼就能认出的工具图标——套索、抓手、喷枪、油漆桶——出自 Susan Kare 之手,我在设计哲学系列中写过她那份 32×32 像素的严谨功力。而图标之下的种种行为,则出自 Bill Atkinson 之笔,他已于 2025 年 6 月离世。6 计算机历史博物馆的这次公开意味着,他的程序可以被研究、被对照、被忠实而非近似地重建——在我看来,这是为一款软件立起的最好的一种纪念碑。

PixelPaint 已在 /paint 上线,与本站其他交互式探索作品并列。它能在 iPad 上用手指操作。双击铅笔,就能看到 FatBits。画点什么,把它存成一个 .mac 文件——并且要知道:一台 1984 年的 Macintosh 也能把它打开。

常见问题

原版 MacPaint 的源代码公开了吗?

公开了。计算机历史博物馆经苹果许可,于 2010 年 7 月公开了 MacPaint 1.3 的源代码(以及 QuickDraw 图形库),供非商业用途使用。1 这次公开在 CHM 目录中登记为藏品编号 102658076,2 内容包括主 Pascal 程序(MacPaint.p)以及 68000 汇编支持文件。GitHub 上有一个官方镜像,位于计算机历史博物馆的账户之下。3

什么是 PackBits 压缩?

PackBits 是 MacPaint 用来压缩文档的游程编码方案:每条扫描线都被打包成字面段和重复段,这在充满空白与重复图案的 1 位图像上效果很好。MacPaint 的 Pascal 把 PackBitsUnpackBits 声明为外部例程(MacPaint.p:420–421),并通过其汇编胶水代码调用系统的 68000 实现。一个 MacPaint 文件由一个 512 字节的头部,加上 720 行 PackBits 压缩数据、每行 72 字节构成——也就是完整的 576×720 页面。

什么是 Atkinson 抖动?

Atkinson 抖动是 Bill Atkinson 为把灰度图像转换到 Macintosh 的 1 位显示而设计的误差扩散算法。每个像素被阈值化为黑或白,产生的误差除以 8 后分配给六个相邻像素——余下的八分之二被刻意丢弃,而非继续传播。舍弃一部分误差,正是 Atkinson 抖动的图像具有那种标志性高对比度的原因。PixelPaint 用它把彩色图画转换为 1 位,用于 .mac 导出以及实时的一位预览。

一个 MacPaint 文档有多大?

576×720 像素,固定不变——在源代码中声明为常量(MacPaint.p:108–109)。在 Macintosh 的 72 DPI 下,这恰好是 8×10 英寸,一张可打印的页面。屏幕从不会一次显示整份文档:绘图窗口是望向页面的一个可移动视口,用抓手平移,或通过”显示整页”重新定位。PixelPaint 重现了同样的文档模型,包括视口在内的一切。


来源


  1. Leonard J. Shustek,“MacPaint and QuickDraw Source Code,” 计算机历史博物馆博客,2010 年 7 月 18 日。这是公开的官方声明;记录了苹果的许可与非商业许可条款,并包含该程序的历史。 

  2. 计算机历史博物馆馆藏目录,“MacPaint source code,” 藏品编号 102658076。 

  3. Computer History Museum,Historical Source Code: MacPaint repository, GitHub。已公开源文件的官方镜像。 

  4. Andy Hertzfeld,“MacPaint Evolution,” Folklore.org。MacPaint 开发历史的第一手资料,包括支撑无闪烁绘图与一级撤销背后的那两块窗口大小的离屏缓冲区(当前状态与上一状态)。 

  5. Infinite Mac——在浏览器中模拟经典 Macintosh 系统,包括 MacPaint。用作行为对比时那台运行中的原版参照标准。 

  6. Adam Engst,“Bill Atkinson Dies from Pancreatic Cancer at 74,” TidBITS,2025 年 6 月 7 日。 

相关文章

面向构建者的 GLSL:真正可用的着色器实验室

一个能快速建立 GLSL 直觉的实用实验室:预设、实时控制、零框架 WebGL。

3 分钟阅读

汉明码:计算机如何自己发现并纠正错误

从 RAM 到二维码和深空通信,交互式理解汉明码如何检测并修复位错误。

4 分钟阅读

工程哲学:Adi Shamir

Adi Shamir 是 RSA 中的“S”,也是一位密码分析大师——他构建安全系统的方式,是同时学会攻破它们。以攻为守,把纯净的数论化为可用的机制。

5 分钟阅读