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:
- Label nama pembicara belum diterjemahkan. Sejauh ini tampaknya nama tersebut merupakan bagian dari gambar.
- Sekitar 70 menu dan nama kecil masih dibiarkan dalam bahasa Inggris.
- Adegan yang berada di blok terbesar belum diuji.
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.