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.

Nächster Beitrag Vorheriger Beitrag