[Astro] #155 Power Mac Emulator — DingusPPC WASMでMac OS 8.1をブラウザ起動(おまけ:Copland D11E4ランチャー)

[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で動かしています。

Coplandといえば、『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でMac OS 8.1が起動した画面

動画

Power Mac Emulatorの起動の様子

Power Mac Emulator

👉 Power Mac Emulator を開く

ROMと起動ディスクを読み込んで Start を押すと、ハッピーMacが出て Mac OS が起動します。画面をクリックするとマウスとキーボードを捕捉し、Esc で解除します。

ROMやディスクは、ブラウザの中(OPFS)に保存されるので、2回目からは読み込み直す必要がありません。Mac OSがハードディスクに書き込んだ内容もそのまま残ります。サーバーへのアップロードは一切ありません。

COPLAND D11E4

👉 おまけ:Copland D11E4 ランチャー(後述)

スクリーンショット

Power Mac Emulator // COPLAND D11E4

用意するもの

このページには、AppleのROMやOSは一切含まれていません。手持ちのものを読み込んで使います。

ファイル内容
ROMPower Macintosh の 4MB ROM(下表)
起動ディスクMac OS 7.5〜9 のCD・HDイメージ(.iso / .img / .dsk)
ドライバヘッダ(任意)素のHFSイメージを起動する場合のみ(後述)

DingusPPCはROMのチェックサムで機種を自動判別します。確認できたROMは次のとおりです。

チェックサム機種
9630C68B / 96CD923DPower Mac 7200 / 7500 / 8500 / 9500(TNT)
79D68D63 / 78F57389Power Mac G3(Beige)
9FEB69B3Power 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 のリポジトリに入っているビルド済みのものをそのまま使っています。自分でビルドはしていません。

Infinite 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 D11E4 ランチャーを開く

Copland が動いているのは、mihaip版とは別の、Michael Steil氏による DingusPPC のフォークです。

11本のパッチは、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 の開発チームに感謝します。

👉 Power Mac Emulator を開く