連載案内: Rust + WebAssembly で書いている x86 エミュレータ rustx86 の開発記。これまでの連載は ネットワーク編 #1・ディスク編 #1・JIT編 #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つ炙り出してくれた。

入口は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 stosb と mov [es:di],al の嵐だ) が全部この分岐を踏み、JIT の高速路から弾かれる。ロードマップの 6a にはこう書いてある — フレームバッファは装置の窓ではなくただのRAM。書き込みフックなし、表示側が毎フレーム全読みして描く。 320×200×8bpp は 64KB なので、dirty 追跡の賢さは要らない。

だから 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
}

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);
}

正直に書くと、この修正の後も 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】


コメント