Skip to content

RP1 — logique, stockage, réseau, mise à jour

Note

Technical reference — French only for now. / Référence technique, en français pour l'instant.

Premier étage du pipeline papyDeck. Seule puce dotée d'un port USB, d'une carte µSD et d'un accès réseau. Aucune contrainte temps réel dure.

Vue système : ArchitectureRoot.md


1. Responsabilités

Domaine Contenu
Stockage Lecture de la µSD : jeux, assets, sauvegardes. Les jeux y sont préchargés par l'utilisateur depuis son PC.
Installation Écriture des NOR de RP2 (atlas de sprites) et RP3 (fond) depuis la µSD.
Mise à jour firmware RP2, RP3 et ESP32.
Logique de jeu Construction de la display list, propriété de la carte du pool de sprites, palette.
Entrées Manettes, clavier, souris via l'ESP32.
Réseau Hall of fame et services en ligne via l'ESP32.
Vidéo Lecture de l'EDID du moniteur, détection du branchement.
Audio Source des échantillons, transmis à RP3 pour insertion dans le flux HDMI.

2. Brochage

GPIO Fonction Interface Détail
0-1 Console + mise à jour RP2 UART0 §4
2-3, 6, 12-13 Secours et provisionnement RP2 GPIO + UART0 alt. §4
4-5 Console + mise à jour RP3 UART1 §4
7-11 Secours et provisionnement RP3 GPIO + UART1 alt. §4
14-15 DDC — lecture EDID I2C1 §5
16 HPD — détection de branchement GPIO entrée §5
17 ESP_EN — reset de l'ESP32 GPIO sortie §6
18-21 Lien SPI vers l'ESP32 SPI0 §6
22 ESP_IO0 — strap de téléchargement GPIO sortie §6
24-25 UART vers l'ESP32 UART1 alt. §6
26-29 Assets et display list vers RP2 — SCK 26, MOSI 27, MISO 28 (inutilisé), CS 29 en GPIO piloté à la main SPI1 §7
34-37 Assets et audio vers RP3 SPI0 alt. / PIO §7
30-32 I2S vers le DAC audio — BCLK, WCLK, DIN PIO §9
33 Reset du DAC audio GPIO sortie §9
38 GPIO1 du DAC audio GPIO §9
39 VSYNC entrant depuis RP3 — actif haut, IRQ sur le front montant GPIO entrée §7
41-47 Carte µSD — CD, CLK, CMD, DAT0-3 PIO §8
23 LED d'état, via 100 Ω GPIO sortie
40 Bouton utilisateur, vers la masse GPIO entrée

Les 48 GPIO de RP1 sont utilisés.


3. Répartition des périphériques

Le RP2350 possède deux UART et deux SPI matériels. Les liens de RP1 en demandent davantage, d'où la répartition suivante.

Périphérique Broches Affectation
UART0 0-1 RP2 : console, mise à jour A/B
UART0 (alt.) 12-13 RP2 : shell du bootrom en secours
UART1 4-5 RP3 : console, mise à jour A/B
UART1 (alt.) 8-9 RP3 : shell du bootrom en secours
UART1 (alt.) 24-25 ESP32 : protocole applicatif et flashage
SPI0 18-21 ESP32 : transferts volumineux
SPI0 (alt.) 34-37 RP3 : assets et audio
SPI1 26-29 RP2 : assets et display list
I2C1 14-15 partagé : EDID du moniteur et contrôle du DAC audio
PIO 30-32 I2S vers le DAC audio
PIO 41-47 µSD en mode 4 bits

UART1 sert trois liens (RP3 console, RP3 secours, ESP32) et SPI0 en sert deux (ESP32, RP3). Ces usages ne sont pas simultanés dans le cas nominal — le secours suppose la cible en reset, l'installation d'un jeu suppose qu'aucune partie n'est en cours — mais deux d'entre eux doivent basculer en PIO si la simultanéité devient nécessaire :

  • audio vers RP3 pendant une partie et trafic manettes depuis l'ESP32 : les deux veulent SPI0. L'un des deux passe en PIO.
  • console RP3 et dialogue ESP32 : les deux veulent UART1. L'un des deux passe en PIO.

Le RP2350 dispose de trois blocs PIO, largement de quoi absorber ces liens. Les broches concernées (18-25, 34-37) sont toutes dans la fenêtre 16-47, donc compatibles d'un même bloc à GPIOBASE = 16.


4. Mise à jour et provisionnement de RP2 et RP3

Le SWD reste le chemin de développement. L'utilisateur final ne voit que le port USB de RP1.

Voie normale — auto-mise à jour A/B

Le bootrom du RP2350 gère nativement partitions A/B, versions, flash update boot et try-before-you-buy avec self-test et rollback (DS p. 354). RP1 reçoit l'image par USB et la pousse par l'UART de console de la cible ; celle-ci, en fonctionnement, écrit sa partition inactive, redémarre, valide ou revient en arrière seule.

Le 1 Mbaud fixe ne concerne que le shell du bootrom : ce lien peut monter à 3 Mbaud, soit environ 4 s/Mo.

Voie de secours et de fabrication — boot UART du bootrom

Disponible par défaut sur puce vierge, sans programmation d'OTP (DS §5.8, p. 417) : « it allows RP2350 to be deployed into the field on multi-device boards without loading firmware or programming OTP bits in advance ».

Séquence : RUN bas → QSPI CSn bas et SD1 haut → RUN relâché → le bootrom ouvre un shell UART à 1 Mbaud sur QSPI SD2 (TX) et SD3 (RX). RP1 y pousse un stub flasheur en SRAM (knock 0x56 0xff 0x8b 0xe4, commandes w puis x), qui reçoit ensuite l'image par GP0/GP1 et programme la NOR.

Conséquences : les NOR sont soudées vierges et provisionnées in situ au premier démarrage ; le débrickage reste toujours possible, le bootrom étant en ROM. Le boot UART exige un XIN dérivable en 48 MHz — le 12 MHz commun est exactement le défaut attendu.

Câblage — 7 fils par cible

RP1 ↔ RP2

# RP1 Sens RP2 Patte QFN-80 R série Rôle
1 GP2 RUN 35 non reset contrôlé
2 GP3 QSPI_SS 75 1 kΩ strap BOOTSEL (bas)
3 GP6 QSPI_SD1 74 1 kΩ strap UART (haut)
4 GP13 (UART0_RX alt.) QSPI_SD2 73 1 kΩ TX du shell bootrom
5 GP12 (UART0_TX alt.) QSPI_SD3 70 1 kΩ RX du shell bootrom
6 GP1 (UART0_RX) GP0 non console, acquittements
7 GP0 (UART0_TX) GP1 non console, image firmware

RP1 ↔ RP3

# RP1 Sens RP3 Patte QFN-80 R série Rôle
1 GP7 RUN 35 non reset contrôlé
2 GP10 QSPI_SS 75 1 kΩ strap BOOTSEL (bas)
3 GP11 QSPI_SD1 74 1 kΩ strap UART (haut)
4 GP9 (UART1_RX alt.) QSPI_SD2 73 1 kΩ TX du shell bootrom
5 GP8 (UART1_TX alt.) QSPI_SD3 70 1 kΩ RX du shell bootrom
6 GP5 (UART1_RX) GP0 non console, acquittements
7 GP4 (UART1_TX) GP1 non console, image firmware

Les broches 12/13 et 8/9 sont les entrées alternatives de UART0 et UART1. En secours, la cible est en reset et son lien console est muet : RP1 rebascule son UART matériel et parle au shell du bootrom avec le même périphérique, sans PIO ni bit-bang.

Règles de câblage

Aucun partage des fils 2 à 5 entre RP2 et RP3. Ce sont les lignes vivantes du bus NOR de chaque cible en fonctionnement ; pendant qu'on récupère l'une, l'autre tourne.

1 kΩ en série dans les fils 2 à 5, placés au départ du bus QSPI, côté cible, pour que la piste vers RP1 et sa capacité d'entrée restent derrière la résistance. Placée côté RP1, la piste pendrait en stub sur un bus tournant à 63-126 MHz. La valeur isole un auditeur passif et borne une contention à ~3 mA au lieu de ~150.

Niveaux vérifiés à travers 1 kΩ contre les pulls internes (DS p. 1338 : SS/SD2/SD3 en pull-up, SD1 en pull-down) : strap SD1 haut ≈ 3,1 V > VIH 2,15 V ; strap CSn bas ≈ 0,16 V < VIL 1,16 V ; UART 1 Mbaud, RC ≈ 10 ns ≪ 1 µs.

RUN : entrée avec le pull-up de RP1, jamais tiré haut. Constaté sur la carte V1 alpha le 2026-08-26 (RP1/src/board.c, run_release()) : le RUN de RP2 et de RP3 n'a pas de pull-up externe, et le pad de RP1 sort de reset en pull-down — laissé en Hi-Z, RP1 maintenait les deux cibles en reset (SWD muet) tant qu'il n'était pas programmé. RP1 configure donc GP2 et GP7 en entrée avec son pull-up interne — pas en Hi-Z — et ne les pilote qu'à l'état bas ; un bouton de reset et la sonde SWD cohabitent sur la même ligne. Le reset commandé (board_reset_rp2/rp3, Ctrl-T / Ctrl-R, bouton USER) est une impulsion basse de 10 ms en sortie, puis retour en entrée + pull-up. Conséquence pratique : RP1 se programme en premier, sinon RP2 et RP3 ne démarrent pas.

Les huit straps (fils 2 à 5) restent en Hi-Z hors séquence de secours — entrée sans pull (hiz()), posés avant de relâcher RUN pour que les cibles ne voient jamais un strap tiré au démarrage. La séquence de secours elle-même (stub UART) n'est pas écrite : ouvert.


5. Lecture de l'EDID

Signal RP1 Vers Interface
DDC_SDA GP14 TPD12S521 broche 18 I2C1 SDA
DDC_SCL GP15 TPD12S521 broche 17 I2C1 SCL
HPD GP16 TPD12S521 broche 19 GPIO entrée

Le TPD12S521 assure le décalage de niveau : RP1 se raccorde directement en 3,3 V sur les broches *_IN, référencées LV_SUPPLY. Ni diviseur ni pull-up 5 V côté RP1. Détail du composant : ArchitectureRP3.md §2.

HPD est également câblé sur RP3 GP20, qui en a besoin pour démarrer ou arrêter le HSTX. RP1 l'utilise pour savoir quand relire l'EDID.

L'EDID fait 128 ou 256 octets, lus à 100 kHz. RP1 en tire la validation du mode 640×480@60, le choix DVI ou HDMI, et le nom du moniteur.


6. Lien vers l'ESP32

Module ESP32-MINI-1 — le seul de la famille à supporter le Bluetooth Classic (BR/EDR), requis par la quasi-totalité des manettes : DualShock 4, DualSense, Switch Pro et l'ensemble des 8BitDo. Les Xbox en firmware v5.x et suivants utilisent BLE, les claviers et souris modernes également. Les ESP32-S3, C3, C6 et H2 sont BLE seulement et ne conviennent pas.

Câblage

RP1 Sens ESP32-MINI-1 Broche Rôle
GP17 EN 8 reset, via 1 kΩ série
GP22 IO0 21 strap : bas au relâchement d'EN = mode téléchargement
GP24 (UART1_TX alt.) RXD0 35 RP1 → ESP
GP25 (UART1_RX alt.) TXD0 36 ESP → RP1
GP18 (SPI0_SCLK) IO18 30 SCLK
GP19 (SPI0_TX) IO23 31 MOSI
GP20 (SPI0_RX) IO19 32 MISO
GP21 (SPI0_SS_N) IO5 29 CS

IO2 (broche 20) doit rester libre. C'est le second strap de boot ; un pull-up externe rendrait le mode téléchargement inaccessible.

Passifs

Composant Entre Rôle
1 kΩ GP17 ↔ EN limite le courant de décharge du condensateur d'EN quand RP1 tire bas. Sans lui, le pic atteint ~195 mA (Rds(on) du pad ≈ 17 Ω). Avec, 3,3 mA.
10 kΩ EN ↔ 3V3 pull-up. La fiche interdit de laisser EN flottant.
1 à 4,7 µF EN ↔ GND retard RC recommandé par Espressif.
100 nF 3V3 ↔ GND découplage rapide, au plus près de la broche 3.
10-22 µF 3V3 ↔ GND bulk : les pics d'émission Wi-Fi sont violents.

Niveaux à travers le diviseur 1 kΩ / 10 kΩ : EN bas = 0,30 V contre VIL_nRST maximum de 0,6 V. Ne pas dépasser 1 kΩ — à 2,2 kΩ on serait à 0,595 V.

Avec 4,7 µF, la descente sous 0,6 V prend ~10 ms et la remontée au-dessus de VIH_nRST (0,75 × VDD) ~60 ms : la séquence de reset complète dure ~70 ms, à respecter dans le firmware.

En service depuis le 2026-08-29 — RP1/src/esp.c

  • UART en PIO sur GP24 (TX) / GP25 (RX), 8N1, 115 200 bauds au départ (esp_uart.pio, les programmes de pico-examples ; UART0 et UART1 sont les consoles de RP2 et RP3). La réception passe par un DMA en anneau de 8 Kio que le lecteur suit par l'adresse d'écriture du canal : la FIFO PIO ne fait que 8 mots, une scrutation par trame ne suffirait pas à 460 800 bauds.
  • EN et IO0 : RP1 les tire bas ou les relâche (pull-ups sur la carte et dans le module) ; impulsion sur EN au démarrage de RP1, l'ESP boote avec lui.
  • Le décodeur des trames de ESP/main/include/papydeck_link.h (A5 5A, type, longueur, charge, CRC-8 sur type+longueur+charge) entre les lignes de texte des journaux de l'ESP, relayées en [esp] …. PAD_STATErp1_esp_pad_mask(n) en bits du simulateur (croix, A B X Y, L R, START SELECT, le stick gauche en croix au-delà d'un tiers de course — une Switch Pro lit ±3 au repos) ; pad.get(n) = emplacement n de l'ESP (état tenu) et, pour le pad 1, le bouton USER et le clavier (appuis). Le dashboard détecte des fronts. sys.pads(){ {slot, type, battery}…, esp = hello vu, gen }.
  • Le pont pour esptool (Ctrl-E, scripts/esp-flash.py) : RP1 devient un adaptateur USB-série — octets dans les deux sens, DTR → IO0, RTS → EN (le câblage auto-reset classique), débit de l'hôte suivi ; fin d'elle-même 3 s après la fermeture du port. Piège : pico_stdio_usb jette tout tant que DTR est bas, et esptool parle DTR bas une fois le reset fait — le pont lit et écrit le CDC de TinyUSB directement (tud_cdc_n_read/write). Vérifié : ROM bootloader d'une flash vierge relayé, esptool chip_id à travers RP1.

Protocole v3 — le canal retour et l'écran MANETTES (2026-08-29)

RP1 parle à l'ESP avec le même cadrage (ESP/main/include/papydeck_link.h) : CMD_PING (hello, rappel des emplacements, clés, fenêtre — envoyé au démarrage de RP1 et à chaque hello qui suit un silence), CMD_PAIR_START {s} / CMD_PAIR_STOP, CMD_FORGET {addr} (adresse nulle = toutes les clés), CMD_DISCONNECT {slot}. L'ESP répond MSG_BONDS (ses clés de liaison, avec l'état connecté et l'emplacement), MSG_DEV_NAME (le nom, à la connexion et dans le rappel), MSG_PAIRING (secondes restantes, à chaque changement et avec le hello).

Politique far-west : par défaut l'ESP ne découvre ni n'accepte aucune manette nouvelle (on_device_discovered répond IGNORE hors fenêtre, pas de scan) ; les manettes appairées reviennent seules. sys.pair(60) ouvre une fenêtre de 60 s avec compte à rebours ; sys.forget(addr) déconnecte et efface la clé (gap_drop_link_key_for_bd_addr), sys.forget() toutes ; sys.disconnect(slot). sys.pads() porte pairing, bonds = { {addr, connected, slot}… }, et le nom de chaque manette. L'écran PARAMETRES → MANETTES du dashboard liste les manettes connues (nom si connectée, adresse sinon), leur état et leur batterie, et propose APPAIRER / OUBLIER une / OUBLIER TOUTES. Côté ESP les commandes arrivent sur une tâche UART et sont prises sur le fil Bluetooth par un timer BTstack de 50 ms. Pièges : btstack_stdio_init() installait le lecteur de BTstack sur UART0 et mangeait les commandes — retiré ; un ESP silencieux 8 s (coincé en mode téléchargement après un flash interrompu, ou planté) est resetté par RP1, straps relâchés, au plus toutes les 30 s — jamais un ESP qui parle.

v4 (2026-08-30) : CMD_RUMBLE {slot, u16 ms, faible, fort}, CMD_LEDS {slot, masque}, CMD_COLOR {slot, r, g, b} — exposés en Lua par pad.rumble/led/color ; l'ESP appelle report_parser.play_dual_rumble / set_player_leds / set_lightbar_color de Bluepad32 quand le parseur de la manette les a, et journalise sinon. L'écran MANETTES a un item TESTER par manette connectée (vibration 300 ms + toutes les LED). Vérifié : deux 8BitDo connectées, LED commandées, « pas de vibration » journalisé pour ce profil.

Répartition logicielle

L'ESP32 porte toute la pile sans fil : appairage Bluetooth, décodage HID, Wi-Fi, TCP/TLS, client HTTP. RP1 reçoit des événements digérés — « manette 2, croix appuyée, stick gauche à (−120, +30) », une douzaine d'octets.

Bluepad32 fonctionne en mode coprocesseur pour un MCU hôte externe, ce qui correspond exactement à cette répartition.

Cette frontière est ce qui rend l'UART suffisant : 4 manettes à 16 octets et 250 Hz représentent 16 Ko/s, soit 16 % d'un UART à 1 Mbaud. Le SPI ne sert qu'aux transferts volumineux — téléchargement de jeux, mise à jour du firmware de l'ESP.

Rôle SPI : RP1 est le maître

Question tranchée le 2026-08-07. RP1 est maître, l'ESP32 est esclave — c'est bien ce que dit le tableau de câblage ci-dessus, et c'est aussi le sens qu'impose Bluepad32 en mode coprocesseur : dans les plateformes AirLift et NINA, « the main processor (host) acts as the SPI master, while the ESP32 module acts as the slave ».

RP1 n'a donc pas à être esclave SPI, et la question du plafond clk_peri/12 du PL022 en esclave — celle qui impose le PIO côté RP2, cf. §7 — ne se pose pas ici. En maître, que ce soit par SPI0 matériel ou par PIO, RP1 génère l'horloge et n'est contraint par rien.

Deux écarts avec le brochage attendu par Bluepad32

Vérifiés en traitant la question ci-dessus, dans docs/plat_airlift.md.

Signal Bluepad32 / AirLift Notre schéma
SCK IO18 IO18
CS IO5 IO5
MOSI IO14 IO23
MISO IO23 IO19
READY IO33 absent

1. Le brochage retenu est celui du VSPI en maître, pas celui de l'esclave AirLift. IO18/19/23 est l'affectation IO_MUX par défaut du VSPI quand l'ESP32 est maître ; en esclave, Bluepad32 attend MOSI sur IO14 et MISO sur IO23. Ce n'est pas bloquant : la matrice GPIO de l'ESP32 route n'importe quel signal SPI vers n'importe quelle broche, il suffit de recompiler avec nos broches. Le prix est de passer par la matrice plutôt que par l'IO_MUX, ce qui abaisse le plafond de l'esclave — sans conséquence, ce lien ne porte que du transfert de masse. Bluepad32 signale d'ailleurs que ses variantes AirLift et NINA n'utilisent déjà pas les mêmes broches entre elles.

2. Il n'y a pas de ligne READY, et un esclave SPI ne peut pas parler le premier. C'est le vrai manque. Bluepad32 utilise IO33 pour que l'ESP signale qu'il a des données ; sans elle, RP1 devrait interroger le lien en permanence.

Ce que ça change, en pratique : rien, à cause de la répartition retenue. Les événements de manette passent par l'UART, pas par le SPI — c'est exactement le canal hors bande qui remplace READY : l'ESP annonce « j'ai n octets » sur l'UART, RP1 cadence ensuite le SPI. La carte n'a donc pas besoin d'un fil de plus.

Mais cela ferme une porte, et il faut le savoir : on ne pourra pas prendre un firmware Bluepad32 AirLift ou NINA tel quel. Il faudra une plateforme Bluepad32 propre à papyDeck, avec notre brochage et notre transport UART. Ce n'est pas un surcoût imprévu — la répartition « événements digérés sur l'UART, SPI pour le volume » l'impliquait déjà — mais ce n'était écrit nulle part.

À réexaminer si le protocole NINA sur SPI devenait souhaitable : il faudrait alors un GPIO de plus sur RP1 pour READY, or les 48 GPIO de RP1 sont déjà tous utilisés (§2). Un fil devrait être repris ailleurs.

Mise à jour de l'ESP32

Voie normale : OTA. RP1 envoie l'image par le protocole applicatif ; l'ESP l'écrit dans sa partition OTA via les API esp_ota_* et redémarre. Partitions, validation et rollback sont gérés par l'ESP-IDF. Aucune manipulation d'EN ni d'IO0.

Voie de secours : bootloader ROM. IO0 bas, EN pulsé, puis protocole esptool sur UART0 — trames SLIP délimitées par 0xC0, SYNC en auto-baud, puis écriture directe ou téléversement du stub loader. Le ROM démarre à 115200 baud.

Mode de téléchargement du silicium : GPIO0 = 0 et GPIO2 = 0 (Table 6 de la fiche). GPIO2 possède un pull-down interne, donc seul GPIO0 est piloté.


7. Chemin des assets

Principe

Les jeux résident sur la µSD, préchargés depuis un PC. Installer un jeu consiste à écrire les NOR de RP2 (atlas de sprites) et RP3 (fond) — une installation, pas un chargement : une fois installé, le jeu démarre instantanément.

Câblage

Lien RP1 Cible Interface RP1 Interface cible
Assets + display list GP26-29 RP2 GP2-5 SPI1 maître PIO — imposé, voir ci-dessous
Assets + audio GP34-37 RP3 GP2-5 SPI0 alt. ou PIO SPI0 matériel ou PIO

Correspondance des broches, RP1 maître et la cible esclave :

Signal RP1 (maître) Cible (esclave)
SCLK SPI_SCLK SPI0_SCLK — GP2
MOSI SPI_TX SPI0_RX — GP4
MISO SPI_RX SPI0_TX — GP3
CS SPI_SS_N SPI0_SS_N — GP5

MOSI et MISO s'inversent entre maître et esclave : la cible reçoit MOSI, donc sur son SPI0_RX, et émet MISO, donc sur son SPI0_TX. Un croisement en miroir des deux fils empêcherait l'usage du périphérique SPI matériel côté cible.

Les broches GP2-5 des cibles sont hors de la fenêtre PIO du bus overlay (GPIOBASE = 16) : il faut donc un second bloc PIO à GPIOBASE = 0, ou le SPI matériel.

Plafond du SPI matériel en esclave — à connaître avant de choisir. La fiche technique RP2350 §12.3.4.4 exige que SSPCLK soit au moins 12 fois plus rapide que le SCK entrant : en esclave le PL022 plafonne donc à clk_peri/12 = 12,5 MHz, contre ~clk_sys/8 ≈ 30 MHz pour un esclave en PIO. La règle vaut pour les deux cibles.

  • RP2 : le PIO est retenu, pour la marge et la simplicité : à 12,5 MHz le paquet de 6 928 o mettrait 4,4 ms, ce qui tiendrait encore devant l'adoption par RP2 (voir Runtime).
  • RP3 : les deux conviennent. Ce lien porte des assets hors temps réel et l'audio (48 kHz stéréo 16 bits = 1,5 Mb/s), très en dessous de 12,5 MHz.

Budget d'installation

Temps de programmation de la NOR W25Q128JV (fiche p. 67) :

Paramètre Typique Maximum
tPP — page de 256 o 0,4 ms 3 ms
tSE — secteur 4 Ko 45 ms 400 ms
tBE2 — bloc 64 Ko 150 ms 2 000 ms
tCE — puce entière 40 s 200 s

Le tPP domine et ne dépend pas de la vitesse du lien. Transmettre 256 octets à 133 MHz prend 15 µs, la programmation interne 400 µs : le transport représente 4 %. Le Quad Page Program (32h) n'apporte rien.

Conséquence : le lien RP1 → cible doit dépasser 640 Ko/s, soit environ 6 MHz de SPI, pour disparaître du budget. En dessous il devient le goulot ; au-dessus, la flash est seule maîtresse du temps. Inutile de viser plus haut.

Choix de la méthode d'effacement pour 16 Mo :

Méthode Nombre Typique Maximum
Puce entière 1 40 s 200 s
Blocs de 64 Ko 256 38 s 512 s
Secteurs de 4 Ko 4096 184 s 1 638 s

L'effacement par secteurs de 4 Ko est 4,8× plus lent et ne doit jamais servir en masse.

Optimisations, par rendement décroissant

  1. N'écrire que ce qui change. Relire les 16 Mo coûte 1 seconde en Fast Read à 133 MHz. Comparer une empreinte par bloc de 64 Ko et ne reprogrammer que les blocs différents rend une réinstallation quasi instantanée. C'est ce qui justifie la ligne MISO sur les deux liens.
  2. Programmer RP2 et RP3 en parallèle. Deux puces distinctes : pendant les 400 µs de programmation de l'une, on alimente l'autre en 15 µs. Recouvrement total, et l'on peut effacer l'une pendant qu'on programme l'autre. Gain jusqu'à 50 %.
  3. N'effacer que la zone utile : 4 Mo = 64 blocs = 9,6 s contre 40 s pour la puce entière.
  4. Scruter le bit BUSY au lieu d'attendre un délai fixe : 0,4 ms typique contre 3 ms maximum.

Budget de référence

Jeu de 12 Mo — 8 Mo d'atlas sur RP2, 4 Mo de fond sur RP3, installation neuve :

RP2 (8 Mo) RP3 (4 Mo)
Effacement, blocs de 64 Ko 19,2 s 9,6 s
Programmation 13,1 s 6,6 s
Sous-total 32,3 s 16,2 s
En parallèle ≈ 32 s

Mise à jour ou relance : quasi nul.

Au maximum de la fiche (3 ms par page), ce même jeu prendrait 4 minutes. L'interface doit donc afficher une barre de progression réelle, jamais une estimation de durée, et mesurer le débit sur les premières pages pour extrapoler.

Runtime

Pendant une partie, le lien SPI1 vers RP2 porte la display list et les ordres de chargement du pool, envoyés au VSYNC. Le paquet fait 6 928 octets — en-tête de 16 o, 256 entrées de 16 o, quatre blocs de contrôle de 640 o (A palette et caméra du fond, B modes, C écritures de carte du fond, D réservé), 16 ordres de chargement de 16 o — soit 2,22 ms à 25 MHz : plus long que le vblank (1,43 ms), et ça n'a pas d'importance. La contrainte historique « tenir dans le vblank, donc ≥ 23 MHz » est caduque : RP2 n'adopte une liste qu'en composant la ligne 480 de la trame qui suit le VSYNC d'envoi (ArchitectureRP2.md §5). Le paquet, parti au front montant de VSYNC, est entièrement reçu à VSYNC + 2,22 ms ; l'adoption vient ≈ 14 ms plus tard : ~14 ms de marge. Le PL022 esclave, plafonné à clk_peri/12 = 12,5 MHz, mettrait 4,4 ms et tiendrait aussi ; l'esclave PIO côté RP2 se justifie par la marge et la simplicité, pas par le vblank. Le plancher de 6 MHz du tableau d'installation ne dimensionne pas non plus le runtime.

Mis en service le 2026-08-26 dans RP1/src/board.c, partagé par rp1_supervisor et le firmware de jeu rp1_papydeck (voir RP1/README.md) :

  • SPI1 maître à 25 MHz, mode 0, MSB first, 8 bits : SCK GP26, MOSI GP27, MISO GP28 (en fonction SPI mais inutilisé : rien ne remonte de RP2), CS GP29 en GPIO piloté à la main, tenu bas sur tout le paquet et relevé par l'IRQ de fin de DMA après vidage du FIFO. L'esclave PIO de RP2 échantillonne ~3 cycles après le front et veut 20 ns de tenue : 30 MHz serait marginal. Il compte exactement 6 928 × 8 bits après le front descendant de CS.
  • Un canal DMA (8 bits, DREQ SPI1 TX, DMA_IRQ_1) lancé depuis l'IRQ VSYNC (GP39, front montant) ; deux paquets statiques alternés en SRAM (2 × 6 928 o).

Format du paquet (petit-boutiste, image mémoire des structs ; RP1/include/rp1/board.h, RP2/include/rp2/dlist.h) :

Offset Taille Contenu
0 u32 magic 0x4C444450 (octets P D D L) — seul champ vérifié par RP2
4 u16 count, 0..256 (RP1 écrête à 256 ; RP2 tronque au-delà)
6 u16 seq, incrémenté par RP1 à chaque board_dl_submit, non vérifié par RP2
8 u16 n_loads, 0..16 ordres de chargement (RP1 écrête à 16 ; RP2 tronque au-delà)
10 u16 réservé, 0
12 u32 réservé, 0
16 256 × 16 entrées ; seules les count premières sont lues, pas de terminateur ; RP1 met les suivantes à zéro
4 112 640 bloc de contrôle A (PDPA, palette + backdrop + drapeaux et caméra du fond), relayé tel quel par RP2 en ligne 480 du bus
4 752 640 bloc de contrôle B (PDPB, modes), relayé tel quel en ligne 481
5 392 640 bloc de contrôle C (PDPC, écritures de carte du fond), relayé tel quel en ligne 482
6 032 640 bloc de contrôle D (réservé, zéro — le compte de blocs doit être pair, ArchitectureRP2.md §3), relayé tel quel en ligne 483
6 672 16 × 16 ordres de chargement (RP2_DL_CTRL_END) ; seuls les n_loads premiers sont lus ; RP1 met les suivants à zéro ; RP2 les exécute dès la réception

Entrée sur le fil (16 octets, rp1_dl_entry_t) :

Offset Type Champ Contenu
0 u32 src offset des pixels du bitmap dans le pool de RP2, tel que RP1 l'a alloué (voir Le pool) ; multiple de 4 ; les intervalles sont à src − 4·h
4 i16 x coin haut-gauche, en pixels écran
6 i16 y idem
8 u16 w rempli par RP1 depuis l'annuaire de l'atlas
10 u16 h idem
12 u16 stride idem, = (w + 3) & ~3
14 u8 flags bit 0 FLIP_H, bit 1 FLIP_V ; RP1 masque à 3, RP2 ignore le reste
15 u8 pad 0

Ordre de chargement sur le fil (16 octets, rp1_dl_load_t) :

Offset Type Champ Contenu
0 u32 nor_off offset du blob (intervalles + pixels) dans la flash de RP2, depuis son début ; multiple de 4
4 u32 pool_off offset de destination dans le pool ; multiple de 4 (de 64 en pratique : une page)
8 u32 len octets, ≠ 0, multiple de 4
12 u32 pad 0

Mécanisme d'envoi — board_dl_submit(entries, count, ctrl, loads, n_loads) :

  • copie les entrées dans celui des deux paquets qui n'est pas en vol, écrit l'en-tête, met à zéro les entrées au-delà de count, copie les quatre blocs de contrôle (RP1_CTRL_BLOCKS) ; ctrl == NULL ⇒ blocs à zéro (génération 0 : RP3 les ignore et garde sa palette ; bloc C à magic nul, ignoré aussi) ; copie les n_loads ordres (écrêtés à 16 ; loads == NULL ⇒ 0) et met les suivants à zéro ; puis marque le paquet en attente ;
  • si une liste était déjà en attente (deux submit entre deux VSYNC), elle est remplacée sans avoir été envoyée : replaced++ ;
  • renvoie toujours true ;
  • au VSYNC, l'IRQ envoie le paquet en attente. Si le DMA précédent est encore en vol : skipped++, la liste reste en attente pour le VSYNC suivant. S'il n'y a pas de liste en attente, rien n'est envoyé — ni la précédente, ni un paquet vide. RP2 ne reçoit alors rien et, faute d'un paquet à adopter à sa ligne 480, construit sa scène de test locale (RP2_TEST_SCENE) — comportement de bring-up, à remplacer par « répéter la trame précédente » (ouvert, côté RP2). Le firmware de jeu soumet une liste à chaque trame ; ce cas ne se présente qu'avec Ctrl-D ou quand la boucle Lua dépasse un VSYNC ;
  • compteurs sent / skipped / replaced dans la ligne d'état (dl_tx skip repl) ; Ctrl-D coupe l'envoi.

Mesuré le 2026-08-26 : 60 paquets par seconde émis, tous reçus et adoptés par RP2 (rx = adopted, 0 rejeté hors le paquet partiel du démarrage) ; envoi coupé, RP2 se replie sur sa scène locale à la trame suivante. Le 2026-08-27, avec les ordres de chargement : 5 648 o en 1,81 ms, rejetees = 0, late = 0 côté RP2 ; le soir même, avec les quatre blocs : 6 928 o en 2,22 ms, late = 1 / desync = 1 (démarrage), txstall = 2, 0 rejet.

Ce que RP2 fait du paquet : magic faux ⇒ bad++, ignoré ; dans la même IRQ, les n_loads ordres partent en DMA NOR → pool (un DMA 32 bits par ordre, enchaînés ; ordre hors bornes ⇒ bad++, file de 32 pleine ⇒ dropped++) ; deux paquets reçus entre deux adoptions ⇒ le premier est écrasé (ses ordres ont été exécutés) ; à l'adoption, une entrée hors bornes (w ou h nul, w > stride, src non multiple de 4, src < 4·h, src + stride·h > 320 Kio) est écartée par RP2 seul, liste compactée dans l'ordre, rejetees++ dans son rapport UART — RP1 n'en sait rien (gfx.stats() ne le compte pas ; avec un annuaire juste, c'est zéro) ; un ordre encore en vol à l'adoption ⇒ late++ (RP1 a dépassé son budget, voir Le pool) ; count = 0 ⇒ trame vide, fond seul. RP2 n'a aucune table pour les listes de RP1 : src + base du pool, et c'est tout (ArchitectureRP2.md §5).

Interim levé le 2026-08-27 : jusque-là src portait un id de bitmap que RP2 résolvait dans sa table de l'atlas. Le format de l'entrée n'a pas changé, son contenu si. Le pool de RP2 — carte, allocateur, ordres de chargement — est à RP1 : voir Le pool — RP1 le possède ci-dessous.

Palette et modes de mélange — en service depuis le 2026-08-26 (RP1/src/gfx.c). RP1 est propriétaire logique de la palette (256 × RGB565), des modes de la table de mélange et du backdrop, avec un compteur de génération : RP1 démarre à gen 1, rp1_gfx_palette_init() la monte (gen = 2 au démarrage), puis +1 à chaque modification effective par gfx.color, gfx.palette, gfx.backdrop, gfx.effect (une valeur identique ne fait rien). Les blocs A et B sont reconstruits seulement quand gen a changé (rp1_gfx_ctrl()) — le bloc A l'est aussi quand la caméra ou les drapeaux du fond changent, sans monter gen — mais envoyés à chaque paquet ; RP2 les relaie tels quels sur le bus overlay, lignes 480 à 483 de sa trame de 484 lignes, et RP3 les applique avant sa ligne 0 suivante.

Bloc A — PDPA, 640 o Type Contenu
0 u32 magic 0x41504450
4 u32 gen
8 u16 backdrop RGB565 — couleur de « rien de dessiné », là où l'index de fond vaut 0
10 u16 flags du fond (depuis le 2026-08-27) : bit 0 — RP1 pilote la caméra (gfx.camera a été appelé au moins une fois) ; bit 1 — carte locale (gfx.bg_local) ; lus par RP3 à chaque paquet, indépendamment de gen
12 i16 cam_x, caméra en pixels monde (lu ssi le bit 0 est levé)
14 i16 cam_y, idem
16 256 × u16 palette RGB565 ; l'entrée 0 est transportée mais ignorée par RP3
528 112 zéro
Bloc B — PDPB, 640 o Type Contenu
0 u32 magic 0x42504450
4 u32 gen
8 u32[2] pad, 0
16 256 × u8 mode de chaque index : 0 TRANSPARENT, 1 OPAQUE, 2 TRANSLUCENT, 3 ADDITIVE, 4 SHADOW, 5 BEHIND ; inconnu ⇒ TRANSPARENT ; mode[0] ignoré
272 368 zéro
Bloc C — PDPC, 640 o (depuis le 2026-08-27) Type Contenu
0 u32 magic 0x43504450
4 u32 seq, +1 à chaque trame (map_block_build, appelé par rp1_gfx_commit), bloc vide ou non — RP3 n'applique qu'un seq différent du dernier, puisque RP2 re-relaie le même paquet quand aucun nouveau n'arrive
8 u16 n_runs
10 u16 bytes, octets de runs à partir de l'offset 16 (informatif)
12 u32 pad, 0
16 ≤ 624 les runs, à la suite : {u8 x, u8 y, u8 n, u8 flags = 0} puis n × u16 entrées de carte petit-boutistes (id de tuile bits 0-9, flip H bit 10, flip V bit 11 — le format de la carte de scène), chaque run arrondi à 4 octets ; x, y en tuiles, modulo la fenêtre de 128 × 64 chez RP3 ; n ≤ 255

Bloc D : réservé, à zéro, ignoré par RP3.

Les runs sont mis en file par gfx.tile, gfx.tiles, gfx.text (§10) dans une FIFO de 24 Kio (MAP_FIFO_BYTES) déjà encodée comme sur le fil ; rp1_gfx_commit() construit le bloc C de la trame avec les runs de tête qui tiennent dans 624 octets, dans l'ordre ; le reste attend la trame suivante (map_pending) ; FIFO pleine ⇒ le run est perdu, map_dropped++, la fonction Lua renvoie false. Ce que RP3 en fait : ArchitectureRP3.md §4 et §5 (mode local : la fenêtre est le monde ; mode NOR : sur-impressions effacées par la prochaine recharge de la rangée ou de la colonne).

Règles d'application par RP3 (ArchitectureRP3.md §4) : les deux magics justes, gen ≠ 0, gen ≠ dernière génération appliquée, et gen égale dans A et B (sinon les deux blocs sont ignorés ; « différente », pas nécessairement croissante) — sauf les drapeaux et la caméra du bloc A, lus à chaque paquet dès que les magics sont bons, et le bloc C, appliqué sur son propre seq, indépendamment de A et B. RP3 fait la différence avec son état courant (index 1..255, couleur ou mode ; le backdrop compte pour un index) : jusqu'à 16 index modifiés (RP3_CTRL_PARTIAL_MAX), rangée + colonne de la table de mélange par index ; au-delà, reconstruction complète (1,2–5 ms). Coût mesuré le 2026-08-26 : ≈ 10 000 à 13 000 cycles par index selon la mesure (56 258 cycles pour 5 index + backdrop ; 160 000 pour 12 index, soit 0,63 ms de vblank, late = 0). L'animation de palette par trame est donc supportée, dans la limite de 16 index par trame.

Interim : la palette de départ est celle de la scène, embarquée dans le firmware à la construction depuis RP3/build/palette.bin (RP1/CMakeLists.txtgenerated/scene_palette.h, FATAL si le fichier manque) ; elle viendra du jeu sur la µSD.

Le pool — RP1 le possède

En service depuis le 2026-08-27 (RP1/include/rp1/pool.h, RP1/src/pool.c). Le pool de RP2 est RP2_POOL_BYTES = 320 Kio de SRAM que RP2 n'alloue jamais : RP1 tient la carte, décide où va chaque bitmap, ordonne les chargements (NOR de RP2 → pool, avec le paquet de display list) et écrit les offsets dans les entrées. Côté RP2 : ArchitectureRP2.md §4, §5, §6.

L'annuaire de l'atlas. La bibliothèque est l'atlas PDSP de RP2 (RP2/tools/mksprites.py, format en ArchitectureRP2.md §8). Son annuaire — en-tête et tables d'animations et de bitmaps, ≤ 8 Kio, les premiers octets de RP2/build/sprites.bin — est embarqué dans rp1_papydeck à la compilation (RP1/CMakeLists.txtgenerated/sprite_atlas_dir.h, SPRITE_ATLAS_FLASH_OFF = 0x100000, FATAL si le fichier manque : lancer mksprites.py d'abord). Interim : l'annuaire viendra avec le jeu depuis la µSD, qui installera aussi l'atlas dans la NOR de RP2. rp1_pool_init() en tire, pour chaque bitmap (≤ 512, RP1_POOL_MAX_BITMAPS), w, h, stride et l'offset NOR de son blob : nor_off = 0x100000 + (16 + 8·n_anims + 12·n_bitmaps) + off − 4·h. Un annuaire invalide (magic, tailles, w > stride, stride non multiple de 4, off < 4·h) est refusé : [pool] *** annuaire d'atlas invalide, et tout gfx.sprite compte unknown.

Pages et blobs. Le pool est découpé en pages de 64 octets (RP1_POOL_PAGE ; 5 120 pages, carte d'occupation de 640 o). Un bitmap est un blob contigu de 4·h octets d'intervalles suivis de stride·h pixels — une seule copie, un seul ordre — sur ⌈len/64⌉ pages consécutives, au premier trou qui convient (first-fit). L'offset des pixels, celui qu'écrit l'entrée, est page × 64 + 4·h. Des pages de 256 octets auraient perdu trop de place sur l'atlas de bring-up (346 Ko arrondis pour 320 disponibles) ; en pages de 64, tous ses blobs tombent juste.

États d'un bitmap : ABSENT ; DEFERRED — pages allouées, ordre pas encore parti (le lot de la trame était plein) ; READY — l'ordre est dans le lot de cette trame ou d'une trame antérieure, donc référençable, y compris par la liste du paquet qui porte l'ordre : RP2 exécute les ordres à la réception, ~13 ms avant d'adopter la liste.

Le lot par trame : au plus 16 ordres (RP1_DL_LOADS) et 96 Kio (RP1_LOAD_BATCH_BYTES, ≈ 5 ms de NOR à 20 Mo/s — hypothèse, budget non chronométré) par paquet. Ce qui ne tient pas est mis en file ordonnée et servi en tête des trames suivantes, dans l'ordre, sans doubler la file ; un bitmap DEFERRED sollicité compte deferred.

Quarantaine : les pages d'un bitmap déchargé alors qu'il était READY ne sont réutilisables qu'à la trame f + 3 (RP1_POOL_QUARANTINE) — la liste qui le référençait peut encore être en vol, adoptée ou affichée : aucun chargement n'écrit ce qu'une trame affichée lit. Un bitmap DEFERRED déchargé rend ses pages immédiatement (rien n'est parti).

API C : rp1_pool_acquire(id) rend l'offset des pixels, en chargeant d'abord si besoin, ou −1 si le bitmap ne peut pas être là à temps — pool plein (full) ou lot plein (deferred), les deux comptés misses ; rp1_pool_resident(id), rp1_pool_unload(id), rp1_pool_loads(&n) (le lot de la trame, pour le paquet), rp1_pool_stats().

Éviction automatique (LRU, depuis le 2026-08-27) : chaque acquire tamponne la trame du dernier usage du bitmap. Quand une allocation ne trouve pas de pages, RP1 évince le bitmap résident dessiné le plus anciennement, à condition qu'il n'ait pas servi depuis RP1_POOL_QUARANTINE trames — aucune trame encore à l'écran ne le lit, donc ses pages sont libres immédiatement, sans quarantaine, et le nouveau bitmap part avec le paquet de cette trame même ; on recommence tant que le trou first-fit manque et qu'il reste un candidat. Le pool est ainsi un cache transparent pour le jeu : gfx.unload n'est plus qu'un conseil. Seul un working set qui dépasse le pool (tout a servi dans les trois dernières trames) donne encore full. Mesuré avec RP1_POOL_LIMIT = 64 Kio (pool volontairement réduit) sur le jeu de test (28 bitmaps à l'écran par trame, 224 en 96 trames) : full = 0, missing figé après la rampe initiale, ≈ 2,6 évictions par trame, pool entre 48 et 63/64 Kio, 28 000 ordres exécutés par RP2 sans rejet ; _draw passe de 1,5 à 2 ms (balayage des 512 entrées à chaque éviction). Avec le pool complet, 0 éviction sur l'atlas de bring-up.

Ordre dans la boucle de trame (main_game.c) :

board_wait_vsync()
board_dl_submit(liste, n, ctrl, ordres, n_ordres)   liste ET ordres de la trame précédente, ensemble
rp1_gfx_frame(f)                                    libère la quarantaine échue, ouvre le lot f, sert la file
_update()  →  rp1_gfx_begin()  →  _draw()  →  rp1_gfx_commit()

Un gfx.sprite de la trame f qui charge met son ordre dans le lot f ; liste et lot partent au VSYNC suivant dans le même paquet ; RP2 charge à la réception et adopte la liste ~13 ms après.

Rapport ([pool], dans la ligne d'état Ctrl-L, après [game]) : [pool] used/total Ko resident=… loads=… (… Ko) misses=… (full=… deferred=…)resident compte les bitmaps résidents ou en route, loads les ordres émis (cumulés). Au démarrage : [pool] N bitmaps dans l'atlas, pool de 320 Ko par pages de 64.

Mesuré le 2026-08-27 (jeu de test, 229 bitmaps utilisés) : atlas entièrement résident en 15 trames (0,25 s) — 229 ordres, 264 Kio, missing = 1 445 pendant la rampe puis stable ; churn du jeu de test (toutes les 180 trames, une famille de 32 torches déchargée, rechargée d'elle-même en 2 trames) : +32 ordres / +36 864 o par cycle, ~15 missing par cycle, pool stable à 264/320 Kio ; RP2 : 0 rejet, 0 bad, 0 late ; RP1 : 2,87 ms par trame.

Ouvert : annuaire et atlas depuis la µSD (installation dans la NOR de RP2 par RP1), compaction (les évictions laissent des trous ; un gros bitmap peut ne plus trouver de pages contiguës alors que la place existe), RP1_POOL_BYTES dupliqué de RP2_POOL_BYTES, budget NOR mesuré au chronomètre.

Firmware — cibles, horloge, consoles

Trois cibles dans RP1/CMakeLists.txt :

Cible PICO_BOARD Dossier Rôle
rp1_game pico2 (défaut) RP1/build banc Lua d'origine (main.c) : VM, sandbox, mesures ; stdio USB + UART
rp1_supervisor papydeck_rp1 RP1/build-papydeck devoirs de carte + display list de test ; USB « papyDeck supervisor » ; 150 MHz du SDK
rp1_papydeck papydeck_rp1 RP1/build-papydeck le firmware de jeu : devoirs de carte + Lua + gfx ; USB « papyDeck RP1 » ; 252 MHz

Les deux cibles papyDeck (cmake -S RP1 -B RP1/build-papydeck -G Ninja -DPICO_BOARD=papydeck_rp1) se chargent par USB sans sonde ni bouton : picotool load -f -x. Les noms de produit USB sont limités à 19 caractères par le pico-sdk (USBD_DESC_STR_MAX) — d'où « papyDeck supervisor », pas « papyDeck RP1 supervisor » ; scripts/rp3-reset.py retrouve le port par l'un ou l'autre nom.

  • Horloge : clk_sys = 252 MHz pour rp1_papydeck, posée avant board_init() : les diviseurs UART sont calculés sur clk_peri, qui suit clk_sys — posée après, les consoles sortaient du bruit. RP1 reste en XIP (pas de copy_to_ram).
  • board_wait_vsync() est bornée à 40 ms : sans VSYNC (RP3 arrêté par le débogueur ou pas encore programmé), la boucle continue de tourner, board_poll() sert la console et Ctrl-R peut relancer RP3. Trouvé à la dure : un program … verify exit laisse RP3 arrêté, et le firmware de jeu restait muet sans autre issue que de reflasher RP1.
  • Consoles : UART1 (GP⅘) ↔ RP3 et UART0 (GP0/1) ← RP2, 115 200 bauds, réception sous interruption dans deux anneaux de 1 Ko (FIFO matérielle 32 o, ligne de rapport 200 o, board_poll() une fois par trame), débordement compté ovf. RP3 est relayé en direct sur le CDC USB dans les deux sens ; RP2 en lecture seule, par lignes préfixées [rp2].
  • Commandes sur le CDC USB : Ctrl-R reset RP3, Ctrl-T reset RP2, Ctrl-L ligne d'état, Ctrl-Q rapports périodiques et consoles RP2/RP3 on/off (off au démarrage depuis le 2026-08-31 : une douzaine de lignes par seconde noyaient [sys] et [lua]), Ctrl-D display list on/off ; toute autre touche part vers RP3 seulement. Le bouton USER (GP40) resette RP3 sur le front d'appui. Ligne d'état [sup] … toutes les 2 s (retenue tant que RP3 a une ligne en cours), bannière à la connexion USB, LED GP23 basculée toutes les 500 ms.

8. Carte µSD

Signal Broche µSD RP1 Mode SPI
DAT0 7 GP44 MISO
DAT1 8 GP45
DAT2 1 GP46
DAT3 2 GP47 CS
CMD 3 GP43 MOSI
CLK 5 GP42 SCK
Détection DET GP41 entrée, pull-up interne, bas carte en place

Brochage mesuré sur la V1 alpha le 2026-08-28 : ce tableau disait DAT0 sur GP47 et DAT3 sur GP44 (« ordre décroissant ») ; avec ce câblage la carte ne répondait pas à CMD0 — le CS n'atteignait pas la carte, restée en mode SD natif. Avec DAT0 sur GP44 et DAT3 sur GP47 elle a répondu du premier coup. C'est donc l'ordre croissant, et le paragraphe sur le quartet à retourner en mode 4 bits ne s'applique pas.

Toutes ces broches sont dans la fenêtre 16-47, donc accessibles d'un bloc PIO à GPIOBASE = 16.

Le mode SD 4 bits et le mode SPI utilisent les mêmes broches. Le RP2350 n'ayant aucun contrôleur SDIO matériel, les deux passent par le PIO. Démarrer en SPI, plus simple, puis basculer en 4 bits sans modification matérielle.

DAT0 à DAT3 sont câblés en ordre croissant (GP44 → GP47) : une implémentation PIO lisant in pins, 4 depuis GP44 obtient le quartet dans l'ordre, sans retournement.

Mode SPI — en service depuis le 2026-08-28

RP1/src/sdcard.c, une machine PIO (programme sdcard.pio, celui de pico-examples : SCK en side-set, CMD en OUT, DAT0 en IN, 4 cycles PIO par bit), CS = DAT3 en GPIO ordinaire. Réveil à 400 kHz (80 horloges CS haut, CMD0 ×10, CMD8, ACMD41 avec HCS jusqu'à 1,5 s, CMD58 pour CCS, CMD16 512 si adressage par octets, CMD9 pour la capacité), puis 12,6 MHz (clkdiv 5 ; 25 MHz est le plafond du mode SPI, à essayer). Lectures CMD17/CMD18 bloquantes, 325 µs par bloc de 512 o. Les broches 42-47 sont au-dessus de 31 : pio_claim_free_sm_and_add_program_for_gpio_range choisit un bloc PIO et met sa base GPIO à 16 ; les masques de broches sont absolus et 64 bits (pio_sm_set_pins_with_mask64), les bases de sm_config_set_*_pins sont les vrais numéros. FatFs R0.15 (RP1/lib/fatfs, lecture seule pour l'instant, noms longs, une volume, exFAT refusé — une carte de 32 Go sort d'usine en exFAT, elle doit être reformatée en FAT32) ; RP1/src/storage.c monte la carte à l'insertion (détecteur débouncé 300 ms, montage bloquant uniquement sous le dashboard), la démonte au retrait, et liste papydeck/games/*/ ayant un main.lua, avec le title de papydeck.json s'il existe. Vérifié : carte SDHC 32 Go, secteur 0 lu correctement à 400 kHz et à 12,6 MHz.

Passifs

Composant Rôle
10 kΩ ×5 CMD, DAT0, DAT1, DAT2, DAT3 → 3V3 exigés par la spec SD, y compris sur DAT1 et DAT2 inutilisés en mode SPI
100 nF + 4,7 µF ×2 VDD du connecteur les cartes SD tirent des pointes brutales à l'écriture
TVS basse capacité 6 lignes de signal connecteur accessible à l'utilisateur
22-33 Ω CLK, si la piste dépasse quelques centimètres terminaison de source

9. Audio

Deux sorties, pour deux situations distinctes.

Sortie Portée Coût matériel
HDMI — data islands insérés par RP3 téléviseurs et écrans avec haut-parleurs aucun
Analogique — DAC I2S sur RP1 écrans sans son, casque, haut-parleur intégré un DAC et son étage analogique

De nombreux moniteurs d'ordinateur n'ont ni haut-parleur ni sortie casque : l'audio HDMI seul les laisserait muets. La voie analogique est donc nécessaire, pas optionnelle.

Le DAC est raccordé à RP1, qui est la source des échantillons : rien ne transite par un autre étage pour cette voie.

TLV320DAC3100

DAC stéréo avec ampli casque et ampli classe D mono BTL intégré — 2,5 W sur 4 Ω à SPKVDD = 5,5 V, 1 W à 3,6 V. Un haut-parleur intégré ne demande donc aucun composant supplémentaire. Adresse I2C 0011000 (0x18).

MCLK n'est pas nécessaire. La PLL du composant accepte BCLK en entrée — page 0, registre 4, bits D3-D2 = 01 pour PLL_CLKIN = BCLK. Deux conséquences : une broche économisée, et la gigue du diviseur fractionnaire PIO est filtrée par la boucle de la PLL, ce qui rend sans objet le fait que 252 MHz ne divise pas rondement.

Signal RP1 Interface
BCLK GP30 PIO, side-set
WCLK GP31 PIO, side-set
DIN GP32 PIO
RESET GP33 GPIO, avec pull-up de 10 kΩ
GPIO1 GP38 GPIO
SDA, SCL GP14-15 I2C1, partagé avec le DDC

Le contrôle passe par le bus DDC. Pas de conflit d'adresse : l'EDID répond en 0x50, le DAC en 0x18. Les pull-ups totalisent 23,5 kΩ — 47 kΩ côté HDMI en parallèle de 47 kΩ côté audio. Avec environ 35 pF de charge (DAC, FET du décaleur, pistes), le temps de montée vaut ~700 ns : conforme au mode standard 100 kHz dont la limite est 1000 ns, mais au-delà des 300 ns du mode rapide. Le DDC fonctionne de toute façon à 100 kHz, et la configuration du DAC ne représente que quelques écritures au démarrage. Le 400 kHz n'est pas disponible sur ce bus.

Cadence I2S

À 48 kHz, BCLK = 3,072 MHz, soit clkdiv = 41,015625 depuis 252 MHz. La partie fractionnaire vaut exactement 4/256 : le diviseur PIO du RP2350, en 16 bits entiers plus 8 fractionnaires, la représente sans erreur. À 44,1 kHz le diviseur tomberait sur 44,642857, non représentable exactement.

L'audio HDMI est cadencé par le temps vidéo, dérivé d'un pixel clock de 25,2 MHz au lieu des 25,175 MHz du standard : un écart de 0,1 %, soit environ 48 échantillons par seconde. Les deux voies ne peuvent donc pas jouer indéfiniment en parallèle depuis un même tampon sans rattrapage.

En pratique, une seule voie est active à la fois : RP1 lit l'EDID pour savoir si l'écran restitue le son et choisit en conséquence. Si les deux doivent jouer ensemble, cadencer l'I2S sur l'accumulateur de RP3 plutôt que sur 48 000 Hz exacts.

Alimentations

Rail Usage Note
3,3 V AVDD, IOVDD, HPVDD rail principal
1,8 V DVDD régulateur dédié, feuille POWER
2,7 à 5,5 V SPKVDD, ampli classe D avec SPKVDD ≥ AVDD

Broches restantes

Aucune. GP23 porte une LED d'état à travers 100 Ω, GP40 un bouton utilisateur vers la masse. Les 48 GPIO de RP1 sont affectés.


10. Le contrat vu par un jeu — boucle de trame et API gfx

Ce que voit un script Lua sur rp1_papydeck (RP1/src/main_game.c, gfx.c, lua_boot.c), au bit près, pour que le simulateur (papyDeck_Sim) rende la même image. Vue d'ensemble des contrats inter-puces : ArchitectureRoot.md §10 ; côté rendu : ArchitectureRP2.md §8 ; palette et modes : §7 ci-dessus.

La boucle de trame

Séquentielle, sur le cœur 0, une itération par VSYNC :

board_wait_vsync()                          front montant de VSYNC (GP39), bornée à 40 ms
board_dl_submit(liste, n, rp1_gfx_ctrl(),
                ordres, n_ordres)           la liste construite à la trame précédente part (DMA), avec ses ordres de chargement
rp1_gfx_frame(f)                            pool : quarantaine échue libérée, lot de la trame ouvert (§7)
_update()                                   logique de jeu
rp1_gfx_begin()                             count = 0 : la liste recommence
_draw()                                     gfx.sprite() × n
rp1_gfx_commit()                            stats.used = n ; le bloc C de la trame prend les écritures de carte en attente (≤ 624 o)
board_poll()                                consoles, touches, LED
  • Seuls les gfx.sprite faits dans _draw sont affichés. La liste n'est remise à zéro qu'entre _update et _draw : un gfx.sprite appelé dans _update s'ajoute à la fin de la liste précédente (déjà copiée dans le paquet par board_dl_submit), peut la faire déborder et compter dropped, puis est jeté par rp1_gfx_begin().
  • _draw est appelée inconditionnellement : le moteur ne la saute ni ne la rejoue jamais. La règle « _draw ne mute pas l'état » reste une bonne pratique, pas un mécanisme.
  • Aucun pas de ramasse-miettes en C : la VM est mise en mode incrémental (lua_gc(L, LUA_GCINC)) et c'est le script qui fait collectgarbage('step', 1) — le script de test le fait en fin de _update.
  • Si _update + _draw dépassent le VSYNC, board_wait_vsync() rend la main au suivant : la trame est répétée côté RP2 (rien n'est envoyé, §7), sans déchirure, et gfx.stats().repeats la compte : chaque front VSYNC écoulé au-delà de celui attendu entre deux tours de boucle est un repeat (board_vsync_count() comparé d'un tour à l'autre ; une attente qui expire sans aucun front, RP3 halté, n'en est pas un).

Latence bout en bout

Ce que _draw déclare à la trame k part au VSYNC k+1 avec les blocs de contrôle construits à ce moment ; RP2 l'adopte à sa ligne 480 de la trame k+1 et relaie les blocs sur ses lignes 480-483 ; RP3 applique palette, caméra et écritures de carte avant sa ligne 0 : sprites, palette, caméra et carte apparaissent à la trame k+2, atomiquement (2 trames + balayage ; ArchitectureRoot.md §6). Un gfx.color, un gfx.camera ou un gfx.text fait dans _update ou dans _draw de la trame k a le même effet — les écritures de carte qui ne tiennent pas dans les 624 octets du bloc C partent aux trames suivantes, dans l'ordre. Latence décidée le 2026-08-26 : on ne change pas. Mesure au chronomètre : ouvert.

L'API gfx

Table globale gfx. Les arguments sont des entiers Lua sauf x, y de gfx.sprite ; luaL_checkinteger accepte 3.0 et refuse 3.5 (« number has no integer representation »).

Fonction Arguments Effet Erreurs
gfx.sprite(id, x, y [, flags]) id entier, id de bitmap dans l'annuaire de l'atlas ; x, y nombres, arrondis par lroundf (demi vers l'extérieur : 100,5 → 101, −0,5 → −1), coin haut-gauche ; flags entier, défaut 0, masqué à & 3 ajoute une entrée à la liste, en chargeant le bitmap au premier usage (ordre dans le paquet de cette trame si le lot a de la place ; sinon le sprite est sauté cette trame, missing++, et revient dès que l'ordre est parti) ; ne renvoie rien jamais : id inconnu ⇒ unknown++, silencieux ; hors écran ⇒ clipped ; pool ou lot plein ⇒ missing ; liste pleine (256) ⇒ dropped, silencieux
gfx.load(id) id entier charge le bitmap sans le dessiner, en évinçant au besoin les bitmaps les moins récemment dessinés (LRU) ; → true s'il est résident ou en route avec le paquet de cette trame, false s'il ne peut pas l'être (working set plus grand que le pool, ou lot plein : il est alors en file) « gfx.load: bitmap %d inconnu »
gfx.unload(id) id entier décharge : pages en quarantaine 3 trames (ou rendues aussitôt s'il n'était pas parti) ; sans effet s'il est absent. Facultatif depuis le LRU : un conseil, pas une obligation « gfx.unload: bitmap %d inconnu »
gfx.resident(id) id entier true ssi l'ordre est parti ou part avec cette trame (READY) ; false sinon, id inconnu compris
gfx.size(id) id entier w, h depuis l'annuaire « gfx.size: bitmap %d inconnu »
gfx.pool() used_bytes, total_bytes, resident : octets de pages tenus par des bitmaps résidents ou en route, 327 680, nombre de ces bitmaps
gfx.stats() table {used, clipped, dropped, repeats, missing, unknown}
gfx.color(i, r, g, b) i ∈ 1..255 ; r, g, b entiers, sans écrêtage palette[i] = (r & 0xF8) << 8 \| (g & 0xFC) << 3 \| b >> 3 ; gen +1 si la valeur change « gfx.color: index %d hors de 1..255 »
gfx.color_get(i) i ∈ 0..255 r, g, b requantifiés depuis le RGB565 : r5<<3 \| r5>>2, g6<<2 \| g6>>4, b5<<3 \| b5>>2 « gfx.color_get: index %d hors de 0..255 »
gfx.palette(t) table ; t[k] = 0xRRGGBB pour l'index k − 1 ; #t plafonné à 256 t[1] (index 0) est lu — doit être un entier — puis ignoré ; les index au-delà de #t restent intacts ; ne touche ni modes ni backdrop ; gen +1 si au moins une couleur change argument non table, élément non entier
gfx.backdrop(r, g, b) entiers, mêmes règles de conversion que gfx.color couleur de « rien de dessiné » (index 0 du fond) ; gen +1 si elle change
gfx.effect(i, mode) i ∈ 1..255, mode ∈ 0..5 mode de mélange de l'index ; gen +1 s'il change ; le mode de l'index 0 n'est pas modifiable « gfx.effect: index %d hors de 1..255 », « gfx.effect: mode %d inconnu »
gfx.camera(x, y) entiers, pixels monde ; transportés en int16 caméra du fond pour la trame de cette liste ; pose le bit 0 des drapeaux pour toujours : dès le premier appel, RP1 pilote la caméra et le rebond de bring-up de RP3 s'arrête. Pixel entier, pas de sous-pixel. En mode NOR, c'est au jeu de rester dans [0, monde − écran] ; en mode local, tout est modulo 1 024 × 512
gfx.bg_local(on) booléen (lua_toboolean) true : la fenêtre de 128 × 64 tuiles de RP3 devient le monde, torique, jamais rechargée depuis la NOR — le mode texte ; false : retour au monde NOR, fenêtre rechargée entière
gfx.tile(x, y, entry) x, y entiers, tuiles (masqués à 255 par RP1, modulo 128 × 64 par RP3) ; entry entier, entrée de carte (id bits 0-9, gfx.TILE_FLIP_H, gfx.TILE_FLIP_V) un run d'une entrée dans la FIFO ; → true, ou false si la FIFO est pleine (map_dropped) types
gfx.tiles(x, y, t) t[1..n] entiers écrit t de gauche à droite à partir de (x, y), découpé en runs de 255 entrées au plus ; → false si l'un des runs a été perdu argument non table, élément non entier
gfx.print(x, y, s [, taille]) x, y nombres (pixels écran, arrondis comme gfx.sprite) ; s chaîne ; taille 1 (défaut, 8×8) ou 2 (16×16) du texte en sprites : une entrée de display list par caractère (l'espace n'en coûte pas), bitmap 64 + code (8×8) ou code (16×16) des fontes système de l'atlas (animations 0 et 1, ArchitectureRP2.md §8), chargés dans le pool à la demande comme n'importe quel sprite ; minuscules repliées, caractères absents ⇒ bloc plein ; transparent, à n'importe quel pixel, par-dessus le fond, encre 191 — pour les HUD ; avance de 8 ou 16 px par caractère ; → false si une entrée a été perdue (liste pleine) « gfx.print: taille %d inconnue (1 ou 2) »
gfx.text(x, y, s [, taille]) s chaîne ; taille 1 (défaut) ou 2 la fonte système (tuiles 704-1023 de tout tileset, ArchitectureRP3.md §5) : taille 1 = 8×8, un run par tranche de 255 caractères, tuile 960 + (c − 32) ; taille 2 = 16×16 (la fonte fight16.png, tout l'ASCII 32..95), deux runs (rangées y et y + 1) de 2 tuiles par caractère à 704 + 4·(c − 32), avance de 2 colonnes par caractère ; c ∈ 32..95, minuscules repliées sur les majuscules, tout autre caractère ⇒ le bloc plein en 8×8 (gfx.TILE_BLOCK, la case de # de la fonte arcade, qui n'a pas ce signe) ou ? en 16×16 ; encre = index 191 ; → comme gfx.tiles « gfx.text: taille %d inconnue (1 ou 2) »

Une composante > 255 ou négative passée à gfx.color / gfx.backdrop déborde dans les autres champs sans erreur (conversion par masques, en unsigned) : c'est au jeu d'écrêter.

Constantes : gfx.FLIP_H = 1, gfx.FLIP_V = 2 (sprites) ; gfx.TILE_FLIP_H = 0x400, gfx.TILE_FLIP_V = 0x800 (entrées de carte) ; modes gfx.TRANSPARENT = 0, gfx.OPAQUE = 1, gfx.TRANSLUCENT = 2, gfx.ADDITIVE = 3, gfx.SHADOW = 4, gfx.BEHIND = 5 (alignées sur rp3_row_mode_t).

Le fond — RP1 le pilote depuis le 2026-08-27 (gfx.c, « The background RP1 drives »). Les écritures de carte sont des runs {x, y, n, 0} + n × u16, encodés comme sur le fil dans une FIFO de 24 Kio ; à rp1_gfx_commit() le bloc C de la trame prend, dans l'ordre, les runs de tête qui tiennent dans 624 octets (4 octets d'en-tête et arrondi à 4 par run : ~300 entrées par trame, une rangée de 128 tuiles = 260 o, une ligne de 80 caractères = 164 o) ; les autres attendent la trame suivante (map_pending) ; FIFO pleine ⇒ run perdu, map_dropped++, false renvoyé. Aucun run n'est appliqué deux fois (seq du bloc C). RP3 écrit les entrées dans sa fenêtre de 128 × 64, torique ; en mode local (ArchitectureRP3.md §5) cette fenêtre est le monde (1 024 × 512 px) et n'est jamais rechargée ; en mode NOR les écritures sont des sur-impressions que la prochaine recharge de la rangée ou de la colonne efface. Ce que ça permet : du texte de HUD gratuit — aucune entrée de display list, aucun cycle du blitter ; un écran plein de 80 × 60 caractères en 16 à 20 trames, une ligne par trame avec de la marge. La caméra et les écritures d'une trame arrivent à l'écran avec ses sprites (k+2). Compteurs dans la ligne d'état Ctrl-L : [map] runs= entries= pending= dropped= (cumulés, et runs encore en file) ; gfx.stats() ne les expose pas. Mesuré avec le jeu de test (gfx.bg_local(true), damier de 128 × 64 rempli une rangée par trame, titre en (4, 4), briques et herbe en (4, 6), compteur « trame N » en (4, 8) toutes les 10 trames, caméra ((frame // 2) % 1024, 64)) : 12 runs / 144 entrées par 120 trames en régime établi, identiques côté RP3, 0 en attente, 0 perdu, RP1 3,2-3,8 ms par trame.

Sémantique des modes (par canal, sur le RGB565 étendu ; RP3/src/blend.c) : OPAQUE = couleur du sprite ; TRANSLUCENT = moyenne exacte sprite/fond ; ADDITIVE = addition saturée ; SHADOW = fond >> 1, couleur du sprite ignorée ; BEHIND = couleur du sprite si l'index de fond vaut 0, sinon fond ; TRANSPARENT = fond. Toute cellule dont l'index de fond vaut 0 prend le backdrop à la place de palette[0].

État initial (au démarrage de rp1_papydeck) : palette = scene_palette de RP3/build/palette.bin, index 0 inclus tel quel, 1..190 l'art, 191 le blanc système ; modes par défaut 0 TRANSPARENT, 1-191 OPAQUE, 192-223 TRANSLUCENT, 224-239 ADDITIVE, 240-247 SHADOW, 248-255 BEHIND (identiques à rp3_blend_default_modes) ; backdrop noir ; gen = 2.

gfx.stats() : used = nombre d'entrées au dernier rp1_gfx_commit — lue depuis _update ou _draw, c'est donc la trame précédente ; clipped, dropped, repeats, missing, unknown sont cumulatifs depuis le démarrage, jamais remis à zéro (les compteurs du fond ne sont pas dans la table : ligne [map] de Ctrl-L). missing = sprites sautés parce que leur bitmap ne pouvait pas être dans le pool à temps (chargement en file, ou working set plus grand que le pool) ; unknown = sprites dont l'id n'est pas dans l'annuaire. repeats = fronts VSYNC passés sans nouvelle liste (compté depuis le 2026-08-27 par différence de board_vsync_count() entre deux tours ; vérifié à +1 par raté provoqué toutes les 300 trames). Le jeu de test n'en fait aucun.

Clipping, en deux étages

  1. RP1, règle exacte depuis le 2026-08-27 (gfx.c, la taille vient de l'annuaire) : entrée écartée (clipped++) ssi x + w ≤ 0, y + h ≤ 0, x ≥ 640 ou y ≥ 480 — avant toute allocation : un sprite hors écran ne coûte ni entrée ni chargement. La marge intérimaire de 64 px (CLIP_MARGIN) a disparu.
  2. RP2, même règle : entrée écartée ssi y + h ≤ 0, y ≥ 480, x + w ≤ 0 ou x ≥ 640, sinon coupée aux quatre bords. FLIP_V : rangée source h − 1 − (Y − y) ; FLIP_H : colonne source w − 1 − cx, l'intervalle opaque [x0, x1) de la rangée devient [w − x1, w − x0) ; (x, y) reste le coin haut-gauche de la boîte dans les deux cas. Un pixel est écrit ssi il est dans l'intervalle opaque de sa rangée et non nul.

Ordre de peinture

Contrat : l'ordre de la display list — donc l'ordre des appels gfx.sprite dans _draw — est l'ordre de peinture ; le dernier déclaré est devant. C'est ce que le jeu et le simulateur supposent, et ce que RP2 fait depuis le 2026-08-27 (liste active triée par index de liste, autotest à lignes de départ différentes ; jusque-là un sprite déclaré après mais commençant plus haut passait derrière). Détail : ArchitectureRP2.md §8.

Programmes, sys et pad — le dashboard

Depuis le 2026-08-27, rp1_papydeck ne fait plus tourner un script mais des programmes, tous sur la même API : RP1/lua/dashboard.lua (le dashboard, celui de la console — au démarrage), embarqué comme octets par CMake (generated/lua_<nom>.h), et les jeux de la micro-SD (Notes-uSD.md). Le jeu de test du bring-up (torches.lua) est parti avec l'atlas de bring-up le 2026-08-31 : il adressait ses ids, que le bundle du jeu remplace.

Sortir de n'importe quel jeu : le bouton USER tenu 3 s, ou B tenu 3 s sur n'importe quelle manette (2026-08-31) — le firmware le voit avant le jeu, qui ne peut pas le confisquer ; sys.exit() reste la sortie propre d'un jeu qui en a une.

Fonction Effet
sys.run(nom) un programme du firmware, ou un jeu de la carte (papydeck/games/<nom>/main.lua, lu en entier — 256 Kio au plus — puis compilé ; le tampon est libéré aussitôt). Change de programme à la fin de la trame : la VM courante est fermée, une neuve créée (sandbox, gfx, pad, sys), la palette revient à celle de la scène, le backdrop à noir, la file d'écritures de carte est vidée — la carte du fond et le pool restent (le nouveau programme repeint, le LRU recycle). Erreur Lua si le nom est inconnu
sys.exit() sys.run("dashboard")
(bouton USER tenu 3 s) le geste de la console, hors de portée du programme : retour au dashboard quoi que fasse le jeu — un main.lua sans sys.exit() (ou cassé) ne piège plus la console. Le programme ne voit ni cet appui ni son « long » à 600 ms qui l'a précédé… si : il voit pad.A à 600 ms, puis la console reprend la main à 3 s
sys.games() { {name=, title=, source=}, … } — ce qui peut être lancé ; aujourd'hui les programmes du firmware moins le dashboard
sys.version() la version du firmware RP1 (git describe à la compilation)
sys.serial() l'identifiant unique de la puce, 16 hexadécimaux
sys.frame() trames depuis le démarrage
sys.install(cible, chemin[, offset]) programme la NOR de "rp2" ou "rp3" avec un fichier de la carte, à offset (défaut 0x100000, l'atlas et la scène). Bloquant, des secondes ; renvoie ok, message. Protocole common/flasher_target.h : réveil sur l'UART console à 115 200 bauds, puis 4 Mbauds, trames d'un secteur de 4 Kio avec CRC32, la cible compare avant d'effacer (S sauté / W écrit), redémarre à la fin — sa bannière [boot] fait le reste. Mesuré le 2026-08-28 : RP3 33 secteurs identiques 0,6 s, 33 écrits 2,3 s ; RP2 85 identiques 1,5 s, 85 écrits 6,0 s (~70 ms par secteur écrit, effacement compris). Pendant l'installation la cible coupe sa vidéo (RP3 : écran noir ; RP2 : plus d'overlay) et RP2 peut redémarrer par son watchdog faute de VSYNC quand c'est RP3 qu'on programme — sans conséquence. Console : Ctrl-F / Ctrl-G écrivent le fichier d'assets à 2 Mo (test du lien)
gfx.bundle() compile et installe le dossier du jeu — tous ses groupes d'un coup (ArchitectureRoot.md §10.9) : chaque groupe <g>.pal + <g>.atlas + ses PNG → un seul PDSP dans la NOR de RP2 (fontes système 0..127, puis toutes les régions, nommées <g>/<région>) ; chaque fond <g>-<f>.map + <g>-<f>-tiles.png → un PDSC dans celle de RP3, à 0x100000 + k·0x21000 (un PDSC vide avec les tuiles système si le jeu n'a pas de fond) ; palettes et variantes <g>.<v>.pal en RAM de RP1 ; annuaire dans le pool (repart vide) ; la première palette et le premier fond deviennent actifs. Renvoie ok, message. Bloquant, des secondes, écran noir (RP2 puis RP3 redémarrent). Jeu de la carte seulement. Le compilateur (RP1/src/pdcompile.c, portable, testé en natif par RP1/tools/pdcompile_host.c) utilise le core/ du simulateur (png.c, inflate.c, atlas.c, par chemin relatif — jamais de -I sur core/, son limits.h masquerait celui du système) ; assets système extraits de sprites.bin et scene.bin à la configuration (RP1/tools/mksysassets.py). Limites : 16 groupes, 24 palettes, 16 fonds, 128 pages, 1 024 régions (le format en permet 2 048 : pool à 32 Kio), images ≤ 65 536 px décodés, tileset ≤ 704 tuiles, carte ≤ 256×128
(empreinte, cache, marqueur — 3d, 2026-08-28) Avant tout, gfx.bundle() calcule l'empreinte du dossier = le ManifestHash de l'App, octet pour octet (common/pdsha256.c : sha256 sur les fichiers triés par nom, nom + NUL + hex(sha256(contenu)) + \n, sans papydeck.json ni les fichiers en .). Trois issues : « EN NOR » — le dernier secteur de la NOR de RP1 (RP1/src/state.c) mémorise l'empreinte de ce que RP2 et RP3 contiennent ; si elle correspond, rien n'est flashé, 0,1 s, pas d'écran noir ; « DU CACHE »papydeck/cache/<empreinte>/ contient rp2.pdsp, rp3.pdsc, bundle.dir (les tables telles quelles, version de cache en tête) et cache.json : les fichiers sont envoyés aux NOR par le lien (secteurs identiques sautés), ~2 s ; « COMPILE » — la compilation écrit les NOR et le cache d'un seul passage (le puits alimente les deux), ~2,6 s pour bundle-test, puis bundle.dir. Le marqueur est oublié par tout ce qui écrit les NOR autrement (Ctrl-F/G, Ctrl-U, Ctrl-K). FatFs est en écriture (FF_FS_READONLY 0, CMD24 bloc par bloc, watchdog nourri pendant le temps de programmation de la carte — des centaines de ms parfois) ; Ctrl-K vide le cache. Le balayage des caches orphelins n'est pas fait
gfx.palette(nom) la palette active — un groupe "niveau1" ou une variante "niveau1.nuit" — envoyée par les blocs A/B ; RP3 reconstruit sa table de mélange (1,3 ms en vblank si plus de 16 index changent). false si inconnue
gfx.fade(nom, trames) interpolation RGB565 de la palette courante vers nom en trames trames, une palette par trame ; les modes de rangée basculent à la fin. false si inconnue
gfx.background(nom) RP3 charge le fond "niveau2-grotte" depuis sa NOR (bloc A, offset 528 : u32 scene_off) : ~2 ms en vblank, pas d'écran noir. false si inconnu
gfx.id(nom) l'id de bitmap de la région "groupe/région" (128 + rang), nil sinon
sys.sd() la carte micro-SD : { present=, mounted=, label=, mib=, games=, gen= } ; gen change à chaque montage ou démontage, un programme le compare pour rafraîchir ses listes
require(nom) plat, minuscules ([a-z0-9_-]), comme dans le simulateur : <dossier du jeu>/<nom>.lua sur la carte, exécuté une fois, résultat mis en cache (registre papydeck.loaded) ; true si le module ne renvoie rien. Erreur Lua ordinaire si le fichier manque, ou depuis un programme du firmware. Pas de package, pas de chemins : le dossier d'un jeu est plat
sys.bg_lost() true une fois après un redémarrage de RP3 : sa carte du fond est vide, le programme repeint ce qu'il y avait dessiné (le dashboard refait son écran). Le simulateur répond toujours false
pad.get([n]) sans argument : n'importe quel pad — le OU de tous les pads connectés, plus le bouton USER et le clavier ; c'est ce qu'un menu ou un jeu à un joueur veut, quel que soit l'emplacement de la manette (2026-08-30 : la manette restante après un OUBLIER ne pilotait plus le dashboard). pad.get(n) : le joueur n (1..4 = emplacement de l'ESP). Le masque du pad pour cette trame, bits du simulateur : pad.LEFT 1, RIGHT 2, UP 4, DOWN 8, A 16, B 32, X 64, Y 128, L 256, R 512, START 1024, SELECT 2048. Sans manette, le bouton USER de la carte : un appui bref (relâché avant 600 ms) = DOWN, un appui long (600 ms tenus : l'événement part sans attendre le relâchement) = A — c'est l'entrée du dashboard, dont chaque écran finit par un item RETOUR. Et, pour le développement, le clavier de la console USB — flèches, Entrée ou z = A, Échap ou x = B, espace = START, Tab = SELECT, w/a/s/d = directions ; une touche est un appui, le bit n'est levé que la trame où elle arrive (un terminal ne connaît pas le relâchement). Seul le pad 1 a une source
pad.rumble(n, ms[, fort[, faible]]) vibration du pad n (1..4) pendant ms, amplitudes 0..255 (faible = fort par défaut). Une manette sans vibration l'ignore — le journal de l'ESP le dit (pas de vibration sur cette manette) ; le profil Android générique (8BitDo inconnues de Bluepad32) n'en a pas
pad.led(n, masque) les LED joueur du pad n, bit k = LED k. La console allume la LED de l'emplacement à la connexion
pad.color(n, r, g, b) la barre lumineuse (DualShock 4, DualSense)
sys.wifi() l'état Wi-Fi de l'ESP32 (2026-08-31, protocole v5) : { esp=, state="off\|idle\|connecting\|connected\|failed", scanning=, ssid=, ip=, rssi=, gen=, aps={ {ssid=, rssi=, secured=}, … } }aps est le dernier scan, du plus fort au plus faible, un par SSID, 10 au plus
sys.wifi_scan() dashboard seulement (voir ci-dessous) : lance une recherche ; les réseaux arrivent en quelques secondes (gen change)
sys.wifi_connect(ssid [, pass]) dashboard seulement : l'ESP32 mémorise le réseau (NVS) et s'y connecte, à chaque démarrage ensuite ; SSID ≤ 32, mot de passe ≤ 64 ; après trois échecs l'état passe à failed mais l'ESP réessaie lentement
sys.wifi_forget() dashboard seulement : oublie le réseau, se déconnecte
sys.http_get(chemin) → corps | nil, erreur dashboard seulement : GET <chemin> sur le serveur de l’App en mémoire (64 Kio) — « Le web » ci-dessous
sys.http_file(chemin, fichier) → ok, message dashboard seulement : le même, droit sur la carte
sys.mkdir(chemin), sys.write_file(chemin, texte), sys.rescan() dashboard seulement : la carte
sys.uninstall(nom) → ok | ok, message dashboard seulement : efface papydeck/games/<nom> (jeu listé, dossier plat — un sous-dossier inattendu fait tout refuser plutôt que laisser un demi-jeu). Le cache par empreinte reste (Ctrl-K). SELECT sur un jeu [SD] de l'écran JEUX ouvre la confirmation
sys.json(texte) → valeur | nil le JSON de l'App en table Lua (objets → tables, tableaux → séquences, null omis)
sys.ticks(), sys.ticks_us(), sys.sin(a), sys.cos(a), sys.score(n), sys.TOUR comme dans le simulateur (API Lua) : du compteur de trames, jamais de l'horloge ; sinus entier Q16.16 sur 1 024èmes de tour

Deux API sys, pas une (2026-08-31). Le dashboard est un programme Lua sur la même API qu'un jeu, mais ce qu'il fait est le système : lancer un jeu, programmer les NOR, appairer ou oublier une manette, choisir le réseau Wi-Fi. Ces fonctions — sys.run, sys.games, sys.install, sys.pair, sys.forget, sys.disconnect, sys.wifi_scan, sys.wifi_connect, sys.wifi_forget — ne sont posées que dans la VM des programmes embarqués dans le firmware (sys_console_lib dans main_game.c) ; un jeu de la carte ne les a pas (nil). Un jeu voit l'état — sys.wifi(), sys.pads(), sys.sd() — et sortira un jour vers un serveur ; il ne changera jamais de réseau. | gfx.fill(x, y, w, h, entrée) | un rectangle d'entrées de carte (runs de w, h rangées) |

Pièges rencontrés : un terminal envoie une flèche en trois octets (ESC [ A) d'un coup, et board_poll() n'en lisait qu'un par trame — la séquence se perdait ; il vide maintenant tout ce qui attend. Un Échap seul n'est tranché comme B qu'après deux trames sans suite.

Le dashboard (dashboard.lua, ~130 lignes) : mode texte 16×16 sur la carte locale (40 × 30 cellules), gfx.bg_local(true), caméra (0, 0), backdrop bleu nuit. Un écran = des lignes d'information puis des items, le curseur (un bloc en sprite qui clignote) ne va que sur les items, et chaque écran finit par RETOUR — de sorte qu'un seul bouton suffit : bref = item suivant (avec retour au premier), long = sélection. Le menu — JEUX, PARAMETRES, PAPYDECK WEB, MISE A JOUR, A PROPOS — et cinq écrans alignés sur la carte micro-SD à venir. Le canal vers la carte de RP3 est lent — 3 rangées de 80 entrées par trame (624 o) — et ça se voit : la première version effaçait puis redessinait tout l'écran à chaque appui (76 runs, 25 trames, un balayage visible). Depuis le 2026-08-28 : le curseur n'est que le sprite (un déplacement n'écrit rien dans la carte), chaque ligne est écrite pleine largeur et un cache par rangée saute celles qui ne changent pas, sans passe d'effacement — un changement d'écran coûte 16 runs (6 trames), mesuré par [map] runs. gfx.fill de tout l'écran ne sert qu'au démarrage et quand RP3 a redémarré (sys.bg_lost()). JEUX liste sys.games() et lance par sys.run ; le jeu revient par sys.exit() (appui long dans le jeu de test). Mesuré : 35 µs par trame au repos, une entrée de display list.

Le Wi-Fi — station, mémorisé par l'ESP32 (2026-08-31)

L'ESP32 porte la pile Wi-Fi (ESP/main/papydeck_wifi.cpp, protocole v5 : CMD_WIFI_SCAN, CMD_WIFI_CONNECT, CMD_WIFI_FORGET, CMD_WIFI_STATUS ; MSG_WIFI_STATUS à chaque changement et avec chaque hello, MSG_WIFI_AP un par réseau après un scan). Les identifiants vivent dans la NVS de l'ESP, à côté des clés de liaison Bluetooth : la console rejoint son réseau toute seule au démarrage, RP1 n'a rien à retenir. Le dashboard, PARAMETRES → WIFI : l'état, CHERCHER LES RESEAUX, un item par réseau (SSID -60 DB *, l'astérisque = protégé), OUBLIER. Choisir un réseau protégé ouvre le clavier à l'écran — une manette obligatoire : croix pour choisir, A tape, B efface (ou renonce si le champ est vide), START valide, SELECT bascule majuscules/minuscules. Les fontes de la console n'ont pas de minuscules : une minuscule s'affiche en 8×8, une majuscule en 16×16, ce qui se lit sans ambiguïté. Tout le clavier est en sprites (gfx.print), pas dans la carte de RP3 : il change à chaque appui.

Ce que le bring-up a coûté, et qui est dans sdkconfig.defaults : la partition d'application passe de 1 Mo à 2,5 Mo (ESP/partitions.csv, NVS et PHY inchangées — les clés survivent) ; le chemin critique Wi-Fi sort de l'IRAM, qui débordait de 6,7 Ko avec le contrôleur Bluetooth (ESP_WIFI_IRAM_OPT, ESP_WIFI_RX_IRAM_OPT à n) ; la pile de la tâche d'événements passe à 4 Ko (le tri des réseaux la faisait déborder — d'abord vu comme un blocage de r_global_int_stop dans une section critique) ; l'interruption de haut niveau du contrôleur BT est coupée (BTDM_CTRL_HLI=n, utile à l'audio, pas aux manettes). Les commandes RP1 → ESP portent 100 octets (un mot de passe fait 64) au lieu de 16. Ouvert : à quoi sert la liaison — le serveur de l’App (jeux, classement, mise à jour), heure — et le pont esptool à 460 800 bauds.

Le web — PAPYDECK WEB, les jeux publiés sur la carte (2026-08-31)

Le tuyau (protocole v6). RP1 a la carte et la logique, l'ESP32 a le réseau : RP1 envoie CMD_HTTP_GET { u16 id, chemin } (l'hôte est le serveur de l’App, TLS par le paquet de certificats d'ESP-IDF), l'ESP répond MSG_HTTP_HEAD (statut, longueur ou inconnue), le corps en MSG_HTTP_DATA { id, u32 offset, ≤ 56 octets } et MSG_HTTP_END { ok, erreur }. Fenêtre de 1 Kio : l'ESP n'a jamais plus de 1 024 octets d'avance sur ce que RP1 a acquitté (CMD_HTTP_ACK toutes les 512 octets reçus) — la carte SD a des latences d'écriture de centaines de ms et l'anneau de réception de RP1 fait 2 Kio. L'offset de chaque tranche fait voir une trame perdue (« PERTE SUR LE LIEN »), la longueur annoncée un transfert court (« TRANSFERT INCOMPLET »). Côté ESP, une tâche dédiée (papydeck_http.cpp, esp_http_client_perform et son gestionnaire d'événements — il décode le chunked du serveur, que le premier essai en _read ne savait pas) ; côté RP1, rp1_esp_http_get(chemin, puits, …) bloque, nourrit le watchdog, sert le lien et les consoles. Une requête à la fois. La connexion est gardée entre les requêtes (keep_alive_enable, un client esp_http_client persistant, fermé après 30 s sans requête — nginx coupe à 75 s ; si le serveur l'a fermée entre-temps, un second essai rouvre) : mesuré le 2026-09-01, 2,7 s pour une requête sur connexion neuve (la poignée de main TLS), 0,13 s sur la connexion gardée. Un jeu de 27 fichiers passe donc d'environ 85 s à moins de 10 s, le lien à 115 200 bauds devenant le poste principal (~0,1 s par Ko).

Identité, à venir (décidé le 2026-09-01) : l'ATECC608 de la V1 sera sur l'I2C de RP1. C'est donc RP1 qui signera — nonce du serveur + numéro de série, ECDSA P-256 par l'élément — et l'ESP restera un tuyau HTTPS ; le serveur vérifie avec la clé publique enregistrée à la sortie de chaque carte et rend un jeton de session. TLS reste l'authentification du serveur seule (paquet de certificats) ; pas de certificat client. Le téléchargement des jeux publics n'a pas besoin de ça — les scores et les jeux privés, si.

Ce qu'en fait le dashboard (Lua, fonctions réservées au firmware — « Deux API sys ») : sys.http_get(chemin) → le corps en mémoire (64 Kio au plus), sys.http_file(chemin, fichier) → droit sur la carte (un échec efface le fichier), sys.mkdir, sys.write_file, sys.rescan ; sys.json(texte) → table Lua (pour tous : un jeu parlera un jour à un serveur). L'écran PAPYDECK WEB : CHARGER LA LISTE (/api/gallery), un item par jeu publié — TITRE V2 puis TELECHARGER, SUR LA CARTE ou MISE A JOUR selon l'empreinte de manifeste : la release la porte, et papydeck.json écrit par le téléchargement la garde ({ "title", "release", "version", "manifest_hash" }, relu par le scan de la carte). Télécharger = /api/releases/<id> (la liste des fichiers) puis chaque fichier dans papydeck/games/<slug>/, puis le manifeste, puis sys.rescan() : le jeu apparaît dans JEUX. Un jeu copié à la main sur la carte n'a pas d'empreinte : il est « MISE A JOUR » tant qu'on ne l'a pas repris d'ici.

Ce que le bring-up a appris : la console de l'ESP (ESP_LOG*) partage l'UART du lien et écrivait directement dans la FIFO, en concurrence avec la file du pilote — une ligne de journal pouvait tomber au milieu d'une trame et casser son CRC (une tranche sur vingt perdue) ; uart_vfs_dev_use_driver(0) la fait passer par le pilote, une écriture = un bloc. pdMS_TO_TICKS(5) vaut zéro tick à 100 Hz : une attente d'ACK « de 10 s » comptée en tels délais durait une milliseconde. Et, sans rapport avec le web mais trouvé grâce à lui : le réveil du flasher lisait le premier octet reçu et le comparait à 'K' — or RP2 pousse son rapport console octet par octet depuis la même boucle, donc ce premier octet était souvent une lettre du rapport ; RP1 renvoyait le réveil à 115 200 à une cible déjà à 4 Mbauds, qui attendait des secteurs jusqu'à son watchdog (« RP2 NE REPOND PAS », et le [sys] rp2 a redemarre qui suivait). Le réveil lit maintenant jusqu'au 'K'.

Ajouté le même jour, par oubli : sys.ticks(), sys.ticks_us(), sys.sin, sys.cos, sys.score et sys.TOUR sur la carte, comme dans le simulateur — shmup téléchargé les réclamait.

Le moteur de jeu — la table phy (2026-08-31)

Le moteur du simulateur, tel quel. papyDeck_Sim/core/phys.c (acteurs en Q16.16, balayage pixel par pixel contre le motif, contacts, coyote, marche), core/mask.c (le motif de collision, .mask ASCII ou .msk binaire) et core/lua_phy.c (les bindings, la table phy) sont compilés par RP1/CMakeLists.txt par chemin relatif — comme png.c, inflate.c et atlas.c pour le compilateur de bundles, et jamais de -I sur core/. Un jeu qui tourne dans le simulateur donne donc les mêmes positions au bit près sur la carte : c'est la promesse du rejeu des traces, et elle ne tient qu'à un seul exemplaire du code. RP1/src/phy.c ne contient que ce que le moteur demande à son hôte (pd_phy_host_t) : lire <nom>.mask / <nom>.msk dans le dossier du jeu (rp1_storage_read, 64 Kio au plus), retrouver un bitmap par son nom (rp1_bundle_id, le même que gfx.id), et fournir le tampon du motif — alloué à la demande (un écran ASCII ≈ 5 Kio, texte et grille), jamais statique : un jeu sans phy.map() ne paie rien. Le monde repart vide à chaque programme (rp1_phy_open dans program_boot).

La référence complète est la section phy de l'API Lua. En bref :

Fonction Effet
phy.map(nom \| nil) → w, h le motif du niveau, nom.mask (ASCII, une case de 8×16 px, # = matière) essayé avant nom.msk ; vide le monde ; le dehors est solide
phy.solid(x, y) le motif à ce pixel — pour dessiner le niveau depuis sa géométrie
phy.gravity(gx, gy) en px/trame², Q16.16
phy.new(w, h) → id, phy.free(id) un acteur : une boîte, en pixels entiers ; ids 1-based
phy.pos, phy.vel, phy.px, phy.box, phy.anchor, phy.flags, phy.layer lisent, ou posent puis rendent ; entiers seulement — un flottant est refusé avec le remède (//)
phy.step() → franchissements, saturés une trame de monde, appelée depuis _update() ; budget PD_PHYS_SWEEP_MAX = 4 096 franchissements
phy.contacts(), phy.contact(k), phy.ground(id), phy.touch(id) les recouvrements du dernier pas (boîte balayée), le sol et le coyote (4 trames), les côtés heurtés
phy.anim_load(prefixe [, cadence, boucle]), phy.anim, phy.anim_done, phy.sprite(id) les animations : game/flamme0, game/flamme1… dans l'annuaire du bundle ; gfx.sprite(phy.sprite(a)) dessine l'acteur, ancre comprise

Constantes : phy.PX = 65536, phy.MASK, phy.GRAVITY, phy.LEFT/RIGHT/UP/DOWN. Limites (core/limits.h, partagé) : 256 acteurs, 32 couches, 256 contacts, 64 animations de 32 images, motif de 64 Kio. Coût mesuré : +9 Ko de code, +17 Ko de RAM statique (acteurs, contacts, animations) ; le dashboard laisse 235 Ko de tas libre. Jeux de test : plateforme (motif, gravité, saut) et torche (animations) du simulateur, copiés tels quels sur la carte.

Supervision et watchdogs

Depuis le 2026-08-28, les trois puces ont un watchdog matériel de 2 s, nourri une fois par trame (RP1 dans sa boucle, dont l'attente de VSYNC est bornée à 40 ms ; RP2 par le cœur 0 à chaque ligne ; RP3 à chaque trame composée) : un blocage ou un HardFault — dont le gestionnaire par défaut du SDK est une boucle infinie — redémarre la puce au lieu de figer l'écran. Et RP1 surveille les deux autres :

Signe Réaction de RP1
pas de VSYNC pendant 500 ms reset de RP3 par RUN (au plus un toutes les 2 s)
RP2 muet pendant 3 s (il imprime toutes les secondes ; le compteur est retenu tant que RP3 n'a pas repris) reset de RP2 par RUN (au plus un toutes les 3 s)
bannière [boot] rp3 … sur la console de RP3 RP3 est reparti avec une carte vide : sys.bg_lost() passe à true
bannière [boot] rp2 … sur celle de RP2 RP2 est reparti avec un pool vide : rp1_pool_invalidate() — tout redevient ABSENT, les pages libres ; les sprites se rechargent au prochain gfx.sprite

Le démarrage (2026-08-28, après la mesure de la rétro-alimentation — voir plus bas) : la première chose que fait chaque puce, avant même de toucher au régulateur et aux horloges, est de vérifier que ce démarrage est un redémarrage par le watchdog (watchdog_caused_reboot()) ; sinon elle se redémarre une fois par watchdog_reboot() — un reset complet de la puce, exactement l'événement qui remettait la carte d'aplomb quand le watchdog expirait après une mise sous tension. Pas de boucle possible : au second passage la cause est le watchdog. Puis board_init() de RP1 pulse RUN de RP2 et RP3 (10 ms bas) et les laisse en sortie haute — plus le simple relâchement sur le pull-up interne d'avant : la prochaine carte met un pull-down sur chaque RUN, pour que RP2 et RP3 restent en reset tant que RP1 l'est lui-même (ses GPIO flottent alors), et ne tournent jamais sur le rail fantôme. Conséquence utile : tout redémarrage de RP1 redémarre RP2 et RP3 — watchdog, Ctrl-W (scripts/rp3-reset.py --rp1), superviseur de tension de la V2, point de test. Vérifié : après Ctrl-W les trois compteurs repartent ensemble.

RP2 et RP3 impriment [boot] rpN reset|watchdog en fin d'initialisation ; RP1 reconnaît le tag octet par octet dans les flux qu'il relaie déjà — c'est ainsi qu'il apprend un redémarrage qu'il n'a pas ordonné (un watchdog). Un reset qu'il ordonne lui-même (supervision, Ctrl-R / Ctrl-T) a les mêmes conséquences, immédiatement. Le trou de VSYNC d'un redémarrage de RP3 est trop court pour servir de critère : la bannière est le seul signal fiable. RP1 dit la cause de son propre démarrage dans la ligne d'état : [sup] boot=watchdog (WATCHDOG.REASON.TIMER : un blocage ou un HardFault), reboot (FORCE : le redémarrage volontaire après la mise sous tension, ou Ctrl-W), sinon run / por d'après POWMAN CHIP_RESET — dont les drapeaux sont collants depuis la dernière mise sous tension, d'où leur rang. Attention : watchdog_enable_caused_reboot() du SDK ne sert à rien ici — watchdog_enable() réécrit le magic de scratch[4] à chaque démarrage, le test devient vrai pour tout démarrage.

La cause des pannes à froid, mesurée le 2026-08-28 (multimètre, carte hors tension, sondes débranchées, HDMI seul, moniteur allumé) : HPD_IN 5 mV, SCL 50 mV, SDA 1,3 V, rail 3V3 à 1,4 V. Le moniteur tire SDA vers son +5 V interne (hors norme, courant) et, à travers le TPD12S521 non alimenté, pré-charge tout le rail 3V3. Les RP2350 s'éveillent sur ce rail fantôme, leur POR interne se libère, et l'arrivée du vrai 3,3 V ne produit plus d'impulsion de reset : ils continuent depuis un état sale — HardFaults sur du code intact (RP1), horloges mortes (RP3). RP2 s'en tirait par tolérance de fabrication, pas par câblage. Un reset de puce suffit à guérir, d'où la parade firmware ci-dessus. Parade matérielle (V2) : superviseur de tension push-pull (~2,9 V) sur RUN de RP1 à la place du bouton reset (point de test à la place), pull-downs sur RUN de RP2/RP3. Hypothèses écartées en route, et pourquoi : diviseur flash (le code SRAM de RP3 était identique au binaire), tension du cœur (1,20 V n'a rien changé), boucle de masse HDMI/USB (elle frapperait RP2 aussi).

Vérifié le 2026-08-28 par les sondes : RP2 figé (halt) → « RP2 muet depuis 3 s : reset RP2 », [boot] rp2 reset, le curseur du dashboard se recharge (loads 3 → 4) ; RP3 figé 3 s → reparti et le dashboard repeint ([map] runs 76 → 228) ; RP1 parqué dans une boucle infinie en SRAM → redémarré par son watchdog (WATCHDOG.REASON = TIMER, boot=watchdog).

Piège : le watchdog est armé avec pause_on_debug (sinon un program d'openocd, qui halte le cœur plusieurs secondes, serait interrompu par le reset), et sur le RP2350 cette pause tient tant que le port de débogage est alimenté — c'est-à-dire après toute session openocd, jusqu'à la prochaine mise sous tension de la carte, cœur en marche ou non (WATCHDOG.CTRL.TIME reste à 2 000 000). Un test du watchdog passe par une mise hors tension, ou par l'effacement des bits PAUSE de CTRL (mww 0x400d8000 0x401e8480). Sans sonde — la console telle que l'utilisateur l'a — rien ne le retient.

Limites

256 entrées par trame ; écran 640 × 480 ; x, y transportés en int16 (le clipping RP1 rend le débordement inatteignable) ; id de bitmap valide 0..n_bitmaps − 1 (atlas de bring-up : 0..245 ; 512 au plus) ; pool de 320 Kio par pages de 64 o, 16 ordres et 96 Kio de chargements par trame (au-delà, file d'attente : le sprite manque une ou deux trames) ; 16 index de palette modifiés par trame avant la reconstruction complète chez RP3 ; 624 octets d'écritures de carte par trame (~300 entrées, 255 par run ; au-delà, file de 24 Kio, dans l'ordre) ; caméra en int16, pixels entiers ; carte locale de 128 × 64 tuiles ; toute erreur de type dans un argument est une erreur Lua ordinaire.

Sandbox et VM

  • Lua 5.4.8, LUA_32BITS : integer = int32 (déborde en silence à 2³¹), number = float simple précision (24 bits de mantisse) ; math.type, //, % se comportent en conséquence. Patch third_party/patches/lua-01-luaconf-allow-external-32bits.patch appliqué à luaconf.h pour que le réglage vienne de CMake.
  • Bibliothèques ouvertes : base, table, string, math, coroutine. Pas de io, os, package, debug ; dofile et loadfile sont nil (« attempt to call a nil value ») ; print va sur la console USB.
  • VM sur le cœur 0, luaL_newstate sur le malloc du SDK (PICO_MALLOC_PANIC=0 : le tas plein devient une erreur Lua rattrapable, pas un panic), pas d'arène statique (ouvert), PICO_STACK_SIZE 0x1000, lua_atpanic installé, script chargé comme source avec le nom de chunk @game.lua et un gestionnaire de traceback. Bring-up : le script est embarqué dans le C (GAME[] de main_game.c), pas lu depuis la µSD.
  • Erreurs : une erreur dans _update ou _draw imprime [lua] <nom>: <msg> sur la console et la trame suivante rappelle la fonction — pas d'arrêt, pas de menu. Une fonction _update ou _draw absente est ignorée en silence. Une erreur au chargement du script ([lua] load failed: … ou [lua] runtime error: … avec traceback) laisse la boucle tourner sans jeu.

La table papydeck — banc et mesure, pas pour un jeu

Exposée par lua_boot.c à toutes les cibles : papydeck.ticks() (ms, int32), papydeck.ticks_us() (µs masqués à 2³¹, ~36 min), papydeck.sleep(ms) (bloquant — dans un jeu il bloque la trame), papydeck.nop(), papydeck.nop4() (coût d'une traversée Lua → C). Le simulateur peut la fournir vide.

Ce qui n'existe pas

gfx.background, gfx.scroll, gfx.scroll_band, gfx.reserve : n'existent pas. Le fond se pilote par gfx.camera, gfx.bg_local, gfx.tile, gfx.tiles, gfx.text depuis le 2026-08-27 (les champs flags/pad du bloc A portent la caméra, le bloc C les écritures) ; un gfx.scroll à sous-pixel n'a pas de sens, le rastériseur de RP3 est au pixel. Restent ouverts : l'écran de démarrage, les écritures persistantes en mode NOR, le changement de tileset à chaud — RP3 n'a pas d'API pour ça (ArchitectureRP3.md §5). Pas de compaction du pool (l'éviction LRU existe, la défragmentation non). Pas d'entrées (manettes), pas d'audio, pas d'assets depuis la µSD (l'annuaire de l'atlas est embarqué à la compilation) : conception (§6, §8, §9), non implémentée.