Aller au contenu

RP2 — sprite engine

Note

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

Deuxième étage du pipeline papyDeck. Compose les scanlines d'overlay à partir d'un atlas de sprites résident en SRAM et les streame vers RP3.

Vue système : ArchitectureRoot.md


0. Vocabulaire

Trois notions distinctes, à ne pas confondre — c'est sur elles que reposent tous les budgets mémoire et cycles de ce document.

Terme Sens Unité
trame une image vidéo complète, 1/60 s 640×480, cadence 60 Hz
bitmap une image de sprite stockée : tableau de pixels 8bpp avec sa largeur, sa hauteur et son stride occupe des blocs du pool
pose un bitmap donné dans un cycle d'animation — « la pose 3 du cycle de marche » désigne un bitmap, pas une instance
instance une occurrence d'un bitmap à l'écran une entrée de display list, 16 octets

Un cycle de marche de 8 poses, c'est 8 bitmaps résidents. Vingt ennemis qui le jouent, c'est vingt instances — et toujours 8 bitmaps.


1. Brochage

GPIO Fonction Interface
0-1 Console + mise à jour depuis RP1 UART0
2-5 Display list depuis RP1 — SCLK GP2, MISO GP3 (inutilisé), MOSI GP4, CS GP5 esclave SPI en PIO (pio1), GPIOBASE = 0
26-41 Bus overlay 16 bits vers RP3 — GP26 porte OVERLAY_0 PIO, GPIOBASE = 16
42 Horloge du bus overlay PIO side-set, sortie
43 HREQ depuis RP3 entrée
44 HSYNC depuis RP3 entrée, non configurée (§3, point 7)
45 VSYNC depuis RP3 entrée, IRQ GPIO sur front montant

Le bloc GP26-45 est relié en miroir au bloc GP45-26 de RP3 : les deux puces se font face, et ce câblage évite de croiser seize pistes. RP2 émet dans l'ordre naturel ; c'est RP3 qui absorbe l'inversion, sans coût d'exécution — voir ArchitectureRP3.md §4. | 6-25, 46-47 | libres — 22 broches | |

Le port QSPI dédié porte la NOR de 16 Mo contenant l'atlas de sprites ; il ne consomme aucun GPIO.

Les broches 2-5 sont hors de la fenêtre PIO du bus overlay. Le bloc PIO du bus occupe 26-43 (seize données, CLK, HREQ — overlay_tx.pio), ce qui impose GPIOBASE = 16 à son bloc ; VSYNC (GP45) est une IRQ GPIO ordinaire, hors PIO, et HSYNC (GP44) n'est pas configuré. L'esclave SPI utilise donc un second bloc PIO, à GPIOBASE = 0 — le RP2350 en a trois.

Réception de la display list

Esclave SPI en PIO, échantillonné à clk_sys, SCK = 25 MHz (fixé par RP1, board.c). Le paquet PDDL fait 6 928 octets (§5) : 2,22 ms à 25 MHz.

La contrainte historique « tenir dans les 1,43 ms de vblank, donc ≥ 23 MHz » est caduque. Le paquet part de RP1 au front montant de VSYNC, arrive à VSYNC + 2,22 ms — après la fin du vblank, à cheval sur la trame active — et n'est adopté par RP2 qu'à sa ligne 480 de la trame qui suit ce VSYNC (§3 point 6, §5), soit ~14 ms plus tard : ~14 ms de marge. Le PIO reste justifié par le plafond du PL022 en esclave (SSPCLK = 12 × SCK ⇒ 12,5 MHz au plus : 4,4 ms, qui tiendraient aussi) — l'argument est la marge et la simplicité, pas le vblank.

Signal RP1 (maître) RP2 (esclave, PIO)
SCLK SPI1_SCLK — GP26 GP2
MOSI SPI1_TX — GP27 GP4
MISO SPI1_RX — GP28 GP3
CS SPI1_SS_N — GP29 GP5

Le brochage est celui du SPI0 matériel — MOSI sur GP4 (SPI0_RX), MISO sur GP3 (SPI0_TX), l'inversion habituelle entre maître et esclave. Le périphérique matériel reste donc disponible pour du débogage à basse vitesse.

En service depuis le 2026-08-26 (RP2/src/spi_rx.pio, dlist.c) : esclave en PIO sur pio1 SM0 à GPIOBASE = 0, mode 0, MSB first, CS avec pull-up côté RP2 (haut tant que RP1 se tait). Cadrage par CS : le CPU arme la machine avec le nombre de bits du paquet, elle attend CS haut puis bas, et compte exactement 6 928 × 8 bits — un in par front montant de SCLK — sans se resynchroniser sur CS en cours de paquet : un paquet plus court ou coupé désaligne la réception jusqu'au réarmement suivant. CS doit donc rester bas sur les 6 928 octets. Autopush 32 bits à gauche : le premier octet arrive dans le poids fort du mot, et le CPU rétablit le petit-boutiste par bswap32 à l'adoption. DMA (DMA_IRQ_1) vers deux tampons alternés de 6 928 octets ; l'IRQ de fin ne vérifie que le magic (bad++ sinon), marque le paquet en attente et lance les ordres de chargement qu'il porte (§6, depuis le 2026-08-27) ; le cœur 0 l'adopte comme liste et blocs de contrôle de la trame suivante quand il compose la ligne 480 (§3 point 6, §5). Sans paquet en attente à ce moment, RP2 construit sa scène de test locale (§8). Mesuré à 25 MHz, RP1 actif : 60 paquets/s, tous adoptés, late = 0, desync = 0, lead_min = 8 (2026-08-26, paquet de 5 648 o) ; le 2026-08-27 au soir, paquet de 6 928 o et quatre blocs de contrôle : late = 1, desync = 1 (démarrage), txstall = 2, 0 rejet. L'échantillon tombe ~3 cycles clk_sys après le front de SCK : 25 MHz laisse 20 ns de tenue, au-dessus de 30 c'est marginal.


2. Chaîne de composition

Phase Cœur Contenu
En continu PIO + DMA Réception du paquet PDDL de la trame suivante — 2,22 ms, à cheval sur la trame active, asynchrone — puis ses ordres de chargement, NOR → pool, en DMA. Zéro CPU.
Ligne 480 cœur 0 Adoption : résolution des id, bucket-sort des instances par scanline de départ, copie des blocs de contrôle — quelques milliers de cycles, pris sur l'avance de l'anneau (§3).
Par ligne CPU Blit des tranches de sprites intersectant la scanline dans un line buffer 8 bpp, transparence comprise.
En parallèle DMA + PIO Émission du line buffer précédent vers RP3. Zéro CPU.

Découpage bi-cœur

Cœur 0 compose les lignes paires, cœur 1 les impaires, dans un anneau de 12 blocs de ligne (slot = ligne % 12, 644 o par bloc, 7 728 o au total — « Cadence de service », §3). Aucun verrou, aucun partage de slot : chaque cœur ne touche que ceux de sa parité.

Chaque cœur dispose donc de deux temps-ligne, soit 16 000 cycles à 252 MHz, pour composer une ligne. Le DMA de sortie les émet en alternance dans un ordre statique. Dérivation complète en §3.

Format du line buffer — 8 bpp

uint32_t ring[12][1 + 160];   // par slot : le compte de transferts (319), puis 640 pixels
// un index de palette par octet, rien d'autre

Le line buffer est l'image exacte du format du bus, un octet par pixel : aucune mise en paquets à la sortie. L'autopull 32 bits de RP2 émet quatre pixels par mot, en miroir de l'autopush 32 bits du récepteur de RP3 (RP3/src/overlay_rx.pio). Le DMA lit le buffer tel quel.

Priorité devant/derrière le fond et effets de composition sont portés par l'index et résolus par RP3 — voir ArchitectureRP3.md §3. RP2 ne manipule que des index.

Convention d'émission — à ne pas « corriger »

Un transfert porte deux pixels. Leur placement est normatif, énoncé en noms de signaux parce que c'est le seul vocabulaire commun aux deux puces :

Sur le fil, le pixel 2k voyage sur OVERLAY_0..7, le pixel 2k+1 sur OVERLAY_8..15.

C'est ce que l'autopull produit gratuitement : le tampon de ligne est en 8 bpp dans l'ordre naturel, le décalage se fait à droite, la moitié basse sort donc en premier. RP2 n'a rien à faire. L'inverse lui coûterait un échange d'octets et ne ferait rien gagner à RP3.

Le piège. Le bus étant câblé en miroir, RP3 reçoit le pixel 2k dans l'octet de poids fort, et sa boucle de composition compense par le placement des décalages — à coût nul. Les deux puces ont donc un levier sur le même effet. Corriger l'échange des deux côtés l'annule et réintroduit le défaut : des pixels adjacents transposés, invisibles hors d'une image test.

Si la convention doit changer un jour, changer l'énoncé ci-dessus d'abord, puis les deux côtés ensemble. Jamais un seul.

Chemin rapide du blitter

Index 0 réservé au transparent. Détection d'octet nul par mot, sur les octets source :

(x - 0x01010101) & ~x & 0x80808080

Source et destination sont toutes deux en 8 bpp : le test et l'écriture portent 4 pixels d'un coup. Par groupe de 4 pixels — un load de 4 index, le test de nullité, un store. Il n'y a pas de ror : le M33 fait des LDR/STR non alignés, et aligner la tête coûte plus cher que le non-aligné (mesuré +11 %, « L'assembleur », §3). Un mot entièrement nul est sauté ; un mot mixte s'écrit octet par octet (it + strb).

Mesuré le 2026-08-26 (RP2/src/blit.S, déroulé ×4) : 2 cycles/px sur les intérieurs opaques, ~6,5 cycles/px sur les mots mixtes. (Estimation d'avant-mesure : « ≤ 4,3 c/px ».)

C'est le seul endroit du projet où l'assembleur ARM est employé : blit.S, les deux boucles d'intervalle (droite et miroir).

Capacité

Mesuré le 2026-08-26 sur la scène dense (§8) : 3 000 sprite-pixels opaques par scanline tiennent dans le budget par cœur (15 774 cycles sur 16 000, 48 entrées actives) ; en mixte, ~2 200 px. La Neo Geo tenait 1 536 px/ligne — on est à ~2×. (Estimation d'avant-mesure : 2 650 à 2 950 px avec 15 % de marge, soit 83 à 92 sprites de 32 px.)

Pour les très petits sprites, c'est le setup par instance et par ligne (~100 cycles mesurés) qui domine et non les pixels : de l'ordre de 125 sprites de 12×12 par scanline, effacement compris (estimation à partir des mesures). Dérivation et mesures en §3, « D'où sortent les limites ».

Boucle du blitter

À la scanline Y, pour une entrée active (rp2_blit_line, sprites.c) :

r    = Y - y                          // rangée dans le bitmap ; r ≥ h ⇒ l'entrée sort de la liste active
si FLIP_V : r = h - 1 - r             // la rangée se lit depuis l'autre bout
[x0, x1) = intervalle opaque de r     // 2 × u16 rangés dans l'atlas à src - 4·h + 4·r (§4, §8)
si x1 ≤ x0 : rangée vide, entrée suivante
row  = src + r * stride               // octets, une multiplication
sans FLIP_H : copier row[x0..x1) vers dst + x + x0
avec FLIP_H : copier row[x0..x1) à l'envers (un `rev` par mot) vers dst + x + w - x1
… en sautant les index valant 0

stride reste en octets — c'est la géométrie du bitmap source ; source et destination sont toutes deux en 8 bpp. Seul l'intervalle opaque de la rangée est visité : sur les torches de bring-up, cela a retiré 89 % des mots (§3). Clipping en deux étages : à l'adoption, une entrée entièrement hors écran (y + h ≤ 0, y ≥ 480, x + w ≤ 0 ou x ≥ 640) n'entre dans aucun casier ; par ligne, l'intervalle est coupé aux bords gauche et droit (destination négative : on avance la source d'autant et on réduit le compte ; on tronque à 640) ; verticalement, l'entrée n'est active que pour max(y, 0) ≤ Y < y + h. Le contrat pixel exact est en §8.


3. Timing

RP2 ne possède pas le temps vidéo : il le subit. RP3 a le HSTX, donc l'horloge pixel, et exporte HREQ, HSYNC et VSYNC. Tout ce qui suit en découle.

La grille

Trame = 525 lignes × 31,746 µs = 16,667 ms  (60 Hz)
┌───────────────── 480 lignes actives ──────────────────┬──── 45 lignes vblank ────┐
│  L0    L1    L2    …                            L479  │         1,43 ms          │
└───────────────────────────────────────────────────────┴──────────────────────────┘

1 ligne  = 31,746 µs = 8 000 cycles à 252 MHz, exactement
1 vblank = 45 × 8 000 = 360 000 cycles

Les 8 000 cycles tombent juste, et ce n'est pas une coïncidence : clk_sys vaut exactement 10 × l'horloge pixel (252 / 25,2), et le total horizontal fait 800 pixels. C'est le genlock qui le garantit — les deux puces comptent le même nombre de cycles par ligne, indéfiniment, sans dérive. Un temps-ligne est donc un nombre entier de cycles CPU, ce qui rend les budgets ci-dessous exacts et non statistiques.

Une ligne

Ligne Lk — 8 000 cycles
HREQ ↓
├──── rafale PIO + DMA : 320 transferts × 6 cycles = 1 920 cycles (7,6 µs, 24 %) ─┤
│     émission du line buffer de la ligne k — zéro CPU                           │
│                                                                                │
├──── CPU, en parallèle : compose une autre ligne, sans rapport avec la rafale ──┤
└────────────────────────────────────────────────────────────────────────────────┘
                                                        ↑ 6 080 cycles de bus libre

Les 6 cycles par échantillon ne sont pas un choix de RP2 : ils sont fixés par le programme du récepteur, RP3/src/overlay_rx.pioin pins, 16 [4] puis jmp x--, soit 1 + 4 + 1. 252 / 6 = 42 MHz. RP2 doit produire l'horloge du bus au même rythme, en side-set.

Mesuré sur la carte le 2026-08-26 avec l'émetteur de bring-up (RP2/src/main.c, motif connu des deux côtés) : RP3 échantillonne sans erreur pour un délai de 10 à 13 cycles après son front HREQ, soit 4 cycles de fenêtre sur les 6 de la période ; il est réglé à 12 (RP3_RX_PHASE_DELAY). Le protocole lui-même a une exigence non écrite jusque-là : RP2 doit pouvoir servir une ligne à la cadence de composition de RP3 (~27 µs, 6 700 cycles, en début de trame, quand RP3 prend ses 6 lignes d'avance — RP3_LINE_BUFFERS − 2), pas seulement à celle du balayage. Un émetteur qui calculait ses lignes en 25 µs ratait, au bring-up, deux HREQ par trame et décalait tout d'une ligne.

La rafale occupe 24 % de la ligne (chiffre corrigé du 16 bpp initial : deux pixels par transfert depuis le passage en 8 bpp). Le reste du temps le bus est au repos. Les quatre blocs de contrôle (A palette et caméra, B modes, C écritures de carte, D réservé) vers RP3 sont les lignes 480 à 483 de la trame bus de 484 lignes (RP2_LINES_PER_FRAME), demandées par HREQ exactement comme des lignes de pixels, dès que RP3 a composé sa ligne 479 — donc pendant les dernières lignes actives, pas « pendant le vblank ».

Le nombre de lignes de contrôle doit être pair (RP2_CTRL_LINES = 4), et rester tel. Les deux cœurs se partagent les numéros de ligne absolus par parité (ci-dessous), et l'adoption du paquet suivant se fait sur le cœur qui compose la ligne 480 (point 6). Avec 483 lignes — essayé le 2026-08-27 — la ligne 480 tombait une trame sur deux sur le cœur 1, et les deux cœurs s'attendaient pour toujours : RP2 muet, RP3 comptant un bloc invalide par trame. Le bloc D, à zéro et ignoré par RP3, n'existe que pour ça.

Deux cœurs, une ligne sur deux

slots :      L0        L1        L2        L3        L4        L5
DMA sort : [ligne 0] [ligne 1] [ligne 2] [ligne 3] [ligne 4] [ligne 5]

cœur 0 :   ├─── compose ligne 2 ───┤├─── compose ligne 4 ───┤├── ligne 6 ──
cœur 1 :             ├─── compose ligne 3 ───┤├─── compose ligne 5 ───┤

           │◄──── 16 000 cycles ────►│

Chaque cœur ne produit qu'une ligne sur deux, donc dispose de deux temps-ligne par ligne composée. C'est un budget de débit, pas de latence : le double-buffering par cœur donne du mou sur le placement exact, mais la moyenne à tenir est bien de 16 000 cycles par ligne.

Deux buffers par cœur suffiraient pour le débit — on écrit dans l'un pendant que le DMA lit l'autre. Mais RP3 demande en rafale (six lignes d'avance en début de trame) : l'anneau fait douze tampons, voir « Cadence de service » ci-dessous.

Cadence de service — le contrat avec RP3

Décidé le 2026-08-26, après l'étape 3 du bring-up, avant d'écrire le blitter.

Le protocole n'a pas de contrôle de flux, et c'est voulu. HREQ est une demande à réponse à latence fixe : RP3 lève la ligne et échantillonne 12 cycles plus tard, quoi qu'il arrive. Si RP2 n'a pas la ligne armée, RP3 capture du vide et RP2 rate le front — décalage d'une ligne jusqu'au VSYNC suivant, constaté au bring-up. Ajouter un acquittement (par exemple attendre le premier front de OVERLAY_CLK) ferait perdre l'échantillonnage système-synchrone, la seule raison pour laquelle 42 MHz est confortable. On garde la demande sans flux et on met la robustesse côté RP2.

Ce que RP3 demande. Le cœur 0 de RP3 compose aussi vite qu'il peut jusqu'à RP3_LINE_BUFFERS − 2 = 6 lignes d'avance sur le balayage, puis suit le balayage. Après son tic vsync (à la ligne 10 du vblank) et 20 µs de grâce, il demande donc 6 lignes d'affilée à ~27 µs (6 700 cycles chacune, mesuré), puis une toutes les 31,75 µs. En moyenne : le balayage. En rafale : 6 lignes en 160 µs.

Ce que RP2 produit. Une ligne coûte jusqu'à 16 000 cycles sur un cœur (63 µs), les deux cœurs alternant : débit 1 ligne par temps-ligne, le balayage exactement — mais une ligne isolée peut prendre deux temps-ligne. « Précomposer les premières lignes pendant le vblank » ne tient pas : la display list de la trame arrive à VSYNC + 2,22 ms (6 928 o à 25 MHz), après la fin du vblank (VSYNC + 1,11 ms).

Contrat retenu : RP2 compose en continu, avec une avance permanente, et l'émission ne dépend jamais de la composition.

  1. Anneau de RP2_LINE_BUFFERS = 12 blocs de 161 mots (640 o de pixels précédés du mot de compte, 7 728 o — les « quatre buffers » du premier §2 étaient un choix, pas un calcul). Le cœur 0 compose les lignes paires, le cœur 1 les impaires, chacun dans le slot ligne % 12.
  2. RP2 ne s'arrête jamais : après la ligne 479 il enchaîne sur la ligne 0 de la trame suivante, avec la display list suivante — déjà reçue depuis une trame et adoptée à la ligne 480 (point 6 ; sans paquet, la scène de test locale, §5 et §8 — comportement de bring-up). Le vblank n'est pas un temps de composition, c'est du temps gagné : pendant les 45 lignes de blanking, l'avance remonte.
  3. Garde d'anneau, la même que celle de RP3 (§3 de RP3, « N−2 ») : un cœur ne compose la ligne L que si L − dernière_ligne_armée ≤ RP2_LINE_BUFFERS − 2. Le slot de la ligne en cours de lecture par le DMA et celui de la suivante sont vivants.
  4. L'émetteur arme toujours la ligne suivante, composée ou non — comme le callback de RP3 rend toujours un pointeur. Si la composition est en retard, la ligne sort telle qu'elle est — le slot contient la ligne L − 12, de la même trame sauf pour L < 12 — et un compteur late l'enregistre : une ligne fausse, jamais une trame décalée. Le cœur en retard ne rattrape pas la ligne perdue : il saute à la prochaine ligne de sa parité au-delà de celle qui s'arme, et les lignes sautées ne sont jamais composées. C'est le même choix que sur RP3, pour la même raison : un retard doit être visible dans un compteur, pas dans un artefact qui dure jusqu'au VSYNC.
  5. Condition de service : l'avance de RP2 doit rester ≥ 6 + 1 = 7 lignes face à la rafale de RP3. Avec 12 tampons, l'avance en régime établi est 10 (12 − 2) ; elle absorbe 3 lignes consécutives à 16 000 cycles avant le premier late. Le tri par bucket de la nouvelle liste, quelques milliers de cycles au changement de trame, se prend sur cette avance.
  6. Ce que ça impose à RP1 : la display list de la trame N doit être chez RP2 avant que RP2 n'adopte — ce qu'il fait à sa ligne 480 de la trame N−1, la première ligne de contrôle, soit ~10 lignes avant le VSYNC qui précède N. Envoyée au VSYNC de N−1 (le contrat de trame de Notes-RP1.md), elle arrive 2,2 ms plus tard : quatorze millisecondes d'avance. Ce que _draw() déclare pendant la trame k est envoyé au VSYNC k+1 et affiché à la trame k+2 : deux trames plus le balayage. (Jusqu'au 2026-08-26 au soir, RP2 adoptait à la ligne 0 de la trame suivante — composée avant le VSYNC, donc avant l'arrivée du paquet — et c'était en réalité trois trames. Mesuré au raisonnement, pas au chronomètre : à instrumenter.) Une trame de moins est possible en envoyant la liste dès que _draw() a fini ; RP2 n'a rien à changer pour ça.
  7. HSYNC reste inutilisé. RP2 n'a pas besoin de la position du faisceau : sa garde se fonde sur ses propres armements, qui sont les demandes de RP3. VSYNC ne sert qu'à vérifier que la ligne armée est bien la 0 et à compter les désynchronisations, qui doivent rester à zéro.

Mesuré le 2026-08-26, sur la carte, avec l'anneau de 12 et un faux blitter qui brûle exactement N cycles de temps réel par ligne sur chaque cœur (DWT, interruptions comprises), face au RP3 complet — fond tuilé, vérification de bus, rafale de 6 lignes en début de trame :

cycles par ligne et par cœur late par trame lead_min erreurs de bus
16 000 — le budget 0 6 0
17 000 (+6 %) 314 0 oui
18 000 (+12,5 %) 373 0 oui

Deux enseignements. L'anneau tient exactement ce que le contrat demande : sous la rafale de RP3 l'avance descend à 6 et n'atteint jamais 0. Et le budget est une falaise, pas une pente : dès que la production passe sous la consommation, l'avance de 9 s'épuise en ~150 lignes et le reste de la trame est faux — le vblank ne rend que 45 lignes d'avance, pas un déficit cumulé. L'anneau absorbe la gigue d'une ligne, jamais un dépassement soutenu. Les 16 000 cycles sont donc une borne dure, interruptions et contention SRAM incluses, et la « marge de 15 % » de la section suivante ne peut se prendre que sur la composition elle-même. Le DMA NOR → SRAM du chargement d'assets n'était pas actif pendant la mesure : à remesurer quand il existera (le seul DMA NOR → SRAM aujourd'hui est la copie de l'atlas au boot, §4).

Le vblank

45 lignes, 360 000 cycles, pendant lesquels le CPU n'a aucune ligne à composer — l'avance de l'anneau remonte à son maximum (10). Ce qui s'y passe, et ce qui ne s'y passe plus :

Quoi Coût Quand, qui
Réception de la display list de la trame suivante 2,22 ms à 25 MHz, zéro CPU PIO + DMA, de VSYNC à VSYNC + 2,22 ms : déborde du vblank sur la trame active ; ses ordres de chargement partent alors en DMA (§6)
Bascule de liste (parité de trame) et copie des blocs de contrôle quelques centaines de cycles cœur 0, à la ligne 480 de la trame précédente — pas au vblank
Résolution des id et bucket-sort des instances par scanline quelques milliers de cycles idem, ligne 480

Le vblank est donc entièrement libre côté CPU. Les chargements NOR → SRAM par trame et la compaction (§6) qui devaient s'y insérer ne sont pas implémentés.

D'où sortent les limites

Pour une scanline donnée :

coût(ligne) = effacement + Σ  ( setup + largeur_opaque × c_px )
                        instances intersectant la ligne

avec, mesurés le 2026-08-26 (« L'assembleur », plus bas) : effacement ≈ 465 cycles (memset de 640 octets), setup ≈ 100 cycles par entrée active et par ligne (tête de liste, intervalle, clipping, appel), c_px = 2 sur les intervalles opaques et ~6,5 sur les mots mixtes. Le budget est 16 000 cycles par ligne et par cœur, et c'est une borne dure : la mesure « falaise » ci-dessus montre qu'à 17 000 le reste de la trame est faux.

Cas Mesure / calcul Résultat
Scène dense opaque (RP2_TEST_SCENE 1) : 48 entrées actives, 3 000 px en pointe 10 026 cycles en moyenne, 15 774 en pointe 3 000 px opaques par ligne tiennent
Scène dense mixte (2) : 3 000 px en pointe 20 330 en moyenne, 33 900 en pointe — hors budget ~2 200 px mixtes par ligne
Scène d'orbite (0) : ~38 entrées de 20 px, 101 px au plus 1 790 / 2 680 tout en setup
Sprites de 12×12 opaques (16 000 − 465) / (100 + 12 × 2) ≈ 125 par scanline (estimation)

Deux régimes, et c'est la largeur qui décide lequel s'applique. Le setup coûte autant que 100 / 2 = 50 pixels opaques : au-dessus de ~50 px de large, le coût est dominé par les pixels et croît avec la surface ; en dessous, il est dominé par le setup et croît avec le nombre d'instances. Un bullet-hell est limité par le nombre de projectiles, un jeu de plateforme par leur taille — et la prochaine étape côté cycles est le setup, pas la boucle.

(Estimation d'avant-mesure, gardée pour mémoire : setup ≈ 20, c_px ≈ 4,3, 15 % de marge soit 13 600 cycles, d'où « 83 à 92 sprites de 32 px, 2 650 à 2 950 px, 190 sprites de 12×12, 3 163 px inatteignables ». La mesure a fait mieux sur les pixels et bien pire sur le setup.)

La marge de 15 % — tranché par la mesure. Les 16 000 cycles sont une borne dure, interruptions, armement des DMA et parcours des casiers compris (ils étaient dans la mesure) ; l'anneau n'absorbe que la gigue d'une ligne. Une marge ne peut donc se prendre que sur la composition elle-même, en ne lui demandant pas les 16 000. Reste à remesurer avec un DMA NOR → SRAM actif en même temps (il n'existe pas encore, §6) : c'est le seul poste absent de la mesure.

Première mesure du blitter — la version C, 2026-08-26

Sur la carte, avec l'anneau de §3 et une display list de test de 256 entrées — 200 torches de 20 × 48 animées (32 images) et 56 impacts de 48 × 48, en orbite — atlas de 255 Ko à l'époque (sans intervalles ; 319 744 o aujourd'hui, 97 % du pool, §4) copié de la NOR en SRAM au démarrage, blitter C de référence (RP2/src/sprites.c : effacement de la ligne, tri par scanline de départ, liste active par cœur, mot par mot avec test d'octet nul) :

par ligne
Sprite-pixels visités, moyenne / max 610 / 1 572
Entrées actives, max 38
Cycles, moyenne / max 4 650 / 9 300 (29 % / 58 % du budget de 16 000)
Soit ~7,6 cycles par pixel visité, 6 en pointe
late / lead_min 0 en régime établi / 6

Et la répartition des mots de 4 pixels visités, sur 60 trames : opaques 3,6 %, mixtes 7 %, entièrement transparents 89 %. Les boîtes englobantes de ces sprites sont presque vides. Le chemin « un mot entier d'un coup » ne sert donc presque jamais ; ce qui a fait tomber la moyenne de 6 650 à 4 650 cycles, c'est un test « mot vide → on saute » ajouté en instrumentant.

Ne pas visiter le vide — fait, mesuré le même jour. L'atlas range devant les pixels de chaque bitmap l'intervalle opaque [x0, x1) de chaque rangée (4 octets par rangée, +18 % d'atlas) ; le blitter le retrouve à src − 4·h — l'entrée de display list garde ses 16 octets — et ne parcourt que lui :

sans intervalles avec intervalles
Pixels visités par ligne, moyenne / max 610 / 1 572 38 / 101
Cycles par ligne, moyenne / max 4 650 / 9 300 1 675 / 2 700
Part du budget de 16 000, moyenne / max 29 % / 58 % 10 % / 17 %

Le coût est désormais celui du setup par entrée (~38 entrées actives par ligne ; ~100 cycles chacune, mesure de « L'assembleur » ci-dessous — le seul chiffre de setup à retenir : tête de liste, intervalle, clipping, appel) plus l'effacement de la ligne (~465 cycles) — le régime « petits sprites, limité par le nombre » de « D'où sortent les limites », et non plus par la surface.

Borne côté pixels — scènes denses, 2026-08-26

Deux bitmaps synthétiques de 64 × 64 dans l'atlas, en 256 exemplaires sur un réseau de 16 × 16 au pas de 40 × 24 px, en dérive d'un pixel par trame pour parcourir tous les alignements : ~2 100 sprite-pixels par scanline en moyenne, 3 000 en pointe — la capacité visée. L'un est entièrement opaque (le chemin rapide seul), l'autre un damier de 1 px (chaque mot mixte, le chemin lent seul). Blitter C, compteurs de la boucle chaude désactivés (RP2_BLIT_STATS = 0, ils coûtaient 10 % ; depuis l'assembleur, RP2_BLIT_STATS = 1 recompile la boucle C à la place de l'asm, les compteurs de mots ne décrivent donc jamais l'assembleur) :

Scène (RP2_TEST_SCENE) px/ligne moy / max cycles/ligne moy / max cycles par pixel
1 — dense opaque 2 100 / 3 000 12 960 / 19 260 5,5 à 5,9
2 — dense mixte 1 800 / 3 000 21 030 / 36 800 11 à 12
0 — orbite, art réel 38 / 101 1 540 / 2 400 setup ≈ 1 500 fixes

Lu contre l'estimation : le chemin rapide C coûte 5,9 cycles/px au lieu des 4,3 comptés (une vingtaine de cycles par mot : chargement non aligné, test d'octet nul, rangement non aligné, boucle), et les mots mixtes en coûtent le double. Dans le budget de 16 000 cycles, une fois les ~1 500 de setup retirés, le C tient donc ~2 450 px opaques par ligne en pointe — sous les 2 650-2 950 visés — et ~1 200 px mixtes. Les deux scènes denses dépassent le budget sur leurs lignes de pointe et le montrent immédiatement (late ≠ 0, lead_min = 0) : l'anneau ne cache rien.

L'assembleur — 2026-08-26, soir

RP2/src/blit.S : les boucles d'intervalle en Thumb-2, déroulées par quatre, tout alignement accepté. Trois enseignements, tous mesurés au DWT sur un cœur seul (rp2_blit_bench, 160 mots) :

boucle de 160 mots C asm, 1 mot par tour asm, déroulée ×4
chemin rapide, source alignée 11 cycles/mot 8,1
source non alignée 12 11
miroir 12 12
mixte 27 27 26
vide — rien à faire 10 11

Ce sont les champs words_aligned / words_unaligned / words_rev / words_mixed / words_empty de rp2_bench_t, imprimés au rapport (« bench 160 mots », §8) avec deux lignes complètes, line_one_entry (une entrée opaque de 64 px) et line_empty (aucune entrée : effacement et parcours seuls) — la mesure du setup et de l'effacement.

  1. Le M33 n'a pas de prédiction de branchement : un saut pris coûte 2-3 cycles, et une boucle « un mot par tour » en prend deux par mot — un mot vide coûtait 10 cycles à ne rien faire. La première version assembleur ne gagnait donc rien sur la scène dense (13 200 contre 12 960 en C). Déroulée par quatre, le chemin rapide s'enchaîne sans saut : 2 cycles par pixel, sous les 4,3 visés.
  2. Aligner la destination coûte plus que le non-aligné. Une tête de 1 à 3 octets pour aligner les str a été mesurée à +11 % sur la scène dense (17 187 contre 15 774 en pointe) : deux sauts par octet contre un ou deux cycles par mot pour un str non aligné. Retirée.
  3. Ce qui reste, c'est le setup : ~100 cycles par entrée et par ligne (tête de liste, intervalle, clipping, appel) plus ~465 d'effacement de ligne. Sur la scène dense, 48 entrées de ~11 mots : le setup pèse autant que les pixels.

Sur les scènes, RP1 muet :

Scène C asm ×4
1 — dense opaque, cycles/ligne moy / max 12 960 / 19 260, late 81 732 10 026 / 15 774, late 4
2 — dense mixte 21 030 / 36 800 20 330 / 33 900
0 — orbite 1 540 / 2 400 1 790 / 2 680 (setup)

La capacité visée est atteinte sur l'opaque : 3 000 px par ligne tiennent dans les 16 000 cycles, de justesse (15 774). Le mixte reste à ~6,5 cycles/px — un it + strb par octet, on ne descendra pas beaucoup sous 5 sans changer de représentation (masque d'opacité par bitmap) ; c'est la vraie limite des sprites à bords doux, et elle est mesurée. L'étape suivante côté cycles est le setup par entrée, pas la boucle.

Flips — faits et mesurés le même jour. Le flip vertical lit la rangée h − 1 − r et son intervalle : gratuit. Le flip horizontal lit la rangée à l'envers, un rev (__builtin_bswap32) par mot, l'intervalle [x0, x1) retombant en [w − x1, w − x0). Sur la scène dense opaque avec les quatre combinaisons en rotation : 12 960 → 13 300 cycles par ligne (+2,6 %), 19 260 → 19 770 en pointe ; sur la scène d'orbite, +12 % — du setup. Les flips restent dans le champ flags de l'entrée (§5), l'atlas n'en sait rien : un décor symétrique se dessine une fois.

Validé par un autotest au démarrage (rp2_blit_selftest), pas à l'œil — les torches sont symétriques : le bloc synthétique à dégradé horizontal, dans les quatre combinaisons de flags, à quatre positions dont deux coupées par les bords, comparé pixel à pixel à une implémentation naïve sur les 480 lignes. 0 pixel faux sur 307 200. Le résultat est dans le rapport de RP2. Ordre de peinture (ajouté le 2026-08-27) : dix entrées de plus, copies du même dégradé décalées en x pour que « qui est devant » se lise dans la valeur du pixel — six dont les suivantes commencent plus haut, quatre en y croissant dont la première expire la première. La marche d'avant (ordre d'activation + swap-remove) y fait 7 120 pixels faux ; la marche ordonnée, 0 sur 307 200.


4. Pool de sprites

Ce qui est implémenté (2026-08-27) : RP1 possède le pool. RP2 réserve RP2_POOL_BYTES = 320 Kio de SRAM (sprites.c) et n'y alloue rien : la carte d'occupation, l'allocateur et les ordres de chargement sont chez RP1 (RP1/src/pool.c, pages de 64 octets, ArchitectureRP1.md §7, « Le pool — RP1 le possède »). Chaque paquet de display list porte jusqu'à 16 ordres « copie len octets de l'offset NOR nor_off à l'offset pool_off du pool », que RP2 exécute dès la réception par DMA (loader.c, §6) ; les entrées portent l'offset des pixels dans le pool et leurs dimensions, RP2 vérifie les bornes et ajoute la base (§5). Une quarantaine de trois trames côté RP1 garantit qu'aucun chargement n'écrit ce qu'une trame affichée lit (§6).

Repli de bring-up : au boot, RP2 copie encore l'atlas PDSP entier de sa NOR (0x10100000) dans le pool, avec ses tables anims[32] et bitmaps[512], par un DMA unique via l'alias XIP_NOCACHE_NOALLOC, pour sa scène de test locale (§8) — tant que RP1 se tait. Dès le premier paquet, le pool est à RP1 et se fait écraser par ses ordres. L'atlas de bring-up fait 347 920 o (pool 343 296 o, plus que les 320 Kio : la copie de boot tronque ; 15 animations, 374 bitmaps — fontes système ids 0-127, torches 20×48 ids 128-351, impacts 48×48 ids 352-371, solid64 372, mixed64 373). Format exact en §8.

Depuis le 2026-08-27 aussi, éviction automatique LRU côté RP1 : un pool plein évince les bitmaps dessinés le plus anciennement (jamais ceux des trois dernières trames), pages libres aussitôt — le pool est un cache transparent (ArchitectureRP1.md §7). Reste ouvert : les 450 Ko, la compaction, l'annuaire et l'atlas venant de la µSD.

Deux granularités découplées

Unité Raison
Allocation page fixe de 64 octets (RP1_POOL_PAGE) allocateur trivial, pas de fragmentation externe fine. La page de 256 octets d'abord prévue perdait trop sur l'atlas de bring-up : 346 624 o une fois chaque blob arrondi, pour 327 680 disponibles — contre 316 672 en pages de 64, tous ses blobs étant multiples de 64
Layout interne linéaire par scanline, stride W_pad un seul run contigu par sprite et par ligne

Un bitmap est un blob contigu — ses 4·h octets d'intervalles puis ses stride·h pixels — de ⌈len/64⌉ pages consécutives. Coût : ≤ 63 octets perdus par bitmap (zéro sur l'atlas de bring-up).

// RP1/include/rp1/pool.h — la carte est chez RP1 ; RP2 n'a que le tableau d'octets
#define RP1_POOL_BYTES (320u * 1024u)                    // = RP2_POOL_BYTES, dupliqué (ouvert)
#define RP1_POOL_PAGE 64u
#define RP1_POOL_PAGES (RP1_POOL_BYTES / RP1_POOL_PAGE)  // 5120

La carte d'occupation — 5 120 bits, 640 octets — vit chez RP1 (RP1/src/pool.c), pas chez RP2. Allouer revient à chercher le premier trou de N pages consécutives en la balayant par mots (first-fit, les mots pleins sautés d'un coup) : quelques microsecondes, et seulement au chargement d'un bitmap.

Layout des bitmaps

Linéaire pleine largeur, pas de tuiles : un seul run par ligne — en pratique l'intervalle opaque de la rangée, rangé dans l'atlas — donc un seul setup (~100 cycles mesurés, §3). Sur un sprite opaque de 64 px de large, 64 × 2 + 100 = 228 cycles/ligne, soit 3,6 c/px effectifs.

W_pad

Le stride : W arrondi au multiple de 4 supérieur ((w + 3) & ~3, mksprites.py). Le blitter accepte tout alignement (pas de rotation, §2) ; ce qui reste à garantir, c'est l'alignement de la table d'intervalles : les h × {u16 x0, u16 x1} d'un bitmap précèdent immédiatement ses pixels (à off − 4·h) et sont lus en uint16_t*, donc off doit être pair — il est en pratique multiple de 4, puisque le pool est aligné sur 4 et que 4·h comme stride × h le sont.

Un sprite de 22 px de large est stocké en stride 24 : deux octets perdus par ligne, plus 4 octets par rangée pour l'intervalle (+18 % sur l'atlas de bring-up). Il n'y a aucune indexation de page, donc pas besoin de largeurs en puissance de 2. Les pages de 64 octets alignent gratuitement chaque blob sur 64, donc chaque base de pixels (page × 64 + 4·h) sur 4.

Exemple de carte mémoire (pages de 64 o ; chaque blob = 4·h octets d'intervalles + stride·h pixels, arrondi à la page ; le src de l'entrée pointe les pixels, à blob + 4·h — dans l'atlas en NOR, les blobs se suivent sans arrondi) :

Bitmap W×H W_pad Blob (4·h + stride·h) Pages Blob à src
joueur_marche_0 24×40 24 160 + 960 = 1 120 18 pool+0x0000 0x00A0
joueur_marche_1 24×40 24 1 120 18 pool+0x0480 0x0520
ennemi_A_0 32×32 32 128 + 1 024 = 1 152 18 pool+0x0900 0x0980
boss_idle_0 60×96 60 384 + 5 760 = 6 144 96 pool+0x0D80 0x0F00
tir_0 12×12 12 48 + 144 = 192 3 pool+0x2580 0x25B0

Des bitmaps linéaires posés côte à côte, chacun avec son propre stride. Pas de tuile, pas de grille ; la page n'est qu'une unité d'allocation, invisible du blitter.

Coût mémoire

Ce qui le consomme Ce qui ne le consomme pas
Pool — 320 Kio aujourd'hui, 450 Ko en cible la variété d'art : bitmaps distincts résidents le nombre d'instances à l'écran
Cycles — 16 000 par scanline et par cœur la quantité à l'écran : instances × largeur le partage de bitmap

coût du pool = Σ sur les types ( nombre de bitmaps du cycle × taille d'un bitmap ) — aucun terme d'instance. Taille d'un bitmap = 4·h + stride·h, arrondie à 64.

Le pool contient des pixels, la display list contient des instances. Vingt ennemis identiques font vingt entrées de display list (320 octets) portant le même pointeur src et une seule copie du bitmap. Le vingt-et-unième coûte 16 octets de display list et zéro octet de pool : la composition est en lecture seule sur la source. Un ennemi retourné horizontalement partage encore le même bitmap, le flip se faisant à la copie.

Vingt ennemis déphasés dans un cycle de 8 poses pointent sur 8 bitmaps — déjà résidents, puisque l'on budgète le cycle complet du type.

Inventaire d'une scène réaliste en 8bpp :

Contenu Calcul Taille
Joueur, 4 directions × 6 poses, 32×48 24 × 1,5 Ko 36 Ko
6 types d'ennemis × 8 poses, 32×32 48 × 1 Ko 48 Ko
Boss, 12 poses, 64×96 12 × 6 Ko 72 Ko
Projectiles et effets, 40 poses, 16×16 40 × 256 o 10 Ko
Explosions, 16 poses, 48×48 16 × 2,25 Ko 36 Ko
HUD et fonte 8 Ko
Total 210 Ko

(Pixels seuls ; s'y ajoutent les intervalles, 4·h octets par bitmap — environ +10 % sur cet inventaire — et l'arrondi à la page de 64 o.)

Cette scène tient dans les 320 Kio du pool actuel — le jeu de test y loge 229 bitmaps de l'atlas de bring-up, 264 Kio, mesuré le 2026-08-27 — et deux fois dans les 450 Ko de la cible. Leviers si nécessaire : le flip horizontal divise par deux les bitmaps directionnels, et la NOR reste la bibliothèque complète — 16 Mo représentent environ 16 000 bitmaps de 32×32.

Bullet-hell à 200 projectiles de 12×12 sur 4 poses : pool = 4 blobs de 192 o (48 d'intervalles + 144 de pixels), 3 pages chacun = 768 o pour 200 projectiles ; cycles sur une scanline traversée par 30 d'entre eux = 30 × (100 + 12 × 2) = 3 720 sur 16 000 (chiffres mesurés de §3 : c'est le setup qui compte). Cas miroir : un décor de 100 tuiles toutes différentes coûte 100 Ko de pool et le même temps CPU que 100 tuiles partagées.


5. Display list

Contrat en service depuis le 2026-08-27 : RP1 possède le pool (§4) et écrit dans l'entrée l'offset des pixels du bitmap dans le pool, ainsi que w, h et stride lus dans l'annuaire de l'atlas ; RP2 n'a aucune table à consulter — il vérifie les bornes, ajoute la base du pool, et c'est tout. (Interim du 2026-08-26, levé : src portait un id de bitmap que RP2 résolvait dans sa copie de la table de l'atlas.)

typedef struct {          // 16 octets, image mémoire = format sur le fil
    const uint8_t *src;   // sur le fil : offset des pixels dans le pool (u32) ; en SRAM : adresse
    int16_t  x, y;        // coin haut-gauche de la boîte w×h, signé pour le clipping
    uint16_t w, h;        // dimensions visibles, remplies par RP1 depuis l'annuaire de l'atlas
    uint16_t stride;      // = W_pad, idem
    uint8_t  flags;       // bit 0 FLIP_H, bit 1 FLIP_V — priorité et effets sont dans l'index (RP3 §3)
    uint8_t  pad;
} sprite_entry_t;

Charge utile : 4 (src) + 4 (x,y) + 4 (w,h) + 2 (stride) + 1 (flags) = 15 octets, que l'alignement du pointeur src porte à 16. RP2 double-bufferise la liste par parité de trame (lists[f & 1]).

Le paquet PDDL sur le fil

Tout est petit-boutiste (image mémoire des structs de rp2/dlist.h et rp2/sprites.h) ; le PIO reçoit MSB first et le CPU rétablit l'ordre par bswap32 à l'adoption. SPI mode 0, MSB first, 25 MHz, CS bas sur tout le paquet, exactement 6 928 octets par assertion de CS (§1).

Offset Taille Contenu
0 u32 magic 0x4C444450 — octets P D D Lseul champ vérifié (dans l'IRQ de fin de DMA)
4 u16 count : 0..256 ; au-delà, tronqué à 256
6 u16 seq : incrémenté par RP1, ni vérifié ni utilisé
8 u16 n_loads : 0..16 ordres de chargement ; au-delà, tronqué à 16
10 u16 réservé, 0, non vérifié
12 u32 réservé, 0, non vérifié
16 256 × 16 entrées sprite_entry_t ; 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 en ligne 480
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), 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

Total 6 928 octets = 16 + 4 096 + 2 560 + 256, soit 2,22 ms à 25 MHz (5 648 o et 1,81 ms jusqu'au 2026-08-27 après-midi, avec deux blocs). Le format des blocs de contrôle appartient à RP1 et RP3 (ArchitectureRP3.md §4) ; RP2 ne les lit pas.

Entrée, 16 octets :

Offset Type Contenu
+0 u32 srcoffset des pixels du bitmap dans le pool ; multiple de 4 ; la table d'intervalles est à src − 4·h
+4 i16 x, coin haut-gauche
+6 i16 y, coin haut-gauche
+8 u16 w — rempli par RP1 depuis l'annuaire de l'atlas
+10 u16 h — idem
+12 u16 stride — idem, = W_pad
+14 u8 flags : bit 0 FLIP_H, bit 1 FLIP_V ; autres bits ignorés par RP2 (RP1 masque à 3)
+15 u8 pad

Ordre de chargement, 16 octets (rp2_load_cmd_t, rp2/loader.h ; rp1_dl_load_t côté RP1) :

Offset Type 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
+8 u32 len — octets, ≠ 0, multiple de 4
+12 u32 pad, 0

Traitement par RP2

  • Magic faux ⇒ paquet ignoré, bad++ (rapport dlist bad). count, seq, reserved ne sont pas vérifiés ; n_loads est écrêté à 16.
  • Dans la même IRQ, les n_loads ordres sont poussés au chargeur (§6) et le premier DMA part : les ordres s'exécutent dès la réception, ~13 ms avant l'adoption de la liste du même paquet.
  • Deux paquets reçus entre deux adoptions ⇒ le premier est écrasé silencieusement (un seul paquet en attente) ; ses ordres de chargement, eux, ont été exécutés.
  • À l'adoption, chaque entrée est vérifiée, et rien de plus : w = 0, h = 0, w > stride, src non multiple de 4, src < 4·h ou src + stride·h > RP2_POOL_BYTES ⇒ entrée écartée et liste compactée (l'ordre des autres est conservé), rejetees++ (ex-unresolved) — invisible pour RP1 ; sinon src devient pool + src. count = 0 ⇒ trame vide (ce n'est pas « pas de paquet »).
  • Un ordre de chargement encore en vol à l'adoption ⇒ late++ (rapport loads late) : RP1 a dépassé son budget de NOR par trame (§6). La liste est adoptée quand même.
  • Les quatre blocs de contrôle sont copiés tels quels dans les tampons de la parité de la trame cible et sortent en lignes 480 à 483 de la trame en cours de composition (§3).

Cycle d'adoption et repli

  • RP1 envoie au front montant de VSYNC la liste construite par _draw() pendant la trame précédente (ArchitectureRP1.md §10) ; sans liste prête, rien n'est envoyé.
  • RP2 adopte le paquet en attente quand le cœur 0 compose la ligne 480 de la trame f (composée jusqu'à 10 lignes avant son émission), comme liste et blocs de contrôle de la trame f+1. Le cœur 1 attend cette adoption avant de composer une ligne de f+1.
  • Sans paquet en attente à ce moment, RP2 construit sa scène de test locale (§8, RP2_TEST_SCENE) pour f+1 ; les blocs de contrôle de cette parité ne sont pas touchés (ceux de f−1 sont réémis ; RP3 les ignore, même génération). Comportement de bring-up, à remplacer par « répéter la trame précédente » — ce que RP1/README.md suppose déjà. Ouvert.
  • Latence : _draw() de la trame k → envoi au VSYNC k+1 → adoption à la ligne 480 de k+1 → composée et affichée trame k+2 : deux trames plus le balayage. Sprites et palette d'un même paquet atterrissent sur la même trame, atomiquement. Vue d'ensemble des cadences : ArchitectureRoot.md §10.

Budget du transfert. 6 928 octets à 25 MHz = 2,22 ms, reçus à VSYNC + 2,22 ms pour une adoption ~14 ms plus tard : ~14 ms de marge. L'envoi déborde déjà sur la trame active, et rien ne s'y oppose puisque RP2 ne bascule de liste qu'à la ligne 480 ; les 16,67 ms d'une trame porteraient 26 Ko même à 12,5 MHz. La contrainte « ≥ 23 MHz pour tenir dans le vblank » n'existe plus (§1).


6. Chargement et anti-tearing

Implémenté le 2026-08-27 (RP2/src/loader.c, RP2/src/dlist.c, RP1/src/pool.c). Le mécanisme réel : les ordres de chargement voyagent dans le paquet de display list (§5, ≤ 16 par paquet) ; RP2 les exécute dès la réception — depuis l'IRQ de fin de DMA du paquet, un DMA 32 bits par ordre, NOR → pool par le stream FIFO du XIP (voir « Stockage dans la NOR »), enchaînés depuis la même ligne d'IRQ (DMA_IRQ_1, file de 32 ordres = deux paquets) — soit ~13 ms avant que la liste du même paquet soit adoptée à la ligne 480. Une liste peut donc référencer un bitmap chargé par son propre paquet. RP2 valide chaque ordre (len ≠ 0, les trois offsets multiples de 4, pool_off + len ≤ RP2_POOL_BYTES, nor_off + len ≤ PICO_FLASH_SIZE_BYTES, sinon bad++), compte dropped si la file est pleine et late si un ordre est encore en vol à l'adoption. La règle anti-tearing est tenue côté RP1 : les pages libérées par un déchargement restent en quarantaine trois trames (RP1_POOL_QUARANTINE) avant d'être réallouées, si bien qu'aucun ordre n'écrit jamais ce qu'une trame affichée ou en cours de composition lit (ArchitectureRP1.md §7). Le delta est automatique : chargement au premier usage, éviction LRU quand le pool est plein (vérifié avec un pool réduit à 64 Kio : full = 0, 0 rejet côté RP2). Ouvert : compaction, budget NOR mesuré au chronomètre.

Un bitmap référencé par une liste envoyée reste en lecture seule tant qu'une trame peut le lire : ses pages, une fois déchargées, ne sont réallouées qu'à la trame f + 3. On ne charge que dans des pages libres, et un bitmap chargé est référençable par la liste du paquet qui porte son ordre — RP2 exécute l'ordre à la réception, avant d'adopter la liste.

Un sprite à moitié chargé n'est donc jamais lu — non par synchronisation fine, mais parce que l'ordre est exécuté ~13 ms avant que la liste qui l'utilise soit adoptée, et que RP2 signale late si ce n'est pas le cas.

Moment Qui Quoi
VSYNC RP1, IRQ envoie le paquet : la liste construite pendant la trame précédente et les ordres qu'elle a provoqués
VSYNC + 2,22 ms RP2, IRQ DMA paquet reçu ; les ordres partent, NOR → pool, dans des pages que rien ne lit (quarantaine de RP1), zéro CPU
Toute la trame RP2, CPU compose en lisant les bitmaps de la liste adoptée à la ligne 480 précédente
Toute la trame RP1 _update, _draw : la liste suivante ; le premier gfx.sprite d'un bitmap absent alloue ses pages et met son ordre dans le lot du prochain paquet
Ligne 480 RP2, cœur 0 adoption de la liste reçue (late++ si un ordre est encore en vol), bucket-sort par ligne (§5)

Le pool n'est jamais rechargé en entier : ce qui transite par trame est le delta que le jeu provoque — premiers usages, gfx.load — dans la limite de 16 ordres et 96 Kio par paquet (RP1_LOAD_BATCH_BYTES, ~5 ms de NOR à 20 Mo/s supposés) ; au-delà, RP1 met en file pour les trames suivantes. Mesuré le 2026-08-27 : l'atlas de bring-up résident en 15 trames (229 ordres, 270 528 o), puis +32 ordres / 36 864 o à chaque cycle de déchargement du jeu de test ; late = 0, bad = 0.

Le chargement lit la flash par le stream FIFO du XIP (XIP_STREAM_ADDR/CTR, FIFO lue à XIP_AUX_STREAM, DREQ XIP_STREAM), pas par un DMA sur l'alias non caché : une lecture DMA de la flash immobilise le port de lecture du DMA le temps de chaque accès (~1 µs par mot), et l'émetteur du bus — un autre canal DMA, qui doit fournir un mot à la PIO toutes les 12 cycles — se retrouve affamé : la FIFO d'émission se vide en pleine ligne et RP3 échantillonne n'importe quoi. Vu à l'œil le 2026-08-27 (une ligne parasite vers la ligne 40, là où les chargements du paquet démarrent) et mesuré par le compteur txstall du rapport (stalls de la FIFO TX de la PIO) : ~3,3 par trame sous chargements continus avec le DMA direct, 2 au total (le démarrage) avec le stream. Avec le stream, le bloc XIP va chercher les mots tout seul et le DMA ne lit la FIFO que quand un mot y est. Un autotest à l'init streame une tranche de l'atlas et la compare à une lecture directe (stream=ok dans le rapport).

Lecture CPU et écriture DMA ne se gênent pas : le port QSPI est entièrement disponible puisque le code tourne en SRAM (copy_to_ram). Côté SRAM, les 10 banques derrière le crossbar — 8 entrelacées par mot — rendent les collisions marginales : 1 800 cycles/ligne en moyenne pour le blitter avec les chargements actifs (2026-08-27), dans le bruit de la mesure sans.

Fragmentation

  1. Compaction en tâche de fondouvert, aucun ordre SRAM → SRAM n'existe. RP1 choisirait un bitmap non référencé par la display list de la trame en cours et ordonnerait un DMA SRAM → SRAM. C'est la règle ci-dessus, sans mécanisme supplémentaire. À ~250 Mo/s, compacter 450 Ko prend ~2 ms, à étaler sur deux trames.
  2. Reconstruction complète sur changement de scène : au rythme de 16 ordres et 96 Kio par paquet, le pool entier se reconstruit en quelques trames derrière un fondu au noir (mesuré : l'atlas de bring-up en 15 trames, borné par les 16 ordres par paquet, pas par la NOR).
  3. Échec proprefait : bitmap non résident à temps ⇒ sprite sauté cette trame (missing côté RP1) ; working set plus grand que le pool (rien d'évinçable) ⇒ full. Jamais de corruption, un bitmap non chargé n'étant jamais référencé.

Stockage dans la NOR

Les bitmaps sont stockés déjà padés à W_pad, chacun précédé de sa table d'intervalles. Chaque ordre de chargement est une rafale séquentielle unique — un blob, intervalles et pixels — à 33 Mo/s pleins, un seul surcoût de commande par bitmap et non par ligne.

Lire l'atlas à la volée coûterait ~0,2 µs d'overhead de commande par tranche, soit ~1 µs pour 32 octets, ce qui plafonnerait à 25-30 sprites par ligne quelle que soit la bande passante crête. C'est ce que le pool résident élimine.


7. Débordement du working set

Le pool est un cache LRU (§4, §6) : ce qui déborde, c'est le working set — les bitmaps dessinés dans les trois dernières trames — pas la bibliothèque. Un jeu peut puiser dans les 16 Mo de l'atlas tant qu'une trame n'en montre pas plus de 320 Kio.

Si les sprites d'une trame dépassent les ~450 Ko utiles, une PSRAM QSPI sur le CS1 du port XIP est possible sur RP2350. Elle partage la bande passante du port et n'est à sortir que si nécessaire. Les 22 GPIO libres laissent également la place à une extension.


8. Contrat de rendu — pixel exact

Ce que le simulateur (papyDeck_Sim) doit reproduire pour être fidèle à RP2, tel que le code le fait le 2026-08-27 (sprites.c, blit.S, dlist.c, loader.c, main.c). Vue d'ensemble RP1 → RP2 → RP3, formats et cadences : ArchitectureRoot.md §10.

Ordre de peinture

Contrat voulu — celui de Notes-composition-RP2.md (note locale, non versionnée), du simulateur et du jeu RP1 : l'ordre de la display list est l'ordre de peinture, la dernière entrée est devant. Le dernier pixel non nul écrit gagne ; pas de z-buffer, pas de test.

Implémentation (conforme depuis le 2026-08-27). Le code bucket-trie les entrées par première ligne visible (max(y, 0), chaînage qui conserve l'ordre de liste à l'intérieur d'un casier), et chaque cœur tient une liste active : un tableau d'index d'entrées maintenu trié par index de liste. Une entrée qui commence sur la ligne courante est glissée à sa place depuis la fin du tableau (les entrées d'une même ligne arrivent dans l'ordre de liste, le balayage est court) ; une entrée qui expire (Y ≥ y + h) est retirée en refermant le trou. La marche peint donc toujours dans l'ordre de la display list, quelle que soit la ligne de départ de chacun.

Histoire : jusqu'au 2026-08-26 le tableau était rempli dans l'ordre d'activation et vidé par swap-remove — une entrée déclarée après mais commençant plus haut passait derrière, et l'expiration d'une entrée permutait les survivantes. L'autotest de §3 le détecte depuis (7 120 pixels faux avec l'ancienne marche). Coût de la marche ordonnée sur la scène RP1 de 221 torches : 1 773 cycles/ligne en moyenne, 3 600 au pire, late = 0 — dans le bruit de la mesure précédente.

Clipping, deux étages

  1. À l'adoption (rp2_list_bucket) : entrée écartée ssi y + h ≤ 0, y ≥ 480, x + w ≤ 0 ou x ≥ 640 (coordonnées int16) ; sinon casier max(y, 0).
  2. Par ligne (rp2_blit_line) : l'entrée est active pour max(y, 0) ≤ Y < y + h ; sur la ligne, l'intervalle destination est coupé à [0, 640) (à gauche, la source avance d'autant ; à droite, le compte est tronqué). Un intervalle vide après coupe n'écrit rien.

Côté RP1, depuis le 2026-08-27, la même règle exacte est appliquée avant l'envoi (RP1 connaît w et h par l'annuaire de l'atlas) : un sprite entièrement hors écran ne coûte ni entrée ni chargement. L'étage 1 de RP2 ne devrait donc plus rien écarter sur une liste de RP1.

Flips

Les flips agissent dans la boîte w × h ; (x, y) reste le coin haut-gauche de la boîte.

  • FLIP_V (bit 1) : la rangée source de la ligne Y est h − 1 − (Y − y) au lieu de Y − y, avec son intervalle.
  • FLIP_H (bit 0) : la colonne source du pixel cx de la boîte est w − 1 − cx ; l'intervalle [x0, x1) de la rangée tombe en [w − x1, w − x0) de la boîte.
  • Les deux se combinent. Référence naïve : ref_pixel() dans sprites.c.

Les intervalles font autorité

Un pixel de l'entrée est écrit à l'écran ssi : il est dans la boîte, x0 ≤ cx < x1 pour l'intervalle de sa rangée source (après flip V ; le flip H déplace l'intervalle comme ci-dessus), et sa valeur est non nulle. Un pixel non nul hors de l'intervalle rangé dans l'atlas n'est jamais dessiné : mksprites.py fait coïncider les deux ([x0, x1) = du premier au dernier pixel non nul + 1, (0, 0) si la rangée est vide), mais c'est l'intervalle qui commande. Une rangée à x1 ≤ x0 est sautée.

Effacement

Chaque ligne commence par memset(dst, 0, 640) (~465 cycles). L'index 0 est « rien de dessiné » : RP3 y met le fond (ou le backdrop si le fond vaut 0 aussi).

Dépassement de budget

  • Un slot non composé à temps est émis tel quel : il contient la ligne L − 12 (même trame sauf pour L < 12), late++. Jamais de décalage de trame.
  • Le cœur en retard saute à la prochaine ligne de sa parité au-delà de next_arm ; les lignes sautées ne sont jamais composées.
  • Garde d'anneau : un cœur n'entame la ligne L que si L − next_arm ≤ 9. Le cœur 1 ne compose aucune ligne de la trame f+1 avant que le cœur 0 ait adopté (ligne 480 de f).
  • VSYNC (front montant, GP45) doit trouver (next_arm − 1) % 484 == 0 ; sinon desync++, abandon du DMA, redémarrage de la machine et réarmement de la ligne 0 suivante. La trame fait 484 lignes et ce compte doit rester pair (§3). Si la resynchronisation a sauté la ligne 480 d'une trame (RP3 absent plusieurs secondes, puis de retour), le cœur 0 adopte de rattrapage la liste de la trame qu'il compose au lieu d'attendre une adoption qui ne viendrait plus (catchup++) — sans quoi les deux cœurs se figeaient (2026-08-27, corrigé).
  • Mesuré le 2026-08-26, scène d'orbite, RP1 actif : late = 0, desync = 0, lead_min = 8 ; le 2026-08-27, trame de 484 lignes, fond piloté par RP1 : late = 1, desync = 1 (démarrage), txstall = 2, 0 rejet.

Scènes de test locales — RP2_TEST_SCENE

Construites par RP2 lui-même (rp2_list_build_test) quand aucun paquet n'est en attente à la ligne 480 (§5) ; ce sont les images que RP3 affiche si RP1 se tait. Les animations sont celles de la table de l'atlas ; hash(v) est le mélangeur de sprites.c, appliqué à i × 2654435761.

Valeur Scène
0 (défaut) 256 entrées : i < 200 torches (animation hash % 7, image frame / 2 + i), les 56 autres impacts (animation 7 + hash % 4) ; base sur un réseau de 16 colonnes au pas de 40 px et de rangées au pas de 30 px, décalage 0..15 px tiré du hachage ; orbite en triangle ±32 px en x et en y, phases tirées du hachage ; position finale décalée de (−8, −8) ; FLIP_H sur les i impairs, FLIP_V sur i % 3 == 0. Art réel, mince, dominé par le setup.
1 256 blocs solid64 (avant-dernière animation) sur un réseau 16 × 16 au pas de 40 × 24 px, x = (i % 16) × 40 + frame % 8 − 4, y = (i / 16) × 24 + (frame / 8) % 8 − 16, flags = i & 3 ; ~2 100 px/ligne, 3 000 en pointe — le chemin rapide seul.
2 idem avec mixed64 (dernière animation, damier de 1 px) — le chemin lent seul.

Options de build voisines : RP2_BLIT_STATS (1 : compile la boucle C à la place de l'asm et compte les mots), RP2_FAKE_BLIT_CYCLES (N ≠ 0 : chaque ligne coûte au moins N cycles ; a servi à mesurer l'anneau, §3).

Rapport console

UART0 (GP0/GP1), toutes les 60 trames, poussé octet par octet dans la FIFO depuis la boucle du cœur 0 — jamais bloquant, pas de __sev, le cœur 1 n'imprime rien. Quatre lignes (main.c) :

rp2 frame F  late=L  desync=D  lead_min=M  lines=N[ (pas d'atlas)]  | blit cyc/line avg c0/c1 max c0/c1  px/line avg c0/c1 max c0/c1
rp2 scene S  selftest flips+clip+ordre: B faux / P px  active max n0/n1  words fast=… mixed=… empty=… tail=…
rp2 dlist rx=… bad=… adopted=… rejetees=…  | loads done=… octets=… bad=… dropped=… late=… stream=ok
rp2 bench 160 mots: aligne=… nonaligne=… rev=… mixte=… vide=… | ligne 1 entree=… vide=…

La NOR est programmée par RP1 (common/flasher_target.c, flasher_target_poll(uart_default) à chaque ligne sur le cœur 0) : sur le réveil \x01PDFL reçu sur l'UART console, RP2 coupe son cœur 1, ses IRQ et ses DMA, passe à 4 Mbauds, reçoit des secteurs de 4 Kio qu'il compare à la flash avant de les effacer et écrire, puis redémarre — voir ArchitectureRP1.md §10, sys.install. Les sondes ne servent plus à changer l'atlas : sprites.bin sur la carte micro-SD (papydeck/install/rp2.bin) et l'item MISE A JOUR du dashboard.

Le watchdog de RP2 est nourri par le cœur 0 à chaque ligne, y compris pendant l'attente de l'anneau quand le bus ne draine plus (RP3 reprogrammé ou en reset) : ce n'est pas RP2 qui est bloqué, et il se resynchronise au retour de RP3 — avant le 2026-08-28 il redémarrait à chaque reprogrammation de RP3. Au tout début de main(), un démarrage qui n'est pas un redémarrage par le watchdog en déclenche un (watchdog_reboot()), une fois : la parade au rail 3V3 pré-chargé par le moniteur (ArchitectureRP1.md §10, Supervision). Et, une fois, en fin d'initialisation, [boot] rp2 reset|watchdog : RP1 y reconnaît un redémarrage (son pool est vide) et oublie ce qu'il y avait chargé (ArchitectureRP1.md §10, Supervision). Un watchdog de 2 s, nourri par le cœur 0 à chaque ligne, redémarre la puce sur blocage ou HardFault (en pause sous débogueur).

dlist rejetees : entrées écartées à l'adoption pour bornes fausses (§5, ex-unresolved). loads : ordres de chargement exécutés (done) et octets copiés, ordres refusés (bad), jetés faute de place dans la file (dropped), adoptions survenues alors qu'un ordre était encore en vol (late) — §6. Mesuré le 2026-08-27 avec le jeu de test de RP1 : rejetees = 0, bad = 0, late = 0, done = 229 (270 528 o) après la rampe puis +32 par cycle de déchargement.

lead_min, les moyennes et maxima par cœur et les compteurs words sont remis à zéro à chaque rapport ; late, desync, lines, active max, les compteurs dlist et loads sont cumulatifs. Les compteurs words … ne comptent que sous RP2_BLIT_STATS. (pas d'atlas) et un autotest à 0xFFFFFFFF signalent un atlas absent ou refusé.

Atlas PDSP — format exact

NOR de RP2 à 0x10100000 (RP2_SPRITES_FLASH_OFFSET), produit par RP2/tools/mksprites.py, petit-boutiste. Format inchangé le 2026-08-27 ; ce qui a changé, c'est qui le lit : RP1 en embarque l'annuaire — en-tête et tables, ≤ 8 Kio, les premiers octets de RP2/build/sprites.bin, à la compilation de rp1_papydeck — et en tire w, h, stride et l'offset NOR de chaque blob (0x100000 + 32 768 + off − 4·h) pour ses ordres de chargement (ArchitectureRP1.md §7). RP2 ne le copie plus entier en SRAM que pour sa scène de test locale, au boot (repli, §4) ; les listes de RP1 ne dépendent pas de cette copie.

Offset Taille Contenu
0 u32 magic 0x50534450 — octets P D S P
4 u32 n_anims ≤ 32
8 u32 n_bitmaps ≤ 512
12 u32 pool_bytes — peut dépasser les 327 680 o du pool de RP2 (voir ci-dessous)
16 n_anims × 8 animations : u16 first (premier bitmap), u16 count, u16 w, u16 h. Les animations 0 et 1 sont les fontes système, toujours : 64 glyphes 16×16 (bitmaps 0..63, fight16.png) puis 64 glyphes 8×8 (bitmaps 64..127, font8x8arcade.png), caractères 32..95, encre = index 191, transparents ailleurs — les mêmes glyphes que les tuiles système de RP3 ; l'art commence au bitmap 128. Les deux dernières sont les blocs synthétiques de l'autotest. Le reste n'est lu que par les scènes de test
16 + 8·n_anims n_bitmaps × 12 bitmaps : u32 off, u16 w, u16 h, u16 stride, u16 pad
16 + 8·n_anims + 12·n_bitmaps zéros jusqu'à 32 768 (2 048 bitmaps au plus, 2026-08-28)
32 768 pool_bytes le pool : pour chaque bitmap, h × {u16 x0, u16 x1} puis stride × h octets de pixels. Offset fixe depuis le 2026-08-28 : les blobs système (fontes, bitmaps 0..127) sont à la même adresse absolue dans tout PDSP, et l'annuaire embarqué de RP1 reste juste pour eux après un reset, même quand la NOR porte le bundle d'un jeu (le curseur du dashboard en deux traits : c'était ça)

Magic absent ou tables trop grandes ⇒ atlas refusé, sprites_ok = false, rapport « (pas d'atlas) », autotest 0xFFFFFFFF, scènes de test vides — les listes de RP1 s'affichent quand même, puisque leurs bitmaps arrivent par ses ordres. Un pool plus grand que les 320 Kio de RP2 est normal depuis que RP1 le streame (l'atlas de bring-up fait 343 296 o avec les fontes) : la copie de boot ne prend que ce qui tient, et la scène de test locale saute les bitmaps au-delà ; mksprites.py range les pixels des blocs synthétiques en tête du pool (leurs fiches restent en fin de table) pour que l'autotest les ait toujours. off = offset des pixels dans le pool ; les intervalles de ce bitmap sont à off − 4·h ; off multiple de 4 (§4). stride = (w + 3) & ~3. Pixel = index de la palette partagée (scripts/pdpal.py, RP3/build/palette.bin), 0 = transparent ; à la génération, alpha ≥ 128 ⇒ plus proche des index 1..191, sinon 0. Les deux derniers bitmaps sont les blocs synthétiques solid64 et mixed64 (avant-dernière et dernière animation), que le firmware retrouve par la fin de la table.