Rust + WebAssembly の x86 エミュレータで FreeDOS を起動して、ブラウザで DOS ゲームを動かした

16bit

Rustでエミュレータを書く 連載 第4回
1. RustとWASMでゲームボーイエミュレータを作る
2. x86エミュレータを自作する — 正解を知っているCPUに答え合わせさせる
3. 8259 PIC と BIOS を書いて、16bit UNIX (ELKS) をログインまで動かした
4. FreeDOS を起動して、ブラウザで DOS ゲームを動かした ← 今ここ

前回は自作の x86 エミュレータで
16bit UNIX (ELKS) をブラウザで動かすところまで書いた。今回はその続きで、
FreeDOS が A:\> まで起動し、DOSのゲームがブラウザで遊べるようになった。

読みどころは犯人探しである。「ゲームの画面が下半分出ない」という症状を追いかけて、
BIOSを疑い、ゲームを疑い、最後に自分が書いて自分で使っていなかった仕組み
行き着くまでの話。

コードは GitHub にある: https://github.com/yoshiharu-ishii/rustx86


動いているもの

FreeDOSのdir

左のメニューで FreeDOS を選ぶと、勝手に A:\> まで進む。ELKSと違って
画面もキーもディスクも全部BIOS経由なので、こちらはBIOS層の検証になる。

ELIZAとの会話

1966年の対話プログラム ELIZA。画面・キーボード・ファイル読み出しが全部
通らないとこの受け答えは出ない
ので、総合試験としてよくできている。


まず32bit命令が要った

FreeDOS の起動途中で、こんな命令に当たって止まった。

53                  PUSH BX
66 9C               PUSHFD                  ← 32bitオペランドサイズ・プレフィクス
66 58               POP EAX
66 0D 00 00 04 00   OR EAX, 0x00040000      ← bit18 = AC フラグ

386と486を見分ける定番のCPU判定である。カーネルは8086ビルドなのに、
起動時に走るユーティリティが386命令を使っていた。

ここで良い発見があった。0x66 はプロテクトモードと独立している。
「既定の幅をひっくり返す」だけのプレフィクスなので、GDTも特権リングも
実装せずにこれだけ先に動かせる。「32bit化」は命令セットとモードという
2つの別の話
だった、というのが今回一番の収穫かもしれない。

そして器を作り直す必要が無かった。連載の最初からレジスタを u32
持っていて、16bit命令が上位を保存する形にしてあったからである。

fn set_reg16(&mut self, r: usize, v: u16) {
    self.regs[r] = (self.regs[r] & 0xFFFF_0000) | v as u32;
}

/// 32bit書き込みは**上位も含めて全部置き換える** (16bitのような保存はしない)
fn set_reg32(&mut self, r: usize, v: u32) {
    self.regs[r] = v;
}

実機の EAX と AX の関係がそのまま出ている。幅は型ではなく実行時のフラグで選ぶ。

/// 幅を実行時に選ぶALU。呼ぶ側に同じ形のコードを2本書かせないための入口
pub fn alu_w(c: &mut Cpu, op: u8, a: u32, b: u32, wide: bool) -> u32 {
    if wide { alu32(c, op, a, b) } else { alu16(c, op, a as u16, b as u16) as u32 }
}

幅対応を忘れた命令は、静かに壊れる

実装したのに動かない。しかもまったく関係ない番地で「0F 4C (CMOVL) が未実装」
という顔をして倒れる。Pentium Pro の命令が8086向けビルドに出てくるはずがない。

原因は TEST EAX, imm32 の幅対応漏れだった。即値を16bitで読むのでIPが2バイトずれ
以後はデータを命令として食い始めていたのである。

見つけたのは、こういう仕掛けを入れたからだった。

if d.opsize32 {
    m.prefixed_ops.insert(op);
}

0x66 が付いて実行されたオペコードを控えておく。止まったときに出す。

--- 0x66 を付けて実行されたオペコード ---
  0x02 0x0d 0x21 0x33 0x39 0x50 0x53 0x58 0x5b 0x8b 0x9c 0x9d 0xa9 0xb9
                                                                  ^^^^ 未対応

一発だった。panicも出さずに静かに壊れるものは、記録を取るしかない。


BIOSの穴が4つ出てきた

前回、アーキテクチャの文書にこう書いた。

BIOSの死んだ役割はサービス提供者、生きている役割は立ち上げとマシンの説明書

今回踏んだ4つは、全部後者だった。書いた本人が同じ穴に4回落ちたことになる。

1. BIOSがPITを設定していなかった

実BIOSはPOST時にタイマを 18.2 Hz に設定する。ELKSは自分で設定するので
気づかなかったが、DOSはBIOSが設定済みであることを前提にしている。

fn install_pit_defaults(&mut self) {
    let pit = &mut self.devices.pit;
    pit.write_control(0x36); // カウンタ0、LoHi、モード3 (方形波)
    pit.write_counter(0, 0x00);
    pit.write_counter(0, 0x00); // 分周値0 = 65536 → 18.2 Hz
    pit.write_control(0x54); // カウンタ1、LoOnly、モード2 (レート生成)
    pit.write_counter(1, 18); // DRAMリフレッシュ
}

時計が進まないだけでなく、「HLTして待つ」形の待ち合わせが永久に目を覚まさない

2. IRQ0/1 がマスクされたままだった

割り込みを全部閉じて渡していた。キーが押されたことを知る手段が割り込みしか
無いのだから、閉じたまま渡す意味が無い。

3. INT 09h がスキャンコードを捨てていた

INT 16h が8042を直接読む作りだったので、INT 16h しか使わない
プログラムだけが動いていた。BIOSデータエリアを直接覗くプログラム
(DOSには多い) からは、キーが永久に来ないように見える。

実BIOSと同じ 8042 → IRQ1 → INT 09h → BIOSの待ち行列 → INT 16h の経路に直した。

4. INT 16h が待つときにIFを開けていなかった

これが決定打だった。

0x00 | 0x10 => match self.take_key() {
    Some(v) => self.cpu.regs[cpu::AX] = v as u32,
    None => {
        // **待つなら割り込みを開ける。**
        //
        // `INT` 命令はIFを落とす (x86の仕様)。落としたまま待つと
        // キーボード割り込みが永久に来ず、待っているものが二度と届かない。
        // 実BIOSの待ちループに `STI` があるのはこのためで、ここでも同じことをする
        self.cpu.set_flag(cpu::IF, true);
        return false;
    }
},

INT 命令はIFを落とす。x86の仕様どおりの正しい挙動である。だから
待つ側が自分で開けなければ、待っているものは二度と来ない。
実BIOSの待ちループに STI があるのは飾りではなかった。

診断: 割り込みの持ち主を出す

途中で「BIOSを直したのに効かない」に何度もぶつかったので、こういうものを出すようにした。

--- 割り込みベクタの持ち主: 0x08=0070:000f(ゲスト) 0x09=0070:0016(ゲスト)
                            0x10=f000:0010(BIOS)   0x16=f000:0016(BIOS) ---

FreeDOSは INT 08h と 09h を自分のものにしている。 つまりFreeDOS下では
こちらのキーボード割り込み処理は一度も走らない。直しても効かないわけである。


そしてゲーム

FreeDOS公式のリポジトリにフリーソフトウェアのゲームが並んでいるので、
起動フロッピーに載せた。ELIZA、四目並べ、スネーク、ハングマン。

動かしてすぐ、スネーク (zmiy) が変だった。画面の下半分が出てこない。

zmiyのハードウェアスクロール

ここから犯人探しが始まる。

疑い1: ゲームがVGAを要求している?

説明書にこうあった。

Zmiy simulates graphics using a 80×50 text mode on VGA.
If running on a non-VGA card, it will use the standard 80×25 mode.

80×50 で描こうとしている。こちらは MC6845 と 0xB8000 を持つCGA相当なので、
VGAではないと答えているつもりだった。答え方が悪いのだろうか。

INT 10h AH=1A (表示装置の種別) で AL=0 を返し、さらに BL=0x02 (カラーCGA) も
明示した。変わらない。

疑い2: 行数の答えが悪い?

BIOSデータエリアの 0x484 (行数-1) を24にしてある。試しに23にしてみた。
何も変わらない。 そもそも読んでいなかった。

疑い3: CRTCを直接叩いている?

トレースを入れた。

CRTC R0d <- 0x00
CRTC R0c <- 0x00

R0C/R0D — 表示開始アドレス。幾何レジスタは触っていない。

疑い4: 機種を名乗っていない?

実機のBIOS ROMには末尾に日付と機種コードが焼かれていて、ソフトはそこを読んで
機械の世代を決める。こちらは 0 のままで、「どの機種でもない機械」に見えていた。
F000:FFFE = 0xFE (PC/XT) を置いた。やはり変わらない。

ソースを読んだ

zmiy はBSDライセンスで、上流の配布物にソースが入っていた。推測をやめて読んだ。

VGA判定はこうだった。

/* returns 0 if no VGA has been detected, non-zero otherwise */
int detectvga(void) {
  union REGS regs;
  regs.x.ax = 0x1A00;
  int86(0x10, &regs, &regs);
  if (regs.h.al == 0x1A) return(1);  /* VGA supported */
  return(0); /* else it's not vga */
}

こちらの返事で正しく非VGAになる。疑い1は無実だった。

そして描画がこうなっていた。

static void refreshscreen(struct gamestruct *game) {
  /* if screen's eight less than 50, then use scrolling when needed */
  if (game->screenheight < 50) {
    int newscrolloff = game->snakeposy[0] - 12;   /* 蛇に追従 */
    if (newscrolloff < 0) newscrolloff = 0;
    else if (newscrolloff > 25) newscrolloff = 25;
    if (newscrolloff != game->scrolloffset) {
      game->scrolloffset = newscrolloff;
      scrollscreen((newscrolloff << 6) + (newscrolloff << 4));  /* = 行 × 80 */
    }
  }
  ...

50行の盤面を常にVRAMへ描き、見える25行の窓をCRTCの開始位置で蛇に追従させる。

ゲームは何も間違っていなかった。CGA機での正しい遊び方だった。

犯人

画面を読み出すこの関数である。

/// テキスト画面の生バイト列
pub fn text_vram(&self) -> &[u8] {
    let b = bus::VRAM_TEXT_BASE as usize;
    &self.mem[b..b + bus::TEXT_LEN]      // ← 常に窓の先頭から4000バイト
}

テキストVRAMの窓は 32KB ある。80×25の1画面はそのうち4000バイトでしかない。
どこから表示するかを決めるのはCRTCのレジスタで、ここを動かすとメモリを
1バイトも書き換えずに画面がスクロールする

そして自分の crtc.rs には、こう書いてあった。

/// 表示を開始するVRAM上の位置 (文字単位)。
/// ここを動かすとメモリを触らずにスクロールできる (ハードウェアスクロール)
pub fn start_offset(&self) -> u16 {

実装して、説明も書いて、描く側が読んでいなかった。

直した後。

pub fn text_vram(&self) -> &[u8] {
    let win = (bus::VRAM_TEXT_END - bus::VRAM_TEXT_BASE + 1) as usize;
    // 開始位置は文字単位。1文字2バイトなので倍にする
    let start = (self.devices.crtc.start_offset() as usize * bus::TEXT_CELL) % win;
    let b = bus::VRAM_TEXT_BASE as usize + start;
    if start + bus::TEXT_LEN <= win {
        &self.mem[b..b + bus::TEXT_LEN]
    } else {
        let base = bus::VRAM_TEXT_BASE as usize;
        &self.mem[base..base + bus::TEXT_LEN]
    }
}

蛇を下へ走らせると、窓が段階的に降りていく。

0行目 → 5 → 9 → 13 → 17 → 22 → 25 → 25 (上限)

25で止まるのは、盤面50行から画面25行を引いた値。ソースの
if (newscrolloff > 25) newscrolloff = 25; と一致する。テストに固定した。

/// **ハードウェアスクロール。**
///
/// テキストVRAMの窓は32KBあり、80x25の1画面はそのうち4000バイトでしかない。
/// **ここを動かすとメモリを1バイトも書き換えずに画面がスクロールする**。
#[test]
fn crtc_start_address_scrolls_the_screen_without_touching_memory() {
    let mut m = machine();
    put(&mut m, 0, 0, b'A', 0x07);
    let below = VRAM_TEXT_BASE + (TEXT_COLS * 25 * 2) as u32;
    m.write8(below, b'B');
    m.write8(below + 1, 0x07);
    assert_eq!(m.text_screen_string().chars().next(), Some('A'));

    // CRTCの開始位置を1画面ぶん進める。**メモリは触らない**
    m.io_write8(0x3D4, 0x0C);
    m.io_write8(0x3D5, (2000u16 >> 8) as u8);
    m.io_write8(0x3D4, 0x0D);
    m.io_write8(0x3D5, 2000u16 as u8);

    assert_eq!(m.text_screen_string().chars().next(), Some('B'));
}

80年代の機械が、あの遅いCPUで滑らかにスクロールできた理由がこれである。
CPUは何もしない。レジスタを2本書き換えるだけで画面が動く。

測り方も間違っていた

途中「駒が1つも無い」「ゲームが止まっている」と判断した場面があったが、
どちらも測り方のミスだった。背景色でしか駒を探しておらず、実際は
毎ステップ画面が変化していた。

相手を疑う前に、自分の物差しを疑うべきだった。 今回いちばんの教訓かもしれない。


動かないものも1本入れてある

ハングマンは動かない。CGAのグラフィックスモード (0x04) を要求するためで、
この機械にはテキスト画面しか無い。

それでも配布スクリプトにあえて入れてある。動かないものが1つ要るからで、
「描いていない」のか「描く先が無い」のかを区別できるようにしてある。

// AH=00: ビデオモード設定。**テキスト以外は実現できない**。
//
// 落とさずに受けるのは、モードを試して戻すプログラムがあるためだが、
// 黙って無視すると「描いた先が存在しない」ことに誰も気づけない。
// 要求されたモードを控えて、後から言えるようにしておく
0x00 => {
    self.video_modes.insert(self.cpu.regs[cpu::AX] as u8 & 0x7F);
}

止まったときにこう出る。

--- テキスト以外のビデオモードを要求された: 0x04 (グラフィックスは未実装 → Tier 6) ---

白い画面の理由が分かるというだけのことだが、これがあるかないかで
調べる時間がまるで違う。


ディスクイメージは配らず、作り方を配る

ELKSもFreeDOSもゲームも自由ソフトウェアなので、再配布自体は許されている。
それでもリポジトリには置かず、取得と組み立てのスクリプトを置いた。

tools/fetch-images.sh

理由は2つある。GPLのバイナリ配布にはソースの提供義務が付くこと。
そしてもう1つが実際に踏んだ失敗で、手で組み立てたイメージは再現できない
手元でゲームを載せたイメージと、他の人が持っているイメージの中身が食い違って
「アプリが入っていない」が起きた。リポジトリに置いても、置き忘れれば同じことが起きる。

スクリプトなら中身が一意に決まり、どこから来たものかもコードに書いてある。


今の状態

$ cargo test --workspace --exclude rustx86-cosim
   81 passed; 0 failed

$ cargo test -p rustx86-cosim
   15 passed; 0 failed     (Unicorn との突き合わせ)

到達点はこうなった。

ELKS 0.9.1 ログイン → シェル。tetris invaders ttypong sl matrix
FreeDOS 1.4 A:\> まで自動。eliza zmiy row4t
ハングマン CGAグラフィックス要求 → Tier 6

次は32bitプロテクトモード、そしてLinux。ネットワークはそのあとに回した。
タブの中で完結するものを先にやると決めたためで、踏み台の用意が要るのは
ネットワークだけだからである。


今回いちばん効いたのは、推測をやめてソースを読んだところだった。
4つ疑って4つとも無実で、答えは自分のリポジトリの中にあった。
しかも「ここを動かすとスクロールできる」と自分で書いてあった。

エミュレータを書いていると、こういうことが定期的に起きる。
動かないものを前にしたとき、相手より先に自分を疑うのが、たぶん一番の近道である。

コメント

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