Un Hello World en assembleur sur Master System

Pour débuter la programmation sur Master System, il est essentiel d'apprendre les bases afin de comprendre le fonctionnement de la console et de s'initier aux joies de l'assembleur. Ecrire un programme "Hello World" est un excellent exercice, mais cette étape s'avère loin d'être aussi aisée qu'avec des langages de programmation de haut niveau. Si afficher un simple message est une affaire de quelques secondes en Python ou en Java, c'est une toute autre paire de manches en assembleur puisque nous allons devoir définir l'entête de la ROM, écrire une séquence de démarrage, effacer la mémoire système, initialiser le VDP, puis charger toutes les données en VRAM avant d'être en mesure d'afficher à l'écran un joli "Hello World".

Bien sûr, ce tutoriel vous expliquera, étape par étape, toute la procédure afin d'écrire votre premier programme en assembleur pour la Master System. Chaque instruction du Z80 a été commentée en anglais, d'une part parce que je commente systématiquement mon code en anglais et de l'autre, parce que j'estime qu'un code source se doit d'être commenté en anglais.

Avant de commencer, vous devez diposer d'un environnement de développement prêt à l'emploi. Si ce n'est pas encore le cas, je vous invite à consulter mon tutoriel Développer sur Master System en assembleur avant de continuer votre lecture. Vous êtes naturellement libre d'utiliser un assembleur autre que WLA DX, mais les directives seront certainement différentes et il faudra réaliser vous-même l'entête à inclure dans la ROM. À vous de voir !

Il va de soit que ce tutoriel est destiné à des personnes utilisant un environnement Unix et ayant un bon niveau de maîtrise en programmation afin de comprendre l'essentiel de chaque étape. Je vous recommande de lire un peu de documentation sur le Z80 avant de commencer.

Préparation des fichiers

Pour réaliser votre premier programme sur Master System, vous devez télécharger l'archive hello-world-template-sms.zip qui se compose des dossiers src, res et des fichiers src/main.asm, res/font_tileset.bin. Il y a également un Makefile vous permettant de rapidement générer et tester la ROM. Par ailleurs, si vous ne disposez pas de Blastem et que vous souhaitez un autre émulateur, remplacez la valeur de EMULATOR se trouvant à la ligne 28 du Makefile, mais ce n'est nécéssaire que si vous souhaitez utiliser la commande make run pour tester directement la ROM.

Après avoir téléchargé l'archive, décompressez-là dans votre répertoire utilisateur ou bien à l'endroit que vous désirez. Si vous utilisez l'éditeur Visual Studio Code, je vous suggère d'installer l'extension WLA-DX for VSCode qui vous permettra de récupérer la couleur syntaxique lorsque vous écrirez du code assembleur.

Mappage de la mémoire

Avant de commencer à écrire du code assembleur, il est impératif de mapper la mémoire ROM. Cette première étape se veut relativement simple grâce à WLA DX qui nous mâche une bonne partie du travail. Ouvrez le fichier "src/main.asm" et commencez par ajouter ces quelques lignes :

.memorymap
    defaultslot 0
    slotsize $8000
    slot 0 $0000
.endme

Analysons ensemble le contenu de ce bloc de code. Ainsi, defaultslot permet de spécifier le slot par défaut, slotsize défini la taille d'un slot, soit la taille totale de la ROM étant par défaut de 32KB sur Master System, ce qui correspond à $8000 en hexadécimal. Pour finir, slot indique où le programme commence pour chaque slot. Ici nous avons donc déclaré defaultslot à 0, slotsize à $8000 et slot à 0 $0000 puisque nous utiliserons un seul slot de 32KB et que notre programme commencera à l'adresse $0000.

Après vient le mappage des banques de données ou bank mapping :

.rombankmap
    bankstotal 1
    banksize $8000
    banks 1
.endro

Ici, nous avons défini bankstotal à 1 puisque nous allons utiliser une seule banque qui contiendra à la fois le code et les ressources. Dans le cas où nous voudrions réaliser un jeu sur une cartouche plus généreuse en espace mémoire, par exemple 128KB, nous aurions été contraints de diviser la ROM en 8 banques d'une taille de 16KB ! L'avantage d'utiliser une seule banque de 32KB, c'est que notre code pourra utiliser plus de 16KB, mais dans les faits, il est rare d'avoir besoin d'autant d'espace mémoire pour du code assembleur.

Pour finir avec le mappage, incluez cette ligne afin d'indiquer sur quelle banque notre code se trouve :

.bank 0 slot 0 

Naturellement, nous avons indiqué ici que nous voulons utiliser la banque 0 située sur le slot 0.

L'entête

Tout programme sur Master System dispose d'un entête ou plus communément appelé "header". L'entête se compose de différentes informations concernant la ROM dont le fameux "checksum" de SEGA.

Il faut savoir qu'à l'époque, c'était SEGA qui se chargeait d'inclure l'entête dans la ROM au moment de produires les cartouches. De nos jours, SEGA n'assurant plus la production des cartouches pour la Master System, c'est à nous qu'incombe cette tâche. Sans entête, impossible d'exécuter notre programme sur une console occidentale et il serait vraiment dommage de ne pas savourer le fruit de notre labeur.

Heureusement WLA DX embarque le support du tag SDSC, ce qui nous simplifie grandement le travail. Ajoutez cette seule ligne afin d'intégrer l'entête à la ROM :

.sdsctag 0.01, "Hello World!", "Simple Hello World! for SEGA Master System ", "Kentosama"

Ici rien de compliqué étant donné que le premier paramètre indique la version de notre programme puis vient son nom, sa description et le nom de l'auteur. Prennez quelques secondes pour remplacer "Kentosama" par votre nom et les autres paramètres si vous le souhaitez.

Les adresses

Pour travailler plus facilement sur notre programme, il est utile de définir en amont les différentes adresses que nous allons utiliser fréquement :

.define VDP_CONTROL     $bf
.define VDP_DATA        $be
.define VRAM_ADDR       $4000
.define CRAM_ADDR       $c000
.define SYS_RAM         $c000

Le VDP_CONTROL ou $bf est une adresse vers le controleur I/O qui nous servira à indiquer au VDP où il doit lire/écrire les données. Le VDP_DATA est l'adresse du controleur I/O chargé de lire/écrire dans la VRAM et la CRAM. Pour le reste, il s'agit simplement des différentes adresses mémoires de la Master System : VRAM, CRAM et RAM.

La séquence de démarrage

Comme nous l'avions vu lorsque nous avons mappé la mémoire, le code démarre à l'adresse $0000. De ce fait, nous allons à cet endroit indiquer au Z80 ce qu'il doit faire au moment de démarrer notre programme :

.org $0000                              ; Program begin
    di                                  ; Disable interrupt
    im 1                                ; Set interrupt mode to 1
    ld sp, $dff0                        ; Stack pile start at $dff0
    jp Main                             ; Jump to the main routine

Nous sommes enfin rentrés dans le vif du sujet avec ce bout de code en assembleur qui s'occupe de désactiver les interruptions du Z80 et de définir le mode d'interruption sur 1. Par la suite, nous faisons pointer la pile (le registre sp) sur l'addresse $dff0 de la RAM, puis nous faisons un saut mémoire vers la routine Main.

Remarquez l'utilisation de la directive .org permettant d'indiquer à l'assembleur que nous voulons écrire du code à une adresse spécifique du programme.

Le gestionnaire de pause

La Master System dispose d'un système de pause différent des autres consoles. Que ce soit le premier ou le second modèle, le bouton pause n'est pas présent sur la manette, mais directement sur la console. En appuyant sur ce bouton, le programme est stoppé jusqu'à une seconde pression. Pour les jeux ayant besoin d'afficher un menu ou d'exécuter une action lorsque le joueur appuie sur le bouton, il est possible d'exécuter du code à l'adresse $0066 :

.org $0066                              ; Pause
    retn                                ; Return

Dans notre programme, nous avons indiqué l'adresse $0066 et nous y avons ajouté un simple retn comme l'indique explicitement SEGA dans la documentation officielle de la Master System. Quelle est l'utilité de retn ? Cet opcode permet d'acquitter une interruption non masquable.

La routine principale

La routine principale du programme va nous permettre d'effectuer plusieurs actions nécéssaires afin d'afficher le message "Hello World" à l'écran. Pour simplifier la lecture, j'ai volontairement séparé le code en sous-routines qu'il faut appeller grâce à l'opcode call :

Main:
    call SYS_ClearRAM                   ; Clear system RAM
    call VDP_Initialize                 ; Initialize VDP
    call VDP_loadFont                   ; Load font in VRAM
    call VDP_WriteMessage               ; Write message on screen
    call VDP_SetDisplayOn               ; Set display to on
-: jr -

À la fin de la routine se trouve une ligne étrange : -: jr -. Ne prennez pas peur, car il s'agit juste d'une boucle infinie que nous aurions pu écrire de manière plus explicite :

MainLoop: jr MainLoop

Remarquez bien l'opcode jr, car nous l'utiliserons très souvent dans notre programme. Il permet de faire un saut relatif en avant ou en arrière avec une plage allant de -127 à 128, soit 8 bits. Cette instruction est donc idéale pour les petits sauts comme les boucles.

Si vous souhaitez essayer votre programme dès à présent avec un émulateur comme Meka ou Blastem, commentez les différentes instructions et utilisez la commande make run.

Effacer la mémoire système

Si vous utilisez exclusivement des langages de haut niveau, vous n'avez certainement pas l'habitude d'effacer la mémoire du système avant son utilisation, mais puisque nous codons en assembleur et que nous sommes au coeur des entrailles de la Master System, il est primordiale de le faire.

Si vous vous demandez pourquoi, sachez que lorsque la console démarre, personne ne peut prédire ce que contient la RAM. Dans bien des cas, on y retrouve différentes données pouvant causer des bugs au moment de lire une variable qui n'aurait pas été initialisée au préalable.

Voici la routine à inclure dans le code pour effacer la RAM de la Master System :

SYS_ClearRAM:
    ld hl, SYS_RAM                      ; Load $c000 in hl
    ld de, $c000                        ; We start at $c000 
    ld bc, $1feb                        ; We want copy 8171 bytes
    ld (hl), l                          ; Set value to 0 (l => $00)
    ldir                                ; Copy 1 byte hl to de and decremet bc
    ret 

Avec ce bloc de code, les choses se compliquent un peu, car nous utilisons plusieurs registres du Z80 (hl, de et bc) ainsi que l'opcode ldir et ret. Prenons le temps de décortiquer cette routine, instruction par instruction.

Nous commençons par stocker l'adresse $c000 de la RAM dans les registres hl, car l'adresse est sur 16bit. Puis nous faisons de même avec les registres de tandis que nous stockons la valeur $1feb (~8KB) dans les registres bc. Pour gagner une instruction, nous stockons l, soit le poids faible de l'adresse $c000, dans le registre hl, c'est à dire $00.

Ceci étant fait, nous utilisons l'opcode ldir qui va nous faire économiser quelques lignes d'assembleur en copiant la valeur contenu dans les registres hl vers de et de décrémenter les registres bc jusqu'à 0. L'opcode ret retourne à la routine Main.

Définir l'adresse du VDP

Ecrire des routines en assembleur c'est bien, mais il très utile de s'aider de macros afin d'avoir un code plus flexible et d'éviter de nombreux allers-retours dans la mémoire. Puisque nous avons besoin de changer souvent l'adresse du controleur de port, il est judicieux de déclarer cette macro dans notre code :

.macro VDP_SetAddress args address
    ld hl, address                      ; Load address in hl
    ld c, VDP_CONTROL                   ; Load $bf in c
    di                                  ; Disable interrupt
    out (c), l                          ; Send l to $bf 
    out (c), h                          ; Send h to $bf
    ei                                  ; Enable interrupt 
.endm

Comme son nom l'indique, la macro VDP_SetAddress va s'occuper d'envoyer une adresse au VDP en passant par le controleur I/O situé à l'adresse bf. Nous chargons cette adresse 8bit dans le registre c puis nous désactivons les interruptions et nous faisons le transfère en deux temps.

Pourquoi en deux temps ? Parce que l'adresse peut potentiellement être sur 16bit et que les controleurs I/O du Z80 ne peuvent adresser plus de 8bit à la fois. Il faut donc deux instructions afin de transférer entièrement l'adresse. Bien évidement, si l'adresse était sur 8bit, nous pourrions faire appel à une seule instruction. Avant de finir la macro, il est important de réactiver les interruptions avec l'opcode ei.

Initialiser le VDP

À présent, il est temps d'initialiser le VDP de la Master System et pour se faire, nous allons utiliser un tableau ou plutôt une série de bytes à inclure à la fin du fichier main.asm :

VDP_REGISTER_DATA:
.db $04, $80, $80, $81, $ff, $82, $ff, $83, $ff, $84, $ff, $85, $fb, $86, $00, $87, $00, $88, $00, $89, $47, $8a  
VDP_REGISTER_END:

Cette série de bytes contient les valeurs et les adresses de chaque registre du VDP. Par exemple $04 désigne la valeur du registre 0 résidant à l'adresse à $80.

Pour exploiter ces données, nous allons utiliser cette routine :

VDP_Initialize:    
    ld hl, VDP_REGISTER_DATA            ; Load VDP_REGISTER_DATA address
    ld b, $16                           ; Write on 11 VDP registers (data with address)
    ld c, VDP_CONTROL                   ; Load VDP_CONTROL address
    otir                                ; Write on all VDP registers

Pour le moment nous n'avons pas besoin d'utiliser la macro VDP_setAddress qui n'est pas du tout adaptée pour ce cas de figure.

Ici, nous chargons l'adresse de VDP_REGISTER_DATA dans les registres hl puis nous définissons le registre b à $16 soit 22. Pourquoi 22 ? Parce que le VDP possède 11 registres et donc 11 valeurs à écrire.

Comme d'habitude, nous chargons l'adresse $bf dans le registre c, mais pour la première fois, nous utilisons l'opcode otir qui va s'occuper d'écrire automatiquement les valeurs dans chaque registre en incrémentant les registres hl et en décrémentant le registre b.

Le VDP possèdant une RAM de 16KB, nous devons également effacer cette mémoire :

    ; Clear VRAM
    VDP_SetAddress VRAM_ADDR            ; Use macro for set VRAM_ADDR to VDP_CONTROL
    ld b, $00                           ; Load zero value in a
    ld de, $4000                        ; Size of RAM (16384 bytes)
    ld c, VDP_DATA                      ; Load $bf in c
-: 
    ld a, b
    out (c), a                          ; Write data to VRAM
    dec de                              ; Decrement b
    ld a, d                             ; Load d in a
    or e                                ; Check if e equal 0
    jr nz, -                            ; Loop if b not equal 00

Cette fois, nous utilisons enfin la macro VDP_SetAddress ! En une seule ligne de code, nous avons défini l'adresse du controleur sur la VRAM, c'est-à-dire $4000, impressionnant n'est-ce pas ? Continuons en chargeant la valeur $00 dans le registre b puis $4000 (16KB) dans les registres de.

Il ne reste plus qu'à faire appel à une boucle afin d'effacer la VRAM en écrivant la valeur $00 à chaque adresse.

L'instruction jr nz, - permet de rester dans la boucle tant que les registres de sont supérieurs à 0.

La routine complète :

VDP_Initialize:
    ld hl, VDP_REGISTER_DATA            ; Load VDP_REGISTER_DATA address
    ld b, $16                           ; Write on 11 VDP registers (data with address)
    ld c, VDP_CONTROL                   ; Load VDP_CONTROL address
    otir                                ; Write on all VDP registers

    ; Clear VRAM
    VDP_SetAddress VRAM_ADDR            ; Use macro for set VRAM_ADDR to VDP_CONTROL
    ld b, $00                           ; Load zero value in a
    ld de, $4000                        ; Size of RAM (16384 bytes)
    ld c, VDP_DATA                      ; Load $bf in c
-: 
    ld a, b
    out (c), a                          ; Write data to VRAM
    dec de                              ; Decrement b
    ld a, d                             ; Load d in a
    or e                                ; Check if e equal 0
    jr nz, -                            ; Loop if b not equal 0
    ret

La palette

Pour charger une palette, la procédure est très simple puisqu'il suffit de définir l'adresse de la CRAM au VDP_CONTROL et transmettre les données sur 8bit au VDP_DATA :

VDP_LoadPalette:
    VDP_SetAddress CRAM_ADDR            ; Use macro for set CRAM_ADDR to VDP_CONTROL
    ld hl, PALETTE_DATA                 ; Load palette data
    ld b, $f                            ; Set counter to $f (16 colors)
    ld c, VDP_DATA                      ; Load VDP_DATA address
    otir                                ; Send data to VDP_DATA
    ret                                 ; Return to subroutine

Ici, c'est la même logique utilisée que pour la routine d'initialisation du VDP avec l'opcode otir.

Bien évidement, il nous faut inclure la palette à la fin du code :

PALETTE_DATA:
.db $00, $11, $12, $13, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00
PALETTE_DATA_END:

La font

Contrairement au micro 8bit de l'époque, la Master System est incapable d'écrire directement du texte à l'écran. Pour se faire, nous devons dessiner des tiles (carreaux) de 8x8 pixels stockés dans la VRAM. Nous allons donc charger un tileset en mémoire que nous pourrons utiliser afin d'écrire le message "Hello World".

Pour une raison pratique, j'ai préféré utiliser un fichier binaire plutôt qu'injecter les données dans le code, ce qui est peu visuel et encombrant. Il vous faudra donc télécharger le binaire à cette adresse et l'inclure au dossier res de votre programme.

Oui, mais comment ajouter un fichier binaire dans notre code ? WLA DX permet de le faire très simplement :

FONT_DATA:
.INCBIN "res/font_tileset.bin" FSIZE FONT_DATA_SIZE

Naturellement incluez ce bout de code à la du fichier main.asm afin de déclarer la font à charger et en parlant de chargement, voici la routine qui nous intéresse :

VDP_loadFont:                           ; Use macro for set VRAM_ADDR to VDP_CONTROL
    call VDP_LoadPalette                ; Load font palette
    VDP_SetAddress VRAM_ADDR            ; Set address in VRAM 
    ld hl, FONT_DATA                    ; Load tileset font data
    ld de, FONT_DATA_SIZE               ; Set size of tileset font data
    ld c, VDP_DATA
-:  
    ld a, (hl)                          ; Load byte in a
    out (c), a                          ; Send byte to VDP
    inc hl                              ; Incremet address
    dec de                              ; Decrement $be
    ld a, d                             ; Load $be in a
    or e                                ; Check if e equal 0
    jr nz, -                            ; Loop if not equal 0
    ret                                 ; Return to subroutine

À ce stade, je pense que je n'ai plus besoin de vous expliquer chaque instruction et opcode de cette routine, mais dans le doute, vous pouvez toujours lire les commentaires qui devraient être assez explicites même si en anglais.

Le message Hello World

Nous voilà au moment tant attendu de ce tutoriel : écrire "Hello World" à l'écran ! Mais avant toute chose, nous devons encore ajouter quelques lignes à la fin du fichier :

.asciitable
map " " to "~" = 0
.enda

MESSAGE_DATA:
.asc "HELLO SMS WORLD!!!"
.db $ff

Il s'agit uniquement de directives provenant de WLA DX permettant de définir la table des caractères ASCII et le message "HELLO SMS WORLD!!!". La directive .db suivie de la valeur $ff, soit 255 en décimal, indique la taille maximale du message en byte.

Voici la routine permettant d'écrire ou plutôt de dessiner le message à l'écran :

VDP_WriteMessage:

    VDP_SetAddress $3ace|VRAM_ADDR      ; Use macro to set $3ace (11101011001110) to VDP_CONTROL
    ld hl,MESSAGE_DATA                  ; Load MESSAGE_DATA in hl
    ld c, VDP_DATA                      ; Load VDP_DATA in c
    ld b, $ff
-:  
    ld a, (hl)                          ; Load contain of hl in a
    out (c), a                          ; Send a to VDP_DATA                    
    xor a                               ; Check if a equal 0 and store result in a
    out (c), a                          ; Send a to VDP_DATA
    inc hl                              ; Incremet hl
    dec b                               ; Decremet b
    cp b                                ; Compare b with a
    jr nz, -                            ; Loop if a not equal 0
    ret                                 ; Return to subroutine

La seule chose pertinente à expliquer ici est la valeur $3ace ou %11101011001110 en binaire. Cette dernière comporte l'adresse de la VRAM pointant vers la table des tiles, mais également la position en x et y. Ainsi, la valeur $3ace permet à notre message de s'afficher au centre de l'écran.

Activer l'affichage du VDP

Par defaut, l'affichage du VDP n'est pas activé et pour changer cet état, nous allons modifier le registre 1 avec cette petite routine :

VDP_SetDisplayOn:
    ld c, VDP_CONTROL                   ; Load $bf in c
    ld a, $40                           ; Load $40 in a 
    out (c), a                          ; Send a to VDP_CONTROL
    ld a, $81                           ; Load $81 (VDP Register 1) in a
    out (c), a                          ; Send $81 to VDP_CONTROL
    ret                                 ; Return to subroutine

C'est une routine très rudimentaire qui inscrit la valeur $40 à l'adresse $81 du VDP. J'ai volontairement attendu la fin de ce tutoriel pour vous la présenter, car en règle général, on initialise et on charge les données lorsque l'écran est éteint et non l'inverse.

Si vous n'avez rien oublié, la commande make run devrait générer la ROM helloworld.sms dans le dossier out et exécuter l'émulateur Blastem affichant un joli "Hello SMS World!!!".

Ce tutoriel touche à sa fin. Félications pour avoir écris votre premier programme en assembleur sur Master System ! Si vous avez aimez ce tutoriel ou si vous avez rencontré des difficultés, n'hésitez pas à laisser un message dans les commentaires.

Le code source ainsi que le fichier binaire sont disponibles en téléchargement sur GitHub

Article suivant

Développer sur Master System en assembleur

0 commentaire

Laisser un commentaire