Rust製x86エミュレータにVGA mode 13hを実装して、ブラウザでDOSのボールを跳ねさせる【rustx86 グラフィックス編 #1】

Rust

連載案内: Rust + WebAssembly で書いている x86 エミュレータ rustx86 の開発記。これまでの連載は ネットワーク編 #1ディスク編 #1JIT編 #1。今回からグラフィックス編が始まる。画面が文字だけだったエミュレータに、初めて画素が映るまで。

rustx86 はこれまで、ELKS・FreeDOS・Linux を起動してもすべてテキスト画面だった。テキストVRAM (0xB8000) に文字と属性を並べ、ブラウザの canvas に80×25の升目を描く。それで Linux のシェルも vi も動くのだが、DOOM も X も、その先の ReactOS も、画素が映らなければ話が始まらない。

今回はその第一歩として VGA mode 13h (320×200×256色) を実装した。core (Rust) に RAMDAC とモード切替と垂直帰線を生やし、wasm の窓を通してブラウザの canvas に画素を出し、最後に FreeDOS の中で自作の .COM を走らせて8色のボールを跳ねさせるところまで。途中で、実物のDOSデバッガが CPU の小さなバグを1つ炙り出してくれた。

BOUNCE.COM — FreeDOSの中で8色のボールが跳ねる

入口は2つ、配管は1つ

最初に決めたのは「どこから作るか」だった。フレームバッファには入口が2つある。

入口 誰が使うか 中身
VGA mode 13h 16bit (FreeDOS・DOOM) INT 10h AH=00 でモード13hを受け、0xA0000 の 320×200×8bpp + DACパレット
リニアFBの申告 32bit (Linux) zero page の screen_info に LFB を申告して vesafb に bind させる

共有するのは後ろ半分 — 画素バッファ → wasm の窓 → canvas の描画 — で、入口だけが違う。mode 13h から始めたのは範囲が閉じているからだ。planar モードや CGA/EGA、VBE は作らない (底が無い側なので ADR で線を引いてある) が、mode 13h 単体は「0xA0000 のリニアな 64KB + 256色の表」で有界である。しかも自作の asm 1本で即日検証できて、共通の配管もここで完成する。vesafb 側はその配管の上に LFB の申告を足すだけになる。

設計の要: フレームバッファは「ただのRAM」

rustx86 のテキストVRAMは書き込みフック式だ。write8 の中で番地が 0xB8000 の窓に入ったら vram_dirty を立て、描画側は dirty のときだけ描き直す。

// core/src/mem/rw.rs (テキストVRAM)
pub fn write8(&mut self, addr: u32, val: u8) {
    // ... 線形→物理変換、デバッガの見張り ...
    self.mem[a] = val;
    if (bus::VRAM_TEXT_BASE as usize..=bus::VRAM_TEXT_END as usize).contains(&a) {
        self.vram_dirty = true;
    }
}

4000バイトのテキスト画面にはこれでいい。しかし画素でこれをやってはいけない。ゲストのピクセルストア (DOOM の内側ループは rep stosbmov [es:di],al の嵐だ) が全部この分岐を踏み、JIT の高速路から弾かれる。ロードマップの 6a にはこう書いてある — フレームバッファは装置の窓ではなくただのRAM。書き込みフックなし、表示側が毎フレーム全読みして描く。 320×200×8bpp は 64KB なので、dirty 追跡の賢さは要らない。

画素の配管 — FBはただのRAM

だから core 側の「フレームバッファ」は、RAM の一部分を見せる窓にすぎない。

// core/src/mem/rw.rs
/// mode 13h のフレームバッファ (320×200、1バイト=色番号)。
/// **ただのRAMの窓**である。書き込みフックもdirtyも無く、表示側が
/// 毎フレーム全読みして描く
pub fn framebuffer(&self) -> &[u8] {
    let b = bus::VRAM_GFX_BASE as usize;
    &self.mem[b..b + bus::GFX_LEN]
}

実のところ 0xA0000 は以前からただのRAMだった。ゲストはずっとここに書けていて、映っていなかっただけなのである。

RAMDAC を「素子」として書く

mode 13h の画素は「256色のどれか」という番号でしかない。番号が何色かを決めるのが RAMDAC (INMOS IMS G171 系) で、ポート 0x3C6〜0x3C9 に居る。rustx86 は装置を「素子 (chip)」「基板 (card)」「バス」に分けて置く流儀 (ADR-0018) なので、RAMDAC は dev/chip/dac.rs に、番地もバスも知らない素子として書いた。

芯は自動歩進にある。0x3C8 に色番号を書いてから 0x3C9 へ R→G→B と3回書くと、3回目で番号が勝手に次へ進む。256色×3バイトを OUT 命令の列だけで流し込むための、ポートが高価だった時代の工夫である。

// core/src/dev/chip/dac.rs
/// 0x3C9 書き: R→G→B の順に1バイトずつ。Bを書くと色番号が自動歩進する。
/// 上位2bitは配線が無い (6bit DAC) ので落とす
pub fn write_data(&mut self, val: u8) {
    let at = self.write_index as usize * 3 + self.write_phase as usize;
    self.rgb[at] = val & 0x3F;
    self.write_phase += 1;
    if self.write_phase == 3 {
        self.write_phase = 0;
        self.write_index = self.write_index.wrapping_add(1);
    }
}

値は6bitのまま持つ (0〜63)。8bit への伸長は描画側 (ブラウザ) の仕事で、チップが持っている値をそのまま見せる。PCスピーカーが speaker_tone() で「今の周波数」を返すだけで WebAudio を鳴らすのは外側、NIC がフレームを返すだけで線に流すのは外側、と同じ境界である。

既定のパレットは先頭16色だけ EGA の配色で埋め、残りは 0 にしてある。実BIOSは256色の既定表 (グレー階調+色相環) を持っているが、mode 13h を使うソフトはほぼ例外なく自分のパレットを流し込むので、使う者が現れるまで作らない。台帳に書いて寝かせる流儀だ。

垂直帰線を時計から合成する

DOS のゲームは 0x3DA (入力状態レジスタ1) の bit3 をポーリングして垂直帰線を待つ。ちらつきを防ぐためでもあり、テンポを取るためでもある — 帰線は 70Hz で来るので、毎フレーム帰線の頭で進めば1秒に70回きっかり描き換わる。

ここが常に同じ値だと、帰線待ちのループは永遠に終わらない。といってレジスタの実体があるわけでもない。そこで機械の時計 (tsc) から合成することにした。

// core/src/mem/io.rs
/// 校正の原点は PIT_CLOCKS_PER_TICK と同じ「64命令 ≒ 1 PITクロック」。
/// mode 13h の垂直同期は70Hz = 1193182/70 ≒ 17045 PITクロック/フレーム
fn video_status(&mut self) -> u8 {
    const FRAME: u64 = 17045 * 64;          // ≒ 1/70秒ぶんの命令数
    const VRETRACE: u64 = FRAME * 4 / 100;  // 帰線はフレーム末尾の約4%
    const LINE: u64 = FRAME / 449;          // 400走査線 + 帰線期間 = 449本
    const HBLANK: u64 = LINE / 5;
    let t = self.cpu.tsc % FRAME;
    let mut st = 0u8;
    // bit0 は「表示していない」— 垂直・水平どちらのブランクでも立つ
    if t >= FRAME - VRETRACE {
        st |= 0x08 | 0x01;
    }
    if (t % LINE) >= LINE - HBLANK {
        st |= 0x01;
    }
    st
}

0x3DA の垂直帰線を tsc から合成する

tsc だけの純関数なので命令数の決定性は無傷である。rustx86 は「同じイメージなら起動までの命令数がビット同一」を門番にしていて (Linux は 970,000,000 命令ちょうどでシェルに着く)、この変更の後も OS 起動回帰・JIT 決定性ゲートは全部ビット同一のまま通った。

INT 10h でモードを切り替える

BIOS は実装せず、INT 命令をフックしてホスト側の関数で肩代わりする (HLE) のが rustx86 の流儀だ。INT 10h AH=00 はこれまでモード番号を台帳に控えるだけだったのを、実際に切り替えるようにした。

// core/src/bios.rs
fn set_video_mode(&mut self, mode: u8, clear: bool) {
    match mode {
        0x13 => {
            self.video_mode = 0x13;
            if clear {
                let b = bus::VRAM_GFX_BASE as usize;
                self.mem[b..b + 0x1_0000].fill(0);
                // 直接fillは自己書き換え検出の横を通るので、写しの無効化を申告
                self.dcache.note_write_range(bus::VRAM_GFX_BASE, 0x1_0000);
            }
            self.write8(0x449, 0x13);   // BDA: 現在のビデオモード
            self.write16(0x44A, 40);    // BDA: 桁数
        }
        0x00..=0x03 => { /* テキストへ戻す。空白+属性0x07で埋めてカーソルを左上へ */ }
        // それ以外 (planar/CGA/EGA) は作らない (ADR-0004)
        _ => {}
    }
}

実BIOSと同じく切り替え時に VRAM を消す (AL の bit7 が「消すな」の指定)。BIOSデータエリアの 0x449 も更新する — DOS のソフトは INT 10h を呼ばずにここを読んでモードを判断することがある。fill で直接書くと自己書き換え検出 (デコード済み命令キャッシュの無効化) の横を通ってしまうので、note_write_range で申告しておく。こういう「速い道を足したら遅い道が拾う条件を明示する」のは、このリポジトリで何度も踏んだ罠の型である。

wasm の窓と canvas

wasm 側には video_mode() / fb_ptr() / palette() を生やした。fb_ptr はテキストVRAMの text_vram_ptr と同じゼロコピーの窓で、JS はリニアメモリを直接読む。

ブラウザ側 (machine.js) は毎フレーム video_mode を見て、テキストの顔とグラフィックの顔を呼び分ける。グラフィックのときは dirty を見ない — 通知が無いのだから、毎フレーム 64KB を全読みして描く。

// web/machine.js — 描かせるのは1フレームに1回だけ
if (this.emu.video_mode() === 0x13) {
  this.onGfxFrame?.(this.fb(), this.emu.palette());
} else if (changed) this.onFrame?.(this.vram(), ...this.cursor(), true);

描き手 (terminal.js) は 320×200 の ImageData を作り、パレットを引きながら 6bit→8bit に伸ばし、2倍の整数拡大で canvas に置く。補間は切る — 画素の縁が滲むと VGA ではなくなる。

// web/terminal.js
drawPixels(fb, pal6) {
  this.gfxOn = true;
  const d = this.gfx.img.data;
  for (let i = 0, o = 0; i < fb.length; i++, o += 4) {
    const p = fb[i] * 3;
    d[o]     = (pal6[p]     << 2) | (pal6[p]     >> 4);  // 63 → 255
    d[o + 1] = (pal6[p + 1] << 2) | (pal6[p + 1] >> 4);
    d[o + 2] = (pal6[p + 2] << 2) | (pal6[p + 2] >> 4);
    d[o + 3] = 255;
  }
  this.gfx.ctx.putImageData(this.gfx.img, 0, 0);
  const { ctx } = this;
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(this.gfx.cvs, x, 0, 640, 400);
}

最初に映したとき、画面に ▲ 0行前 (キーを打つと最新へ) というテキスト端末のバナーが被さって出た。犯人は端末のカーソル点滅タイマーで、530msごとに draw() を呼んでテキストの黒地を画素の上に塗っていた。しかもグラフィック中はテキストの取り込み (sample()) が走らないので cells が空のまま、draw() は履歴表示に落ちて「0行前」のバナーを描く。対処は gfxOn の旗1本 — グラフィックの顔が出ている間、テキストの描き手は黙る。旗を下ろすのはテキストの顔だけが呼ぶ sample() である。

検証1: 自作の asm で3点を閉じる

検証の当て先は3つ決めてあった。1つ目は自作の asm で、これは決定的なので CI 回帰に載る。

; asm/mode13.asm (抜粋)
    mov  ax, 0x0013
    int  0x10
    ; パレット: 色16=赤 色17=緑 色18=青 (連続書きで歩進)
    mov  dx, 0x3C8
    mov  al, 16
    out  dx, al
    inc  dx
    mov  al, 63
    out  dx, al          ; R
    xor  al, al
    out  dx, al          ; G
    out  dx, al          ; B → 色17へ
    ; 画素: 先頭行に 0..255 の色番号を並べる
    mov  ax, 0xA000
    mov  es, ax
    xor  di, di
    xor  al, al
.grad:
    stosb
    inc  al
    jnz  .grad

Rust 側のテストはモード遷移・BDA の反映・画素が生バイトであること・パレットの自動歩進・帰線ビットが時間で両方の顔を見せること・テキスト復帰時のクリア・スナップショットの往復、の7本。wasm 経由でも vga-check.mjs が同じ盤で照合し、CI の機械層に常設した。

検証2: 実物のDOSが炙り出したCPUのバグ

2つ目の当て先はFreeDOS の実物ソフトだ。手元のフロッピーには mode 13h を使うソフトが無かったが、DEBUG.COM (FreeDOS の lDebug) が入っている。ならば DOS の中でその場で組めばいい。

A:\>debug
-a
489E:0100 mov ax,13
489E:0103 int 10
489E:0105 mov ax,a000
489E:0108 mov es,ax
489E:010A xor di,di
489E:010C mov cx,fa00
489E:010F mov al,2
489E:0111 rep stosb
489E:0113 int 20
489E:0115
-n vga.com
-r cx
CX 0000  :15
-w
Writing 0015 bytes
-q
Unexpected single-step interrupt
Cannot quit, attached process didn't terminate!

w は成功した — 本物の DOS が FAT に VGA.COM を書いた。ところが q で lDebug が “Unexpected single-step interrupt” と言う。lDebug の INT 1 ハンドラが、トレース中でもないのに呼ばれたのだ。

rustx86 の Machine はベクタごとの割り込み回数と初出位置を数えている。q の前後で INT 1 が 0→1 に増え、初出は 0000:7464 — DOS カーネルの領域だった。TF (トラップフラグ) が 0→1 に立つ瞬間を見張る診断を書いたところ、立てた「命令」の番地のバイト列が 0a 00 41 63 63 65 — ASCII で “Acce”、“Access denied” の文字列の中だった。つまり TF は流れ弾で、実行は既にデータ領域に迷い込んでいた。

ここで TF の配り方を読み直した。rustx86 は命令の実行前に TF を控え、命令が終わってから INT 1 を配る。普通のトレースならこれで正しい。だが INT n は TF を落としてから跳ぶ命令で、x86 の約束ではINT の後にシングルステップは起きない (割り込みハンドラの中へはトレースで入れない)。実行前の値だけ見て配っていたので、DOS のデバッガがトレース中に INT 21h を踏むたび、ハンドラの中で余分な INT 1 が鳴っていたのである。

// core/src/lib.rs — 修正後
// **実行後にもTFを見る** — 命令自身がTFを落としたら配らない。
// INT n はTFを落としてから跳ぶので、その後のシングルステップは起きない。
// POPF/IRETがTFを落とした直後も同じく起きない (Intelの明文の癖)
if tf && self.cpu.flag(cpu::TF) && !self.halted {
    cpu::interrupt(self, 1);
}

INT n の後にシングルステップ例外を配ってはいけない

正直に書くと、この修正の後も lDebug の q は同じ文句を言う。データ領域へ迷い込む原因は別にあり、lDebug の方言なのか残るバグなのかはまだ切り分けていない (台帳に載せた)。だが TF の抑制は x86 の意味論として正しく、cosim も OS 起動回帰も JIT 決定性ゲートもビット同一のまま通った。実物のソフトを当てる価値はこういうところにある。

q が使えないなら使わなければいい。テストは2回起動方式にした。1回目の起動で lDebug に VGA.COM を書かせ、Machine からフロッピーの中身を取り出し、その盤面でもう一度素の DOS を起動して vga を実行する。

// core/tests/vga.rs (抜粋)
// DOSが書いた盤面ごと取り出す (VGA.COM入りのフロッピー)
let written = m.disk.as_ref().expect("disk").data.clone();

// --- 2回目の起動: 素のDOSがFATから読んで実行する ---
let mut m = boot_freedos_to_prompt(written);
type_slowly(&mut m, "vga\n");
for _ in 0..100_000_000 {
    m.step();
    if m.video_mode == 0x13 { break; }
}
assert_eq!(m.video_mode, 0x13);
let fb = m.framebuffer();
assert!(fb.iter().all(|&b| b == 2), "画面が色2で塗り切れていない");

DOS が書いた FAT が次の起動でも読める、まで含めた証明になった。

BOUNCE.COM — 8色のボールが跳ねる

最後に、動くものを1本。1990年前後の DOS ゲームの骨格をそのまま小さくした BOUNCE.COM (380バイト) を書いて、FreeDOS のイメージに同梱した。

; tools/guest/bounce/bounce.asm (抜粋)
main:
    ; 垂直帰線を待つ: 帰線中なら抜けるのを待ち、次の帰線の頭で進む
    mov dx, 0x3DA
.in_retrace:
    in al, dx
    test al, 8
    jnz .in_retrace
.wait_retrace:
    in al, dx
    test al, 8
    jz .wait_retrace

    ; 1周目: 全部消す / 2周目: 全部動かす (壁で反転) / 3周目: 全部描く
    ...
    ; キーを覗く。押されていなければ次のフレームへ
    mov ah, 0x01
    int 0x16
    jz main
    xor ah, ah
    int 0x16
    mov ax, 0x0003      ; テキストモードへ戻す
    int 0x10
    int 0x20

ボールは (x, y, vx, vy, 色) の表で持ち、DAC には9色 (ボール8色+壁) を表から一括で流し込む。自動歩進を1ループで使う当時の定石だ。エミュレータの側から見ると、これは合成した垂直帰線で実物のゲームループが「止まらず・暴走せず」一定のテンポで回ることの実地試験である。

テストもそのまま書いた。FreeDOS から bounce を起動し、壁の色と DAC の橙を確かめ、10フレーム後にボールの重心が 3〜60 画素動いていること (帰線が速すぎても遅すぎても落ちる)、スペースでテキストへ戻って ver が通ることまで。ブラウザでは 53 MIPS で実時間どおりに跳ね、キーを押すとクリーンな A:\> に戻る。

次回

6a の残り半分は Linux 側 — zero page の screen_info に LFB を申告して vesafb を bind させる入口だ。こちらは Worker の中で動く Linux の画素を canvas へ運ぶ経路 (OffscreenCanvas) も要る。その先にマウス、ISO 起動、そして DOOM。

今回のコードは PR #204 (core)・PR #205 (web + 実物DOS検証 + TF修正)・PR #206 (BOUNCE.COM)。

次回: Rust製x86エミュレータでLinuxのフレームバッファを映す — vesafbは居なかった、efifbで描く【rustx86 グラフィックス編 #2】

コメント

タイトルとURLをコピーしました