API native de la console de fantaisie Vircon32

A Vircon32-assembly targeting lua compiler

API native de la console de fantaisie Vircon32

Ce document couvre UNIQUEMENT l’API native de Vircon32 — la surface active lorsque ni le mode de compatibilité --#api pico8 ni --#api tic80 n’est sélectionné. Sous ces modes, spr()/btn()/etc. sont à la place la propre API de cette console (voir la documentation de compatibilité PICO-8 / TIC-80), et les appels ci-dessous ne sont pas disponibles.

L’Aide-mémoire ci-dessous liste sur une seule page toutes les intrinsèques et tous les ports ioports.* ; les sections suivantes détaillent chaque partie.

L’ordre d’écriture des ports SPU compte pour le son ; voir Son : music.* / sfx.*.

Le clavier et la souris (key(), kbd.*, mouse(), mouse.*) ne fonctionnent qu’avec un adaptateur matériel v32io ou l’émulateur modifié — la console n’a ni l’un ni l’autre ; voir Clavier et souris : ce qu’il faut.


Table des Matières


Aide-mémoire

Une intrinsèque est un nom que le compilateur reconnaît et transforme en instructions en ligne ou en appel d’une routine du runtime : il n’y a ni table ni fonction Lua derrière. Une fonction à vous portant le même nom (spr, rgba, color, …) remplace l’intrinsèque.

Fonctions intrinsèques

Graphismes

Appel Renvoie Ce qu’il fait
spr(region, x, y [, sx [, sy [, angle [, color [, blend]]]]]) nil Dessine une région de texture ; choisit le dessin simple, zoomé, pivoté ou les deux selon les arguments. Détails
rect(x1, y1, x2, y2 [, color]) nil Contour de rectangle de 1 pixel entre deux coins inclus. Couleur : mot empaqueté, blanc par défaut. Détails
rectfill(x1, y1, x2, y2 [, color]) nil Rectangle plein, un seul dessin GPU.
print(x, y, value) nil Dessine value (convertie par tostring) au pixel x, y avec la police du BIOS.
ioports.gpu.clear([color]) / clear(r, g, b [, a]) nil Fixe la couleur d’effacement (facultatif) et efface l’écran. Détails
ioports.gpu.draw([mode]) nil Dessine la région sélectionnée en gpu.x, gpu.y. mode : "draw" (par défaut), "zoom", "rotate", "rotozoom", ou 0–3.
ioports.gpu.blending(mode) nil Fixe le mode de mélange : "alpha"/"default", "add", "subtract" (littéral chaîne).
ioports.gpu.sync() nil Attend l’image suivante (WAIT) ; identique à system.wait().
rgba(r, g, b [, a]) mot assemblé Assemble une couleur dans le mot 0xAABBGGRR du GPU. Composantes bornées à 0–255, alpha 255 par défaut. Détails
color(n) mot assemblé Transforme un nombre contenant une couleur assemblée en mot (couleurs calculées ou chargées). Détails
hex("0x…") mot assemblé Un littéral chaîne de chiffres hexadécimaux, sous forme du mot 32 bits exact.

Entrées

Appel Renvoie Ce qu’il fait
btn(id [, pad]) booléen Le bouton id (0–10, ordre matériel) est enfoncé. Détails
btnp(id [, pad]) booléen Le bouton id a été pressé à cette image.

Clavier (périphérique v32kbd) — détails. Demande un adaptateur matériel v32io ou l’émulateur modifié : sans eux, aucune touche n’est jamais enfoncée (ce qu’il faut).

Appel Renvoie Ce qu’il fait
key([k]) booléen La touche k (code ou nom littéral : "a", "enter", "shift") est enfoncée ; sans k : n’importe quelle touche.
keyp([k [, hold, period]]) booléen La touche k s’est enfoncée à cette image (répétition avec hold/period, comme TIC-80).
kbd.read() nombre / nil Appui suivant sous forme du caractère tapé (Maj et Verr Maj appliqués).
kbd.event() nombre / nil Événement suivant : +code enfoncée, -code relâchée.
kbd.glyph(v) nombre / nil Le caractère de la police du BIOS que représente une valeur de kbd.read() (accents, cadres), ou nil pour un code de touche.
kbd.port([n]) nombre Port de manette du clavier (2 par défaut) ; le changer repart de zéro.
kbd.capslock(), kbd.connected() booléen État de Verr Maj ; périphérique branché.
kbd.clear() nil Oublie les événements non lus.

Souris (périphérique v32mouse) — détails. Demande un adaptateur matériel v32io ou l’émulateur modifié : sans l’un d’eux, le pointeur ne bouge pas (ce qu’il faut).

Appel Renvoie Ce qu’il fait
mouse() x, y, left, middle, right, scrollx, scrolly Pointeur (pixels d’écran) et boutons (booléens), comme celui de TIC-80 ; défilement toujours 0.
mouse.pressed([b]), mouse.released([b]) booléen Un bouton de b (1 gauche, 2 droit, 4 milieu ; sommes ; aucun : n’importe lequel) s’est enfoncé / a été relâché à cette image.
mouse.buttons() nombre Boutons enfoncés, 1 + 2 + 4.
mouse.delta() dx, dy Le mouvement de cette image, en pixels.
mouse.position([x, y]) x, y Le pointeur ; le déplace si des valeurs sont données.
mouse.bounds(x1, y1, x2, y2) nil Zone où reste le pointeur (par défaut, l’écran).
mouse.scale([n]), mouse.port([n]) nombre Pixels par pas (2 par défaut) ; port de manette (3 par défaut).
mouse.connected() booléen Périphérique branché.

Son — détails

Appel Renvoie Ce qu’il fait
music.play(snd [, ch [, loop [, vol [, start]]]]) canal Joue un son, sur le canal 0 par défaut.
music.pause([ch]), music.resume([ch]), music.stop([ch]) nil Contrôle du canal. resume(ch) n’agit que sur un canal en pause (un canal en lecture ou arrêté est laissé tel quel) ; sans argument, reprend tous les canaux en pause.
music.playing([ch]) booléen Le canal joue-t-il.
music.volume(vol [, ch]) nil Volume du canal.
sfx.play(snd [, ch [, vol [, speed]]]) canal Joue un effet sur le prochain des canaux 1–15.
sfx.stop([ch]), sfx.volume(vol [, ch]) nil Arrête les effets (canaux 1–15), règle le volume.
ioports.spu.cmd(mode) (ou .command) nil Commande SPU brute sur le canal sélectionné. Détails

Tilemaps, carte mémoire, système

Appel Renvoie Ce qu’il fait
tilemap.get(MAP, x, y) nombre / nil Tuile en x, y d’un --#tilemap. Détails
tilemap.set(MAP, x, y, v) v Change une tuile (la carte est copiée en RAM à la première écriture).
tilemap.render(MAP, sx, sy, w, h, x, y, tw, th [, skip]) nil Dessine un bloc de tuiles.
memcard.save(v [, pos]), memcard.load([pos]) v Lit/écrit un mot sur la carte mémoire. Détails
memcard.load_table(pos) table / nil Charge une table enregistrée avec memcard.save(t).
memcard.title(str\|table) nil Fixe le titre/id de la carte (une chaîne, ou une table de 20 nombres).
memcard.title() table Lit le titre/id de la carte (une table des 20 mots, indices 1–20).
memcard[pos], memcard[pos] = v   Identiques à load / save.
system.wait() / system.halt() nil WAIT jusqu’à l’image suivante / HLT. Détails
system.date() / system.time() chaîne, 3 nombres "YYYY-MM-DD", a, m, j / "HH:MM:SS", h, m, s.
system.frames() / system.cycles() nombre Images depuis la mise sous tension / cycles CPU de l’image en cours (aussi sans ()).

Langage et assembleur en ligne

Appel Ce qu’il fait
tostring(v), tonumber(s [, base]), type(v) Comme en Lua.
pairs(t), ipairs(t) Itérateurs pour for k, v in ….
setmetatable, getmetatable, rawget, rawset, rawlen, rawequal Métatables : __index, __newindex, __call, __tostring, __len, __metatable.
__asm__("…") Assembleur en ligne avec substitution {var} ; registres et pile sont sauvegardés autour.
__rawasm__("…") Assembleur recopié tel quel dans la sortie.

print et ioports.* sont des noms du mode natif ; sous --#api pico8 ou --#api tic80, print, spr, btn, etc. sont les fonctions de cette console (voir PICO8.md et TIC80.md). Non disponibles : printf, pcall/error/assert, select, next, string.match/gmatch, os.*, io.*, coroutine.*.

ioports.* — tous les ports matériels

Chaque propriété lit ou écrit directement un port d’E/S de Vircon32 (IN/OUT), sans consultation de table. Types :

L = lecture, É = écriture.

ioports.gpu.* — graphismes

Propriété Port Accès Type Signification
ioports.gpu.texture GPU_SelectedTexture L/É int Texture utilisée par les réglages de région et les dessins (noms --#texture, ou -1 pour la texture du BIOS).
ioports.gpu.region GPU_SelectedRegion L/É int Région (0–4095) de la texture sélectionnée à définir ou dessiner.
ioports.gpu.minX, minY GPU_RegionMinX/Y L/É int Coin supérieur gauche de la région, en pixels de texture. En écrire un fixe aussi hotX/hotY à la même valeur.
ioports.gpu.maxX, maxY GPU_RegionMaxX/Y L/É int Coin inférieur droit de la région (inclus).
ioports.gpu.hotX, hotY GPU_RegionHotSpotX/Y L/É int Point chaud de la région : le point placé en gpu.x, gpu.y. À fixer après minX/minY.
ioports.gpu.x, y GPU_DrawingPointX/Y L/É int Position à l’écran du prochain dessin.
ioports.gpu.scaleX, scaleY GPU_DrawingScaleX/Y L/É float Échelle des dessins zoomés.
ioports.gpu.angle GPU_DrawingAngle L/É float Angle des dessins pivotés, en radians.
ioports.gpu.bgcolor GPU_ClearColor L/É color Couleur utilisée par clear().
ioports.gpu.multiply GPU_MultiplyColor L/É color Couleur par laquelle chaque dessin est multiplié (0xFFFFFFFF = inchangé).
ioports.gpu.blending GPU_ActiveBlending L/É int Numéro du mode de mélange (alpha 0x20, add 0x21, subtract 0x22) ; ou appelez ioports.gpu.blending("add").
ioports.gpu.pixels GPU_RemainingPixels L int Pixels que le GPU peut encore dessiner à cette image.

Méthodes : ioports.gpu.clear(), ioports.gpu.draw(), ioports.gpu.blending(), ioports.gpu.sync() (voir le tableau ci-dessus).

ioports.inp.* — manettes

Propriété Port Accès Type Signification
ioports.inp.gamepad INP_SelectedGamepad L/É int Manette (0–3) lue par les autres propriétés. Sa lecture renvoie la dernière valeur affectée (les émulateurs ne savent pas relire ce port ; voir Le port de manette).
ioports.inp.status INP_GamepadConnected L bool La manette sélectionnée est-elle connectée.
ioports.inp.left, right, up, down INP_GamepadLeft/… L int Croix : images depuis l’appui (> 0) ou depuis le relâchement (< 0).
ioports.inp.A, B, X, Y, L, R, START INP_GamepadButton* L int Boutons, même codage.
ioports.inp.inputs (tous les précédents) L int Tous les boutons de la manette sélectionnée en un masque de bits, enfoncé = 1 : bit 10 gauche, 9 droite, 8 haut, 7 bas, 6 START, 5 A, 4 B, 3 X, 2 Y, 1 L, 0 R.

ioports.spu.* — son

Propriété Port Accès Type Signification
ioports.spu.volume SPU_GlobalVolume L/É float Volume général.
ioports.spu.channel SPU_SelectedChannel L/É int Canal (0–15) sur lequel agissent les propriétés chan* et cmd().
ioports.spu.sound SPU_SelectedSound L/É int Son (nom --#sound) sur lequel agissent les propriétés de son.
ioports.spu.length SPU_SoundLength L int Longueur du son sélectionné, en échantillons.
ioports.spu.soundloop SPU_SoundPlayWithLoop L/É bool Le son sélectionné boucle par défaut.
ioports.spu.loopstart, loopend SPU_SoundLoopStart/End L/É int Points de boucle du son sélectionné, en échantillons.
ioports.spu.state SPU_ChannelState L int Canal sélectionné : 0x40 arrêté, 0x41 en pause, 0x42 en lecture.
ioports.spu.chansound SPU_ChannelAssignedSound L/É int Son attribué au canal sélectionné.
ioports.spu.chanvolume SPU_ChannelVolume L/É float Volume du canal.
ioports.spu.chanspeed SPU_ChannelSpeed L/É float Vitesse de lecture du canal (1.0 = normale).
ioports.spu.chanloop SPU_ChannelLoopEnabled L/É bool Le canal boucle. À fixer après cmd("play").
ioports.spu.chanpos SPU_ChannelPosition L/É int Position de lecture du canal, en échantillons.

Méthode : ioports.spu.cmd(mode) / ioports.spu.command(mode) — "play", "pause", "stop", "resume", "pauseall", "stopall", "resumeall".

ioports.tim.*, rng, car, mem — minuterie, nombres aléatoires, cartouche, carte mémoire

Propriété Port Accès Type Signification
ioports.tim.date TIM_CurrentDate L int Année × 65536 + jour de l’année (system.date() le décode).
ioports.tim.time TIM_CurrentTime L int Secondes depuis minuit (system.time() le décode).
ioports.tim.frames TIM_FrameCounter L int Images depuis la mise sous tension (= system.frames).
ioports.tim.cycles TIM_CycleCounter L int Cycles CPU de l’image en cours (= system.cycles).
ioports.rng.value RNG_CurrentValue L int Nombre aléatoire matériel suivant.
ioports.rng.seed RNG_CurrentValue É int Initialise le générateur matériel.
ioports.car.connected CAR_Connected L bool Une cartouche est insérée.
ioports.car.romsize CAR_ProgramROMSize L int Taille de la ROM programme, en mots.
ioports.car.numvtex, numvsnd CAR_NumberOfTextures/Sounds L int Textures / sons de la cartouche.
ioports.mem.connected MEM_Connected L bool Une carte mémoire est insérée.

Écrire un port en lecture seule, lire un port en écriture seule, ou un nom inconnu (ioports.gpu.colour) est une erreur de compilation qui liste les noms valides.

Bibliothèque standard

Bibliothèque Fonctions
math abs acos asin atan atan2 ceil cos cosh deg exp floor fmod frexp ldexp log log10 max min modf pow rad random randomseed sin sinh sqrt tan tanh, constantes pi huge e
string byte char find format gsub len lower rep reverse sub upper (aussi en méthodes : s:sub(1, 3))
table concat insert move pack remove sort unpack
opérateurs + - * / % ^ //, .., #, == ~= < > <= >=, and or not, & | ~ << >> (voir le README)

Les nombres sont des float32 (24 bits significatifs) ; les chaînes qui ressemblent à des nombres sont converties dans les calculs ("5" + 1 vaut 6), comme en Lua.


Son : music.* / sfx.*

music.play(SOUND [, CHANNEL [, LOOP [, VOL [, START]]]])  -> canal utilisé
music.pause  ([CHANNEL])
music.resume ([CHANNEL])
music.stop   ([CHANNEL])
music.playing([CHANNEL])                                  -> booléen
music.volume (VOL [, CHANNEL])

sfx.play(SOUND [, CHANNEL [, VOL [, SPEED]]])             -> canal utilisé
sfx.stop([CHANNEL])
sfx.volume(VOL [, CHANNEL])

ioports.spu.cmd(MODE)   -- échappatoire brute, voir ci-dessous

music utilise par défaut le canal 0. sfx.play() sans canal tourne en rotation (round-robin) sur les canaux 1–15 via un mot de RAM réservé par le compilateur (VIRCON32_SFX_CURSOR), de sorte qu’un effet ne coupe jamais la musique. sfx ne boucle jamais : tout ce qui doit être soutenu passe par music.play(..., true) sur son propre canal.

sfx.stop() sans canal arrête uniquement les canaux 1–15, délibérément PAS StopAllChannels, qui couperait aussi la musique. C’est __builtin_vircon32_sfx_stop_all.

Le curseur avance de manière inconditionnelle plutôt que de rechercher un canal inactif : chercher coûterait jusqu’à 15 IN + comparaisons sur le chemin critique (sfx.play() s’exécute à chaque saut et chaque pas) pour se prémunir contre un cas qui ne survient que lorsque 15 effets se chevauchent, où le plus ancien est de toute façon le bon candidat à perdre.

SPU de Vircon32 : l’ordre d’écriture des ports

La règle

OUT SPU_SelectedChannel, ch
OUT SPU_Command, SPUCommand_StopSelectedChannel   ; 1
OUT SPU_ChannelAssignedSound, snd
OUT SPU_ChannelVolume, R                          ; port flottant
OUT SPU_Command, SPUCommand_PlaySelectedChannel
OUT SPU_ChannelLoopEnabled, 0|1                   ; 2 - APRÈS la commande
OUT SPU_ChannelPosition, samples                  ; 3 - APRÈS la commande

Trois comportements de la console imposent cela. Les trois échouent en silence — pas d’erreur, pas d’écriture de port rejetée, juste le mauvais son.

1. Un son ne s’assigne qu’à un canal ARRÊTÉ. 2 et 3. La commande de lecture écrase à la fois la boucle ET la position.

Un drapeau de boucle ou une recherche (seek) écrits avant la commande sont ignorés. Notez que le drapeau de boucle est remplacé par le PlayWithLoop du SON, qui est faux à moins que quelque chose n’ait défini SPU_SoundPlayWithLoop sur ce son — donc le bouclage au niveau du canal ne fonctionne que s’il est écrit après la commande. Écrivez-le même quand il vaut 0, puisque la commande vient juste de le remplacer par le drapeau du son.

Pour un canal En Pause (Paused), PlayChannel() ne prend aucune des deux branches — elle se contente de définir State = Playing, donc sur un canal en pause Play reprend sans perturber la position ni la boucle. Dans tout autre état ce n’est pas une reprise : un canal Arrêté est redémarré, et un canal déjà en lecture est redéclenché depuis la position 0 (vérifié sur le PlayChannel() de l’émulateur).

États des canaux et ce que signifie réellement resume

channel_stopped 0x40, channel_paused 0x41, channel_playing 0x42. SPU_ChannelState est en lecture seule (WriteSPUChannelState renvoie faux).

Il n’existe pas de commande ResumeSelectedChannel. ResumeAllChannels est littéralement une boucle qui appelle PlayChannel() sur chaque canal en pause (elle laisse les autres tranquilles), donc music.resume(ch) est PlaySelectedChannel gardé par un test d’état : le compilateur lit SPU_ChannelState et n’émet la commande que si le canal est En Pause (0x41). Reprendre un canal en lecture ou arrêté est donc sans effet, comme l’était déjà resume() sans argument. Un Play sans garde aurait rembobiné au début un canal en lecture. (Cette garde a été ajoutée dans 20261014-dev ; les versions antérieures émettaient le Play nu.)

Constantes de l’assembleur. L’assembleur Vircon32 accepte les noms de ports (SPU_Command, …) et les noms de valeurs (SPUCommand_PlaySelectedChannel, SPUChannelState_*, GPUCommand_*, GPUBlendingMode_*) uniquement comme opérandes de IN / OUT ; dans MOV, PUSH et IEQ ils sont rejetés. Le compilateur émet donc toujours la valeur numérique à cet endroit (la table de commandes de src/intrinsics/spu.c et le 0x41 ci-dessus) ; ce que vous écrivez dans __asm__ / __rawasm__ doit faire de même : OUT SPU_Command, 0x31 est valide et MOV R0, SPUCommand_PauseSelectedChannel est une erreur de l’assembleur.

Un « resume » qui redémarre depuis le début signifie que le canal était ARRÊTÉ, pas en pause. C’est la signature diagnostique d’un drapeau de boucle perdu : le son est arrivé à sa fin, le canal est passé à Arrêté, et Play l’a rembobiné.

PauseChannel() définit State = Paused de manière inconditionnelle — elle ne vérifie pas si le canal est déjà arrêté, malgré le fait que la documentation de l’API C indique que pause n’a « aucun effet si déjà arrêté ». Ainsi, mettre en pause un canal terminé le laisse En Pause à la position 0, et une reprise ultérieure le lit depuis le début (une vraie reprise d’un canal En Pause, depuis la position 0). Protégez-vous avec une vérification SPU_ChannelState == 0x42 si cela importe.

Types de ports

SPU_ChannelPosition est un port ENTIER — un index d’échantillon, borné par la console à 0 .. SoundLength-1. audio.h déclare set_channel_position( int ), et WriteSPUChannelPosition() lit Value.AsInteger. La table IOPortMap avait ioports.spu.chanpos déclaré en IOPORT_TYPE_FLOAT, ce qui y écrivait des motifs de bits flottants bruts ; corrigé en IOPORT_TYPE_INTEGER.

Ports véritablement flottants : SPU_ChannelVolume (borné 0–8), SPU_ChannelSpeed (0–128, change la hauteur du son), SPU_GlobalVolume (borné 0–2). Les écritures NaN/inf sur l’un de ces ports sont ignorées plutôt que rejetées.

music.volume(VOL [, CHANNEL]) / sfx.volume(VOL [, CHANNEL])

Définit le volume de lecture. VOL est obligatoire. CHANNEL a trois significations distinctes selon ce qu’écrit le site d’appel :

Appel Signification
music.volume(VOL) applique VOL à chaque canal que cet espace de noms possède actuellement (voir le suivi de possession de canal, ci-dessous)
music.volume(VOL, -1) le véritable volume global matériel (SPU_GlobalVolume, borné 0–2) — tous les canaux, inconditionnellement
music.volume(VOL, N) le volume de ce canal précis (SPU_ChannelVolume, borné 0–8), N dans 0–15

sfx.volume se comporte de manière identique, sur les propres canaux suivis de sfx.

music.play(MUSIC, 0, true)   -- réclame le canal 0 pour la musique
sfx.play(BLIP)                -- réclame un canal 1-15 pour le sfx

sfx.volume(0.3)               -- baisse UNIQUEMENT le(s) canal/canaux sfx ci-dessus ;
                               -- le canal 0 (musique) n'est pas touché
music.volume(0.5)             -- baisse la musique ; le sfx n'est pas touché
music.volume(1.0, 0)          -- canal 0 spécifiquement, volume maximal
sfx.volume(0.0, -1)           -- véritable coupure globale du son, les deux espaces de noms

Les numéros de canal explicites (N ou -1) ne changent jamais la possession — seul .play() le fait. Un canal en pause ou arrêté est toujours trouvé par le mode « canaux suivis » sans argument ; seule la lecture d’un canal différent sous l’autre espace de noms le libère.

Suivi de possession de canal

Deux mots de RAM réservés par le compilateur, VIRCON32_MUSIC_CHANNEL_MASK et VIRCON32_SFX_CHANNEL_MASK, chacun un masque de bits sur les canaux 0–15 : le bit N activé signifie que le canal N s’est vu assigner en dernier un son par le .play() de cet espace de noms. La possession est exclusive par construction — chaque music.play(S, ch) réclame ch pour la musique et l’efface du masque sfx, et chaque sfx.play(S, ch) fait l’inverse — puisqu’un canal n’appartient en réalité à aucun des deux espaces de noms au niveau matériel, seulement par convention selon la manière dont les appels .play() du jeu l’ont utilisé. Les deux masques commencent à 0 (rien de réclamé) ; music.volume(VOL)/sfx.volume(VOL) sans canal est un no-op tant que rien n’a réellement été lu sur cet espace de noms.

Seul .play() touche les masques. .pause(), .resume(), .stop(), et .volume() lui-même ne le font jamais — donc un canal actuellement en pause ou arrêté reste « possédé » par celui qui y a lu en dernier, et reste capté par le mode volume sans argument.

Génération de code

Un appel entièrement littéral (music.volume(0.5, 0), sfx.volume(0.0, -1)) se replie en une séquence linéaire de OUT à la compilation, comme le reste de cette surface. music.volume(VOL)/sfx.volume(VOL) sans argument de canal est toujours un CALL — quels canaux sont actuellement possédés n’est connu qu’à l’exécution — vers __builtin_vircon32_volume_mask, qui boucle sur les bits 0–15 du masque de l’espace de noms et écrit SPU_ChannelVolume pour chacun activé. Tout autre appel à valeur dynamique va vers __builtin_vircon32_volume, qui compare la valeur du canal à -1 (global) à l’exécution et borne dans le cas contraire.

Pourquoi les noms nus ont disparu

play / pause / resume / stop étaient les quatre identifiants les plus sujets aux collisions qu’un jeu pouvait vouloir pour lui-même, ce que l’ancienne garde de contournement resolve_symbol() dans le répartiteur compensait. Ils ont disparu ; les espaces de noms les remplacent, et le mécanisme d’alias les restaure par choix plutôt que par défaut.

Alias à la compilation

play = music.play enregistre un alias et n’émet rien. Les appels ultérieurs à play(...) compilent vers la séquence OUT en ligne identique — aucune valeur à l’exécution, aucun CALL, aucune RAM. La surface pouvant recevoir un alias est music.play, music.pause, music.resume, music.stop, music.playing, music.volume, sfx.play, sfx.stop, et sfx.volume.

Limites, toutes délibérées :

Si des valeurs de fonction sonore de première classe sont un jour souhaitées, le précédent est __mathfn_sin / __mathfn_log dans runtime.s — de véritables étiquettes encapsulées avec BOXED_FUNCTION, résolues dans try_emit_table_get_intrinsic() où math.sin en tant que valeur existe déjà. Ce serait additif ; la table d’alias reste le chemin à coût nul.

music.playing() et pourquoi un drapeau Lua est le mauvais interrupteur

SPU_ChannelState est un port entier en lecture seule qui renvoie channel_stopped 0x40, channel_paused 0x41, channel_playing 0x42. music.playing(ch) sélectionne le canal, le lit, et renvoie un véritable booléen Lua.

Un interrupteur pause/resume construit sur un drapeau Lua se dérègle dès qu’un son se termine de lui-même : le programme croit toujours qu’il est en lecture, donc l’appui suivant met en pause un canal déjà arrêté au lieu de le reprendre. Interroger le matériel ne peut pas se dérégler.

Génération de code : repliement hybride

Tous les arguments connus à la compilation → séquence linéaire de OUT, pas de CALL. Tout ce qui est dynamique → empile et fait un CALL vers la routine d’exécution, qui gère la valeur par défaut pour nil, le décodage de la véracité (truthiness) Lua, et le bornage de canal. Tout ou rien : un seul argument dynamique envoie tout l’appel sur le chemin d’exécution.

sfx.play() nécessite en plus un canal littéral explicite pour pouvoir se replier, puisque le chemin automatique doit lire et avancer le curseur. sfx.play(BLIP) — la forme courante — est donc toujours un CALL, qui est aussi l’émission la plus petite.

Les noms --#sound comptent comme connus à la compilation : node_cart_hint() attribue déjà à MUSIC son identifiant de ressource, donc music.play(MUSIC, 0) émet OUT SPU_ChannelAssignedSound, 0 plutôt que de lire la globale en RAM. Le repliement est supprimé lorsque spu_name_is_rebound() trouve que le nom a été réaffecté, utilisé comme variable de boucle, déclaré comme paramètre, ou mentionné dans de l’assembleur en ligne n’importe où dans l’AST.


ioports.spu.cmd() — l’échappatoire brute

Émet une commande SPU brute contre ce que SPU_SelectedChannel nomme actuellement. Aucune sélection de canal, aucune assignation de son, aucune valeur par défaut — associez-la aux propriétés ioports.spu.* pour les séquences que les intrinsèques ne couvrent pas (fondus enchaînés, recherche précise à l’échantillon près, points de boucle personnalisés).

Elle porte les mêmes contraintes d’ordre de ports que tout le reste : définissez chanloop après cmd("play"), jamais avant.

Modes : "play" 0, "pause" 1, "stop" 2, "pauseall" 3, "resume" 4, "allstop" 5, plus les alias "resumeall" et "stopall". Omis ou nil → "play".


Ports d’E/S booléens

Un booléen Lua n’est pas un flottant. true/false/nil sont des motifs de bits encapsulés en NaN ; les ports matériels échangent en entiers 0 et

  1. Les écritures décodent la véracité (truthiness) Lua (seuls nil et false sont faux), avec des littéraux se repliant en un immédiat 0/1 brut. Les lectures se branchent vers BOXED_TRUE/BOXED_FALSE.

Affecte ioports.spu.chanloop, ioports.spu.soundloop, ioports.inp.status, ioports.car.connected, ioports.mem.connected.

Les ports booléens renvoient true/false, pas 1.0/0.0. L’arithmétique sur l’un d’eux doit être réécrite sous la forme if p then 1 else 0.


Système : system.*

system.wait()   -> nil   (WAIT jusqu'au prochain signal de nouveau cycle)
system.halt()   -> nil   (HLT -- arrête le CPU)
system.date()   -> chaîne, année, mois, jour
system.time()   -> chaîne, heure, minute, seconde

system.wait() / system.halt()

system.wait() se compile directement en WAIT — la même instruction qu’utilise ioports.gpu.sync(), et interchangeable avec elle ; toutes deux attendent simplement le prochain signal de nouveau cycle de la minuterie. system.halt() se compile en HLT, arrêtant le CPU purement et simplement — pour un programme qui a terminé son travail et n’a plus rien à afficher.

system.date() / system.time()

Les deux décodent l’horloge temps réel de la puce de minuterie (voir la Spécification Système Vircon32, Partie 7, section 1.2 — c’est une véritable horloge murale/RTC, pas un compteur monotone depuis le démarrage) et renvoient quatre valeurs : une chaîne formatée, puis les trois composantes numériques.

local date_str, year, month, day     = system.date()   -- "2026-09-06", 2026, 9, 6
local time_str, hour, minute, second = system.time()    -- "03:00:04", 3, 0, 4

La chaîne de system.date() est "AAAA-MM-JJ" ; celle de system.time() est "HH:MM:SS". Les deux sont toujours complétées par des zéros à une largeur fixe (le %0*d de printf, pas un plafond strict) — year est complétée à 4 chiffres mais jamais tronquée, donc une année au-delà de 9999 (jusqu’au maximum matériel de CurrentYear, 65535) s’affiche quand même en entier, simplement plus large que 4 caractères ; mois/jour/ heure/minute/seconde font toujours exactement 2 chiffres. Les années bissextiles suivent la règle grégorienne standard (divisible par 4, sauf les siècles à moins d’être divisibles par 400) — la spécification confirme que la plage du nombre de jours s’étend à 365 lors d’une année bissextile mais ne définit pas la règle elle-même, donc c’est la seule lecture saine et universelle de celle-ci. Il n’y a pas de chaîne de format ni de construction de table à la os.time()/os.date() — c’est un décodage à forme fixe du registre matériel, pas une bibliothèque de dates générale.

system.frames / system.cycles

local f = system.frames()   -- TIM_FrameCounter -- images depuis la mise sous tension
local c = system.cycles()   -- TIM_CycleCounter -- cycles CPU depuis le début de l'image

Les deux sont des compteurs matériels en lecture seule, et aucun n’est une heure réelle. system.frames() compte les images depuis la mise sous tension ; system.cycles() compte les cycles CPU depuis le début de l’image en cours et repart de 0 à chaque image, si bien que le lire juste avant system.wait() indique quelle part du budget CPU de l’image a été utilisée. system.frames() est naturellement adapté au minutage du type « toutes les N images, fais X » (c’est exactement ce que la démo de défilement de tilemap utilise pour cadencer sa vitesse de défilement) puisqu’il tourne librement quoi que fasse le programme, contrairement à une variable de comptage artisanale qu’il faut se rappeler d’incrémenter à chaque image. Les deux sont également accessibles en tant que ports bruts (ioports.tim.frames, ioports.tim.cycles) — voir Autres ports d’E/S bruts — les noms system.* sont identiques, simplement sous l’espace de noms le plus facile à découvrir.


Graphismes : spr()

spr(region_id, x, y [, scale_x [, scale_y [, angle_deg [, color_mult [, blend_mode]]]]])

Dessine la région de texture region_id de la GPU (telle que déclarée par un indice --#texture, ou un index de région brut) à la position de pixel (x, y).

Argument Par défaut Notes
region_id obligatoire index de région GPU (GPU_SelectedRegion)
x, y obligatoires position de dessin en haut à gauche, GPU_DrawingPointX/Y
scale_x, scale_y 1.0 facteurs d’échelle X/Y indépendants
angle_deg 0 rotation, en degrés, sens antihoraire ; convertie en radians en interne (le GPU_DrawingAngle de la console est en radians)
color_mult 0xFFFFFFFF couleur de multiplication RGBA compressée — 0xFFFFFFFF signifie « aucun changement »
blend_mode "alpha" (0x20) une chaîne nommant le mode ou sa valeur numérique — voir les modes de fondu ci-dessous

Un argument peut être omis (simplement arrêter de fournir les arguments suivants) ou passé comme un nil explicite pour le sauter et atteindre un argument ultérieur — spr(id, x, y, nil, nil, 45) pour faire pivoter sans toucher à l’échelle — les deux signifient « utiliser la valeur par défaut ».

Modes de fondu — passez soit la chaîne du nom, soit le nombre :

Chaîne Nombre Effet
"alpha" ou "default" 0x20 fondu alpha normal (par défaut)
"add" 0x21 additif — éclaircit ce qui est dessous
"subtract" 0x22 soustractif — assombrit ce qui est dessous
spr(7, 100, 80, nil, nil, nil, nil, "add")       -- effet de lueur / lumière
spr(7, 100, 80, nil, nil, nil, nil, 0x21)        -- la même chose, en numérique
spr(7, 100, 80, nil, nil, nil, 0x80FFFFFF, "alpha")

La chaîne du mode doit être une chaîne littérale ; elle est résolue en son nombre à la compilation, et toute autre chaîne ("multiply", une faute de frappe) est une erreur de compilation. Une chaîne stockée dans une variable n’est pas reconnue (il n’y a pas de dispatch de chaînes à l’exécution — elle atteindrait le GPU comme un pointeur, pas comme un mode) ; gardez plutôt le mode dans une variable sous forme de nombre (local mode = 0x21). Les nombres sont transmis tels quels, comme avant.

spr() ne renvoie rien (nil Lua), ce qui correspond au fait que la console n’a aucune valeur significative à renvoyer d’un appel de dessin. Plus de 8 arguments déclenche un avertissement à la compilation ; les arguments en trop sont ignorés.

Répartition à l’exécution, pas de repliement à la compilation

Contrairement à l’API son, spr() appelle toujours __builtin_vircon32_spr — il n’existe aucun chemin rapide en OUT linéaire pour un appel entièrement littéral. La routine lit les valeurs réelles à l’exécution de scale_x/scale_y/angle_deg à chaque appel et choisit la moins coûteuse des quatre commandes de dessin GPU à chaque fois :

Condition Commande
échelle = 1,1 et angle = 0 GPUCommand_DrawRegion
échelle ≠ 1 et angle = 0 GPUCommand_DrawRegionZoomed
échelle = 1,1 et angle ≠ 0 GPUCommand_DrawRegionRotated
échelle ≠ 1 et angle ≠ 0 GPUCommand_DrawRegionRotozoomed

color_mult et blend_mode sont écrits inconditionnellement à chaque appel, avant cette répartition — GPU_MultiplyColor et GPU_ActiveBlending sont un état GPU persistant que chaque variante de dessin consulte, contrairement à GPU_DrawingScaleX/Y/GPU_DrawingAngle, qui ne sont écrits que dans les branches qui les utilisent réellement (sans danger de les laisser périmés, puisque par exemple DrawRegionRotated est défini pour ignorer entièrement l’échelle).

color_mult est un mot compressé 0xAABBGGRR, écrit tel quel dans GPU_MultiplyColor, sans conversion flottant → entier (le même modèle que ioports.gpu.clear(couleur)). Un littéral numérique (0xFFFFFFFF, 0x80FFFFFF, -1) est converti en ce mot à la compilation. Toute autre expression doit déjà contenir le mot compressé : rgba(r, g, b [, a]) (voir Couleurs : rgba()), hex("0xFF8080FF"), ou une variable affectée depuis l’un d’eux. Un nombre calculé à l’exécution n’est pas converti — un float32 ne peut pas représenter exactement une couleur 32 bits. Pour une couleur qui change à l’exécution (un fondu), gardez les composantes sous forme de nombres et assemblez-les au moment du dessin :

spr(REGION_SOLID, x, y, w, h, 0, rgba(0, 0, 0, frame * 8))   -- fondu au noir

Un nil explicitement passé pour un argument optionnel (par exemple spr(id, x, y, nil, nil, 45)) est traité de façon identique à cet argument étant totalement omis — les deux retombent sur la valeur par défaut. Un appel situé dans un contexte d’expression (local unused = spr(1, 10, 10)) se voit correctement assigner nil, comme tout autre intrinsèque de ce document.

ioports.gpu.clear([couleur]) / ioports.gpu.clear(r, g, b [, a])

Efface l’écran : écrit GPU_ClearColor (si une couleur est donnée) puis émet GPUCommand_ClearScreen. Omettez les arguments pour effacer avec ce que GPU_ClearColor contient actuellement. La couleur peut être donnée de quatre façons :

Forme Exemple Notes
nom prédéfini clear("black") "black", "white", "blue", "red", "green" — chaîne littérale, résolue à la compilation ; tout autre nom est une erreur de compilation
littéral compressé clear(0xFF202020) 0xAABBGGRR ; replié à la compilation en mot brut de 32 bits
mot compressé clear(rgba(32, 32, 32)), clear(hex("0xFF202020")), clear(c) une valeur non littérale est écrite telle quelle sur le port, elle doit donc déjà contenir le mot brut — c’est-à-dire provenir de rgba() ou de hex()
composantes clear(32, 32, 32), clear(r, g, b, 128) rouge, vert, bleu, alpha, chacune de 0 à 255 ; alpha est facultatif et vaut 255 (opaque) par défaut
ioports.gpu.clear("black")
ioports.gpu.clear(0xFF202020)       -- 0xAABBGGRR compressé, gris foncé
ioports.gpu.clear(32, 32, 32)       -- le même gris foncé, en composantes
ioports.gpu.clear(r, g, b)          -- fonctionne aussi avec des variables ; alpha = 255
ioports.gpu.clear(0, 0, 64, 128)    -- bleu foncé semi-transparent
ioports.gpu.clear()                 -- réutilise le dernier ClearColor défini

Notez que l’ordre des octets du GPU est 0xAABBGGRR — le rouge est l’octet de poids faible — c’est la raison principale de la forme en composantes : clear(r, g, b) les range dans le bon ordre pour vous.

La forme en composantes accepte 3 ou 4 arguments (2, ou plus de 4, est une erreur de compilation). Quand toutes les composantes sont littérales, elle est repliée en une seule constante à la compilation — les littéraux hors plage sont bornés à 0–255 avec un avertissement. Sinon chaque composante est évaluée comme une expression ordinaire, bornée à 0–255, tronquée en entier et assemblée à l’exécution. Un nil explicite pour a signifie opaque, comme l’omettre, de même qu’un alpha valant nil à l’exécution ; il n’y a pas de vérification de nil à l’exécution sur le rouge, le vert et le bleu, donc une variable valant nil à cet endroit donne une couleur indéfinie.

Pourquoi une variable compressée a besoin de hex() : les nombres de v32lua sont des float32, donc un nombre comme 0xFF202020 stocké dans une variable contient un flottant, pas les bits de la couleur, et clear() ne peut pas les distinguer à l’exécution. Les littéraux écrits directement dans l’appel fonctionnent, car ils sont repliés à la compilation. rgba() (ci-dessous) produit aussi le mot brut.

Couleurs : rgba(r, g, b [, a])

Renvoie le mot compressé 0xAABBGGRR du GPU pour une couleur — le mot brut de 32 bits, pas un nombre Lua — pour tout ce qui en attend un : le color_mult de spr(), ioports.gpu.clear(couleur), ioports.gpu.multiply, ioports.gpu.bgcolor.

spr(id, x, y, 1, 1, 0, rgba(255, 255, 255, alpha))   -- fondu
ioports.gpu.multiply = rgba(r, g, b)                 -- sans conversion flottante
ioports.gpu.clear(rgba(16, 16, 48))

Attention — gardez les composantes, pas le mot. Un mot brut dont les bits de poids fort correspondent à l’une des étiquettes de valeur du langage est cette valeur pour le reste du programme : rgba(0, 0, 192, 255) vaut 0xFFC00000, c’est-à-dire nil, et 0xFF8xxxxx se lit comme une table. Le passer directement à un appel ou à un port est toujours sûr (il est seulement copié). Le ranger dans une table (une valeur nil supprime la clé), le tester (if c then) ou le comparer à nil ne l’est pas. hex() a le même problème. L’usage sûr est de garder les composantes sous forme de nombres et d’appeler rgba() là où la couleur est utilisée, comme dans les exemples ci-dessus.

Couleurs : color(n)

Transforme un nombre qui contient une couleur assemblée 0xAABBGGRR en mot brut, pour les couleurs que vous avez sous forme de nombre plutôt que de composantes : calculée par arithmétique, lue dans une table de couleurs ou chargée depuis la carte mémoire.

local palette = { 0xFF1D2B53, 0xFF7E2553, 0xFF008751 }
ioports.gpu.multiply = color(palette[i])
spr(id, x, y, 1, 1, 0, color(base + fade * 0x01000000))

Ports entiers et littéraux

Un nombre écrit dans un port entier (ioports.gpu.x, bgcolor, multiply, …) est converti par CFI, tronqué vers zéro. Un littéral numérique est au contraire écrit tel quel sous forme de mot 32 bits exact, calculé à la compilation : ioports.gpu.bgcolor = 0xFF003366 stocke 0xFF003366 (une conversion de float le saturerait), ioports.gpu.x = -5 stocke -5 et ioports.gpu.y = 12.7 stocke 12. Les littéraux hors de [-2^31, 2^32) sont saturés avec un avertissement.

Nombres donnés à des ports couleur. Un nombre Lua est un flottant : ses bits ne forment pas une couleur. Quand un port couleur (ioports.gpu.clear, multiply, bgcolor…), le color_mult de spr ou la couleur de rect/rectfill reçoit un nombre qui n’est pas un littéral, le compilateur l’arrondit désormais vers le bas en entier et l’utilise comme mot 0xAABBGGRR (modulo 2^32), avec un avertissement : un float32 ne garde que 24 bits (alpha ≥ 0x80 demande rgba()/hex()). Avant, ce sont les bits du flottant qui partaient : ioports.gpu.clear = 8 effaçait en noir à 25 % d’alpha, que l’émulateur mélange à l’image précédente. --allow-float-as-clearcolor (ou la directive --#allow-float-as-clearcolor) rétablit ce comportement pour la couleur d’effacement seulement, à dessein, pour une traînée de flou de mouvement. Utilisez rgba(), hex() ou un littéral pour des couleurs exactes.

Définir des régions de texture

Le region_id de spr() ne fait référence à rien tant qu’une région n’a pas réellement été découpée dans une texture chargée. Il n’existe aucune création de région côté compilateur — ce sont six écritures de ports brutes, normalement faites une fois dans init() (ou une fois par texture au début de main() s’il n’y a pas de init() séparé), une région à la fois :

ioports.gpu.texture = SPRITES   -- sélectionne dans quelle texture chargée cette région est découpée
ioports.gpu.region  = 1         -- sélectionne l'EMPLACEMENT de région 1 à définir (c'est l'id que spr() utilisera)
ioports.gpu.minX = 6            -- coin supérieur gauche de la région, en pixels de texture
ioports.gpu.minY = 156
ioports.gpu.maxX = 58           -- coin inférieur droit, inclus
ioports.gpu.maxY = 208
ioports.gpu.hotX = 6            -- voir ci-dessous -- PAS 0
ioports.gpu.hotY = 156          -- voir ci-dessous -- PAS 0

Points chauds des régions : ce qu’il faut savoir et ce que fait le compilateur

Le point chaud (hotspot) d’une région permet de la dessiner par rapport à un point de référence, mais l’oublier peut poser problème : un point chaud non défini peut valoir 0, 0 par défaut. Si ce point est assez loin des coins de la région, celle-ci risque de ne pas se dessiner là où vous le voulez, voire pas du tout à l’écran.

C’est pourquoi v32lua fixe automatiquement ioports.gpu.hotX et ioports.gpu.hotY, afin qu’une région dont le point chaud a été oublié reste visible :

quand on écrit le X et le Y minimaux d’une région, le X et le Y du point chaud prennent exactement les mêmes valeurs. Le point chaud est donc par défaut le coin supérieur gauche de la région.

Pour placer le point chaud ailleurs qu’au coin supérieur gauche, il suffit de le fixer APRÈS avoir écrit les X et Y minimaux.

GPU_RegionMinX/MinY/MaxX/MaxY/HotSpotX/HotSpotY sont les ports bruts derrière ioports.gpu.minX etc. — voir la table complète des ports dans Autres ports d’E/S bruts pour tout le reste qu’expose ioports.gpu.* (point de dessin, échelle, angle, couleur de multiplication, mélange, et le compteur en lecture seule GPU-occupée ioports.gpu.pixels).


Graphismes : rect() / rectfill()

rect(x1, y1, x2, y2 [, color])       -- contour de 1 pixel
rectfill(x1, y1, x2, y2 [, color])   -- plein

(x1, y1) et (x2, y2) sont deux coins opposés, tous deux inclus, dans n’importe quel ordre : rectfill(10, 20, 19, 24) couvre 10 × 5 pixels, colonnes 10–19 et lignes 20–24, et rectfill(19, 24, 10, 20) est le même rectangle. Les coordonnées sont arrondies à l’entier inférieur (rectfill(9.8, ...) commence à la colonne 9) ; nil ou une valeur qui n’est pas un nombre compte pour 0. Deux coins identiques dessinent un pixel.

color est un mot empaqueté 0xAABBGGRR, exactement comme le color_mult de spr() : un littéral (0xFF0000FF), rgba(), color(), hex(), ou une variable qui en contient un. Absent ou nil : blanc opaque. Une couleur d’alpha inférieur à 255 se mélange selon le mode de mélange courant (ioports.gpu.blending), que rect() laisse tel quel.

rectfill(0, 0, 639, 359, rgba(0, 0, 64))      -- tout l'écran, bleu foncé
rect(100, 50, 199, 99, 0xFF00FFFF)           -- cadre jaune de 100 x 50
rectfill(px, py, px + 15, py + 15, rgba(255, 0, 0, 128))   -- rouge translucide

Comment il dessine

Les deux dessinent avec la texture du BIOS (-1), région 256 : un pixel blanc défini par le BIOS, en (469, 29), avec son point d’ancrage dessus. Rien n’est ajouté à la cartouche. rectfill() est un seul dessin agrandi de cette région à l’échelle (largeur, hauteur), teinté par la couleur de multiplication — exact au pixel pour toute taille (la correction d’agrandissement du GPU garde l’échantillonnage dans ce seul pixel). rect() fait jusqu’à 4 dessins qui ne se chevauchent pas (bords haut et bas sur toute la largeur, les côtés entre eux), si bien qu’un contour translucide n’est pas plus sombre aux coins.

L’état du GPU utilisé par l’appel est rétabli ensuite : texture et région sélectionnées, couleur de multiplication, échelle de dessin. Un rect() peut se glisser au milieu de code de dessin ioports.gpu.* sans le perturber.

Chaque dessin coûte des pixels GPU comme n’importe quel autre (ioports.gpu.pixels), plus la pénalité d’agrandissement du GPU ; un rectfill() plein écran coûte un écran de pixels.

Une fonction à vous nommée rect ou rectfill remplace l’intrinsèque, comme pour tous les intrinsèques. Avec --#api pico8, ces deux noms sont les versions PICO-8 à couleurs de palette (voir PICO8.md) ; avec --#api tic80, rect(x, y, w, h, color) / rectb(...) sont celles de TIC-80 (voir TIC80.md).


Entrées : btn() / btnp()

btn(id [, player])   -> booléen, actuellement maintenu enfoncé
btnp(id [, player])  -> booléen, vrai uniquement sur l'image où il a été pressé pour la première fois

player sélectionne une manette 0–3 (INP_SelectedGamepad) ; omis ou nil utilise la manette déjà sélectionnée sans écrire le port.

Identifiants de boutons

Ordre matériel de Vircon32, pas celui de PICO-8 ni de TIC-80 :

id Bouton IOPort
0 Gauche INP_GamepadLeft
1 Droite INP_GamepadRight
2 Haut INP_GamepadUp
3 Bas INP_GamepadDown
4 Start INP_GamepadButtonStart
5 A INP_GamepadButtonA
6 B INP_GamepadButtonB
7 X INP_GamepadButtonX
8 Y INP_GamepadButtonY
9 L (gâchette gauche) INP_GamepadButtonL
10 R (gâchette droite) INP_GamepadButtonR

Un id en dehors de 0–10, ou une combinaison non mappée, renvoie false plutôt que de produire une erreur — il n’existe aucun chemin d’erreur au niveau système d’exploitation disponible sur cette cible (voir pcall/error/assert dans la liste des fonctionnalités différées du compilateur).

btn() : sondage direct

__builtin_vircon32_btn lit le port INP_Gamepad* mappé pour la manette sélectionnée et renvoie true lorsque le matériel signale qu’il est enfoncé (>= 1).

btnp() : détection de front

btnp() a besoin d’un état que le matériel ne suit pas de lui-même : « ce bouton n’était-il pas enfoncé à l’image précédente, et l’est-il maintenant ». Cet état vit dans VIRCON32_BTN_PREV_STATE, une plage RAM fixe de 44 mots réservée par le compilateur — un mot par paire (joueur, bouton), player * 11 + button_id — mise à jour à chaque appel de btnp() quel que soit le résultat. Seule une véritable transition non-enfoncé → enfoncé renvoie true ; un bouton maintenu sur plusieurs images renvoie true une fois, puis false à chaque image suivante jusqu’à ce qu’il soit relâché puis pressé à nouveau.

Un player hors limites (une valeur explicite en dehors de 0–3) est borné à 0–3 avant d’être utilisé comme index dans cette table de 44 mots, plutôt que de laisser calculer une adresse en dehors de celle-ci.

ioports.inp.inputs — masque d’un mot

local mask = ioports.inp.inputs   -- manette actuelle, les 11 boutons en une seule lecture

Lit chaque port INP_Gamepad* pour la manette actuellement sélectionnée (ioports.inp.gamepad) et les rassemble en un seul nombre de 11 bits en une fois, au lieu de onze appels séparés à btn(). Disposition des bits, du MSB au LSB :

Bit 10 9 8 7 6 5 4 3 2 1 0
Bouton Gauche Droite Haut Bas Start A B X Y L R

Chaque bit vaut 1 si ce bouton se lit actuellement comme enfoncé (> 0), la même sémantique de « actuellement maintenu » que btn() — c’est un instantané d’état maintenu, pas déclenché par front ; il n’existe pas d’équivalent en masque de bits de btnp(). Utile pour transmettre l’entrée d’une image entière en une seule valeur (par exemple dans un journal de replay/entrée) plutôt que pour la logique de jeu courante bouton par bouton, où btn()/btnp() se lisent plus clairement.

Ce qui n’est délibérément PAS ici

Cela correspond au matériel Vircon32 sous-jacent plutôt qu’aux conventions de PICO-8/TIC-80 ; cette émulation vit entièrement dans les couches de compatibilité --#api pico8/--#api tic80, pas ici.


Clavier et souris : ce qu’il faut (v32io)

⚠ Demande du matériel en plus ou un émulateur modifié. La console Vircon32 n’a ni clavier ni souris : ses seuls périphériques d’entrée sont quatre manettes. key(), keyp(), kbd.*, mouse(), mouse.* et stat(32..34) de PICO-8 lisent un clavier ou une souris qui arrivent à la console déguisés en manette, par l’une des deux solutions v32io ci-dessous. Sans l’une d’elles, aucune entrée clavier ni souris n’est possible : les fonctions se compilent et s’exécutent, mais aucune touche n’est jamais enfoncée, aucun caractère n’est jamais tapé et le pointeur de la souris ne bouge pas. Cela vaut pour les émulateurs Vircon32 d’origine et pour tout autre environnement.

1. L’adaptateur matériel v32io (fonctionne avec n’importe quel émulateur Vircon32). Une carte Waveshare RP2350-USB-A (avec la résistance R13 retirée, pour que son port USB-A puisse accueillir des périphériques basse vitesse) qui exécute le firmware v32io. Un clavier ou une souris USB se branche sur l’adaptateur ; l’adaptateur se branche sur l’ordinateur et y apparaît comme une manette USB ordinaire nommée v32io:kbd ou v32io:mouse, selon ce qui y est branché. Comme ce n’est qu’une manette, il fonctionne avec l’émulateur d’origine comme avec le modifié. Configuration, une fois par mode : créer un profil de joystick avec l’éditeur de commandes de l’émulateur (EditControls), en associant ses boutons 0 à 10 à Left, Right, Up, Down, Start, A, B, X, Y, L, R dans cet ordre — l’adaptateur a un mode configuration pour cela (clavier : Arrêt défil, puis F1–F11 pour les 11 commandes ; souris : maintenir les trois boutons 2 secondes, puis chaque clic gauche appuie sur la commande suivante) — et choisir ce profil pour le port de manette attendu par le programme. Le firmware, ses notes de compilation et la procédure complète sont dans le projet v32io (firmware/).

2. Un émulateur Vircon32 modifié (sans matériel). Les périphériques v32io intégrés au DesktopEmulator lui-même, qui lisent le clavier et la souris de l’ordinateur. Au choix :

Dans son menu Gamepads, choisir la manette attendue par le programme et sélectionner v32kbd ou v32mouse. v32kbd ne peut pas être sélectionné tant qu’une manette utilise le périphérique Keyboard d’origine (les deux ont besoin du clavier de l’ordinateur). Avec v32mouse, donner le focus à la fenêtre de l’émulateur capture le pointeur ; Ctrl gauche + Alt gauche le libère. Le périphérique de manette Keyboard propre à l’émulateur d’origine n’est pas un clavier v32io : il ne fait qu’associer quelques touches à des boutons de manette, qu’un programme qui le lit voit comme des touches aléatoires.

Quel port de manette. Par défaut, le programme lit le clavier sur le port de manette 2 (le troisième) et la souris sur le port 3 (le quatrième), ce qui laisse les ports 0 et 1 aux manettes des joueurs. Les ports sont numérotés à partir de 0 et les menus de l’émulateur à partir de 1 : le port 2 est Gamepad 3 et le port 3 est Gamepad 4. Branchez le périphérique sur ce port, ou indiquez au programme où il est : --keyboard N / --mouse N sur la ligne de commande, --#keyboard N / --#mouse N dans le source, ou kbd.port(n) / mouse.port(n) à l’exécution.

Vérifier le périphérique. kbd.connected() et mouse.connected() indiquent si une manette est branchée sur ce port — la console ne peut pas distinguer un périphérique v32io d’une vraie manette. Avec l’adaptateur matériel, sa manette n’existe que tant qu’un clavier ou une souris y est branché. Une manette ordinaire laissée sur le port du clavier ou de la souris est lue comme tel : ses boutons deviennent des touches, des clics et des mouvements aléatoires. Un programme qui doit aussi fonctionner sans ces périphériques devrait proposer une autre façon de jouer (commandes à la manette, clavier à l’écran) et, idéalement, laisser choisir le port.

Comment ça marche

La conception suit les bibliothèques C du projet v32io (v32io.h, keyboard.h, mouse.h), dont la référence des protocoles est PROTOCOLS.md :


Clavier : key() / keyp() / kbd.*

key([k])                      -> booléen, k enfoncée (sans k : n'importe quelle touche)
keyp([k [, hold, period]])    -> booléen, k enfoncée à cette image (+ répétition)
kbd.read()                    -> caractère tapé suivant (un nombre), ou nil
kbd.event()                   -> événement suivant : +code enfoncée, -code relâchée, ou nil
kbd.glyph(v)                 -> caractère de la police du BIOS pour une valeur de kbd.read(), ou nil pour un code de touche
kbd.port([n])                 -> port de manette du clavier (et le change)
kbd.capslock()                -> booléen, Verr Maj actif
kbd.connected()               -> booléen, quelque chose est branché sur ce port
kbd.clear()                   -- oublie les événements non lus

⚠ Uniquement avec un périphérique v32io. Il faut l’adaptateur matériel v32io avec un clavier USB, ou le périphérique v32kbd de l’émulateur modifié — voir Clavier et souris : ce qu’il faut. Sans eux, key() est toujours faux et kbd.read() toujours nil.

Ces fonctions lisent un clavier complet à travers un périphérique v32kbd : un clavier USB que la console voit comme une manette ordinaire, dont les 11 commandes transportent des événements de touches au lieu de boutons (voir le projet v32io). Il se branche sur un port de manette — le port 2 (le troisième) par défaut, ce qui laisse les ports 0 et 1 aux manettes ordinaires. On le change avec --keyboard N sur la ligne de commande, une indication --#keyboard N dans le source, ou kbd.port(n) à l’exécution.

key()/keyp() suivent ceux de TIC-80 : mêmes noms, mêmes règles, avec les codes de touche v32kbd. Avec --#api tic80, ces deux appels prennent les codes de TIC-80 (voir TIC80.md) ; kbd.* fonctionne avec toutes les API. Une globale à vous nommée kbd (une table que vous affectez) remplace les fonctions kbd.* intégrées.

Codes de touche

Un code désigne une touche, pas un caractère : les touches qui tapent un caractère utilisent ce caractère sans majuscule, disposition US — 'a'–'z' (97–122), '0'–'9' (48–57), espace (32) et ` - = [ ] \ ; ' , . / — et les autres :

Code Touche Code Touche
1 Haut 11 Ctrl droit
2 Bas 12 Alt gauche (Option)
3 Gauche 13 Entrée
4 Droite 14–25 F1–F12
5 Verr Maj 26 Alt droit (Option)
6 Maj gauche 27 Échap
7 Maj droite 28 GUI gauche (Commande, Windows)
8 Retour arrière 29 GUI droite
9 Tab 127 Suppr
10 Ctrl gauche    

Les touches du pavé numérique donnent les mêmes codes que leurs équivalents du clavier principal.

k peut aussi être un littéral de chaîne, converti en code à la compilation : un caractère ("a", "/", " " ; un caractère majuscule ou shifté désigne sa touche, donc "A" est la touche a et "!" la touche 1), ou un nom — up down left right enter (return) tab space backspace delete (del) escape (esc) capslock lshift rshift lctrl rctrl lalt ralt lgui rgui f1–f12, sans distinction de casse. Quatre noms valent pour les deux côtés : shift, ctrl, alt, gui (key("shift") est vrai tant que l’une des deux touches Maj est enfoncée). Un nom inconnu est une erreur de compilation. Seuls les littéraux sont convertis : une chaîne rangée dans une variable n’est pas une touche (false).

function game_loop()
    if key("left")  then x = x - 2 end
    if key("right") then x = x + 2 end
    if keyp("space") then fire() end
    if key("ctrl") and keyp("s") then save() end
    if keyp("down", 20, 4) then menu_next() end   -- se répète tant qu'elle reste enfoncée
end

key([k]), keyp([k [, hold, period]])

key(k) est vrai tant que la touche est enfoncée. keyp(k) est vrai à l’image où elle s’enfonce ; avec hold et period donnés et ≥ 0, aussi tant que la touche reste enfoncée, à partir de hold images, toutes les period images (period 0 : à chaque image) — en comptant les images après la première, comme keyp et btnp de TIC-80. Pas de répétition par défaut. Sans k, key() signifie « une touche est enfoncée » et keyp() « une touche s’est enfoncée à cette image ». Un code sans touche correspondante vaut false.

Texte tapé : kbd.read()

local text = ""
function game_loop()
    local c = kbd.read()
    while c do
        if c == 8 then                          -- Retour arrière
            text = string.sub(text, 1, -2)
        else
            local g = kbd.glyph(c)              -- nil for a key with no character
            if g then text = text .. string.char(g) end
        end
        c = kbd.read()
    end
    print(0, 0, text .. "_")
end

kbd.read() renvoie l’appui suivant sous la forme du caractère tapé, avec Maj et Verr Maj appliqués tels qu’ils étaient au moment de l’appui ("A", "!", "{" … en nombres, selon la police du BIOS), et les touches sans caractère sous forme de leur code (Entrée 13, Retour arrière 8, flèches 1–4…). Les relâchements sont ignorés. nil quand il n’y a plus rien.

Les caractères hors ASCII US (lettres accentuées, €, symboles de cadre) sont saisis par l’extension texte de v32io : le périphérique envoie le caractère sous forme de deux événements, que le code clavier du compilateur réassemble (une première moitié seule est abandonnée après 30 images, et un périphérique rebranché repart à zéro). kbd.read() renvoie un tel caractère sous la forme de son code dans la police du BIOS (Windows-1252, 128–255) ; les symboles de cadre 14–31 reviennent en 256 + code, pour ne pas être pris pour des codes de touche (Entrée 13, flèches 1–4…). kbd.glyph(v) fait l’inverse : il donne le caractère à passer à print ou string.char(), et nil pour un code de touche. kbd.event() ignore les caractères tapés et ne rapporte que les touches.

kbd.event() renvoie au contraire tous les événements, appuis et relâchements, sous forme du code de touche sans Maj : positif pour un appui, négatif pour un relâchement (-97 : la touche a a été relâchée). Les deux lisent la même file : utilisez l’une ou l’autre. La file contient 64 événements ; au-delà, les nouveaux sont perdus jusqu’à ce qu’elle soit lue (kbd.clear() la vide ; les touches enfoncées que voit key() ne changent pas).

kbd.port([n]), kbd.capslock(), kbd.connected()

kbd.port(n) déplace le clavier sur le port de manette n (0–3, borné) et repart de zéro : touches enfoncées, événements en file et Verr Maj sont oubliés, et l’état actuel du périphérique sert de point de départ. Il renvoie le port ; kbd.port() ne fait que le renvoyer. kbd.capslock() est l’état de Verr Maj, tenu en comptant ses appuis (désactivé au départ). kbd.connected() est vrai quand quelque chose est branché sur le port du clavier.

Lire à chaque image

Le périphérique signale au plus un événement de touche par image et le garde jusqu’au suivant : il faut donc le lire à chaque image, sinon des événements se perdent. Le compilateur s’en charge quand le programme utilise le clavier :

Une boucle main() qui attend avec system.wait() ne perd donc rien, même si elle ne regarde le clavier que de temps en temps. Un __rawasm__("WAIT") nu saute cette lecture. Rien de tout cela n’est présent dans un programme qui n’utilise pas le clavier.

Le port de manette

Le port du clavier est lu sans perturber la manette sélectionnée par le programme : btn(), btnp() et ioports.inp.* continuent de lire la manette qu’ils lisaient. Ne lisez pas le port du clavier avec btn() : ses « boutons » sont des bits du code de touche.

La manette sélectionnée est mémorisée par l’environnement d’exécution (V32IO_GAMEPAD) au lieu d’être relue dans INP_SelectedGamepad : les émulateurs Vircon32 renvoient une valeur erronée quand on lit ce port. Les lectures de ioports.inp.gamepad donnent elles aussi la valeur mémorisée. Une écriture du port par __rawasm__ contourne ce mécanisme.


Souris : mouse() / mouse.*

mouse()                       -> x, y, left, middle, right, scrollx, scrolly
mouse.pressed([b])            -> booléen, un bouton de b s'est enfoncé à cette image
mouse.released([b])           -> booléen, un bouton de b a été relâché à cette image
mouse.buttons()               -> boutons enfoncés : 1 gauche + 2 droit + 4 milieu
mouse.delta()                 -> dx, dy : le mouvement de cette image
mouse.position([x, y])        -> x, y (et y place le pointeur)
mouse.bounds(x1, y1, x2, y2)  -- la zone où reste le pointeur
mouse.scale([n])              -> pixels parcourus par pas (et le change)
mouse.port([n])               -> port de manette de la souris (et le change)
mouse.connected()             -> booléen, quelque chose est branché sur ce port

⚠ Uniquement avec un périphérique v32io. Il faut l’adaptateur matériel v32io avec une souris USB, ou le périphérique v32mouse de l’émulateur modifié — voir Clavier et souris : ce qu’il faut. Sans lui, le pointeur reste à son point de départ et aucun bouton n’est enfoncé.

Ces fonctions lisent une souris à travers un périphérique v32mouse : une souris USB que la console voit comme une manette ordinaire (voir le projet v32io). Il se branche sur un port de manette — le port 3 (le quatrième) par défaut, comme dans la démo souris de v32io, ce qui laisse la place à un clavier (port 2) et aux manettes de deux joueurs (ports 0 et 1). On le change avec --mouse N sur la ligne de commande, une indication --#mouse N dans le source, ou mouse.port(n) à l’exécution.

mouse() est celui de TIC-80 : x, y du pointeur, puis left, middle, right en booléens, puis scrollx, scrolly, toujours 0 — le périphérique n’a pas de place pour la molette. Le même appel, avec les mêmes 7 valeurs, fonctionne aussi avec --#api tic80 et --#api pico8, dans les unités d’écran de cette console (voir TIC80.md et PICO8.md) ; mouse.* fonctionne avec toutes les API. Une fonction ou une globale à vous nommée mouse les remplace tous.

function game_loop()
    local x, y, left = mouse()
    if mouse.pressed(1) then          -- le bouton gauche s'est enfoncé
        click_at(x, y)
    end
    if left then
        draw_at(x, y)                 -- maintenu
    end
    rectfill(x - 1, y - 1, x + 1, y + 1)
end

Le pointeur

Le périphérique signale un mouvement, pas une position, donc l’environnement d’exécution tient un pointeur : il part du centre de l’écran (320, 180), se déplace de pas × échelle (échelle 2 par défaut : 2 pixels par pas) et reste dans ses limites (tout l’écran 640 × 360 par défaut, bords inclus). mouse.bounds() le limite à une zone (un nil garde ce bord tel quel) et l’y ramène ; mouse.position(x, y) le place (chaque coordonnée donnée est arrondie à l’entier inférieur et gardée dans les limites ; nil la conserve) ; mouse.scale(n) règle la vitesse (n ≥ 1). Les trois renvoient leurs valeurs actuelles, donc mouse.position() et mouse.scale() ne font que lire. mouse.delta() est le mouvement de cette image en pixels (après l’échelle, avant les limites) ; 0, 0 s’il n’a pas bougé.

Boutons

left, middle et right restent vrais tant que les boutons sont enfoncés. Pour mouse.pressed(b), mouse.released(b) et mouse.buttons(), les boutons sont des nombres : 1 gauche, 2 droit, 4 milieu, additionnés pour « l’un de ceux-ci » (mouse.pressed(3) : gauche ou droit). Sans b, n’importe quel bouton. pressed / released ne sont vrais qu’à l’image où le bouton a changé. L’adaptateur garde chaque changement de bouton au moins 25 ms, donc même un clic rapide dure plus d’une image.

Comment elle est lue

Le périphérique tient deux compteurs, un par axe, qui font le tour de 12 positions, une commande de la manette changeant à chaque pas ; chaque lecture les compare à la précédente, ce qui donne de −5 à +5 pas par axe (6 ne se distingue pas de −6 et compte comme aucun mouvement). Comme le clavier, la souris doit donc être lue à chaque image — et le compilateur s’en charge quand le programme l’utilise : chaque appel à mouse/mouse.* la lit (une fois par image), les boucles de game_loop(), TIC-80 et PICO-8 la lisent à chaque image, et system.wait(), ioports.gpu.sync() et le flip() de PICO-8 la lisent avant leur WAIT (ce qui arrive alors compte pour l’image suivante). Une boucle main() qui attend avec system.wait() ne perd aucun mouvement même si elle ne regarde la souris que de temps en temps. Le mouvement n’est mesuré qu’entre deux lectures d’un périphérique branché espacées d’au plus 2 images ; dans tous les autres cas — un périphérique tout juste branché, des images passées dans un écran de pause, une boucle __rawasm__("WAIT") — la lecture prend seulement les compteurs comme nouveau point de départ, si bien que le pointeur ne saute jamais.

mouse.port(n) déplace la souris sur le port de manette n (0–3, borné) et repart de l’état actuel du périphérique (le pointeur reste où il est). mouse.connected() est vrai quand quelque chose est branché sur le port de la souris. Comme pour le clavier, lire le port de la souris ne perturbe pas la manette sélectionnée par le programme.


Tilemap : tilemap.*

tilemap.get(NAME, x, y)        -> nombre ou nil (hors limites)
tilemap.set(NAME, x, y, v)     -> v (une écriture hors limites est un no-op silencieux)
tilemap.render(NAME, sx, sy, w, h, x, y, tile_w, tile_h [, skip_id])

NAME est toujours un identifiant nu déclaré par --#tilemap, résolu entièrement à la compilation — jamais une valeur d’exécution, la même restriction (et la même raison) que les noms --#sound/--#texture : il n’y a rien de sensé contre quoi un nom calculé dynamiquement pourrait se résoudre, puisque tout l’intérêt est que le compilateur connaisse la largeur/hauteur et l’emplacement en ROM de la tilemap par son nom avant que le moindre code ne s’exécute.

Contrairement à --#texture/--#sound, une tilemap n’est pas une ressource cart-XML — pas d’entrée <textures>/<sounds>, pas d’identifiant de ressource intégré dans le code généré. Ses données sont incrustées sous forme de valeurs littérales directement dans le programme assemblé.

–#tilemap NOM “fichier” et le format CSV

--#tilemap LEVEL1 "level1.csv"

Le fichier est du texte brut : des lignes d’identifiants de tuiles séparés par des virgules, une ligne par rangée. Le nombre de lignes devient la hauteur de la tilemap ; le nombre de valeurs de la première ligne devient sa largeur, et chaque autre ligne doit correspondre exactement à ce nombre, sinon c’est une erreur de compilation — une carte irrégulière lisant silencieusement des données aléatoires au-delà d’une ligne courte est pire que de refuser de compiler. Un identifiant de tuile n’est qu’un nombre ; il n’a pas de signification requise, mais la signification naturelle (et celle que suppose tilemap.render()) est un identifiant de région GPU, prêt à être transmis à spr().

Ce format est délibérément assez simple pour que la sortie Export As… CSV (par calque) de Tiled puisse être utilisée directement sans étape de conversion — aucune analyse TMX/TSX nulle part dans ce compilateur.

tilemap.get() / tilemap.set()

local id = tilemap.get(LEVEL1, 4, 2)   -- tuile en colonne 4, ligne 2
tilemap.set(LEVEL1, 4, 2, 99)          -- l'écraser

Les deux sont indexés à partir de 0, (x, y) = (colonne, ligne). tilemap.get() hors limites (n’importe quel axe, n’importe quelle direction) renvoie nil, comme lire au-delà de la fin d’une table Lua. tilemap.set() hors limites est un no-op silencieux — il n’y a pas de valeur sensée à renvoyer pour « vous avez essayé d’écrire nulle part », donc elle décline simplement, reflétant la façon dont le mset() de la couche de compatibilité TIC-80 traite déjà une écriture hors plage.

Les valeurs sont stockées et renvoyées comme de simples nombres sans bornage — contrairement au mset() de la couche TIC-80, qui borne à 0–255 parce que les identifiants de sprites de TIC-80 ont la taille d’un octet. Une valeur de tuile ici est simplement ce que le code appelant veut qu’elle signifie, typiquement un identifiant de région GPU, qui peut largement dépasser 255.

Promotion paresseuse de la ROM vers la RAM

Une tilemap commence sa vie en lecture seule, se trouvant là où le compilateur a placé ses données dans l’image du programme — tilemap.get() avant toute écriture lit directement depuis là, sans coût de RAM. Le premier tilemap.set() contre une tilemap donnée la promeut : alloue une copie privée en RAM et copie chaque cellule, et ce n’est qu’après cela que la tilemap devient modifiable. Toute lecture ou écriture vers une tilemap différente qui n’a pas été promue n’est pas affectée — la promotion est suivie par tilemap, pas globalement. Un deuxième tilemap.set() ultérieur sur une tilemap déjà promue écrit directement, sans recopier ni perturber les écritures antérieures.

tilemap.render()

tilemap.render(LEVEL1, sx, sy, w, h, x, y, tile_w, tile_h)
tilemap.render(LEVEL1, sx, sy, w, h, x, y, tile_w, tile_h, skip_id)

Dessine une région de w par h cellules, en commençant à la cellule de tilemap (sx, sy), vers l’écran en commençant au pixel (x, y), espacées de tile_w/tile_h pixels par cellule — un appel spr(tile_value, screen_x, screen_y) par cellule visible, en lecture seule (ne promeut jamais). sx/sy sont bornés à 0 .. max(0, dimension - w_ou_h), la même philosophie de bornage que celle qu’utilise déjà le map() de la couche de compatibilité TIC-80, de sorte qu’une position de défilement puisse être poussée au-delà du véritable bord de la carte sans dessiner de données aléatoires ni exiger que l’appelant la borne d’abord.

tile_w/tile_h sont obligatoires, contrairement au map() de TIC-80, qui suppose une grille fixe de 8×8 — cette API n’a aucune supposition équivalente sur laquelle se rabattre, puisque les régions GPU peuvent être de n’importe quelle taille. Elles ne contrôlent que l’espacement en pixels entre les cellules dessinées ; render() dessine chaque région à sa propre taille native quels que soient tile_w/ tile_h, donc une région plus étroite ou plus courte que le pas se cale contre un bord de sa cellule plutôt que d’être étirée pour la remplir.

skip_id (optionnel) — une cellule dont la valeur est égale à skip_id ne reçoit aucun appel spr() du tout, utile pour une carte éparse où la plupart des cellules signifient « rien ici ». C’est un mécanisme plus grossier que la transparence par colorkey du map() de TIC-80 (qui mélange par pixel) ; les régions natives portent déjà un véritable canal alpha, donc le besoin courant est simplement « ne prends pas la peine de dessiner cette cellule », pas « dessine-la mais fais disparaître certains pixels par mélange ».

Le défilement se fait par granularité de cellule, pas au sous-pixel. sx/sy sont des indices de cellule ; il n’y a de décalage de source fractionnaire nulle part dans la conception, donc déplacer sx de 1 déplace le contenu dessiné d’un tile_w entier à l’écran — la même limitation que possède le propre map() de TIC-80. Un défilement pixel fluide nécessiterait de dessiner une rangée/colonne supplémentaire au-delà de w/h et de décaler l’origine écran de tout le bloc d’un reste de pixel de sous-tuile ; c’est une extension réelle et distincte, non implémentée ici.

Ce qui n’est délibérément PAS ici


Carte mémoire : memcard.*

memcard.save(value, position)   -> value   (écriture brute, exactement 1 mot)
memcard.save(value)             -> value   (auto-ajout ; voir ci-dessous)
memcard.load(position)          -> value   (lecture brute, exactement 1 mot)
memcard.load()                  -> value   (position 0)
memcard.load_table(position)    -> table ou nil   (voir Tables, ci-dessous)
memcard.title(str|table)        -> nil     (définit le titre/id de 20 mots)
memcard.title()                 -> table   (lit le titre/id de 20 mots, indices 1-20)

memcard[position]                  == memcard.load(position)
memcard[position] = value          == memcard.save(value, position)

La carte mémoire est un véritable périphérique matériel Vircon32 : une plage de stockage fixe et persistante à l’adresse physique 0x30000000, entièrement séparée de la ROM de la cartouche et de la RAM de Vircon32. Contrairement à la RAM, elle survit à un cycle d’alimentation — c’est le dispositif de sauvegarde de partie de la console.

Cette VM est adressée par mot dans son intégralité, et la carte mémoire suit la même convention : une adresse (et une position) avance par mots entiers de 4 octets, pas par octets individuels. Cela correspond à la façon dont cette VM stocke déjà les chaînes Lua en interne — un mot par caractère (voir string.len()) — plutôt qu’une représentation compressée par octet.

memcard.save() / memcard.load()

Il existe deux formes distinctes, choisies selon qu’une position est donnée ou non :

Avec une position explicite — la primitive de bas niveau. Écrit (ou lit) exactement un mot brut à position, sans aucune forme de comptabilité. position 0 est le premier mot de la région de données ; voir Disposition des adresses ci-dessous pour la plage complète, y compris comment les positions négatives atteignent le titre. Vous êtes entièrement responsable de savoir ce que vous mettez où — écrire deux fois la même position l’écrase simplement.

memcard.save(1234, 0)     -- mot 0 : nombre brut
memcard.save(true, 1)     -- mot 1 : booléen brut
local hi = memcard.load(0)  -- 1234

Sans aucune position — memcard.save(value) s’auto-ajoute via un curseur persistant stocké sur la carte elle-même (pas en RAM), de sorte que des sauvegardes répétées sans position continuent d’étendre un journal à travers de nombreuses sessions de jeu au lieu d’écraser le mot 0 à chaque exécution. Cette forme est consciente du type : sauvegarder une véritable chaîne Lua écrit son contenu complet (étiqueté et préfixé par sa longueur, voir Étiquettes de type), pas seulement un pointeur brut.

memcard.save("high score run")   -- ajouté au curseur actuel
memcard.save(9001)               -- ajouté juste après

Le curseur lui-même — « combien de mots ont été auto-ajoutés jusqu’ici » — est lisible à tout moment sous la forme memcard.load(-1) / memcard[-1] ; il n’existe pas de fonction de comptage séparée.

memcard.load() sans position lit toujours le mot 0 — elle ne suit pas le curseur d’auto-ajout comme le fait memcard.save(). Relire une entrée écrite par la forme auto-ajoutée signifie lire vous-même son mot d’étiquette à une position connue (voir Étiquettes de type), ou simplement savoir ce que vous y avez écrit.

memcard[position]

memcard[position] et memcard[position] = value sont des raccourcis pour la forme à position explicite de load/save ci-dessus — jamais pour la forme auto-ajoutée, puisque la syntaxe de crochets de Lua n’a aucun moyen d’exprimer « pas d’index ».

memcard[0] = 1234
local hi = memcard[0]        -- 1234
local cursor = memcard[-1]    -- lit le curseur d'auto-ajout
**memcard.title(str table) -> nil**

Définit le titre/id de la carte mémoire : 20 mots au début de la zone de titre de la carte. Ceci est indépendant de tout indice de cartouche --#title, qui nomme la cartouche, pas les données de sauvegarde — une seule cartouche peut avoir de nombreuses cartes mémoire en circulation, chacune avec son propre titre.

Deux formes d’argument :

Tout le reste (un nombre, une fonction, true/false/nil, ou une table dont les éléments 1..20 comprennent un non-nombre, comme une chaîne) est une panique à l’exécution : l’écran rouge PANIC avec le message RUNTIME ERROR: memcard.title() MUST BE STRING OR INDEXED TABLE et la valeur fautive, puis la console s’arrête. De ce que la forme table s’écrit mot par mot découle ceci :

memcard.title("My Save File")

local id = {}
for i = 1, 20 do id[i] = i * 100 end
memcard.title(id)               -- 20 mots numériques

memcard.title() -> table

Renvoie le titre/id de la carte mémoire sous la forme d’une nouvelle table de 20 nombres, indices 1 à 20 (les tables de cette version commencent à 1), un mot chacun. Pour un titre défini à partir d’une chaîne, l’élément n est le code du caractère n (0 au-delà de la fin de la chaîne). La table est allouée à neuf à chaque appel ; le tas ne ramasse pas les miettes, lisez donc le titre une fois, pas à chaque image. Comme le setter, il est indépendant de tout indice de cartouche --#title.

local title = memcard.title()
-- title[1] .. title[20] ; pour "My Save File" : 77, 121, 32, 83, ... 0, 0

Tables : memcard.save() / memcard.load_table()

memcard.save(a_table) — la forme auto-ajoutée sans position uniquement — écrit un volcage brut du contenu de la table : un parcours direct de son stockage interne en compartiments de hachage (hash buckets), de la même manière que l’on ferait fwrite() d’une structure vers un fichier en C. Ce n’est pas un sérialiseur récursif général.

local highscores = { alice = 500, bob = 350, carol = 900 }
memcard.save(highscores)

local restored = memcard.load_table(0)
print(10, 10, restored.alice)   -- 500

memcard.save(a_table, position) (la forme à position explicite) n’est affectée par rien de tout cela — elle écrit toujours un unique mot de pointeur brut, exactement comme sauvegarder une table de cette façon l’a toujours fait. Le format de volcage de table ci-dessous est exclusivement une fonctionnalité de la forme auto-ajoutée sans position.

Pourquoi « le côté hachage » et pas un tableau : l’implémentation des tables de ce compilateur dispose en principe d’un chemin rapide pour la partie tableau, mais son chemin de réallocation est actuellement un stub non implémenté — la capacité ne dépasse jamais 0, donc chaque table, qu’elle ait la forme d’un tableau ({1, 2, 3}) ou non, est déjà stockée entièrement dans la chaîne de compartiments de hachage. Il n’existe aujourd’hui aucun « cas tableau » séparé et plus simple à traiter spécialement ; volcager le côté hachage couvre toutes les tables telles qu’elles existent réellement en ce moment.

Ce qui est copié, et ce qui ne l’est pas : chaque clé et valeur est écrite exactement telle qu’elle est encapsulée (boxed). Une clé/valeur nombre, booléen, ou nil survit au cycle aller-retour correctement pour toujours. Une clé ou valeur qui est elle-même une table, une chaîne, ou une fonction est écrite comme son pointeur brut — pas désencapsulée récursivement — donc elle n’a de sens que dans la même exécution qui l’a écrite ; la recharger dans une session future (ou après que la cible du pointeur ait bougé ou été collectée) est indéfini. C’est la même limite de sécurité que trace déjà le reste de memcard.* (voir Note de sécurité) — un volcage de table ne la franchit pas, il l’applique simplement par entrée plutôt qu’une seule fois.

memcard.load_table(position) reconstruit une table neuve à partir d’un volcage écrit de cette manière. position est obligatoire — contrairement à memcard.load(), il n’y a pas de valeur par défaut sensée « position 0 » pour quelque chose dont la taille en mots n’est pas connue avant que l’entrée elle-même ne soit lue. Elle valide le mot d’étiquette avant de faire confiance à quoi que ce soit après lui ; lire à une position qui ne contient pas un volcage de table renvoie nil plutôt que de mal interpréter des mots non liés comme un nombre de paires et des clés/valeurs aléatoires.

Disposition des adresses

0x30000000  +-------------------------------------+  position -24
            |  titre : 20 caractères               |
            |  (memcard.title() écrit ici)         |  position -5
            +-------------------------------------+
            |  réservé (3 mots, inutilisés)        |  position -4 .. -2
            +-------------------------------------+
            |  curseur d'auto-ajout                 |  position -1
0x30000018  +-------------------------------------+  position 0
            |  région de données                   |
            |  (memcard.save()/.load()/[pos])     |
            |  ...                                 |
0x3003FFFF  +-------------------------------------+  dernier mot valide

position est toujours relative au début de la région de données (0x30000018). Les positions négatives remontent vers le bloc titre/métadonnées — atteignable, mais seulement en allant délibérément au négatif. Notez que la région de métadonnées (4 mots, positions -4..-1) est séparée du bloc titre (20 mots, positions -24..-5) — auparavant, les métadonnées étaient prélevées sur les 4 derniers mots du titre lui-même, plafonnant le titre utilisable à 16 caractères ; les 20 mots complets sont disponibles maintenant.

Constante Valeur Signification
VIRCON32_MEMCARD_BASE 0x30000000 début de la carte ; position -24
VIRCON32_MEMCARD_DATA_BASE 0x30000018 position 0
VIRCON32_MEMCARD_CURSOR_ADDR 0x30000017 le curseur d’auto-ajout ; position -1
VIRCON32_MEMCARD_END 0x3003FFFF dernier mot valide, inclus

Étiquettes de type (forme auto-ajoutée uniquement)

memcard.save(value) sans position écrit l’une de trois formes au curseur, puis avance le curseur du nombre de mots que cette forme a utilisés :

Type de valeur Disposition Mots utilisés
Véritable chaîne Lua [TAG_STRING][length][char 0][char 1]... 2 + length
Table [TAG_TABLE][pair_count][key 0][val 0]... 2 + 2 * pair_count
Nombre / booléen / nil / fonction [TAG_SCALAR][raw value] 2

TAG_SCALAR vaut 0, TAG_STRING vaut 1, TAG_TABLE vaut 2. Une fonction sauvegardée de cette manière est stockée comme son pointeur encapsulé brut sous TAG_SCALAR — voir la note de sécurité ci-dessous, ce n’est pas une sérialisation générale.

La forme à position explicite (memcard.save(value, position) / memcard[position] = value) n’écrit jamais d’étiquette — c’est toujours exactement un mot brut, quel que soit le type de valeur. Cela inclut les tables : une table sauvegardée avec une position explicite est un unique mot de pointeur brut, pas un volcage — voir Tables ci-dessus.

Note de sécurité

Un nombre, booléen, ou nil survit au cycle aller-retour correctement pour toujours — ce motif de bits signifie la même chose à n’importe quelle exécution. Une véritable chaîne ou table Lua sauvegardée via la forme auto-ajoutée survit également correctement au cycle aller-retour — les caractères réels d’une chaîne sont copiés, et les paires clé/valeur réelles d’une table sont copiées, restaurables avec memcard.load_table(). Une table sauvegardée via la forme à position explicite, une chaîne sauvegardée via la forme à position explicite (qui stocke un pointeur brut, pas le contenu réel de la chaîne), ou une valeur de fonction — y compris toute valeur de ce type trouvée comme clé ou valeur à l’intérieur d’une table sauvegardée, puisque celles-ci ne sont pas désencapsulées récursivement — survit au cycle aller-retour correctement au sein de la même exécution — son pointeur reste valide — mais devient dénuée de sens après qu’un démarrage neuf la relise depuis une véritable carte mémoire, puisqu’il n’est pas garanti que la disposition du tas/ROM corresponde d’une exécution à l’autre. Tenez-vous en aux nombres/booléens/nil (ou aux véritables chaînes/tables de ceux-ci, via la forme auto-ajoutée) pour tout ce qui est destiné à survivre à un véritable cycle de sauvegarde/rechargement.

Ce qui n’est délibérément PAS ici

Tant dget()/dset() de PICO-8 que pmem() de TIC-80 sont des API sans rapport qui utilisent également cette même plage d’adresses physiques selon leurs propres dispositions — memcard.* n’est pas disponible (erreur de compilation) sous --#api pico8/--#api tic80 afin d’éviter qu’un programme ne mélange les deux et ne corrompe celle qu’il n’adresse pas actuellement.


Autres ports d’E/S bruts

Chaque port ioports.* vit dans une seule table (IOPortMap de core.c), organisée en sept catégories : tim, rng, gpu, spu, inp, car, mem. Les catégories son (spu), graphismes (gpu, partiellement — voir Définir des régions de texture), et entrées (inp, partiellement — voir btn()/btnp()) sont couvertes plus haut là où elles disposent d’un enrobage de plus haut niveau. Ce qui reste est soit du matériel brut sans aucun enrobage, soit un enrobage qui ne couvre qu’une partie d’une catégorie. La table complète des ports se trouve dans l’Aide-mémoire.

Une catégorie ou un nom de propriété inconnu est une erreur de compilation listant les catégories valides, pas un no-op silencieux ni une lecture de globale non déclarée — voir validate_ioports_path().

ioports.tim.* — minuterie brute

local d = ioports.tim.date    -- TIM_CurrentDate :  année * 65536 + jour de l'année
local t = ioports.tim.time    -- TIM_CurrentTime :  secondes depuis minuit
local f = ioports.tim.frames  -- TIM_FrameCounter : identique à system.frames()
local c = ioports.tim.cycles  -- TIM_CycleCounter : identique à system.cycles()

ioports.tim.date/ioports.tim.time sont les registres bruts compressés que system.date()/system.time() décodent en une chaîne formatée et trois nombres séparés — utilisez system.date()/system.time() sauf si c’est la représentation compressée elle-même qui est nécessaire (par exemple, stocker un mot dans une carte mémoire plutôt que trois champs séparés).

ioports.rng.* — générateur aléatoire matériel

local r = ioports.rng.value   -- RNG_CurrentValue, lecture : la valeur aléatoire suivante
ioports.rng.seed = 12345      -- RNG_CurrentValue, écriture : re-semer le générateur

Le même registre matériel sous-jacent dans les deux directions — la lecture renvoie la valeur aléatoire actuelle (et fait avancer le générateur), l’écriture le re-sème. C’est le propre générateur aléatoire matériel de la console, indépendant de math.random() (qui est un générateur pseudo-aléatoire logiciel dans le moteur d’exécution, semé séparément) — les deux ne partagent pas d’état et ne produiront pas la même séquence à partir de la même graine.

ioports.car.* — informations sur la cartouche

local ok   = ioports.car.connected  -- CAR_Connected,        booléen
local size = ioports.car.romsize    -- CAR_ProgramROMSize,   entier
local ntex = ioports.car.numvtex    -- CAR_NumberOfTextures, entier
local nsnd = ioports.car.numvsnd    -- CAR_NumberOfSounds,   entier

Introspection en lecture seule de la cartouche actuellement insérée — la taille de sa ROM de programme en mots, et combien de textures/sons son cart-XML a déclarés. Puisque la propre cartouche d’un programme en cours d’exécution est toujours connectée, que ioports.car.connected renvoie false n’est pas un cas que le code de cartouche normal doit gérer ; cela existe pour la complétude de la table de ports plutôt que comme une condition de branchement pratique ; en réalité, seulement quelque chose transigé dans le BIOS.

ioports.mem.connected — présence d’une carte mémoire

if ioports.mem.connected then
    memcard.save(highscore)
end

MEM_Connected, booléen, lecture seule — indique si une carte mémoire est réellement présente avant que les appels memcard.* ne la touchent.