Ho realizzato un port di Doom con Flutter e dart:ffi
Ormai è esattamente un anno che lavoro su Flutter, sia nel mio lavoro diurno, che per i miei progetti personali. L'app che sto sviluppando per il mio lavoro diurno mi ha permesso di iniziare a conoscere l'ambiente Flutter, ma è fondamentalmente un software gestionale dove faccio delle CRUD, roba non molto interessante dal punto di vista prettamente informatico; si tratta di creare delle viste, dei pulsanti e collegarli a dei servizi remoti realizzati in PHP.
Nei miei progetti personali mi piace andare oltre e fare qualcosa di più interessante. Se c'è qualcuno che legge questo blog o mi segue su Twitter sa che sto lavorando a un'app che emula il sintetizzatore Omnichord, un progetto che vede l'UI scritta con Dart/Flutter e il motore audio scritto in C++, con un bridge realizzato mediante quella meraviglia chiamata dart:ffi.
Ed è da qui che voglio partire, perchè circa due settimane fa mi sono chiesto: Flutter può far girare Doom?
Quel codice è stato la base di tutti i port moderni, e del meme "Can it run Doom?", perchè chiunque ha provato a fare i port più improbabili (qualcuno ha mezionato i test di gravidanza?).
La risposta alla domanda è SI. Ci sono riuscito, con non poche difficoltà, ma è stato una lavoretto fatto in circa una decina di giorni, trovate tutto documentato nei commit su GitHub.
Sarà un post molto lungo, e sarà diviso in 12 capitoli.
Voglio farvi vedere tutti i passaggi per arrivare al risultato, anche tutti gli errori che ho fatto (e non pochi, alcuni davvero imperdonabili).
- Proviamo a compilare il codice sorgente su un sistema moderno Linux
- Proviamo a compilare il codice sorgente su Flutter
- Come diavolo leggo il file WAD?
- Un solo Thread non basta
- Ho bisogno del framebuffer
- E ora che faccio con il framebuffer?
- A quanto pare sono costretto a mappare gli input (e a renderli Thread Safe)
- La palette dei colori non è un argomento banale
- Risolviamo i bug che io stesso ho introdotto
- Un po' di frontend: creiamo un controller comodo e funzionale
- Compiliamo su iOS
- Conclusioni
CAPITOLO 1 - Proviamo a compilare il codice sorgente su un sistema moderno Linux
Prima di importare il codice sorgente su Flutter, ho voluto provare a compilarlo nativamente su Linux.Se avviene la compilazione, di sicuro avrò meno lavoro da fare all'interno dell'ambiente di Flutter.
Quindi ho clonato il progetto e ho lanciato il classico comando make. Come prevedibile non ha funzionato. Stiamo parlando di un sorgente rilasciato nel 1997, troppe cose sono cambiate nei compilatori da allora, e soprattutto i sistemi non sono più a 32 ma a 64 bit.
Occorreva mettersi un po’ a lavoro. Diciamo che i primi errori non sono stati difficili da risolvere.
Per esercizio e per forzarmi a ragionare, alcuni errori sono riuscito a risolverli da solo.
Per altri, dove indicato, ho seguito questo fantastico post sul blog Deus In Machina.
Inutile dire che senza quel post a un certo punto sarei arrivato a un punto morto, quindi ringrazio Diego Crespo per il suo utilissimo post.
Iniziamo. Primo errore.Questo si è risolto semplicemente togliendo la s di troppo da errnos.h
La libreria errno è una standard del C, non ho idea del perchè ci fosse quella s.
Andiamo avanti.Qui le cose si fanno più complesse.
Il pezzo di codice incriminato è questo.Si tratta dell'inizializzazione della Struct che contiene le impostazioni di default.
Ecco una delle tante stranezze del C. Abbiamo una stringa che viene convertita in un valore intero.
Si tratta del terzo elemento della Struct, chiamato defaultvalue, che dovrebbe essere un intero.
Infatti ad esempio vediamo che il valore con chiave "mouse_sensitivity" ha come default 5.
Cosa sta facendo quella conversione? Sta convertendo l'indirizzo del primo carattere di quella stringa in un intero.
Quindi abbiamo l'intero che rappresenta un indirizzo di memoria.
Un trucchetto davvero furbo, perchè di solito gli indirizzi di memoria sono rappresentati da puntatori a un tipo. In questo modo è possibile avere sia dei valori interi reali, sia degli indirizzi di memoria che rappresentano l'inizio di una stringa.
Una soluzione che farebbe rabbrividire in molti di questi tempi, ma stiamo parlando di una codebase di più di 30 anni fa, questi trucchetti erano all'ordine del giorno.
Il problema è che un int è un valore a 32 bit, mentre un puntatore in un sistema a 64 bit è appunto grande 64 bit, quindi quel cast non può funzionare.
Occorre quindi fare in modo che il tipo di defaultvalue nella Struct sia abbastanza grande da contenere un valore a 64 bit, quindi long long (64 bit) è sufficiente. Anche tutti gli altri cast dovranno essere modificati da int a long long.
Osservazione: quelle stringhe sono definite come valore globale, non si trovano in nessuna funzione, quindi quegli indirizzi saranno validi per tutta la durata del programma.
Andiamo avanti.

A parte tanti warning assolutamente ignorabili, tutto il codice è stato compilato correttamente (almeno in apparenza), c’è solo un problema con il linker. Quello che si capisce è che si tratta di un problema sul file i_sound.c, anche se non ci viene detto null’altro.
Ci ho messo un po’ a capire il problema, fallendo miseramente. Da questo punto in poi ammetto di aver seguito pedissequamente il blog di Diego, quindi se volete approfondire questo aspetto vi rimando al suo articolo.
Alla fine il codice è stato compilato su Linux e il gioco è partito.Questa sarà la base da cui iniziare su Flutter.
CAPITOLO 2 - Proviamo a compilare il codice sorgente su Flutter
Quindi creo un nuovo progetto vuoto con Flutter e copio-incollo il sorgente di Doom al suo interno.
Creo CMakeLists.txt riportando le impostazioni del Makefile originale.
L'obiettivo finale è che venga creata la shared object libdoom.so, che è quella che verrà data in pasto a dart:ffi
Attualmente sto lavorando su target Android; lavorerò successivamente per il port iOS.
Chiaramente il sorgente così com'è non mi serve a nulla; occorre modificarlo opportunamente per poter sfruttare il sistema grafico di Flutter, o gestire gli input, ma prima di tutto è necessario che venga compilato senza errori.
Il compilatore che viene utilizzato da Flutter con target Android è Clang che è abbastanza diverso da gcc, come scoprirò presto.
Da qui in poi non ci sarà nessuna guida a pararmi il culo, sono nelle mie mani :-)
Primo errore: values.h su Android non esiste.
flutterdoom/android/app/src/main/linuxdoom-1.10/doomtype.h:42:10:
fatal error: 'values.h' file not found
42 | #include <values.h>
| ^~~~~~~~~~
1 error generated.
A quanto pare values.h era il vecchio nome di limits.h, quindi effettuo una sostituzione.
Ok funziona, adesso l’errore è un altro.
flutterdoom/android/app/src/main/linuxdoom-1.10/l_net.c:76:17:
error: use of undeclared identifier 'IPPORT_USERRESERVED'
76 | int DOOMPORT = (IPPORT_USERRESERVED +0x1d );
Manca IPPORT_USERRESERVED, che non è definita nelle librerie di Android. Aggiungo la definizione nel file i_net.h, il valore dovrebbe essere 5000 (ho cercato online).
Non mi servirà a nulla in ogni caso, perchè il supporto di rete ovviamente non ci sarà.
Altro flutter run, altri errori (oh boy sarà lunga).
flutterdoom/android/app/src/main/linuxdoom-1.10/m_fixed.c:63:19:
error: use of undeclared identifier 'MININT'
63 | return (a^b)<0 ? MININT : MAXINT;
| ^
flutterdoom/android/app/src/main/linuxdoom-1.10/m_fixed.c:63:28:
error: use of undeclared identifier 'MAXINT'
63 | return (a^b)<0 ? MININT : MAXINT;
| ^
MININT e MAXINT erano definiti nel vecchio values.h e non sono presenti in limits.h (dove invece sono presenti INT_MIN e INT_MAX).Quindi torno in doomtype.h e li aggiungo.
Faccio la stessa cosa su m_bbox.h
flutterdoom/android/app/src/main/linuxdoom-1.10/am_map.c:786:12:
error: type specifier missing, defaults to 'int'; ISO C99 and later do not support implicit int [-Wimplicit-int]
786 | static nexttic = 0;
| ~~~~~~ ^
| int
flutterdoom/android/app/src/main/linuxdoom-1.10/am_map.c:859:14:
error: type specifier missing, defaults to 'int'; ISO C99 and later do not support implicit int [-Wimplicit-int]
859 | register outcode1 = 0;
| ~~~~~~~~ ^
| int
flutterdoom/android/app/src/main/linuxdoom-1.10/am_map.c:860:14:
error: type specifier missing, defaults to 'int'; ISO C99 and later do not support implicit int [-Wimplicit-int]
860 | register outcode2 = 0;
| ~~~~~~~~ ^
| int
flutterdoom/android/app/src/main/linuxdoom-1.10/am_map.c:861:14:
error: type specifier missing, defaults to 'int'; ISO C99 and later do not support implicit int [-Wimplicit-int]
861 | register outside;
| ~~~~~~~~ ^
| int
flutterdoom/android/app/src/main/linuxdoom-1.10/am_map.c:992:12:
error: type specifier missing, defaults to 'int'; ISO C99 and later do not support implicit int [-Wimplicit-int]
992 | static fuck = 0;
| ~~~~~~ ^
| int
Questi sono facili, basta aggiungere int dopo static e register.Notare la variabile fuck, qualcuno non sapeva che nome dare a quella variabile. xD
flutterdoom/android/app/src/main/linuxdoom-1.10/i_video.c:32:10:
fatal error: 'X11/Xlib.h' file not found
32 | #include <X11/Xlib.h>
| ^~~~~~~~~~~~
1 error generated
Ok, ci stiamo avvicinando. Il compilatore mi sta dicendo che non trova nessun header di X11, e questo va benissimo perchè Android non utilizza X11 ma un sottosistema grafico proprietario.
Quello che farò successivamente sarà utilizzare direttamente il framebuffer scritto da Doom, quindi non mi serve nessun sottosistema grafico.
Ho eliminato tutti i riferimenti a X11 presenti in i_video.c. Successivamente ci tornerò spesso su questo file.
Andiamo avanti. Questi errori non finiscono mai.
flutterdoom/android/app/src/main/linuxdoom-1.10/r_segs.c:165:32:
error: use of undeclared identifier 'MAXSHORT'
165 | if (maskedtexturecol[dc_x] != MAXSHORT)
| ^
flutterdoom/android/app/src/main/linuxdoom-1.10/r_segs.c:185:31:
error: use of undeclared identifier 'MAXSHORT'
185 | maskedtexturecol[dc_x] = MAXSHORT;
| ^
La definizione di MAXSHORT è in realtà presente in doomtype.h, ma non sul branch Linux, quindi semplicemente la faccio uscire dal branch e la rendo universale.flutterdoom/android/app/src/main/linuxdoom-1.10/m_misc.c:257:48:
error: initializer element is not a compile-time constant
257 | {"sndserver", (int *) &sndserver_filename, (long long) "sndserver"},
| ^~~~~~~~~~~~~~~~~~~~~~~
Guarda chi si rivede, la stuttura dati dei defaults. A quanto pare Clang diversamente da gcc vuole che defaultvalue sia una costante già conosciuta in fase di compilazione. Le ho provate un po’ tutte qui, ma nessuna soluzione ha funzionato. Ho anche interpellato un LLM (non lo faccio così spesso) ma nessuna soluzione è stata trovata. Alla fine ho deciso di mettere l’intero 0 al posto di quegli indirizzi, visto che non è una sezione critica del codice, e per il momento l’audio in ogni caso non ci sarà.
La compilazione finalmente va a buon fine, anche se c’è un altro errore.

L’errore visualizzato non mi da alcuna informazione utile.
A questo punto devo necessariamente attivare la stampa a video dei messaggi generati dall’engine di Doom.
Per fare questo avevo già pronto l’header debug.h realizzato per il progetto dell’Omnichord, che non contiene altro che una macro chiamata LOG che richiama la funzione di Android __android_log_print.
Tutto quello che devo fare è sostituire nel progetto tutti i printf che trovo con LOG, e importare l’header dove serve.
Fatto questo, altri errori di compilazione. Questa non l’avevo vista arrivare.
flutterdoom/android/app/src/main/linuxdoom-1.10/doomtype.h:34:22:
error: expected identifier
34 | typedef enum {false, true} boolean;
| ^
Android/Sdk/ndk/28.2.13676358/toolchains/llvm/prebuilt/linux-x86_64/lib/clang/19/include/stdbool.h:21:14:
note: expanded from macro 'true'
21 | #define true 1
| ^
2 errors generated.
Il motivo è presto detto, in debug.h includo android/log.h che include a sua volta stdbool.h, e questo genera un conflitto.
Ecco la parte di codice incriminata. 30 anni fa non avevano il tipo boolean quindi se lo sono creati in questo modo, con un enum.
La mia soluzione. Semplice e funzionale.In tutti i punti di codice in cui si fa riferimento a true e false, saranno compilati come interi 1 e 0.
Ci siamo, la compilazione va a buon fine, questa volta senza errori scritti in rosso. Anche se ottengo Lost connection to device e nessun messaggio a video.
In effetti è corretto così, perchè ho dimenticato una cosa fondamentale.
Anche se ora il codice C viene compilato, non viene mai invocato da dart:ffi, quindi adesso occorre andare su Flutter e mettere in piedi tutto quello che serve per permettere la comunicazione tra Flutter e C.
Prima di fare questo, devo individuare la funzione main di Doom. Si trova in i_main.c, e tutto quello che fa è leggere i parametri della riga di comando e invocare la funzione D_DoomMain().
Andiamo su Dart. Metto in piedi il caricamento della libreria libdoom.so e l’invocazione diretta di D_DoomMain().
Per adesso devo solo verificare che tutto funzioni, quindi la codebase sarà il più minimale possibile.
Devo solo verificare che D_DoomMain() si avvii correttamente.
Il momento della verità è arrivato…

FUNZIONA!!!
Cioè, non funziona, ma è entrato in D_DoomMain e ha iniziato a stampare messaggi. Sono felicissimo :-)
Si blocca dicendo “Init WADfiles”, perchè in effetti il file WAD non c’è.
Breve spiegazione: l’engine di Doom non contiene nessun tipo di asset hardcodato, nè alcuna descrizione dei livelli. Tutto questo si trova in un file con estensione WAD. L’unica differenza tra Doom Shareware, Doom 1 e Doom 2 è il file WAD, per il resto il motore è sempre lo stesso.
Il motore si aspetta di trovarlo nella stessa directory in cui si trova l’eseguibile, o il file .so in questo caso. Quindi mi sorge un quesito amletico.
CAPITOLO 3 - Come diavolo leggo il file WAD?
La domanda non è banale, perchè il path di installazione dell'app sul sistema (Android o iOS) è nascosto dal sistema operativo. Non ho idea di dove si trovi e neanche come recuperarlo. E non sono neanche sicuro che sia lo stesso path dove viene inclusa libdoom.so.Quello che ho pensato come soluzione è questo: includo il file WAD come asset del progetto, e quando l'app si avvia, recupero l'asset e lo copio nella directory utente associata all'app, che è l'unica directory in cui l'app ha i permessi di scrittura.
Il team di Flutter mette a disposizione la libreria path_provider, che espone il path della directory utente grazie alla funzione getApplicationDocumentsDirectory().
Questo è il boilerplate che ho messo su. Nulla di particolarmente complesso.La copia del WAD avviene solo la prima volta che si avvia l'app.
Con dart:ffi il passaggio di una stringa avviene esattamente come in C, cioè si passa un puntatore a Utf8, che equivale a char:
doomMain = dylib.lookup<NativeFunction<Void Function(Pointer<Utf8>)>>('D_DoomMain').asFunction();
doomMain(widget.wadPath.toNativeUtf8()); // Occorre convertire l'oggetto String in questo modo
Quindi questa stringa la passo al Widget MainApp, che a sua volta la passa al D_DoomMain tramite dart:ffi, e infine la passa alla funzione IdentifyVersion, che ho opportunamente modificato.
Questa funzione si occupa di riconoscere quale versione di Doom abbiamo, in base al nome del file WAD.
Il WAD della shareware si chiama doom1.wad, quello della versione completa è doom.wad, e infine abbiamo doom2.wad. Io sto usando la versione shareware.
L’articolo sta diventando troppo lungo quindi evito di riportare qui la funzione IdentifyVersion, che è molto lunga e non fa nulla di interessante se non tanti confronti.
Il trucco ha funzionato, adesso la versione Shareware viene riconosciuta, e in questo modo l’engine ha a disposizione il path utente dell’app, che mi tornerà utile più avanti per la gestione dei savestate del gioco.

Il messaggio D_DoomLoop STARTED l’ho inserito io all’inizio di D_DoomLoop, che è il punto di non ritorno.
Si tratta della funzione in cui parte il loop while infinito dove è gestito il gioco.
Tecnicamente, la schermata iniziale di Doom sta andando, anche se chiaramente non si vede.
Quello che non si vede, anche, è l’Hello World di Flutter sul mio dispositivo, che mostra pagina bianca, e questo era prevedibile. Vediamo perchè.
CAPITOLO 4 - Un solo Thread non basta
Flutter gira su un solo Thread, quindi è chiaro quello che sta succedendo, una volta che viene avviata D_DoomLoop, la funzione D_DoomMain non termina mai, e di conseguenza anche doomMain su Flutter. Quindi l'initState a sua volta è bloccato, di conseguenza non viene avviato neanche il build del Widget.
La soluzione è far girare Doom su un nuovo Thread, e qui ho 2 possibili soluzioni:
- provo l'Isolate di Dart;
- creo un nuovo Thread con C.
Però la mia pigrizia ha avuto la meglio, e così ho deciso di creare il nuovo Thread in C.
Conosco bene l'argomento, si tratta di aggiungere poche righe di codice e modificare leggermente il main.
Ecco la ricetta:
- aggiungere #include <pthread.h> nel file d_main.c;
- creare la funzione FlutterDoomStart che si occuperà di creare il Thread per D_DoomMain.
Gli argomenti per D_DoomMain li passo tramite la struct ThreadArgs.
ThreadArgs lo alloco nell'heap perchè diversamente, nello stack, sarebbe stato distrutto al termine della funzione FlutterDoomStart.
typedef struct ThreadArgs { char* wad_path; } ThreadArgs; // ... void FlutterDoomStart(char* wad_path) { pthread_t doom_thread; ThreadArgs* thread_args = malloc(sizeof(ThreadArgs)); if (thread_args == NULL) { LOG("ThreadArgs error"); return; } thread_args->wad_path = wad_path; if (pthread_create(&doom_thread, NULL, &D_DoomMain, thread_args) != 0) { LOG("pthread_create error"); return; } return; } - modificare opportunamente D_DoomMain.
void* D_DoomMain (void* args) { ThreadArgs* thread_args = (ThreadArgs*)args; // ...
E funziona perfettamente.

CAPITOLO 5 - Ho bisogno del framebuffer
Siamo arrivati a un punto cruciale. Dobbiamo prelevare il framebuffer del motore di Doom.
Il framebuffer è un'area di memoria dove vengono salvate le informazioni da visualizzare a schermo. Di solito è un array della dimensione larghezza x altezza schermo, dove ogni elemento è un colore opportunamente codificato.
Analizzando il D_DoomMain ho trovato questo:
// init subsystems
LOG ("V_Init: allocate screens.\n");
V_Init ();
Direi che non ci sono dubbi, è questo il pezzo di codice che mi interessa. Vediamo cosa c’è dentro V_Init()
void V_Init (void)
{
int i;
byte* base;
// stick these in low dos memory on PCs
base = I_AllocLow (SCREENWIDTH*SCREENHEIGHT*4);
for (i=0 ; i<4 ; i++)
screens[i] = base + i*SCREENWIDTH*SCREENHEIGHT;
}
Eccolo qui. Non mi aspettavo di trovare ben 4 framebuffer. Faccio qualche ricerca e ho conferma che il framebuffer che mi interessa è screens[0], gli altri sono utilizzati per la transizione wipe (di questa ne parleremo approfonditamente dopo) e la barra di stato del gioco.
La mia strategia sarà questa: il framebuffer lo creo all’interno di Flutter, e passo il suo indirizzo come argomento a D_DoomMain.
Prima di tutto facciamo in modo che Doom allochi internamente solo screens[1], screens[2], screens[3].
void V_Init (void)
{
int i;
byte* base;
// stick these in low dos memory on PCs
base = I_AllocLow (SCREENWIDTH*SCREENHEIGHT*3);
for (i=1 ; i<4 ; i++)
screens[i] = base + i*SCREENWIDTH*SCREENHEIGHT;
}
C’è qualcosa che non va vero? Qualcuno di voi avrà già notato un bug grande quanto una casa. Io purtroppo me ne accorgerò molto più in là, ma a questo ci arriveremo :-)
Lato Flutter, quindi devo allocare un buffer abbastanza grande per Doom, di dimensione 320 * 200.
Il tipo richiesto è byte, che non è altro che un alias per unsigned char.
dart:ffi mette a disposizione anche questo formato nativo, insieme alla funzione malloc.
Il puntatore al framebuffer lo passo a FlutterDoomStart.
Per adesso il codice è tutto nel main e il framebuffer è una variabile globale, ma a fine progetto farò un grosso refactoring per dare una struttura più sensata al codice.
A questo punto sto sperimentando e non bado tanto ai formalismi.
Pointer<UnsignedChar> framebuffer = malloc<UnsignedChar>(320 * 200);
// ...
class MainApp extends StatefulWidget {
final String wadPath;
const MainApp({super.key, required this.wadPath});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
@override
void initState() {
super.initState();
final dylib = DynamicLibrary.open('libdoom.so');
final void Function(Pointer<Utf8>, Pointer<UnsignedChar>) flutterDoomStart
= dylib.lookup<NativeFunction<Void Function(Pointer<Utf8>, Pointer<UnsignedChar>)>>('FlutterDoomStart').asFunction();
flutterDoomStart(widget.wadPath.toNativeUtf8(), framebuffer);
}
/// ...
Torniamo in C, FlutterDoomStart è diventata così:
typedef struct ThreadArgs {
char* wad_path;
byte* external_fb;
} ThreadArgs;
// ...
void FlutterDoomStart(char* wad_path, byte* external_fb) {
pthread_t doom_thread;
ThreadArgs* thread_args = malloc(sizeof(ThreadArgs));
if (thread_args == NULL) {
LOG("ThreadArgs error");
return;
}
thread_args->wad_path = wad_path;
thread_args->external_fb = external_fb;
if (pthread_create(&doom_thread, NULL, &D_DoomMain, thread_args) != 0) {
LOG("pthread_create error");
return;
}
return;
}
Infine devo anche assegnare a screen[0] l’indirizzo del framebuffer di Flutter:
// init subsystems
LOG ("V_Init: allocate screens.\n");
V_Init ();
screens[0] = thread_args->external_fb;
Lo so avrei dovuto farlo dentro V_Init, ma poi mi sono detto “chi se ne frega”.
Quindi è tutto pronto. Rilancio “flutter run” per vedere che non ci siano errori, e tutto viene compilato correttamente.
CAPITOLO 6 - E ora che faccio con il framebuffer?
Ora vengono fuori due problemi.
Il primo: come visualizzo il framebuffer su Flutter? Mi serve un equivalmente del tag html <canvas>, che può essere scritto pixel per pixel.
Effettuo qualche ricerca e scopro che in Flutter la cosa più simile al canvas è il Widget CustomPaint. Ok, diciamo che il problema è risolto, ci sarà da scrivere un po' di boilerplate ma nulla di impossibile.
Secondo problema: il framerate di Doom è 35hz, quindi il framebuffer viene aggiornato 35 volte ogni secondo.
Questo vuol dire che il mio "schermo" dovrà in qualche modo essere sincronizzato con Doom. In altre parole, Flutter deve sapere quando un nuovo fotogramma è pronto per essere visualizzato.
Come faccio?
Devo fare in modo che Doom mandi un segnale a Flutter. Ora, dart:ffi fa senza problemi il contrario, cioè inviare messaggi da Flutter verso C.
Invertire la direzione è una questione meno banale e anche non tanto documentata, ma si può fare.
Questa soluzione l'ho già sperimentata con successo sull'app Omnichord.
La "guida", se così vogliamo chiamarla, è un post del 2021 su StackOverflow scritto Daco Harkes, un ingegnere di Google.
Di seguito riporto cosa ho fatto, senza troppe spiegazioni perchè tutta l'API che c'è dietro è una blackbox per me, so solo che funziona e che dietro c'è sempre Dart Isolate.
- Nella codebase di Doom ho creato una cartella chiamata dart e ho importato tutti i file che si trovano nella directory del Flutter SDK bin/cache/dart-sdk/include
- Ho creato ex-novo una piccola interfaccia, chiamata dart_interface.c
#include "dart_interface.h" Dart_Port dart_receive_port = ILLEGAL_PORT; void registerDartPort(Dart_Port port) { dart_receive_port = port; } void notifyDartFrameReady() { if (dart_receive_port == ILLEGAL_PORT) { return; } Dart_PostInteger_DL(dart_receive_port, 0); } - In D_DoomLoop, subito dopo la chiamata a D_Display, ho aggiunto la chiamata a notifyDartFrameReady() (questo più tardi scopriremo che è stato un errore):
// Update display, next frame, with current state. D_Display (); notifyDartFrameReady(); - Ho aggiunto dart/dart_api_dl.c e dart_interface.c nelle SOURCES di CMakeLists.txt
- Lato Flutter, ho creato un nuovo StatefulWidget chiamato Doom che viene inizializzato in questo modo:
framebuffer = malloc<UnsignedChar>(framebufferSize); framebuffer32 = Uint32List(framebufferSize); ui.Image? frame; @override void initState() { super.initState(); nativePort = receivePort.sendPort.nativePort; final dylib = DynamicLibrary.open('libdoom.so'); final int Function(Pointer<Void>) dartInitializeApiDL = dylib.lookup<NativeFunction<IntPtr Function(Pointer<Void>)>>('Dart_InitializeApiDL').asFunction(); dartInitializeApiDL(NativeApi.initializeApiDLData); final void Function(int) registerDartPort = dylib.lookup<NativeFunction<Void Function(Int64)>>('registerDartPort').asFunction(); registerDartPort(nativePort); receivePort.listen((dynamic message) async { // Invoked at new frame ready for (int i=0; i<framebufferSize; i++) { framebuffer32[i] = 0xFF000000 | (framebuffer[i] << 16) | (framebuffer[i] << 8) | (framebuffer[i]); } var immutableBuffer = await ImmutableBuffer.fromUint8List(framebuffer32.buffer.asUint8List()); final ui.Codec codec = await ui.ImageDescriptor.raw( immutableBuffer, width: 320, height: 200, rowBytes: null, pixelFormat: ui.PixelFormat.rgba8888, ).instantiateCodec(); final ui.FrameInfo frameInfo = await codec.getNextFrame(); frame = frameInfo.image; setState(() {}); }); final void Function(Pointer<Utf8>, Pointer<Uint8>) flutterDoomStart = dylib.lookup<NativeFunction<Void Function(Pointer<Utf8>, Pointer<Uint8>)>>('FlutterDoomStart').asFunction(); flutterDoomStart(widget.wadPath.toNativeUtf8(), framebuffer); }
Questa funzione è quasi tutta un boilerplate poco comprensibile. Mea culpa, ma come già detto quando sono in piena sperimentazione scrivo le cose nel modo più sporco e veloce possibile; solo molto più avanti nei progetti mi preoccupo di rifattorizzare e magari scrivere qualche commento.
Quello che ci interessa sapere di questa funzione è questo:
la callback collegata a receivePort.listen viene invocata ogni volta che D_DoomLoop chiama notifyDartFrameReady.
(Per essere precisi, ogni volta che viene chiamata Dart_PostInteger_DL, il valore 0 che allego alla funzione arriva alla callback ricevente tramite l’argomento dynamic message; in questo specifico caso non è necessario gestire i message ricevuti perchè mi serve soltanto sapere quando Doom ha terminato di scrivere un nuovo frame)
A quel punto avviene la conversione del framebuffer in un frame digeribile dal CustomPaint di Flutter, e infine viene chiamato setState per rebuildare il widget. Tutto questo avviene, come abbiamo detto prima, 35 volte ogni secondo.
Punto di attenzione su questa conversione:
for (int i=0; i<framebufferSize; i++) {
framebuffer32[i] = 0xFF000000 | (framebuffer[i] << 16) | (framebuffer[i] << 8) | (framebuffer[i]);
}
Abbiamo detto che il framebuffer di Doom è 8 bit, cioè 256 colori, mentre il CustomPaint accetta immagini a 24 bit, ovvero circa 16 milioni di colori.
Occorre quindi convertire i singoli pixel da 8 a 24 bit.
Il problema è che la palette di Doom mi è “ignota”, almeno per ora. Più avanti ci sarà un capitolo intero dedicato ad essa.
Per visualizzare comunque qualcosa a schermo decido di convertire i byte in input in valori a 24 bit a scala di grigi, ovvero stessa quantità di rosso, verde e blu. Ai colori ci penserò dopo.
Il CustomPaint è così:
@override
Widget build(BuildContext context) {
if (frame == null) {
return Text("Doom is starting...");
}
else {
return CustomPaint(
painter: FramebufferPainter(),
size: const ui.Size(320, 200)
);
}
}
}
class FramebufferPainter extends CustomPainter {
FramebufferPainter();
@override
void paint(ui.Canvas canvas, ui.Size size) {
final Rect src = Rect.fromLTWH(0, 0, frame!.width.toDouble(), frame!.height.toDouble());
canvas.drawImageRect(
frame!,
src,
src,
Paint()
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
Basta, mi sono annoiato con tutto questo codice insensato, vediamo se abbiamo ottenuto qualcosa.
La qualità è orribile ma tutto il lavoro fatto fino ad adesso ha portato a questo risultato, e a me sembra la cosa più bella del mondo.
FLUTTER STA FACENDO GIRARE DOOM.
La schermata iniziale di Doom, per essere precisi :-)
Qualcosa è strano però. Che io ricordi, dopo qualche secondo in cui non si fa nulla dovrebbe partire la partita demo, quella pre-registrata in cui il personaggio si muove da solo. Infatti ci speravo in questo, questo esperimento sarebbe dovuto finire con la gestione della palette dei colori e la partita che andava avanti da sola.
E invece non succede nulla.
Sulla console, ad ogni cambio dell'immagine, appare questo messaggio:
D/flutter ( 9703): Demo is from a different game version!
Faccio un po' di ricerche, e viene fuori che le partite demo sono pre-registrate nel WAD (e questo lo avevo già intuito), e che tutti i WAD appartengono alla versione 1.9, mentre il codice sorgente è della versione 1.10
Non c'è modo di trovare WAD compatibili pienamente con la versione 1.10, sembra che non esistano.
C'è poco da fare, mi tocca fare una cosa che non avevo voglia di fare, ma ormai è necessaria. Questo progetto mi sfuggirà di mano e diventerà più grande di quanto avessi pensato all'inizio, ne sono certo.
CAPITOLO 7 - A quanto pare sono costretto a mappare gli input (e a renderli Thread Safe)
Avete presente quando gli Youtuber iniziano il video dicendo "Questo video non volevo farlo".
Ecco, questo sviluppo non volevo farlo :-) Ma ormai sono arrivato fino qui, non ha senso fermarsi.
Per capire se il gioco effettivamente si avvia, ho bisogno di inviare dei segnali di input a Doom.
Su doomdef.h trovo la mappatura completa dei tasti, ci sono quasi tutti, e quelli che mancano sono semplicemente i soliti codici ASCII (es. il tasto space equivale all'intero 32).
// DOOM keyboard definition.
// This is the stuff configured by Setup.Exe.
// Most key data are simple ascii (uppercased).
//
#define KEY_RIGHTARROW 0xae
#define KEY_LEFTARROW 0xac
#define KEY_UPARROW 0xad
#define KEY_DOWNARROW 0xaf
#define KEY_ESCAPE 27
#define KEY_ENTER 13
#define KEY_TAB 9
#define KEY_F1 (0x80+0x3b)
#define KEY_F2 (0x80+0x3c)
#define KEY_F3 (0x80+0x3d)
#define KEY_F4 (0x80+0x3e)
#define KEY_F5 (0x80+0x3f)
#define KEY_F6 (0x80+0x40)
#define KEY_F7 (0x80+0x41)
#define KEY_F8 (0x80+0x42)
#define KEY_F9 (0x80+0x43)
#define KEY_F10 (0x80+0x44)
#define KEY_F11 (0x80+0x57)
#define KEY_F12 (0x80+0x58)
#define KEY_BACKSPACE 127
#define KEY_PAUSE 0xff
#define KEY_EQUALS 0x3d
#define KEY_MINUS 0x2d
#define KEY_RSHIFT (0x80+0x36)
#define KEY_RCTRL (0x80+0x1d)
#define KEY_RALT (0x80+0x38)
#define KEY_LALT KEY_RALT
Creo in C una chiamata DartPostInput che dovrà prendere in input i segnali inviati da Dart e inviarli a sua volta alla funzione D_PostEvent, che è la funzione di Doom che prende in input tutti i tipi di eventi (tastiera, mouse, controller).
L’input di questa funzione è una Struct event_t composta da 2 campi: il codice ASCII del tasto premuto, e il tipo di gesture (down/up).
void DartPostInput(int dart_key, int dart_pressed_down) {
event_t new_event;
new_event.data1 = dart_key;
new_event.type = dart_pressed_down ? ev_keydown : ev_keyup;
D_PostEvent(&new_event);
}
//
// D_PostEvent
// Called by the I/O functions when input is detected
//
void D_PostEvent (event_t* ev)
{
events[eventhead] = *ev;
eventhead = (++eventhead)&(MAXEVENTS-1);
}
Lato Flutter creo una classe dove riporto tutta la mappatura completa dei tasti.
class AsciiKeys {
static const Map<String, int> keyCodes = {
"KEY_RIGHTARROW": 0xae,
"KEY_LEFTARROW": 0xac,
"KEY_UPARROW": 0xad,
"KEY_DOWNARROW": 0xaf,
// ...
"5": 53,
"6": 54,
"7": 55,
"Y": 121
};
}
Definisco lato dart:ffi la funzione dartPostInput, che verrà chiamata da ogni eventDown ed eventUp dei pulsanti. Questa funzione prenderà in input i 2 interi che devono essere mandati a Doom, ovvero l’ASCII del tasto e il gesture, che in questo caso sarà 1 per indicare il tasto premuto, 0 per indicare il tasto rilasciato.
final void Function(int, int) dartPostInput =
dylib.lookup<NativeFunction<Void Function(Int32, Int32)>>('DartPostInput').asFunction();
Tutta questa procedura dovrebbe funzionare, ma ha un grosso difetto: non è Thread Safe. Ne sono perfettamente consapevole.
La chiamata a DartPostInput avviene dal Thread principale, che non è quello dove sta girando il motore di Doom.
Dallo stesso Thread viene quindi poi chiamata D_PostEvent che va a scrivere sul buffer circolare events.
Potrebbe succedere che il thread UI venga messo in pausa quando non ha ancora finito di scrivere la struttura event_t sull’indice attuale di events. Potrebbe quindi succedere che la funzione “worker” che si occupa di leggere events legga dei dati errati e che il software vada in crash o che abbia un comportamento indesiderato.
Il problema potrebbe porsi anche con l’indice eventhead, anch’esso una risorsa condivisa tra i due Threads.
Proviamo quindi a rendere il tutto Thread Safe. la libreria pthread.h mette a disposizione il sistema di mutex, che è quello che fa al caso mio. Il funzionamento è semplice: quando entro in D_PostEvent blocco il mutex, e dopo aver eseguito le operazione di scrittura, lo sblocco.
La funzione che legge il buffer dovrà attendere lo sblocco del mutex per per poter accedere a events.
Modifichiamo D_PostEvent:
#include <pthread.h>
pthread_mutex_t event_mutex = PTHREAD_MUTEX_INITIALIZER;
// ...
void D_PostEvent (event_t* ev)
{
pthread_mutex_lock(&event_mutex);
events[eventhead] = *ev;
eventhead = (++eventhead)&(MAXEVENTS-1);
pthread_mutex_unlock(&event_mutex);
}
La funzione che si occupa di leggere il buffer circolare events è D_ProcessEvents:
void D_ProcessEvents (void)
{
event_t* ev;
// IF STORE DEMO, DO NOT ACCEPT INPUT
if ( ( gamemode == commercial ) && (W_CheckNumForName("map01")<0) )
return;
for ( ; eventtail != eventhead ; eventtail = (++eventtail)&(MAXEVENTS-1) )
{
ev = &events[eventtail];
if (M_Responder (ev))
continue; // menu ate the event
G_Responder (ev);
}
}
La modifico così:
void D_ProcessEvents (void)
{
event_t* ev;
event_t ev_copy;
int localhead;
// IF STORE DEMO, DO NOT ACCEPT INPUT
if ( ( gamemode == commercial ) && (W_CheckNumForName("map01")<0) )
return;
pthread_mutex_lock(&event_mutex);
localhead = eventhead;
for ( ; eventtail != localhead ; eventtail = (++eventtail)&(MAXEVENTS-1) )
{
ev_copy = events[eventtail];
pthread_mutex_unlock(&event_mutex);
ev = &ev_copy;
if (M_Responder (ev))
goto continue_loop; // menu ate the event
G_Responder (ev);
continue_loop:
pthread_mutex_lock(&event_mutex);
}
pthread_mutex_unlock(&event_mutex);
}
D_ProcessEvents acquisisce il lock, e questo avviene solo se il lock non è già occupato da D_PostEvent, copia localmente l’indice eventhead, entra nel loop e copia localmente anche la struttura event_t. A questo punto il lock viene rilasciato.
D_PostEvent è quindi libero di fare quello che vuole, e questo perchè D_ProcessEvents lavorerà su dati presenti solo al suo interno e non più su variabili globali.
Quindi vengono chiamate le funzioni M_Responder e G_Responder che si occupano di processare effettivamente i dati, e alla fine del loop viene riacquisito il lock. In questo modo, quando il loop ricomincia, anche l’aggiornamento di eventtail viene protetto, che ricordiamo essere una variabile globale.
Viene eseguita nuovamente la copia del nuovo evento in locale e così via.
Una volta usciti dal ciclo, il lock viene rilasciato definitivamente.
Fatto questo, realizzo abbastanza velocemente su Flutter con una combinazione di Container, Row e Column un rudimentale controller, davvero molto brutto.
Vediamo se riesco ad entrare nel gioco vero e proprio.
La buona notizia è che i tasti funzionano, riesco ad entrare nel menu.
La cattiva invece è non riesco ad entrare nel gioco, ottengo un errore di tipo SIGSEGV e il programma termina.
F/libc (18384): Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x14 in tid 18771 (ple.flutterdoom), pid 18384 (ple.flutterdoom)
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'samsung/j6ltexx/j6lte:10/QP1A.190711.020/J600FNXXSACVB1:user/release-keys'
Revision: '2'
ABI: 'arm'
Timestamp: 2025-12-04 23:14:47+0100
pid: 18384, tid: 18771, name: ple.flutterdoom >>> com.example.flutterdoom <<<
uid: 10568
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x14
Cause: null pointer dereference
r0 00000000 r1 00000001 r2 00000000 r3 00000000
r4 a7a2a230 r5 a7a2a230 r6 a7a2a230 r7 a7a29f30
r8 000047d0 r9 000047d0 r10 ebe4e034 r11 a7a2a230
ip a5bd62b4 sp a7a29f08 lr a5ba523d pc a5bd17e6
backtrace:
#00 pc 000497e6 /data/app/com.example.flutterdoom-BK0haLh0jGqqgyyiCtgFXw==/base.apk!libdoom.so (offset 0x330000) (Z_Malloc+42) (BuildId: 84f93e185bf2460ea002e280cde72fee54022427)
#01 pc 0001d239 /data/app/com.example.flutterdoom-BK0haLh0jGqqgyyiCtgFXw==/base.apk!libdoom.so (offset 0x330000) (wipe_initMelt+104) (BuildId: 84f93e185bf2460ea002e280cde72fee54022427)
#02 pc 0001d61f /data/app/com.example.flutterdoom-BK0haLh0jGqqgyyiCtgFXw==/base.apk!libdoom.so (offset 0x330000) (wipe_ScreenWipe+74) (BuildId: 84f93e185bf2460ea002e280cde72fee54022427)
#03 pc 0001dbb5 /data/app/com.example.flutterdoom-BK0haLh0jGqqgyyiCtgFXw==/base.apk!libdoom.so (offset 0x330000) (D_Display+908) (BuildId: 84f93e185bf2460ea002e280cde72fee54022427)
#04 pc 0001ddc3 /data/app/com.example.flutterdoom-BK0haLh0jGqqgyyiCtgFXw==/base.apk!libdoom.so (offset 0x330000) (D_DoomLoop+274) (BuildId: 84f93e185bf2460ea002e280cde72fee54022427)
#05 pc 0001f453 /data/app/com.example.flutterdoom-BK0haLh0jGqqgyyiCtgFXw==/base.apk!libdoom.so (offset 0x330000) (D_DoomMain+3026) (BuildId: 84f93e185bf2460ea002e280cde72fee54022427)
#06 pc 000a8147 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+20) (BuildId: 68663d64ac44a935374eee374ddf6c9d)
#07 pc 00061467 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+30) (BuildId: 68663d64ac44a935374eee374ddf6c9d)
Lost connection to device.
L’errore specifico è null pointer dereference, e abbiamo anche un backtrace abbastanza chiaro, si capisce infatti che il problema è legato alla transizione wipe, che è quella transizione tipo sangue che scivola verso il basso che avviene tra una schermata e l’altra.
Quello che posso provare, momentaneamente, è disattivare la transizione.
Per farlo, basta commentare un paio di sezioni di codice nella funzione D_Display, che è quella che si occupa di disegnare a schermo.
boolean wipe = false;
//
// ...
//
// save the current screen if about to wipe
if (gamestate != wipegamestate)
{
wipe = true;
wipe_StartScreen(0, 0, SCREENWIDTH, SCREENHEIGHT);
}
else
wipe = false;
//
//...
//
// wipe update
wipe_EndScreen(0, 0, SCREENWIDTH, SCREENHEIGHT);
wipestart = I_GetTime () - 1;
do
{
do
{
nowtime = I_GetTime ();
tics = nowtime - wipestart;
} while (!tics);
wipestart = nowtime;
done = wipe_ScreenWipe(wipe_Melt, 0, 0, SCREENWIDTH, SCREENHEIGHT, tics);
I_UpdateNoBlit ();
M_Drawer (); // menu is drawn even on top of wipes
I_FinishUpdate (); // page flip or blit buffer
} while (!done);
Queste sono le righe di codice che ho commentato. Ho anche forzato a false la variabile wipe.
Non mi resta che riprovare:
Stavolta funziona davvero. Sto davvero giocando a Doom usando Flutter come motore grafico :-)
Questa palette a scala di grigi tutto sommato non è così male, ha il suo fasciono e sembra quasi di giocare con un vecchio GameBoy.
Ma adesso è arrivato il momento di gestire i colori.
CAPITOLO 8 - La palette dei colori non è un argomento banale
Come dicevo parecchie righe fa, i 256 colori di Doom non sono parte di una palette standard, almeno non nei sistemi moderni.
Ce ne sono diverse che vengono cambiate durante il gioco, ad esempio c'è la palette che vira sul rosso quando il player sta morendo o viene colpito, oppure quella che vira sul verde quando indossiamo la tuta antiradiazioni.
Per ogni colore abbiamo 1 byte per ogni componente (rosso/verde/blu), quindi ogni colore occupa 3 byte, e in totale la palette completa occupa quindi 768 bytes. In realtà ogni componente non usa tutti e 8 i bit, ma soltanto i primi 6 bit.
In conclusione, Doom può visualizzare al massimo 256 colori contemporaneamente, pescando però tra 262.144 colori possibili, cioè il totale di colori rappresentabili dai numeri a 18 bit.
Tutti questi numeri non sono casuali, si tratta infatti di come funzionava il sistema grafico VGA nei vecchi computer con MS-DOS.
Come posso fare in modo che Doom comunichi in tempo reale la palette da utilizzare a Flutter?
Potrei usare la stessa tecnica adottata per il framebuffer, ovvero alloco l'array con dart:ffi, passo l'indirizzo dell'array a Doom, e faccio in modo che la palette dei colori venga scritta su quell'area di memoria.
Alla ricezione del segnale di nuovo fotogramma disponibile, Flutter andrà a leggere la palette attuale e la userà per convertire il framebuffer di Doom nel frame digeribile dal CustomPainter.
Facciamo un passo alla volta. Prima di tutto alloco l'array all'interno di Flutter e modifico la funzione flutterDoomStart:
final Pointer<Uint32> palette = malloc<Uint32>(256);
// ...
final void Function(Pointer<Utf8>, Pointer<UnsignedChar>, Pointer<Uint32>) flutterDoomStart
= dylib.lookup<NativeFunction<Void Function(Pointer<Utf8>, Pointer<UnsignedChar>, Pointer<Uint32>)>>('FlutterDoomStart').asFunction();
flutterDoomStart(widget.wadPath.toNativeUtf8(), framebuffer, palette);
Lato C modifico la funzione ricevente FlutterDoomStart e dichiaro la variabile globale external_palette:
uint32_t* external_palette;
// ...
void FlutterDoomStart(char* wad_path, byte* external_fb, uint32_t* _external_palette) {
external_palette = _external_palette;
pthread_t doom_thread;
ThreadArgs* thread_args = malloc(sizeof(ThreadArgs));
if (thread_args == NULL) {
LOG("ThreadArgs error");
return;
}
thread_args->wad_path = wad_path;
thread_args->external_fb = external_fb;
if (pthread_create(&doom_thread, NULL, &D_DoomMain, thread_args) != 0) {
LOG("pthread_create error");
return;
}
return;
}
Adesso che ho la variabile su cui scrivere la palette, devo solo trovare il punto nel codice di Doom che mi permetta di farlo.
Il punto corretto è nel file i_video.c, e in modo specifico nella funzione I_SetPalette. Tutto il file i_video.c lo si può proprio intendere allo stesso modo delle interfacce della OOP, ovvero un contratto che definisce delle funzioni vuote da implementare.
Infatti è su questo file dove era stato definito tutto il boilerplate per comunicare con il sottosistema grafico di Linux, X11. Tutti i port di Doom devono quindi implementare opportunamente queste funzioni per comunicare con lo specifico sottosistema grafico del proprio sistema operativo.
Vediamo come ho implementato I_SetPalette:
extern uint32_t* external_palette;
// ...
void I_SetPalette (byte* palette)
{
for (int i=0 ; i<255 ; i++) {
external_palette[i] = 0xFF000000;
external_palette[i] |= (gammatable[usegamma][*palette++] & ~3) << 16;
external_palette[i] |= (gammatable[usegamma][*palette++] & ~3) << 8;
external_palette[i] |= (gammatable[usegamma][*palette++] & ~3);
}
}
Itero per tutto l’array palette, che ricordiamo essere formato da 768 bytes. Sono 256 iterazioni del ciclo for, e per ogni iterazione leggo 3 bytes.
Per ogni byte mantengo i primi 6 bit azzerando gli ultimi 2 tramite l’operazione di mascheramento.
Ogni byte letto viene inserito tramite OR e bitshifting nella locazione di external_palette, che è un valore a 32 bit in formato PixelFormat.rgba8888, dove il primo byte da sinistra rappresenta il canale alpha, poi il blu, il verde e infine il rosso.
Punto di attenzione: per poter permettere a Doom di gestire la gamma dei colori, che è modificabile da interfaccia con il tasto F11, pesco il valore finale del componente dall’array gammatable che è definito come costante all’interno della codebase di Doom.
La correzione gamma è importante perchè permette di schiarire complessivamente i colori, infatti su alcuni schermi l’immagine potrebbe ad esempio risultare troppo scura, e tramite la correzione gamma è possibile applicare una compensazione.
Tornando su Flutter, modifico la funzione che viene invocata ad ogni nuovo fotogramma disponibile:
receivePort.listen((dynamic message) async {
// Invoked at new frame ready
for (int i=0; i<framebufferSize; i++) {
framebuffer32[i] = palette[framebuffer[i]];
}
// ...
La conversione a 32 bit è già avvenuta su I_SetPalette, quindi su questa funzione devo andare a convertire il framebuffer da 8 a 32 bit semplicemente pescando il colore corrispondente dall’array condiviso palette.
Proviamo a vedere se il tutto funziona.Ci siamo quasi, ho solo invertito il blu con il rosso, poco male.
void I_SetPalette (byte* palette)
{
for (int i=0 ; i<255 ; i++) {
external_palette[i] = 0xFF000000;
external_palette[i] |= (gammatable[usegamma][*palette++] & ~3);
external_palette[i] |= (gammatable[usegamma][*palette++] & ~3) << 8;
external_palette[i] |= (gammatable[usegamma][*palette++] & ~3) << 16;
}
}
Notate quando vengo colpito dall'avversario come lo schermo viri sul rosso, oppure quando raccolgo un oggetto vira sul verde.
In quei momenti il motore di Doom sta richiamando la funzione I_SetPalette con una nuova palette di colori.
Visto che nel capitolo precedente abbiamo parlato di Thread Safety, è doveroso fare un’osservazione anche per il framebuffer e la palette.
La lettura e la scrittura di questi due array infatti non è atomica, e durante la lettura anche parziale da parte di Flutter, L’array potrebbe essere modificato da Doom. Quindi tutto il sottosistema grafico che ho messo in piedi NON è Thread Safe, ma secondo me possiamo convivere tranquillamente con questo difetto.
Il peggio che può succedere è che ci sia un glitch grafico e nulla di più. In tutti i test che ho fatto non si è mai verificata questa condizione, perchè Flutter è abbastanza veloce da convertire il framebuffer prima che Doom crei un nuovo frame.
Capitolo 9 - Risolviamo i bug che io stesso ho introdotto
Il problema che vorrei risolvere è quello legato alla transizione wipe.
Come funzione l'effetto wipe?
Semplificando il più possibile: viene fatta una copia della schermata di partenza su screens[2], poi una copia della schermata di destinazione su screens[3], l'effetto quindi produce la transizione trasformando l'immagine in screens[2] in quella di screens[3], scrivendo il risultato su screens[0].
Riattivo quindi le sezioni che avevo commentato in precedenza.
boolean wipe;
//
// ...
//
// save the current screen if about to wipe
if (gamestate != wipegamestate)
{
wipe = true;
wipe_StartScreen(0, 0, SCREENWIDTH, SCREENHEIGHT);
}
else
wipe = false;
//
//...
//
// wipe update
wipe_EndScreen(0, 0, SCREENWIDTH, SCREENHEIGHT);
wipestart = I_GetTime () - 1;
do
{
do
{
nowtime = I_GetTime ();
tics = nowtime - wipestart;
} while (!tics);
wipestart = nowtime;
done = wipe_ScreenWipe(wipe_Melt, 0, 0, SCREENWIDTH, SCREENHEIGHT, tics);
I_UpdateNoBlit ();
M_Drawer (); // menu is drawn even on top of wipes
I_FinishUpdate (); // page flip or blit buffer
} while (!done);
Rilanciando l’app, noto qualcosa di strano, cioè che l’errore che viene visualizzato in console non è sempre lo stesso. Inoltre è capitato qualche volta che il gioco partisse, anche se comunque senza effetto wipe.
Si tratta quindi di un comportamento indefinito, probabilmente le funzioni collegate all’effetto staranno provando ad accedere a delle locazioni di memoria non inizializzate correttamente.
Proviamo a vedere cosa c’è dentro wipe_StartScreen.
int
wipe_StartScreen
( int x,
int y,
int width,
int height )
{
wipe_scr_start = screens[2];
I_ReadScreen(wipe_scr_start);
return 0;
}
Sembra che faccia la copia dello schermo nel framebuffer screens[2].
Vediamo cosa fa I_ReadScreen.
void I_ReadScreen (byte* scr)
{
}
Ho capito cosa è successo. Quando sono andato a togliere tutti i riferimenti a X11 nel file i_video.c ci sono andato pesante con la mannaia :-)
Ho eliminato più di quello che dovevo. Quindi procedo a ripristinare questa funzione, si tratta semplicemente del comando che permette la copia della memoria da una locazione ad un’altra.
void I_ReadScreen (byte* scr)
{
memcpy (scr, screens[0], SCREENWIDTH*SCREENHEIGHT);
}
Provo a rilanciare il gioco.
Il problema persiste, non è cambiato nulla. C’è quindi un altro bug da qualche altra parte.
Lo stacktrace che ho pubblicato più in alto purtroppo non è consistente, come ho già scritto prima ogni volta che avvio il gioco ottengo un errore diverso.A volte il gioco parte, ma ho notato una cosa: la status bar è spesso corrotta.
void V_Init (void)
{
int i;
byte* base;
// stick these in low dos memory on PCs
base = I_AllocLow (SCREENWIDTH*SCREENHEIGHT*3);
for (i=1 ; i<4 ; i++)
screens[i] = base + i*SCREENWIDTH*SCREENHEIGHT;
}
Cosa è successo: non dovendo più allocare la memoria per 4 framebuffer ma solo 3, avevo modificato il parametro di I_AllocLow per allocare la memoria necessaria per soli 3 framebuffer.
Il ciclo for assegna l’indirizzo di memoria di base a ciascuno dei 3 framebuffer.
Il problema è che, nell’ultimo framebuffer, il 3, assegno l’indirizzo di un’area di memoria non allocata, a causa dell’offset errato ottenuto con l’indice i.
Ecco la funzione corretta:
void V_Init (void)
{
int i;
byte* base;
// stick these in low dos memory on PCs
base = I_AllocLow (SCREENWIDTH*SCREENHEIGHT*3);
for (i=0 ; i<3 ; i++)
screens[i+1] = base + i*SCREENWIDTH*SCREENHEIGHT;
}
Adesso il gioco non crasha più in modo randomico. Purtroppo però ancora nessun effetto wipe.
Questo problema però è stato più semplice da risolvere.
La funzione notifyDartFrameReady che ricordiamo serve a segnalare a Flutter che è pronto un nuovo frame, la chiamo all’interno di D_DoomLoop, subito dopo D_Display.
#include "dart_interface.h"
// ...
void D_DoomLoop (void)
{
// ...
// Update display, next frame, with current state.
D_Display ();
notifyDartFrameReady();
// ...
}
Guardando dentro D_Display, è chiaro perchè non si vede il wipe.
void D_Display (void)
{
// ...
// normal update
if (!wipe)
{
I_FinishUpdate (); // page flip or blit buffer
return;
}
// wipe update
wipe_EndScreen(0, 0, SCREENWIDTH, SCREENHEIGHT);
wipestart = I_GetTime () - 1;
do
{
do
{
nowtime = I_GetTime ();
tics = nowtime - wipestart;
} while (!tics);
wipestart = nowtime;
done = wipe_ScreenWipe(wipe_Melt, 0, 0, SCREENWIDTH, SCREENHEIGHT, tics);
I_UpdateNoBlit ();
M_Drawer (); // menu is drawn even on top of wipes
I_FinishUpdate (); // page flip or blit buffer
} while (!done);
}
Ovvero: quando non c’è il wipe, viene chiamata I_FinishUpdate e poi esce da D_Display. Quando c’è il wipe, parte il ciclo che genera l’effetto, chiamando I_FinishUpdate per ogni iterazione.
Il problema è che la segnalazione del nuovo frame viene fatta solo all’uscita di D_Display, quando in realtà va fatta all’interno di I_FinishUpdate. Modifico la funzione.
void I_FinishUpdate (void)
{
// ...
notifyDartFrameReady();
}
CAPITOLO 10 - Un po' di frontend: creiamo un controller comodo e funzionale
A questo punto vorrei in primis rendere l'app graficamente più gradevole, e vorrei realizzare un controller che riesca a coprire tutte le funzionalità principali del gioco.
L'idea del controller è questa: i tasti direzionali non solo devono gestire il gesture down e il gesture up, ma anche il move.
Perchè questo? Perchè permetterebbe all'utente di impugnare lo smartphone come se fosse una piccola console portatile, e di utilizzare i tasti direzionali con il pollice, potenzialmente senza doverlo mai sollevare dallo schermo. Secondo me questo darebbe un'esperienza di gioco migliore. Quando ho iniziato il progetto, volevo solo realizzare un proof of concept, e invece ormai sono quasi al livello di MVP. L'avevo detto che mi sarebbe sfuggito di mano :-)
Arrivato a questo punto ho fatto un refactoring molto importante della struttura del codice; ho creato nuovi Widget, partendo dal livello di astrazione più basso, cioè il widget Tasto, poi tutte le varie sezioni, cioè Tasti Direzionali, Tasti Numerici e Tasti Funzione.
Alla fine, dopo parecchie prove, ho ottenuto questo layout di cui sono molto soddisfatto.Non entrerò troppo nei dettagli del codice, però come scrivevo sopra, i Tasti Direzionali forse meritano un piccolo approfondimento.
Premetto che non ho utilizzato GestureDetector, ma ho usato il Listener, perchè a differenza del GestureDetector non introduce latenza, essendo il Widget più a basso livello per quanto riguarda gli input.
I tasti direzionali, sono un unico Listener, a differenza di tutti gli altri tasti dove invece ognuno di essi è un Listener.
Avere un unico Listener mi ha permesso di gestire oltre che il gesture down e up, anche il move. Ad ogni movimento del dito, viene invocata una callback che si occupa di capire quale tasto si sta premendo in quel momento, in base alle coordinate x/y del puntatore (il dito) rispetto all'area occupata dal quadrato che racchiude tutti i Tasti Direzionali.
Sto semplificando molto, la questione è meno banale di così perchè ad esempio durante l'operazione di move il puntatore può uscire fuori dall'area del controller, quindi va rilasciato il pulsante che si stava premendo, ed eventualmente il puntatore potrebbe anche strisciare nuovamente nell'area del controller, quindi premendo un nuovo tasto (o lo stesso rilasciato il precedenza).
Se volete approfondire, trovate tutto su lib/keyboard/directional_keys.dart.
Mi sono quindi limitato alla visualizzazione portrait, e ho anche aggiunto sul Main di Flutter un piccolo pezzo di codice che (in teoria) dovrebbe forzare questo orientamento.
Sono comunque molto soddisfatto del risultato, adesso è perfettamemnte giocabile.
Si può regolare al volo la gamma, cioè la luminosità di Doom, per poter giocare anche in situazioni in cui lo schermo non si vede in modo ottimale, ad esempio durante il giorno, all'aperto.
Ho anche gestito il save/load, modificando le funzioni coinvolte sul sorgente di Doom (non approfondirò ulteriormente l'argomento).
CAPITOLO 11 - Compiliamo su iOS
Per tutta la durata di questo sviluppo, ho utilizzato come target il mio vecchio e fidato smartphone Samsung J6 con Android 10.
Manca quindi un passaggio fondamentale per giustificare l'utilizzo di Flutter: la compilazione su iOS.
Ho da poco convertito il mio vecchio Thinkpad T530 in un Hackintosh, è arrivato il momento di sfoderarlo.
Sono perfettamente consapevole che usare un Hackintosh per sviluppare su Flutter è una pratica sconsigliata, ma è solo ai fini di testing.Non ho intenzione di pubblicare sullo store app compilate sull'Hackintosh, anche perchè si rischierebbe un grosso ban da parte di Apple.
Per quello eventualmente userò qualche servizio di CI/CD tipo Codemagic. Per questo progetto non è necessario, in ogni caso.
Ci ho messo un po' per capire cosa andava inserito nella directory ios per istruirla a compilare il codice C.
Alcune guide dicevano di usare Xcode e trascinare i sorgenti all'interno della sezione Runner, ma il codice non veniva assolutamente compilato.
Ho provato un po' di tutto senza successo.
Alla fine ho creato un plugin Flutter con il template "plugin_ffi":
flutter create --platforms=android,ios --template=plugin_ffi native_add
Questo comando l'ho preso direttamente dalla documentazione di Flutter. Permette di creare un plugin template con dart:ffi già configurato per essere compilato sia su Android che iOS.
Analizzando la cartella ios al suo interno sono riuscito a capire cosa dovevo fare.
Non starò qui a spiegarlo dettagliatamente, si trattava di spostare tutto il sorgente in C all'interno della cartella ios, altrimenti veniva ignorato, creare un file .podspec e modificare la sezione del codice Dart in cui viene caricata la libreria.
Per vedere cosa ho fatto nello specifico, trovate un commit direttamente nel progetto.
Finalmente ho potuto provare a compilare. E qui ho scoperto che il compilatore di Xcode è un pochino più pignolo di quello Android.
Ho provveduto ad effettuare le correzioni richieste in base agli errori (nulla di particolarmente sconvolgente), e dopo qualche tentativo il gioco è stato compilato correttamente ed è partito anche sull'iOS Simulator :-)
Missione compiuta!

Conclusioni
Che dire, è stata una cavalcata lunghissima.
Questo progetto mi ha permesso di dare uno sguardo all'interno della storia dei videogiochi, e anche di dimostrare la potenza e la versatilità di Flutter, non che ce ne fosse bisogno.
Possibili sviluppi futuri:
- Audio
Al momento il gioco è completamente muto. Nella versione Linux gli effetti audio venivano invocati asincronamente su un altro applicativo che fungeva da server audio. Nella versione Flutter bisogna capire come gestire la cosa. Non ho assolutamente guardato come il codice sorgente di Doom gestisce tutta la parte audio, quindi questa cosa me la metto da parte per il futuro.
Penso che ci tornerò, perchè potrebbe essere interessante a livello didattico. - Modalità landscape
Con magari un controller a video, semitrasparente e integrato con la schermata del gioco. - Gestione di controller hardware
Ad esempio con una tastiera esterna collegata tramite la porta USB, e magari anche con un mouse (cosa che non ho mai usato per giocare a Doom, ma so che molti lo fanno). - Caricamento di WAD da parte dell'utente
Attualmente, solo il WAD della versione shareware funziona. Con delle piccolissime modifiche al codice è comunque possibile caricare anche altri WAD, ma sarebbe bello che fosse l'utente finale ad aprirli sull'app. - Gestione dell'uscita dall'app
Questo è divertente: attualmente quando si va nel menu del gioco, e si sceglie di quittare, effettivamente l'app si chiude.
Ma questo in realtà non me lo aspettavo, perchè quello che succede è che avviene un Segmentation Fault e l'app crasha completamente :-)
Ad onor di cronaca, questo succede anche nella versione compilata su Linux. Non è un problemone, ma prima o poi proverò a risolverlo.
Sono veramente soddisfatto di come è venuto questo articolo. Ho cercato di mettere più informazioni possibili. Spero possiate trovarlo utile.
È stato inviato e sarà moderato prima della pubblicazione.