[JavaScript] 自作Z80コアでSHARP PC-G850Vポケコンエミュレーターを実装。Grant Searle BASIC・割り込み駆動ACIA・5x7フォントレンダリングの全記録
はじめに
自作Z80コアはこれまでMSX1、SG-1000と2つのゲーム機に載せ替えてきた。いずれもVDP(画面表示チップ)を持つシステムであり、Z80コアのバス分離設計がそのまま活きる構成だった。
[JavaScript] 自作Z80コアでSG-1000エミュレーターを実装。BIOS不要・SN76489音源・TMS9918A共有設計の全記録
SG-1000エミュレーターの実装記録。Z80コアとTMS9918Aの共有設計によるコスト削減。
lain-lab.com3台目のターゲットとして選んだのは、ゲーム機ではなくポケットコンピューター。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起動
コード入力(BASIC)
実行(RUN)
動画(GIF)
システム構成の概要
本エミュレーターは「PC-G850Vの筐体を模したUI」の中で「Grant Searle SimpleZ80互換のハードウェア」を動かす構成になっている。
| 項目 | 仕様 |
|---|---|
| CPU | Z80(自作コア、ZEXDOC全67項目パス済み) |
| ROM | Grant Searle 8K BASIC($0000-$1FFF、Intel HEXからロード) |
| RAM | 56KB($2000-$FFFF) |
| シリアルI/O | MC6850 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マイコン界隈のデファクトスタンダードとなっている。
Grant's 7-chip Z80 computer
A fully operational Z80 computer running BASIC can't get simpler than this!
searle.x10host.com1-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 による最小限のスタートアップコードが配置されている。
- スタックポインタの初期化
- IM1割り込みモードの設定
- MC6850 ACIAの初期化
- $0100〜のBASICインタプリタ本体へジャンプ
- 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) | 代替CLS | Grant 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=""">"</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="<"><</button>
<button class="key sym" data-key=">">></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/音源 | メモリマップ | 固有実装量 |
|---|---|---|---|---|---|
| 1 | MSX1 | TMS9918A | AY-3-8910 + SCC | スロット+MegaROM | ~800行 |
| 2 | SG-1000 | TMS9918A | SN76489 | フラットROM | ~400行 |
| 3 | PC-G850V | Canvas 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() メソッドが両方のユースケースに対応できている点で、コア設計の汎用性が証明された。
到達点
| 項目 | ステータス |
|---|---|
| CPU | Z80A(ZEXDOC全67項目パス、IM1割り込み) |
| ROM | Grant Searle 8K BASIC(Microsoft BASIC 4.7b互換) |
| RAM | 56KB(56,958 bytes free) |
| LCD | 144×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's 7-chip Z80 computer
A fully operational Z80 computer running BASIC can't get simpler than this!
searle.x10host.comGrant Searle氏の8K BASICは、Nascom版Microsoft BASIC 4.7をベースに、ハードウェア固有コードを除去してシリアルI/Oのみで動作するよう改変されたものである。配列、文字列操作、数学関数(SIN, COS, SQR, LOG等)、HEX変換まで備えたフル機能のBASICが、わずか8KBのROMに収まっている。
Z80コアを一度しっかり作り、バスをコールバックで分離するだけで、ゲーム機からポケコンまで何でも動く。MSXの800行、SG-1000の400行、そしてPC-G850Vの300行。載せ替えるたびに実装量が減っていくのは、共有コア資産が正しく機能している証拠である。