hrxdev
Italiano

#2 Un mondo, salvataggi, replay e un bug nascosto nel formato di salvataggio

· Nick

Dalla prima voce, il nucleo ha guadagnato un mondo in cui mettere le cose, dei file in cui conservarlo e un modo per dimostrare che due esecuzioni concordano. Ancora niente grafica e niente personaggi. Gran parte di questa voce è idraulica, e una parte è un bug che si è rivelato degno di una storia.

Un mondo fatto di tessere

Il mondo è una griglia di tessere da 1×1 m su livelli discreti, numerati da zero in su. Un muro o una porta è una tessera; una scala sarà un collegamento speciale tra due livelli, ma le scale non sono ancora costruibili. Internamente il mondo è un insieme di strati piatti (pavimento, struttura, flag della struttura) per l’intera mappa, più uno strato derivato di percorribilità. La mappa è tracciata in chunk da 16×16 con contatori di versione, così un renderer o una ricerca di percorso possono chiedere “quest’area è cambiata?” senza confrontare le tessere.

La dimensione del mondo si sceglie alla creazione. Si costruisce con comandi che prendono un rettangolo: posare un pavimento, un muro o una porta su un’area, oppure rimuoverli. Se una parte del rettangolo è bloccata, il resto viene comunque costruito e il comando riferisce ciò che ha saltato. Per ora esistono tre tipi di tessera integrati: pavimento, muro, porta. D’ora in poi la mappa fa parte dell’hash del mondo e dei salvataggi. Viene confrontata con un modello di riferimento lento e ovviamente corretto su migliaia di comandi casuali.

Salvataggi e replay

Un salvataggio e un replay condividono lo stesso contenitore di file. È diviso in sezioni etichettate, ciascuna con un tipo e una versione, così le nuove parti del mondo (personaggi, bisogni, relazioni) potranno diventare nuove sezioni più avanti senza rompere i file vecchi. Ci sono checksum sull’intestazione e sul corpo, il corpo è compresso, e ogni dimensione viene verificata rispetto a un limite prima di allocare memoria, così un file corrotto o ostile non può far divorare al gioco tutta la tua RAM. I tipi di tessera sono salvati per nome e non per numero, quindi un salvataggio continua a funzionare se l’elenco dei tipi cambia.

Un replay è ciò che volevo fin dall’inizio: un seed più il registro dei comandi. Durante la registrazione, il nucleo scrive anche un hash di controllo ogni 100 tick. Quando riproduce un replay, confronta lungo il percorso il risultato di ogni comando e ogni hash di controllo, e si ferma al primo tick in cui divergono. Un replay ricorda anche quale build del nucleo l’ha registrato, così un replay di un’altra build viene rifiutato anziché divergere in silenzio.

Il nucleo stesso non tocca mai i file: legge e scrive stream, ed è il gioco a decidere dove stanno i salvataggi. Questo confine è imposto dallo stesso meccanismo di API vietate di prima.

Non ancora fatto: uno strumento da riga di comando che riproduca un file di replay, e la parte di gioco relativa al salvataggio (una cartella dei salvataggi, salvataggi automatici, scritture sicure).

Dimostrare che due esecuzioni concordano

Il test di determinismo ora esegue uno scenario in due processi separati: un seed, un copione di comandi di costruzione (alcuni deliberatamente non validi) e un sistema di prova che attinge a tutti i flussi casuali. Ogni processo lo esegue in tre modi — dall’inizio alla fine, passando per un salvataggio e un ripristino, e passando per un replay dei byte registrati — e confronta gli hash a ogni tick. Poi i due processi confrontano i loro hash tra loro ogni 10 tick.

L’ho rotto apposta per essere sicuro che possa fallire. Un Random senza seed introdotto di nascosto nel tick ha reso rosso il test. Un hash di stringa randomizzato, che .NET inizializza in modo diverso in ogni processo, è passato inosservato dentro un solo processo ed è stato scoperto solo confrontandone due. È questo il motivo per cui i processi sono due.

La regola che ho messo per iscritto è che una stessa build del nucleo deve dare gli stessi hash su processori x64 e arm64, così un replay registrato su un Mac con chip Apple deve poter essere riprodotto su un PC Windows. Esistono hash di riferimento fissati e file di esempio di salvataggio e replay proprio per questo controllo. La trigonometria e gli esponenziali in virgola mobile differiscono tra le piattaforme, quindi sono vietati in tutto ciò che influisce sul mondo; mi servirà un sostituto deterministico la prima volta che un sistema ne avrà bisogno. Questi file di riferimento cambiano solo deliberatamente: se un hash deriva, la modifica va spiegata prima di essere accettata.

Un benchmark capace di dire no

Ora c’è un runner senza interfaccia che prende uno scenario, un seed e un numero di tick, e stampa un report JSON: mediana e 99º percentile del tempo per tick, il numero di agenti, l’hash del mondo e quanti byte sono stati allocati per tick. Il primo scenario costruisce una mappa 512×512 su 16 livelli con circa 52.000 comandi di costruzione.

La baseline è conservata per macchina, perché i tempi di un portatile non significano nulla su un desktop. Una regressione è una mediana oltre il 10% sopra la baseline o un 99º percentile oltre il 35% sopra, e solo se l’aumento supera 0,05 ms, dato che un tick quasi vuoto sta alla risoluzione del timer. Una soglia vale su ogni macchina: zero byte allocati per tick. Devo essere onesto su ciò che questo misura oggi. Senza sistemi e senza personaggi un tick dura circa 35 nanosecondi, quindi la soglia di tempo è per lo più addormentata fino all’arrivo dei primi sistemi veri nella tappa del movimento. La soglia di allocazione è quella che lavora adesso.

Il test instabile che non era instabile

Mentre costruivo il benchmark, un test del formato di salvataggio ha iniziato a fallire su alcune build pur senza che il nucleo fosse cambiato. Il test inverte uno a uno ciascun byte di un replay compresso e richiede che il caricatore li rifiuti tutti. In una build, un byte vicino alla fine del file è stato accettato. Nella build precedente, lo stesso test passava.

Il motivo per cui compariva e scompariva: un replay porta l’identificatore della build che lo ha scritto, quindi i byte compressi cambiano da build a build, e con loro il byte che viene invertito. La falla c’era sempre stata; il test se n’è accorto solo quando i dadi sono caduti così.

La falla in sé: il checksum copriva i dati dopo la decompressione, e il decompressore di .NET si è rivelato indulgente. Accetta uno stream che non dice mai di essere finito e ignora tutto ciò che segue la fine. Invertire un bit di riempimento nell’ultimo byte produce uno stream diverso, ma ancora valido, che si decomprime negli stessi dati, quindi il checksum combaciava ancora e un file danneggiato veniva caricato come buono.

La correzione è una nuova versione del contenitore. Il checksum ora copre i byte così come sono memorizzati nel file e viene verificato prima della decompressione, e la decompressione deve terminare esattamente dove dice l’intestazione. Un corpo vuoto viene memorizzato non compresso, dato che il compressore non scrive nulla per un input vuoto. I controlli ora usano stream compressi fissi che non dipendono dalla build, oltre al fuzzing su file danneggiati. I file di esempio sono stati rigenerati per la nuova versione; gli hash del mondo non sono cambiati.

Altrove

Il sito, quello che stai leggendo, ora mostra due contatori di visite nella pagina iniziale: visitatori unici di sempre e delle ultime 24 ore. Sono conteggiati dal log delle pagine del server web, senza JavaScript, senza cookie e senza servizi esterni. Un indirizzo viene conservato solo come hash con salt, i bot sono filtrati tramite lo user agent, e i numeri accumulano qualche minuto di ritardo. Più persone dietro la stessa rete contano come una.

Prossimi passi

La prima tappa, lo scheletro del nucleo, è conclusa. Ora tocca a M1, la vista di costruzione in Godot: muri, porte e stanze dal modello del mondo, sezione per piano, una telecamera che ruota a passi di 90° e cubi semplici al posto della grafica. Il lavoro non è ancora iniziato. Vedi la roadmap.

← Tutte le voci del devlog · Prossima: #3 →