Reverse Engineering von Prince of Persia auf dem Apple II

Ich habe versucht, den Originalcode von Prince of Persia auf dem Apple II teilweise zu reverse engineeren - was ziemlich interessant war!

Reverse Engineering von Prince of Persia auf dem Apple II
Foto von Museums Victoria / Unsplash

Ich habe versucht, einige Teile des Codes von Prince of Persia zu "Reverse Engineeren", was ziemlich interessant war!
Ich kam dazu, tiefer in die Materie einzutauchen, als ich mit Golo Roden von the native web in Kontakt war, welcher mich fragte, ob ich nicht interessiert wäre diesen Code in einem Live-Stream auf YouTube zu präsentieren .

Es war eine faszinierende Erfahrung, in die Welt von 6502 Assembler, diesem mythischen Spiel und der Spieleentwicklung in den 80er Jahren einzutauchen. Das Lesen des Quellecodes ließ mich verstehen, wie viel Arbeit und Wissen darin steckte, und es zeigt mir, wie dankbar wir für die Dinge sein können, die wir heute für selbstverständlich halten.

Danksagung

Zunächst einmal möchte ich Jordan Mechner für dieses erstaunliche Stück Geschichte danken !
Vielen Dank an Roger Wagner, der mir mit seinem Buch „ Assembly Lines: The Complete Book“ geholfen hat, den 6502 zu verstehen. 
Vielen Dank auch an Fabien Sanglard für die wirklich gute Übersicht über den PoP-Code, die mir sehr dabei geholfen hat, mich in den Code einzuarbeiten.

Die 6502-CPU des Apple II

Der Apple II wird durch die MOS 6502-CPU angetrieben, die auch auf dem Commodore C64 und dem Nintendo NES zu finden ist (MOS wurde später im Jahr 1976 von Commodore gekauft).

Der Erfolg des 6502 beruhte vor allem auf seinem niedrigen Preis. Der 6502 kostete damals nur 25 US-Dollar . Zum Vergleich: Intels 8080 kostete rund 250 US-Dollar.

Ein Grund dafür, dass der 6502 so günstig war, war, dass er nur 3510 Transistoren benötigte. Das ist weniger als die Hälfte der Anzahl, die beim Hauptkonkurrenten Zilog Z80 verwendet wurde. Dieser hatte 8500 für eine vergleichbare (oder zum teil schlechtere) Leistung.

Eine Möglichkeit, wie der 6502 die Transistoranzahl niedrig halten konnte, war der sparsame Einsatz von Registern. Er hatte nur einen 8-Bit-Akkumulator, zwei 8-Bit-Indexregister, einen 8-Bit-Stackpointer und einen 16-Bit-Programmzähler.

Verglichen mit dem Konkurrenzprodukt Zilog Z80 mit 8x 8-Bit-Registern und 4x 16-Bit-Registern, sind dass bedeutend weniger.

Warum hatte der 6502 mit so wenigen Registern eine so gute Leistung erbracht?

Er konnte schnell auf die ersten 256 Bytes des Speichers zugreifen (Page Zero genannt, da das erste Byte der Zwei-Byte-Adresse Null ist), und zwar mit Anweisungen, die nur ein Byte zur Angabe des Speicherplatzes benötigten.

Der gleiche Trick ließ sich auch auf den Stack anwenden, der sich direkt über Page Zero befindet, auf der sogenannten Page One welche von Adresse $100 bis $1FFreicht. Das bedeutet, dass der Stackpointer ebenfalls nur ein einziges Byte benötigt, um diesen Teil des Speichers anzusprechen.

Der 6502 hatte nur 56 Befehle, was ebenfalls weniger ist als bei seinen Konkurrenten. Aber er verwendete einen Trick, um das auszugleichen, indem er verschiedene Adressierungsmodi für mehrere Befehle verwendete, was die Möglichkeiten mit solch wenigen Registern erweiterte.

🙃
Funfact: 6502 ist der Prozessor des Roboters Bender in der Serie Futurama

Weitere Informationen zum 6502 findest du in diesem tollen Blog-Beitrag .

Verbindung zum Speicher

Der Apple II verfügte standardmäßig über einen maximalen Arbeitsspeicher von 64 KB RAM. Da die CPU nur maximal 64 KB adressieren kann (Maximal 2 byte für die Adressierung waren möglich). Daher wurde zum Beispiel bei den Nachfolgern //e und //gs ein weiterer Trick verwendet. Indem RAM-Bänke sozusagen getauscht werden konnten, um so bis zu 128 KB RAM adressieren zu können.

Der Speicherbereich von $D000bis $DFFF war für Soft Switches reserviert, die bei gesetztem Wert das Umschalten zwischen RAM-Bänken und unterschiedlicher Hardware ermöglichten. Da dieser Bereich durch die Soft Switches reserviert war, konnte das fehlende 4k RAM an dieser Stelle mit dem Speicher im Bereich $D000 bis $DFFF ( Bank 0 und Bank 1) umgeschaltet werden.

Das gleiche Umschalten war mit dem Zusatzspeicher (Auxiliary Memory) und mit dem ROM möglich.
Aber es war nicht nur ein einfaches Umschalten zwischen Speichern. Die Soft-Switches konnten so eingestellt werden, dass sie das Lesen und/oder Schreiben in einem bestimmten Teil des Speichers ermöglichten.

Auch auf weitere Hardware wurde über den Speicher zugegriffen, da diese direkt an den Speicher gemappt war. So gibt es beispielsweise verschiedene Videomodi mit unterschiedlichen Auflösungen und Farben, die ebenfalls über Softswitches eingestellt werden konnten.

Übersicht über die Speicherzuordnung

APPLE //e RAM - (Speicherzuweisung basierend auf EQ.S und Erkenntnissen im Code)

Beginnen wir nun mit dem Code von Prince of Persia.

Wie die Dateien strukturiert sind

Die Game Engine besteht aus zahlreichen .SDateien. Früher war dies eine Möglichkeit, Code zu strukturieren und natürlich war die Dateigröße solcher Dateien aufgrund des verfügbaren RAM irgendwie begrenzt.

Alle diese .SDateien enthalten eine ORGAnweisung, die dem Assembler mitteilt, wo die Dateien in den RAM geladen werden.

Es gibt einige Dateien, die Referenzen in Form von Konstanten enthalten. Diese werden verwendet, um auf verschiedene Teile des Programms oder der Daten zu verweisen.
Die meisten Dateien enthalten am Anfang solche Referenzdateien:

 lst
 put EQ
 lst
 put GAMEEQ
 lst
 put SOUNDNAMES
 lst off

Sie enthalten auch zahlreiche jmpAnweisungen am Anfang direkt nach der ORGAnweisung. Und das ist wichtig für die Referenzierung mit mehreren Dateien. Sie sind die direkte Referenz auf einige labelim Code direkt danach.

Ein JuMP Befehl benötigt 3 Bytes im Speicher, was bedeutet, dass der nächste Befehl jmpdirekt danach im Speicher liegt.

Ein Beispiel wie es im Speicher aussieht (links) im Vergleich zum ASM Code selbst (rechts):

                1           org $400
0400: 4C 0C 04	2           jmp GR
0403: 4C 0E 04	3           jmp DRAWALL
0406: 4C 10 04	4           jmp CONTRL
0409: 4C 12 04	5           jmp VERSION
040C: EA        6   GR      NOP
040D: EA        7           NOP
040E: EA        8   DRAWALL NOP
040F: EA        9           NOP
0410: EA        10  CONTRL  NOP
0411: EA        11          NOP
0412: EA        12  VERSION	NOP
0413: EA        11          NOP
0414: EA        11          RTS

Die ORGDirektive weist den Assembler an, am Speicherort $400 zu beginnen. In diesem Code gibt es mehrere, labelsz. B. GRauf Zeile $040C.
Die JuMP- Direktive steht für 4Cin HEX im Speicher, gefolgt von der Zeilennummer (2 Bytes - Little-Endian-Reihenfolge), auf die sie sich bezieht. Beispielsweise der erste jmpbezieht sich auf GRin Zeile $040C.

Der Vorteil solcher Sprungblöcke am Anfang ist, dass sie immer die gleiche Adresse im Code behalten, auch wenn es Änderungen in dieser Datei gibt. So wird es einfach, von einer Datei zu einer Funktion zu springen, die in einer anderen Datei programmiert wurde, indem einfach auf eine bestimmte feste Stelle im Speicher verwiesen wird.

Und dabei kommen Dateien wie die EQ.S ins Spiel. Hier ein Teil davon:

    grafix = $400

    dum grafix          => Instructs the Assembler to start with label values at a given index of $400

    gr ds 3             => Therefore the label `gr` starts at $400 and reservse 3 bytes of memory
    drawall ds 3        => then `drawall` must be at $403 and reservse 3 bytes of memory
    controller ds 3     => $406 + 3 bytes
    ds 3                => $409 + 3 bytes
    saveblue ds 3       => $40C + 3 bytes

Wenn man dann mit gr auf einen Sprung zu einer anderen Datei verweist, weiß der Assembler, anhand einer Datei wie EQ.S, dass er zu Speicheradresse  $400 springen muss. Und auf Adresse $400folgt dann ein weiterer Sprung, der uns schließlich zur Funktion dieser bestimmten Datei bringt.
Es gibt auch jmp's, die direkt auf einen bestimmten Ort verweisen, ohne dass Labels verwendet werden.
Wenn man also wissen möchte, wohin ein Sprung verweist, muss man möglicherweise mehrere Dateien überprüfen. Heutzutage ist es vergleichsweise einfach möglich eine Funktion zu finden, mithilfe von Suchfunktionen über mehrere Dateien hinweg. Beispielsweise in einem Editor/IDE oder mit Tools wie grepim Terminal.

Boot - Erster Bootvorgang von der Diskette

Der Apple II beginnt die ersten Sektoren von der Diskette in den Registerbereich $800 des RAMs zu laden.
Dieser Teil befindet sich in der Datei BOOT.S

**APPLE II RAM**
----------------------
$FFFF - |
        |
        |
        |
        |
        |
        |
        |
        |
        |
        |
        |
        |------------
        | BOOT.S - 
$0800 - |------------
        |
        |
        |
        |
        |
$0000 - |

Dies wird durch die Ausgabe von $01 am Anfang des Programms ( Zeile:21 ) erreicht. Dadurch wird die Apple-Firmware veranlasst, automatisch einen Sektor von track:0 in den RAM bei Adresse $800 zu laden sowie von dort aus die Ausführung zu beginnen.

Beim Booten werden in Zeile 31 zunächst einige wichtige Softswitches gesetzt (z. B. Display leeren, auf Textmodus umschalten, RWTS16 einrichten etc.). Anschließend wird der rest von BOOT.S und RWTS18 in den RAM geladen. Hierzu wird die ROM-Routine RWTS16 des Apple II verwendet, um mithilfe der Skewing-Tabelle ( Zeile 79 ) eine Reihe von Sektoren in den RAM zu laden.
Die Skewing-Tabelle wird verwendet, um mit einer Interleaving genannten Technik auf die sequentiellen Sektoren der Diskette zuzugreifen.

Aus Wikipedia zum Thema Interleaving:
Bei Blockspeichergeräten wie Diskettenlaufwerken ist Interleaving eine Technik, die verwendet wird, um die Leistung eines langsamen Systems zu verbessern, indem sequenziell abgerufene Daten in nicht sequenzielle Blöcke, normalerweise Sektoren, gesetzt werden.

Interleaving wurde verwendet, um die Sektoren möglichst effizient anzuordnen, sodass nach dem Lesen eines Sektors Zeit für die Verarbeitung bleibt und dann der nächste Sektor in der Sequenz zum Lesen bereit steht.

Hardwareprüfung

Prince of Persia benötigt zum Ausführen die vollen 128 KB RAM. Daher prüft es gleich zu Beginn, ob der Zusatzspeicher vorhanden ist, was beim Apple //e und //gs normalerweise der Fall ist (z. B. Zeile:113 und Zeile:167 ). Dies wird durch Schreiben, Lesen und Vergleichen der Daten aus dem Haupt- und Zusatzspeicher erreicht. Wenn der Zusatzspeicher nicht vorhanden ist, wird der Datenvergleich fehlgeschlagen.

Lese-/Schreibroutine RWTS18

Beim Start lädt BOOT.S außerdem eine benutzerdefinierte Lese-/Schreibroutine für die Diskette namens RWTS18 . Diese Routine wird später verwendet, um die anderen Sektoren von der Diskette zu laden. Die RWTS18- Routine verwendet größere Sektoren/Tracks als die ursprüngliche Routine des Apple II, was dabei hilft, eine größere Menge an Daten zu verwalten und zu speichern, da bei diesem Ansatz weniger ungenutzter Speicherplatz verschwendet wird.

💡
Einen Blogbeitrag mit weiteren Einzelheiten zu dieser Routine findest du im Blogeintrag von Fabien Sanglard, mit einer wirklich guten Übersicht über den PoP-Code .

Nachdem die Routine eingerichtet ist, lädt sie mehrere Daten in den RAM und springt schließlich aus BOOT.S zur RAM-Adresse $EE00Zeile: 138 ).
Diese Adresse befindet sich am Anfang der Datei HIRES.S.
HIRES.S springt direkt am Anfang zu Adresse $F880Zeile: 13 ), was wiederum der Anfang von MASTER.S ist.

Übersicht über den RAM in dieser Phase

Master - Hauptroutine, die den Spielstart steuert

Wir befinden uns nun am Anfang von MASTER.S und springen dort direkt an den Anfang der Firstboot- Routine bei Zeile:186 .

Von da an wird es kompliziert, da das Programm eine Menge notwendiger Daten für das spätere Spiel lädt. Und es ist nicht einfach zu verstehen, was genau geladen wird, ohne die Diskettensektoren tiefgründiger zu analysieren. Aber das ist nicht trivial, da später klarer wird, was einige dieser Daten sein werden.

Es werden also mehrere Datenfragmente geladen:

  • Hires Tabellen/Code von $E000 bis $ED00 von der Diskette über RWTS18 ( Zeile: 186 ) in den RAM
  • Permanenter Code und Daten von der Diskette über RWTS18 (lädt so viel von Stufe 3, wie es behalten kann)
    • in AUX RAM von $0400 bis $2700, also GRAFIX.S & TOPCTRL.S (und vielleicht mehr?) ( MASTER:line:207 )
    • einige Teile in den Haupt-RAM ( MASTER:line:1095 )
    • Lädt AUX-LC-Material (Tracks 19-21 und 34) (einschließlich Musikset 1) in den Haupt-RAM und verschiebt es dann in den AUX-RAM ( MISC:Zeile:152 ).

Nach Abschluss des Ladevorgangs wird das Laufwerk ausgeschaltet. Außerdem prüft das Programm, ob es sich bei der Hardware um eine //gs handelt, welcher spezielle Anzeigeeinstellungen für Super-Hi-Res-Bilder hat ( MASTER:line:215 & GRAFIX:line:2015 ).

Das Spiel hat zwei Modi. Einer ist der Builder, der die Level des Spiels erstellt, und der andere Modus ist das Spiel selbst. Einige Teile des Programms sind nur im Speicher vorhanden, wenn einer dieser Modi aktiv ist.

Anschließend initialisiert das Spiel einige Teile der Hardware, wie zum Beispiel die Zentrierung des Joysticks ( GRAFIX:line:1232 ).
Und einige Basisvariablen (die im Grunde Speicheradressen sind) ( TOPCTRL:line:223 ).

Anschließend wird der Sound aktiviert (auch wenn noch kein Ton abgespielt wird) und wir springen direkt in den sogenannten Attract-Modus.

Das Spiel beginnt endlich

Der Attract-Modus ist ein selbstspielender Start des Spiels, der als Demonstrationsmodus fungiert und dazu dient, potentielle Spieler anzulocken - Daher der Name "Attract Mode" ( MASTER:line:687 ).

Zunächst wird die Musik eingeschaltet, indem in einem Register, oberhalb von $300 , eine 1 gespeichert wird. Allerdings wird noch keine Musik abgespielt, da dieses Register keiner Hardware zugeordnet ist. Es könnte jedoch interessant sein zu wissen, dass Register im Bereich dieser Adresse später etwas mit Musik oder einer Konfiguration zu tun haben könnten.

Das Spiel richtet dann den Bildschirm durch Einstellen einiger Soft-Switches für double hi-res ein ( UNPACK:line:627 ) und lädt viele Daten für die erste Phase des Spiels ( MASTER:line:1168 ) sowie Musikdaten ( MASTER:line:1180 ).

Anschließend werden die Daten in den Bereich verschoben, in welchem der double hi-res Bildschirm gemappt ist, um ihn schließlich als folgendes Bild anzuzeigen:

Herausgeberhinweis „Broderbund Software präsentiert“

Außerdem wird die Musik nun auf eine unterbrechbare Weise abgespielt, was interessant ist!
Es werden die zuvor geladenen Flags gelesen, die die Musik/den Ton aktiviert haben ( MASTER:line:1389 )

  • Während der Musikwiedergabe wird geprüft, ob Tasten gedrückt wurden ( MASTER:line:1389 )
    • Esc zum Anhalten, Strg-S zum Ausschalten des Tons (wodurch auch die vorherigen Flags für die Musik umgeschaltet werden)
    • Return A = ASCII-Wert (FF für Button)
    • Es verwendet eine Soundbibliothek namens Music System II von Kyle Freeman zum Abspielen von Sounds ( MSYS II )

Danach wird der Bildschirm wieder geleert ( MASTER:line:764 ) 

💡
Wenn der Spieler eine Taste drückt, springen wir aus dem Attrack-Modus (der im Wesentlichen aus dem Prolog und eine Demoszene besteht) in das Gameplay.

Jetzt wird als Autor „A Game by Jordan Mechner“ angezeigt und im Grunde passiert dasselbe wie im vorherigen Schritt:

Autorhinweis „A Game by Jordan Mechner“

Das gleiche passiert erneut für das nächste Bild:

Zeigt den Titelbildschirm „Prince of Persia“

Nach der Einblendung des Abspanns beginnt die Wiedergabe des Prolog 1

Das Laden von hochauflösenden Bildern erfolgt anscheinend basierend auf den Speicherorten ( MASTER:line:100 ), dasselbe gilt für Musik ( MASTER:line:116 ) .

Es beginnt mit der Prinzessinnen-Szene:

Prinzessinnen-Szene - Zimmer der Prinzessin: Wesir startet Sanduhr

Anschließend startet der Prolog Teil 2, der ähnlich funktioniert wie Prolog 1. Folgend wird der Titel noch einmal angezeigt, allerdings ohne zusätzliche Musik.

Abschließend ist eine Demo des Spiels zu sehen. Und all das wird nun immer wieder wiederholt, bis eine Taste gedrückt wird, um die Demo zu unterbrechen sowie das Spiel zu starten.

Meiner Meinung nach ist es ab diesem Punkt nicht mehr besonders spannend tiefer zu graben, da das Spiel ab jetzt immer neue Daten lädt, auf Benutzereingaben reagiert usw.. Das kann man natürlich machen, wenn man mehr verstehen will, es wird aber ein sehr zeitintensives Unterfangen. Man muss wissen, dass die Entwicklung dieses Spiels rund 5 Jahre gedauert hat! – was im Grunde bedeutet, dass das Reverse Engineering deutlich länger dauern kann.

Code

Weiterführende Software

Zugehörige Dokumente

Videos zum Thema