在瀏覽器中重建 MacPaint:1984 年的原始碼就是規格書
PixelPaint 是一款能運作的 MacPaint 1.3 重現版,可在你的瀏覽器中透過 /paint 開啟,並針對 Bill Atkinson 的原始 Pascal 原始碼逐一行為驗證——這份原始碼由電腦歷史博物館(Computer History Museum)於 2010 年公開。對橡皮擦按兩下,它會擦除整個視窗,然後把你先前使用的工具交還給你,因為這正是 ChooseTool 在 MacPaint.p 第 3651 行所做的事。
{.answer-block}
2010 年,電腦歷史博物館在取得 Apple 許可後,公開了 MacPaint 1.3 的原始碼:這是 Bill Atkinson 於 1984 年 1 月隨初代 Macintosh 出貨的應用程式。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 中從頭實作,從不逐行翻譯任何一行程式碼。
這篇文章談的是那條規則的代價,以及它換來了什麼。簡短版本是:原始碼是必要的,卻不充分。程式的行為藏在字裡行間——在常數裡、在位元遮罩裡、在註解裡、在一段程序的形狀裡——而把它挖掘出來是考古,不是抄寫。
原始碼不等於行為
一次忠實的重現需要三樣工具,而我最後三樣都用上了:
- 以原始碼為規格。 每一項有爭議的行為,都靠閱讀實作它的那段程序來釐清,並附上行號引用。
- 以能運作的原版為判準。 Infinite Mac 能在瀏覽器中啟動真正的 System 時代 Mac,磁碟上還裝著貨真價實的 MacPaint。5 當原始碼對手感語焉不詳時——噴槍的節奏、快速移動時筆刷的內插——就由模擬器來定奪。
- 以獨立實作為交叉驗證。 針對檔案格式,我另外用 Python 寫了第二套解碼器,與應用程式不共用任何程式碼,並要求兩者在雙向轉換上逐位元組一致。
唯一不能用的工具,是記憶。無論是我的記憶,還是網路的記憶。關於 MacPaint「大家都知道」的那些事,一旦你得讓某個像素出現在精確的座標上,多半都不夠明確。
在 1984 年,按兩下代表什麼
有一項行為,是任何螢幕截圖都告訴不了你的。在 MacPaint 中,對工具面板上的某個工具按兩下,是一道指令。這道指令的分派集中在一段程序裡,也就是 MacPaint.p:3651–3699 的 ChooseTool:
- 橡皮擦:擦除整個視窗,然後回復到先前選取的工具。
- 筆刷:開啟筆刷形狀選擇器。
- 選取框:選取整個視窗。
- 手形工具:開啟「顯示整頁」(Show Page)。
- 鉛筆:切換 FatBits,也就是像素層級的放大檢視。
橡皮擦這個情況有個細節,只有原始碼才會透露。在這一切之前的第 3643 行,有一道防護判斷:
IF theTool <> eraseTool THEN prevTool := theTool;
橡皮擦永遠不會成為「先前的工具」。所以當按兩下擦掉一切時,程式交還給你的,是你原本真正在用的筆刷或鉛筆——橡皮擦是過客,不是歸宿。Atkinson 在回復那一行上的註解說得很直白:{ we wont need the eraser anymore }。這是以單一條件式表達出來的互動設計,而它從外部完全看不見,直到你注意到 MacPaint 在清空之後從不會把你晾在橡皮擦上。PixelPaint 從這段程序實作了全部五種按兩下行為,並由一套自動化的瀏覽器測試逐一端到端驗證。
選取框那個情況裡還藏著第二個細節。當按兩下選取整個視窗時,原始碼在設定選取範圍之前,會為矩形的右緣和底緣各加一。這就引出了那兩個差一(off-by-one)的問題。
兩個差一問題,以及誰才是對的
專案進行到一半,一次審查發現我的版本裡有兩處「預覽與實際不符」:
- 選取框實際擷取的範圍,似乎比它的橡皮筋預覽少了一個像素。
- 橡皮擦印下的範圍,比它的游標預覽大了一個像素。
這兩者都是那種你花三十秒挪動一個 +1 就能「修好」的臭蟲——往哪個方向挪都行。而擁有原始碼的意義正在於:你沒得選。你去查清楚哪一邊才是錯的。
選取框其實沒有臭蟲。 QuickDraw 的矩形是「不含底緣與右緣」的:從 (10,10) 到 (20,20) 的矩形涵蓋十個像素,而非十一個。在這套慣例下,我版本裡的橡皮筋與擷取範圍本來就一致——從 10,10 拖曳到 20,20,選取的正是 10×10。審查實際比對的,是形狀工具的預覽(它正確地包含了一段閉區間的最後一個像素)與選取框的開區間擷取。兩套不同的慣例,各自都對,只是恰好比鄰而居。結論是:什麼都不改,把原因寫下來。
橡皮擦確實有臭蟲——是我的。 在原版中,橡皮擦的方塊與它的游標完全相等:一個 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 把畫布的後備儲存區上限壓在約 1,670 萬像素。若天真地把整頁尺寸乘上 FatBits 的放大倍率來配置,將需要 2,650 萬像素——這樣的畫布在我希望能正常運作的那些 iPad 上,會悄無聲息地渲染成一片空白。這次移植讓顯示用的畫布維持在可視區尺寸,改為套用一個文件空間的視圖變換;後備儲存區在 8 倍放大下實測為 42 萬像素,而一次整頁的「繪製加渲染」影格耗時 6.4 毫秒,在 60 Hz 下不到一個影格。Atkinson 以隱藏的離螢幕緩衝區解決 128K 的記憶體預算;4 這次瀏覽器移植則以一個變換解決一個隱藏的配置上限。同樣的紀律,不同的牆。
1984 年的 Mac 讀得懂的檔案
一個無法與原版交換文件的重現版,只是個立體透視模型。MacPaint 的檔案格式由原始碼本身記錄下來:一個 512 位元組的標頭,接著是以 720 條掃描線構成的頁面,每條 72 位元組,並以 PackBits 壓縮——這是一種行程長度(run-length)編碼方案,Pascal 端從不實作,只作宣告(PackBits/UnpackBits,在 MacPaint.p:420–421 標記為 EXTERNAL;MyTools.a 中的組合語言黏合程式碼把它們當作系統陷阱(trap)來分派)。標頭裡帶著程式的圖樣調色盤,因此一份文件會記得自己是用哪些圖樣繪製的。
PixelPaint 能讀寫這種格式。匯出時,會讓頁面通過 Atkinson 自己的誤差擴散抖動(dither)演算法降到 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 年、僅一行的彩蛋,就此保存了下來。
文字是實心墨水,而原始碼修好了我的臭蟲。 在觸控測試中,輸入的文字有時提交了零個像素。原因在於:我的文字提交流程讓字形像素經過填色圖樣的篩選,於是稀疏的圖樣悄悄地把字母吞掉了。原版從不這麼做——無論圖樣為何,文字都以實心的前景墨水繪製(UpdateText/PatchText,MacPaint.p:992–1106)。閱讀那段程序,比對我自己的假設除錯還來得快,而且毫無爭辯餘地地敲定了修正方式。
還有一項發現,姑且記上一筆:PaintAsm.a 裡有一個名為 Monkey 的函式——它是 Macintosh 團隊所用的隨機輸入壓力測試器的掛鉤,由一個名為 MonkeyLives 的旗標守著。Atkinson 把他的測試工具和他的位元搬移程式(blitter)擺在同一個檔案裡出貨。工匠會把自己的治具留在工作檯上。
我保留了什麼,又更動了什麼
忠實是這次的設計原則,所以偏離之處寥寥可數、皆屬刻意,而且都寫在應用程式裡——「關於」對話框一一列出,就像一部摹本版會揭露自己與原作的出入那樣:
- 一組 16 色調色盤,疊加在 1 位元引擎之上。抖動與 .mac 匯出的路徑,隨時能還你貨真價實的單色。
- 一個 100 步的復原堆疊。 原版恰好只有一層復原,因為 Atkinson 保留了兩塊視窗大小的離螢幕緩衝區——當前狀態與前一狀態——並在兩者間互換。4 那是對 128K RAM 的一次英勇解法。重現那個限制只會淪為角色扮演;它所回應的那套記憶體模型,早已不復存在。
- 可選的橡皮擦尺寸、一個選用的散點噴灑模式,以及參考影像描摹功能。 這些都是新增項目,一律預設關閉或明顯屬於現代功能,沒有一項取代了原版的行為。
同樣是出於刻意,原版的部分外顯功能沒有被移植:磁碟文件的生命週期(儲存、另存新檔、回復、關閉)屬於一台以軟碟為基礎的機器,被連續自動儲存加上明確的匯出所取代。但「檔案 > 列印」保留了下來——PrintDoc(MacPaint.p:4307)為原版的「檔案」選單收尾,而列印時只會渲染出畫作本身,像素銳利,絕不會印出瀏覽器的介面外框。
整個專案裡最古怪的臭蟲,根本不是 1984 年的問題。存檔功能悄悄失效了好幾週,原因是我自己的分析腳本攔截了錨點(anchor)的點擊——包括對 blob: URL 的點擊——而存檔的程式碼在點擊之後同步撤銷了那個 blob URL,趕在瀏覽器開始下載之前。一個來自 1984 年的程式不會和自己的遙測作對。而在 2026 年重現它,顯然就會。
去畫點什麼吧
你會認得的那些工具圖示——套索、手形工具、噴槍罐、油漆桶——出自 Susan Kare 之手,她那套 32×32 像素的紀律,我曾在設計哲學系列中寫過。圖示底下的那些行為,則由 Bill Atkinson 寫就,他於 2025 年 6 月辭世。6 電腦歷史博物館的這次公開,意味著他的程式能被研究、被對照驗證、被誠實地——而非近似地——重建,而這在我看來,是軟體所能擁有的最好的一種紀念碑。
PixelPaint 已在 /paint 上線,與本站其他的互動探索並列。它在 iPad 上用手指就能操作。對鉛筆按兩下,即可看見 FatBits。畫點什麼,把它存成一份 .mac 檔案,並且知道:一台 1984 年的 Macintosh 打得開它。
常見問題
原版 MacPaint 的原始碼可以取得嗎?
是的。電腦歷史博物館在取得 Apple 許可後,於 2010 年 7 月公開了 MacPaint 1.3 的原始碼(以及 QuickDraw 繪圖函式庫),供非商業用途使用。1 這份公開資料的 CHM 目錄編號為 102658076,2 內含主要的 Pascal 程式(MacPaint.p)以及 68000 組合語言的支援檔案。GitHub 上,在電腦歷史博物館的帳號下,有一份官方鏡像。3
什麼是 PackBits 壓縮?
PackBits 是 MacPaint 用來壓縮文件的行程長度編碼方案:每一條掃描線都封裝成「原文行程」與「重複行程」,這在充滿留白與重複圖樣的 1 位元影像上運作得很好。MacPaint 的 Pascal 把 PackBits 和 UnpackBits 宣告為外部常式(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 重現了同樣的文件模型,連同可視區在內,一應俱全。
資料來源
-
Leonard J. Shustek,“MacPaint and QuickDraw Source Code,” 電腦歷史博物館部落格,2010 年 7 月 18 日。公開發布公告;記載了 Apple 的許可與非商業授權,並涵蓋該程式的歷史。 ↩↩
-
電腦歷史博物館館藏目錄,“MacPaint source code,” 編號 102658076。 ↩↩
-
電腦歷史博物館,Historical Source Code: MacPaint repository, GitHub。已公開原始碼檔案的官方鏡像。 ↩
-
Andy Hertzfeld,“MacPaint Evolution,” Folklore.org。MacPaint 開發歷史的第一手來源,包括支撐無閃爍繪圖與單層復原背後的那兩塊視窗大小的離螢幕緩衝區(當前狀態與前一狀態)。 ↩↩
-
Infinite Mac——在瀏覽器中模擬的經典 Macintosh 系統,包含 MacPaint。用作行為比對時能運作的原版判準。 ↩
-
Adam Engst,“Bill Atkinson Dies from Pancreatic Cancer at 74,” TidBITS,2025 年 6 月 7 日。 ↩