[JavaScript] 自作Z80コアでSHARP PC-G850Vポケコンエミュレーターを実装。Grant Searle BASIC・割り込み駆動ACIA・5x7フォントレンダリングの全記録

[JavaScript] 自作Z80コアでSHARP PC-G850Vポケコンエミュレーターを実装。Grant Searle BASIC・割り込み駆動ACIA・5x7フォントレンダリングの全記録

はじめに

自作Z80コアはこれまでMSX1、SG-1000と2つのゲーム機に載せ替えてきた。いずれもVDP(画面表示チップ)を持つシステムであり、Z80コアのバス分離設計がそのまま活きる構成だった。

3台目のターゲットとして選んだのは、ゲーム機ではなくポケットコンピューター。SHARPの PC-G850V は、Z80 CPUを搭載した学校教育向けのプログラマブル電卓であり、BASIC/Cの開発環境を内蔵した「手のひらのコンピューター」だった。

ただし、今回のエミュレーターでは実機のファームウェアROMは使用しない。代わりに Grant Searle氏の8K BASIC(Microsoft BASIC 4.7b互換)をROMとして搭載し、シリアルターミナル方式のBASIC環境をPC-G850Vの筐体UIで動かすという構成を採っている。

この記事では、Grant Searle SimpleZ80のメモリ/I/Oマップ上でZ80コアを動かし、MC6850 ACIAのエミュレーション、割り込み駆動のシリアル入力、144×48ピクセルLCDへのソフトウェアフォントレンダリング、そしてレスポンシブ対応のソフトキーボードUIまでを記録する。

スクリーンショット

起動画面

起動画面

ENTERでBASIC起動

ENTERでBASIC起動

コード入力(BASIC)

BASIC コード入力

実行(RUN)

実行(RUN)

動画(GIF)

起動画面

システム構成の概要

本エミュレーターは「PC-G850Vの筐体を模したUI」の中で「Grant Searle SimpleZ80互換のハードウェア」を動かす構成になっている。

項目仕様
CPUZ80(自作コア、ZEXDOC全67項目パス済み)
ROMGrant Searle 8K BASIC($0000-$1FFF、Intel HEXからロード)
RAM56KB($2000-$FFFF)
シリアルI/OMC6850 ACIA互換(ポート$80:ステータス、$81:データ)
割り込みIM1($0038)、シリアル受信時に発火
表示144×48ピクセル モノクロLCD(Canvas 2D)
フォント5×7ピクセル ビットマップ(ASCII 0x20〜0x7E、95文字)
表示領域24文字 × 6行
入力物理キーボード + タッチ対応ソフトキーボード

実機のPC-G850VはSED1560系のLCDコントローラーやI/Oチップを持つが、これらはエミュレートしていない。「Z80 + シリアルターミナル」という最小構成のコンピューターを、ポケコンの見た目で包んだ形である。


1. Grant Searle SimpleZ80とは

Grant Searle氏が設計した「7チップで動くZ80コンピューター」は、自作Z80マイコン界隈のデファクトスタンダードとなっている。

1-1. メモリマップ

\$0000 - \$1FFF : ROM 8KB(BASIC インタプリタ)
\$2000 - \$FFFF : RAM 56KB(ユーザー領域)

1-2. I/Oマップ

$80 : MC6850 ACIA ステータスレジスタ(Read)/ コントロールレジスタ(Write)
$81 : MC6850 ACIA データレジスタ(Read/Write)

実機では $80-$BF の範囲が部分デコードでACIAに割り当てられるが、エミュレーターでは $80 と $81 だけを処理すれば十分である。

1-3. 起動シーケンス

ROMの先頭($0000)には intmini.asm による最小限のスタートアップコードが配置されている。

  1. スタックポインタの初期化
  2. IM1割り込みモードの設定
  3. MC6850 ACIAの初期化
  4. $0100〜のBASICインタプリタ本体へジャンプ
  5. BASICが Z80 BASIC Ver 4.7b バナーを表示し、MEMORY TOP? プロンプトへ

2. メモリとバスの接続

Z80コアのバス接続は4つのコールバックだけで完結する。MSXやSG-1000と同一のインターフェース。

const memory = new Uint8Array(0x10000);

const z80 = new Z80({
  readByte: (addr) => memory[addr & 0xffff],
  writeByte: (addr, val) => {
    // $0000-$1FFF は ROM 領域(書き込み禁止)
    if (addr >= 0x2000) {
      memory[addr & 0xffff] = val & 0xff;
    }
  },
  inPort: (port) => { /* ACIA読み出し */ },
  outPort: (port, val) => { /* ACIA書き込み・LCD出力 */ },
});

MSXのスロット機構(200行)やSG-1000のフラットROM(10行)と比較しても、Grant Searle構成は最もシンプルなメモリマップである。ROM保護の if (addr >= 0x2000) が唯一のロジック。


3. MC6850 ACIA エミュレーション

MC6850はモトローラ製の非同期通信インターフェースアダプターで、Grant Searleの設計ではZ80とシリアルターミナルの間に位置する。エミュレーターではこれを「キーバッファとLCD出力」に置き換えている。

3-1. ステータスレジスタ(ポート$80 Read)

Bit 0 : RDRF(受信データレジスタフル)— キーバッファにデータがあれば1
Bit 1 : TDRE(送信データレジスタ空)— 常に1(LCD出力は即座に完了する)
inPort: (port) => {
  const p = port & 0xff;
  if (p === 0x80) {
    let status = 0x02; // TDRE: 送信常にready
    if (keyBuffer.length > 0) status |= 0x01; // RDRF
    return status;
  }
  if (p === 0x81) {
    return keyBuffer.length > 0 ? keyBuffer.shift() : 0x00;
  }
  return 0xff;
},

3-2. データレジスタ(ポート$81)

  • Read: キーバッファの先頭を取り出す(FIFO)
  • Write: ASCIIコードをLCDに1文字出力
outPort: (port, val) => {
  const p = port & 0xff;
  if (p === 0x81) {
    putChar(val & 0x7f); // 7bitマスク(パリティビット除去)
  }
},

4. 割り込み駆動のシリアル入力

4-1. ハマった問題:ENTERを押しても反応しない

初期実装では、キーバッファにデータを入れるだけのポーリング方式で入力を処理していた。

// 初期の(動かない)実装
function pushKey(asciiCode) {
  keyBuffer.push(asciiCode);
}

BASICは起動し、バナーも表示されたが、キー入力に一切反応しなかった。

4-2. 原因

Grant Searle BASICのROMには intmini.asm による割り込みサービスルーチン(ISR)が組み込まれている。ISRは$0038(IM1ベクタ)に配置され、ACIAからデータを読み取ってRAM上のリングバッファに格納する。BASICインタプリタはこのRAMバッファから文字を読む。

つまり、ACIAの受信割り込みが発火しない限り、ISRが動かず、バッファは空のままとなる。

キー押下 → keyBufferにpush → ??? → BASICが読む

                               ↑ ここで割り込みが必要

4-3. 修正:1行の追加

function pushKey(asciiCode) {
  keyBuffer.push(asciiCode);
  z80.interrupt(0xFF); // IM1割り込みを発火 → $0038のISRがACIAを読む
}

z80.interrupt() を呼ぶことで、Z80にIM1割り込みを通知する。Z80コアは現在の命令完了後に$0038へジャンプし、ISRがポート$81を読み出してRAMバッファに格納し、BASICがそこから文字を取得する。

この1行の追加だけで、MEMORY TOP?プロンプトへのENTER応答、プログラム入力、LIST、RUNがすべて動作するようになった。


5. 144×48 LCD への描画

5-1. VRAMとフォントの設計

PC-G850Vの液晶は144×48ピクセルのモノクロドットマトリクスである。エミュレーターでは1ピクセル = 1バイト(0/1)のフレームバッファを用意し、Canvas 2Dで描画する。

const VRAM_WIDTH = 144;
const VRAM_HEIGHT = 48;
const COLS = 24;  // 144 / 6 = 24文字
const ROWS = 6;   // 48 / 8 = 6行
const vram = new Uint8Array(VRAM_WIDTH * VRAM_HEIGHT);

5-2. 5×7 ビットマップフォント(全ASCII対応)

フォントデータはASCII 0x20(スペース)から0x7E(チルダ)までの95文字を、1文字あたり5バイトの連続配列として格納している。各バイトは1列分のピクセルデータで、Bit 0が最上段、Bit 6が最下段に対応する。

// 95文字 × 5バイト = 475バイトのフォントテーブル
const FONT = new Uint8Array([
  // 0x20 ' '
  0x00, 0x00, 0x00, 0x00, 0x00,
  // 0x21 '!'
  0x00, 0x00, 0x5F, 0x00, 0x00,
  // ... (全95文字)
  // 0x61 'a' — 小文字も完備
  0x20, 0x54, 0x54, 0x54, 0x78,
  // ...
]);

描画関数は文字コードからインデックスを計算し、5×7のドットパターンをVRAMに展開する。6列目は文字間スペースとして自動クリアされる。

function drawCharToVRAM(charCode, x, y) {
  const idx = charCode - 0x20;
  const startX = x * 6;
  const startY = y * 8;

  for (let col = 0; col < 5; col++) {
    const byteData = (idx >= 0 && idx < 95) ? FONT[idx * 5 + col] : 0;
    for (let row = 0; row < 7; row++) {
      const px = startX + col;
      const py = startY + row;
      if (px < VRAM_WIDTH && py < VRAM_HEIGHT) {
        vram[py * VRAM_WIDTH + px] = (byteData >> row) & 1;
      }
    }
  }
}

5-3. 制御コードの処理

BASICが出力する制御コードをLCD操作に変換する。

コード意味処理
0x0D (CR)復帰カーソルを行頭へ移動
0x0A (LF)改行カーソルを次行へ。最終行ならスクロール
0x08 (BS)後退カーソルを1文字戻し、その位置をスペースで上書き
0x0C (FF)改頁画面全消去(CLS)
0x1A (SUB)代替CLSGrant Searle BASICが使用するCLS制御コード
0x07 (BEL)ベル無視
function putChar(code) {
  switch (code) {
    case 0x0D: cursorX = 0; return;
    case 0x0A:
      cursorX = 0;
      if (cursorY < ROWS - 1) cursorY++;
      else scrollVRAM();
      return;
    case 0x08:
      if (cursorX > 0) cursorX--;
      drawCharToVRAM(0x20, cursorX, cursorY);
      return;
    case 0x0C:
    case 0x1A:
      clearScreen();
      return;
  }

  if (code >= 0x20 && code <= 0x7E) {
    drawCharToVRAM(code, cursorX, cursorY);
    cursorX++;
    if (cursorX >= COLS) {
      cursorX = 0;
      if (cursorY < ROWS - 1) cursorY++;
      else scrollVRAM();
    }
  }
}

5-4. スクロール処理

VRAMの内容を8ピクセル(1行分)上方向にコピーし、最終行をゼロクリアする。

function scrollVRAM() {
  const linePixels = VRAM_WIDTH * 8;
  vram.copyWithin(0, linePixels, VRAM_WIDTH * VRAM_HEIGHT);
  vram.fill(0, VRAM_WIDTH * VRAM_HEIGHT - linePixels);
}

6. Intel HEX ローダー

Grant Searle BASICのROMは .hex(Intel HEX形式)で配布されている。起動時にこれをfetchで読み込み、メモリに展開する。

6-1. Intel HEXフォーマット

:10 0000 00 F3C30001FFFFFFFFFFE5C5D5...  XX
 │  │     │  │                           │
 │  │     │  │                           └─ チェックサム
 │  │     │  └─ データバイト列(byteCount個)
 │  │     └─ レコードタイプ(00=データ、01=EOF)
 │  └─ 開始アドレス(16bit)
 └─ バイト数
async function loadROM() {
  const response = await fetch('./rom.hex');
  const hexText = await response.text();

  for (const line of hexText.split(/\r?\n/)) {
    if (!line.startsWith(':')) continue;
    const byteCount = parseInt(line.substring(1, 3), 16);
    const addr = parseInt(line.substring(3, 7), 16);
    const recordType = parseInt(line.substring(7, 9), 16);

    if (recordType === 0) { // データレコード
      for (let i = 0; i < byteCount; i++) {
        memory[addr + i] = parseInt(line.substring(9 + i * 2, 11 + i * 2), 16);
      }
    }
  }

  z80.reset();
  cpuLoop();
  renderLCD();
}

7. ソフトキーボード UI

7-1. レスポンシブ筐体デザイン

CSSコンテナクエリ(container-type: inline-size)を使い、ウィンドウ幅に応じてキーサイズ・フォントサイズが自動スケーリングする。600px以下ではQWERTYセクションとテンキーセクションが縦並びに切り替わる。

.pokecon-container {
  container-type: inline-size;
}

.key {
  height: clamp(26px, 5.5cqw, 36px);
  font-size: clamp(0.5rem, 1.4cqw, 0.75rem);
}

@media (max-width: 600px) {
  .main-keys { flex-direction: column; }
}

7-2. 記号キー行の追加

BASICプログラミングに必須な記号(" ( ) $ : < > = # @ ^ &)は、実機のポケコンではSHIFT+キーの組み合わせで入力する。エミュレーターでは物理キーボードからは直接打てるが、ソフトキーからは打てない問題があったため、専用の記号キー行を追加した。

<div class="key-row sym-row">
  <button class="key sym" data-key="&quot;">"</button>
  <button class="key sym" data-key="(">(</button>
  <button class="key sym" data-key=")">)</button>
  <button class="key sym" data-key="$">$</button>
  <button class="key sym" data-key=":">:</button>
  <button class="key sym" data-key="<">&lt;</button>
  <button class="key sym" data-key=">">&gt;</button>
  <button class="key sym" data-key="=">=</button>
  <!-- ... -->
</div>

キーイベントハンドラは data-key 属性が1文字ならそのASCIIコードを送信する共通処理のため、HTML側にボタンを追加するだけでJS側の変更は不要。

7-3. 入力処理の統一

物理キーボードとソフトキーの両方が同じ pushKey(asciiCode) 関数に合流し、キーバッファへの追加と割り込み発火を一元化している。

物理キーボード ─── keydown ───┐
                               ├─ pushKey(code) → keyBuffer → interrupt → ISR
ソフトキーボード ── click ────┘

8. Z80コアの載せ替え実績まとめ

#システムVDP/画面PSG/音源メモリマップ固有実装量
1MSX1TMS9918AAY-3-8910 + SCCスロット+MegaROM~800行
2SG-1000TMS9918ASN76489フラットROM~400行
3PC-G850VCanvas LCDなしROM+RAM~300行

PC-G850VはVDPもPSGも持たず、ACIAのステータス/データレジスタ2個だけのI/Oマップなので、Z80コアを除いたシステム固有の実装が最も少ない。バスコールバック4関数の中身がそれぞれ数行で済んでいる。


9. デバッグ事例:割り込みの欠如

9-1. 症状

ROM読み込み・BASIC起動バナー表示までは正常に動作した。MEMORY TOP? プロンプトが表示され、カーソルが点滅している。しかし、ENTERキーを押しても一切反応しない。コンソールエラーなし。

9-2. 調査

キーバッファへの追加は確認できた。ACIAステータスレジスタのRDRFビットも正しく1になっていた。しかしBASICがポート$81を読みに来ない。

Grant Searleのサイトを確認すると「Full interrupt driven input with buffer」の記述があり、ISRがACIAを読んでRAMバッファに格納する構造であることが判明した。

9-3. 修正

pushKey 関数に z80.interrupt(0xFF) を1行追加。これによりZ80にIM1割り込みが通知され、$0038のISRがACIAからデータを読み取り、RAMリングバッファ経由でBASICに渡る。

SG-1000のVBlank割り込みとは異なり、こちらは「イベント駆動」の割り込み — キーが押された瞬間だけ発火する。Z80コアの interrupt() メソッドが両方のユースケースに対応できている点で、コア設計の汎用性が証明された。


到達点

項目ステータス
CPUZ80A(ZEXDOC全67項目パス、IM1割り込み)
ROMGrant Searle 8K BASIC(Microsoft BASIC 4.7b互換)
RAM56KB(56,958 bytes free)
LCD144×48 モノクロ(24文字×6行、5x7フォント、スクロール、CLS)
入力物理キーボード + ソフトキーボード(QWERTY+テンキー+記号行)
BASIC機能PRINT, LIST, RUN, CLS, IF/THEN, FOR/NEXT, GOTO, GOSUB, 文字列操作, 数学関数
UIレスポンシブ筐体デザイン、カーソル点滅、コンテナクエリ対応

注意事項

本エミュレーターはPC-G850Vの実機ファームウェアを使用していない。外観はPC-G850Vを模しているが、内部はGrant Searle SimpleZ80互換のシリアルターミナルBASICマシンである。実機のPCAL, GRAPHIC命令, C言語環境, カセットI/Oなどは再現されていない。

Grant Searle 8K BASIC について

Grant Searle氏の8K BASICは、Nascom版Microsoft BASIC 4.7をベースに、ハードウェア固有コードを除去してシリアルI/Oのみで動作するよう改変されたものである。配列、文字列操作、数学関数(SIN, COS, SQR, LOG等)、HEX/BIN/BIN変換まで備えたフル機能のBASICが、わずか8KBのROMに収まっている。


Z80コアを一度しっかり作り、バスをコールバックで分離するだけで、ゲーム機からポケコンまで何でも動く。MSXの800行、SG-1000の400行、そしてPC-G850Vの300行。載せ替えるたびに実装量が減っていくのは、共有コア資産が正しく機能している証拠である。