#2 Sebuah dunia, simpanan, replay, dan bug yang bersembunyi di format simpanan
· Nick
Sejak catatan pertama, inti sudah punya dunia untuk menaruh berbagai hal, file untuk menyimpannya, dan cara membuktikan bahwa dua jalannya permainan sama. Masih belum ada grafis dan belum ada karakter. Sebagian besar catatan ini tentang pekerjaan pipa-pipa, dan satu bagiannya adalah bug yang ternyata layak diceritakan.
Dunia dari ubin
Dunia adalah kisi ubin berukuran 1×1 m pada tingkat-tingkat terpisah, dinomori dari nol ke atas. Dinding atau pintu adalah sebuah ubin; tangga nantinya akan menjadi tautan khusus antara dua tingkat, tetapi tangga belum dibuat. Secara internal, dunia adalah kumpulan lapisan datar (lantai, struktur, penanda struktur) untuk seluruh peta, ditambah lapisan turunan untuk bisa-tidaknya dilalui. Peta dilacak dalam chunk 16×16 dengan penghitung versi, supaya renderer atau pencarian jalur bisa bertanya “apakah area ini berubah?” tanpa membandingkan ubin.
Ukuran dunia dipilih saat dunia dibuat. Pembangunan dilakukan lewat perintah yang mengambil sebuah persegi panjang: memasang lantai, dinding, atau pintu di suatu area, atau membongkarnya. Jika sebagian persegi panjang terhalang, sisanya tetap dibangun dan perintah melaporkan apa yang dilewati. Untuk saat ini ada tiga jenis ubin bawaan: lantai, dinding, pintu. Mulai sekarang peta menjadi bagian dari hash dunia dan simpanan. Peta diperiksa terhadap model referensi yang lambat tapi jelas benar, pada ribuan perintah acak.
Simpanan dan replay
Simpanan dan replay berbagi satu wadah file. Wadah itu terbagi menjadi bagian-bagian berlabel, masing-masing dengan tipe dan versi, sehingga bagian baru dunia (karakter, kebutuhan, hubungan) nantinya bisa menjadi bagian baru tanpa merusak file lama. Ada checksum atas header dan isi, isinya dikompresi, dan setiap ukuran diperiksa terhadap batas sebelum memori dialokasikan, sehingga file yang rusak atau berniat jahat tidak bisa membuat game menghabiskan seluruh RAM. Jenis ubin disimpan berdasarkan nama, bukan nomor, sehingga simpanan tetap berfungsi jika daftar jenisnya berubah.
Replay adalah yang saya inginkan sejak awal: seed ditambah log perintah. Saat merekam, inti juga menulis hash kontrol setiap 100 tick. Saat memutar ulang, ia membandingkan hasil setiap perintah dan setiap hash kontrol di sepanjang jalan, dan berhenti pada tick pertama yang tidak cocok. Replay juga mengingat build inti mana yang merekamnya, sehingga replay dari build lain ditolak alih-alih diam-diam menyimpang.
Inti sendiri tidak pernah menyentuh file: ia membaca dan menulis stream, dan game yang memutuskan di mana simpanan disimpan. Batas itu ditegakkan oleh mesin API terlarang yang sama seperti sebelumnya.
Belum selesai: alat baris perintah untuk memutar file replay, dan sisi game dari penyimpanan (folder simpanan, simpan otomatis, penulisan yang aman).
Membuktikan dua jalannya permainan sama
Uji determinisme kini menjalankan satu skenario dalam dua proses terpisah: sebuah seed, skrip perintah pembangunan (beberapa sengaja tidak valid), dan sistem uji yang mengambil dari setiap aliran acak. Tiap proses menjalankannya dengan tiga cara — langsung sampai selesai, melalui simpan dan pulihkan, dan melalui replay dari byte yang direkam — lalu membandingkan hash di setiap tick. Kedua proses kemudian membandingkan hash mereka satu sama lain setiap tick ke-10.
Saya sengaja merusaknya untuk memastikan uji ini bisa gagal. Random tanpa seed yang diselundupkan ke dalam tick membuat uji berubah merah. Hash string yang diacak, yang di-seed .NET secara berbeda di setiap proses, tidak terdeteksi di dalam satu proses dan baru tertangkap saat membandingkan dua proses. Itulah alasan utama adanya dua proses.
Aturan yang saya tetapkan adalah satu build inti harus memberi hash yang sama di prosesor x64 dan arm64, jadi replay yang direkam di Mac dengan chip Apple harus bisa diputar di PC Windows. Hash referensi yang dikunci serta contoh file simpanan dan replay ada persis untuk pemeriksaan itu. Trigonometri dan eksponensial bilangan pecahan berbeda antarplatform, jadi keduanya terlarang di apa pun yang memengaruhi dunia; saya akan butuh pengganti yang deterministik saat pertama kali sebuah sistem memerlukannya. File referensi ini hanya berubah secara sengaja: jika sebuah hash bergeser, perubahannya harus dijelaskan sebelum diterima.
Benchmark yang bisa berkata tidak
Kini ada runner tanpa antarmuka yang menerima skenario, seed, dan jumlah tick, lalu mencetak laporan JSON: median dan persentil ke-99 waktu per tick, jumlah agen, hash dunia, dan berapa byte yang dialokasikan per tick. Skenario pertama membangun peta 512×512 dengan 16 tingkat menggunakan sekitar 52.000 perintah pembangunan.
Baseline disimpan per mesin, karena waktu dari laptop tidak berarti apa-apa di desktop. Regresi adalah median lebih dari 10% di atas baseline atau persentil ke-99 lebih dari 35% di atasnya, dan hanya jika kenaikannya lebih dari 0,05 ms, karena tick yang hampir kosong berada di batas resolusi pengatur waktu. Satu gerbang berlaku di setiap mesin: nol byte dialokasikan per tick. Saya harus jujur tentang apa yang diukur saat ini. Tanpa sistem dan tanpa karakter, satu tick butuh sekitar 35 nanodetik, jadi gerbang waktu sebagian besar tertidur sampai sistem nyata pertama datang di tonggak pergerakan. Gerbang alokasilah yang bekerja sekarang.
Uji yang tampak acak padahal tidak
Saat membangun benchmark, sebuah uji format simpanan mulai gagal pada sebagian build meskipun intinya tidak berubah. Uji itu membalik setiap byte replay terkompresi satu per satu dan mewajibkan pemuat menolak semuanya. Pada satu build, sebuah byte di dekat akhir file diterima. Pada build sebelumnya, uji yang sama lulus.
Alasan ia datang dan pergi: replay membawa pengenal build yang menulisnya, sehingga byte terkompresinya berbeda dari build ke build, dan begitu pula byte yang dibalik. Celahnya selalu ada; uji itu hanya menyadarinya ketika dadunya jatuh ke sana.
Celahnya sendiri: checksum mencakup data setelah dekompresi, dan dekompresor .NET ternyata pemaaf. Ia menerima stream yang tidak pernah menyatakan dirinya selesai, dan mengabaikan apa pun setelah akhir. Membalik bit pengisi di byte terakhir menghasilkan stream lain yang masih valid dan didekompresi menjadi data yang sama, sehingga checksum tetap cocok dan file yang rusak dimuat seolah baik.
Perbaikannya adalah versi wadah baru. Checksum kini mencakup byte sebagaimana tersimpan di file dan diperiksa sebelum dekompresi, dan dekompresi harus berakhir tepat di tempat yang disebutkan header. Isi kosong disimpan tanpa kompresi, karena kompresor tidak menulis apa pun untuk masukan kosong. Pemeriksaan kini memakai stream terkompresi tetap yang tidak bergantung pada build, ditambah fuzzing terhadap file rusak. Contoh file dibuat ulang untuk versi baru; hash dunia tidak berubah.
Hal lain
Situs web yang sedang kamu baca kini menampilkan dua penghitung pengunjung di halaman depan: pengunjung unik sepanjang waktu dan dalam 24 jam terakhir. Hitungannya diambil dari log halaman server web, tanpa JavaScript, tanpa cookie, dan tanpa layanan luar. Alamat hanya disimpan sebagai hash bergaram, bot disaring berdasarkan user agent, dan angkanya terlambat beberapa menit. Beberapa orang di balik satu jaringan dihitung sebagai satu.
Berikutnya
Tonggak pertama, kerangka inti, sudah selesai. Berikutnya adalah M1, tampilan pembangunan di Godot: dinding, pintu, dan ruangan dari model dunia, pemotongan lantai, kamera yang berputar per 90°, dan kubus polos sebagai pengganti grafis. Pengerjaannya belum dimulai. Lihat peta jalan.