Riset Translasi Summon Night: Swordcraft Story 2 (GBA)

Catatan riset ROM versi USA (16 MB). Semua proses dikerjakan dari HP menggunakan Python di Pydroid3 dan emulator LinkBoy.

Kenapa aku mulai

Aku ingin memainkan salah satu game favoritku dalam Bahasa Indonesia.

Sebelumnya aku pernah mengerjakan Mario & Luigi: Superstar Saga. Waktu itu struktur teksnya cukup mudah dipahami karena memakai tabel karakter dan pointer biasa. Jadi awalnya kukira Summon Night: Swordcraft Story 2 bakal kurang lebih sama.

Ternyata tidak.

Mulai membongkar ROM

Riset ini kulakukan sambil mencoba berbagai skrip kecil. Aku menjalankannya di HP, melihat output atau hex dump-nya, lalu mencoba memahami pola yang muncul. Kalau sudah punya dugaan baru, skripnya diubah dan hasilnya diuji lagi di emulator.

Dalam prosesnya aku juga banyak berdiskusi dengan Claude dari Anthropic. Polanya kurang lebih begitu terus sampai akhirnya beberapa bagian format ROM mulai kelihatan.

Teks ternyata dikompresi LZ77

Teks Inggris ternyata disimpan sebagai karakter full-width CP932, misalnya Hello, dengan ukuran 2 byte per karakter.

Data tersebut berada di dalam blok yang dikompresi menggunakan LZ77 GBA. Ciri yang paling mudah dikenali adalah byte 0x10 di awal blok.

Setelah didekompresi, setiap blok skrip diawali dengan magic PSI3.

def lz77_decompress(data, off):
    if data[off] != 0x10: return None
    size = int.from_bytes(data[off+1:off+4], "little")
    p, out = off + 4, bytearray()
    while len(out) < size:
        flags = data[p]; p += 1
        for bit in range(8):
            if len(out) >= size: break
            if flags & (0x80 >> bit):                 # salinan dari output sebelumnya
                b1, b2 = data[p], data[p+1]; p += 2
                dist = (((b1 & 0xF) << 8) | b2) + 1
                for _ in range((b1 >> 4) + 3): out.append(out[-dist])
            else:                                      # byte literal
                out.append(data[p]); p += 1
    return bytes(out[:size]), p

Aku kemudian memindai seluruh ROM dengan fungsi tersebut dan mencari hasil dekompresi yang diawali PSI3.

Hasilnya: ditemukan 972 blok PSI3, dan 888 di antaranya berisi teks.

Mencari lokasi blok dan pointer

Setelah tahu bentuk bloknya, masalah berikutnya adalah mencari bagaimana game menemukan masing-masing blok.

Ternyata blok-blok tersebut tersusun rapat dalam slot berukuran kelipatan 16 byte. Tidak ada pointer langsung ke setiap blok. Sebagai gantinya, game memakai tabel indeks yang berisi pasangan u32 untuk offset dan ukuran, keduanya dihitung dalam satuan 16 byte.

Lokasinya seperti ini:

Data Lokasi
972 blok PSI3 terkompresi 0xABF7DC–0xC7F5FC
Tabel indeks (±2.551 entri, 972 terisi) 0xABA824–0xABF7DC, basis 0xABA81C
39 skrip PSI3 tak terkompresi 0x8A41EC–0x8B14CC
String UI mentah sekitar 0x8EE54–0x930D6
Tabel rekaman tetap 0x4E0B28 (stride 132), 0x4EF118 (116), 0x4F33F4 (184)

Untuk mendapatkan alamat blok dari entri tabel, rumusnya cukup sederhana:

BASE = 0xABA81C

def block_start(entry):
    off, size = entry
    return BASE + 16 * off, 16 * size

# contoh: (0x1C408, 0xA7) -> 0xC7E89C, slot 2672 byte

Ada juga string UI yang memakai pointer biasa. Contohnya Is this okay? berada di 0x8F3C4, sedangkan pointer-nya ada di 0x4C7B34. Nilai pointer tersebut menggunakan alamat 0x08000000 + alamat ROM.

Memahami format skrip

Setelah lokasi blok ditemukan, bagian berikutnya adalah memahami isi skripnya.

Format dasarnya menggunakan kata 16-bit little-endian setelah header PSI3 dan ukuran sebesar 0x10 byte.

Beberapa opcode yang sudah berhasil dikenali:

06 03 <teks> 00 00  = satu baris dialog
07 03              = ganti halaman
03 00 <target>     = lompat

Alamat target lompatan dihitung dari target + 0x10.

Teks terjemahan lebih panjang

Masalah pertama muncul ketika teks Indonesia lebih panjang daripada teks Inggris.

Kalau teks langsung disisipkan, posisi data setelahnya ikut bergeser. Akibatnya target lompatan yang berada di bawah teks tersebut ikut berubah. Selain itu, masih ada opcode lain yang belum sepenuhnya kupahami.

Solusinya adalah memakai trampolin.

Teks lama diganti dengan instruksi lompatan menuju teks baru yang ditempatkan di bagian lain blok, lalu teks baru tersebut melompat kembali ke posisi setelah teks lama.

Dengan cara ini, struktur asli blok tidak perlu digeser.

JUMP, TEXT, HDR = b"\x03\x00", b"\x06\x03", 0x10

# S = awal teks lama
# E = tepat setelah terminatornya
# A = alamat teks baru

block[S:S+4] = JUMP + struct.pack("<H", A - HDR)

new = TEXT + new_text + b"\x00\x00" + JUMP + struct.pack("<H", E - HDR)

block[4:8] = struct.pack("<I", len(block))

Blok yang membesar tidak muat

Masalah berikutnya muncul ketika hasil terjemahan membuat ukuran blok setelah dekompresi menjadi lebih besar daripada slot aslinya.

Solusinya adalah relokasi.

Blok yang terlalu besar dipindahkan ke ruang kosong di ujung ROM. Setelah itu, entri pada tabel indeks diarahkan ke lokasi barunya.

Ukuran ROM akhirnya menjadi 18 MB.

struct.pack_into(
    "<II",
    rom,
    entry_pos,
    (new_addr - BASE) // 16,
    new_slot // 16
)

Karakter aneh di ujung baris

Aku juga sempat menemukan masalah dengan tanda ….

Awalnya … (81 63) terlihat seperti tiga titik biasa. Ternyata setelahnya selalu ada karakter ┓ (84 AD) yang digunakan game untuk menggambar titik ketiga.

Pola yang sama ternyata juga digunakan oleh beberapa karakter lain:

♪ + ┛
◎ + ┏

Jadi pasangan karakter tersebut tidak boleh dianggap sebagai penanda halaman atau bagian dari kontrol teks.

"…".encode("cp932") + "┓".encode("cp932")
# 81 63 84 AD

Placeholder nama

Ada beberapa karakter Yunani seperti β, γ, δ, ε, dan ι yang ternyata bukan teks biasa. Game menggantinya dengan nama tertentu saat dialog berjalan.

Karena itu, jumlah placeholder dalam terjemahan harus tetap sama dengan teks aslinya.

Batas ukuran blok

Masih ada satu hal yang belum benar-benar diketahui: seberapa besar buffer yang disediakan game untuk setiap blok setelah didekompresi.

Blok terbesar yang ditemukan berukuran 41.234 byte. Untuk menghindari masalah yang belum diketahui, ukuran blok dibatasi dan diuji secara bertahap.

Teks di luar skrip

Tidak semua teks berada di dalam blok PSI3.

String UI yang memakai pointer bisa dipindahkan dengan mengganti pointer-nya. Contohnya teks menu dan layar nama.

Sementara itu, beberapa data seperti nama item, skill, dan musuh berada dalam tabel dengan ukuran field tetap. Bagian seperti ini tidak bisa begitu saja diperbesar, jadi teks harus tetap muat di lebar field aslinya.

Hasil sementara

Sampai tahap ini, dialog sudah berhasil berjalan di emulator.

Hasil yang sudah didapat:

Bagian Jumlah
Halaman ditimpa langsung ±6.300
Halaman menggunakan trampolin ±14.100
Blok yang direlokasi 498

Masih ada beberapa bagian yang belum selesai:

Terima kasih

Terima kasih kepada Claude yang menemani proses riset ini dari awal. Banyak bagian dari format ROM ini yang akhirnya bisa kupahami setelah berulang kali mencoba skrip, melihat hasilnya, lalu membongkar bagian yang masih belum jelas.