[Astro] #155 Power Mac Emulator — DingusPPC WASMでMac OS 8.1をブラウザ起動(おまけ:Copland D11E4ランチャー)
はじめに
きっかけは、1本のニュースでした。
Appleが1996年に開発を中止した幻のOS「Copland」の最終ビルド D11E4 が、ブラウザの中で起動するようになった、というものです。作ったのは、C64のROM解析やGEOSのソース公開で知られる Michael Steil 氏(pagetable.com)。DingusPPC というPowerPC Macエミュレーターに11本のパッチを当てて、WASMで動かしています。
Apple Copland D11E4 Emulator in Your Browser – pagetable.com
Apple's ill-fated Copland operating system is notoriously hard to run on real hardware, and has not previously been available in emulation. Here is the last build, D11E4 from June 1996, in an improved DingusPPC.
www.pagetable.comCoplandといえば、『serial experiments lain』で玲音のNAVIが動かしていた「Copland OS Enterprise」の元ネタです。lain-labとしては見過ごせません。
ただ、調べていくうちに分かったのは、Coplandを動かすには特別なディスクとNVRAMが必要で、それを公開の範囲で用意する手段がない、ということでした。そこで方針を変えて、PowerPC Mac エミュレーターそのものを自分のページで動かす ことにしました。
これまでZ80(MSXなど10機種)、6502(Apple II / C64 / Atari 2600)とCPUエミュレーターを作ってきましたが、今回はCPUコアを自作するのではありません。既存のエミュレーターのWASMビルドに、ブラウザ側の「つなぎ」を書く仕事です。
スクリーンショット
動画
Power Mac Emulator
ROMと起動ディスクを読み込んで Start を押すと、ハッピーMacが出て Mac OS が起動します。画面をクリックするとマウスとキーボードを捕捉し、Esc で解除します。
ROMやディスクは、ブラウザの中(OPFS)に保存されるので、2回目からは読み込み直す必要がありません。Mac OSがハードディスクに書き込んだ内容もそのまま残ります。サーバーへのアップロードは一切ありません。
COPLAND D11E4
👉 おまけ:Copland D11E4 ランチャー(後述)
スクリーンショット
用意するもの
このページには、AppleのROMやOSは一切含まれていません。手持ちのものを読み込んで使います。
| ファイル | 内容 |
|---|---|
| ROM | Power Macintosh の 4MB ROM(下表) |
| 起動ディスク | Mac OS 7.5〜9 のCD・HDイメージ(.iso / .img / .dsk) |
| ドライバヘッダ(任意) | 素のHFSイメージを起動する場合のみ(後述) |
DingusPPCはROMのチェックサムで機種を自動判別します。確認できたROMは次のとおりです。
| チェックサム | 機種 |
|---|---|
9630C68B / 96CD923D | Power Mac 7200 / 7500 / 8500 / 9500(TNT) |
79D68D63 / 78F57389 | Power Mac G3(Beige) |
9FEB69B3 | Power Mac 6100 / 7100 / 8100(PDM) |
読み込むと、ファイル一覧に種類(ROM / HD / CD / HFS*)が表示されます。先頭のバイトを見て判定しています。
if (sig(0) === "ER") return "apm"; // パーティションマップ付き
if (head.subarray(0x8001, 0x8006) === "CD001") return "iso";
const s2 = sig(1024);
if (s2 === "BD" || s2 === "H+") return "hfs-bare"; // 素のHFS / HFS+
構成
src/pages/ppc-mac.astro UI(画面 + 右パネル)
public/ppc-mac/
├─ main.js UI側:入力・描画・ログ表示・ファイル管理
└─ worker.js Worker側:workerApi を実装して DingusPPC を起動
public/libs/dingusppc/
├─ dingusppc.js Emscripten ローダー(mihaip版ビルド)
└─ dingusppc.wasm エミュレーター本体(約1.9MB)
dingusppc.js / .wasm は、Infinite Mac のリポジトリに入っているビルド済みのものをそのまま使っています。自分でビルドはしていません。
mihaip/infinite-mac: A classic Mac loaded with everything you'd want
80's, 90's and early 2000s classic Macs that load instantly in a browser and have access to a large software library.
github.comInfinite Mac は Mihai Parparita 氏による、クラシックMacをブラウザで動かすプロジェクトです。Mini vMac、Basilisk II、SheepShaver、DingusPPC、PearPC、Previous など7種類のエミュレーターをWASM化して、ソフトウェアライブラリ付きで公開しています。
Infinite Mac をそのまま自前でホストすることも考えました。ただ、ディスクイメージのチャンク分割や Cloudflare Worker を前提にした構成で、ビルドの工程にmacOS版のエミュレーターまで使います。今回は「ROMもディスクもユーザーが読み込む」方針なので、エミュレーター本体だけを借りて、つなぎの部分を自分で書くことにしました。
workerApi:C++から呼ばれる20個の口
mihaip版の DingusPPC は、ホスト依存の部分が「SDL版」と「JS版」の2本立てになっています。JS版は驚くほど薄く、C++側からはグローバルの workerApi を EM_ASM で呼ぶだけです。
// devices/video/display_js.cpp(抜粋)
EM_ASM_({ workerApi.didOpenVideo($0, $1); }, width, height);
EM_ASM_({ workerApi.blit($0, $1); }, frame_buffer, frame_buffer_size);
// utils/imgfile_js.cpp(抜粋)
return EM_ASM_DOUBLE({
return workerApi.disks.read($0, $1, $2, $3);
}, impl->disk_id, buf, double(offset), double(length));
呼ばれる口は全部で20個ほどです。
| 分類 | 関数 |
|---|---|
| 画面 | didOpenVideo(w, h) / blit(ptr, size) |
| 入力 | acquireInputLock() / releaseInputLock() / getInputValue(addr) |
| 音 | didOpenAudio() / audioBufferSize() / enqueueAudio() |
| ディスク | disks.open / read / write / size / close |
| その他 | sleep() / reportError() / setAbortError() |
つまり、この workerApi オブジェクトを自分で用意すれば、wasmには一切手を入れずに動かせます。Infinite Mac の worker.ts(約900行)を読んで仕様を確認し、必要な部分だけを worker.js(約450行)に書き直しました。
画面:フレームバッファをSharedArrayBufferへ
DingusPPC は画面が変化したときだけ blit を呼びます(xxHashで前のフレームと比較済み)。Worker側では wasm のメモリから SharedArrayBuffer にコピーしてカウンタを進め、UI側は requestAnimationFrame でカウンタが変わっていたら描きます。
// worker.js
blit(ptr, size) {
if (ptr && size <= vFrame.length) {
vFrame.set(mod.HEAPU8.subarray(ptr, ptr + size));
Atomics.add(vHeader, 0, 1); // フレーム番号
}
periodic();
},
// main.js
function draw() {
const id = Atomics.load(vHeader, 0);
if (id !== lastFrame) {
lastFrame = id;
imageData.data.set(vFrame.subarray(0, w * h * 4));
ctx.putImageData(imageData, 0, 0);
}
requestAnimationFrame(draw);
}
入力:ロック付きの共有バッファ
エミュレーターが動き始めると、Workerの main() は二度と戻ってきません。Workerのイベントループが止まるので、postMessage で入力を送っても受け取れません。
そこで、入力は SharedArrayBuffer 上の Int32Array に書き込みます。UIとWorkerが同時に触らないように、先頭の1要素を4状態のロックとして使います。
0: READY_FOR_UI UIが書ける
1: UI_LOCK UIが書き込み中
2: READY_FOR_EMUL エミュが読める
3: EMUL_LOCK エミュが読み込み中
// worker.js:DingusPPC がイベントをポーリングするたびに呼ばれる
acquireInputLock() {
return Atomics.compareExchange(input, 0, 2, 3) === 2 ? 1 : 0;
},
releaseInputLock() {
resetInput(); // 読んだ値を消す
Atomics.store(input, 0, 0); // UIに返す
},
UI側は、たまったマウス移動量を合算して1回で書き込みます。ボタンやキーは1回のやり取りで1つずつしか送れないので、重なったら次回に回します。
マウスは相対移動(delta)で渡します。DingusPPC のADBマウスは相対値しか使わないので、Pointer Lock を使って movementX / movementY を送っています。
ディスク:OPFS SyncAccessHandle
DingusPPC はディスクを読むとき、「このオフセットから何バイト」を disks.read(id, ptr, offset, length) で要求してきます。Workerの中なら、OPFS の SyncAccessHandle で 同期的に 部分読み書きができます。wasm のメモリに直接読み込めるので、数百MBのイメージでもメモリに丸ごと載せる必要がありません。
class OpfsDisk {
read(buf, offset) { return this.ah.read(buf, {at: offset}); }
write(buf, offset) { this.dirty = true; return this.ah.write(buf, {at: offset}); }
}
// disks.read:wasmヒープの該当範囲へ直接読み込む
read(id, ptr, offset, length) {
return this.opened.get(id).read(mod.HEAPU8.subarray(ptr, ptr + length), offset);
},
以前作った Storage Benchmark で、IndexedDB と OPFS(SyncAccessHandle)を比べたことがあります。そのときの結果どおり、エミュレーターのディスクとしても申し分ない速さでした。
素のHFSイメージを起動する
Mac OS 8.1 のCDを吸い出したイメージを読み込んだところ、判定は「素のHFS」でした。パーティションマップもドライバもない、HFSボリュームだけのイメージです。
7200 / 7500 のROMは、ディスクのパーティションマップにあるドライバを読んで起動します。ドライバがないディスクは、起動ディスクとして認識されません。DingusPPC のCD経路はイメージをそのまま読むので、「?」付きフロッピーのままでした。
Infinite Mac はこの問題を、ドライバ入りのヘッダを先頭に付け足してHDとして見せる 方法で解決しています。これを移植しました。
[ヘッダ(約912KB) ][ 素のHFSボリューム ]
Block0: Driver Descriptor Map ("ER")
#1 Apple_partition_map
#2-8 Apple_Driver43 / ATA / FWDriver / Patches ...
#9 Apple_HFS ← ここのブロック数を実サイズに書き換える
class HeaderedDisk {
constructor(disk, baseHeader) {
const header = baseHeader.slice(0);
const view = new DataView(header.buffer);
const hfsBlocks = Math.floor(disk.size / 512);
view.setInt32(0x4, (header.length + hfsBlocks * 512) / 512); // sbBlkCount
const pm = 9 * 512; // #9 Apple_HFS
view.setInt32(pm + 12, hfsBlocks); // pmPartBlkCnt
view.setInt32(pm + 84, hfsBlocks); // pmDataCnt
this.header = header;
}
read(buf, offset) {
// ヘッダとHFSにまたがる読み込みは2つに分けて処理
}
}
ヘッダの元ファイル(Device Image Header (All Drivers).hda)は Apple のドライバを含むので、ページには置いていません。ROMと同じく、ユーザーが読み込む扱いです。これで Mac OS 8.1 のインストーラーが起動しました。
つまずいたところ
ログの洪水
最初はDingusPPCのログをそのまま console に流していました。すると起動だけで警告が3,000件以上出て、表示がなかなか始まりません。中身はほとんどが、ROMがRAMの範囲を探る Access to unmapped physical memory(正常な動作)です。
さらに調べると、ログを止めるつもりで --log-to-stderr を外すと、DingusPPC は MEMFS 上の dingusppc.log に書き続けます。つまりメモリの中でファイルが膨らみ続けます。
対策として、ログは常に stderr に出して --log-verbosity で絞ることにしました。Worker側では行をまとめて200msごとに送り、アドレスだけが違う同じ警告は1行に集約します。
// 数字を # に置き換えて「同じ内容」を判定
const logKey = (t) => t.replace(/0x[0-9a-f]+|\d+/gi, "#");
WARN ppcmmu.cpp:885 Access to unmapped physical memory ×128441 のように表示されます。ログパネルは普段は折りたたんで、ERR / WARN の件数だけを表示しています。
偽ROMでのテスト
作業環境にはAppleのROMがないので、4MBの乱数ファイルを偽ROMにして動作を確認しました。機種判別はもちろん失敗します。ただ、機種を手動で指定すると、マシンの構築、CPUの実行開始、画面を開く処理まで進みます。これで、wasmのロード、引数、ファイルシステム、workerApi の経路が全部つながっていることを確かめてから、実ROMでの確認に進みました。
またしても Astro の scoped CSS
#154 と同じく、JSで生成したファイル一覧の行にスタイルが当たりませんでした。今回は :global() で対応しています。途中でファイルを入れ替えたときは、devサーバーが古いCSSを返し続けたので、再起動が必要でした。
おまけ:Copland D11E4 ランチャー
Copland が動いているのは、mihaip版とは別の、Michael Steil氏による DingusPPC のフォークです。
mist64/dingusppc (copland-boot)
The 11 patches necessary for unlocking Copland. Explanations are in the commit messages.
github.com11本のパッチは、ADB・Cuda・SWIM3・Bandit・DBDMA・MACE・ESCCといった、エミュレートしているハードの挙動を実機に合わせる修正です。Copland本体には手を入れていません。
2つのビルドは、同じ DingusPPC でもブラウザ側の設計が根本的に違います。
| Power Mac Emulator(mihaip版) | Copland(mist64版) | |
|---|---|---|
| 実行方式 | Workerの中で回りっぱなし | 数百万命令ずつ実行して制御を返す |
| 入力 | SharedArrayBuffer + ロック | 普通の postMessage |
| COOP/COEP | 必要 | 不要 |
| ディスク | OPFSに直接読み書き | メモリに展開(再読み込みで元に戻る) |
mist64版は、エミュレーターを小分けに実行してWorkerのイベントループに毎回戻る設計です。そのおかげで SharedArrayBuffer が要らず、どんな静的ホストでもヘッダー設定なしで動きます。同じ問題に対する、もう一つのきれいな答えだと思います。
このランチャーは、氏のウィジェット(copland.js / worker.js / dingusppc.js / dingusppc.wasm、いずれもGPL)を無改造で使い、ROM・NVRAM・ディスクをユーザーが選んだファイルの blob URL として渡しているだけです。
import {mount} from "/libs/copland-mist64/copland.js";
const url = (f) => URL.createObjectURL(f); // OPFS に保存したファイル
mount($("machine"), {
rom: url(rom), nvram: url(nvram),
diskmode: "gz", diskgz: url(disk), // gz でも生イメージでも受け付ける
});
Copland の起動には、氏が専用に組み上げたディスクとNVRAMが必要です。このページには含まれていないので、各自で用意できる方向けのランチャーです。Copland が実際に動く様子は、ぜひ氏のページで体験してください。
クレジットとライセンス
このページは、以下のプロジェクトと作者の方々の仕事の上に成り立っています。
- DingusPPC(GPL-3.0)— divingkatae、maximum、spatium ほか DingusPPC 開発チーム
github.com/dingusdev/dingusppc - mihaip/dingusppc の WASM ビルド、および Infinite Mac(Apache-2.0)— Mihai Parparita 氏
workerApiの仕様とドライバヘッダの生成処理は、Infinite Mac の実装を参考に移植しています。
github.com/mihaip/dingusppc / github.com/mihaip/infinite-mac - Copland パッチ、WASM ポート、ウィジェット(GPL-3.0)— Michael Steil 氏
copland-boot / wasm-port / pagetable.com - Mac OS / Copland © Apple Computer, Inc.
ROM・NVRAM・ドライバヘッダ・OSイメージは、このサイトでは配布していません。
lain-lab が配信しているのは、上記の GPL / Apache-2.0 のソフトウェアと、自作のつなぎのコードだけです。GPLソフトウェアのソースコードは、上記のリンク先から入手できます。
おわりに
MSX から始まった Z80、Apple II や C64 の 6502 に続いて、今回は PowerPC です。CPUコアは自作していませんが、「エミュレーターの外側」を丸ごと書いたのは初めてでした。
画面、入力、ディスク、ログ。wasm の外に出てくる口はたった20個ほどです。それでも、その向こうでは1990年代のPower Macがまるごと動いています。そして、同じ DingusPPC から、SharedArrayBuffer を使う設計と使わない設計という、まったく違う2つの答えが生まれていました。これも面白い発見でした。
Infinite Mac の Mihai Parparita 氏、Copland を掘り起こした Michael Steil 氏、そして DingusPPC の開発チームに感謝します。