1 points par GN⁺ 2 시간 전 | Aucun commentaire pour le moment. | Partager sur WhatsApp
  • Fame Boy est un émulateur Game Boy implémenté en F# ; il fonctionne sur desktop et sur le web avec le son, et propose un jeu dans le navigateur ainsi que le code source sur GitHub
  • Le cœur de l’émulateur et le frontend ont été simplifiés pour ne partager que framebuffer, audiobuffer, stepEmulator() et getJoypadState(state) ; un stepper exécute successivement le CPU, les timers, le port série, l’APU et le PPU afin d’assurer une synchronisation sur un seul thread
  • L’implémentation du CPU exploite les discriminated unions et match de F# pour modéliser 512 opcodes sous la forme de 58 instructions, avec une conception utilisant les types From et To pour empêcher au niveau du typage les états illégaux comme l’écriture dans une valeur immédiate
  • Le PPU a choisi un rendu par scanline plutôt qu’un pixel FIFO fidèle à la Game Boy réelle, ce qui le rend plus rapide et plus simple, mais certains jeux qui s’appuient sur le timing de la file de pixels peuvent ne pas fonctionner correctement
  • Le portage web a été réalisé avec Fable ; après correction des problèmes liés au fait que les opérations bit à bit sur 8 et 16 bits suivaient la sémantique 32 bits de JavaScript, l’émulateur a fonctionné avec un bundle JS d’environ 100 Ko, et les optimisations de performance ainsi que les builds de release ont permis d’atteindre environ 1000 FPS sur desktop

Contexte et objectifs du projet

  • Bien qu’il travaille comme ingénieur logiciel depuis plus de 8 ans, l’auteur avait le sentiment de ne pas comprendre comment un ordinateur fonctionne réellement, et a décidé d’apprendre en créant lui-même un émulateur
  • Comme il avait beaucoup joué à Pokémon dans son enfance, il a choisi la Game Boy : un vrai matériel, d’une complexité relativement limitée, avec en plus un lien personnel fort
  • Avant de se lancer directement dans la Game Boy, il a suivi From NAND to Tetris afin de comprendre les éléments de base d’un ordinateur, comme les registres, la mémoire et l’ALU
  • Pour se familiariser avec le développement d’émulateurs, il a d’abord implémenté en F# l’émulateur CHIP-8 Fip-8
  • Après plusieurs mois de travail, il a terminé Fame Boy, un émulateur Game Boy avec son, fonctionnant sur desktop et sur le web
  • Il est possible d’y jouer dans le navigateur, et le code source est publié sur GitHub

Architecture de l’émulateur

  • Afin de fonctionner à la fois sur desktop et sur le web, l’interface entre le cœur de l’émulateur et le frontend a été maintenue volontairement simple
  • L’interface principale entre le frontend et le cœur se compose de deux tableaux et de deux fonctions
    • framebuffer : un tableau de nuances 160×144 contenant blanc, clair, foncé et noir
    • audiobuffer : un buffer audio circulaire à 32768 Hz avec des têtes de lecture et d’écriture
    • stepEmulator() : exécute une instruction CPU et renvoie le nombre de cycles consommés
    • getJoypadState(state) : callback par lequel le frontend transmet l’état du joypad à l’émulateur, généralement appelée une fois par frame
  • Fame Boy est modélisé d’une manière proche du matériel réel de la Game Boy
    • Le CPU ne connaît pas le matériel en dehors du memory map, à l’image du Sharp LR35902 de la vraie Game Boy, et n’utilise que l’IoController pour les signaux d’interruption
    • Le CPU est la partie du codebase la plus typiquement F#, avec un recours important à la modélisation de domaine fonctionnelle
    • Memory.fs stocke l’essentiel de la RAM de la Game Boy et joue le rôle de memory map et de bus entre le CPU, l’IO Controller et la cartouche
    • Pour des raisons de performance, Memory.fs partage des références vers les tableaux de VRAM et d’OAM RAM du PPU
    • IoController.fs a été séparé lorsque Memory.fs est devenu trop chargé en logique ; même s’il n’existe pas de contrôleur IO unique sur la vraie Game Boy, cela regroupe le traitement des registres matériels au même endroit et rend les interfaces des composants plus simples et plus sûres
  • La fonction stepper de Emulator.fs sert de colle pour l’ensemble de l’émulateur, en combinant les fonctions d’exécution par étape de chaque composant
let stepper () =
    // Execute a single instruction
    // Each instruction uses a different amount of cycles
    let mCycles = stepCpu cpu io

    for _ in 1..mCycles do
        stepTimers timer io
        stepSerial serial io
        // The APU technically runs at 4x CPU-cycles, but can be batched
        stepApu apu

    let tCycles = mCycles * 4

    // The PPU operates at 4x CPU-cycles. The APU should be here too
    for _ in 1..tCycles do
        stepPpu ppu

    // Return cycles taken so the frontend runs the emulator at the right speed
    mCycles
  • Les composants matériels réels s’exécutent en parallèle à partir d’un oscillateur maître central, mais comme Fame Boy fonctionne sur un seul thread, les composants doivent être exécutés séquentiellement
  • La fonction stepper centralise l’exécution afin que tous les composants restent synchronisés
  • Pour atteindre une vitesse jouable, l’émulateur doit s’exécuter avec le bon nombre de cycles par seconde ; à 60 FPS, il faut environ 17 500 cycles CPU par frame
  • Le frontend pilote l’émulateur au rythme de l’échantillonnage audio quand le son est activé, et au framerate lorsqu’il est en mode muet

Implémentation du CPU et F#

  • L’émulateur CHIP-8 avait été écrit de manière pure, sans membres mutable, avec copie des tableaux, mais Fame Boy fait largement usage d’un état mutable

  • La Game Boy est bien plus rapide que CHIP-8, et copier plus de 16 Ko de mémoire des millions de fois par seconde n’est pas une approche adaptée

  • Le choix de F# pour Fame Boy vient du fait que son système de types riche se prête bien à la modélisation des instructions CPU, et aussi tout simplement parce que l’auteur apprécie F#

  • Modélisation du domaine

    • Lors de l’implémentation du CPU, l’auteur a suivi la Complete Technical Reference de Gekkio et a regroupé les instructions comme dans ce document
    • Au départ, Instructions.fs contenait des discriminated unions par type d’instruction
    • type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions```
  • type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions

    • Plusieurs instructions partagent le concept commun de position d’opérande

      • immediate, qui lit la valeur de l’octet en mémoire juste après l’instruction
      • direct, qui lit et écrit dans les registres du CPU
      • indirect, qui lit et écrit à l’emplacement mémoire pointé par le registre CPU HL
    • En extrayant ce concept de position et en le séparant en types From et To, il a pu exprimer les instructions de chargement de façon plus concise

    • type To = | Direct of Register | Indirect

    • type From = | Immediate of uint8 | Direct of Register | Indirect

    • type LoadInstr = | Load of From * To // These form a tuple, like Load<From, To> in C# // ... other instructions

    • Avec cette approche, les instructions CPU ont été réduites de 512 opcodes à 58 instructions

    • Généraliser le domaine peut faire courir le risque d’autoriser des états invalides, mais le système de types permet de l’éviter

    • Si l’on utilisait un type de position unique Loc au lieu de From et To, une instruction invalide comme Load(Loc.Direct D, Loc.Immediate) pourrait être compilée, en stockant la valeur d’un registre dans une position de valeur immédiate

    • Le matériel de la Game Boy ne prend pas en charge l’écriture dans une valeur immédiate ; modéliser correctement le domaine avec les types F# permet donc de garantir que les états illégaux ne peuvent pas être représentés dans le système

    • Il existe une seule exception : l’opcode 0x76

      • Si l’on se base uniquement sur le motif de l’opcode, cela correspondrait à une forme comme Load(From.Indirect, To.Indirect), qui chargerait la valeur 8 bits de l’emplacement HL vers ce même emplacement HL
      • Les types de Fame Boy l’autorisent, mais cette instruction n’existe pas sur la vraie Game Boy
      • Logiquement, c’est un NOP et ce n’est pas dangereux ; en pratique, le lecteur d’opcodes décode 0x76 en HALT, donc ce code n’est pas atteignable
    • Après avoir utilisé match et Option en F#, revenir à un switch classique lui a semblé plus grossier et plus propice aux erreurs, ce qui l’amène à recommander d’essayer un langage fonctionnel

  • Rester simple

    • Comme l’objectif du projet n’était pas de créer le meilleur émulateur possible mais d’apprendre le fonctionnement du matériel informatique, il n’a pas étudié en profondeur le code d’autres émulateurs

    • En voyant le code suivant dans les sources de CAMLBOY, il a apprécié le fait de pouvoir passer uniquement les flags voulus, dans n’importe quel ordre

    • set_flags ~h:false ~z:(!a = zero) ();

    • F# ne permettait pas de reproduire exactement cette approche, car son système de types qui prend en charge l’application partielle pousse à éviter la surcharge de méthodes et les paramètres par défaut

    • Au départ, il l’a implémenté en passant un tableau et un type de flag, comme ceci

    • cpu.setFlags [ Half, false; Zero, a = 0uy ]

    • Puis, au cours du refactoring, il l’a transformé en cette implémentation fondée sur des fonctions pures dans Cpu/State.fs L81

    • module Flags = let inline setZ (v: bool) (f: uint8) = if v then f ||| ZMask else f &&& ~~~ZMask

      let inline setH (v: bool) (f: uint8) = // ... the other flag functions and definitions

    • // Other files

    • cpu.Flags <- cpu.Flags |> setH false |> setZ (a = 0uy)

    • Ces nouvelles fonctions se composent facilement, se testent bien et restent de simples fonctions pures

    • L’ancienne implémentation était plus verbeuse, car il fallait remonter les valeurs dans un type union discriminée et les placer dans un tableau

    • Les nouvelles fonctions étaient inline, ne nécessitaient pas d’allocation sur le tas et offraient aussi de meilleures performances, augmentant le FPS de l’émulateur d’environ 10 %

  • Tests

    • L’implémentation initiale du CPU avançait en lançant la ROM de Tetris puis en implémentant chaque instruction au fur et à mesure qu’un opcode non pris en charge était rencontré
    • match opcode with
    • | 0x00 -> Nop
    • | _ -> failwith "Unimplemented opcode"
    • Cette méthode obligeait à naviguer au hasard dans la documentation technique, ce qui rendait la répétition fastidieuse, et il était difficile de savoir si les instructions étaient correctement implémentées
    • Pour résoudre ces deux problèmes, il a introduit des tests unitaires
    • Il a écrit lui-même le code de l’émulateur pour apprendre, mais a utilisé l’IA pour générer les cas de test
    • Il fournissait dans le prompt les spécifications issues de la documentation technique et demandait d’écrire des tests basés sur ces spécifications, sans regarder le code de l’émulateur
    • Pendant que l’IA générait les tests, il lisait lui-même les spécifications et implémentait la logique jusqu’à ce que les tests passent, pratiquant ainsi un véritable développement piloté par les tests
    • Les tests ont aussi permis de découvrir quelques bugs dans des instructions déjà implémentées
    • Les tests étaient revus et améliorés régulièrement, et loin de freiner l’apprentissage, ils l’ont aidé à consacrer son énergie aux parties les plus intéressantes

Les composants après le CPU

  • PPU

    • La Game Boy n’a pas de GPU mais un PPU, c’est-à-dire un picture processing unit
    • Beaucoup d’articles sur la création d’émulateurs Game Boy se concentrent sur le CPU et ne consacrent que quelques paragraphes au PPU, mais dans Fame Boy, comprendre le PPU a pris plus de temps
    • Le CPU semblait assez naturel grâce à l’expérience acquise avec From NAND to Tetris et CHIP-8, tandis que le PPU ressemblait davantage à un travail mécanique consistant à suivre les étapes nécessaires pour afficher les pixels à l’écran
    • Au début, au lieu d’essayer de comprendre d’un coup le FIFO de pixels et tout le pipeline du PPU, l’auteur a commencé par lire et parser les tuiles et la carte d’arrière-plan depuis la mémoire pour les afficher à l’écran
    • Cette approche a permis de voir le CPU fonctionner et, grâce à la simplicité de Tetris, d’obtenir un résultat qui ressemblait presque à un vrai jeu Game Boy
    • Le fait d’avoir commencé par les vues des tuiles et de l’arrière-plan a continué à aider ensuite, de l’implémentation de l’écran réel jusqu’au débogage de bugs détaillés dans les données des sprites
    • Le PPU de Fame Boy présente d’importantes imprécisions matérielles
      • La vraie Game Boy utilise une file FIFO, comme un moniteur CRT, pour placer les pixels un par un à l’écran
      • Fame Boy rend toute la ligne de balayage au début de la période de dessin de cette ligne
    • Cette méthode est plus rapide, le code est plus simple et tous les jeux visés fonctionnaient, donc il n’y a pas eu besoin de passer à une file de pixels
    • Les jeux qui exploitent le matériel de la Game Boy jusqu’à ses limites et utilisent le timing de la file de pixels ne fonctionneront pas correctement dans Fame Boy, mais comme la plupart des jeux n’utilisent pas le matériel de manière aussi agressive, ils devraient globalement fonctionner
  • Joypad

    • En plus du PPU et de l’APU, le joypad a aussi été pris en charge
    • L’implémentation initiale était très simple et écrire les tests l’était aussi
    • Mais après de gros refactorings, elle cassait presque toujours
    • Le registre matériel du joypad est lu et écrit à la fois par le CPU et par le jeu, ce qui rend les interactions complexes
    • Au départ, le CPU écrivait l’état du joypad dans le registre à chaque cycle, mais comme un humain ne change pas l’état des boutons des millions de fois par seconde, cela a été modifié pour ne faire la mise à jour qu’une fois par frame
    • Résultat, la croix directionnelle ne fonctionnait plus
    • Le matériel de la Game Boy ne peut lire que la moitié des boutons à la fois, et les jeux dépendent presque toujours du fait de lire le registre du joypad deux fois ou plus à très court intervalle, avec un registre qui change entre les lectures
    • Un registre mis en cache une seule fois par frame ne changeait pas entre deux lectures, ce qui faisait que la moitié des boutons ne fonctionnait pas
    • Finalement, l’implémentation a été modifiée pour que IoController ne mette à jour le registre du joypad que lorsque le CPU le lit
    • Voir aussi la documentation joypad de Pandocs
  • Son

    • Une fois l’émulateur fonctionnel créé, en jouant à la version web, l’auteur a trouvé que l’absence de son donnait une impression de vide et a donc ajouté l’APU, c’est-à-dire l’audio processing unit
    • Il a découvert que plusieurs émulateurs sont pilotés non pas par le framerate, mais par le taux d’échantillonnage audio du frontend
    • Au début, cela lui a semblé contre-intuitif, il a donc étudié le taux d’échantillonnage dynamique et essayé d’implémenter une approche où le framerate pilote l’émulateur
    • Le son était le composant le plus difficile à appréhender sur le plan conceptuel, et comprendre le fonctionnement des différents registres audio et canaux a pris du temps
    • Sur cette partie, l’IA a beaucoup aidé en jouant un rôle de professeur, avec plusieurs échanges de questions-réponses avant même d’écrire le code
    • Comme pour le PPU, terminer les canaux un par un procurait une grande satisfaction, et entendre la musique de Tetris s’enrichir peu à peu a aussi permis de comprendre comment elle est construite
    • Le CPU et le PPU fonctionnent en effectuant exactement X opérations par frame, et X se calcule facilement, mais pour l’APU il y avait beaucoup plus de valeurs à choisir et à ajuster
    • Seul le taux d’échantillonnage de l’APU a été facile à fixer
      • Le véritable APU de la Game Boy est suffisamment flexible pour que l’émulateur puisse utiliser le taux d’échantillonnage qu’il veut
      • Fame Boy a choisi 32768Hz
      • Avec une horloge CPU à 1048576Hz, 32768Hz correspond à 1 échantillon tous les 128 cycles CPU, ce qui permet de synchroniser parfaitement l’état de l’APU en n’utilisant que des entiers
      • Comme 128 est aussi divisible par 4, il est possible de traiter les étapes de l’APU par groupes de 4 sans perdre l’alignement avec les instructions CPU
    • Les autres valeurs étaient bien plus instables et, comme l’auteur n’est pas ingénieur du son, il a dû les ajuster empiriquement
    • Il y avait aussi des problèmes propres à chaque frontend et à chaque plateforme
      • Sur PC, le son fonctionnait bien, mais sur MacBook il ressemblait à un bruit de cascade
      • Après avoir corrigé le problème sur MacBook, la version desktop pour PC ne se lançait plus à cause d’une condition de concurrence
    • L’auteur a finalement abandonné l’idée d’une solution intelligente par taux d’échantillonnage dynamique, et en faisant piloter l’émulateur par l’audio, le son est devenu beaucoup plus stable sur plusieurs appareils
    • L’audio est l’un des points où l’interface entre l’émulateur et le frontend fuit le plus, mais une synchronisation précise est indispensable pour éviter les dissonances

Méthode de pilotage de l’émulateur

  • La différence entre un pilotage par l’audio et un pilotage par frame est liée à la perception humaine
  • Si le signal audio est interrompu, les haut-parleurs bougent fortement à cause de la brusque variation du signal, ce qui produit un bruit de pop
  • Si la vidéo est interrompue, le lecteur vidéo saute une ou deux frames parce que les données n’arrivent pas à temps, mais comme cela ne pousse rien de physique, la gêne sensorielle est moindre
  • À l’intérieur de Fame Boy, l’audio et la vidéo sont parfaitement synchronisés par conception
  • Mais sur l’ordinateur qui exécute l’émulateur, l’audio et la vidéo sont indépendants, et l’un ou l’autre peut parfois prendre du retard
  • Si l’audio et la vidéo du frontend se désynchronisent, il y a deux possibilités
    • Synchroniser l’audio du frontend avec l’audio de l’émulateur et supprimer parfois des frames
    • Synchroniser la vidéo du frontend avec les frames de l’émulateur et supprimer parfois de l’audio
  • Le côté choisi « pilote » l’émulateur, et l’autre reste aussi proche que possible
  • Le pilotage basé sur le framerate est relativement simple
let mutable cycles = 0

while (runEmulator) do
    cycles <- cycles + targetCyclesPerMs * lastFrameTime

    while cycles > 0 do
        let cyclesTaken = stepEmulator ()
        cycles <- cycles - cyclesTaken

    draw ppu.framebuffer
  • Le pilotage basé sur le son est plus délicat, car Raylib et Web Audio gèrent l’audio différemment
  • Le flux général est le suivant
let tryQueueAudio apu stepEmulator =
    if frontend.audioBuffer.hasSpace () then
        while apu.writeHead - apu.readHead < samplesNeeded do
            stepEmulator ()

        frontend.audioBuffer.fill apu.audioBuffer

while (runEmulator) do
    tryQueueAudio apu stepEmulator

    draw ppu.framebuffer
  • La différence essentielle est que stepEmulator n’est plus contrôlé par lastFrameTime, mais piloté selon les besoins du tampon audio du frontend
  • samplesNeeded doit calculer le nombre d’appels à stepEmulator nécessaire pour s’adapter à différents taux d’échantillonnage tout en produisant 60 FPS
  • Comme le tampon audio du frontend ne se soucie que de se remplir, il peut appeler stepEmulator trop souvent ou pas assez par frame, avec pour conséquence possible que le framebuffer ne soit pas mis à jour à temps
  • Le frontend web permet de tester la version pilotée par frame en ajoutant ?frame-driven à l’URL
  • La version pilotée par frame est visuellement plus fluide, mais produit parfois des pops audio
  • Le frontend web piloté par l’audio bascule lui aussi en mode frame-driven quand le bouton muet est activé, puisque les pops ne s’entendent plus
  • L’implémentation n’est pas parfaite, mais comme les pops audio donnent une pire impression que les saccades vidéo et qu’un mode muet semblait vide, le frontend web utilise par défaut le pilotage par l’audio
  • L’audio est l’un des rares domaines de Fame Boy qui ne donnent pas entière satisfaction, et l’auteur aimerait y revenir un jour

Publication sur le web avec Fable

  • Une fois que le PPU fonctionnait à peu près et que quelque chose commençait à s’afficher sur l’écran desktop, l’objectif a été de porter Fame Boy sur le web
  • Après avoir lu la documentation de Fable, installé le package, configuré la boucle principale et ajouté un peu de style, tout était prêt à être exécuté en une ou deux heures
  • La première version Fable exécutée affichait l’écran de manière étrange, et après un peu de débogage, pour éviter d’y passer trop de temps, j’ai essayé WebAssembly avec Blazor
  • Blazor aussi s’est lancé facilement et, cette fois, cela fonctionnait réellement, mais à environ 8 FPS, donc quasiment injouable
  • Il n’est pas certain que le problème vienne de Blazor lui-même, et suivre les guides de performance de l’équipe .NET n’a pas aidé
  • Le débogage étant aussi peu pratique, je suis revenu à Fable pour vérifier ce qui n’allait pas dans le processus de conversion JavaScript
  • Fable place les fichiers JS générés juste à côté du code source, et ils étaient en fait assez lisibles
  • Cela a facilité la compréhension du nouveau code et le débogage dans les outils de développement du navigateur
  • Dans les outils de développement, j’ai remarqué que les valeurs des registres CPU étaient anormales
    • Les registres CPU de Fame Boy et de la Game Boy sont des entiers non signés sur 8 bits, donc leur plage devrait être de 0 à 255
    • Pourtant, des valeurs comme -15565461 apparaissaient
  • Dans la documentation de Fable, j’ai trouvé la documentation de compatibilité sur les types numériques

(non-standard) Bitwise operations for 16 bit and 8 bit integers use the underlying JavaScript 32 bit bitwise semantics. Results are not truncated as expected, and shift operands are not masked to fit the data type.

  • Cela correspondait exactement à l’explication selon laquelle les opérations bit à bit sur des entiers 16 bits et 8 bits utilisent la sémantique des opérations bit à bit 32 bits de JavaScript, et que les résultats ne sont pas tronqués comme prévu
  • Après avoir trouvé dans le code les endroits où les valeurs 8 bits devaient être tronquées et corrigé les problèmes liés, le frontend web a fonctionné correctement
  • Comme il n’utilise que du JS sans runtime .NET, le bundle web pèse environ 100 Ko
  • Hormis cet étrange problème de uint8, l’expérience avec Fable a été plutôt agréable, et tout le code source a pu rester en F#

Amélioration des performances

  • Une fois que des résultats ont commencé à s’afficher à l’écran, un simple log FPS a été ajouté dans la console
  • Au départ, c’était environ 55 à 60 FPS en mode debug, ce qui semblait venir de la tentative de Raylib de maintenir la v-sync
  • En désactivant la v-sync, le résultat est monté à environ 70 FPS, mais avec du jitter
  • Puis, à mesure que des fonctionnalités étaient ajoutées, les performances ont progressivement baissé jusqu’à 45 FPS, et désactiver la v-sync n’a pas aidé
  • En lançant le profiler JetBrains Rider, mapAddress est apparu comme un goulot d’étranglement suspect
  • Comme presque tous les composants accèdent à la mémoire, il est apparu que le coût des accès mémoire était plus élevé que prévu
  • Le code en cause fonctionnait en mappant les adresses mémoire vers une union discriminée MemoryRegion, puis en lisant et en écrivant à partir de là
type MemoryRegion =
    | RomBase of offset: int
    // ... others

let mapAddress (addr: int) : MemoryRegion =
        match addr with
        | a when a < 0x4000 -> RomBase a
        // ... others

type DmgMemory(arr: uint8 array) =
    // Arrays for romBase etc

    member this.read address =
        match mapAddress address with
        | RomBase i -> romBase[i]
        // ... others

    member this.write address value =
        match mapAddress address with
        | RomBase _ -> ()
        // ... others
  • J’avais essayé d’étendre à la mémoire le flux de modélisation du domaine utilisé pour le CPU, et le résultat était que chaque lecture/écriture mémoire créait et mappait un objet MemoryRegion
  • Cette approche allouait des millions d’objets par seconde sur le tas, et augmentait aussi le nombre de branches que le compilateur JIT devait traiter
  • Un seul changement, consistant à supprimer l’union discriminée et la fonction de mapping pour accéder directement aux tableaux, a doublé les FPS
  • Les benchmarks ont ensuite montré que l’essentiel du gain venait des optimisations JIT sur les branches et les sites d’appel localisés
  • Même en transformant MemoryRegion en struct DU pour qu’il soit alloué sur la pile, les performances ne s’amélioraient que d’environ 15 %, les 85 % restants provenant de la suppression de la DU et de la fonction de mapping
  • Par la suite, il y a eu d’autres cas où un passage à des struct DU ou des approches peu idiomatiques en F# a été retenu
  • À partir de l’implémentation du PPU, l’optimisation est devenue nécessaire, et il a fallu renoncer dans une certaine mesure à un F# idiomatique
  • En consultant régulièrement le profiler et en améliorant lentement les performances, le projet est monté à environ 120 FPS
  • Le plus gros gain de FPS est venu de la désactivation du build debug, avec environ 1000 FPS en mode release
  • Les performances ont continué à être surveillées et ajustées régulièrement jusqu’à la fin

Benchmarks

  • Considérant que se contenter de regarder le chiffre FPS dans la console n’était pas une bonne façon de mesurer les performances, un projet BenchmarkDotNet a été ajouté au milieu du projet pour mesurer les performances desktop
  • Ensuite, un simple benchmark web utilisant Node.js a été créé afin d’estimer de manière similaire les performances dans le navigateur web
  • Les ROM de démonstration suivantes ont été utilisées dans les benchmarks pour tester des scénarios réalistes
    • Flag : une boucle courte sans son
    • Roboto : une démo de plus d’une minute utilisant beaucoup d’effets visuels et du son
    • Merken : similaire à Roboto, mais avec une ROM à mémoire paginée pour tester la mémoire
  • Les performances desktop en FPS sur un PC Windows Ryzen 9 7900 et un MacBook Air M4 sont les suivantes
CPU Flag Roboto Merken
Ryzen 9 7900 1785 1943 1422
Apple M4 1907 2508 1700
  • Les performances web en FPS sont les suivantes
CPU Flag Roboto Merken
Ryzen 9 7900 646 883 892
Apple M4 779 976 972
  • Fame Boy fonctionne correctement sur les deux plateformes
  • Contrairement aux attentes, l’APU, c’est-à-dire le son, a plus d’impact que le PPU sur les performances de l’émulateur
  • Désactiver le PPU augmente les performances desktop d’environ 250 FPS, mais désactiver l’APU les augmente d’environ 500 FPS

Utilisation de l’IA

  • L’auteur estime qu’il était impossible d’éviter complètement l’influence de l’IA, même dans un projet d’apprentissage, et documente donc de manière transparente sa façon de l’utiliser
  • Tout au long du projet, l’IA a surtout été utilisée comme outil d’assistance
    • demandes de revue de code
    • interlocuteur pour discuter et examiner des idées
    • interprétation concise de documentation technique
  • L’auteur a essayé de réduire au maximum la quantité de code écrit par l’IA
  • Comme il voulait produire quelque chose qu’il pourrait montrer à d’autres avec fierté, il a choisi de laisser du code écrit directement par lui-même plutôt que de simplement partager des prompts
  • PR d’amélioration des performances

    • Vers la fin du projet, il a fourni le dépôt au CLI pour lui demander de chercher des optimisations de performance
    • Il lui a donné quelques idées et l’a aussi laissé tenter ce qu’il voulait, ce qui a permis de plus que doubler les performances dans certains benchmarks
    • Les détails se trouvent dans cette PR
    • Cela a toutefois aussi introduit des bugs, qu’il a dû identifier et corriger lui-même
    • L’une des grosses optimisations, consistant à « mettre à jour STAT uniquement lors des changements de mode/LY », cassait certains jeux et démos qui dépendent de mises à jour plus fréquentes, et a été corrigée dans ce commit de correction
  • « L’hiver des timers »

    • L’historique Git comporte un grand vide, période que l’auteur appelle le « timer winter »

    • Ce n’est pas qu’il ne travaillait pas sur l’émulateur, mais qu’il était bloqué par un bug l’empêchant de dépasser l’écran de copyright de Tetris

    • Il a passé plus de 20 heures à déboguer, à fouiller le Discord emu-dev, à créer des tests et même à soumettre le problème aux premiers modèles d’IA, sans parvenir à le résoudre

    • Après avoir fait une pause de quelques semaines, il a essayé Claude Opus, qui a trouvé le problème en quelques minutes

    • Le problème venait du fait que le timer n’avançait qu’une seule fois par instruction, au lieu d’avancer autant de fois que le nombre de cycles consommés par l’instruction

    • let stepEmulator () = let cyclesTaken = stepCpu cpu

      // Before stepTimers timer memory // only once per instruction

      // The fix for _ in 1..cyclesTaken do // cpuCycles can vary between 1 and 6 stepTimers timer memory

    • Comme les cycles CPU peuvent varier de 1 à 6, dans l’ancienne implémentation le timer fonctionnait en moyenne 2 à 3 fois plus lentement que dans la réalité

    • L’écran de copyright ne faisait donc que rester affiché plus longtemps, et le vrai problème était simplement qu’il n’avait pas attendu 1 à 2 minutes

    • Le corps de l’article lui-même a été majoritairement rédigé directement par l’auteur

Ce que j’ai appris et conclusion

  • L’objectif principal était d’apprendre comment fonctionne un ordinateur, et de ce point de vue, c’est une grande réussite
  • Le travail a été très amusant, au point de commencer après le travail en se disant « aujourd’hui, juste une fonctionnalité », puis de se retrouver à 2 h du matin à vouloir corriger encore un seul bug
  • L’auteur a envisagé de tenter aussi la Game Boy Advance, mais à la lecture des spécifications, il a eu l’impression que cela demanderait environ trois fois plus d’efforts pour seulement 20 % de compréhension supplémentaire du matériel
  • La Game Boy offrait un bon équilibre pour apprendre, et il peut s’arrêter là pour le moment
  • Il n’est pas certain d’être devenu un meilleur software engineer, mais il comprend clairement un peu mieux les outils qu’il utilise tous les jours
  • Les questions ou commentaires peuvent être envoyés par e-mail

Aucun commentaire pour le moment.

Aucun commentaire pour le moment.