Videoausgabe und Interrupts
Im letzten Teil konnte man die Inhalte, die auf dem Bildschirm dargestellt werden sollten im Speicher erkennen. Jetzt wird es Zeit für den Textmodus eine Bildschirmausgabe zu programmieren.
Implementierung des virtuellen Bildschirms
Für die Bildschirmausgabe wird der HTML5 Canvas in der CPUView Klasse definiert.
<canvas id="screen" width="448" height="284" style="border:1px solid #000000; width: 448px; height: 284px;"></canvas>
Damit hat man einen 448x284 Pixel großen Bereich. Dieser kann mittels der width und height Style-Attribute skaliert werden. z.B. würde width: 884px; height: 568 die Anzeigegröße, bei gleichbleibender Pixelzahl, in beide Richtungen verdoppeln.
Die Klasse Video.ts ist für die eigentliche Ausgabe verantwortlich.
Initial wird der übergebene Canvas mit der hellblauen Farbe gefüllt und das glätten der Pixel bei Skalierung abgeschaltet.
Als nächstes werden die 320*200 Pixel in denen der Text dargestellt wird (25 Zeilen mit je 40 Zeichen, 8x8 Punkte pro Zeichen) in einer Variable als ImageData gespeichert.
Damit kann man den kompletten Inhalt als Array modifizieren und später in einem Rutsch zurückliefern. Dies geht viel schneller, als für jeden Pixel einzeln zu zeichnen. Evtl. kann man den Code später noch optimieren, indem die Ergebnisse von https://jsperf.com/canvas-pixel-manipulation verwendet.
In der update Methode findet die eigentliche Befüllung der ImageData Variable statt.
Hier wird bei gesetzten Pixeln die hellblaue Farbe (#0088ff) und bei nicht gesetztem Pixeln die dunkelblaue (#0000aa) Farbe verwendet.
Um die zu setzenden Pixel zu ermitteln wird für jeden Bildschirmcode, der ab der Speicherstelle 0x0400 angelegt ist, werden die zugehörigen Bytes aus dem Character ROM gelesen.
Dazu ein Beispiel:
- Der Bildschirmcode 0x01 steht für das Zeichen "A".
- Jedes Zeichen benötigt 8 Bytes im Character ROM.
Bildschirmcode * 8 (bzw. 3 mal bitweise nach links schieben) ist die Startposition des Zeichens im ROM.
- Jedes Byte ist eine Zeile des Zeichens (8 Zeilen mit je 8 Punkten)
- Ein gesetztes Bit ist ein Punkt auf dem Bildschirm.
Daher beginnt das Zeichen A bei der Position 0x08 und endet bei 0x0f. Wenn man das Character ROM mit einem Hex-Editor betrachtet, findet man dort die Bytes 0x18 0x3c 0x66 0x7e 0x66 0x66 0x66 0x00.
08: 18 -> 00011000 -> ##
09: 3c -> 00111100 -> ####
0a: 66 -> 01100110 -> ## ##
0b: 7e -> 01111110 -> ######
0c: 66 -> 01100110 -> ## ##
0d: 66 -> 01100110 -> ## ##
0e: 66 -> 01100110 -> ## ##
0f: 00 -> 00000000 ->
Von oben nach unten sind die einzelnen Bytes des Zeichens dargestellt. Rechts daneben habe ich die Bit-Repräsentation geschrieben und daneben nur die gesetzten Bits als Raute dargestellt.
Die Update Methode durchläuft dies für alle Zeichen. Da die ImageData Variable ein fortlaufendes Array ist und die Zeilen und Spalten der einzelnen Punkte nicht direkt angegeben werden können, muss die Position in der Variable berechnet werden.
let pixelPosX = (screenPositionX << 3) + currentCol;
let pixelPosY = (screenPositionY << 3) + currentRow;
// fill linear pixel buffer
let posInBuffer = ((pixelPosY * 320) + pixelPosX) << 2;
Die Position wird mal 4 gerechnet, da pro Punkt die einzelnen Farbkomponenten nacheinander abgelegt werden. D.h. je ein Byte für Rot, Grün, Blau und Transparenz. (RGBA).
Interrupt
Um den blinkenden Cursor zu erhalten, wird in regelmäßigen Abständen der normale Programmablauf (warten auf Tastatureingaben) unterbrochen und eine spezielle Routine abgearbeitet. Dazu wird nach jedem Bildaufbau (ca. 50 mal pro Sekunde beim PAL Standard) ein Interrupt erzeugt. Dieser sorgt dafür, das die CPU die Arbeit unterbricht und zu der Routine springt, deren Adresse per Definition an der Speicherstelle $fffe / $ffff zu finden ist.
Im Kernal ROM ist das der Wert $FF48. Am Ende der Routine sieht man den indirekten Sprung, dessen Ziel in $0314 steht.
IRQ-Einsprung
FF48 48 PHA Akku auf Stapel retten
FF49 8A TXA X nach Akku
FF4A 48 PHA X-Register retten
FF4B 98 TYA Y nach Akku
FF4C 48 PHA Y-Register retten
FF4D BA TSX Stapelzeiger als Zähler in X
FF4E BD 04 01 LDA $0104,X Break-Flag vom Stapel holen
FF51 29 10 AND #$10 und testen
FF53 F0 03 BEQ $FF58 nicht gesetzt
FF55 6C 16 03 JMP ($0316) BREAK - Routine
FF58 6C 14 03 JMP ($0314) Interrupt - Routine
Durch das Betriebssystem steht an der genanten Speicherstelle 31 ea, daher geht es ab $ea31 weiter:
Interrupt-Routine
EA31 20 EA FF JSR $FFEA Stop-Taste, Zeit erhöhen
EA34 A5 CC LDA $CC Blink-Flag für Cursor
EA36 D0 29 BNE $EA61 nicht blinkend, dann weiter
EA38 C6 CD DEC $CD Blinkzähler erniedrigen
EA3A D0 25 BNE $EA61 nicht Null, dann weiter
EA3C A9 14 LDA #$14 Blinkzähler wieder auf 20
setzen
EA3E 85 CD STA $CD und speichern
EA40 A4 D3 LDY $D3 Cursorspalte
EA42 46 CF LSR $CF Blinkschalter eins dann C=1
EA44 AE 87 02 LDX $0287 Farbe unter Cursor
EA47 B1 D1 LDA ($D1),Y Zeichen-Kode holen
EA49 B0 11 BCS $EA5C Blinkschalter war ein, dann
weiter
EA4B E6 CF INC $CF Blinkschalter ein
EA4D 85 CE STA $CE Zeichen unter Cursor merken
EA4F 20 24 EA JSR $EA24 Zeiger in Farb-RAM berechnen
EA52 B1 F3 LDA ($F3),Y Farb-Code holen
EA54 8D 87 02 STA $0287 und merken
EA57 AE 86 02 LDX $0286 Farb-Code unter Cursor
EA5A A5 CE LDA $CE Zeichen unter Cursor holen
EA5C 49 80 EOR #$80 RVS-Bit umdrehen
EA5E 20 1C EA JSR $EA1C Zeichen und Farbe setzen
[...]
EA7E AD 0D DC LDA $DC0D IRQ-Flag löschen
EA81 68 PLA Accu aus dem Stapel holen
EA82 A8 TAY und in Y-Register schieben
EA83 68 PLA Accu aus dem Stapel holen
EA84 AA TAX und in X-Register schieben
EA85 68 PLA und Rückkehr vom Interrupt
EA86 40 RTI
Ab Adresse $EA34 sieht man, das sich die Routine um den blinkenden Cursor kümmert.
Um eine regelmäßige Unterbrechung zu erzeugen, wird von der Ausführungsschleife (emulationLoop in index.js) ein Flag in der CPU-Klasse nach ca. 20000 CPU Zyklen gesetzt. Die jeweils benötigte Anzahl von Zyklen wird aus der Opcode Tabelle gelesen und in der CPU step Methode aufsummiert.
// run main loop
let targetCycleCount = this.cpu.getCycleCount() + 20000;
this.cpu.setInterruptOccured(); // interrupt every frame
Um auch die neueren ROM Revisionen nutzen zu können, wird das Ende eines Bildaufbaus im Speicher des Zeilenzählers simuliert, indem dieser für eine kurze Zeit (VBLANK Phase) auf 0 gesetzt wird.
let blankingPeriodLow = targetCycleCount - 100;
if ((this.cpu.getCycleCount() >= blankingPeriodLow) && (this.cpu.getCycleCount() <= targetCycleCount)) {
this.memory.writeByte(0xd012, 0);
} else {
this.memory.writeByte(0xd012, 1);
}
Woher kommt die Zahl 20000?
Kurzfassung Es ist ein Näherungswert, der die Anzahl von Zyklen darstellt, die verfügbar sind während der Bildschirm einmal aktualisiert wird. Bei PAL Systemen sind dies 50 Aktualisierungen pro Sekunde (50 Hz).
Langfassung Das Thema wird in Hardware Basics Part 1 - Tick Tock, know your clock und VIC-II for Beginners Part 3 - Beyond the Screen: Rasters and Cycles gut erklärt.
Ergebnis
Durch die Einführung des regelmäßigen Interrupt Aufrufs wird nun der blinkende Cursor dargestellt. Den Quellcode gibt es wieder in meinem Gitlab-Projekt TSC64Emu.
Eine ausführbare Version befindet sich hier.
Ausblick
Es fehlt noch eine Tastatur um mit dem Emulator zu interagieren.